dsh-sidecard-ask 2.1.4 → 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,42 @@
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
+
3
40
  ## 2.1.4
4
41
 
5
42
  **主题:把「技术方案评审团」查出的 11 条缺陷一次修完。四套脚本全绿,新增 21 条回归断言。**
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.4'
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',
@@ -2322,7 +2322,11 @@ window.__ModuleLoader__.load({
2322
2322
  // 继续往一张看不见的卡片里写。
2323
2323
  for (const other of store.state.cards) {
2324
2324
  if (other.id !== card.id && other.surface === 'native-rightbar') {
2325
- patchCard(other.id, { surface: 'flow' })
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 })
2326
2330
  }
2327
2331
  }
2328
2332
  this.cardId = card.id
@@ -2356,12 +2360,15 @@ window.__ModuleLoader__.load({
2356
2360
  const row = Array.isArray(rows)
2357
2361
  ? rows.find(entry => entry?.kind === CARD_KIND
2358
2362
  && 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)
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))
2365
2372
  && (typeof entry?.sessionId !== 'string'
2366
2373
  || store.state.sessionId === null
2367
2374
  || entry.sessionId === store.state.sessionId))
@@ -6129,6 +6136,26 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6129
6136
  : [],
6130
6137
  })
6131
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
+
6132
6159
  const actions = {
6133
6160
  openPopover(candidate) {
6134
6161
  // P2-2: the composer must be remembered BEFORE the popover's
@@ -6167,6 +6194,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6167
6194
  const next = store.state.history
6168
6195
  .filter(item => item.id !== id)
6169
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)
6170
6200
  writePersisted(next)
6171
6201
  store.set({
6172
6202
  history: next,
@@ -6194,7 +6224,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6194
6224
  const controller = controllers.get(id)
6195
6225
  if (controller !== undefined) {
6196
6226
  controller.abort()
6197
- void postJson('cancel', { id }).catch(() => {})
6227
+ void postJson('cancel', { id, sessionId: store.state.sessionId }).catch(() => {})
6198
6228
  }
6199
6229
  controllers.delete(id)
6200
6230
  pendingDeltas.delete(id)
@@ -6211,6 +6241,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6211
6241
  surfaces.native.close(card)
6212
6242
  } catch { /* the right rail owns its lifecycle */ }
6213
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)
6214
6248
  const next = store.state.history
6215
6249
  .filter(item => item.id !== id)
6216
6250
  .slice(0, historyCap())
@@ -6237,6 +6271,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6237
6271
  * by definition: it is the one action allowed to drop the old keys.
6238
6272
  */
6239
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
+ }
6240
6283
  try {
6241
6284
  for (const key of [PERSIST_KEY, PERSIST_V2_KEY, PERSIST_LEGACY_KEY]) {
6242
6285
  window.localStorage?.removeItem(key)
@@ -6542,7 +6585,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6542
6585
  // so POST /cancel too. Fire-and-forget on purpose — the card is
6543
6586
  // already stopped locally, and a dead host must not turn the
6544
6587
  // Stop button into a failure.
6545
- void postJson('cancel', { id: card.id }).catch(() => {})
6588
+ void postJson('cancel', { id: card.id, sessionId: store.state.sessionId }).catch(() => {})
6546
6589
  }
6547
6590
  patchCard(card.id, { status: 'stopped' })
6548
6591
  },
@@ -6591,7 +6634,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6591
6634
  controller.abort()
6592
6635
  // Same host-side release as the Stop button: closing a card
6593
6636
  // mid-stream must not leave the child running server-side.
6594
- void postJson('cancel', { id: card.id }).catch(() => {})
6637
+ void postJson('cancel', { id: card.id, sessionId: store.state.sessionId }).catch(() => {})
6595
6638
  }
6596
6639
  controllers.delete(card.id)
6597
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.4'
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
  }
@@ -1901,7 +1924,16 @@ function createSideEngine(ctx, configOf) {
1901
1924
 
1902
1925
  /** Detach this run's stream listener (frames are per attempt). */
1903
1926
  let detach = () => {}
1904
- 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
+ }
1905
1937
  runs.set(id, runRecord)
1906
1938
 
1907
1939
  try {
@@ -2123,10 +2155,28 @@ function createSideEngine(ctx, configOf) {
2123
2155
  }
2124
2156
  }
2125
2157
 
2126
- /** Abort one in-flight run. */
2127
- 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) {
2128
2172
  const record = runs.get(id)
2129
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
+ }
2130
2180
  record.abort()
2131
2181
  return true
2132
2182
  }
@@ -2603,7 +2653,12 @@ export function apply(ctx, patchConfig) {
2603
2653
  // 只回本次取消的结果:`runs` 是跨会话的全局表,把它的 id 列表回给
2604
2654
  // 任意可信页面等于泄露别的会话正在追问的卡片 id(而 cancel 本身也不
2605
2655
  // 校验归属),Client 侧也没有任何地方消费这个字段。
2606
- writeOk(res, { cancelled: engine.cancel(id) })
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) })
2607
2662
  }
2608
2663
 
2609
2664
  /** `/config` — merge a partial config into the user layer and persist it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",