@jayyuen66/dsh-ocr-review 0.1.4-rc → 0.1.4-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/client.js +21 -16
- package/host.js +142 -91
- package/package.json +1 -1
- package/scripts/get-cred.mjs +59 -14
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
|
|
|
31
31
|
- The packages are on the public npm registry, so installation needs no credentials.
|
|
32
32
|
- The published files are only `host.js`, `client.js`, `cordis.patch.yml` and `scripts` (`prepack` rebuilds both bundles); source repository: see `repository.url` in package.json.
|
|
33
33
|
- No `@deepseek-ai/*` goes in `dependencies`, and that is not a style preference. Consumers of this package are dsh plugins installed into a profile (`nodeLinker: hoisted`, `autoInstallPeers: false`), so any host package listed under `dependencies` is written into that profile as a **real directory**, coexisting with the copy the host process already loaded. `@deepseek-ai/dsh-tools` keys its `TOOL_RUNTIME_SCHEDULER` with a plain `Symbol` (not `Symbol.for`), so symbol identity is per module instance: the host's `dsh-agent-loop` reads a `ToolRuntime` mounted from the duplicate using its own copy's symbol, gets `undefined`, and **every native tool call throws** `Cannot read properties of undefined (reading 'prepare')` — the whole profile loses its tools. This package's build, typecheck and tests all stay green right up to that point, so `test/publish-manifest.test.ts` pins the invariant in the manifest instead.
|
|
34
|
-
- The four host packages this bundle imports as **values** are required (non-optional) `peerDependencies`: `@deepseek-ai/schemastery` (the host's fork of schemastery — only it implements the 0.1.7 `.volatile()` resolution), `@deepseek-ai/dsh-credentials`, `@deepseek-ai/dsh-home-paths` and `@deepseek-ai/dsh-atomic-write`. The host provides them at runtime, and a profile installs no peers, so no duplicate appears. `build-host.mjs` externalizes `dependencies` *and* `
|
|
34
|
+
- The four host packages this bundle imports as **values** are required (non-optional) `peerDependencies`: `@deepseek-ai/schemastery` (the host's fork of schemastery — only it implements the 0.1.7 `.volatile()` resolution), `@deepseek-ai/dsh-credentials`, `@deepseek-ai/dsh-home-paths` and `@deepseek-ai/dsh-atomic-write`. The host provides them at runtime, and a profile installs no peers, so no duplicate appears. `build-host.mjs` externalizes `dependencies`, `peerDependencies` *and* `optionalDependencies`, so `host.js` keeps the bare specifiers unchanged.
|
|
35
35
|
- Those four are repeated in `devDependencies` — that is what lets this repository's own typecheck, build and tests resolve them. Every other `@deepseek-ai/*` is type-only (erased at build, no specifier left in `host.js`) and stays in `devDependencies` alone.
|
|
36
36
|
- What is left in `dependencies` is genuinely this package's own runtime code: `@jayyuen66/dsh-plugin-shared` and `js-yaml` (the last one used only by `scripts/get-cred.mjs`).
|
|
37
37
|
|
package/README.zh-CN.md
CHANGED
|
@@ -57,7 +57,7 @@ allowBuilds:
|
|
|
57
57
|
|
|
58
58
|
如果你更希望完全不执行安装期脚本,可以写 `'@alibaba-group/open-code-review': false`:`ocr` 同样可用(launcher 直接从平台包目录取二进制),但宿主 `readPendingBuilds()` 只认值为 `set this to true or false` 的条目,改成 `false` 后官方审批流程就不再能代为授权,只能手工维护。
|
|
59
59
|
- `dependencies` 里不放任何 `@deepseek-ai/*`,这不是风格偏好。本包的消费方是 dsh 插件,被装进 profile(`nodeLinker: hoisted`、`autoInstallPeers: false`),只要宿主包出现在 `dependencies`,pnpm 就会把它在那颗 profile 里落成**真实目录**,与宿主进程已经加载的那份并存。而 `@deepseek-ai/dsh-tools` 的 `TOOL_RUNTIME_SCHEDULER` 是普通 `Symbol`(不是 `Symbol.for`),符号身份按模块实例计算——宿主 `dsh-agent-loop` 拿自己那份的 Symbol 去读由副本挂载出来的 `ToolRuntime`,得到 `undefined`,于是**每一次原生工具调用都抛** `Cannot read properties of undefined (reading 'prepare')`,整个 profile 的工具一起失效。本包的构建、类型检查与测试在这之前全部照绿,所以判据只能由 `test/publish-manifest.test.ts` 落在 manifest 面。
|
|
60
|
-
- 产物里以**值**导入的四枚宿主包是非 optional 的 `peerDependencies`:`@deepseek-ai/schemastery`(宿主 fork 的 schemastery,0.1.7 的 `.volatile()` 解析只在它有实现)、`@deepseek-ai/dsh-credentials`、`@deepseek-ai/dsh-home-paths`、`@deepseek-ai/dsh-atomic-write`。运行期由宿主在场提供,而 profile 不装 peer,所以不产生副本;`build-host.mjs` 对 `dependencies` 与 `
|
|
60
|
+
- 产物里以**值**导入的四枚宿主包是非 optional 的 `peerDependencies`:`@deepseek-ai/schemastery`(宿主 fork 的 schemastery,0.1.7 的 `.volatile()` 解析只在它有实现)、`@deepseek-ai/dsh-credentials`、`@deepseek-ai/dsh-home-paths`、`@deepseek-ai/dsh-atomic-write`。运行期由宿主在场提供,而 profile 不装 peer,所以不产生副本;`build-host.mjs` 对 `dependencies`、`peerDependencies` 与 `optionalDependencies` 三张表一律外部化,`host.js` 里的裸说明符因此一字不变。
|
|
61
61
|
- 这四枚同时另列一份进 `devDependencies`——那才是本包自己的 typecheck、build、test 解析得到它们的方式。其余 `@deepseek-ai/*` 全是 type-only(构建即擦除,`host.js` 里不留说明符),只留在 `devDependencies`。
|
|
62
62
|
- `dependencies` 剩下的确实是本包自己的运行期代码:`@jayyuen66/dsh-plugin-shared` 与 `js-yaml`(后者只被 `scripts/get-cred.mjs` 用)。
|
|
63
63
|
|
package/client.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// GENERATED by buildClient() in build-client.mjs — edit src/client-entry.ts, then run `node build-client.mjs`.
|
|
2
2
|
window.__ModuleLoader__.load({
|
|
3
|
-
id:
|
|
3
|
+
id: "@jayyuen66/dsh-ocr-review",
|
|
4
4
|
factory: (require) => {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
'use strict';
|
|
6
|
+
const module = { exports: {} };
|
|
7
|
+
let exports = module.exports;
|
|
7
8
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
8
9
|
let react = require("react");
|
|
9
10
|
//#region src/ui-messages.ts
|
|
10
11
|
const UI_MESSAGES = {
|
|
11
12
|
zh: {
|
|
12
13
|
cardTitle: "open-code-review 代码审查",
|
|
13
|
-
cardDescription: "ocr 工具配置:选择 provider/model 应用到 OCR
|
|
14
|
+
cardDescription: "ocr 工具配置:选择 provider/model 应用到 OCR(应用后 key 由 api_key_cmd 动态读取,不再写入明文)+ 审查参数设置",
|
|
14
15
|
statusReadOnly: "当前作用域只读",
|
|
15
16
|
statusDirty: "有未保存的修改,点「保存」生效",
|
|
16
17
|
statusClean: "无未保存的修改",
|
|
@@ -18,6 +19,7 @@ const UI_MESSAGES = {
|
|
|
18
19
|
saving: "保存中…",
|
|
19
20
|
revert: "撤销",
|
|
20
21
|
saveFailed: "保存失败:",
|
|
22
|
+
saveRefused: "未写入(宿主拒收或跳过):{fields}",
|
|
21
23
|
loadProvidersFailed: "加载 providers 失败:{error}(端点未就绪?重启 dsh 后重试)",
|
|
22
24
|
loadingProviders: "加载 providers…",
|
|
23
25
|
noKeySuffix: "(未配置 key)",
|
|
@@ -68,7 +70,7 @@ const UI_MESSAGES = {
|
|
|
68
70
|
},
|
|
69
71
|
en: {
|
|
70
72
|
cardTitle: "open-code-review code review",
|
|
71
|
-
cardDescription: "ocr tool setup: pick a provider/model and apply it to OCR (keys are read dynamically via api_key_cmd
|
|
73
|
+
cardDescription: "ocr tool setup: pick a provider/model and apply it to OCR (once applied, keys are read dynamically via api_key_cmd and no longer written in plain text) + review parameters",
|
|
72
74
|
statusReadOnly: "This scope is read-only",
|
|
73
75
|
statusDirty: "Unsaved changes — press Save to apply",
|
|
74
76
|
statusClean: "No unsaved changes",
|
|
@@ -76,6 +78,7 @@ const UI_MESSAGES = {
|
|
|
76
78
|
saving: "Saving…",
|
|
77
79
|
revert: "Revert",
|
|
78
80
|
saveFailed: "save failed: ",
|
|
81
|
+
saveRefused: "not written (host refused or skipped): {fields}",
|
|
79
82
|
loadProvidersFailed: "failed to load providers: {error} (endpoint not ready? restart dsh and retry)",
|
|
80
83
|
loadingProviders: "loading providers…",
|
|
81
84
|
noKeySuffix: " (no key configured)",
|
|
@@ -359,7 +362,7 @@ function OcrPluginCard(props) {
|
|
|
359
362
|
}
|
|
360
363
|
}, headRow, chevron);
|
|
361
364
|
const body = open ? (0, react.createElement)("div", { className: "ocr-card-body" }, children) : null;
|
|
362
|
-
return (0, react.createElement)("
|
|
365
|
+
return (0, react.createElement)("div", { className: `ocr-card${open ? " ocr-card-open" : ""}` }, header, body);
|
|
363
366
|
}
|
|
364
367
|
/** 整数数字输入行(保存条模式:合法输入上报草稿,不直写)。 */
|
|
365
368
|
function NumberRow(props) {
|
|
@@ -827,7 +830,10 @@ async function runTestConnection(deps) {
|
|
|
827
830
|
deps.setBusy(false);
|
|
828
831
|
}
|
|
829
832
|
}
|
|
830
|
-
/** 只写差异字段:touched 值为 undefined → 写 null(= 清回 schema 默认)。
|
|
833
|
+
/** 只写差异字段:touched 值为 undefined → 写 null(= 清回 schema 默认)。
|
|
834
|
+
* 受理位必须回来:`set` 回 `false` 表示宿主拒收或跳过了这次写入(值没落盘),
|
|
835
|
+
* 这时不能清 touched、也不能报成功——否则用户看到「已保存」而配置原封未动。
|
|
836
|
+
* 传输失败仍以 rejection 表达。 */
|
|
831
837
|
async function saveSettingsDraft(deps) {
|
|
832
838
|
const { t, value, touched } = deps;
|
|
833
839
|
const keys = diffTouched(touched, value);
|
|
@@ -836,11 +842,16 @@ async function saveSettingsDraft(deps) {
|
|
|
836
842
|
deps.setSaveError(null);
|
|
837
843
|
const ops = keys.map(async (field) => {
|
|
838
844
|
const draft = touched[field];
|
|
839
|
-
|
|
845
|
+
return deps.set(field, draft === void 0 ? null : draft);
|
|
840
846
|
});
|
|
841
847
|
try {
|
|
842
|
-
await Promise.all(ops);
|
|
848
|
+
const settled = await Promise.all(ops);
|
|
843
849
|
deps.setSettingsBusy(false);
|
|
850
|
+
const refused = keys.filter((_field, index) => settled[index] !== true);
|
|
851
|
+
if (refused.length > 0) {
|
|
852
|
+
deps.setSaveError(t("saveRefused", { fields: refused.join(", ") }));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
844
855
|
deps.setTouched({});
|
|
845
856
|
} catch (error) {
|
|
846
857
|
deps.setSettingsBusy(false);
|
|
@@ -1012,13 +1023,7 @@ function apply(ctx) {
|
|
|
1012
1023
|
inject: () => ({
|
|
1013
1024
|
t,
|
|
1014
1025
|
hooks: { card: store },
|
|
1015
|
-
set:
|
|
1016
|
-
try {
|
|
1017
|
-
await scope.set(field, value);
|
|
1018
|
-
} catch (error) {
|
|
1019
|
-
console.error(`[ocr-review-card] set ${field} failed:`, error);
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1026
|
+
set: (field, value) => scope.set(field, value)
|
|
1022
1027
|
})
|
|
1023
1028
|
}, OcrCard);
|
|
1024
1029
|
});
|
package/host.js
CHANGED
|
@@ -341,14 +341,15 @@ function scalarMismatch(type, value) {
|
|
|
341
341
|
function objectViolations(schema, value, path, walk) {
|
|
342
342
|
if (!isRecord(value)) return [`${path}: expected object`];
|
|
343
343
|
const { properties, required, additionalProperties } = schema;
|
|
344
|
+
const declared = (key) => properties !== void 0 && Object.hasOwn(properties, key) ? properties[key] : void 0;
|
|
344
345
|
if (additionalProperties === false) {
|
|
345
|
-
const extra = Object.keys(value).filter((key) =>
|
|
346
|
+
const extra = Object.keys(value).filter((key) => declared(key) === void 0);
|
|
346
347
|
if (extra.length > 0) return [`${path}: fields outside the schema: ${extra.join(", ")}`];
|
|
347
348
|
}
|
|
348
|
-
for (const key of required ?? []) if (!(key
|
|
349
|
+
for (const key of required ?? []) if (!Object.hasOwn(value, key) || value[key] === void 0) return [`${path}: missing required field ${key}`];
|
|
349
350
|
const violations = [];
|
|
350
351
|
for (const key of Object.keys(value)) {
|
|
351
|
-
const child =
|
|
352
|
+
const child = declared(key);
|
|
352
353
|
if (child !== void 0) violations.push(...walk(child, value[key], `${path}.${key}`));
|
|
353
354
|
}
|
|
354
355
|
return violations;
|
|
@@ -360,42 +361,6 @@ function arrayViolations(schema, value, path, walk) {
|
|
|
360
361
|
if (items === void 0) return [];
|
|
361
362
|
return value.flatMap((item, index) => walk(items, item, `${path}[${index}]`));
|
|
362
363
|
}
|
|
363
|
-
/** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
|
|
364
|
-
* 恰好一支命中,多支/零支都算违规。 */
|
|
365
|
-
function violationsOf(schema, value, path) {
|
|
366
|
-
if (schema.oneOf !== void 0) {
|
|
367
|
-
const passing = schema.oneOf.filter((branch) => violationsOf(branch, value, path).length === 0);
|
|
368
|
-
if (passing.length !== 1) return [`${path}: matched ${passing.length} of ${schema.oneOf.length} oneOf branches`];
|
|
369
|
-
return [];
|
|
370
|
-
}
|
|
371
|
-
const { type } = schema;
|
|
372
|
-
if (type === "object") return objectViolations(schema, value, path, violationsOf);
|
|
373
|
-
if (type === "array") return arrayViolations(schema, value, path, violationsOf);
|
|
374
|
-
if (type === void 0) return [];
|
|
375
|
-
if (scalarMismatch(type, value)) return [`${path}: expected ${type}`];
|
|
376
|
-
if (schema.const !== void 0 && value !== schema.const) return [`${path}: must be exactly ${JSON.stringify(schema.const)}`];
|
|
377
|
-
if (schema.enum !== void 0 && !schema.enum.includes(value)) return [`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`];
|
|
378
|
-
return [];
|
|
379
|
-
}
|
|
380
|
-
/**
|
|
381
|
-
* 校验并放行工具返回值:必须命中该工具自己的 output schema。
|
|
382
|
-
* 违规抛错而非静默裁剪——静默修正是第二个 bug 的开始,抛错让回归在开发期
|
|
383
|
-
* (vitest/首次调用)就炸出来。
|
|
384
|
-
* @param schema 该工具注册时声明的 output.schema(与宿主校验用的是同一个对象)
|
|
385
|
-
* @param value execute 交回的 canonical 值(结构化对象 / 数组 / 标量 / null)
|
|
386
|
-
* @param toolName 工具名(违规信息要点名,模型才知道该重试哪一次调用)
|
|
387
|
-
* @param messages 双语文案
|
|
388
|
-
* @returns 原值(校验只读不改,值原样交给宿主)
|
|
389
|
-
*/
|
|
390
|
-
function assertToolOutput(schema, value, toolName, messages) {
|
|
391
|
-
assertGuardable(schema, toolName, messages);
|
|
392
|
-
const violations = violationsOf(schema, value, "result");
|
|
393
|
-
if (violations.length > 0) throw new Error(format(messages.resultSchemaViolation, {
|
|
394
|
-
tool: toolName,
|
|
395
|
-
violations: violations.join("; ")
|
|
396
|
-
}));
|
|
397
|
-
return value;
|
|
398
|
-
}
|
|
399
364
|
/** provider key 形状:`sk-…`/`ark-…`/`ghp-…` 一类「短前缀 + 长随机串」。
|
|
400
365
|
*
|
|
401
366
|
* 为什么额外要求随机串里至少有一个数字或大写字母:真实 key 是随机 base62(32 位
|
|
@@ -403,14 +368,19 @@ function assertToolOutput(schema, value, toolName, messages) {
|
|
|
403
368
|
* (`src/my-super-long-directory-name/index.ts`)。少了这道约束,session/delegate
|
|
404
369
|
* 透传出的路径会被抹成 ***REDACTED***,模型拿不到可行动的位置信息。 */
|
|
405
370
|
const STANDALONE_KEY = /\b[A-Za-z]{2,5}-(?=[A-Za-z0-9_-]{20,}\b)(?=[A-Za-z0-9_-]*[0-9A-Z])[A-Za-z0-9_-]{20,}\b/gu;
|
|
406
|
-
/** `Authorization:
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
const
|
|
412
|
-
/**
|
|
413
|
-
|
|
371
|
+
/** `Authorization: <scheme> <value>` / `Proxy-Authorization: …`:认**头名**而不是
|
|
372
|
+
* 枚举认证方案。头名本身就是「后面跟着凭据」的信号,Basic / Token / Digest 乃至
|
|
373
|
+
* vendor 自定义 scheme 都不会漏;而只认头名还避开了正文里「token based
|
|
374
|
+
* authentication」这类措辞被误抹。scheme 组连同其后的空白一起捕获,回填时凭据被
|
|
375
|
+
* 替换而 scheme 词保留。 */
|
|
376
|
+
const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
|
|
377
|
+
/** 裸 scheme + 值(没有头名时 OCR 也会整行回显)。scheme 只留 bearer:它在散文中几乎
|
|
378
|
+
* 不出现,而 token / basic 之类会撞上「token count」「basic usage」等普通措辞。 */
|
|
379
|
+
const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
|
|
380
|
+
/** `api_key: "xxx"`、`apiKey=xxx`、`token: xxx`、`x-api-key: xxx` 等赋值形态。
|
|
381
|
+
* 值下限取 4 且容许一个 scheme 词:`token: Basic <base64>` 里赋值形态先能吃到的只是
|
|
382
|
+
* scheme 词(Basic 恰好 5 字符),下限更高时整条失配,后面那个 base64 就原样进了日志。 */
|
|
383
|
+
const KEY_ASSIGNMENT = /(?<name>\b(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
|
|
414
384
|
const REDACTION = "***REDACTED***";
|
|
415
385
|
/**
|
|
416
386
|
* 抹掉文本里 key 形状的片段。
|
|
@@ -425,7 +395,49 @@ const REDACTION = "***REDACTED***";
|
|
|
425
395
|
* 要求随机串特征,避免把要交给模型的文件路径一起抹掉。
|
|
426
396
|
*/
|
|
427
397
|
function redactKeyMaterial(text) {
|
|
428
|
-
return text.replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>${REDACTION}`).replaceAll(STANDALONE_KEY, REDACTION);
|
|
398
|
+
return text.replaceAll(AUTH_HEADER_VALUE, `$<name>$<scheme>${REDACTION}`).replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>$<scheme>${REDACTION}`).replaceAll(STANDALONE_KEY, REDACTION);
|
|
399
|
+
}
|
|
400
|
+
/** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
|
|
401
|
+
* 恰好一支命中,多支/零支都算违规。 */
|
|
402
|
+
function violationsOf(schema, value, path) {
|
|
403
|
+
const violations = [];
|
|
404
|
+
const { oneOf } = schema;
|
|
405
|
+
if (oneOf !== void 0) {
|
|
406
|
+
const passing = oneOf.filter((branch) => violationsOf(branch, value, path).length === 0);
|
|
407
|
+
if (passing.length !== 1) violations.push(`${path}: matched ${passing.length} of ${oneOf.length} oneOf branches`);
|
|
408
|
+
}
|
|
409
|
+
const { type } = schema;
|
|
410
|
+
if (type === "object") violations.push(...objectViolations(schema, value, path, violationsOf));
|
|
411
|
+
else if (type === "array") violations.push(...arrayViolations(schema, value, path, violationsOf));
|
|
412
|
+
else if (type !== void 0 && scalarMismatch(type, value)) violations.push(`${path}: expected ${type}`);
|
|
413
|
+
if (schema.const !== void 0 && value !== schema.const) violations.push(`${path}: must be exactly ${JSON.stringify(schema.const)}`);
|
|
414
|
+
if (schema.enum !== void 0 && !schema.enum.includes(value)) violations.push(`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`);
|
|
415
|
+
return violations;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* 校验并放行工具返回值:必须命中该工具自己的 output schema。
|
|
419
|
+
* 违规抛错而非静默裁剪——静默修正是第二个 bug 的开始,抛错让回归在开发期
|
|
420
|
+
* (vitest/首次调用)就炸出来。
|
|
421
|
+
* @param schema 该工具注册时声明的 output.schema(与宿主校验用的是同一个对象)
|
|
422
|
+
* @param value execute 交回的 canonical 值(结构化对象 / 数组 / 标量 / null)
|
|
423
|
+
* @param toolName 工具名(违规信息要点名,模型才知道该重试哪一次调用)
|
|
424
|
+
* @param messages 双语文案
|
|
425
|
+
* @returns 原值(校验只读不改,值原样交给宿主)
|
|
426
|
+
*/
|
|
427
|
+
function assertToolOutput(schema, value, toolName, messages) {
|
|
428
|
+
assertGuardable(schema, toolName, messages);
|
|
429
|
+
const violations = violationsOf(schema, value, "result");
|
|
430
|
+
if (violations.length > 0) {
|
|
431
|
+
const MAX_LISTED = 20;
|
|
432
|
+
const MAX_CHARS = 2e3;
|
|
433
|
+
const omitted = violations.length - MAX_LISTED;
|
|
434
|
+
const listed = violations.slice(0, MAX_LISTED).join("; ") + (omitted > 0 ? ` …(+${omitted})` : "");
|
|
435
|
+
throw new Error(format(messages.resultSchemaViolation, {
|
|
436
|
+
tool: toolName,
|
|
437
|
+
violations: redactKeyMaterial(listed).slice(0, MAX_CHARS)
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
return value;
|
|
429
441
|
}
|
|
430
442
|
//#endregion
|
|
431
443
|
//#region lib/argv-guard.ts
|
|
@@ -563,19 +575,23 @@ function idValue(value, name, messages) {
|
|
|
563
575
|
* 负数/非整数由 OCR 侧报错,但桥接层先做同源校验给出更早的错误)。
|
|
564
576
|
* value 为 undefined/null 时省略 flag(跟随 OCR flag 默认值)。
|
|
565
577
|
*
|
|
566
|
-
* 注入面:数值经 `String(num)` 落进命令串(不走 shq
|
|
567
|
-
*
|
|
568
|
-
* 命令串里的数值 token 结构上不可能带 shell 元字符。
|
|
578
|
+
* 注入面:数值经 `String(num)` 落进命令串(不走 shq)。数值 token 结构上不可能带
|
|
579
|
+
* shell 元字符,「只含数字」的形态校验顺带把 1e21 写成 `1e+21` 这类形态也挡住。
|
|
569
580
|
*/
|
|
570
581
|
function pushIntFlag(parts, flag, value, min, messages) {
|
|
571
582
|
if (value === void 0 || value === null) return;
|
|
572
|
-
const
|
|
583
|
+
const reject = () => {
|
|
584
|
+
throw new Error(format(messages.intFlagMustBeInt, {
|
|
585
|
+
flag,
|
|
586
|
+
min: String(min),
|
|
587
|
+
received: describeValue(value)
|
|
588
|
+
}));
|
|
589
|
+
};
|
|
590
|
+
const decimalString = typeof value === "string" && /^\d+$/u.test(value.trim());
|
|
591
|
+
if (typeof value !== "number" && !decimalString) reject();
|
|
592
|
+
const num = Number(value);
|
|
573
593
|
const text = String(num);
|
|
574
|
-
if (!Number.
|
|
575
|
-
flag,
|
|
576
|
-
min: String(min),
|
|
577
|
-
received: describeValue(value)
|
|
578
|
-
}));
|
|
594
|
+
if (!Number.isSafeInteger(num) || num < min || !/^\d+$/u.test(text)) reject();
|
|
579
595
|
parts.push(flag, text);
|
|
580
596
|
}
|
|
581
597
|
/** 模式参数解析:workspace(默认)/commit/branch 互斥,非法组合与错类型抛错。
|
|
@@ -983,14 +999,15 @@ function str(value) {
|
|
|
983
999
|
* 故作兜底值。
|
|
984
1000
|
*/
|
|
985
1001
|
function nonNegativeInt(value) {
|
|
986
|
-
|
|
987
|
-
|
|
1002
|
+
const truncated = Math.trunc(value);
|
|
1003
|
+
if (!Number.isSafeInteger(truncated) || truncated < 0) return 0;
|
|
1004
|
+
return truncated;
|
|
988
1005
|
}
|
|
989
1006
|
function num(value) {
|
|
990
1007
|
if (typeof value === "number") return nonNegativeInt(value);
|
|
991
1008
|
if (typeof value === "string") {
|
|
992
1009
|
const trimmed = value.trim();
|
|
993
|
-
if (trimmed
|
|
1010
|
+
if (!/^\d+$/u.test(trimmed)) return 0;
|
|
994
1011
|
return nonNegativeInt(Number(trimmed));
|
|
995
1012
|
}
|
|
996
1013
|
return 0;
|
|
@@ -1153,6 +1170,42 @@ function delegatePreviewFailure() {
|
|
|
1153
1170
|
mergeBase: ""
|
|
1154
1171
|
};
|
|
1155
1172
|
}
|
|
1173
|
+
/**
|
|
1174
|
+
* preview 的条目数组 → 结构化清单;返回 null 表示「上游给了条目却一条都没解析出来」。
|
|
1175
|
+
*
|
|
1176
|
+
* 判据分两层:部分条目解析不出来照旧丢弃(OCR 偶发脏条目不该毁掉整份清单),
|
|
1177
|
+
* 但一条不剩时不能继续以 ok:true 的形态报「0 个文件可审」——那不是「没有可审的
|
|
1178
|
+
* 文件」,是半截产出,模型与用户都会拿它当范围结论。
|
|
1179
|
+
*/
|
|
1180
|
+
function toDelegateFiles(raw) {
|
|
1181
|
+
if (!Array.isArray(raw)) return [];
|
|
1182
|
+
const files = raw.map((entry) => {
|
|
1183
|
+
if (!isRecord(entry)) return null;
|
|
1184
|
+
const entryPath = str(entry["path"]);
|
|
1185
|
+
if (entryPath === "") return null;
|
|
1186
|
+
return {
|
|
1187
|
+
path: entryPath,
|
|
1188
|
+
status: str(entry["status"]),
|
|
1189
|
+
insertions: num(entry["insertions"]),
|
|
1190
|
+
deletions: num(entry["deletions"])
|
|
1191
|
+
};
|
|
1192
|
+
}).filter((fileEntry) => fileEntry !== null);
|
|
1193
|
+
return raw.length > 0 && files.length === 0 ? null : files;
|
|
1194
|
+
}
|
|
1195
|
+
/** excluded_files 同款门:条目形状与 reviewable 一致,缺 path 即不可用。 */
|
|
1196
|
+
function toExcludedFiles(raw) {
|
|
1197
|
+
if (!Array.isArray(raw)) return [];
|
|
1198
|
+
const files = raw.map((entry) => {
|
|
1199
|
+
if (!isRecord(entry)) return null;
|
|
1200
|
+
const entryPath = str(entry["path"]);
|
|
1201
|
+
if (entryPath === "") return null;
|
|
1202
|
+
return {
|
|
1203
|
+
path: entryPath,
|
|
1204
|
+
reason: str(entry["reason"])
|
|
1205
|
+
};
|
|
1206
|
+
}).filter((fileEntry) => fileEntry !== null);
|
|
1207
|
+
return raw.length > 0 && files.length === 0 ? null : files;
|
|
1208
|
+
}
|
|
1156
1209
|
/** 解析 `ocr delegate preview --format json` 输出。 */
|
|
1157
1210
|
function parseDelegatePreview(text) {
|
|
1158
1211
|
let doc;
|
|
@@ -1168,22 +1221,9 @@ function parseDelegatePreview(text) {
|
|
|
1168
1221
|
const value = root[key];
|
|
1169
1222
|
if (value !== void 0 && !Array.isArray(value)) return delegatePreviewFailure();
|
|
1170
1223
|
}
|
|
1171
|
-
const reviewable =
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
path: str(fileRaw["path"]),
|
|
1175
|
-
status: str(fileRaw["status"]),
|
|
1176
|
-
insertions: num(fileRaw["insertions"]),
|
|
1177
|
-
deletions: num(fileRaw["deletions"])
|
|
1178
|
-
};
|
|
1179
|
-
}).filter((fileEntry) => fileEntry !== null && fileEntry.path.length > 0) : [];
|
|
1180
|
-
const excluded = Array.isArray(root["excluded_files"]) ? root["excluded_files"].map((fileRaw) => {
|
|
1181
|
-
if (!isRecord(fileRaw)) return null;
|
|
1182
|
-
return {
|
|
1183
|
-
path: str(fileRaw["path"]),
|
|
1184
|
-
reason: str(fileRaw["reason"])
|
|
1185
|
-
};
|
|
1186
|
-
}).filter((fileEntry) => fileEntry !== null && fileEntry.path.length > 0) : [];
|
|
1224
|
+
const reviewable = toDelegateFiles(root["reviewable_files"]);
|
|
1225
|
+
const excluded = toExcludedFiles(root["excluded_files"]);
|
|
1226
|
+
if (reviewable === null || excluded === null) return delegatePreviewFailure();
|
|
1187
1227
|
return {
|
|
1188
1228
|
ok: true,
|
|
1189
1229
|
mode: str(root["mode"]),
|
|
@@ -1223,15 +1263,20 @@ function parseDelegateRules(text) {
|
|
|
1223
1263
|
ok: false,
|
|
1224
1264
|
groups: []
|
|
1225
1265
|
};
|
|
1266
|
+
const groups = groupsRaw.filter((groupRaw) => isRecord(groupRaw) && Array.isArray(groupRaw["files"])).map((groupRaw) => ({
|
|
1267
|
+
groupId: num(groupRaw["group_id"]),
|
|
1268
|
+
source: str(groupRaw["source"]),
|
|
1269
|
+
pattern: str(groupRaw["pattern"]),
|
|
1270
|
+
files: groupRaw.files.filter((file) => typeof file === "string" && file.trim() !== "").map((file) => file.trim()),
|
|
1271
|
+
rule: str(groupRaw["rule"])
|
|
1272
|
+
})).filter((group) => group.files.length > 0);
|
|
1273
|
+
if (groupsRaw.length > 0 && groups.length === 0) return {
|
|
1274
|
+
ok: false,
|
|
1275
|
+
groups: []
|
|
1276
|
+
};
|
|
1226
1277
|
return {
|
|
1227
1278
|
ok: true,
|
|
1228
|
-
groups
|
|
1229
|
-
groupId: num(groupRaw["group_id"]),
|
|
1230
|
-
source: str(groupRaw["source"]),
|
|
1231
|
-
pattern: str(groupRaw["pattern"]),
|
|
1232
|
-
files: groupRaw.files.map((file) => str(file)).filter(Boolean),
|
|
1233
|
-
rule: str(groupRaw["rule"])
|
|
1234
|
-
})).filter((group) => group.files.length > 0)
|
|
1279
|
+
groups
|
|
1235
1280
|
};
|
|
1236
1281
|
}
|
|
1237
1282
|
//#endregion
|
|
@@ -1795,6 +1840,7 @@ async function providersForCard(gateway, paths, messages) {
|
|
|
1795
1840
|
*/
|
|
1796
1841
|
async function applyProviderSelection(gateway, paths, selection, messages) {
|
|
1797
1842
|
const { provider, model } = selection;
|
|
1843
|
+
if (typeof provider !== "string" || typeof model !== "string" || provider.trim() === "" || model.trim() === "") throw new Error(format(messages.providerModelRequired, {}));
|
|
1798
1844
|
const { providers, source } = readProviders(gateway, messages);
|
|
1799
1845
|
if (source.status !== "ready") throw new Error(source.message);
|
|
1800
1846
|
const target = providers.find((providerCfg) => providerCfg.name === provider);
|
|
@@ -1834,11 +1880,11 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
|
|
|
1834
1880
|
* api_key_cmd 并删除明文,返回 'migrated';找不到 envKey 或构造命令失败 →
|
|
1835
1881
|
* 'unknown';无明文 → 'noPlain'(不动作)。
|
|
1836
1882
|
*/
|
|
1837
|
-
function migrateEntry(entry, envKey,
|
|
1883
|
+
function migrateEntry(entry, envKey, context) {
|
|
1838
1884
|
if (typeof entry["api_key"] !== "string" || entry["api_key"].length === 0) return "noPlain";
|
|
1839
|
-
if (envKey === void 0 || envKey === "") return "unknown";
|
|
1885
|
+
if (envKey === void 0 || envKey === "" || !context.resolvableEnvKeys.has(envKey)) return "unknown";
|
|
1840
1886
|
try {
|
|
1841
|
-
entry["api_key_cmd"] = buildApiKeyCmd(scriptPath, envKey, dshHome, messages);
|
|
1887
|
+
entry["api_key_cmd"] = buildApiKeyCmd(context.scriptPath, envKey, context.dshHome, context.messages);
|
|
1842
1888
|
} catch {
|
|
1843
1889
|
return "unknown";
|
|
1844
1890
|
}
|
|
@@ -1865,13 +1911,18 @@ async function migratePlaintextKeys(gateway, paths, messages) {
|
|
|
1865
1911
|
if (source.status !== "ready" && source.status !== "credentials-unavailable") throw new Error(source.message);
|
|
1866
1912
|
parseExistingConfig(precheck, paths.ocrConfigJson, messages);
|
|
1867
1913
|
const scriptPath = credentialScriptPath(paths);
|
|
1868
|
-
if (!existsSync(scriptPath))
|
|
1869
|
-
migrated: [],
|
|
1870
|
-
skippedUnknown: []
|
|
1871
|
-
};
|
|
1914
|
+
if (!existsSync(scriptPath)) throw new Error(format(messages.credScriptMissing, { path: scriptPath }));
|
|
1872
1915
|
const dshHome = resolveDshHome();
|
|
1873
1916
|
const envByProvider = /* @__PURE__ */ new Map();
|
|
1874
1917
|
for (const providerCfg of providers) if (providerCfg.apiKeyEnv !== null && providerCfg.apiKeyEnv !== "") envByProvider.set(providerCfg.name, providerCfg.apiKeyEnv);
|
|
1918
|
+
const candidateEnvKeys = [...new Set(envByProvider.values())];
|
|
1919
|
+
const resolvedFlags = await Promise.all(candidateEnvKeys.map((envKey) => gateway.credentialResolvable(envKey)));
|
|
1920
|
+
const migrateContext = {
|
|
1921
|
+
scriptPath,
|
|
1922
|
+
dshHome,
|
|
1923
|
+
resolvableEnvKeys: new Set(candidateEnvKeys.filter((_envKey, index) => resolvedFlags[index] === true)),
|
|
1924
|
+
messages
|
|
1925
|
+
};
|
|
1875
1926
|
return await updateConfigLocked(paths.ocrConfigJson, async () => {
|
|
1876
1927
|
const doc = parseExistingConfig(readText(paths.ocrConfigJson), paths.ocrConfigJson, messages);
|
|
1877
1928
|
const customsRaw = fieldOf(doc, "custom_providers");
|
|
@@ -1883,7 +1934,7 @@ async function migratePlaintextKeys(gateway, paths, messages) {
|
|
|
1883
1934
|
const migrated = [];
|
|
1884
1935
|
const skippedUnknown = [];
|
|
1885
1936
|
for (const [name, raw] of Object.entries(customs)) if (isRecord(raw)) {
|
|
1886
|
-
const result = migrateEntry(raw, envByProvider.get(name),
|
|
1937
|
+
const result = migrateEntry(raw, envByProvider.get(name), migrateContext);
|
|
1887
1938
|
if (result === "migrated") migrated.push(name);
|
|
1888
1939
|
else if (result === "unknown") skippedUnknown.push(name);
|
|
1889
1940
|
}
|
package/package.json
CHANGED
package/scripts/get-cred.mjs
CHANGED
|
@@ -66,16 +66,28 @@ function isRecord(value) {
|
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* 数据目录:显式参数 > $DSH_HOME > homedir()/.dsh(空/全空白按未设置处理)。
|
|
69
|
+
*
|
|
70
|
+
* 相对路径在这里是硬错误而不是照用:本脚本的 cwd 是 OCR 当时所在的那个仓库,
|
|
71
|
+
* 相对路径会被解析成 `<被审查仓库>/.dsh/…`——正是文件头声明要避免的「拿被审查
|
|
72
|
+
* 仓库里的文件当用户凭据」。猜不出用户指的是哪个目录,就不猜。
|
|
69
73
|
* @param {string | undefined} fromArg
|
|
70
74
|
* @returns {string}
|
|
71
75
|
*/
|
|
72
76
|
function dataDirFrom(fromArg) {
|
|
73
77
|
const arg = typeof fromArg === "string" ? fromArg.trim() : "";
|
|
78
|
+
const fromEnv = typeof process.env.DSH_HOME === "string" ? process.env.DSH_HOME.trim() : "";
|
|
79
|
+
let chosen = path.join(homedir(), ".dsh");
|
|
80
|
+
if (fromEnv !== "") {
|
|
81
|
+
chosen = fromEnv;
|
|
82
|
+
}
|
|
74
83
|
if (arg !== "") {
|
|
75
|
-
|
|
84
|
+
chosen = arg;
|
|
76
85
|
}
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
if (!path.isAbsolute(chosen)) {
|
|
87
|
+
process.stderr.write(`[get-cred] dsh 数据目录必须是绝对路径,收到 ${JSON.stringify(chosen)}\n`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
return chosen;
|
|
79
91
|
}
|
|
80
92
|
|
|
81
93
|
/** 取值过程中遇到的坏消息(文件损坏/权限);只在最终失败时一并报出,
|
|
@@ -113,17 +125,29 @@ function isNotFound(error) {
|
|
|
113
125
|
*/
|
|
114
126
|
function yamlErrorDetail(error) {
|
|
115
127
|
if (error instanceof Error) {
|
|
116
|
-
// js-yaml 的 YAMLException 覆盖了 toString(compact):传 true
|
|
117
|
-
//
|
|
128
|
+
// js-yaml 的 YAMLException 覆盖了 toString(compact):传 true 走紧凑分支,
|
|
129
|
+
// 只有这一条路不带 snippet。别的 Error 会忽略这个参数,返回普通的
|
|
130
|
+
// "Name: message",同样不含出错点邻域的原文。
|
|
118
131
|
const compact = error.toString(true);
|
|
119
132
|
if (typeof compact === "string" && compact !== "") {
|
|
120
133
|
return compact;
|
|
121
134
|
}
|
|
122
|
-
|
|
135
|
+
// 拿不到紧凑形态时**不能**退回 error.message:js-yaml 构造器里赋的就是
|
|
136
|
+
// this.message = formatError(this, false),那份 message 正是带 snippet 的
|
|
137
|
+
// 那一条。改用 reason + 行列定位自行拼装,两者都不含原文。
|
|
138
|
+
const reason = typeof error.reason === "string" ? error.reason : "";
|
|
139
|
+
const { mark } = error;
|
|
140
|
+
const line = isRecord(mark) ? mark.line : undefined;
|
|
141
|
+
const column = isRecord(mark) ? mark.column : undefined;
|
|
142
|
+
const located =
|
|
143
|
+
typeof line === "number" && typeof column === "number"
|
|
144
|
+
? ` (line:${line + 1}, column:${column + 1})`
|
|
145
|
+
: "";
|
|
146
|
+
return `${reason}${located}`;
|
|
123
147
|
}
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
return
|
|
148
|
+
// 到这里 error 不是 Error 实例:String(error) 会隐式走 Object.prototype.toString
|
|
149
|
+
// 产出 "[object Object]" 这种毫无信息量的文本,直接给空串。
|
|
150
|
+
return "";
|
|
127
151
|
}
|
|
128
152
|
|
|
129
153
|
function fromCredentialsDoc(credPath, refName) {
|
|
@@ -146,8 +170,21 @@ function fromCredentialsDoc(credPath, refName) {
|
|
|
146
170
|
}
|
|
147
171
|
}
|
|
148
172
|
const refs = isRecord(doc) ? doc.refs : undefined;
|
|
149
|
-
|
|
150
|
-
|
|
173
|
+
let value = isRecord(refs) ? refs[refName] : undefined;
|
|
174
|
+
if (value !== undefined && typeof value !== "string") {
|
|
175
|
+
// 只报形状,绝不把值本身写进 problems(明文不落日志)。refs.<REF> 被写成
|
|
176
|
+
// 数字/布尔/映射时若静默当「没有值」,用户改了托管文档却拿到下一层的旧值,
|
|
177
|
+
// 全程零提示。
|
|
178
|
+
problems.push(
|
|
179
|
+
`凭证文件 refs.${refName} 不是字符串(是 ${Array.isArray(value) ? "数组" : typeof value}):${credPath}`,
|
|
180
|
+
);
|
|
181
|
+
value = undefined;
|
|
182
|
+
}
|
|
183
|
+
// OCR 的 api_key_cmd 契约只认**单行** stdout(空/多行都是硬错误)。托管文档里
|
|
184
|
+
// 引号内的空白与块标量(| / >)分别产出空白值与多行值:交给 OCR 只会换来隔了
|
|
185
|
+
// 一层的「resolve LLM endpoint」报错,而这一层本可以当没有值、继续往下回退。
|
|
186
|
+
const key = typeof value === "string" ? value.trim() : "";
|
|
187
|
+
return key !== "" && !/[\r\n]/u.test(key) ? key : undefined;
|
|
151
188
|
}
|
|
152
189
|
|
|
153
190
|
/**
|
|
@@ -157,8 +194,13 @@ function fromCredentialsDoc(credPath, refName) {
|
|
|
157
194
|
*/
|
|
158
195
|
function unquotedValue(raw) {
|
|
159
196
|
const trimmed = raw.trim();
|
|
160
|
-
const
|
|
161
|
-
|
|
197
|
+
const [first] = trimmed;
|
|
198
|
+
// 只有首尾**配对**的引号才剥。单边引号是用户写坏了,硬砍首尾等于把凭据改短
|
|
199
|
+
// 一位,拿一个错 key 去打上游(401),比报「未配置」难排查得多。
|
|
200
|
+
if (trimmed.length > 1 && (first === '"' || first === "'") && trimmed.endsWith(first)) {
|
|
201
|
+
return trimmed.slice(1, -1);
|
|
202
|
+
}
|
|
203
|
+
return trimmed;
|
|
162
204
|
}
|
|
163
205
|
|
|
164
206
|
/**
|
|
@@ -178,7 +220,10 @@ function fromUserDotEnv(envPath, refName) {
|
|
|
178
220
|
}
|
|
179
221
|
const pattern = new RegExp(`^\\s*(?:export\\s+)?${refName}\\s*=\\s*(?<value>.*)$`, "u");
|
|
180
222
|
let hit = "";
|
|
181
|
-
|
|
223
|
+
// 按 /\r?\n/ 拆而不是 "\n":上面那条正则的 `.*` 不匹配 \r、`$` 又要求串尾,
|
|
224
|
+
// 而按 "\n" 拆出来的 CRLF 行每行都带尾随 \r,于是每一行都匹配不上——整个 .env
|
|
225
|
+
// 层对所有 key 静默失配,用户只看到隔了一层的「取不到凭据」。
|
|
226
|
+
for (const line of (text ?? "").split(/\r?\n/u)) {
|
|
182
227
|
const value = unquotedValue(pattern.exec(line)?.groups?.value ?? "");
|
|
183
228
|
if (value.length > 0) {
|
|
184
229
|
hit = value;
|