Repository navigation
Releases
v0.10.1 — 修复 output 契约,插件可被 DSH 真正激活
0.10.1 —— 修复工具 output 契约,让插件能被 DSH 真正激活
0.10.0 装到主机后整条插件条目不激活,宿主报:
TypeError: tool "remote_write" must declare output { schema, render, presentationMeta? }
根因
dsh-tools 的 register() 在注册期硬校验 output:必须是对象、output.render 必须是函数,然后才 assertSupportedJsonSchema(output.schema)。
本仓 16 处 output 声明里:3 处有 schema 但缺 render,11 处是裸 JSON Schema(连 schema 包装层都没有);writeOp 把裸 schema 直接透传给 reg,于是 remote_write / remote_mkdir / remote_move / remote_delete 全部非法 —— 一条不合规即整条插件不激活。
为什么 1090 个测试全绿却没抓到:测试里的 ctx.tools.register 是假实现,只把 definition 塞进 Map,真实校验从未执行。
修法
新增导出 withOutputContract(definition),统一收口三种写法:
- 已带
render的:原样保留 - 只有
schema的:补render: renderText((_a, v) => v) - 裸 JSON Schema:包成
{ schema: raw, render } undefined/null/ 非对象:退化为{ type: 'object' },绝不抛
registerRemoteTools 与 registerAdvancedTools 的 reg 都先过它。
验证
- 新增
test/output-contract.test.js:复刻 Host 校验条件,对两个入口注册出的每一个工具逐一断言。 - 用真实 Host 校验器(
assertSupportedJsonSchema)注册已安装的 24 个工具:bad 0,render 输出合法。 - 离线注册期体检脚本对
role ∈ {hub, auto, worker, both}四条路径全跑:PROBLEMS 0。 - 用一个全新 web profile 真实启动(装入本 tarball):
dsh web启动零 activation warning,/api/remote-agent/healthz→ 200。 - 全量:1090 tests / 1087 pass / 0 fail / 3 skip;lint 通过;drift 134/134;diag-inject 11/11(24 工具、22 路由)。
升级提示
cordis.patch.yml 含 config 行,市场只能重启生效(热挂载仅支持纯 insert)。装完重启 DSH 一次即可。
v0.10.0 — 会话门禁、真实自检、双向文件与桌面截图
0.10.0 — 全面能力升级
参考 @hope_phenom/dsh-plugin-remote-use 的机制重新梳理远程能力,保留本项目原有架构:direct + relay、X25519/AES-GCM E2E、worker 是任务状态权威、断连续跑、manifest-before-bytes、只有显式请求才传成品。没有把 ACP 明文任务流或"每次连接新建 DSH 子进程"带进现有链路。
新增
- 真实自检与派单前 fresh gate —
remote_selfcheck返回结构化checks[]/verdict/blockingFailures,真实写读删每个 root,GUI 用途实际截图。新节点在创建任务前默认重新检查,失败即不派单;precheck: false可显式跳过。 - 后台管理 —
remote_agents提供list/status/wait,复用同一任务注册表与 handoff;remote_interrupt是取消语义别名。 - 双向大文件 —
remote_file提供stat/send/fetch:单文件 512MiB、256KiB 分块、offset 重对齐、完整 SHA256、.part与原子 no-clobber。worker 仍受 roots/denyPaths/realpath 约束;上传必须confirm: "yes"。 - 桌面截图 —
remote_screenshot在 Windows worker 捕获完整虚拟桌面,PNG 经既有 E2E 通道分块返回;有大小、数量、TTL 上限,校验后才原子落盘。 - 可选会话门禁 —
sessionGate: true时每个会话须先/remote-agent on(别名/remote-use on)才能调用remote_*;按 session 隔离、内存保存、缺 session fail closed。默认false,旧配置行为不变。
修复
- agent 任务"假成功":
ok现在排除hadError(0.9.11 遗留的终态判定)。 - 文件传输 P0:源文件在传输中增长时不再无限重试,只有
offset-conflict才重对齐。 - 覆盖竞态:no-clobber 改用同目录 hard link 原子发布,临时名用
randomUUID。 - 分块读取不再每块重哈希整个文件(512MiB 曾产生约 1TiB 额外读)。
- worker teardown 清理截图与未完成上传;桌面短读不再返回零填充。
安全与边界
- 新文件与截图能力复用既有 FS RPC,不增加旁路 HTTP,不向 relay 暴露明文。
- 自检的 GUI 项只约束 GUI 用途,不阻塞 headless
agent/exec。 - 高级工具在 execute 入口再次检查 session gate;Host 没有全局 guard 也不会绕过。
质量门禁
tests 1086(1083 pass / 0 fail / 3 skip)
契约 24 个 remote_* 工具、22 条 UI 路由
漂移检查 134/134
lint eslint 严格档通过
packaging files/exports 一致性 4/4
完整变更:见 CHANGELOG.md
v0.9.10 - README 补齐四项能力介绍
v0.9.10
文档:README 补齐四项能力的介绍
用户反馈(第 2 次):「readme 好像没有功能相关的介绍」。0.9.8 加了四项能力,
但 README 只更新了工具数量 —— 没有任何功能介绍。
新增三节
| 小节 | 内容 |
|---|---|
| 七、一键修复 | 两步流程(预览/确认)+ 6 条护栏表(只改托管块 / 备份 / 写后校验 / 回滚 / 撤销 / 绝不用 0.0.0.0) |
| 八、外部工具检测 | 四个工具「缺了会怎样」表 + 为什么不代下载的边界说明 |
| 九、文件写入与进程诊断 | 写操作 5 条护栏表 + remote_kill 三道护栏(含拒绝时的具体提示语) |
更新已有章节
- 五、出问题能自己诊断:补「一键修复」「错误翻译成人话」「连接失败可见」
「外部工具检测」四行;「面板运行中分组」补上"最近一步在干什么" - 用法 / 读副机上的文件:原写"只读,没有任何写操作" —— 这句在 0.9.8
之后已经不准确,改成"这一组是只读的",并新增两小节:- 「改副机上的文件(写操作,需确认)」—— 含
resolveWritable的
设计理由(为什么不能复用只读的resolveAny) - 「排查副机上的进程与端口」—— 含三个典型用法示例 + kill 三道护栏表
- 「改副机上的文件(写操作,需确认)」—— 含
- 安全:从 3 条扩到 6 条(写操作、kill、SMB),并新增一张
「本插件主动放宽边界的三处」 表 —— 把所有放宽集中列在一处,别处没有 - 结构:补 5 个新模块(
auto-fix/explain-error/external-tools/
listen-server/instance-config)
写文档时的一条原则
不只写"有什么功能",还写**"为什么这么做"**:
- 一键修复为什么是两步 → 因为改的是 DSH 自己的配置,我们踩过事故
- 外部工具为什么不代下载 → 因为会变成软件分发方,供应链责任全落在我们头上
- 写操作为什么不递归删 → 因为不可逆损失太大
- kill 为什么拒杀父进程 → 因为副机失联后你彻底失去远程修复能力
新增断言
doc-drift 96:README 必须有七/八/九三节标题 + 「主动放宽边界」+
「为什么不代下载」+「三道护栏」。这样以后加功能忘了写文档会立刻失败。
写断言时踩了个小坑:变量名用了
readme,与模块顶部的readme同名,
被 eslint 的no-shadow拦下。改名rootReadme3。
packaging 4 项
doc-drift 132/132
diag:inject 11 项
tests 1032(1030 pass / 0 fail / 2 skip)
lint 干净
这一版只改文档
0.9.8 加了四项能力,但 README 只更新了工具数量,没有任何功能介绍。
这一版把文档补齐。
新增三节
七、一键修复
两步流程 + 6 条护栏表:
[预览修复] → 只读。列出"将要改什么" + "不会做什么"
[确认修复] → 才写盘(必须带 confirm: "yes")
| 护栏 | 防的是什么 |
|---|---|
| 只改托管块 | 你手写的其它条目一个字节不碰 |
| 写前必备份 | .bak-<时间戳>,随时可回 |
| 写后必校验 | 确认条目在、没有重复 id |
| 失败必回滚 | 保留备份并提示用「撤销」 |
| 可一键撤销 | 用备份覆盖回去 |
绝不用 0.0.0.0 |
新版 DSH 硬拒绝,会直接启动崩溃 |
八、外部工具检测
四个工具「缺了会怎样」表,以及为什么不代下载:
零 npm 依赖是核心卖点;而代下载第三方二进制会让我们变成软件分发方 ——
装哪个版本、从哪下、校验和怎么保证、装失败算谁的、供应链攻击面,
全落在我们头上。所以只做「探测 + 告诉你命令」,执行权在你。
九、文件写入与进程诊断
写操作 5 条护栏 + remote_kill 三道护栏(含拒绝时的具体提示语)。
更新的已有章节
- 五、诊断 —— 补「一键修复」「错误翻译成人话」「连接失败可见」「外部工具检测」
- 用法 / 读文件 —— 原写「只读,没有任何写操作」,这句在 0.9.8 之后
已不准确,改成「这一组是只读的」;并新增两小节:- 「改副机上的文件(写操作,需确认)」—— 含
resolveWritable的设计理由 - 「排查副机上的进程与端口」—— 含三个典型用法示例
- 「改副机上的文件(写操作,需确认)」—— 含
- 安全 —— 3 条扩到 6 条,并新增一张 「本插件主动放宽边界的三处」 汇总表
- 结构 —— 补 5 个新模块
写文档的一条原则
不只写「有什么功能」,还写**「为什么这么做」**:
- 一键修复为什么是两步 → 因为改的是 DSH 自己的配置,我们踩过事故
- 外部工具为什么不代下载 → 因为会变成软件分发方
- 写操作为什么不递归删 → 因为不可逆损失太大
- kill 为什么拒杀父进程 → 因为副机失联后你彻底失去远程修复能力
新增断言
doc-drift 96:README 必须有七/八/九三节 + 「主动放宽边界」+
「为什么不代下载」+「三道护栏」。以后加功能忘了写文档会立刻失败。
质量
CI 7/7(一次通过)
packaging 4 项
doc-drift 132/132
diag:inject 11 项
tests 1032(1030 pass / 0 fail / 2 skip)
lint 干净
v0.9.9 - 一键修复 / 外部工具检测 / 写操作与进程诊断 / UI 折叠
v0.9.9
0.9.8 的 CI 回归修复。 本地全绿,但 macOS 与 Windows 上失败。
修复:测试断言直接比绝对路径(realpath 与平台表示差异)
现象(CI run 39389528713,macOS + Windows 失败,Linux 通过):
macOS: actual '/private/var/folders/.../b.txt'
expected '/var/folders/.../b.txt'
Windows: actual 'C:\Users\runneradmin\AppData\Local\Temp\...\b.txt'
expected 'C:\Users\RUNNER~1\AppData\Local\Temp\...\b.txt'
根因:fs-service 的写操作返回 realpath 后的路径 —— 这是刻意的正确行为
(realpath 才能识破软链逃逸与 8.3 短名)。但测试用 path.join(root, ...)
直接比字符串:
- macOS:
/var是/private/var的软链 - Windows CI:
RUNNER~1是runneradmin的 8.3 短名
两者都让"直接比字符串"失败。Linux 恰好没有这层间接,所以本地和 ubuntu 都过。
修法:这条用例真正要验证的是"移动成功且内容对",不是"路径字符串相等"。
改成比 basename + 文件内容:
assert.equal(path.basename(r.to), 'b.txt')
assert.equal(fs.readFileSync(b, 'utf8'), 'A')0.9.8 的四项能力(本版包含)
| 能力 | 关键护栏 |
|---|---|
| 一键修复 | 只改托管块 / 写前备份 / 写后校验 / 二次确认 / 可撤销 / 绝不用 0.0.0.0 |
| 外部工具检测 | 只探测 + 给命令,不代下载(守供应链边界) |
| 写操作 ×4 | confirm 必填 / 默认不覆盖 / 不递归删 / 原子写 / 8MB 上限 / roots 护栏 |
| 进程诊断 ×3 | kill 三道护栏:confirm / 拒杀自身与父进程 / 拒杀系统关键进程 |
| UI 折叠 | 首屏只留当下需要的;配置/体检/共享收进 <details> |
packaging 4 项
doc-drift 131/131
diag:inject 11 项
tests 1032(1030 pass / 0 fail / 2 skip)
lint 干净
这一版在做什么
用户要求把之前「为什么不做」的四项都做掉:
| 想法 | 这一版的答案 |
|---|---|
| 一键修复配置 | ✅ 做了,但两步确认 + 只改托管块 + 可撤销 |
| 自动装依赖 | ✅ 收窄为外部工具检测,只给命令,不代下载 |
| 更多工具 | ✅ 12 → 19(4 个写操作 + 3 个进程诊断) |
| 重写 UI | ✅ 改为折叠区块,首屏只留当下需要的 |
四项全都在放宽边界,所以每一道护栏都对应一次真实事故。
① 一键修复(会改 DSH 的 cordis.patch.yml)
你此前明确说过「不要改 DSH」,而这项恰恰要改它。所以:
[预览修复] → 只读,列出"将要改什么" + "不会做什么"
[确认修复] → 才写盘(必须带 confirm: "yes")
护栏:只改托管块(你的其它条目一个字节不碰)· 写前必备份 · 写后必校验 ·
失败必回滚 · 提供「撤销」· 绝不用 0.0.0.0(会让 DSH 启动崩溃,我们踩过 10 份 crash 日志)。
只修机械可判定的项(webserver host / role)。端口占用、防火墙、装软件这些
需要人判断的,只给建议不动手。
② 外部工具检测(不代下载)
✓ Tailscale 1.102.4
✓ PowerShell 5
✓ git git version 2.54.0.windows.1
✓ Node.js v24.18.0
只做三件事:探测在不在 · 说清缺了会怎样 · 给官方链接 + 安装命令(你自己复制执行)。
不代下载是刻意的边界:零 npm 依赖是核心卖点;代下载第三方二进制会让我们
变成软件分发方(版本/来源/校验/责任/供应链攻击面全落在我们头上)。
③ 工具 12 → 19
写操作(remote_write / remote_mkdir / remote_move / remote_delete):
- 必须传
confirm: "yes"(不可逆,AI 会"顺手"多调一次) - 默认不覆盖已有文件 —— 防手滑清空
- 不递归删除:非空目录一律拒绝
- 原子写(临时文件 + rename),失败不留半截
- 单次上限 8MB(防写爆磁盘)
进程诊断(remote_ps / remote_port / remote_kill):
kill 有三道护栏:
- 必须
confirm: "yes" - 拒绝杀本进程与父进程 —— 否则副机失联,你彻底失去远程修复能力
- 拒绝杀系统关键进程(System/csrss/wininit/services/lsass 等)
④ UI 折叠
面板区块到了 9 个,首屏被淹没。改用原生 <details>:
- 首屏只留:错误横幅 + 状态卡 + 节点 + 任务 + 派发表单
- 「接入与配置」「环境体检与工具」「工作区共享」收进折叠块
- 默认展开策略:没副机 → 展开接入;体检有失败项 → 展开体检
踩坑记录(都写进了代码注释)
① resolveAny 要求路径已存在 → remote_write 100% 失败
只读工具用 realpathSync 是对的,但写操作的目标本来就是要新建的。
新增 resolveWritable:目标不存在时向上找第一个存在的祖先做 realpath。
② 多行 PowerShell 脚本 join(' ') 缺分号 → UnexpectedToken。
③ process-service.js 忘了导出 createProcessService → 6 个测试文件全部加载失败。
④ 测试锚点被新代码干扰 → indexOf('confirm-required') 找到的第一处不再是被测那条。
⑤ CI 平台差异:测试直接比绝对路径,macOS(/var → /private/var)与
Windows(RUNNER~1 8.3 短名)都失败,Linux 恰好通过。改成比 basename + 内容。
安装
# DSH 插件对话框里填:
github:zbzbhs/dsh-remote-agent
# 或 npm
npm install -g dsh-remote-agent@0.9.9质量
CI 7/7(Windows / macOS / Linux × Node 22 / 24)
packaging 4 项
doc-drift 131/131
diag:inject 11 项
tests 1032(1030 pass / 0 fail / 2 skip)
lint 干净
v0.9.7 - fix X25519 key rebuild (CI JWK regression) + UX five improvements
v0.9.7
0.9.6 的 CI 回归修复。 0.9.6 在本地全绿,但 GitHub Actions 的
ubuntu-latest / node 24.x上 E2E 握手全部超时。
修复:X25519 私钥重建改用 DER,不再依赖 JWK 的 x 校验
现象(CI run 39384173448,只有 ubuntu/node24 失败):
Error: timeout waiting for e2e session on test-worker
code: 'ERR_CRYPTO_INVALID_JWK'
根因:lib/e2e.js 用 JWK 重建私钥:
createPrivateKey({ key: { kty: 'OKP', crv: 'X25519', d: b64, x: '' }, format: 'jwk' })本地 Node 24.18 接受 x: '',但 CI 那个 Node 24 小版本不接受
(空串不是合法 base64url;省略 x 同样被拒)。
这个 bug 为什么藏了这么久:失败表现为
timeout waiting for e2e session —— 真因(ERR_CRYPTO_INVALID_JWK)被
完全掩盖,看起来像时序抖动,而本地又复现不了。
修法:直接用 DER 构造 —— X25519 私钥的 PKCS#8 就是
固定 16 字节前缀 + 32 字节标量。这条路径不经过 JWK 校验,跨 Node
小版本稳定。
实测验证:DER 构造出的私钥导出的公钥 x 与原始 JWK 完全一致,
DH 协商结果与正常生成的密钥对一致。
测试:test/e2e.test.js 新增 2 条 ——
双方派生会话密钥必须一致(E2E 握手的核心数学)、非法长度必须明确抛错。
0.9.6 的五项体验提升(本版包含)
- ④ 统一错误翻译层 —— errno + 17 个协议码 + HTTP + 文案特征,每条带"下一步"
- ① 副机连接状态可见 —— 记录每次连接尝试,空态显示原因与修法
- ② 一键复制修法 —— 体检项旁的「复制修法」按钮(只复制,不代改文件)
- ③ 首次使用向导 —— 编号步骤 + 每步"应该看到什么"
- ⑤ 任务进度摘要 —— 任务行直接显示"最近:调用工具 Write · 共 23 步"
packaging 4 项
doc-drift 126/126
diag:inject 11 项
tests 1009(1007 pass / 0 fail / 2 skip)
lint 干净
这个补丁版在修什么
0.9.6 的 CI 回归。 本地全绿,但 GitHub Actions 的
ubuntu-latest / node 24.x 上 E2E 握手全部超时。
真因
Error: timeout waiting for e2e session on test-worker
code: 'ERR_CRYPTO_INVALID_JWK'
lib/e2e.js 用 JWK 重建 X25519 私钥:
createPrivateKey({ key: { kty:'OKP', crv:'X25519', d: b64, x: '' }, format:'jwk' })本地 Node 24.18 接受 x: '',但 CI 那个小版本不接受
(空串不是合法 base64url;省略 x 同样被拒)。
为什么这个 bug 藏了很久
失败表现为 timeout waiting for e2e session —— 真因被完全掩盖,
看起来像时序抖动,而本地又复现不了。
修法
直接用 DER 构造 —— X25519 私钥的 PKCS#8 就是固定 16 字节前缀 +
32 字节标量。这条路径不经过 JWK 校验,跨 Node 小版本稳定。
实测:DER 构造出的私钥导出的公钥 x 与原始 JWK 完全一致,DH 协商
结果与正常生成的密钥对一致。
同时包含 0.9.6 的五项体验提升
| # | 提升 |
|---|---|
| ④ | 错误翻译成人话 —— errno + 17 个协议码 + HTTP + 文案特征,每条带"下一步" |
| ① | 副机连接状态可见 —— 记录每次连接尝试,空态显示原因与修法 |
| ② | 一键复制修法 —— 体检项旁的「复制修法」按钮(只复制,不代改文件) |
| ③ | 首次使用向导 —— 编号步骤 + 每步"应该看到什么" |
| ⑤ | 任务进度摘要 —— 任务行直接显示"最近:调用工具 Write · 共 23 步" |
安装
# DSH 插件对话框里填:
github:zbzbhs/dsh-remote-agent
# 或 npm
npm install -g dsh-remote-agent@0.9.7质量
CI 7/7(Windows / macOS / Linux × Node 22 / 24)
packaging 4 项
doc-drift 126/126
diag:inject 11 项
tests 1009(1007 pass / 0 fail / 2 skip)
lint 干净
v0.9.6 - UX: error translation + connection diagnostics + wizard + progress
v0.9.6
用户体验:五项提升("出了问题不知道原因"的正面回应)
用户实测反馈的核心痛点不是功能不够,而是看不到系统在做什么。
下面五项都指向同一件事:让每一步的状态可见、每个错误可行动。
④ 统一错误翻译层(lib/explain-error.js)
此前原始错误直接抛给用户:
connect ECONNREFUSED 100.86.48.54:19387
handshake timeout: no welcome/accepted within 15000ms
task t-xxx terminal: timeout after 40000ms
HTTP 401 {"error":"unauthorized"}
项目里原本有一份 friendlyConnectError(lib/setup.js),但只覆盖 5 类
网络错误、且没用到协议错误码。现在统一到一处,覆盖:
- errno(ECONNREFUSED / ETIMEDOUT / EADDRINUSE / EADDRNOTAVAIL / …)
- 协议错误码(17 个,全部有专门翻译)
- HTTP 状态(401/403/404/405/409/500/502/503/504)
- 文案特征(
timeout after Nms→ 换算成秒、handshake timeout→ 版本差异)
每条都给"下一步做什么",并保留原始信息(raw)供排查。
设计约束:绝不抛、不猜(拿不准的如实说"未知,请附原始错误")。
① 副机连接状态可见
此前:副机连不上时主机只显示「0 在线」,没有任何原因。
用户为此排查了两天(重启回引导页、19387 不监听、SMB 密码…)。
现在:注册表记录每一次连接尝试(成功/失败 + 原因 + 时间 + 远端地址,
环形上限 50 条)。面板节点区空态渲染:
✗ newland 连接失败 2 分钟前
100.72.146.73:19387 连不上(超时)
→ 检查:① 两台机器能否互 ping;② 主机防火墙;③ 是否同一局域网
原始:connect ETIMEDOUT
并区分三种情形(排查方向完全不同):
- 有失败记录 → 显示原因与修法
- 从没尝试过 → "副机还没开始连"
- 连过但现在不在线 → "副机 DSH 可能没在跑"
② 一键复制修法
体检项旁边加「复制修法」按钮。修法里常含可直接粘贴的 YAML/命令,
让用户手抄是纯粹的摩擦。
只做复制,不做"一键修复" —— 后者会改用户文件,风险 > 收益
(我们刚因为改 DSH 文件出过事故)。
③ 首次使用向导
配置完成后的"下一步"从一句话变成编号步骤 + 每步"你应该看到什么":
1 重启 DSH 必须
关掉 DSH 窗口再打开。配置只在启动时读取。
2 生成配对码
重启后这个面板会出现「副机接入」按钮 —— 点它,复制那行 dshra1://
3 在副机上粘贴
副机也装本插件 → 打开面板 → 粘贴 → 保存 → 重启副机 DSH
4 等它连上
「在线节点」会从 0 变成 1。若一直是 0,看「节点」区的失败原因。
角色选择页也加了一句判断提示:
通常:你现在用的这台(你在这里操作 DSH 的)= 主机;
另一台要被派活的电脑 = 副机。
⑤ 任务进度摘要
长任务(agent 跑 10 分钟)原来只有展开详情才能看到「进行步骤」。
现在任务行下方直接显示:
运行中 · 已跑 6m12s
最近:调用工具 Write · 共 23 步
只对运行中的任务显示(已完成看结果摘要更有用),只取最后一条有内容的步骤。
顺带修复
lib/node/index.js的friendlyConnectErrorimport 已无用(被 explain-error
替代),删除explainError的取值路径逐层 try ——String(err)在toString抛错时
会崩,而翻译层的职责是"绝不抛"(测试抓到的真 bug)
测试
test/explain-error.test.js20 条:errno / 协议码全覆盖(断言 17 个
ERROR_CODE 都有专门翻译,不能落到兜底)/ HTTP / 文案特征 / 健壮性
(含toString抛错的恶意对象)/ 渲染- doc-drift 新增 88(翻译层覆盖面)、89(连接历史链路)、90(复制按钮 +
编号步骤 + 进度摘要)
写测试时又踩了一次"断言自己的问题":健壮性用例的消息里写了
String(v),而那个用例的 toString 是故意抛错的 → 断言在构造消息时
自己先崩,误报成"实现抛错"。改用下标标识用例。
packaging 4 项
doc-drift 126/126
diag:inject 11 项
tests 1007(1005 pass / 0 fail / 2 skip)
lint 干净
这一版在解决什么
用户实测反馈的核心痛点不是功能不够,而是:
出了问题,不知道原因。
具体证据(都来自真实排查过程):
| 遇到的问题 | 当时能看到什么 |
|---|---|
| 副机连不上 | 只显示「0 在线」,没有任何原因 |
| 每次重启回引导页 | 不知道配置被谁改了 |
| 10 份 crash 日志 | 只有原始堆栈 |
| 体检报了问题 | 修法要手抄 YAML |
所以这一版全部指向同一件事:让每一步的状态可见、每个错误可行动。
五项提升
④ 错误翻译成人话
改前:connect ECONNREFUSED 100.86.48.54:19387
改后:100.86.48.54:19387 上没有服务在监听
连接被主动拒绝 —— 对端机器在,但那个端口没程序在听。
→ 检查:① 主机的 DSH 是否在跑;② 端口是否写对;③ 防火墙
覆盖 errno(网络/文件系统)+ 17 个协议错误码 + HTTP 状态 + 文案特征。
① 副机连接状态可见
记录每一次连接尝试(成功/失败 + 原因 + 时间 + 远端地址),面板空态直接显示:
✗ newland 连接失败 2 分钟前
100.72.146.73:19387 连不上(超时)
→ 检查:① 两台机器能否互 ping;② 防火墙;③ 是否同一局域网
原始:connect ETIMEDOUT
并区分三种情形(排查方向完全不同):有失败记录 / 从没尝试过 / 连过又断了。
② 一键复制修法
体检项旁加「复制修法」按钮。只复制,不代改文件 —— 改用户配置风险太高。
③ 首次使用向导
从一句话变成编号步骤,每步告诉你"应该看到什么":
1 重启 DSH 必须
2 生成配对码
3 在副机上粘贴
4 等它连上 「在线节点」会从 0 变成 1
⑤ 任务进度摘要
运行中 · 已跑 6m12s
最近:调用工具 Write · 共 23 步
安装
# DSH 插件对话框里填:
github:zbzbhs/dsh-remote-agent
# 或 npm
npm install -g dsh-remote-agent@0.9.6质量
packaging 4 项
doc-drift 126/126
diag:inject 11 项
tests 1007(1005 pass / 0 fail / 2 skip)
lint 干净
v0.9.5 — 修 0.9.4 装上后激活失败(duplicate upgrade route)
[0.9.5] - 2026-10-20
紧急修复:0.9.4 装上后 DSH 报激活失败,插件显示"异常"。
webserver: duplicate upgrade route "/api/remote-agent/node" 1 entry did not activate remote-agent (dsh-remote-agent)0.9.4 在全新启动的场景是好的(我验证过单次 apply 注册一次),
但热重载会撞车 —— 而 DSH 在插件安装/升级后正是靠热重载生效的。
🔴 根因:我们的注册不可回收,而宿主不会帮我们回收
webServer.registerUpgrade(path) 在 DSH 里是按 path 查表的,同路径第二次
注册直接抛(组合级契约,它这么做是对的):
registerUpgrade(route) {
if (this.upgrades.has(route.path)) throw new Error(`webserver: duplicate upgrade route "${route.path}"`)
this.upgrades.set(route.path, route)
return () => { this.upgrades.delete(route.path) }
}而本插件的注册全部收集在 __disposers 里,通过 applyHub() 的返回值
(disposeAll)交给调用方保管 —— 但 DSH 不认识这个返回值。热重载时它只
重新 apply,不会回头调我们的 disposer。于是第二代的注册压在第一代的残留上:
第一代 apply → 注册 /api/remote-agent/node(表里有了)
DSH 热重载 → 直接再 apply(不 dispose)
第二代 apply → registerUpgrade(同一 path) → throw → 整个 entry 激活失败
0.9.3 及以前没暴露,是因为那时改完配置都靠"重启 DSH"(进程重来,表是新的);
0.9.4 是在插件管理页里安装/升级,走了热重载路径 —— 这是场景差异,不是新引入的代码。
修复
- apply 变成可重入(
lib/index.js):按注册宿主(ctx.webServer服务
实例,读不到时退回 ctx)记住"当前这一代"的 dispose;重新 apply 时先自己把
上一代摘干净再装。不指望宿主帮我们清理 —— 这是唯一在各种宿主行为下都
成立的修法。 disposeAll改成逆序(lib/hub/index.js):此前是正序跑的,会先拆运行时
(registry.dispose())再拆工具,工具的 disposer 可能碰到已销毁的 registry。
现在与 cordis 的"逆序释放"语义一致。- 独立监听加了 EADDRINUSE 有限重试:重入时先
close()旧监听、立刻用同一
端口重新listen,在 Windows 上经常还拿不到端口(close 是异步的)。重试
4 次(200/400/600ms 退避),真被别的进程占用仍会如实报错。 handles.dispose:apply 的返回值现在带一个顶层dispose(),任何愿意调
它的宿主都能正确清理。
为什么 CI 没拦住(第三次同类)
仓库里所有测试桩都比宿主宽松:hub-worker-e2e.test.js 的 makeCtx() 里
register() 是 table.set()(同名静默覆盖)、registerUpgrade() 只是再挂一个
server.on('upgrade')。真宿主会抛,桩不会 —— 桩比宿主宽松 = 假绿灯。
新增 test/webserver-contract.test.js:照宿主源码造一个重复即抛的严格 ctx,
钉住四条契约:
- 单次 apply 只注册一条 upgrade 路由
- 同一 ctx 重复 apply 不抛(就是本次的事故回归)
- apply → dispose → apply 可反复进行,且旧世代的 dispose 不得影响新世代
- 重入后独立监听仍能绑回同一端口(热重载不能把端点变成永久不可用)
升级须知
- 装完请在插件页把开关关一次再打开(或重启 DSH),让热重载重新加载新代码。
- 如果你之前点了「生成口令」,副机要用新口令重新配对。
packaging 4 项
doc-drift 123/123
diag:inject 11 项
tests 987(985 pass / 0 fail / 2 skip)
lint 干净
v0.9.4 —— 修完 0.9.3 审查的 19 项(2 Critical + 5 High)
修完 0.9.3 审查的全部 19 项(2 Critical + 5 High + 6 Medium + 6 Low)。
0.9.3 请在装了的同时升级到本版:0.9.3 有两个 Critical —— 面板生成的
配对码用的是 DSH webserver 的端口(粘了 100% 连不上),以及nodeToken
为空时独立监听成了 tailnet 内的无鉴权接入点。
Critical
- C1 配对码端口取错 —— 前端把浏览器地址栏端口(58626 之类)拼进配对码,
而节点端点在 19387。现在/pair-info从实际监听状态取端口,响应带
portSource,候选地址标usable并排在前面。 - C2 配对码地址顺序与绑定顺序相反 ——
detectLanIps()自己实现了一套
分类(私网优先),而绑定是 Tailscale 优先。现在共用listCandidateAddresses()。
High
- H1
nodeToken为空 = 无鉴权接入点 —— token 为空且没显式指定可达
listenHost时强制绑127.0.0.1,面板给黄色提示引导去「生成口令」。 - H2 面板保存会整块重写托管块,凭证全丢 —— 新增文本级字段保留
(nodeToken/uiPullRoot/listenHost/listenPort/relay…),
表单只覆盖它管辖的 6 个键。 - H3 面板没有生成口令的入口 —— 新增
POST /setup/rotate-token
(仅接受回环请求),面板「副机接入」加「生成口令」(带二次确认),
明文只回一次。 - H4 CI 连红两次还发了 npm —— 自检的手工复制清单漏文件导致 20 个用例
全挂。改成自动扫lib/*.js全量复制(同一规矩第六次被踩,这次结构性修掉)。 - H5 架构换了诊断层没换 → 面板报假红 —— reachability 改两层结构 + getter,
listener四态,横幅三态;env-check 重写;README 安装段重写。
Medium
- M1
writeInstanceConfig漏传config(配了stateDir的机器持久化失效)。 - M2
listenHost/listenPort/relay进 schema 与持久化白名单。 - M3 instance.json 覆盖 patch 时明确 log(不再静默);
--doctor也报告。 - M4 独立监听启动失败在面板与体检里可见(带原因与修法)。
- M5 CLI 向导写完 patch 同步写 instance.json。
- M6 CHANGELOG 补 0.9.3 / 0.9.4 条目。
Low
server.on('error') 不再静默吞(L1)· 回环判据统一为 isLoopbackHost()(L2)·
explicitPort 看原始键(L3)· /healthz 匿名只回 { ok: true },带
x-dsh-token 才给详情(L4)· 候选地址稳定排序(L6)。
升级须知
- 不用再给
webserver填 host;防火墙放行listenPort(默认 19387)。 - 在面板里点过「生成口令」的,副机要用新口令重新配对。
packaging 4 项
doc-drift 123/123
diag:inject 11 项
tests 982(980 pass / 0 fail / 2 skip)
lint eslint 严格档干净
CI 7 job 全绿(win/macos/ubuntu × node 22/24 + packaging + lint)
v0.9.1 - fix env-check role wiring + SMB share dir + doctor merge
v0.9.1
0.9.0 发布约 40 分钟后补发。两个 High 都是真实功能失效
(体检对新装用户全面误报、SMB 共享完全不可用),趁用户数还少尽快修正。
修复(High):环境体检对默认安装全面误报,且五项检查静默跳过
/env-check 路由传的是原始 config.role,而 0.9.0 起默认安装的生效配置
就是 role: 'auto'(Config 的 .default('auto') + 包内 patch 显式写入)。
envChecks 的判据 isHub = role === 'hub' || role === 'both' 拿到未解析的
'auto' 判 false,于是:
- reachability / port / firewall / tailscale / nodes 五项全部跳过
—— 而这五项正是体检存在的理由(副机连不上的静默失败) inject按 worker 三件套判定 → hub 正确的 inject 反被判"缺服务"role检查误报未配置
实测(修复前):
role=auto → { ok:false, failed:2, items:[role(FAIL), inject(FAIL)] }
影响放大:9.0 的"首次自动体检"让这份"双误报 + 零诊断"在每个新装用户
首次打开面板时自动弹出。
修复(双保险):
- 路由改传
resolveRole(config) || 'hub'(与setup-routes同一份实现) envChecks自己也解析 —— 即使调用方传错'auto'也能推断
(判据:配置里写了 role 就以配置为准交给resolveRole;没这个字段才用
调用方给的值。两个极端写法都会误报,已由 5 个场景用例覆盖)
实测(修复后):
role=auto → role ✓ / inject ✓ / reachability ✗(正确报出 loopback)/ nodes ✗
修复(High):SMB「生成共享命令」100% 失败
面板的 doPlan 恒定发送 dir: '' —— 面板里根本没有选择目录的 UI,
而副机侧 if (dir === '') reply ok:false 'plan 需要 dir' 按设计拒绝。
于是工作区共享的唯一主动作完全不可用(probe 与后端 RPC 正常,API 直调可绕)。
修复:
- 加目录选择 UI,数据源是
node.roots(nodeView一直透出它,那是副机的
安全边界、也正是该共享的目录),默认第一项 - 无
roots时退回可手填的输入框 + 说明原因 - 换副机时重置候选与 plan/probe 结果(否则会显示别人的共享命令)
dir为空时禁用按钮(不给用户"点了才失败")
修复(Medium):CLI --doctor 与面板体检是两套并行实现
三处注释曾声称"命令行与面板共用 lib/env-check.js 的同一份逻辑" —— 那是失真的:
CLI 走 setup.js 的 doctorChecks(14 项静态),面板走 envChecks(8 项运行时态),
检查项不相交。CLI 有 Node 版本 / hubUrl / token 但没有 loopback 诊断
(本版本最重要的那条),面板有 reachability 但没有前者。
修复:两段都跑、结果合并。顺带修掉 CLI 把 role 写死成 'hub' 的同类 bug
(grab('role') || 'hub' 会让 auto-worker 被当 hub 体检)→ 改用 resolveRole。
实测新增输出:✓ Web 服务绑在可达地址 —— 当前监听 http://0.0.0.0:19387
修复(Medium):模型下拉的「自定义…」永远展不开文本框
原实现靠 fv.model === '__custom__' 判定,但表单里存的是真实模型名或空串,
从来不是字面量 '__custom__':
- 从"跟随默认"(model='')选自定义 → 旧值仍为 '' →
isCustom=false→ 弹回 - 从任一有效选项选自定义 → 旧模型名被保留 →
isCustom=false→ 同样弹回
唯一能进 isCustom 的路径是"表单预填了清单外模型名"(先有鸡)。
修复:加独立布尔 state modelCustom,isCustom = modelCustom || (model 非空 且不在清单里)。
修复(Low ×5)
- templates.js 注释残留旧重试参数(~1.3s / 封顶 200ms)与常量
(16 次 / 500ms / ~6s)不符 → 改为引用常量名 + 指向事故史。
这条最容易埋雷:后来者可能照注释把窗口"修"回去。 net share文本解析对含空格路径截断(D:\Deepseek Harness file
→D:\Deepseek)→ 改用Get-SmbShare | ConvertTo-Json取结构化路径,
net share仅作回退。本项目自己的目录名就带空格,这个 bug 在本机即会触发。autoFix承诺了不存在的能力(全仓库无 fix 路由 / 无--fix参数,
面板也没修复按钮)→ 语义改为"修法机械、照抄即可",文案全部改写,
reachability 项直接给可粘贴的 YAML 片段。- README 两处数字漂移:状态行仍写 0.8.1、断言数写 117(实际 118)→ 修正中英两版。
- hub ingest 无终态守卫:晚期
TASK_END/TASK_SUMMARY可把已置
FAILED(node-unreachable)改写回真实终态(良性但破坏"终态即终态"心智模型)
→ 加TERMINAL_STATUS.has(t.status)守卫(同终态重发幂等放行)。
新增:装配级测试(10 条)
test/assembly-wiring.test.js —— 全部锚在接线处而非模块内部:
两个 High 的共同点是"单测覆盖了模块本身,没人测模块被装配进系统时的形状"。
envChecks({role:'hub'}) 全绿证明不了路由传对了;buildShareCommand 全绿
证明不了面板真的发了 dir。
另加 doc-drift 断言 84/85 钉住这两条接线,并做了负向验证
(回退修复后断言正确报 DRIFT)。
packaging 4 项
doc-drift 120/120
diag:inject 11 项
tests 949(948 pass / 0 fail / 1 skip)
lint 干净
为什么有这个补丁版
0.9.0 发布约 40 分钟后补发。两个 High 都是真实功能失效:
| # | 问题 | 影响面 |
|---|---|---|
| H1 | 环境体检把 role: auto 判成非 hub |
100% 新装用户,且首次自动体检会让"双误报 + 零诊断"自动弹出 |
| H2 | SMB「生成共享命令」恒发 dir: "" |
SMB 共享的唯一主动作 100% 失败 |
两个都是"模块本身正确、装配进系统时接线断了"—— 单测全绿但功能对用户不可用。
安装
# DSH 插件对话框里填:
github:zbzbhs/dsh-remote-agent
# 或 npm
npm install -g dsh-remote-agent@0.9.1从 0.9.0 升级需要做什么
什么都不用做,直接升级即可。
如果你已经装过 0.9.0 并打开过面板:
- 之前看到的体检结果("role 未配置"、"inject 缺服务")是误报,忽略它
- 升级后重新打开面板会自动体检一次,这次是对的
- 如果你试过「生成共享命令」失败 —— 那是 H2,升级后可用
完整修复清单
- H1 环境体检 role 接线(路由传解析后角色 + envChecks 自己也能解析 auto)
- H2 SMB 面板加目录选择(数据源
node.roots) - M1 CLI
--doctor合并 envChecks(补上 loopback 诊断) - M2 模型下拉「自定义…」真正展开文本框(加
modelCustomstate) - L1 templates.js 注释残留的旧重试参数
- L2
net share对含空格路径截断(改用Get-SmbShare) - L3
autoFix承诺了不存在的修复能力(改语义 + 给可粘贴片段) - L4 README 版本号与断言数
- L5 hub ingest 终态守卫
新增:装配级测试
test/assembly-wiring.test.js 10 条 + doc-drift 断言 84/85,全部锚在接线处。
这次的教训写进了测试:envChecks({role:'hub'}) 全绿证明不了路由传对了;
buildShareCommand 全绿证明不了面板真的发了 dir。
v0.9.0 - out-of-box + SMB workspace sharing + env health-check + review fixes
v0.9.0
⚠ 行为变更:exec 任务默认超时 1 小时 → 10 分钟
升级前请读这一段。
此前 exec 任务实际拿到的超时是 60 分钟(不论文档说什么)。
原因是三处缺陷叠加(详见下方「代码审查修复」的 H1):
tools.js里有个 TDZ 自引用 bug,让分模式默认值那条分支永远走不到hub/index.js用numOr(defaults.timeoutMs, 1h)兜底,把explicitTimeoutMs
压成恒 3600000 → 优先级args > 显式配置 > 分模式默认被压扁成「恒 1h」- 面板 dispatch 同样写死
?? 3600000
三处都修了,所以现在:
| 模式 | 默认超时 | 意味着 |
|---|---|---|
exec |
10 分钟(EXEC_DEFAULT_TIMEOUT_MS) |
失控命令最多跑 10 分钟就被杀,不再占机一小时 |
agent |
1 小时(不变) | agent 任务本来就需要长窗口 |
如果你有 exec 任务本来就要跑 10 分钟以上,显式传 timeoutMs:
- 工具:
remote_task({ ..., timeoutMs: 1800000 })(30 分钟) - 面板:派发表单的「超时毫秒」字段
- 配置:
defaults.timeoutMs(作用于所有任务,优先于分模式默认)
修复:代码审查(1 High + 3 Medium + 6 Low)
WorkBuddy 2026-10-17 对 lib/ 全部 26 个源文件(约 19,900 行)做全量审查,
发现的缺陷逐条核实为真后修复。
H1(High)tools.js TDZ 自引用
const v = validateTaskStart({ constraints: {
timeoutMs: ... ?? (v.spec.mode === 'exec' ? ... : ...)
^ v 正在初始化,进入 TDZ?? 链左侧全空时抛 ReferenceError。
为什么 829 个测试全绿仍带此 bug:e2e helper 恒传 timeoutMs(?? 第一项
短路)、单测构造时 defaults 也总有值 —— 该分支从未被求值。这是典型的
「测试环境与生产装配层差异造成的盲区」。
生产没炸但更糟:normalizeConfig 的 1h 兜底让 TDZ 永不触发,却把分模式
默认压成死代码(见上方行为变更)。
修复:改用已在 v 之前求值的 routeMode。
M1(Medium)relay 第三条 hub 写路径漏背压
C1-1 的背压修复只加在 broadcastHubs / broadcastHubsBinary 两条路径上,
node 上行帧 → 全 hub 广播是第三条,漏网。而 ws.js 的 overBudget 只是
查询属性、不自动断连,所以这条路径的写缓冲没有任何上限检查。
实测过的后果形态:单个停滞对端把 relay writableLength 推到 339.8 MiB、
RSS 419 MiB。中继是 NAT 场景唯一会合点、单进程常驻,OOM 即全体断连。
M2(Medium)grep 单文件分支无大小护栏
目录扫描路径有 GREP_FILE_MAX(2MB)与 GREP_TIME_MS(10s)双护栏,
但 st.isFile() 分支直接 readFileSync 全文。触发形态:remote_grep 的
path 指向工作区内巨大文本文件(应用日志 / 数据库转储 / core dump)→
副机 OOM + 同步阻塞事件循环。这是一条可达的 DoS 面(agent 自主选择 grep 目标)。
M3(Medium)approveTask 节点不可达时删除任务
原实现 this.tasks.delete(taskId) —— 任务从 hub 视图彻底消失(不是失败、
不是挂起,是不存在),与该方法注释自称的「转 failed 终态」直接矛盾。
后果:remote_status / 面板都查不到;awaitTerminal 等不到终态只能靠超时
兜底(最长 1 小时+);与 ui-routes 的 502 文案不符。
修复:保留记录 + 置 FAILED + error.code='node-unreachable' + 唤醒等待者。
Low 系列(修 6 条)
- L1 副机 transport 缺 RelayLink 的两条并发不变式:
connecting闸门(start()被调两次会开两条 TCP 连接)、close 必须校验
this.conn === conn(否则陈旧连接的 close 把活着的新连接引用清掉 → 副机永久失联) - L2 RESOURCE 分支
rec未判空(影子节点竞态必抛 TypeError) - L3 runner 的 active 注册晚于
await createAgent(窗口期内 cancel 返回
false,正是 #26-5 修过的形态复现)→ 改为先占位再 await - L4
E2E_REOFFER_DEBOUNCE_MS死常量 → 真正按 250ms 时间窗去抖
(⚠ 它是实例字段不是 static,必须经this.读) - L5/L6/L7 未修(记录在案):relay 模式 hub 去重被中继层兜住、WS 版本校验
双方恒发 13、hub 空闲淘汰被副机 15s 心跳掩盖 —— 属一致性改进而非缺陷
测试
新增 test/code-review-regressions.test.js 11 条,覆盖 H1/M1/M2/M3/L1/L2/L4。
H1 的测试刻意不走 e2e helper(那正是盲区成因)。
文档:README 补「功能」章节
用户指出「README 好像没有功能相关的介绍」—— 核实后发现比这更严重:
- 版本号落后 4 个版本(写 0.4.0,实际 0.8.x)
- 工具数量写错(写「7 个」,实际 12 个:8 个
remote_*+ 4 个 fs 工具),
出现在架构图、目录表、工具速查三处 - 确实没有功能总览章节
新增「功能」六组:派任务 / 拿结果 / 翻文件 / 本地 IDE 打开(SMB)/ 自我诊断 /
断连跨网安全。英文版同步。
新增断言 83:README 声明的工具数量必须等于代码里 reg( + fsOp( 的注册数,
两边任一变化立刻失败(做过负向验证)。
关于 0.8.0 与 0.8.1(记录)
0.8.0 的 publish 第一次报「假成功」(tarball 未落地),当时判断它卡在 staging
并跳过发布了 0.8.1。事后查 registry 发现 0.8.0 最终也发布成功了
(07:43:45,比 0.8.1 晚 3 分钟)—— 那次 unpublish 同样假成功。
两个版本内容基本相同(都是 #26-4/#26-5 + EPERM 修复 + #27 完整版),
不影响使用。教训:npm publish 的成功/失败判定必须用 Cache-Control: no-cache
读 registry 核实,不能只看 exit code 或缓存副本。
文档:README 补「功能」章节 + 修正 4 个版本号的漂移
用户指出「README 好像没有功能相关的介绍」—— 核实后发现比这更严重:
1. 版本号落后 4 个版本
README L33: | **状态** | 0.4.0 —— 主机侧端到端可用 ... |
实际: npm latest = 0.8.1
2. 工具数量写错(7 → 12)
README 写「7 个 remote_* 工具」,实际是 12 个(8 个 remote_* +
4 个 fs 工具)。这个数字在 3 处出现(架构图、目录表、工具速查)。
3. 确实没有「功能」章节
原结构是「五分钟上手 → 核心不变式 → 安装 → 用法」——「用法」里只有工具速查,
没有一个"这个插件能干什么"的总览。读者只能从零散段落里拼。
修复
新增「功能」章节,分六组:
- 把任务派给副机 —— 两种模式 / 两阶段审批 / dry-run / 仓库感知 /
负载感知 / 模型下拉 / 分模式超时 / 并发上限 - 拿结果但不搬整个工作区 —— handoff 卡 / 三分类 / 显式拉取 /
断点续传 / 变更 diff - 直接翻副机的文件 —— 四个只读工具(不进任务队列)
- 用本地 IDE 打开副机文件 —— SMB 共享(含安全警示)
- 出问题能自己诊断 —— 环境体检 / 可达性自检 / 队列健康 /
运行中分组 / 取消真打断 / 审计日志 - 断连、跨网、安全 —— 断连不终止 / 两种传输 / E2E / 主机身份 /
凭证不出副机 / 零依赖
另修:架构图工具数、状态行(0.4.0 → 0.8.1)、测试数表、
lib/ 目录表(补 env-check.js、role: auto)。
英文版 README.en.md 同步(同样的 4 处漂移 + 完整 Features 章节)。
加了一条断言防复发(83)
工具数量这种「数字漂移」是最容易积累的文档债务 —— 每次加工具都忘了改
README,而读者会拿它当事实。所以新增断言:数代码里 reg( + fsOp(
的注册调用数,与 README 里的声明比对,两边任一变化都会立刻失败。
做了负向验证:临时把 README 的 12 改成 7,断言正确报错并给出
「实际 12 个(reg=8 + fsOp=4)vs 声明 7 个」。
packaging 4 项
doc-drift 118/118(+1)
diag:inject 11 项
tests 938(937 pass / 0 fail / 1 skip)
lint 干净
功能:工作区共享(SMB)—— 用本地 IDE 打开副机的文件
用户需求:想用本地 IDE 直接打开副机的项目目录。
实现方式:把副机的工作区通过 Windows SMB 共享出去,主机
net use X: \\副机\dsh-work-... 映射成盘符。
⚠ 这是本插件唯一主动放宽安全边界的功能
开启后:
roots/denyPaths全部失效(Windows 内核直读,插件拦不住)- 「清单先于字节」失效(中间产物、日志、
.env、.git密钥全部可见) - 新增凭证面(主机需要副机的 Windows 账号)
所以按用户要求做了三重防护:
- 二次确认(硬要求)——
POST /share缺confirm: 'yes'直接 400,
没有默认通过的分支;面板上必须勾选「我知道风险」按钮才可点 - 只读 / 读写两选项,默认只读(最小权限);读写用
-ChangeAccess
而非-FullAccess(后者会把共享 ACL 也交出去,主机能改权限) - 不代执行 —— 只生成可复制的命令,执行由用户在管理员 PowerShell 里做
为什么不代执行(重要设计决策)
New-SmbShare 需要管理员,而 DSH 通常以普通用户运行。Node 里提权的
失败表现(静默失败 / 被安全软件拦 / 权限半生效)比让用户手动跑一条命令
难排查得多。所以 handleShareReq 只做三件事:probe(探测管理员状态
与已有共享)、plan(生成共享 + 映射命令)、unshare-plan(生成卸载命令)。
实现要点
- 协议:新增
SHARE_REQ/SHARE_RES帧,与fs/req同款 RPC 模式 - E2E 强制:
SHARE_REQ进E2E_REQUIRED_TYPES—— 帧里含工作区
绝对路径(暴露副机目录结构),不许明文过中继 - 独立 pending 表:
sharePending与fsPending刻意分开 —— 混用会让
超时归因变模糊(一条 share 超时被记成 fs-timeout,排查方向直接跑偏) - 共享名稳定哈希:同一目录反复开关共享名不变(不留孤儿共享),固定
dsh-work-前缀便于识别与清理(不会误删用户的其它共享) - 共享目录过 roots 边界:
plan的目录必须经resolveWorkspace
(realpath + roots 前缀 + denyPaths),与 fs 工具同一条规则
文档
新增 docs/workspace-share.md:完整风险披露、
三步用法(面板开启 → 副机管理员执行 → 主机映射)、关闭方法、常见问题
(连不上怎么查、为什么共享名是怪名字、能不能共享整个盘、与 relay 的关系)。
测试
test/share-service.test.js17 条:共享名稳定性、只读/读写映射
(含"不得用 FullAccess")、单引号转义防注入、探测不抛、跨平台安全test/share-integration.test.js12 条:帧已声明、E2E 强制、
pending 表独立、断连 fail-fast、二次确认是硬要求、只读默认、
面板勾选框、不代执行、目录过 roots 边界
doc-drift 新增 81(接线完整)、82(安全约束)。
packaging 4 项
doc-drift 117/117
diag:inject 11 项
tests 926(925 pass / 0 fail / 1 skip)
lint 干净
开箱即用:装完就能用,不用读文档
用户标准(2026-10-17):"我希望是小白可用的,是开箱即用的"。
此前有三道门槛,每一道都能让小白卡死:
role默认''→ 两台机器都要操作一次,不是开箱即用webserver没绑0.0.0.0→ 副机永远连不上,且完全静默- 副机表单 4 个字段 → 不知道节点 ID / 工作区填什么
核心设计:role 默认 auto,按 hubUrl 推断
- 默认
hub:副机装完会以 hub 身份启动 —— 注册一个 nodeToken 为空、
不校验凭证的直连端点。这是实打实的攻击面(v0.7.0 之前的老问题)。 - 默认
'':两台机器都要操作一次,不是开箱即用。 - 默认
auto:没配hubUrl就是主机(零操作),配了hubUrl就是副机
(贴完配对码零操作)。两者都开箱即用,且不给副机开不该开的端点。
判据只有一条,但它是副机与主机最本质的区别:hubUrl 是副机独有字段,
hub 侧根本不读它。配对码展开后必然产出 hubUrl,所以"贴码 = 自动变副机"。
推断结果带 inferred 标记(no-hubUrl / hubUrl),面板可显示依据。
显式写 hub/worker/both 永远优先于推断 —— 用户写的不该被猜掉。
包内 patch 让 hub 真能被访问
- id: webserver
config:
host: '0.0.0.0'
port: 19387不覆盖这一行,hub 只监听 loopback → 副机永远连不上。这是"能不能用"的决定性一条。
安全披露(写进 patch 注释 + README + 面板 + 体检):绑 0.0.0.0 意味着
DSH 的整个 Web GUI 也能被局域网访问,不只是本插件的节点端点。DSH 自带
Host/Origin 围栏 + cookie 鉴权,但暴露面确实变大了。用户可在 profile 里覆盖
回 127.0.0.1(此时请改用 relay 模式)。
优先级安全:cordis.yml 头注释说明应用顺序是 bundles → profile patch →
--patch overlays,所以用户 profile 里的同名行会覆盖包内默认。
副机表单简化
- 工作区预填(Windows
D:\work,其他~/work)—— 此前是空,小白不知道
填什么:填相对路径被拒、填C:\太宽(安全边界)、填不存在的目录任务全报
workspace-denied - 节点 ID 折叠为高级选项 —— 默认取主机名,90% 用户不用改
- 目录自动创建 —— 写入配置时
mkdirSync(recursive)建不存在的 roots
(只建不改;相对路径跳过交给 resolveRoots 去拒;失败不阻塞写配置,
结果进warnings随响应回给面板)
首次自动体检
面板加载时自动跑一次体检(autoChecked ref 保证只跑一次,不进 4 秒轮询)。
此前要用户主动点「体检」按钮 —— 但小白不知道要点。
文档修正
README 安装段重写。顺带修正一个长期错误建议:旧文档教用户写
inject: [],那是错的 —— 副机 agent 的 ctx 继承插件的依赖声明,
inject: [] 会让 agent 没有任何 shell/文件工具,任务"跑完了但什么都没干"。
正确写法是 ['tools', 'agentLoop', 'sessions']。doc-drift 断言 30 也跟着改。
测试
test/out-of-box.test.js 15 条:auto 推断的四种情形(没 hubUrl / 有 hubUrl /
空白 hubUrl / 显式 role 优先)、空 role 仍走引导、非法 role 不回退 hub、
包内 patch 绑 0.0.0.0 + 安全披露、表单预填/折叠、自动建目录、首次自动体检。
doc-drift 新增 79(auto + webserver 绑定)、80(表单简化 + 自动建目录 + 自动体检)。
packaging 4 项
doc-drift 115/115
diag:inject 11 项
tests 897(896 pass / 0 fail / 1 skip)
lint 干净
功能:环境体检(命令行为准,面板可见)
这个插件最贵的失败全是静默的:
- webserver 绑 loopback → 副机永远连不上,而主机侧毫无异常
(hub healthz 200、面板能开、工具都在) - inject 缺服务 → hub 半死(路由活着但 8 个工具全丢)
- roots 不存在 → 任务全被
workspace-denied拒绝
用户只能猜"地址错了?token 错了?防火墙?"。
本版新增 lib/env-check.js,命令行与面板共用同一份逻辑:
envChecks()逐项检查:角色已配置 / inject 含必需服务 / Web 服务绑在
可达地址 / 端口可用 / 防火墙 / roots 存在 / Tailscale / 副机在线- 每项都带 detail(为什么) 与 fix(怎么修) —— 不是干巴巴一个 ✗
autoFix标记"我们能自动改配置修好它"
三档处置(产品决策):
| 档位 | 做法 | 例子 |
|---|---|---|
| A 自动修 | 只改我们自己的配置,不下载任何东西 | webserver 绑 0.0.0.0、role/inject/roots |
| B 提示 + 命令 | 给官方链接与命令,不代下载 | Tailscale 未安装 |
| C 只诊断 | 报清楚,让人判断 | 端口占用、防火墙、模型凭证 |
为什么不代下载(边界):零 npm 依赖 / 零构建是核心卖点。代用户下载第三方
软件会让本插件变成软件分发方 —— 装哪个版本、从哪下、校验和怎么保证、装失败
算谁的、供应链攻击面,全落在我们头上。所以档位 B 只给链接与命令。
配套:
applyHub新增可达性自检:调webServer.getOrigin()判定是否绑在
loopback,是则log('warn')带修法。自检本身包 try/catch ——
getOrigin()在 listen 前会抛,绝不能因为自检让 hub 起不来- 面板新增红色横幅(绑 loopback 时)+「体检」按钮 + 体检结果区
- 新增
GET /api/remote-agent/ui/env-check路由
测试:test/env-check.test.js 24 条(loopback 识别的 8 种写法 +
各检查项的通过与失败 + 修法必须具体 + warn 不计入 failed +
config 传 null 不抛 + 渲染每条 ✗ 都带修法)。
doc-drift 新增 77(三处齐备)、78(覆盖静默失败项 + 不代下载边界)。
写测试时抓到 3 个真问题:config: null 会 TypeError、Tailscale 文案漏了
"不代下载"说明、以及一个断言的正则转义层数写错。
安装
# DSH 插件对话框里填:
github:zbzbhs/dsh-remote-agent
# 或 npm
npm install -g dsh-remote-agent@0.9.0开箱即用:装完重启 DSH 就能用。包内 patch 默认 role: auto(没配 hubUrl
就是主机),并把 `webs...