@jayyuen66/dsh-ocr-review 0.1.4-rc → 0.1.4

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
@@ -27,9 +27,9 @@ const MESSAGES = {
27
27
  detailSeparator: ":",
28
28
  stdoutTruncated: "[ocr-review] 注意:输出超过 {limit} 字节上限已截断{spill}",
29
29
  outputSpill: "(完整输出已落盘:{path})",
30
- 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) 看元数据与逐文件检查点。后台运行不设宿主超时,只在 2 小时后兜底回收(挂死不动会一直占着官方作业桶),取消本工具调用即终止该后台 OCR(宿主 kill + 命令内回收守护)。",
30
+ 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,默认 2 小时)到点后回收(挂死不动会一直占着官方作业桶),取消本工具调用即终止该后台 OCR(宿主 kill + 命令内回收守护)。",
31
31
  jobReasonAborted: "ocr-review 工具调用被中止",
32
- jobReasonTimeout: "ocr-review 后台评审超过 2 小时,兜底回收",
32
+ jobReasonTimeout: "ocr-review 后台评审超过兜底窗口({minutes} 分钟),兜底回收",
33
33
  jobReasonUnload: "ocr-review 插件卸载",
34
34
  jobOutputGap: "\n[ocr-review] 注意:有一次输出读取失败,已按原游标重试(这段可能不完整)",
35
35
  requiredNotArray: "parameters.required 必须是字符串数组",
@@ -83,6 +83,7 @@ const MESSAGES = {
83
83
  configPathLayoutInvalid: "设置项 ocrConfigPath 必须是 <X>/.opencodereview/config.json 布局:{path}。OCR 端没有配置文件级的路径覆盖(只按 <HOME>/.opencodereview/config.json 定位),本插件通过给 ocr 子进程注入 HOME=<X> 让它生效;任意形状的文件路径写下去 OCR 也不会读",
84
84
  noApiKeyEnv: "provider {provider} 未声明 apiKeyEnv,无法动态读取 key(OCR 自定义 provider 不支持无 key 解析)",
85
85
  keyNotResolvable: "provider {provider} 的 API key 未配置:dsh 凭据服务未解析到 {ref}",
86
+ keyNotReachable: "provider {provider} 的 API key 存在于凭据层「{source}」,OCR 运行时取不到它:dsh 起 shell 会剥掉名字含 KEY/PASSWORD/SECRET/TOKEN 或 DSH_ 前缀的环境变量,get-cred 也刻意不读被审查仓库的 .env。请把 key 存入 dsh 托管凭据($DSH_HOME/.credentials.yaml 的 refs.{ref})或 $DSH_HOME/.env 后重试",
86
87
  credScriptMissing: "get-cred 脚本缺失:{path}(api_key_cmd 无法工作)",
87
88
  reaperDegraded: "[ocr-review] 宿主回收守护已降级:ps/pgrep 不可用(进程信息命令被沙箱拒绝),改由宿主管进程树。dsh 硬退出时 ocr 可能留孤儿进程。",
88
89
  warningUnreadable: "(一条格式无法解析的 warning:既不是字符串,也没有 file/error 字段)",
@@ -108,7 +109,7 @@ const MESSAGES = {
108
109
  paramBackgroundPreview: "业务上下文(会出现在输出中供审查参考)",
109
110
  paramExclude: "gitignore 风格排除模式(可多值)",
110
111
  paramExcludeShort: "排除模式",
111
- paramWait: "true(缺省)=等待完成并返回聚合摘要;false=后台启动立即返回(2 小时后兜底回收),用 ocr_session 轮询结果。两种模式都带宿主回收守护:dsh 退出(含 kill -9)时 OCR 进程一并结束",
112
+ paramWait: "true(缺省)=等待完成并返回聚合摘要;false=后台启动立即返回(兜底窗口 ocrBackgroundMaxMs 默认 2 小时),用 ocr_session 轮询结果。两种模式都带宿主回收守护:dsh 退出(含 kill -9)时 OCR 进程一并结束",
112
113
  paramProvider: "本次运行临时指定已配置 provider",
113
114
  paramModel: "本次运行临时覆盖 model",
114
115
  paramResume: "从之前中断的会话 id 恢复(会话 id 在返回结果里)",
@@ -157,9 +158,9 @@ const MESSAGES = {
157
158
  detailSeparator: ": ",
158
159
  stdoutTruncated: "[ocr-review] note: output exceeded the {limit}-byte cap and was truncated{spill}",
159
160
  outputSpill: " (full output spilled to {path})",
160
- 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. Background runs get reclaimed by a 2-hour backstop (a wedged one would otherwise keep occupying the official job bucket); cancelling this tool call terminates the background OCR (host kill + the reaper guard inside the command).",
161
+ 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. Background runs get no host timeout; they are reclaimed when the backstop window (ocrBackgroundMaxMs, 2 hours by default) elapses (a wedged one would otherwise keep occupying the official job bucket); cancelling this tool call terminates the background OCR (host kill + the reaper guard inside the command).",
161
162
  jobReasonAborted: "ocr-review tool call aborted",
162
- jobReasonTimeout: "ocr-review background review exceeded 2 hours and was reclaimed",
163
+ jobReasonTimeout: "the ocr-review background review exceeded its backstop window ({minutes} minutes) and was reclaimed",
163
164
  jobReasonUnload: "ocr-review plugin unloaded",
164
165
  jobOutputGap: "\n[ocr-review] note: one output read failed and was retried from the same offset (this stretch may be incomplete)",
165
166
  requiredNotArray: "parameters.required must be an array of strings",
@@ -213,6 +214,7 @@ const MESSAGES = {
213
214
  configPathLayoutInvalid: "the ocrConfigPath setting must follow the <X>/.opencodereview/config.json layout: {path}. OCR has no file-level path override (it only looks at <HOME>/.opencodereview/config.json); this plugin makes it effective by injecting HOME=<X> into the ocr subprocess. A file path of any other shape would never be read by OCR",
214
215
  noApiKeyEnv: "provider {provider} declares no apiKeyEnv, so the key cannot be read dynamically (OCR custom providers do not support keyless resolution)",
215
216
  keyNotResolvable: "the API key of provider {provider} is not configured: the dsh credentials service resolved no {ref}",
217
+ keyNotReachable: "the API key of provider {provider} lives in the credential source \"{source}\", which OCR cannot reach at runtime: dsh strips variables named KEY/PASSWORD/SECRET/TOKEN or prefixed DSH_ from spawned shells, and get-cred deliberately does not read the reviewed repository's .env. Store the key in the dsh managed credentials ($DSH_HOME/.credentials.yaml refs.{ref}) or $DSH_HOME/.env and retry",
216
218
  credScriptMissing: "get-cred script missing: {path} (api_key_cmd cannot work)",
217
219
  reaperDegraded: "[ocr-review] host reaper degraded: ps/pgrep are unavailable (process-info commands denied by the sandbox), so the host manages the process tree. dsh may leave an orphaned ocr process.",
218
220
  warningUnreadable: "(one unreadable warning entry: neither a string nor an object with file/error)",
@@ -238,7 +240,7 @@ const MESSAGES = {
238
240
  paramBackgroundPreview: "Business context (it shows up in the output for review reference)",
239
241
  paramExclude: "gitignore-style exclusion patterns (multiple allowed)",
240
242
  paramExcludeShort: "Exclusion patterns",
241
- paramWait: "true (default) = wait for completion and return the aggregated summary; false = start in the background and return immediately (a 2-hour backstop reclaims it), poll with ocr_session. Both modes carry the host reaper guard: when dsh exits (including kill -9) the OCR process is terminated too",
243
+ paramWait: "true (default) = wait for completion and return the aggregated summary; false = start in the background and return immediately (reclaimed when the backstop window ocrBackgroundMaxMs, 2 hours by default, elapses), poll with ocr_session. Both modes carry the host reaper guard: when dsh exits (including kill -9) the OCR process is terminated too",
242
244
  paramProvider: "Temporarily use a configured provider for this run",
243
245
  paramModel: "Temporarily override the model for this run",
244
246
  paramResume: "Resume from a previously interrupted session id (the id is in the result)",
@@ -341,14 +343,15 @@ function scalarMismatch(type, value) {
341
343
  function objectViolations(schema, value, path, walk) {
342
344
  if (!isRecord(value)) return [`${path}: expected object`];
343
345
  const { properties, required, additionalProperties } = schema;
346
+ const declared = (key) => properties !== void 0 && Object.hasOwn(properties, key) ? properties[key] : void 0;
344
347
  if (additionalProperties === false) {
345
- const extra = Object.keys(value).filter((key) => properties?.[key] === void 0);
348
+ const extra = Object.keys(value).filter((key) => declared(key) === void 0);
346
349
  if (extra.length > 0) return [`${path}: fields outside the schema: ${extra.join(", ")}`];
347
350
  }
348
- for (const key of required ?? []) if (!(key in value) || value[key] === void 0) return [`${path}: missing required field ${key}`];
351
+ for (const key of required ?? []) if (!Object.hasOwn(value, key) || value[key] === void 0) return [`${path}: missing required field ${key}`];
349
352
  const violations = [];
350
353
  for (const key of Object.keys(value)) {
351
- const child = properties?.[key];
354
+ const child = declared(key);
352
355
  if (child !== void 0) violations.push(...walk(child, value[key], `${path}.${key}`));
353
356
  }
354
357
  return violations;
@@ -360,42 +363,6 @@ function arrayViolations(schema, value, path, walk) {
360
363
  if (items === void 0) return [];
361
364
  return value.flatMap((item, index) => walk(items, item, `${path}[${index}]`));
362
365
  }
363
- /** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
364
- * 恰好一支命中,多支/零支都算违规。 */
365
- function violationsOf(schema, value, path) {
366
- if (schema.oneOf !== void 0) {
367
- const passing = schema.oneOf.filter((branch) => violationsOf(branch, value, path).length === 0);
368
- if (passing.length !== 1) return [`${path}: matched ${passing.length} of ${schema.oneOf.length} oneOf branches`];
369
- return [];
370
- }
371
- const { type } = schema;
372
- if (type === "object") return objectViolations(schema, value, path, violationsOf);
373
- if (type === "array") return arrayViolations(schema, value, path, violationsOf);
374
- if (type === void 0) return [];
375
- if (scalarMismatch(type, value)) return [`${path}: expected ${type}`];
376
- if (schema.const !== void 0 && value !== schema.const) return [`${path}: must be exactly ${JSON.stringify(schema.const)}`];
377
- if (schema.enum !== void 0 && !schema.enum.includes(value)) return [`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`];
378
- return [];
379
- }
380
- /**
381
- * 校验并放行工具返回值:必须命中该工具自己的 output schema。
382
- * 违规抛错而非静默裁剪——静默修正是第二个 bug 的开始,抛错让回归在开发期
383
- * (vitest/首次调用)就炸出来。
384
- * @param schema 该工具注册时声明的 output.schema(与宿主校验用的是同一个对象)
385
- * @param value execute 交回的 canonical 值(结构化对象 / 数组 / 标量 / null)
386
- * @param toolName 工具名(违规信息要点名,模型才知道该重试哪一次调用)
387
- * @param messages 双语文案
388
- * @returns 原值(校验只读不改,值原样交给宿主)
389
- */
390
- function assertToolOutput(schema, value, toolName, messages) {
391
- assertGuardable(schema, toolName, messages);
392
- const violations = violationsOf(schema, value, "result");
393
- if (violations.length > 0) throw new Error(format(messages.resultSchemaViolation, {
394
- tool: toolName,
395
- violations: violations.join("; ")
396
- }));
397
- return value;
398
- }
399
366
  /** provider key 形状:`sk-…`/`ark-…`/`ghp-…` 一类「短前缀 + 长随机串」。
400
367
  *
401
368
  * 为什么额外要求随机串里至少有一个数字或大写字母:真实 key 是随机 base62(32 位
@@ -403,14 +370,19 @@ function assertToolOutput(schema, value, toolName, messages) {
403
370
  * (`src/my-super-long-directory-name/index.ts`)。少了这道约束,session/delegate
404
371
  * 透传出的路径会被抹成 ***REDACTED***,模型拿不到可行动的位置信息。 */
405
372
  const STANDALONE_KEY = /\b[A-Za-z]{2,5}-(?=[A-Za-z0-9_-]{20,}\b)(?=[A-Za-z0-9_-]*[0-9A-Z])[A-Za-z0-9_-]{20,}\b/gu;
406
- /** `Authorization: Bearer xxx` / `bearer xxx`。
407
- *
408
- * 字符集写 `[a-z…]` 而不是 `[A-Za-z…]`:本条带 `i` 旗标,`a-z` 在大小写不敏感下
409
- * 已同时匹配 `A-Z`——再并列一枚 `A-Z` 是同一个字符集抄两遍(sonarjs 判为字符类内重复),
410
- * 删掉重复的那一位,匹配语言不变。 */
411
- const BEARER_VALUE = /(?<scheme>\bbearer\s+)[a-z0-9._~+/=-]{12,}/giu;
412
- /** `api_key: "xxx"`、`apiKey=xxx`、`token: xxx`、`x-api-key: xxx` 等赋值形态。 */
413
- const KEY_ASSIGNMENT = /(?<name>\b(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)[^\s"',;)}\]]{6,}/giu;
373
+ /** `Authorization: <scheme> <value>` / `Proxy-Authorization: …`:认**头名**而不是
374
+ * 枚举认证方案。头名本身就是「后面跟着凭据」的信号,Basic / Token / Digest 乃至
375
+ * vendor 自定义 scheme 都不会漏;而只认头名还避开了正文里「token based
376
+ * authentication」这类措辞被误抹。scheme 组连同其后的空白一起捕获,回填时凭据被
377
+ * 替换而 scheme 词保留。 */
378
+ const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
379
+ /** 裸 scheme + 值(没有头名时 OCR 也会整行回显)。scheme 只留 bearer:它在散文中几乎
380
+ * 不出现,而 token / basic 之类会撞上「token count」「basic usage」等普通措辞。 */
381
+ const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
382
+ /** `api_key: "xxx"`、`apiKey=xxx`、`token: xxx`、`x-api-key: xxx` 等赋值形态。
383
+ * 值下限取 4 且容许一个 scheme 词:`token: Basic <base64>` 里赋值形态先能吃到的只是
384
+ * scheme 词(Basic 恰好 5 字符),下限更高时整条失配,后面那个 base64 就原样进了日志。 */
385
+ const KEY_ASSIGNMENT = /(?<name>\b(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
414
386
  const REDACTION = "***REDACTED***";
415
387
  /**
416
388
  * 抹掉文本里 key 形状的片段。
@@ -425,7 +397,49 @@ const REDACTION = "***REDACTED***";
425
397
  * 要求随机串特征,避免把要交给模型的文件路径一起抹掉。
426
398
  */
427
399
  function redactKeyMaterial(text) {
428
- return text.replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>${REDACTION}`).replaceAll(STANDALONE_KEY, REDACTION);
400
+ return text.replaceAll(AUTH_HEADER_VALUE, `$<name>$<scheme>${REDACTION}`).replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>$<scheme>${REDACTION}`).replaceAll(STANDALONE_KEY, REDACTION);
401
+ }
402
+ /** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
403
+ * 恰好一支命中,多支/零支都算违规。 */
404
+ function violationsOf(schema, value, path) {
405
+ const violations = [];
406
+ const { oneOf } = schema;
407
+ if (oneOf !== void 0) {
408
+ const passing = oneOf.filter((branch) => violationsOf(branch, value, path).length === 0);
409
+ if (passing.length !== 1) violations.push(`${path}: matched ${passing.length} of ${oneOf.length} oneOf branches`);
410
+ }
411
+ const { type } = schema;
412
+ if (type === "object") violations.push(...objectViolations(schema, value, path, violationsOf));
413
+ else if (type === "array") violations.push(...arrayViolations(schema, value, path, violationsOf));
414
+ else if (type !== void 0 && scalarMismatch(type, value)) violations.push(`${path}: expected ${type}`);
415
+ if (schema.const !== void 0 && value !== schema.const) violations.push(`${path}: must be exactly ${JSON.stringify(schema.const)}`);
416
+ if (schema.enum !== void 0 && !schema.enum.includes(value)) violations.push(`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`);
417
+ return violations;
418
+ }
419
+ /**
420
+ * 校验并放行工具返回值:必须命中该工具自己的 output schema。
421
+ * 违规抛错而非静默裁剪——静默修正是第二个 bug 的开始,抛错让回归在开发期
422
+ * (vitest/首次调用)就炸出来。
423
+ * @param schema 该工具注册时声明的 output.schema(与宿主校验用的是同一个对象)
424
+ * @param value execute 交回的 canonical 值(结构化对象 / 数组 / 标量 / null)
425
+ * @param toolName 工具名(违规信息要点名,模型才知道该重试哪一次调用)
426
+ * @param messages 双语文案
427
+ * @returns 原值(校验只读不改,值原样交给宿主)
428
+ */
429
+ function assertToolOutput(schema, value, toolName, messages) {
430
+ assertGuardable(schema, toolName, messages);
431
+ const violations = violationsOf(schema, value, "result");
432
+ if (violations.length > 0) {
433
+ const MAX_LISTED = 20;
434
+ const MAX_CHARS = 2e3;
435
+ const omitted = violations.length - MAX_LISTED;
436
+ const listed = violations.slice(0, MAX_LISTED).join("; ") + (omitted > 0 ? ` …(+${omitted})` : "");
437
+ throw new Error(format(messages.resultSchemaViolation, {
438
+ tool: toolName,
439
+ violations: redactKeyMaterial(listed).slice(0, MAX_CHARS)
440
+ }));
441
+ }
442
+ return value;
429
443
  }
430
444
  //#endregion
431
445
  //#region lib/argv-guard.ts
@@ -563,19 +577,23 @@ function idValue(value, name, messages) {
563
577
  * 负数/非整数由 OCR 侧报错,但桥接层先做同源校验给出更早的错误)。
564
578
  * value 为 undefined/null 时省略 flag(跟随 OCR flag 默认值)。
565
579
  *
566
- * 注入面:数值经 `String(num)` 落进命令串(不走 shq)。Number.isInteger 对
567
- * 1e21 为 true 而 String() 会写成 `1e+21`,故再加一道「只含数字」的形态校验:
568
- * 命令串里的数值 token 结构上不可能带 shell 元字符。
580
+ * 注入面:数值经 `String(num)` 落进命令串(不走 shq)。数值 token 结构上不可能带
581
+ * shell 元字符,「只含数字」的形态校验顺带把 1e21 写成 `1e+21` 这类形态也挡住。
569
582
  */
570
583
  function pushIntFlag(parts, flag, value, min, messages) {
571
584
  if (value === void 0 || value === null) return;
572
- const num = typeof value === "number" ? value : Number(value);
585
+ const reject = () => {
586
+ throw new Error(format(messages.intFlagMustBeInt, {
587
+ flag,
588
+ min: String(min),
589
+ received: describeValue(value)
590
+ }));
591
+ };
592
+ const decimalString = typeof value === "string" && /^\d+$/u.test(value.trim());
593
+ if (typeof value !== "number" && !decimalString) reject();
594
+ const num = Number(value);
573
595
  const text = String(num);
574
- if (!Number.isInteger(num) || num < min || !/^\d+$/u.test(text)) throw new Error(format(messages.intFlagMustBeInt, {
575
- flag,
576
- min: String(min),
577
- received: describeValue(value)
578
- }));
596
+ if (!Number.isSafeInteger(num) || num < min || !/^\d+$/u.test(text)) reject();
579
597
  parts.push(flag, text);
580
598
  }
581
599
  /** 模式参数解析:workspace(默认)/commit/branch 互斥,非法组合与错类型抛错。
@@ -698,8 +716,11 @@ const memoBox = {};
698
716
  */
699
717
  function ocrCommand(locate = locateLauncher, memo = memoBox) {
700
718
  if (memo.value === void 0) {
701
- const launcher = locate();
702
- memo.value = launcher === null ? "ocr" : shq(launcher);
719
+ if (process.platform === "win32") memo.value = "ocr";
720
+ else {
721
+ const launcher = locate();
722
+ memo.value = launcher === null ? "ocr" : shq(launcher);
723
+ }
703
724
  }
704
725
  return memo.value;
705
726
  }
@@ -983,14 +1004,15 @@ function str(value) {
983
1004
  * 故作兜底值。
984
1005
  */
985
1006
  function nonNegativeInt(value) {
986
- if (!Number.isFinite(value) || value < 0) return 0;
987
- return Math.trunc(value);
1007
+ const truncated = Math.trunc(value);
1008
+ if (!Number.isSafeInteger(truncated) || truncated < 0) return 0;
1009
+ return truncated;
988
1010
  }
989
1011
  function num(value) {
990
1012
  if (typeof value === "number") return nonNegativeInt(value);
991
1013
  if (typeof value === "string") {
992
1014
  const trimmed = value.trim();
993
- if (trimmed === "") return 0;
1015
+ if (!/^\d+$/u.test(trimmed)) return 0;
994
1016
  return nonNegativeInt(Number(trimmed));
995
1017
  }
996
1018
  return 0;
@@ -1153,6 +1175,42 @@ function delegatePreviewFailure() {
1153
1175
  mergeBase: ""
1154
1176
  };
1155
1177
  }
1178
+ /**
1179
+ * preview 的条目数组 → 结构化清单;返回 null 表示「上游给了条目却一条都没解析出来」。
1180
+ *
1181
+ * 判据分两层:部分条目解析不出来照旧丢弃(OCR 偶发脏条目不该毁掉整份清单),
1182
+ * 但一条不剩时不能继续以 ok:true 的形态报「0 个文件可审」——那不是「没有可审的
1183
+ * 文件」,是半截产出,模型与用户都会拿它当范围结论。
1184
+ */
1185
+ function toDelegateFiles(raw) {
1186
+ if (!Array.isArray(raw)) return [];
1187
+ const files = raw.map((entry) => {
1188
+ if (!isRecord(entry)) return null;
1189
+ const entryPath = str(entry["path"]);
1190
+ if (entryPath === "") return null;
1191
+ return {
1192
+ path: entryPath,
1193
+ status: str(entry["status"]),
1194
+ insertions: num(entry["insertions"]),
1195
+ deletions: num(entry["deletions"])
1196
+ };
1197
+ }).filter((fileEntry) => fileEntry !== null);
1198
+ return raw.length > 0 && files.length === 0 ? null : files;
1199
+ }
1200
+ /** excluded_files 同款门:条目形状与 reviewable 一致,缺 path 即不可用。 */
1201
+ function toExcludedFiles(raw) {
1202
+ if (!Array.isArray(raw)) return [];
1203
+ const files = raw.map((entry) => {
1204
+ if (!isRecord(entry)) return null;
1205
+ const entryPath = str(entry["path"]);
1206
+ if (entryPath === "") return null;
1207
+ return {
1208
+ path: entryPath,
1209
+ reason: str(entry["reason"])
1210
+ };
1211
+ }).filter((fileEntry) => fileEntry !== null);
1212
+ return raw.length > 0 && files.length === 0 ? null : files;
1213
+ }
1156
1214
  /** 解析 `ocr delegate preview --format json` 输出。 */
1157
1215
  function parseDelegatePreview(text) {
1158
1216
  let doc;
@@ -1168,22 +1226,9 @@ function parseDelegatePreview(text) {
1168
1226
  const value = root[key];
1169
1227
  if (value !== void 0 && !Array.isArray(value)) return delegatePreviewFailure();
1170
1228
  }
1171
- const reviewable = Array.isArray(root["reviewable_files"]) ? root["reviewable_files"].map((fileRaw) => {
1172
- if (!isRecord(fileRaw)) return null;
1173
- return {
1174
- path: str(fileRaw["path"]),
1175
- status: str(fileRaw["status"]),
1176
- insertions: num(fileRaw["insertions"]),
1177
- deletions: num(fileRaw["deletions"])
1178
- };
1179
- }).filter((fileEntry) => fileEntry !== null && fileEntry.path.length > 0) : [];
1180
- const excluded = Array.isArray(root["excluded_files"]) ? root["excluded_files"].map((fileRaw) => {
1181
- if (!isRecord(fileRaw)) return null;
1182
- return {
1183
- path: str(fileRaw["path"]),
1184
- reason: str(fileRaw["reason"])
1185
- };
1186
- }).filter((fileEntry) => fileEntry !== null && fileEntry.path.length > 0) : [];
1229
+ const reviewable = toDelegateFiles(root["reviewable_files"]);
1230
+ const excluded = toExcludedFiles(root["excluded_files"]);
1231
+ if (reviewable === null || excluded === null) return delegatePreviewFailure();
1187
1232
  return {
1188
1233
  ok: true,
1189
1234
  mode: str(root["mode"]),
@@ -1223,15 +1268,20 @@ function parseDelegateRules(text) {
1223
1268
  ok: false,
1224
1269
  groups: []
1225
1270
  };
1271
+ const groups = groupsRaw.filter((groupRaw) => isRecord(groupRaw) && Array.isArray(groupRaw["files"])).map((groupRaw) => ({
1272
+ groupId: num(groupRaw["group_id"]),
1273
+ source: str(groupRaw["source"]),
1274
+ pattern: str(groupRaw["pattern"]),
1275
+ files: groupRaw.files.filter((file) => typeof file === "string" && file.trim() !== "").map((file) => file.trim()),
1276
+ rule: str(groupRaw["rule"])
1277
+ })).filter((group) => group.files.length > 0);
1278
+ if (groupsRaw.length > 0 && groups.length === 0) return {
1279
+ ok: false,
1280
+ groups: []
1281
+ };
1226
1282
  return {
1227
1283
  ok: true,
1228
- groups: groupsRaw.filter((groupRaw) => isRecord(groupRaw) && Array.isArray(groupRaw["files"])).map((groupRaw) => ({
1229
- groupId: num(groupRaw["group_id"]),
1230
- source: str(groupRaw["source"]),
1231
- pattern: str(groupRaw["pattern"]),
1232
- files: groupRaw.files.map((file) => str(file)).filter(Boolean),
1233
- rule: str(groupRaw["rule"])
1234
- })).filter((group) => group.files.length > 0)
1284
+ groups
1235
1285
  };
1236
1286
  }
1237
1287
  //#endregion
@@ -1464,8 +1514,17 @@ function readText(configPath) {
1464
1514
  return "";
1465
1515
  }
1466
1516
  }
1467
- /** 单引号转义(api_key_cmd 内嵌命令用,模块级纯函数)。 */
1517
+ /**
1518
+ * api_key_cmd 内嵌值的转义。POSIX 走单引号(ocr 在 Unix 用 `sh -c` 跑 api_key_cmd,
1519
+ * keycmd_unix.go);Windows 的 ocr 用 **cmd.exe** 跑它(keycmd_windows.go),单引号
1520
+ * 在那里是普通字符、命令必坏,故走双引号:值内 `"` 翻倍、收尾反斜杠再翻倍(防被
1521
+ * CommandLineToArgvW 当成转义吃掉收尾引号)。ref 名白名单 `^[A-Z0-9_]+$` 不含两种
1522
+ * 引号;脚本与数据目录是盘上常规路径。已知残留:cmd.exe 会对双引号内的 `%VAR%`
1523
+ * 做环境展开,路径含 `%` 的 Windows 部署需自行规避(Windows 非目标平台,此处只
1524
+ * 保证常规路径形态正确)。
1525
+ */
1468
1526
  function shqDyn(value) {
1527
+ if (process.platform === "win32") return `"${value.replaceAll("\"", "\"\"").replace(/\\+$/u, (backslashes) => backslashes + backslashes)}"`;
1469
1528
  return `'${value.replaceAll("'", String.raw`'\''`)}'`;
1470
1529
  }
1471
1530
  /** api_key_cmd 值构造:脚本路径与 env key 名(及可选的 dsh 数据目录)shq 转义后
@@ -1534,19 +1593,73 @@ function readShqTokens(body) {
1534
1593
  return malformed ? null : tokens;
1535
1594
  }
1536
1595
  /**
1537
- * api_key_cmd → 三个槽位;只认本包写出的那一种形态。
1596
+ * 读一个 cmd.exe 双引号串:解出它的值与结束位置(`""` 是翻倍的引号)。
1597
+ * 未闭合返回 null:宁可判「读不懂」,也不把半截命令当可信输入。
1598
+ */
1599
+ function readDqToken(body, start) {
1600
+ let value = "";
1601
+ let i = start + 1;
1602
+ let closed = false;
1603
+ while (i < body.length && !closed) {
1604
+ const ch = body[i];
1605
+ if (ch === "\"" && body[i + 1] === "\"") {
1606
+ value += "\"";
1607
+ i += 2;
1608
+ } else if (ch === "\"") {
1609
+ closed = true;
1610
+ i += 1;
1611
+ } else {
1612
+ value += ch;
1613
+ i += 1;
1614
+ }
1615
+ }
1616
+ if (!closed) return null;
1617
+ return {
1618
+ value: value.replace(/(?:\\\\)+$/u, (backslashes) => "\\".repeat(backslashes.length / 2)),
1619
+ next: i
1620
+ };
1621
+ }
1622
+ /**
1623
+ * cmd.exe 双引号形态的 token 列表(buildApiKeyCmd 的 win32 臂写出的那种:
1624
+ * `node "a" "b" "c"`);形态不符(裸词 / 未闭合)返回 null。
1625
+ *
1626
+ * 只在 readShqTokens 判 null 之后被调用,而那条路必有非空白内容 ⇒ 循环至少走一轮,
1627
+ * 解析成功的列表至少一枚 token;末尾的 `return tokens` 对实际可达的输入不可能交出
1628
+ * 空列表(parseApiKeyCmd 的 `script === ""` 闸对它另有兜底)。
1629
+ */
1630
+ function readDqTokens(body) {
1631
+ const tokens = [];
1632
+ let i = 0;
1633
+ while (i < body.length) {
1634
+ while (i < body.length && (body[i] === " " || body[i] === " ")) i += 1;
1635
+ if (i >= body.length) break;
1636
+ if (body[i] !== "\"") return null;
1637
+ const token = readDqToken(body, i);
1638
+ if (token === null) return null;
1639
+ tokens.push(token.value);
1640
+ i = token.next;
1641
+ }
1642
+ return tokens;
1643
+ }
1644
+ /**
1645
+ * api_key_cmd → 三个槽位;认本包写出的**两种**形态(POSIX 单引号与 Windows 双引号)。
1538
1646
  *
1539
1647
  * 为什么需要反解:写进 config.json 的脚本路径是**写那一刻**的插件安装目录(绝对
1540
1648
  * 路径),而插件会被重装、换 profile、换发布形态——目录一变,ocr 执行 api_key_cmd
1541
1649
  * 就是 MODULE_NOT_FOUND,而它把这件事报成「resolve LLM endpoint 失败」,错误完全
1542
1650
  * 指不到「本包被挪了地方」这个真因。没有反解就无从判断一条 api_key_cmd 是否还活着。
1543
1651
  *
1544
- * 为什么只认一种形态:认不出来的一律给 undefined,由调用方原样保留。改写一条读不
1545
- * 懂的命令串,可能把用户手写的凭据取用方式弄坏,而那正是本包明令不该插手的部分。
1652
+ * 为什么两种形态都认:config.json 可能跨机迁移(HOME 同步、换部署),反解不能假设
1653
+ * 「写它的平台 = 读它的平台」。POSIX 形态优先判(单引号);POSIX 判不出且整体是
1654
+ * 双引号形态时再走 cmd.exe 那一档。
1655
+ *
1656
+ * 为什么认不出来一律给 undefined:由调用方原样保留。改写一条读不懂的命令串,可能
1657
+ * 把用户手写的凭据取用方式弄坏,而那正是本包明令不该插手的部分。
1546
1658
  */
1547
1659
  function parseApiKeyCmd(command) {
1548
1660
  const body = command.replace(/^node[ \t]+/u, "");
1549
- const tokens = body === command ? null : readShqTokens(body);
1661
+ if (body === command) return null;
1662
+ const tokens = readShqTokens(body) ?? readDqTokens(body);
1550
1663
  if (tokens === null) return null;
1551
1664
  const script = tokens[0] ?? "";
1552
1665
  const envKey = tokens[1] ?? "";
@@ -1729,6 +1842,32 @@ function repointDanglingKeyScripts(doc, current, messages) {
1729
1842
  /** 外部 ocr CLI 的默认配置目录/文件名(宿主服务管不到,只能由本包直读直写)。 */
1730
1843
  const OCR_CONFIG_DIR = ".opencodereview";
1731
1844
  const OCR_CONFIG_FILE = "config.json";
1845
+ /**
1846
+ * 宿主起 shell 子进程时的**环境净化**模式(dsh-subprocess 的 SENSITIVE_ENV_PATTERN
1847
+ * `KEY|PASSWORD|SECRET|TOKEN` 大小写不敏感命中即剥,另加 `DSH_` 前缀全剥)。
1848
+ * 本包不传 `env`,OCR 子进程拿到的环境就是净化后的那一份——名字命中本模式的
1849
+ * 凭据 ref,OCR 及其孙进程(get-cred)在「继承环境」这一层永远读到空。
1850
+ */
1851
+ const HOST_SCRUBBED_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN|^DSH_/iu;
1852
+ /**
1853
+ * key 在 **OCR 运行时**是否真的取得到(get-cred 三层里有没有一层够得到它)。
1854
+ *
1855
+ * 宿主侧 resolve() 的 source 是「dsh 进程自己的取值层」,不等于「OCR 子进程够得到的
1856
+ * 层」——少了这道判定就是「select 成功 → OCR 401」的假绿:
1857
+ * - `file`($DSH_HOME/.credentials.yaml)→ get-cred 第 2 层,可达;
1858
+ * - `user-env`($DSH_HOME/.env)→ get-cred 第 3 层,可达;
1859
+ * - `env`(dsh 进程继承的环境)→ 只有 ref 名**不**命中宿主净化模式时才可达
1860
+ * (API key 类 ref 几乎全含 KEY/TOKEN,实际常不可达);
1861
+ * - `project-env`(dsh 工作区 .env)→ get-cred 刻意不读被审查仓库的 .env,永不可达;
1862
+ * - 未知层 / `undefined`(未配置)→ 一律按不可达,由调用方分别报「未配置」。
1863
+ * @param source credentials.resolve() 交出的层 id(gateway.credentialSource)。
1864
+ * @param ref 凭据 ref 名(get-cred/buildApiKeyCmd 用的那个大写名)。
1865
+ */
1866
+ function keyReachableAtOcrRuntime(source, ref) {
1867
+ if (source === "file" || source === "user-env") return true;
1868
+ if (source === "env") return !HOST_SCRUBBED_ENV_PATTERN.test(ref);
1869
+ return false;
1870
+ }
1732
1871
  /** 无凭据可问时的一律 false(卡片据 source 显示原因,不显示假的「未配置 key」)。 */
1733
1872
  function withoutKeyFlags(providers) {
1734
1873
  return providers.map((providerCfg) => ({
@@ -1795,6 +1934,7 @@ async function providersForCard(gateway, paths, messages) {
1795
1934
  */
1796
1935
  async function applyProviderSelection(gateway, paths, selection, messages) {
1797
1936
  const { provider, model } = selection;
1937
+ if (typeof provider !== "string" || typeof model !== "string" || provider.trim() === "" || model.trim() === "") throw new Error(format(messages.providerModelRequired, {}));
1798
1938
  const { providers, source } = readProviders(gateway, messages);
1799
1939
  if (source.status !== "ready") throw new Error(source.message);
1800
1940
  const target = providers.find((providerCfg) => providerCfg.name === provider);
@@ -1803,10 +1943,16 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
1803
1943
  provider
1804
1944
  }));
1805
1945
  if (target.apiKeyEnv === null || target.apiKeyEnv === "") throw new Error(format(messages.noApiKeyEnv, { provider }));
1806
- if (!await gateway.credentialResolvable(target.apiKeyEnv)) throw new Error(format(messages.keyNotResolvable, {
1946
+ const keySource = await gateway.credentialSource(target.apiKeyEnv);
1947
+ if (keySource === void 0) throw new Error(format(messages.keyNotResolvable, {
1807
1948
  provider,
1808
1949
  ref: target.apiKeyEnv
1809
1950
  }));
1951
+ if (!keyReachableAtOcrRuntime(keySource, target.apiKeyEnv)) throw new Error(format(messages.keyNotReachable, {
1952
+ provider,
1953
+ ref: target.apiKeyEnv,
1954
+ source: keySource
1955
+ }));
1810
1956
  const scriptPath = credentialScriptPath(paths);
1811
1957
  if (!existsSync(scriptPath)) throw new Error(format(messages.credScriptMissing, { path: scriptPath }));
1812
1958
  const apiKeyCmd = buildApiKeyCmd(scriptPath, target.apiKeyEnv, resolveDshHome(), messages);
@@ -1834,11 +1980,11 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
1834
1980
  * api_key_cmd 并删除明文,返回 'migrated';找不到 envKey 或构造命令失败 →
1835
1981
  * 'unknown';无明文 → 'noPlain'(不动作)。
1836
1982
  */
1837
- function migrateEntry(entry, envKey, scriptPath, dshHome, messages) {
1983
+ function migrateEntry(entry, envKey, context) {
1838
1984
  if (typeof entry["api_key"] !== "string" || entry["api_key"].length === 0) return "noPlain";
1839
- if (envKey === void 0 || envKey === "") return "unknown";
1985
+ if (envKey === void 0 || envKey === "" || !context.reachableEnvKeys.has(envKey)) return "unknown";
1840
1986
  try {
1841
- entry["api_key_cmd"] = buildApiKeyCmd(scriptPath, envKey, dshHome, messages);
1987
+ entry["api_key_cmd"] = buildApiKeyCmd(context.scriptPath, envKey, context.dshHome, context.messages);
1842
1988
  } catch {
1843
1989
  return "unknown";
1844
1990
  }
@@ -1865,13 +2011,23 @@ async function migratePlaintextKeys(gateway, paths, messages) {
1865
2011
  if (source.status !== "ready" && source.status !== "credentials-unavailable") throw new Error(source.message);
1866
2012
  parseExistingConfig(precheck, paths.ocrConfigJson, messages);
1867
2013
  const scriptPath = credentialScriptPath(paths);
1868
- if (!existsSync(scriptPath)) return {
1869
- migrated: [],
1870
- skippedUnknown: []
1871
- };
2014
+ if (!existsSync(scriptPath)) throw new Error(format(messages.credScriptMissing, { path: scriptPath }));
1872
2015
  const dshHome = resolveDshHome();
1873
2016
  const envByProvider = /* @__PURE__ */ new Map();
1874
2017
  for (const providerCfg of providers) if (providerCfg.apiKeyEnv !== null && providerCfg.apiKeyEnv !== "") envByProvider.set(providerCfg.name, providerCfg.apiKeyEnv);
2018
+ const candidateEnvKeys = [...new Set(envByProvider.values())];
2019
+ const keySources = await Promise.all(candidateEnvKeys.map((envKey) => gateway.credentialSource(envKey)));
2020
+ const reachableEnvKeys = /* @__PURE__ */ new Set();
2021
+ for (const [index, envKey] of candidateEnvKeys.entries()) {
2022
+ const keySource = keySources[index];
2023
+ if (keySource !== void 0 && keyReachableAtOcrRuntime(keySource, envKey)) reachableEnvKeys.add(envKey);
2024
+ }
2025
+ const migrateContext = {
2026
+ scriptPath,
2027
+ dshHome,
2028
+ reachableEnvKeys,
2029
+ messages
2030
+ };
1875
2031
  return await updateConfigLocked(paths.ocrConfigJson, async () => {
1876
2032
  const doc = parseExistingConfig(readText(paths.ocrConfigJson), paths.ocrConfigJson, messages);
1877
2033
  const customsRaw = fieldOf(doc, "custom_providers");
@@ -1883,7 +2039,7 @@ async function migratePlaintextKeys(gateway, paths, messages) {
1883
2039
  const migrated = [];
1884
2040
  const skippedUnknown = [];
1885
2041
  for (const [name, raw] of Object.entries(customs)) if (isRecord(raw)) {
1886
- const result = migrateEntry(raw, envByProvider.get(name), scriptPath, dshHome, messages);
2042
+ const result = migrateEntry(raw, envByProvider.get(name), migrateContext);
1887
2043
  if (result === "migrated") migrated.push(name);
1888
2044
  else if (result === "unknown") skippedUnknown.push(name);
1889
2045
  }
@@ -2384,7 +2540,7 @@ const SESSION_OUTPUT_SCHEMA = {
2384
2540
  * owner 口径是「状态归 ocr 自己,围栏由本包显式放开」,故直接要 `danger-full-access`。
2385
2541
  *
2386
2542
  * **「能不能只放开需要写的那一条路径」:不能。** 官方按调用携带的 `SandboxPolicyRequest`
2387
- * 只有 `session` 与 `mode` 两个字段(dsh-sandbox-policy 0.2.1-alpha.1 的
2543
+ * 只有 `session` 与 `mode` 两个字段(dsh-sandbox-policy 0.2.1-alpha.2 的
2388
2544
  * lib/types/index.d.ts:50),**没有**附加可写路径、没有自定义 HOME、没有目录级豁免。
2389
2545
  * 可选的收紧路径只有「改 ocr 自己的状态落点」或「等上游加字段」,两者都不是本包能在
2390
2546
  * 一次补丁里做完的事。故此处维持放开,但把边界写明:放开的是 ocr 这一个子进程的整棵
@@ -2410,7 +2566,16 @@ function failureDetail(result, messages) {
2410
2566
  const detail = redactKeyMaterial(result.stderr.text === "" ? result.stdout.text : result.stderr.text);
2411
2567
  return detail.length <= 800 ? detail : `${truncateEnd(detail, 400)}${messages.middleEllipsis}${truncateStart(detail, 400)}`;
2412
2568
  }
2413
- /** 前台执行:失败抛错(含给模型的行动提示),成功返回 stdout 文本。
2569
+ /** 截断说明(stdoutTruncated 文案 + 可选的落盘位置),判据见 ForegroundRun 的注释。 */
2570
+ function stdoutTruncationNote(limitBytes, run, messages) {
2571
+ if (!run.truncated) return "";
2572
+ const spill = run.spillPath === void 0 ? "" : format(messages.outputSpill, { path: run.spillPath });
2573
+ return `\n${format(messages.stdoutTruncated, {
2574
+ limit: String(limitBytes),
2575
+ spill
2576
+ })}`;
2577
+ }
2578
+ /** 前台执行:失败抛错(含给模型的行动提示),成功返回 stdout 的一手结果。
2414
2579
  * timeoutMs 恒有值(tuningOf(config)),宿主按 min(请求, shell.maxTimeoutMs) 收口。
2415
2580
  * signal 必传:harness ToolExecution.signal 是必填的调用方取消权。 */
2416
2581
  async function runForeground(host, command, workdir, run, signal) {
@@ -2436,14 +2601,11 @@ async function runForeground(host, command, workdir, run, signal) {
2436
2601
  throw new Error(detail === "" ? head : `${head}${messages.detailSeparator}${detail}`);
2437
2602
  }
2438
2603
  const { text, truncated, spillPath } = result.stdout;
2439
- if (truncated) {
2440
- const spill = typeof spillPath === "string" ? format(messages.outputSpill, { path: spillPath }) : "";
2441
- return `${text}\n${format(messages.stdoutTruncated, {
2442
- limit: String(run.stdoutMaxBytes),
2443
- spill
2444
- })}`;
2445
- }
2446
- return text;
2604
+ return {
2605
+ text,
2606
+ truncated,
2607
+ spillPath
2608
+ };
2447
2609
  }
2448
2610
  /**
2449
2611
  * 终止 OCR 进程,忽略"已经没有必要杀"的那一类异常(进程自然退出、已被注册表或宿主回收)。
@@ -2568,7 +2730,8 @@ async function startBackground(host, job, signal) {
2568
2730
  killProc(proc);
2569
2731
  throw error;
2570
2732
  }
2571
- enforceOcrDeadline(registry, jobId, messages.jobReasonTimeout, job.backgroundMaxMs);
2733
+ const backstopMinutes = Math.round(job.backgroundMaxMs / 6e4);
2734
+ enforceOcrDeadline(registry, jobId, format(messages.jobReasonTimeout, { minutes: String(backstopMinutes) }), job.backgroundMaxMs);
2572
2735
  const stop = () => {
2573
2736
  try {
2574
2737
  registry.kill(jobId, void 0, messages.jobReasonAborted);
@@ -2888,15 +3051,15 @@ function createGateway(host) {
2888
3051
  return false;
2889
3052
  }
2890
3053
  },
2891
- credentialResolvable: async (envKey) => {
3054
+ credentialSource: async (envKey) => {
2892
3055
  const svc = credentialsService();
2893
- if (svc === void 0 || !isCredentialRefName(envKey)) return false;
2894
- try {
2895
- const resolved = await svc.resolve(credentialRef(envKey));
2896
- return resolved !== void 0 && resolved.value.length > 0;
3056
+ let resolved;
3057
+ if (svc !== void 0 && isCredentialRefName(envKey)) try {
3058
+ resolved = await svc.resolve(credentialRef(envKey));
2897
3059
  } catch {
2898
- return false;
3060
+ resolved = void 0;
2899
3061
  }
3062
+ return resolved !== void 0 && resolved.value.length > 0 ? resolved.source : void 0;
2900
3063
  }
2901
3064
  };
2902
3065
  }
@@ -2946,6 +3109,21 @@ async function canonicalReviewRoot(host, root) {
2946
3109
  } catch {}
2947
3110
  return realpathOrSelf(root);
2948
3111
  }
3112
+ /**
3113
+ * 判定 webserver 绑定地址字面量是否回环(127/8、::1 及其全展开/IPv4 映射形态;
3114
+ * `%zone` 后缀剥掉再比,对齐宿主 isLoopbackHost 的判定域)。
3115
+ *
3116
+ * 为什么不是 `host === "0.0.0.0"`:宿主 webserver 的 Config 在装载期就**拒绝通配
3117
+ * 地址**(0.2.1-alpha.2 packages/host/webserver/src/index.ts:263-267 —— 0.0.0.0/::
3118
+ * 一律 throw,要求绑定本机某个具体接口的 IP 字面量),所以那个等式是永假死分支,
3119
+ * 唯一需要 trust 闸放行局域网 Host 头的场景(绑定 LAN IP,容器/远端部署的正路)
3120
+ * 反而被当成回环、整批 403。host 经 config 校验必为具体 IP 字面量,故本函数
3121
+ * 不必处理主机名与通配,只枚举回环字面量的常见书写。
3122
+ */
3123
+ function isLoopbackBindAddress(host) {
3124
+ const value = host.toLowerCase().replace(/%.*$/u, "");
3125
+ return value === "::1" || value === "0:0:0:0:0:0:0:1" || value.startsWith("127.") || /^::ffff:127(?:\.\d{1,3}){3}$/u.test(value);
3126
+ }
2949
3127
  /** POST select 的响应:把 body 里的 provider/model 应用到 OCR 配置,按需附带 llm test 回执。 */
2950
3128
  async function sendSelectResponse(text, res, deps) {
2951
3129
  const msgs = deps.messages();
@@ -2996,7 +3174,7 @@ async function sendSelectResponse(text, res, deps) {
2996
3174
  function registerConfigEndpoints(ctx, host, config, csrf) {
2997
3175
  const webServer = host.get("webServer");
2998
3176
  if (!webServer) return;
2999
- const servingNonLoopback = webServer.host === "0.0.0.0";
3177
+ const servingNonLoopback = !isLoopbackBindAddress(webServer.host);
3000
3178
  const gateway = createGateway(host);
3001
3179
  const messages = () => localeMessages(host);
3002
3180
  const disposeProviders = webServer.register({
@@ -3343,13 +3521,13 @@ function registerDelegatePreviewTool(host, config) {
3343
3521
  ...args,
3344
3522
  repo
3345
3523
  }, runMsgs);
3346
- const text = await runForeground(host, withHomeEnv(command, homeOverride), workdir, tuningOf(config), exec.signal);
3347
- const parsed = parseDelegatePreview(text);
3524
+ const stdout = await runForeground(host, withHomeEnv(command, homeOverride), workdir, tuningOf(config), exec.signal);
3525
+ const parsed = parseDelegatePreview(stdout.text);
3348
3526
  if (!parsed.ok) return format(runMsgs.parseFailedPlain, {
3349
3527
  tool: "ocr delegate preview",
3350
3528
  limit: String(RAW_ECHO_LIMIT),
3351
- raw: echoRaw(text, RAW_ECHO_LIMIT)
3352
- });
3529
+ raw: echoRaw(stdout.text, RAW_ECHO_LIMIT)
3530
+ }) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs);
3353
3531
  return {
3354
3532
  mode: parsed.mode,
3355
3533
  repository: parsed.repository,
@@ -3392,13 +3570,13 @@ function registerDelegateRuleTool(host, config) {
3392
3570
  repo: ocrRepo(args, exec, runMsgs),
3393
3571
  paths: args["paths"]
3394
3572
  }, runMsgs);
3395
- const text = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
3396
- const parsed = parseDelegateRules(text);
3573
+ const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
3574
+ const parsed = parseDelegateRules(stdout.text);
3397
3575
  if (!parsed.ok) return format(runMsgs.parseFailedPlain, {
3398
3576
  tool: "ocr delegate rule",
3399
3577
  limit: String(RAW_ECHO_LIMIT),
3400
- raw: echoRaw(text, RAW_ECHO_LIMIT)
3401
- });
3578
+ raw: echoRaw(stdout.text, RAW_ECHO_LIMIT)
3579
+ }) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs);
3402
3580
  return {
3403
3581
  groupCount: parsed.groups.length,
3404
3582
  groups: parsed.groups.map((group) => ({
@@ -3452,12 +3630,13 @@ function registerSessionTool(host, config) {
3452
3630
  ...args,
3453
3631
  repo
3454
3632
  }, runMsgs);
3455
- const text = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
3633
+ const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
3634
+ const { text } = stdout;
3456
3635
  const stripped = text.trim();
3457
3636
  const empty = stripped === "null" || stripped === "[]";
3458
3637
  const capped = text.length > 12e3 ? `${echoRaw(text, 11900)}\n${runMsgs.sessionTruncated}` : redactKeyMaterial(text);
3459
3638
  return {
3460
- sessionOutput: empty ? runMsgs.sessionEmpty : capped,
3639
+ sessionOutput: (empty ? runMsgs.sessionEmpty : capped) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
3461
3640
  hint: runMsgs.sessionResumeHint
3462
3641
  };
3463
3642
  }
@@ -3497,4 +3676,4 @@ var host_default = {
3497
3676
  apply
3498
3677
  };
3499
3678
  //#endregion
3500
- export { configSchema as Config, createGateway, host_default as default, ocrRepo, parseJsonBody, registerConfigEndpoints, requiredKeys };
3679
+ export { configSchema as Config, createGateway, host_default as default, isLoopbackBindAddress, ocrRepo, parseJsonBody, registerConfigEndpoints, requiredKeys };