# 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..` ⇒ 🔴 **把 `(实例, 包名)` 签死** ⇒ 借别人的令牌改个身份头**不可能**(机验:`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-平台形态/`