@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/README.md +11 -10
- package/README.zh-CN.md +10 -9
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/client.js +22 -17
- package/host.js +313 -134
- package/package.json +63 -45
- package/scripts/get-cred.mjs +65 -16
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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) =>
|
|
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
|
|
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 =
|
|
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:
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
const
|
|
412
|
-
/**
|
|
413
|
-
|
|
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
|
|
567
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
702
|
-
|
|
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
|
-
|
|
987
|
-
|
|
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
|
|
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 =
|
|
1172
|
-
|
|
1173
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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))
|
|
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),
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3054
|
+
credentialSource: async (envKey) => {
|
|
2892
3055
|
const svc = credentialsService();
|
|
2893
|
-
|
|
2894
|
-
try {
|
|
2895
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 };
|