@jayyuen66/dsh-ocr-review 0.1.5-rc → 0.1.6

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
@@ -6,7 +6,7 @@ English · [中文](README.zh-CN.md)
6
6
 
7
7
  - Wraps the external `ocr` (open-code-review) CLI into 5 model tools plus 1 web settings card for dsh; the host validates tool arguments not at all, so every shape and type gate lives in this package.
8
8
  - The host half (`host.ts`) declares 6 `.volatile()` settings fields — the namespace is implicit: the host projects the form from the profile entry id `ocr-review` and the plugin calls no `settings.register`.
9
- - It also registers 5 tools, one systemPrompt routing section (`ocr-review-routing`, order 1555) and 5 `/_dsh/ocr-review/*` endpoints.
9
+ - It also registers 5 tools, one systemPrompt routing section (`ocr-review-routing`, order 1555) and 5 methods on the official Typert Remote namespace `ocrReview`.
10
10
  - The client half (`src/client-entry.ts` → `client.js`) is the settings card, taking provider/model from the `llm-pi-ai` namespace of the settings service, plus three read-only contributions about the external CLI: a badge and a section on the plugin's detail page and the guidance shown right after the bundle is enabled.
11
11
  - It writes provider/model into the external ocr CLI's own `~/.opencodereview/config.json` — as an `api_key_cmd` command, never as a plaintext key.
12
12
  - Review results come back as a structured summary whose fields split into two notions: what OCR found (`status`, `aggregation`, `topComments`, `warnings`) and whether the run actually covered the selected files (`coverage`, `budgetExceeded`, `toolCalls`). Under `--audience agent` the CLI drops its progress lines to `io.Discard`, so the second notion is only reachable through those fields.
@@ -14,29 +14,40 @@ English · [中文](README.zh-CN.md)
14
14
  ## Prerequisites (external CLI)
15
15
 
16
16
  - 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`.
17
- - 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
+ - Commands are built against the contract verified line-by-line on the **1.13.0** source (header comments of `lib/cli.ts` and `lib/parse.ts`). That version is declared once, in `ocrCliVersion` in package.json, and mirrored by `EXPECTED_OCR_CLI_VERSION` in `lib/cli.ts`; `test/publish-manifest.test.ts` asserts the two are the same string and `test/readme-parity.test.ts` asserts this paragraph says it too, so "the README tells you to install A while the code verifies against B" cannot be self-consistent on both sides.
18
18
  - Multi-value `--exclude`/`--path` are merged into one comma-separated flag, and `--format` only accepts json/text/sarif.
19
- - 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.
20
- - 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`.
19
+ - **You install the CLI yourself.** This package deliberately ships no `optionalDependencies`: pulling `@alibaba-group/open-code-review` in as a dependency changed the user's global toolchain as a side effect of installing a plugin, and — worse — its absolute path resolved *ahead of* whatever the user had on PATH, so upgrading the CLI in the terminal never reached the plugin and the version on the card never matched the one they were running.
20
+ - Command resolution order, therefore: the first `ocr` executable found on `PATH` (its absolute path becomes the command word), then a copy somebody placed in this package's own dependency tree as a fallback, and only if neither exists the bare `ocr` (which is what the tools refuse to spawn, see below). The card, the tool preflight and the actual command word all read this one judgement, so "which copy is used" cannot be told three different ways. On Windows the bundled fallback is skipped outright (`bin/ocr.js` is a shebang script that pwsh/cmd cannot execute) and the command word stays the bare `ocr`, because an absolute path with backslashes is not reliably quotable in `cmd`.
21
+ - Install it with either command; the npm one carries the verified version, the brew one cannot:
22
+
23
+ ```sh
24
+ brew install open-code-review # formula tracks the newest release
25
+ npm i -g @alibaba-group/open-code-review@1.13.0 # pinned to the verified version
26
+ ```
27
+
28
+ `homebrew/core` carries a single stable formula with no versioned variant (`open-code-review@x.y` does not exist), so brew cannot pin: what you get is whatever is newest. When that differs from the version above, the tools say so (see "External CLI status and version"). `ocrInstallHint` lets a deployment lead with one of the two when nothing is installed yet; a CLI that is already installed is always described by its own source.
29
+ - With neither path available the tools refuse before spawning and give the install commands above with the version filled in; if a candidate existed when the verdict was taken but the child still cannot exec it, the shell answers 127 and the tool reports "ocr command not found" pointing at the card's External CLI row.
21
30
  - 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.
22
31
  - 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`.
23
32
  - When either is missing the card shows the degradation reason while tools and settings keep working.
24
- - The host must be `>= 0.2.1-alpha.2`: declared in `engines.dsh`, which is documentation only — no loader or installer reads it. This bundle imports no symbol from the `@deepseek-ai/dsh` package itself (every service face comes from the `@deepseek-ai/dsh-*` sub-packages), so it is not declared as a dependency of any kind.
33
+ - The host must be `>= 0.2.1-rc.2`. That floor sits in `engines.dsh`, which is documentation only — no loader or installer reads it. What actually gates installation is the exact spelling of the four peer pins: the host compares every peer range against its own runtime version (prereleases participate) and refuses with `incompatible-version` when one does not match. On a machine still running dsh 0.2.1-rc.2, upgrade dsh first, or record an exemption for this package in the profile's `compatibility.json` (`dsh plugin --profile <profile> allow-version <name@version> --dsh-version <runtime version> --accept-risk`). This bundle imports no symbol from the `@deepseek-ai/dsh` package itself (every service face comes from the `@deepseek-ai/dsh-*` sub-packages), so it is not declared as a dependency of any kind.
25
34
 
26
35
  ## External CLI status and version
27
36
 
28
- One judgement, `resolveOcrAvailability` in `lib/cli.ts`, answers "is there an `ocr` here, and where does it come from": the bundled launcher first, otherwise a PATH scan (`PATHEXT` extensions on Windows). It is pure filesystem — `statSync(...).isFile()`, never a spawn — and it answers **candidate**, not **runnable**: a file that exists but cannot execute (no executable bit, or the bundled entry present while its platform binary is missing) passes the probe and is reported by the real spawn instead, which is why the "command not found" wording is kept as a separate fallback.
37
+ One judgement, `resolveOcrAvailability` in `lib/cli.ts`, answers "is there an `ocr` here, which copy, and how was it installed": the first executable found on `PATH` (with `PATHEXT` extensions on Windows), otherwise a copy inside this package's own dependency tree. "How it was installed" is read off the file the symlink in `bin` actually points at (`realpathSync`): anything under a `node_modules` is an npm-style global install, anything under `Cellar`/`Homebrew` is a formula, and neither of those is reported as unrecognised rather than guessed — the brew-vs-npm order matters because a formula-installed Node puts its own global npm packages under the same `homebrew` prefix. It is pure filesystem — `statSync(...).isFile()`, never a spawn — and it answers **candidate**, not **runnable**: a file that exists but cannot execute (no executable bit, or the bundled entry present while its platform binary is missing) passes the probe and is reported by the real spawn instead, which is why the "command not found" wording is kept as a separate fallback.
29
38
 
30
39
  | Surface | What it does with that answer |
31
40
  | ------- | ----------------------------- |
32
- | The five tools | The probe runs before anything spawns. With no candidate on either path the model gets "neither path found, install it like this" instead of an exit-127 reverse-derivation. Re-read per call, never cached per process: installing the CLI takes effect on the next call without restarting dsh |
33
- | Settings card | A status line **outside** the provider panel (whether this machine has `ocr` and whether the host can list providers are two different facts, and a machine missing the CLI must still see the reason), plus one `Detect version` button |
34
- | Detail page | A badge beside the title and a section under the page's own content, both rendering null for a subject that is not this package and while the read has not come back |
35
- | After enabling | The activation guidance slot says the install recipe only when the answer is `none` — the outermost position a third-party bundle may render in |
41
+ | The five tools | The probe runs before anything spawns. With no candidate on either path the model gets "neither path found, install it like this" — with the verified version inside the command — instead of an exit-127 reverse-derivation. Re-read per call, never cached per process: installing the CLI takes effect on the next call without restarting dsh |
42
+ | Settings card | A status line **outside** the provider panel (whether this machine has `ocr` and whether the host can list providers are two different facts, and a machine missing the CLI must still see the reason): which copy, which install method, which version this plugin was verified against, which version was last measured, plus the install commands when nothing is there. One `Detect version` button |
43
+ | Detail page | A badge beside the title and a section under the page's own content, both rendering null for a subject that is not this package and while the read has not come back. The badge switches to "version differs" once a measurement disagrees with the verified version |
44
+ | After enabling | The activation guidance slot says the install recipe, version and all, only when the answer is `none` — the outermost position a third-party bundle may render in |
36
45
 
37
46
  - The plugin **list** card's tag row is not plugin-extensible: those tags are the host's own verdicts on the entry (`running` / `off` / `problem`). The two places above are as far out as a bundle can reach, and they are client-half surfaces, so a disabled plugin cannot warn that its CLI is missing — disabling it removes the thing that would have said so.
38
- - All three client surfaces read the same `ocrBinary` field of `GET providers`, and none of them computes availability locally: three separate judgements is how a status display drifts.
39
- - **Version is not free**: reading it spawns `ocr version`, so it runs only when you press `Detect version` — never at apply and never while a page renders. The receipt shows the CLI's own output underneath the version line, with terminal control sequences stripped (upstream's launcher hard-codes the colour codes around its "new version available" nudge, and that nudge is printed by the JS wrapper, so `--color never` cannot reach it) — the line you are meant to select and copy is `npm i -g …`, and copying it must not drag escape bytes into your terminal. One completed round trip re-reads the payload, so the status line and the version you just got describe the same moment (install the CLI while the page is open and the row no longer contradicts the receipt). This package deliberately provides no update action and no clipboard API (a non-secure context would fail silently, which looks like the button doing nothing).
47
+ - All three client surfaces read the same `ocrBinary` field of the `providers` method, and none of them computes availability, the install channel or the install command locally: three separate judgements is how a status display drifts, and a command written out in two places is how the card ends up teaching a version the plugin does not verify against.ot verify against.
48
+ - **Version is not free**: reading it spawns `ocr version`. It runs when you press `Detect version`, and once at apply when `startupVerify` is on (the same switch that governs the apply-time `ocr llm test`, because both are "may the plugin start a process at load"). A page render never spawns: the `providers` reply only reports the version measured so far, or `null` when nothing has been measured yet — the card says "verified against 1.13.0" and stays quiet about your machine until a measurement exists.
49
+ - **A version disagreement is shown and said**: the receipt line names both numbers (measured vs verified), the badge flips, and `ocr_review` / `ocr_scan` append a note to the summary's `hint` so the model is told this result did not come from the verified version. It is never presented as a hard failure — a newer CLI usually works — and this package runs no install or upgrade itself: the command it shows depends on which install method that copy came from (`brew upgrade open-code-review` for a formula, `npm i -g @alibaba-group/open-code-review@<verified>` for a global package), with the brew caveat spelled out because a formula cannot pin.
50
+ - The receipt shows the CLI's own output underneath the version line, with terminal control sequences stripped (upstream's launcher hard-codes the colour codes around its "new version available" nudge, and that nudge is printed by the JS wrapper, so `--color never` cannot reach it) — the line you are meant to select and copy is `npm i -g …`, and copying it must not drag escape bytes into your terminal. One completed round trip re-reads the payload, so the status line and the version you just got describe the same moment (install the CLI while the page is open and the row no longer contradicts the receipt). This package deliberately provides no update action and no clipboard API (a non-secure context would fail silently, which looks like the button doing nothing).
40
51
  - **Upstream self-update**: the `ocr` npm wrapper runs `scripts/update.js` on its own during invocations (18-minute cooldown, pid lock), and that script executes `npm i -g @alibaba-group/open-code-review@<latest>`. The `ocrNoAutoUpdate` deployment switch sets `OCR_NO_UPDATE=1` on every command this package spawns, so a review never changes global packages under you; it defaults to off, i.e. upstream behaviour. The version probe always sets it, switch or not — probing is a read and must not install anything. The launcher prints its "new version available" nudge to stderr *before* checking that variable, so the nudge still arrives in the receipt text.
41
52
 
42
53
  ## Installation
@@ -45,6 +56,7 @@ One judgement, `resolveOcrAvailability` in `lib/cli.ts`, answers "is there an `o
45
56
  dsh plugin --profile web add @jayyuen66/dsh-ocr-review
46
57
  ```
47
58
 
59
+ - **Upgrading an installed copy is remove-then-add.** While the profile runs HMR (the shipped profiles do), `add` on a package the profile already depends on refuses *before* pnpm runs - `application: 'failed'` with `error.code: 'already-installed'`, no profile file touched - and so does the Web page's install box. Run `dsh plugin --profile web remove @jayyuen66/dsh-ocr-review` first, then `add` the target version; the removal stops the old bundle's plugins and drops its module mappings, so the new version starts without a restart. Without HMR the same `add` runs pnpm and reports `restart-required`.
48
60
  - The packages are on the public npm registry, so installation needs no credentials.
49
61
  - 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.
50
62
  - 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.
@@ -52,40 +64,28 @@ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
52
64
  - 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.
53
65
  - 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`).
54
66
 
55
- ## pnpm blocks the dependency script on install (ERR_PNPM_IGNORED_BUILDS)
56
-
57
- This package keeps `@alibaba-group/open-code-review` in `optionalDependencies`, and upstream ships a `postinstall` (`scripts/install.js`). pnpm 10+ does not run dependency build scripts unless allowlisted, so the install ends like this:
58
-
59
- ```
60
- Error: ERR_PNPM_IGNORED_BUILDS
61
- × installing dependencies
62
- ╰─▶ Ignored build scripts: @alibaba-group/open-code-review@1.12.11
63
- ```
64
-
65
- **Allowlisting is a one-time step, and only this package triggers it** — no other plugin in this family depends on anything with a build script.
67
+ ## Installing and upgrading the external CLI
66
68
 
67
- The straightforward fix is to allowlist it in the profile's `pnpm-workspace.yaml` (under `<dsh data dir>/profiles/<profile>/`):
69
+ This package spawns `ocr`; it does not install it. Pick whichever fits your machine:
68
70
 
69
- ```yaml
70
- allowBuilds:
71
- '@alibaba-group/open-code-review': true
71
+ ```sh
72
+ brew install open-code-review # the formula tracks the newest release
73
+ npm i -g @alibaba-group/open-code-review@1.13.0 # pinned to the version this plugin verifies against
72
74
  ```
73
75
 
74
- Then re-run `pnpm i`. The approval persists in that profile under the exact package name and survives later failed installs.
75
-
76
- The host also ships an approval flow of its own: a failed install surfaces "allow these scripts and retry" on the web plugin page, and an Agent can grant it for you through `install_bundle`'s `approvedBuilds` once you have said so in the conversation. The host validates only the pending package names, not the conversational consent, so you must approve it explicitly.
77
-
78
- **About that postinstall:** on the normal path it does nothing. The platform binary comes from upstream's `optionalDependencies` (`@alibaba-group/ocr-<os>-<arch>`, each carrying `os` / `cpu` fields so pnpm installs only the one matching your platform); `install.js` detects that, prints `Binary provided by platform package, skipping download.` and returns. Measured, `ocr` runs identically whether or not the script executes — the only difference is whether that code runs during install, and it only downloads when the platform package is missing, which under pnpm never happens.
79
-
80
- If you would rather run nothing at install time, write `'@alibaba-group/open-code-review': false` instead: `ocr` still works (the launcher reads the binary straight out of the platform package directory), but the host's `readPendingBuilds()` only recognises entries whose value is `set this to true or false`, so once it is `false` the approval flow can no longer grant it and you maintain that entry by hand.
76
+ - **brew cannot pin a version.** `homebrew/core` carries one stable formula for `open-code-review` and no versioned variant (`open-code-review@1.12` does not exist), so `brew install` / `brew upgrade` always yields whatever is newest upstream. If the plugin's verified version is what you need, install it with npm; the card says the same thing wherever it shows a brew command.
77
+ - **The plugin never runs either command.** The version probe sets `OCR_NO_UPDATE=1` so that reading state cannot trigger upstream's own global install, and `ocrNoAutoUpdate` extends that to every command (see above).
78
+ - **Upgrading an existing install** is described by that install's own source: `brew upgrade open-code-review` for a formula, `npm i -g @alibaba-group/open-code-review@<version>` for a global package. Where the path says neither (`~/bin/ocr` copied by hand, `npx`, a prefix we do not recognise), the deployment's `ocrInstallHint` decides which of the two to lead with.
79
+ - **Upgrading from a release that shipped the CLI** (`optionalDependencies`, every release up to and including 0.1.5-rc): nothing needs doing, because PATH now wins over the bundled copy, so the stale one stops shadowing your own. If you want it gone from the profile's tree, remove it there (`pnpm remove @alibaba-group/open-code-review` under `<dsh data dir>/profiles/<profile>/`, or reinstall the profile); the `allowBuilds: {'@alibaba-group/open-code-review': true}` entry that release needed in that profile's `pnpm-workspace.yaml` can be deleted too.
80
+ - **`ERR_PNPM_IGNORED_BUILDS` no longer comes from this package.** Earlier releases tripped it because upstream's wrapper carries a `postinstall` (`scripts/install.js`) and pnpm 10+ will not run a dependency's build script unless allowlisted. With no dependency to run, that error can only be coming from some other package; the host's own approval flow (the web plugin page, or `install_bundle`'s `approvedBuilds`) still covers those cases, and the host validates only the pending package names — you have to approve explicitly either way.
81
81
 
82
82
  ## Enabling it in dsh
83
83
 
84
84
  - The in-package `cordis.patch.yml` declares `- id: ocr-review` / `name: "@jayyuen66/dsh-ocr-review"` and is pointed at by `dsh.bundle.patch` in `package.json`.
85
85
  - `dsh plugin --profile web add/remove` registers and removes it, then restart dsh.
86
86
  - The card needs a web profile (`dsh.client.platform: web`, `immediately: true`).
87
- - The five `/_dsh/ocr-review/*` endpoints hang off an `inject(["webServer"])` child fiber: with no webServer (a TUI host) the child never activates, so the endpoints simply do not exist while tools and settings stay available.
88
- - On the real host webServer arrives about a second after this entry, which is why it has to be a dependency rather than a one-off `ctx.get` in apply.
87
+ - The five `ocrReview` methods are registered on the apply fiber (`installOcrRemote`, unconditional): they need no optional host service, so a TUI host that never brings up a web layer still has them, and tools and settings stay available either way.
88
+ - The gateway exposes them only to clients of this profile; authentication and the source checks are its own, so this package has no CSRF token, no body cap and no route disposer of its own.
89
89
  - Deployment defaults can go on the profile's `config:` line (cordis validates and fills defaults through the exported `Config` schema); precedence: runtime settings value > line config > built-in default.
90
90
 
91
91
  ## Tools exposed to the model
@@ -124,32 +124,41 @@ A fourth non-volatile field is a deployment decision rather than a tuning value,
124
124
  | ------------------ | ------- | ------------------------------------------------------------------------------------------------------ |
125
125
  | `ocrNoAutoUpdate` | `false` | When true, every `ocr` command this package spawns carries `OCR_NO_UPDATE=1`, which stops upstream's wrapper from running `npm i -g @alibaba-group/open-code-review@<latest>` in the middle of a review. `false` keeps upstream behaviour (18-minute cooldown, pid lock). The version probe sets the variable regardless of this switch |
126
126
 
127
+ The fifth is about which install command a user is *shown*, which the same reasoning keeps off the card (a deployment that standardises on brew or on npm should not have each machine discover it separately):
128
+
129
+ | Key | Default | Use |
130
+ | --------------- | -------- | ------------------------------------------------------------------------------------------------------ |
131
+ | `ocrInstallHint` | `both` | When nothing is installed, which command the tools, the card and the activation guidance lead with: `both` (show both), `brew`, `npm`. It is a fallback, not an override: once a candidate exists its own install source decides the wording, because telling a formula user to run `npm i -g` would leave a second copy behind while PATH still resolves to the old one. The npm command always carries the verified version; the brew one never can |
132
+
127
133
  ## Credentials and config files
128
134
 
129
135
  | Aspect | Fact |
130
136
  | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
137
  | 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 |
132
- | 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`) |
138
+ | Precedence mirroring | get-cred reads its three layers in the **same order** as the official credentials-local: `refs.<REF>` in `$DSH_HOME/.credentials.yaml` first, then the inherited process environment, then `$DSH_HOME/.env`. Getting the order wrong fails silently: the host treats the managed file as the effective credential, so a script that answers with the environment copy first makes OCR authenticate against a different key than the one the card reports while both sides show success. dsh 0.2.1-rc.2 moved the environment layer below the managed file, and this package's script plus `test/get-cred.test.ts` moved with it |
139
+ | 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`) |
133
140
  | 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" |
134
- | `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 |
135
- | `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 |
141
+ | `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 |
142
+ | `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 the lookup order is the one named under **Precedence mirroring** above (managed document > inherited environment > `.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 |
143
+
144
+ ## Public methods
136
145
 
137
- ## Public endpoints
146
+ The card's contract surface is one official Typert Remote namespace, `ocrReview` (`OCR_REMOTE_NAMESPACE` in `lib/remote-shape.ts`), whose five methods are marked programmatically (`markRemote` in `host.ts`). There are no `/_dsh/*` routes any more: authentication, the Host / DNS-rebinding / Origin checks and the request-size limit are the host `/api` gateway's job, so this package holds no CSRF token, no body cap, no 405 arm and no route disposer.
138
147
 
139
- | Route | Method | Input | Success payload |
140
- | ---------------------------------------- | ------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
141
- | `/_dsh/ocr-review/providers` | GET | — | `{providers (each with a hasKey boolean), current, getCredReady, source, csrf, endpointVerify, ocrBinary}`, never containing a key. `ocrBinary` is the filesystem-only availability verdict (`{source: "bundled" \| "path" \| "none", path?}`), read fresh per request; `endpointVerify` is the apply-time `ocr llm test` result or null when it has not run |
142
- | `/_dsh/ocr-review/select` | POST | body `{provider, model}`, both must hit the list returned by `describe()` | `{ok, applied, llmTest}` (`llmTest` is null when `autoVerify` is off) |
143
- | `/_dsh/ocr-review/migrate` | POST | — | `{ok, migrated, skippedUnknown}` |
144
- | `/_dsh/ocr-review/test` | POST | — | `{ok, output}` (output is redacted first, then cut to 8000 characters) |
145
- | `/_dsh/ocr-review/probe` | POST | — | `{ok, version, output, reason}` — spawns `ocr version` with a 15s wall clock and `OCR_NO_UPDATE=1`, then reads the version out of its output. `version` is null unless the command succeeded and printed a parseable version; `reason` names the failure arm (`command not found`, non-zero exit, timeout, or exit 0 with nothing parseable, which is what an unreleased build looks like); `output` is redacted and cut the same way as `/test`, with terminal control sequences stripped, so an upstream upgrade notice stays copyable |
146
- | Front guard of the four write endpoints | — | — | The first statement of each handler is `guardTrust` (Host authority -> the `sec-fetch-site` allowlist -> a verbatim `origin` compare; failing it gives 403 with `{ok:false,error:"untrusted host authority" \| "cross-origin request rejected"}`); 405 for non-POST carrying `Allow: POST` and `{ok:false,error:"POST only"}`; 403 for a missing or wrong `x-ocr-csrf` header; 413 above the 8KB body cap; 400 for an aborted stream. The token is regenerated on every plugin apply and the old one stops working |
148
+ | Method | Input | Success payload |
149
+ | --- | --- | --- |
150
+ | `providers()` | — | `{providers (each with a hasKey boolean), current, getCredReady, source, endpointVerify, ocrBinary}`, never containing a key. `ocrBinary` is the filesystem-only availability verdict plus the version context the card renders: `{source: "path" \| "bundled" \| "none", path?, channel?: "brew" \| "npm" \| "unknown", expectedVersion, measuredVersion: string \| null, installHint}` — `measuredVersion` is whatever the last `ocr version` produced (apply-time self-check or a pressed Detect), `null` when nothing has run yet, and reading this method never spawns. `installHint` is that install sentence, localized and composed host-side so the card cannot drift from the version the plugin verifies against. `endpointVerify` is the apply-time `ocr llm test` result or null when it has not run |
151
+ | `select(request)` | `{provider, model}`, both must hit the list returned by `describe()` | `{ok, applied, llmTest}` (`llmTest` is null when `autoVerify` is off) |
152
+ | `migrate()` | — | `{ok, migrated, skippedUnknown}` |
153
+ | `test()` | — | `{ok, output}` (`ok` here is the **test verdict**, not whether the call ran; output is redacted first, then cut to 8000 characters) |
154
+ | `probe()` | — | `{ok, version, expectedVersion, consistent, output, reason, changeText}` — spawns `ocr version` with a 15s wall clock and `OCR_NO_UPDATE=1`, reads the version out of its output and writes it into this apply's slot so the next `providers` read and the next `ocr_review`/`ocr_scan` receipt describe the same measurement. `version` is null unless the command succeeded and printed a parseable version; `consistent` is a **three-state** (`true` / `false` / `null` — null means no version was read, where claiming a mismatch would be claiming something with no evidence); `reason` names the failure arm (`command not found`, non-zero exit, timeout, or exit 0 with nothing parseable, which is what an unreleased build looks like); `changeText` carries the install/upgrade sentence only when there is a command to give — a measured version that differs, or no candidate at all — and is empty on a timeout with the CLI still in place; `output` is redacted and cut the same way as `test`, with terminal control sequences stripped, so an upstream upgrade notice stays copyable |
155
+ | Refusals | — | the write methods answer `{ok:false, error, status}`, where `status` is a **retained semantic code, not an HTTP status**: the gateway frames the reply, the client renders `error`, and the number is what lets the tests pin which rule fired (400 for an empty or off-list selection, 500 for a write that failed or a credential script that is missing) |
147
156
 
148
- These five routes live and die with the plugin's injected child fiber. The official `webServer.register` hands back a disposer the **caller must invoke**, and it throws on a duplicate path outright (installed dsh-host-webserver/lib/index.js:177-184), while the route table belongs to the host-provided webServer instance and does **not** die when this plugin's fiber does. This package attaches all five disposers to the effect of the `inject(["webServer"])` child fiber, so a hot reload (old fiber disposes, then a new apply runs) cannot collide on the path and kill the whole settings card, and disabling or uninstalling the plugin makes these endpoints unreachable again.
157
+ The service registers at construction (that is cordis' `Service` contract, via `ctx.reflect.provide`) and is withdrawn with the fiber it was made on, which is why `installOcrRemote` returns nothing useful and why a hot reload cannot leave a stale namespace behind — the disposer bookkeeping the old routes needed is gone with them. The client half (`src/remote.ts`) holds the five descriptors, `$mount`s them once and then reads the derived key `remote.ocrReview`; with no `remote` service in the client context there is no reader at all and the card falls back to its `remoteUnavailable` line. There is deliberately **no client-side wall clock** on these calls — the timeout belongs to the gateway, and a second one in the card is how a slow write gets reported as lost while it actually landed.
149
158
 
150
159
  ## The background job surface (official `ctx.jobs`)
151
160
 
152
- The external `ocr` subprocess started by `wait: false` is now also registered as an **unowned** job in the host's `ctx.jobs` (`kind: "ocr-review"`, id `ocr-review-N`). What changed is who can see and stop it, not what the model receives:
161
+ The external `ocr` subprocess started by `wait: false` is also registered as an **unowned** job in the host's `ctx.jobs` (`kind: "ocr-review"`, id `ocr-review-<16 hex>`, handed out by the registry from a truncated randomUUID rather than a counter; being unowned also means the official `job/registered` / `job/settled` session lifecycle records are not written, since those need an owner). What changed is who can see and stop it, not what the model receives:
153
162
 
154
163
  | Aspect | Fact |
155
164
  | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -157,7 +166,7 @@ The external `ocr` subprocess started by `wait: false` is now also registered as
157
166
  | Broken executor reader | When the executor's reader throws, the official pump only writes one line to the host log and afterwards reads that channel as empty (measured) ⇒ this package writes **one** "this section is incomplete" note into the ring, exactly once, and stops knocking on the broken reader |
158
167
  | Controller lifetime | Attached only for the instant of `start`: the registry refuses `start` when no controller serves the owner, and on the web surface the host's own `tool-jobs` row is disabled at the host plane (each preset realm mounts its own), so an unowned job needs a controller of our own. We attach it and detach it immediately (measured: detaching restores the refusal, and read/kill/settlement never need it again) - a resident token would pry that gate open for a composition that deliberately has no job tools |
159
168
  | Three reap paths | The optional backstop window, a plugin unload, and a session dispose all call `jobs.kill(id, undefined, reason)` instead of killing the process around it, so the record moves to `stopping` and the reason lands in `detail` (measured: bypassing it left the row stuck at `running` forever). They may overlap - killing a `stopping` job still returns `requested` and `proc.kill()` is idempotent. Reasons are model-facing, so they come from the bilingual catalog |
160
- | Nothing there may throw | An error raised inside a host event handler is rethrown by Node via `process.nextTick` ⇒ uncaught ⇒ **the whole host process exits** (reproduced locally: `Error: unknown job ...` followed by `EXIT=1`). The official package does not cover for producers either - `killJob` calls `job.cancel()` bare (installed dsh-jobs-local:611-622, and only the teardown arm wraps each call in try/catch), and a reloaded registry throws for an unfamiliar id (same file `:554-558`). So two layers each catch once: `cancel` swallows the "process is already gone" family through `killProc`, `stop` catches the registry arm and falls back to killing our own process. One test per layer - deleting either layer turns it red |
169
+ | Nothing there may throw | An error raised inside a host event handler is rethrown by Node via `process.nextTick` ⇒ uncaught ⇒ **the whole host process exits** (reproduced locally: `Error: unknown job ...` followed by `EXIT=1`). The official package does not cover for producers either - `killJob` calls `job.cancel()` bare (dsh-jobs-local's `killJob`, and only the teardown arm wraps each call in try/catch), and a reloaded registry throws for an unfamiliar id (the `expect` throw in the same file). So two layers each catch once: `cancel` swallows the "process is already gone" family through `killProc`, `stop` catches the registry arm and falls back to killing our own process. One test per layer - deleting either layer turns it red |
161
170
  | The optional backstop | We still give the external CLI no host `timeoutMs` (a long review would be cut off), but "no end at all" gained a victim with this adoption - the 10-slot unowned bucket is **shared host-wide** (measured: `running` and `stopping` both count, overflow refuses new `start`s), so one hung CLI parks a slot forever. The backstop uses the registry's own `wait(id, ms)` + `kill`, not a host timer and not a bare `setTimeout` |
162
171
  | Unowned exposure | Unowned means visible to every session - any caller can `list`/`read`/`kill` it. Known and accepted for this wave (owned jobs would need the `dsh-agent` registry, measured unavailable); rolling the adoption back is the only way to close it |
163
172
  | Settled-record window | The registry never recycles settled records on its own, and it outlives this plugin - so before each registration this package prunes the oldest settled `ocr-review` records down to 9 and only then starts, so the roster never holds more than 10 (each ring is capped at 256 KiB). At unload both jobs are done: live ones are killed through the registry, settled ones are removed - disabling this plugin means there is no "next apply" left to prune the window. Live and stopping records are never pruned: the registry answers overflow by **refusing `start`** (measured: the unowned bucket holds 10), not by killing somebody's review |
@@ -167,7 +176,7 @@ The external `ocr` subprocess started by `wait: false` is now also registered as
167
176
 
168
177
  | Aspect | Fact |
169
178
  | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
170
- | Process boundary | The reviewed repository's paths and diff content go into the `ocr` child process, and OCR sends them to the LLM endpoint it has in its own config.json; that egress is the review feature itself and does not pass through the dsh host. Plaintext keys never appear in tool results or in `/_dsh/*`, and the value the host gets from `resolve()` is used only to confirm "it resolves right now" and is discarded on the spot |
179
+ | Process boundary | The reviewed repository's paths and diff content go into the `ocr` child process, and OCR sends them to the LLM endpoint it has in its own config.json; that egress is the review feature itself and does not pass through the dsh host. Plaintext keys never appear in tool results or in the `ocrReview` payloads, and the value the host gets from `resolve()` is used only to confirm "it resolves right now" and is discarded on the spot |
171
180
  | Redaction of raw output | Raw OCR stdout/stderr (failure details, llm test, parse-failure echoes, session passthrough) all go through `redactKeyMaterial()` before leaving the host: bearer and assignment forms are redacted strictly, while the standalone-key form additionally demands random-string features so that file paths handed to the model are not erased as well |
172
181
  | Places written | This package writes to only two places: the external ocr CLI's `config.json` (plus its random-suffixed `.tmp`, the `.bak` backup, and the short-lived `config.json.lock` held only for the duration of one read-modify-write) and `ocr-review-*`/`ocr-scan-*` in the system temp directory (removed right after reading). Plaintext that predates a migration may still sit in the `.bak` produced by that same write (mode 0600); removing it for good means deleting that backup yourself. Writes outside those two places can still happen **inside the CLI's own wrapper**: unless `ocrNoAutoUpdate` is on, an `ocr` invocation may trigger upstream's global `npm i -g` of a newer release. This package never runs an installer itself, and the version probe always suppresses that path |
173
182