dsh-approval-consult 1.1.1 → 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
@@ -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.1**, 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.1).
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
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.1.tgz` from
64
+ 1. Download `dsh-approval-consult-1.1.2.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.1**,已发布到 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.1)。
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
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.1.tgz`;
52
+ `dsh-approval-consult-1.1.2.tgz`;
53
53
  2. 打开 DSH Web → 左侧「插件」→「添加插件」→「安装第三方插件」,填这个 tarball 的绝对路径,
54
54
  或直接填下载链接
55
- `https://github.com/0tests/dsh-approval-consult/releases/download/v1.1.1/dsh-approval-consult-1.1.1.tgz`;
55
+ `https://github.com/0tests/dsh-approval-consult/releases/download/v1.1.2/dsh-approval-consult-1.1.2.tgz`;
56
56
  3. 安装完成后点「立即启用」,重启一次 `dsh web` 更稳。
57
57
 
58
58
  ### 方式 C:本地目录(源码 / 开发)
package/lib/impl.js CHANGED
@@ -492,6 +492,8 @@ export function apply(ctx, config, runtime) {
492
492
  jobs: new Map(),
493
493
  /** uploadId -> 附件引用登记(图片只存引用,字节在 DSH 附件服务里) */
494
494
  attachments: new Map(),
495
+ /** consultKey -> 进行中的上传预占数(先占名额再 saveImage,失败释放) */
496
+ attachmentPending: new Map(),
495
497
  /** 侧调用 options 集合(对象身份),llm/stream 捕获器直接跳过 */
496
498
  sideOptions: new WeakSet(),
497
499
  /** 已被本插件包装/记录过的 options(避免根与 agent 两处监听重复处理) */
@@ -540,6 +542,7 @@ export function apply(ctx, config, runtime) {
540
542
  state = previousState
541
543
  // 瞬态字段一律重建;旧 jobs 已被上一代 dispose 中止。
542
544
  state.jobs = new Map()
545
+ state.attachmentPending = new Map()
543
546
  state.sideOptions = new WeakSet()
544
547
  state.wrappedOptions = new WeakSet()
545
548
  state.agentDisposers = new Map()
@@ -947,8 +950,9 @@ export function apply(ctx, config, runtime) {
947
950
  }
948
951
 
949
952
  /**
950
- * 把这次审批和一条审查记录配对:优先用「审查理由出现在审批理由里」精确匹配,
951
- * 其次用工具名 + 参数一致,最后退化为同 session 下同工具名且未被认领的最近审查。
953
+ * 把这次审批和一条审查记录配对:只接受**可验证的关联**——参数完全一致,或审批理由里
954
+ * 原样包含审查理由。工具名相同不再构成匹配依据,也不跨 session 猜测(安全审查 2026-11-03)。
955
+ * 无法准确关联时返回 null,让面板显示「缺少审查材料」或对当前调用重新评估。
952
956
  */
953
957
  function matchReview(sessionId, toolName, exec, reason) {
954
958
  const list = state.reviews.get(sessionId)
@@ -962,45 +966,26 @@ export function apply(ctx, config, runtime) {
962
966
  const action = review.action
963
967
  if (action === null) continue
964
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
965
979
  let score = 1
966
- if (typeof reason === 'string' && typeof review.decision?.reason === 'string' && review.decision.reason.length > 0) {
967
- if (reason.includes(review.decision.reason)) score += 4
968
- }
969
- if (execArgs !== undefined) {
970
- const reviewArgs = canonicalJson(normalizeArguments(action.arguments))
971
- if (reviewArgs.length > 0 && reviewArgs === execArgs) score += 3
972
- }
980
+ if (reasonHit) score += 4
981
+ if (sameArgs) score += 3
973
982
  if (review.decision !== null) score += 1
974
983
  if (score > bestScore || (score === bestScore && best !== null && review.at > best.at)) {
975
984
  best = review
976
985
  bestScore = score
977
986
  }
978
987
  }
979
- if (best !== null) return best
980
- const sameTool = candidates.filter((review) => review.action?.name === toolName)
981
- if (sameTool.length === 1) return sameTool[0]
982
- // 跨 session 兜底:某些会话的审查请求可能被记到了别的 sessionId 上。
983
- // 只接受高置信匹配(参数完全一致或审查理由原样出现在审批理由里),且必须唯一。
984
- let crossMatch = null
985
- for (const [otherSession, otherList] of state.reviews) {
986
- if (otherSession === sessionId || !Array.isArray(otherList)) continue
987
- for (const review of otherList) {
988
- if (review.claimedBy !== null || review.error !== null) continue
989
- if (review.action === null || review.action.name !== toolName) continue
990
- if (Date.now() - review.at > 120_000) continue
991
- const reviewArgs = canonicalJson(normalizeArguments(review.action.arguments))
992
- const sameArgs = execArgs !== undefined && reviewArgs.length > 0 && reviewArgs === execArgs
993
- const reasonHit =
994
- typeof reason === 'string' &&
995
- typeof review.decision?.reason === 'string' &&
996
- review.decision.reason.length >= 12 &&
997
- reason.includes(review.decision.reason)
998
- if (!sameArgs && !reasonHit) continue
999
- if (crossMatch !== null) return null
1000
- crossMatch = review
1001
- }
1002
- }
1003
- return crossMatch
988
+ return best
1004
989
  }
1005
990
 
1006
991
  /** pending 结束:记录结果、中止仍在流式输出的侧调用。 */
@@ -1025,6 +1010,16 @@ export function apply(ctx, config, runtime) {
1025
1010
  /* ignore */
1026
1011
  }
1027
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
+ }
1028
1023
  log.info(`[approval-consult] pending ${record.key} resolved outcome=${String(record.outcome)}`)
1029
1024
  // 审批结束:立即失效该审批下的图片引用(字节仍由 DSH 附件服务保留,不在这里删)。
1030
1025
  try {
@@ -1279,6 +1274,25 @@ export function apply(ctx, config, runtime) {
1279
1274
  }
1280
1275
  }
1281
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
+
1282
1296
  const entry = {
1283
1297
  id: `q:${nextSeq()}`,
1284
1298
  question,
@@ -1626,37 +1640,55 @@ export function apply(ctx, config, runtime) {
1626
1640
  if (review.decision !== null) {
1627
1641
  return { review, reused: true }
1628
1642
  }
1629
- const controller = new AbortController()
1630
- const timer = setTimeout(() => controller.abort(new Error('advisory review timeout')), 90_000)
1631
- const options = {
1632
- ...review.options,
1633
- maxTokens: 1800,
1634
- signal: controller.signal,
1643
+ if (record.resolvedAt !== null && record.resolvedAt !== undefined) {
1644
+ throw httpError(409, 'approval-resolved', '这次审批已经结束了,不能继续重新评估。')
1635
1645
  }
1636
- state.sideOptions.add(options)
1637
- state.stats.sideCalls += 1
1638
- try {
1639
- for await (const chunk of ctx.llm.stream(options)) {
1640
- if (!chunk || typeof chunk !== 'object') continue
1641
- if (chunk.type === 'text-delta' && typeof chunk.text === 'string') review.text += chunk.text
1642
- else if (chunk.type === 'block-end' && chunk.block?.type === 'text' && typeof chunk.block.text === 'string' && review.text.length === 0) review.text = chunk.block.text
1643
- else if (chunk.type === 'usage') review.usage = chunk.usage ?? null
1644
- else if (chunk.type === 'finish') {
1645
- review.finishKind = chunk.reason?.kind ?? null
1646
- if (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted') review.error = messageOf(chunk.reason.failure)
1647
- }
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,
1648
1659
  }
1649
- review.decisionText = review.text.trim()
1650
- review.decision = parseReviewDecision(review.decisionText)
1651
- if (review.decision === null && review.error === null) {
1652
- 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
1653
1685
  }
1654
- } catch (error) {
1655
- review.error = friendlySideCallError(error)
1656
- noteError('advisory-classify', error)
1686
+ })()
1687
+ review.classifyPromise = task
1688
+ try {
1689
+ await task
1657
1690
  } finally {
1658
- clearTimeout(timer)
1659
- state.sideOptions.delete(options)
1691
+ review.classifyPromise = null
1660
1692
  }
1661
1693
  return { review, reused: false }
1662
1694
  }
@@ -2487,20 +2519,26 @@ export function apply(ctx, config, runtime) {
2487
2519
  }
2488
2520
  const serviceLimit = Number(attachmentStore.imageLimits?.maxImageBytes)
2489
2521
  const limit = Math.min(cfg.maxAttachmentBytes, Number.isFinite(serviceLimit) && serviceLimit > 0 ? serviceLimit : MAX_ATTACHMENT_BODY_BYTES)
2490
- const data = await readBinaryBody(req, limit)
2491
- if (data.length === 0) throw httpError(400, 'attachment-bad-request', '图片内容为空。')
2492
- let ref = null
2522
+ // 先占名额再读取/保存:超额或并发上传不会浪费宿主存储与解析资源。
2523
+ reserveAttachmentSlot(record.key)
2493
2524
  try {
2494
- ref = await attachmentStore.saveImage({
2495
- data,
2496
- mediaType,
2497
- name: sanitizeAttachmentName(query.get('name')),
2498
- })
2499
- } catch (error) {
2500
- 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)
2501
2541
  }
2502
- const item = registerAttachment(ref, record.key, record.sessionId)
2503
- sendJson(res, 200, { ok: true, attachment: attachmentMeta(item) })
2504
2542
  }),
2505
2543
  )
2506
2544
  addDisposer(
@@ -3230,6 +3268,30 @@ export function apply(ctx, config, runtime) {
3230
3268
  return out
3231
3269
  }
3232
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
+
3233
3295
  /** 登记一张已存入附件服务的图片,返回对外展示的元数据。 */
3234
3296
  function registerAttachment(ref, consultKey, sessionId) {
3235
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.1",
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": {