diff --git a/.gitignore b/.gitignore index d94c8acc..3b584786 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,9 @@ dist/ # 内核升级 CLI 的本地构建产物(与 /homed 同理,不入库) /homed-upgrade /kernelplan.md + +# dshplgadapter 规划书(工作文档,不进仓库) +# 与 /plan.md、/kernelplan.md 同一惯例:规划/设计在交接前会反复改, +# 入库后要么产生一堆"改文档"提交,要么与实际实现漂移。 +# 实际实现落地时,结论会进 docs/zh/ 下的正式设计文档。 +/dshplgadapter-plan.md diff --git a/docs/zh/dshplgadapter-plan.md b/docs/zh/dshplgadapter-plan.md deleted file mode 100644 index 6f26cd2f..00000000 --- a/docs/zh/dshplgadapter-plan.md +++ /dev/null @@ -1,765 +0,0 @@ -# dshplgadapter 插件项目规划书 - -> **状态**:规划(未开始实现) -> -> **定位**:把 DeepSeek Harness(下称 dsh)的 Cordis 插件生态接入 HologramAgent。 -> 参考原型是 **clawhubadapter**()—— -> 它把 OpenClaw 生态接入 HA,dshplgadapter 把 dsh 生态接入 HA, -> 两者是**同一个模式的两次实例化**。 -> -> ⚠️ **该插件已从内核仓迁出**(内核仓 commit `a0077a2`)。 -> 本文中凡引用 clawhubadapter 源码的地方,**一律以上述仓库为准**, -> 不要到内核仓的 `internal/plugins/` 里找(那里已经没有了)。 -> -> **建立于**:2026-11-04。 -> -> 本文所有事实均来自实测命令,不凭记忆。关键实测附命令原文,便于复查。 - ---- - -## 〇、两个前置结论(先钉死,否则后面全是空谈) - -### 结论 1:clawhubadapter 的设计可以整套照搬 - -它解决的问题与 dshplgadapter 的**完全同构**: - -| 共同点 | clawhubadapter(OpenClaw) | dshplgadapter(dsh) | -| --- | --- | --- | -| 外部原型 | 一插件一目录,`main.js` 导出 `{register(api)}` | 一插件一包,Node ESM 导出 `apply(ctx)` | -| 插件格式 | JSON 清单 + JS 入口 | `package.json` + `cordis.patch.yml` | -| 运行需要 | Node 宿主 | Cordis 宿主(Node) | -| 拿到什么 | 工具、通道、Provider、阶段钩子 | 工具(`ctx.tools`)、事件(`ctx.on`)、服务 | -| 内核怎么办 | 起 sidecar 子进程 + JSON-RPC over stdio | **完全相同** | - -所以**不需要重新设计架构**。要做的只是换掉三样东西: -① 子进程里的"假宿主"(simulator/main.js → 一个 Cordis Context); -② 注册通知的类型表(OpenClaw 的 `type` → Cordis 的注册面); -③ 包发现与安装逻辑(`main.js`/`openclaw.plugin.json` → `package.json`+cordis.patch)。 - -### 结论 2:它**必须是外部插件**(`plugin.bin`),不是内置插件 - -> **本节 2026-11-04 重写过一次。** 原结论是“必须内置”,依据是“外部插件不能发 -> 事件、所以移交 skill 只能靠概率”。该依据**被实测推翻**:外部插件自带的 skill -> 由 **pluginmgr 在安装/启动时自己移交**,与插件发不发事件无关。 -> -> 而 clawhubadapter 已经实打实迁成了外部插件并端到端跑通(见 §0.3), -> 所以 dshplgadapter 同形态。 - -#### 2.1 外部插件确实发不了事件(这条仍然成立,且已实测) - -三层证据: - -1. `capability_test.go:279` 把 `events.publish` 列进 **`forbiddenPrefixes`** —— - 内核**刻意**不向外部插件提供事件发布,且有测试守着不许加回去。 -2. proc 协议表只有 `MethodEventsSubscribe` / `MethodEventsUnsubscribe`, - **没有** `events.publish`(`internal/plugin/proc/protocol.go:235-236`)。 -3. 外部插件模板 `proc_main.go.tmpl` 不注入 event subscriber - ⇒ `Events()` 恒为 `nil`(`builtin-only.md:25` 权威裁定)。 -4. 更早一道:公开的 `EventSubscriber` 接口**没有 `Publish` 方法**, - 所以这是**编译期**就拦住(实测变异:把 `s.Events().Publish` 加回去, - `hmapdev build` 直接报 `s.Events().Publish undefined`)。 - -#### 2.2 但“发不了事件”**不阻碍**外部化 —— 因为不用发 - -实测得到的真实链路(隔离实例,2026-11-04): - -```text -15:06:27 [pluginmgr] clawhubadapter: handed off skill clawhub-tools to skillmgr -15:06:27 [pluginmgr] startup rescan: re-handed off 1 bundled skill(s) -15:06:27 [skillmgr] registered via skill_detected event: clawhub-tools -15:06:27 [plugin] loaded SKILL: clawhub-tools v1.0.0 -``` - -注意这条链上**没有一行来自插件自己**: - -- 插件只把 SKILL 放在工程根 `skill/<名字>/SKILL.md`,`hmapdev build` 打进包; -- 内核/pluginmgr 在**安装**与**每次启动**时扫 `<插件目录>/skill/`, - 自己发 `skill_detected`(`pluginmgr/skill_handout.go`); -- 内置插件走另一条路 `plugin/builtin_skill.go` 的 `SkillProvider`(编译期 embed)。 - -⇒ **“插件带 skill”与“插件发事件”是两件不相干的事**。前者是分发物约定, -后者是运行期能力。外部插件做前者完全没问题。 - -#### 2.3 迁移实测:三处差异,全部可绕 - -把 clawhubadapter(迁出前的内部版)搬到 `hmapdev` 骨架上,只改 import, -`hmapdev build` 报四个错: - -```text -./plugin.go:1103: too many arguments in call to s.InjectInputSync - have (string, string, string, map[string]interface{}) - want (string, string, string) -./plugin.go:1106: invalid operation: out != nil (mismatched types string and untyped nil) -./plugin.go:1107: out.Payload undefined (type string has no field or method Payload) -./plugin.go:1323: s.Events().Publish undefined (type sdk.EventSubscriber has no field or method Publish) -``` - -| 差异 | 修法 | 代价 | -| --- | --- | --- | -| `InjectInputSync` 签名不同 | 用外部签名,返回 `string`,空串即“没回复” | 改一处 | -| `Events().Publish` 不存在 | **删掉,不需要** | 见 2.2 | -| `internal/safeclose` 拿不到 | 自写 `safeClose[T any]` | 新文件 40 行 | -| `init()` 自注册不可用 | 改为 `NewPluginFactory` | 改一处 | - -#### 2.4 迁移结果(已验证) - -```text -go vet / gofmt 干净 -go test -race ok(20 条原有 + 4 条外部形态判据) -hmapdev build packaged clawhubadapter_bundle.hmap -隔离实例端到端 loaded: clawhubadapter + 10 个工具全注册 - loaded SKILL: clawhub-tools v1.0.0 -``` - -> **决策**:dshplgadapter 做成**外部插件**,与迁移后的 clawhubadapter 同形态。 -> 工程放独立目录 + `plg.json`,用 `hmapdev build` 产出 `.hmap`。 - -### 结论 3:外部插件带 skill 走 `skill/` 目录,不走事件 - -把这条单列,因为它是上面推翻结论 2 的关键事实,而且很容易搞混: - -| 谁带 skill | 放在哪 | 谁负责登记 | -| --- | --- | --- | -| 外部插件 | 工程根 `skill/<名字>/SKILL.md`(打进 .hmap) | **pluginmgr** 安装时 + 每次启动重扫 | -| 内置插件 | Go 源码 embed + 实现 `SkillProvider` | **内核** `InstallBuiltinSkill`(随插件启停出入) | -| 用户自己写 | `/skills/` | skillmgr 启动全扫 | - -三者的共同终点都是 skillmgr 的 `loadOne`(幂等),但**入口完全不同**。 -写外部插件时只需要第一种。 - -一条实操纪律(`hmapdev build` 会检查):**`auto_start=false` 的插件必须带 -`skill/` 目录**,否则用户装完不知道该先 `plugin_enable` —— 等价于没装。 -clawhubadapter 是 `auto_start` 默认 true,所以不强制;但带上 skill 仍是对的。 - ---- - -## 一、参考原型:clawhubadapter 的实际结构 - -先看清楚要抄什么。它的四个文件是分层的,**每层职责单一**: - -| 文件 | 行数 | 职责 | dshplgadapter 是否照搬 | -| --- | --- | --- | --- | -| `plugin.go` | 1526 | 生命周期 + 工具注册 + 包管理 | 照搬骨架,换包发现逻辑 | -| `sidecar.go` | 420 | **JSON-RPC over stdio + pending-id 配对** | **原样照搬** | -| `registry.go` | 326 | 注册通知 → SDK 调用的**转译表** | 换转译表内容 | -| `simulator/main.js` | 422 | 子进程里的**假宿主** | **换成 Cordis Context** | - -### 1.1 `sidecar.go` 是最有价值的部分(原样照搬) - -它解决了"跨进程调用"的全部脏活,与具体插件格式**无关**: - -```text -sidecarRequest / sidecarResponse JSON-RPC 帧 -pendingResp 每请求一个 channel + 自己的 close 守卫 -readLoop 读 stdout,按 id 派发响应,其余当通知 -call(method, params) 发请求并等响应 -NotifyChan() 把主动通知推给上层 -waitReady() ping 探活,30s 超时 -Close() 优雅关停 -``` - -**它踩过的坑(照搬时不要重犯)**: - -- `pendingResp` 的 `once` **必须每个请求一个**,不能共用全局 `Once` —— - `close(ch)` 二次即 panic,而 `Close()` 有多条路径会走。 -- `waitReady` 超时从 5s 提到 30s(`2915006`):慢机器上 Node 冷启动会超时。 - 注释里写了取法依据:比 systemd `TimeoutStopSec`(25s) 宽,与用户可感知的 - "卡住"阈值(约 30s)持平。 -- 读循环里 stdout 只能有 JSON,**任何 `console.log` 都会坏帧**。 - 这一条 dsh 侧尤其危险 —— 见 §2.3。 - -### 1.2 `registry.go` 的转译表(照抄结构,换内容) - -它把"子进程发来的注册通知"分派成 SDK 调用: - -```go -func (d *RegistryDispatcher) Dispatch(typeStr string, data json.RawMessage, ...) { - switch typeStr { - case "tool": d.toolReg.Dispatch(...) // → s.RegisterTool - case "channel": d.channelReg.Dispatch(...) // → s.RegisterInputChannel - case "provider", ...: d.providerReg.Dispatch(...) - case "hook", "lifecycle", ...: d.stageReg.Dispatch(...) // → s.RegisterStage - default: d.capRecorder.Record(...) // 未知类型:只记账 - } -} -``` - -**注意 `default` 分支**:未知注册类型不报错,只记进 `CapRecorder`。 -这是刻意的 —— 外部生态的注册面比我们的多,硬拒绝会让插件直接不可用。 -dsh 侧注册类型更多(见 §3.2),**必须保留这个宽容策略**,否则第一天就崩。 - -### 1.3 `simulator/main.js` 是"假宿主"(换成 Cordis) - -它做的事情本质上是**骗过插件**,让插件以为自己跑在真宿主里: - -```js -const entry = pluginEntry.default || pluginEntry; -if (typeof entry !== 'object' || typeof entry.register !== 'function') { - process.stderr.write(`[simulator] plugin entry must export {default: {register(api)}}\n`); - process.exit(1); -} -const api = { - id, name, version, description, source, rootDir, config, pluginConfig, - logger: { debug, info, warn, error }, - resolvePath, registerTool, registerProvider, registerChannel, ... -}; -entry.register(api); -``` - -dsh 这一层**不需要手写假宿主**,因为 Cordis 的 `Context` 可以无参构造: - -```bash -$ grep "export declare class Context" @deepseek-ai/cordis/lib/types/context.d.ts -40:export declare class Context { -60: constructor(); -``` - -这是个关键利好:**dsh 的宿主是真的能跑起来的**,不用伪造。 -但代价是初始化更重(Cordis 有自己的 loader / fiber / service 体系), -需要实测哪些服务必须注册、哪些可以缺失。 - ---- - -## 二、dsh 侧的事实(全部实测,2026-11-04 本机) - -### 2.1 版本与包布局 - -```bash -$ python3 -c "import json;print(json.load(open('/usr/lib/node_modules/@deepseek-ai/dsh/package.json'))['version'])" -0.2.1-rc.2 -``` - -- `@deepseek-ai/cordis` **4.0.5** —— 插件框架 -- `@deepseek-ai/dsh-*` 约 200 个包(内核按 plugin 拆得极细) -- `@deepseek-ai/dsh-tools` 提供 `defineTool()` - -### 2.2 插件形态:包 + cordis.patch.yml - -一个真实外部插件(`dsh-mobile-nav`): - -```jsonc -// package.json -{ - "name": "@dsh-external/dsh-mobile-nav", - "type": "module", - "main": "lib/index.js", - "exports": { ".": {...}, "./client": {...}, "./cordis.patch.yml": "./cordis.patch.yml" }, - "dsh": { "client": ... } // ← 客户端半边的声明 -} -``` - -```yaml -# cordis.patch.yml -- insert: - - id: dsh-mobile-nav - name: '@dsh-external/dsh-mobile-nav' -``` - -```js -// lib/index.js —— 主入口 -export function apply(ctx) { - ctx.effect(() => installResponseCompression(), 'dsh-mobile-nav: response compression'); -} -``` - -**装载链路**(实测 profile 配置。`` = `~/.dsh/profiles/<名字>`, -本机为 `web` profile,即 `/root/.dsh/profiles/web`;其他机器上只是 `$HOME` 不同): - -```bash -/package.json - ├─ dependencies: @dsh-external/xxx, dsh-mail-bridge, ... - └─ dsh.profile.bundles: [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", ... ] - -/cordis.yml → 空数组(root,由 patch 组合) -/cordis.patch.yml → 覆盖层 -/node_modules/@dsh-external/* → 软链到真实目录 -``` - -⇒ **dshplgadapter 的"扫描目标"就是 `/node_modules/`** -(含 `@dsh-external/`、`@scope/`、无 scope 的),加上 `dsh.profile.bundles` 清单。 - -### 2.3 ★ dsh 插件与 clawhubadapter 最大的不同:日志会污染协议通道 - -这是**必须提前设计**的风险,不是实现细节。 - -clawhubadapter 的 OpenClaw 模拟器把 `logger` 接到 stderr: - -```js -logger: { debug: () => {}, info: () => {}, warn: () => {}, - error: (...a) => process.stderr.write(`[plugin] ${a.join(' ')}\n`) } -``` - -而 dsh 生态的插件**大量使用 `console.log`**,且 dsh 自己的 logger 语义就是 -"写到进程输出"。一旦子进程 stdout 被日志污染: - -```text -内核 readLoop 收到非 JSON 文本 ⇒ 报「收到非法 JSON 帧」并丢弃 -⇒ 该行日志丢了,还多一条看起来像协议故障的报错 -``` - -本仓已经为这个坑付过代价(`hologram-plugin-dev` skill 记载:生产累计 1038 条 -这类报错,定位真因花了一整轮排查)。 - -**设计方案:给协议单独一条通道,不让它和 stdout 抢。** - -三条路,优先级从高到低: - -| 方案 | 做法 | 代价 | -| --- | --- | --- | -| **A(推荐)** | 子进程用 **fd 3** 做协议通道,stdout/stderr 全部当日志 | 要确认 Node 侧能拿 fd 3(`fs.writeSync(3)` 可以做) | -| B | 起子进程时把 stdout 重定向到文件,协议走另一条 | 需要 sidecar 支持独立管道 | -| C | 不改协议,但在 simulator 里**劫持** `console.log` 指到 stderr | 只能拦住模拟器内的调用,插件用 `process.stdout.write` 仍然漏 | - -**A 的可行性要先验证**(这是阶段 0 的第一件事): -`node -e "require('fs').writeSync(3,'x')"` 在 Go 侧 `ExtraFiles` 注入 fd 3 后是否可用。 - -### 2.4 dsh 插件的注册面(要转译的东西) - -从 `dsh-mail-bridge`(**最重要的参照物**,2586 行,已生产运行)实测: - -```js -export const inject = ['agents', 'tools']; // ← Cordis 要求声明依赖服务 -export const name = 'dsh-mail-bridge'; -export function apply(ctx, config) { ... } -``` - -它用到的 ctx 面: - -| API | 用途 | dshplgadapter 转译成 | -| --- | --- | --- | -| `ctx.tools.register(defineTool({...}))` | 注册工具 | `s.RegisterTool` | -| `ctx.on('session/event', fn)` | 会话事件 | 阶段钩子 / 事件订阅 | -| `ctx.on('agent/status', fn)` | agent 状态变化 | 阶段钩子 | -| `ctx.on('approval/request', fn, next)` | 权限请求(**有 next 链**) | 需确认 HA 侧对应物 | -| `ctx.on('tools/execute', fn, next)` | 工具执行前后(**中间件链**) | `before_toolcall` / `after_toolcall` | -| `ctx.on('tools/post-execute', fn)` | 工具执行后 | `after_toolcall` | -| `ctx.effect(disposer, label)` | 注册清理函数 | 插件 Stop 时调用 | -| `ctx.agents.create/get/list/resume` | 建/查 agent | 需确认是否暴露 | -| `ctx.logger.info/warn/error` | 日志 | 转 stderr / 内核日志 | -| `ctx.sessionQuery` | 会话查询 | 可选 | -| `ctx.userQuestions` | 向用户提问 | 可选 | - -**注意 `ctx.on(..., next)` 的中间件链**:dsh 的 `approval/request` 和 -`tools/execute` 都是 `(payload, next) => Promise` 形态,插件可以拦截并决定 -是否放行。这与 HA 的 `before_toolcall`(能拒绝/改参)语义接近,但**不是一一对应** —— -dsh 的链是异步 promise 链,可以有多个插件串联;HA 的阶段钩子是同步返回。 -⇒ 转译时**不能在链中间阻塞等 HA 的异步判定**,否则会死锁。需要设计成: -"先收集所有插件的判定,再一次性提交给 HA"(类似 §1.2 的记账策略)。 - -### 2.5 已存在的同类先例(务必读) - -**`dsh-mail-bridge`** —— 2586 行 TypeScript,生产运行中,做的是 -**dsh 插件 ↔ AgentMail 平台桥接**。 - -它不解决“dsh 插件进 HA”,但它解决了**“如何在 dsh 插件里正确使用 ctx 面”**: - -- `inject = ['agents','tools']` 的声明方式 -- `defineTool` 的正确用法(含一个历史坑:参数 spec 必须转成合法 JSON Schema) -- `ctx.on` 的五个事件、`ctx.effect` 的清理约定 -- **`ctx.logger` 不进 journalctl**,日志要用 `console.error`(它踩过, - 注释里写得很清楚) - -#### 在哪里拿这个参考 - -⚠️ **它没有在 HologramAgent 组织下,也不在本仓。** 三个可选源,按可靠性排序: - -| 源 | 地址 | 说明 | -| --- | --- | --- | -| **本机生产拷贝**(最完整) | `/opt/agentmail/plugins/dsh-mail-bridge/current/src/index.ts` | 2586 行,这台机器上有。若接手的人在**同一台机器**上开发,直接用这个 | -| 公开镜像 | | 同名同用途(909 KB,2026-10-15)。**不是同一份**(行数与目录不同),但 ctx 用法可直接参考 | -| 自己装一份 | `dsh plugin --profile web add <包名>` 后读 `node_modules/` | 依赖已发布到 npm 时可行 | - -> 如果接手的人不在这台机器上,**公开镜像 + dsh 官方 tutorial 已经够用**, -> 不依赖本机路径。不要把它写成“必读”阻断项。 - ---- - -## 三、设计 - -### 3.1 总体形态(与 clawhubadapter 逐层对照) - -```text - HologramAgent 内核(homed) - │ - dshplgadapter(外部插件,plugin.bin 子进程) - │ - ┌────────────────────┴────────────────────┐ - │ │ - 发现与安装 运行期 - (扫描 profile) (每 dsh 插件一个 sidecar) - │ │ - 读 package.json fork node - 读 cordis.patch.yml ↕ JSON-RPC(fd 3) - 判断是否有 apply() Cordis Context + 插件 - │ │ - └──── 纯 SKILL 条目 → skillmgr ←──────────┘ - (同 clawhubadapter 的职责链) -``` - -### 3.2 注册转译表(dsh → HA) - -| dsh 侧的注册 | HA 侧的落点 | 备注 | -| --- | --- | --- | -| `ctx.tools.register(defineTool(...))` | `s.RegisterTool` | 参数 schema 要转 JSON Schema | -| `ctx.on('tools/execute', fn, next)` | `StageBeforeToolcall` + `StageAfterToolcall` | 见 §2.4 的异步链注意 | -| `ctx.on('approval/request', fn, next)` | 待定(HA 无直接对应) | 阶段 3 再定 | -| `ctx.on('session/event', fn)` | `Subscribe`(只读) | | -| `ctx.on('agent/status', fn)` | 阶段钩子 | | -| `ctx.effect(fn, label)` | 记下来,`Stop()` 时逆序调用 | | -| `ctx.logger.*` | 转 stderr(不要走 stdout) | | -| 其他未知 | 记进 `CapRecorder`,不报错 | 同 clawhubadapter 的 default 分支 | - -### 3.3 包发现规则 - -扫描 `/node_modules/`,一个目录算"dsh 插件"当且仅当: - -1. `package.json` 能解析,且 -2. (`package.json` 有 `dsh` 字段)**或**(存在 `cordis.patch.yml`)**或** - (入口 `lib/index.js` / `main` 里能 static-scan 到 `export function apply`) - -第 3 条**不做静态扫描**,改为"试着加载,加载失败就当普通包跳过" —— -理由:ESM 的静态分析在正则层面不可靠,容易把非插件误判成插件(或反之)。 -加载失败必须**只打日志、不中断扫描**(同 clawhubadapter `Start` 里 `continue` 的处理)。 - -**纯 SKILL 条目**(有 `SKILL.md` 但无 `apply`)→ 走 `publishSkillDetected` -交给 skillmgr,与 clawhubadapter 完全一致。 - -### 3.4 安全(把 clawhubadapter 刚修的两个洞从一开始就堵上) - -本仓 2026-11-04 刚修了 clawhubadapter 的两个真实漏洞,**dshplgadapter 必须 -从一开始就带**,不要重犯: - -| 漏洞 | 教训 | dshplgadapter 的做法 | -| --- | --- | --- | -| ZIP Slip(解压任意路径写) | `filepath.Join(dest, entry)` 不校验 | 复用 `safeArchiveTarget` 思路(可考虑提到公共包) | -| 路径穿越(`name=".."` 删数据目录) | 名字来自模型,拼路径前必须校验 | 包名一律走 `resolveUnderRoot` | -| 无校验和的远程安装 | 下载即执行 | 阶段 1 **只支持本地已安装包**;远程安装推迟到阶段 4 并带 sha256 | - -**一条额外的、dsh 特有的风险**:dsh 插件是 **Node 代码**,`apply()` 里可以 -`import('node:child_process')` 直接执行任意命令。这与 HA 的沙箱模型直接冲突 -(沙箱目前唯一真实生效的是 files 插件自己的 `pathWithinSandbox`,见 -`plan.md` 的记录)。⇒ **阶段 1 必须先做"要不要允许 dsh 插件跑任意代码"的决策**, -不能默认允许后再补救。 - ---- - -## 四、分阶段实施 - -每个阶段独立可交付、独立可验证。**不合并交付**。 - -### 阶段 0:可行性探针(不改产品代码) - -目的:把三个"不确定"变成"确定"。**任何一条失败都要回来改设计**。 - -| # | 探针 | 判据 | -| --- | --- | --- | -| 0.1 | fd 3 做协议通道 | Go `ExtraFiles` 注入 + Node `fs.writeSync(3)` 能双向通信 | -| 0.2 | 裸 `new Context()` 能承载插件 | 加载 `dsh-mobile-nav` 的 `apply` 不报错 | -| 0.3 | `defineTool` 的 schema 能转成 HA 的 JSON Schema | 拿 `dsh-mail-bridge` 的一个真实工具做往返 | -| 0.4 | `inject: ['agents','tools']` 缺服务时的行为 | 确认是"报错退出"还是"降级",决定要不要伪造服务 | - -产物:`docs/zh/dshplgadapter-probe.md`(实测记录 + 结论)。 - -### 阶段 1:最小闭环(只支持工具) - -- 工程骨架:用 `hmapdev init dshplgadapter` 生成(**不是** `internal/plugins/dshplgadapter/` - —— 它是外部插件,不进内核仓)。把它会拿到的目录结构与 - 对照(后者是已验证跑通的同形态工程)。 -- 扫描 `node_modules/`,找到插件,起 sidecar -- **只转译 `ctx.tools.register`** → `s.RegisterTool` -- 一个测试插件(自己写的最小 dsh 插件)验证"装了 → 模型能调" - -判据(行为级,非实现级): - -- `TestDiscoversDSHPlugin` —— 造一个带 `apply` 的包,必须被扫描到 -- `TestRegistersToolFromPlugin` —— 插件注册的工具出现在 HA 工具表 -- `TestUnknownRegisterTypeDoesNotCrash` —— 未知注册类型只记账不崩 -- `TestPluginLoadFailureDoesNotAbortScan` —— 一个坏包不影响其他包 - -### 阶段 2:事件与钩子 - -- `ctx.on('tools/execute'|'tools/post-execute')` → HA 的两个工具阶段 -- `ctx.on('session/event')` → 只读订阅 -- **异步链的语义问题解决**(§2.4 提到的死锁风险) - -判据: - -- `TestToolExecuteHookCanDeny` —— 插件拒绝后 HA 不执行该工具 -- `TestHookChainDoesNotDeadlock` —— 多插件串联不卡死(带超时) - -### 阶段 3:纯 SKILL 交接 + 生命周期 - -- 非插件包 → `publishSkillDetected`(**内置身份**,可用 Events) -- `ctx.effect` 的清理函数在 Stop 时逆序调用 -- 热重载 - -判据: - -- `TestSkillHandoffToSkillmgr` —— 造一个只有 `SKILL.md` 的目录,skillmgr 收到 -- `TestEffectsRunInReverseOnStop` - -### 阶段 4(可选,需单独决策):远程安装 - -- 从 npm / dsh 插件市场安装 -- **必须带 sha256 校验**,且默认关闭 - ---- - -## 五、参考清单(写代码前该读什么) - -### 5.1 参考实现:clawhubadapter(已验证跑通) - -> **源码仓库:** -> -> ⚠️ 它已于 2026-11-04 从内核仓迁出(commit `a0077a2`)。 -> **不要到内核仓的 `internal/plugins/` 里找**(已经删了)。 -> 下文以 `` 代指该仓库根目录。 - -| 文件 | 为什么 | -| --- | --- | -| `/sidecar.go` | **协议层原型**,原样照搬(JSON-RPC + pending-id 配对 + NotifyChan + 优雅 Close) | -| `/registry.go` | **转译表原型**,换内容照抄结构(含 `default` 宽容分支) | -| `/plugin.go` | 生命周期、包发现、工具注册的骨架;文件头就是 `NewPluginFactory` | -| `/safeclose.go` | 内核 `internal/safeclose` 的外部替代(泛型覆盖 3 种 channel) | -| `/simulator/main.js` | 假宿主原型,换成 Cordis Context | -| `/skill/clawhub-tools/SKILL.md` | 随包 skill 的真实写法(外部插件带 skill 的范本) | -| `/external_form_test.go` | 四条外部形态判据,**直接照搬改包名** | - -下面两个文件**仍在内核仓**(它们是内核侧机制,不能迁出): - -| 文件 | 为什么 | -| --- | --- | -| `internal/plugins/pluginmgr/skill_handout.go` | 外部插件随包 skill 的**移交机制**(安装时 + 启动重扫) | -| `internal/plugins/skillmgr/plugin.go` | 交接口终点(`loadOne`、`isNativeSkillPath`、`scanExisting`) | - -### 5.2 外部参考(dsh 侧) - -#### ★ 5.2.1 dsh 官方文档站(一手资料,**必读**) - -仓库:****(公开,master 分支, -2026-11-03 仍在更新)。三个包(`dsh` / `dsh-tools` / `cordis`)全部来自这个 monorepo -(看它们的 `package.json` 的 `repository` 字段即可证实)。 - -| 路径 | 为什么读 | -| --- | --- | -| `docs/cordis-tutorial/01-first-plugin.zh.md` ~ `07-into-the-harness.zh.md` | **七步中文教程**,从插件生命期到 effects 到服务到事件到配置到 HMR。这是阶段 1-3 的直接依据 | -| `docs/cordis-primer.zh.md` | Cordis 框架入门(Context / service / inject 的心智模型) | -| `docs/tool-execution-pipeline.md` | **工具执行管道**。§2.4 提到的 `tools/execute` 异步中间件链到底怎么跑,以这个为准 | -| `docs/api/`(含 `cordis-api/`) | `ctx` 面各服务的接口定义 | -| `docs/cookbook/adding-a-tool.zh.md` | 新增工具的官方做法 | -| `docs/capability-seams.md` | 能力接缝(“什么东西可以插进来”) | -| `docs/architecture.zh.md` / `docs/module-graph.md` | 整体架构与模块图 | - -> 这份仓库里的文档大多有 `.zh.md` 中文版,直接看中文即可。 - -#### 5.2.2 插件侧 API 的真实用法参考 - -| 源 | 为什么 | -| --- | --- | -| `dsh-mail-bridge` 的 `src/index.ts` | 2586 行生产代码,**dsh 插件侧 API 的最可靠参考**。`inject` 声明、`ctx.tools.register`、`ctx.on` 五种事件、`ctx.effect` 清理、`logger` 不进 journalctl 的坑。见 §2.5 的**三个可选源**(本机拷贝 / 公开镜像 `QIKO0721/dsh-mail-bridge` / 自己装一份) | -| `@deepseek-ai/dsh-tools/lib/types/schema.d.ts:248` | `defineTool` 签名与 `DefineToolOptions` 全字段(本地 node_modules,随 dsh 安装) | -| `@deepseek-ai/cordis/lib/types/context.d.ts:40` | `Context` 类(`constructor()` 无参,是阶段 0.2 的依据) | -| `/node_modules/@dsh-external/dsh-mobile-nav/` | 一个最小真实插件(含 `cordis.patch.yml`)。`` = `~/.dsh/profiles/web` | - -> 后三条是**安装 dsh 后本地就有**的路径,不是某个人的家目录约定: -> `~/.dsh/profiles//` 是 dsh 的固定布局。 - -### 5.3 SDK 侧 - -**仓库:** - -两种拿法,任选: - -- 克隆到内核仓的 `third_party/hologramagentsdk`(`go.mod` 里的 `replace` 默认指向这里) -- 或直接读 GitHub(文档与 `sdk/*.go` 都在仓里,不必克隆) - -另外:本机装了 `hmapdev` 后,它会在 `~/.homeagent/hmapdev/sdk/<版本>/` -放一份对应版本的 SDK(本机为 `v1.4.0`)—— 那是**构建用**副本,内容与上述仓一致。 - -| 文档 | 为什么要看 | -| --- | --- | -| `docs/guide/capability-boundary.md` | **先看这个**。决定什么能做、什么只能内置 | -| `docs/api/builtin-only.md` | 仅内置可用的 API 清单(`Events()` 恒 nil 的裁定在此,第 25 行) | -| `docs/guide/capability-boundary.md` §「判定依据」 | 三条权威来源(proc 模板 / internal/sdk / proc 协议表)—— 有疑问就按这三条查 | -| `docs/api/tools.md` / `docs/api/stages.md` | `ToolDef` / 阶段钩子的字段全集 | -| `sdk/plugin.go` | 字段以源码为准,`grep -A30 "type ToolDef struct"` | -| `tools/hmapdev/templates/proc_main.go.tmpl` | **外部插件实际拿到什么能力,全在这一个文件里**(`buildPluginSDK` 的注入清单) | - -### 5.4 hmapdev 要问的东西 - -```bash -hmapdev version # 工具链 + SDK 模块 + 构建用 Go(实测 1.4.0 / go1.27.1) -hmapdev sdk current # 当前活跃 SDK(实测 v1.4.0) -hmapdev sdk list # 已装版本(v1.4.0 / v1.3.0 / v1.2.0 / v0.8.0) -hmapdev --help # ★ build/debug **不支持** --help,看这个 -hmapdev init # 拿脚手架(会生成 plugin.go + plg.json + go.mod + rawfile/) -hmapdev build # 产出 dist/_bundle.hmap(ZIP,含多平台 plugin.bin..) -``` - -**两个实测踩过的坑**: - -1. `go build` 在插件工程里**不成立** —— 缺 `main`,入口由 `hmapdev build` - 注入生成(`z_proc_gen.go`)。所以"能不能编译"要用 `hmapdev build` 判断, - 不要用 `go build`。 -2. proc 运行时调 **`NewPluginFactory`**(不是 `NewPlugin`)。手写入口时名字必须对, - 否则 `undefined: NewPluginFactory`。 - -> dshplgadapter 是**外部插件**,所以 `hmapdev` 全程参与构建: -> `hmapdev init` 生成骨架 → `hmapdev build` 产出 `.hmap`(内含 `plugin.bin..` -> 与随包 `skill/`)。与本仓 `internal/plugins/` 的内置插件(走主仓 `go build`) -> **是两套不同的构建路径**,不要混。 - -### 5.5 可直接照拄的迁移范本(先读这个再开工) - -2026-11-04 已把 clawhubadapter 完整迁为外部插件并跑通, -源码在 ****。 -dshplgadapter 开工前建议先读这三个文件,它们是迁移后**已验证可编译、可运行**的真实形态: - -| 文件 | 看什么 | -| --- | --- | -| `plugin.go` 开头的 `NewPluginFactory` | 外部入口的正确写法(含注册改工厂的差异) | -| `safeclose.go` | 内核 `internal/safeclose` 的外部替代(泛型覆盖三种 channel) | -| `external_form_test.go` | 四条外部形态判据,**直接照搬改包名** | - -还有一个实操细节:`plg.json` 的 `name_en` 决定产物文件名 -(`toSnake(name_en)` → `_bundle.hmap`)。如果 `name_en` 写成 -"Dsh Plg Adapter",产物会是 `dsh_plg_adapter_bundle.hmap`,与包名 -`dshplgadapter` 不一致 —— 安装脚本里很容易对不上。**把 `name_en` -设成与 `name` 相同**。 - ---- - -## 六、风险清单 - -| 风险 | 影响 | 处置 | -| --- | --- | --- | -| **stdout 被 dsh 插件日志污染** | 协议坏帧,插件整体不可用 | 阶段 0.1 先验证 fd 3 方案;未解决不进阶段 1 | -| **dsh 插件可执行任意 Node 代码** | 绕过 HA 沙箱 | 阶段 1 前先做**允许与否**的决策(§3.4) | -| dsh 版本漂移(本机 0.2.1-rc.2,RC 版会变) | 插件 API 变,适配层碎 | 适配层按 `inject` 声明**动态**发现能力,不硬编码包名 | -| `@@deepseek-ai/dsh-*` 200+ 包的加载开销 | 内存/启动时间 | 只加载被 `bundles` 声明的,不全扫 | -| 异步中间件链与 HA 同步阶段语义冲突 | 死锁或语义丢失 | 阶段 2 专项;先在探针里复现 | -| 本仓 `channels.lua` 类问题:加了代码但静默退化 | 难查的 nil | 照 `luac` 那次的教训,每条新路径都要有行为判据 | - ---- - -## 七、明确不做 - -- **不做 dsh 的 client 半边**(`exports["./client"]`、浏览器 UI)。 - 那是 dsh 前端的事,HA 没有对应位置。 -- **不实现 Cordis 的完整服务树**。只提供插件**声明了 `inject`** 的那几个服务, - 其余缺失即报错退出(fail-loud),不伪造。 -- **不在阶段 1-3 做远程安装**(§3.4 第三条)。 -- **不为了兼容 dsh 而改 HA 的阶段钩子语义**。转译层负责适配,内核不动。 - ---- - -## 八、判据总表(贯穿各阶段) - -| 判据 | 钉什么 | -| --- | --- | -| `TestDiscoversDSHPlugin` | 扫描真的能认出 dsh 插件 | -| `TestRegistersToolFromPlugin` | 工具真的进了 HA 工具表(不是只记日志) | -| `TestUnknownRegisterTypeDoesNotCrash` | 宽容策略 | -| `TestPluginLoadFailureDoesNotAbortScan` | 一个坏包不毁全部 | -| `TestProtocolSurvivesPluginLogging` | ★ 插件狂打日志时协议仍通(对应最大风险) | -| `TestPackageNameCannotEscapeRoot` | 路径穿越(抄 clawhubadapter 的教训) | -| `TestArchiveExtractionStaysInsideRoot` | ZIP Slip(同上) | -| `TestEffectsRunInReverseOnStop` | 清理顺序 | -| `TestSkillHandoffToSkillmgr` | 职责链交接 | - -**每条判据都要做变异验证**:把对应实现改回错误形态,判据必须变红。 -只写判据不做变异,等于没验证(本仓 `agentcli-terminal` 那次教训: -第一版变异"只改一句话"没红,因为关键词在文中另有 13 处,那次变异无效)。 - ---- - -## 九、交接须知(给接手实现的人) - -这一节是给**别人**看的,假定对方没有本机环境、没有上下文。 - -### 9.1 你需要的东西,按重要性 - -| # | 东西 | 在哪 | 是否阻塞 | -| --- | --- | --- | --- | -| 1 | 内核仓 | | **阻塞**(必须能编译 homed) | -| 2 | SDK 仓 | | **阻塞**(`hmapdev` 要用) | -| 3 | 参考插件 clawhubadapter | | **阻塞**(原型,照抄结构) | -| 4 | dsh 官方仓库 | | **阻塞**(`docs/cordis-tutorial/` 是唯一权威教程) | -| 5 | 本机 dsh 安装 | `npm i -g @deepseek-ai/dsh` | 探针需要 | -| 6 | `dsh-mail-bridge` 参考 | 见 §2.5 三个源 | 不阻塞(有公开镜像) | - -**只有第 6 项依赖特定机器**,其余都能从公开网络拿到。 - -### 9.2 环境自检(开工前跑一遍) - -```bash -# 工具链 -hmapdev version # 期望 1.4.0+;SDK 模块必须是 hologramagentsdk -hmapdev sdk current # 期望 v1.4.0+ - -# dsh -node -e "console.log(require('@deepseek-ai/dsh/package.json').version)" # 期望 0.2.1-rc.2 -node -e "const c=require('@deepseek-ai/cordis'); const ctx=new c.Context(); console.log('Context ok', !!ctx)" # 阶段 0.2 的前提 - -# 内核仓能编译 -cd && go build ./... - -# 参考插件能编 -cd && hmapdev build # 产出 dist/*.hmap 即通过 -``` - -任何一条不过,先解决再进入阶段 0。 - -### 9.3 三条别再踩的坑(都是本仓真实付过代价的) - -1. **`go build` 在插件工程里不成立** —— 缺 `main`,入口由 `hmapdev build` - 注入生成。判断"能不能编译"必须用 `hmapdev build`,不要用 `go build`。 -2. **入口函数名是 `NewPluginFactory`,不是 `NewPlugin`** —— proc 运行时按名调用, - 写错报 `undefined: NewPluginFactory`(错误指向生成代码,很难一眼看出)。 -3. **子进程 stdout 是协议通道,只能有 JSON** —— 任何 `console.log` 都会造成 - "非法 JSON 帧"。这是本方案**最大的技术风险**(见 §2.3),也是阶段 0.1 - 必须先验掉的原因。 - -### 9.4 建议的推进顺序(与 §四 一致) - -```text -阶段 0 ← 先做这个,别跳 - ├─ 0.1 fd3 协议通道 ← 不过就不要往下走 - ├─ 0.2 裸 Context 承载插件 - ├─ 0.3 defineTool schema 往返 - └─ 0.4 inject 缺服务时的行为 -阶段 1 最小闭环(只 tools) -阶段 2 事件与钩子(注意 §2.4 的异步链死锁风险) -阶段 3 纯 SKILL 交接 + 生命周期 -阶段 4 远程安装(需单独决策) -``` - -### 9.5 一个前置决策(不要默认放行) - -dsh 插件是 **Node 代码**,`apply()` 里可以 `import('node:child_process')` -执行任意命令。这与 HologramAgent 的沙箱模型冲突(当前内核里真正生效的沙箱 -只有 files 插件自己的 `pathWithinSandbox`)。 - -**在阶段 1 开工前必须先决定**:允不允许 dsh 插件跑任意代码? - -- 允许 ⇒ 必须明确写进文档,并告知部署方(等价于"装 dsh 插件 = 给本机执行权限") -- 不允许 ⇒ 需要一个进程级限制方案,工作量显著增加,要单独评估 - -不要"先跑通再说"——那等于默认给了执行权限,事后收不回来。 - -### 9.6 这个方案里哪些是"已验证",哪些是"推测" - -诚实标注,避免接手人把推测当事实: - -| 结论 | 状态 | 依据 | -| --- | --- | --- | -| clawhubadapter 能做成外部插件 | ✅ **已验证** | 已迁出并生产运行(本仓 commit `a0077a2`) | -| 外部插件自带 skill 由 pluginmgr 移交 | ✅ **已验证** | 生产日志三段链路(§0.3) | -| `Events().Publish` 外部不可用 | ✅ **已验证** | 编译期拦截 + `capability_test.go` 禁令 | -| `Context` 可无参构造 | ✅ **已验证** | 读 `.d.ts` 确认签名(但**未实际加载过插件**) | -| dsh 插件能在裸 Context 里跑起来 | ⚠️ **推测** | 阶段 0.2 要验 | -| fd3 通道可行 | ⚠️ **推测** | 阶段 0.1 要验 | -| `defineTool` schema 可无损转换 | ⚠️ **推测** | 阶段 0.3 要验 | -| 异步中间件链不与应用层死锁 | ⚠️ **推测** | 阶段 2 要验 | - -⇒ **阶段 0 的全部意义就是把下面四个"推测"变成"已验证"**。