@jayyuen66/dsh-ocr-review 0.1.3 → 0.1.4-rc
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 +5 -2
- package/README.zh-CN.md +5 -2
- package/client.js +48 -50
- package/cordis.patch.yml +1 -1
- package/host.js +403 -57
- package/package.json +37 -33
- package/scripts/get-cred.mjs +30 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ English · [中文](README.zh-CN.md)
|
|
|
20
20
|
- Platforms: macOS and Linux are the targets. Process reaping (the reaper guard around `ocr_review` / `ocr_scan`) depends on a POSIX toolchain - bash traps and job control plus the process-group semantics of `ps` / `pgrep` / `kill`. None of that exists on Windows, so the package runs the bare command there (guarded by `process.platform`): no reaping, but it still works. The cost is that a hard host exit can leave an orphaned OCR process.
|
|
21
21
|
- The provider list and the key status come from the official host channels `ctx.settings.describe()` (the `value.providers` of the `llm-pi-ai` entry) and `ctx.credentials`.
|
|
22
22
|
- When either is missing the card shows the degradation reason while tools and settings keep working.
|
|
23
|
-
- The host must be `>=0.2.
|
|
23
|
+
- The host must be `>= 0.2.1-alpha.1`: declared in `peerDependencies` (the host checks it on plugin install from 0.1.7-rc; alpha.1 has no such gate yet) and in `engines.dsh` (same value, read by nothing).
|
|
24
24
|
|
|
25
25
|
## Installation
|
|
26
26
|
|
|
@@ -30,7 +30,10 @@ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
|
|
|
30
30
|
|
|
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* `peerDependencies`, so `host.js` keeps the bare specifiers unchanged.
|
|
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
|
+
- 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`).
|
|
34
37
|
|
|
35
38
|
## pnpm blocks the dependency script on install (ERR_PNPM_IGNORED_BUILDS)
|
|
36
39
|
|
package/README.zh-CN.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
- 平台:目标平台是 macOS / Linux。进程收割(`ocr_review` / `ocr_scan` 的 reaper 守护)依赖 POSIX 工具链——bash 的 trap 与作业控制,加上 `ps` / `pgrep` / `kill` 的进程组语义。Windows 上这层整体缺席,本包按 `process.platform` 直接放行裸命令(不收割但能跑);代价是宿主硬退出后 OCR 可能留下孤儿进程。
|
|
20
20
|
- provider 清单与 key 状态走宿主官方通道 `ctx.settings.describe()`(`llm-pi-ai` 那条的 `value.providers`)与 `ctx.credentials`。
|
|
21
21
|
- 两者缺席时卡片显示降级原因,工具与设置照常可用。
|
|
22
|
-
- 宿主版本要求 `>=0.2.
|
|
22
|
+
- 宿主版本要求 `>= 0.2.1-alpha.1`:写在 `peerDependencies`(0.1.7-rc 起宿主装插件时校验它;alpha.1 还没有这道门)与 `engines.dsh`(同值、无人读)。
|
|
23
23
|
|
|
24
24
|
## 安装
|
|
25
25
|
|
|
@@ -56,7 +56,10 @@ allowBuilds:
|
|
|
56
56
|
**关于这个 postinstall:** 它在正常路径下什么都不做。平台二进制由上游的 `optionalDependencies`(`@alibaba-group/ocr-<os>-<arch>`,各自带 `os` / `cpu` 字段,pnpm 只装匹配当前平台的那个)提供,`install.js` 检测到就打印 `Binary provided by platform package, skipping download.` 后直接返回。实测放行与不放行都能让 `ocr` 正常执行,差别只是要不要在安装期跑那段脚本——它只在平台包装不上时才下载兜底,而在 pnpm 下平台包必然装得上。
|
|
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` 与 `peerDependencies` 两张表一律外部化,`host.js` 里的裸说明符因此一字不变。
|
|
61
|
+
- 这四枚同时另列一份进 `devDependencies`——那才是本包自己的 typecheck、build、test 解析得到它们的方式。其余 `@deepseek-ai/*` 全是 type-only(构建即擦除,`host.js` 里不留说明符),只留在 `devDependencies`。
|
|
62
|
+
- `dependencies` 剩下的确实是本包自己的运行期代码:`@jayyuen66/dsh-plugin-shared` 与 `js-yaml`(后者只被 `scripts/get-cred.mjs` 用)。
|
|
60
63
|
|
|
61
64
|
## 在 dsh 里启用
|
|
62
65
|
|
package/client.js
CHANGED
|
@@ -126,14 +126,12 @@ const UI_MESSAGES = {
|
|
|
126
126
|
}
|
|
127
127
|
};
|
|
128
128
|
//#endregion
|
|
129
|
-
//#region node_modules/.pnpm/@jayyuen66+dsh-plugin-shared@0.1.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
129
|
+
//#region node_modules/.pnpm/@jayyuen66+dsh-plugin-shared@0.1.3_@deepseek-ai+cordis@4.0.5-alpha.1_@deepseek-ai+dsh-c_6c028874c2202469b2088f67c885abe7/node_modules/@jayyuen66/dsh-plugin-shared/dist/record.js
|
|
130
|
+
function e(e) {
|
|
131
|
+
return typeof e == `object` && !!e && !Array.isArray(e);
|
|
133
132
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return isRecord(value) ? value[key] : void 0;
|
|
133
|
+
function t(t, n) {
|
|
134
|
+
return e(t) ? t[n] : void 0;
|
|
137
135
|
}
|
|
138
136
|
//#endregion
|
|
139
137
|
//#region src/client-entry.ts
|
|
@@ -176,43 +174,43 @@ function errorSummary(error) {
|
|
|
176
174
|
}
|
|
177
175
|
function modelOf(raw) {
|
|
178
176
|
return {
|
|
179
|
-
id: strOf(
|
|
180
|
-
name: strOf(
|
|
177
|
+
id: strOf(t(raw, "id")),
|
|
178
|
+
name: strOf(t(raw, "name"))
|
|
181
179
|
};
|
|
182
180
|
}
|
|
183
181
|
function providerOf(raw) {
|
|
184
|
-
const modelsArr =
|
|
182
|
+
const modelsArr = t(raw, "models");
|
|
185
183
|
const models = Array.isArray(modelsArr) ? modelsArr.map((item) => modelOf(item)) : [];
|
|
186
|
-
const apiKeyEnvRaw =
|
|
184
|
+
const apiKeyEnvRaw = t(raw, "apiKeyEnv");
|
|
187
185
|
return {
|
|
188
|
-
name: strOf(
|
|
189
|
-
displayName: strOf(
|
|
186
|
+
name: strOf(t(raw, "name")),
|
|
187
|
+
displayName: strOf(t(raw, "displayName")),
|
|
190
188
|
apiKeyEnv: typeof apiKeyEnvRaw === "string" ? apiKeyEnvRaw : null,
|
|
191
|
-
baseURL: strOf(
|
|
189
|
+
baseURL: strOf(t(raw, "baseURL")),
|
|
192
190
|
models,
|
|
193
|
-
hasKey:
|
|
191
|
+
hasKey: t(raw, "hasKey") === true
|
|
194
192
|
};
|
|
195
193
|
}
|
|
196
194
|
function providersPayloadOf(json) {
|
|
197
|
-
const providersArr =
|
|
195
|
+
const providersArr = t(json, "providers");
|
|
198
196
|
const providers = Array.isArray(providersArr) ? providersArr.map((item) => providerOf(item)) : [];
|
|
199
|
-
const currentRaw =
|
|
197
|
+
const currentRaw = t(json, "current");
|
|
200
198
|
const current = {
|
|
201
|
-
provider: strOf(
|
|
202
|
-
model: strOf(
|
|
203
|
-
keyIsDynamic:
|
|
204
|
-
url: strOf(
|
|
199
|
+
provider: strOf(t(currentRaw, "provider")),
|
|
200
|
+
model: strOf(t(currentRaw, "model")),
|
|
201
|
+
keyIsDynamic: t(currentRaw, "keyIsDynamic") === true,
|
|
202
|
+
url: strOf(t(currentRaw, "url"))
|
|
205
203
|
};
|
|
206
|
-
const sourceRaw =
|
|
204
|
+
const sourceRaw = t(json, "source");
|
|
207
205
|
return {
|
|
208
206
|
providers,
|
|
209
207
|
current,
|
|
210
|
-
getCredReady:
|
|
208
|
+
getCredReady: t(json, "getCredReady") === true,
|
|
211
209
|
source: {
|
|
212
|
-
status: strOf(
|
|
213
|
-
message: strOf(
|
|
210
|
+
status: strOf(t(sourceRaw, "status")),
|
|
211
|
+
message: strOf(t(sourceRaw, "message"))
|
|
214
212
|
},
|
|
215
|
-
csrf: strOf(
|
|
213
|
+
csrf: strOf(t(json, "csrf"))
|
|
216
214
|
};
|
|
217
215
|
}
|
|
218
216
|
/** 三行表单控件(数字/文本/下拉)共用的类名 = CARD_CSS 里 `.ocr-select{…}` 那条规则。
|
|
@@ -698,7 +696,7 @@ async function refreshProviders(sinks) {
|
|
|
698
696
|
try {
|
|
699
697
|
const res = await fetch(PROVIDERS_URL, { headers: { accept: "application/json" } });
|
|
700
698
|
if (!res.ok) {
|
|
701
|
-
const detail = strOf(
|
|
699
|
+
const detail = strOf(t(await res.json().catch(() => ({})), "error"));
|
|
702
700
|
throw new Error(detail === "" ? `HTTP ${res.status}` : detail);
|
|
703
701
|
}
|
|
704
702
|
const payload = providersPayloadOf(await res.json());
|
|
@@ -729,11 +727,11 @@ async function postWithCsrf(url, body, deps) {
|
|
|
729
727
|
}
|
|
730
728
|
/** POST select:把选中的 provider/model 写进 OCR 配置,回执里按 autoVerify 决定是否带 llm test。 */
|
|
731
729
|
async function runApplySelection(deps) {
|
|
732
|
-
const { t, effectiveProvider, effectiveModel } = deps;
|
|
730
|
+
const { t: t$1, effectiveProvider, effectiveModel } = deps;
|
|
733
731
|
if (!effectiveProvider || !effectiveModel) {
|
|
734
732
|
deps.setActionMsg({
|
|
735
733
|
ok: false,
|
|
736
|
-
text: t("pickProviderModel")
|
|
734
|
+
text: t$1("pickProviderModel")
|
|
737
735
|
});
|
|
738
736
|
return;
|
|
739
737
|
}
|
|
@@ -744,14 +742,14 @@ async function runApplySelection(deps) {
|
|
|
744
742
|
provider: effectiveProvider,
|
|
745
743
|
model: effectiveModel
|
|
746
744
|
}))).json();
|
|
747
|
-
if (
|
|
745
|
+
if (t(selJson, "ok") === true) {
|
|
748
746
|
let testText;
|
|
749
|
-
const llmTestRaw =
|
|
750
|
-
if (
|
|
751
|
-
const testOutput = strOf(
|
|
752
|
-
testText =
|
|
753
|
-
} else testText = t("notVerified");
|
|
754
|
-
const appliedText = t("applied", {
|
|
747
|
+
const llmTestRaw = t(selJson, "llmTest");
|
|
748
|
+
if (e(llmTestRaw)) {
|
|
749
|
+
const testOutput = strOf(t(llmTestRaw, "output")).slice(0, 400);
|
|
750
|
+
testText = t(llmTestRaw, "ok") === true ? t$1("llmTestPassed") : `${t$1("llmTestFailed")}${testOutput}`;
|
|
751
|
+
} else testText = t$1("notVerified");
|
|
752
|
+
const appliedText = t$1("applied", {
|
|
755
753
|
provider: effectiveProvider,
|
|
756
754
|
model: effectiveModel,
|
|
757
755
|
test: testText
|
|
@@ -762,14 +760,14 @@ async function runApplySelection(deps) {
|
|
|
762
760
|
});
|
|
763
761
|
deps.refresh();
|
|
764
762
|
} else {
|
|
765
|
-
const failure = strOf(
|
|
763
|
+
const failure = strOf(t(selJson, "error"));
|
|
766
764
|
deps.setActionMsg({
|
|
767
765
|
ok: false,
|
|
768
|
-
text: failure === "" ? t("applyFailed") : failure
|
|
766
|
+
text: failure === "" ? t$1("applyFailed") : failure
|
|
769
767
|
});
|
|
770
768
|
}
|
|
771
769
|
} catch (error) {
|
|
772
|
-
const failedText = `${t("requestFailed")}${errorSummary(error)}`;
|
|
770
|
+
const failedText = `${t$1("requestFailed")}${errorSummary(error)}`;
|
|
773
771
|
deps.setActionMsg({
|
|
774
772
|
ok: false,
|
|
775
773
|
text: failedText
|
|
@@ -780,24 +778,24 @@ async function runApplySelection(deps) {
|
|
|
780
778
|
}
|
|
781
779
|
/** POST migrate:明文 api_key 一键迁到 api_key_cmd,回执把迁移/跳过两个名单拼成一行。 */
|
|
782
780
|
async function runMigrateKeys(deps) {
|
|
783
|
-
const { t } = deps;
|
|
781
|
+
const { t: t$2 } = deps;
|
|
784
782
|
deps.setBusy(true);
|
|
785
783
|
deps.setActionMsg(null);
|
|
786
784
|
try {
|
|
787
785
|
const json = await (await deps.post(MIGRATE_URL)).json();
|
|
788
|
-
const migratedVal =
|
|
789
|
-
const skippedVal =
|
|
786
|
+
const migratedVal = t(json, "migrated");
|
|
787
|
+
const skippedVal = t(json, "skippedUnknown");
|
|
790
788
|
const migrated = Array.isArray(migratedVal) ? migratedVal.filter((item) => typeof item === "string") : [];
|
|
791
789
|
const skipped = Array.isArray(skippedVal) ? skippedVal.filter((item) => typeof item === "string") : [];
|
|
792
|
-
const ok =
|
|
793
|
-
const head = ok ? (migrated.length > 0 ? t("migratedPrefix") + migrated.join(", ") : t("noPlainKey")) + (skipped.length > 0 ? t("skippedPrefix") + skipped.join(", ") : "") : t("migrateFailed");
|
|
790
|
+
const ok = t(json, "ok") === true;
|
|
791
|
+
const head = ok ? (migrated.length > 0 ? t$2("migratedPrefix") + migrated.join(", ") : t$2("noPlainKey")) + (skipped.length > 0 ? t$2("skippedPrefix") + skipped.join(", ") : "") : t$2("migrateFailed");
|
|
794
792
|
deps.setActionMsg({
|
|
795
793
|
ok,
|
|
796
794
|
text: head
|
|
797
795
|
});
|
|
798
796
|
deps.refresh();
|
|
799
797
|
} catch (error) {
|
|
800
|
-
const failedText = `${t("requestFailed")}${errorSummary(error)}`;
|
|
798
|
+
const failedText = `${t$2("requestFailed")}${errorSummary(error)}`;
|
|
801
799
|
deps.setActionMsg({
|
|
802
800
|
ok: false,
|
|
803
801
|
text: failedText
|
|
@@ -808,19 +806,19 @@ async function runMigrateKeys(deps) {
|
|
|
808
806
|
}
|
|
809
807
|
/** POST test:手动触发 ocr llm test,输出原样回显在动作条上。 */
|
|
810
808
|
async function runTestConnection(deps) {
|
|
811
|
-
const { t } = deps;
|
|
809
|
+
const { t: t$3 } = deps;
|
|
812
810
|
deps.setBusy(true);
|
|
813
811
|
deps.setActionMsg(null);
|
|
814
812
|
try {
|
|
815
813
|
const json = await (await deps.post(TEST_URL)).json();
|
|
816
|
-
const ok =
|
|
817
|
-
const testOutput = strOf(
|
|
814
|
+
const ok = t(json, "ok") === true;
|
|
815
|
+
const testOutput = strOf(t(json, "output"));
|
|
818
816
|
deps.setActionMsg({
|
|
819
817
|
ok,
|
|
820
|
-
text: (ok ? t("llmTestPassed") : t("llmTestFailed")) + testOutput
|
|
818
|
+
text: (ok ? t$3("llmTestPassed") : t$3("llmTestFailed")) + testOutput
|
|
821
819
|
});
|
|
822
820
|
} catch (error) {
|
|
823
|
-
const failedText = `${t("requestFailed")}${errorSummary(error)}`;
|
|
821
|
+
const failedText = `${t$3("requestFailed")}${errorSummary(error)}`;
|
|
824
822
|
deps.setActionMsg({
|
|
825
823
|
ok: false,
|
|
826
824
|
text: failedText
|
package/cordis.patch.yml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# ocr-review
|
|
1
|
+
# ocr-review:open-code-review 工作区审查插件(bundle)。
|
|
2
2
|
# 双面包机制:本包含 Host 半(host.ts:5 个工具 ocr_review/ocr_scan/
|
|
3
3
|
# ocr_delegate_preview/ocr_delegate_rule/ocr_session + settings namespace「ocr-review」+
|
|
4
4
|
# webServer 配置端点)与 Client 半(src/client-entry.ts → client.js:设置卡片,
|
package/host.js
CHANGED
|
@@ -84,6 +84,9 @@ const MESSAGES = {
|
|
|
84
84
|
noApiKeyEnv: "provider {provider} 未声明 apiKeyEnv,无法动态读取 key(OCR 自定义 provider 不支持无 key 解析)",
|
|
85
85
|
keyNotResolvable: "provider {provider} 的 API key 未配置:dsh 凭据服务未解析到 {ref}",
|
|
86
86
|
credScriptMissing: "get-cred 脚本缺失:{path}(api_key_cmd 无法工作)",
|
|
87
|
+
reaperDegraded: "[ocr-review] 宿主回收守护已降级:ps/pgrep 不可用(进程信息命令被沙箱拒绝),改由宿主管进程树。dsh 硬退出时 ocr 可能留孤儿进程。",
|
|
88
|
+
warningUnreadable: "(一条格式无法解析的 warning:既不是字符串,也没有 file/error 字段)",
|
|
89
|
+
commaInListItem: "{name} 的数组形式里有一项自身含逗号:{item}。数组与逗号字符串两条路径 join 后无法区分,而 glob 花括号({ts,tsx})恰好含逗号——静默拆成多项会改变审查/过滤范围。改用逗号字符串形式。",
|
|
87
90
|
providerModelRequired: "provider 与 model 必填",
|
|
88
91
|
bodyParseFailed: "body 解析失败:{message}",
|
|
89
92
|
reviewToolDescription: "用 open-code-review(ocr)对 Git 变更做深度学习代码审查:OCR 调用其自身配置的 LLM(provider/model 在 ocr-review 设置卡选择)独立审查,返回按严重度/类别聚合的评论摘要。scope=workspace 审工作区 staged+unstaged+untracked 变更(默认);scope=commit 审单个提交;scope=branch 用从/到分支对比(merge-base)。常用先 ocr_delegate_preview 看范围。effort 控制深度(low=1 轮/medium=2/high=3,越深越贵)。background 提供需求/业务上下文能显著提升质量。首次审查前确保 ocr 已配置且 'ocr llm test' 通过。",
|
|
@@ -211,6 +214,9 @@ const MESSAGES = {
|
|
|
211
214
|
noApiKeyEnv: "provider {provider} declares no apiKeyEnv, so the key cannot be read dynamically (OCR custom providers do not support keyless resolution)",
|
|
212
215
|
keyNotResolvable: "the API key of provider {provider} is not configured: the dsh credentials service resolved no {ref}",
|
|
213
216
|
credScriptMissing: "get-cred script missing: {path} (api_key_cmd cannot work)",
|
|
217
|
+
reaperDegraded: "[ocr-review] host reaper degraded: ps/pgrep are unavailable (process-info commands denied by the sandbox), so the host manages the process tree. dsh may leave an orphaned ocr process.",
|
|
218
|
+
warningUnreadable: "(one unreadable warning entry: neither a string nor an object with file/error)",
|
|
219
|
+
commaInListItem: "an item of the array form of {name} contains a comma itself: {item}. The array form and the comma-joined string are indistinguishable after joining, and glob braces ({ts,tsx}) contain commas — silently splitting changes the review/filter scope. Use the comma-separated string form.",
|
|
214
220
|
providerModelRequired: "provider and model are both required",
|
|
215
221
|
bodyParseFailed: "failed to parse the body: {message}",
|
|
216
222
|
reviewToolDescription: "Deep code review of Git changes with open-code-review (ocr): OCR runs its own configured LLM (provider/model picked in the ocr-review settings card) as an independent reviewer and returns comments aggregated by severity/category. scope=workspace reviews staged+unstaged+untracked changes (default); scope=commit reviews one commit; scope=branch compares from/to branches (merge-base). Commonly start with ocr_delegate_preview to see the scope. effort controls depth (low=1 pass / medium=2 / high=3, deeper costs more). Passing requirements/business context via background markedly improves quality. Before the first review make sure ocr is configured and 'ocr llm test' passes.",
|
|
@@ -423,17 +429,34 @@ function redactKeyMaterial(text) {
|
|
|
423
429
|
}
|
|
424
430
|
//#endregion
|
|
425
431
|
//#region lib/argv-guard.ts
|
|
426
|
-
/**
|
|
432
|
+
/**
|
|
433
|
+
* POSIX sh 单引号转义:' → '\''。把任意字符串安全放进单引号内,防注入。
|
|
434
|
+
*
|
|
435
|
+
* NUL 一律拒绝:POSIX 命令串根本无法表示 NUL 字节,含 NUL 的值经 `bash -c '<inner>'`
|
|
436
|
+
* 会被截断或报错,产出「看起来成功但 argv 被截断」的不确定行为。当前各调用点恰好都先
|
|
437
|
+
* 做了 NUL 拒绝,但那是靠调用顺序维持的隐式不变量;这里把闸下沉到实现自身,让
|
|
438
|
+
* 「转义后的串一定能被 shell 完整读回」由本函数保证,而不是指望每个新调用点记得先查一遍。
|
|
439
|
+
*/
|
|
427
440
|
function shq(value) {
|
|
441
|
+
if (value.includes("\0")) throw new Error("shq:值里含 NUL 字节,转义后的命令串无法被 shell 完整读回(漏了上游 NUL 检查)");
|
|
428
442
|
return `'${value.replaceAll("'", String.raw`'\''`)}'`;
|
|
429
443
|
}
|
|
430
|
-
/**
|
|
444
|
+
/**
|
|
445
|
+
* 绝对路径校验(macOS/Linux):非空、trim 后以 / 开头、无 NUL 字节。
|
|
446
|
+
*
|
|
447
|
+
* 先 trim 再判:调用点会先 trim 再传进来,而直接调用 `args.repo` 的路径若带首尾空白,
|
|
448
|
+
* 会被「必须是绝对路径」这条误导性文案拦下——读起来像是路径写错了格式,实际只是多了
|
|
449
|
+
* 几个空格。顺带也挡住全空白(trim 后为空 ⇒ 触发 repoRequired)。
|
|
450
|
+
*/
|
|
431
451
|
function assertAbsoluteRoot(root, messages) {
|
|
432
|
-
|
|
433
|
-
if (
|
|
434
|
-
if (
|
|
435
|
-
|
|
452
|
+
const trimmed = typeof root === "string" ? root.trim() : "";
|
|
453
|
+
if (trimmed.length === 0) throw new Error(messages.repoRequired);
|
|
454
|
+
if (trimmed.includes("\0")) throw new Error(format(messages.noNulBytes, { name: "repo" }));
|
|
455
|
+
if (!trimmed.startsWith("/")) throw new Error(format(messages.repoMustBeAbsolute, { root: trimmed }));
|
|
456
|
+
return trimmed;
|
|
436
457
|
}
|
|
458
|
+
/** limit 非法(NaN / ≤0 / 非整数)时的保守兜底上限:宁可比调用方的意图更严。 */
|
|
459
|
+
const DEFAULT_LIST_LIMIT = 200;
|
|
437
460
|
const EFFORTS = [
|
|
438
461
|
"low",
|
|
439
462
|
"medium",
|
|
@@ -452,6 +475,10 @@ function listSourceItems(value, name, messages) {
|
|
|
452
475
|
const items = [];
|
|
453
476
|
for (const item of value) {
|
|
454
477
|
if (typeof item !== "string") throw new TypeError(format(messages.listItemsMustBeString, { name }));
|
|
478
|
+
if (item.includes(",")) throw new TypeError(format(messages.commaInListItem, {
|
|
479
|
+
name,
|
|
480
|
+
item
|
|
481
|
+
}));
|
|
455
482
|
items.push(item);
|
|
456
483
|
}
|
|
457
484
|
return items;
|
|
@@ -460,12 +487,13 @@ function listSourceItems(value, name, messages) {
|
|
|
460
487
|
}
|
|
461
488
|
/** 原始项 → 取值(commaList 的第二段:只判内容,NUL 抛错、trim、丢空串,满 limit 即止)。 */
|
|
462
489
|
function takeTrimmedItems(items, name, limit, messages) {
|
|
490
|
+
const cap = Number.isSafeInteger(limit) && limit > 0 ? limit : DEFAULT_LIST_LIMIT;
|
|
463
491
|
const out = [];
|
|
464
492
|
for (const item of items) {
|
|
465
493
|
if (item.includes("\0")) throw new Error(format(messages.noNulBytes, { name }));
|
|
466
494
|
const trimmed = item.trim();
|
|
467
495
|
if (trimmed.length > 0) out.push(trimmed);
|
|
468
|
-
if (out.length >=
|
|
496
|
+
if (out.length >= cap) break;
|
|
469
497
|
}
|
|
470
498
|
return out;
|
|
471
499
|
}
|
|
@@ -625,18 +653,26 @@ function optionalFlags(args, messages) {
|
|
|
625
653
|
* - 组长场景(detached 复刻 runner)dsh 硬退出后 +2s:stub 走 trap 优雅收尾、
|
|
626
654
|
* 孙进程随组消亡——只杀 $ocr 与其直接子级会漏孙进程(实测泄漏被收养);
|
|
627
655
|
* - 非组长场景守护收 TERM:stub/stub 包装层/孙进程全部消亡。
|
|
656
|
+
* 降级口径:宿主文件沙箱围栏会把进程信息类命令一并 EPERM 拒绝(实测 /bin/ps),于是
|
|
657
|
+
* 一次性探测 ps 与 pgrep 两个可用性(HAVEPS / HAVEPG)。**任一不可用就不挂守护**:原样
|
|
658
|
+
* 跑命令、把进程树交回宿主,并向 stderr 报一次(不是每 2 秒刷屏)。为什么是「整条不挂」
|
|
659
|
+
* 而不是「挂个残缺版」——pgrep 只出现在后代快照 desc() 里,它不可用时 cs 恒空,reap
|
|
660
|
+
* 于是只杀得掉中间 bash 壳,真 ocr 命令与其孙进程全部存活:那不是「少一条看护」,而是
|
|
661
|
+
* 伪装成看护在位却放任进程持续烧 token。
|
|
628
662
|
* 只包 review/scan(分钟级任务);delegate/session/llm-test 秒级命令不包。
|
|
629
663
|
*/
|
|
630
|
-
function wrapWithHostReaper(command) {
|
|
664
|
+
function wrapWithHostReaper(command, messages) {
|
|
631
665
|
if (process.platform === "win32") return command;
|
|
632
666
|
return `bash -c ${shq([
|
|
633
|
-
`p0=$(ps -o ppid= -p $$ | tr -d ' ')`,
|
|
667
|
+
`p0=$(ps -o ppid= -p $$ 2>/dev/null | tr -d ' '); [ -n "$p0" ] && HAVEPS=1 || HAVEPS=0`,
|
|
668
|
+
`pgrep -P 1 >/dev/null 2>&1 && HAVEPG=1 || HAVEPG=0`,
|
|
669
|
+
`[ "$HAVEPS" = 1 ] && [ "$HAVEPG" = 1 ] || { printf '%s\\n' ${shq(messages.reaperDegraded)} >&2; { ${command} ; }; exit $?; }`,
|
|
634
670
|
`{ ${command} ; } & ocr=$!`,
|
|
635
671
|
`desc() { for k in $(pgrep -P "$1" 2>/dev/null); do desc "$k"; printf '%s ' "$k"; done; }`,
|
|
636
|
-
`isl() { [ "$(ps -o pgid= -p $$ | tr -d ' ')" = "$$" ]; }`,
|
|
672
|
+
`isl() { [ "$HAVEPS" = 1 ] && [ "$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')" = "$$" ]; }`,
|
|
637
673
|
`reap() { cs=$(desc "$ocr"); isl && kill -TERM -- -$$ 2>/dev/null; kill -TERM "$ocr" 2>/dev/null; for c in $cs; do kill -TERM "$c" 2>/dev/null; done; sleep 1; killmPid(){ kill -0 "$1" 2>/dev/null && kill -KILL "$1" 2>/dev/null; }; killmPid "$ocr"; for c in $cs; do killmPid "$c"; done; isl && kill -KILL -- -$$ 2>/dev/null; }`,
|
|
638
674
|
`trap 'reap; exit 143' TERM INT`,
|
|
639
|
-
`while kill -0 "$ocr" 2>/dev/null && [ "$(ps -o ppid= -p $$ | tr -d ' ')" = "$p0" ]; do sleep 2; done`,
|
|
675
|
+
`while kill -0 "$ocr" 2>/dev/null && { [ "$HAVEPS" = 0 ] || [ "$(ps -o ppid= -p $$ 2>/dev/null | tr -d ' ')" = "$p0" ]; }; do sleep 2; done`,
|
|
640
676
|
`kill -0 "$ocr" 2>/dev/null && reap`,
|
|
641
677
|
`wait "$ocr"; exit $?`
|
|
642
678
|
].join("; "))}`;
|
|
@@ -862,13 +898,24 @@ const SEVERITY_ORDER = [
|
|
|
862
898
|
* 严重度归一:白名单外的值(含 OCR 自造等级、大小写异形)一律按 medium。
|
|
863
899
|
* 聚合与排序共用这一个函数——此前两处各自实现(排序 indexOf 未命中得 -1 而把
|
|
864
900
|
* UNKNOWN 排到 critical 之前,聚合却归 medium),同一份数据两种口径。
|
|
901
|
+
*
|
|
902
|
+
* trim 放在**本层**而不是留给 parse.ts:本模块声明自己是「唯一口径」,调用方不一定
|
|
903
|
+
* 来自 parse.ts 的解码路径。少了它," critical"(带首尾空白)会掉到 medium——一个
|
|
904
|
+
* 高危等级被静默降级成中危,正是这层要防的事。
|
|
905
|
+
*
|
|
906
|
+
* known 一并回传:归一是「必须发生」的降级,但降级本身不该是**无痕**的——调用方要能
|
|
907
|
+
* 统计出「有多少条走了兜底」,否则契约异常被彻底吞掉,聚合里看不出来、排序里也看不出来。
|
|
865
908
|
*/
|
|
866
909
|
function normalizeSeverity(severity) {
|
|
867
|
-
|
|
910
|
+
const matched = SEVERITY_ORDER.find((level) => level === severity.trim().toLowerCase());
|
|
911
|
+
return {
|
|
912
|
+
level: matched ?? "medium",
|
|
913
|
+
known: matched !== void 0
|
|
914
|
+
};
|
|
868
915
|
}
|
|
869
916
|
/** 排序位:critical→high→medium→low(未知已归 medium,永不为 -1)。 */
|
|
870
917
|
function severityRank(severity) {
|
|
871
|
-
return SEVERITY_ORDER.indexOf(normalizeSeverity(severity));
|
|
918
|
+
return SEVERITY_ORDER.indexOf(normalizeSeverity(severity).level);
|
|
872
919
|
}
|
|
873
920
|
/** 聚合评论:按严重度/类别计数。severity 归类以 critical→high→medium→low 基准。 */
|
|
874
921
|
function aggregateComments(comments) {
|
|
@@ -878,16 +925,20 @@ function aggregateComments(comments) {
|
|
|
878
925
|
medium: 0,
|
|
879
926
|
low: 0
|
|
880
927
|
};
|
|
881
|
-
const
|
|
928
|
+
const categoryCounts = /* @__PURE__ */ new Map();
|
|
929
|
+
let unknownSeverityCount = 0;
|
|
882
930
|
for (const comment of comments) {
|
|
883
|
-
const
|
|
884
|
-
bySeverity[
|
|
885
|
-
if (
|
|
931
|
+
const { level, known } = normalizeSeverity(comment.severity);
|
|
932
|
+
bySeverity[level] += 1;
|
|
933
|
+
if (!known) unknownSeverityCount += 1;
|
|
934
|
+
if (comment.category) categoryCounts.set(comment.category, (categoryCounts.get(comment.category) ?? 0) + 1);
|
|
886
935
|
}
|
|
936
|
+
const byCategory = Object.fromEntries(categoryCounts);
|
|
887
937
|
return {
|
|
888
938
|
total: comments.length,
|
|
889
939
|
bySeverity,
|
|
890
|
-
byCategory
|
|
940
|
+
byCategory,
|
|
941
|
+
unknownSeverityCount
|
|
891
942
|
};
|
|
892
943
|
}
|
|
893
944
|
//#endregion
|
|
@@ -923,13 +974,24 @@ function str(value) {
|
|
|
923
974
|
* start_line 输出成 "42",一律当 0 会把真实评论判成「未锚定」而被摘要丢掉),
|
|
924
975
|
* 空串/NaN/Infinity/其它类型回 0。
|
|
925
976
|
*/
|
|
977
|
+
/**
|
|
978
|
+
* 行号与增删统计的有效域:有限、非负、整数。
|
|
979
|
+
*
|
|
980
|
+
* 为什么把负数和小数一起挡掉:行号出现 "-3" 会被下游当成「未锚定的评论」而丢弃
|
|
981
|
+
* (分类错了——它是畸形值不是缺锚点),而 insertions: -5 会原样传播进给模型看的
|
|
982
|
+
* diff 统计。两者都是上游畸形值被当合法值透传。0 在下游有既定语义(未锚定 / 无统计),
|
|
983
|
+
* 故作兜底值。
|
|
984
|
+
*/
|
|
985
|
+
function nonNegativeInt(value) {
|
|
986
|
+
if (!Number.isFinite(value) || value < 0) return 0;
|
|
987
|
+
return Math.trunc(value);
|
|
988
|
+
}
|
|
926
989
|
function num(value) {
|
|
927
|
-
if (typeof value === "number") return
|
|
990
|
+
if (typeof value === "number") return nonNegativeInt(value);
|
|
928
991
|
if (typeof value === "string") {
|
|
929
992
|
const trimmed = value.trim();
|
|
930
993
|
if (trimmed === "") return 0;
|
|
931
|
-
|
|
932
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
994
|
+
return nonNegativeInt(Number(trimmed));
|
|
933
995
|
}
|
|
934
996
|
return 0;
|
|
935
997
|
}
|
|
@@ -995,11 +1057,18 @@ function parseReviewOutput(text, messages) {
|
|
|
995
1057
|
const skipped = status === "skipped";
|
|
996
1058
|
const sumRaw = root["summary"];
|
|
997
1059
|
const sum = isRecord(sumRaw) ? sumRaw : void 0;
|
|
998
|
-
const
|
|
1060
|
+
const warningsRaw = root["warnings"];
|
|
1061
|
+
let warningsArr;
|
|
1062
|
+
if (warningsRaw === void 0) warningsArr = [];
|
|
1063
|
+
else if (Array.isArray(warningsRaw)) warningsArr = warningsRaw.map((warn) => {
|
|
999
1064
|
if (typeof warn === "string") return warn;
|
|
1000
|
-
if (isRecord(warn))
|
|
1001
|
-
|
|
1002
|
-
|
|
1065
|
+
if (isRecord(warn)) {
|
|
1066
|
+
const joined = [str(warn["file"]), str(warn["error"])].filter(Boolean).join(messages.warningSeparator);
|
|
1067
|
+
if (joined !== "") return joined;
|
|
1068
|
+
}
|
|
1069
|
+
return messages.warningUnreadable;
|
|
1070
|
+
}).filter(Boolean);
|
|
1071
|
+
else warningsArr = [messages.warningUnreadable];
|
|
1003
1072
|
return {
|
|
1004
1073
|
ok: true,
|
|
1005
1074
|
status,
|
|
@@ -1095,6 +1164,10 @@ function parseDelegatePreview(text) {
|
|
|
1095
1164
|
if (!isRecord(doc)) return delegatePreviewFailure();
|
|
1096
1165
|
const root = doc;
|
|
1097
1166
|
if (!hitsContract(root, DELEGATE_PREVIEW_KEYS)) return delegatePreviewFailure();
|
|
1167
|
+
for (const key of ["reviewable_files", "excluded_files"]) {
|
|
1168
|
+
const value = root[key];
|
|
1169
|
+
if (value !== void 0 && !Array.isArray(value)) return delegatePreviewFailure();
|
|
1170
|
+
}
|
|
1098
1171
|
const reviewable = Array.isArray(root["reviewable_files"]) ? root["reviewable_files"].map((fileRaw) => {
|
|
1099
1172
|
if (!isRecord(fileRaw)) return null;
|
|
1100
1173
|
return {
|
|
@@ -1141,18 +1214,24 @@ function parseDelegateRules(text) {
|
|
|
1141
1214
|
ok: false,
|
|
1142
1215
|
groups: []
|
|
1143
1216
|
};
|
|
1217
|
+
if (root["groups"] !== void 0 && !Array.isArray(root["groups"])) return {
|
|
1218
|
+
ok: false,
|
|
1219
|
+
groups: []
|
|
1220
|
+
};
|
|
1221
|
+
const groupsRaw = Array.isArray(root["groups"]) ? root["groups"] : [];
|
|
1222
|
+
if (groupsRaw.some((groupRaw) => isRecord(groupRaw) && groupRaw["files"] !== void 0 && !Array.isArray(groupRaw["files"]))) return {
|
|
1223
|
+
ok: false,
|
|
1224
|
+
groups: []
|
|
1225
|
+
};
|
|
1144
1226
|
return {
|
|
1145
1227
|
ok: true,
|
|
1146
|
-
groups: Array.isArray(
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
rule: str(groupRaw["rule"])
|
|
1154
|
-
};
|
|
1155
|
-
}).filter((group) => group !== null && group.files.length > 0) : []
|
|
1228
|
+
groups: groupsRaw.filter((groupRaw) => isRecord(groupRaw) && Array.isArray(groupRaw["files"])).map((groupRaw) => ({
|
|
1229
|
+
groupId: num(groupRaw["group_id"]),
|
|
1230
|
+
source: str(groupRaw["source"]),
|
|
1231
|
+
pattern: str(groupRaw["pattern"]),
|
|
1232
|
+
files: groupRaw.files.map((file) => str(file)).filter(Boolean),
|
|
1233
|
+
rule: str(groupRaw["rule"])
|
|
1234
|
+
})).filter((group) => group.files.length > 0)
|
|
1156
1235
|
};
|
|
1157
1236
|
}
|
|
1158
1237
|
//#endregion
|
|
@@ -1174,20 +1253,39 @@ const PROVIDERS_FIELD = "providers";
|
|
|
1174
1253
|
* 端点按 OpenAI 线协议打出去——select 成功、OCR 调不通。表外值透传,让
|
|
1175
1254
|
* renderSelectedConfig 的白名单给出可读错误。
|
|
1176
1255
|
*/
|
|
1177
|
-
const OCR_PROTOCOL_BY_API =
|
|
1178
|
-
"openai-completions"
|
|
1179
|
-
"openai-responses"
|
|
1180
|
-
"anthropic-messages"
|
|
1181
|
-
|
|
1256
|
+
const OCR_PROTOCOL_BY_API = /* @__PURE__ */ new Map([
|
|
1257
|
+
["openai-completions", "openai"],
|
|
1258
|
+
["openai-responses", "openai-responses"],
|
|
1259
|
+
["anthropic-messages", "anthropic"]
|
|
1260
|
+
]);
|
|
1261
|
+
/** 对象/数组形态的 `api` 的透传标记:可读,但绝不会被误认成任何合法协议名。 */
|
|
1262
|
+
const API_UNREADABLE = "[unreadable]";
|
|
1263
|
+
/**
|
|
1264
|
+
* settings 里的 `api` 字段 → 协议查表用的键。
|
|
1265
|
+
*
|
|
1266
|
+
* 三种形态必须分开,否则畸形声明会被静默降级成「能用的」配置:
|
|
1267
|
+
* - 未声明(undefined / null)⇒ "",交给 llm-pi-ai 的 openai-completions 兜底语义;
|
|
1268
|
+
* - 字符串 ⇒ trim 后原样(表外值原样透传,由写侧白名单拒绝);
|
|
1269
|
+
* - 数字 / 布尔 ⇒ String(),同样落进「表外值」这条正路;
|
|
1270
|
+
* - 对象 / 数组 ⇒ 固定标记。用 String(raw) 会走 Object.prototype.toString 得到
|
|
1271
|
+
* "[object Object]",那既无信息量又看着像协议名;显式标记更能说明「这里读不出来」。
|
|
1272
|
+
*/
|
|
1273
|
+
function apiKeyOf(raw) {
|
|
1274
|
+
if (raw === void 0 || raw === null) return "";
|
|
1275
|
+
if (typeof raw === "string") return raw.trim();
|
|
1276
|
+
if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
|
|
1277
|
+
return API_UNREADABLE;
|
|
1278
|
+
}
|
|
1182
1279
|
/** 原始 model 对象 → DshModel(缺 id 视为无效,返回 null)。 */
|
|
1183
1280
|
function modelFromRaw(modelRaw) {
|
|
1184
1281
|
if (!isRecord(modelRaw)) return null;
|
|
1185
1282
|
const rec = modelRaw;
|
|
1186
|
-
const id = typeof rec["id"] === "string" ? rec["id"] : "";
|
|
1283
|
+
const id = typeof rec["id"] === "string" ? rec["id"].trim() : "";
|
|
1187
1284
|
if (!id) return null;
|
|
1285
|
+
const name = typeof rec["name"] === "string" ? rec["name"].trim() : "";
|
|
1188
1286
|
return {
|
|
1189
1287
|
id,
|
|
1190
|
-
name:
|
|
1288
|
+
name: name === "" ? id : name
|
|
1191
1289
|
};
|
|
1192
1290
|
}
|
|
1193
1291
|
/** 原始 provider 对象 → DshProvider(缺 baseURL 视为无效,返回 null)。 */
|
|
@@ -1195,9 +1293,9 @@ function providerFromRaw(name, raw) {
|
|
|
1195
1293
|
if (!isRecord(raw)) return null;
|
|
1196
1294
|
const entry = raw;
|
|
1197
1295
|
const apiKeyEnv = typeof entry["apiKeyEnv"] === "string" && entry["apiKeyEnv"].trim() ? entry["apiKeyEnv"].trim() : null;
|
|
1198
|
-
const api =
|
|
1199
|
-
const protocol = api === "" ? "openai" : OCR_PROTOCOL_BY_API
|
|
1200
|
-
const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"] : "";
|
|
1296
|
+
const api = apiKeyOf(entry["api"]);
|
|
1297
|
+
const protocol = api === "" ? "openai" : OCR_PROTOCOL_BY_API.get(api) ?? api;
|
|
1298
|
+
const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"].trim() : "";
|
|
1201
1299
|
if (!baseURL) return null;
|
|
1202
1300
|
const models = Array.isArray(entry["models"]) ? entry["models"].map(modelFromRaw).filter((model) => model !== null) : [];
|
|
1203
1301
|
return {
|
|
@@ -1230,16 +1328,29 @@ function providersFromSettingsValue(value) {
|
|
|
1230
1328
|
//#region lib/provider-source.ts
|
|
1231
1329
|
/** provider 清单所在的官方 settings 命名空间(base bundle 的 pi-ai 适配器注册)。 */
|
|
1232
1330
|
const PI_AI_NAMESPACE = "llm-pi-ai";
|
|
1233
|
-
const SOURCE_READY = {
|
|
1331
|
+
const SOURCE_READY = Object.freeze({
|
|
1234
1332
|
status: "ready",
|
|
1235
1333
|
message: ""
|
|
1236
|
-
};
|
|
1334
|
+
});
|
|
1335
|
+
/** settings 通道侧抛错(health / descriptors / 投影三处共用同一状态与同一份文案)。 */
|
|
1336
|
+
const SOURCE_SETTINGS_FAILED = "settings-failed";
|
|
1237
1337
|
/**
|
|
1238
1338
|
* 读 provider 清单并把一切失败折进 source(不抛):会话热路径与 webServer 端点
|
|
1239
1339
|
* 都只消费返回值,服务缺席/抛错时卡片仍能显示原因。
|
|
1240
1340
|
*/
|
|
1241
1341
|
function readProviders(gateway, messages) {
|
|
1242
|
-
|
|
1342
|
+
let health;
|
|
1343
|
+
try {
|
|
1344
|
+
health = gateway.health();
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
return {
|
|
1347
|
+
providers: [],
|
|
1348
|
+
source: {
|
|
1349
|
+
status: SOURCE_SETTINGS_FAILED,
|
|
1350
|
+
message: format(messages.sourceSettingsFailed, { message: errorText(error) })
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1243
1354
|
if (!health.settings) return {
|
|
1244
1355
|
providers: [],
|
|
1245
1356
|
source: {
|
|
@@ -1254,7 +1365,7 @@ function readProviders(gateway, messages) {
|
|
|
1254
1365
|
return {
|
|
1255
1366
|
providers: [],
|
|
1256
1367
|
source: {
|
|
1257
|
-
status:
|
|
1368
|
+
status: SOURCE_SETTINGS_FAILED,
|
|
1258
1369
|
message: format(messages.sourceSettingsFailed, { message: errorText(error) })
|
|
1259
1370
|
}
|
|
1260
1371
|
};
|
|
@@ -1267,7 +1378,25 @@ function readProviders(gateway, messages) {
|
|
|
1267
1378
|
message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
|
|
1268
1379
|
}
|
|
1269
1380
|
};
|
|
1270
|
-
|
|
1381
|
+
if (!isRecord(fieldOf(hit.value, "providers"))) return {
|
|
1382
|
+
providers: [],
|
|
1383
|
+
source: {
|
|
1384
|
+
status: "namespace-unavailable",
|
|
1385
|
+
message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
let providers;
|
|
1389
|
+
try {
|
|
1390
|
+
providers = providersFromSettingsValue(hit.value);
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
return {
|
|
1393
|
+
providers: [],
|
|
1394
|
+
source: {
|
|
1395
|
+
status: SOURCE_SETTINGS_FAILED,
|
|
1396
|
+
message: format(messages.sourceSettingsFailed, { message: errorText(error) })
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1271
1400
|
if (!health.credentials) return {
|
|
1272
1401
|
providers,
|
|
1273
1402
|
source: {
|
|
@@ -1283,7 +1412,12 @@ function readProviders(gateway, messages) {
|
|
|
1283
1412
|
/** 逐 provider 问凭据服务「有没有 key」(并行;只回布尔)。 */
|
|
1284
1413
|
async function markKeyFlags(gateway, providers) {
|
|
1285
1414
|
return await Promise.all(providers.map(async (providerCfg) => {
|
|
1286
|
-
const
|
|
1415
|
+
const ref = providerCfg.apiKeyEnv;
|
|
1416
|
+
if (ref === null || ref === "") return {
|
|
1417
|
+
...providerCfg,
|
|
1418
|
+
hasKey: false
|
|
1419
|
+
};
|
|
1420
|
+
const hasKey = await gateway.credentialConfigured(ref).catch(() => false);
|
|
1287
1421
|
return {
|
|
1288
1422
|
...providerCfg,
|
|
1289
1423
|
hasKey
|
|
@@ -1351,6 +1485,78 @@ function buildApiKeyCmd(scriptPath, envKey, dshHome, messages) {
|
|
|
1351
1485
|
if (dshHome !== void 0) parts.push(shqDyn(dshHome));
|
|
1352
1486
|
return parts.join(" ");
|
|
1353
1487
|
}
|
|
1488
|
+
/**
|
|
1489
|
+
* 读一个 shq 单引号串:解出它的值与结束位置。
|
|
1490
|
+
*
|
|
1491
|
+
* 认的是 shqDyn 自己写出的转义形态——值里的单引号被写成四字符的 '\''。所以遇到
|
|
1492
|
+
* 单引号时,只有后面紧跟这四字符才算转义,否则是真闭合。未闭合(串被截断)返回
|
|
1493
|
+
* undefined:宁可判「读不懂」,也不要把半截命令当成可信输入。
|
|
1494
|
+
*/
|
|
1495
|
+
function readShqToken(text, start) {
|
|
1496
|
+
let value = "";
|
|
1497
|
+
let i = start + 1;
|
|
1498
|
+
let closed = false;
|
|
1499
|
+
while (i < text.length && !closed) {
|
|
1500
|
+
const ch = text[i];
|
|
1501
|
+
if (ch === "'" && text.startsWith(String.raw`'\''`, i)) {
|
|
1502
|
+
value += "'";
|
|
1503
|
+
i += 4;
|
|
1504
|
+
} else if (ch === "'") {
|
|
1505
|
+
closed = true;
|
|
1506
|
+
i += 1;
|
|
1507
|
+
} else {
|
|
1508
|
+
value += ch;
|
|
1509
|
+
i += 1;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return closed ? {
|
|
1513
|
+
value,
|
|
1514
|
+
next: i
|
|
1515
|
+
} : null;
|
|
1516
|
+
}
|
|
1517
|
+
/** shq 串 → token 列表;形态不符(未闭合 / 出现裸词)返回 null。 */
|
|
1518
|
+
function readShqTokens(body) {
|
|
1519
|
+
const tokens = [];
|
|
1520
|
+
let i = 0;
|
|
1521
|
+
let malformed = false;
|
|
1522
|
+
while (i < body.length && !malformed) {
|
|
1523
|
+
while (i < body.length && (body[i] === " " || body[i] === " ")) i += 1;
|
|
1524
|
+
if (i >= body.length) break;
|
|
1525
|
+
if (body[i] === "'") {
|
|
1526
|
+
const token = readShqToken(body, i);
|
|
1527
|
+
if (token === null) malformed = true;
|
|
1528
|
+
else {
|
|
1529
|
+
tokens.push(token.value);
|
|
1530
|
+
i = token.next;
|
|
1531
|
+
}
|
|
1532
|
+
} else malformed = true;
|
|
1533
|
+
}
|
|
1534
|
+
return malformed ? null : tokens;
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* api_key_cmd → 三个槽位;只认本包写出的那一种形态。
|
|
1538
|
+
*
|
|
1539
|
+
* 为什么需要反解:写进 config.json 的脚本路径是**写那一刻**的插件安装目录(绝对
|
|
1540
|
+
* 路径),而插件会被重装、换 profile、换发布形态——目录一变,ocr 执行 api_key_cmd
|
|
1541
|
+
* 就是 MODULE_NOT_FOUND,而它把这件事报成「resolve LLM endpoint 失败」,错误完全
|
|
1542
|
+
* 指不到「本包被挪了地方」这个真因。没有反解就无从判断一条 api_key_cmd 是否还活着。
|
|
1543
|
+
*
|
|
1544
|
+
* 为什么只认一种形态:认不出来的一律给 undefined,由调用方原样保留。改写一条读不
|
|
1545
|
+
* 懂的命令串,可能把用户手写的凭据取用方式弄坏,而那正是本包明令不该插手的部分。
|
|
1546
|
+
*/
|
|
1547
|
+
function parseApiKeyCmd(command) {
|
|
1548
|
+
const body = command.replace(/^node[ \t]+/u, "");
|
|
1549
|
+
const tokens = body === command ? null : readShqTokens(body);
|
|
1550
|
+
if (tokens === null) return null;
|
|
1551
|
+
const script = tokens[0] ?? "";
|
|
1552
|
+
const envKey = tokens[1] ?? "";
|
|
1553
|
+
if (script === "" || tokens.length < 2 || tokens.length > 3 || !/^[A-Z0-9_]+$/u.test(envKey)) return null;
|
|
1554
|
+
return {
|
|
1555
|
+
script,
|
|
1556
|
+
envKey,
|
|
1557
|
+
dshHome: tokens[2]
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1354
1560
|
/** 现有 config 文本 → 盘面对象。
|
|
1355
1561
|
*
|
|
1356
1562
|
* 解析失败必须抛错而非给 `{}`:调用方(renderSelectedConfig /
|
|
@@ -1481,6 +1687,43 @@ function defaultCredScriptPath(pluginDir) {
|
|
|
1481
1687
|
function credentialScriptPath(paths) {
|
|
1482
1688
|
return paths.getCredScript === "" ? defaultCredScriptPath(paths.pluginDir) : paths.getCredScript;
|
|
1483
1689
|
}
|
|
1690
|
+
/** config.json 的全文渲染(与 renderSelectedConfig 末尾同一形态:两空格缩进 + 结尾换行)。 */
|
|
1691
|
+
function renderConfigDoc(doc) {
|
|
1692
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* 把 custom_providers 里「脚本已不在」的 api_key_cmd 重指向当前安装目录。
|
|
1696
|
+
*
|
|
1697
|
+
* 为什么必须有这一步:api_key_cmd 里烤的是**写那一刻**的插件安装绝对路径。插件会被
|
|
1698
|
+
* 重装、换 profile(desktop / browser / 任意自定义名)、换发布形态,目录一变就悬空
|
|
1699
|
+
* ——而 ocr 侧的报错是「resolve LLM endpoint: api_key_cmd ... MODULE_NOT_FOUND」,完全
|
|
1700
|
+
* 指不到真因。没有自愈,用户只能手改一个自己都不知道错在哪的配置文件。
|
|
1701
|
+
*
|
|
1702
|
+
* 三条不越界的判据(宁可漏修也不猜):
|
|
1703
|
+
* 1. 当前脚本自己都不在(包没装全)⇒ 一律不动,把「get-cred 脚本缺失」留给 apply 侧报;
|
|
1704
|
+
* 2. 旧脚本**还活着** ⇒ 不动。显式指定优先于 pluginDir 兜底,用户有意指到别处
|
|
1705
|
+
* (自制脚本、共享副本)时不该被本包拽回来;
|
|
1706
|
+
* 3. 命令串读不懂(不是本包写出的形态)⇒ 不动。那多半是用户手写的,改写它就是在
|
|
1707
|
+
* 插手本包明令不碰的部分。
|
|
1708
|
+
*
|
|
1709
|
+
* dshHome 同样可能被搬:原值已不存在时换成宿主此刻解析出的那个。
|
|
1710
|
+
*/
|
|
1711
|
+
function repointDanglingKeyScripts(doc, current, messages) {
|
|
1712
|
+
if (!existsSync(current.script)) return { repointed: [] };
|
|
1713
|
+
const custom = fieldOf(doc, "custom_providers");
|
|
1714
|
+
if (!isRecord(custom)) return { repointed: [] };
|
|
1715
|
+
return { repointed: Object.entries(custom).flatMap(([name, entry]) => {
|
|
1716
|
+
if (!isRecord(entry)) return [];
|
|
1717
|
+
const raw = fieldOf(entry, "api_key_cmd");
|
|
1718
|
+
if (typeof raw !== "string" || raw === "") return [];
|
|
1719
|
+
const parsed = parseApiKeyCmd(raw);
|
|
1720
|
+
if (parsed === null || parsed.script === current.script) return [];
|
|
1721
|
+
if (existsSync(parsed.script)) return [];
|
|
1722
|
+
const dshHome = parsed.dshHome !== void 0 && !existsSync(parsed.dshHome) ? current.dshHome : parsed.dshHome;
|
|
1723
|
+
entry["api_key_cmd"] = buildApiKeyCmd(current.script, parsed.envKey, dshHome, messages);
|
|
1724
|
+
return [name];
|
|
1725
|
+
}) };
|
|
1726
|
+
}
|
|
1484
1727
|
//#endregion
|
|
1485
1728
|
//#region lib/ocr-config.ts
|
|
1486
1729
|
/** 外部 ocr CLI 的默认配置目录/文件名(宿主服务管不到,只能由本包直读直写)。 */
|
|
@@ -1655,6 +1898,51 @@ async function migratePlaintextKeys(gateway, paths, messages) {
|
|
|
1655
1898
|
};
|
|
1656
1899
|
});
|
|
1657
1900
|
}
|
|
1901
|
+
/**
|
|
1902
|
+
* 调用 ocr 之前的一次性指针自愈:把 config.json 里指向已不存在目录的 api_key_cmd
|
|
1903
|
+
* 拉回当前安装目录(判据见 lib/config-store.ts 的 repointDanglingKeyScripts)。
|
|
1904
|
+
*
|
|
1905
|
+
* 为什么放在调用前而不是启动时:插件可能在会话进行到一半被重装或换 profile,启动时
|
|
1906
|
+
* 修好的一次覆盖不了「上次跑完到这次开跑」之间发生的迁移。
|
|
1907
|
+
*
|
|
1908
|
+
* 为什么永不抛错:这是锦上添花的修复,不能因为它把工具拖死。坏 JSON、锁抢不到、权限
|
|
1909
|
+
* 不足、设置项写坏——一律放过,让 ocr 报它自己的原始错误,那才是诊断价值最高的形态。
|
|
1910
|
+
*
|
|
1911
|
+
* 常态零锁开销:先无锁读一次做判据,确实要改写才进 withFileLock(锁内按新快照重判,
|
|
1912
|
+
* 避免把并发写入的 provider 选择覆盖掉)。
|
|
1913
|
+
*/
|
|
1914
|
+
async function ensureKeyScriptPointer(paths, messages) {
|
|
1915
|
+
const scriptPath = credentialScriptPath(paths);
|
|
1916
|
+
if (!existsSync(scriptPath)) return {
|
|
1917
|
+
repointed: [],
|
|
1918
|
+
checked: false
|
|
1919
|
+
};
|
|
1920
|
+
const current = {
|
|
1921
|
+
script: scriptPath,
|
|
1922
|
+
dshHome: resolveDshHome()
|
|
1923
|
+
};
|
|
1924
|
+
if (readText(paths.ocrConfigJson).trim() === "") return {
|
|
1925
|
+
repointed: [],
|
|
1926
|
+
checked: true
|
|
1927
|
+
};
|
|
1928
|
+
try {
|
|
1929
|
+
return {
|
|
1930
|
+
repointed: await updateConfigLocked(paths.ocrConfigJson, async () => {
|
|
1931
|
+
const doc = parseExistingConfig(readText(paths.ocrConfigJson), paths.ocrConfigJson, messages);
|
|
1932
|
+
const result = repointDanglingKeyScripts(doc, current, messages);
|
|
1933
|
+
if (result.repointed.length === 0) return result.repointed;
|
|
1934
|
+
await writeConfigAtomic(paths.ocrConfigJson, renderConfigDoc(doc));
|
|
1935
|
+
return result.repointed;
|
|
1936
|
+
}),
|
|
1937
|
+
checked: true
|
|
1938
|
+
};
|
|
1939
|
+
} catch {
|
|
1940
|
+
return {
|
|
1941
|
+
repointed: [],
|
|
1942
|
+
checked: false
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1658
1946
|
//#endregion
|
|
1659
1947
|
//#region host.ts
|
|
1660
1948
|
const PLUGIN_DIR = import.meta.dirname;
|
|
@@ -1921,10 +2209,12 @@ const REVIEW_OUTPUT_SCHEMA = { oneOf: [
|
|
|
1921
2209
|
required: [
|
|
1922
2210
|
"total",
|
|
1923
2211
|
"bySeverity",
|
|
1924
|
-
"byCategory"
|
|
2212
|
+
"byCategory",
|
|
2213
|
+
"unknownSeverityCount"
|
|
1925
2214
|
],
|
|
1926
2215
|
properties: {
|
|
1927
2216
|
total: { type: "integer" },
|
|
2217
|
+
unknownSeverityCount: { type: "integer" },
|
|
1928
2218
|
bySeverity: {
|
|
1929
2219
|
type: "object",
|
|
1930
2220
|
additionalProperties: false,
|
|
@@ -2080,6 +2370,36 @@ const SESSION_OUTPUT_SCHEMA = {
|
|
|
2080
2370
|
hint: { type: "string" }
|
|
2081
2371
|
}
|
|
2082
2372
|
};
|
|
2373
|
+
/**
|
|
2374
|
+
* ocr 子进程一律按 `danger-full-access` 请求(官方按调用携带的 `SandboxExecutionPolicy`)。
|
|
2375
|
+
*
|
|
2376
|
+
* 为什么必须显式要:ocr CLI 的状态目录是 `$HOME/.opencodereview`(Go 侧 `os.UserHomeDir()`,
|
|
2377
|
+
* 无 `--config` 可改),落在 workspace 之外,而宿主沙箱的 `workspace-write` 只放行
|
|
2378
|
+
* workspaceRoot 与后端临时区——于是每次会话写入被 EPERM 拒绝。后果不是"少个功能"而是
|
|
2379
|
+
* **跑完的评审被丢掉**:同步调用在最后一步 `finalize session` 失败、对外整体报错;
|
|
2380
|
+
* 后台调用则落不了盘,作业列表、`--resume` 与进度可观测性同时失效。
|
|
2381
|
+
*
|
|
2382
|
+
* 为什么不给 `workspace-write` + 重定向 HOME 兜着:那样得把 `config.json` 搬进 workspace,
|
|
2383
|
+
* 密钥与状态管理跟着进仓,且换发布形态就要再搬一次——为绕开一条策略把用户目录写进用户仓。
|
|
2384
|
+
* owner 口径是「状态归 ocr 自己,围栏由本包显式放开」,故直接要 `danger-full-access`。
|
|
2385
|
+
*
|
|
2386
|
+
* **「能不能只放开需要写的那一条路径」:不能。** 官方按调用携带的 `SandboxPolicyRequest`
|
|
2387
|
+
* 只有 `session` 与 `mode` 两个字段(dsh-sandbox-policy 0.2.1-alpha.1 的
|
|
2388
|
+
* lib/types/index.d.ts:50),**没有**附加可写路径、没有自定义 HOME、没有目录级豁免。
|
|
2389
|
+
* 可选的收紧路径只有「改 ocr 自己的状态落点」或「等上游加字段」,两者都不是本包能在
|
|
2390
|
+
* 一次补丁里做完的事。故此处维持放开,但把边界写明:放开的是 ocr 这一个子进程的整棵
|
|
2391
|
+
* 文件系统写权限,不是宿主其它能力;且 ocr 读什么、写什么仍由上面那条 argv 闸与
|
|
2392
|
+
* 凭据链约束。
|
|
2393
|
+
*
|
|
2394
|
+
* `workspaceRoot` 在这个模式下不被消费,但官方要求「即使不消费也照样携带」(让调用方
|
|
2395
|
+
* 先解析一次策略、再选执行路径),故传 workdir 而不是编一个占位根。
|
|
2396
|
+
*/
|
|
2397
|
+
function ocrSandboxPolicy(workspaceRoot) {
|
|
2398
|
+
return {
|
|
2399
|
+
mode: "danger-full-access",
|
|
2400
|
+
workspaceRoot
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2083
2403
|
/** 失败详情:stderr 优先、空则退 stdout;先脱敏再截断(明文 key 绝不进工具错误
|
|
2084
2404
|
* ——错误文本会被持久化进会话日志)。省略标记是文案,故脱敏与截断的位置不变,
|
|
2085
2405
|
* 只把中间的连接标记换成当前语言的那一份。两半各用一个函数,因为它们是两种缺陷:
|
|
@@ -2100,7 +2420,8 @@ async function runForeground(host, command, workdir, run, signal) {
|
|
|
2100
2420
|
workdir,
|
|
2101
2421
|
timeoutMs: run.timeoutMs,
|
|
2102
2422
|
stdoutMaxBytes: run.stdoutMaxBytes,
|
|
2103
|
-
signal
|
|
2423
|
+
signal,
|
|
2424
|
+
sandboxPolicy: ocrSandboxPolicy(workdir)
|
|
2104
2425
|
});
|
|
2105
2426
|
const result = await (await host.shell.execute(spec)).result();
|
|
2106
2427
|
if (result.exitCode !== 0 || result.timedOut || result.aborted) {
|
|
@@ -2224,7 +2545,8 @@ async function startBackground(host, job, signal) {
|
|
|
2224
2545
|
workdir: job.workdir,
|
|
2225
2546
|
onExpiry: "none",
|
|
2226
2547
|
stdoutMaxBytes: job.stdoutMaxBytes,
|
|
2227
|
-
signal
|
|
2548
|
+
signal,
|
|
2549
|
+
sandboxPolicy: ocrSandboxPolicy(job.workdir)
|
|
2228
2550
|
});
|
|
2229
2551
|
const proc = await host.shell.execute(spec);
|
|
2230
2552
|
pruneOcrJobHistory(registry);
|
|
@@ -2278,7 +2600,8 @@ async function runCollect(host, command, workdir, run) {
|
|
|
2278
2600
|
command,
|
|
2279
2601
|
workdir,
|
|
2280
2602
|
timeoutMs: run.timeoutMs,
|
|
2281
|
-
stdoutMaxBytes: run.stdoutMaxBytes
|
|
2603
|
+
stdoutMaxBytes: run.stdoutMaxBytes,
|
|
2604
|
+
sandboxPolicy: ocrSandboxPolicy(workdir)
|
|
2282
2605
|
});
|
|
2283
2606
|
const result = await (await host.shell.execute(spec)).result();
|
|
2284
2607
|
const joined = result.stdout.text === "" || result.stderr.text === "" ? result.stdout.text + result.stderr.text : `${result.stdout.text}\n${result.stderr.text}`;
|
|
@@ -2480,6 +2803,26 @@ function pluginPaths(config, messages) {
|
|
|
2480
2803
|
function ocrHome(config) {
|
|
2481
2804
|
return ocrHomeOverride(readOcrConfigPath(config));
|
|
2482
2805
|
}
|
|
2806
|
+
/**
|
|
2807
|
+
* 调用 ocr 之前的一次性 api_key_cmd 指针自愈(lib/ocr-config.ts 的
|
|
2808
|
+
* ensureKeyScriptPointer)。
|
|
2809
|
+
*
|
|
2810
|
+
* 为什么必须在这一层而不是启动时:config.json 里的脚本路径是**写那一刻**的插件安装
|
|
2811
|
+
* 目录,而插件会被重装、换 profile(desktop / browser / 任意自定义名)、换发布形态。
|
|
2812
|
+
* 目录一变那条命令就悬空,ocr 侧报的是「resolve LLM endpoint 失败」,与真因毫无
|
|
2813
|
+
* 关系——五个工具一次性全挂,而配置文件里没有任何线索指向「本包被挪了地方」。
|
|
2814
|
+
* 当前路径一律取自 PLUGIN_DIR(import.meta.dirname,host.ts:181),与 profile 命名
|
|
2815
|
+
* 无关,故 desktop / browser / 自定义名走的是同一条路径。
|
|
2816
|
+
*
|
|
2817
|
+
* 与 ocrHome 同一口径:**不抛错**。pluginPaths 在坏设置下会抛,而那正是「工具照常
|
|
2818
|
+
* 可用、错误由端点侧报给卡片」的分工,不能因为一次自愈把工具拖死。
|
|
2819
|
+
*/
|
|
2820
|
+
async function healKeyScriptPointer(host, config) {
|
|
2821
|
+
const messages = localeMessages(host);
|
|
2822
|
+
try {
|
|
2823
|
+
await ensureKeyScriptPointer(pluginPaths(config, messages), messages);
|
|
2824
|
+
} catch {}
|
|
2825
|
+
}
|
|
2483
2826
|
/** 运行 ocr llm test 校验当前配置(select 后自动验证 / 手动测试共用)。
|
|
2484
2827
|
* 命令词与五个工具同一解析源(buildLlmTestCommand:随包 launcher 装法下 PATH 上
|
|
2485
2828
|
* 没有裸 `ocr`),HOME 随 ocrConfigPath 重定向。墙钟与输出上限都是 entry config
|
|
@@ -2817,6 +3160,7 @@ function registerReviewTool(host, config) {
|
|
|
2817
3160
|
const eff = effortOf(args["effort"], config, runMsgs);
|
|
2818
3161
|
const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-review-")) : void 0;
|
|
2819
3162
|
const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
|
|
3163
|
+
await healKeyScriptPointer(host, config);
|
|
2820
3164
|
const homeOverride = ocrHome(config);
|
|
2821
3165
|
try {
|
|
2822
3166
|
const { command, workdir } = buildReviewCommand({
|
|
@@ -2824,7 +3168,7 @@ function registerReviewTool(host, config) {
|
|
|
2824
3168
|
repo,
|
|
2825
3169
|
effort: eff
|
|
2826
3170
|
}, outPath, runMsgs);
|
|
2827
|
-
const guarded = wrapWithHostReaper(withHomeEnv(command, homeOverride));
|
|
3171
|
+
const guarded = wrapWithHostReaper(withHomeEnv(command, homeOverride), runMsgs);
|
|
2828
3172
|
if (outPath === void 0) return await startBackground(host, {
|
|
2829
3173
|
command: guarded,
|
|
2830
3174
|
workdir,
|
|
@@ -2909,13 +3253,14 @@ function registerScanTool(host, config) {
|
|
|
2909
3253
|
const repo = ocrRepo(args, exec, runMsgs);
|
|
2910
3254
|
const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-scan-")) : void 0;
|
|
2911
3255
|
const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
|
|
3256
|
+
await healKeyScriptPointer(host, config);
|
|
2912
3257
|
const homeOverride = ocrHome(config);
|
|
2913
3258
|
try {
|
|
2914
3259
|
const { command, workdir } = buildScanCommand({
|
|
2915
3260
|
...args,
|
|
2916
3261
|
repo
|
|
2917
3262
|
}, outPath, runMsgs);
|
|
2918
|
-
const guarded = wrapWithHostReaper(withHomeEnv(command, homeOverride));
|
|
3263
|
+
const guarded = wrapWithHostReaper(withHomeEnv(command, homeOverride), runMsgs);
|
|
2919
3264
|
if (outPath === void 0) return await startBackground(host, {
|
|
2920
3265
|
command: guarded,
|
|
2921
3266
|
workdir,
|
|
@@ -2992,6 +3337,7 @@ function registerDelegatePreviewTool(host, config) {
|
|
|
2992
3337
|
run: async (args, exec) => {
|
|
2993
3338
|
const runMsgs = localeMessages(host);
|
|
2994
3339
|
const repo = ocrRepo(args, exec, runMsgs);
|
|
3340
|
+
await healKeyScriptPointer(host, config);
|
|
2995
3341
|
const homeOverride = ocrHome(config);
|
|
2996
3342
|
const { command, workdir } = buildDelegatePreviewCommand({
|
|
2997
3343
|
...args,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jayyuen66/dsh-ocr-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4-rc",
|
|
4
4
|
"description": "dsh 工作区代码审查插件:把 open-code-review(ocr CLI)封装为 5 个模型工具 + 设置卡片(从 settings.yaml 选 provider/model 应用到 OCR 配置)。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "JayYuen",
|
|
@@ -28,39 +28,39 @@
|
|
|
28
28
|
"registry": "https://registry.npmjs.org"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@
|
|
32
|
-
"@deepseek-ai/dsh-credentials": "0.2.0-rc.2",
|
|
33
|
-
"@deepseek-ai/dsh-home-paths": "0.2.0-rc.2",
|
|
34
|
-
"@deepseek-ai/schemastery": "3.18.4",
|
|
35
|
-
"@jayyuen66/dsh-plugin-shared": "^0.1.0",
|
|
31
|
+
"@jayyuen66/dsh-plugin-shared": "^0.1.3",
|
|
36
32
|
"js-yaml": "^5.4.2"
|
|
37
33
|
},
|
|
38
34
|
"devDependencies": {
|
|
39
|
-
"@deepseek-ai/cordis": "4.0.
|
|
40
|
-
"@deepseek-ai/dsh-agent": "0.2.
|
|
41
|
-
"@deepseek-ai/dsh-api-remotes": "0.2.
|
|
42
|
-
"@deepseek-ai/dsh-
|
|
43
|
-
"@deepseek-ai/dsh-client-
|
|
44
|
-
"@deepseek-ai/dsh-client-
|
|
45
|
-
"@deepseek-ai/dsh-client-ui-
|
|
46
|
-
"@deepseek-ai/dsh-client-ui-
|
|
47
|
-
"@deepseek-ai/dsh-client-ui-
|
|
48
|
-
"@deepseek-ai/dsh-client-ui-
|
|
49
|
-
"@deepseek-ai/dsh-
|
|
50
|
-
"@deepseek-ai/dsh-
|
|
51
|
-
"@deepseek-ai/dsh-
|
|
52
|
-
"@deepseek-ai/dsh-
|
|
53
|
-
"@deepseek-ai/dsh-
|
|
54
|
-
"@deepseek-ai/dsh-
|
|
55
|
-
"@deepseek-ai/dsh-
|
|
56
|
-
"@deepseek-ai/dsh-
|
|
57
|
-
"@deepseek-ai/dsh-
|
|
58
|
-
"@deepseek-ai/dsh-
|
|
59
|
-
"@deepseek-ai/dsh-
|
|
60
|
-
"@deepseek-ai/dsh-
|
|
61
|
-
"@deepseek-ai/dsh-
|
|
62
|
-
"@deepseek-ai/dsh-
|
|
63
|
-
"@deepseek-ai/dsh-
|
|
35
|
+
"@deepseek-ai/cordis": "4.0.5-alpha.1",
|
|
36
|
+
"@deepseek-ai/dsh-agent": "0.2.1-alpha.1",
|
|
37
|
+
"@deepseek-ai/dsh-api-remotes": "0.2.1-alpha.1",
|
|
38
|
+
"@deepseek-ai/dsh-atomic-write": "0.2.1-alpha.1",
|
|
39
|
+
"@deepseek-ai/dsh-client-locale": "0.2.1-alpha.1",
|
|
40
|
+
"@deepseek-ai/dsh-client-store": "0.2.1-alpha.1",
|
|
41
|
+
"@deepseek-ai/dsh-client-ui-layout": "0.2.1-alpha.1",
|
|
42
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-alpha.1",
|
|
43
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-alpha.1",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.2.1-alpha.1",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-alpha.1",
|
|
46
|
+
"@deepseek-ai/dsh-credentials": "0.2.1-alpha.1",
|
|
47
|
+
"@deepseek-ai/dsh-home-paths": "0.2.1-alpha.1",
|
|
48
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.1-alpha.1",
|
|
49
|
+
"@deepseek-ai/dsh-jobs": "0.2.1-alpha.1",
|
|
50
|
+
"@deepseek-ai/dsh-jobs-local": "0.2.1-alpha.1",
|
|
51
|
+
"@deepseek-ai/dsh-package-manifest": "0.2.1-alpha.1",
|
|
52
|
+
"@deepseek-ai/dsh-ptc-runtime": "0.2.1-alpha.1",
|
|
53
|
+
"@deepseek-ai/dsh-sandbox": "0.2.1-alpha.1",
|
|
54
|
+
"@deepseek-ai/dsh-scope": "0.2.1-alpha.1",
|
|
55
|
+
"@deepseek-ai/dsh-session": "0.2.1-alpha.1",
|
|
56
|
+
"@deepseek-ai/dsh-session-projection": "0.2.1-alpha.1",
|
|
57
|
+
"@deepseek-ai/dsh-settings": "0.2.1-alpha.1",
|
|
58
|
+
"@deepseek-ai/dsh-shell": "0.2.1-alpha.1",
|
|
59
|
+
"@deepseek-ai/dsh-subprocess": "0.2.1-alpha.1",
|
|
60
|
+
"@deepseek-ai/dsh-system-prompt": "0.2.1-alpha.1",
|
|
61
|
+
"@deepseek-ai/dsh-tools": "0.2.1-alpha.1",
|
|
62
|
+
"@deepseek-ai/dsh-user-approval": "0.2.1-alpha.1",
|
|
63
|
+
"@deepseek-ai/schemastery": "3.18.5-alpha.1",
|
|
64
64
|
"@types/node": "26.6.3",
|
|
65
65
|
"@types/react": "19.3.0",
|
|
66
66
|
"@vitest/coverage-v8": "5.0.2",
|
|
@@ -75,7 +75,11 @@
|
|
|
75
75
|
"vitest": "5.0.2"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
|
-
"@deepseek-ai/dsh": "^0.2.
|
|
78
|
+
"@deepseek-ai/dsh": "^0.2.1-alpha.1",
|
|
79
|
+
"@deepseek-ai/dsh-atomic-write": "0.2.1-alpha.1",
|
|
80
|
+
"@deepseek-ai/dsh-credentials": "0.2.1-alpha.1",
|
|
81
|
+
"@deepseek-ai/dsh-home-paths": "0.2.1-alpha.1",
|
|
82
|
+
"@deepseek-ai/schemastery": "3.18.5-alpha.1"
|
|
79
83
|
},
|
|
80
84
|
"peerDependenciesMeta": {
|
|
81
85
|
"@deepseek-ai/dsh": {
|
|
@@ -87,7 +91,7 @@
|
|
|
87
91
|
},
|
|
88
92
|
"icon": "icon.svg",
|
|
89
93
|
"engines": {
|
|
90
|
-
"dsh": ">=0.2.
|
|
94
|
+
"dsh": ">=0.2.1-alpha.1"
|
|
91
95
|
},
|
|
92
96
|
"dsh": {
|
|
93
97
|
"manifestVersion": 1,
|
package/scripts/get-cred.mjs
CHANGED
|
@@ -97,6 +97,35 @@ function isNotFound(error) {
|
|
|
97
97
|
* @param {string} refName
|
|
98
98
|
* @returns {string | undefined}
|
|
99
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* YAML 解析错误的**脱敏**描述:只留 reason + 行列定位,不带出错位置前后若干行的原文。
|
|
102
|
+
*
|
|
103
|
+
* 为什么必须自己拼:js-yaml 的 YAMLException 有两处会把 mark.snippet(出错点邻域的
|
|
104
|
+
* **原文**)附在文本后面——构造函数里的 `this.message = formatError(this, false)`,以及
|
|
105
|
+
* `toString()` 在 compact 为假时的分支。而 .credentials.yaml 的 refs.<REF> 里放的就是
|
|
106
|
+
* 明文 key,于是一处缩进/引号笔误就会把邻近行的真实 key 写进 stderr(OCR 会记录
|
|
107
|
+
* api_key_cmd 的 stderr,也可能进宿主日志),直接违反「明文只在取用时出现一次」。
|
|
108
|
+
* 实测 `String(err)` 与 `err.message` 两条路都会带出 snippet,只有 toString(true)
|
|
109
|
+
* (紧凑格式)是干净的。
|
|
110
|
+
*
|
|
111
|
+
* @param {unknown} error
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function yamlErrorDetail(error) {
|
|
115
|
+
if (error instanceof Error) {
|
|
116
|
+
// js-yaml 的 YAMLException 覆盖了 toString(compact):传 true 走紧凑分支。
|
|
117
|
+
// 别的 Error 会忽略这个参数,返回普通的 "Name: message",同样不含 snippet。
|
|
118
|
+
const compact = error.toString(true);
|
|
119
|
+
if (typeof compact === "string" && compact !== "") {
|
|
120
|
+
return compact;
|
|
121
|
+
}
|
|
122
|
+
return error.message;
|
|
123
|
+
}
|
|
124
|
+
// 兜底也不能用 String(error):error 是 unknown,非字符串时会被隐式走
|
|
125
|
+
// Object.prototype.toString,产出 "[object Object]" 这种毫无信息量的文本。
|
|
126
|
+
return error instanceof Error ? error.message : "";
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
function fromCredentialsDoc(credPath, refName) {
|
|
101
130
|
let text;
|
|
102
131
|
try {
|
|
@@ -113,7 +142,7 @@ function fromCredentialsDoc(credPath, refName) {
|
|
|
113
142
|
try {
|
|
114
143
|
doc = load(text);
|
|
115
144
|
} catch (error) {
|
|
116
|
-
problems.push(`凭证文件不是合法 YAML:${credPath} ${
|
|
145
|
+
problems.push(`凭证文件不是合法 YAML:${credPath} ${yamlErrorDetail(error)}`);
|
|
117
146
|
}
|
|
118
147
|
}
|
|
119
148
|
const refs = isRecord(doc) ? doc.refs : undefined;
|