@jayyuen66/dsh-ocr-review 0.1.4-rc.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,14 +13,14 @@ English · [中文](README.zh-CN.md)
13
13
  ## Prerequisites (external CLI)
14
14
 
15
15
  - This package imports no OCR library, it only spawns processes: `ocr review` / `ocr scan` / `ocr delegate preview` / `ocr delegate rule` / `ocr session` / `ocr llm test`.
16
- - Commands are built against the contract verified line-by-line on the v1.12.x source (header comments of `lib/cli.ts` and `lib/parse.ts`), shaped like `ocr review --audience agent --format json --effort medium --output <temp file>`.
16
+ - Commands are built against the contract verified line-by-line on the v1.12.13 source (header comments of `lib/cli.ts` and `lib/parse.ts`; the `optionalDependencies` pin is `^1.12.13`), shaped like `ocr review --audience agent --format json --effort medium --output <temp file>`.
17
17
  - Multi-value `--exclude`/`--path` are merged into one comma-separated flag, and `--format` only accepts json/text/sarif.
18
- - Command resolution order: the launcher bundled with the package first - `@alibaba-group/open-code-review` (an `optionalDependencies` entry, whose own optionalDependencies pick the platform binary) resolved to the absolute path of its `bin/ocr.js` - and only when that does not resolve does it fall back to a bare `ocr` on PATH. Both installation shapes keep working, so installing this plugin is enough to get a working tool without a separate brew / npm i -g step. Resolution is lazy and memoized once per process (the same shape dsh core uses to resolve `@vscode/ripgrep`); a failed resolve never throws at load time, which would take the whole plugin offline over one optional binary.
18
+ - Command resolution order: the launcher bundled with the package first - `@alibaba-group/open-code-review` (an `optionalDependencies` entry, whose own optionalDependencies pick the platform binary) resolved to the absolute path of its `bin/ocr.js` - and only when that does not resolve does it fall back to a bare `ocr` on PATH. Both installation shapes keep working, so installing this plugin is enough to get a working tool without a separate brew / npm i -g step. Resolution is lazy and memoized once per process (the same shape dsh core uses to resolve `@vscode/ripgrep`); a failed resolve never throws at load time, which would take the whole plugin offline over one optional binary. On Windows the bundled launcher is skipped outright (`bin/ocr.js` is a shebang script that pwsh/cmd cannot execute), so the bare `ocr` on PATH is the only supported shape there.
19
19
  - With neither path available the child exits 127 and the tool reports "ocr command not found", offering `brew install open-code-review` or `npm i -g @alibaba-group/open-code-review`.
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.
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` - and the `HOME=` assignment prefix behind a custom `ocrConfigPath` is POSIX-only too. None of that exists on Windows: the package runs the bare command there (guarded by `process.platform`), skips the bundled launcher (a bare `ocr` on PATH is required), and writes the `api_key_cmd` with double-quote escaping because ocr runs credential commands through `cmd.exe` on Windows. The cost is that a hard host exit can leave an orphaned OCR process on Windows.
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.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).
23
+ - The host must be `>= 0.2.1-alpha.2`: declared in `peerDependencies` and in `engines.dsh` (same value, read by nothing).
24
24
 
25
25
  ## Installation
26
26
 
@@ -29,7 +29,7 @@ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
29
29
  ```
30
30
 
31
31
  - The packages are on the public npm registry, so installation needs no credentials.
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.
32
+ - The published files are only `host.js`, `client.js`, `cordis.patch.yml`, `scripts` and `THIRD_PARTY_NOTICES.md` (third-party licenses of the bundled runtime dependencies; `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
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.
@@ -105,10 +105,11 @@ Three deployment-level tuning keys are deliberately **not** on the settings card
105
105
 
106
106
  | Aspect | Fact |
107
107
  | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
- | Credential form in the config | What lands in the config is a command, not a secret: `api_key_cmd` = `node '<path of scripts/get-cred.mjs in this package>' '<REF>' '<dsh data dir>'` (all three parts single-quote escaped; the third is the host's current `resolveDshHome()` result, so get-cred's fallback layers stay correct when HOME is redirected or the dsh home is set outside the env), and the REF name must match `^[A-Z0-9_]+$` or the command is refused |
109
- | How writes happen | Atomic replacements via the official `@deepseek-ai/dsh-atomic-write`: a random-suffixed `.<hex>.tmp` sibling created with `wx`, then rename, with the new inode carrying 0600 (parent directories 0700). The `.bak` backup is copied by this package **before** the rename and chmod-ed to 0600 (the official helper has no backup semantics). The whole read→decide→write span is serialized by the official `withFileLock`, so a short-lived `config.json.lock` appears in that directory during a write and is removed in `finally`; a lock left by a crashed holder is taken over via a pid liveness probe. **Known inconsistency**: contention beyond the official default 2s wait makes the write throw `atomic-write: timed out waiting for the writer lock at …`, and the endpoint answers 400 with that English text verbatim — it comes from the official package and bypasses `lib/messages.ts`, unlike every other user-visible error here. Before this swap the read-modify-write shared one event-loop tick, so this failure mode did not exist; mapping it would need an injectable wait budget plus a timeout test, so this package documents it instead of silently widening the surface. An existing config that is invalid JSON or whose top level is not an object aborts the write instead of "treating it as empty and overwriting" |
110
- | `POST migrate` | Replaces the plaintext `api_key` of custom provider entries with `api_key_cmd` and deletes the plaintext; entries whose `apiKeyEnv` is not found in settings are left untouched and listed in `skippedUnknown`; nothing is written when nothing was migrated |
111
- | `scripts/get-cred.mjs` | Spawned by the external ocr CLI itself and the only place in this package still reading host credential files. The data directory is resolved as explicit third argument > `$DSH_HOME` > `~/.dsh`, and values are looked up in inherited process environment > `refs.<REF>` of `<dsh data dir>/.credentials.yaml` > `<dsh data dir>/.env`. The project `.env` of the reviewed repository is deliberately not read; when all three layers miss it exits non-zero and names every location it checked |
108
+ | Credential form in the config | What lands in the config is a command, not a secret: `api_key_cmd` = `node '<path of scripts/get-cred.mjs in this package>' '<REF>' '<dsh data dir>'` (POSIX, single-quote escaped) or `node "<path>" "<REF>" "<dsh data dir>"` (Windows, double-quote escaped - ocr runs credential commands through `cmd.exe` there); the third part is the host's current `resolveDshHome()` result, so get-cred's fallback layers stay correct when HOME is redirected or the dsh home is set outside the env, and the REF name must match `^[A-Z0-9_]+$` or the command is refused |
109
+ | Reachability gate | `/select` and `/migrate` refuse a key whose credential source OCR cannot reach at runtime: dsh strips variables named `KEY`/`PASSWORD`/`SECRET`/`TOKEN` or prefixed `DSH_` from spawned shells (`scrubbedParentEnv`), and get-cred deliberately does not read the reviewed repository's `.env`. Keys that only live in the dsh process environment or the workspace `.env` therefore resolve on the host side yet fail at OCR runtime - previously a silent "select succeeded → OCR 401". Sources `file` (`$DSH_HOME/.credentials.yaml`) and `user-env` (`$DSH_HOME/.env`) pass; `env` passes only when the ref name matches none of the scrubbed patterns; `project-env` never passes. The refusal happens before any write, with the fix named in the error text (store the key in the managed credentials file or `$DSH_HOME/.env`) |
110
+ | How writes happen | Atomic replacements via the official `@deepseek-ai/dsh-atomic-write`: a random-suffixed `.<hex>.tmp` sibling created with `wx`, then rename, with the new inode carrying 0600 (parent directories 0700). The `.bak` backup is copied by this package **before** the rename and chmod-ed to 0600 (the official helper has no backup semantics). The whole read→decide→write span is serialized by the official `withFileLock`, so a short-lived `config.json.lock` appears in that directory during a write and is removed in `finally`; a lock left by a crashed holder is taken over via a pid liveness probe. **Known inconsistency**: contention beyond the official default 2s wait makes the write throw `atomic-write: timed out waiting for the writer lock at …`, and the endpoint answers 400 with that English text verbatim — it comes from the official package and bypasses `lib/messages.ts`, unlike every other user-visible error here. Before this swap the read-modify-write shared one event-loop tick, so this failure mode did not exist; mapping it would need an injectable wait budget plus a timeout test, so this package documents it instead of silently widening the surface. An existing config that is invalid JSON or whose top level is not an object aborts the write instead of "treating it as empty and overwriting" |
111
+ | `POST migrate` | Replaces the plaintext `api_key` of custom provider entries with `api_key_cmd` and deletes the plaintext; entries whose key source is unreachable at OCR runtime (same gate as `/select`) or whose `apiKeyEnv` is not found in settings are left untouched and listed in `skippedUnknown`; nothing is written when nothing was migrated |
112
+ | `scripts/get-cred.mjs` | Spawned by the external ocr CLI itself and the only place in this package still reading host credential files. The data directory is resolved as explicit third argument > `$DSH_HOME` > `~/.dsh`, and values are looked up in inherited process environment > `refs.<REF>` of `<dsh data dir>/.credentials.yaml` > `<dsh data dir>/.env` - note the inherited layer is usually empty for credential refs, because dsh scrubs `KEY`/`PASSWORD`/`SECRET`/`TOKEN`- and `DSH_*`-named variables out of spawned shells. The project `.env` of the reviewed repository is deliberately not read; when all three layers miss it exits non-zero and names every location it checked |
112
113
 
113
114
  ## Public endpoints
114
115
 
package/README.zh-CN.md CHANGED
@@ -12,14 +12,14 @@
12
12
  ## 前置条件(外部 CLI)
13
13
 
14
14
  - 本包不 import 任何 OCR 库,只起进程:`ocr review` / `ocr scan` / `ocr delegate preview` / `ocr delegate rule` / `ocr session` / `ocr llm test`。
15
- - 命令按 v1.12.x 源码逐一核对(`lib/cli.ts`、`lib/parse.ts` 头注释),形如 `ocr review --audience agent --format json --effort medium --output <临时文件>`。
15
+ - 命令按 v1.12.13 源码逐一核对(`lib/cli.ts`、`lib/parse.ts` 头注释;`optionalDependencies` 钉 `^1.12.13`),形如 `ocr review --audience agent --format json --effort medium --output <临时文件>`。
16
16
  - 多值 `--exclude`/`--path` 合并成一个逗号分隔 flag,`--format` 只认 json/text/sarif。
17
- - 命令解析优先级:先解析**随包装上**的 `@alibaba-group/open-code-review`(`optionalDependencies`,平台二进制由它自己的 optionalDependencies 选一),取其 `bin/ocr.js` 绝对路径;解析不到才回落到 PATH 上的裸 `ocr`——两种装法都继续支持,装上本插件即工具可用,不必用户自己再 brew/npm i -g 一遍。解析是惰性的且每进程只做一次(与 dsh 核心解析 `@vscode/ripgrep` 同款),解析失败不在装载期抛,否则整包会因一个可选二进制而下线。
17
+ - 命令解析优先级:先解析**随包装上**的 `@alibaba-group/open-code-review`(`optionalDependencies`,平台二进制由它自己的 optionalDependencies 选一),取其 `bin/ocr.js` 绝对路径;解析不到才回落到 PATH 上的裸 `ocr`——两种装法都继续支持,装上本插件即工具可用,不必用户自己再 brew/npm i -g 一遍。解析是惰性的且每进程只做一次(与 dsh 核心解析 `@vscode/ripgrep` 同款),解析失败不在装载期抛,否则整包会因一个可选二进制而下线。Windows 上随包 launcher 整体跳过(`bin/ocr.js` 是 shebang 脚本,pwsh/cmd 无法直执行),PATH 上的全局 `ocr` 是 Windows 唯一受支持的装法。
18
18
  - 两条路径都没有时子进程 exit 127,工具报「ocr 命令不存在」并给出 `brew install open-code-review` 或 `npm i -g @alibaba-group/open-code-review`。
19
- - 平台:目标平台是 macOS / Linux。进程收割(`ocr_review` / `ocr_scan` 的 reaper 守护)依赖 POSIX 工具链——bash 的 trap 与作业控制,加上 `ps` / `pgrep` / `kill` 的进程组语义。Windows 上这层整体缺席,本包按 `process.platform` 直接放行裸命令(不收割但能跑);代价是宿主硬退出后 OCR 可能留下孤儿进程。
19
+ - 平台:目标平台是 macOS / Linux。进程收割(`ocr_review` / `ocr_scan` 的 reaper 守护)依赖 POSIX 工具链——bash 的 trap 与作业控制,加上 `ps` / `pgrep` / `kill` 的进程组语义;自定义 `ocrConfigPath` 背后的 `HOME=` 赋值前缀同样是 POSIX 专属。Windows 上这层整体缺席:本包按 `process.platform` 直接放行裸命令(不收割但能跑),并跳过随包 launcher(Windows 需 PATH 上的全局 `ocr`);`api_key_cmd` 在 Windows 按双引号转义写出(ocr 在那边经 `cmd.exe` 跑凭据命令)。代价是宿主硬退出后 OCR 可能留下孤儿进程。
20
20
  - provider 清单与 key 状态走宿主官方通道 `ctx.settings.describe()`(`llm-pi-ai` 那条的 `value.providers`)与 `ctx.credentials`。
21
21
  - 两者缺席时卡片显示降级原因,工具与设置照常可用。
22
- - 宿主版本要求 `>= 0.2.1-alpha.1`:写在 `peerDependencies`(0.1.7-rc 起宿主装插件时校验它;alpha.1 还没有这道门)与 `engines.dsh`(同值、无人读)。
22
+ - 宿主版本要求 `>= 0.2.1-alpha.2`:写在 `peerDependencies` 与 `engines.dsh`(同值、无人读)。
23
23
 
24
24
  ## 安装
25
25
 
@@ -28,7 +28,7 @@ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
28
28
  ```
29
29
 
30
30
  - 包在公共 npm 上,安装不需要凭据。
31
- - 发布面只含 `host.js`、`client.js`、`cordis.patch.yml`、`scripts`(`prepack` 重建两个 bundle),源码仓见 package.json 的 `repository.url`。
31
+ - 发布面只含 `host.js`、`client.js`、`cordis.patch.yml`、`scripts` 与 `THIRD_PARTY_NOTICES.md`(随包运行时依赖的第三方许可证;`prepack` 重建两个 bundle),源码仓见 package.json 的 `repository.url`。
32
32
 
33
33
  ## 安装时 pnpm 拦下依赖脚本(ERR_PNPM_IGNORED_BUILDS)
34
34
 
@@ -103,10 +103,11 @@ allowBuilds:
103
103
 
104
104
  | 面 | 事实 |
105
105
  | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
106
- | config 里的凭据形态 | 写进去的是命令不是明文:`api_key_cmd` = `node '<本包 scripts/get-cred.mjs 的路径>' '<REF>' '<dsh 数据目录>'`(三段都过单引号转义;第三段是宿主此刻 `resolveDshHome()` 的解析值,HOME 重定向或非 env 指定 dsh home 时 get-cred 的兜底层才不会跑偏),REF 名必须匹配 `^[A-Z0-9_]+$`,否则拒绝构造 |
106
+ | config 里的凭据形态 | 写进去的是命令不是明文:POSIX 上 `api_key_cmd` = `node '<本包 scripts/get-cred.mjs 的路径>' '<REF>' '<dsh 数据目录>'`(三段都过单引号转义);Windows 上按双引号转义(ocr 在那边经 `cmd.exe` 跑凭据命令,单引号是普通字符)。第三段是宿主此刻 `resolveDshHome()` 的解析值,HOME 重定向或非 env 指定 dsh home 时 get-cred 的兜底层才不会跑偏;REF 名必须匹配 `^[A-Z0-9_]+$`,否则拒绝构造 |
107
+ | 可达性闸 | `/select` 与 `/migrate` 会拒绝「OCR 运行时取不到」的 key:dsh 起 shell 会剥掉名字命中 `KEY`/`PASSWORD`/`SECRET`/`TOKEN` 或 `DSH_` 前缀的环境变量(`scrubbedParentEnv`),get-cred 也刻意不读被审查仓库的 `.env`——只存在于 dsh 进程环境或工作区 `.env` 的 key 在宿主侧解析得到、OCR 运行时却取不到,旧行为是「select 成功 → OCR 401」的假绿。`file`(`$DSH_HOME/.credentials.yaml`)与 `user-env`(`$DSH_HOME/.env`)两层放行;`env` 仅当 ref 名不被净化模式命中才放行;`project-env` 一律不放行。拒绝发生在写盘之前,错误文本写明补救方式(把 key 存入托管凭据或 `$DSH_HOME/.env`) |
107
108
  | 写入方式 | 只走原子替换(交官方 `@deepseek-ai/dsh-atomic-write`):同目录随机后缀 `.<hex>.tmp` → rename,新文件权限 0600 由新 inode 携带、父目录 0700;备份 `.bak` 由本包在 rename **之前**复制并 chmod 0600;整段「读→判→写」由官方 `withFileLock` 串行化,RMW 期间同目录会短暂出现 `config.json.lock`(正常路径下 `finally` 删除,持锁进程崩溃时按 pid 探活接管);现有 config 是坏 JSON 或顶层非对象时中止写入,绝不「按空配置覆盖」。**已知不一致**:并发争用超过官方默认 2s 等待上限时,写会以 `atomic-write: timed out waiting for the writer lock at …` 抛出并回 400 JSON,这条文本来自官方件、不走 `lib/messages.ts`(本包其余用户可见错误都在消息表里);换装前 RMW 同处一个 tick、没有超时这条路,是异步化换来的新失败模式,映射它需要给锁等待时间加注入缝并为 2s 超时写测试,本包选择记录而不是静默扩面 |
108
- | `POST migrate` | 把 custom provider 条目里的明文 `api_key` 换成 `api_key_cmd` 并删除明文;settings 里查不到 `apiKeyEnv` 的条目原样不动、列进 `skippedUnknown`;一条都没迁移成功时不写盘 |
109
- | `scripts/get-cred.mjs` | 由外部 ocr CLI 自己起进程调用,是本包唯一还在读宿主凭据文件的地方。数据目录按「命令行第三参数 > `$DSH_HOME` > `~/.dsh`」定位,取值层序为继承来的进程环境变量 > `<dsh 数据目录>/.credentials.yaml` 的 `refs.<REF>` > `<dsh 数据目录>/.env`。刻意不读被审查仓库的项目 `.env`;三层全落空即非零退出,并把查过的位置一并报出 |
109
+ | `POST migrate` | 把 custom provider 条目里的明文 `api_key` 换成 `api_key_cmd` 并删除明文;key 所在层在 OCR 运行时不可达(与 `/select` 同一道闸)或 settings 里查不到 `apiKeyEnv` 的条目原样不动、列进 `skippedUnknown`;一条都没迁移成功时不写盘 |
110
+ | `scripts/get-cred.mjs` | 由外部 ocr CLI 自己起进程调用,是本包唯一还在读宿主凭据文件的地方。数据目录按「命令行第三参数 > `$DSH_HOME` > `~/.dsh`」定位,取值层序为继承来的进程环境变量 > `<dsh 数据目录>/.credentials.yaml` 的 `refs.<REF>` > `<dsh 数据目录>/.env`——注意「继承环境」这一层对凭据 ref 实际常为空:dsh 起 shell 会剥掉名字含 `KEY`/`PASSWORD`/`SECRET`/`TOKEN` 或 `DSH_` 前缀的变量。刻意不读被审查仓库的项目 `.env`;三层全落空即非零退出,并把查过的位置一并报出 |
110
111
 
111
112
  ## 对外接口
112
113
 
@@ -0,0 +1,56 @@
1
+ # Third-party notices
2
+
3
+ 本包自身编写的代码以 MIT 发布,见 [`LICENSE`](./LICENSE)。本文件记录**与第三方的边界**:
4
+ 哪一部分来自第三方、在什么意义上「来自」,以及本包自身代码与第三方代码的分界在哪里。
5
+
6
+ ## 结论:随包分发的第三方内容只有一处
7
+
8
+ `host.ts`、`lib/`、`src/`、`scripts/` 下全部是本仓原创代码,不含任何第三方源码或文案。
9
+ `@alibaba-group/open-code-review`(外部 `ocr` CLI)与 `js-yaml` 都是**运行期依赖**——本包
10
+ 通过它们的公开入口调用它们,许可文本随各自的包走,本文件不重复其文本;见下面的运行期依赖表。
11
+
12
+ 唯一随包分发的第三方内容是**图标**(`icon.svg`),见下节。
13
+
14
+ ## 图标:Tabler Icons
15
+
16
+ - 上游项目:`Tabler Icons`
17
+ - 上游仓库:https://github.com/tabler/tabler-icons
18
+ - 上游许可:MIT,Copyright (c) Paweł Kuna
19
+ - 取用图标:`code`(https://icones.js.org/icons/tabler/code)
20
+
21
+ `icon.svg` 的形状取自该图标的**两枚尖括号**:坐标整体平移(+1, −1)并去掉中间的斜杠笔画,
22
+ 另加一枚对勾段(形态对齐 Tabler `check` 的笔画风格)。改动都不引入新的第三方形状:把
23
+ `currentColor` 换成本包插件图标配色(`#658EFF` 主色 + `#54ECE7` 底衬),并把外层 `<g>` 与
24
+ 画布尺寸对齐本族插件统一的 `36×36` / `viewBox 0 0 24 24` 规格。
25
+
26
+ ## 机制与接口来源:open-code-review(ocr CLI)
27
+
28
+ - 上游项目:`open-code-review`
29
+ - 上游仓库:https://github.com/alibaba/open-code-review
30
+ - 上游许可:Apache-2.0,Copyright 2026 Alibaba
31
+ - 验证版本:1.12.13(`optionalDependencies` 钉 `^1.12.13`)
32
+
33
+ 本包**使用**而非**包含**以下接口(`host.ts` / `lib/cli.ts` 的头注释按该版本源码逐条核对):
34
+
35
+ | 接口 | 用途 |
36
+ | --- | --- |
37
+ | `ocr review` / `ocr scan` | diff 审查与全文件扫描(`--audience agent --format json --output` 机器可读面) |
38
+ | `ocr delegate preview` / `ocr delegate rule` | 委托模式脚手架(文件清单 + 规则分组,OCR 端零 LLM 消耗) |
39
+ | `ocr session list/show/comments` | 会话查询闭环(含 `--resume` 恢复) |
40
+ | `ocr llm test` | select 后的连通性验证 |
41
+ | `config.json` 的 `custom_providers.<name>.{url,protocol,model,api_key_cmd}` | provider/model 应用面;key 经 `api_key_cmd` 动态读取,绝不落明文 |
42
+
43
+ 这些都是 CLI 的外层面,但不是任何文档承诺过的稳定 API——本包把版本钉在 `^1.12.13` 正是为了
44
+ 守住这一组契约;上游变更时以本包的契约闸测试(`test/cli.test.ts`、`test/parse.test.ts`)为
45
+ 首道防线。
46
+
47
+ ## 运行期依赖
48
+
49
+ 运行期依赖的许可由各自包随包分发,本包不重复其文本:
50
+
51
+ | 依赖 | 版本 | 许可 | 引入方 |
52
+ | --- | --- | --- | --- |
53
+ | `@alibaba-group/open-code-review` | ^1.12.13 | Apache-2.0(Alibaba) | `optionalDependencies`;平台二进制由它自己的 `@alibaba-group/ocr-<os>-<arch>` 选一 |
54
+ | `js-yaml` | ^5.4.3 | MIT | `scripts/get-cred.mjs` 读 dsh 托管凭据文档 |
55
+
56
+ dsh 宿主包(`@deepseek-ai/dsh` 及其子包)是 peer 依赖,由宿主提供、不随本包分发。
package/client.js CHANGED
@@ -129,7 +129,7 @@ const UI_MESSAGES = {
129
129
  }
130
130
  };
131
131
  //#endregion
132
- //#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
132
+ //#region node_modules/.pnpm/@jayyuen66+dsh-plugin-shared@0.1.4_@deepseek-ai+cordis@4.0.5-alpha.1_@deepseek-ai+dsh-c_472a88c28a1052d4f20b6206ef930fa9/node_modules/@jayyuen66/dsh-plugin-shared/dist/record.js
133
133
  function e(e) {
134
134
  return typeof e == `object` && !!e && !Array.isArray(e);
135
135
  }
package/host.js CHANGED
@@ -27,9 +27,9 @@ const MESSAGES = {
27
27
  detailSeparator: ":",
28
28
  stdoutTruncated: "[ocr-review] 注意:输出超过 {limit} 字节上限已截断{spill}",
29
29
  outputSpill: "(完整输出已落盘:{path})",
30
- backgroundPolling: "先 job_output(job_id=<本回执的 jobId>) 读这条后台评审的实时输出与最终结局——按 id 走,启动即可读,一定有结果;要停它就 job_kill(job_id=<同一 id>)。会话落盘后再走 ocr_session:ocr_session(action=list) 找本仓库最新会话(启动几秒后出现,按 scanPaths/时间匹配);completed_files == selected_files 即已完成;再 ocr_session(action=comments, id) 取全部评论、ocr_session(action=show, id) 看元数据与逐文件检查点。后台运行不设宿主超时,只在 2 小时后兜底回收(挂死不动会一直占着官方作业桶),取消本工具调用即终止该后台 OCR(宿主 kill + 命令内回收守护)。",
30
+ backgroundPolling: "先 job_output(job_id=<本回执的 jobId>) 读这条后台评审的实时输出与最终结局——按 id 走,启动即可读,一定有结果;要停它就 job_kill(job_id=<同一 id>)。会话落盘后再走 ocr_session:ocr_session(action=list) 找本仓库最新会话(启动几秒后出现,按 scanPaths/时间匹配);completed_files == selected_files 即已完成;再 ocr_session(action=comments, id) 取全部评论、ocr_session(action=show, id) 看元数据与逐文件检查点。后台运行不设宿主超时,只在兜底窗口(ocrBackgroundMaxMs,默认 2 小时)到点后回收(挂死不动会一直占着官方作业桶),取消本工具调用即终止该后台 OCR(宿主 kill + 命令内回收守护)。",
31
31
  jobReasonAborted: "ocr-review 工具调用被中止",
32
- jobReasonTimeout: "ocr-review 后台评审超过 2 小时,兜底回收",
32
+ jobReasonTimeout: "ocr-review 后台评审超过兜底窗口({minutes} 分钟),兜底回收",
33
33
  jobReasonUnload: "ocr-review 插件卸载",
34
34
  jobOutputGap: "\n[ocr-review] 注意:有一次输出读取失败,已按原游标重试(这段可能不完整)",
35
35
  requiredNotArray: "parameters.required 必须是字符串数组",
@@ -83,6 +83,7 @@ const MESSAGES = {
83
83
  configPathLayoutInvalid: "设置项 ocrConfigPath 必须是 <X>/.opencodereview/config.json 布局:{path}。OCR 端没有配置文件级的路径覆盖(只按 <HOME>/.opencodereview/config.json 定位),本插件通过给 ocr 子进程注入 HOME=<X> 让它生效;任意形状的文件路径写下去 OCR 也不会读",
84
84
  noApiKeyEnv: "provider {provider} 未声明 apiKeyEnv,无法动态读取 key(OCR 自定义 provider 不支持无 key 解析)",
85
85
  keyNotResolvable: "provider {provider} 的 API key 未配置:dsh 凭据服务未解析到 {ref}",
86
+ keyNotReachable: "provider {provider} 的 API key 存在于凭据层「{source}」,OCR 运行时取不到它:dsh 起 shell 会剥掉名字含 KEY/PASSWORD/SECRET/TOKEN 或 DSH_ 前缀的环境变量,get-cred 也刻意不读被审查仓库的 .env。请把 key 存入 dsh 托管凭据($DSH_HOME/.credentials.yaml 的 refs.{ref})或 $DSH_HOME/.env 后重试",
86
87
  credScriptMissing: "get-cred 脚本缺失:{path}(api_key_cmd 无法工作)",
87
88
  reaperDegraded: "[ocr-review] 宿主回收守护已降级:ps/pgrep 不可用(进程信息命令被沙箱拒绝),改由宿主管进程树。dsh 硬退出时 ocr 可能留孤儿进程。",
88
89
  warningUnreadable: "(一条格式无法解析的 warning:既不是字符串,也没有 file/error 字段)",
@@ -108,7 +109,7 @@ const MESSAGES = {
108
109
  paramBackgroundPreview: "业务上下文(会出现在输出中供审查参考)",
109
110
  paramExclude: "gitignore 风格排除模式(可多值)",
110
111
  paramExcludeShort: "排除模式",
111
- paramWait: "true(缺省)=等待完成并返回聚合摘要;false=后台启动立即返回(2 小时后兜底回收),用 ocr_session 轮询结果。两种模式都带宿主回收守护:dsh 退出(含 kill -9)时 OCR 进程一并结束",
112
+ paramWait: "true(缺省)=等待完成并返回聚合摘要;false=后台启动立即返回(兜底窗口 ocrBackgroundMaxMs 默认 2 小时),用 ocr_session 轮询结果。两种模式都带宿主回收守护:dsh 退出(含 kill -9)时 OCR 进程一并结束",
112
113
  paramProvider: "本次运行临时指定已配置 provider",
113
114
  paramModel: "本次运行临时覆盖 model",
114
115
  paramResume: "从之前中断的会话 id 恢复(会话 id 在返回结果里)",
@@ -157,9 +158,9 @@ const MESSAGES = {
157
158
  detailSeparator: ": ",
158
159
  stdoutTruncated: "[ocr-review] note: output exceeded the {limit}-byte cap and was truncated{spill}",
159
160
  outputSpill: " (full output spilled to {path})",
160
- backgroundPolling: "First read this background review's live output and final outcome with job_output(job_id=<the jobId in this receipt>) — that path goes by id, so it is readable immediately after start and always has a result; job_kill(job_id=<the same id>) stops it. Once the session has landed, use ocr_session: ocr_session(action=list) finds this repository's newest session (it appears a few seconds after start; match by scanPaths/time); completed_files == selected_files means it is done. Then ocr_session(action=comments, id) for all comments and ocr_session(action=show, id) for metadata and per-file checkpoints. Background runs get reclaimed by a 2-hour backstop (a wedged one would otherwise keep occupying the official job bucket); cancelling this tool call terminates the background OCR (host kill + the reaper guard inside the command).",
161
+ backgroundPolling: "First read this background review's live output and final outcome with job_output(job_id=<the jobId in this receipt>) — that path goes by id, so it is readable immediately after start and always has a result; job_kill(job_id=<the same id>) stops it. Once the session has landed, use ocr_session: ocr_session(action=list) finds this repository's newest session (it appears a few seconds after start; match by scanPaths/time); completed_files == selected_files means it is done. Then ocr_session(action=comments, id) for all comments and ocr_session(action=show, id) for metadata and per-file checkpoints. Background runs get no host timeout; they are reclaimed when the backstop window (ocrBackgroundMaxMs, 2 hours by default) elapses (a wedged one would otherwise keep occupying the official job bucket); cancelling this tool call terminates the background OCR (host kill + the reaper guard inside the command).",
161
162
  jobReasonAborted: "ocr-review tool call aborted",
162
- jobReasonTimeout: "ocr-review background review exceeded 2 hours and was reclaimed",
163
+ jobReasonTimeout: "the ocr-review background review exceeded its backstop window ({minutes} minutes) and was reclaimed",
163
164
  jobReasonUnload: "ocr-review plugin unloaded",
164
165
  jobOutputGap: "\n[ocr-review] note: one output read failed and was retried from the same offset (this stretch may be incomplete)",
165
166
  requiredNotArray: "parameters.required must be an array of strings",
@@ -213,6 +214,7 @@ const MESSAGES = {
213
214
  configPathLayoutInvalid: "the ocrConfigPath setting must follow the <X>/.opencodereview/config.json layout: {path}. OCR has no file-level path override (it only looks at <HOME>/.opencodereview/config.json); this plugin makes it effective by injecting HOME=<X> into the ocr subprocess. A file path of any other shape would never be read by OCR",
214
215
  noApiKeyEnv: "provider {provider} declares no apiKeyEnv, so the key cannot be read dynamically (OCR custom providers do not support keyless resolution)",
215
216
  keyNotResolvable: "the API key of provider {provider} is not configured: the dsh credentials service resolved no {ref}",
217
+ keyNotReachable: "the API key of provider {provider} lives in the credential source \"{source}\", which OCR cannot reach at runtime: dsh strips variables named KEY/PASSWORD/SECRET/TOKEN or prefixed DSH_ from spawned shells, and get-cred deliberately does not read the reviewed repository's .env. Store the key in the dsh managed credentials ($DSH_HOME/.credentials.yaml refs.{ref}) or $DSH_HOME/.env and retry",
216
218
  credScriptMissing: "get-cred script missing: {path} (api_key_cmd cannot work)",
217
219
  reaperDegraded: "[ocr-review] host reaper degraded: ps/pgrep are unavailable (process-info commands denied by the sandbox), so the host manages the process tree. dsh may leave an orphaned ocr process.",
218
220
  warningUnreadable: "(one unreadable warning entry: neither a string nor an object with file/error)",
@@ -238,7 +240,7 @@ const MESSAGES = {
238
240
  paramBackgroundPreview: "Business context (it shows up in the output for review reference)",
239
241
  paramExclude: "gitignore-style exclusion patterns (multiple allowed)",
240
242
  paramExcludeShort: "Exclusion patterns",
241
- paramWait: "true (default) = wait for completion and return the aggregated summary; false = start in the background and return immediately (a 2-hour backstop reclaims it), poll with ocr_session. Both modes carry the host reaper guard: when dsh exits (including kill -9) the OCR process is terminated too",
243
+ paramWait: "true (default) = wait for completion and return the aggregated summary; false = start in the background and return immediately (reclaimed when the backstop window ocrBackgroundMaxMs, 2 hours by default, elapses), poll with ocr_session. Both modes carry the host reaper guard: when dsh exits (including kill -9) the OCR process is terminated too",
242
244
  paramProvider: "Temporarily use a configured provider for this run",
243
245
  paramModel: "Temporarily override the model for this run",
244
246
  paramResume: "Resume from a previously interrupted session id (the id is in the result)",
@@ -714,8 +716,11 @@ const memoBox = {};
714
716
  */
715
717
  function ocrCommand(locate = locateLauncher, memo = memoBox) {
716
718
  if (memo.value === void 0) {
717
- const launcher = locate();
718
- memo.value = launcher === null ? "ocr" : shq(launcher);
719
+ if (process.platform === "win32") memo.value = "ocr";
720
+ else {
721
+ const launcher = locate();
722
+ memo.value = launcher === null ? "ocr" : shq(launcher);
723
+ }
719
724
  }
720
725
  return memo.value;
721
726
  }
@@ -1509,8 +1514,17 @@ function readText(configPath) {
1509
1514
  return "";
1510
1515
  }
1511
1516
  }
1512
- /** 单引号转义(api_key_cmd 内嵌命令用,模块级纯函数)。 */
1517
+ /**
1518
+ * api_key_cmd 内嵌值的转义。POSIX 走单引号(ocr 在 Unix 用 `sh -c` 跑 api_key_cmd,
1519
+ * keycmd_unix.go);Windows 的 ocr 用 **cmd.exe** 跑它(keycmd_windows.go),单引号
1520
+ * 在那里是普通字符、命令必坏,故走双引号:值内 `"` 翻倍、收尾反斜杠再翻倍(防被
1521
+ * CommandLineToArgvW 当成转义吃掉收尾引号)。ref 名白名单 `^[A-Z0-9_]+$` 不含两种
1522
+ * 引号;脚本与数据目录是盘上常规路径。已知残留:cmd.exe 会对双引号内的 `%VAR%`
1523
+ * 做环境展开,路径含 `%` 的 Windows 部署需自行规避(Windows 非目标平台,此处只
1524
+ * 保证常规路径形态正确)。
1525
+ */
1513
1526
  function shqDyn(value) {
1527
+ if (process.platform === "win32") return `"${value.replaceAll("\"", "\"\"").replace(/\\+$/u, (backslashes) => backslashes + backslashes)}"`;
1514
1528
  return `'${value.replaceAll("'", String.raw`'\''`)}'`;
1515
1529
  }
1516
1530
  /** api_key_cmd 值构造:脚本路径与 env key 名(及可选的 dsh 数据目录)shq 转义后
@@ -1579,19 +1593,73 @@ function readShqTokens(body) {
1579
1593
  return malformed ? null : tokens;
1580
1594
  }
1581
1595
  /**
1582
- * api_key_cmd → 三个槽位;只认本包写出的那一种形态。
1596
+ * 读一个 cmd.exe 双引号串:解出它的值与结束位置(`""` 是翻倍的引号)。
1597
+ * 未闭合返回 null:宁可判「读不懂」,也不把半截命令当可信输入。
1598
+ */
1599
+ function readDqToken(body, start) {
1600
+ let value = "";
1601
+ let i = start + 1;
1602
+ let closed = false;
1603
+ while (i < body.length && !closed) {
1604
+ const ch = body[i];
1605
+ if (ch === "\"" && body[i + 1] === "\"") {
1606
+ value += "\"";
1607
+ i += 2;
1608
+ } else if (ch === "\"") {
1609
+ closed = true;
1610
+ i += 1;
1611
+ } else {
1612
+ value += ch;
1613
+ i += 1;
1614
+ }
1615
+ }
1616
+ if (!closed) return null;
1617
+ return {
1618
+ value: value.replace(/(?:\\\\)+$/u, (backslashes) => "\\".repeat(backslashes.length / 2)),
1619
+ next: i
1620
+ };
1621
+ }
1622
+ /**
1623
+ * cmd.exe 双引号形态的 token 列表(buildApiKeyCmd 的 win32 臂写出的那种:
1624
+ * `node "a" "b" "c"`);形态不符(裸词 / 未闭合)返回 null。
1625
+ *
1626
+ * 只在 readShqTokens 判 null 之后被调用,而那条路必有非空白内容 ⇒ 循环至少走一轮,
1627
+ * 解析成功的列表至少一枚 token;末尾的 `return tokens` 对实际可达的输入不可能交出
1628
+ * 空列表(parseApiKeyCmd 的 `script === ""` 闸对它另有兜底)。
1629
+ */
1630
+ function readDqTokens(body) {
1631
+ const tokens = [];
1632
+ let i = 0;
1633
+ while (i < body.length) {
1634
+ while (i < body.length && (body[i] === " " || body[i] === " ")) i += 1;
1635
+ if (i >= body.length) break;
1636
+ if (body[i] !== "\"") return null;
1637
+ const token = readDqToken(body, i);
1638
+ if (token === null) return null;
1639
+ tokens.push(token.value);
1640
+ i = token.next;
1641
+ }
1642
+ return tokens;
1643
+ }
1644
+ /**
1645
+ * api_key_cmd → 三个槽位;认本包写出的**两种**形态(POSIX 单引号与 Windows 双引号)。
1583
1646
  *
1584
1647
  * 为什么需要反解:写进 config.json 的脚本路径是**写那一刻**的插件安装目录(绝对
1585
1648
  * 路径),而插件会被重装、换 profile、换发布形态——目录一变,ocr 执行 api_key_cmd
1586
1649
  * 就是 MODULE_NOT_FOUND,而它把这件事报成「resolve LLM endpoint 失败」,错误完全
1587
1650
  * 指不到「本包被挪了地方」这个真因。没有反解就无从判断一条 api_key_cmd 是否还活着。
1588
1651
  *
1589
- * 为什么只认一种形态:认不出来的一律给 undefined,由调用方原样保留。改写一条读不
1590
- * 懂的命令串,可能把用户手写的凭据取用方式弄坏,而那正是本包明令不该插手的部分。
1652
+ * 为什么两种形态都认:config.json 可能跨机迁移(HOME 同步、换部署),反解不能假设
1653
+ * 「写它的平台 = 读它的平台」。POSIX 形态优先判(单引号);POSIX 判不出且整体是
1654
+ * 双引号形态时再走 cmd.exe 那一档。
1655
+ *
1656
+ * 为什么认不出来一律给 undefined:由调用方原样保留。改写一条读不懂的命令串,可能
1657
+ * 把用户手写的凭据取用方式弄坏,而那正是本包明令不该插手的部分。
1591
1658
  */
1592
1659
  function parseApiKeyCmd(command) {
1593
1660
  const body = command.replace(/^node[ \t]+/u, "");
1594
- const tokens = body === command ? null : readShqTokens(body);
1661
+ if (body === command) return null;
1662
+ const tokens = readShqTokens(body) ?? readDqTokens(body);
1595
1663
  if (tokens === null) return null;
1596
1664
  const script = tokens[0] ?? "";
1597
1665
  const envKey = tokens[1] ?? "";
@@ -1774,6 +1842,32 @@ function repointDanglingKeyScripts(doc, current, messages) {
1774
1842
  /** 外部 ocr CLI 的默认配置目录/文件名(宿主服务管不到,只能由本包直读直写)。 */
1775
1843
  const OCR_CONFIG_DIR = ".opencodereview";
1776
1844
  const OCR_CONFIG_FILE = "config.json";
1845
+ /**
1846
+ * 宿主起 shell 子进程时的**环境净化**模式(dsh-subprocess 的 SENSITIVE_ENV_PATTERN
1847
+ * `KEY|PASSWORD|SECRET|TOKEN` 大小写不敏感命中即剥,另加 `DSH_` 前缀全剥)。
1848
+ * 本包不传 `env`,OCR 子进程拿到的环境就是净化后的那一份——名字命中本模式的
1849
+ * 凭据 ref,OCR 及其孙进程(get-cred)在「继承环境」这一层永远读到空。
1850
+ */
1851
+ const HOST_SCRUBBED_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN|^DSH_/iu;
1852
+ /**
1853
+ * key 在 **OCR 运行时**是否真的取得到(get-cred 三层里有没有一层够得到它)。
1854
+ *
1855
+ * 宿主侧 resolve() 的 source 是「dsh 进程自己的取值层」,不等于「OCR 子进程够得到的
1856
+ * 层」——少了这道判定就是「select 成功 → OCR 401」的假绿:
1857
+ * - `file`($DSH_HOME/.credentials.yaml)→ get-cred 第 2 层,可达;
1858
+ * - `user-env`($DSH_HOME/.env)→ get-cred 第 3 层,可达;
1859
+ * - `env`(dsh 进程继承的环境)→ 只有 ref 名**不**命中宿主净化模式时才可达
1860
+ * (API key 类 ref 几乎全含 KEY/TOKEN,实际常不可达);
1861
+ * - `project-env`(dsh 工作区 .env)→ get-cred 刻意不读被审查仓库的 .env,永不可达;
1862
+ * - 未知层 / `undefined`(未配置)→ 一律按不可达,由调用方分别报「未配置」。
1863
+ * @param source credentials.resolve() 交出的层 id(gateway.credentialSource)。
1864
+ * @param ref 凭据 ref 名(get-cred/buildApiKeyCmd 用的那个大写名)。
1865
+ */
1866
+ function keyReachableAtOcrRuntime(source, ref) {
1867
+ if (source === "file" || source === "user-env") return true;
1868
+ if (source === "env") return !HOST_SCRUBBED_ENV_PATTERN.test(ref);
1869
+ return false;
1870
+ }
1777
1871
  /** 无凭据可问时的一律 false(卡片据 source 显示原因,不显示假的「未配置 key」)。 */
1778
1872
  function withoutKeyFlags(providers) {
1779
1873
  return providers.map((providerCfg) => ({
@@ -1849,10 +1943,16 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
1849
1943
  provider
1850
1944
  }));
1851
1945
  if (target.apiKeyEnv === null || target.apiKeyEnv === "") throw new Error(format(messages.noApiKeyEnv, { provider }));
1852
- if (!await gateway.credentialResolvable(target.apiKeyEnv)) throw new Error(format(messages.keyNotResolvable, {
1946
+ const keySource = await gateway.credentialSource(target.apiKeyEnv);
1947
+ if (keySource === void 0) throw new Error(format(messages.keyNotResolvable, {
1853
1948
  provider,
1854
1949
  ref: target.apiKeyEnv
1855
1950
  }));
1951
+ if (!keyReachableAtOcrRuntime(keySource, target.apiKeyEnv)) throw new Error(format(messages.keyNotReachable, {
1952
+ provider,
1953
+ ref: target.apiKeyEnv,
1954
+ source: keySource
1955
+ }));
1856
1956
  const scriptPath = credentialScriptPath(paths);
1857
1957
  if (!existsSync(scriptPath)) throw new Error(format(messages.credScriptMissing, { path: scriptPath }));
1858
1958
  const apiKeyCmd = buildApiKeyCmd(scriptPath, target.apiKeyEnv, resolveDshHome(), messages);
@@ -1882,7 +1982,7 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
1882
1982
  */
1883
1983
  function migrateEntry(entry, envKey, context) {
1884
1984
  if (typeof entry["api_key"] !== "string" || entry["api_key"].length === 0) return "noPlain";
1885
- if (envKey === void 0 || envKey === "" || !context.resolvableEnvKeys.has(envKey)) return "unknown";
1985
+ if (envKey === void 0 || envKey === "" || !context.reachableEnvKeys.has(envKey)) return "unknown";
1886
1986
  try {
1887
1987
  entry["api_key_cmd"] = buildApiKeyCmd(context.scriptPath, envKey, context.dshHome, context.messages);
1888
1988
  } catch {
@@ -1916,11 +2016,16 @@ async function migratePlaintextKeys(gateway, paths, messages) {
1916
2016
  const envByProvider = /* @__PURE__ */ new Map();
1917
2017
  for (const providerCfg of providers) if (providerCfg.apiKeyEnv !== null && providerCfg.apiKeyEnv !== "") envByProvider.set(providerCfg.name, providerCfg.apiKeyEnv);
1918
2018
  const candidateEnvKeys = [...new Set(envByProvider.values())];
1919
- const resolvedFlags = await Promise.all(candidateEnvKeys.map((envKey) => gateway.credentialResolvable(envKey)));
2019
+ const keySources = await Promise.all(candidateEnvKeys.map((envKey) => gateway.credentialSource(envKey)));
2020
+ const reachableEnvKeys = /* @__PURE__ */ new Set();
2021
+ for (const [index, envKey] of candidateEnvKeys.entries()) {
2022
+ const keySource = keySources[index];
2023
+ if (keySource !== void 0 && keyReachableAtOcrRuntime(keySource, envKey)) reachableEnvKeys.add(envKey);
2024
+ }
1920
2025
  const migrateContext = {
1921
2026
  scriptPath,
1922
2027
  dshHome,
1923
- resolvableEnvKeys: new Set(candidateEnvKeys.filter((_envKey, index) => resolvedFlags[index] === true)),
2028
+ reachableEnvKeys,
1924
2029
  messages
1925
2030
  };
1926
2031
  return await updateConfigLocked(paths.ocrConfigJson, async () => {
@@ -2435,7 +2540,7 @@ const SESSION_OUTPUT_SCHEMA = {
2435
2540
  * owner 口径是「状态归 ocr 自己,围栏由本包显式放开」,故直接要 `danger-full-access`。
2436
2541
  *
2437
2542
  * **「能不能只放开需要写的那一条路径」:不能。** 官方按调用携带的 `SandboxPolicyRequest`
2438
- * 只有 `session` 与 `mode` 两个字段(dsh-sandbox-policy 0.2.1-alpha.1 的
2543
+ * 只有 `session` 与 `mode` 两个字段(dsh-sandbox-policy 0.2.1-alpha.2 的
2439
2544
  * lib/types/index.d.ts:50),**没有**附加可写路径、没有自定义 HOME、没有目录级豁免。
2440
2545
  * 可选的收紧路径只有「改 ocr 自己的状态落点」或「等上游加字段」,两者都不是本包能在
2441
2546
  * 一次补丁里做完的事。故此处维持放开,但把边界写明:放开的是 ocr 这一个子进程的整棵
@@ -2461,7 +2566,16 @@ function failureDetail(result, messages) {
2461
2566
  const detail = redactKeyMaterial(result.stderr.text === "" ? result.stdout.text : result.stderr.text);
2462
2567
  return detail.length <= 800 ? detail : `${truncateEnd(detail, 400)}${messages.middleEllipsis}${truncateStart(detail, 400)}`;
2463
2568
  }
2464
- /** 前台执行:失败抛错(含给模型的行动提示),成功返回 stdout 文本。
2569
+ /** 截断说明(stdoutTruncated 文案 + 可选的落盘位置),判据见 ForegroundRun 的注释。 */
2570
+ function stdoutTruncationNote(limitBytes, run, messages) {
2571
+ if (!run.truncated) return "";
2572
+ const spill = run.spillPath === void 0 ? "" : format(messages.outputSpill, { path: run.spillPath });
2573
+ return `\n${format(messages.stdoutTruncated, {
2574
+ limit: String(limitBytes),
2575
+ spill
2576
+ })}`;
2577
+ }
2578
+ /** 前台执行:失败抛错(含给模型的行动提示),成功返回 stdout 的一手结果。
2465
2579
  * timeoutMs 恒有值(tuningOf(config)),宿主按 min(请求, shell.maxTimeoutMs) 收口。
2466
2580
  * signal 必传:harness ToolExecution.signal 是必填的调用方取消权。 */
2467
2581
  async function runForeground(host, command, workdir, run, signal) {
@@ -2487,14 +2601,11 @@ async function runForeground(host, command, workdir, run, signal) {
2487
2601
  throw new Error(detail === "" ? head : `${head}${messages.detailSeparator}${detail}`);
2488
2602
  }
2489
2603
  const { text, truncated, spillPath } = result.stdout;
2490
- if (truncated) {
2491
- const spill = typeof spillPath === "string" ? format(messages.outputSpill, { path: spillPath }) : "";
2492
- return `${text}\n${format(messages.stdoutTruncated, {
2493
- limit: String(run.stdoutMaxBytes),
2494
- spill
2495
- })}`;
2496
- }
2497
- return text;
2604
+ return {
2605
+ text,
2606
+ truncated,
2607
+ spillPath
2608
+ };
2498
2609
  }
2499
2610
  /**
2500
2611
  * 终止 OCR 进程,忽略"已经没有必要杀"的那一类异常(进程自然退出、已被注册表或宿主回收)。
@@ -2619,7 +2730,8 @@ async function startBackground(host, job, signal) {
2619
2730
  killProc(proc);
2620
2731
  throw error;
2621
2732
  }
2622
- enforceOcrDeadline(registry, jobId, messages.jobReasonTimeout, job.backgroundMaxMs);
2733
+ const backstopMinutes = Math.round(job.backgroundMaxMs / 6e4);
2734
+ enforceOcrDeadline(registry, jobId, format(messages.jobReasonTimeout, { minutes: String(backstopMinutes) }), job.backgroundMaxMs);
2623
2735
  const stop = () => {
2624
2736
  try {
2625
2737
  registry.kill(jobId, void 0, messages.jobReasonAborted);
@@ -2939,15 +3051,15 @@ function createGateway(host) {
2939
3051
  return false;
2940
3052
  }
2941
3053
  },
2942
- credentialResolvable: async (envKey) => {
3054
+ credentialSource: async (envKey) => {
2943
3055
  const svc = credentialsService();
2944
- if (svc === void 0 || !isCredentialRefName(envKey)) return false;
2945
- try {
2946
- const resolved = await svc.resolve(credentialRef(envKey));
2947
- return resolved !== void 0 && resolved.value.length > 0;
3056
+ let resolved;
3057
+ if (svc !== void 0 && isCredentialRefName(envKey)) try {
3058
+ resolved = await svc.resolve(credentialRef(envKey));
2948
3059
  } catch {
2949
- return false;
3060
+ resolved = void 0;
2950
3061
  }
3062
+ return resolved !== void 0 && resolved.value.length > 0 ? resolved.source : void 0;
2951
3063
  }
2952
3064
  };
2953
3065
  }
@@ -2997,6 +3109,21 @@ async function canonicalReviewRoot(host, root) {
2997
3109
  } catch {}
2998
3110
  return realpathOrSelf(root);
2999
3111
  }
3112
+ /**
3113
+ * 判定 webserver 绑定地址字面量是否回环(127/8、::1 及其全展开/IPv4 映射形态;
3114
+ * `%zone` 后缀剥掉再比,对齐宿主 isLoopbackHost 的判定域)。
3115
+ *
3116
+ * 为什么不是 `host === "0.0.0.0"`:宿主 webserver 的 Config 在装载期就**拒绝通配
3117
+ * 地址**(0.2.1-alpha.2 packages/host/webserver/src/index.ts:263-267 —— 0.0.0.0/::
3118
+ * 一律 throw,要求绑定本机某个具体接口的 IP 字面量),所以那个等式是永假死分支,
3119
+ * 唯一需要 trust 闸放行局域网 Host 头的场景(绑定 LAN IP,容器/远端部署的正路)
3120
+ * 反而被当成回环、整批 403。host 经 config 校验必为具体 IP 字面量,故本函数
3121
+ * 不必处理主机名与通配,只枚举回环字面量的常见书写。
3122
+ */
3123
+ function isLoopbackBindAddress(host) {
3124
+ const value = host.toLowerCase().replace(/%.*$/u, "");
3125
+ return value === "::1" || value === "0:0:0:0:0:0:0:1" || value.startsWith("127.") || /^::ffff:127(?:\.\d{1,3}){3}$/u.test(value);
3126
+ }
3000
3127
  /** POST select 的响应:把 body 里的 provider/model 应用到 OCR 配置,按需附带 llm test 回执。 */
3001
3128
  async function sendSelectResponse(text, res, deps) {
3002
3129
  const msgs = deps.messages();
@@ -3047,7 +3174,7 @@ async function sendSelectResponse(text, res, deps) {
3047
3174
  function registerConfigEndpoints(ctx, host, config, csrf) {
3048
3175
  const webServer = host.get("webServer");
3049
3176
  if (!webServer) return;
3050
- const servingNonLoopback = webServer.host === "0.0.0.0";
3177
+ const servingNonLoopback = !isLoopbackBindAddress(webServer.host);
3051
3178
  const gateway = createGateway(host);
3052
3179
  const messages = () => localeMessages(host);
3053
3180
  const disposeProviders = webServer.register({
@@ -3394,13 +3521,13 @@ function registerDelegatePreviewTool(host, config) {
3394
3521
  ...args,
3395
3522
  repo
3396
3523
  }, runMsgs);
3397
- const text = await runForeground(host, withHomeEnv(command, homeOverride), workdir, tuningOf(config), exec.signal);
3398
- const parsed = parseDelegatePreview(text);
3524
+ const stdout = await runForeground(host, withHomeEnv(command, homeOverride), workdir, tuningOf(config), exec.signal);
3525
+ const parsed = parseDelegatePreview(stdout.text);
3399
3526
  if (!parsed.ok) return format(runMsgs.parseFailedPlain, {
3400
3527
  tool: "ocr delegate preview",
3401
3528
  limit: String(RAW_ECHO_LIMIT),
3402
- raw: echoRaw(text, RAW_ECHO_LIMIT)
3403
- });
3529
+ raw: echoRaw(stdout.text, RAW_ECHO_LIMIT)
3530
+ }) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs);
3404
3531
  return {
3405
3532
  mode: parsed.mode,
3406
3533
  repository: parsed.repository,
@@ -3443,13 +3570,13 @@ function registerDelegateRuleTool(host, config) {
3443
3570
  repo: ocrRepo(args, exec, runMsgs),
3444
3571
  paths: args["paths"]
3445
3572
  }, runMsgs);
3446
- const text = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
3447
- const parsed = parseDelegateRules(text);
3573
+ const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
3574
+ const parsed = parseDelegateRules(stdout.text);
3448
3575
  if (!parsed.ok) return format(runMsgs.parseFailedPlain, {
3449
3576
  tool: "ocr delegate rule",
3450
3577
  limit: String(RAW_ECHO_LIMIT),
3451
- raw: echoRaw(text, RAW_ECHO_LIMIT)
3452
- });
3578
+ raw: echoRaw(stdout.text, RAW_ECHO_LIMIT)
3579
+ }) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs);
3453
3580
  return {
3454
3581
  groupCount: parsed.groups.length,
3455
3582
  groups: parsed.groups.map((group) => ({
@@ -3503,12 +3630,13 @@ function registerSessionTool(host, config) {
3503
3630
  ...args,
3504
3631
  repo
3505
3632
  }, runMsgs);
3506
- const text = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
3633
+ const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
3634
+ const { text } = stdout;
3507
3635
  const stripped = text.trim();
3508
3636
  const empty = stripped === "null" || stripped === "[]";
3509
3637
  const capped = text.length > 12e3 ? `${echoRaw(text, 11900)}\n${runMsgs.sessionTruncated}` : redactKeyMaterial(text);
3510
3638
  return {
3511
- sessionOutput: empty ? runMsgs.sessionEmpty : capped,
3639
+ sessionOutput: (empty ? runMsgs.sessionEmpty : capped) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
3512
3640
  hint: runMsgs.sessionResumeHint
3513
3641
  };
3514
3642
  }
@@ -3548,4 +3676,4 @@ var host_default = {
3548
3676
  apply
3549
3677
  };
3550
3678
  //#endregion
3551
- export { configSchema as Config, createGateway, host_default as default, ocrRepo, parseJsonBody, registerConfigEndpoints, requiredKeys };
3679
+ export { configSchema as Config, createGateway, host_default as default, isLoopbackBindAddress, ocrRepo, parseJsonBody, registerConfigEndpoints, requiredKeys };
package/package.json CHANGED
@@ -1,7 +1,18 @@
1
1
  {
2
2
  "name": "@jayyuen66/dsh-ocr-review",
3
- "version": "0.1.4-rc.1",
3
+ "version": "0.1.4",
4
4
  "description": "dsh 工作区代码审查插件:把 open-code-review(ocr CLI)封装为 5 个模型工具 + 设置卡片(从 settings.yaml 选 provider/model 应用到 OCR 配置)。",
5
+ "keywords": [
6
+ "code-review",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "ocr",
10
+ "open-code-review"
11
+ ],
12
+ "homepage": "https://github.com/JayYuen666/dsh-ocr-review#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/JayYuen666/dsh-ocr-review/issues"
15
+ },
5
16
  "license": "MIT",
6
17
  "author": "JayYuen",
7
18
  "repository": {
@@ -13,9 +24,14 @@
13
24
  "host.js",
14
25
  "client.js",
15
26
  "cordis.patch.yml",
16
- "scripts"
27
+ "scripts",
28
+ "THIRD_PARTY_NOTICES.md"
17
29
  ],
18
30
  "type": "module",
31
+ "sideEffects": [
32
+ "./host.js",
33
+ "./client.js"
34
+ ],
19
35
  "main": "host.js",
20
36
  "exports": {
21
37
  ".": "./host.js",
@@ -28,57 +44,58 @@
28
44
  "registry": "https://registry.npmjs.org"
29
45
  },
30
46
  "dependencies": {
31
- "@jayyuen66/dsh-plugin-shared": "^0.1.3",
32
- "js-yaml": "^5.4.2"
47
+ "@jayyuen66/dsh-plugin-shared": "^0.1.4",
48
+ "js-yaml": "^5.4.3"
33
49
  },
34
50
  "devDependencies": {
35
51
  "@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",
52
+ "@deepseek-ai/dsh-agent": "0.2.1-alpha.2",
53
+ "@deepseek-ai/dsh-api-remotes": "0.2.1-alpha.2",
54
+ "@deepseek-ai/dsh-atomic-write": "0.2.1-alpha.2",
55
+ "@deepseek-ai/dsh-client-locale": "0.2.1-alpha.2",
56
+ "@deepseek-ai/dsh-client-store": "0.2.1-alpha.2",
57
+ "@deepseek-ai/dsh-client-ui-layout": "0.2.1-alpha.2",
58
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-alpha.2",
59
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-alpha.2",
60
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.1-alpha.2",
61
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.1-alpha.2",
62
+ "@deepseek-ai/dsh-credentials": "0.2.1-alpha.2",
63
+ "@deepseek-ai/dsh-home-paths": "0.2.1-alpha.2",
64
+ "@deepseek-ai/dsh-host-webserver": "0.2.1-alpha.2",
65
+ "@deepseek-ai/dsh-jobs": "0.2.1-alpha.2",
66
+ "@deepseek-ai/dsh-jobs-local": "0.2.1-alpha.2",
67
+ "@deepseek-ai/dsh-output-retention": "0.2.1-alpha.2",
68
+ "@deepseek-ai/dsh-package-manifest": "0.2.1-alpha.2",
69
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.1-alpha.2",
70
+ "@deepseek-ai/dsh-sandbox": "0.2.1-alpha.2",
71
+ "@deepseek-ai/dsh-scope": "0.2.1-alpha.2",
72
+ "@deepseek-ai/dsh-session": "0.2.1-alpha.2",
73
+ "@deepseek-ai/dsh-session-projection": "0.2.1-alpha.2",
74
+ "@deepseek-ai/dsh-settings": "0.2.1-alpha.2",
75
+ "@deepseek-ai/dsh-shell": "0.2.1-alpha.2",
76
+ "@deepseek-ai/dsh-subprocess": "0.2.1-alpha.2",
77
+ "@deepseek-ai/dsh-system-prompt": "0.2.1-alpha.2",
78
+ "@deepseek-ai/dsh-tools": "0.2.1-alpha.2",
79
+ "@deepseek-ai/dsh-user-approval": "0.2.1-alpha.2",
63
80
  "@deepseek-ai/schemastery": "3.18.5-alpha.1",
64
- "@types/node": "26.6.3",
81
+ "@types/node": "26.6.5",
65
82
  "@types/react": "19.3.0",
66
- "@vitest/coverage-v8": "5.0.2",
83
+ "@vitest/coverage-v8": "5.0.3",
67
84
  "eslint-plugin-sonarjs": "4.2.2",
68
- "oxfmt": "0.71.0",
69
- "oxlint": "1.86.0",
70
- "oxlint-tsgolint": "7.0.2003",
85
+ "oxfmt": "0.72.0",
86
+ "oxlint": "1.87.0",
87
+ "oxlint-tsgolint": "7.0.2004",
71
88
  "react": "^19.3.0",
72
- "rolldown": "1.2.11",
89
+ "rolldown": "1.2.13",
73
90
  "typescript": "7.0.2",
74
- "vite": "8.3.1",
75
- "vitest": "5.0.2"
91
+ "vite": "8.3.4",
92
+ "vitest": "5.0.3"
76
93
  },
77
94
  "peerDependencies": {
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",
95
+ "@deepseek-ai/dsh": "^0.2.1-alpha.2",
96
+ "@deepseek-ai/dsh-atomic-write": "0.2.1-alpha.2",
97
+ "@deepseek-ai/dsh-credentials": "0.2.1-alpha.2",
98
+ "@deepseek-ai/dsh-home-paths": "0.2.1-alpha.2",
82
99
  "@deepseek-ai/schemastery": "3.18.5-alpha.1"
83
100
  },
84
101
  "peerDependenciesMeta": {
@@ -87,11 +104,12 @@
87
104
  }
88
105
  },
89
106
  "optionalDependencies": {
90
- "@alibaba-group/open-code-review": "^1.12.11"
107
+ "@alibaba-group/open-code-review": "^1.12.13"
91
108
  },
92
109
  "icon": "icon.svg",
93
110
  "engines": {
94
- "dsh": ">=0.2.1-alpha.1"
111
+ "dsh": ">=0.2.1-alpha.2",
112
+ "node": ">=22.12.0"
95
113
  },
96
114
  "dsh": {
97
115
  "manifestVersion": 1,
@@ -25,8 +25,12 @@
25
25
  // env 档同样不可靠。
26
26
  //
27
27
  // 取值的层序镜像官方 credentials-local 的优先级里**够得到**的那两层:
28
- // 1. 继承来的进程环境(dsh 的 env 层就是最高层:`DEEPSEEK_API_KEY=… dsh` 时,
29
- // OCR 作为孙进程本来就继承得到,过去只读文件反而把它丢了);
28
+ // 1. 继承来的进程环境。注意:dsh 起 shell 会经 scrubbedParentEnv 剥掉名字命中
29
+ // KEY/PASSWORD/SECRET/TOKEN 或 DSH_ 前缀的变量——凭据 ref 几乎全中招,所以对
30
+ // 经 dsh shell 起的 OCR,这一层对凭据 ref 实际常为空;它只在 ref 名不被净化
31
+ // 模式命中、或调用链不经 dsh shell(手工在终端跑 ocr)时才有值。宿主侧的
32
+ // select/migrate 已按同一事实加闸(keyReachableAtOcrRuntime):key 只存在于
33
+ // 环境层/项目 .env 的配置会在写盘前被拒绝,而不是等 OCR 401。
30
34
  // 2. `$DSH_HOME/.credentials.yaml` 的 `refs.<REF>`(version: 1 托管文档,
31
35
  // 格式来源 packages/credentials/credentials-local/src/index.ts:169-216);
32
36
  // 3. `$DSH_HOME/.env`(用户级回退层)。