dsh-sidecard-ask 2.1.7 → 2.1.9

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,91 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.9
4
+
5
+ **主题:承载面适配对齐 dsh-better-sidebar 0.28.2,修掉两个已复现的用户可见缺陷。**
6
+
7
+ 触发是一次专家评审(架构 / 安全 / 验收三线独立复核):0.28.x 把 better 的 tab 从它自己的
8
+ workbench 搬进了 **DSH 原生右栏**,而本插件仍按「better 是一个独立面板」建模——落点矩阵、
9
+ `dockRail` 锚点、tab 归属、菜单入口四处同时失真。四个发布闸门当时全绿(1928),
10
+ 但绿只证明旧契约面没坏。
11
+
12
+ ### 修复(适配)
13
+
14
+ - **A1 跨会话关闭/删除不关 tab**:归属从进程级单槽 `cardId` 改为按 `(kind, session)` 记录
15
+ (`owners` Map)。单槽在跨会话时会误判「别的卡拥有它」,让 A 会话的 tab 永远关不掉。
16
+ 会话未知(历史条目、幽灵 id)时仍退回单槽,保住 2.1.5 R2 的归属语义。
17
+ - **A2 重载后的幽灵面板没有出口**:无主 body 现在渲染「关闭此页」;**只有本页真的挂载过**
18
+ 该 tab 才自动关闭。宿主会持久化栏布局,别的窗口恢复的同一张 tab 不会被本页抢走。
19
+ - **A3 dockRail 铺到别人的页面上**:`CardHost` 根节点声明 `data-dsa-card-id`,
20
+ `findRail` 拒绝不属于本层卡片的 body;全部候选被占时 `data-dsa-dock="occupied"`,浮层保持浮动。
21
+ - **A4 `+` 菜单里的空页胶囊**:better 描述符加 `hidden: true`——保留 `openTab` 可用,只是不再进菜单。
22
+ 原生 guide 那一条缺宿主语义材料,仍会显示,已记入 README §5.6「仍未验证」。
23
+ - **A5 禁用态被误判成故障**:`available()` 读 0.28.x 的 `isTabEnabled`,禁用即 `reason='tab-disabled'`,
24
+ `auto` 直接落浮层,不再先挂 600 ms 再报「承载面未证实」。
25
+ - **A6 非当前会话的卡被证明迁走**:宿主对非当前屏的 `openTab` 是排队而不是拒绝,渲染证明
26
+ 现在只对当前屏会话武装。
27
+ - **A7 自检看不清真相**:上报增 `railTabs`(宿主清单里本插件行数)与 `mountedTabId`。
28
+
29
+ ### 修复(安全 / 归属)
30
+
31
+ - `/config` 改 SMTP 主机/端口/TLS/账号任一项 → 已存授权码作废(凭据不再跟随新目标外送)。
32
+ - `/config` 的 `sideTools: "inherit"` 需要 `confirmInheritTools: true`(防误触;不是安全边界)。
33
+ - 只读护栏 **fail-closed**:注册表答了却认不出只读工具时拒跑(新错误码 `no-readonly-guard`,
34
+ 客户端中英文案 + 错误码分类已补),不再静默继承父会话全部工具。
35
+ - `cardId` 不可预测;better tab 标题不再携带问题原文;`tab.meta.cardId` 校验归属会话。
36
+ - `closeRailRow` 容忍带 `dsh-better-sidebar:` 前缀的 kind;`/ask` 的 start 帧回带
37
+ `parentSessionId` / `requestedSessionId`,跨会话作答可被看见。
38
+
39
+ ### 新增(行为)
40
+
41
+ - **答完自动展开卡片**:浮层默认是胶囊(一行状态),答案在那里只看得到「已完成」三个字。
42
+ 现在回答结束(`done` / `stopped` / `error`)时自动把浮层切到卡片形态并展开该卡,
43
+ 答案内容直接出现在侧边卡片里。设置页新增「答完自动展开卡片」开关(默认开);
44
+ 置 `autoExpandOnDone: false` 可恢复旧行为(始终停在胶囊,点一下才展开)。
45
+ ### 闸门
46
+
47
+ - `node test/verify.mjs` → 58 / 0
48
+ - `node test/contract-test.mjs` → 1499 / 0
49
+ - `node test/float-placement.mjs` → 100 / 0
50
+ - `node test/smoke-test.mjs` → 291 / 0
51
+ - 合计 **1948**(2.1.8 基线 1928,新增 20 条:hidden、禁用门、SMTP 换目标清密码、
52
+ inherit 确认、`no-readonly-guard` 拒跑等)。
53
+
54
+ ### 未执行
55
+
56
+ 真实浏览器/Electron 点按与视觉、桌面 profile、macOS/Linux 均未验证;详见 README §5.6。
57
+
58
+ ## 2.1.8
59
+
60
+ **主题:交付《非 Node 宿主契约》文档,无代码改动。**
61
+
62
+ 本轮没有功能与缺陷修复,发布物只多一份文档(`NODE-HOST-CONTRACT.md`,392 行),
63
+ 内容是从本插件 2.1.7 的真实调用点反推出来的宿主契约面:需要宿主实现什么、
64
+ 语义边界在哪、少了会怎样、如何验收。写它的直接起因是「用 Go 写一个 DSH 宿主」
65
+ 这个需求——结论是纯 Go 宿主无法复用本插件代码,必须先把契约对齐再谈实现。
66
+
67
+ ### 新增
68
+
69
+ - **`NODE-HOST-CONTRACT.md`**(已加入 `files`,随包发布)。分 14 节:
70
+ - 硬依赖与软依赖分层表(L1–L11):只有「运行时 + `(req,res)` 语义」与
71
+ 「前端模块加载器 + 具名槽位」是硬的,其余全部可降级。
72
+ - 插件真正触碰的 `req`/`res` 成员**全量 13 个**、`ctx.*` **全量 7 个**——
73
+ 照这张表实现即可,不必复刻 Node 的 HTTP 类。
74
+ - 8 个 API 方法的动词/鉴权/语义表;`ping` 是唯一免鉴权的动词。
75
+ - `connection.requestRejection` 的 6 种返回值语义(含 fail-closed 与
76
+ 「服务缺失即放行」这条最容易做空的路径)。
77
+ - SSE 帧格式、15 s 心跳、`res.write` 必须即时刷出的理由。
78
+ - 两代子代理 API 形状、`delivery:'caller'` 的产品语义、工具白名单的交集规则。
79
+ - 路径与原子写(`$DSH_HOME` 空白算未设置、profile slug 正则、0700/0600/rename)。
80
+ - 前端契约:`__ModuleLoader__`、`/plugins` 与 rev、槽位回退链、右栏 DOM 标记、
81
+ localStorage 键与预算。
82
+ - **V1–V8 验收清单**(可直接 curl)与 **10 条反模式**。
83
+
84
+ ### 说明
85
+
86
+ - 代码、API、行为与 2.1.7 完全一致;本版仅为把文档纳入发布物并让仓库与 npm 对齐。
87
+ - 文档里凡未经实测的推理都显式标注为 `[推断]`,不当作契约引用。
88
+
3
89
  ## 2.1.7
4
90
 
5
91
  **主题:修复「技术方案评审团」在 0.2.1-rc.5(桌面 Electron 44.7.0 / 网页端同源)上复核出的 8 条缺陷。**
@@ -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/README.md CHANGED
@@ -167,6 +167,7 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
167
167
  | `sideSurface` | `auto` \| `native-rightbar` \| `better-sidebar` \| `flow` | `auto` | **窗口模式(侧边承载面)**:自动挑选(先 dsh-better-sidebar,再内置浮层卡片)/ 强制 DSH 原生右侧栏(不参与自动)/ 强制 dsh-better-sidebar / 强制内置浮层卡片 | 立即 |
168
168
  | `maxChars` | 200–60000 | `4000` | **最大字符数**:超出部分头尾保留、中间截断并标注 | 立即 |
169
169
  | `floatMode` | `capsule` \| `full` | `capsule` | **内置浮层的默认形态**:胶囊(一行,最小遮挡)/ 完整卡片栈。运行时可即时收起为胶囊,但不写回本项——刷新后仍回到这里设的默认。旧配置里存过 `off` 的会在启动时校验失败、回退为 `capsule`,并在 `problems` 里给出提示(`index.js` 的 `sanitizeLayer`),无需手工迁移 | 立即 |
170
+ | `autoExpandOnDone` | 布尔 | `true` | **答完自动展开卡片**:回答结束(`done` / `stopped` / `error`)时把浮层从胶囊切到卡片形态并展开该卡,答案正文直接显示在侧边卡片里。置 `false` 则始终停在胶囊,点一下才展开(2.1.9 起) | 立即 |
170
171
  | `floatMaxWidth` | 240–560 | `340` | **内置浮层宽度上限**(px):实际宽度 = `min(本项, 对话区右侧可用带宽)`,可用带宽不足 240px 时才会压入对话区 | 立即 |
171
172
  | `dockRail` | 布尔 | `true` | **右侧栏打开时占满它**:答案卡铺满右栏标签条下方的内容区,读起来像栏内的一页,而不是 340px 的小浮层。收起为胶囊、点卡片底部「关闭」、右栏宽度 < 280px 或高度 < 240px 时自动回到常规浮层;实现靠测量右栏 DOM 矩形,**没有**走 DSH 的右栏 API(见 §4.2 与 §6.1) | 立即 |
172
173
  | `shortcut` | 形如 `Alt+Q`、`Ctrl+Shift+K` | `Alt+Q` | **快捷键**:唤起提问框(`trigger` 允许时) | 立即 |
@@ -549,6 +550,72 @@ dsh-better-sidebar 为此专门带了一条防御(`html[data-platform] body>[d
549
550
 
550
551
  ---
551
552
 
553
+ ### 5.6 2026-11-03 复核(v2.1.9 / dsh-better-sidebar 0.28.2)
554
+
555
+ 本轮把适配核对的**对象**从上表的 0.22.1(网页)/ 0.24.1(桌面)更新到本机实际运行的
556
+ **0.28.2**(`SIDEBAR_SERVICE_VERSION = "0.28.2"`,`SIDEBAR_FEATURES` 14 项),并修掉四处
557
+ 按旧模型写的假设。证据分三类:**源码级**(读 0.28.2 的 `lib/types/client/service.d.ts`
558
+ 与 `src/client/*.ts`)、**替身复现**(`test/` 下的 0.28.2 形状替身)、**真机**(本轮**未做**,见末尾)。
559
+
560
+ #### 0.28.2 的关键事实(相对 0.22.1 的漂移)
561
+
562
+ | # | 事实 | 对适配的影响 |
563
+ |---|---|---|
564
+ | 1 | 组件类 seed 的 `openTab` 实际落到 **DSH 原生右栏**(`service.ts:1120-1145`),不再进 better 自己的 workbench | better 与 native **共用同一个物理容器**;`dockRail` 的锚点会指到别人的页面 |
565
+ | 2 | tab id 由宿主 mint(`tab1…`),seed id 被忽略,`closeTab(seed.id)` 是 no-op | 关闭必须用 `CardHost` 记录的真实 id(2.1.6 S1 已做) |
566
+ | 3 | 同 kind 每会话只有**一个 page 地址**(无 `multiple`) | 同会话再开一张 = 导航,旧卡必须迁回浮层(2.1.6 S2 已做) |
567
+ | 4 | `isTabEnabled(type)` 禁用后 `openTab` **warn + return**,不抛错 | 禁用态会被误判成「承载面未证实」并弹假故障(2.1.9 A5 修) |
568
+ | 5 | 每个注册类型自动进 `+` 菜单,并生成 guide 胶囊 | 点开是一张无 `cardId` 的空页(2.1.9 A4 用 `hidden: true` 修) |
569
+ | 6 | `OpenTabSeed.target` 支持 `right/bottom/side`,另有 `preferNewPane` 与 split pane | 本插件不传 target(默认 right);分栏时 panel 级回退会横跨两栏 |
570
+ | 7 | 非当前会话的 `openTab` 是**排队**,不是拒绝;只有 on-screen 才 mount | 600 ms 渲染证明会把排队中的卡误判为失败(2.1.9 A6 修) |
571
+ | 8 | 镜像注册的 `id` 是 `dsh-better-sidebar:<id>`、`kind` 是描述符 id | 两个适配器同 kind 相撞的旧缺陷**已清除**;但 `closeRailRow` 需容忍带前缀的 kind |
572
+
573
+ > `.d.ts` 里 `openTab` 的注释写「每次 open 落在 bottom workbench」,而 `OpenTabSeed.target`
574
+ > 的注释写默认 `'right'` 是原生右栏——两者矛盾,**只有 `src/client/service.ts` 给出真值**。
575
+ > 下一位维护者不要只看 `.d.ts`。
576
+
577
+ #### 本版修掉的适配缺陷
578
+
579
+ | 编号 | 缺陷 | 修法 |
580
+ |---|---|---|
581
+ | A1 | 跨会话关闭/删除不关 better tab(进程级单槽 `cardId` 误判归属) | 归属改为按 `(kind, session)` 记(`owners` Map);「本会话有别的卡持有才拒绝」,会话未知时退回单槽 |
582
+ | A2 | 刷新后宿主还原的 better tab 无人认领、没有出口 | 空页渲染「关闭此页」;**只有本页真的挂载过**该 tab 才自动关(别的窗口的 tab 不抢) |
583
+ | A3 | `dockRail` 把浮层卡铺到另一张卡自己的页面上 | `CardHost` 根节点声明 `data-dsa-card-id`;`findRail` 拒绝不属于本层的 body,全被拒时 `data-dsa-dock="occupied"` |
584
+ | A4 | `+` 菜单两条「点开即空页」胶囊 | descriptor 加 `hidden: true`(保留可 open,只是不进菜单);原生 guide 那条缺宿主语义材料,仍会显示 |
585
+ | A5 | 禁用的 tab 类型仍被 `auto` 选中并报假故障 | `available()` 读 `isTabEnabled`,禁用即 `reason='tab-disabled'`,直接落浮层 |
586
+ | A6 | 非当前会话的卡被 600 ms 证明迁走 | 证明只对「当前屏会话」武装 |
587
+ | A7 | 自检看不出「到底有没有落栏」 | 上报增 `railTabs`(宿主清单里本插件行数)与 `mountedTabId`(最近一次真实挂载的宿主 tab id) |
588
+
589
+ #### 安全相关(同一轮,详见 §十二)
590
+
591
+ - `/config` 改 `feedbackSmtpHost/Port/Secure/User` 任一项 → **已存授权码作废**。否则「空密码 = 保留旧值」
592
+ 等于把凭据交给新主机,而 `AUTH PLAIN` 是明文。
593
+ - `/config` 写 `sideTools: "inherit"` 需要 `confirmInheritTools: true`。**这不是安全边界**
594
+ (同源脚本可以伪造这个字段),只让这次提权必须是有意的;同源信任边界由宿主权限决定。
595
+ - 只读护栏改为 **fail-closed**:工具注册表答了却认不出任何只读工具时,除非另有 `agentPreset`
596
+ 收窄,否则**拒跑**(新错误码 `no-readonly-guard`,卡片文案中英齐全)。
597
+ - `cardId` 改为不可预测(`card-<base36 时间>-<序号>-<随机 6 位>`);better tab 标题不再携带问题原文
598
+ (0.28.x 会把 title/meta 随布局持久化);`tab.meta.cardId` 增加「属于本会话」校验。
599
+
600
+ #### 0.28.2 新 API 的准入条件(采用前必须满足)
601
+
602
+ - `updateTab(tabId, { title, path, meta }, sessionId)`:① 只允许 `embeddedTabIds` 里的自持 tabId;
603
+ ② `sessionId === card.sessionId`;③ **永不带 `path`**(那等于给出「在后台会话打开任意文件路径」的原语)。
604
+ - `stateSubscription` / `getSnapshot`:只用来读,**禁止**用快照的 `sessionId` 重定向在飞请求。
605
+ - `pluginSettings`:只放 UI 偏好,绝不放卡片正文或选中文本。
606
+
607
+ #### 仍未验证(如实记录)
608
+
609
+ - 真实浏览器 / Electron 页面里的**点按与视觉**:本机无 GUI 自动化。`dockRail` 的 `occupied`、
610
+ `hidden` 之后的 `+` 菜单、跨窗口清扫都**没有被真机执行**。
611
+ - 桌面端:desktop profile 同样是 `dsh-better-sidebar ^0.28.2`,§5.3 的 0.24.1 核对**已过期**;
612
+ 本版按同一套源码事实处理,但没跑桌面。
613
+ - macOS / Linux:零用例,仓库没有 CI。
614
+ - `openTabs` 是否跨窗口共享(`app.asar` 内的 `sidebar-right` 读不到):因此「自动清扫」只采用
615
+ 「本页挂载过」这一按页成立的判据,没有做 boot 期盲扫。
616
+
617
+ ---
618
+
552
619
  ## 六、未知 DSH API:占位接口与替换方式
553
620
 
554
621
  开发时以**运行时能力探测**为主,任何"本机没验证到"的接口都在代码里显式留了占位与替换点。它们集中在三处: