Files
dsh_shenxian/dsh-server-docs/01-规范/08-插件开发与对接规范.md
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

217 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 08 · 插件开发与对接规范
> **读者**:写插件的会话(自研 / 第三方 / 端侧同源)—— 提案、开发、交付时都按本文。
> **维护方**:插件投放与分库线(平台侧)|**落文** 2026-09-23|**长期维护**(跟改本文件即可)
> **上位权威(冲突时以它们为准)**:`03-数据库/DB-03-插件数据面规范.md`(数据面)· `05-交接单/插件投放与分库线-01共享只读包库与插件数据面.md`(投放链路)· `DEPLOY-本部署.md §6.4`(建库权限)
> **本文只写「插件侧必须照做的契约」**;平台内部实现(怎么物化、怎么路由)不在此复制。
---
## §0 一句话
插件只做三件事:**把声明放对位置**(可选)、**用一个函数取目录**、**把包交上来**。
其余全部在平台侧 —— 装到哪台机、怎么装、库怎么建、凭据怎么投,插件**一概不管、也不许假设**。
## §1 开工四问(先答,答案决定后面走哪条路)
| # | 问题 | 怎么判 | 结论 |
|---|---|---|---|
| 1 | 有「使用中数据」要持久化吗? | 换个实例/重启后必须还在的数据 | **有** ⇒ 走 §3;**无** ⇒ **一个字都不写**(§3-0) |
| 2 | 有文件产物吗? | 缓存在实例本地、丢了可重建 | 落 §4 目录;⛔ 不用 `homedir()` |
| 3 | 需要凭据吗? | 连外部服务要 token / key | 与 §4 同目录、**平台或运维投递**,插件**只读** |
| 4 | 装到哪台机? | —— | **平台决定**(Manager / worker 都可能)⇒ ⛔ 插件不得写死本机路径 |
## §2 包形态(硬要求)
- **打包**:`npm pack` 出 `.tgz` 交付;包名用 scope 形态(现网为 `@dsh-local/<name>`)。
- **`package.json` 三块**(`dsh` 字段),缺哪块就没有对应能力:
```json
{
"name": "@dsh-local/dsh-plugin-<name>",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js",
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web", "inject": [] },
"data": { "schema": "./dsh.data.yaml" }
},
"peerDependencies": { "@deepseek-ai/dsh": "^0.1.5-rc.1" },
"dependencies": {}
}
```
- `dsh.bundle.patch` / `dsh.client` 与既有插件同族;`dsh.data` **仅当有数据面时写**(§3)。
- **依赖**:`dependencies` 尽量留空;⛔ 不许把数据库驱动(`pg` / `better-sqlite3` / `node:sqlite`)、Redis / MQ 客户端打进包。
- **体积与内存**:实例**硬顶 1024 MiB**、V8 堆按配额推导 ⇒ ⛔ 不引入重依赖、不常驻大内存结构。
- ⛔ **不许改 `@deepseek-ai/dsh-*`**(平台红线 R2,官方核心零改动);⛔ 不许要求平台"给某用户铺 profile"(投放只有候选池 → 用户自助启用一条路)。
## §3 数据面(**有持久化数据才看本节**)
### §3-0 第一步:先判「有没有」
**没有使用中数据**(业务数据在远端系统 / 只做工具面转发 / 凭据由平台投递)⇒ **不要在 `package.json` 里加 `dsh.data`,也不要放 `dsh.data.yaml`**。
三条后果必须知道:
1. 无 `dsh.data` ⇒ 内核判 `level:'none'` ⇒ **数据面门禁直接放行**,插件可正常发布与启用。
2. 若写一个**空声明**(`tables: []`)⇒ 判 `invalid` ⇒ 状态**恒为 `blocked`**,**永远发不出去**。
3. 只在包内放一个 `dsh.data.yaml` 但 `package.json` 里不引用它 ⇒ **根本不会被读取**(平台**不自动探测**该文件)。
### §3-1 声明放哪(唯一入口)
**唯一入口 = `package.json` 的 `dsh.data.schema`**,两种写法等价:
- **内联对象**:`"schema": { "schemaVersion": 1, "tables": [ … ] }`
- **包内路径**:`"schema": "./dsh.data.yaml"`(字符串,只接受包内 `.yaml` / `.yml` / `.json`;⛔ 不许 `../` 逃出包)
> ⚠️ **口径澄清(2026-09-23)**:`DB-03 §三` 与平台回复里说的「插件在 `im.data` 上声明表」,指的是**运行时对象的措辞**;**声明的存放位置**只认 `dsh.data.schema`(源码 `src/db/plugin-data/schema.ts:701-746` 现读)。按本文写,不要按别的写法试。
### §3-2 声明模板(可复制)
```yaml
schemaVersion: 1 # ≥1 的整数;库结构一变就 +1(只增不减)
tables:
- name: character_sheet # 实际表名 => p_<pluginId>_character_sheet
scope: room # room | user(必填,归属列强制)
columns:
- { name: pc_name, type: text, notNull: true, maxBytes: 128 }
- { name: sheet, type: json, notNull: true, maxBytes: 16384 }
- { name: sheet_rev, type: integer, default: 1 }
indexes:
- { columns: [pc_name], unique: true }
```
### §3-3 校验器实际会拒的东西(照抄避免返工)
| 项 | 规则 |
|---|---|
| `schemaVersion` | 必须 **≥1 的整数** |
| `tables` | **1 ~ 20** 张;表名 `^[a-z][a-z0-9_]{0,40}$`;⛔ 不许重名 |
| `scope` | **必填**,只能 `user` 或 `room`(缺 ⇒ 拒) |
| `columns` | 每表 **1 ~ 20** 列;列名同表名正则;⛔ 不许重名 |
| 类型 | 仅 9 种中性类型:`text` `bigtext` `integer` `bigint` `real` `boolean` `json` `timestamp` `uuid` |
| `json` 列 | `maxBytes` **必填** |
| `indexes` | 可选;`columns` 必须是**本表已声明**的列 |
| `pluginId` | ⛔ **不要在声明里写** —— 由**包名**推导(去 scope、小写、非 `[a-z0-9_]` 折 `_`),写了也不生效 |
**内核自动补齐**(⛔ 不用也不能自己写):库名 `dshs_pl_<pluginId>`、表名前缀 `p_<pluginId>_`、**归属列**(`user_id` / `room_id`)、审计列 `created_at` / `updated_at`。
### §3-4 运行时读写(SDK 面)
- 读写一律走内核数据 API:`im.data.table<T>(name).insert / update / delete / find / count`;
- 同库内多表原子:`im.data.tx([...])`(⛔ **跨库无事务**,PG 跨 database 没有原生事务);
- 归属由内核强制:`scope: user` 只能读写**自己**的行;`scope: room` 复用房间成员判定。
### §3-5 迁移:只增不减
- **允许**:加表 / 加列(带默认值)/ 加索引 ⇒ `schemaVersion` +1。
- **⛔ 一律拒**:删列 / 改类型 / 重命名 ⇒ 迁移预演 `executable=false` ⇒ 执行返回 **`409 plan_blocked`**,库**一字不动**。
- 🔴 **两个版本号是两件事**:包 `version`(代码版本)与 `schemaVersion`(库结构版本)—— 判「库该不该动」**只看 `schemaVersion`**。
- 平台在**非首次建库**的迁移前会先做 `pg_dump --schema-only` 结构备份;备份失败 ⇒ **不执行迁移**(`500 schema_backup_failed`)。
### §3-6 红线(违反即不予启用)
⛔ 不直连数据库|⛔ 不写原生 SQL(含触发器/存储过程)|⛔ 不访问别的插件库|⛔ 不自组跨库事务|⛔ 不绕过归属判定|⛔ **不以实例本地库 / 本地文件当数据权威**(可当缓存,权威必须在插件库)。
**机器判据**(提包前自己跑,必须零命中):
```bash
grep -rE "CREATE TABLE|ALTER TABLE|DROP TABLE|better-sqlite3|from 'pg'|require\('pg'\)" <包目录>
```
## §4 文件落点(D4)
**落点 = `$DSH_HOME/.dsh/plugins/<pluginId>/`**(平台侧同一绝对路径 = `/var/lib/dshs/users/<uid>/home/.dsh/plugins/<pluginId>/`;实例内**不重映射**)。
**取目录只有一种姿势**:
```js
const base = process.env.DSH_HOME // 平台注入,= <userRoot>/home,必存在
if (!base) throw new Error('DSH_HOME 缺失:无法定位插件数据目录') // fail-fast
const dir = join(base, '.dsh', 'plugins', 'dsh_plugin_<name>') // <pluginId>,与库名同一个 token
```
- ⛔ **禁止 `homedir()` 回退**:实例内 `HOME` = `<userRoot>/ws`,而 `ws` 会被平台按产物清理 ⇒ 回退不是"降级可用",是**把数据写到会被清掉的位置**。
- ⛔ **不要去找 `im.paths.pluginData()`** —— **该 API 不存在**(2026-09-23 实测:源仓 `src/`、宿主仓 `deepseek-harness`、`node_modules` 三处零命中)。
- **目录自己建**:插件首次用时 `mkdir -p`(幂等)。平台侧**不碰**该目录(用户 home 的托管文件名白名单只有 3 个裸文件名)。
- **凭据 / 投递类文件与插件数据同目录**(同一清理单元)、文件 `0600`、属主 = **该用户 uid**;插件**只读**,⛔ 不覆盖投递来的文件名。
- ⚠️ **跨机**:`<userRoot>` 是**那台机上的**路径。用户实例在 worker 上时,在管理机写 `<userRoot>/home/…` = **静默空操作**(平台已踩过)⇒ 投递必须发生在**实例所在那台机**。
- **实例内其他已知事实**:共享包库 `/var/lib/dshs/bundled-plugins/` 是**只读**挂载(`touch` ⇒ `Read-only file system`);`HOME` = `<userRoot>/ws`、`DSH_HOME` = `<userRoot>/home`。
**`pluginId` 速查**:`@dsh-local/dsh-plugin-carbon` ⇒ `dsh_plugin_carbon`(⛔ 不是 `carbon`)。目录名与库名后缀**同一个 token**。
## §5 投放链路(谁做什么)
```
admin 上传 tgz
└─ 平台侧物化:解包 → chown -R root:root → pnpm install --prod(store 在共享层内)
⇒ /var/lib/dshs/bundled-plugins/<扁平包名>/ ← 节点级「共享只读包库」(D2)
└─ 检测三项:安全 + 兼容 + **数据面声明校验** ⇒ 通过 = pending
└─ admin 点 [建库](仅"有数据面"的插件需要)⇒ CREATE DATABASE dshs_pl_<pluginId>
└─ [发布] ⇒ 用户才能启用
用户(任意登录用户)在实例「功能管理」点启用
└─ POST /api/plugins/mine/apply ⇒ 路由到实例所在那台机
└─ worker: POST /plugins/apply ⇒ profile 写依赖引用 "pkg": "link:<共享层绝对路径>"
└─ pnpm 建 node_modules/<pkg> 链接 ⇒ 共享层**实体一份**、用户侧只有链接
```
**插件侧要做的只有**:把包交上来(并说明"有无数据面""是否需要凭据")。⛔ 上传 / 建库 / 发布 / 启用**都不是插件侧动作**。
**D2 判据**:启用后用户侧 `node_modules/<pkg>` 必须是**链接**、用户目录下**零实体**(`link:` 协议,2026-09-23 落地)。
⚠️ **只进池不发布 ⇒ 用户启用必撞 `plugin_bundle_missing`**(装配目标就是共享层)—— 这是平台侧操作顺序问题,插件侧只需知道这个错误码含义。
## §6 错误码表(看到什么该判给谁)
| 错误码 / 现象 | 含义 | 归属 |
|---|---|---|
| `plugin_bundle_missing` | 包不在共享层(没上传/没发布/该节点未同步) | **平台侧**(含跨节点内容分发缺口) |
| `datastore_not_ready` | 有声明但未建库 / 版本不满足。⚠️ **无数据面插件不该出现它** | 平台侧(admin 未建库) |
| `invalid_plugin_db_name`(400) | 库名不合规(正常不该发生,由包名推导) | 平台侧 |
| `409` 检测不过 | 安全 / 兼容 / 声明校验未过(**声明非法会在一次响应里列全**) | **插件侧**(改声明或改包) |
| `409 plan_blocked` | 迁移含删列 / 改类型 / 重命名 | **插件侧**(改 schemaVersion 策略) |
| `500 schema_backup_failed` | 迁移前结构备份失败 ⇒ 未执行迁移 | 平台侧(运维) |
| `PG_CREATEDB_MISSING`(503) | 平台未授 `CREATEDB` | 平台侧(运维,见 `DEPLOY-本部署.md §6.4`) |
| 插件在实例内 `EACCES` / `EROFS` | 写到只读区(共享层)或路径不对 | **插件侧**(照 §4 取目录) |
## §7 提包前自测清单(插件侧自己跑完再交)
1. `grep` 红线机器判据 **零命中**(§3-6)。
2. 有数据面 ⇒ 声明通过 §3-3 全部规则;无数据面 ⇒ **确认 `package.json` 里没有 `dsh.data`**。
3. `npm pack` 后 `tar -tzf <tgz> | head` 确认包根有 `package.json`、`lib/`、`cordis.patch.yml`(有 client 的还有 `lib/client.js`)。
4. `dsh` 三块字段形态与 §2 一致(`bundle.patch` / `client` / 按需 `data`)。
5. 交付时**一并带上**:tgz + `sha256` + 声明快照(或"无数据面")+ 上述自测输出。
## §8 端侧边界
端侧(桌面壳 / 浏览器)访问**同一个平台 API**:
- ✅ 只保证「能看见 + 能开通」;⛔ **不要求端侧下载或安装插件包**。
- ⛔ **不下发平台级凭据到客户端**(客户端只持有该用户自己的会话)。
- ⛔ 端侧**不落插件数据**;插件数据在服务端该用户的主目录与插件库。
## §9 回报格式(交给平台侧 / 对接方时)
必须带齐,否则平台侧无法定位:
1. 包名 + 版本 + `sha256`;
2. 「有数据面 / 无数据面」明确写一句;有则附声明快照;
3. 失败时**把错误码与文案原样贴回**(⛔ 不要转述、不要只写"失败了");
4. 涉及的用户 / 实例标识(userId、hostId),以及**看到现象的时间点**;
5. 自测清单(§7)的输出。
## §10 别做清单(一次看全)
⛔ 改 `@deepseek-ai/dsh-*` ・⛔ 要求平台铺 profile ・⛔ 自己上传 / 建库 / 发布(那不是插件侧动作)・⛔ 走 handoff / 打开 `DSHS_ENABLE_PATCH` ・⛔ 扩大权限面 ・⛔ 引入外部服务进程(Redis / MQ)・⛔ 写裸 SQL / 直连 DB ・⛔ 以本地库为权威 ・⛔ 用 `homedir()` 拼路径 ・⛔ 提交空数据面声明 ・⛔ 假设实例在哪台机。
---
## §11 变更记录
| 日期 | 变更 | 依据 |
|---|---|---|
| 2026-09-23 | 首版:包形态 / 数据面(含"零数据面不要写空声明"与声明唯一入口澄清)/ 文件落点 D4 / 投放链路 / 错误码 / 自测清单 / 边界 | 现读源码 `src/db/plugin-data/schema.ts`、`src/web/routes/business-plugins.ts`、`src/fs/user-fs.ts`、`src/supervisor/orchestrator.ts`;`DB-03`;carbon 线两问裁定(`dsh-plugin-carbon/对接文档_carbon插件-平台侧两处阻塞_20260922.md §L`) |