dsh-approval-consult 1.1.0 → 1.1.2

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
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![ci](https://github.com/0tests/dsh-approval-consult/actions/workflows/ci.yml/badge.svg)](https://github.com/0tests/dsh-approval-consult/actions/workflows/ci.yml)
4
4
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![npm](https://img.shields.io/npm/v/dsh-approval-consult.svg)](https://www.npmjs.com/package/dsh-approval-consult)
5
6
 
6
7
  > **English** · [中文主 README](README.md)
7
8
 
@@ -52,10 +53,15 @@ notes or reject blindly. This plugin adds a consultation panel on top of the app
52
53
 
53
54
  Requires DSH Web `0.2.1-rc.2` (tested) and Node `>=20`.
54
55
 
55
- Current version **v1.1.0** ([release notes](https://github.com/0tests/dsh-approval-consult/releases/tag/v1.1.0)).
56
- The npm package is not published yet — install the release tarball:
56
+ Current version **v1.1.2**, 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.2).
57
57
 
58
- 1. Download `dsh-approval-consult-1.1.0.tgz` from
58
+ ```bash
59
+ npm install dsh-approval-consult
60
+ ```
61
+
62
+ Or install the release tarball:
63
+
64
+ 1. Download `dsh-approval-consult-1.1.2.tgz` from
59
65
  [Releases](https://github.com/0tests/dsh-approval-consult/releases/latest).
60
66
  2. Open DSH Web → **Plugins** → **Add plugin** → **Install third-party plugin** and point it at the
61
67
  tarball's absolute path (or the download URL directly).
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![ci](https://github.com/0tests/dsh-approval-consult/actions/workflows/ci.yml/badge.svg)](https://github.com/0tests/dsh-approval-consult/actions/workflows/ci.yml)
4
4
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![npm](https://img.shields.io/npm/v/dsh-approval-consult.svg)](https://www.npmjs.com/package/dsh-approval-consult)
5
6
 
6
7
  > [English](README.en.md) · 中文
7
8
  >
@@ -36,25 +37,31 @@ Auto 审查(`@deepseek-ai/dsh-experimental-auto-review`)会在工具执行
36
37
 
37
38
  ## 安装
38
39
 
39
- > 当前版本 **v1.1.0**([Release 说明](https://github.com/0tests/dsh-approval-consult/releases/tag/v1.1.0))。
40
- > npm 包尚未发布;先装 Release 里的 tarball 即可,发布 npm 后同样的包名可直接安装。
40
+ > 当前版本 **v1.1.2**,已发布到 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.2)。
41
+ > `npm install dsh-approval-consult`,或让 Agent 用 `plugin_manager` 安装这个包名;下面也保留了 tarball / 本地目录两种方式。
41
42
 
42
- ### 方式 A:从 Release 安装(推荐)
43
+ ### 方式 A:npm
44
+
45
+ 1. `npm install dsh-approval-consult`(或让 Agent 用 `plugin_manager` 的 `install_bundle` 安装 npm 包名);
46
+ 2. 在 DSH Web 的「插件」里启用;
47
+ 3. 重启一次 `dsh web` 更稳。
48
+
49
+ ### 方式 B:从 Release 安装(tarball)
43
50
 
44
51
  1. 在 [Releases](https://github.com/0tests/dsh-approval-consult/releases/latest) 下载
45
- `dsh-approval-consult-1.1.0.tgz`;
52
+ `dsh-approval-consult-1.1.2.tgz`;
46
53
  2. 打开 DSH Web → 左侧「插件」→「添加插件」→「安装第三方插件」,填这个 tarball 的绝对路径,
47
54
  或直接填下载链接
48
- `https://github.com/0tests/dsh-approval-consult/releases/download/v1.1.0/dsh-approval-consult-1.1.0.tgz`;
55
+ `https://github.com/0tests/dsh-approval-consult/releases/download/v1.1.2/dsh-approval-consult-1.1.2.tgz`;
49
56
  3. 安装完成后点「立即启用」,重启一次 `dsh web` 更稳。
50
57
 
51
- ### 方式 B:本地目录(源码 / 开发)
58
+ ### 方式 C:本地目录(源码 / 开发)
52
59
 
53
60
  1. `git clone https://github.com/0tests/dsh-approval-consult`;
54
61
  2. 打开 DSH Web →「插件」→「添加插件」→「安装第三方插件」,输入该目录的绝对路径;
55
62
  3. 安装完成后点「立即启用」。
56
63
 
57
- ### 方式 C:CLI / 会话内
64
+ ### 方式 D:CLI / 会话内
58
65
 
59
66
  让 Agent 用 `plugin_manager` 的 `install_bundle` 安装该 tarball / 目录 / npm 包(需要审批权限)。
60
67
 
package/lib/client.js CHANGED
@@ -960,6 +960,8 @@ window.__ModuleLoader__.load({
960
960
  injectError: turn.injectError ?? null,
961
961
  truncated: turn.truncated === true,
962
962
  jobId: typeof turn.jobId === 'string' && turn.jobId.length > 0 ? turn.jobId : null,
963
+ /** 这条问答附带的图片元数据(/context 刷新后必须保留,否则缩略图会消失)。 */
964
+ attachments: Array.isArray(turn.attachments) ? turn.attachments : [],
963
965
  }))
964
966
  : []
965
967
  return {
package/lib/impl.js CHANGED
@@ -59,6 +59,14 @@ const SIDE_TOOLCALL_CORRECTION =
59
59
  const INJECT_SOURCE_KIND = 'approval-consult'
60
60
  const CONTEXT_SUMMARY_MAX_CHARS = 120
61
61
  const MAX_BODY_BYTES = 64 * 1024
62
+ /** 插件版本(诊断用):从 package.json 读取,避免手写版本号漂移。 */
63
+ const PLUGIN_VERSION = (() => {
64
+ try {
65
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
66
+ } catch {
67
+ return '0.0.0'
68
+ }
69
+ })()
62
70
  /** 图片上传请求体上限(再受 DSH 附件服务的 imageLimits 约束)。 */
63
71
  const MAX_ATTACHMENT_BODY_BYTES = 8 * 1024 * 1024
64
72
  /** 附件引用登记的有效期(审批结束后立即失效,这里是兜底)。 */
@@ -484,6 +492,8 @@ export function apply(ctx, config, runtime) {
484
492
  jobs: new Map(),
485
493
  /** uploadId -> 附件引用登记(图片只存引用,字节在 DSH 附件服务里) */
486
494
  attachments: new Map(),
495
+ /** consultKey -> 进行中的上传预占数(先占名额再 saveImage,失败释放) */
496
+ attachmentPending: new Map(),
487
497
  /** 侧调用 options 集合(对象身份),llm/stream 捕获器直接跳过 */
488
498
  sideOptions: new WeakSet(),
489
499
  /** 已被本插件包装/记录过的 options(避免根与 agent 两处监听重复处理) */
@@ -532,6 +542,7 @@ export function apply(ctx, config, runtime) {
532
542
  state = previousState
533
543
  // 瞬态字段一律重建;旧 jobs 已被上一代 dispose 中止。
534
544
  state.jobs = new Map()
545
+ state.attachmentPending = new Map()
535
546
  state.sideOptions = new WeakSet()
536
547
  state.wrappedOptions = new WeakSet()
537
548
  state.agentDisposers = new Map()
@@ -939,8 +950,9 @@ export function apply(ctx, config, runtime) {
939
950
  }
940
951
 
941
952
  /**
942
- * 把这次审批和一条审查记录配对:优先用「审查理由出现在审批理由里」精确匹配,
943
- * 其次用工具名 + 参数一致,最后退化为同 session 下同工具名且未被认领的最近审查。
953
+ * 把这次审批和一条审查记录配对:只接受**可验证的关联**——参数完全一致,或审批理由里
954
+ * 原样包含审查理由。工具名相同不再构成匹配依据,也不跨 session 猜测(安全审查 2026-11-03)。
955
+ * 无法准确关联时返回 null,让面板显示「缺少审查材料」或对当前调用重新评估。
944
956
  */
945
957
  function matchReview(sessionId, toolName, exec, reason) {
946
958
  const list = state.reviews.get(sessionId)
@@ -954,45 +966,26 @@ export function apply(ctx, config, runtime) {
954
966
  const action = review.action
955
967
  if (action === null) continue
956
968
  if (action.name !== toolName) continue
969
+ const reviewArgs = canonicalJson(normalizeArguments(action.arguments))
970
+ const argsKnown = execArgs !== undefined && reviewArgs.length > 0
971
+ const sameArgs = argsKnown && reviewArgs === execArgs
972
+ const reasonHit =
973
+ typeof reason === 'string' &&
974
+ typeof review.decision?.reason === 'string' &&
975
+ review.decision.reason.length >= 12 &&
976
+ reason.includes(review.decision.reason)
977
+ // 参数已知但不一致、或两者都无法核对:一律不复用(宁可缺材料,不展示错误结论)。
978
+ if (!sameArgs && !reasonHit) continue
957
979
  let score = 1
958
- if (typeof reason === 'string' && typeof review.decision?.reason === 'string' && review.decision.reason.length > 0) {
959
- if (reason.includes(review.decision.reason)) score += 4
960
- }
961
- if (execArgs !== undefined) {
962
- const reviewArgs = canonicalJson(normalizeArguments(action.arguments))
963
- if (reviewArgs.length > 0 && reviewArgs === execArgs) score += 3
964
- }
980
+ if (reasonHit) score += 4
981
+ if (sameArgs) score += 3
965
982
  if (review.decision !== null) score += 1
966
983
  if (score > bestScore || (score === bestScore && best !== null && review.at > best.at)) {
967
984
  best = review
968
985
  bestScore = score
969
986
  }
970
987
  }
971
- if (best !== null) return best
972
- const sameTool = candidates.filter((review) => review.action?.name === toolName)
973
- if (sameTool.length === 1) return sameTool[0]
974
- // 跨 session 兜底:某些会话的审查请求可能被记到了别的 sessionId 上。
975
- // 只接受高置信匹配(参数完全一致或审查理由原样出现在审批理由里),且必须唯一。
976
- let crossMatch = null
977
- for (const [otherSession, otherList] of state.reviews) {
978
- if (otherSession === sessionId || !Array.isArray(otherList)) continue
979
- for (const review of otherList) {
980
- if (review.claimedBy !== null || review.error !== null) continue
981
- if (review.action === null || review.action.name !== toolName) continue
982
- if (Date.now() - review.at > 120_000) continue
983
- const reviewArgs = canonicalJson(normalizeArguments(review.action.arguments))
984
- const sameArgs = execArgs !== undefined && reviewArgs.length > 0 && reviewArgs === execArgs
985
- const reasonHit =
986
- typeof reason === 'string' &&
987
- typeof review.decision?.reason === 'string' &&
988
- review.decision.reason.length >= 12 &&
989
- reason.includes(review.decision.reason)
990
- if (!sameArgs && !reasonHit) continue
991
- if (crossMatch !== null) return null
992
- crossMatch = review
993
- }
994
- }
995
- return crossMatch
988
+ return best
996
989
  }
997
990
 
998
991
  /** pending 结束:记录结果、中止仍在流式输出的侧调用。 */
@@ -1017,6 +1010,16 @@ export function apply(ctx, config, runtime) {
1017
1010
  /* ignore */
1018
1011
  }
1019
1012
  }
1013
+ // 审批结束:中止仍在进行的「重新评估」,避免审批结束后继续计费(安全审查 2026-11-03)。
1014
+ for (const candidate of [record.review, record.advisoryReview]) {
1015
+ const classifyController = candidate?.classifyController ?? null
1016
+ if (classifyController === null) continue
1017
+ try {
1018
+ classifyController.abort(new Error('approval resolved'))
1019
+ } catch {
1020
+ /* ignore */
1021
+ }
1022
+ }
1020
1023
  log.info(`[approval-consult] pending ${record.key} resolved outcome=${String(record.outcome)}`)
1021
1024
  // 审批结束:立即失效该审批下的图片引用(字节仍由 DSH 附件服务保留,不在这里删)。
1022
1025
  try {
@@ -1271,6 +1274,25 @@ export function apply(ctx, config, runtime) {
1271
1274
  }
1272
1275
  }
1273
1276
 
1277
+ /* 上面的 await 会让出执行权:必须在不再 await 的临界段里重新核对审批状态与并发名额,
1278
+ 否则多个并发请求会一起通过数量检查(安全审查 2026-11-03)。 */
1279
+ if (record.resolvedAt !== null) {
1280
+ throw httpError(409, 'approval-resolved', '这次审批已经结束了,不能继续提问。')
1281
+ }
1282
+ if (target === 'reviewer') {
1283
+ const reviewAfterAwait = record.review ?? record.advisoryReview ?? null
1284
+ if (reviewAfterAwait === null || reviewAfterAwait.options === null || reviewAfterAwait.options === undefined) {
1285
+ throw httpError(409, 'reviewer-context-missing', '本次审批的审查材料已失效,请重新评估后再提问。')
1286
+ }
1287
+ if (reviewAfterAwait.advisory === true && reviewAfterAwait.decision === null) {
1288
+ throw httpError(409, 'review-not-initialized', '审查材料已就绪,但还没有重新评估。请先点「让审查 AI 重新评估」,再继续提问。')
1289
+ }
1290
+ }
1291
+ const activeJobsAfterAwait = [...record.jobs].filter((jobId) => state.jobs.get(jobId)?.status === 'streaming').length
1292
+ if (activeJobsAfterAwait >= cfg.maxConcurrentJobs) {
1293
+ throw httpError(429, 'too-many-jobs', `同一审批下最多同时有 ${cfg.maxConcurrentJobs} 个回答在生成,请等其中一个结束。`)
1294
+ }
1295
+
1274
1296
  const entry = {
1275
1297
  id: `q:${nextSeq()}`,
1276
1298
  question,
@@ -1618,37 +1640,55 @@ export function apply(ctx, config, runtime) {
1618
1640
  if (review.decision !== null) {
1619
1641
  return { review, reused: true }
1620
1642
  }
1621
- const controller = new AbortController()
1622
- const timer = setTimeout(() => controller.abort(new Error('advisory review timeout')), 90_000)
1623
- const options = {
1624
- ...review.options,
1625
- maxTokens: 1800,
1626
- signal: controller.signal,
1643
+ if (record.resolvedAt !== null && record.resolvedAt !== undefined) {
1644
+ throw httpError(409, 'approval-resolved', '这次审批已经结束了,不能继续重新评估。')
1627
1645
  }
1628
- state.sideOptions.add(options)
1629
- state.stats.sideCalls += 1
1630
- try {
1631
- for await (const chunk of ctx.llm.stream(options)) {
1632
- if (!chunk || typeof chunk !== 'object') continue
1633
- if (chunk.type === 'text-delta' && typeof chunk.text === 'string') review.text += chunk.text
1634
- else if (chunk.type === 'block-end' && chunk.block?.type === 'text' && typeof chunk.block.text === 'string' && review.text.length === 0) review.text = chunk.block.text
1635
- else if (chunk.type === 'usage') review.usage = chunk.usage ?? null
1636
- else if (chunk.type === 'finish') {
1637
- review.finishKind = chunk.reason?.kind ?? null
1638
- if (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted') review.error = messageOf(chunk.reason.failure)
1639
- }
1646
+ // 同一条记录只允许一个进行中的评估:重复请求共享同一个 Promise,避免重复计费与输出混合。
1647
+ if (review.classifyPromise !== undefined && review.classifyPromise !== null) {
1648
+ await review.classifyPromise
1649
+ return { review, reused: true }
1650
+ }
1651
+ const task = (async () => {
1652
+ const controller = new AbortController()
1653
+ const timer = setTimeout(() => controller.abort(new Error('advisory review timeout')), 90_000)
1654
+ review.classifyController = controller
1655
+ const options = {
1656
+ ...review.options,
1657
+ maxTokens: 1800,
1658
+ signal: controller.signal,
1640
1659
  }
1641
- review.decisionText = review.text.trim()
1642
- review.decision = parseReviewDecision(review.decisionText)
1643
- if (review.decision === null && review.error === null) {
1644
- review.error = `审查输出无法解析:${clip(review.decisionText, 200)}`
1660
+ state.sideOptions.add(options)
1661
+ state.stats.sideCalls += 1
1662
+ try {
1663
+ for await (const chunk of ctx.llm.stream(options)) {
1664
+ if (!chunk || typeof chunk !== 'object') continue
1665
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') review.text += chunk.text
1666
+ else if (chunk.type === 'block-end' && chunk.block?.type === 'text' && typeof chunk.block.text === 'string' && review.text.length === 0) review.text = chunk.block.text
1667
+ else if (chunk.type === 'usage') review.usage = chunk.usage ?? null
1668
+ else if (chunk.type === 'finish') {
1669
+ review.finishKind = chunk.reason?.kind ?? null
1670
+ if (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted') review.error = messageOf(chunk.reason.failure)
1671
+ }
1672
+ }
1673
+ review.decisionText = review.text.trim()
1674
+ review.decision = parseReviewDecision(review.decisionText)
1675
+ if (review.decision === null && review.error === null) {
1676
+ review.error = `审查输出无法解析:${clip(review.decisionText, 200)}`
1677
+ }
1678
+ } catch (error) {
1679
+ review.error = friendlySideCallError(error)
1680
+ noteError('advisory-classify', error)
1681
+ } finally {
1682
+ clearTimeout(timer)
1683
+ state.sideOptions.delete(options)
1684
+ review.classifyController = null
1645
1685
  }
1646
- } catch (error) {
1647
- review.error = friendlySideCallError(error)
1648
- noteError('advisory-classify', error)
1686
+ })()
1687
+ review.classifyPromise = task
1688
+ try {
1689
+ await task
1649
1690
  } finally {
1650
- clearTimeout(timer)
1651
- state.sideOptions.delete(options)
1691
+ review.classifyPromise = null
1652
1692
  }
1653
1693
  return { review, reused: false }
1654
1694
  }
@@ -2479,20 +2519,26 @@ export function apply(ctx, config, runtime) {
2479
2519
  }
2480
2520
  const serviceLimit = Number(attachmentStore.imageLimits?.maxImageBytes)
2481
2521
  const limit = Math.min(cfg.maxAttachmentBytes, Number.isFinite(serviceLimit) && serviceLimit > 0 ? serviceLimit : MAX_ATTACHMENT_BODY_BYTES)
2482
- const data = await readBinaryBody(req, limit)
2483
- if (data.length === 0) throw httpError(400, 'attachment-bad-request', '图片内容为空。')
2484
- let ref = null
2522
+ // 先占名额再读取/保存:超额或并发上传不会浪费宿主存储与解析资源。
2523
+ reserveAttachmentSlot(record.key)
2485
2524
  try {
2486
- ref = await attachmentStore.saveImage({
2487
- data,
2488
- mediaType,
2489
- name: sanitizeAttachmentName(query.get('name')),
2490
- })
2491
- } catch (error) {
2492
- throw mapAttachmentError(error)
2525
+ const data = await readBinaryBody(req, limit)
2526
+ if (data.length === 0) throw httpError(400, 'attachment-bad-request', '图片内容为空。')
2527
+ let ref = null
2528
+ try {
2529
+ ref = await attachmentStore.saveImage({
2530
+ data,
2531
+ mediaType,
2532
+ name: sanitizeAttachmentName(query.get('name')),
2533
+ })
2534
+ } catch (error) {
2535
+ throw mapAttachmentError(error)
2536
+ }
2537
+ const item = registerAttachment(ref, record.key, record.sessionId)
2538
+ sendJson(res, 200, { ok: true, attachment: attachmentMeta(item) })
2539
+ } finally {
2540
+ releaseAttachmentSlot(record.key)
2493
2541
  }
2494
- const item = registerAttachment(ref, record.key, record.sessionId)
2495
- sendJson(res, 200, { ok: true, attachment: attachmentMeta(item) })
2496
2542
  }),
2497
2543
  )
2498
2544
  addDisposer(
@@ -2994,7 +3040,7 @@ export function apply(ctx, config, runtime) {
2994
3040
  })()
2995
3041
  return {
2996
3042
  ok: true,
2997
- version: '1.0.0',
3043
+ version: PLUGIN_VERSION,
2998
3044
  uptimeMs: Math.round(process.uptime() * 1000),
2999
3045
  shim,
3000
3046
  config: {
@@ -3222,6 +3268,30 @@ export function apply(ctx, config, runtime) {
3222
3268
  return out
3223
3269
  }
3224
3270
 
3271
+ /** 该审批下进行中的上传预占数。 */
3272
+ function pendingAttachmentCount(consultKey) {
3273
+ return state.attachmentPending.get(consultKey) ?? 0
3274
+ }
3275
+
3276
+ /**
3277
+ * 上传前预占名额:超额/并发上传不会先 saveImage 再被拒(安全审查 2026-11-03)。
3278
+ * 调用方必须在 finally 里 releaseAttachmentSlot。
3279
+ */
3280
+ function reserveAttachmentSlot(consultKey) {
3281
+ const existing = attachmentsForConsult(consultKey).length
3282
+ const pending = pendingAttachmentCount(consultKey)
3283
+ if (existing + pending >= cfg.maxAttachmentsPerConsult) {
3284
+ throw httpError(413, 'attachment-too-many', `同一审批下最多保留 ${cfg.maxAttachmentsPerConsult} 张图片。`)
3285
+ }
3286
+ state.attachmentPending.set(consultKey, pending + 1)
3287
+ }
3288
+
3289
+ function releaseAttachmentSlot(consultKey) {
3290
+ const pending = pendingAttachmentCount(consultKey)
3291
+ if (pending <= 1) state.attachmentPending.delete(consultKey)
3292
+ else state.attachmentPending.set(consultKey, pending - 1)
3293
+ }
3294
+
3225
3295
  /** 登记一张已存入附件服务的图片,返回对外展示的元数据。 */
3226
3296
  function registerAttachment(ref, consultKey, sessionId) {
3227
3297
  const existing = attachmentsForConsult(consultKey)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-approval-consult",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
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",
37
+ "test": "node tests/unit.mjs && node tests/locale-parity.mjs && node tests/css-classes.mjs && node tests/security.mjs",
38
38
  "check": "node --check lib/impl.js && node --check lib/client.js && node --check lib/index.js"
39
39
  },
40
40
  "engines": {