From 0be792c3a49418558382aa50fa938b5f19f468de Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:17:51 +0000 Subject: [PATCH 1/5] fix(dor): spawn agent-browser by resolved path, not bare name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dor ab` computed the PATH-resolved absolute path, forwarded it to the host, and then spawned the bare name anyway. cross-spawn resolves a bare name through `which`, which searches `process.cwd()` before PATH on Windows, so an `agent-browser.cmd` in a cloned repository won the race against the real install — repo content executing with no gate, on the command `dor skill` mandates for every page view. Raised as the one BLOCKER of the 2026-09-19 security audit (CA-13). --- docs/specs/dor-cli.md | 12 ++++++++++++ docs/specs/dor-cli.rationale.md | 16 ++++++++++++++++ dor/src/commands/agent-browser.ts | 25 ++++++++++++++++++------- dor/test/cli-output.test.mjs | 7 ++++++- scripts/spec-word-budgets.json | 2 +- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 4df784f95..99c38339e 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -97,6 +97,11 @@ tab/eval/screenshot commands, and anything added later. It is the only code escaping, and passes through untouched on POSIX. **Never forward an argument containing a literal `%VAR%`** — `cmd.exe` expands it through a `.cmd` shim, an unavoidable batch limitation; today's forwarded arguments carry none. +- **`dor ab` spawns the `PATH`-resolved absolute path, never the bare name** — + cross-spawn resolves a bare name through `which`, which searches the cwd + before `PATH` on Windows (rationale). It falls back to the bare name only when + the walk found nothing, so absence still surfaces as ENOENT. The host's + candidate list still ends in a bare name (`## Future`). - **`windowsHide`.** Without it every `.cmd` shim flashes a focus-stealing console window, once per screenshot stream-frame pulse (rationale). - **Resolve on `exit`, not `close`, with an exit-time snapshot** — the @@ -684,6 +689,13 @@ Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `openCommand` in ` ## Future +- **Resolve the host's agent-browser candidates on `PATH` too.** + `runWithBinaryFallback` still ends its list with the bare + `DEFAULT_AGENT_BROWSER_BIN`, so on Windows the extension-host or Tauri-app + working directory is searched first — narrower than `dor ab`'s case, since a + user does not clone into it. Sharing `resolveBinaryPath` means moving it to + `dor-lib-common` beside `spawnAndCapture`. + - **Surface a dead control channel in the UI.** A lost bind leaves one `[dor-control]` line on the host's stderr, and all a user sees is `dor` reporting "Dormouse control endpoint is not available in this terminal yet" — diff --git a/docs/specs/dor-cli.rationale.md b/docs/specs/dor-cli.rationale.md index 8e5306c22..c7e3b96ae 100644 --- a/docs/specs/dor-cli.rationale.md +++ b/docs/specs/dor-cli.rationale.md @@ -22,6 +22,22 @@ The file viewer needs Node types, but the renderer imports CLI protocol and shel **The two Windows spawn failures cross-spawn absorbs.** Node's `spawn` does not consult `PATHEXT`, so a bare `agent-browser` ENOENTs instead of resolving the `agent-browser.cmd` shim npm/vfox installs (on POSIX that file is a real executable with a shebang); and Node ≥22 refuses to spawn `.cmd`/`.bat` without a shell (the CVE-2024-27980 hardening), so the resolved absolute `.cmd` EINVALs too. Neither failure has a POSIX counterpart. +**Why the bare name is a code-execution primitive on Windows.** cross-spawn +delegates resolution to `which`, whose Windows branch prepends `process.cwd()` +to the search path (`which@2.0.2/which.js:19-21`, comment *"windows always +checks the cwd first"*), and its `.cmd`-shim path re-emits the **bare** name +into `cmd.exe` (`cross-spawn@7.0.6/lib/parse.js:36,48-59`), which resolves the +cwd first as well. `dor` inherits the pane's cwd, so before this rule a cloned +repository containing `agent-browser.cmd` executed on the next `dor ab` — and +`dor skill` mandates `dor ab` for every page view. The 2026-09-19 security audit +([run 35432996343](https://github.com/diffplug/dormouse/actions/runs/35432996343)) +raised it as its one BLOCKER: the hijack needs a *legitimate* install present, +because `agentBrowserIsMissing` refuses to spawn when the PATH walk finds +nothing. The same audit named three sidecar sites spawning system binaries by +bare name (`standalone/sidecar/pty-core.js`, `standalone/sidecar/clipboard-ops.js`) +whose cwd is the app directory rather than a user's repository; +`pty-core.js`'s `%SystemRoot%\System32` join is the pattern those should follow. + **What a missing `windowsHide` looks like.** cross-spawn routes `.cmd` shims through `cmd.exe`, which owns a real console window, and the browser panel's screenshot loop spawns one per stream-frame pulse — a live page flickers focus-stealing windows several times a second. **Why none of the `exit`-vs-`close` trouble surfaced on macOS.** The `agent-browser` daemon double-forks and detaches from the inherited fds, so `close` fires normally; only on Windows, where the daemon holds the parent's stdout/stderr pipes for its whole life, does a `close`-only wait hang forever. diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index 9eb25f765..1ea5293d2 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -238,13 +238,24 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P const binary = env[AGENT_BROWSER_BIN_ENV] || DEFAULT_AGENT_BROWSER_BIN; const exec = options.execAgentBrowser ?? execAgentBrowserProcess; - // Resolve the binary to an absolute path once: it both proves the install - // present (below) and travels to the host as `binaryPath` (a GUI host may not - // share this terminal's PATH). undefined means "not found on PATH" — or, for - // an explicit path, simply "returned verbatim", which agentBrowserIsMissing - // re-checks on disk. + // Resolve the binary to an absolute path once: it proves the install present + // (below), is what we spawn (see `execTarget`), and travels to the host as + // `binaryPath` (a GUI host may not share this terminal's PATH). undefined + // means "not found on PATH" — or, for an explicit path, simply "returned + // verbatim", which agentBrowserIsMissing re-checks on disk. const binaryPath = resolveBinaryPath(binary, env); + // Spawn the resolved path, never the bare name: cross-spawn resolves a bare + // name through `which`, which checks `process.cwd()` *before* PATH on Windows + // (and re-emits the bare name into cmd.exe for a `.cmd` shim, which does the + // same). Since `dor` inherits the pane's cwd, a bare-name spawn would let an + // `agent-browser.cmd` sitting in a cloned repository win the race against the + // real install — repo content executing with no gate, which + // docs/specs/dor-tool.md -> Trust treats as a boundary. Falls back to the bare + // name only when the PATH walk found nothing, which is the ENOENT path below. + // See docs/specs/dor-cli.md -> "Spawning External Binaries". + const execTarget = binaryPath ?? binary; + // Detect a missing install deterministically, before spawning. A failed spawn // on Windows emits BOTH 'error' (ENOENT) and 'close' (a libuv error code); if // 'close' wins that race the process resolves with a bogus exit code and no @@ -257,7 +268,7 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P let result: AgentBrowserExecResult; try { - result = await exec(binary, ['--session', session, ...rest]); + result = await exec(execTarget, ['--session', session, ...rest]); } catch (error) { if (isMissingBinaryError(error)) { return fail(missingBinaryMessage(binary)); @@ -272,7 +283,7 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P // passthrough rather than nagging about the missing surface. if (!(client instanceof Error)) { try { - const status = await exec(binary, streamStatusArgs(session)); + const status = await exec(execTarget, streamStatusArgs(session)); const wsPort = parseStreamPort(status.stdout); // Pass the absolute path resolved above so the host (which may not share // this terminal's PATH) can run host-side tab/close commands. diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 6eb4e8b70..ac8cab401 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -1386,7 +1386,7 @@ test('agent-browser respects DORMOUSE_AGENT_BROWSER_BIN and forwards it as binar assert.equal(surfaceRequest(client).binaryPath, '/opt/custom/agent-browser'); }); -test('agent-browser resolves the binary on PATH to an absolute binaryPath', async () => { +test('agent-browser spawns the PATH-resolved absolute path, never the bare name', async () => { await withTempDir('dor-ab-', async (dir) => { // On Windows a bare name isn't executable and resolveBinaryPath walks // PATHEXT (.cmd/.exe/.bat), so the on-disk shim must carry one of those @@ -1403,6 +1403,11 @@ test('agent-browser resolves the binary on PATH to an absolute binaryPath', asyn // resolveBinaryPath splits on the same, so a POSIX-only `:` would hide dir. env: { PATH: ['/nonexistent', dir].join(delimiter) }, }); + // Both spawns take the resolved path. Spawning the bare name instead would + // hand the inherited cwd a code-execution primitive on Windows, where + // cross-spawn's `which` searches it before PATH — docs/specs/dor-cli.md -> + // "Spawning External Binaries". + assert.deepEqual(ab.calls.map((call) => call[0]), [binPath, binPath]); assert.equal(surfaceRequest(client).binaryPath, binPath); }); }); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 5708c5379..56b36d2d7 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,7 +6,7 @@ "docs/specs/auto-update.md": 1200, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4700, - "docs/specs/dor-cli.md": 6250, + "docs/specs/dor-cli.md": 6350, "docs/specs/dor-tool.md": 4050, "docs/specs/glossary.md": 3000, "docs/specs/hosted.md": 1050, From ce8ffd25f0020f968c05bdc3d15e53c58b0db088 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:32:30 +0000 Subject: [PATCH 2/5] Address review: make the PATH walk agree with which(1) Promoting `resolveBinaryPath`'s answer from "the install is present" to "the thing we spawn" made its divergence from cross-spawn's `which` observable: a fixed `.cmd`/`.exe`/`.bat` order that ignores a customised `PATHEXT`, and a bare `existsSync` that returns a directory or a non-executable file `which` would have walked past to reach the real install further along `PATH`. The second is not Windows-specific. Skip anything that is not an executable regular file, and order Windows candidates by `PATHEXT` when set, else by cmd.exe's own default. Pinned by a test that mutation-checks to 142/1 on reverting the executability test. --- docs/specs/dor-cli.md | 6 +++++ docs/specs/dor-cli.rationale.md | 9 ++++++++ dor/src/commands/agent-browser.ts | 38 ++++++++++++++++++++++++++----- dor/test/cli-output.test.mjs | 25 ++++++++++++++++++++ scripts/spec-word-budgets.json | 2 +- 5 files changed, 73 insertions(+), 7 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 99c38339e..363714ef6 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -102,6 +102,12 @@ tab/eval/screenshot commands, and anything added later. It is the only code before `PATH` on Windows (rationale). It falls back to the bare name only when the walk found nothing, so absence still surfaces as ENOENT. The host's candidate list still ends in a bare name (`## Future`). +- **The walk must agree with `which` on which file that is**, since its answer + is what executes: skip a directory or a non-executable file rather than + returning it, and order Windows candidates by `PATHEXT` when set, else by + `cmd.exe`'s default. A laxer test turns a `PATH` entry `which` walked past into + an EACCES/EISDIR failure. Pinned by *skips a PATH entry that is not an + executable file* in `dor/test/cli-output.test.mjs`. - **`windowsHide`.** Without it every `.cmd` shim flashes a focus-stealing console window, once per screenshot stream-frame pulse (rationale). - **Resolve on `exit`, not `close`, with an exit-time snapshot** — the diff --git a/docs/specs/dor-cli.rationale.md b/docs/specs/dor-cli.rationale.md index c7e3b96ae..43e0a9b9b 100644 --- a/docs/specs/dor-cli.rationale.md +++ b/docs/specs/dor-cli.rationale.md @@ -38,6 +38,15 @@ bare name (`standalone/sidecar/pty-core.js`, `standalone/sidecar/clipboard-ops.j whose cwd is the app directory rather than a user's repository; `pty-core.js`'s `%SystemRoot%\System32` join is the pattern those should follow. +**What promoting the walk to the spawn target changed.** Before it, the walk +only proved the install present and travelled to the host as a hint, so its +divergence from `which` was inert: a fixed `.cmd`/`.exe`/`.bat` order and a bare +`existsSync`. Once it decides what runs, both diverge observably — a directory +holding `agent-browser.exe` and `agent-browser.cmd` would switch which one runs, +and a non-executable file or directory named `agent-browser` earlier on `PATH` +would fail EACCES/EISDIR where `which` walked past it to the real install (that +one is not Windows-specific). Raised on review of the fix, before it merged. + **What a missing `windowsHide` looks like.** cross-spawn routes `.cmd` shims through `cmd.exe`, which owns a real console window, and the browser panel's screenshot loop spawns one per stream-frame pulse — a live page flickers focus-stealing windows several times a second. **Why none of the `exit`-vs-`close` trouble surfaced on macOS.** The `agent-browser` daemon double-forks and detaches from the inherited fds, so `close` fires normally; only on Windows, where the daemon holds the parent's stdout/stderr pipes for its whole life, does a `close`-only wait hang forever. diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index 1ea5293d2..d0a999182 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -14,7 +14,7 @@ import { AGENT_BROWSER_BIN_ENV, DEFAULT_AGENT_BROWSER_BIN, } from 'dor-lib-common'; -import { existsSync } from 'node:fs'; +import { accessSync, constants, existsSync, statSync } from 'node:fs'; import type { CliEnv, AgentBrowserExecResult, @@ -35,9 +35,13 @@ import { const INSTALL_HINT = 'npm i -g agent-browser'; const INSTALL_DOCS = 'https://agent-browser.dev'; -// Extensions a bare command name can carry on Windows, in PATH-search order. -// Shared by resolveBinaryPath (PATH walk) and existsCandidate (explicit path). -const WINDOWS_BIN_EXTS = ['.cmd', '.exe', '.bat']; +// Extensions a bare command name can carry on Windows. This is cmd.exe's own +// default PATHEXT order, which is what cross-spawn's `which` uses when the +// variable is unset — `resolveBinaryPath` now picks the file that gets spawned, +// so it has to agree with the resolver it replaced (docs/specs/dor-cli.md → +// "Spawning External Binaries"). Shared by resolveBinaryPath (PATH walk) and +// existsCandidate (explicit path, where order only affects which one we report). +const WINDOWS_BIN_EXTS = ['.com', '.exe', '.bat', '.cmd']; /** * Clear, multi-line guidance shown when the user's agent-browser binary is @@ -410,17 +414,39 @@ function shouldManageSurface(exitCode: number, rest: string[]): boolean { return subcommand !== undefined && subcommand !== 'close'; } +/** + * Whether `candidate` is a file this platform would actually run. `which` (and + * so cross-spawn) skips a directory or a non-executable file and keeps walking; + * since the walk's answer is now the spawn target, a laxer test here would turn + * a `PATH` entry `which` ignored into an EACCES/EISDIR failure. On Windows the + * extension decides executability, so being a regular file is the whole test. + */ +function isExecutableFile(candidate: string): boolean { + try { + if (!statSync(candidate).isFile()) return false; + if (process.platform === 'win32') return true; + accessSync(candidate, constants.X_OK); + return true; + } catch { + return false; + } +} + export function resolveBinaryPath(binary: string, env: CliEnv): string | undefined { if (binary.includes('/') || binary.includes('\\')) return binary; const pathVar = env.PATH; if (!pathVar) return undefined; const isWindows = process.platform === 'win32'; - const names = isWindows ? WINDOWS_BIN_EXTS.map((ext) => `${binary}${ext}`) : [binary]; + // Honour a customised PATHEXT, as `which` does; fall back to cmd.exe's default. + const exts = isWindows + ? (env.PATHEXT ?? WINDOWS_BIN_EXTS.join(';')).split(';').filter(Boolean) + : ['']; + const names = exts.map((ext) => `${binary}${ext}`); for (const dir of pathVar.split(isWindows ? ';' : ':')) { if (!dir) continue; for (const name of names) { const candidate = `${dir}${isWindows ? '\\' : '/'}${name}`; - if (existsSync(candidate)) return candidate; + if (isExecutableFile(candidate)) return candidate; } } return undefined; diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index ac8cab401..b32b773f6 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -1412,6 +1412,31 @@ test('agent-browser spawns the PATH-resolved absolute path, never the bare name' }); }); +test('agent-browser skips a PATH entry that is not an executable file', async () => { + await withTempDir('dor-ab-skip-', async (shadowRoot) => { + await withTempDir('dor-ab-real-', async (realRoot) => { + // `which` (and so cross-spawn, which the bare-name spawn used to reach) + // walks past a directory or a non-executable file. resolveBinaryPath now + // picks what gets spawned, so a laxer test would turn a PATH entry `which` + // ignored into an EACCES/EISDIR failure instead of finding the real + // install further along. + const ext = process.platform === 'win32' ? '.cmd' : ''; + await mkdir(join(shadowRoot, `agent-browser${ext}`)); + const realPath = join(realRoot, `agent-browser${ext}`); + await writeFile(realPath, '#!/bin/sh\n', { mode: 0o755 }); + const ab = fakeAgentBrowser(); + const client = fixtureClient(); + await runCli(['ab', 'snapshot'], { + client, + execAgentBrowser: ab.exec, + env: { PATH: [shadowRoot, realRoot].join(delimiter) }, + }); + assert.equal(ab.calls[0][0], realPath); + assert.equal(surfaceRequest(client).binaryPath, realPath); + }); + }); +}); + // The caller terminal (surface:2) plus the host identity `dor list --json` folds // in. The control socket is private host plumbing (the CLI is the public API), so // the host block must not echo it — the snapshot proves the field is absent. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 56b36d2d7..c0d0239ef 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,7 +6,7 @@ "docs/specs/auto-update.md": 1200, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4700, - "docs/specs/dor-cli.md": 6350, + "docs/specs/dor-cli.md": 6400, "docs/specs/dor-tool.md": 4050, "docs/specs/glossary.md": 3000, "docs/specs/hosted.md": 1050, From 1618c9aecd5909eb8cd3670473f17f2e13d456ea Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:45:00 +0000 Subject: [PATCH 3/5] Address review: mirror which(1) exactly, and pin every rule on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round two found the previous commit asserted an agreement it did not have. `which@2`'s hardcoded fallback is npm's `.EXE;.CMD;.BAT;.COM`, not `cmd.exe`'s `.COM;.EXE;.BAT;.CMD`, so copying the shell inverted `.com`-vs-`.exe` and `.bat`-vs-`.cmd`; it reads that list with `||`, so an empty `PATHEXT` falls back instead of yielding no candidates and reporting a present install as missing; and it unshifts an empty extension when the command contains a `.`, so `agent-browser.exe` is tried as itself. All three verified against `getPathInfo` in `which/which.js` at 2.0.2. Separately: `agentBrowserIsMissing` returned false with no `PATH` to search ("ambiguous, let the spawn decide"), and the spawn's fallback is the bare name — the cwd-first path this branch exists to close. An unresolvable bare name is now a missing install on every route. The extension rules are Windows-only and this suite runs on Linux in CI, so they were unenforceable as written: the ordering logic moves into an exported `binaryCandidateNames(binary, env, isWindows)` and is pinned through the argument. Each of the six rules now mutation-checks red on Linux. --- docs/specs/dor-cli.md | 26 ++++++--- docs/specs/dor-cli.rationale.md | 9 +++- dor/src/commands/agent-browser.ts | 58 ++++++++++++++------- dor/test/cli-output.test.mjs | 87 +++++++++++++++++++++++++++++-- scripts/spec-word-budgets.json | 2 +- 5 files changed, 151 insertions(+), 31 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 363714ef6..e6c6e12fc 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -102,12 +102,26 @@ tab/eval/screenshot commands, and anything added later. It is the only code before `PATH` on Windows (rationale). It falls back to the bare name only when the walk found nothing, so absence still surfaces as ENOENT. The host's candidate list still ends in a bare name (`## Future`). -- **The walk must agree with `which` on which file that is**, since its answer - is what executes: skip a directory or a non-executable file rather than - returning it, and order Windows candidates by `PATHEXT` when set, else by - `cmd.exe`'s default. A laxer test turns a `PATH` entry `which` walked past into - an EACCES/EISDIR failure. Pinned by *skips a PATH entry that is not an - executable file* in `dor/test/cli-output.test.mjs`. +- **The walk must select the file `which` would**, since its answer is what + executes — a divergence either runs a different binary or reports a present + install as missing. It skips a directory or a non-executable file rather than + returning it, and on Windows takes its extension list from `PATHEXT` **or**, + when that is unset *or empty*, from `which`'s own hardcoded + `.EXE;.CMD;.BAT;.COM` — npm's order, not `cmd.exe`'s — trying the empty + extension first when the name already carries one. +- **A bare name that cannot be resolved is a missing install, never a bare-name + spawn** — including when there is no `PATH` to search at all, since the + fallback would otherwise be the cwd-first path the rule above closes. + + The Windows rules are pinned by *the Windows candidate list mirrors which(1)*, + which takes `isWindows` as an argument rather than reading `process.platform` + — CI runs this suite on Linux only, so a platform-gated assertion would assert + an unenforced claim. + + Source of truth: `binaryCandidateNames`, `resolveBinaryPath` and + `agentBrowserIsMissing` in `dor/src/commands/agent-browser.ts`; `getPathInfo` + in `which/which.js` is what they mirror; pinned in + `dor/test/cli-output.test.mjs`. - **`windowsHide`.** Without it every `.cmd` shim flashes a focus-stealing console window, once per screenshot stream-frame pulse (rationale). - **Resolve on `exit`, not `close`, with an exit-time snapshot** — the diff --git a/docs/specs/dor-cli.rationale.md b/docs/specs/dor-cli.rationale.md index 43e0a9b9b..006d84bcd 100644 --- a/docs/specs/dor-cli.rationale.md +++ b/docs/specs/dor-cli.rationale.md @@ -45,7 +45,14 @@ divergence from `which` was inert: a fixed `.cmd`/`.exe`/`.bat` order and a bare holding `agent-browser.exe` and `agent-browser.cmd` would switch which one runs, and a non-executable file or directory named `agent-browser` earlier on `PATH` would fail EACCES/EISDIR where `which` walked past it to the real install (that -one is not Windows-specific). Raised on review of the fix, before it merged. +one is not Windows-specific). A second round found that `which@2`'s fallback list +is npm's `.EXE;.CMD;.BAT;.COM` rather than `cmd.exe`'s `.COM;.EXE;.BAT;.CMD`, so +copying the shell's order inverts `.com`-vs-`.exe` and `.bat`-vs-`.cmd`; that +`which` uses `||` rather than `??` for it, so an empty `PATHEXT` falls back +instead of yielding no candidates; and that it unshifts an empty extension when +the command contains a `.`, so `agent-browser.exe` is searched as itself. All +four are `getPathInfo` in `which/which.js`, read at 2.0.2. Both rounds were +review findings on the fix, before it merged. **What a missing `windowsHide` looks like.** cross-spawn routes `.cmd` shims through `cmd.exe`, which owns a real console window, and the browser panel's screenshot loop spawns one per stream-frame pulse — a live page flickers focus-stealing windows several times a second. diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index d0a999182..8fe8ed489 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -35,13 +35,14 @@ import { const INSTALL_HINT = 'npm i -g agent-browser'; const INSTALL_DOCS = 'https://agent-browser.dev'; -// Extensions a bare command name can carry on Windows. This is cmd.exe's own -// default PATHEXT order, which is what cross-spawn's `which` uses when the -// variable is unset — `resolveBinaryPath` now picks the file that gets spawned, -// so it has to agree with the resolver it replaced (docs/specs/dor-cli.md → -// "Spawning External Binaries"). Shared by resolveBinaryPath (PATH walk) and -// existsCandidate (explicit path, where order only affects which one we report). -const WINDOWS_BIN_EXTS = ['.com', '.exe', '.bat', '.cmd']; +// Extensions a bare command name can carry on Windows, and the order to try +// them in. This is `which@2`'s own hardcoded fallback — npm's list, deliberately +// NOT cmd.exe's `.COM;.EXE;.BAT;.CMD` — because `resolveBinaryPath` now picks +// the file that gets spawned and has to choose the same one cross-spawn's +// `which` would (docs/specs/dor-cli.md → "Spawning External Binaries"). +// Source: `getPathInfo` in `which/which.js`. Shared by resolveBinaryPath (PATH +// walk) and existsCandidate (explicit path, where order only affects reporting). +const WINDOWS_BIN_EXTS = ['.EXE', '.CMD', '.BAT', '.COM']; /** * Clear, multi-line guidance shown when the user's agent-browser binary is @@ -432,16 +433,32 @@ function isExecutableFile(candidate: string): boolean { } } +/** + * The filenames to try for a bare `binary`, in order — `which`'s extension logic, + * which the walk has to reproduce because its answer is what gets spawned. Takes + * `isWindows` rather than reading `process.platform` so the Windows ordering is + * testable off Windows: every rule here is Windows-only, and a Linux-only CI + * that could not exercise them would be asserting an unenforced claim. + * + * Mirrors `getPathInfo` in `which/which.js` on three points a hand-rolled walk + * gets wrong: `||` (not `??`), so an *empty* PATHEXT falls back rather than + * yielding no candidates; the fallback list is npm's, not `cmd.exe`'s; and an + * empty extension comes first when the name already carries one, so + * `agent-browser.exe` is tried as itself and not only as `agent-browser.exe.EXE`. + */ +export function binaryCandidateNames(binary: string, env: CliEnv, isWindows: boolean): string[] { + if (!isWindows) return [binary]; + const exts = (env.PATHEXT || WINDOWS_BIN_EXTS.join(';')).split(';').filter(Boolean); + if (binary.includes('.')) exts.unshift(''); + return exts.map((ext) => `${binary}${ext}`); +} + export function resolveBinaryPath(binary: string, env: CliEnv): string | undefined { if (binary.includes('/') || binary.includes('\\')) return binary; const pathVar = env.PATH; if (!pathVar) return undefined; const isWindows = process.platform === 'win32'; - // Honour a customised PATHEXT, as `which` does; fall back to cmd.exe's default. - const exts = isWindows - ? (env.PATHEXT ?? WINDOWS_BIN_EXTS.join(';')).split(';').filter(Boolean) - : ['']; - const names = exts.map((ext) => `${binary}${ext}`); + const names = binaryCandidateNames(binary, env, isWindows); for (const dir of pathVar.split(isWindows ? ';' : ':')) { if (!dir) continue; for (const name of names) { @@ -458,20 +475,23 @@ function isMissingBinaryError(error: unknown): boolean { /** * Whether the binary can be proven absent without spawning it, given the path - * `resolveBinaryPath` already produced for it. Returns true only when the absence - * is certain; ambiguous cases (no PATH to search) fall through to the spawn, - * which still rejects with ENOENT. + * `resolveBinaryPath` already produced for it. Every "not found" answer ends the + * call here rather than at the spawn, because the spawn's own fallback is the + * bare name and cross-spawn resolves that against the cwd first on Windows. */ -function agentBrowserIsMissing(binary: string, env: CliEnv, resolvedPath: string | undefined): boolean { +export function agentBrowserIsMissing(binary: string, env: CliEnv, resolvedPath: string | undefined): boolean { // Explicit path (e.g. a DORMOUSE_AGENT_BROWSER_BIN override): resolveBinaryPath // hands such a path back verbatim without touching disk, so check it (and // Windows launcher extensions) directly. if (binary.includes('/') || binary.includes('\\')) { return !existsCandidate(binary, process.platform === 'win32'); } - // Bare name: resolvedPath is the PATH walk's result. Without a PATH to search - // we can't prove anything, so let the spawn decide. - if (!env.PATH) return false; + // Bare name: resolvedPath is the PATH walk's result. With no PATH to search + // there is nowhere the binary could legitimately be, and falling through to + // the spawn would hand cross-spawn a bare name — whose `which` searches the + // cwd first on Windows, the one thing `execTarget` exists to prevent. So an + // absent PATH is "missing", not "ambiguous". + if (!env.PATH) return true; return resolvedPath === undefined; } diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index b32b773f6..9e176abc6 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -7,6 +7,7 @@ import { delimiter, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runCli } from '../dist/cli.js'; import { buildShellCommandForKind, shellCommandKind } from '../dist/commands/shell-quote.js'; +import { agentBrowserIsMissing, binaryCandidateNames } from '../dist/commands/agent-browser.js'; import { msysToWindowsCwd } from '../dist/commands/shared.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -371,6 +372,17 @@ function awaitClient(outcome) { }; } +// Windows resolves a path case-insensitively and `which` returns the extension +// as PATHEXT spells it, not as the file on disk does — so a `.cmd` install is +// legitimately reported as `...\\agent-browser.CMD`. Compare accordingly there. +function assertSamePath(actual, expected, message) { + if (process.platform === 'win32') { + assert.equal(String(actual).toLowerCase(), expected.toLowerCase(), message); + } else { + assert.equal(actual, expected, message); + } +} + function fakeAgentBrowser({ exitCode = 0, stdout = '✓ ok\n', stderr = '' } = {}) { const calls = []; return { @@ -1407,8 +1419,9 @@ test('agent-browser spawns the PATH-resolved absolute path, never the bare name' // hand the inherited cwd a code-execution primitive on Windows, where // cross-spawn's `which` searches it before PATH — docs/specs/dor-cli.md -> // "Spawning External Binaries". - assert.deepEqual(ab.calls.map((call) => call[0]), [binPath, binPath]); - assert.equal(surfaceRequest(client).binaryPath, binPath); + assert.equal(ab.calls.length, 2); + for (const [target] of ab.calls) assertSamePath(target, binPath); + assertSamePath(surfaceRequest(client).binaryPath, binPath); }); }); @@ -1431,12 +1444,78 @@ test('agent-browser skips a PATH entry that is not an executable file', async () execAgentBrowser: ab.exec, env: { PATH: [shadowRoot, realRoot].join(delimiter) }, }); - assert.equal(ab.calls[0][0], realPath); - assert.equal(surfaceRequest(client).binaryPath, realPath); + assertSamePath(ab.calls[0][0], realPath); + assertSamePath(surfaceRequest(client).binaryPath, realPath); }); }); }); +// Windows-only: off Windows the walk uses a single empty extension, so the name +// is already tried as itself and there is nothing to regress. +test('agent-browser tries a name that already carries an extension as itself', { + skip: process.platform !== 'win32' ? 'Windows-only PATHEXT behaviour' : false, +}, async () => { + await withTempDir('dor-ab-ext-', async (dir) => { + const binPath = join(dir, 'agent-browser.exe'); + await writeFile(binPath, '', { mode: 0o755 }); + const ab = fakeAgentBrowser(); + const client = fixtureClient(); + // `which` unshifts an empty extension when the command contains a `.`; + // without it the walk only ever looks for `agent-browser.exe.EXE` and + // reports a present install as missing. + await runCli(['ab', 'snapshot'], { + client, + execAgentBrowser: ab.exec, + env: { PATH: dir, DORMOUSE_AGENT_BROWSER_BIN: 'agent-browser.exe' }, + }); + assertSamePath(ab.calls[0][0], binPath); + }); +}); + +test('the Windows candidate list mirrors which(1) on every edge', () => { + // Exercised off Windows by passing isWindows, because all three rules are + // Windows-only and this suite runs on Linux in CI — a platform-gated + // assertion here would be an unenforced claim. `which`'s own logic: + // `opt.pathExt || process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM'`, plus an + // empty extension unshifted when the command contains a `.`. + const npmOrder = ['.EXE', '.CMD', '.BAT', '.COM']; + assert.deepEqual( + binaryCandidateNames('agent-browser', {}, true), + npmOrder.map((ext) => `agent-browser${ext}`), + 'unset PATHEXT takes npm\u2019s list, not cmd.exe\u2019s .COM-first order', + ); + assert.deepEqual( + binaryCandidateNames('agent-browser', { PATHEXT: '' }, true), + npmOrder.map((ext) => `agent-browser${ext}`), + 'an empty PATHEXT falls back rather than yielding no candidates', + ); + assert.deepEqual( + binaryCandidateNames('agent-browser', { PATHEXT: '.EXE;.PS1' }, true), + ['agent-browser.EXE', 'agent-browser.PS1'], + 'a customised PATHEXT is honoured verbatim, in its own order', + ); + assert.deepEqual( + binaryCandidateNames('agent-browser.exe', {}, true)[0], + 'agent-browser.exe', + 'a name carrying an extension is tried as itself first', + ); + assert.deepEqual( + binaryCandidateNames('agent-browser', { PATHEXT: '.EXE' }, false), + ['agent-browser'], + 'PATHEXT is Windows-only; off Windows the name is the only candidate', + ); +}); + +test('an unresolvable bare name is a missing install, including with no PATH', () => { + // The bare-name fallback at the spawn is for "the walk found nothing on a PATH + // we searched". With no PATH there is nothing to search, and reporting + // "ambiguous" would fall through to cross-spawn with the bare name — cwd-first + // on Windows, which is what spawning the resolved path exists to prevent. + // Pinned on the helper because the CLI's two paths produce the same message. + assert.equal(agentBrowserIsMissing('agent-browser', {}, undefined), true); + assert.equal(agentBrowserIsMissing('agent-browser', { PATH: '/nonexistent' }, undefined), true); +}); + // The caller terminal (surface:2) plus the host identity `dor list --json` folds // in. The control socket is private host plumbing (the CLI is the public API), so // the host block must not echo it — the snapshot proves the field is absent. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index c0d0239ef..bd8e41404 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,7 +6,7 @@ "docs/specs/auto-update.md": 1200, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4700, - "docs/specs/dor-cli.md": 6400, + "docs/specs/dor-cli.md": 6500, "docs/specs/dor-tool.md": 4050, "docs/specs/glossary.md": 3000, "docs/specs/hosted.md": 1050, From 9aa2c665932d358f14b0b277beabe989dbbb8224 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:57:42 +0000 Subject: [PATCH 4/5] Address review: scope the invariant to PATH, and pin the X_OK half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round three found the new invariant stating the opposite of the fix in the one case that motivated it: `which`'s Windows branch prepends `process.cwd()`, so "the walk must select the file `which` would", read literally, licenses the cloned repository's `agent-browser.cmd` — and the spec is what an editor reads. The agreement is now scoped to the `PATH` directories, with excluding the cwd stated as its own imperative. Two claims that no longer held: the bare-name fallback is unreachable on the real path now that an unresolvable name is reported before the spawn, so neither the spec nor the comment says "absence surfaces as ENOENT"; and `isMissingBinaryError` covers only a binary that disappears between the walk and the spawn. The X_OK probe was unpinned — `statSync().isFile()` already rejected the directory the walk test shadowed with, so deleting the probe alone stayed green. `isExecutableFile` takes `isWindows` like `binaryCandidateNames`, a unit test drives both branches, and the walk test gains a POSIX non-executable shadow. Each of the three halves now mutation-checks red on Linux. --- docs/specs/dor-cli.md | 40 +++++++++++++++++-------------- docs/specs/dor-cli.rationale.md | 8 +++++-- dor/src/commands/agent-browser.ts | 21 +++++++++++----- dor/test/cli-output.test.mjs | 39 ++++++++++++++++++++++++++++-- scripts/spec-word-budgets.json | 2 +- 5 files changed, 81 insertions(+), 29 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index e6c6e12fc..c1dc58c7c 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -99,24 +99,28 @@ tab/eval/screenshot commands, and anything added later. It is the only code an unavoidable batch limitation; today's forwarded arguments carry none. - **`dor ab` spawns the `PATH`-resolved absolute path, never the bare name** — cross-spawn resolves a bare name through `which`, which searches the cwd - before `PATH` on Windows (rationale). It falls back to the bare name only when - the walk found nothing, so absence still surfaces as ENOENT. The host's - candidate list still ends in a bare name (`## Future`). -- **The walk must select the file `which` would**, since its answer is what - executes — a divergence either runs a different binary or reports a present - install as missing. It skips a directory or a non-executable file rather than - returning it, and on Windows takes its extension list from `PATHEXT` **or**, - when that is unset *or empty*, from `which`'s own hardcoded - `.EXE;.CMD;.BAT;.COM` — npm's order, not `cmd.exe`'s — trying the empty - extension first when the name already carries one. -- **A bare name that cannot be resolved is a missing install, never a bare-name - spawn** — including when there is no `PATH` to search at all, since the - fallback would otherwise be the cwd-first path the rule above closes. - - The Windows rules are pinned by *the Windows candidate list mirrors which(1)*, - which takes `isWindows` as an argument rather than reading `process.platform` - — CI runs this suite on Linux only, so a platform-gated assertion would assert - an unenforced claim. + before `PATH` on Windows (rationale). The host's candidate list still ends in a + bare name (`## Future`). +- **Within the `PATH` directories, and only those, the walk must select the file + `which` would** — a divergence either runs a different binary or reports a + present install as missing. **Never extend the search to the cwd**, which is + the one place `which` looks and the rule above exists to exclude. Inside that + scope: skip a directory or a non-executable file rather than returning it, and + on Windows take the extension list from `PATHEXT` **or**, when that is unset + *or empty*, from `which`'s own hardcoded `.EXE;.CMD;.BAT;.COM` — npm's order, + not `cmd.exe`'s — trying the empty extension first when the name already + carries one. +- **A bare name the walk cannot resolve is a missing install, reported before + the spawn** — including when there is no `PATH` to search at all, since the + spawn's own fallback is the bare name. That leaves `?? binary` unreachable on + the real path, and `isMissingBinaryError` covering only a binary that + disappears between the walk and the spawn. + + Both Windows-only rules are pinned through an `isWindows` argument rather than + a `process.platform` read, because CI runs this suite on Linux only and a + platform-gated assertion would assert an unenforced claim. The one assertion + that cannot be written that way is the POSIX executable bit, since + `accessSync(X_OK)` reports every readable file as executable on Windows. Source of truth: `binaryCandidateNames`, `resolveBinaryPath` and `agentBrowserIsMissing` in `dor/src/commands/agent-browser.ts`; `getPathInfo` diff --git a/docs/specs/dor-cli.rationale.md b/docs/specs/dor-cli.rationale.md index 006d84bcd..78061b75d 100644 --- a/docs/specs/dor-cli.rationale.md +++ b/docs/specs/dor-cli.rationale.md @@ -51,8 +51,12 @@ copying the shell's order inverts `.com`-vs-`.exe` and `.bat`-vs-`.cmd`; that `which` uses `||` rather than `??` for it, so an empty `PATHEXT` falls back instead of yielding no candidates; and that it unshifts an empty extension when the command contains a `.`, so `agent-browser.exe` is searched as itself. All -four are `getPathInfo` in `which/which.js`, read at 2.0.2. Both rounds were -review findings on the fix, before it merged. +four are `getPathInfo` in `which/which.js`, read at 2.0.2. A third round caught +the invariant stating the opposite of the fix in the case that motivated it — +`which`'s Windows branch prepends `process.cwd()`, so an unscoped "select the +file `which` would" licenses the hijack — and that the X_OK probe was unpinned +because `statSync().isFile()` already rejected the directory the test shadowed +with. All three rounds were review findings on the fix, before it merged. **What a missing `windowsHide` looks like.** cross-spawn routes `.cmd` shims through `cmd.exe`, which owns a real console window, and the browser panel's screenshot loop spawns one per stream-frame pulse — a live page flickers focus-stealing windows several times a second. diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index 8fe8ed489..8b23dcb00 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -256,8 +256,12 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P // same). Since `dor` inherits the pane's cwd, a bare-name spawn would let an // `agent-browser.cmd` sitting in a cloned repository win the race against the // real install — repo content executing with no gate, which - // docs/specs/dor-tool.md -> Trust treats as a boundary. Falls back to the bare - // name only when the PATH walk found nothing, which is the ENOENT path below. + // docs/specs/dor-tool.md -> Trust treats as a boundary. + // + // The `?? binary` branch is unreachable on the real path and is a type-level + // belt only: agentBrowserIsMissing already ends the call whenever binaryPath is + // undefined, and an explicit path comes back from resolveBinaryPath verbatim. + // Only a stub exec (tests), which skips that check, reaches it. // See docs/specs/dor-cli.md -> "Spawning External Binaries". const execTarget = binaryPath ?? binary; @@ -420,12 +424,14 @@ function shouldManageSurface(exitCode: number, rest: string[]): boolean { * so cross-spawn) skips a directory or a non-executable file and keeps walking; * since the walk's answer is now the spawn target, a laxer test here would turn * a `PATH` entry `which` ignored into an EACCES/EISDIR failure. On Windows the - * extension decides executability, so being a regular file is the whole test. + * extension decides executability, so being a regular file is the whole test — + * taken as an argument, like `binaryCandidateNames`, so both branches are + * reachable from a Linux-only CI. */ -function isExecutableFile(candidate: string): boolean { +export function isExecutableFile(candidate: string, isWindows: boolean): boolean { try { if (!statSync(candidate).isFile()) return false; - if (process.platform === 'win32') return true; + if (isWindows) return true; accessSync(candidate, constants.X_OK); return true; } catch { @@ -463,12 +469,15 @@ export function resolveBinaryPath(binary: string, env: CliEnv): string | undefin if (!dir) continue; for (const name of names) { const candidate = `${dir}${isWindows ? '\\' : '/'}${name}`; - if (isExecutableFile(candidate)) return candidate; + if (isExecutableFile(candidate, isWindows)) return candidate; } } return undefined; } +// Narrow, now that an unresolvable name never reaches the spawn: this catches a +// binary that disappeared between the PATH walk and the spawn, plus a stub exec's +// injected ENOENT. function isMissingBinaryError(error: unknown): boolean { return !!error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT'; } diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 9e176abc6..711295a3b 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -7,7 +7,7 @@ import { delimiter, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runCli } from '../dist/cli.js'; import { buildShellCommandForKind, shellCommandKind } from '../dist/commands/shell-quote.js'; -import { agentBrowserIsMissing, binaryCandidateNames } from '../dist/commands/agent-browser.js'; +import { agentBrowserIsMissing, binaryCandidateNames, isExecutableFile } from '../dist/commands/agent-browser.js'; import { msysToWindowsCwd } from '../dist/commands/shared.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -1435,6 +1435,13 @@ test('agent-browser skips a PATH entry that is not an executable file', async () // install further along. const ext = process.platform === 'win32' ? '.cmd' : ''; await mkdir(join(shadowRoot, `agent-browser${ext}`)); + // Off Windows a second shadow, a regular file without the executable bit: + // the directory above is rejected by the isFile() test alone, so this is + // what makes the X_OK probe load-bearing in the walk itself. + if (process.platform !== 'win32') { + await mkdir(join(shadowRoot, 'second')); + await writeFile(join(shadowRoot, 'second', 'agent-browser'), '', { mode: 0o644 }); + } const realPath = join(realRoot, `agent-browser${ext}`); await writeFile(realPath, '#!/bin/sh\n', { mode: 0o755 }); const ab = fakeAgentBrowser(); @@ -1442,7 +1449,9 @@ test('agent-browser skips a PATH entry that is not an executable file', async () await runCli(['ab', 'snapshot'], { client, execAgentBrowser: ab.exec, - env: { PATH: [shadowRoot, realRoot].join(delimiter) }, + env: { + PATH: [shadowRoot, join(shadowRoot, 'second'), realRoot].join(delimiter), + }, }); assertSamePath(ab.calls[0][0], realPath); assertSamePath(surfaceRequest(client).binaryPath, realPath); @@ -1472,6 +1481,32 @@ test('agent-browser tries a name that already carries an extension as itself', { }); }); +test('isExecutableFile rejects a directory on both platforms, and a non-executable file off Windows', async () => { + await withTempDir('dor-exec-', async (dir) => { + const asDirectory = join(dir, 'shadow'); + await mkdir(asDirectory); + const notExecutable = join(dir, 'not-executable'); + await writeFile(notExecutable, '', { mode: 0o644 }); + const executable = join(dir, 'executable'); + await writeFile(executable, '', { mode: 0o755 }); + + // `isWindows` is a parameter rather than a `process.platform` read so both + // branches are reachable from this Linux-only suite. `statSync().isFile()` + // alone covers the directory, so the X_OK probe is what the middle pair pins. + assert.equal(isExecutableFile(asDirectory, false), false); + assert.equal(isExecutableFile(asDirectory, true), false); + assert.equal(isExecutableFile(executable, false), true); + assert.equal(isExecutableFile(join(dir, 'absent'), false), false); + // On Windows the extension decides executability, so a mode-0644 regular + // file is runnable there and `accessSync(X_OK)` reports every readable file + // as executable — which is why the POSIX half cannot be asserted there. + assert.equal(isExecutableFile(notExecutable, true), true); + if (process.platform !== 'win32') { + assert.equal(isExecutableFile(notExecutable, false), false); + } + }); +}); + test('the Windows candidate list mirrors which(1) on every edge', () => { // Exercised off Windows by passing isWindows, because all three rules are // Windows-only and this suite runs on Linux in CI — a platform-gated diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index bd8e41404..9feeef214 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,7 +6,7 @@ "docs/specs/auto-update.md": 1200, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4700, - "docs/specs/dor-cli.md": 6500, + "docs/specs/dor-cli.md": 6550, "docs/specs/dor-tool.md": 4050, "docs/specs/glossary.md": 3000, "docs/specs/hosted.md": 1050, From 4af22b0ece571d41bc885ce5af438132a1154f9d Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:07:21 +0000 Subject: [PATCH 5/5] Address review: keep PATHEXT's trailing empty extension, and point at isExecutableFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getPathInfo` splits PATHEXT without a filter, so a trailing separator — ordinary on Windows — leaves a final empty extension and tries the name unsuffixed. The walk's `.filter(Boolean)` dropped it, which would report missing where `which` returned a path. Nothing runnable lives at an extension-less name on Windows, but the invariant claims parity without qualification and every other divergence on this branch is written down. `isExecutableFile` became a rule-bearer in the previous commit and was missing from the rule's `Source of truth:` list. --- docs/specs/dor-cli.md | 4 ++-- dor/src/commands/agent-browser.ts | 6 +++++- dor/test/cli-output.test.mjs | 5 +++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index c1dc58c7c..2f32323f0 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -122,8 +122,8 @@ tab/eval/screenshot commands, and anything added later. It is the only code that cannot be written that way is the POSIX executable bit, since `accessSync(X_OK)` reports every readable file as executable on Windows. - Source of truth: `binaryCandidateNames`, `resolveBinaryPath` and - `agentBrowserIsMissing` in `dor/src/commands/agent-browser.ts`; `getPathInfo` + Source of truth: `binaryCandidateNames`, `isExecutableFile`, `resolveBinaryPath` + and `agentBrowserIsMissing` in `dor/src/commands/agent-browser.ts`; `getPathInfo` in `which/which.js` is what they mirror; pinned in `dor/test/cli-output.test.mjs`. - **`windowsHide`.** Without it every `.cmd` shim flashes a focus-stealing diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index 8b23dcb00..ba053ad87 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -454,7 +454,11 @@ export function isExecutableFile(candidate: string, isWindows: boolean): boolean */ export function binaryCandidateNames(binary: string, env: CliEnv, isWindows: boolean): string[] { if (!isWindows) return [binary]; - const exts = (env.PATHEXT || WINDOWS_BIN_EXTS.join(';')).split(';').filter(Boolean); + // No `.filter(Boolean)`: `getPathInfo` splits without one, so a trailing + // separator — ordinary on Windows — leaves a final empty extension that tries + // the name unsuffixed. Nothing runnable lives there, but dropping it would make + // the walk report missing where `which` returned a path. + const exts = (env.PATHEXT || WINDOWS_BIN_EXTS.join(';')).split(';'); if (binary.includes('.')) exts.unshift(''); return exts.map((ext) => `${binary}${ext}`); } diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 711295a3b..0810fd117 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -1529,6 +1529,11 @@ test('the Windows candidate list mirrors which(1) on every edge', () => { ['agent-browser.EXE', 'agent-browser.PS1'], 'a customised PATHEXT is honoured verbatim, in its own order', ); + assert.deepEqual( + binaryCandidateNames('agent-browser', { PATHEXT: '.EXE;' }, true), + ['agent-browser.EXE', 'agent-browser'], + 'a trailing separator keeps the empty extension, as splitting without a filter does', + ); assert.deepEqual( binaryCandidateNames('agent-browser.exe', {}, true)[0], 'agent-browser.exe',