文档库:目录改为编号制(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/;交接单不入库(政策)。
14 KiB
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字段),缺哪块就没有对应能力:
{
"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。
三条后果必须知道:
- 无
dsh.data⇒ 内核判level:'none'⇒ 数据面门禁直接放行,插件可正常发布与启用。 - 若写一个空声明(
tables: [])⇒ 判invalid⇒ 状态恒为blocked,永远发不出去。 - 只在包内放一个
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 声明模板(可复制)
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(含触发器/存储过程)|⛔ 不访问别的插件库|⛔ 不自组跨库事务|⛔ 不绕过归属判定|⛔ 不以实例本地库 / 本地文件当数据权威(可当缓存,权威必须在插件库)。
机器判据(提包前自己跑,必须零命中):
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>/;实例内不重映射)。
取目录只有一种姿势:
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 提包前自测清单(插件侧自己跑完再交)
grep红线机器判据 零命中(§3-6)。- 有数据面 ⇒ 声明通过 §3-3 全部规则;无数据面 ⇒ 确认
package.json里没有dsh.data。 npm pack后tar -tzf <tgz> | head确认包根有package.json、lib/、cordis.patch.yml(有 client 的还有lib/client.js)。dsh三块字段形态与 §2 一致(bundle.patch/client/ 按需data)。- 交付时一并带上:tgz +
sha256+ 声明快照(或"无数据面")+ 上述自测输出。
§8 端侧边界
端侧(桌面壳 / 浏览器)访问同一个平台 API:
- ✅ 只保证「能看见 + 能开通」;⛔ 不要求端侧下载或安装插件包。
- ⛔ 不下发平台级凭据到客户端(客户端只持有该用户自己的会话)。
- ⛔ 端侧不落插件数据;插件数据在服务端该用户的主目录与插件库。
§9 回报格式(交给平台侧 / 对接方时)
必须带齐,否则平台侧无法定位:
- 包名 + 版本 +
sha256; - 「有数据面 / 无数据面」明确写一句;有则附声明快照;
- 失败时把错误码与文案原样贴回(⛔ 不要转述、不要只写"失败了");
- 涉及的用户 / 实例标识(userId、hostId),以及看到现象的时间点;
- 自测清单(§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) |