@jayyuen66/dsh-ocr-review 0.1.9 → 0.1.11

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
  *
@@ -620,16 +674,23 @@ const UNDERSCORE_TOKEN = /\b(?:gh[pousr]|github_pat)_\w{20,}\b/gu;
620
674
  const REDACTION = "***REDACTED***";
621
675
  /** token 尾部至少一枚非 `-` 的字母/数字。 */
622
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;
623
681
  /** 独立 key 形态的替换臂:随机串段里真有数字或大写才抹,否则原样交回(文件路径要保持可读)。
624
682
  * 两侧任一是路径分隔符的匹配是路径段而非 key:ocr CLI 会把仓库路径拍平成 kebab 段嵌进
625
683
  * session 的 file_path(`…/sessions/Users-yuanjiang-Documents-…/uuid.jsonl`),原路径里
626
684
  * 只要有大写组件(macOS 的 Documents/Desktop 一类)就过得了「数字或大写」那道护栏——
627
- * 裸 key 从不出现在斜杠两侧,路径段也从不出现在别处,按上下文分比按字符集猜稳。 */
685
+ * 裸 key 从不出现在斜杠两侧,路径段也从不出现在别处,按上下文分比按字符集猜稳。
686
+ * 已知凭据前缀不走这条豁免:见 `KNOWN_KEY_PREFIX`。 */
628
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;
629
690
  const before = whole[offset - 1];
630
691
  const after = whole[offset + match.length];
631
692
  if (before === "/" || after === "/" || before === "\\" || after === "\\") return match;
632
- return TOKEN_TAIL_CHARACTER.test(match.slice(match.indexOf("-") + 1)) ? REDACTION : match;
693
+ return REDACTION;
633
694
  }
634
695
  /**
635
696
  * 抹掉文本里 key 形状的片段。
@@ -646,8 +707,18 @@ function redactStandaloneKey(match, offset, whole) {
646
707
  function redactKeyMaterial(text) {
647
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);
648
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);
649
715
  /** ECMA-48 的 CSI 序列:ESC `[` + 参数位 + 中间位 + 终结位(SGR 颜色码就是终结位 `m`)。 */
650
- 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");
651
722
  /**
652
723
  * 剥掉终端控制序列,只给**要显示给人复制**的那段输出用。
653
724
  *
@@ -657,24 +728,39 @@ const ANSI_CSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "
657
728
  * 复制进终端的恰好是那条 `npm i -g …`——复制到的是带控制字符的脏命令。
658
729
  */
659
730
  function stripAnsi(text) {
660
- return text.replaceAll(ANSI_CSI, "");
731
+ return text.replaceAll(ANSI_OSC_DCS, "").replaceAll(ANSI_CSI, "").replaceAll(ANSI_ESC_SINGLE, "");
661
732
  }
662
- /** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
663
- * 恰好一支命中,多支/零支都算违规。 */
664
- function violationsOf(schema, value, path) {
665
- const violations = [];
733
+ /** oneOf 分支数必须恰好为 1。试跑只关心「这一支有没有违规」,不保留文案;
734
+ * limit 0 让大数组不至于为每支各留一份名单。 */
735
+ function oneOfViolations(schema, value, path, sink, walk) {
666
736
  const { oneOf } = schema;
667
- if (oneOf !== void 0) {
668
- const passing = oneOf.filter((branch) => violationsOf(branch, value, path).length === 0);
669
- 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;
670
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) {
671
752
  const { type } = schema;
672
- if (type === "object") for (const violation of objectViolations(schema, value, path, violationsOf)) violations.push(violation);
673
- else if (type === "array") for (const violation of arrayViolations(schema, value, path, violationsOf)) violations.push(violation);
674
- else if (type !== void 0 && scalarMismatch(type, value)) violations.push(`${path}: expected ${type}`);
675
- if (schema.const !== void 0 && value !== schema.const) violations.push(`${path}: must be exactly ${JSON.stringify(schema.const)}`);
676
- if (schema.enum !== void 0 && !schema.enum.includes(value)) violations.push(`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`);
677
- 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);
678
764
  }
679
765
  /**
680
766
  * 校验并放行工具返回值:必须命中该工具自己的 output schema。
@@ -688,15 +774,18 @@ function violationsOf(schema, value, path) {
688
774
  */
689
775
  function assertToolOutput(schema, value, toolName, messages) {
690
776
  assertGuardable(schema, toolName, messages);
691
- const violations = violationsOf(schema, value, "result");
692
- if (violations.length > 0) {
693
- const MAX_LISTED = 20;
694
- const MAX_CHARS = 2e3;
695
- const omitted = violations.length - MAX_LISTED;
696
- 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})` : "");
697
786
  throw new Error(format(messages.resultSchemaViolation, {
698
787
  tool: toolName,
699
- violations: redactKeyMaterial(listed).slice(0, MAX_CHARS)
788
+ violations: redactKeyMaterial(listed).slice(0, MAX_VIOLATION_CHARS)
700
789
  }));
701
790
  }
702
791
  return value;
@@ -727,8 +816,14 @@ function shq(value) {
727
816
  * 归一(posix.normalize + 去尾随斜杠)的目的:同一个仓库只允许一种字符串形态到达下游。
728
817
  * `/repo//sub`、`/repo/./a`、`/repo/` 与 `/repo/sub` 是同一目录的几种写法,而会话目录
729
818
  * 等按路径派生的键会把这些写法各存一份;`..` 段按词法折叠成实际目标(`/repo/../..`
730
- * 落到 `/`)——折叠是 normalize 对绝对路径的既有行为,不存在「解析后仍含 `..`」的
731
- * 形态,所以这里没有可写的拒绝分支。
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 共享根(两者的根形态各不止一种,不能只比字面量)。
732
827
  */
733
828
  function assertAbsoluteRoot(root, messages) {
734
829
  const trimmed = typeof root === "string" ? root.trim() : "";
@@ -736,11 +831,15 @@ function assertAbsoluteRoot(root, messages) {
736
831
  if (trimmed.includes("\0")) throw new Error(format(messages.noNulBytes, { name: "repo" }));
737
832
  if (trimmed.startsWith("/")) {
738
833
  const normalized = path.posix.normalize(trimmed);
739
- return normalized.length > 1 ? normalized.replace(TRAILING_POSIX_SLASHES, "") : 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;
740
837
  }
741
838
  if (process.platform === "win32" && path.win32.isAbsolute(trimmed)) {
742
839
  const normalized = path.win32.normalize(trimmed);
743
- return normalized.length > 3 ? normalized.replace(TRAILING_PATH_SEPARATORS, "") : 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;
744
843
  }
745
844
  throw new Error(format(messages.repoMustBeAbsolute, { root: trimmed }));
746
845
  }
@@ -790,8 +889,8 @@ function resolveRepoFile(root, value, name, maxBytes, messages) {
790
889
  name,
791
890
  path: text
792
891
  }));
793
- const stat = statSync(real);
794
- 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, {
795
894
  name,
796
895
  path: text
797
896
  }));
@@ -901,17 +1000,27 @@ function resolveOutputFormat(value) {
901
1000
  // lib/cli.ts
902
1001
  /** Homebrew 安装路径(`Cellar` / `Homebrew` 任一段)。 */
903
1002
  const HOMEBREW_PATH = /(?:^|[/\\])(?:Cellar|Homebrew)(?:[/\\]|$)/iu;
904
- /** `ocr` 版本行:`open-code-review v1.13.3`(允许预发布/构建元数据)。 */
1003
+ /** `ocr` 版本行:`open-code-review v1.13.4`(允许预发布/构建元数据)。 */
905
1004
  const OCR_VERSION = /open-code-review\s+v?(?<version>\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/u;
906
1005
  /** `--exclude` 的模式条数上限。**不是审查范围的上限**,只是防一条失控输入把命令串撑爆;
907
1006
  * 真实使用里几十条就到头,给到 2000 是为了"尽量不截断"——超了会明确告警,不静默丢。 */
908
1007
  const EXCLUDE_LIST_LIMIT = 2e3;
909
1008
  /** `--path` 的路径条数上限(同 EXCLUDE_LIST_LIMIT 的取向:够大 + 超限告警)。 */
910
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;
911
1018
  /** `--rule` / `--background-file` 的文件大小上限:规则是检查清单,背景是需求文档。 */
912
1019
  const RULE_FILE_MAX_BYTES = 262144;
913
1020
  const BACKGROUND_FILE_MAX_BYTES = 1048576;
914
1021
  const MAX_GIT_PROCS_FLAG = "--max-git-procs";
1022
+ /** `review` / `scan` / `delegate preview` 共用:四类命令构造各自转发同一枚上限。 */
1023
+ const MAX_TOKENS_FLAG = "--max-tokens";
915
1024
  /** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
916
1025
  * pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
917
1026
  const BACKGROUND_FLAG = "--background";
@@ -924,7 +1033,7 @@ const BACKGROUND_FLAG = "--background";
924
1033
  * test/readme-parity.test.ts 再断言两侧 README 写的也是它。三处只有一处是人写的、
925
1034
  * 靠门禁钉住另两处,就不会出现「README 教用户装 A、代码按 B 校验」这种两边各自自洽的错。
926
1035
  */
927
- const EXPECTED_OCR_CLI_VERSION = "1.13.3";
1036
+ const EXPECTED_OCR_CLI_VERSION = "1.13.4";
928
1037
  /** 报错用的值形态(永不调用可能被劫持的 toJSON/toString)。host.ts 的入参闸
929
1038
  * (effort/wait)复用同一口径,故导出。 */
930
1039
  function describeValue(value) {
@@ -1087,15 +1196,14 @@ function pushTextFlag(parts, flag, value, messages) {
1087
1196
  const text = idValue(value, flag.slice(2), messages);
1088
1197
  if (text !== "") parts.push(flag, shq(text));
1089
1198
  }
1090
- /** --exclude/--background/--provider/--model 等通用 flag。
1091
- * 注意:ocr 的 --exclude 是 String 类型(实测多个 flag 实例只认最后一个,
1092
- * 逗号分隔才生效)——多值必须合并为一个 flag 逗号连接,绝不能展开多个。
1093
- * resume 不在这里:它与 scope 是互斥语义(workspace 禁恢复),由
1094
- * buildReviewCommand 在拿到 scopeFlags 结果后前置校验。 */
1095
- 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) {
1096
1206
  const parts = [];
1097
- const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
1098
- if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1099
1207
  pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
1100
1208
  pushTextFlag(parts, "--provider", args.provider, messages);
1101
1209
  pushTextFlag(parts, "--model", args.model, messages);
@@ -1139,7 +1247,7 @@ function wrapWithHostReaper(command, messages) {
1139
1247
  if (process.platform === "win32") return command;
1140
1248
  return `bash -c ${shq([
1141
1249
  `p0=$(ps -o ppid= -p $$ 2>/dev/null | tr -d ' '); [ -n "$p0" ] && HAVEPS=1 || HAVEPS=0`,
1142
- `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`,
1143
1251
  `[ "$HAVEPS" = 1 ] && [ "$HAVEPG" = 1 ] || { printf '%s\\n' ${shq(messages.reaperDegraded)} >&2; { ${command} ; }; exit $?; }`,
1144
1252
  `{ ${command} ; } & ocr=$!`,
1145
1253
  `desc() { for k in $(pgrep -P "$1" 2>/dev/null); do desc "$k"; printf '%s ' "$k"; done; }`,
@@ -1376,6 +1484,7 @@ function buildReviewCommand(args, outputPath, messages) {
1376
1484
  if (preview) {
1377
1485
  if (resume !== "") throw new Error(messages.previewRejectsResume);
1378
1486
  parts.push("--preview", ...scopeFlags(args, messages), ...selectionFlags(args, repo, messages));
1487
+ pushIntFlag(parts, MAX_TOKENS_FLAG, args.maxTokens, 0, messages);
1379
1488
  if (outputPath !== void 0) parts.push("--output", shq(outputPath));
1380
1489
  return {
1381
1490
  command: parts.join(" "),
@@ -1390,13 +1499,10 @@ function buildReviewCommand(args, outputPath, messages) {
1390
1499
  pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
1391
1500
  pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
1392
1501
  const scopeParts = scopeFlags(args, messages);
1393
- parts.push(...scopeParts, ...optionalFlags(args, messages));
1502
+ parts.push(...scopeParts, ...selectionFlags(args, repo, messages), ...backgroundFlags(args, messages));
1394
1503
  pushBoolFlag(parts, "--no-filter", args.noFilter, "noFilter", messages);
1395
- const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1396
- if (rule !== null) parts.push("--rule", shq(rule));
1397
1504
  const backgroundFile = repoFileOf(args.backgroundFile, repo, "backgroundFile", BACKGROUND_FILE_MAX_BYTES, messages);
1398
1505
  if (backgroundFile !== null) parts.push("--background-file", shq(backgroundFile));
1399
- pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
1400
1506
  if (resume !== "") {
1401
1507
  if (scopeParts.length === 0) throw new Error(messages.resumeNeedsScope);
1402
1508
  parts.push("--resume", shq(resume));
@@ -1425,7 +1531,10 @@ function buildScanCommand(args, outputPath, messages) {
1425
1531
  "--format",
1426
1532
  outputFormat
1427
1533
  ];
1428
- if (preview) parts.push("--preview");
1534
+ if (preview) {
1535
+ parts.push("--preview");
1536
+ pushIntFlag(parts, MAX_TOKENS_FLAG, args.maxTokens, 0, messages);
1537
+ }
1429
1538
  const paths = commaList(args.path, "path", PATH_LIST_LIMIT, messages);
1430
1539
  if (paths.length > 0) parts.push("--path", shq(paths.join(",")));
1431
1540
  if (!preview) {
@@ -1435,7 +1544,7 @@ function buildScanCommand(args, outputPath, messages) {
1435
1544
  pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
1436
1545
  pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
1437
1546
  pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
1438
- pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
1547
+ pushIntFlag(parts, MAX_TOKENS_FLAG, args.maxTokens, 0, messages);
1439
1548
  pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
1440
1549
  pushBoolFlag(parts, "--no-plan", args.noPlan, "noPlan", messages);
1441
1550
  pushBoolFlag(parts, "--no-dedup", args.noDedup, "noDedup", messages);
@@ -1463,16 +1572,12 @@ function buildDelegatePreviewCommand(args, messages) {
1463
1572
  "preview",
1464
1573
  "--format",
1465
1574
  "json",
1466
- ...scopeFlags(scopeArgs, messages)
1575
+ ...scopeFlags(scopeArgs, messages),
1576
+ ...selectionFlags(scopeArgs, repo, messages),
1577
+ ...backgroundFlags(scopeArgs, messages)
1467
1578
  ];
1468
- const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
1469
- if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1470
- pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
1471
1579
  const backgroundFile = repoFileOf(args.backgroundFile, repo, "backgroundFile", BACKGROUND_FILE_MAX_BYTES, messages);
1472
1580
  if (backgroundFile !== null) parts.push("--background-file", shq(backgroundFile));
1473
- const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1474
- if (rule !== null) parts.push("--rule", shq(rule));
1475
- pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
1476
1581
  return {
1477
1582
  command: parts.join(" "),
1478
1583
  workdir: repo
@@ -1481,7 +1586,7 @@ function buildDelegatePreviewCommand(args, messages) {
1481
1586
  /** 构造 `ocr delegate rule` 命令(路径为位置参数,逐条 shq 转义)。 */
1482
1587
  function buildDelegateRuleCommand(args, messages) {
1483
1588
  const repo = assertAbsoluteRoot(args.repo, messages);
1484
- 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));
1485
1590
  if (paths.length === 0) throw new Error(messages.pathsRequired);
1486
1591
  const parts = [
1487
1592
  ocrCommand(),
@@ -1543,13 +1648,14 @@ const SESSION_SEVERITIES = [
1543
1648
  "medium",
1544
1649
  "low"
1545
1650
  ];
1546
- /** severity 过滤:大小写归一 + 白名单;未知值报错而不是交给 CLI 返回空集。 */
1651
+ /** severity 过滤:大小写归一 + 白名单 + 去重;未知值报错而不是交给 CLI 返回空集。 */
1547
1652
  function severityFilter(value, messages) {
1548
- return commaList(value, "severity", SESSION_SEVERITIES.length, messages).map((item) => {
1653
+ const items = commaList(value, "severity", SESSION_FILTER_LIST_LIMIT, messages).map((item) => {
1549
1654
  const lower = item.toLowerCase();
1550
1655
  if (!SESSION_SEVERITIES.includes(lower)) throw new Error(format(messages.invalidSeverity, { value: item }));
1551
1656
  return lower;
1552
1657
  });
1658
+ return [...new Set(items)];
1553
1659
  }
1554
1660
  /** session list --limit:缺省 10(OCR 原生默认 20,本包收紧;OCR 的 0=不限在本包
1555
1661
  * 上限 100 下无意义)。显式给出时必须是 1-100 的整数(与工具 description 的承诺
@@ -1677,7 +1783,7 @@ function buildSessionCommand(args, messages) {
1677
1783
  before: idValue(args.before, "before", messages),
1678
1784
  after: idValue(args.after, "after", messages),
1679
1785
  severities: severityFilter(args.severity, messages),
1680
- categories: commaList(args.category, "category", 50, messages),
1786
+ categories: commaList(args.category, "category", SESSION_FILTER_LIST_LIMIT, messages),
1681
1787
  hasFilters: false,
1682
1788
  limit: args.limit,
1683
1789
  output: idValue(args.output, "output", messages),
@@ -1737,6 +1843,7 @@ function aggregateComments(comments) {
1737
1843
  if (category !== "") categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
1738
1844
  }
1739
1845
  const byCategory = Object.fromEntries(categoryCounts);
1846
+ Object.setPrototypeOf(byCategory, null);
1740
1847
  return {
1741
1848
  total: comments.length,
1742
1849
  bySeverity,
@@ -1831,7 +1938,9 @@ function llmOf(raw) {
1831
1938
  model
1832
1939
  };
1833
1940
  }
1834
- /** `resume` 回执:字段全空时回 null(CLI 只在真的续审时发这个对象)。 */
1941
+ /** `retry_report`:三个计数全为 0 时回 null(CLI 只在这一场发生过重试/恢复时才发
1942
+ * 这个对象;键是 total_retries / recovered_requests / failed_requests,不是 resume 的
1943
+ * resumed_from / reused_files 那一组)。 */
1835
1944
  function retryOf(raw) {
1836
1945
  if (!isRecord(raw)) return null;
1837
1946
  const view = {
@@ -1863,11 +1972,15 @@ function capLines(lines, total, messages) {
1863
1972
  if (lines.length >= total) return lines;
1864
1973
  return [...lines, format(messages.diagnosticOmitted, { count: String(total - lines.length) })];
1865
1974
  }
1866
- /** 计数字段的合法形态:非负整数或纯十进制数字串(与 {@link num} 的取值域对齐,
1867
- * 否则 "x" 或 -1 会被 num 折成 0,把「读不出」伪装成「零失败」)。 */
1975
+ /** 计数字段的合法形态:**安全整数域内**的非负整数或纯十进制数字串——与 {@link num}
1976
+ * 的取值域是同一道闸。只查「是不是数字串」不够:`"99999999999999999999"` 过得了
1977
+ * `^\d+$`,而 num() 的 nonNegativeInt 会把它折成 0,于是「读不出」被伪装成「零失败」
1978
+ * (tool_calls 不进 unreadable、preview 的计数回退到本地长度),两种口径必须一起判。 */
1868
1979
  function isCount(value) {
1869
1980
  if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0;
1870
- return typeof value === "string" && DECIMAL_INTEGER.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));
1871
1984
  }
1872
1985
  /**
1873
1986
  * 解析 `tool_calls`。字段缺失或为 null(这一场没有工具调用记录)返回 null;
@@ -1886,8 +1999,7 @@ function toolCallsOf(raw, messages) {
1886
1999
  view: null,
1887
2000
  unreadable: true
1888
2001
  };
1889
- const rows = Array.isArray(details) ? details : [];
1890
- const rendered = rows.filter((row) => isRecord(row)).map((row) => {
2002
+ const rendered = (Array.isArray(details) ? details : []).filter((row) => isRecord(row)).map((row) => {
1891
2003
  const key = str(row["file_path"]);
1892
2004
  const tool = str(row["tool_name"]);
1893
2005
  const error = str(row["error"]);
@@ -1897,7 +2009,7 @@ function toolCallsOf(raw, messages) {
1897
2009
  view: {
1898
2010
  total: num(doc["total"]),
1899
2011
  failure: num(doc["failure"]),
1900
- failures: capLines(rendered.slice(0, DIAGNOSTIC_DETAIL_LIMIT), rows.length, messages)
2012
+ failures: capLines(rendered.slice(0, DIAGNOSTIC_DETAIL_LIMIT), rendered.length, messages)
1901
2013
  },
1902
2014
  unreadable: false
1903
2015
  };
@@ -2016,17 +2128,35 @@ function reviewFailure(status, message) {
2016
2128
  * 诊断字段读不出来时**不判整场失败**(评论仍然有效、token 已经花掉),而是各补一条
2017
2129
  * 说明进同一个 warnings 出口——「这一路没有数据」必须看得见,但不能把结果作废。
2018
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
+ }
2019
2151
  function diagnosticsOf(root, messages) {
2152
+ const warningsRaw = root["warnings"];
2020
2153
  const warnings = [];
2021
2154
  let limitReached = false;
2022
- const warningsRaw = root["warnings"];
2023
- if (warningsRaw !== void 0) {
2024
- if (Array.isArray(warningsRaw)) for (const warn of warningsRaw) {
2025
- warnings.push(warningLineOf(warn, messages));
2026
- limitReached ||= isRecord(warn) && LIMIT_WARNING_TYPES.has(str(warn["type"]));
2027
- }
2028
- else warnings.push(messages.warningUnreadable);
2029
- }
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);
2030
2160
  const toolCalls = toolCallsOf(root["tool_calls"], messages);
2031
2161
  const coverage = coverageOf(root["manifest"], messages);
2032
2162
  if (toolCalls.unreadable) warnings.push(format(messages.diagnosticUnreadable, { field: "tool_calls" }));
@@ -2108,17 +2238,24 @@ function parseReviewOutput(text, messages) {
2108
2238
  droppedComments
2109
2239
  };
2110
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
+ }
2111
2246
  /** 默认摘要计划(no-object-as-default-parameter:避免对象字面量作参数默认值)。 */
2112
2247
  const DEFAULT_PLAN = {
2113
2248
  maxComments: 12,
2114
2249
  includeZeroLine: false
2115
2250
  };
2116
- /** 单条评论的 top 位置(0 行未锚定降级为 :0)。 */
2117
- function locationOf(comment) {
2118
- const fallbackPath = comment.path || "(path unknown)";
2119
- if (comment.startLine <= 0) return `${fallbackPath}:0`;
2120
- const suffix = comment.endLine > comment.startLine ? `-${comment.endLine}` : "";
2121
- 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}`;
2122
2259
  }
2123
2260
  /**
2124
2261
  * 生成工具返回给模型的结构化摘要(省 token:给聚合与 top-N 评论,不塞全量)。
@@ -2149,7 +2286,7 @@ function summarizeReview(parsed, messages, plan = DEFAULT_PLAN) {
2149
2286
  droppedCount: parsed.comments.length - ranked.length,
2150
2287
  invalidCommentCount: parsed.droppedComments,
2151
2288
  topComments: ranked.map((comment) => ({
2152
- location: locationOf(comment),
2289
+ location: locationOf(comment, messages),
2153
2290
  severity: comment.severity,
2154
2291
  category: comment.category,
2155
2292
  content: comment.content,
@@ -2161,14 +2298,16 @@ function summarizeReview(parsed, messages, plan = DEFAULT_PLAN) {
2161
2298
  hint: messages.reviewSummaryHint
2162
2299
  };
2163
2300
  }
2164
- /** preview/rule 文档各自的契约键:一个都不命中即视为「不是该命令的输出」。 */
2165
- const DELEGATE_PREVIEW_KEYS = [
2166
- "mode",
2167
- "repository",
2168
- "total_files",
2169
- "reviewable_files"
2170
- ];
2171
- 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"];
2172
2311
  function hitsContract(root, keys) {
2173
2312
  return keys.some((key) => root[key] !== void 0);
2174
2313
  }
@@ -2192,7 +2331,6 @@ function delegatePreviewFailure() {
2192
2331
  * 文件」,是半截产出,模型与用户都会拿它当范围结论。
2193
2332
  */
2194
2333
  function toDelegateFiles(raw) {
2195
- if (!Array.isArray(raw)) return [];
2196
2334
  const files = raw.map((entry) => {
2197
2335
  if (!isRecord(entry)) return null;
2198
2336
  const entryPath = str(entry["path"]);
@@ -2214,7 +2352,6 @@ function toDelegateFiles(raw) {
2214
2352
  * 于是「被排除文件及原因」永远只有文件没有原因——schema 与工具描述都在承诺后者。
2215
2353
  * `reason` 作回退保留:形如 `{"reason":…}` 的旧形态/别家产物仍读得出来。 */
2216
2354
  function toExcludedFiles(raw) {
2217
- if (!Array.isArray(raw)) return [];
2218
2355
  const files = raw.map((entry) => {
2219
2356
  if (!isRecord(entry)) return null;
2220
2357
  const entryPath = str(entry["path"]);
@@ -2237,12 +2374,12 @@ function parseDelegatePreview(text) {
2237
2374
  if (!isRecord(doc)) return delegatePreviewFailure();
2238
2375
  const root = doc;
2239
2376
  if (!hitsContract(root, DELEGATE_PREVIEW_KEYS)) return delegatePreviewFailure();
2240
- for (const key of ["reviewable_files", "excluded_files"]) {
2241
- const value = root[key];
2242
- if (value !== void 0 && !Array.isArray(value)) return delegatePreviewFailure();
2243
- }
2244
- const reviewable = toDelegateFiles(root["reviewable_files"]);
2245
- 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 ?? []);
2246
2383
  if (reviewable === null || excluded === null) return delegatePreviewFailure();
2247
2384
  return {
2248
2385
  ok: true,
@@ -2274,11 +2411,12 @@ function parseDelegateRules(text) {
2274
2411
  ok: false,
2275
2412
  groups: []
2276
2413
  };
2277
- if (root["groups"] !== void 0 && !Array.isArray(root["groups"])) return {
2414
+ const groupsValue = root["groups"];
2415
+ if (!Array.isArray(groupsValue)) return {
2278
2416
  ok: false,
2279
2417
  groups: []
2280
2418
  };
2281
- const groupsRaw = Array.isArray(root["groups"]) ? root["groups"] : [];
2419
+ const groupsRaw = groupsValue;
2282
2420
  if (groupsRaw.some((groupRaw) => isRecord(groupRaw) && groupRaw["files"] !== void 0 && !Array.isArray(groupRaw["files"]))) return {
2283
2421
  ok: false,
2284
2422
  groups: []
@@ -2369,26 +2507,26 @@ function compareSideOf(raw) {
2369
2507
  range: isRecord(raw) ? str(raw["range"]) : ""
2370
2508
  };
2371
2509
  }
2372
- function compareCommentOf(raw) {
2510
+ function compareCommentOf(raw, messages) {
2373
2511
  const comment = commentOf(raw);
2374
2512
  if (comment === null) return null;
2375
2513
  return {
2376
- location: locationOf(comment),
2514
+ location: locationOf(comment, messages),
2377
2515
  severity: comment.severity,
2378
2516
  category: comment.category,
2379
2517
  content: comment.content
2380
2518
  };
2381
2519
  }
2382
2520
  /** 一个 compare 桶:缺失/非数组判整个 compare 失败,部分条目坏只丢该条并计入 dropped。 */
2383
- function compareBucketOf(raw) {
2521
+ function compareBucketOf(raw, messages) {
2384
2522
  if (!Array.isArray(raw)) return null;
2385
2523
  return {
2386
- 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),
2387
2525
  total: raw.length
2388
2526
  };
2389
2527
  }
2390
2528
  /** 解析 `ocr session compare --json`:四个桶与两侧会话标识。 */
2391
- function parseSessionCompare(text) {
2529
+ function parseSessionCompare(text, messages) {
2392
2530
  let doc;
2393
2531
  try {
2394
2532
  doc = JSON.parse(text);
@@ -2402,10 +2540,10 @@ function parseSessionCompare(text) {
2402
2540
  ok: false,
2403
2541
  view: null
2404
2542
  };
2405
- const newBucket = compareBucketOf(doc["new"]);
2406
- const persisting = compareBucketOf(doc["persisting"]);
2407
- const resolved = compareBucketOf(doc["resolved"]);
2408
- 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);
2409
2547
  if (newBucket === null || persisting === null || resolved === null || notReviewed === null) return {
2410
2548
  ok: false,
2411
2549
  view: null
@@ -2686,23 +2824,32 @@ const REVIEW_SUMMARY_SCHEMA = {
2686
2824
  hint: { type: "string" }
2687
2825
  }
2688
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
+ ];
2689
2842
  /** review/scan `preview:true` 的文件清单对象(lib/parse.ts 的 PreviewView)。 */
2690
2843
  const PREVIEW_FILE_SCHEMA = {
2691
2844
  type: "object",
2692
2845
  additionalProperties: false,
2693
2846
  required: [
2694
- "path",
2695
- "status",
2696
- "insertions",
2697
- "deletions",
2847
+ ...PREVIEW_FILE_REQUIRED,
2698
2848
  "willReview",
2699
2849
  "excludeReason"
2700
2850
  ],
2701
2851
  properties: {
2702
- path: { type: "string" },
2703
- status: { type: "string" },
2704
- insertions: { type: "number" },
2705
- deletions: { type: "number" },
2852
+ ...PREVIEW_FILE_FIELDS,
2706
2853
  willReview: { type: "boolean" },
2707
2854
  excludeReason: { type: "string" }
2708
2855
  }
@@ -2773,18 +2920,8 @@ const PREVIEW_OUTPUT_SCHEMA = { oneOf: [{
2773
2920
  items: {
2774
2921
  type: "object",
2775
2922
  additionalProperties: false,
2776
- required: [
2777
- "path",
2778
- "status",
2779
- "insertions",
2780
- "deletions"
2781
- ],
2782
- properties: {
2783
- path: { type: "string" },
2784
- status: { type: "string" },
2785
- insertions: { type: "number" },
2786
- deletions: { type: "number" }
2787
- }
2923
+ required: [...PREVIEW_FILE_REQUIRED],
2924
+ properties: { ...PREVIEW_FILE_FIELDS }
2788
2925
  }
2789
2926
  },
2790
2927
  excludedFiles: {
@@ -2947,10 +3084,16 @@ const TEXT_OUTPUT_SCHEMA = {
2947
3084
  const ROUTING_NAME = "ocr-review-routing";
2948
3085
  const ROUTING_ORDER = 1555;
2949
3086
  // lib/provider-projection.ts
2950
- /** llm-pi-ai 命名空间里 provider 表的字段名。 */
3087
+ /** llm-pi-ai 命名空间里 provider 表的字段名。导出给 lib/provider-source.ts:读口判
3088
+ * 「命名空间里有 providers 表吗」与投影取同一张表,两处各写一份字面量迟早漂。 */
2951
3089
  const PROVIDERS_FIELD = "providers";
2952
- /** provider 的 baseURL 只认 http(s)。 */
2953
- const HTTP_URL$1 = /^https?:\/\//iu;
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;
2954
3097
  /**
2955
3098
  * llm-pi-ai 的 `api` 协议 → OCR config 的 `protocol` 映射。
2956
3099
  *
@@ -3017,16 +3160,37 @@ function modelFromRaw(modelRaw) {
3017
3160
  name: name === "" ? id : name
3018
3161
  };
3019
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
+ }
3020
3180
  /** 原始 provider 对象 → DshProvider(缺 baseURL 或形状不合视为无效,返回 null)。 */
3021
3181
  function providerFromRaw(name, raw) {
3022
3182
  if (!isRecord(raw)) return null;
3023
3183
  const entry = raw;
3024
- 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;
3025
3187
  const api = apiKeyOf(entry["api"]);
3026
3188
  const protocol = api === null ? "openai" : OCR_PROTOCOL_BY_API.get(api) ?? api;
3027
3189
  const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"].trim() : "";
3028
- if (!baseURL || !HTTP_URL$1.test(baseURL)) return null;
3029
- 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 : [];
3030
3194
  const models = modelsRaw.map((model) => modelFromRaw(model)).filter((model) => model !== null);
3031
3195
  if (modelsRaw.length > 0 && models.length === 0) return null;
3032
3196
  const displayName = typeof entry["displayName"] === "string" ? entry["displayName"].trim() : "";
@@ -3034,6 +3198,7 @@ function providerFromRaw(name, raw) {
3034
3198
  name,
3035
3199
  displayName: displayName === "" ? name : displayName,
3036
3200
  apiKeyEnv,
3201
+ apiKeyEnvInvalid,
3037
3202
  protocol,
3038
3203
  baseURL,
3039
3204
  models
@@ -3066,23 +3231,35 @@ const SOURCE_READY = Object.freeze({
3066
3231
  /** settings 通道侧抛错(health / descriptors / 投影三处共用同一状态与同一份文案)。 */
3067
3232
  const SOURCE_SETTINGS_FAILED = "settings-failed";
3068
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
+ /**
3069
3249
  * 读 provider 清单并把一切失败折进 source(不抛):会话热路径与卡片读面
3070
3250
  * 都只消费返回值,服务缺席/抛错时卡片仍能显示原因。
3071
3251
  */
3072
3252
  function readProviders(gateway, messages) {
3073
- let health;
3253
+ let settingsUp;
3254
+ let credentialsUp;
3074
3255
  try {
3075
- health = gateway.health();
3256
+ const health = gateway.health();
3257
+ settingsUp = health.settings;
3258
+ credentialsUp = health.credentials;
3076
3259
  } catch (error) {
3077
- return {
3078
- providers: [],
3079
- source: {
3080
- status: SOURCE_SETTINGS_FAILED,
3081
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3082
- }
3083
- };
3260
+ return settingsFailed(messages, error);
3084
3261
  }
3085
- if (!health.settings) return {
3262
+ if (!settingsUp) return {
3086
3263
  providers: [],
3087
3264
  source: {
3088
3265
  status: "settings-unavailable",
@@ -3093,26 +3270,14 @@ function readProviders(gateway, messages) {
3093
3270
  try {
3094
3271
  descriptors = gateway.descriptors();
3095
3272
  } catch (error) {
3096
- return {
3097
- providers: [],
3098
- source: {
3099
- status: SOURCE_SETTINGS_FAILED,
3100
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3101
- }
3102
- };
3273
+ return settingsFailed(messages, error);
3103
3274
  }
3104
3275
  let hit;
3105
3276
  try {
3106
3277
  if (!Array.isArray(descriptors)) throw new TypeError(`ctx.settings.describe() 没有给出数组(收到 ${typeof descriptors})`);
3107
3278
  hit = descriptors.find((descriptor) => descriptor.ns === PI_AI_NAMESPACE);
3108
3279
  } catch (error) {
3109
- return {
3110
- providers: [],
3111
- source: {
3112
- status: SOURCE_SETTINGS_FAILED,
3113
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3114
- }
3115
- };
3280
+ return settingsFailed(messages, error);
3116
3281
  }
3117
3282
  if (hit === void 0) return {
3118
3283
  providers: [],
@@ -3121,7 +3286,7 @@ function readProviders(gateway, messages) {
3121
3286
  message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
3122
3287
  }
3123
3288
  };
3124
- const providersValue = fieldOf(hit.value, "providers");
3289
+ const providersValue = fieldOf(hit.value, PROVIDERS_FIELD);
3125
3290
  if (!isRecord(providersValue)) return {
3126
3291
  providers: [],
3127
3292
  source: {
@@ -3133,15 +3298,9 @@ function readProviders(gateway, messages) {
3133
3298
  try {
3134
3299
  providers = providersFromSettingsValue(hit.value);
3135
3300
  } catch (error) {
3136
- return {
3137
- providers: [],
3138
- source: {
3139
- status: SOURCE_SETTINGS_FAILED,
3140
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3141
- }
3142
- };
3301
+ return settingsFailed(messages, error);
3143
3302
  }
3144
- if (!health.credentials) return {
3303
+ if (!credentialsUp) return {
3145
3304
  providers,
3146
3305
  source: {
3147
3306
  status: "credentials-unavailable",
@@ -3156,14 +3315,10 @@ function readProviders(gateway, messages) {
3156
3315
  /** 逐 provider 问凭据服务「有没有 key」(并行;只回布尔)。 */
3157
3316
  async function markKeyFlags(gateway, providers) {
3158
3317
  return await Promise.all(providers.map(async (providerCfg) => {
3159
- const ref = providerCfg.apiKeyEnv;
3160
- if (ref === null || ref === "") return {
3161
- ...providerCfg,
3162
- hasKey: false
3163
- };
3164
- let hasKey;
3318
+ let hasKey = false;
3165
3319
  try {
3166
- hasKey = await gateway.credentialConfigured(ref);
3320
+ const ref = providerCfg.apiKeyEnv;
3321
+ if (ref !== null && ref !== "") hasKey = await gateway.credentialConfigured(ref);
3167
3322
  } catch {
3168
3323
  hasKey = false;
3169
3324
  }
@@ -3176,10 +3331,9 @@ async function markKeyFlags(gateway, providers) {
3176
3331
  // lib/config-store.ts
3177
3332
  /** api_key_cmd 脚本名(pluginDir 兜底路径的唯一拼法来源)。 */
3178
3333
  const GET_CRED_SCRIPT = path.join("scripts", "get-cred.mjs");
3179
- /** 尾部反斜杠 / 翻倍反斜杠 / env 键 / 行首 node 命令。 */
3334
+ /** 尾部反斜杠 / 翻倍反斜杠 / 行首 node 命令。 */
3180
3335
  const TRAILING_BACKSLASHES = /\\+$/u;
3181
3336
  const DOUBLED_BACKSLASHES = /(?:\\\\)+$/u;
3182
- const ENV_KEY = /^[A-Z0-9_]+$/u;
3183
3337
  const LEADING_NODE_CMD = /^node[ \t]+/u;
3184
3338
  /** 未知值 → 安全字符串(非字符串给 '')。 */
3185
3339
  function strOf(value) {
@@ -3261,10 +3415,11 @@ function shqDyn(value) {
3261
3415
  * $DSH_HOME > homedir()/.dsh):ocrConfigPath 重定向 ocr 子进程的 HOME 后,
3262
3416
  * get-cred 的 homedir() 兜底会跟着跑偏,须在宿主侧把解析出的数据目录显式钉进
3263
3417
  * 命令。undefined(未重定向)时省略,沿用 env/默认档。
3264
- * env key 白名单维持 ^[A-Z0-9_]+$(刻意比宿主 CredentialRef 的语法紧:OCR 侧
3265
- * 惯例大写,且这是写进 config.json 的可信输入)。 */
3418
+ * env key 白名单是 CREDENTIAL_ENV_KEY(定义在 lib/provider-projection.ts,投影与写侧
3419
+ * 共用同一枚;刻意比宿主 CredentialRef 的语法紧:OCR 侧惯例大写,且这是写进
3420
+ * config.json 的可信输入)。 */
3266
3421
  function buildApiKeyCmd(scriptPath, envKey, dshHome, messages) {
3267
- if (!ENV_KEY.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 }));
3268
3423
  const parts = [
3269
3424
  "node",
3270
3425
  shqDyn(scriptPath),
@@ -3392,7 +3547,7 @@ function parseApiKeyCmd(command) {
3392
3547
  if (tokens === null) return null;
3393
3548
  const script = tokens[0] ?? "";
3394
3549
  const envKey = tokens[1] ?? "";
3395
- if (script === "" || tokens.length < 2 || tokens.length > 3 || !ENV_KEY.test(envKey)) return null;
3550
+ if (script === "" || tokens.length < 2 || tokens.length > 3 || !CREDENTIAL_ENV_KEY.test(envKey)) return null;
3396
3551
  return {
3397
3552
  script,
3398
3553
  envKey,
@@ -3438,6 +3593,27 @@ async function updateConfigLocked(targetPath, operation) {
3438
3593
  return withFileLock(targetPath, operation);
3439
3594
  }
3440
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
+ /**
3441
3617
  * 原子写回 config.json:先留 `.bak` 备份,再交官方 `writeFileAtomic` 落盘。
3442
3618
  * 此前是 `writeFileSync` 直写:进程/磁盘在写入中途出问题就会留下半截配置
3443
3619
  * (OCR 与用户都读不到),且没有任何回滚点。rename 在同目录内是原子的。
@@ -3446,14 +3622,24 @@ async function updateConfigLocked(targetPath, operation) {
3446
3622
  * 创建(`dirMode`)与新 inode 携带 `mode` 全是官方件既有行为,本仓不再手抄
3447
3623
  * (实测)。本地只剩 `.bak`:官方写文件面无备份语义,且备份必须落在
3448
3624
  * rename **之前**——挪到之后备份到的就是新内容。
3625
+ *
3626
+ * 备份的权限必须**在创建那一刻**就是 0600:备份里可能仍有迁移前的明文 api_key,
3627
+ * 而 `copyFileSync` 沿用源文件权限位,"先复制再 chmod"之间既有宽权限窗口,中途
3628
+ * 崩溃/被杀还会把宽权限永久留下。因此先删旧备份、再按 0600 直接创建;删与写之间
3629
+ * 崩溃只会少一份备份,目标文件尚未动过。
3449
3630
  */
3450
3631
  async function writeConfigAtomic(targetPath, text) {
3632
+ const target = await writableTargetOf(targetPath);
3451
3633
  const bakPath = `${targetPath}.bak`;
3452
- if (existsSync(targetPath)) {
3453
- copyFileSync(targetPath, bakPath);
3454
- 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
+ });
3455
3641
  }
3456
- await writeFileAtomic(targetPath, text, {
3642
+ await writeFileAtomic(target, text, {
3457
3643
  mode: 384,
3458
3644
  dirMode: 448
3459
3645
  });
@@ -3505,7 +3691,8 @@ function renderSelectedConfig(input, messages) {
3505
3691
  customs[input.provider] = entry;
3506
3692
  doc["provider"] = input.provider;
3507
3693
  doc["custom_providers"] = customs;
3508
- if (input.language !== void 0) doc["language"] = input.language;
3694
+ const language = input.language?.trim();
3695
+ if (language !== void 0 && language !== "") doc["language"] = language;
3509
3696
  return `${JSON.stringify(doc, null, 2)}\n`;
3510
3697
  }
3511
3698
  /**
@@ -3662,8 +3849,9 @@ async function providersForCard(gateway, paths, messages, endpointVerify) {
3662
3849
  * 任何一步不合条件都抛可读错误(Remote 的 select/migrate 兜成 `status: 400`,卡片显示原因)。
3663
3850
  */
3664
3851
  async function applyProviderSelection(gateway, paths, selection, messages) {
3665
- const { provider, model } = selection;
3666
- 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();
3667
3855
  const { providers, source } = readProviders(gateway, messages);
3668
3856
  if (source.status !== "ready") throw new Error(source.message);
3669
3857
  const target = providers.find((providerCfg) => providerCfg.name === provider);
@@ -3671,7 +3859,11 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
3671
3859
  ns: PI_AI_NAMESPACE,
3672
3860
  provider
3673
3861
  }));
3674
- 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
+ }
3675
3867
  const keySource = await gateway.credentialSource(target.apiKeyEnv);
3676
3868
  if (keySource === void 0) throw new Error(format(messages.keyNotResolvable, {
3677
3869
  provider,
@@ -3693,7 +3885,7 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
3693
3885
  provider,
3694
3886
  model,
3695
3887
  apiKeyCmd,
3696
- ...selection.language === void 0 ? {} : { language: selection.language }
3888
+ ...selection.language === void 0 || selection.language.trim() === "" ? {} : { language: selection.language.trim() }
3697
3889
  }, messages);
3698
3890
  await writeConfigAtomic(paths.ocrConfigJson, next);
3699
3891
  });
@@ -3711,20 +3903,19 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
3711
3903
  */
3712
3904
  function migrateEntry(entry, envKey, context) {
3713
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";
3714
3908
  if (envKey === void 0 || envKey === "" || !context.reachableEnvKeys.has(envKey)) return "unknown";
3715
- try {
3716
- entry["api_key_cmd"] = buildApiKeyCmd(context.scriptPath, envKey, context.dshHome, context.messages);
3717
- } catch {
3718
- return "unknown";
3719
- }
3909
+ entry["api_key_cmd"] = buildApiKeyCmd(context.scriptPath, envKey, context.dshHome, context.messages);
3720
3910
  delete entry["api_key"];
3721
3911
  return "migrated";
3722
3912
  }
3723
3913
  /**
3724
3914
  * 迁移:把自定义 provider 条目里的明文 api_key 改为 api_key_cmd(动态读凭据)。
3725
3915
  * api_key_cmd 优先于 api_key,因此清除明文即可。
3726
- * 对「明文 api_key + 无 api_key_cmd」的条目,依据 settings 里该 provider 的
3727
- * apiKeyEnv 补充命令;settings 里找不到的 provider 不动(不破坏既有配置),
3916
+ * 只对「明文 api_key + 无 api_key_cmd」的条目动手,依据 settings 里该 provider 的
3917
+ * apiKeyEnv 补充命令;**已有非空 api_key_cmd 的条目不覆写**(那条命令可能正在生效,
3918
+ * 换掉它等于无声改配置),与 settings 里找不到的 provider 一起记进 skippedUnknown,
3728
3919
  * 在返回信息里说明。credentials 缺席不阻断本操作(它只要 name→apiKeyEnv 映射),
3729
3920
  * settings 侧读不到则抛错——那正是「不知道该写哪条命令」的状态,静默跳过会让用户
3730
3921
  * 以为迁移过了。
@@ -3776,7 +3967,7 @@ async function migratePlaintextKeys(gateway, paths, messages) {
3776
3967
  migrated,
3777
3968
  skippedUnknown
3778
3969
  };
3779
- await writeConfigAtomic(paths.ocrConfigJson, `${JSON.stringify(doc, null, 2)}\n`);
3970
+ await writeConfigAtomic(paths.ocrConfigJson, renderConfigDoc(doc));
3780
3971
  return {
3781
3972
  migrated,
3782
3973
  skippedUnknown
@@ -3793,8 +3984,10 @@ async function migratePlaintextKeys(gateway, paths, messages) {
3793
3984
  * 为什么永不抛错:这是锦上添花的修复,不能因为它把工具拖死。坏 JSON、锁抢不到、权限
3794
3985
  * 不足、设置项写坏——一律放过,让 ocr 报它自己的原始错误,那才是诊断价值最高的形态。
3795
3986
  *
3796
- * 常态零锁开销:先无锁读一次做判据,确实要改写才进 withFileLock(锁内按新快照重判,
3797
- * 避免把并发写入的 provider 选择覆盖掉)。
3987
+ * 锁外那一次读只用于区分两种**不需要动锁**的状态:文件不存在/空白(连配置目录都不该
3988
+ * 被凭空造出来)与读不动(报 checked: false)。其余一律进 withFileLock,并在锁内按
3989
+ * 新快照重判——不拿锁外快照决定「要不要改」,那份快照随时可能过期,为省一次锁而多留
3990
+ * 一个只能靠并发触发的分支不值。
3798
3991
  */
3799
3992
  async function ensureKeyScriptPointer(paths, messages) {
3800
3993
  const scriptPath = credentialScriptPath(paths);
@@ -3840,11 +4033,37 @@ async function ensureKeyScriptPointer(paths, messages) {
3840
4033
  // lib/remote-shape.ts
3841
4034
  /** 宿主服务键 = 客户端线上命名空间(两侧共用的唯一来源)。 */
3842
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
+ };
3843
4051
  // lib/mcp-config.ts
3844
4052
  /** 整体上限:MCP 配置是一份人工维护的小表,不承载大文件。 */
3845
4053
  const MCP_MAX_BYTES = 65536;
3846
- /** remote MCP server 的 url 只认 http(s)。 */
3847
- const HTTP_URL = /^https?:\/\//iu;
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
+ }
3848
4067
  const MCP_MAX_SERVERS = 100;
3849
4068
  const MCP_MAX_ARRAY = 100;
3850
4069
  const MCP_MAX_TEXT = 4096;
@@ -3858,6 +4077,19 @@ const KNOWN_FIELDS = /* @__PURE__ */ new Set([
3858
4077
  "setup",
3859
4078
  "env"
3860
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
+ }
3861
4093
  function failure(name, field, reason, messages) {
3862
4094
  return {
3863
4095
  ok: false,
@@ -3868,6 +4100,19 @@ function failure(name, field, reason, messages) {
3868
4100
  })
3869
4101
  };
3870
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
+ }
3871
4116
  function textField(value, name, field, messages) {
3872
4117
  if (typeof value !== "string" || value.trim() === "") return failure(name, field, "must be a non-empty string", messages);
3873
4118
  const trimmed = value.trim();
@@ -3891,14 +4136,14 @@ function stringArray(value, name, field, messages) {
3891
4136
  }
3892
4137
  function headersField(value, name, messages) {
3893
4138
  if (!isRecord(value) || Object.keys(value).length > 50) return failure(name, "headers", "must be an object of at most 50 string headers", messages);
3894
- const out = {};
4139
+ const headers = [];
3895
4140
  for (const [header, raw] of Object.entries(value)) {
3896
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);
3897
- out[header] = raw;
4142
+ headers.push([header, raw]);
3898
4143
  }
3899
4144
  return {
3900
4145
  ok: true,
3901
- value: out
4146
+ value: recordOf(headers)
3902
4147
  };
3903
4148
  }
3904
4149
  function normalizeStdio(raw, name, messages) {
@@ -3934,7 +4179,7 @@ function normalizeRemote(raw, name, messages) {
3934
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);
3935
4180
  const url = textField(raw["url"], name, "url", messages);
3936
4181
  if (!url.ok) return url;
3937
- if (!HTTP_URL.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);
3938
4183
  const entry = {
3939
4184
  type: "remote",
3940
4185
  url: url.value
@@ -3949,10 +4194,33 @@ function normalizeRemote(raw, name, messages) {
3949
4194
  value: entry
3950
4195
  };
3951
4196
  }
3952
- /** 单个 server 条目 → 只含已知字段的普通对象;未知字段直接拒绝,避免写出 OCR 不认的配置。 */
3953
- 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) {
3954
4220
  if (!isRecord(raw)) return failure(name, "entry", "must be an object", messages);
3955
- 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;
3956
4224
  const type = raw["type"] === void 0 ? "stdio" : raw["type"];
3957
4225
  if (type !== "stdio" && type !== "remote") return failure(name, "type", "must be stdio or remote", messages);
3958
4226
  const base = type === "stdio" ? normalizeStdio(raw, name, messages) : normalizeRemote(raw, name, messages);
@@ -3962,9 +4230,10 @@ function normalizeServer(name, raw, messages) {
3962
4230
  if (!tools.ok) return tools;
3963
4231
  base.value["tools"] = tools.value;
3964
4232
  }
4233
+ for (const [field, value] of preserved) base.value[field] = value;
3965
4234
  return base;
3966
4235
  }
3967
- function normalizeServers(raw, messages) {
4236
+ function normalizeServers(raw, messages, baseline) {
3968
4237
  if (!isRecord(raw)) return {
3969
4238
  ok: false,
3970
4239
  error: messages.mcpShapeInvalid
@@ -3974,26 +4243,35 @@ function normalizeServers(raw, messages) {
3974
4243
  ok: false,
3975
4244
  error: format(messages.mcpTooManyServers, { limit: String(MCP_MAX_SERVERS) })
3976
4245
  };
3977
- const out = {};
4246
+ const entries = [];
3978
4247
  for (const name of names) {
3979
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);
3980
- 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);
3981
4251
  if (!parsed.ok) return parsed;
3982
- out[name] = parsed.value;
4252
+ entries.push([name, parsed.value]);
3983
4253
  }
3984
4254
  return {
3985
4255
  ok: true,
3986
- value: out
4256
+ value: recordOf(entries)
3987
4257
  };
3988
4258
  }
3989
- /** 读侧用异步读:卡片端点是 async 面,读大文件时不该占住事件循环。 */
3990
4259
  function isNotFound(error) {
3991
4260
  return isRecord(error) && error["code"] === "ENOENT";
3992
4261
  }
3993
- /** 读当前 `mcp_servers`,交给卡片编辑;文件不存在按空表处理,坏 JSON/读不动返回可读错误。 */
4262
+ /**
4263
+ * 读当前 `mcp_servers`,交给卡片编辑;文件不存在按空表处理,坏 JSON/读不动返回可读错误。
4264
+ *
4265
+ * 读侧是 async 面(卡片端点不该占住事件循环),且**先 stat 再读**:写侧有 MCP_MAX_BYTES
4266
+ * 上限,读侧不设闸就等于允许任意大的 config.json 整个进内存。
4267
+ */
3994
4268
  async function readMcpServers(configPath, messages) {
3995
4269
  let text;
3996
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
+ };
3997
4275
  text = await readFile(configPath, "utf8");
3998
4276
  } catch (error) {
3999
4277
  if (!isNotFound(error)) return {
@@ -4007,7 +4285,8 @@ async function readMcpServers(configPath, messages) {
4007
4285
  }
4008
4286
  if (!text.trim()) return {
4009
4287
  ok: true,
4010
- json: "{}"
4288
+ json: "{}",
4289
+ revision: mcpServersRevision({})
4011
4290
  };
4012
4291
  let doc;
4013
4292
  try {
@@ -4024,7 +4303,8 @@ async function readMcpServers(configPath, messages) {
4024
4303
  const servers = doc["mcp_servers"];
4025
4304
  if (servers === void 0 || servers === null) return {
4026
4305
  ok: true,
4027
- json: "{}"
4306
+ json: "{}",
4307
+ revision: mcpServersRevision({})
4028
4308
  };
4029
4309
  if (!isRecord(servers)) return {
4030
4310
  ok: false,
@@ -4032,11 +4312,24 @@ async function readMcpServers(configPath, messages) {
4032
4312
  };
4033
4313
  return {
4034
4314
  ok: true,
4035
- json: JSON.stringify(servers, null, 2)
4315
+ json: JSON.stringify(servers, null, 2),
4316
+ revision: mcpServersRevision(servers)
4036
4317
  };
4037
4318
  }
4038
- /** 校验并写回 `mcp_servers`;只改这一支,其余配置原样保留。 */
4039
- 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) {
4040
4333
  if (Buffer.byteLength(json, "utf8") > MCP_MAX_BYTES) return {
4041
4334
  ok: false,
4042
4335
  error: format(messages.mcpJsonTooLarge, { limit: String(MCP_MAX_BYTES) })
@@ -4050,22 +4343,30 @@ async function saveMcpServers(configPath, json, messages) {
4050
4343
  error: format(messages.mcpJsonInvalid, { cause: String(error) })
4051
4344
  };
4052
4345
  }
4053
- const normalized = normalizeServers(raw, messages);
4054
- if (!normalized.ok) return normalized;
4346
+ const shaped = normalizeServers(raw, messages);
4347
+ if (!shaped.ok) return shaped;
4055
4348
  try {
4056
- return {
4057
- ok: true,
4058
- count: await updateConfigLocked(configPath, async () => {
4059
- const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
4060
- doc["mcp_servers"] = normalized.value;
4061
- await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
4062
- return Object.keys(normalized.value).length;
4063
- })
4064
- };
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
+ });
4065
4366
  } catch (error) {
4066
4367
  return {
4067
4368
  ok: false,
4068
- error: format(messages.mcpReadFailed, {
4369
+ error: format(messages.mcpSaveFailed, {
4069
4370
  where: configPath,
4070
4371
  cause: String(error)
4071
4372
  })
@@ -4080,10 +4381,16 @@ async function applyChatGptModel(configPath, model, messages) {
4080
4381
  error: messages.chatGptModelRequired
4081
4382
  };
4082
4383
  try {
4083
- await updateConfigLocked(configPath, async () => {
4384
+ return await updateConfigLocked(configPath, async () => {
4084
4385
  const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
4085
- const providers = isRecord(doc["providers"]) ? doc["providers"] : {};
4086
- 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 : {};
4087
4394
  delete entry["api_key"];
4088
4395
  delete entry["api_key_cmd"];
4089
4396
  entry["model"] = trimmed;
@@ -4091,8 +4398,8 @@ async function applyChatGptModel(configPath, model, messages) {
4091
4398
  doc["providers"] = providers;
4092
4399
  doc["provider"] = "chatgpt";
4093
4400
  await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
4401
+ return { ok: true };
4094
4402
  });
4095
- return { ok: true };
4096
4403
  } catch (error) {
4097
4404
  return {
4098
4405
  ok: false,
@@ -4319,8 +4626,8 @@ function textBlock(value) {
4319
4626
  * 按调用携带的 `SandboxPolicyRequest` 只有 `session` 与 `mode` 两个字段,**没有**附加可写
4320
4627
  * 路径、没有自定义 HOME、没有目录级豁免;而 `SandboxMode` 的取值只有
4321
4628
  * `read-only | workspace-write | danger-full-access` 三档,中间那一档的可写根就是
4322
- * `workspaceRoot` 一枚。两处声明都取自本包实际解析到的那版(dsh-sandbox-policy 与
4323
- * dsh-sandbox 的 0.2.1-rc.3,`lib/types/index.d.ts`)。
4629
+ * `workspaceRoot` 一枚。两处声明都取自本包实际解析到的 dsh-sandbox-policy 与
4630
+ * dsh-sandbox 的 `lib/types/index.d.ts`。
4324
4631
  * 可选的收紧路径只有「改 ocr 自己的状态落点」或「等上游加字段」,两者都不是本包能在
4325
4632
  * 一次补丁里做完的事。故此处维持放开,但把边界写明:放开的是 ocr 这一个子进程的整棵
4326
4633
  * 文件系统写权限,不是宿主其它能力;且 ocr 读什么、写什么仍由上面那条 argv 闸与
@@ -4469,8 +4776,14 @@ async function startBackground(host, job, ledger) {
4469
4776
  sandboxPolicy: ocrSandboxPolicy(job.workdir)
4470
4777
  });
4471
4778
  const proc = await host.shell.execute(spec);
4472
- pruneOcrJobHistory(registry, job.owner, ledger);
4473
- 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
+ }
4474
4787
  let jobId;
4475
4788
  try {
4476
4789
  jobId = startUnderOcrController(registry, {
@@ -4879,7 +5192,7 @@ function cliStatusPayload(status, config, messages, measured) {
4879
5192
  * @returns 同一份摘要,或 hint 尾部多了一句的副本。
4880
5193
  */
4881
5194
  function withCliVersionNote(summary, measured, messages) {
4882
- if (measured === null || measured === "1.13.3") return summary;
5195
+ if (measured === null || measured === "1.13.4") return summary;
4883
5196
  const note = format(messages.versionMismatch, {
4884
5197
  actual: measured,
4885
5198
  expected: EXPECTED_OCR_CLI_VERSION
@@ -4905,14 +5218,15 @@ async function runLlmTest(host, config) {
4905
5218
  };
4906
5219
  }
4907
5220
  /** effort 入参闸:undefined/null → 设置卡默认(auto 表示跟随 OCR 配置);字符串 →
4908
- * 白名单钳制(未知值回落 auto,与 --format 同一纪律);其余类型抛错——静默回落会改变审查深度。 */
5221
+ * 白名单钳制(未知值按「未指定」处理,回落设置卡值,与 undefined 同一条路);其余类型抛错
5222
+ * ——静默回落会改变审查深度,所以类型错误必须当场说,而认不出的字符串只能当没指定。 */
4909
5223
  function effortOf(value, config, messages) {
4910
5224
  if (value === void 0 || value === null) return readEffort(config);
4911
5225
  if (typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
4912
5226
  name: "effort",
4913
5227
  received: describeValue(value)
4914
5228
  }));
4915
- return clampEffort(value, "medium");
5229
+ return clampEffort(value, readEffort(config));
4916
5230
  }
4917
5231
  /**
4918
5232
  * wait 入参闸:undefined → **false(后台缺省)**;布尔透传;其余类型抛错——字符串
@@ -5029,7 +5343,7 @@ async function canonicalReviewRoot(host, root) {
5029
5343
  stdoutMaxBytes: 4096
5030
5344
  });
5031
5345
  const top = exitCode === 0 ? output.trim() : "";
5032
- if (top.startsWith("/")) return top;
5346
+ if (path.isAbsolute(top)) return top;
5033
5347
  } catch {}
5034
5348
  return realpathOrSelf(root);
5035
5349
  }
@@ -5045,7 +5359,7 @@ async function selectReply(provider, model, deps) {
5045
5359
  if (provider === "" || model === "") return {
5046
5360
  ok: false,
5047
5361
  error: msgs.providerModelRequired,
5048
- status: 400
5362
+ status: OCR_REFUSE_STATUS.invalid
5049
5363
  };
5050
5364
  try {
5051
5365
  const language = readLanguage(deps.config);
@@ -5065,7 +5379,7 @@ async function selectReply(provider, model, deps) {
5065
5379
  return {
5066
5380
  ok: false,
5067
5381
  error: errorText(error),
5068
- status: 400
5382
+ status: OCR_REFUSE_STATUS.invalid
5069
5383
  };
5070
5384
  }
5071
5385
  }
@@ -5102,7 +5416,7 @@ async function probeReply(deps) {
5102
5416
  return {
5103
5417
  ok: false,
5104
5418
  error: errorText(error),
5105
- status: 400
5419
+ status: OCR_REFUSE_STATUS.invalid
5106
5420
  };
5107
5421
  }
5108
5422
  }
@@ -5124,7 +5438,7 @@ async function providersReply(deps) {
5124
5438
  return {
5125
5439
  ok: false,
5126
5440
  error: errorText(error),
5127
- status: 500
5441
+ status: OCR_REFUSE_STATUS.internal
5128
5442
  };
5129
5443
  }
5130
5444
  }
@@ -5140,7 +5454,7 @@ async function migrateReply(deps) {
5140
5454
  return {
5141
5455
  ok: false,
5142
5456
  error: errorText(error),
5143
- status: 400
5457
+ status: OCR_REFUSE_STATUS.invalid
5144
5458
  };
5145
5459
  }
5146
5460
  }
@@ -5156,7 +5470,7 @@ async function testReply(deps) {
5156
5470
  return {
5157
5471
  ok: false,
5158
5472
  error: errorText(error),
5159
- status: 400
5473
+ status: OCR_REFUSE_STATUS.invalid
5160
5474
  };
5161
5475
  }
5162
5476
  }
@@ -5166,39 +5480,59 @@ async function mcpReply(deps) {
5166
5480
  const result = await readMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, msgs);
5167
5481
  return result.ok ? {
5168
5482
  ok: true,
5169
- json: result.json
5483
+ json: result.json,
5484
+ revision: result.revision
5170
5485
  } : {
5171
5486
  ok: false,
5172
5487
  error: result.error,
5173
- status: 400
5488
+ status: OCR_REFUSE_STATUS.invalid
5174
5489
  };
5175
5490
  } catch (error) {
5176
5491
  return {
5177
5492
  ok: false,
5178
5493
  error: errorText(error),
5179
- status: 500
5494
+ status: OCR_REFUSE_STATUS.internal
5180
5495
  };
5181
5496
  }
5182
5497
  }
5183
- async function saveMcpReply(json, deps) {
5498
+ /** 保存回执:`request` 是整笔入参(`json` 与可选的基线 `revision`),两者都要过形态判定。 */
5499
+ async function saveMcpReply(request, deps) {
5184
5500
  const msgs = deps.messages();
5501
+ const { json, revision } = isRecord(request) ? request : {};
5185
5502
  if (typeof json !== "string") return {
5186
5503
  ok: false,
5187
5504
  error: format(msgs.mustBeString, {
5188
5505
  name: "json",
5189
5506
  received: describeValue(json)
5190
5507
  }),
5191
- status: 400
5508
+ status: OCR_REFUSE_STATUS.invalid
5192
5509
  };
5193
- const result = await saveMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, json, msgs);
5194
- return result.ok ? {
5195
- ok: true,
5196
- count: result.count
5197
- } : {
5510
+ if (revision !== void 0 && typeof revision !== "string") return {
5198
5511
  ok: false,
5199
- error: result.error,
5200
- status: 400
5512
+ error: format(msgs.mustBeString, {
5513
+ name: "revision",
5514
+ received: describeValue(revision)
5515
+ }),
5516
+ status: OCR_REFUSE_STATUS.invalid
5201
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
+ }
5202
5536
  }
5203
5537
  async function authReply(deps) {
5204
5538
  try {
@@ -5216,7 +5550,7 @@ async function authReply(deps) {
5216
5550
  return {
5217
5551
  ok: false,
5218
5552
  error: errorText(error),
5219
- status: 400
5553
+ status: OCR_REFUSE_STATUS.invalid
5220
5554
  };
5221
5555
  }
5222
5556
  }
@@ -5228,17 +5562,25 @@ async function chatGptReply(model, deps) {
5228
5562
  name: "model",
5229
5563
  received: describeValue(model)
5230
5564
  }),
5231
- status: 400
5232
- };
5233
- const result = await applyChatGptModel(pluginPaths(deps.config, msgs).ocrConfigJson, model, msgs);
5234
- return result.ok ? {
5235
- ok: true,
5236
- model: model.trim()
5237
- } : {
5238
- ok: false,
5239
- error: result.error,
5240
- status: 400
5565
+ status: OCR_REFUSE_STATUS.invalid
5241
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
+ }
5242
5584
  }
5243
5585
  /**
5244
5586
  * 设置卡的九条读/写方法(providers / select / migrate / test / probe / mcp / saveMcp / auth / chatGpt)。
@@ -5284,9 +5626,9 @@ var OcrReviewRemoteService = class extends TypertRemoteService {
5284
5626
  async mcp() {
5285
5627
  return mcpReply(this.deps);
5286
5628
  }
5287
- /** 校验并写入 `mcp_servers`;其他配置原样保留。 */
5629
+ /** 校验并写入 `mcp_servers`;其他配置原样保留。`revision` 为读侧基线,不一致即整笔拒绝。 */
5288
5630
  async saveMcp(request) {
5289
- return saveMcpReply(isRecord(request) ? request.json : void 0, this.deps);
5631
+ return saveMcpReply(request, this.deps);
5290
5632
  }
5291
5633
  /** 只读查询 ChatGPT 登录态;登录命令原样带回给卡片显示。 */
5292
5634
  async auth() {
@@ -5906,7 +6248,7 @@ function registerSessionTool(host, config, probe) {
5906
6248
  repo
5907
6249
  }, runMsgs);
5908
6250
  const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
5909
- const parsed = parseSessionCompare(stdout.text);
6251
+ const parsed = parseSessionCompare(stdout.text, runMsgs);
5910
6252
  if (parsed.ok) return {
5911
6253
  ...parsed.view,
5912
6254
  hint: runMsgs.sessionCompareHint
@@ -6054,7 +6396,9 @@ function endpointVerifyOf(result) {
6054
6396
  };
6055
6397
  }
6056
6398
  async function probeEndpointAtApply(host, config, sink) {
6057
- sink.value = endpointVerifyOf(await runLlmTest(host, config));
6399
+ try {
6400
+ sink.value = endpointVerifyOf(await runLlmTest(host, config));
6401
+ } catch {}
6058
6402
  }
6059
6403
  /**
6060
6404
  * 装载期版本自检(fire-and-forget):跑一次 `ocr version`,把实测版本落在本次 apply 的闭包里。
@@ -6068,7 +6412,9 @@ async function probeEndpointAtApply(host, config, sink) {
6068
6412
  * @param sink 本次 apply 的闭包槽(与 POST probe 写的是同一个)
6069
6413
  */
6070
6414
  async function probeVersionAtApply(host, config, sink) {
6071
- sink.value = (await runVersionProbe(host, config, localeMessages(host))).version;
6415
+ try {
6416
+ sink.value = (await runVersionProbe(host, config, localeMessages(host))).version;
6417
+ } catch {}
6072
6418
  }
6073
6419
  function apply(ctx, config, probe) {
6074
6420
  const host = resolveHost(ctx);