- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
20 KiB
S5「第一块迁移:IM 插件侧」· 落地与回执(2026-09-26 · 第 43 棒)
执行依据:本线入口
接续入口_插件投放与分库线_20260922.md§2 第 42 棒块「本线下一项」|方案交付物/功能打包到基础插件-分步实施方案-20260926.md §3.10.7 + §4 避坑总表|规格书D:/github/dsh_shenxian/dsh-server-docs/05-交接单/交接单_包规格书_20260926.md §〇/§五 A/§五 C。 会话:插件投放与分库线-43(域锁持有者已复读断言)。 读数:tmp/p43/读数-20260926.txt(76 ✅ / 0 ❌)·tmp/p43/尺读数-S5-20260926.txt。
一、一句话结论
IM 插件侧已按「照 IM 的接入方式重写」的方式整块进了包 —— 包 @dsh-local/ai1net 现在自带三条精确路由(/api/ai1net/im/rooms · messages · longpoll)+ IM 插件侧 SDK(含同进程 core 模式),并把两条残留补在了代码里:每插件令牌(fail-closed) 与 实例级单例长轮询闸。
🔴 最强的一条证据 = 迁移前后「同一组输入 ⇒ 平台侧效果流水逐行完全相同」(A=13 行 / B=13 行,逐行逐字符相同)—— 旧 SDK 直连平台面、新 SDK 走包面转平台面,两条路对同一个平台产生了字面一样的行为。
⚠️ 但必须与「已通」严格分开(详见 §六):包内那一半是真实代码(凭据解析 / 出示 / 本机 fail-closed 拦截 / 单例闸);平台侧那一半(令牌签发 + 桥端点校验 + 启用集校验)本棒是沙箱建模,真实 im.ts 里尚未实现 ⇒ 属投放线待落地。⛔ 不能据此断言「真机投放已通」。
二、S5-1「照抄」逐段对照表(⛔ 判据 = 与上游对得上)
| 段 | 上游权威(只读照抄来源) | 本包落点 | 是否逐字/同口径 |
|---|---|---|---|
| 环境变量名 | sdk/im-plugin-host/index.mjs:DSH_PLATFORM_IM_URL / DSH_IM_INSTANCE_TOKEN |
lib/im-plugin-sdk.mjs ENV_BASE_URL / ENV_INSTANCE_TOKEN |
✅ 逐字(机验 ⑦) |
| 插件身份头 | src/web/routes/im.ts:291:x-dsh-im-plugin-id(值 = 包名) |
PLUGIN_ID_HEADER |
✅ 逐字(机验 ⑦) |
| 实例令牌头 | 同上:x-dsh-im-instance-token |
INSTANCE_TOKEN_HEADER |
✅ 逐字(机验 ⑦) |
| 长轮询硬上限 | SDK: SERVER_POLL_HOLD_MS = 20_000 |
SERVER_POLL_HOLD_MS = 20_000 |
✅ 同值(机验 ⑦) |
| 平台面 8 条路径 | plugins/register(341) · unregister(368) · out/frame(382) · out/message(424) · bridge(465) · bridge/pull(484) · bridge/result(504) + im/rooms/rooms/:id/messages |
PLATFORM_ROUTES(八条,全在 /api/im/ 下) |
✅ 同口径(机验 ⑦) |
| 方法面 | ready/unregister/onSpeakRule/onEvent/startCallbacks/frame/say/rooms/messages/subscribe/close |
createAi1netImHost() 同名同形 |
✅ 逐条 |
pluginIdOf 口径 |
src/im/sdk/host.ts:112(包名 → 下划线形态) |
沙箱 pluginIdOf 逐字照抄 |
✅ 逐字(仅沙箱侧用;⚠️ 与令牌绑定的"包名"是两个口径,见下) |
四条面(装配 / 运行 / 取数 / 回调)逐条复现
| 面 | 判据 | 结果 | 原始输出位置 |
|---|---|---|---|
| 装配 | 端A 客户端形态 + 端B 平台形态 各三条前提成立 | ✅ rc=0 ×2 |
尺读数 §S5-1 |
| 运行 | 靶子未出现于启动失败面 | ⚠️ rc=0(空真,见 §五) |
尺读数 §运行 |
| 取数 | 真实 import + 真实模块求值 | ✅ rc=0 |
尺读数 §S5-2 |
| 回调 | 真实 apply(ctx),spy ctx 逐字记录 4 条路由 |
✅ rc=0 |
尺读数 §S5-3 |
🔴 回调面抓到的四条路由(原文):{"path":"/api/ai1net/ping","methods":["GET"]} · {"path":"/api/ai1net/im/rooms","methods":["GET"]} · {"path":"/api/ai1net/im/messages","methods":["GET","POST"]} · {"path":"/api/ai1net/im/longpoll","methods":["GET"]} —— 全部精确路径(⛔ 无通配 / 无参数 / 无尾斜杠),对应 §4-B2「桌面端只支持精确路径」。
M5 功能面(经包内真实路由转发到平台面)
POST kind=register ⇒ 200(出向前提)|GET rooms ⇒ count=2 且只含自己参与的 r1/r2(⛔ 不含别人的 r3)|GET messages?roomId=r1 ⇒ count=2 · cursor=2|POST kind=frame ⇒ 200 且帧不落库(消息数 2→2)|POST kind=message ⇒ seq=3 且真落库|say(r2 非成员) ⇒ outbound-denied|payload 非对象 ⇒ 400 bad-payload(包内先拦,⛔ 不打平台)|kind 非法 ⇒ 400 bad-kind。
三、S5-2 残留①:每插件令牌("插件是否真在该实例启用")
残留原文(
交付物/全功能承载判定-底层插件能否承-20260926.md §2.6):「⚠️ 不校验"该插件是否真在该实例启用"(im.ts:305-307自述:与/api/im/data/*同源,现靠逐次审计兜底)⇒ 补法 = 每插件令牌,属投放线。」
落点与语义
- 令牌形如
p1.<base64url(pluginId\0instanceKey\0exp)>.<hmacSha256(secret, body).base64url.slice(0,32)>⇒ 🔴 把(实例, 包名)签死 ⇒ 借别人的令牌改个身份头不可能(机验:token-plugin-mismatch)。 - 🔴 三态凭据语义(本棒实测踩出来的关键一条):
undefined(没带身份头)= 本包自己 ⇒ 允许用本机 env;''(带了身份头却没带凭据)⇒ 必须拒。⇒ 杜绝「包替任何插件签身份」这条漏洞(首版有,已修,见 §七)。 - 拒绝口径:
plugin-not-enabled→ 403;instance-token-rejected/plugin-token-rejected→ 401;longpoll-*→ 409;platform-not-configured→ 503;bad-*→ 400;其余 502。
负控(⛔ 判据 = 未启用必须拒,且平台零调用)
| # | 输入 | 期望 | 实测 |
|---|---|---|---|
| ① | 不带令牌 | 拒 | ✅ 403 plugin-not-enabled + 平台侧一次都没被调(calls 保持 8) |
| ② | 借别人的令牌 | 拒 | ✅ 403 + 借令牌那次平台侧也零调用 |
| ③ | 签名伪造 | 拒 | ✅ 包内闸拒 + 平台侧零调用(包内先拦) |
| ④ | 令牌合法但不在启用集 | 拒(由平台侧拒) | ✅ 403 plugin-not-enabled,且这条确实打到了平台 ⇒ 证明平台侧那道闸真在起作用(⛔ 不是包内假绿) |
| ⑤ | 正控:正确令牌 | 过 | ✅ 200 ⇒ 证明"拒"不是空拒 |
| ⑥ | 直测平台桥端点(绕开包) | — | ✅ 实例令牌错 ⇒ 401 instance-token-rejected;无每插件令牌 ⇒ 401 plugin-token-rejected |
| ⑦ | 调用方带错的实例令牌 | 仍 200 | ✅ 因为实例令牌由包注入(heads() 始终覆盖),插件不必也不该持它 |
🔴 一处口径澄清(本棒纠正了一个易混点):每插件令牌闸只作用于桥端点(plugins/* · bridge/* · out/*)。会话面(/api/im/rooms、/rooms/:id/messages)上游走 imAuth,没有这道闸 ⇒ ⛔ 拿会话面测令牌闸会假绿(首版就这么打错过,已改打 POST /api/ai1net/im/messages)。
四、S5-3 残留②:一个实例只允许一条常驻长轮询
残留原文:「⚠️ 一条实例只该有一条常驻长轮询(
im.ts:482原文:⛔ 别每插件一条)—— 直接关系连接额度。」
落点
ResidentLongPollGate(实例级单例闸):claim(pluginId, at)⇒ 同插件幂等(热重启自愈)/异插件拒且回holder(便于改走同进程分发);release(仅持有者);isOwner;status{holder, since, refusals, idempotentReclaims}。- 包内导出单例
instanceLongPollGate⇒ 同一实例内所有插件共用一把闸(这是"单例"的落点)。
负控(⛔ 判据 = 故意起两条 ⇒ 第二条必须被检出,且真的没起)
| # | 输入 | 实测 |
|---|---|---|
| ① | 第一条 claim |
✅ 200 · holder=@dsh-local/im-plugin-demo |
| ② | 第二条(另一插件)claim |
✅ 409 longpoll-already-running,且回"是谁持有" |
| ③ | 非持有者 poll |
✅ 409 longpoll-not-owner(第二条真的没起) |
| ④ | 同插件重入 claim |
✅ 200 且 idempotent=true(热重启自愈) |
| ⑤ | owner poll ⇒ 取工作项 ⇒ 回投 |
✅ 1 条 + 200;同 callId 二次回投 ⇒ unknown-call-id(幂等,⛔ 不重复 resolve) |
| ⑥ | 持有者 release ⇒ 别的插件 claim |
✅ 200(⛔ 不是永久占用) |
| ⑦ | 同进程 core 模式复跑 |
✅ 第二条 startCallbacks 乐观置位后回滚为 false、一次 poll 都没发(polls=0)、闸记 refusals=1、持有者没被顶掉 |
五、S5-4 回归:迁移前后同输入 ⇒ 同行为
做法:scenario(host) 刻意不用定时器(消除时序噪声)⇒ 同一组输入跑两遍 —— Run A = 上游原物 SDK 直连平台面(迁移前口径:只认实例令牌,无令牌闸);Run B = 包内新 SDK → 包面(真实 apply(ctx)) → 平台面(迁移后口径:令牌闸 + 启用集)。比对平台侧效果流水(确定性文本,⛔ 无时间 / 无随机数)与顶层语义。
| 判据 | 结果 |
|---|---|
| 两边都真的产生了平台侧效果 | ✅ A=13 · B=13 |
| 🔴 效果流水逐行完全相同 | ✅ 逐行逐字符相同(原文见 tmp/p43/读数-20260926.txt ⑤ 段) |
| 顶层语义 14 项逐项一致 | ✅ 全过(ready / rooms.count / messages.count·cursor / frame / say(r1).seq=3 / say(r2)=outbound-denied / 增量拉取 1 与 0 / unregister / subscribe) |
🔴 两边同起点是这条判据成立的前提 —— platformPre/platformPost.resetSeed() 必须各调一次;首版只在一侧复位 ⇒ 前序测试动过的房间被当成"迁移前后的行为差异"(实测症状 A=2 B=3),已修(见 §七-④)。
六、🔴 真实 vs 建模(逐条标注 · ⛔ 不许把建模当"已通")
| 项 | 真实行为(读码 / 只读取证) | 本棒做法 | 是否 = 真实 |
|---|---|---|---|
| 包内那一半(凭据解析/出示/本机 fail-closed 拦截/单例闸) | 新增代码,就在包里跑(真实 apply + 真实 HTTP 分派) | 真实代码 | ✅ 真实 |
| 平台侧那一半(令牌签发 + 桥端点校验 + 启用集校验) | 真实 im.ts 里目前没有这道闸(残留① 的存在就是因为它没有) |
沙箱 platform-mock 里建模(requirePluginToken 开关:false=迁移前口径/true=迁移后口径) |
⚠️ 建模 |
| 桥端点 8 条路径 / 身份头 / 长轮询硬上限 | 真端点存在且在跑(im.ts 逐条实读) |
逐字照抄 + 沙箱本机 http | ⚠️ 口径真实、传输建模 |
pluginIdOf 形态映射 |
上游 SDK 里逐字实现 | 逐字照抄 | ✅ 真实(照抄) |
| 会话面鉴权 | 真平台走 imAuth(实例令牌 → 用户身份) |
沙箱同口径建模(u1) |
⚠️ 建模 |
| 长轮询"只许一条"的强制点 | 上游只有自述(im.ts:482 原文),平台侧无强制点 |
强制点落在包内闸(这正是残留② 的补法) | ✅ 真实(补法落在包内) |
bridge/pull 工作项投递 |
真平台按实例排队 | 沙箱同口径(pushWork) |
⚠️ 建模 |
| 部署形态(包怎么进实例 / 谁签发令牌) | 属投放线,本棒 ⛔ 不碰 | ⛔ 不做 | ⛔ 未覆盖 |
🔴 结论纪律:本棒能断言的只有 —— 「照 IM 接入方式重写的那套插件侧逻辑,在既有桥口径下四条面齐全、两条残留的补法在包内成立、迁移前后行为逐行一致」。⛔ 不能据此断言「真机投放已通」「平台侧令牌闸已上线」「跨机 IM 已通」—— 平台侧那一半与部署形态都还在沙箱外。
七、§4 避坑总表对表(本棒相关类目逐条)
| # | 现象 | 本棒怎么处理 | 证据 |
|---|---|---|---|
| B1 | 桌面端 pending (waiting for service: webServer) |
只用 connection 一处扩展点;⛔ 零 webServer 引用 |
门禁 ①c「零 webServer 引用」✅ |
| B2 | API 注册了却 404(桌面端只认精确路径) | 三条 M5 路由逐条列精确路径,并运行时真挂一次断言 | 门禁 ①e「四条路径互不重复 / 每条有 methods」+ 回调面 4 条原文 |
| B3 | 「放进一个包」落了地一半 | 包内零直连平台库路径(正则红线扫描)+ 平台侧动作全部走桥 | 门禁 ①e「零直连平台库路径」✅ |
| A2 | HTTP 路由静默 404(inject 未声明) |
inject = ['connection'] 逐字声明 |
门禁 ①c「逐字声明」✅ + 取数面 |
| A6 | 一个可选插件把壳搞得起不来 | 全部装配失败路径降级 warn,⛔ 不抛 | 包内 8 条 warn 原文已打印(全部是预期拒绝,无一条装配失败) |
| A5/E2 | 改了却没生效(UI 侧) | ⚠️ 本棒未取到浏览器级,故不断言 UI 生效 | §八「未取到」 |
| E1 | 测试跑的是构建产物 | 门禁 --check-only rc=0;尺子直接打 lib/ 实件 |
尺读数 ⓪ ✅ |
| E6/R3 | exports.default 禁用 |
门禁 ①b「零 exports.default」✅(S2 基线未破) |
尺读数 ⓪ ✅ |
| D3 | 列限声明改了却没生效 | 门禁 ①d 回读 DECL_DIGEST=88c752a3e12597b3…(8 表 / 29 列,S3 基线未破) |
尺读数 ⓪ ✅ |
八、尺子回归(⛔ 未改尺子一行)/ 未取到(如实)
| 面 | 靶子 / 入口 | rc | 说明 |
|---|---|---|---|
| 包内门禁 | node scripts/build.mjs --check-only(包目录) |
0 | 「✅ 门禁通过」;新增 ①e M5 门禁 全绿,M5_DIGEST=55cdc0d5fc8eb99d |
| 装配 · 端A 客户端形态 | --profile-json tmp/p43/端A-客户端形态/package.json |
0 | 解析到 dsh-plugin-ai1net/lib/index.js 20798 B |
| 装配 · 端B 平台形态 | --profile-json tmp/p43/端B-平台形态/package.json |
0 | 同上(两端同一份产物) |
| 取数 | --target @dsh-local/ai1net --entry lib/index.js |
0 | 真实 import;inject=['connection'] ✅ |
| 回调 | 同上 | 0 | 真实 apply(ctx),routeCalls 逐字 4 条 |
v-neg.sh all |
尺子自身(⛔ 靶子非本包) | 0 | 装配/运行/取数/回调 四条红/绿双态均成立 ⇒ 尺子仍能判红 |
v-baseline.sh |
47 共享层 + 本机 desktop profile | 0 | 平台侧 4 包;客户端侧 4 bundle |
v-ui.sh |
浏览器级 | 3 | ⚠️ 未取到(见下) |
⇒ S2 / S3 / S4 的绿没被本棒弄红。
⚠️ 未取到 / 不成立(如实)
| 项 | 结果 | 原因 |
|---|---|---|
v-ui.sh(浏览器级) |
⚠️ rc=3 未取到 | CDP 127.0.0.1:9330 不可达 ⇒ 本机无运行中的真壳;启动器属客户端线,本线 ⛔ 不代启 |
v-plugin.sh 运行 面 |
⚠️ rc=0 但属"空真" | 可用启动日志 mtime = 2026-09-25T09:19:43,早于本包存在 ⇒ "无失败条目"不构成证据 |
M5 UI 卡片(plugins.item 槽 + 三条路由的界面) |
⚠️ 未取到 | 同上,需真壳(同 S2/S3/S4 同一笔欠账) |
| 真机投放 / 平台侧令牌闸上线 | ⛔ 刻意不做 | 属硬边界「⛔ 不代用户起实例 / 不碰把包装进去」+ 属投放线 |
实机 requirePluginToken 开关 |
⚠️ 仅沙箱 | 真实 im.ts 尚无该闸(= 残留① 的本体) |
⇒ ⛔ 以上五项都不折算成"已知 / 通过"。
九、两处自纠 + 六条本棒踩出来的坑(如实)
| # | 症状 | 根因 | 处置 |
|---|---|---|---|
| ① | 平台回 bad-manifest / bad-args |
callPlatform(path,{body: obj}) 直接给 fetch ⇒ body 变 "[object Object]" |
在 SDK platform() 内统一 JSON.stringify |
| ② | 读接口被误判 502 | /api/im/rooms 回 {rooms,count,mode,meId} 无 ok 字段 ⇒ reply() 把"缺 ok"当失败 |
改成 failed = out==null || out.ok===false(缺 ok 不算失败) |
| ③ | 负控假绿(拿会话面测令牌闸) | 会话面走 imAuth,没有桥身份闸 ⇒ 打它测不出东西 |
改打出向 POST /api/ai1net/im/messages(桥端点,有闸) |
| ④ | 回归比对"迁前迁后不一致"(A=2 B=3) |
只有一侧复位 ⇒ 前序测试动过的房间被当成行为差异 | 加 resetSeed(),两侧各调一次(同起点) |
| ⑤ | 「包替任何插件签身份」漏洞 | host 侧从本机 env 回退取令牌 ⇒ 调用方不带凭据也能被"代表" | 引入三态凭据语义(undefined / '' / 值)⇒ '' 必拒 |
| ⑥ | 门禁 ①e「→ 应为 4 处…得到 2」 | 注册写在 for 循环里 ⇒ 文本计数 ≠ 注册数 |
门禁从"数文本"升级为「运行时真挂一次再断言」(spy ctx 捕获 seen[]) |
⚠️ 首版另有一处无效负控:pluginId 口径混用(pluginIdOf 下划线形态 vs 头里的包名)⇒ 闸键与身份头对不上,此时负控会"通过"但原因是错的。已统一为包名(callerOf / 闸键 / 令牌载荷)。
十、硬边界自查 / 遗留 / 回滚
- ⛔ 未改官方 dsh 主程序与缓存(R2):只读
D:/github/dsh_shenxian/src/web/routes/im.ts、sdk/im-plugin-host/index.mjs、src/im/sdk/host.ts(照抄来源),零写。 - ⛔ 未重造分发与更新机制:M5 只做「插件侧路由 + SDK」,分发仍走 S4 的拉取器。
- ⛔ 未代用户起实例;⛔ 未把包装进任何 profile(装配用的是
tmp/p43/下的沙箱 profile)。 - ⛔ 未改客户端工作区
dsh-ai1net-desktop/dsh-desktop-0.1.7rc2任何文件(只读引用;端A是 profile 副本)。 - ⛔ 包内零直连平台库路径(正则红线扫描 ✅);⛔ 未 commit / 未 push。
- ⚠️ 红线门禁:本棒未触发任何一项 —— 未重启控制面、未中断在线用户、改动文件 6 个(< 10)、无不可逆操作(沙箱可整体
mv归档)。 - 🔴 停点:S4 的 P1 已解除;S9 的 P2 仍待拍板,⛔ 本棒未跨过。本棒未发现新停点。
- 本棒改动的包内文件(6 个):
lib/im-plugin-sdk.mjs(新建 47,964 B)|lib/index.js(20,798 B)|lib/client.js(17,210 B)|scripts/build.mjs(23,007 B,新增 ①e 门禁 + ①c 改为"零裸标识符 import"+ 语法门禁扩到.mjs)|package.json(exports["./im-plugin-sdk"])|README.md。 - 遗留(如实):① 浏览器级 / M5 卡片仍欠一笔真壳取证(客户端线窗口)② 平台侧令牌闸与"启用集"校验尚未进真实
im.ts(投放线)③ M5 界面只到"能渲染一个只读面板",交互面(发消息 / 开关回调)留后续块 ④ 沙箱platform-mock的requirePluginToken只是开关,不是真平台实现。 - 回滚:本棒无生产副作用 ⇒ 回滚 =
mv tmp/p43/ 归档/(⛔ 不用rm);包内改动可用git逆转(若包目录未入 git ⇒ 以lib/im-plugin-sdk.mjs删除 + 其余 5 文件回退到 S4 版本为准)。
十一、出处
- 规格书:
D:/github/dsh_shenxian/dsh-server-docs/05-交接单/交接单_包规格书_20260926.md(§〇 路径约定 / §五 A 冻结规格 / §五 C 指针) - 方案:
交付物/功能打包到基础插件-分步实施方案-20260926.md §3.10.7(S5-1…S5-4)+§4 避坑总表(A/B/D/E 类) - 残留两条原文:
交付物/全功能承载判定-底层插件能否承-20260926.md §2.6 - 上游权威(只读):
D:/github/dsh_shenxian/src/web/routes/im.ts|D:/github/dsh_shenxian/sdk/im-plugin-host/index.mjs|src/im/sdk/host.ts:112 - 前置回执:
交付物/分发与更新-S4落地与三轮回执-20260926.md(§八 真实 vs 建模已逐条继承) - 本棒产物:包内
E:/ProgramData/AIProject/dsh-plugin-ai1net/{lib/im-plugin-sdk.mjs,lib/index.js,lib/client.js,scripts/build.mjs,package.json,README.md} - 本棒读数(⛔ 不入库):
tmp/p43/platform-mock.mjs|tmp/p43/run-s5.mjs|tmp/p43/run-scales.sh|tmp/p43/读数-20260926.txt|tmp/p43/尺读数-S5-20260926.txt|tmp/p43/端A-客户端形态/|tmp/p43/端B-平台形态/