dsh-sidecard-ask 2.1.6 → 2.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.8
4
+
5
+ **主题:交付《非 Node 宿主契约》文档,无代码改动。**
6
+
7
+ 本轮没有功能与缺陷修复,发布物只多一份文档(`NODE-HOST-CONTRACT.md`,392 行),
8
+ 内容是从本插件 2.1.7 的真实调用点反推出来的宿主契约面:需要宿主实现什么、
9
+ 语义边界在哪、少了会怎样、如何验收。写它的直接起因是「用 Go 写一个 DSH 宿主」
10
+ 这个需求——结论是纯 Go 宿主无法复用本插件代码,必须先把契约对齐再谈实现。
11
+
12
+ ### 新增
13
+
14
+ - **`NODE-HOST-CONTRACT.md`**(已加入 `files`,随包发布)。分 14 节:
15
+ - 硬依赖与软依赖分层表(L1–L11):只有「运行时 + `(req,res)` 语义」与
16
+ 「前端模块加载器 + 具名槽位」是硬的,其余全部可降级。
17
+ - 插件真正触碰的 `req`/`res` 成员**全量 13 个**、`ctx.*` **全量 7 个**——
18
+ 照这张表实现即可,不必复刻 Node 的 HTTP 类。
19
+ - 8 个 API 方法的动词/鉴权/语义表;`ping` 是唯一免鉴权的动词。
20
+ - `connection.requestRejection` 的 6 种返回值语义(含 fail-closed 与
21
+ 「服务缺失即放行」这条最容易做空的路径)。
22
+ - SSE 帧格式、15 s 心跳、`res.write` 必须即时刷出的理由。
23
+ - 两代子代理 API 形状、`delivery:'caller'` 的产品语义、工具白名单的交集规则。
24
+ - 路径与原子写(`$DSH_HOME` 空白算未设置、profile slug 正则、0700/0600/rename)。
25
+ - 前端契约:`__ModuleLoader__`、`/plugins` 与 rev、槽位回退链、右栏 DOM 标记、
26
+ localStorage 键与预算。
27
+ - **V1–V8 验收清单**(可直接 curl)与 **10 条反模式**。
28
+
29
+ ### 说明
30
+
31
+ - 代码、API、行为与 2.1.7 完全一致;本版仅为把文档纳入发布物并让仓库与 npm 对齐。
32
+ - 文档里凡未经实测的推理都显式标注为 `[推断]`,不当作契约引用。
33
+
34
+ ## 2.1.7
35
+
36
+ **主题:修复「技术方案评审团」在 0.2.1-rc.5(桌面 Electron 44.7.0 / 网页端同源)上复核出的 8 条缺陷。**
37
+
38
+ ### 修复
39
+
40
+ - **A1(中)页面重载后右侧栏留下关不掉的「幽灵面板」**。宿主会持久化栏布局,重载
41
+ 后把插件的 tab 还原出来;而「哪张卡拥有它」(`surfaces.native.cardId`)是进程内存,
42
+ 卡片本身也不持久化。旧归属门是「`cardId !== card.id` 就拒绝」,于是没有卡片认领、
43
+ 也永远关不掉,面板长期渲染「该卡片已关闭」。现在归属门改为**已知别的卡拥有才拒绝,
44
+ 无主时按 kind + 会话定位**;并在注册完成时、以及 900 ms 后各清扫一次无主的同 kind
45
+ tab(绝不碰其它插件的 kinds)。
46
+ - **A2(中)删除历史条目时孤儿 tab 不关**。`deleteRailTab` 与 `surfaces.better.close`
47
+ 在 `cardId === null` 时直接 return,删除后栏里留一块空面板。改为无主放行后,删除
48
+ 路径能真正清掉那张 tab。
49
+ - **A4(低)承载面标签标错宿主**。`CardHost` 过去读全局 `state.surface`;原生栏与
50
+ better 镜像同时存在时,better 栏里的卡会被标成「原生右栏」。两个宿主槽现在都显式
51
+ 传自己的 `surface`,标签优先采用它。
52
+ - **A8(低)设置页文案仍写死 `0.2.1-rc.3`**。只读开关与 preset 提示改为「0.2.x 宿主
53
+ 均不支持(`0.2.1-rc.3` 实测、`rc.5` 复核)」。
54
+ - **CANCEL(中)`/cancel` 接受不带 sessionId 的调用**。run 有归属会话时,旧逻辑只在
55
+ 「两边都非空且不等」才拒绝,于是不带 sessionId 的调用能中止任意会话的子代理。现在
56
+ 只要 run 知道归属,就要求调用方给出**相等**的 sessionId,否则拒绝;老客户端一直带
57
+ sessionId,不受影响。
58
+ - **AUTH-EDGE(低)`requestRejection` 的意外返回值被当成放行**。旧实现把任何非
59
+ 401/403 的值都当通过。现在只有 `undefined / null / false / 200` 算放行,其余按未
60
+ 认证(403)处理并留一条 warn;`connection` 服务缺失时的日志由 info 改为 warn
61
+ (网页端就是这个形态,等于「没有宿主级认证」,不该静音)。
62
+ - **MULTITAB(低)跨页历史被「最后写者」复活**。桌面窗口与浏览器各持一份 store、每次
63
+ 写整个数组:A 删掉一条后,B 的下一次同步会把它写回来。现在监听 `storage` 事件,
64
+ 别的页面写盘后立即采用磁盘上的权威集合。
65
+ - **承载面未证实的空面板(低)**。卡被判定「承载面未证实」迁回浮层时,栏里那张 tab
66
+ 没有关闭,留下「已移到浮层」的空面板。迁移时同时请求两个宿主关掉自己的 tab。
67
+
68
+ ### 评估后不改
69
+
70
+ - **run 没有归属会话时 `/cancel` 仍允许取消**。这类 run(宿主没给 sessionId、脚本
71
+ 直调)若一并拒绝,就只能等 120 s 超时才结束;归属会话已知时已经收紧,故保留。
72
+
73
+ ### 验证
74
+
75
+ `verify 57 / contract 1487 / float-placement 100 / smoke 283 = 1927 全绿`。本轮净增
76
+ 16 条断言:无主 tab 在注册时即被清扫且不碰其它 kind、删除历史条目时无主 tab 仍被
77
+ 关闭、承载面标签跟随宿主而非全局偏好、`/cancel` 拒绝错会话与缺会话。
78
+
3
79
  ## 2.1.6
4
80
 
5
81
  **主题:修复桌面端 0.2.1-rc.3 / 网页端 0.2.1-rc.4 与 0.2.2-alpha.2 上的 4 个承载面与会话归属缺陷。**
@@ -0,0 +1,392 @@
1
+ # NODE-HOST-CONTRACT — 非 Node 宿主必须实现的契约面
2
+
3
+ > **这份文档是什么**:`dsh-sidecard-ask` 2.1.7 在真实运行中**实际依赖**的宿主能力清单,按层拆开,逐条给出调用点、语义、缺失后果与验收方法。
4
+ >
5
+ > **不是什么**:不是 DSH 官方规范,也不是"全部插件"的契约。它由**本插件源码的真实调用点反推**得出,凡是"宿主侧参照实现"的内容都标注了出处(rc.5 解包源码),凡是**推断**而非实测的都显式标注。想用 Go 之类非 Node 运行时写一个宿主,这份就是最小的对表依据。
6
+ >
7
+ > 标注约定:`[实测]` = 本机 rc.5 上跑过/抓过;`[源码]` = 读插件源码得到;`[对照]` = 读宿主 rc.5 源码得到;`[推断]` = 未实测的推理,不可当契约用。
8
+
9
+ ---
10
+
11
+ ## §0 结论先行
12
+
13
+ 1. **这个插件不是"可选 Node"**:它 import 了 `node:fs` / `node:os` / `node:path` / `node:net` / `node:tls`,用了全局 `Buffer` 与 `process.env`,并且把 Node 的 `(req, res)` 双流对象**直接当成契约**(`req.on('data'|'end'|'error')`、`res.write` 分块刷出 SSE)。**换宿主 = 换运行时语义,不是换 HTTP 框架。**
14
+ 2. **硬依赖只有两条链路**:①`webServer` 注册路由 + 这套 req/res 语义;②前端 `window.__ModuleLoader__` + React + 具名槽位。其余(`subagents` / `tools` / `connection` / `sidebarRightTabs` / `sidebarRight` / `betterSidebar`)**全是软依赖**:拿不到就降级,插件照跑。
15
+ 3. **最容易被忽略的一条**:`Buffer` 与 `Buffer.byteLength` 被用于响应体长度与请求体拼接(`index.js:1478`、`index.js:1409`)。任何宿主必须提供 UTF-8 字节语义的等价物,否则 `content-length` 一算错就会挂住连接。
16
+
17
+ ---
18
+
19
+ ## §1 契约分层总表
20
+
21
+ | 层 | 名称 | 插件是否要 | 缺失后果 |
22
+ |---|---|---|---|
23
+ | L1 | JS 运行时 + Node 内建模块 | **硬** | 模块加载即失败,整个插件不启动 |
24
+ | L2 | HTTP 服务与 `(req,res)` 语义 | **硬** | 8 个 API 全部 404/挂死,后端功能全灭 |
25
+ | L3 | 插件上下文(生命周期/服务定位) | **硬** | 无 `inject` 就无法注册路由 |
26
+ | L4 | 鉴权仲裁 `connection.requestRejection` | 软 | 降级为"仅 loopback/Origin 围栏"(会 warn) |
27
+ | L5 | 子代理 `subagents` | 软 | 生成类功能不可用,`/state` 报 `NO_START_CAPABILITIES` |
28
+ | L6 | 工具注册表 `tools` | 软 | 只读工具白名单失效,子代理继承全量工具 |
29
+ | L7 | 文件系统(配置持久化) | 软 | 配置不落盘,每次重启回默认 |
30
+ | L8 | 出站 `net`/`tls`(SMTP 反馈) | 软 | 反馈只落本地日志 |
31
+ | L9 | 前端模块加载器 `/plugins` + `__ModuleLoader__` | 软(无则纯 API) | 浏览器里没有任何界面 |
32
+ | L10 | 具名槽位 `slots` | 软 | 界面无处挂载(前端空跑) |
33
+ | L11 | 右侧栏/第三方栏服务 | 软 | 右栏承载面不可用,退回浮层 |
34
+
35
+ **一句话取舍**:想先跑通"能生成"的最小宿主,只做 **L1+L2+L3+L5**;想跑通"有界面",还得加 **L9+L10**。
36
+
37
+ ---
38
+
39
+ ## §2 R1 · 模块与会话身份
40
+
41
+ `[源码]`
42
+
43
+ - 插件是 **ESM**(`package.json` `"type": "module"`),入口 `index.js` 导出四个具名符号:`name`、`inject`、`PLUGIN_VERSION`,以及默认的宿主注入函数(`export function inject` 同名常量与运行时函数在 DSH 里由 loader 解析)。
44
+ - 硬依赖声明:**只有** `webServer`
45
+
46
+ ```js
47
+ export const inject = ['webServer'] // index.js:45
48
+ ```
49
+
50
+ 宿主必须能表达"我需要 webServer 服务"并在其可用后再调用插件入口;其余服务通过 `ctx.get()` 探测。
51
+ - 版本门在 `package.json`:`engines: { node: ">=20", dsh: ">=0.1.2-rc.1 <0.3.0" }`。宿主侧语义 `[对照]`:`semver.satisfies(runtimeVersion, range, { includePrerelease: true })` —— **预发布版本必须参与比较**(rc.3/rc.5 都是预发布)。自己实现门禁时若丢掉 `includePrerelease`,rc 版会被判为不满足。
52
+ - `PLUGIN_VERSION` 必须能被前端读到:客户端校验它等于自身 `CLIENT_VERSION`(`test/verify.mjs` 也在仓库侧校验三处一致)。
53
+
54
+ **替代要求**:Go 宿主若用嵌入式 JS 引擎,至少要提供 ESM `import` + 具名导出语义;CommonJS 转换会打断 `node:` 前缀解析与顶层 `export const`。
55
+
56
+ ---
57
+
58
+ ## §3 R2 · 插件上下文子集(这是全部)
59
+
60
+ `[实测]` 对插件源码里 `ctx.*` 的成员访问做全量统计,**只有 7 个成员**:
61
+
62
+ | 成员 | 次数 | 语义要求 |
63
+ |---|---|---|
64
+ | `ctx.logger` | 20 | 至少 `info` / `warn` 两个方法,可带多参 |
65
+ | `ctx.get(name)` | 11 | 按名取服务,**未注册返回 `undefined`**(不能抛) |
66
+ | `ctx.effect(fn)` | 4 | 调用 `fn`,其返回值为 disposer,插件卸载/重载时回调;`fn` 抛错按单个效果失败处理 |
67
+ | `ctx.on(event, handler)` | 2 | 事件订阅,返回 disposer |
68
+ | `ctx.webServer` | 2 | 见 §4 |
69
+ | `ctx.subagents` | 2 | 见 §8 |
70
+ | `ctx.tools` | 1 | 见 §9 |
71
+
72
+ 前端另有 `ctx.inject` / `ctx.slots`(见 §12):
73
+
74
+ | 成员 | 语义要求 |
75
+ |---|---|
76
+ | `ctx.inject(['a','b'], cb)` | 所列服务**全部**就绪后调用 `cb(injectedCtx)`;返回 disposer |
77
+ | `ctx.inject('name', cb)` | 单服务版本 |
78
+ | `ctx.slots.inject(name, cb)` | 槽位存在后注册,返回 disposer |
79
+ | `ctx.slots.register(options, component)` | 向槽位注册,返回 disposer |
80
+
81
+ `[推断]` **disposer 必须幂等且不能抛**:插件在卸载路径上会连续调用多个 disposer,其中一个抛错会掩盖后续释放(代码里到处是 `try { dispose() } catch {}`,说明开发者认为它可能抛)。
82
+
83
+ ---
84
+
85
+ ## §4 R3 · HTTP 路由与 `(req,res)` 语义
86
+
87
+ ### 4.1 注册形状
88
+
89
+ `[源码]` `index.js:2873` 起(prefix 分支在 `index.js:2876`):
90
+
91
+ ```js
92
+ ctx.webServer.register({ kind: 'prefix', path: '/sidecard-ask/api', handler })
93
+ // 若宿主拒绝 prefix,则退化为逐方法精确路由:
94
+ ctx.webServer.register({ kind: 'exact', path: '/sidecard-ask/api/ping', handler })
95
+ ```
96
+
97
+ - `kind: 'prefix'` 是首选;`register` **抛错**即触发退化路径,所以宿主若不支持 prefix,必须**同步抛错**,不能静默注册成功。
98
+ - `register` 必须返回 disposer(插件用 `ctx.effect` 包裹)。
99
+ - handler 签名 `(req, res)`,由宿主在**每个请求**上调用;同一 handler 要能并发处理多个请求(插件自身用 `await` 读体,不阻塞其它连接)。
100
+
101
+ ### 4.2 插件真正触碰的 req/res 成员(全量)
102
+
103
+ `[实测]` 静态统计 + 人工核对,**就是这些,多一个都不需要**:
104
+
105
+ | 对象 | 成员 | 必须满足的语义 |
106
+ |---|---|---|
107
+ | `req` | `.method` | 大写 HTTP 动词字符串 |
108
+ | `req` | `.url` | 至少 `pathname[+query]`;插件用 `new URL(url, 'http://dsh.internal').pathname` 解析 |
109
+ | `req` | `.headers` | 小写键的普通对象;取值可能是 `string` 或 `string[]`(代码按 `string` 判型) |
110
+ | `req` | `.on(event, cb)` | 必须支持 `'data'`(chunk 须有 `.length` 与可 `Buffer.concat` 的字节序)、`'end'`、`'error'` |
111
+ | `req` | `.removeListener` | 可为 `undefined` 的判断已被代码兼容(`typeof === 'function'` 才调) |
112
+ | `req` | `.destroy()` | 用于超限时中止读体 |
113
+ | `res` | `.writeHead(status, headers)` | 设置状态行与响应头 |
114
+ | `res` | `.write(string)` | **必须能被多次调用且每次立即刷给客户端**(SSE 靠它) |
115
+ | `res` | `.end([body])` | 结束响应;可无参数 |
116
+ | `res` | `.headersSent` | 布尔 |
117
+ | `res` | `.writableEnded` | 布尔 |
118
+ | `res` | `.destroyed` | 布尔 |
119
+ | `res` | `.on('close', cb)` | 只需 `'close``(`index.js:2628` 用它判断浏览器是否离开) |
120
+
121
+ **关键点**:`res.write` 的**即时刷出**是 SSE 的全部基础。任何把响应体缓存在内存里最后一次性写出的宿主,会让流式卡片"整段答案在结束时一次性出现",还可能导致心跳失效、被中间层判死。
122
+
123
+ ### 4.3 请求体读取
124
+
125
+ `[源码]` `index.js:1447` `readJsonBody`:
126
+
127
+ - 流式累积 chunk,累计超过 **`1 << 20` 字节(1 MiB)** 立即 `req.destroy()` 并抛 `{code:'too-large'}` → 客户端收 400。
128
+ - **空体返回 `null`**(不是 `{}`,也不是错误)。
129
+ - 非 JSON 抛 `{code:'bad-request'}`。
130
+ - 不依赖 `content-type`,不依赖 `content-length`。
131
+
132
+ ### 4.4 响应与错误形状
133
+
134
+ `[源码]` `writeJson`(`index.js:1413`)设置的三个头是硬要求:
135
+
136
+ ```
137
+ content-type: application/json; charset=utf-8
138
+ cache-control: no-store
139
+ content-length: Buffer.byteLength(body) // UTF-8 字节数,不是字符串长度
140
+ ```
141
+
142
+ 成功:`{"ok":true,"value":<任意>}`;失败:`{"ok":false,"error":{"code":"<token>","message":"<中文可读>"}}`(`writeError`,`index.js:1436`)。
143
+
144
+ `[实测]` 线上真实响应(未登录访问):
145
+
146
+ ```json
147
+ {"ok":false,"error":{"code":"unauthorized","message":"需要先通过 DSH 登录(宿主会话未认证)"}}
148
+ ```
149
+
150
+ ---
151
+
152
+ ## §5 R4 · API 方法表与鉴权闸门
153
+
154
+ ### 5.1 方法表
155
+
156
+ `[源码]` `index.js:2728`:
157
+
158
+ ```js
159
+ const API_METHODS = ['ping', 'state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
160
+ ```
161
+
162
+ | 方法 | 动词 | 过鉴权闸门 | 说明 |
163
+ |---|---|---|---|
164
+ | `ping` | GET / HEAD | **否**(唯一豁免) | 只回 `{version, at}`,用于探活 |
165
+ | `state` | GET / POST | 是 | 返回能力、SMTP 解析、provenance 等全量诊断面 |
166
+ | `ask` | POST | 是 | 起一次子代理并开 SSE |
167
+ | `cancel` | POST | 是 | 取消某次运行(带归属校验) |
168
+ | `config` | POST | 是 | 读写配置 |
169
+ | `reset` | POST | 是 | 重置 |
170
+ | `diagnose` | POST | 是 | 客户端诊断上报 |
171
+ | `feedback` | POST | 是 | SMTP 反馈 |
172
+
173
+ 未在表内的方法名一律 404(**动词错误不改变 404**:方法是先于动词判定的)。
174
+
175
+ ### 5.2 两道闸门,顺序不可换
176
+
177
+ `[源码]` `routeHandler`(`index.js:2786`)顺序是:**信任围栏 → 方法名校验 → ping → 鉴权 → 业务**。
178
+
179
+ **闸门 1 · 信任围栏** `isTrustedRequest(req, trustedHosts)`(`index.js:1374`):
180
+
181
+ 1. 必须有 `host` 头,且能构成合法 authority,否则拒。
182
+ 2. `trustedHosts` 里带端口 → 比 `host:port` 全等;不带端口 → 只比 hostname。
183
+ 3. hostname 既非 loopback 又不在 `trustedHosts` → 拒。
184
+ - loopback 判定(`isLoopbackHostname`,`index.js:1360`):`localhost`、`[::1]`、`127.x.y.z`(四段、每段 1–3 位数字且 ≤255)。**不含 `::ffff:127.0.0.1`、不含 `0.0.0.0`**。
185
+ 4. `sec-fetch-site: cross-site` → 拒。
186
+ 5. 有 `origin` → 必须与 `host` 的**完整 authority** 相同(端口不同即不同源);无 `origin` → 放行。
187
+
188
+ **闸门 2 · 会话认证** `authRejection(req)`(`index.js:2754`)—— 返回值语义是这份契约里最需要精确复制的一段:
189
+
190
+ | `connection.requestRejection(req)` 返回 | 插件行为 |
191
+ |---|---|
192
+ | `401` | 响应 401 `unauthorized` |
193
+ | `403` | 响应 403 `forbidden` |
194
+ | `undefined` / `null` / `false` / `200` | **放行** |
195
+ | 其它任意值(对象/字符串/…) | 按拒处理 → 403,并 warn |
196
+ | 抛异常 | **fail-closed** → 401 |
197
+ | 服务不存在(`ctx.get('connection')` 为 `undefined`) | **放行**,只记一条 warn(每个进程一次) |
198
+
199
+ `[设计]` 最后一行是"WEB 端正常形态":宿主没把 `connection` 暴露给插件时,loopback+Origin 围栏就是全部模型。**做 Go 宿主时若实现了会话却忘了暴露该服务,等于无认证**——这是本契约最容易踩空的一条。
200
+
201
+ ---
202
+
203
+ ## §6 R5 · SSE 契约
204
+
205
+ `[源码]` `openSse`(`index.js:1503`):
206
+
207
+ 响应头:
208
+
209
+ ```
210
+ content-type: text/event-stream; charset=utf-8
211
+ cache-control: no-cache, no-transform
212
+ connection: keep-alive
213
+ x-accel-buffering: no
214
+ ```
215
+
216
+ 帧格式(严格):
217
+
218
+ ```
219
+ event: <name>\n
220
+ data: <JSON>\n
221
+ \n
222
+ ```
223
+
224
+ 心跳:每 **15 000 ms** 写一次注释帧 `: keep-alive\n\n`,定时器置 `unref()`(不阻止进程退出)。
225
+ 结束语义:`send()` 在 `closed` 后静默丢弃;`end()` **幂等**且必须清掉心跳定时器;`closed` 为真当且仅当内部已关或 `res.writableEnded === true`。
226
+
227
+ **宿主侧要求**:`res.write` 必须是"写进 socket 并 flush",且客户端的半关闭要能被 `res.on('close')` 感知(否则心跳会对着已死连接写一整晚)。
228
+
229
+ ---
230
+
231
+ ## §7 R6 · 子代理(两代 API 并存)
232
+
233
+ `[源码]` `index.js:1616` `subagentsService()` / `usesActivationApi()` / `startSubagent()`:
234
+
235
+ - 服务被认为"可启动"的条件:`typeof service.start === 'function' || typeof service.startActivation === 'function'`。
236
+ - **旧面(≤0.2.1-alpha.1)**:`service.start(provider, request)` → 返回 `{ id, localAgent?, result: Promise, dispose }`。
237
+ - **新面(0.2.1-alpha.2+)**:`service.startActivation(spec)`,`spec = { provider, label, delivery: 'caller', signal, request }`,返回 `{ childId, result, dispose }`。
238
+ - `request` 是同一个 `SubagentStartRequest` 形状,插件会带 `prompt`、`parent`,并按能力追加 `persona` / `toolFilter` / **`agentPreset`**。
239
+ - `delivery: 'caller'` 的语义是"子代理不进父会话的可见对话"——**这是产品语义,不是优化项**;宿主把它实现成默认归档进父会话,卡片就会污染主对话。
240
+ - 还需要 `service.list()` 返回 provider 名数组(拿不到就视为空)。
241
+ - 能力探测:`service.capabilities?.(provider)` 用于判断 `persona` / `toolFilter` 是否被支持;不支持时插件**如实上报** `toolFilter:'unsupported'` 而不是谎报 `'preset'`(`index.js:2004`)。
242
+
243
+ **事件契约**:
244
+
245
+ ```js
246
+ ctx.on('agent/assistant-stream', payload => …) // index.js:1587
247
+ ctx.on('session/event', (session, event) => …) // index.js:1597(event.type === 'assistant/chunk')
248
+ ```
249
+
250
+ `[推断]` 插件用这两种事件把子代理的输出帧桥接到 SSE;宿主若不发这两类事件,`ask` 只会返回一个空流(不会 500)。做 Go 宿主时**先实现 `startActivation` + 一条能推 chunk 的事件通道**即可让生成链路活起来。
251
+
252
+ ---
253
+
254
+ ## §8 R7 · 工具白名单
255
+
256
+ `[源码]` `readOnlyToolFilter(ctx)`(`index.js:1337`):
257
+
258
+ - `ctx.get('tools')`;无服务或 `typeof tools.schemas !== 'function'` → 返回 `undefined`(= 不限制,子代理继承全量工具)。
259
+ - `tools.schemas()` 抛错 → `undefined`。
260
+ - 交集为空 → `undefined`(**不能**返回空数组:`tools.restrict({allow: []})` 会让宿主直接拒绝启动)。
261
+ - 返回 `{allow: string[]}`,**每个名字都必须是注册表认识的名字**,否则 `restrict()` 会拒绝整次启动。
262
+
263
+ ---
264
+
265
+ ## §9 R8 · 文件系统与路径
266
+
267
+ `[源码]` `index.js:1296` 起:
268
+
269
+ - `configDir()` = `${$DSH_HOME || ~/.dsh}/sidecard-ask`;**`$DSH_HOME` 为空白算未设置**;绝不使用 `process.cwd()`。
270
+ - `configFilePath()` = 在 `$DSH_PROFILE` 匹配 `/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/` 时用 `config.<profile>.json`,否则退回共享的 `config.json`(profile 非法值**不得**进入路径)。
271
+ - 写入:`mkdirSync(dir,{recursive:true,mode:0700})` → 写 `<path>.tmp`(`mode: 0600`)→ `renameSync` 原子替换 → 失败时删临时文件并返回 `{ok:false,error}`。
272
+ - 读取:文件不存在(`ENOENT`)按"未配置"处理;其它错误记 warn 并按未配置处理;损坏 JSON 同。
273
+
274
+ **宿主要求**:`rename` 必须同分区原子;权限位在 Windows 上是 no-op(插件已在注释里声明这一点,不是 bug)。
275
+
276
+ ---
277
+
278
+ ## §10 R9 · 出站 SMTP
279
+
280
+ `[源码]`:`net.connect`(明文 25 / STARTTLS 587 的裸连接)+ `tls.connect`(隐式 TLS 465,带 `servername` 与 `rejectUnauthorized`)。
281
+
282
+ `[声明]` 插件自带 SMTP 客户端在 **STARTTLS 协商阶段是明文**的(平台限制,已在 README/CHANGELOG 记为已知限制)。做 Go 宿主时可以直接忽略这一层——反馈功能是纯可选。
283
+
284
+ ---
285
+
286
+ ## §11 R10 · 前端契约
287
+
288
+ `[源码]` 客户端是**单文件 UMD-ish 模块**,不是 ESM:
289
+
290
+ ```js
291
+ window.__ModuleLoader__.load({ id: 'dsh-sidecard-ask', factory(require) { const React = require('react'); … } }) // client.js:27
292
+ ```
293
+
294
+ ### 11.1 宿主必须提供的浏览器侧能力
295
+
296
+ | 能力 | 要求 |
297
+ |---|---|
298
+ | `/plugins/<pkg>/client.js?rev=<12 hex>` | 能被 `<script>` 加载;`[对照]` rev = `sha1(mtime,ctime,size)` 取前 12 位十六进制 |
299
+ | `window.__ModuleLoader__` | 提供 `.load({id, factory})`,并在 `factory` 内提供 `require('react')` |
300
+ | React | 需可 `createElement`;插件不依赖具体版本特性 |
301
+ | 具名槽位 | `slots.inject(name)/(register)`,见下表 |
302
+
303
+ ### 11.2 槽位与回退顺序
304
+
305
+ `[源码]` `client.js:7360` 起,逐级回退(第一个存在的即用):
306
+
307
+ | 用途 | 回退链 |
308
+ |---|---|
309
+ | 浮层 | `shell.overlay` → `conversation.input.dock` → `conversation.composer.dock` |
310
+ | 设置页 | `settings.section` → `settings.plugins.tab` |
311
+ | 会话 id 采集 | `conversation.input.right` → `conversation.input.left` → `conversation.composer.dock` → `conversation.input.dock` |
312
+ | 右栏卡片体 | `sidebar.right.pane.tab`(+ `.title`),key = `CARD_KIND` |
313
+
314
+ ### 11.3 右栏 DOM 标记(无公开契约,靠实测)
315
+
316
+ `client.js:449` `RAIL_SELECTORS`:
317
+
318
+ ```
319
+ [data-dsa-rail-host] // 插件自己的节点,最可靠
320
+ [data-sidebar-right-panel][data-sidebar-right-open] [data-dockkit-pane]
321
+ [data-sidebar-right-panel][data-sidebar-right-open]
322
+ [data-sidebar-right-session][data-sidebar-right-open]
323
+ [data-dockkit-pane] / [data-dockkit-column]
324
+ [data-slot^="sidebar.right"]
325
+ ```
326
+
327
+ **注意**:折叠态面板会滑出视口,插件另有一次"矩形是否在屏内"的判定,所以宿主不能只靠 `data-*-open`。
328
+
329
+ ### 11.4 右栏服务形状
330
+
331
+ `[源码]` + `[对照]` rc.5:
332
+
333
+ ```js
334
+ sidebarRightTabs.register({ id, kind, title: () => string, guide: [{ id, order, title, description }] }) → disposer
335
+ sidebarRight.openTab(kind, { params }) // [对照] 记录里 contentId = pageAddress(kind) = `sidebar://<kind>`
336
+ sidebarRight.openTabs.getSnapshot() → [{ id, kind, contentId, title, sessionId? }]
337
+ sidebarRight.closeIn(sessionId, tabId)
338
+ ```
339
+
340
+ `[对照]` 关键事实:**tab 记录只有 `{id, kind, contentId, title}`**,没有 `params`、没有 `meta`;插件的 `cardId` 只能靠适配器里的内存字段 + `contentId` 归属推导。第三方 tab body **拿不到 tab 对象**。
341
+
342
+ ### 11.5 localStorage 键
343
+
344
+ `client.js:5905`:`dsh-sidecard-ask:cards:v3` / `:v2` / `:v1`(v3 起带 `writtenAt`,用于区分"我们写空过"与"从未写过")。预算:单字段 65 536 字符、总 2 000 000 字符、最多 10 张卡。宿主若替换存储后端,必须保留 `storage` 事件的跨窗口通知语义。
345
+
346
+ ---
347
+
348
+ ## §12 验收清单(Go 宿主可直接照跑)
349
+
350
+ 前置:宿主已加载插件,`BASE=http://127.0.0.1:<port>/sidecard-ask/api`。
351
+
352
+ | # | 动作 | 期望 |
353
+ |---|---|---|
354
+ | V1 | `curl -s $BASE/ping` | `{"ok":true,"value":{"version":"2.1.7","at":<ms>}}`,**无 Cookie 也应 200** |
355
+ | V2 | 未登录 `curl -s -X POST $BASE/config -d '{}'` | 401 + `error.code === "unauthorized"`(若你的宿主无会话体系,此条应 200——**这一条决定你是否真的有认证**) |
356
+ | V3 | `curl -s $BASE/nope` | 404 + `error.code === "not-found"`;`POST /ping` → 405 |
357
+ | V4 | 伪造 `Host: evil.com` 请求 | 403 `forbidden` |
358
+ | V5 | 带 `Origin: http://127.0.0.1:<other-port>` | 403(端口不同即不同源) |
359
+ | V6 | `POST $BASE/ask`(`range` 流) | 先收到响应头 `text/event-stream`,**首帧 < 1s 到达**,15s 内有 `: keep-alive`;客户端断开后宿主无残留定时器 |
360
+ | V7 | `POST $BASE/ask` 发 1.5 MiB body | 400 `too-large`,连接不挂死 |
361
+ | V8 | 浏览器打开右栏 | 插件 tab 出现在 guide 列表;打开后 `[data-dsa-rail-host]` 内出现卡片体;关 tab 后同 kind 的 tab 记录消失(`openTabs.getSnapshot()` 里不再有 `contentId === 'sidebar://<kind>'`) |
362
+
363
+ `[建议]` 把仓库里的 `test/harness.mjs`(假宿主)当作契约夹具的**参照实现**:它把本文档 §3–§8 的每一层都实现了一遍,一个 Go 宿主若能通过同样的断言序列,基本就对齐了。
364
+
365
+ ---
366
+
367
+ ## §13 反模式清单(踩了就中招)
368
+
369
+ 1. **把 `res.write` 做成"攒着最后一起发"** → SSE 变成一次性快照,卡片空白到结束。
370
+ 2. **`register` 不支持 `prefix` 却静默成功** → 所有方法名解析不到,全 404。
371
+ 3. **`ctx.get()` 对未注册服务抛错** → 插件在探测阶段直接崩,而它预期的是 `undefined`。
372
+ 4. **暴露了会话体系却没给 `connection` 服务** → 插件按"无认证"放行,等于敞开。
373
+ 5. **`openTab` 的 `contentId` 不按 `sidebar://<kind>`** → 插件无法判断某 tab 是否属于自己,会去关别人的 tab(2.1.6 已修过一次这类 bug)。
374
+ 6. **`tools.restrict({allow: []})` 返回成功但实际不做限制** → 只读承诺失效,用户以为安全实则全量工具。
375
+ 7. **`$DSH_HOME` 为空串时当成有效路径** → 配置写到 cwd,出现"两份配置"。
376
+ 8. **`engines.dsh` 比较漏 `includePrerelease`** → rc 版本被误判不满足,插件根本不加载。
377
+ 9. **`Buffer.byteLength` 换成 `string.length`** → 中文响应体 `content-length` 偏小,连接被截断。
378
+ 10. **`requestRejection` 返回对象表示拒绝** → 插件按契约只认 `401/403` 数字,其它一律 403;若你的"放行"用对象表示,会被当成拒绝。
379
+
380
+ ---
381
+
382
+ ## §14 最小可行宿主(如果真要写 Go 宿主)
383
+
384
+ `[推断]` 以下不是已验证方案,只是这份契约推出来的结论:
385
+
386
+ - **最省力的路**:Go 进程内嵌 `libnode`(CGO)跑本插件的 JS,Go 只负责 HTTP/文件/界面。这样 L1–L8 全部由 Node 承担,你要写的只是 L2 的 `(req,res)` 适配与 L9–L11。
387
+ - **纯 Go 的路**:必须自带 JS 引擎并支持 ESM(goja 不支持 ESM,需要先打包成 CJS 并注入 `node:` 垫片),且要自己实现 `Buffer`、`(req,res)` 双流、SSE flush。工作量≈重写宿主,插件本体 **0 行复用**。
388
+ - **无论哪条路**:先用 §12 的 V1–V8 当门禁,再谈界面。
389
+
390
+ ---
391
+
392
+ *本文档由 `dsh-sidecard-ask` 2.1.7 源码反推,宿主侧参照实现为 DSH 0.2.1-rc.5(桌面端 AppImage 内 `app.asar` 解包的 `@deepseek-ai/dsh-*` 包)。凡标注 `[推断]` 的段落未经实测,不得当作契约引用。*
package/client.js CHANGED
@@ -117,7 +117,7 @@ window.__ModuleLoader__.load({
117
117
  * the Host and the module's `api.version` both read it, so a report can
118
118
  * never claim a generation the browser is not actually running.
119
119
  */
120
- const CLIENT_VERSION = '2.1.6'
120
+ const CLIENT_VERSION = '2.1.8'
121
121
 
122
122
  /**
123
123
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -707,9 +707,9 @@ window.__ModuleLoader__.load({
707
707
  setMinChars: '最小触发字符数',
708
708
  setConcurrency: '并发作答上限',
709
709
  setSideTools: '侧边作答工具权限',
710
- setSideToolsReadonly: '只读(需宿主支持,0.2.1 无效)',
710
+ setSideToolsReadonly: '只读(需宿主支持,0.2.x 无效)',
711
711
  setSideToolsInherit: '继承当前会话',
712
- setSideToolsHint: '这个开关只在宿主支持工具白名单时才生效;0.2.1-rc.3 不支持,侧边作答会继承当前会话的全部工具。要真正收窄,请填写下面的 preset(0.2.1-rc.3 已支持)。',
712
+ setSideToolsHint: '这个开关只在宿主支持工具白名单时才生效;0.2.x 宿主(0.2.1-rc.3 实测、rc.5 复核)都不支持,侧边作答会继承当前会话的全部工具。要真正收窄,请填写下面的 preset(0.2.1 起支持)。',
713
713
  setSideAgentPreset: '只读 preset(可选)',
714
714
  setSideAgentPresetPlaceholder: '留空则不启用',
715
715
  setSideAgentPresetHint: '子代理以该 agent preset 启动,工具集由 preset 决定(0.2.1 唯一可靠的收窄通道);留空时退回探针拒写',
@@ -982,9 +982,9 @@ window.__ModuleLoader__.load({
982
982
  setMinChars: 'Minimum selection length',
983
983
  setConcurrency: 'Max concurrent answers',
984
984
  setSideTools: 'Side answerer tool access',
985
- setSideToolsReadonly: 'Read-only (needs host support; no-op on 0.2.1)',
985
+ setSideToolsReadonly: 'Read-only (needs host support; no-op on 0.2.x)',
986
986
  setSideToolsInherit: 'Inherit from the session',
987
- setSideToolsHint: 'This switch only works when the host supports a tool allow-list; 0.2.1-rc.3 does not, so the side answerer inherits every tool of the session. To really narrow it, set the preset below (supported on 0.2.1-rc.3).',
987
+ setSideToolsHint: 'This switch only works when the host supports a tool allow-list; no 0.2.x host does (verified on 0.2.1-rc.3 and re-checked on rc.5), so the side answerer inherits every tool of the session. To really narrow it, set the preset below (supported since 0.2.1).',
988
988
  setSideAgentPreset: 'Read-only preset (optional)',
989
989
  setSideAgentPresetPlaceholder: 'Leave empty to disable',
990
990
  setSideAgentPresetHint: 'Starts the subagent with this agent preset, so the preset decides the tool set — the only reliable narrowing channel on 0.2.1; when empty the write-refusing probe is the fallback',
@@ -2368,7 +2368,12 @@ window.__ModuleLoader__.load({
2368
2368
  // tab 把 contentId 固定写成 `sidebar://<kind>`(不是卡片 id),所以
2369
2369
  // 过去用 `contentId === card.id` 判归属永远匹配失败、关闭恒失效
2370
2370
  // (2.1.5 R2 的回归)。kind 单地址模型下 `cardId` 才是唯一归属信号。
2371
- if (this.cardId !== card.id) return false
2371
+ // 严格归属(只认 `cardId === card.id`)治好了 2.1.5 R2 的"串卡",
2372
+ // 却把另一种真实场景关在门外:页面重载后宿主会从布局里恢复出这张
2373
+ // tab,而 `cardId` 是内存态、必然是 null —— 此时没有卡片认领它,
2374
+ // 删除历史条目也永远关不掉,就是专家复现的"幽灵面板"。
2375
+ // 规则改为:**已知别的卡拥有它才拒绝**;无主时按 kind + 会话定位。
2376
+ if (this.cardId !== null && this.cardId !== card.id) return false
2372
2377
  // 卡片自己的会话优先:用户切走会话后关闭 A 的 tab,仍应落在 A。
2373
2378
  const ownerSession = typeof card.sessionId === 'string' && card.sessionId !== ''
2374
2379
  ? card.sessionId
@@ -2472,7 +2477,9 @@ window.__ModuleLoader__.load({
2472
2477
  close(card) {
2473
2478
  if (!this.available() || typeof this.service.closeTab !== 'function') return false
2474
2479
  // 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab。
2475
- if (this.cardId !== card.id) return false
2480
+ // 同 native.close:无主(cardId 为 null,页面重载后)也要放行,
2481
+ // 否则在历史管理器里删掉一条 better 卡,栏里那张 tab 永远留空。
2482
+ if (this.cardId !== null && this.cardId !== card.id) return false
2476
2483
  const sessionId = typeof card.sessionId === 'string' && card.sessionId !== ''
2477
2484
  ? card.sessionId
2478
2485
  : (store.state.sessionId ?? undefined)
@@ -6271,7 +6278,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6271
6278
  if (better.service === null || typeof better.service?.closeTab !== 'function') return
6272
6279
  // 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab;释放一张
6273
6280
  // 不是当前所有者的历史条目,会把现在真正在栏里的卡片连带关掉。
6274
- if (better.cardId !== id) return
6281
+ // 无主(null)时也放行:理由见 better.close。
6282
+ if (better.cardId !== null && better.cardId !== id) return
6275
6283
  const scope = typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : undefined
6276
6284
  const mountedId = embeddedTabIds.get(id)
6277
6285
  if (typeof mountedId === 'string' && mountedId !== '') {
@@ -6302,12 +6310,44 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6302
6310
  const native = surfaces.native
6303
6311
  if (native === null || native === undefined) return
6304
6312
  if (native.controller === null || native.controller === undefined) return
6305
- if (native.cardId !== id) return
6313
+ // 无主(null)时也放行:理由见 native.close。
6314
+ if (native.cardId !== null && native.cardId !== id) return
6306
6315
  try {
6307
6316
  native.close({ id, sessionId })
6308
6317
  } catch { /* the rail owns its tab's lifetime */ }
6309
6318
  }
6310
6319
 
6320
+ /**
6321
+ * Close rail tabs that no live card can claim.
6322
+ *
6323
+ * The host persists the rail layout, so after a page reload our tab is
6324
+ * restored — but "which card owns it" (`surfaces.native.cardId` /
6325
+ * `surfaces.better.cardId`) is process memory, and cards themselves are
6326
+ * not persisted. The result is a panel that renders "card closed" and
6327
+ * that the old build could not even close (expert A1). When no card of
6328
+ * this page owns the address, the tab is provably orphaned: close it.
6329
+ */
6330
+ function sweepOrphanRailTabs() {
6331
+ const native = surfaces.native
6332
+ if (native !== null && native !== undefined && native.cardId === null
6333
+ && native.controller !== null && native.controller !== undefined) {
6334
+ let rows = []
6335
+ try {
6336
+ rows = typeof native.controller.openTabs?.getSnapshot === 'function'
6337
+ ? native.controller.openTabs.getSnapshot()
6338
+ : []
6339
+ } catch { rows = [] }
6340
+ for (const row of Array.isArray(rows) ? rows : []) {
6341
+ if (row?.kind !== CARD_KIND) continue
6342
+ if (typeof row.sessionId !== 'string' || typeof row.tabId !== 'string') continue
6343
+ try { native.controller.closeIn(row.sessionId, row.tabId) } catch { /* rail owns it */ }
6344
+ }
6345
+ }
6346
+ // better 栏刻意不在这里扫:插件的 tab 只有在宿主真的 mount 它时才
6347
+ // 存在,而 mount 是“当前页面有卡片”的证明;多窗口下另一个窗口的
6348
+ // 活跃卡片与本页共享同一个 session,盲扫会替它关掉 tab。
6349
+ }
6350
+
6311
6351
  const actions = {
6312
6352
  openPopover(candidate) {
6313
6353
  // P2-2: the composer must be remembered BEFORE the popover's
@@ -6554,6 +6594,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6554
6594
  if (renderedCardId === card.id) return
6555
6595
  store.set({ surface: 'flow', surfaceNote: t('surfaceUnproven') })
6556
6596
  patchCard(card.id, { surface: 'flow' })
6597
+ // 卡已经回到浮层,栏里那张 tab 就没有内容了;不关掉会留下
6598
+ // 一块"已移到浮层"的空面板,用户还得自己找它(专家 E)。
6599
+ try { surfaces.native.close(card) } catch { /* rail owns it */ }
6600
+ try { surfaces.better.close(card) } catch { /* host may be gone */ }
6557
6601
  toast(t('surfaceUnproven'))
6558
6602
  scheduleReport(0)
6559
6603
  }, 600)
@@ -7386,6 +7430,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7386
7430
  if (registry === null || registry === undefined) return
7387
7431
  surfaces.native.registry = registry
7388
7432
  surfaces.native.controller = controller ?? null
7433
+ // 布局恢复出来的 tab 没有卡片认领,注册完立刻清一次。
7434
+ try { sweepOrphanRailTabs() } catch { /* best effort */ }
7389
7435
  // Register the tab TYPE first, then its body/title slots — and
7390
7436
  // roll the type back if a slot refuses to register.
7391
7437
  //
@@ -7418,7 +7464,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7418
7464
  slotDisposers.push(ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
7419
7465
  name: 'sidebar.right.pane.tab',
7420
7466
  key: CARD_KIND,
7421
- }, props => h(CardHost, { props, actions }))))
7467
+ }, props => h(CardHost, { props, actions, surface: 'native-rightbar' }))))
7422
7468
  slotDisposers.push(ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
7423
7469
  name: 'sidebar.right.pane.tab.title',
7424
7470
  key: CARD_KIND,
@@ -7494,9 +7540,12 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7494
7540
  component: props => h(CardHost, {
7495
7541
  props: { tab: props?.tab, sessionId: props?.scope?.sessionId },
7496
7542
  actions,
7543
+ surface: 'better-sidebar',
7497
7544
  }),
7498
7545
  })
7499
7546
  surfaces.better.service = service
7547
+ // 同 native:注册完清掉无主的 better 栏 tab。
7548
+ try { sweepOrphanRailTabs() } catch { /* best effort */ }
7500
7549
  surfaces.better.dispose = typeof off === 'function' ? off : null
7501
7550
  disposers.push(() => {
7502
7551
  try { surfaces.better.dispose?.() } catch { /* already disposed */ }
@@ -7525,12 +7574,17 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7525
7574
  const navigation = props?.tab?.navigation ?? props?.navigation
7526
7575
  const fromParams = navigation?.params?.cardId
7527
7576
  if (typeof fromParams === 'string' && fromParams !== '') return fromParams
7577
+ // 回退只对"宿主不给 tab 对象"的内置右栏成立(它本来就这么设计:
7578
+ // 导航参数不进 tab 记录)。better-sidebar 一定给 tab;它的 meta 里
7579
+ // 没有 cardId 时,这张 tab 不是卡片的,拿原生适配器的地址顶替会
7580
+ // 渲染出**另一张卡**(专家 A1-host 的跨面误伤)。
7581
+ if (props?.tab !== undefined && props.tab !== null) return undefined
7528
7582
  const recorded = surfaces.native.cardId
7529
7583
  return typeof recorded === 'string' && recorded !== '' ? recorded : undefined
7530
7584
  }
7531
7585
 
7532
7586
  /** Embedded card body used by both external hosts. */
7533
- function CardHost({ props }) {
7587
+ function CardHost({ props, surface }) {
7534
7588
  const state = useStoreState()
7535
7589
  const cardId = hostCardIdOf(props)
7536
7590
  const card = state.cards.find(item => item.id === cardId)
@@ -7578,7 +7632,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7578
7632
  h(Styles, null),
7579
7633
  h(MemoAnswerCard, {
7580
7634
  card,
7581
- surfaceLabel: state.surface === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
7635
+ // 标签说的是"这张卡现在挂在哪个承载面",与用户选的全局
7636
+ // 偏好无关:两个宿主可能同时存在(原生栏 + better 镜像),
7637
+ // 读 state.surface 会把 better 栏里的卡标成"原生右栏"(专家 A4)。
7638
+ surfaceLabel: (surface ?? state.surface) === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
7582
7639
  onCopy: element => actions.copy(card, element),
7583
7640
  onFollowUp: text => actions.followUp(card, text),
7584
7641
  onRetry: () => actions.retry(card),
@@ -7602,6 +7659,35 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7602
7659
  console.warn(`[${PLUGIN_ID}] card history restore failed:`, error)
7603
7660
  }
7604
7661
 
7662
+ // The adapters register asynchronously (ctx.inject callbacks), so the
7663
+ // sweep above may run before the rail data exists; one delayed retry
7664
+ // covers a layout that is restored after this line.
7665
+ const orphanSweepTimer = setTimeout(() => {
7666
+ try { sweepOrphanRailTabs() } catch { /* best effort */ }
7667
+ }, 900)
7668
+ disposers.push(() => clearTimeout(orphanSweepTimer))
7669
+
7670
+ // Cross-tab history sync. Two pages (desktop window + browser) each
7671
+ // hold their own store and write the WHOLE array, so the last writer
7672
+ // wins: A deletes an entry, B still has it in memory, B's next write
7673
+ // resurrects it (expert MULTITAB). Adopting the on-disk envelope
7674
+ // whenever another page writes makes B's next sync start from the
7675
+ // authoritative set instead of its stale memory.
7676
+ let storageListener = null
7677
+ try {
7678
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
7679
+ storageListener = event => {
7680
+ if (event !== null && event !== undefined && typeof event.key === 'string'
7681
+ && event.key !== PERSIST_KEY) return
7682
+ try { store.set({ history: readPersisted() }) } catch { /* read defensively */ }
7683
+ }
7684
+ window.addEventListener('storage', storageListener)
7685
+ disposers.push(() => {
7686
+ try { window.removeEventListener('storage', storageListener) } catch { /* gone */ }
7687
+ })
7688
+ }
7689
+ } catch { /* a host without storage events simply has no cross-tab sync */ }
7690
+
7605
7691
  // §4.5 family singleton: register this copy, (re)elect on every join
7606
7692
  // or leave, and gate the interaction surface on the verdict.
7607
7693
  const family = familyTable(window)
package/index.js CHANGED
@@ -45,7 +45,7 @@ export const name = 'dsh-sidecard-ask'
45
45
  export const inject = ['webServer']
46
46
 
47
47
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
48
- export const PLUGIN_VERSION = '2.1.6'
48
+ export const PLUGIN_VERSION = '2.1.8'
49
49
 
50
50
  /** Route prefix of the plugin's own API. */
51
51
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -2172,10 +2172,12 @@ function createSideEngine(ctx, configOf) {
2172
2172
  const record = runs.get(id)
2173
2173
  if (record === undefined) return false
2174
2174
  const owner = record.sessionId
2175
- if (typeof owner === 'string' && owner !== ''
2176
- && typeof sessionId === 'string' && sessionId !== ''
2177
- && owner !== sessionId) {
2178
- return false
2175
+ if (typeof owner === 'string' && owner !== '') {
2176
+ // 归属判定必须是"两边都在且相等":只要这个 run 知道自己在哪个会话,
2177
+ // 没带 sessionId(或带错)的调用方一律拒绝 —— 否则任何拿到 run id 的
2178
+ // 页面都能中止别的会话的子代理(专家 CANCEL)。老客户端一直带
2179
+ // sessionId,因此这条收紧不会打断既有调用方。
2180
+ if (typeof sessionId !== 'string' || sessionId === '' || owner !== sessionId) return false
2179
2181
  }
2180
2182
  record.abort()
2181
2183
  return true
@@ -2752,15 +2754,27 @@ export function apply(ctx, patchConfig) {
2752
2754
  const authRejection = req => {
2753
2755
  const connection = connectionService()
2754
2756
  if (connection === undefined) {
2757
+ // 专家 AUTH-EDGE:这是 WEB 端的正常形态(宿主没把 connection 服务
2758
+ // 暴露给插件),但它同时意味着"没有宿主级认证"—— 单条 info 等于把
2759
+ // 风险静音,改成 warn,且每个进程仍只记一次。
2755
2760
  if (!authGateNoted) {
2756
2761
  authGateNoted = true
2757
- ctx.logger?.info?.('[sidecard-ask] 未挂载 connection 服务:API 仅保留 loopback/Origin 围栏')
2762
+ ctx.logger?.warn?.('[sidecard-ask] 未挂载 connection 服务:API 仅保留 loopback/Origin 围栏,无宿主级认证')
2758
2763
  }
2759
2764
  return undefined
2760
2765
  }
2761
2766
  try {
2762
2767
  const rejection = connection.requestRejection(req)
2763
- return rejection === 401 || rejection === 403 ? rejection : undefined
2768
+ if (rejection === 401 || rejection === 403) return rejection
2769
+ // 旧实现把"任何非 401/403 的值"都当放行,包括对象/字符串这类仲裁器
2770
+ // 可能用来表示"拒绝"的返回值(专家 AUTH-EDGE)。契约未公开,所以
2771
+ // 只在明确表示放行时放行:undefined/null/false/200;其余按未认证
2772
+ // 处理并留一条 warn,万一是契约误判能立刻被看见。
2773
+ if (rejection === undefined || rejection === null || rejection === false || rejection === 200) {
2774
+ return undefined
2775
+ }
2776
+ ctx.logger?.warn?.('[sidecard-ask] requestRejection 返回了意外的值,按未认证处理:', rejection)
2777
+ return 403
2764
2778
  } catch (error) {
2765
2779
  // Fail closed: an unreadable auth service must not widen the surface.
2766
2780
  ctx.logger?.warn?.('[sidecard-ask] 鉴权校验异常,按未认证处理:', error?.message ?? error)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -21,7 +21,8 @@
21
21
  "icon.svg",
22
22
  "README.md",
23
23
  "CHANGELOG.md",
24
- "LICENSE"
24
+ "LICENSE",
25
+ "NODE-HOST-CONTRACT.md"
25
26
  ],
26
27
  "engines": {
27
28
  "node": ">=20",