# 贡献指南 感谢参与。本文件写给**第一次接触这个仓库的人**:怎么把环境跑起来、代码要遵守什么约定、以及几个会浪费你半天的坑。 ## 1. 环境准备 需要一个可用的 DSH(DeepSeek Harness)环境: - **DSH Desktop**:安装目录下 `resources/runtime/cli/bin/dsh.cmd` 就是 CLI。 - **npm 版**:`dsh` 在 PATH 里。 两者都支持本插件。开发期建议用**隔离 profile**(见下),不要动你日常在用的 profile。 ## 2. 本地开发 ```bash # 隔离 DSH_HOME(放在仓库外) export DSH_HOME="$HOME/.dsh-multi-remote" # Windows PowerShell: $env:DSH_HOME = "$HOME\.dsh-multi-remote" # 建隔离 profile 并挂载本仓库 dsh --profile dev --from-default-profile web --dump-config dsh plugin --profile dev add "link:$PWD" # 启动(改代码后重启) dsh --profile dev --no-open --port 19388 ``` 验证: ```bash curl http://127.0.0.1:19388/multi-remote/selfcheck # {"ok":true,...,"registered":true,"visibleToModel":true,...} ``` > **本仓库没有构建步骤**:宿主直接加载 `src/index.js` 与 `src/client.js`。 > 除非将来引入 TS 或前端框架,否则请不要引入构建链。 ### 跑测试 ```bash node --test # 自动发现 test/ 下的 *.test.mjs ``` `test/static-contract.test.mjs` 是一道**静态闸门**:不启动 DSH,只检查那些「一错就让插件装不上、或让整个 harness 起不来」的硬约束(身份标识一致、peer 依赖已声明、路由唯一、无本机路径泄漏等)。**提交前请确保它全绿。** ## 3. 插件 API 契约 ### 3.1 入口形状 ```js export const name = '@jk-uzi/dsh-multi-remote' // 插件身份,与包名一致 export const inject = ['webServer', 'tools'] // 依赖的宿主服务 export function apply(ctx, config) { /* ... */ } ``` `name` / `inject` / `Config` 都是可选的;只要有 `apply` 就会被识别为插件。 ### 3.2 注册模型工具 ```js import { defineTool } from '@deepseek-ai/dsh-tools' ctx.tools.register(defineTool({ name: 'multi_remote_hello', description: '……', parameters: { who: { type: 'string' } }, // DSL:扁平 map output: { schema: { type: 'object', properties: { message: { type: 'string', required: true } } }, render: (_args, value) => [{ type: 'text', text: value.message }], }, async execute(args, exec) { return { message: `hi ${args.who}` } }, })) ``` - `ctx.tools.register()` **返回 disposer**,惯例是 `ctx.effect(() => ctx.tools.register(...), label)`。 - `defineTool` 白送**入参校验**;如果直接用 raw JSON Schema 调 `ctx.tools.register(def)`,则**没有入参校验**(`required` 要写成字符串数组,不是 `required: true`)。 - 工具名 `run_code` 是保留字,不能占用。 ### 3.3 注册 HTTP 路由 ```js ctx.effect(() => ctx.webServer.register({ kind: 'exact', // 'exact' | 'prefix' path: '/multi-remote/ping', handler: (req, res) => { res.writeHead(200, {...}); res.end('...') }, }), 'dsh-multi-remote: /multi-remote/ping') ``` - 同一个 `(kind, path)` **重复注册会直接抛错**,并让整棵插件树启动失败——路径必须全局唯一。 - handler 拿的是原生 `node:http` 的 `req` / `res`,响应完全自己写。 ### 3.4 客户端半边 `src/client.js` **不是 ESM**,而是手写的懒 CJS 包: ```js window.__ModuleLoader__.load({ id: '@jk-uzi/dsh-multi-remote', // 必须等于包名 factory: (require) => { var module = { exports: {} } var exports = module.exports function apply(ctx) { /* ctx.slots / ctx.locale ... */ } exports.apply = apply exports.inject = ['slots', 'locale'] // 客户端 cordis 服务名,不是包名 return module.exports }, }) ``` 宿主按 `package.json` 的 `exports['./client']` 定位文件,服务出去的 URL 是 `/plugins/<包名>/client.js`。 ## 4. 最容易踩的坑 ### 4.1 安装:pnpm 构建脚本表态(会让插件「装了但不加载」) pnpm 11+ 默认拒绝任何「没提前表态」的依赖构建脚本,并且**以非零退出码失败**: ``` [ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: cpu-features@0.0.10, ssh2@1.17.0 ``` 而 `dsh plugin` **只在安装成功时才把插件登记进 `dsh.profile.bundles`**。于是: ``` node_modules/@jk-uzi/dsh-multi-remote ← 装上了 dsh.profile.bundles ← 没有它 → 永远不加载,且没有任何提示 ``` **本仓库的表态**(`pnpm-workspace.yaml`):`ssh2` 与 `cpu-features` 都**拒绝构建**。 理由是 `ssh2` 是纯 JS 实现、不构建不影响功能(已实测),而 `cpu-features` 需要 C++ 工具链、会让部分用户装不上。所以本插件零原生依赖。 真正需要表态的是**用户的 profile 目录**(`dsh plugin add` 在那里跑 pnpm)。 用自带安装器一次做对: ```bash node scripts/install.mjs --profile <你的 profile> ``` 它会写入表态 → 安装 → **回读校验 bundle 真的被登记**,没登记就非零退出。 ### 4.2 宿主注入的包必须写进 `peerDependencies` **宿主注入的 `@deepseek-ai/*` 包,必须写进 `package.json` 的 `peerDependencies`。** DSH 宿主按**插件清单里声明的依赖**做裸包名路由。只 `import` 不声明的话,插件会在启动时报: ``` multi-remote (@jk-uzi/dsh-multi-remote): failed to import # 底层:ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-tools' ``` 这些包只存在于宿主的 `app.asar` 内,profile 里没有,所以靠 Node 自身的向上查找是找不到的。**新增任何 `@deepseek-ai/*` 导入时,同步补 `peerDependencies`。** 另外:**不要写 `dsh.compatibility.dshReleases`**——该字段在当前 DSH 版本里不存在。兼容性判定看的是 `peerDependencies` 里 `@deepseek-ai/dsh-*` 的 semver 范围。 **范围必须写成跨线开区间,不能用 caret。** DSH 在 import bundle **之前**校验所有 `@deepseek-ai/dsh-*` peer,**任一条不匹配就整包丢弃**——表现为「升级 DSH 后插件整个不见了」。而 caret 在 `0.x` 上锁死小版本线(`^0.2.0` 容不下 `0.3.x`)。所以要写: ```json "@deepseek-ai/dsh-tools": ">=0.2.0-rc.2 <0.3.0" ``` `@deepseek-ai/cordis` 不参与该校验,保持 caret 即可。这两条都有静态闸门盯着。 ### 4.3 预设隔离:工具必须挂在隔离域**内** 远程会话的执行世界靠**预设隔离**:在预设的隔离域里把 `fs` / `shell` 换成远程实现。 但**光换 provider 不够**——工具必须**也挂在这个域内**: - `agent.ctx` 解析到的是**全局**(本机)`ctx.shell`(实测)。 - 工具捕获的是它**挂载时所在 scope** 的服务。 - 所以在域外注册工具 = 命令落在本机;不注册 = 远程会话根本没有能跑命令的工具。 同理,工具的默认 `cwd` 是**会话工作目录**(本机锚点路径),必须翻译成远程路径, 否则远程 `cd` 直接失败。 ### 4.4 host key 指纹的格式 `ssh2` 在 `hostHash: 'sha256'` 下交给 `hostVerifier` 的**不是原始密钥,也不是 base64 指纹**, 而是**摘要的 hex 编码**。必须**只重新编码成 base64,不能再哈希一次**—— 搞错的表现是「每次连接都误报中间人攻击」。 ### 4.5 Windows 上 spawn `.cmd` Node 18.20+/20.12+/22+ 出于安全考虑**不再允许直接 `spawn` `.cmd` / `.bat`**(抛 `EINVAL`)。 DSH Desktop 的 CLI 恰好是 `dsh.cmd`。安装器会把批处理壳解析成真正的 exe + 脚本 (顺便展开 `%~dp0`),从而完全绕开 shell。 ### 4.6 WSL:并发必须 ≤ 4,且**绝不 SIGKILL `wsl.exe`** 这条是**实测踩出来的**,代价是 WSL 服务被打死、需要提权重启。 - 每次 `wsl.exe` 调用有 **~106 ms** 固定开销,所以「一个文件一次调用」不可用。 - 并发 **1–4 路正常**;**8 路以上出现 30 秒级停顿与 50–87% 失败率**。 - 更激进的突发(64 路)+ SIGKILL 在途实例会把 **WSL relay 彻底打死** —— 之后 `wsl -l -v`、`wsl --terminate`、`wsl --shutdown` **全部超时**, 只能提权 `Restart-Service WslService` 或重启机器。 所以 `WslTransport` 里 `MAX_CONCURRENT = 4` 是**硬限流**(带 FIFO 队列), 取消走 SIGTERM,并且测试里有负对照盯着。 ### 4.7 WSL:不要用 UNC 路径做 transport `\\wsl.localhost\\...` 看起来是「真实本地路径」,但实测表明它**又慢又失真**: | 问题 | 实测 | |---|---| | 宿主 git 极慢 | 2077 文件仓库:`git status` **4.9s**、`git diff --numstat` **18.6s** | | 符号链接不可达 | `lstat`→EISDIR、`stat`→ENOENT、`readlink`→EISDIR;`/bin`、`/lib`、`/sbin` 进不去 | | 元数据是编的 | `mode` 恒 0666、uid/gid 恒 0 | | `fs.watch` 不可用 | → EISDIR | | 逐文件开销 | ~0.8 ms/stat(本地 NTFS ~0.008 ms,约 **100×**) | 所以 WSL 后端的文件操作**全部在发行版内部**用一次 `wsl.exe -e` 完成。 ### 4.8 WSL:`wsl.exe` 的输出编码与错误通道 - `wsl.exe` **自己**的消息(`-l -q`、`--status`、致命错误)是 **UTF-16LE 无 BOM** - **子进程**输出是原始 UTF-8 - 一次调用可能两种编码都有 - **致命错误打在 stdout 上、退出码 `-1`**(`Wsl/`)—— 只看 stderr 会把 「发行版不存在」误判成「成功但没有输出」 - **不要从退出码推断 ENOENT**:直接 `wsl -e <不存在的命令>` 给 **1**, 经 `bash -lc` 给 **127** - 用户数据**绝不拼进 shell 字符串**:用「常量脚本 + 位置参数」 (`wsl -e sh -c '<常量脚本>' sh "$1"`),`wsl.exe -e` 传 argv 是逐字的 ### 4.9 Docker:Engine API 的几个坑 **不要 shell 出 `docker.exe`** —— CLI 不保证存在。Node 内置 `http` 能直连命名管道 (`socketPath: '\\\\.\\pipe\\dockerDesktopLinuxEngine'`),零依赖就够。 - **文件内容走 `archive` 端点**(tar),**元数据走 exec**: archive 不依赖容器里的任何工具,distroless 里也能读写文件。 - **archive 对符号链接返回链接本身,不跟随** → 读符号链接会拿到空字符串。 必须先 `realpath` 再重取,并给嵌套深度上限防成环。 - **`mode` 是 Go 的 `os.FileMode`,不是 POSIX `st_mode`**:目录 bit31、符号链接 bit27。 当成 `st_mode` 会把目录认成普通文件。 - **多路复用流的 `streamType` 有 4 种**:0 stdin / 1 stdout / 2 stderr / **3 Systemerr**。 第 3 种是守护进程自己的错误,必须浮出来(`docker-modem` 漏了这类)。 - **`ExitCode ?? 0` 会把失败报成成功**:必须 `Running === false` **且** ExitCode 非 null。 - **`Accept-Encoding: gzip` 会让守护进程压 tar** —— 我们从不设这个头。 - **archive 响应体不排空会占住容器锁** —— 总是完整读完。 - **取消不是 `docker kill`**(那会杀掉用户的整个容器)—— 取消是销毁 start 连接。 ### 4.10 写脚本给容器执行:`exit` 会截断追加的 heredoc 一个实测踩到的坑:想把内容传给容器里的脚本时,**不能**写成 「常量脚本 + `\n` + heredoc」—— 脚本末尾的 `exit 0` 会**先结束 shell**, heredoc 根本不执行。结果是**文件建出来了但是空的**,看起来像写成功。 内容必须**内联在同一个脚本里**。测试里有一条专门盯这个(断言 heredoc 在 `exit` 之前)。 ### 4.11 容器里可能没有 `bash`,也没有 coreutils **实测**(`alpine:3.20`): - **没有 `bash`** —— 只有 busybox 的 `sh` - **busybox 的 `find` 没有 `-printf`**:`find: unrecognized: -printf` - **busybox 的 `stat` 没有 `--printf`**(`-c` 有,格式串与 coreutils 一致) - `base64 -w0` 在 busybox 上**能用** 所以: - 探测到没有 `bash` 就退化到 `sh`;**连 `sh` 都没有**(distroless / scratch) 就明确报错,并说明**文件内容读写仍然可用**(走 archive,不依赖容器工具) - 列目录用 **busybox / coreutils 双兼容**的写法: ```sh find -- "$d" -mindepth 1 -maxdepth 1 -exec sh -c ' for f in "$@"; do stat -c "%n|%F|%s|%Y|%a" -- "$f" 2>/dev/null | tr "\n" "\0" done ' sh {} + ``` `find -exec ... +` 两种实现都支持,**一次 exec 拿全**;`tr '\n' '\0'` 让文件名里的 空格/换行都不破坏解析。 ### 4.12 容器「没在运行」不等于「不能用」 **实测**:`archive` 端点对**已停止**的容器照样可用(`HEAD` 给元数据、`GET` 给 tar), 而 `exec` 不行。所以不要「容器没在运行就拒绝连接」—— 那会白白丢掉一个真实可用的能力。 正确做法:允许连接,但**把两种失败分开报**,因为修法完全不同: | 情况 | 报什么 | 用户该做什么 | |---|---|---| | 容器没在运行 | 「没有在运行,无法执行命令」+ 说明文件内容仍可读写 | `docker start` | | 容器在跑但没 shell | 「没有可用的 shell」+ 说明文件内容仍可读写 | 换镜像,或只用文件读写 | 混成一句「操作失败」,用户不知道该干什么。 ### 4.13 单个 argv 参数上限是 128 KiB(不是 `ARG_MAX`) **这条最坑**。Linux 的**单个参数**上限是 `MAX_ARG_STRLEN` = **131 072 字节** (32 × PAGE_SIZE),与 `ARG_MAX`(2 MiB)**毫无关系**。超了内核直接 `E2BIG`, 容器里看到的是: ``` exec /usr/bin/bash: argument list too long ``` 实测:单个参数 **131 071 OK / 131 072 失败**。 推论:**任何把内容放进命令行的写法都有这个天花板** —— heredoc、base64、 `printf '%s'` 全都一样。文件内容必须走**请求体**(Docker 走 `archive` 端点的 tar)。 > 反面教材:早期实现把 base64 内联进 `bash -c