@jayyuen66/dsh-ocr-review 0.1.8 → 0.1.10

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/host.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // GENERATED by build-host.mjs — edit host.ts / lib/*.ts, then run `node build-host.mjs`.
2
2
  import { createRequire } from "node:module";
3
3
  import Schema from "@deepseek-ai/schemastery";
4
- import { chmodSync, copyFileSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
4
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import path from "node:path";
7
7
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
@@ -12,8 +12,9 @@ import { truncateEnd, truncateStart } from "@jayyuen66/dsh-plugin-shared/lib/tex
12
12
  import { fieldOf, hasMethods, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
13
13
  import { expandHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
14
14
  import { errorText } from "@jayyuen66/dsh-plugin-shared/lib/errors";
15
- import { mkdir, readFile } from "node:fs/promises";
15
+ import { mkdir, readFile, readlink, realpath, stat } from "node:fs/promises";
16
16
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
17
+ import { createHash } from "node:crypto";
17
18
  // lib/messages.ts
18
19
  const MESSAGES = {
19
20
  zh: {
@@ -30,7 +31,7 @@ const MESSAGES = {
30
31
  detailSeparator: ":",
31
32
  stdoutTruncated: "[ocr-review] 注意:输出超过 {limit} 字节上限已截断{spill}",
32
33
  outputSpill: "(完整输出已落盘:{path})",
33
- backgroundPolling: "先 job_output(job_id=<本回执的 jobId>) 读这条后台评审的实时输出与最终结局——按 id 走,启动即可读,一定有结果;要停它就 job_kill(job_id=<同一 id>)。会话落盘后再走 ocr_session:ocr_session(action=list) 找本仓库最新会话(启动几秒后出现,按 scanPaths/时间匹配);completed_files == selected_files 即已完成;再 ocr_session(action=comments, id) 取全部评论、ocr_session(action=show, id) 看元数据与逐文件检查点。后台评审不随工具调用结束而停止,默认也不设时限(ocrBackgroundMaxMs=0 表示无兜底窗口);只有 dsh 宿主退出、本会话被停止、插件卸载或显式 job_kill 才会结束。",
34
+ backgroundPolling: "先 job_output(job_id=<本回执的 jobId>) 读这条后台评审的实时输出与最终结局——按 id 走,启动即可读,一定有结果;要停它就 job_kill(job_id=<同一 id>)。会话落盘后再走 ocr_session:ocr_session(action=list) 找本仓库最新会话(启动几秒后出现,按 scanPaths/时间匹配);completed_files + reused_files + waived_files == selected_files 即已完成(failed_files 是没审成的部分,reused 是续扫复用的文件);再 ocr_session(action=comments, id) 取全部评论、ocr_session(action=show, id) 看元数据与逐文件检查点。后台评审不随工具调用结束而停止,默认也不设时限(ocrBackgroundMaxMs=0 表示无兜底窗口);只有 dsh 宿主退出、本会话被停止、插件卸载或显式 job_kill 才会结束。",
34
35
  jobReasonSessionStopped: "ocr-review 会话已停止,回收在跑的后台评审",
35
36
  jobReasonTimeout: "ocr-review 后台评审超过兜底窗口({minutes} 分钟),兜底回收",
36
37
  jobReasonUnload: "ocr-review 插件卸载",
@@ -47,6 +48,7 @@ const MESSAGES = {
47
48
  repoRequired: "repo 必填,且为 Git 仓库绝对路径",
48
49
  repoRequiredUnknownWorkspace: "repo 必填:当前会话工作区未知,请显式传入 Git 仓库绝对路径",
49
50
  repoMustBeAbsolute: "repo 必须是绝对路径:{root}",
51
+ repoMustNotBeFilesystemRoot: "repo 不能是文件系统根({root}):根下任何文件都会被当成仓内文件,--rule / --background-file 会把仓库外的内容送进模型;请给出具体仓库目录",
50
52
  noNulBytes: "{name} 不能包含 NUL 字节",
51
53
  boolMustBeBoolean: "{name} 必须是布尔值(true/false),收到 {received}",
52
54
  repoRootUnreadable: "repo 根目录不可读:{path}",
@@ -78,13 +80,14 @@ const MESSAGES = {
78
80
  resultSchemaMalformed: "ocr {tool}: output schema 自身不合法——{detail}(插件内 bug:schema 的写法超出了返回形态守卫支持的官方子集)",
79
81
  ocrNotJson: "OCR 未返回合法 JSON 输出",
80
82
  ocrNotObject: "OCR JSON 输出不是对象",
81
- ocrStatusInvalid: "OCR 输出 status 非法或缺失:{status}(期望 {expected});exit 0 不代表评审完成,请查 stderr 与 ~/.opencodereview/sessions/",
83
+ ocrStatusInvalid: "OCR 输出 status 非法或缺失:{status}(期望 {expected});exit 0 不代表评审完成,请查 stderr 与 OCR 会话目录(默认 ~/.opencodereview/sessions/,自定义 ocrConfigPath 时随 HOME 重定向)",
82
84
  statusNone: "(无)",
83
85
  ocrStatusFailed: "OCR 回执自报 status=failed(这一场评审没有成功完成)",
84
86
  ocrCommentsNotArray: "OCR 输出 comments 字段不是数组,无法确认评论完整性",
85
87
  warningSeparator: ":",
86
88
  reviewSummaryHint: "完整评论与逐文件失败详情请用 `ocr_session(action=show/comments, id=<session_id>)` 查看;start_line=0 表示 OCR 未能锚定行号,可依据 suggestion/existing_code 在文件中定位。两套口径不要混:droppedCount 与 invalidCommentCount 同时为 0 只说明**本摘要**列全了 OCR 产出的评论(否则调大 maxComments 或置 0 后复核);而**这一场有没有审完**看 coverage(selected 是分母,completed/reused/failed/waived 是它的互斥划分;coverage 为 null 时退回看 warnings 与 status=partial)和 budgetExceeded(true = token 预算花完提前收工,status 里只写 partial,看不出这一层)。toolCalls.failure 大于 0 说明当前模型的工具调用没跑通,换模型比重试同一个更省 token。切勿把「本摘要无 critical」当作「代码干净」。",
87
89
  lineZeroNote: "(line 0: 定位失败,按 existing_code 手动定位)",
90
+ pathUnknown: "(路径未知)",
88
91
  parseFailedWithReason: "{tool} 输出解析失败:{message}\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
89
92
  parseFailedPlain: "{tool} 输出解析失败\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
90
93
  previewParseFailed: "OCR 的 --preview 回执不是可识别的文件清单(files 缺失或条目形状不合)",
@@ -93,13 +96,16 @@ const MESSAGES = {
93
96
  mcpReadFailed: "读取 MCP 配置失败({where}):{cause}",
94
97
  mcpJsonInvalid: "MCP 配置不是合法 JSON:{cause}",
95
98
  mcpJsonTooLarge: "MCP 配置超过 {limit} 字节上限",
96
- mcpShapeInvalid: "MCP 配置必须是 {{name: entry}} 形式的对象",
99
+ mcpShapeInvalid: "MCP 配置必须是 {name: entry} 形式的对象",
97
100
  mcpTooManyServers: "MCP servers 最多 {limit} 个",
98
101
  mcpServerInvalid: "MCP server「{name}」的 {field} 不合法:{reason}",
99
102
  mcpSaved: "MCP servers 已保存({count} 个)",
103
+ mcpSaveFailed: "写入 MCP 配置失败({where}):{cause}",
104
+ mcpSaveConflict: "MCP 配置在读取之后已被改动(另一张设置卡或手工编辑),本次保存未写入。请重新载入后再保存。",
100
105
  chatGptModelRequired: "ChatGPT 模型名必填",
101
106
  chatGptSaved: "已切换到 ChatGPT provider,模型:{model}",
102
107
  chatGptSaveFailed: "写入 ChatGPT 配置失败:{cause}",
108
+ chatGptProvidersInvalid: "config.json 里的 providers 或 providers.chatgpt 不是对象,无法安全写入(先手工修正再保存)",
103
109
  compareNeedsBoth: "action=compare 需要同时给出 before 与 after 两个会话 id",
104
110
  compareRejectsArgs: "action=compare 不接受 id/limit/severity/category",
105
111
  exportRejectsArgs: "action=export 不接受 before/after/limit/severity/category",
@@ -195,7 +201,7 @@ const MESSAGES = {
195
201
  filePreviewHint: "这是文件选择预览:没有调用 LLM、没有创建会话。要真正审查请去掉 preview 后重跑。列表按上限截断;truncated=true 时请用 exclude/path 缩小范围。",
196
202
  ruleHint: "按分组规则逐个审查对应文件,输出评论建议格式:{path, content, start_line, end_line, category, severity}。",
197
203
  sessionEmpty: "(暂无历史评审会话:先跑一次 ocr_review/ocr_scan 再查询)",
198
- sessionTruncated: "…[截断] 完整内容见 ~/.opencodereview/sessions/",
204
+ sessionTruncated: "…[截断] 完整内容见 OCR 会话目录(默认 ~/.opencodereview/sessions/,自定义 ocrConfigPath 时随 HOME 重定向)",
199
205
  sessionResumeHint: "要恢复中断的区间/commit 评审:ocr_review ... --resume <session_id>(工作区评审不支持恢复);scan 会话用 ocr_scan ... --resume <session_id>。",
200
206
  rulesCheckHint: "输出里的 Source 是命中的层(--rule/项目/全局/内置),Pattern 是命中的 glob;规则按声明顺序先到先得。",
201
207
  llmHint: "providers 是内置清单,models 是 ChatGPT 账号专属列表;评审实际用哪个 provider/model 仍以 OCR 配置为准。",
@@ -239,7 +245,7 @@ const MESSAGES = {
239
245
  detailSeparator: ": ",
240
246
  stdoutTruncated: "[ocr-review] note: output exceeded the {limit}-byte cap and was truncated{spill}",
241
247
  outputSpill: " (full output spilled to {path})",
242
- backgroundPolling: "First read this background review's live output and final outcome with job_output(job_id=<the jobId in this receipt>) — that path goes by id, so it is readable immediately after start and always has a result; job_kill(job_id=<the same id>) stops it. Once the session has landed, use ocr_session: ocr_session(action=list) finds this repository's newest session (it appears a few seconds after start; match by scanPaths/time); completed_files == selected_files means it is done. Then ocr_session(action=comments, id) for all comments and ocr_session(action=show, id) for metadata and per-file checkpoints. A background run does not stop when the tool call ends and has no time limit by default (ocrBackgroundMaxMs=0 means no backstop window); it ends only when the dsh host exits, this session is stopped, the plugin unloads, or you call job_kill.",
248
+ backgroundPolling: "First read this background review's live output and final outcome with job_output(job_id=<the jobId in this receipt>) — that path goes by id, so it is readable immediately after start and always has a result; job_kill(job_id=<the same id>) stops it. Once the session has landed, use ocr_session: ocr_session(action=list) finds this repository's newest session (it appears a few seconds after start; match by scanPaths/time); completed_files + reused_files + waived_files == selected_files means it is done (failed_files is the part that did not complete, and reused means files carried over by a resumed run). Then ocr_session(action=comments, id) for all comments and ocr_session(action=show, id) for metadata and per-file checkpoints. A background run does not stop when the tool call ends and has no time limit by default (ocrBackgroundMaxMs=0 means no backstop window); it ends only when the dsh host exits, this session is stopped, the plugin unloads, or you call job_kill.",
243
249
  jobReasonSessionStopped: "the ocr-review session stopped; running background reviews were reclaimed",
244
250
  jobReasonTimeout: "the ocr-review background review exceeded its backstop window ({minutes} minutes) and was reclaimed",
245
251
  jobReasonUnload: "ocr-review plugin unloaded",
@@ -256,6 +262,7 @@ const MESSAGES = {
256
262
  repoRequired: "repo is required and must be the absolute path of a Git repository",
257
263
  repoRequiredUnknownWorkspace: "repo is required: the current session workspace is unknown, pass the absolute path of a Git repository explicitly",
258
264
  repoMustBeAbsolute: "repo must be an absolute path: {root}",
265
+ repoMustNotBeFilesystemRoot: "repo must not be a filesystem root ({root}): every file under it would count as inside the repo, so --rule / --background-file could feed files from outside it to the model; pass a concrete repository directory",
259
266
  boolMustBeBoolean: "{name} must be a boolean (true/false), got {received}",
260
267
  repoRootUnreadable: "the repo root is not readable: {path}",
261
268
  repoFileMissing: "{name} points at a file that does not exist: {path}",
@@ -287,13 +294,14 @@ const MESSAGES = {
287
294
  resultSchemaMalformed: "ocr {tool}: the output schema itself is malformed — {detail} (plugin bug: the schema goes beyond the official subset the result guard supports)",
288
295
  ocrNotJson: "OCR returned no valid JSON output",
289
296
  ocrNotObject: "the OCR JSON output is not an object",
290
- ocrStatusInvalid: "OCR output has an invalid or missing status: {status} (expected {expected}); exit 0 does not mean the review completed — check stderr and ~/.opencodereview/sessions/",
297
+ ocrStatusInvalid: "OCR output has an invalid or missing status: {status} (expected {expected}); exit 0 does not mean the review completed — check stderr and the OCR session directory (default ~/.opencodereview/sessions/, relocated with HOME when ocrConfigPath is customized)",
291
298
  statusNone: "(none)",
292
299
  ocrStatusFailed: "the OCR receipt reports status=failed (this review run did not complete)",
293
300
  ocrCommentsNotArray: "the comments field of the OCR output is not an array, comment completeness cannot be confirmed",
294
301
  warningSeparator: ": ",
295
302
  reviewSummaryHint: "Use `ocr_session(action=show/comments, id=<session_id>)` for the full comments and per-file failure details; start_line=0 means OCR could not anchor a line — locate it via suggestion/existing_code. Keep the two notions apart: droppedCount and invalidCommentCount both being 0 only means THIS SUMMARY listed every comment OCR produced (otherwise raise maxComments, or set it to 0, and re-check). Whether the run itself covered everything is answered by coverage (selected is the denominator, completed/reused/failed/waived are its disjoint partition; fall back to warnings and status=partial when coverage is null) and by budgetExceeded (true = the token budget stopped the run early — status still only says partial). A toolCalls.failure above 0 means the current model's tool calls did not work: switch models instead of retrying the same one. Never treat \"no critical in this summary\" as \"the code is clean\".",
296
303
  lineZeroNote: "(line 0: not anchored, locate it via existing_code)",
304
+ pathUnknown: "(path unknown)",
297
305
  parseFailedWithReason: "{tool} output parsing failed: {message}\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
298
306
  parseFailedPlain: "{tool} output parsing failed\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
299
307
  previewParseFailed: "the OCR --preview receipt is not a recognizable file list (files missing or every entry malformed)",
@@ -306,9 +314,12 @@ const MESSAGES = {
306
314
  mcpTooManyServers: "at most {limit} MCP servers are allowed",
307
315
  mcpServerInvalid: "MCP server \"{name}\" has an invalid {field}: {reason}",
308
316
  mcpSaved: "MCP servers saved ({count})",
317
+ mcpSaveFailed: "failed to write the MCP config ({where}): {cause}",
318
+ mcpSaveConflict: "the MCP config changed after it was read (another settings card or a manual edit); nothing was written. Reload and save again.",
309
319
  chatGptModelRequired: "a ChatGPT model slug is required",
310
320
  chatGptSaved: "switched to the ChatGPT provider, model: {model}",
311
321
  chatGptSaveFailed: "failed to write the ChatGPT config: {cause}",
322
+ chatGptProvidersInvalid: "providers or providers.chatgpt in config.json is not an object; fix it by hand before saving",
312
323
  compareNeedsBoth: "action=compare requires both before and after session ids",
313
324
  compareRejectsArgs: "action=compare does not take id/limit/severity/category",
314
325
  exportRejectsArgs: "action=export does not take before/after/limit/severity/category",
@@ -404,7 +415,7 @@ const MESSAGES = {
404
415
  filePreviewHint: "This is a file-selection preview: no LLM call and no session were created. Drop preview and rerun to actually review. Lists are capped; when truncated=true, narrow the scope with exclude/path.",
405
416
  ruleHint: "Review the files of each group against its rule and emit comment suggestions in the shape {path, content, start_line, end_line, category, severity}.",
406
417
  sessionEmpty: "(no review sessions yet: run ocr_review/ocr_scan once before querying)",
407
- sessionTruncated: "…[truncated] full content in ~/.opencodereview/sessions/",
418
+ sessionTruncated: "…[truncated] full content in the OCR session directory (default ~/.opencodereview/sessions/, relocated with HOME when ocrConfigPath is customized)",
408
419
  sessionResumeHint: "To resume an interrupted range/commit review: ocr_review ... --resume <session_id> (workspace reviews cannot be resumed); scan sessions use ocr_scan ... --resume <session_id>.",
409
420
  rulesCheckHint: "Source names the winning layer (--rule/project/global/built-in) and Pattern the glob; rules are first-match in declaration order.",
410
421
  llmHint: "providers is the built-in catalogue; models is the ChatGPT account-specific list; the effective review provider/model still comes from OCR's own config.",
@@ -481,23 +492,33 @@ const SCHEMA_TYPES = /* @__PURE__ */ new Set([
481
492
  "boolean",
482
493
  "null"
483
494
  ]);
484
- /**
485
- * schema 里的关键字必须全部落在守卫支持的子集内(递归查 oneOf/properties/items),
486
- * 关键字的**取值形态**也必须合法。
487
- *
488
- * 违例抛错而不是当违规值报:schema 是插件自己的代码,错的是代码不是模型的数据。
489
- *
490
- * 为什么取值形态也要查:只查键名的话,`type: ["string","null"]` 这种官方子集不认的
491
- * 写法会让 `scalarMismatch` 走进最后那支 `value !== null`——于是 `null` 放行、字符串
492
- * 反而判违规,守卫给出的结论与它自己声称支持的子集无关。`enum` 写成字符串同理:
493
- * 走到 `enum.includes` 时成了子串匹配,非字符串值还会抛 `map is not a function`。
494
- */
495
+ /** schema 形态错误的统一出口:错的是插件自己的 schema,不是模型交回的数据。 */
495
496
  function malformed(toolName, detail, messages) {
496
497
  return new Error(format(messages.resultSchemaMalformed, {
497
498
  tool: toolName,
498
499
  detail
499
500
  }));
500
501
  }
502
+ /** 容器关键字的取值形态:`properties` 是字段表、`required` 是字段名数组、
503
+ * `additionalProperties` 是布尔或子 schema、`items` 是单个子 schema。
504
+ *
505
+ * 形态不对时走查器会**静默丢掉整条约束**(数组 properties 把下标当字段名、字符串
506
+ * required 逐字符迭代、对象 additionalProperties 与 `=== false` 比较恒假),
507
+ * 与 `type`/`enum` 写错是同一类「守卫假装支持」的缺陷,所以同样当场抛。
508
+ * 容器关键字也必须显式声明 `type`:走查按 `type` 分派,缺了它这些约束从不生效。 */
509
+ function assertContainerShapes(schema, toolName, messages) {
510
+ const { properties, required, additionalProperties, items, type } = schema;
511
+ if ([
512
+ properties,
513
+ required,
514
+ additionalProperties,
515
+ items
516
+ ].filter((value) => value !== void 0).length > 0 && type === void 0) throw malformed(toolName, "properties/required/additionalProperties/items 必须同时声明 type", messages);
517
+ if (properties !== void 0 && !isRecord(properties)) throw malformed(toolName, "properties 必须是字段名的对象", messages);
518
+ if (required !== void 0 && (!Array.isArray(required) || required.some((key) => typeof key !== "string"))) throw malformed(toolName, "required 必须是字段名数组", messages);
519
+ if (additionalProperties !== void 0 && typeof additionalProperties !== "boolean" && !isRecord(additionalProperties)) throw malformed(toolName, "additionalProperties 必须是布尔或子 schema", messages);
520
+ if (items !== void 0 && !isRecord(items)) throw malformed(toolName, "items 必须是单个子 schema(不支持元组数组)", messages);
521
+ }
501
522
  /** 关键字白名单:守卫只认官方子集,多一个都算插件自己的 schema 写错了。 */
502
523
  function assertKnownKeywords(schema, toolName, messages) {
503
524
  for (const keyword of Object.keys(schema)) if (!SUPPORTED_KEYWORDS.has(keyword)) throw new Error(format(messages.resultSchemaUnsupported, {
@@ -517,11 +538,22 @@ function assertOneOfShape(oneOf, toolName, messages) {
517
538
  if (Array.isArray(oneOf) && oneOf.length >= 2) return;
518
539
  throw malformed(toolName, "oneOf 必须是至少两项的数组", messages);
519
540
  }
520
- /** `enum` 的取值形态:非空数组。写成字符串时 `enum.includes` 会退化成子串匹配。 */
541
+ /** JSON 标量(null / 布尔 / 有限数字 / 字符串):const/enum 的比较只在标量上成立。 */
542
+ function isJsonScalar(value) {
543
+ return value === null || typeof value === "boolean" || typeof value === "string" || typeof value === "number" && Number.isFinite(value);
544
+ }
545
+ /** `enum` 的取值形态:非空数组,且成员必须是 JSON 标量。
546
+ * 非标量成员在 `includes` 下按引用比较,deep-equal 也会失配;本守卫的子集只认标量。 */
521
547
  function assertEnumShape(enumValues, toolName, messages) {
522
548
  if (enumValues === void 0) return;
523
- if (Array.isArray(enumValues) && enumValues.length > 0) return;
524
- throw malformed(toolName, "enum 必须是非空数组", messages);
549
+ if (Array.isArray(enumValues) && enumValues.length > 0 && enumValues.every((entry) => isJsonScalar(entry))) return;
550
+ throw malformed(toolName, "enum 必须是非空的 JSON 标量数组", messages);
551
+ }
552
+ /** `const` 的取值形态:JSON 标量。对象/数组型 const 只能用引用比较,
553
+ * 而值侧总是新对象——deep-equal 也在本守卫的子集之外。 */
554
+ function assertConstShape(schema, toolName, messages) {
555
+ const { const: constValue } = schema;
556
+ if (constValue !== void 0 && !isJsonScalar(constValue)) throw malformed(toolName, "const 必须是 JSON 标量(本守卫不支持对象/数组比较)", messages);
525
557
  }
526
558
  /**
527
559
  * schema 里的关键字必须全部落在守卫支持的子集内(递归查 oneOf/properties/items),
@@ -536,15 +568,17 @@ function assertEnumShape(enumValues, toolName, messages) {
536
568
  */
537
569
  function assertGuardable(schema, toolName, messages) {
538
570
  assertKnownKeywords(schema, toolName, messages);
571
+ assertContainerShapes(schema, toolName, messages);
539
572
  const { type, oneOf, enum: enumValues } = schema;
540
- if (type !== void 0 && oneOf !== void 0) throw malformed(toolName, "type 与 oneOf 不能同时声明", messages);
541
573
  assertTypeShape(type, toolName, messages);
542
574
  assertOneOfShape(oneOf, toolName, messages);
543
575
  assertEnumShape(enumValues, toolName, messages);
576
+ assertConstShape(schema, toolName, messages);
544
577
  for (const branch of Array.isArray(oneOf) ? oneOf : []) assertGuardable(branch, toolName, messages);
545
578
  for (const property of Object.values(schema.properties ?? {})) assertGuardable(property, toolName, messages);
546
- const { items } = schema;
579
+ const { items, additionalProperties } = schema;
547
580
  if (items !== void 0) assertGuardable(items, toolName, messages);
581
+ if (additionalProperties !== void 0 && typeof additionalProperties !== "boolean") assertGuardable(additionalProperties, toolName, messages);
548
582
  }
549
583
  /** 标量分支的形态判定(true = 不合形态;判据对齐官方 checkValue 的标量臂:
550
584
  * number 须为有限 JSON 数、integer 须整数值)。 */
@@ -555,30 +589,50 @@ function scalarMismatch(type, value) {
555
589
  if (type === "boolean") return typeof value !== "boolean";
556
590
  return value !== null;
557
591
  }
592
+ /** 违规清单的保留上限与最终文本上限:失败消息会被宿主持久化进会话日志,两者都限量。 */
593
+ const MAX_VIOLATIONS_LISTED = 20;
594
+ const MAX_VIOLATION_CHARS = 2e3;
595
+ function recordViolation(sink, message) {
596
+ sink.total += 1;
597
+ if (sink.messages.length < sink.limit) sink.messages.push(message);
598
+ }
599
+ const MAX_FIELDS_LISTED = 10;
600
+ /** 字段名单同样限量:一个多余字段值可以声明任意多键,整份名单不该无界地进失败消息。 */
601
+ function listedFields(keys) {
602
+ const omitted = keys.length - MAX_FIELDS_LISTED;
603
+ return keys.slice(0, MAX_FIELDS_LISTED).join(", ") + (omitted > 0 ? ` …(+${omitted})` : "");
604
+ }
558
605
  /** object 分支:isRecord → additionalProperties:false 的多余键 → required(缺键与
559
606
  * 「键在但值是 undefined」同判,与官方一致)→ 逐声明字段递归。 */
560
- function objectViolations(schema, value, path, walk) {
561
- if (!isRecord(value)) return [`${path}: expected object`];
607
+ function objectViolations(schema, value, path, sink, walk) {
608
+ if (!isRecord(value)) {
609
+ recordViolation(sink, `${path}: expected object`);
610
+ return;
611
+ }
562
612
  const { properties, required, additionalProperties } = schema;
563
613
  const declared = (key) => properties !== void 0 && Object.hasOwn(properties, key) ? properties[key] : void 0;
564
614
  if (additionalProperties === false) {
565
615
  const extra = Object.keys(value).filter((key) => declared(key) === void 0);
566
- if (extra.length > 0) return [`${path}: fields outside the schema: ${extra.join(", ")}`];
616
+ if (extra.length > 0) recordViolation(sink, `${path}: fields outside the schema: ${listedFields(extra)}`);
617
+ }
618
+ for (const key of required ?? []) if (!Object.hasOwn(value, key) || value[key] === void 0) {
619
+ recordViolation(sink, `${path}: missing required field ${key}`);
620
+ return;
567
621
  }
568
- for (const key of required ?? []) if (!Object.hasOwn(value, key) || value[key] === void 0) return [`${path}: missing required field ${key}`];
569
- const violations = [];
570
622
  for (const key of Object.keys(value)) {
571
623
  const child = declared(key);
572
- if (child !== void 0) violations.push(...walk(child, value[key], `${path}.${key}`));
624
+ if (child !== void 0) walk(child, value[key], `${path}.${key}`, sink);
573
625
  }
574
- return violations;
575
626
  }
576
627
  /** array 分支:Array.isArray → 无 items 视为任意元素 → 逐元素递归(违规一次列全)。 */
577
- function arrayViolations(schema, value, path, walk) {
578
- if (!Array.isArray(value)) return [`${path}: expected array`];
628
+ function arrayViolations(schema, value, path, sink, walk) {
629
+ if (!Array.isArray(value)) {
630
+ recordViolation(sink, `${path}: expected array`);
631
+ return;
632
+ }
579
633
  const { items } = schema;
580
- if (items === void 0) return [];
581
- return value.flatMap((item, index) => walk(items, item, `${path}[${index}]`));
634
+ if (items === void 0) return;
635
+ for (let index = 0; index < value.length; index += 1) walk(items, value[index], `${path}[${index}]`, sink);
582
636
  }
583
637
  /** provider key 形状:`sk-…`/`ark-…`/`ghp-…` 一类「短前缀 + 长随机串」。
584
638
  *
@@ -591,16 +645,16 @@ function arrayViolations(schema, value, path, walk) {
591
645
  * 能嵌套任意多个候选起点,而「run 内是否存在数字或大写」这类前瞻要把后面整段 run 反向
592
646
  * 回溯一遍——逐起点相乘就是 O(n²)(实测 400_000 字符 51 秒,正好是 `stdoutMaxBytes`
593
647
  * 的默认档)。run 是首个 `-` 之后的那一段:前缀只含字母,故首个 `-` 必是分界。 */
594
- const STANDALONE_KEY = /\b[A-Za-z]{2,5}-[A-Za-z0-9_-]{20,}\b/gu;
648
+ const STANDALONE_KEY = /\b[A-Za-z]{2,5}-[\w-]{20,}\b/gu;
595
649
  /** `Authorization: <scheme> <value>` / `Proxy-Authorization: …`:认**头名**而不是
596
650
  * 枚举认证方案。头名本身就是「后面跟着凭据」的信号,Basic / Token / Digest 乃至
597
651
  * vendor 自定义 scheme 都不会漏;而只认头名还避开了正文里「token based
598
652
  * authentication」这类措辞被误抹。scheme 组连同其后的空白一起捕获,回填时凭据被
599
653
  * 替换而 scheme 词保留。 */
600
- const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
654
+ const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)(?<scheme>[a-z][\w-]*[ \t]+)?[^\s"',;)}\]]{4,}/giu;
601
655
  /** 裸 scheme + 值(没有头名时 OCR 也会整行回显)。scheme 只留 bearer:它在散文中几乎
602
656
  * 不出现,而 token / basic 之类会撞上「token count」「basic usage」等普通措辞。 */
603
- const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
657
+ const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[\w.~+/=-]{4,}/giu;
604
658
  /** `api_key: "xxx"`、`apiKey=xxx`、`token: xxx`、`x-api-key: xxx` 等赋值形态。
605
659
  * 值下限取 4 且容许一个 scheme 词:`token: Basic <base64>` 里赋值形态先能吃到的只是
606
660
  * scheme 词(Basic 恰好 5 字符),下限更高时整条失配,后面那个 base64 就原样进了日志。
@@ -611,16 +665,32 @@ const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
611
665
  *
612
666
  * 字符类写 `[A-Z0-9]` 而不是 `[A-Za-z0-9]`:这条正则带 `i` 标志,两者等价,
613
667
  * 而重复的字符区间会让静态检查报 duplicates-in-character-class。 */
614
- const KEY_ASSIGNMENT = /(?<name>(?<![A-Z0-9])(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
668
+ const KEY_ASSIGNMENT = /(?<name>(?<![A-Z0-9])(?:api[_-]?key|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?<scheme>[a-z][\w-]*[ \t]+)?[^\s"',;)}\]]{4,}/giu;
615
669
  /** GitHub 系 token 的**下划线**形态(`ghp_…`、`github_pat_…`):STANDALONE_KEY 要求
616
670
  * 「字母前缀 + 连字符」分界,这类值里没有连字符,前缀后又紧跟下划线,故单列一条。
617
671
  * 不并进 STANDALONE_KEY 是为了不放开那里的连字符要求——那条要求同时承担「别把
618
672
  * kebab 小写路径当 key 抹掉」的职责。 */
619
- const UNDERSCORE_TOKEN = /\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b/gu;
673
+ const UNDERSCORE_TOKEN = /\b(?:gh[pousr]|github_pat)_\w{20,}\b/gu;
620
674
  const REDACTION = "***REDACTED***";
621
- /** 独立 key 形态的替换臂:随机串段里真有数字或大写才抹,否则原样交回(文件路径要保持可读)。 */
622
- function redactStandaloneKey(match) {
623
- return /[0-9A-Z]/u.test(match.slice(match.indexOf("-") + 1)) ? REDACTION : match;
675
+ /** token 尾部至少一枚非 `-` 的字母/数字。 */
676
+ const TOKEN_TAIL_CHARACTER = /[0-9A-Z]/u;
677
+ /** 已知 provider key 的前缀:即便两侧是路径分隔符也按 key 处理。路径豁免是为了让
678
+ * 仓库路径保持可读,而裸 key 同样可能被嵌进 URL 或日志路径(代理回显、错误行),
679
+ * 这条豁免不能替它开口。 */
680
+ const KNOWN_KEY_PREFIX = /^(?:sk|ark|xai|gsk|ghp)-/iu;
681
+ /** 独立 key 形态的替换臂:随机串段里真有数字或大写才抹,否则原样交回(文件路径要保持可读)。
682
+ * 两侧任一是路径分隔符的匹配是路径段而非 key:ocr CLI 会把仓库路径拍平成 kebab 段嵌进
683
+ * session 的 file_path(`…/sessions/Users-yuanjiang-Documents-…/uuid.jsonl`),原路径里
684
+ * 只要有大写组件(macOS 的 Documents/Desktop 一类)就过得了「数字或大写」那道护栏——
685
+ * 裸 key 从不出现在斜杠两侧,路径段也从不出现在别处,按上下文分比按字符集猜稳。
686
+ * 已知凭据前缀不走这条豁免:见 `KNOWN_KEY_PREFIX`。 */
687
+ function redactStandaloneKey(match, offset, whole) {
688
+ if (!TOKEN_TAIL_CHARACTER.test(match.slice(match.indexOf("-") + 1))) return match;
689
+ if (KNOWN_KEY_PREFIX.test(match)) return REDACTION;
690
+ const before = whole[offset - 1];
691
+ const after = whole[offset + match.length];
692
+ if (before === "/" || after === "/" || before === "\\" || after === "\\") return match;
693
+ return REDACTION;
624
694
  }
625
695
  /**
626
696
  * 抹掉文本里 key 形状的片段。
@@ -637,8 +707,18 @@ function redactStandaloneKey(match) {
637
707
  function redactKeyMaterial(text) {
638
708
  return text.replaceAll(AUTH_HEADER_VALUE, `$<name>$<scheme>${REDACTION}`).replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>$<scheme>${REDACTION}`).replaceAll(UNDERSCORE_TOKEN, REDACTION).replaceAll(STANDALONE_KEY, redactStandaloneKey);
639
709
  }
710
+ /** ESC 由运行时构造:`eslint(no-control-regex)` 不许控制字符出现在正则字面量里,而这里
711
+ * 必须按真 ESC 匹配——放宽成「匹配 `[33m` 这种文本」会把日志里合法的方括号内容一起吃掉。 */
712
+ const ESC = String.fromCodePoint(27);
713
+ /** BEL:OSC 的另一种合法终结符。 */
714
+ const BEL = String.fromCodePoint(7);
640
715
  /** ECMA-48 的 CSI 序列:ESC `[` + 参数位 + 中间位 + 终结位(SGR 颜色码就是终结位 `m`)。 */
641
- const ANSI_CSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "gu");
716
+ const ANSI_CSI = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, "gu");
717
+ /** OSC(ESC `]` …)与 DCS/PM/APC/SOS(ESC `P`/`X`/`^`/`_` …):以 BEL 或 ST(ESC `\`)收尾。
718
+ * 上游可能用 OSC 改窗口标题/超链接,DCS 回显设备控制;只剥 CSI 会让这些字节留在正文里。 */
719
+ const ANSI_OSC_DCS = new RegExp(`${ESC}(?:\\][^${BEL}${ESC}]*|[PX^_][^${ESC}]*)(?:${BEL}|${ESC}\\\\)?`, "gu");
720
+ /** 其余单字符 ESC 序列(ESC + 一个终结字符):放在 CSI/OSC/DCS 之后跑,避免抢走它们的起始字符。 */
721
+ const ANSI_ESC_SINGLE = new RegExp(`${ESC}[^\\[\\]PX^_]`, "gu");
642
722
  /**
643
723
  * 剥掉终端控制序列,只给**要显示给人复制**的那段输出用。
644
724
  *
@@ -648,24 +728,39 @@ const ANSI_CSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "
648
728
  * 复制进终端的恰好是那条 `npm i -g …`——复制到的是带控制字符的脏命令。
649
729
  */
650
730
  function stripAnsi(text) {
651
- return text.replaceAll(ANSI_CSI, "");
731
+ return text.replaceAll(ANSI_OSC_DCS, "").replaceAll(ANSI_CSI, "").replaceAll(ANSI_ESC_SINGLE, "");
652
732
  }
653
- /** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
654
- * 恰好一支命中,多支/零支都算违规。 */
655
- function violationsOf(schema, value, path) {
656
- const violations = [];
733
+ /** oneOf 分支数必须恰好为 1。试跑只关心「这一支有没有违规」,不保留文案;
734
+ * limit 0 让大数组不至于为每支各留一份名单。 */
735
+ function oneOfViolations(schema, value, path, sink, walk) {
657
736
  const { oneOf } = schema;
658
- if (oneOf !== void 0) {
659
- const passing = oneOf.filter((branch) => violationsOf(branch, value, path).length === 0);
660
- if (passing.length !== 1) violations.push(`${path}: matched ${passing.length} of ${oneOf.length} oneOf branches`);
737
+ if (oneOf === void 0) return;
738
+ let passing = 0;
739
+ for (const branch of oneOf) {
740
+ const probe = {
741
+ messages: [],
742
+ limit: 0,
743
+ total: 0
744
+ };
745
+ walk(branch, value, path, probe);
746
+ if (probe.total === 0) passing += 1;
661
747
  }
748
+ if (passing !== 1) recordViolation(sink, `${path}: matched ${passing} of ${oneOf.length} oneOf branches`);
749
+ }
750
+ /** type 分派的容器/标量走查,以及同级的 const/enum 比较。 */
751
+ function leafViolations(schema, value, path, sink, walk) {
662
752
  const { type } = schema;
663
- if (type === "object") for (const violation of objectViolations(schema, value, path, violationsOf)) violations.push(violation);
664
- else if (type === "array") for (const violation of arrayViolations(schema, value, path, violationsOf)) violations.push(violation);
665
- else if (type !== void 0 && scalarMismatch(type, value)) violations.push(`${path}: expected ${type}`);
666
- if (schema.const !== void 0 && value !== schema.const) violations.push(`${path}: must be exactly ${JSON.stringify(schema.const)}`);
667
- if (schema.enum !== void 0 && !schema.enum.includes(value)) violations.push(`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`);
668
- return violations;
753
+ if (type === "object") objectViolations(schema, value, path, sink, walk);
754
+ else if (type === "array") arrayViolations(schema, value, path, sink, walk);
755
+ else if (type !== void 0 && scalarMismatch(type, value)) recordViolation(sink, `${path}: expected ${type}`);
756
+ if (schema.const !== void 0 && value !== schema.const) recordViolation(sink, `${path}: must be exactly ${JSON.stringify(schema.const)}`);
757
+ if (schema.enum !== void 0 && !schema.enum.includes(value)) recordViolation(sink, `${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`);
758
+ }
759
+ /** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
760
+ * 恰好一支命中,多支/零支都算违规。 */
761
+ function violationsOf(schema, value, path, sink) {
762
+ oneOfViolations(schema, value, path, sink, violationsOf);
763
+ leafViolations(schema, value, path, sink, violationsOf);
669
764
  }
670
765
  /**
671
766
  * 校验并放行工具返回值:必须命中该工具自己的 output schema。
@@ -679,20 +774,26 @@ function violationsOf(schema, value, path) {
679
774
  */
680
775
  function assertToolOutput(schema, value, toolName, messages) {
681
776
  assertGuardable(schema, toolName, messages);
682
- const violations = violationsOf(schema, value, "result");
683
- if (violations.length > 0) {
684
- const MAX_LISTED = 20;
685
- const MAX_CHARS = 2e3;
686
- const omitted = violations.length - MAX_LISTED;
687
- const listed = violations.slice(0, MAX_LISTED).join("; ") + (omitted > 0 ? ` …(+${omitted})` : "");
777
+ const sink = {
778
+ messages: [],
779
+ limit: MAX_VIOLATIONS_LISTED,
780
+ total: 0
781
+ };
782
+ violationsOf(schema, value, "result", sink);
783
+ if (sink.total > 0) {
784
+ const omitted = sink.total - sink.messages.length;
785
+ const listed = sink.messages.join("; ") + (omitted > 0 ? ` …(+${omitted})` : "");
688
786
  throw new Error(format(messages.resultSchemaViolation, {
689
787
  tool: toolName,
690
- violations: redactKeyMaterial(listed).slice(0, MAX_CHARS)
788
+ violations: redactKeyMaterial(listed).slice(0, MAX_VIOLATION_CHARS)
691
789
  }));
692
790
  }
693
791
  return value;
694
792
  }
695
793
  // lib/argv-guard.ts
794
+ /** 归一后剥掉的尾随分隔符。 */
795
+ const TRAILING_POSIX_SLASHES = /\/+$/u;
796
+ const TRAILING_PATH_SEPARATORS = /[\\/]+$/u;
696
797
  /**
697
798
  * POSIX sh 单引号转义:' → '\''。把任意字符串安全放进单引号内,防注入。
698
799
  *
@@ -715,8 +816,14 @@ function shq(value) {
715
816
  * 归一(posix.normalize + 去尾随斜杠)的目的:同一个仓库只允许一种字符串形态到达下游。
716
817
  * `/repo//sub`、`/repo/./a`、`/repo/` 与 `/repo/sub` 是同一目录的几种写法,而会话目录
717
818
  * 等按路径派生的键会把这些写法各存一份;`..` 段按词法折叠成实际目标(`/repo/../..`
718
- * 落到 `/`)——折叠是 normalize 对绝对路径的既有行为,不存在「解析后仍含 `..`」的
719
- * 形态,所以这里没有可写的拒绝分支。
819
+ * 落到 `/`)——折叠是 normalize 对绝对路径的既有行为,不存在「解析后仍含 ..」的形态。
820
+ *
821
+ * 但**折叠到文件系统根必须拒绝**,这不是防御性噪音:/repo/../.. 与 / 都归一成 /,而下游
822
+ * resolveRepoFile 的边界判据是 path.relative(rootReal, real) 不以 .. 开头——根为 / 时它对
823
+ * /etc/passwd 给出 etc/passwd,判据恒成立,于是 --rule /etc/passwd 会被当成仓内文件读进
824
+ * OCR 并送给 LLM(子进程跑在 danger-full-access 下)。所以归一后落在根上的输入一律按
825
+ * 「repo 给得太宽」拒绝;win32 用 path.win32.parse 解出的根与归一结果比较,覆盖盘符根
826
+ * 与 UNC 共享根(两者的根形态各不止一种,不能只比字面量)。
720
827
  */
721
828
  function assertAbsoluteRoot(root, messages) {
722
829
  const trimmed = typeof root === "string" ? root.trim() : "";
@@ -724,11 +831,15 @@ function assertAbsoluteRoot(root, messages) {
724
831
  if (trimmed.includes("\0")) throw new Error(format(messages.noNulBytes, { name: "repo" }));
725
832
  if (trimmed.startsWith("/")) {
726
833
  const normalized = path.posix.normalize(trimmed);
727
- return normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
834
+ const collapsed = normalized.length > 1 ? normalized.replace(TRAILING_POSIX_SLASHES, "") : normalized;
835
+ if (collapsed === "/") throw new Error(format(messages.repoMustNotBeFilesystemRoot, { root: trimmed }));
836
+ return collapsed;
728
837
  }
729
838
  if (process.platform === "win32" && path.win32.isAbsolute(trimmed)) {
730
839
  const normalized = path.win32.normalize(trimmed);
731
- return normalized.length > 3 ? normalized.replace(/[\\/]+$/u, "") : normalized;
840
+ const collapsed = normalized.length > 3 ? normalized.replace(TRAILING_PATH_SEPARATORS, "") : normalized;
841
+ if (path.win32.parse(collapsed).root === collapsed) throw new Error(format(messages.repoMustNotBeFilesystemRoot, { root: trimmed }));
842
+ return collapsed;
732
843
  }
733
844
  throw new Error(format(messages.repoMustBeAbsolute, { root: trimmed }));
734
845
  }
@@ -778,8 +889,8 @@ function resolveRepoFile(root, value, name, maxBytes, messages) {
778
889
  name,
779
890
  path: text
780
891
  }));
781
- const stat = statSync(real);
782
- if (!stat.isFile()) throw new Error(format(messages.repoFileNotFile, {
892
+ const stat = statSync(real, { throwIfNoEntry: false });
893
+ if (stat === void 0 || !stat.isFile()) throw new Error(format(messages.repoFileNotFile, {
783
894
  name,
784
895
  path: text
785
896
  }));
@@ -887,17 +998,31 @@ function resolveOutputFormat(value) {
887
998
  return REVIEW_OUTPUT_FORMATS.find((candidate) => candidate === value) ?? "json";
888
999
  }
889
1000
  // lib/cli.ts
890
- /** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
891
- * pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
1001
+ /** Homebrew 安装路径(`Cellar` / `Homebrew` 任一段)。 */
1002
+ const HOMEBREW_PATH = /(?:^|[/\\])(?:Cellar|Homebrew)(?:[/\\]|$)/iu;
1003
+ /** `ocr` 版本行:`open-code-review v1.13.3`(允许预发布/构建元数据)。 */
1004
+ const OCR_VERSION = /open-code-review\s+v?(?<version>\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/u;
892
1005
  /** `--exclude` 的模式条数上限。**不是审查范围的上限**,只是防一条失控输入把命令串撑爆;
893
1006
  * 真实使用里几十条就到头,给到 2000 是为了"尽量不截断"——超了会明确告警,不静默丢。 */
894
1007
  const EXCLUDE_LIST_LIMIT = 2e3;
895
1008
  /** `--path` 的路径条数上限(同 EXCLUDE_LIST_LIMIT 的取向:够大 + 超限告警)。 */
896
1009
  const PATH_LIST_LIMIT = 2e3;
1010
+ /** `ocr delegate rule` 的位置参数条数上限。与 PATH_LIST_LIMIT 分列,是因为两者的投放
1011
+ * 方式不同:这些路径逐个作为位置参数进 argv,而 `--path` 把同量级的路径压进一个逗号串;
1012
+ * 上限仍只用于拦住失控输入,合法清单以 OCR 侧实际处理能力为准。 */
1013
+ const DELEGATE_PATHS_LIMIT = 200;
1014
+ /** 会话过滤列表(severity/category)的条数上限。它拦的是失控输入的长度,不是取值域:
1015
+ * severity 的合法取值另由白名单收口,拿枚举个数当上限会把「合法取值 + 一个重复值」误报成
1016
+ * 超限。 */
1017
+ const SESSION_FILTER_LIST_LIMIT = 50;
897
1018
  /** `--rule` / `--background-file` 的文件大小上限:规则是检查清单,背景是需求文档。 */
898
1019
  const RULE_FILE_MAX_BYTES = 262144;
899
1020
  const BACKGROUND_FILE_MAX_BYTES = 1048576;
900
1021
  const MAX_GIT_PROCS_FLAG = "--max-git-procs";
1022
+ /** `review` / `scan` / `delegate preview` 共用:四类命令构造各自转发同一枚上限。 */
1023
+ const MAX_TOKENS_FLAG = "--max-tokens";
1024
+ /** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
1025
+ * pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
901
1026
  const BACKGROUND_FLAG = "--background";
902
1027
  /**
903
1028
  * 本包的契约是按外部 `ocr` CLI 的哪一版核对的(README 的前置条件、装法建议与工具回显里
@@ -1071,15 +1196,14 @@ function pushTextFlag(parts, flag, value, messages) {
1071
1196
  const text = idValue(value, flag.slice(2), messages);
1072
1197
  if (text !== "") parts.push(flag, shq(text));
1073
1198
  }
1074
- /** --exclude/--background/--provider/--model 等通用 flag。
1075
- * 注意:ocr 的 --exclude 是 String 类型(实测多个 flag 实例只认最后一个,
1076
- * 逗号分隔才生效)——多值必须合并为一个 flag 逗号连接,绝不能展开多个。
1077
- * resume 不在这里:它与 scope 是互斥语义(workspace 禁恢复),由
1078
- * buildReviewCommand 在拿到 scopeFlags 结果后前置校验。 */
1079
- function optionalFlags(args, messages) {
1199
+ /** 只作用于 LLM 阶段的说明面 flag:--background / --provider / --model。
1200
+ * preview 不建 LLM 运行时,故这三个值不参与文件选择(`agent.Preview` 的入参里没有
1201
+ * Background,也不加载 provider 配置);非 preview 分支与 delegate preview 各自按需调用。
1202
+ * 注意:ocr 的 --exclude 是 String 类型(实测多个 flag 实例只认最后一个,逗号分隔才生效)
1203
+ * ——多值必须合并为一个 flag 逗号连接,绝不能展开多个。exclude 与 rule/maxGitProcs 同属
1204
+ * 选择面,住在 selectionFlags。 */
1205
+ function backgroundFlags(args, messages) {
1080
1206
  const parts = [];
1081
- const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
1082
- if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1083
1207
  pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
1084
1208
  pushTextFlag(parts, "--provider", args.provider, messages);
1085
1209
  pushTextFlag(parts, "--model", args.model, messages);
@@ -1123,7 +1247,7 @@ function wrapWithHostReaper(command, messages) {
1123
1247
  if (process.platform === "win32") return command;
1124
1248
  return `bash -c ${shq([
1125
1249
  `p0=$(ps -o ppid= -p $$ 2>/dev/null | tr -d ' '); [ -n "$p0" ] && HAVEPS=1 || HAVEPS=0`,
1126
- `pgrep -P 1 >/dev/null 2>&1 && HAVEPG=1 || HAVEPG=0`,
1250
+ `pgrep -P 1 >/dev/null 2>&1; [ $? -lt 2 ] && HAVEPG=1 || HAVEPG=0`,
1127
1251
  `[ "$HAVEPS" = 1 ] && [ "$HAVEPG" = 1 ] || { printf '%s\\n' ${shq(messages.reaperDegraded)} >&2; { ${command} ; }; exit $?; }`,
1128
1252
  `{ ${command} ; } & ocr=$!`,
1129
1253
  `desc() { for k in $(pgrep -P "$1" 2>/dev/null); do desc "$k"; printf '%s ' "$k"; done; }`,
@@ -1185,7 +1309,7 @@ function realPathOrSelf(candidate) {
1185
1309
  function inferOcrInstallChannel(candidate) {
1186
1310
  const resolved = realPathOrSelf(candidate);
1187
1311
  if (resolved.includes("node_modules")) return "npm";
1188
- return /(?:^|[/\\])(?:Cellar|Homebrew)(?:[/\\]|$)/iu.test(resolved) ? "brew" : "unknown";
1312
+ return HOMEBREW_PATH.test(resolved) ? "brew" : "unknown";
1189
1313
  }
1190
1314
  /** brew 的 formula 名与 npm 的包名不同:formula 不带 scope。 */
1191
1315
  const OCR_BREW_FORMULA = "open-code-review";
@@ -1324,7 +1448,7 @@ function buildVersionCommand() {
1324
1448
  * 读不出即 null:未发布构建的 `<Version>` 是 `dev`,没有版本号可报,猜一个等于编一个。
1325
1449
  */
1326
1450
  function parseOcrVersion(text) {
1327
- return /open-code-review\s+v?(?<version>\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/u.exec(text)?.groups?.["version"] ?? null;
1451
+ return OCR_VERSION.exec(text)?.groups?.["version"] ?? null;
1328
1452
  }
1329
1453
  /**
1330
1454
  * `--format` 交给 OCR 的取值:本包只认 json——`lib/parse.ts` 的解析层只解 JSON 一份形状。
@@ -1360,6 +1484,7 @@ function buildReviewCommand(args, outputPath, messages) {
1360
1484
  if (preview) {
1361
1485
  if (resume !== "") throw new Error(messages.previewRejectsResume);
1362
1486
  parts.push("--preview", ...scopeFlags(args, messages), ...selectionFlags(args, repo, messages));
1487
+ pushIntFlag(parts, MAX_TOKENS_FLAG, args.maxTokens, 0, messages);
1363
1488
  if (outputPath !== void 0) parts.push("--output", shq(outputPath));
1364
1489
  return {
1365
1490
  command: parts.join(" "),
@@ -1374,13 +1499,10 @@ function buildReviewCommand(args, outputPath, messages) {
1374
1499
  pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
1375
1500
  pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
1376
1501
  const scopeParts = scopeFlags(args, messages);
1377
- parts.push(...scopeParts, ...optionalFlags(args, messages));
1502
+ parts.push(...scopeParts, ...selectionFlags(args, repo, messages), ...backgroundFlags(args, messages));
1378
1503
  pushBoolFlag(parts, "--no-filter", args.noFilter, "noFilter", messages);
1379
- const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1380
- if (rule !== null) parts.push("--rule", shq(rule));
1381
1504
  const backgroundFile = repoFileOf(args.backgroundFile, repo, "backgroundFile", BACKGROUND_FILE_MAX_BYTES, messages);
1382
1505
  if (backgroundFile !== null) parts.push("--background-file", shq(backgroundFile));
1383
- pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
1384
1506
  if (resume !== "") {
1385
1507
  if (scopeParts.length === 0) throw new Error(messages.resumeNeedsScope);
1386
1508
  parts.push("--resume", shq(resume));
@@ -1409,7 +1531,10 @@ function buildScanCommand(args, outputPath, messages) {
1409
1531
  "--format",
1410
1532
  outputFormat
1411
1533
  ];
1412
- if (preview) parts.push("--preview");
1534
+ if (preview) {
1535
+ parts.push("--preview");
1536
+ pushIntFlag(parts, MAX_TOKENS_FLAG, args.maxTokens, 0, messages);
1537
+ }
1413
1538
  const paths = commaList(args.path, "path", PATH_LIST_LIMIT, messages);
1414
1539
  if (paths.length > 0) parts.push("--path", shq(paths.join(",")));
1415
1540
  if (!preview) {
@@ -1419,7 +1544,7 @@ function buildScanCommand(args, outputPath, messages) {
1419
1544
  pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
1420
1545
  pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
1421
1546
  pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
1422
- pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
1547
+ pushIntFlag(parts, MAX_TOKENS_FLAG, args.maxTokens, 0, messages);
1423
1548
  pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
1424
1549
  pushBoolFlag(parts, "--no-plan", args.noPlan, "noPlan", messages);
1425
1550
  pushBoolFlag(parts, "--no-dedup", args.noDedup, "noDedup", messages);
@@ -1447,16 +1572,12 @@ function buildDelegatePreviewCommand(args, messages) {
1447
1572
  "preview",
1448
1573
  "--format",
1449
1574
  "json",
1450
- ...scopeFlags(scopeArgs, messages)
1575
+ ...scopeFlags(scopeArgs, messages),
1576
+ ...selectionFlags(scopeArgs, repo, messages),
1577
+ ...backgroundFlags(scopeArgs, messages)
1451
1578
  ];
1452
- const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
1453
- if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1454
- pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
1455
1579
  const backgroundFile = repoFileOf(args.backgroundFile, repo, "backgroundFile", BACKGROUND_FILE_MAX_BYTES, messages);
1456
1580
  if (backgroundFile !== null) parts.push("--background-file", shq(backgroundFile));
1457
- const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1458
- if (rule !== null) parts.push("--rule", shq(rule));
1459
- pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
1460
1581
  return {
1461
1582
  command: parts.join(" "),
1462
1583
  workdir: repo
@@ -1465,7 +1586,7 @@ function buildDelegatePreviewCommand(args, messages) {
1465
1586
  /** 构造 `ocr delegate rule` 命令(路径为位置参数,逐条 shq 转义)。 */
1466
1587
  function buildDelegateRuleCommand(args, messages) {
1467
1588
  const repo = assertAbsoluteRoot(args.repo, messages);
1468
- const paths = commaList(args.paths, "paths", 200, messages).map((filePath) => shq(filePath));
1589
+ const paths = commaList(args.paths, "paths", DELEGATE_PATHS_LIMIT, messages).map((filePath) => shq(filePath));
1469
1590
  if (paths.length === 0) throw new Error(messages.pathsRequired);
1470
1591
  const parts = [
1471
1592
  ocrCommand(),
@@ -1527,13 +1648,14 @@ const SESSION_SEVERITIES = [
1527
1648
  "medium",
1528
1649
  "low"
1529
1650
  ];
1530
- /** severity 过滤:大小写归一 + 白名单;未知值报错而不是交给 CLI 返回空集。 */
1651
+ /** severity 过滤:大小写归一 + 白名单 + 去重;未知值报错而不是交给 CLI 返回空集。 */
1531
1652
  function severityFilter(value, messages) {
1532
- return commaList(value, "severity", SESSION_SEVERITIES.length, messages).map((item) => {
1653
+ const items = commaList(value, "severity", SESSION_FILTER_LIST_LIMIT, messages).map((item) => {
1533
1654
  const lower = item.toLowerCase();
1534
1655
  if (!SESSION_SEVERITIES.includes(lower)) throw new Error(format(messages.invalidSeverity, { value: item }));
1535
1656
  return lower;
1536
1657
  });
1658
+ return [...new Set(items)];
1537
1659
  }
1538
1660
  /** session list --limit:缺省 10(OCR 原生默认 20,本包收紧;OCR 的 0=不限在本包
1539
1661
  * 上限 100 下无意义)。显式给出时必须是 1-100 的整数(与工具 description 的承诺
@@ -1661,7 +1783,7 @@ function buildSessionCommand(args, messages) {
1661
1783
  before: idValue(args.before, "before", messages),
1662
1784
  after: idValue(args.after, "after", messages),
1663
1785
  severities: severityFilter(args.severity, messages),
1664
- categories: commaList(args.category, "category", 50, messages),
1786
+ categories: commaList(args.category, "category", SESSION_FILTER_LIST_LIMIT, messages),
1665
1787
  hasFilters: false,
1666
1788
  limit: args.limit,
1667
1789
  output: idValue(args.output, "output", messages),
@@ -1721,6 +1843,7 @@ function aggregateComments(comments) {
1721
1843
  if (category !== "") categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
1722
1844
  }
1723
1845
  const byCategory = Object.fromEntries(categoryCounts);
1846
+ Object.setPrototypeOf(byCategory, null);
1724
1847
  return {
1725
1848
  total: comments.length,
1726
1849
  bySeverity,
@@ -1729,6 +1852,8 @@ function aggregateComments(comments) {
1729
1852
  };
1730
1853
  }
1731
1854
  // lib/parse.ts
1855
+ /** 只认十进制整数的行号/计数文本。 */
1856
+ const DECIMAL_INTEGER = /^\d+$/u;
1732
1857
  /**
1733
1858
  * OCR 评审 JSON 的 status 枚举(判据在 CLI 侧 `cmd/opencodereview/output.go` 的 jsonOutput
1734
1859
  * 装配与 `internal/session/manifest.go` 的 TerminalState)。
@@ -1784,7 +1909,7 @@ function num(value) {
1784
1909
  if (typeof value === "number") return nonNegativeInt(value);
1785
1910
  if (typeof value === "string") {
1786
1911
  const trimmed = value.trim();
1787
- if (!/^\d+$/u.test(trimmed)) return 0;
1912
+ if (!DECIMAL_INTEGER.test(trimmed)) return 0;
1788
1913
  return nonNegativeInt(Number(trimmed));
1789
1914
  }
1790
1915
  return 0;
@@ -1813,7 +1938,9 @@ function llmOf(raw) {
1813
1938
  model
1814
1939
  };
1815
1940
  }
1816
- /** `resume` 回执:字段全空时回 null(CLI 只在真的续审时发这个对象)。 */
1941
+ /** `retry_report`:三个计数全为 0 时回 null(CLI 只在这一场发生过重试/恢复时才发
1942
+ * 这个对象;键是 total_retries / recovered_requests / failed_requests,不是 resume 的
1943
+ * resumed_from / reused_files 那一组)。 */
1817
1944
  function retryOf(raw) {
1818
1945
  if (!isRecord(raw)) return null;
1819
1946
  const view = {
@@ -1845,11 +1972,15 @@ function capLines(lines, total, messages) {
1845
1972
  if (lines.length >= total) return lines;
1846
1973
  return [...lines, format(messages.diagnosticOmitted, { count: String(total - lines.length) })];
1847
1974
  }
1848
- /** 计数字段的合法形态:非负整数或纯十进制数字串(与 {@link num} 的取值域对齐,
1849
- * 否则 "x" 或 -1 会被 num 折成 0,把「读不出」伪装成「零失败」)。 */
1975
+ /** 计数字段的合法形态:**安全整数域内**的非负整数或纯十进制数字串——与 {@link num}
1976
+ * 的取值域是同一道闸。只查「是不是数字串」不够:`"99999999999999999999"` 过得了
1977
+ * `^\d+$`,而 num() 的 nonNegativeInt 会把它折成 0,于是「读不出」被伪装成「零失败」
1978
+ * (tool_calls 不进 unreadable、preview 的计数回退到本地长度),两种口径必须一起判。 */
1850
1979
  function isCount(value) {
1851
1980
  if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0;
1852
- return typeof value === "string" && /^\d+$/u.test(value.trim());
1981
+ if (typeof value !== "string") return false;
1982
+ const trimmed = value.trim();
1983
+ return DECIMAL_INTEGER.test(trimmed) && Number.isSafeInteger(Number(trimmed));
1853
1984
  }
1854
1985
  /**
1855
1986
  * 解析 `tool_calls`。字段缺失或为 null(这一场没有工具调用记录)返回 null;
@@ -1868,8 +1999,7 @@ function toolCallsOf(raw, messages) {
1868
1999
  view: null,
1869
2000
  unreadable: true
1870
2001
  };
1871
- const rows = Array.isArray(details) ? details : [];
1872
- const rendered = rows.filter((row) => isRecord(row)).map((row) => {
2002
+ const rendered = (Array.isArray(details) ? details : []).filter((row) => isRecord(row)).map((row) => {
1873
2003
  const key = str(row["file_path"]);
1874
2004
  const tool = str(row["tool_name"]);
1875
2005
  const error = str(row["error"]);
@@ -1879,7 +2009,7 @@ function toolCallsOf(raw, messages) {
1879
2009
  view: {
1880
2010
  total: num(doc["total"]),
1881
2011
  failure: num(doc["failure"]),
1882
- failures: capLines(rendered.slice(0, DIAGNOSTIC_DETAIL_LIMIT), rows.length, messages)
2012
+ failures: capLines(rendered.slice(0, DIAGNOSTIC_DETAIL_LIMIT), rendered.length, messages)
1883
2013
  },
1884
2014
  unreadable: false
1885
2015
  };
@@ -1998,17 +2128,35 @@ function reviewFailure(status, message) {
1998
2128
  * 诊断字段读不出来时**不判整场失败**(评论仍然有效、token 已经花掉),而是各补一条
1999
2129
  * 说明进同一个 warnings 出口——「这一路没有数据」必须看得见,但不能把结果作废。
2000
2130
  */
2131
+ /**
2132
+ * OCR 的 warnings 数组 → 渲染行 + 限流旗标(`limitReached`)。
2133
+ *
2134
+ * 明细到 DIAGNOSTIC_DETAIL_LIMIT 就停:条目带 file/message 全文,大会话里几百条能把
2135
+ * 「省 token 的摘要」变成第二份原始输出。旗标必须扫**完整数组**——限流条目落在明细
2136
+ * 上限之外时同样要抬旗,否则截断本身会把「这一场被限流」这条事实吃掉。
2137
+ */
2138
+ function warningLinesOf(warningsRaw, messages) {
2139
+ const lines = [];
2140
+ let limitReached = false;
2141
+ for (const warn of warningsRaw) {
2142
+ limitReached ||= isRecord(warn) && LIMIT_WARNING_TYPES.has(str(warn["type"]));
2143
+ if (lines.length < DIAGNOSTIC_DETAIL_LIMIT) lines.push(warningLineOf(warn, messages));
2144
+ }
2145
+ if (warningsRaw.length > DIAGNOSTIC_DETAIL_LIMIT) lines.push(format(messages.diagnosticOmitted, { count: String(warningsRaw.length - DIAGNOSTIC_DETAIL_LIMIT) }));
2146
+ return {
2147
+ lines,
2148
+ limitReached
2149
+ };
2150
+ }
2001
2151
  function diagnosticsOf(root, messages) {
2152
+ const warningsRaw = root["warnings"];
2002
2153
  const warnings = [];
2003
2154
  let limitReached = false;
2004
- const warningsRaw = root["warnings"];
2005
- if (warningsRaw !== void 0) {
2006
- if (Array.isArray(warningsRaw)) for (const warn of warningsRaw) {
2007
- warnings.push(warningLineOf(warn, messages));
2008
- limitReached ||= isRecord(warn) && LIMIT_WARNING_TYPES.has(str(warn["type"]));
2009
- }
2010
- else warnings.push(messages.warningUnreadable);
2011
- }
2155
+ if (Array.isArray(warningsRaw)) {
2156
+ const { lines, limitReached: flagged } = warningLinesOf(warningsRaw, messages);
2157
+ warnings.push(...lines);
2158
+ limitReached = flagged;
2159
+ } else if (warningsRaw !== void 0) warnings.push(messages.warningUnreadable);
2012
2160
  const toolCalls = toolCallsOf(root["tool_calls"], messages);
2013
2161
  const coverage = coverageOf(root["manifest"], messages);
2014
2162
  if (toolCalls.unreadable) warnings.push(format(messages.diagnosticUnreadable, { field: "tool_calls" }));
@@ -2090,17 +2238,24 @@ function parseReviewOutput(text, messages) {
2090
2238
  droppedComments
2091
2239
  };
2092
2240
  }
2241
+ /** 行号锚点:`start-end` / `start`;未锚定(start_line<=0)回空串,由调用方决定怎么表达。 */
2242
+ function lineRangeOf(comment) {
2243
+ if (comment.startLine <= 0) return "";
2244
+ return comment.endLine > comment.startLine ? `${comment.startLine}-${comment.endLine}` : `${comment.startLine}`;
2245
+ }
2093
2246
  /** 默认摘要计划(no-object-as-default-parameter:避免对象字面量作参数默认值)。 */
2094
2247
  const DEFAULT_PLAN = {
2095
2248
  maxComments: 12,
2096
2249
  includeZeroLine: false
2097
2250
  };
2098
- /** 单条评论的 top 位置(0 行未锚定降级为 :0)。 */
2099
- function locationOf(comment) {
2100
- const fallbackPath = comment.path || "(path unknown)";
2101
- if (comment.startLine <= 0) return `${fallbackPath}:0`;
2102
- const suffix = comment.endLine > comment.startLine ? `-${comment.endLine}` : "";
2103
- return `${fallbackPath}:${comment.startLine}${suffix}`;
2251
+ /** 单条评论的位置标签:`path:start[-end]`,未锚定降级为 `:0`。
2252
+ *
2253
+ * path 缺失时的占位文本来自 messages(`pathUnknown`),不在这里硬编码:本模块产出的
2254
+ * 人读文本全部经 messages(见文件头「文案」一段),硬编码的英文占位会让中文界面里
2255
+ * 冒出英文,也与 renderComment 里那份副本各说一套。 */
2256
+ function locationOf(comment, messages) {
2257
+ const range = lineRangeOf(comment);
2258
+ return `${comment.path || messages.pathUnknown}:${range === "" ? "0" : range}`;
2104
2259
  }
2105
2260
  /**
2106
2261
  * 生成工具返回给模型的结构化摘要(省 token:给聚合与 top-N 评论,不塞全量)。
@@ -2131,7 +2286,7 @@ function summarizeReview(parsed, messages, plan = DEFAULT_PLAN) {
2131
2286
  droppedCount: parsed.comments.length - ranked.length,
2132
2287
  invalidCommentCount: parsed.droppedComments,
2133
2288
  topComments: ranked.map((comment) => ({
2134
- location: locationOf(comment),
2289
+ location: locationOf(comment, messages),
2135
2290
  severity: comment.severity,
2136
2291
  category: comment.category,
2137
2292
  content: comment.content,
@@ -2143,14 +2298,16 @@ function summarizeReview(parsed, messages, plan = DEFAULT_PLAN) {
2143
2298
  hint: messages.reviewSummaryHint
2144
2299
  };
2145
2300
  }
2146
- /** preview/rule 文档各自的契约键:一个都不命中即视为「不是该命令的输出」。 */
2147
- const DELEGATE_PREVIEW_KEYS = [
2148
- "mode",
2149
- "repository",
2150
- "total_files",
2151
- "reviewable_files"
2152
- ];
2153
- const DELEGATE_RULE_KEYS = ["groups", "schema_version"];
2301
+ /** preview/rule 文档各自的结构性键:一个都不命中即视为「不是该命令的输出」。
2302
+ *
2303
+ * 只取结构性键(数组字段),不取 `mode` / `schema_version` / `total_files` 这类
2304
+ * 任何回执都可能带的通用键:`{"schema_version":"1.0","error":"auth failed"}` 命中
2305
+ * 通用键就会过闸,被报成「0 个文件可审 / 0 条规则」——那是假阴性的范围结论,比解析
2306
+ * 失败更难察觉。两侧由 CLI 的结构体标签保证必带(cmd/opencodereview/delegate_cmd.go:
2307
+ * `ReviewableFiles []… \`json:"reviewable_files"\`` 与 `Groups []… \`json:"groups"\``,
2308
+ * 都没有 omitempty,空清单也发 [])。 */
2309
+ const DELEGATE_PREVIEW_KEYS = ["reviewable_files"];
2310
+ const DELEGATE_RULE_KEYS = ["groups"];
2154
2311
  function hitsContract(root, keys) {
2155
2312
  return keys.some((key) => root[key] !== void 0);
2156
2313
  }
@@ -2174,7 +2331,6 @@ function delegatePreviewFailure() {
2174
2331
  * 文件」,是半截产出,模型与用户都会拿它当范围结论。
2175
2332
  */
2176
2333
  function toDelegateFiles(raw) {
2177
- if (!Array.isArray(raw)) return [];
2178
2334
  const files = raw.map((entry) => {
2179
2335
  if (!isRecord(entry)) return null;
2180
2336
  const entryPath = str(entry["path"]);
@@ -2196,7 +2352,6 @@ function toDelegateFiles(raw) {
2196
2352
  * 于是「被排除文件及原因」永远只有文件没有原因——schema 与工具描述都在承诺后者。
2197
2353
  * `reason` 作回退保留:形如 `{"reason":…}` 的旧形态/别家产物仍读得出来。 */
2198
2354
  function toExcludedFiles(raw) {
2199
- if (!Array.isArray(raw)) return [];
2200
2355
  const files = raw.map((entry) => {
2201
2356
  if (!isRecord(entry)) return null;
2202
2357
  const entryPath = str(entry["path"]);
@@ -2219,12 +2374,12 @@ function parseDelegatePreview(text) {
2219
2374
  if (!isRecord(doc)) return delegatePreviewFailure();
2220
2375
  const root = doc;
2221
2376
  if (!hitsContract(root, DELEGATE_PREVIEW_KEYS)) return delegatePreviewFailure();
2222
- for (const key of ["reviewable_files", "excluded_files"]) {
2223
- const value = root[key];
2224
- if (value !== void 0 && !Array.isArray(value)) return delegatePreviewFailure();
2225
- }
2226
- const reviewable = toDelegateFiles(root["reviewable_files"]);
2227
- const excluded = toExcludedFiles(root["excluded_files"]);
2377
+ const reviewableRaw = root["reviewable_files"];
2378
+ if (!Array.isArray(reviewableRaw)) return delegatePreviewFailure();
2379
+ const excludedRaw = root["excluded_files"];
2380
+ if (excludedRaw !== void 0 && !Array.isArray(excludedRaw)) return delegatePreviewFailure();
2381
+ const reviewable = toDelegateFiles(reviewableRaw);
2382
+ const excluded = toExcludedFiles(excludedRaw ?? []);
2228
2383
  if (reviewable === null || excluded === null) return delegatePreviewFailure();
2229
2384
  return {
2230
2385
  ok: true,
@@ -2256,11 +2411,12 @@ function parseDelegateRules(text) {
2256
2411
  ok: false,
2257
2412
  groups: []
2258
2413
  };
2259
- if (root["groups"] !== void 0 && !Array.isArray(root["groups"])) return {
2414
+ const groupsValue = root["groups"];
2415
+ if (!Array.isArray(groupsValue)) return {
2260
2416
  ok: false,
2261
2417
  groups: []
2262
2418
  };
2263
- const groupsRaw = Array.isArray(root["groups"]) ? root["groups"] : [];
2419
+ const groupsRaw = groupsValue;
2264
2420
  if (groupsRaw.some((groupRaw) => isRecord(groupRaw) && groupRaw["files"] !== void 0 && !Array.isArray(groupRaw["files"]))) return {
2265
2421
  ok: false,
2266
2422
  groups: []
@@ -2351,26 +2507,26 @@ function compareSideOf(raw) {
2351
2507
  range: isRecord(raw) ? str(raw["range"]) : ""
2352
2508
  };
2353
2509
  }
2354
- function compareCommentOf(raw) {
2510
+ function compareCommentOf(raw, messages) {
2355
2511
  const comment = commentOf(raw);
2356
2512
  if (comment === null) return null;
2357
2513
  return {
2358
- location: locationOf(comment),
2514
+ location: locationOf(comment, messages),
2359
2515
  severity: comment.severity,
2360
2516
  category: comment.category,
2361
2517
  content: comment.content
2362
2518
  };
2363
2519
  }
2364
2520
  /** 一个 compare 桶:缺失/非数组判整个 compare 失败,部分条目坏只丢该条并计入 dropped。 */
2365
- function compareBucketOf(raw) {
2521
+ function compareBucketOf(raw, messages) {
2366
2522
  if (!Array.isArray(raw)) return null;
2367
2523
  return {
2368
- items: raw.map((entry) => compareCommentOf(entry)).filter((entry) => entry !== null).slice(0, COMPARE_BUCKET_LIMIT),
2524
+ items: raw.map((entry) => compareCommentOf(entry, messages)).filter((entry) => entry !== null).slice(0, COMPARE_BUCKET_LIMIT),
2369
2525
  total: raw.length
2370
2526
  };
2371
2527
  }
2372
2528
  /** 解析 `ocr session compare --json`:四个桶与两侧会话标识。 */
2373
- function parseSessionCompare(text) {
2529
+ function parseSessionCompare(text, messages) {
2374
2530
  let doc;
2375
2531
  try {
2376
2532
  doc = JSON.parse(text);
@@ -2384,10 +2540,10 @@ function parseSessionCompare(text) {
2384
2540
  ok: false,
2385
2541
  view: null
2386
2542
  };
2387
- const newBucket = compareBucketOf(doc["new"]);
2388
- const persisting = compareBucketOf(doc["persisting"]);
2389
- const resolved = compareBucketOf(doc["resolved"]);
2390
- const notReviewed = compareBucketOf(doc["not_reviewed"]);
2543
+ const newBucket = compareBucketOf(doc["new"], messages);
2544
+ const persisting = compareBucketOf(doc["persisting"], messages);
2545
+ const resolved = compareBucketOf(doc["resolved"], messages);
2546
+ const notReviewed = compareBucketOf(doc["not_reviewed"], messages);
2391
2547
  if (newBucket === null || persisting === null || resolved === null || notReviewed === null) return {
2392
2548
  ok: false,
2393
2549
  view: null
@@ -2668,23 +2824,32 @@ const REVIEW_SUMMARY_SCHEMA = {
2668
2824
  hint: { type: "string" }
2669
2825
  }
2670
2826
  };
2827
+ /** preview 文件条目里两个输出共有的四字段(review/scan 的预览与 delegate preview
2828
+ * 的文件清单都从这里取,避免两份 additionalProperties:false 各自演进后一边通过、
2829
+ * 另一边被守卫拒绝)。 */
2830
+ const PREVIEW_FILE_FIELDS = {
2831
+ path: { type: "string" },
2832
+ status: { type: "string" },
2833
+ insertions: { type: "number" },
2834
+ deletions: { type: "number" }
2835
+ };
2836
+ const PREVIEW_FILE_REQUIRED = [
2837
+ "path",
2838
+ "status",
2839
+ "insertions",
2840
+ "deletions"
2841
+ ];
2671
2842
  /** review/scan `preview:true` 的文件清单对象(lib/parse.ts 的 PreviewView)。 */
2672
2843
  const PREVIEW_FILE_SCHEMA = {
2673
2844
  type: "object",
2674
2845
  additionalProperties: false,
2675
2846
  required: [
2676
- "path",
2677
- "status",
2678
- "insertions",
2679
- "deletions",
2847
+ ...PREVIEW_FILE_REQUIRED,
2680
2848
  "willReview",
2681
2849
  "excludeReason"
2682
2850
  ],
2683
2851
  properties: {
2684
- path: { type: "string" },
2685
- status: { type: "string" },
2686
- insertions: { type: "number" },
2687
- deletions: { type: "number" },
2852
+ ...PREVIEW_FILE_FIELDS,
2688
2853
  willReview: { type: "boolean" },
2689
2854
  excludeReason: { type: "string" }
2690
2855
  }
@@ -2755,18 +2920,8 @@ const PREVIEW_OUTPUT_SCHEMA = { oneOf: [{
2755
2920
  items: {
2756
2921
  type: "object",
2757
2922
  additionalProperties: false,
2758
- required: [
2759
- "path",
2760
- "status",
2761
- "insertions",
2762
- "deletions"
2763
- ],
2764
- properties: {
2765
- path: { type: "string" },
2766
- status: { type: "string" },
2767
- insertions: { type: "number" },
2768
- deletions: { type: "number" }
2769
- }
2923
+ required: [...PREVIEW_FILE_REQUIRED],
2924
+ properties: { ...PREVIEW_FILE_FIELDS }
2770
2925
  }
2771
2926
  },
2772
2927
  excludedFiles: {
@@ -2929,8 +3084,16 @@ const TEXT_OUTPUT_SCHEMA = {
2929
3084
  const ROUTING_NAME = "ocr-review-routing";
2930
3085
  const ROUTING_ORDER = 1555;
2931
3086
  // lib/provider-projection.ts
2932
- /** llm-pi-ai 命名空间里 provider 表的字段名。 */
3087
+ /** llm-pi-ai 命名空间里 provider 表的字段名。导出给 lib/provider-source.ts:读口判
3088
+ * 「命名空间里有 providers 表吗」与投影取同一张表,两处各写一份字面量迟早漂。 */
2933
3089
  const PROVIDERS_FIELD = "providers";
3090
+ /** provider 的 baseURL 里出现空白或控制字符:URL 解析器会把换行/制表符直接吃掉、
3091
+ * 把空格编码进路径,畸形串因此可能变形后通过,故先按原文拒绝。 */
3092
+ const RAW_SPACE_OR_CONTROL = /[\p{Cc} ]/u;
3093
+ /** 可写进 config.json `api_key_cmd` 的凭据 ref 名(config-store.ts 的构造与反解共用这一枚)。
3094
+ * 收紧到「大写 + 数字 + 下划线」有两层理由:OCR 侧凭据惯例大写;把真实 key 误填进
3095
+ * `apiKeyEnv` 时它不会被当作 ref 送进凭据查询、也不会在失败文案里被原样回显。 */
3096
+ const CREDENTIAL_ENV_KEY = /^[A-Z0-9_]+$/u;
2934
3097
  /**
2935
3098
  * llm-pi-ai 的 `api` 协议 → OCR config 的 `protocol` 映射。
2936
3099
  *
@@ -2997,16 +3160,37 @@ function modelFromRaw(modelRaw) {
2997
3160
  name: name === "" ? id : name
2998
3161
  };
2999
3162
  }
3163
+ /** baseURL 的可用形态:http(s)、有主机、无内嵌凭据。
3164
+ *
3165
+ * 只用前缀正则(此前的 `/^https?:\/\//`)放行了 `https://`(无主机)、内嵌
3166
+ * `user:pass@` 的地址与含换行/空白的串:内嵌凭据会把明文密码写进 OCR config.json,
3167
+ * 其余形态把故障拖到 OCR 调用期才暴露。`new URL` 能拒掉无主机与空白主机,
3168
+ * 但会**静默吃掉**换行/制表符并把空格编码进路径,所以原文里先禁空白与控制字符。 */
3169
+ function isAcceptableBaseUrl(value) {
3170
+ if (RAW_SPACE_OR_CONTROL.test(value)) return false;
3171
+ let parsed;
3172
+ try {
3173
+ parsed = new URL(value);
3174
+ } catch {
3175
+ return false;
3176
+ }
3177
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
3178
+ return parsed.username === "" && parsed.password === "";
3179
+ }
3000
3180
  /** 原始 provider 对象 → DshProvider(缺 baseURL 或形状不合视为无效,返回 null)。 */
3001
3181
  function providerFromRaw(name, raw) {
3002
3182
  if (!isRecord(raw)) return null;
3003
3183
  const entry = raw;
3004
- const apiKeyEnv = typeof entry["apiKeyEnv"] === "string" && entry["apiKeyEnv"].trim() ? entry["apiKeyEnv"].trim() : null;
3184
+ const rawApiKeyEnv = typeof entry["apiKeyEnv"] === "string" ? entry["apiKeyEnv"].trim() : "";
3185
+ const apiKeyEnv = CREDENTIAL_ENV_KEY.test(rawApiKeyEnv) ? rawApiKeyEnv : null;
3186
+ const apiKeyEnvInvalid = apiKeyEnv === null && rawApiKeyEnv !== "" ? rawApiKeyEnv : null;
3005
3187
  const api = apiKeyOf(entry["api"]);
3006
3188
  const protocol = api === null ? "openai" : OCR_PROTOCOL_BY_API.get(api) ?? api;
3007
3189
  const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"].trim() : "";
3008
- if (!baseURL || !/^https?:\/\//iu.test(baseURL)) return null;
3009
- const modelsRaw = Array.isArray(entry["models"]) ? entry["models"] : [];
3190
+ if (!baseURL || !isAcceptableBaseUrl(baseURL)) return null;
3191
+ const modelsField = entry["models"];
3192
+ if (modelsField !== void 0 && modelsField !== null && !Array.isArray(modelsField)) return null;
3193
+ const modelsRaw = Array.isArray(modelsField) ? modelsField : [];
3010
3194
  const models = modelsRaw.map((model) => modelFromRaw(model)).filter((model) => model !== null);
3011
3195
  if (modelsRaw.length > 0 && models.length === 0) return null;
3012
3196
  const displayName = typeof entry["displayName"] === "string" ? entry["displayName"].trim() : "";
@@ -3014,6 +3198,7 @@ function providerFromRaw(name, raw) {
3014
3198
  name,
3015
3199
  displayName: displayName === "" ? name : displayName,
3016
3200
  apiKeyEnv,
3201
+ apiKeyEnvInvalid,
3017
3202
  protocol,
3018
3203
  baseURL,
3019
3204
  models
@@ -3046,23 +3231,35 @@ const SOURCE_READY = Object.freeze({
3046
3231
  /** settings 通道侧抛错(health / descriptors / 投影三处共用同一状态与同一份文案)。 */
3047
3232
  const SOURCE_SETTINGS_FAILED = "settings-failed";
3048
3233
  /**
3234
+ * settings 侧任一读取点抛错时的统一出口:空清单 + settings-failed 原因。
3235
+ *
3236
+ * 抽出来的理由不是「少写几行」:这条是**契约出口**(readProviders 承诺不抛),四处
3237
+ * 各手写一份时,改文案或改状态码只改一处就漂了;现在它只在这里定义一次。
3238
+ */
3239
+ function settingsFailed(messages, error) {
3240
+ return {
3241
+ providers: [],
3242
+ source: {
3243
+ status: SOURCE_SETTINGS_FAILED,
3244
+ message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3245
+ }
3246
+ };
3247
+ }
3248
+ /**
3049
3249
  * 读 provider 清单并把一切失败折进 source(不抛):会话热路径与卡片读面
3050
3250
  * 都只消费返回值,服务缺席/抛错时卡片仍能显示原因。
3051
3251
  */
3052
3252
  function readProviders(gateway, messages) {
3053
- let health;
3253
+ let settingsUp;
3254
+ let credentialsUp;
3054
3255
  try {
3055
- health = gateway.health();
3256
+ const health = gateway.health();
3257
+ settingsUp = health.settings;
3258
+ credentialsUp = health.credentials;
3056
3259
  } catch (error) {
3057
- return {
3058
- providers: [],
3059
- source: {
3060
- status: SOURCE_SETTINGS_FAILED,
3061
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3062
- }
3063
- };
3260
+ return settingsFailed(messages, error);
3064
3261
  }
3065
- if (!health.settings) return {
3262
+ if (!settingsUp) return {
3066
3263
  providers: [],
3067
3264
  source: {
3068
3265
  status: "settings-unavailable",
@@ -3073,26 +3270,14 @@ function readProviders(gateway, messages) {
3073
3270
  try {
3074
3271
  descriptors = gateway.descriptors();
3075
3272
  } catch (error) {
3076
- return {
3077
- providers: [],
3078
- source: {
3079
- status: SOURCE_SETTINGS_FAILED,
3080
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3081
- }
3082
- };
3273
+ return settingsFailed(messages, error);
3083
3274
  }
3084
3275
  let hit;
3085
3276
  try {
3086
3277
  if (!Array.isArray(descriptors)) throw new TypeError(`ctx.settings.describe() 没有给出数组(收到 ${typeof descriptors})`);
3087
3278
  hit = descriptors.find((descriptor) => descriptor.ns === PI_AI_NAMESPACE);
3088
3279
  } catch (error) {
3089
- return {
3090
- providers: [],
3091
- source: {
3092
- status: SOURCE_SETTINGS_FAILED,
3093
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3094
- }
3095
- };
3280
+ return settingsFailed(messages, error);
3096
3281
  }
3097
3282
  if (hit === void 0) return {
3098
3283
  providers: [],
@@ -3101,7 +3286,7 @@ function readProviders(gateway, messages) {
3101
3286
  message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
3102
3287
  }
3103
3288
  };
3104
- const providersValue = fieldOf(hit.value, "providers");
3289
+ const providersValue = fieldOf(hit.value, PROVIDERS_FIELD);
3105
3290
  if (!isRecord(providersValue)) return {
3106
3291
  providers: [],
3107
3292
  source: {
@@ -3113,15 +3298,9 @@ function readProviders(gateway, messages) {
3113
3298
  try {
3114
3299
  providers = providersFromSettingsValue(hit.value);
3115
3300
  } catch (error) {
3116
- return {
3117
- providers: [],
3118
- source: {
3119
- status: SOURCE_SETTINGS_FAILED,
3120
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3121
- }
3122
- };
3301
+ return settingsFailed(messages, error);
3123
3302
  }
3124
- if (!health.credentials) return {
3303
+ if (!credentialsUp) return {
3125
3304
  providers,
3126
3305
  source: {
3127
3306
  status: "credentials-unavailable",
@@ -3136,14 +3315,10 @@ function readProviders(gateway, messages) {
3136
3315
  /** 逐 provider 问凭据服务「有没有 key」(并行;只回布尔)。 */
3137
3316
  async function markKeyFlags(gateway, providers) {
3138
3317
  return await Promise.all(providers.map(async (providerCfg) => {
3139
- const ref = providerCfg.apiKeyEnv;
3140
- if (ref === null || ref === "") return {
3141
- ...providerCfg,
3142
- hasKey: false
3143
- };
3144
- let hasKey;
3318
+ let hasKey = false;
3145
3319
  try {
3146
- hasKey = await gateway.credentialConfigured(ref);
3320
+ const ref = providerCfg.apiKeyEnv;
3321
+ if (ref !== null && ref !== "") hasKey = await gateway.credentialConfigured(ref);
3147
3322
  } catch {
3148
3323
  hasKey = false;
3149
3324
  }
@@ -3156,6 +3331,10 @@ async function markKeyFlags(gateway, providers) {
3156
3331
  // lib/config-store.ts
3157
3332
  /** api_key_cmd 脚本名(pluginDir 兜底路径的唯一拼法来源)。 */
3158
3333
  const GET_CRED_SCRIPT = path.join("scripts", "get-cred.mjs");
3334
+ /** 尾部反斜杠 / 翻倍反斜杠 / 行首 node 命令。 */
3335
+ const TRAILING_BACKSLASHES = /\\+$/u;
3336
+ const DOUBLED_BACKSLASHES = /(?:\\\\)+$/u;
3337
+ const LEADING_NODE_CMD = /^node[ \t]+/u;
3159
3338
  /** 未知值 → 安全字符串(非字符串给 '')。 */
3160
3339
  function strOf(value) {
3161
3340
  return typeof value === "string" ? value : "";
@@ -3228,7 +3407,7 @@ function readExistingText(configPath, messages) {
3228
3407
  * 保证常规路径形态正确)。
3229
3408
  */
3230
3409
  function shqDyn(value) {
3231
- if (process.platform === "win32") return `"${value.replaceAll("\"", "\"\"").replace(/\\+$/u, (backslashes) => backslashes + backslashes)}"`;
3410
+ if (process.platform === "win32") return `"${value.replaceAll("\"", "\"\"").replace(TRAILING_BACKSLASHES, (backslashes) => backslashes + backslashes)}"`;
3232
3411
  return `'${value.replaceAll("'", String.raw`'\''`)}'`;
3233
3412
  }
3234
3413
  /** api_key_cmd 值构造:脚本路径与 env key 名(及可选的 dsh 数据目录)shq 转义后
@@ -3236,10 +3415,11 @@ function shqDyn(value) {
3236
3415
  * $DSH_HOME > homedir()/.dsh):ocrConfigPath 重定向 ocr 子进程的 HOME 后,
3237
3416
  * get-cred 的 homedir() 兜底会跟着跑偏,须在宿主侧把解析出的数据目录显式钉进
3238
3417
  * 命令。undefined(未重定向)时省略,沿用 env/默认档。
3239
- * env key 白名单维持 ^[A-Z0-9_]+$(刻意比宿主 CredentialRef 的语法紧:OCR 侧
3240
- * 惯例大写,且这是写进 config.json 的可信输入)。 */
3418
+ * env key 白名单是 CREDENTIAL_ENV_KEY(定义在 lib/provider-projection.ts,投影与写侧
3419
+ * 共用同一枚;刻意比宿主 CredentialRef 的语法紧:OCR 侧惯例大写,且这是写进
3420
+ * config.json 的可信输入)。 */
3241
3421
  function buildApiKeyCmd(scriptPath, envKey, dshHome, messages) {
3242
- if (!/^[A-Z0-9_]+$/u.test(envKey)) throw new Error(format(messages.badEnvKey, { key: envKey }));
3422
+ if (!CREDENTIAL_ENV_KEY.test(envKey)) throw new Error(format(messages.badEnvKey, { key: envKey }));
3243
3423
  const parts = [
3244
3424
  "node",
3245
3425
  shqDyn(scriptPath),
@@ -3319,7 +3499,7 @@ function readDqToken(body, start) {
3319
3499
  }
3320
3500
  if (!closed) return null;
3321
3501
  return {
3322
- value: value.replace(/(?:\\\\)+$/u, (backslashes) => "\\".repeat(backslashes.length / 2)),
3502
+ value: value.replace(DOUBLED_BACKSLASHES, (backslashes) => "\\".repeat(backslashes.length / 2)),
3323
3503
  next: i
3324
3504
  };
3325
3505
  }
@@ -3361,13 +3541,13 @@ function readDqTokens(body) {
3361
3541
  * 把用户手写的凭据取用方式弄坏,而那正是本包明令不该插手的部分。
3362
3542
  */
3363
3543
  function parseApiKeyCmd(command) {
3364
- const body = command.replace(/^node[ \t]+/u, "");
3544
+ const body = command.replace(LEADING_NODE_CMD, "");
3365
3545
  if (body === command) return null;
3366
3546
  const tokens = readShqTokens(body) ?? readDqTokens(body);
3367
3547
  if (tokens === null) return null;
3368
3548
  const script = tokens[0] ?? "";
3369
3549
  const envKey = tokens[1] ?? "";
3370
- if (script === "" || tokens.length < 2 || tokens.length > 3 || !/^[A-Z0-9_]+$/u.test(envKey)) return null;
3550
+ if (script === "" || tokens.length < 2 || tokens.length > 3 || !CREDENTIAL_ENV_KEY.test(envKey)) return null;
3371
3551
  return {
3372
3552
  script,
3373
3553
  envKey,
@@ -3413,6 +3593,27 @@ async function updateConfigLocked(targetPath, operation) {
3413
3593
  return withFileLock(targetPath, operation);
3414
3594
  }
3415
3595
  /**
3596
+ * 原子写回的落点:符号链接解到真身。
3597
+ *
3598
+ * ocr CLI 自己用 `os.WriteFile`(跟随链接)写这份配置,用户把 `config.json` 链到
3599
+ * dotfiles 仓库是常见用法。官方 `writeFileAtomic` 以 rename 提交,它会**替换链接本身**
3600
+ * (包内文档原文:"replaces a symlinked target itself instead of writing through to its
3601
+ * referent"),于是链接消失、真身停在旧内容,配置仓库从此与运行配置脱钩。
3602
+ * `realpath` 对普通文件返回自身、对链接返回最终真身,一条判据就够;悬空链接解不出真身,
3603
+ * 按链接文本定位它指向的位置(首写会创建该文件);文件尚未存在时两者都失败,仍用原路径。
3604
+ */
3605
+ async function writableTargetOf(targetPath) {
3606
+ try {
3607
+ return await realpath(targetPath);
3608
+ } catch {
3609
+ try {
3610
+ return path.resolve(path.dirname(targetPath), await readlink(targetPath));
3611
+ } catch {
3612
+ return targetPath;
3613
+ }
3614
+ }
3615
+ }
3616
+ /**
3416
3617
  * 原子写回 config.json:先留 `.bak` 备份,再交官方 `writeFileAtomic` 落盘。
3417
3618
  * 此前是 `writeFileSync` 直写:进程/磁盘在写入中途出问题就会留下半截配置
3418
3619
  * (OCR 与用户都读不到),且没有任何回滚点。rename 在同目录内是原子的。
@@ -3421,14 +3622,24 @@ async function updateConfigLocked(targetPath, operation) {
3421
3622
  * 创建(`dirMode`)与新 inode 携带 `mode` 全是官方件既有行为,本仓不再手抄
3422
3623
  * (实测)。本地只剩 `.bak`:官方写文件面无备份语义,且备份必须落在
3423
3624
  * rename **之前**——挪到之后备份到的就是新内容。
3625
+ *
3626
+ * 备份的权限必须**在创建那一刻**就是 0600:备份里可能仍有迁移前的明文 api_key,
3627
+ * 而 `copyFileSync` 沿用源文件权限位,"先复制再 chmod"之间既有宽权限窗口,中途
3628
+ * 崩溃/被杀还会把宽权限永久留下。因此先删旧备份、再按 0600 直接创建;删与写之间
3629
+ * 崩溃只会少一份备份,目标文件尚未动过。
3424
3630
  */
3425
3631
  async function writeConfigAtomic(targetPath, text) {
3632
+ const target = await writableTargetOf(targetPath);
3426
3633
  const bakPath = `${targetPath}.bak`;
3427
- if (existsSync(targetPath)) {
3428
- copyFileSync(targetPath, bakPath);
3429
- chmodSync(bakPath, 384);
3634
+ if (existsSync(target)) {
3635
+ const previous = readFileSync(target);
3636
+ rmSync(bakPath, { force: true });
3637
+ writeFileSync(bakPath, previous, {
3638
+ mode: 384,
3639
+ flag: "wx"
3640
+ });
3430
3641
  }
3431
- await writeFileAtomic(targetPath, text, {
3642
+ await writeFileAtomic(target, text, {
3432
3643
  mode: 384,
3433
3644
  dirMode: 448
3434
3645
  });
@@ -3480,7 +3691,8 @@ function renderSelectedConfig(input, messages) {
3480
3691
  customs[input.provider] = entry;
3481
3692
  doc["provider"] = input.provider;
3482
3693
  doc["custom_providers"] = customs;
3483
- if (input.language !== void 0) doc["language"] = input.language;
3694
+ const language = input.language?.trim();
3695
+ if (language !== void 0 && language !== "") doc["language"] = language;
3484
3696
  return `${JSON.stringify(doc, null, 2)}\n`;
3485
3697
  }
3486
3698
  /**
@@ -3637,8 +3849,9 @@ async function providersForCard(gateway, paths, messages, endpointVerify) {
3637
3849
  * 任何一步不合条件都抛可读错误(Remote 的 select/migrate 兜成 `status: 400`,卡片显示原因)。
3638
3850
  */
3639
3851
  async function applyProviderSelection(gateway, paths, selection, messages) {
3640
- const { provider, model } = selection;
3641
- if (typeof provider !== "string" || typeof model !== "string" || provider.trim() === "" || model.trim() === "") throw new Error(format(messages.providerModelRequired, {}));
3852
+ if (typeof selection.provider !== "string" || typeof selection.model !== "string" || selection.provider.trim() === "" || selection.model.trim() === "") throw new Error(format(messages.providerModelRequired, {}));
3853
+ const provider = selection.provider.trim();
3854
+ const model = selection.model.trim();
3642
3855
  const { providers, source } = readProviders(gateway, messages);
3643
3856
  if (source.status !== "ready") throw new Error(source.message);
3644
3857
  const target = providers.find((providerCfg) => providerCfg.name === provider);
@@ -3646,7 +3859,11 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
3646
3859
  ns: PI_AI_NAMESPACE,
3647
3860
  provider
3648
3861
  }));
3649
- if (target.apiKeyEnv === null || target.apiKeyEnv === "") throw new Error(format(messages.noApiKeyEnv, { provider }));
3862
+ if (target.apiKeyEnv === null) {
3863
+ const invalid = target.apiKeyEnvInvalid;
3864
+ if (invalid !== null) throw new Error(format(messages.badEnvKey, { key: invalid }));
3865
+ throw new Error(format(messages.noApiKeyEnv, { provider }));
3866
+ }
3650
3867
  const keySource = await gateway.credentialSource(target.apiKeyEnv);
3651
3868
  if (keySource === void 0) throw new Error(format(messages.keyNotResolvable, {
3652
3869
  provider,
@@ -3668,7 +3885,7 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
3668
3885
  provider,
3669
3886
  model,
3670
3887
  apiKeyCmd,
3671
- ...selection.language === void 0 ? {} : { language: selection.language }
3888
+ ...selection.language === void 0 || selection.language.trim() === "" ? {} : { language: selection.language.trim() }
3672
3889
  }, messages);
3673
3890
  await writeConfigAtomic(paths.ocrConfigJson, next);
3674
3891
  });
@@ -3686,20 +3903,19 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
3686
3903
  */
3687
3904
  function migrateEntry(entry, envKey, context) {
3688
3905
  if (typeof entry["api_key"] !== "string" || entry["api_key"].length === 0) return "noPlain";
3906
+ const existingCmd = entry["api_key_cmd"];
3907
+ if (typeof existingCmd === "string" && existingCmd.trim() !== "") return "unknown";
3689
3908
  if (envKey === void 0 || envKey === "" || !context.reachableEnvKeys.has(envKey)) return "unknown";
3690
- try {
3691
- entry["api_key_cmd"] = buildApiKeyCmd(context.scriptPath, envKey, context.dshHome, context.messages);
3692
- } catch {
3693
- return "unknown";
3694
- }
3909
+ entry["api_key_cmd"] = buildApiKeyCmd(context.scriptPath, envKey, context.dshHome, context.messages);
3695
3910
  delete entry["api_key"];
3696
3911
  return "migrated";
3697
3912
  }
3698
3913
  /**
3699
3914
  * 迁移:把自定义 provider 条目里的明文 api_key 改为 api_key_cmd(动态读凭据)。
3700
3915
  * api_key_cmd 优先于 api_key,因此清除明文即可。
3701
- * 对「明文 api_key + 无 api_key_cmd」的条目,依据 settings 里该 provider 的
3702
- * apiKeyEnv 补充命令;settings 里找不到的 provider 不动(不破坏既有配置),
3916
+ * 只对「明文 api_key + 无 api_key_cmd」的条目动手,依据 settings 里该 provider 的
3917
+ * apiKeyEnv 补充命令;**已有非空 api_key_cmd 的条目不覆写**(那条命令可能正在生效,
3918
+ * 换掉它等于无声改配置),与 settings 里找不到的 provider 一起记进 skippedUnknown,
3703
3919
  * 在返回信息里说明。credentials 缺席不阻断本操作(它只要 name→apiKeyEnv 映射),
3704
3920
  * settings 侧读不到则抛错——那正是「不知道该写哪条命令」的状态,静默跳过会让用户
3705
3921
  * 以为迁移过了。
@@ -3751,7 +3967,7 @@ async function migratePlaintextKeys(gateway, paths, messages) {
3751
3967
  migrated,
3752
3968
  skippedUnknown
3753
3969
  };
3754
- await writeConfigAtomic(paths.ocrConfigJson, `${JSON.stringify(doc, null, 2)}\n`);
3970
+ await writeConfigAtomic(paths.ocrConfigJson, renderConfigDoc(doc));
3755
3971
  return {
3756
3972
  migrated,
3757
3973
  skippedUnknown
@@ -3768,8 +3984,10 @@ async function migratePlaintextKeys(gateway, paths, messages) {
3768
3984
  * 为什么永不抛错:这是锦上添花的修复,不能因为它把工具拖死。坏 JSON、锁抢不到、权限
3769
3985
  * 不足、设置项写坏——一律放过,让 ocr 报它自己的原始错误,那才是诊断价值最高的形态。
3770
3986
  *
3771
- * 常态零锁开销:先无锁读一次做判据,确实要改写才进 withFileLock(锁内按新快照重判,
3772
- * 避免把并发写入的 provider 选择覆盖掉)。
3987
+ * 锁外那一次读只用于区分两种**不需要动锁**的状态:文件不存在/空白(连配置目录都不该
3988
+ * 被凭空造出来)与读不动(报 checked: false)。其余一律进 withFileLock,并在锁内按
3989
+ * 新快照重判——不拿锁外快照决定「要不要改」,那份快照随时可能过期,为省一次锁而多留
3990
+ * 一个只能靠并发触发的分支不值。
3773
3991
  */
3774
3992
  async function ensureKeyScriptPointer(paths, messages) {
3775
3993
  const scriptPath = credentialScriptPath(paths);
@@ -3815,9 +4033,37 @@ async function ensureKeyScriptPointer(paths, messages) {
3815
4033
  // lib/remote-shape.ts
3816
4034
  /** 宿主服务键 = 客户端线上命名空间(两侧共用的唯一来源)。 */
3817
4035
  const OCR_REMOTE_NAMESPACE = "ocrReview";
4036
+ /**
4037
+ * 拒绝回执的**种类码**(`status` 位)的唯一出处。
4038
+ *
4039
+ * 不是 HTTP 状态:本包已无 HTTP 面,卡片只读 `error`。集中定义的理由是它此前只是裸
4040
+ * `number`:宿主实现在十几处各写一次字面量、类型收不到任何提示,调用方也分不清
4041
+ * 「本包自定的种类码」与顺手塞进来的 HTTP 状态码。测试仍**独立写死**数字(不从这份表
4042
+ * 派生),否则两边一起错时判据会跟着错。
4043
+ *
4044
+ * · `invalid`:入参/配置不合法,卡片改完就能重试。
4045
+ * · `internal`:宿主侧读取或执行失败(读不到配置、子进程起不来),不是改参数能解决的。
4046
+ */
4047
+ const OCR_REFUSE_STATUS = {
4048
+ invalid: 400,
4049
+ internal: 500
4050
+ };
3818
4051
  // lib/mcp-config.ts
3819
4052
  /** 整体上限:MCP 配置是一份人工维护的小表,不承载大文件。 */
3820
4053
  const MCP_MAX_BYTES = 65536;
4054
+ /**
4055
+ * remote MCP server 的 url 只认 http(s),且必须解析得出一台主机。
4056
+ * 前缀匹配(旧实现)会放行 "http://" 这种没有主机的串——写进配置时不报错,OCR 到连接时才炸,
4057
+ * 报错点离用户动作很远,所以在这里用 URL 解析判它。
4058
+ */
4059
+ function isHttpServerUrl(value) {
4060
+ try {
4061
+ const parsed = new URL(value);
4062
+ return (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.hostname !== "";
4063
+ } catch {
4064
+ return false;
4065
+ }
4066
+ }
3821
4067
  const MCP_MAX_SERVERS = 100;
3822
4068
  const MCP_MAX_ARRAY = 100;
3823
4069
  const MCP_MAX_TEXT = 4096;
@@ -3831,6 +4077,19 @@ const KNOWN_FIELDS = /* @__PURE__ */ new Set([
3831
4077
  "setup",
3832
4078
  "env"
3833
4079
  ]);
4080
+ /**
4081
+ * `mcp_servers` 这一支的摘要,读侧交给卡片、写侧在锁内重算后比对。
4082
+ *
4083
+ * 只覆盖 `mcp_servers`:保存本来也只改这一支,`providers`/keys 等由别的动作维护,把它们
4084
+ * 算进基线会让「改了别处」也变成保存冲突。取值用 `JSON.stringify` 的紧凑形态——两侧都由
4085
+ * 同一份文本解析而来,键序一致,摘要可直接逐字节比较。
4086
+ *
4087
+ * @param servers - 从 config.json 解析出的 `mcp_servers`(缺省按空表)
4088
+ * @returns sha256 十六进制摘要
4089
+ */
4090
+ function mcpServersRevision(servers) {
4091
+ return createHash("sha256").update(JSON.stringify(servers)).digest("hex");
4092
+ }
3834
4093
  function failure(name, field, reason, messages) {
3835
4094
  return {
3836
4095
  ok: false,
@@ -3841,6 +4100,19 @@ function failure(name, field, reason, messages) {
3841
4100
  })
3842
4101
  };
3843
4102
  }
4103
+ /**
4104
+ * 用 CreateDataProperty 落动态键,再把原型置空。
4105
+ *
4106
+ * `out[name] = value` 在 name 为 "__proto__" 时走的是 Object.prototype 的 setter:该条目
4107
+ * 既不会成为自有属性(写进 config.json 后静默少一条 server),value 还成了 out 的原型。
4108
+ * Object.fromEntries 走 CreateDataProperty,__proto__ 会落成普通自有键(实测:hasOwn 为 true、
4109
+ * 原型仍是 Object.prototype、JSON.stringify 带上它);置空原型是让读侧查不到继承成员。
4110
+ */
4111
+ function recordOf(entries) {
4112
+ const out = Object.fromEntries(entries);
4113
+ Object.setPrototypeOf(out, null);
4114
+ return out;
4115
+ }
3844
4116
  function textField(value, name, field, messages) {
3845
4117
  if (typeof value !== "string" || value.trim() === "") return failure(name, field, "must be a non-empty string", messages);
3846
4118
  const trimmed = value.trim();
@@ -3864,14 +4136,14 @@ function stringArray(value, name, field, messages) {
3864
4136
  }
3865
4137
  function headersField(value, name, messages) {
3866
4138
  if (!isRecord(value) || Object.keys(value).length > 50) return failure(name, "headers", "must be an object of at most 50 string headers", messages);
3867
- const out = {};
4139
+ const headers = [];
3868
4140
  for (const [header, raw] of Object.entries(value)) {
3869
4141
  if (header.trim() === "" || typeof raw !== "string" || raw.length > MCP_MAX_TEXT) return failure(name, `headers.${header}`, "must be a non-empty name and a string value", messages);
3870
- out[header] = raw;
4142
+ headers.push([header, raw]);
3871
4143
  }
3872
4144
  return {
3873
4145
  ok: true,
3874
- value: out
4146
+ value: recordOf(headers)
3875
4147
  };
3876
4148
  }
3877
4149
  function normalizeStdio(raw, name, messages) {
@@ -3907,7 +4179,7 @@ function normalizeRemote(raw, name, messages) {
3907
4179
  if (raw["command"] !== void 0 || raw["args"] !== void 0 || raw["env"] !== void 0 || raw["setup"] !== void 0) return failure(name, "type", "remote does not accept command/args/env/setup", messages);
3908
4180
  const url = textField(raw["url"], name, "url", messages);
3909
4181
  if (!url.ok) return url;
3910
- if (!/^https?:\/\//iu.test(url.value)) return failure(name, "url", "must start with http:// or https://", messages);
4182
+ if (!isHttpServerUrl(url.value)) return failure(name, "url", "must be a valid http:// or https:// URL with a host", messages);
3911
4183
  const entry = {
3912
4184
  type: "remote",
3913
4185
  url: url.value
@@ -3922,10 +4194,33 @@ function normalizeRemote(raw, name, messages) {
3922
4194
  value: entry
3923
4195
  };
3924
4196
  }
3925
- /** 单个 server 条目 → 只含已知字段的普通对象;未知字段直接拒绝,避免写出 OCR 不认的配置。 */
3926
- function normalizeServer(name, raw, messages) {
4197
+ /**
4198
+ * 单个 server 条目 → 只含已知字段的普通对象。
4199
+ *
4200
+ * 未知字段默认拒绝(避免写出 OCR 不认的配置),但**盘上同一条目已有的未知字段会原样接回**:
4201
+ * OCR 自己的 MCPServerConfig 带 unknownJSONFields 契约(读进来不认识的键在来回写时留着),
4202
+ * 手写或新版写下的字段在这里丢掉,等于用户改一个字段就静默删掉另一个。
4203
+ * 判据是「基线里出现过这个键」——新条目里出现未知键仍判非法,否则一次拼错会被写成 OCR 忽略的键。
4204
+ */
4205
+ /**
4206
+ * 未知字段的保留判据(理由见 normalizeServer 的 JSDoc)。
4207
+ */
4208
+ function preservedFieldsOf(name, raw, baseline, messages) {
4209
+ const preserved = [];
4210
+ for (const field of Object.keys(raw)) if (!KNOWN_FIELDS.has(field)) {
4211
+ if (baseline !== void 0 && !(isRecord(baseline) && Object.hasOwn(baseline, field))) return failure(name, field, "is not a supported MCP field", messages);
4212
+ preserved.push([field, raw[field]]);
4213
+ }
4214
+ return {
4215
+ ok: true,
4216
+ value: preserved
4217
+ };
4218
+ }
4219
+ function normalizeServer(name, raw, messages, baseline) {
3927
4220
  if (!isRecord(raw)) return failure(name, "entry", "must be an object", messages);
3928
- for (const field of Object.keys(raw)) if (!KNOWN_FIELDS.has(field)) return failure(name, field, "is not a supported MCP field", messages);
4221
+ const preservedFields = preservedFieldsOf(name, raw, baseline, messages);
4222
+ if (!preservedFields.ok) return preservedFields;
4223
+ const preserved = preservedFields.value;
3929
4224
  const type = raw["type"] === void 0 ? "stdio" : raw["type"];
3930
4225
  if (type !== "stdio" && type !== "remote") return failure(name, "type", "must be stdio or remote", messages);
3931
4226
  const base = type === "stdio" ? normalizeStdio(raw, name, messages) : normalizeRemote(raw, name, messages);
@@ -3935,9 +4230,10 @@ function normalizeServer(name, raw, messages) {
3935
4230
  if (!tools.ok) return tools;
3936
4231
  base.value["tools"] = tools.value;
3937
4232
  }
4233
+ for (const [field, value] of preserved) base.value[field] = value;
3938
4234
  return base;
3939
4235
  }
3940
- function normalizeServers(raw, messages) {
4236
+ function normalizeServers(raw, messages, baseline) {
3941
4237
  if (!isRecord(raw)) return {
3942
4238
  ok: false,
3943
4239
  error: messages.mcpShapeInvalid
@@ -3947,26 +4243,35 @@ function normalizeServers(raw, messages) {
3947
4243
  ok: false,
3948
4244
  error: format(messages.mcpTooManyServers, { limit: String(MCP_MAX_SERVERS) })
3949
4245
  };
3950
- const out = {};
4246
+ const entries = [];
3951
4247
  for (const name of names) {
3952
4248
  if (name.trim() === "" || name.includes("\0") || name.length > 64) return failure(name, "name", "must be a non-empty key of at most 64 characters", messages);
3953
- const parsed = normalizeServer(name, raw[name], messages);
4249
+ const entryBaseline = baseline === void 0 ? void 0 : baseline[name] ?? {};
4250
+ const parsed = normalizeServer(name, raw[name], messages, entryBaseline);
3954
4251
  if (!parsed.ok) return parsed;
3955
- out[name] = parsed.value;
4252
+ entries.push([name, parsed.value]);
3956
4253
  }
3957
4254
  return {
3958
4255
  ok: true,
3959
- value: out
4256
+ value: recordOf(entries)
3960
4257
  };
3961
4258
  }
3962
- /** 读侧用异步读:卡片端点是 async 面,读大文件时不该占住事件循环。 */
3963
4259
  function isNotFound(error) {
3964
4260
  return isRecord(error) && error["code"] === "ENOENT";
3965
4261
  }
3966
- /** 读当前 `mcp_servers`,交给卡片编辑;文件不存在按空表处理,坏 JSON/读不动返回可读错误。 */
4262
+ /**
4263
+ * 读当前 `mcp_servers`,交给卡片编辑;文件不存在按空表处理,坏 JSON/读不动返回可读错误。
4264
+ *
4265
+ * 读侧是 async 面(卡片端点不该占住事件循环),且**先 stat 再读**:写侧有 MCP_MAX_BYTES
4266
+ * 上限,读侧不设闸就等于允许任意大的 config.json 整个进内存。
4267
+ */
3967
4268
  async function readMcpServers(configPath, messages) {
3968
4269
  let text;
3969
4270
  try {
4271
+ if ((await stat(configPath)).size > MCP_MAX_BYTES) return {
4272
+ ok: false,
4273
+ error: format(messages.mcpJsonTooLarge, { limit: String(MCP_MAX_BYTES) })
4274
+ };
3970
4275
  text = await readFile(configPath, "utf8");
3971
4276
  } catch (error) {
3972
4277
  if (!isNotFound(error)) return {
@@ -3980,7 +4285,8 @@ async function readMcpServers(configPath, messages) {
3980
4285
  }
3981
4286
  if (!text.trim()) return {
3982
4287
  ok: true,
3983
- json: "{}"
4288
+ json: "{}",
4289
+ revision: mcpServersRevision({})
3984
4290
  };
3985
4291
  let doc;
3986
4292
  try {
@@ -3997,7 +4303,8 @@ async function readMcpServers(configPath, messages) {
3997
4303
  const servers = doc["mcp_servers"];
3998
4304
  if (servers === void 0 || servers === null) return {
3999
4305
  ok: true,
4000
- json: "{}"
4306
+ json: "{}",
4307
+ revision: mcpServersRevision({})
4001
4308
  };
4002
4309
  if (!isRecord(servers)) return {
4003
4310
  ok: false,
@@ -4005,11 +4312,24 @@ async function readMcpServers(configPath, messages) {
4005
4312
  };
4006
4313
  return {
4007
4314
  ok: true,
4008
- json: JSON.stringify(servers, null, 2)
4315
+ json: JSON.stringify(servers, null, 2),
4316
+ revision: mcpServersRevision(servers)
4009
4317
  };
4010
4318
  }
4011
- /** 校验并写回 `mcp_servers`;只改这一支,其余配置原样保留。 */
4012
- async function saveMcpServers(configPath, json, messages) {
4319
+ /**
4320
+ * 校验并写回 `mcp_servers`;只改这一支,其余配置原样保留。
4321
+ *
4322
+ * `revision` 是读侧交回的基线:锁内重算盘上这一支的摘要,与基线不一致就整笔拒绝。没有这道
4323
+ * 比较时,两张设置卡(或卡片与手工编辑)先后保存,后一笔会把前一笔新增/修改的条目整段覆盖掉,
4324
+ * 而两笔各自都「成功」。
4325
+ *
4326
+ * @param configPath - 目标 config.json
4327
+ * @param json - 卡片交回的 `mcp_servers` JSON 文本
4328
+ * @param messages - 双语文案
4329
+ * @param revision - 读侧交回的摘要;`undefined` 表示调用方没有基线(旧客户端),跳过比较
4330
+ * @returns 保存后的条数与新摘要,或拒绝理由
4331
+ */
4332
+ async function saveMcpServers(configPath, json, messages, revision) {
4013
4333
  if (Buffer.byteLength(json, "utf8") > MCP_MAX_BYTES) return {
4014
4334
  ok: false,
4015
4335
  error: format(messages.mcpJsonTooLarge, { limit: String(MCP_MAX_BYTES) })
@@ -4023,22 +4343,30 @@ async function saveMcpServers(configPath, json, messages) {
4023
4343
  error: format(messages.mcpJsonInvalid, { cause: String(error) })
4024
4344
  };
4025
4345
  }
4026
- const normalized = normalizeServers(raw, messages);
4027
- if (!normalized.ok) return normalized;
4346
+ const shaped = normalizeServers(raw, messages);
4347
+ if (!shaped.ok) return shaped;
4028
4348
  try {
4029
- return {
4030
- ok: true,
4031
- count: await updateConfigLocked(configPath, async () => {
4032
- const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
4033
- doc["mcp_servers"] = normalized.value;
4034
- await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
4035
- return Object.keys(normalized.value).length;
4036
- })
4037
- };
4349
+ return await updateConfigLocked(configPath, async () => {
4350
+ const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
4351
+ const existing = isRecord(doc["mcp_servers"]) ? doc["mcp_servers"] : {};
4352
+ if (revision !== void 0 && mcpServersRevision(existing) !== revision) return {
4353
+ ok: false,
4354
+ error: messages.mcpSaveConflict
4355
+ };
4356
+ const normalized = normalizeServers(raw, messages, existing);
4357
+ if (!normalized.ok) return normalized;
4358
+ doc["mcp_servers"] = normalized.value;
4359
+ await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
4360
+ return {
4361
+ ok: true,
4362
+ count: Object.keys(normalized.value).length,
4363
+ revision: mcpServersRevision(normalized.value)
4364
+ };
4365
+ });
4038
4366
  } catch (error) {
4039
4367
  return {
4040
4368
  ok: false,
4041
- error: format(messages.mcpReadFailed, {
4369
+ error: format(messages.mcpSaveFailed, {
4042
4370
  where: configPath,
4043
4371
  cause: String(error)
4044
4372
  })
@@ -4053,10 +4381,16 @@ async function applyChatGptModel(configPath, model, messages) {
4053
4381
  error: messages.chatGptModelRequired
4054
4382
  };
4055
4383
  try {
4056
- await updateConfigLocked(configPath, async () => {
4384
+ return await updateConfigLocked(configPath, async () => {
4057
4385
  const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
4058
- const providers = isRecord(doc["providers"]) ? doc["providers"] : {};
4059
- const entry = isRecord(providers["chatgpt"]) ? providers["chatgpt"] : {};
4386
+ const providersRaw = doc["providers"];
4387
+ const entryRaw = isRecord(providersRaw) ? providersRaw["chatgpt"] : void 0;
4388
+ if (providersRaw !== void 0 && providersRaw !== null && !isRecord(providersRaw) || entryRaw !== void 0 && entryRaw !== null && !isRecord(entryRaw)) return {
4389
+ ok: false,
4390
+ error: messages.chatGptProvidersInvalid
4391
+ };
4392
+ const providers = isRecord(providersRaw) ? providersRaw : {};
4393
+ const entry = isRecord(entryRaw) ? entryRaw : {};
4060
4394
  delete entry["api_key"];
4061
4395
  delete entry["api_key_cmd"];
4062
4396
  entry["model"] = trimmed;
@@ -4064,8 +4398,8 @@ async function applyChatGptModel(configPath, model, messages) {
4064
4398
  doc["providers"] = providers;
4065
4399
  doc["provider"] = "chatgpt";
4066
4400
  await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
4401
+ return { ok: true };
4067
4402
  });
4068
- return { ok: true };
4069
4403
  } catch (error) {
4070
4404
  return {
4071
4405
  ok: false,
@@ -4118,10 +4452,13 @@ function isLiveOcrStatus(status) {
4118
4452
  * @param jobs 官方注册表
4119
4453
  * @returns {void}
4120
4454
  */
4121
- function pruneOcrJobHistory(jobs) {
4122
- const settled = jobs.list().filter((job) => job.kind === OCR_JOB_KIND && !isLiveOcrStatus(job.status));
4455
+ function pruneOcrJobHistory(jobs, owner, ledger) {
4456
+ const settled = jobs.list(owner).filter((job) => job.kind === OCR_JOB_KIND && !isLiveOcrStatus(job.status));
4123
4457
  const excess = settled.length - 9;
4124
- for (const job of settled.slice(0, Math.max(excess, 0))) jobs.remove(job.id);
4458
+ for (const job of settled.slice(0, Math.max(excess, 0))) {
4459
+ jobs.remove(job.id, owner);
4460
+ ledger.delete(job.id);
4461
+ }
4125
4462
  }
4126
4463
  /**
4127
4464
  * 在 controller 闸门下起一条未拥有作业,起完立刻摘掉。
@@ -4169,10 +4506,44 @@ async function enforceOcrDeadline(jobs, id, reason, backgroundMaxMs) {
4169
4506
  * @param reason 写进模型可见 detail 的理由(双语字典取)
4170
4507
  * @returns {void}
4171
4508
  */
4172
- function reclaimOcrJobs(jobs, reason) {
4173
- const mine = jobs.list().filter((job) => job.kind === OCR_JOB_KIND);
4174
- for (const job of mine) if (isLiveOcrStatus(job.status)) jobs.kill(job.id, void 0, reason);
4175
- else jobs.remove(job.id);
4509
+ function reclaimSessionJobs(jobs, reason, owner, ledger) {
4510
+ for (const job of jobs.list(owner)) if (job.kind === OCR_JOB_KIND && job.owner === owner) {
4511
+ ledger.delete(job.id);
4512
+ if (isLiveOcrStatus(job.status)) jobs.kill(job.id, owner, reason);
4513
+ else jobs.remove(job.id, owner);
4514
+ }
4515
+ }
4516
+ /** 卸载时收一条有主作业:id 只有台账知道,kill/remove 必须带 owner 作 caller。 */
4517
+ function reclaimOwnedJob(jobs, id, owner, reason) {
4518
+ try {
4519
+ if (isLiveOcrStatus(jobs.get(id, owner).status)) jobs.kill(id, owner, reason);
4520
+ else jobs.remove(id, owner);
4521
+ } catch {}
4522
+ }
4523
+ /**
4524
+ * 卸载时收掉本包起过的全部作业:无主的经 `list()` 枚举,有主的只有台账知道 id
4525
+ * (注册表对没有 caller 的视图隐藏它们,且 kill/remove 必须带 owner 作 caller)。
4526
+ */
4527
+ function reclaimAllJobs(jobs, reason, ledger) {
4528
+ for (const job of jobs.list()) if (job.kind === OCR_JOB_KIND) {
4529
+ if (isLiveOcrStatus(job.status)) jobs.kill(job.id, void 0, reason);
4530
+ else jobs.remove(job.id);
4531
+ }
4532
+ const owned = new Map(ledger);
4533
+ ledger.clear();
4534
+ for (const [id, owner] of owned) if (owner !== void 0) reclaimOwnedJob(jobs, id, owner, reason);
4535
+ }
4536
+ /** 工具调用发起时的会话 id;形状不对就当没有(agentless)。 */
4537
+ function sessionIdOf(exec) {
4538
+ const id = exec.agent?.session?.id;
4539
+ return typeof id === "string" && id.length > 0 ? id : void 0;
4540
+ }
4541
+ /** 该调用可绑定的作业 owner:会话 id 存在、且宿主注册表里确有这条活 Agent。
4542
+ * 给不出活 Agent 时退回 agentless —— 拿一个注册表不认的 id 去 start 只会整条失败。 */
4543
+ function ownerOf(host, exec) {
4544
+ const id = sessionIdOf(exec);
4545
+ const agents = host.get("agents");
4546
+ return id !== void 0 && agents?.get(id) !== void 0 ? id : void 0;
4176
4547
  }
4177
4548
  /** 当前会话工作区(repo 缺省回退源);非字符串视为缺。
4178
4549
  * 官方形状:`agent?: Agent` → `session: Session` → `header: SessionHeader` →
@@ -4393,7 +4764,7 @@ async function outcomeWhenSettled(proc) {
4393
4764
  * @param job 这次后台评审的描述
4394
4765
  * @returns 后台启动回执(canonical 对象,模型侧契约)
4395
4766
  */
4396
- async function startBackground(host, job) {
4767
+ async function startBackground(host, job, ledger) {
4397
4768
  const registry = host.get("jobs");
4398
4769
  if (registry === void 0) throw new Error("background review unavailable: the host provides no ctx.jobs (load @deepseek-ai/dsh-jobs-local); run it with wait:true instead");
4399
4770
  const spec = host.shell.resolve({
@@ -4405,13 +4776,20 @@ async function startBackground(host, job) {
4405
4776
  sandboxPolicy: ocrSandboxPolicy(job.workdir)
4406
4777
  });
4407
4778
  const proc = await host.shell.execute(spec);
4408
- pruneOcrJobHistory(registry);
4409
- const messages = localeMessages(host);
4779
+ let messages;
4780
+ try {
4781
+ pruneOcrJobHistory(registry, job.owner, ledger);
4782
+ messages = localeMessages(host);
4783
+ } catch (error) {
4784
+ killProc(proc);
4785
+ throw error;
4786
+ }
4410
4787
  let jobId;
4411
4788
  try {
4412
4789
  jobId = startUnderOcrController(registry, {
4413
4790
  kind: OCR_JOB_KIND,
4414
4791
  label: `${job.tool} ${job.repo}`,
4792
+ ...job.owner === void 0 ? {} : { owner: job.owner },
4415
4793
  output: reviewSources(proc, messages),
4416
4794
  run: () => ({
4417
4795
  cancel: () => {
@@ -4424,6 +4802,7 @@ async function startBackground(host, job) {
4424
4802
  killProc(proc);
4425
4803
  throw error;
4426
4804
  }
4805
+ ledger.set(jobId, job.owner);
4427
4806
  const backstopMinutes = Math.round(job.backgroundMaxMs / 6e4);
4428
4807
  enforceOcrDeadline(registry, jobId, format(messages.jobReasonTimeout, { minutes: String(backstopMinutes) }), job.backgroundMaxMs);
4429
4808
  return {
@@ -4839,14 +5218,15 @@ async function runLlmTest(host, config) {
4839
5218
  };
4840
5219
  }
4841
5220
  /** effort 入参闸:undefined/null → 设置卡默认(auto 表示跟随 OCR 配置);字符串 →
4842
- * 白名单钳制(未知值回落 auto,与 --format 同一纪律);其余类型抛错——静默回落会改变审查深度。 */
5221
+ * 白名单钳制(未知值按「未指定」处理,回落设置卡值,与 undefined 同一条路);其余类型抛错
5222
+ * ——静默回落会改变审查深度,所以类型错误必须当场说,而认不出的字符串只能当没指定。 */
4843
5223
  function effortOf(value, config, messages) {
4844
5224
  if (value === void 0 || value === null) return readEffort(config);
4845
5225
  if (typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
4846
5226
  name: "effort",
4847
5227
  received: describeValue(value)
4848
5228
  }));
4849
- return clampEffort(value, "medium");
5229
+ return clampEffort(value, readEffort(config));
4850
5230
  }
4851
5231
  /**
4852
5232
  * wait 入参闸:undefined → **false(后台缺省)**;布尔透传;其余类型抛错——字符串
@@ -4963,7 +5343,7 @@ async function canonicalReviewRoot(host, root) {
4963
5343
  stdoutMaxBytes: 4096
4964
5344
  });
4965
5345
  const top = exitCode === 0 ? output.trim() : "";
4966
- if (top.startsWith("/")) return top;
5346
+ if (path.isAbsolute(top)) return top;
4967
5347
  } catch {}
4968
5348
  return realpathOrSelf(root);
4969
5349
  }
@@ -4979,7 +5359,7 @@ async function selectReply(provider, model, deps) {
4979
5359
  if (provider === "" || model === "") return {
4980
5360
  ok: false,
4981
5361
  error: msgs.providerModelRequired,
4982
- status: 400
5362
+ status: OCR_REFUSE_STATUS.invalid
4983
5363
  };
4984
5364
  try {
4985
5365
  const language = readLanguage(deps.config);
@@ -4999,7 +5379,7 @@ async function selectReply(provider, model, deps) {
4999
5379
  return {
5000
5380
  ok: false,
5001
5381
  error: errorText(error),
5002
- status: 400
5382
+ status: OCR_REFUSE_STATUS.invalid
5003
5383
  };
5004
5384
  }
5005
5385
  }
@@ -5036,7 +5416,7 @@ async function probeReply(deps) {
5036
5416
  return {
5037
5417
  ok: false,
5038
5418
  error: errorText(error),
5039
- status: 400
5419
+ status: OCR_REFUSE_STATUS.invalid
5040
5420
  };
5041
5421
  }
5042
5422
  }
@@ -5058,7 +5438,7 @@ async function providersReply(deps) {
5058
5438
  return {
5059
5439
  ok: false,
5060
5440
  error: errorText(error),
5061
- status: 500
5441
+ status: OCR_REFUSE_STATUS.internal
5062
5442
  };
5063
5443
  }
5064
5444
  }
@@ -5074,7 +5454,7 @@ async function migrateReply(deps) {
5074
5454
  return {
5075
5455
  ok: false,
5076
5456
  error: errorText(error),
5077
- status: 400
5457
+ status: OCR_REFUSE_STATUS.invalid
5078
5458
  };
5079
5459
  }
5080
5460
  }
@@ -5090,7 +5470,7 @@ async function testReply(deps) {
5090
5470
  return {
5091
5471
  ok: false,
5092
5472
  error: errorText(error),
5093
- status: 400
5473
+ status: OCR_REFUSE_STATUS.invalid
5094
5474
  };
5095
5475
  }
5096
5476
  }
@@ -5100,39 +5480,59 @@ async function mcpReply(deps) {
5100
5480
  const result = await readMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, msgs);
5101
5481
  return result.ok ? {
5102
5482
  ok: true,
5103
- json: result.json
5483
+ json: result.json,
5484
+ revision: result.revision
5104
5485
  } : {
5105
5486
  ok: false,
5106
5487
  error: result.error,
5107
- status: 400
5488
+ status: OCR_REFUSE_STATUS.invalid
5108
5489
  };
5109
5490
  } catch (error) {
5110
5491
  return {
5111
5492
  ok: false,
5112
5493
  error: errorText(error),
5113
- status: 500
5494
+ status: OCR_REFUSE_STATUS.internal
5114
5495
  };
5115
5496
  }
5116
5497
  }
5117
- async function saveMcpReply(json, deps) {
5498
+ /** 保存回执:`request` 是整笔入参(`json` 与可选的基线 `revision`),两者都要过形态判定。 */
5499
+ async function saveMcpReply(request, deps) {
5118
5500
  const msgs = deps.messages();
5501
+ const { json, revision } = isRecord(request) ? request : {};
5119
5502
  if (typeof json !== "string") return {
5120
5503
  ok: false,
5121
5504
  error: format(msgs.mustBeString, {
5122
5505
  name: "json",
5123
5506
  received: describeValue(json)
5124
5507
  }),
5125
- status: 400
5508
+ status: OCR_REFUSE_STATUS.invalid
5126
5509
  };
5127
- const result = await saveMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, json, msgs);
5128
- return result.ok ? {
5129
- ok: true,
5130
- count: result.count
5131
- } : {
5510
+ if (revision !== void 0 && typeof revision !== "string") return {
5132
5511
  ok: false,
5133
- error: result.error,
5134
- status: 400
5512
+ error: format(msgs.mustBeString, {
5513
+ name: "revision",
5514
+ received: describeValue(revision)
5515
+ }),
5516
+ status: OCR_REFUSE_STATUS.invalid
5135
5517
  };
5518
+ try {
5519
+ const result = await saveMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, json, msgs, revision);
5520
+ return result.ok ? {
5521
+ ok: true,
5522
+ count: result.count,
5523
+ revision: result.revision
5524
+ } : {
5525
+ ok: false,
5526
+ error: result.error,
5527
+ status: OCR_REFUSE_STATUS.invalid
5528
+ };
5529
+ } catch (error) {
5530
+ return {
5531
+ ok: false,
5532
+ error: errorText(error),
5533
+ status: OCR_REFUSE_STATUS.internal
5534
+ };
5535
+ }
5136
5536
  }
5137
5537
  async function authReply(deps) {
5138
5538
  try {
@@ -5150,7 +5550,7 @@ async function authReply(deps) {
5150
5550
  return {
5151
5551
  ok: false,
5152
5552
  error: errorText(error),
5153
- status: 400
5553
+ status: OCR_REFUSE_STATUS.invalid
5154
5554
  };
5155
5555
  }
5156
5556
  }
@@ -5162,17 +5562,25 @@ async function chatGptReply(model, deps) {
5162
5562
  name: "model",
5163
5563
  received: describeValue(model)
5164
5564
  }),
5165
- status: 400
5166
- };
5167
- const result = await applyChatGptModel(pluginPaths(deps.config, msgs).ocrConfigJson, model, msgs);
5168
- return result.ok ? {
5169
- ok: true,
5170
- model: model.trim()
5171
- } : {
5172
- ok: false,
5173
- error: result.error,
5174
- status: 400
5565
+ status: OCR_REFUSE_STATUS.invalid
5175
5566
  };
5567
+ try {
5568
+ const result = await applyChatGptModel(pluginPaths(deps.config, msgs).ocrConfigJson, model, msgs);
5569
+ return result.ok ? {
5570
+ ok: true,
5571
+ model: model.trim()
5572
+ } : {
5573
+ ok: false,
5574
+ error: result.error,
5575
+ status: OCR_REFUSE_STATUS.invalid
5576
+ };
5577
+ } catch (error) {
5578
+ return {
5579
+ ok: false,
5580
+ error: errorText(error),
5581
+ status: OCR_REFUSE_STATUS.internal
5582
+ };
5583
+ }
5176
5584
  }
5177
5585
  /**
5178
5586
  * 设置卡的九条读/写方法(providers / select / migrate / test / probe / mcp / saveMcp / auth / chatGpt)。
@@ -5218,9 +5626,9 @@ var OcrReviewRemoteService = class extends TypertRemoteService {
5218
5626
  async mcp() {
5219
5627
  return mcpReply(this.deps);
5220
5628
  }
5221
- /** 校验并写入 `mcp_servers`;其他配置原样保留。 */
5629
+ /** 校验并写入 `mcp_servers`;其他配置原样保留。`revision` 为读侧基线,不一致即整笔拒绝。 */
5222
5630
  async saveMcp(request) {
5223
- return saveMcpReply(isRecord(request) ? request.json : void 0, this.deps);
5631
+ return saveMcpReply(request, this.deps);
5224
5632
  }
5225
5633
  /** 只读查询 ChatGPT 登录态;登录命令原样带回给卡片显示。 */
5226
5634
  async auth() {
@@ -5359,7 +5767,7 @@ async function runScanPreview(run) {
5359
5767
  });
5360
5768
  }
5361
5769
  }
5362
- function registerReviewTool(host, config, probe, versionSink) {
5770
+ function registerReviewTool(host, config, probe, versionSink, ledger) {
5363
5771
  const msgs = localeMessages(host);
5364
5772
  define(host, {
5365
5773
  name: "ocr_review",
@@ -5481,10 +5889,11 @@ function registerReviewTool(host, config, probe, versionSink) {
5481
5889
  workdir,
5482
5890
  tool: "ocr_review",
5483
5891
  repo,
5892
+ owner: ownerOf(host, exec),
5484
5893
  stdoutMaxBytes: config.stdoutMaxBytes,
5485
5894
  backgroundMaxMs: config.ocrBackgroundMaxMs,
5486
5895
  env: ocrEnv(config)
5487
- });
5896
+ }, ledger);
5488
5897
  try {
5489
5898
  await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
5490
5899
  } catch (error) {
@@ -5508,7 +5917,7 @@ function registerReviewTool(host, config, probe, versionSink) {
5508
5917
  }
5509
5918
  });
5510
5919
  }
5511
- function registerScanTool(host, config, probe, versionSink) {
5920
+ function registerScanTool(host, config, probe, versionSink, ledger) {
5512
5921
  const msgs = localeMessages(host);
5513
5922
  define(host, {
5514
5923
  name: "ocr_scan",
@@ -5616,10 +6025,11 @@ function registerScanTool(host, config, probe, versionSink) {
5616
6025
  workdir,
5617
6026
  tool: "ocr_scan",
5618
6027
  repo,
6028
+ owner: ownerOf(host, exec),
5619
6029
  stdoutMaxBytes: config.stdoutMaxBytes,
5620
6030
  backgroundMaxMs: config.ocrBackgroundMaxMs,
5621
6031
  env: ocrEnv(config)
5622
- });
6032
+ }, ledger);
5623
6033
  try {
5624
6034
  await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
5625
6035
  } catch (error) {
@@ -5838,7 +6248,7 @@ function registerSessionTool(host, config, probe) {
5838
6248
  repo
5839
6249
  }, runMsgs);
5840
6250
  const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
5841
- const parsed = parseSessionCompare(stdout.text);
6251
+ const parsed = parseSessionCompare(stdout.text, runMsgs);
5842
6252
  if (parsed.ok) return {
5843
6253
  ...parsed.view,
5844
6254
  hint: runMsgs.sessionCompareHint
@@ -5986,7 +6396,9 @@ function endpointVerifyOf(result) {
5986
6396
  };
5987
6397
  }
5988
6398
  async function probeEndpointAtApply(host, config, sink) {
5989
- sink.value = endpointVerifyOf(await runLlmTest(host, config));
6399
+ try {
6400
+ sink.value = endpointVerifyOf(await runLlmTest(host, config));
6401
+ } catch {}
5990
6402
  }
5991
6403
  /**
5992
6404
  * 装载期版本自检(fire-and-forget):跑一次 `ocr version`,把实测版本落在本次 apply 的闭包里。
@@ -6000,32 +6412,37 @@ async function probeEndpointAtApply(host, config, sink) {
6000
6412
  * @param sink 本次 apply 的闭包槽(与 POST probe 写的是同一个)
6001
6413
  */
6002
6414
  async function probeVersionAtApply(host, config, sink) {
6003
- sink.value = (await runVersionProbe(host, config, localeMessages(host))).version;
6415
+ try {
6416
+ sink.value = (await runVersionProbe(host, config, localeMessages(host))).version;
6417
+ } catch {}
6004
6418
  }
6005
6419
  function apply(ctx, config, probe) {
6006
6420
  const host = resolveHost(ctx);
6007
6421
  const endpointVerify = { value: null };
6008
6422
  const versionSink = { value: null };
6423
+ const jobLedger = /* @__PURE__ */ new Map();
6009
6424
  if (config.startupVerify) {
6010
6425
  probeEndpointAtApply(host, config, endpointVerify);
6011
6426
  probeVersionAtApply(host, config, versionSink);
6012
6427
  }
6013
6428
  ctx.effect(() => () => {
6014
6429
  const jobs = ctx.get("jobs");
6015
- if (jobs !== void 0) reclaimOcrJobs(jobs, localeMessages(ctx).jobReasonUnload);
6430
+ if (jobs !== void 0) reclaimAllJobs(jobs, localeMessages(ctx).jobReasonUnload, jobLedger);
6016
6431
  }, "ocr-review: background jobs");
6017
- ctx.effect(() => ctx.on("session/disposed", () => {
6432
+ ctx.effect(() => ctx.on("session/disposed", (session) => {
6018
6433
  const jobs = ctx.get("jobs");
6019
6434
  if (jobs === void 0) return;
6435
+ const owner = session.id;
6436
+ if (typeof owner !== "string" || owner.length === 0) return;
6020
6437
  try {
6021
- reclaimOcrJobs(jobs, localeMessages(ctx).jobReasonSessionStopped);
6438
+ reclaimSessionJobs(jobs, localeMessages(ctx).jobReasonSessionStopped, owner, jobLedger);
6022
6439
  } catch {}
6023
6440
  }), "ocr-review: session stop reaps background runs");
6024
6441
  host.inject(["settings"], (child) => {
6025
6442
  child.effect(() => child.settings.configure({ auto: false }, host.fiber));
6026
6443
  });
6027
- registerReviewTool(host, config, probe, versionSink);
6028
- registerScanTool(host, config, probe, versionSink);
6444
+ registerReviewTool(host, config, probe, versionSink, jobLedger);
6445
+ registerScanTool(host, config, probe, versionSink, jobLedger);
6029
6446
  registerDelegatePreviewTool(host, config, probe);
6030
6447
  registerDelegateRuleTool(host, config, probe);
6031
6448
  registerSessionTool(host, config, probe);