dsh-sidecard-ask 2.1.3 → 2.1.4

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,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.4
4
+
5
+ **主题:把「技术方案评审团」查出的 11 条缺陷一次修完。四套脚本全绿,新增 21 条回归断言。**
6
+ 排查依据:Software Architect / Application Security Engineer / Reality Checker 三方评审 + 源码复核 + 实测复现。
7
+
8
+ ### 修复
9
+
10
+ - **B1(阻断,静默数据丢失)关闭的卡片在下次答题完成时被抹掉**。`syncPersisted()` 只从
11
+ 「活跃卡片」重建整个持久化集合再整键覆盖,而 `close()` 只把卡片移出活跃区、durable 快照
12
+ 仍留在 `store.state.history` 里,于是关掉的那张被静默删除(`contract-test` 原先把破坏后的
13
+ 结果当成了期望值)。现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目,按 id 去重(活跃方
14
+ 优先)后按 newest-first 截到 cap。删除路径本来就先改 `store.history` 再写盘,并集不会把
15
+ 它们删掉的条目复活。
16
+ - **B2(高,虚假安全保证)`agentPreset` 在 0.2.1-alpha.2+ 的 activation 面没被转发**。
17
+ `startSubagent` 只在旧 `start(provider, request)` 面转发 `agentPreset`,新面的 `request`
18
+ 漏了它 → 子代理拿默认全套工具,而 start 帧仍按配置上报 `toolFilter: 'preset'`,卡片于是
19
+ 对用户谎报「工具集由 preset 决定」。现在两个面都转发。
20
+ - **B3(中高,降级链断裂)Rung 1 `sessions.using()` 没有 try/catch**。它一抛(会话归档、
21
+ 形状漂移,或它内部的 `no-session-face`),整条「改发到主对话」就断在第一档,后面
22
+ `retain` / `inputActions` / `draft` 三档全部不可达。现在与 Rung 2 对称降级,
23
+ `prompt-rejected` 仍照旧抛给调用方(那是用户可见的拒绝,不是通道缺失)。
24
+ - **B4(中,原生栏串卡)第二张卡开进原生右栏时第一张没有迁回浮层**。它只是被盖住——卡片
25
+ 仍标 `native-rightbar`、store 里被过滤掉,流式输出继续往看不见的卡片里写,且 rail 上那张
26
+ 永远关不掉。现在 `open()` 先把栏里其它卡片迁回 `flow`;`close()` 增加归属校验(栏被另一
27
+ 张卡占用时不替它关)与 `contentId` 行匹配(多行时关「属于这张卡」的那行);`openTab`
28
+ 抛错时回滚 `cardId`,避免 CardHost 去渲染一张根本没有 tab 的卡。
29
+ - **B5(中低)`/cancel` 把跨会话的全局 run id 列表回给页面**。`runs` 是跨会话的全局表,
30
+ 回 `active` 等于泄露别的会话正在追问的卡片 id(`cancel` 本身也不校验归属),而 Client
31
+ 从不消费它。现在只回 `{ cancelled }`。
32
+ - **B6(中低,迁移被永久挡住)空的 v3 信封会阻断 v1/v2 迁移**。`raw !== null` 就直接
33
+ return,损坏的 JSON 或旧版本留下的空信封同样挡住。现在写盘时带 `writtenAt` 标记,只有
34
+ 「本版本自己写的空信封」才算权威(用户确实清空了历史,绝不从 v2/v1 复活),没有该标记的
35
+ 空信封与损坏 JSON 继续向下探测。
36
+ - **B7(低,卸载泄漏)`dispose()` 只清了 `streamListeners`,漏了 `chunkListeners`**。第二个
37
+ 流来源的订阅者会留下仍被 `session/event` 桥持有的闭包(它捕获整个 engine 状态)。
38
+ - **B9(低,死重量)持久化写死 `slice(-12)`,而 `followUp` 只发 6 轮**。改成
39
+ `HISTORY_TURNS`,与上下文预算同一个上限。
40
+ - **B10(低,死代码)`let finished = false` 只赋值不读取**,删除。
41
+ - **B11(低,可诊断性)信任围栏的 403 只说 `forbidden`**。现在说明「Host 不在信任范围 +
42
+ 怎么放行(127.0.0.1 或 `trustedHosts`)」。有意**不**自动信任绑定地址:插件读不到宿主的
43
+ bind 配置,猜一个就等于自己拆掉 DNS-rebinding 防线。
44
+ - **B8(条件性,平台限制)配置里的 SMTP 密码以明文落在 `~/.dsh/` 下的用户层 JSON**。
45
+ 写盘已带 `mode: 0o600`(POSIX 生效,Windows 忽略),代码注释与 README 均已注明;
46
+ 无进程内可做的进一步缓解,**保留为已文档化的限制**,建议优先用环境变量。
47
+
48
+ ### 验证
49
+
50
+ `node test/verify.mjs` / `contract-test.mjs` / `float-placement.mjs` / `smoke-test.mjs`:
51
+ **57 / 1446 / 100 / 277 全绿(1880 条,0 失败)**。相对修复前 1859 条新增 21 条回归断言,
52
+ 逐条对应上面的缺陷:关卡片后历史仍在(contract)、activation 面转发 preset 且无能力时
53
+ 不带该字段(smoke)、`using()` 抛错降级而 `prompt-rejected` 仍抛(contract)、空/损坏 v3
54
+ 信封不挡迁移且自有空信封不被复活(contract)、原生栏单地址与关 tab 归属(contract)、
55
+ `/cancel` 不泄露 `active`(smoke)。
56
+
3
57
  ## 2.1.3
4
58
 
5
59
  **主题:折叠态胶囊不再压在聊天输入框上(2026-10-25 实拍报告)。**
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.4'
121
121
 
122
122
  /**
123
123
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -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,22 @@ 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
+ patchCard(other.id, { surface: 'flow' })
2326
+ }
2327
+ }
2310
2328
  this.cardId = card.id
2311
- this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
2329
+ try {
2330
+ this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
2331
+ } catch (error) {
2332
+ // 开栏失败就把地址回滚,免得 CardHost 去渲染一张根本没有 tab 的卡。
2333
+ this.cardId = null
2334
+ throw error
2335
+ }
2312
2336
  return true
2313
2337
  },
2314
2338
  close(card) {
@@ -2319,6 +2343,10 @@ window.__ModuleLoader__.load({
2319
2343
  // and lets CardHost show its closed-card face.
2320
2344
  const controller = this.controller
2321
2345
  if (controller === null || controller === undefined) return false
2346
+ // 归属校验:栏里那条同 kind 的 tab 若正被另一张卡片占用,就不能替它
2347
+ // 关掉(否则关 A 会把 B 的 tab 一起关掉)。cardId === null 是"清理
2348
+ // 残留 tab"的路径,此时按 kind 关是对的。
2349
+ if (this.cardId !== null && this.cardId !== card.id) return false
2322
2350
  let rows = []
2323
2351
  try {
2324
2352
  rows = typeof controller.openTabs?.getSnapshot === 'function'
@@ -2328,6 +2356,12 @@ window.__ModuleLoader__.load({
2328
2356
  const row = Array.isArray(rows)
2329
2357
  ? rows.find(entry => entry?.kind === CARD_KIND
2330
2358
  && typeof entry?.tabId === 'string' && entry.tabId !== ''
2359
+ // 单地址模型下这里只该有一行;万一宿主保留了多行,必须关"属于这张
2360
+ // 卡片"的那一行(openTab 把 cardId 写进 params → contentId),
2361
+ // 否则会替另一张卡片关掉它的 tab。
2362
+ && (typeof entry?.contentId !== 'string'
2363
+ || entry.contentId === ''
2364
+ || entry.contentId === card.id)
2331
2365
  && (typeof entry?.sessionId !== 'string'
2332
2366
  || store.state.sessionId === null
2333
2367
  || entry.sessionId === store.state.sessionId))
@@ -5841,13 +5875,32 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5841
5875
  } catch { return [] }
5842
5876
  }
5843
5877
 
5878
+ /** 是否是「本版本自己写下的空信封」(带 writtenAt 标记)。 */
5879
+ function isOwnedEmptyEnvelope(raw) {
5880
+ try {
5881
+ const value = JSON.parse(raw)
5882
+ return value !== null
5883
+ && typeof value === 'object'
5884
+ && value.version === 3
5885
+ && Array.isArray(value.cards)
5886
+ && Number.isFinite(value.writtenAt)
5887
+ } catch {
5888
+ return false
5889
+ }
5890
+ }
5891
+
5844
5892
  function readPersisted() {
5845
5893
  let raw = null
5846
5894
  try {
5847
5895
  raw = window.localStorage?.getItem(PERSIST_KEY)
5848
5896
  } catch { return [] }
5849
5897
  const v3 = parseEnvelope(raw, 3).slice(0, 50)
5850
- if (v3.length > 0 || (raw !== null && raw !== undefined)) return v3
5898
+ if (v3.length > 0) return v3
5899
+ // 空的 v3 信封只有在「本版本自己写的」时候才算权威:它带 writtenAt,
5900
+ // 说明用户确实把历史清空了,此时绝不能回落到 v2/v1 把旧记录复活。
5901
+ // 而没有 writtenAt 的空信封(旧版本写的 / 迁移中途留下的)或损坏的
5902
+ // JSON 必须继续向下探测 —— 否则 v1、v2 的数据会被永久挡住。
5903
+ if (raw !== null && raw !== undefined && isOwnedEmptyEnvelope(raw)) return v3
5851
5904
  // v2 → v3: v2 entries have no `versions`, so the field is seeded empty.
5852
5905
  let v2raw = null
5853
5906
  try {
@@ -5881,7 +5934,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5881
5934
  let lastError = null
5882
5935
  for (;;) {
5883
5936
  try {
5884
- window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining }))
5937
+ window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining, writtenAt: Date.now() }))
5885
5938
  return remaining
5886
5939
  } catch (error) {
5887
5940
  lastError = error
@@ -5907,7 +5960,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5907
5960
  .filter(card => card.carrier === 'side'
5908
5961
  && (card.status === 'done' || card.status === 'stopped')
5909
5962
  && card.text !== '')
5910
- .slice(0, cap)
5911
5963
  .map(card => ({
5912
5964
  id: card.id,
5913
5965
  question: card.question,
@@ -5923,7 +5975,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5923
5975
  createdAt: Number.isFinite(card.createdAt) ? card.createdAt : Date.now(),
5924
5976
  provider: typeof card.provider === 'string' ? card.provider : '',
5925
5977
  elapsedMs: Number.isFinite(card.elapsedMs) ? card.elapsedMs : null,
5926
- history: Array.isArray(card.history) ? card.history.slice(-12) : [],
5978
+ // 持久化的历史轮次与上下文预算同一个上限(HISTORY_TURNS):
5979
+ // 这里曾经写死 `slice(-12)`,而 followUp 只发 6 轮,多出来的
5980
+ // 6 轮永远不会被读,是纯死重量。
5981
+ history: Array.isArray(card.history) ? card.history.slice(-HISTORY_TURNS) : [],
5927
5982
  // P2-2: kept so a replayed rewrite card still offers "insert".
5928
5983
  actionId: typeof card.actionId === 'string' ? card.actionId.slice(0, 60) : null,
5929
5984
  // P2-5b: bounded, text-capped older answers. `question` is short
@@ -5947,7 +6002,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5947
6002
  store.set({ history: [] })
5948
6003
  return
5949
6004
  }
5950
- const persisted = writePersisted(finished)
6005
+ // #6(2026-10-26 修复):关闭一张卡片只把它从「活跃区」移除,
6006
+ // durable 快照仍然留在 store.history 里。这里过去只从活跃卡片
6007
+ // 重建整个集合再整键覆盖,于是「关掉的那张」会在下一次答题完成
6008
+ // 时被静默抹掉(contract-test 把破坏后的结果当成了期望)。
6009
+ // 现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目(按 id 去重,
6010
+ // 活跃方优先),再按 newest-first 截到 cap。
6011
+ // 删除路径(deleteCard / deleteHistoryCard / 改投主对话 /
6012
+ // clearHistory)都是先改 store.history 再写盘,所以并集不会把它们
6013
+ // 删掉的条目复活。
6014
+ const liveIds = new Set(finished.map(entry => entry.id))
6015
+ const kept = store.state.history.filter(entry => entry !== null
6016
+ && typeof entry === 'object'
6017
+ && typeof entry.id === 'string'
6018
+ && !liveIds.has(entry.id))
6019
+ const merged = [...finished, ...kept]
6020
+ .sort((left, right) => (Number(right.createdAt) || 0) - (Number(left.createdAt) || 0))
6021
+ .slice(0, cap)
6022
+ const persisted = writePersisted(merged)
5951
6023
  store.set({ history: persisted })
5952
6024
  }
5953
6025
 
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.4'
49
49
 
50
50
  /** Route prefix of the plugin's own API. */
51
51
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -1541,7 +1541,7 @@ function openSse(res) {
1541
1541
  * Build the engine that owns every side-card run.
1542
1542
  * @param {import('@deepseek-ai/cordis').Context} ctx - host plugin context.
1543
1543
  * @param {() => Record<string, unknown>} configOf - live config reader.
1544
- * @returns {{ask: Function, cancel: Function, activeIds: Function, dispose: Function, capabilities: Function}}
1544
+ * @returns {{ask: Function, cancel: Function, dispose: Function, capabilities: Function}}
1545
1545
  */
1546
1546
  function createSideEngine(ctx, configOf) {
1547
1547
  /** id → run record. */
@@ -1630,6 +1630,11 @@ function createSideEngine(ctx, configOf) {
1630
1630
  parent: fields.parent,
1631
1631
  ...(fields.persona === undefined ? {} : { persona: fields.persona }),
1632
1632
  ...(fields.toolFilter === undefined ? {} : { toolFilter: fields.toolFilter }),
1633
+ // 0.2.1-alpha.2+ 的 `request` 与旧 `start(provider, request)` 是同一个
1634
+ // SubagentStartRequest 形状,`agentPreset` 必须一起转发。漏掉这一项
1635
+ // 会让子代理拿到默认全套工具,而 start 帧仍按配置上报
1636
+ // `toolFilter: 'preset'`,卡片于是对用户谎报"工具集由 preset 决定"。
1637
+ ...(fields.agentPreset === undefined ? {} : { agentPreset: fields.agentPreset }),
1633
1638
  },
1634
1639
  })
1635
1640
  return {
@@ -1893,7 +1898,6 @@ function createSideEngine(ctx, configOf) {
1893
1898
  /** Which source produced deltas: process-local frames or durable chunks. */
1894
1899
  let framesSeen = false
1895
1900
  let chunksSeen = false
1896
- let finished = false
1897
1901
 
1898
1902
  /** Detach this run's stream listener (frames are per attempt). */
1899
1903
  let detach = () => {}
@@ -2078,7 +2082,6 @@ function createSideEngine(ctx, configOf) {
2078
2082
  })
2079
2083
  return
2080
2084
  }
2081
- finished = true
2082
2085
  sink.send('done', {
2083
2086
  id,
2084
2087
  text,
@@ -2140,9 +2143,12 @@ function createSideEngine(ctx, configOf) {
2140
2143
  }
2141
2144
  runs.clear()
2142
2145
  streamListeners.clear()
2146
+ // 第二个流来源的订阅者也要清:只清 streamListeners 会在插件卸载后
2147
+ // 留下仍被 `session/event` 桥持有的闭包(它捕获了整个 engine 状态)。
2148
+ chunkListeners.clear()
2143
2149
  }
2144
2150
 
2145
- return { ask, cancel, dispose, capabilities, activeIds: () => [...runs.keys()] }
2151
+ return { ask, cancel, dispose, capabilities }
2146
2152
  }
2147
2153
 
2148
2154
  // ────────────────────────────────────────────────────────────────────────────
@@ -2594,7 +2600,10 @@ export function apply(ctx, patchConfig) {
2594
2600
  writeError(res, 400, 'bad-request', '缺少 id')
2595
2601
  return
2596
2602
  }
2597
- writeOk(res, { cancelled: engine.cancel(id), active: engine.activeIds() })
2603
+ // 只回本次取消的结果:`runs` 是跨会话的全局表,把它的 id 列表回给
2604
+ // 任意可信页面等于泄露别的会话正在追问的卡片 id(而 cancel 本身也不
2605
+ // 校验归属),Client 侧也没有任何地方消费这个字段。
2606
+ writeOk(res, { cancelled: engine.cancel(id) })
2598
2607
  }
2599
2608
 
2600
2609
  /** `/config` — merge a partial config into the user layer and persist it. */
@@ -2707,7 +2716,16 @@ export function apply(ctx, patchConfig) {
2707
2716
  /** One request → one response; the dispatcher owns path parsing and the fence. */
2708
2717
  const routeHandler = async (req, res) => {
2709
2718
  if (!isTrustedRequest(req, trustedHostsOf())) {
2710
- writeError(res, 403, 'forbidden', 'forbidden')
2719
+ // 说明"为什么被拒 + 怎么放行":这条 403 只会出现在 DSH 被绑到非回环
2720
+ // 地址、或经反向代理访问时(Host 既不是 loopback,也不在 trustedHosts
2721
+ // 里)。有意不自动信任绑定地址:插件读不到宿主的 bind 配置,猜一个就等于
2722
+ // 自己把 DNS-rebinding 防线拆掉。
2723
+ writeError(
2724
+ res,
2725
+ 403,
2726
+ 'forbidden',
2727
+ 'Host 不在信任范围:请用 http://127.0.0.1:<端口> 访问,或把该地址写进 trustedHosts 配置',
2728
+ )
2711
2729
  return
2712
2730
  }
2713
2731
  // 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.4",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",