build / build-and-scan (push) Waiting to run
文档库:目录改为编号制(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/;交接单不入库(政策)。
217 lines
14 KiB
Markdown
217 lines
14 KiB
Markdown
# 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`) |
|