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