Skip to content

Releases

v0.10.1 — 修复 output 契约,插件可被 DSH 真正激活

Choose a tag to compare

@zbzbhs zbzbhs released this 02 Nov 08:57

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 — 会话门禁、真实自检、双向文件与桌面截图

Choose a tag to compare

@zbzbhs zbzbhs released this 02 Nov 08:27

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 补齐四项能力介绍

Choose a tag to compare

@zbzbhs zbzbhs released this 20 Oct 08:35

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 折叠

Choose a tag to compare

@zbzbhs zbzbhs released this 20 Oct 08:03

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 有三道护栏:

  1. 必须 confirm: "yes"
  2. 拒绝杀本进程与父进程 —— 否则副机失联,你彻底失去远程修复能力
  3. 拒绝杀系统关键进程(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

Choose a tag to compare

@zbzbhs zbzbhs released this 20 Oct 07:19

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

Choose a tag to compare

@zbzbhs zbzbhs released this 20 Oct 07:08

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 的 friendlyConnectError import 已无用(被 explain-error
    替代),删除
  • explainError 的取值路径逐层 try —— String(err) 在 toString 抛错时
    会崩,而翻译层的职责是"绝不抛"(测试抓到的真 bug)

测试

  • test/explain-error.test.js 20 条: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)

Choose a tag to compare

@zbzbhs zbzbhs released this 20 Oct 05:21

[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 是在插件管理页里安装/升级,走了热重载路径 —— 这是场景差异,不是新引入的代码。

修复

  1. apply 变成可重入(lib/index.js):按注册宿主(ctx.webServer 服务
    实例,读不到时退回 ctx)记住"当前这一代"的 dispose;重新 apply 时先自己把
    上一代摘干净再装。不指望宿主帮我们清理 —— 这是唯一在各种宿主行为下都
    成立的修法。
  2. disposeAll 改成逆序(lib/hub/index.js):此前是正序跑的,会先拆运行时
    (registry.dispose())再拆工具,工具的 disposer 可能碰到已销毁的 registry。
    现在与 cordis 的"逆序释放"语义一致。
  3. 独立监听加了 EADDRINUSE 有限重试:重入时先 close() 旧监听、立刻用同一
    端口重新 listen,在 Windows 上经常还拿不到端口(close 是异步的)。重试
    4 次(200/400/600ms 退避),真被别的进程占用仍会如实报错。
  4. 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)

Choose a tag to compare

@zbzbhs zbzbhs released this 20 Oct 04:38

修完 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

Choose a tag to compare

@zbzbhs zbzbhs released this 17 Oct 16:24

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 的"首次自动体检"让这份"双误报 + 零诊断"在每个新装用户
首次打开面板时自动弹出
。

修复(双保险):

  1. 路由改传 resolveRole(config) || 'hub'(与 setup-routes 同一份实现)
  2. 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 模型下拉「自定义…」真正展开文本框(加 modelCustom state)
  • 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

Choose a tag to compare

@zbzbhs zbzbhs released this 17 Oct 15:42

v0.9.0

⚠ 行为变更:exec 任务默认超时 1 小时 → 10 分钟

升级前请读这一段。

此前 exec 任务实际拿到的超时是 60 分钟(不论文档说什么)。
原因是三处缺陷叠加(详见下方「代码审查修复」的 H1):

  1. tools.js 里有个 TDZ 自引用 bug,让分模式默认值那条分支永远走不到
  2. hub/index.js 用 numOr(defaults.timeoutMs, 1h) 兜底,把 explicitTimeoutMs
    压成恒 3600000 → 优先级 args > 显式配置 > 分模式默认 被压扁成「恒 1h」
  3. 面板 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. 确实没有「功能」章节

原结构是「五分钟上手 → 核心不变式 → 安装 → 用法」——「用法」里只有工具速查,
没有一个"这个插件能干什么"的总览。读者只能从零散段落里拼。

修复

新增「功能」章节,分六组:

  1. 把任务派给副机 —— 两种模式 / 两阶段审批 / dry-run / 仓库感知 /
    负载感知 / 模型下拉 / 分模式超时 / 并发上限
  2. 拿结果但不搬整个工作区 —— handoff 卡 / 三分类 / 显式拉取 /
    断点续传 / 变更 diff
  3. 直接翻副机的文件 —— 四个只读工具(不进任务队列)
  4. 用本地 IDE 打开副机文件 —— SMB 共享(含安全警示)
  5. 出问题能自己诊断 —— 环境体检 / 可达性自检 / 队列健康 /
    运行中分组 / 取消真打断 / 审计日志
  6. 断连、跨网、安全 —— 断连不终止 / 两种传输 / 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 账号)

所以按用户要求做了三重防护:

  1. 二次确认(硬要求)—— POST /share 缺 confirm: 'yes' 直接 400,
    没有默认通过的分支;面板上必须勾选「我知道风险」按钮才可点
  2. 只读 / 读写两选项,默认只读(最小权限);读写用 -ChangeAccess
    而非 -FullAccess(后者会把共享 ACL 也交出去,主机能改权限)
  3. 不代执行 —— 只生成可复制的命令,执行由用户在管理员 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.js 17 条:共享名稳定性、只读/读写映射
    (含"不得用 FullAccess")、单引号转义防注入、探测不抛、跨平台安全
  • test/share-integration.test.js 12 条:帧已声明、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):"我希望是小白可用的,是开箱即用的"。

此前有三道门槛,每一道都能让小白卡死:

  1. role 默认 '' → 两台机器都要操作一次,不是开箱即用
  2. webserver 没绑 0.0.0.0 → 副机永远连不上,且完全静默
  3. 副机表单 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...

Read more