dsh-sidecard-ask 2.1.3 → 2.1.5

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,96 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.5
4
+
5
+ **主题:修完「技术方案评审团」在 0.2.1-rc.3(桌面 Electron 44.7.0 / 网页端同源)上复核出的 8 条缺陷。**
6
+ 四套脚本全绿(57 / 1454 / 100 / 279 = 1890),新增 10 条回归断言。
7
+
8
+ ### 修复
9
+
10
+ - **R1(中)原生栏把卡迁回浮层时没展开,卡在两端同时不可见**。`surfaces.native.open()` 只把
11
+ 被挤出的卡 `surface` 改回 `'flow'`,但浮层只挂载 `surface === 'flow' && open` 的卡
12
+ (`FloatingLayer`),于是那张卡既不在栏里、也不在屏幕上,流式输出还继续往它里面写。
13
+ 现在迁移时一并置 `open: true`。
14
+ - **R2(中)关 A 卡片会关掉 B 的 tab**。`close()` 认领 tab 的判据是"`contentId` 为空就算我的",
15
+ 而单地址模型下栏里那条 tab 可能正显示另一张卡;`this.cardId` 已被清空时(页面重载后)
16
+ 就会误关别人的 tab,连带把别人的正文一起关掉。现在要求 `contentId === 本卡 id`,或
17
+ 宿主没报 `contentId` 且适配器仍持有本卡地址。
18
+ - **R3(中)删历史 / 删卡片不释放原生栏的 tab**。tab 是宿主自己的状态,不随 `store.cards`
19
+ 变化;删掉卡片后页面里留着一张渲染"已不存在卡片"的空壳,直到用户手动关。新增
20
+ `releaseNativeTab(id)`,`deleteHistoryCard` / `deleteCard` / `clearHistory`(仅历史态卡)都会调用。
21
+ - **R4(中)设置页把只读开关标成"只读(推荐)",而 0.2.1-rc.3 上它根本不生效**。
22
+ 宿主的 `SubagentCapabilities` 没有 `toolFilter`,`readOnlyToolFilter()` 返回的白名单会被丢弃,
23
+ 侧边作答实际继承会话全部工具——卡片上的 `toolFilter: 'unsupported'` 是对的,但设置页的
24
+ "推荐"是假的。标签改为「只读(需宿主支持,0.2.1 无效)」。
25
+ - **R5(低)`setSideToolsHint` 谎称"只读靠探针自动拒写"**。代码里没有任何"拒写探针",
26
+ 真实机制是 `toolFilter`(宿主不支持即失效)或 `agentPreset`(0.2.1-rc.3 支持)。文案改为
27
+ 指向 preset,并写明版本前提。
28
+ - **R6(中)`/cancel` 不校验归属**。`runs` 是进程级全局表,任何已认证页面拿到(或猜到)一个
29
+ run id 就能打断别的会话的追问。run 记录现在带 `sessionId`,`cancel(id, sessionId)` 在双方
30
+ 都已知且不一致时按"没找到"处理;不带 `sessionId` 的老客户端保持可用。
31
+ - **R8(低)写盘失败的报错只有 errno 原文**。`toWireError()` 现按 `EACCES` / `EPERM` / `EROFS` /
32
+ `ENOSPC` / `EBUSY` / `ENOENT` 追加中文处置提示,`error.code` 不变(程序分支仍按码判断)。
33
+
34
+ ### 评估后不改
35
+
36
+ - **R7 `/state` 回配置文件的绝对路径**:该接口要求宿主登录(页面外是 `401 unauthorized`),
37
+ 且这个字段是设置页"到底读的哪个文件"的唯一依据。已写进 README §隐私与安全第 16 条,
38
+ 说明暴露面边界与转发时的处置办法。
39
+
40
+ ## 2.1.4
41
+
42
+ **主题:把「技术方案评审团」查出的 11 条缺陷一次修完。四套脚本全绿,新增 21 条回归断言。**
43
+ 排查依据:Software Architect / Application Security Engineer / Reality Checker 三方评审 + 源码复核 + 实测复现。
44
+
45
+ ### 修复
46
+
47
+ - **B1(阻断,静默数据丢失)关闭的卡片在下次答题完成时被抹掉**。`syncPersisted()` 只从
48
+ 「活跃卡片」重建整个持久化集合再整键覆盖,而 `close()` 只把卡片移出活跃区、durable 快照
49
+ 仍留在 `store.state.history` 里,于是关掉的那张被静默删除(`contract-test` 原先把破坏后的
50
+ 结果当成了期望值)。现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目,按 id 去重(活跃方
51
+ 优先)后按 newest-first 截到 cap。删除路径本来就先改 `store.history` 再写盘,并集不会把
52
+ 它们删掉的条目复活。
53
+ - **B2(高,虚假安全保证)`agentPreset` 在 0.2.1-alpha.2+ 的 activation 面没被转发**。
54
+ `startSubagent` 只在旧 `start(provider, request)` 面转发 `agentPreset`,新面的 `request`
55
+ 漏了它 → 子代理拿默认全套工具,而 start 帧仍按配置上报 `toolFilter: 'preset'`,卡片于是
56
+ 对用户谎报「工具集由 preset 决定」。现在两个面都转发。
57
+ - **B3(中高,降级链断裂)Rung 1 `sessions.using()` 没有 try/catch**。它一抛(会话归档、
58
+ 形状漂移,或它内部的 `no-session-face`),整条「改发到主对话」就断在第一档,后面
59
+ `retain` / `inputActions` / `draft` 三档全部不可达。现在与 Rung 2 对称降级,
60
+ `prompt-rejected` 仍照旧抛给调用方(那是用户可见的拒绝,不是通道缺失)。
61
+ - **B4(中,原生栏串卡)第二张卡开进原生右栏时第一张没有迁回浮层**。它只是被盖住——卡片
62
+ 仍标 `native-rightbar`、store 里被过滤掉,流式输出继续往看不见的卡片里写,且 rail 上那张
63
+ 永远关不掉。现在 `open()` 先把栏里其它卡片迁回 `flow`;`close()` 增加归属校验(栏被另一
64
+ 张卡占用时不替它关)与 `contentId` 行匹配(多行时关「属于这张卡」的那行);`openTab`
65
+ 抛错时回滚 `cardId`,避免 CardHost 去渲染一张根本没有 tab 的卡。
66
+ - **B5(中低)`/cancel` 把跨会话的全局 run id 列表回给页面**。`runs` 是跨会话的全局表,
67
+ 回 `active` 等于泄露别的会话正在追问的卡片 id(`cancel` 本身也不校验归属),而 Client
68
+ 从不消费它。现在只回 `{ cancelled }`。
69
+ - **B6(中低,迁移被永久挡住)空的 v3 信封会阻断 v1/v2 迁移**。`raw !== null` 就直接
70
+ return,损坏的 JSON 或旧版本留下的空信封同样挡住。现在写盘时带 `writtenAt` 标记,只有
71
+ 「本版本自己写的空信封」才算权威(用户确实清空了历史,绝不从 v2/v1 复活),没有该标记的
72
+ 空信封与损坏 JSON 继续向下探测。
73
+ - **B7(低,卸载泄漏)`dispose()` 只清了 `streamListeners`,漏了 `chunkListeners`**。第二个
74
+ 流来源的订阅者会留下仍被 `session/event` 桥持有的闭包(它捕获整个 engine 状态)。
75
+ - **B9(低,死重量)持久化写死 `slice(-12)`,而 `followUp` 只发 6 轮**。改成
76
+ `HISTORY_TURNS`,与上下文预算同一个上限。
77
+ - **B10(低,死代码)`let finished = false` 只赋值不读取**,删除。
78
+ - **B11(低,可诊断性)信任围栏的 403 只说 `forbidden`**。现在说明「Host 不在信任范围 +
79
+ 怎么放行(127.0.0.1 或 `trustedHosts`)」。有意**不**自动信任绑定地址:插件读不到宿主的
80
+ bind 配置,猜一个就等于自己拆掉 DNS-rebinding 防线。
81
+ - **B8(条件性,平台限制)配置里的 SMTP 密码以明文落在 `~/.dsh/` 下的用户层 JSON**。
82
+ 写盘已带 `mode: 0o600`(POSIX 生效,Windows 忽略),代码注释与 README 均已注明;
83
+ 无进程内可做的进一步缓解,**保留为已文档化的限制**,建议优先用环境变量。
84
+
85
+ ### 验证
86
+
87
+ `node test/verify.mjs` / `contract-test.mjs` / `float-placement.mjs` / `smoke-test.mjs`:
88
+ **57 / 1446 / 100 / 277 全绿(1880 条,0 失败)**。相对修复前 1859 条新增 21 条回归断言,
89
+ 逐条对应上面的缺陷:关卡片后历史仍在(contract)、activation 面转发 preset 且无能力时
90
+ 不带该字段(smoke)、`using()` 抛错降级而 `prompt-rejected` 仍抛(contract)、空/损坏 v3
91
+ 信封不挡迁移且自有空信封不被复活(contract)、原生栏单地址与关 tab 归属(contract)、
92
+ `/cancel` 不泄露 `active`(smoke)。
93
+
3
94
  ## 2.1.3
4
95
 
5
96
  **主题:折叠态胶囊不再压在聊天输入框上(2026-10-25 实拍报告)。**
package/README.md CHANGED
@@ -1088,4 +1088,12 @@ npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>" # 旧名
1088
1088
  15. **部署前缀不带尾斜杠时(2.1.0)**:`API` 基址会先按 `document.baseURI` 猜一次,若得到 404
1089
1089
  则回退到根绝对路径重试一次。这是唯一安全的判别方式(401/403 都不能用来改基址),
1090
1090
  代价是每次页面加载最多多一次 404 往返——正常部署下不会触发。
1091
+ 16. **`/state` 会回配置文件的绝对路径(`provenance.persistedPath`,2.1.5 复核后保留)**:这是设置页
1092
+ 用来说明"到底读的是哪个文件"的诊断字段,且该接口要求宿主登录(页面外拿到的是
1093
+ `401 unauthorized`)。它**不是**无鉴权暴露面;如果你把 `/state` 的响应转发到别处,请自行删掉
1094
+ 这个字段。同一响应里的 `persistedError` 会带上 errno 的中文提示(`EACCES` / `EPERM` /
1095
+ `ENOSPC` / `EBUSY` 等),原始错误码仍保留在 `error.code` 上供程序分支。
1096
+ 17. **`/cancel` 校验归属(2.1.5)**:请求体带 `sessionId` 且与 run 的发起会话不一致时,按"没找到"
1097
+ 返回 `cancelled: false`(不回 403,避免把别的会话的 run id 是否存在当成探测面)。不带
1098
+ `sessionId` 的老客户端仍然可以取消——向后兼容优先于收紧。
1091
1099
 
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.3'
120
+ const CLIENT_VERSION = '2.1.5'
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: '只读(推荐)',
710
+ setSideToolsReadonly: '只读(需宿主支持,0.2.1 无效)',
711
711
  setSideToolsInherit: '继承当前会话',
712
- setSideToolsHint: '在 0.2.1 上,只读靠探针自动拒写,不依赖此开关;要彻底收窄工具,请填写下面的 preset',
712
+ setSideToolsHint: '这个开关只在宿主支持工具白名单时才生效;0.2.1-rc.3 不支持,侧边作答会继承当前会话的全部工具。要真正收窄,请填写下面的 preset(0.2.1-rc.3 已支持)。',
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 (recommended)',
985
+ setSideToolsReadonly: 'Read-only (needs host support; no-op on 0.2.1)',
986
986
  setSideToolsInherit: 'Inherit from the session',
987
- setSideToolsHint: 'On 0.2.1 read-only is enforced by the write-refusing probe, not by this switch; set a preset below to narrow the tool set itself',
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).',
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',
@@ -2178,16 +2178,26 @@ window.__ModuleLoader__.load({
2178
2178
  }
2179
2179
 
2180
2180
  // Rung 1 — `using()`: the documented helper, present from 0.1.6-alpha.2.
2181
+ // 与 Rung 2 必须对称地包 try/catch:`using()` 本身(或它内部的
2182
+ // `no-session-face` 抛出)一失败就该降级到 retain → inputActions →
2183
+ // draft,而不是让整个"改发到主对话"断在这里 —— 后两档存在的理由
2184
+ // 正是这种情况。`prompt-rejected` 仍然照旧抛给调用方(那是用户可见的
2185
+ // 拒绝,不是通道缺失)。
2181
2186
  if (sessionId !== null && typeof sessions?.using === 'function') {
2182
- const result = await sessions.using(sessionId, { source: 'gateway' }, async (reference) => {
2183
- const face = reference?.binding?.session
2184
- if (face === undefined || typeof face.prompt !== 'function') {
2185
- throw Object.assign(new Error('session face has no prompt()'), { code: 'no-session-face' })
2186
- }
2187
- return face.prompt(parts, 'queue')
2188
- })
2189
- accepted(result)
2190
- return { mode: 'session', via: 'sessions.using' }
2187
+ try {
2188
+ const result = await sessions.using(sessionId, { source: 'gateway' }, async (reference) => {
2189
+ const face = reference?.binding?.session
2190
+ if (face === undefined || typeof face.prompt !== 'function') {
2191
+ throw Object.assign(new Error('session face has no prompt()'), { code: 'no-session-face' })
2192
+ }
2193
+ return face.prompt(parts, 'queue')
2194
+ })
2195
+ accepted(result)
2196
+ return { mode: 'session', via: 'sessions.using' }
2197
+ } catch (error) {
2198
+ if (error?.code === 'prompt-rejected') throw error
2199
+ console.warn(`[${PLUGIN_ID}] sessions.using 提交失败,尝试下一级:`, error)
2200
+ }
2191
2201
  }
2192
2202
 
2193
2203
  // Rung 2 — `retain()` + explicit release: the same operation spelled out,
@@ -2307,8 +2317,26 @@ window.__ModuleLoader__.load({
2307
2317
  cardId: null,
2308
2318
  open(card) {
2309
2319
  if (!this.available() || typeof this.controller.openTab !== 'function') return false
2320
+ // 同一个 kind 在原生栏里只有一个地址(见 cardId 的注释):先把栏里
2321
+ // 其它卡片迁回浮层,否则它们既不在栏里也不在浮层上,流式输出还会
2322
+ // 继续往一张看不见的卡片里写。
2323
+ for (const other of store.state.cards) {
2324
+ if (other.id !== card.id && other.surface === 'native-rightbar') {
2325
+ // `open: true` is load-bearing: the floating layer only mounts a
2326
+ // card whose `surface === 'flow'` AND `open` is true, so migrating
2327
+ // a card back to the flow while leaving it folded hid it on BOTH
2328
+ // surfaces — not in the rail, not on screen, still streaming.
2329
+ patchCard(other.id, { surface: 'flow', open: true })
2330
+ }
2331
+ }
2310
2332
  this.cardId = card.id
2311
- this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
2333
+ try {
2334
+ this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
2335
+ } catch (error) {
2336
+ // 开栏失败就把地址回滚,免得 CardHost 去渲染一张根本没有 tab 的卡。
2337
+ this.cardId = null
2338
+ throw error
2339
+ }
2312
2340
  return true
2313
2341
  },
2314
2342
  close(card) {
@@ -2319,6 +2347,10 @@ window.__ModuleLoader__.load({
2319
2347
  // and lets CardHost show its closed-card face.
2320
2348
  const controller = this.controller
2321
2349
  if (controller === null || controller === undefined) return false
2350
+ // 归属校验:栏里那条同 kind 的 tab 若正被另一张卡片占用,就不能替它
2351
+ // 关掉(否则关 A 会把 B 的 tab 一起关掉)。cardId === null 是"清理
2352
+ // 残留 tab"的路径,此时按 kind 关是对的。
2353
+ if (this.cardId !== null && this.cardId !== card.id) return false
2322
2354
  let rows = []
2323
2355
  try {
2324
2356
  rows = typeof controller.openTabs?.getSnapshot === 'function'
@@ -2328,6 +2360,15 @@ window.__ModuleLoader__.load({
2328
2360
  const row = Array.isArray(rows)
2329
2361
  ? rows.find(entry => entry?.kind === CARD_KIND
2330
2362
  && typeof entry?.tabId === 'string' && entry.tabId !== ''
2363
+ // Ownership, not just kind. The tab belongs to this card when the
2364
+ // host echoes the id it was opened with (`openTab` carries it in
2365
+ // `params.cardId`, which the rail publishes as `contentId`), or
2366
+ // when the host reported no id at all AND this adapter still holds
2367
+ // that card's address. Claiming a tab merely because `contentId`
2368
+ // was empty made "close A" close B's tab — and with it B's body.
2369
+ && (entry.contentId === card.id
2370
+ || ((entry.contentId === undefined || entry.contentId === null || entry.contentId === '')
2371
+ && this.cardId === card.id))
2331
2372
  && (typeof entry?.sessionId !== 'string'
2332
2373
  || store.state.sessionId === null
2333
2374
  || entry.sessionId === store.state.sessionId))
@@ -5841,13 +5882,32 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5841
5882
  } catch { return [] }
5842
5883
  }
5843
5884
 
5885
+ /** 是否是「本版本自己写下的空信封」(带 writtenAt 标记)。 */
5886
+ function isOwnedEmptyEnvelope(raw) {
5887
+ try {
5888
+ const value = JSON.parse(raw)
5889
+ return value !== null
5890
+ && typeof value === 'object'
5891
+ && value.version === 3
5892
+ && Array.isArray(value.cards)
5893
+ && Number.isFinite(value.writtenAt)
5894
+ } catch {
5895
+ return false
5896
+ }
5897
+ }
5898
+
5844
5899
  function readPersisted() {
5845
5900
  let raw = null
5846
5901
  try {
5847
5902
  raw = window.localStorage?.getItem(PERSIST_KEY)
5848
5903
  } catch { return [] }
5849
5904
  const v3 = parseEnvelope(raw, 3).slice(0, 50)
5850
- if (v3.length > 0 || (raw !== null && raw !== undefined)) return v3
5905
+ if (v3.length > 0) return v3
5906
+ // 空的 v3 信封只有在「本版本自己写的」时候才算权威:它带 writtenAt,
5907
+ // 说明用户确实把历史清空了,此时绝不能回落到 v2/v1 把旧记录复活。
5908
+ // 而没有 writtenAt 的空信封(旧版本写的 / 迁移中途留下的)或损坏的
5909
+ // JSON 必须继续向下探测 —— 否则 v1、v2 的数据会被永久挡住。
5910
+ if (raw !== null && raw !== undefined && isOwnedEmptyEnvelope(raw)) return v3
5851
5911
  // v2 → v3: v2 entries have no `versions`, so the field is seeded empty.
5852
5912
  let v2raw = null
5853
5913
  try {
@@ -5881,7 +5941,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5881
5941
  let lastError = null
5882
5942
  for (;;) {
5883
5943
  try {
5884
- window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining }))
5944
+ window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining, writtenAt: Date.now() }))
5885
5945
  return remaining
5886
5946
  } catch (error) {
5887
5947
  lastError = error
@@ -5907,7 +5967,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5907
5967
  .filter(card => card.carrier === 'side'
5908
5968
  && (card.status === 'done' || card.status === 'stopped')
5909
5969
  && card.text !== '')
5910
- .slice(0, cap)
5911
5970
  .map(card => ({
5912
5971
  id: card.id,
5913
5972
  question: card.question,
@@ -5923,7 +5982,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5923
5982
  createdAt: Number.isFinite(card.createdAt) ? card.createdAt : Date.now(),
5924
5983
  provider: typeof card.provider === 'string' ? card.provider : '',
5925
5984
  elapsedMs: Number.isFinite(card.elapsedMs) ? card.elapsedMs : null,
5926
- history: Array.isArray(card.history) ? card.history.slice(-12) : [],
5985
+ // 持久化的历史轮次与上下文预算同一个上限(HISTORY_TURNS):
5986
+ // 这里曾经写死 `slice(-12)`,而 followUp 只发 6 轮,多出来的
5987
+ // 6 轮永远不会被读,是纯死重量。
5988
+ history: Array.isArray(card.history) ? card.history.slice(-HISTORY_TURNS) : [],
5927
5989
  // P2-2: kept so a replayed rewrite card still offers "insert".
5928
5990
  actionId: typeof card.actionId === 'string' ? card.actionId.slice(0, 60) : null,
5929
5991
  // P2-5b: bounded, text-capped older answers. `question` is short
@@ -5947,7 +6009,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5947
6009
  store.set({ history: [] })
5948
6010
  return
5949
6011
  }
5950
- const persisted = writePersisted(finished)
6012
+ // #6(2026-10-26 修复):关闭一张卡片只把它从「活跃区」移除,
6013
+ // durable 快照仍然留在 store.history 里。这里过去只从活跃卡片
6014
+ // 重建整个集合再整键覆盖,于是「关掉的那张」会在下一次答题完成
6015
+ // 时被静默抹掉(contract-test 把破坏后的结果当成了期望)。
6016
+ // 现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目(按 id 去重,
6017
+ // 活跃方优先),再按 newest-first 截到 cap。
6018
+ // 删除路径(deleteCard / deleteHistoryCard / 改投主对话 /
6019
+ // clearHistory)都是先改 store.history 再写盘,所以并集不会把它们
6020
+ // 删掉的条目复活。
6021
+ const liveIds = new Set(finished.map(entry => entry.id))
6022
+ const kept = store.state.history.filter(entry => entry !== null
6023
+ && typeof entry === 'object'
6024
+ && typeof entry.id === 'string'
6025
+ && !liveIds.has(entry.id))
6026
+ const merged = [...finished, ...kept]
6027
+ .sort((left, right) => (Number(right.createdAt) || 0) - (Number(left.createdAt) || 0))
6028
+ .slice(0, cap)
6029
+ const persisted = writePersisted(merged)
5951
6030
  store.set({ history: persisted })
5952
6031
  }
5953
6032
 
@@ -6057,6 +6136,26 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6057
6136
  : [],
6058
6137
  })
6059
6138
 
6139
+ /**
6140
+ * Close the right-rail tab when it is showing this card.
6141
+ *
6142
+ * The rail's tab is not derived from `store.cards`: deleting a card (or
6143
+ * a history entry) only removed it from the stores, so a card that lived
6144
+ * in the native rail left its tab behind — a body rendering an id that
6145
+ * no longer exists, until the user closed it by hand. Only the tab this
6146
+ * adapter actually opened is touched; `close` re-checks ownership.
6147
+ * @param {string} id - the card id that is going away.
6148
+ */
6149
+ function releaseNativeTab(id) {
6150
+ const native = surfaces.native
6151
+ if (native === null || native === undefined) return
6152
+ if (native.controller === null || native.controller === undefined) return
6153
+ if (native.cardId !== id) return
6154
+ try {
6155
+ native.close({ id })
6156
+ } catch { /* the rail owns its tab's lifetime */ }
6157
+ }
6158
+
6060
6159
  const actions = {
6061
6160
  openPopover(candidate) {
6062
6161
  // P2-2: the composer must be remembered BEFORE the popover's
@@ -6095,6 +6194,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6095
6194
  const next = store.state.history
6096
6195
  .filter(item => item.id !== id)
6097
6196
  .slice(0, historyCap())
6197
+ // A deleted entry must not keep a right-rail tab alive: the tab is
6198
+ // not part of the store, so nothing else would ever close it.
6199
+ releaseNativeTab(id)
6098
6200
  writePersisted(next)
6099
6201
  store.set({
6100
6202
  history: next,
@@ -6122,7 +6224,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6122
6224
  const controller = controllers.get(id)
6123
6225
  if (controller !== undefined) {
6124
6226
  controller.abort()
6125
- void postJson('cancel', { id }).catch(() => {})
6227
+ void postJson('cancel', { id, sessionId: store.state.sessionId }).catch(() => {})
6126
6228
  }
6127
6229
  controllers.delete(id)
6128
6230
  pendingDeltas.delete(id)
@@ -6139,6 +6241,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6139
6241
  surfaces.native.close(card)
6140
6242
  } catch { /* the right rail owns its lifecycle */ }
6141
6243
  }
6244
+ // A card restored from durable history has no live `surface`, so the
6245
+ // branch above cannot see its tab; this checks the adapter's own
6246
+ // address instead and is a no-op when nothing is on the rail.
6247
+ releaseNativeTab(id)
6142
6248
  const next = store.state.history
6143
6249
  .filter(item => item.id !== id)
6144
6250
  .slice(0, historyCap())
@@ -6165,6 +6271,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6165
6271
  * by definition: it is the one action allowed to drop the old keys.
6166
6272
  */
6167
6273
  clearHistory() {
6274
+ // A history-only card that happens to sit on the rail loses its
6275
+ // durable entry here; leaving the tab up would show a body for an
6276
+ // entry the manager no longer has. A card that is still live keeps
6277
+ // its tab — this action does not touch running cards.
6278
+ const railCard = surfaces.native?.cardId
6279
+ if (typeof railCard === 'string' && railCard !== ''
6280
+ && !store.state.cards.some(card => card.id === railCard)) {
6281
+ releaseNativeTab(railCard)
6282
+ }
6168
6283
  try {
6169
6284
  for (const key of [PERSIST_KEY, PERSIST_V2_KEY, PERSIST_LEGACY_KEY]) {
6170
6285
  window.localStorage?.removeItem(key)
@@ -6470,7 +6585,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6470
6585
  // so POST /cancel too. Fire-and-forget on purpose — the card is
6471
6586
  // already stopped locally, and a dead host must not turn the
6472
6587
  // Stop button into a failure.
6473
- void postJson('cancel', { id: card.id }).catch(() => {})
6588
+ void postJson('cancel', { id: card.id, sessionId: store.state.sessionId }).catch(() => {})
6474
6589
  }
6475
6590
  patchCard(card.id, { status: 'stopped' })
6476
6591
  },
@@ -6519,7 +6634,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6519
6634
  controller.abort()
6520
6635
  // Same host-side release as the Stop button: closing a card
6521
6636
  // mid-stream must not leave the child running server-side.
6522
- void postJson('cancel', { id: card.id }).catch(() => {})
6637
+ void postJson('cancel', { id: card.id, sessionId: store.state.sessionId }).catch(() => {})
6523
6638
  }
6524
6639
  controllers.delete(card.id)
6525
6640
  // A closed card's buffered-but-unflushed deltas belong to nobody:
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.3'
48
+ export const PLUGIN_VERSION = '2.1.5'
49
49
 
50
50
  /** Route prefix of the plugin's own API. */
51
51
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -1248,6 +1248,21 @@ export async function sendSmtpMessage(endpoint, mail, { timeoutMs = 20_000, now
1248
1248
  }
1249
1249
  }
1250
1250
 
1251
+ /**
1252
+ * Plain-language hints for the filesystem errnos that actually reach a save on
1253
+ * Windows/macOS/Linux. The raw `EACCES: permission denied, open '…'` line is
1254
+ * accurate but tells a user nothing about what to do; the code stays on the
1255
+ * wire (clients branch on it) and the hint is appended to the message.
1256
+ */
1257
+ const ERRNO_HINTS = {
1258
+ EACCES: '(没有写权限:检查文件是否被其它程序占用,或改用可写目录)',
1259
+ EPERM: '(系统拒绝写入:文件可能被占用或只读)',
1260
+ EROFS: '(所在磁盘是只读的)',
1261
+ ENOSPC: '(磁盘空间不足,设置未保存)',
1262
+ EBUSY: '(文件正被其它进程占用,请稍后重试)',
1263
+ ENOENT: '(目标目录不存在)',
1264
+ }
1265
+
1251
1266
  /**
1252
1267
  * Reduce any thrown value to a wire error the Client can present.
1253
1268
  * @param {unknown} error - the thrown value.
@@ -1258,7 +1273,15 @@ export function toWireError(error, fallbackCode = 'internal') {
1258
1273
  if (error !== null && typeof error === 'object') {
1259
1274
  const code = typeof error.code === 'string' && error.code !== '' ? error.code : undefined
1260
1275
  const message = typeof error.message === 'string' && error.message !== '' ? error.message : undefined
1261
- if (message !== undefined) return { code: code ?? fallbackCode, message }
1276
+ if (message !== undefined) {
1277
+ const hint = code !== undefined && Object.prototype.hasOwnProperty.call(ERRNO_HINTS, code)
1278
+ ? ERRNO_HINTS[code]
1279
+ : undefined
1280
+ return {
1281
+ code: code ?? fallbackCode,
1282
+ message: hint === undefined || message.includes(hint) ? message : `${message}${hint}`,
1283
+ }
1284
+ }
1262
1285
  }
1263
1286
  return { code: fallbackCode, message: String(error) }
1264
1287
  }
@@ -1541,7 +1564,7 @@ function openSse(res) {
1541
1564
  * Build the engine that owns every side-card run.
1542
1565
  * @param {import('@deepseek-ai/cordis').Context} ctx - host plugin context.
1543
1566
  * @param {() => Record<string, unknown>} configOf - live config reader.
1544
- * @returns {{ask: Function, cancel: Function, activeIds: Function, dispose: Function, capabilities: Function}}
1567
+ * @returns {{ask: Function, cancel: Function, dispose: Function, capabilities: Function}}
1545
1568
  */
1546
1569
  function createSideEngine(ctx, configOf) {
1547
1570
  /** id → run record. */
@@ -1630,6 +1653,11 @@ function createSideEngine(ctx, configOf) {
1630
1653
  parent: fields.parent,
1631
1654
  ...(fields.persona === undefined ? {} : { persona: fields.persona }),
1632
1655
  ...(fields.toolFilter === undefined ? {} : { toolFilter: fields.toolFilter }),
1656
+ // 0.2.1-alpha.2+ 的 `request` 与旧 `start(provider, request)` 是同一个
1657
+ // SubagentStartRequest 形状,`agentPreset` 必须一起转发。漏掉这一项
1658
+ // 会让子代理拿到默认全套工具,而 start 帧仍按配置上报
1659
+ // `toolFilter: 'preset'`,卡片于是对用户谎报"工具集由 preset 决定"。
1660
+ ...(fields.agentPreset === undefined ? {} : { agentPreset: fields.agentPreset }),
1633
1661
  },
1634
1662
  })
1635
1663
  return {
@@ -1893,11 +1921,19 @@ function createSideEngine(ctx, configOf) {
1893
1921
  /** Which source produced deltas: process-local frames or durable chunks. */
1894
1922
  let framesSeen = false
1895
1923
  let chunksSeen = false
1896
- let finished = false
1897
1924
 
1898
1925
  /** Detach this run's stream listener (frames are per attempt). */
1899
1926
  let detach = () => {}
1900
- const runRecord = { abort: () => controller.abort(), dispose: undefined }
1927
+ const runRecord = {
1928
+ abort: () => controller.abort(),
1929
+ dispose: undefined,
1930
+ // Which session asked. `runs` is process-global, so `/cancel` needs an
1931
+ // owner to check against: without it any trusted page could abort any
1932
+ // other session's run by guessing (or sniffing) a run id.
1933
+ sessionId: typeof request.sessionId === 'string' && request.sessionId !== ''
1934
+ ? request.sessionId
1935
+ : null,
1936
+ }
1901
1937
  runs.set(id, runRecord)
1902
1938
 
1903
1939
  try {
@@ -2078,7 +2114,6 @@ function createSideEngine(ctx, configOf) {
2078
2114
  })
2079
2115
  return
2080
2116
  }
2081
- finished = true
2082
2117
  sink.send('done', {
2083
2118
  id,
2084
2119
  text,
@@ -2120,10 +2155,28 @@ function createSideEngine(ctx, configOf) {
2120
2155
  }
2121
2156
  }
2122
2157
 
2123
- /** Abort one in-flight run. */
2124
- function cancel(id) {
2158
+ /**
2159
+ * Abort one in-flight run.
2160
+ *
2161
+ * `runs` is process-global: every session shares it. When both sides know the
2162
+ * owning session and they differ, this is a cross-session cancel and is
2163
+ * refused (it reports `false`, exactly like an unknown id, so it does not
2164
+ * confirm that the id exists elsewhere). A request that cannot name its
2165
+ * session is still honoured — older clients and scripted calls must keep
2166
+ * working.
2167
+ * @param {string} id - the run id.
2168
+ * @param {string|null} [sessionId] - the session asking for the cancel.
2169
+ * @returns {boolean} whether a run was aborted.
2170
+ */
2171
+ function cancel(id, sessionId = null) {
2125
2172
  const record = runs.get(id)
2126
2173
  if (record === undefined) return false
2174
+ const owner = record.sessionId
2175
+ if (typeof owner === 'string' && owner !== ''
2176
+ && typeof sessionId === 'string' && sessionId !== ''
2177
+ && owner !== sessionId) {
2178
+ return false
2179
+ }
2127
2180
  record.abort()
2128
2181
  return true
2129
2182
  }
@@ -2140,9 +2193,12 @@ function createSideEngine(ctx, configOf) {
2140
2193
  }
2141
2194
  runs.clear()
2142
2195
  streamListeners.clear()
2196
+ // 第二个流来源的订阅者也要清:只清 streamListeners 会在插件卸载后
2197
+ // 留下仍被 `session/event` 桥持有的闭包(它捕获了整个 engine 状态)。
2198
+ chunkListeners.clear()
2143
2199
  }
2144
2200
 
2145
- return { ask, cancel, dispose, capabilities, activeIds: () => [...runs.keys()] }
2201
+ return { ask, cancel, dispose, capabilities }
2146
2202
  }
2147
2203
 
2148
2204
  // ────────────────────────────────────────────────────────────────────────────
@@ -2594,7 +2650,15 @@ export function apply(ctx, patchConfig) {
2594
2650
  writeError(res, 400, 'bad-request', '缺少 id')
2595
2651
  return
2596
2652
  }
2597
- writeOk(res, { cancelled: engine.cancel(id), active: engine.activeIds() })
2653
+ // 只回本次取消的结果:`runs` 是跨会话的全局表,把它的 id 列表回给
2654
+ // 任意可信页面等于泄露别的会话正在追问的卡片 id(而 cancel 本身也不
2655
+ // 校验归属),Client 侧也没有任何地方消费这个字段。
2656
+ // 归属校验:请求方必须带上自己的 sessionId,与 run 记录不一致时按"没
2657
+ // 找到"处理(不回 403,避免把别的会话的 id 是否存在当成探测面)。
2658
+ const sessionId = typeof body?.sessionId === 'string' && body.sessionId !== ''
2659
+ ? body.sessionId
2660
+ : null
2661
+ writeOk(res, { cancelled: engine.cancel(id, sessionId) })
2598
2662
  }
2599
2663
 
2600
2664
  /** `/config` — merge a partial config into the user layer and persist it. */
@@ -2707,7 +2771,16 @@ export function apply(ctx, patchConfig) {
2707
2771
  /** One request → one response; the dispatcher owns path parsing and the fence. */
2708
2772
  const routeHandler = async (req, res) => {
2709
2773
  if (!isTrustedRequest(req, trustedHostsOf())) {
2710
- writeError(res, 403, 'forbidden', 'forbidden')
2774
+ // 说明"为什么被拒 + 怎么放行":这条 403 只会出现在 DSH 被绑到非回环
2775
+ // 地址、或经反向代理访问时(Host 既不是 loopback,也不在 trustedHosts
2776
+ // 里)。有意不自动信任绑定地址:插件读不到宿主的 bind 配置,猜一个就等于
2777
+ // 自己把 DNS-rebinding 防线拆掉。
2778
+ writeError(
2779
+ res,
2780
+ 403,
2781
+ 'forbidden',
2782
+ 'Host 不在信任范围:请用 http://127.0.0.1:<端口> 访问,或把该地址写进 trustedHosts 配置',
2783
+ )
2711
2784
  return
2712
2785
  }
2713
2786
  // Path parsing runs BEFORE the session gate now, because `ping` must be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",