dsh-approval-consult 1.1.3 → 1.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/README.en.md CHANGED
@@ -53,7 +53,7 @@ notes or reject blindly. This plugin adds a consultation panel on top of the app
53
53
 
54
54
  Requires DSH Web `0.2.1-rc.2` (tested) and Node `>=20`.
55
55
 
56
- Current version **v1.1.3**, published to npm ([`dsh-approval-consult`](https://www.npmjs.com/package/dsh-approval-consult)) and as a [GitHub release](https://github.com/0tests/dsh-approval-consult/releases/tag/v1.1.3).
56
+ Current version **v1.1.5**, published to npm ([`dsh-approval-consult`](https://www.npmjs.com/package/dsh-approval-consult)) and as a [GitHub release](https://github.com/0tests/dsh-approval-consult/releases/tag/v1.1.5).
57
57
 
58
58
  ```bash
59
59
  npm install dsh-approval-consult
@@ -61,7 +61,7 @@ npm install dsh-approval-consult
61
61
 
62
62
  Or install the release tarball:
63
63
 
64
- 1. Download `dsh-approval-consult-1.1.3.tgz` from
64
+ 1. Download `dsh-approval-consult-1.1.5.tgz` from
65
65
  [Releases](https://github.com/0tests/dsh-approval-consult/releases/latest).
66
66
  2. Open DSH Web → **Plugins** → **Add plugin** → **Install third-party plugin** and point it at the
67
67
  tarball's absolute path (or the download URL directly).
package/README.md CHANGED
@@ -37,7 +37,7 @@ Auto 审查(`@deepseek-ai/dsh-experimental-auto-review`)会在工具执行
37
37
 
38
38
  ## 安装
39
39
 
40
- > 当前版本 **v1.1.3**,已发布到 npm([`dsh-approval-consult`](https://www.npmjs.com/package/dsh-approval-consult))和 [GitHub Release](https://github.com/0tests/dsh-approval-consult/releases/tag/v1.1.3)。
40
+ > 当前版本 **v1.1.5**,已发布到 npm([`dsh-approval-consult`](https://www.npmjs.com/package/dsh-approval-consult))和 [GitHub Release](https://github.com/0tests/dsh-approval-consult/releases/tag/v1.1.5)。
41
41
  > `npm install dsh-approval-consult`,或让 Agent 用 `plugin_manager` 安装这个包名;下面也保留了 tarball / 本地目录两种方式。
42
42
 
43
43
  ### 方式 A:npm
@@ -49,10 +49,10 @@ Auto 审查(`@deepseek-ai/dsh-experimental-auto-review`)会在工具执行
49
49
  ### 方式 B:从 Release 安装(tarball)
50
50
 
51
51
  1. 在 [Releases](https://github.com/0tests/dsh-approval-consult/releases/latest) 下载
52
- `dsh-approval-consult-1.1.3.tgz`;
52
+ `dsh-approval-consult-1.1.5.tgz`;
53
53
  2. 打开 DSH Web → 左侧「插件」→「添加插件」→「安装第三方插件」,填这个 tarball 的绝对路径,
54
54
  或直接填下载链接
55
- `https://github.com/0tests/dsh-approval-consult/releases/download/v1.1.3/dsh-approval-consult-1.1.3.tgz`;
55
+ `https://github.com/0tests/dsh-approval-consult/releases/download/v1.1.5/dsh-approval-consult-1.1.5.tgz`;
56
56
  3. 安装完成后点「立即启用」,重启一次 `dsh web` 更稳。
57
57
 
58
58
  ### 方式 C:本地目录(源码 / 开发)
package/lib/impl.js CHANGED
@@ -1278,6 +1278,7 @@ export function apply(ctx, config, runtime) {
1278
1278
 
1279
1279
  /* 上面的 await 会让出执行权:必须在不再 await 的临界段里重新核对审批状态与并发名额,
1280
1280
  否则多个并发请求会一起通过数量检查(安全审查 2026-11-03)。 */
1281
+ refreshReconstructed(record)
1281
1282
  if (record.resolvedAt !== null) {
1282
1283
  throw httpError(409, 'approval-resolved', '这次审批已经结束了,不能继续提问。')
1283
1284
  }
@@ -1394,6 +1395,8 @@ export function apply(ctx, config, runtime) {
1394
1395
  entry.answer = job.text
1395
1396
  entry.truncated = finishKind === 'length'
1396
1397
  entry.usage = job.usage
1398
+ // 模型可能在后台轮询前就结束:提交结果/自动重试前同步审批。
1399
+ refreshReconstructed(record)
1397
1400
  if (job.controller.signal.aborted) {
1398
1401
  job.status = 'cancelled'
1399
1402
  entry.status = 'cancelled'
@@ -1677,6 +1680,8 @@ export function apply(ctx, config, runtime) {
1677
1680
  if (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted') error = messageOf(chunk.reason.failure)
1678
1681
  }
1679
1682
  }
1683
+ refreshReconstructed(record)
1684
+ if (controller.signal.aborted) throw controller.signal.reason ?? new Error('review cancelled')
1680
1685
  review.text = text
1681
1686
  review.decisionText = text.trim()
1682
1687
  review.decision = parseReviewDecision(review.decisionText)
@@ -1833,7 +1838,8 @@ export function apply(ctx, config, runtime) {
1833
1838
  const key = typeof input?.consultKey === 'string' ? input.consultKey : ''
1834
1839
  if (key.length > 0) {
1835
1840
  const direct = state.pendings.get(key)
1836
- if (direct !== undefined) return direct
1841
+ // 命中缓存也要同步状态:重建记录可能在别处已经被允许/拒绝(安全复查 2026-11-04)。
1842
+ if (direct !== undefined) return refreshReconstructed(direct)
1837
1843
  }
1838
1844
  const sessionId = input?.sessionId === undefined || input.sessionId === null ? '' : String(input.sessionId)
1839
1845
  if (sessionId.length === 0) return null
@@ -1842,56 +1848,60 @@ export function apply(ctx, config, runtime) {
1842
1848
  const keys = state.pendingBySession.get(sessionId)
1843
1849
  if (!Array.isArray(keys) || keys.length === 0) {
1844
1850
  const rebuilt = reconstructPending(sessionId, callId.length > 0 ? callId : undefined, toolName.length > 0 ? toolName : undefined)
1845
- if (rebuilt !== null) refreshReconstructed(rebuilt)
1846
- return rebuilt
1851
+ return rebuilt === null ? null : refreshReconstructed(rebuilt)
1847
1852
  }
1848
1853
  for (let index = keys.length - 1; index >= 0; index -= 1) {
1849
1854
  const record = state.pendings.get(keys[index])
1850
1855
  if (record === undefined || record.resolvedAt !== null) continue
1851
1856
  if (callId.length > 0) {
1852
- if (record.callId === callId) return record
1857
+ if (record.callId === callId) return refreshReconstructed(record)
1853
1858
  continue
1854
1859
  }
1855
1860
  if (toolName.length > 0) {
1856
- if (record.toolName === toolName) return record
1861
+ if (record.toolName === toolName) return refreshReconstructed(record)
1857
1862
  continue
1858
1863
  }
1859
- return record
1864
+ return refreshReconstructed(record)
1860
1865
  }
1861
1866
  for (let index = keys.length - 1; index >= 0; index -= 1) {
1862
1867
  const record = state.pendings.get(keys[index])
1863
1868
  if (record === undefined) continue
1864
1869
  if (callId.length > 0 && record.callId !== callId) continue
1865
1870
  if (callId.length === 0 && toolName.length > 0 && record.toolName !== toolName) continue
1866
- return record
1871
+ return refreshReconstructed(record)
1867
1872
  }
1868
1873
  // 记录可能因插件热重载/重启丢失:从会话日志里的 approval/asked(尚无 approval/decided)重建。
1869
1874
  const rebuilt = reconstructPending(sessionId, callId.length > 0 ? callId : undefined, toolName.length > 0 ? toolName : undefined)
1870
- if (rebuilt !== null) refreshReconstructed(rebuilt)
1871
- return rebuilt
1875
+ return rebuilt === null ? null : refreshReconstructed(rebuilt)
1872
1876
  }
1873
1877
 
1874
1878
  /**
1875
1879
  * 重建出来的记录没有连接到 approval/request 的 next(),需要自己看日志确认是否已经有结果,
1876
- * 否则用户一旦在别处决定,面板会一直停在“等待审批”。
1880
+ * 否则用户一旦在别处决定,面板会一直停在“等待审批”(安全复查 2026-11-04)。
1881
+ * 命中结果时统一走 finalizePending,确保中止侧调用、失效图片引用等清理一致。
1877
1882
  */
1878
- function refreshReconstructed(record) {
1879
- if (record.reconstructed !== true || record.resolvedAt !== null) return
1880
- if (record.approvalId === undefined) return
1883
+ function refreshReconstructed(record, decisionsBySession) {
1884
+ if (record === null || record === undefined) return record
1885
+ if (record.reconstructed !== true || record.resolvedAt !== null) return record
1886
+ if (record.approvalId === undefined) return record
1881
1887
  try {
1882
- const agent = liveAgentBySessionId(record.sessionId)
1883
- if (agent === null || typeof agent.session?.snapshotEvents !== 'function') return
1884
- const events = agent.session.snapshotEvents()
1885
- for (const event of events) {
1886
- if (event?.type !== 'approval/decided') continue
1887
- if (event.data?.id === undefined || String(event.data.id) !== record.approvalId) continue
1888
- record.resolvedAt = Date.now()
1889
- record.outcome = typeof event.data?.outcome === 'string' ? event.data.outcome : null
1890
- return
1888
+ let decisions = decisionsBySession?.get(record.sessionId)
1889
+ if (decisions === undefined) {
1890
+ const agent = liveAgentBySessionId(record.sessionId)
1891
+ if (agent === null || typeof agent.session?.snapshotEvents !== 'function') return record
1892
+ decisions = new Map()
1893
+ for (const event of agent.session.snapshotEvents()) {
1894
+ if (event?.type !== 'approval/decided' || event.data?.id === undefined) continue
1895
+ decisions.set(String(event.data.id), typeof event.data?.outcome === 'string' ? event.data.outcome : null)
1896
+ }
1897
+ // 后台一轮检查中,同一会话只读取和索引一次日志。
1898
+ decisionsBySession?.set(record.sessionId, decisions)
1891
1899
  }
1892
- } catch {
1893
- /* ignore */
1900
+ if (decisions.has(record.approvalId)) finalizePending(record, decisions.get(record.approvalId), null)
1901
+ } catch (error) {
1902
+ noteError('refresh-reconstructed', error)
1894
1903
  }
1904
+ return record
1895
1905
  }
1896
1906
 
1897
1907
  /**
@@ -2234,6 +2244,7 @@ export function apply(ctx, config, runtime) {
2234
2244
  const jobId = typeof query.jobId === 'string' ? query.jobId : ''
2235
2245
  const job = state.jobs.get(jobId)
2236
2246
  if (job === undefined) throw httpError(404, 'job-not-found', '这次回答已经过期或不存在。')
2247
+ refreshReconstructed(state.pendings.get(job.consultKey))
2237
2248
  const cursor = intIn(query.cursor, 0, 0, Number.MAX_SAFE_INTEGER)
2238
2249
  const safeCursor = Math.min(cursor, job.text.length)
2239
2250
  return {
@@ -2500,7 +2511,7 @@ export function apply(ctx, config, runtime) {
2500
2511
  throw httpError(404, 'attachment-unknown', '这张图片已经失效,请重新上传。')
2501
2512
  }
2502
2513
  // 审批结束后图片引用必须立即失效(finalizePending 会清理,这里兜底防止竞态残留)。
2503
- const pendingForImage = state.pendings.get(consultKey)
2514
+ const pendingForImage = refreshReconstructed(state.pendings.get(consultKey))
2504
2515
  if (pendingForImage === undefined || pendingForImage.resolvedAt !== null) {
2505
2516
  throw httpError(404, 'attachment-unknown', '这张图片已经失效,请重新上传。')
2506
2517
  }
@@ -2513,6 +2524,11 @@ export function apply(ctx, config, runtime) {
2513
2524
  } catch (error) {
2514
2525
  throw mapAttachmentError(error)
2515
2526
  }
2527
+ // readImage 等待期间也可能结束审批或撤销引用,发送字节前再次核对。
2528
+ refreshReconstructed(pendingForImage)
2529
+ if (pendingForImage.resolvedAt !== null || state.attachments.get(id) !== item || Date.now() > item.expiresAt) {
2530
+ throw httpError(404, 'attachment-unknown', '这张图片已经失效,请重新上传。')
2531
+ }
2516
2532
  sendImage(res, stored.data, stored.ref?.mediaType ?? item.ref.mediaType)
2517
2533
  return
2518
2534
  }
@@ -2542,6 +2558,7 @@ export function apply(ctx, config, runtime) {
2542
2558
  const data = await readBinaryBody(req, limit)
2543
2559
  if (data.length === 0) throw httpError(400, 'attachment-bad-request', '图片内容为空。')
2544
2560
  // 读取期间审批可能已经结束:结束后的图片一律不再登记(否则会“复活”已失效引用)。
2561
+ refreshReconstructed(record)
2545
2562
  if (record.resolvedAt !== null) {
2546
2563
  throw httpError(409, 'approval-resolved', '这次审批已经结束,不能再上传图片。')
2547
2564
  }
@@ -2557,6 +2574,7 @@ export function apply(ctx, config, runtime) {
2557
2574
  }
2558
2575
  // saveImage 也可能耗时:登记前再复核一次;若此时审批已结束,本次保存的宿主对象
2559
2576
  // 没有对应引用(宿主接口未提供删除语义),但绝不会被登记成可读取的附件。
2577
+ refreshReconstructed(record)
2560
2578
  if (record.resolvedAt !== null) {
2561
2579
  throw httpError(409, 'approval-resolved', '这次审批已经结束,不能再上传图片。')
2562
2580
  }
@@ -3452,6 +3470,14 @@ export function apply(ctx, config, runtime) {
3452
3470
  }
3453
3471
  }
3454
3472
 
3473
+ // 重建记录没有原审批的完成回调:即使面板关闭,也要取消侧调用并失效附件。
3474
+ // 后台延迟为一次轮询间隔(正常调度下约 1 秒);敏感路由另做即时复核。
3475
+ const reconstructedTimer = setInterval(() => {
3476
+ const decisionsBySession = new Map()
3477
+ for (const record of state.pendings.values()) refreshReconstructed(record, decisionsBySession)
3478
+ }, 1000)
3479
+ if (typeof reconstructedTimer.unref === 'function') reconstructedTimer.unref()
3480
+ addDisposer(() => clearInterval(reconstructedTimer))
3455
3481
 
3456
3482
  const sweepTimer = setInterval(() => {
3457
3483
  try {
@@ -3524,6 +3550,11 @@ export function apply(ctx, config, runtime) {
3524
3550
  }
3525
3551
  }
3526
3552
  disposers.length = 0
3553
+ for (const record of state.pendings.values()) {
3554
+ for (const review of [record.review, record.advisoryReview]) {
3555
+ review?.classifyController?.abort(new Error('approval-consult unloaded'))
3556
+ }
3557
+ }
3527
3558
  for (const job of state.jobs.values()) {
3528
3559
  if (job.status !== 'streaming') continue
3529
3560
  job.status = 'cancelled'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-approval-consult",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Approval consultation panel for DeepSeek Harness: while a tool call waits for approval, ask the executor AI and the auto-review AI independently, in two isolated channels with prompt-cache reuse.",
@@ -34,7 +34,7 @@
34
34
  "NOTICE.md"
35
35
  ],
36
36
  "scripts": {
37
- "test": "node tests/unit.mjs && node tests/locale-parity.mjs && node tests/css-classes.mjs && node tests/security.mjs",
37
+ "test": "node tests/unit.mjs && node tests/locale-parity.mjs && node tests/css-classes.mjs && node tests/security.mjs && node tests/security-lifecycle.mjs",
38
38
  "check": "node --check lib/impl.js && node --check lib/client.js && node --check lib/index.js"
39
39
  },
40
40
  "engines": {