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

14 KiB
Raw Permalink Blame History

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。

三条后果必须知道:

  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 声明模板(可复制)

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 提包前自测清单(插件侧自己跑完再交)

  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)