@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/CHANGELOG.md +68 -4
- package/LICENSE +1 -1
- package/README.md +14 -14
- package/README.zh-CN.md +13 -13
- package/THIRD_PARTY_NOTICES.md +36 -6
- package/client.js +101 -47
- package/docs/DETAIL.md +5 -5
- package/docs/DETAIL.zh.md +5 -5
- package/host.js +659 -313
- package/package.json +53 -42
- package/scripts/get-cred.mjs +124 -59
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 {
|
|
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
|
|
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 配置必须是 {
|
|
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
|
-
/**
|
|
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
|
|
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))
|
|
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)
|
|
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)
|
|
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))
|
|
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
|
-
|
|
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
|
|
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(`${
|
|
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
|
-
/**
|
|
663
|
-
*
|
|
664
|
-
function
|
|
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
|
|
668
|
-
|
|
669
|
-
|
|
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")
|
|
673
|
-
else if (type === "array")
|
|
674
|
-
else if (type !== void 0 && scalarMismatch(type, value))
|
|
675
|
-
if (schema.const !== void 0 && value !== schema.const)
|
|
676
|
-
if (schema.enum !== void 0 && !schema.enum.includes(value))
|
|
677
|
-
|
|
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
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/** --
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
|
|
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, ...
|
|
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)
|
|
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,
|
|
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",
|
|
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 过滤:大小写归一 +
|
|
1651
|
+
/** severity 过滤:大小写归一 + 白名单 + 去重;未知值报错而不是交给 CLI 返回空集。 */
|
|
1547
1652
|
function severityFilter(value, messages) {
|
|
1548
|
-
|
|
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",
|
|
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
|
-
/** `
|
|
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
|
-
/**
|
|
1867
|
-
*
|
|
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
|
-
|
|
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
|
|
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),
|
|
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
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
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
|
-
/**
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
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
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
]
|
|
2171
|
-
|
|
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
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
const reviewable = toDelegateFiles(
|
|
2245
|
-
const excluded = toExcludedFiles(
|
|
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
|
-
|
|
2414
|
+
const groupsValue = root["groups"];
|
|
2415
|
+
if (!Array.isArray(groupsValue)) return {
|
|
2278
2416
|
ok: false,
|
|
2279
2417
|
groups: []
|
|
2280
2418
|
};
|
|
2281
|
-
const groupsRaw =
|
|
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
|
-
|
|
2695
|
-
"status",
|
|
2696
|
-
"insertions",
|
|
2697
|
-
"deletions",
|
|
2847
|
+
...PREVIEW_FILE_REQUIRED,
|
|
2698
2848
|
"willReview",
|
|
2699
2849
|
"excludeReason"
|
|
2700
2850
|
],
|
|
2701
2851
|
properties: {
|
|
2702
|
-
|
|
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
|
-
|
|
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
|
|
2953
|
-
|
|
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
|
|
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 || !
|
|
3029
|
-
const
|
|
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
|
|
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 (!
|
|
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,
|
|
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 (!
|
|
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
|
-
|
|
3160
|
-
if (ref === null || ref === "") return {
|
|
3161
|
-
...providerCfg,
|
|
3162
|
-
hasKey: false
|
|
3163
|
-
};
|
|
3164
|
-
let hasKey;
|
|
3318
|
+
let hasKey = false;
|
|
3165
3319
|
try {
|
|
3166
|
-
|
|
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
|
-
/** 尾部反斜杠 / 翻倍反斜杠 /
|
|
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
|
|
3265
|
-
*
|
|
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 (!
|
|
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 || !
|
|
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(
|
|
3453
|
-
|
|
3454
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3666
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
3727
|
-
* apiKeyEnv
|
|
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,
|
|
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
|
-
*
|
|
3797
|
-
*
|
|
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
|
-
/**
|
|
3847
|
-
|
|
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
|
|
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
|
-
|
|
4142
|
+
headers.push([header, raw]);
|
|
3898
4143
|
}
|
|
3899
4144
|
return {
|
|
3900
4145
|
ok: true,
|
|
3901
|
-
value:
|
|
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 (!
|
|
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
|
-
/**
|
|
3953
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
4252
|
+
entries.push([name, parsed.value]);
|
|
3983
4253
|
}
|
|
3984
4254
|
return {
|
|
3985
4255
|
ok: true,
|
|
3986
|
-
value:
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
4039
|
-
|
|
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
|
|
4054
|
-
if (!
|
|
4346
|
+
const shaped = normalizeServers(raw, messages);
|
|
4347
|
+
if (!shaped.ok) return shaped;
|
|
4055
4348
|
try {
|
|
4056
|
-
return {
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
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.
|
|
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
|
|
4086
|
-
const
|
|
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`
|
|
4323
|
-
* dsh-sandbox 的
|
|
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
|
-
|
|
4473
|
-
|
|
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.
|
|
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
|
-
*
|
|
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,
|
|
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 (
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
5494
|
+
status: OCR_REFUSE_STATUS.internal
|
|
5180
5495
|
};
|
|
5181
5496
|
}
|
|
5182
5497
|
}
|
|
5183
|
-
|
|
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:
|
|
5508
|
+
status: OCR_REFUSE_STATUS.invalid
|
|
5192
5509
|
};
|
|
5193
|
-
|
|
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:
|
|
5200
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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);
|