dsh-approval-consult 1.1.1 → 1.1.3

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.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).
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.3.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.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)。
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.3.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.3/dsh-approval-consult-1.1.3.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,28 @@ 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 (argsKnown && !sameArgs) continue
979
+ // 参数未知时,才退化为「理由原样命中」这一弱证据;两者都无法核对则不复用。
980
+ if (!sameArgs && !reasonHit) continue
965
981
  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
- }
982
+ if (reasonHit) score += 4
983
+ if (sameArgs) score += 3
973
984
  if (review.decision !== null) score += 1
974
985
  if (score > bestScore || (score === bestScore && best !== null && review.at > best.at)) {
975
986
  best = review
976
987
  bestScore = score
977
988
  }
978
989
  }
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
990
+ return best
1004
991
  }
1005
992
 
1006
993
  /** pending 结束:记录结果、中止仍在流式输出的侧调用。 */
@@ -1025,6 +1012,16 @@ export function apply(ctx, config, runtime) {
1025
1012
  /* ignore */
1026
1013
  }
1027
1014
  }
1015
+ // 审批结束:中止仍在进行的「重新评估」,避免审批结束后继续计费(安全审查 2026-11-03)。
1016
+ for (const candidate of [record.review, record.advisoryReview]) {
1017
+ const classifyController = candidate?.classifyController ?? null
1018
+ if (classifyController === null) continue
1019
+ try {
1020
+ classifyController.abort(new Error('approval resolved'))
1021
+ } catch {
1022
+ /* ignore */
1023
+ }
1024
+ }
1028
1025
  log.info(`[approval-consult] pending ${record.key} resolved outcome=${String(record.outcome)}`)
1029
1026
  // 审批结束:立即失效该审批下的图片引用(字节仍由 DSH 附件服务保留,不在这里删)。
1030
1027
  try {
@@ -1279,6 +1276,25 @@ export function apply(ctx, config, runtime) {
1279
1276
  }
1280
1277
  }
1281
1278
 
1279
+ /* 上面的 await 会让出执行权:必须在不再 await 的临界段里重新核对审批状态与并发名额,
1280
+ 否则多个并发请求会一起通过数量检查(安全审查 2026-11-03)。 */
1281
+ if (record.resolvedAt !== null) {
1282
+ throw httpError(409, 'approval-resolved', '这次审批已经结束了,不能继续提问。')
1283
+ }
1284
+ if (target === 'reviewer') {
1285
+ const reviewAfterAwait = record.review ?? record.advisoryReview ?? null
1286
+ if (reviewAfterAwait === null || reviewAfterAwait.options === null || reviewAfterAwait.options === undefined) {
1287
+ throw httpError(409, 'reviewer-context-missing', '本次审批的审查材料已失效,请重新评估后再提问。')
1288
+ }
1289
+ if (reviewAfterAwait.advisory === true && reviewAfterAwait.decision === null) {
1290
+ throw httpError(409, 'review-not-initialized', '审查材料已就绪,但还没有重新评估。请先点「让审查 AI 重新评估」,再继续提问。')
1291
+ }
1292
+ }
1293
+ const activeJobsAfterAwait = [...record.jobs].filter((jobId) => state.jobs.get(jobId)?.status === 'streaming').length
1294
+ if (activeJobsAfterAwait >= cfg.maxConcurrentJobs) {
1295
+ throw httpError(429, 'too-many-jobs', `同一审批下最多同时有 ${cfg.maxConcurrentJobs} 个回答在生成,请等其中一个结束。`)
1296
+ }
1297
+
1282
1298
  const entry = {
1283
1299
  id: `q:${nextSeq()}`,
1284
1300
  question,
@@ -1626,37 +1642,65 @@ export function apply(ctx, config, runtime) {
1626
1642
  if (review.decision !== null) {
1627
1643
  return { review, reused: true }
1628
1644
  }
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,
1645
+ if (record.resolvedAt !== null && record.resolvedAt !== undefined) {
1646
+ throw httpError(409, 'approval-resolved', '这次审批已经结束了,不能继续重新评估。')
1635
1647
  }
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
- }
1648
+ // 同一条记录只允许一个进行中的评估:重复请求共享同一个 Promise,避免重复计费与输出混合。
1649
+ if (review.classifyPromise !== undefined && review.classifyPromise !== null) {
1650
+ await review.classifyPromise
1651
+ return { review, reused: true }
1652
+ }
1653
+ const task = (async () => {
1654
+ const controller = new AbortController()
1655
+ const timer = setTimeout(() => controller.abort(new Error('advisory review timeout')), 90_000)
1656
+ review.classifyController = controller
1657
+ // 每次尝试使用独立缓冲:上一次失败留下的半截输出/错误不能污染本次结果。
1658
+ let text = ''
1659
+ let error = null
1660
+ let usage = null
1661
+ let finishKind = null
1662
+ const options = {
1663
+ ...review.options,
1664
+ maxTokens: 1800,
1665
+ signal: controller.signal,
1648
1666
  }
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)}`
1667
+ state.sideOptions.add(options)
1668
+ state.stats.sideCalls += 1
1669
+ try {
1670
+ for await (const chunk of ctx.llm.stream(options)) {
1671
+ if (!chunk || typeof chunk !== 'object') continue
1672
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
1673
+ else if (chunk.type === 'block-end' && chunk.block?.type === 'text' && typeof chunk.block.text === 'string' && text.length === 0) text = chunk.block.text
1674
+ else if (chunk.type === 'usage') usage = chunk.usage ?? null
1675
+ else if (chunk.type === 'finish') {
1676
+ finishKind = chunk.reason?.kind ?? null
1677
+ if (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted') error = messageOf(chunk.reason.failure)
1678
+ }
1679
+ }
1680
+ review.text = text
1681
+ review.decisionText = text.trim()
1682
+ review.decision = parseReviewDecision(review.decisionText)
1683
+ if (review.decision === null && error === null) {
1684
+ error = `审查输出无法解析:${clip(review.decisionText, 200)}`
1685
+ }
1686
+ } catch (thrown) {
1687
+ review.text = text
1688
+ error = friendlySideCallError(thrown)
1689
+ noteError('advisory-classify', thrown)
1690
+ } finally {
1691
+ review.error = error
1692
+ review.usage = usage
1693
+ review.finishKind = finishKind
1694
+ clearTimeout(timer)
1695
+ state.sideOptions.delete(options)
1696
+ review.classifyController = null
1653
1697
  }
1654
- } catch (error) {
1655
- review.error = friendlySideCallError(error)
1656
- noteError('advisory-classify', error)
1698
+ })()
1699
+ review.classifyPromise = task
1700
+ try {
1701
+ await task
1657
1702
  } finally {
1658
- clearTimeout(timer)
1659
- state.sideOptions.delete(options)
1703
+ review.classifyPromise = null
1660
1704
  }
1661
1705
  return { review, reused: false }
1662
1706
  }
@@ -2455,6 +2499,11 @@ export function apply(ctx, config, runtime) {
2455
2499
  if (item === undefined || item.consultKey !== consultKey || Date.now() > item.expiresAt) {
2456
2500
  throw httpError(404, 'attachment-unknown', '这张图片已经失效,请重新上传。')
2457
2501
  }
2502
+ // 审批结束后图片引用必须立即失效(finalizePending 会清理,这里兜底防止竞态残留)。
2503
+ const pendingForImage = state.pendings.get(consultKey)
2504
+ if (pendingForImage === undefined || pendingForImage.resolvedAt !== null) {
2505
+ throw httpError(404, 'attachment-unknown', '这张图片已经失效,请重新上传。')
2506
+ }
2458
2507
  if (attachmentStore === null || typeof attachmentStore.readImage !== 'function') {
2459
2508
  throw httpError(409, 'attachment-unavailable', '当前环境没有可用的附件服务。')
2460
2509
  }
@@ -2487,20 +2536,35 @@ export function apply(ctx, config, runtime) {
2487
2536
  }
2488
2537
  const serviceLimit = Number(attachmentStore.imageLimits?.maxImageBytes)
2489
2538
  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
2539
+ // 先占名额再读取/保存:超额或并发上传不会浪费宿主存储与解析资源。
2540
+ reserveAttachmentSlot(record.key)
2493
2541
  try {
2494
- ref = await attachmentStore.saveImage({
2495
- data,
2496
- mediaType,
2497
- name: sanitizeAttachmentName(query.get('name')),
2498
- })
2499
- } catch (error) {
2500
- throw mapAttachmentError(error)
2542
+ const data = await readBinaryBody(req, limit)
2543
+ if (data.length === 0) throw httpError(400, 'attachment-bad-request', '图片内容为空。')
2544
+ // 读取期间审批可能已经结束:结束后的图片一律不再登记(否则会“复活”已失效引用)。
2545
+ if (record.resolvedAt !== null) {
2546
+ throw httpError(409, 'approval-resolved', '这次审批已经结束,不能再上传图片。')
2547
+ }
2548
+ let ref = null
2549
+ try {
2550
+ ref = await attachmentStore.saveImage({
2551
+ data,
2552
+ mediaType,
2553
+ name: sanitizeAttachmentName(query.get('name')),
2554
+ })
2555
+ } catch (error) {
2556
+ throw mapAttachmentError(error)
2557
+ }
2558
+ // saveImage 也可能耗时:登记前再复核一次;若此时审批已结束,本次保存的宿主对象
2559
+ // 没有对应引用(宿主接口未提供删除语义),但绝不会被登记成可读取的附件。
2560
+ if (record.resolvedAt !== null) {
2561
+ throw httpError(409, 'approval-resolved', '这次审批已经结束,不能再上传图片。')
2562
+ }
2563
+ const item = registerAttachment(ref, record.key, record.sessionId)
2564
+ sendJson(res, 200, { ok: true, attachment: attachmentMeta(item) })
2565
+ } finally {
2566
+ releaseAttachmentSlot(record.key)
2501
2567
  }
2502
- const item = registerAttachment(ref, record.key, record.sessionId)
2503
- sendJson(res, 200, { ok: true, attachment: attachmentMeta(item) })
2504
2568
  }),
2505
2569
  )
2506
2570
  addDisposer(
@@ -3230,6 +3294,30 @@ export function apply(ctx, config, runtime) {
3230
3294
  return out
3231
3295
  }
3232
3296
 
3297
+ /** 该审批下进行中的上传预占数。 */
3298
+ function pendingAttachmentCount(consultKey) {
3299
+ return state.attachmentPending.get(consultKey) ?? 0
3300
+ }
3301
+
3302
+ /**
3303
+ * 上传前预占名额:超额/并发上传不会先 saveImage 再被拒(安全审查 2026-11-03)。
3304
+ * 调用方必须在 finally 里 releaseAttachmentSlot。
3305
+ */
3306
+ function reserveAttachmentSlot(consultKey) {
3307
+ const existing = attachmentsForConsult(consultKey).length
3308
+ const pending = pendingAttachmentCount(consultKey)
3309
+ if (existing + pending >= cfg.maxAttachmentsPerConsult) {
3310
+ throw httpError(413, 'attachment-too-many', `同一审批下最多保留 ${cfg.maxAttachmentsPerConsult} 张图片。`)
3311
+ }
3312
+ state.attachmentPending.set(consultKey, pending + 1)
3313
+ }
3314
+
3315
+ function releaseAttachmentSlot(consultKey) {
3316
+ const pending = pendingAttachmentCount(consultKey)
3317
+ if (pending <= 1) state.attachmentPending.delete(consultKey)
3318
+ else state.attachmentPending.set(consultKey, pending - 1)
3319
+ }
3320
+
3233
3321
  /** 登记一张已存入附件服务的图片,返回对外展示的元数据。 */
3234
3322
  function registerAttachment(ref, consultKey, sessionId) {
3235
3323
  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.3",
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": {