mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
docs(plan): dshplgadapter 规划书 —— 按外部插件形态重写
## 方向 把 dsh(DeepSeek Harness)的 Cordis 插件生态接入 HologramAgent, 参考原型是刚迁出的 clawhubadapter(两者同构:兼容层 + sidecar + 转译注册)。 ## 本文件在被推翻过一次,纠正记在正文里 初版结论是「必须做成内置插件」,依据是「外部插件发不了事件,所以 skill 移交只能靠概率」。该依据实测被推翻: · 外部插件自带 skill 由 pluginmgr 在安装/启动时移交,与插件发不发事件无关 · clawhubadapter 已实际迁成外部插件并端到端跑通 所以 v2 改成外部插件,并把原因写进 §0 结论 2(保留纠错痕迹, 下次有人拿旧结论当依据时会看到为什么它不成立)。 ## 内容的依据 全部来自实测,不凭记忆: dsh 0.2.1-rc.2 / cordis 4.0.5 / dsh-tools 的 defineTool 签名 /opt/agentmail/plugins/dsh-mail-bridge/src/index.ts(2586 行生产代码, 是 dsh 插件侧 API 最可靠的文档) third_party/hologramagentsdk 的 capability-boundary.md 与 proc_main.go.tmpl (外部插件实际拿到什么能力,全在那一个文件里) ## 最该先做的一件事 不是写代码,是探针 0.1:验证 fd 3 做协议通道。 理由:dsh 生态插件大量用 console.log,而子进程 stdout 是 JSON-RPC 通道。 本仓为这个坑付过代价(生产累计 1038 条「非法 JSON 帧」报错,定位真因 花了一整轮)。clawhubadapter 靠「模拟器把 logger 接到 stderr」躲过去, 但那只管模拟器内的调用,插件直接 process.stdout.write 仍会漏。 这条不解决就不该进阶段 1。
This commit is contained in:
614
docs/zh/dshplgadapter-plan.md
Normal file
614
docs/zh/dshplgadapter-plan.md
Normal file
@ -0,0 +1,614 @@
|
||||
# dshplgadapter 插件项目规划书
|
||||
|
||||
> **状态**:规划(未开始实现)
|
||||
>
|
||||
> **定位**:把 DeepSeek Harness(下称 dsh)的 Cordis 插件生态接入 HologramAgent。
|
||||
> 参考原型就是本仓的 `internal/plugins/clawhubadapter/` —— 它把 OpenClaw 生态
|
||||
> 接入 HA,dshplgadapter 把 dsh 生态接入 HA,两者是**同一个模式的两次实例化**。
|
||||
>
|
||||
> **建立于**: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 迁移实测:三处差异,全部可绕
|
||||
|
||||
把 `internal/plugins/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`(随插件启停出入) |
|
||||
| 用户自己写 | `<data_dir>/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 配置):
|
||||
|
||||
```bash
|
||||
/root/.dsh/profiles/web/package.json
|
||||
├─ dependencies: @dsh-external/xxx, dsh-mail-bridge, ...
|
||||
└─ dsh.profile.bundles: [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", ... ]
|
||||
|
||||
/root/.dsh/profiles/web/cordis.yml → 空数组(root,由 patch 组合)
|
||||
/root/.dsh/profiles/web/cordis.patch.yml → 覆盖层
|
||||
/root/.dsh/profiles/web/node_modules/@dsh-external/* → 软链到真实目录
|
||||
```
|
||||
|
||||
⇒ **dshplgadapter 的"扫描目标"就是 `<profile>/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 已存在的同类先例(务必读)
|
||||
|
||||
`/opt/agentmail/plugins/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`(它踩过,
|
||||
注释里写得很清楚)
|
||||
|
||||
⇒ 它的 `src/index.ts` 是 dsh 插件侧 API 的**最可靠文档**,比 dsh 官方文档更实在。
|
||||
|
||||
---
|
||||
|
||||
## 三、设计
|
||||
|
||||
### 3.1 总体形态(与 clawhubadapter 逐层对照)
|
||||
|
||||
```text
|
||||
HologramAgent 内核(homed)
|
||||
│
|
||||
dshplgadapter(外部插件,plugin.bin 子进程)
|
||||
│
|
||||
┌────────────────────┴────────────────────┐
|
||||
│ │
|
||||
发现与安装 运行期
|
||||
(扫描 profile) (每 dsh 插件一个 sidecar)
|
||||
│ │
|
||||
读 package.json fork node <simulator>
|
||||
读 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 包发现规则
|
||||
|
||||
扫描 `<profile>/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:最小闭环(只支持工具)
|
||||
|
||||
- `internal/plugins/dshplgadapter/` 骨架(抄 clawhubadapter 的 4 文件结构)
|
||||
- 扫描 `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 必读(按顺序)
|
||||
|
||||
> `internal/plugins/clawhubadapter/` 已于 2026-11-04 **迁出内核仓**,
|
||||
> 现位于 `/home/program/clawhubadapter-plugin/clawhubadapter/`。
|
||||
> 下面的路径都是迁移后的新位置。
|
||||
|
||||
| 文件 | 为什么 |
|
||||
| --- | --- |
|
||||
| `<ext>/sidecar.go` | **协议层原型**,原样照搬(JSON-RPC + pending-id 配对 + NotifyChan + 优雅 Close) |
|
||||
| `<ext>/registry.go` | **转译表原型**,换内容照抄结构(含 `default` 宽容分支) |
|
||||
| `<ext>/plugin.go` | 生命周期、包发现、工具注册的骨架 |
|
||||
| `<ext>/simulator/main.js` | 假宿主原型,换成 Cordis Context |
|
||||
| `<ext>/skill/clawhub-tools/SKILL.md` | 随包 skill 的真实写法(外部插件带 skill 的范本) |
|
||||
| `internal/plugins/pluginmgr/skill_handout.go` | 外部插件随包 skill 的**移交机制**(安装时 + 启动重扫) |
|
||||
| `internal/plugins/skillmgr/plugin.go` | 交接口终点(`loadOne`、`isNativeSkillPath`、`scanExisting`) |
|
||||
|
||||
`<ext>` = `/home/program/clawhubadapter-plugin/clawhubadapter`。
|
||||
|
||||
### 5.2 外部参考(dsh 侧)
|
||||
|
||||
| 路径 | 为什么 |
|
||||
| --- | --- |
|
||||
| `/opt/agentmail/plugins/dsh-mail-bridge/src/index.ts` | **dsh 插件侧 API 的最可靠文档**(2586 行生产代码)。`inject` 声明、`ctx.tools.register`、`ctx.on` 五种事件、`ctx.effect` 清理、logger 不进 journalctl 的坑 |
|
||||
| `/root/.dsh/profiles/web/package.json` | 装载配置的真实形状(`dsh.profile.bundles`) |
|
||||
| `/root/.dsh/profiles/web/node_modules/@dsh-external/dsh-mobile-nav/` | 一个最小真实插件(含 `cordis.patch.yml`) |
|
||||
| `@deepseek-ai/dsh-tools/lib/types/schema.d.ts:248` | `defineTool` 的签名与 `DefineToolOptions` 全字段 |
|
||||
| `@deepseek-ai/cordis/lib/types/context.d.ts:40` | `Context` 类(`constructor()` 无参,是阶段 0.2 的依据) |
|
||||
|
||||
### 5.3 SDK 侧(`third_party/hologramagentsdk/`,即完整 SDK 仓)
|
||||
|
||||
| 文档 | 为什么要看 |
|
||||
| --- | --- |
|
||||
| `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 <name> # 拿脚手架(会生成 plugin.go + plg.json + go.mod + rawfile/)
|
||||
hmapdev build # 产出 dist/<name>_bundle.hmap(ZIP,含多平台 plugin.bin.<os>.<arch>)
|
||||
```
|
||||
|
||||
**两个实测踩过的坑**:
|
||||
|
||||
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.<os>.<arch>`
|
||||
> 与随包 `skill/`)。与本仓 `internal/plugins/` 的内置插件(走主仓 `go build`)
|
||||
> **是两套不同的构建路径**,不要混。
|
||||
|
||||
### 5.5 可直接照抄的迁移范本(先读这个再开工)
|
||||
|
||||
2026-11-04 已把 clawhubadapter 完整迁为外部插件并跑通,工程在
|
||||
`/home/program/clawhubadapter-plugin/clawhubadapter/`。dshplgadapter 开工前
|
||||
建议先读这三个文件,它们是迁移后**已验证可编译、可运行**的真实形态:
|
||||
|
||||
| 文件 | 看什么 |
|
||||
| --- | --- |
|
||||
| `plugin.go` 开头的 `NewPluginFactory` | 外部入口的正确写法(含注册改工厂的差异) |
|
||||
| `safeclose.go` | 内核 `internal/safeclose` 的外部替代(泛型覆盖三种 channel) |
|
||||
| `external_form_test.go` | 四条外部形态判据,**直接照搬改包名** |
|
||||
|
||||
还有一个实操细节:`plg.json` 的 `name_en` 决定产物文件名
|
||||
(`toSnake(name_en)` → `<snake>_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 处,那次变异无效)。
|
||||
Reference in New Issue
Block a user