fix(dor): spawn agent-browser by resolved path, not bare name - #705
dormouse-bot wants to merge 5 commits into
Conversation
`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).
Deploying mouseterm with
|
| Latest commit: |
4af22b0
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://7fe440f5.mouseterm.pages.dev |
| Branch Preview URL: | https://daily-review-runs-3544178685.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
Draft feedback on work in progress, not a merge verdict — mark it ready when you want the full review.
The spawn target moves from cross-spawn's resolution to resolveBinaryPath's hand-rolled PATH walk, and those two don't resolve the same file. The walk tests candidates with existsSync in a fixed WINDOWS_BIN_EXTS order (.cmd, .exe, .bat), while the bare-name spawn it replaces went through cross-spawn to which, which requires an executable file and orders Windows candidates by PATHEXT (default .COM;.EXE;.BAT;.CMD;…). Two observable differences: a PATH directory holding both agent-browser.exe and agent-browser.cmd used to run the .exe and now runs the .cmd (and a customised PATHEXT is ignored outright); and a non-executable file — or a directory — named agent-browser earlier on PATH used to be skipped by which in favour of the real install further along, where now it becomes execTarget and the spawn fails with a raw EACCES/EISDIR instead. The second one is not Windows-specific.
That divergence was inert while binaryPath only proved the install present and travelled to the host as a hint; promoting it to the thing that executes is what makes it matter, and neither the spec rule nor the rationale mentions that resolution semantics moved with it. Worth either matching which more closely in the walk (honour env.PATHEXT when set, and check executability rather than mere existence) or stating in docs/specs/dor-cli.md that dor ab resolves by its own fixed extension order — as written, a reader takes "the PATH-resolved absolute path" to mean the path PATH resolution would have produced anyway.
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.
|
Taken, both halves — the divergence is real and the second one isn't
Pinned by skips a PATH entry that is not an executable file, mutation-checked
|
dormouse-bot
left a comment
There was a problem hiding this comment.
Draft feedback on work in progress, not a merge verdict — mark it ready when you want the full review.
The executability test lands, and so does the PATHEXT-when-set half. The
fallback half doesn't: which@2.0.2 hardcodes '.EXE;.CMD;.BAT;.COM' — its
getPathInfo reads opt.pathExt || process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM'
— which is npm's own list, not cmd.exe's .COM;.EXE;.BAT;.CMD. So the
constant's comment, the spec bullet, and the commit message each assert an
agreement that doesn't hold, and two orderings invert against the resolver being
replaced: a directory holding both agent-browser.com and agent-browser.exe
runs the .com where cross-spawn ran the .exe, and .bat-vs-.cmd flips the
same way. The .exe-vs-.cmd case from the last round does land right — both
lists put .exe first — so that conclusion was correct and the reason given for
it wasn't.
Two more on the same expression:
env.PATHEXT ?? …keeps an emptyPATHEXT, soextsis[], the walk
resolves nothing, anddor abfails with "agent-browser is not installed"
while the install sits onPATH.whichuses||there and falls back to
its default.- A bare name that already carries an extension —
DORMOUSE_AGENT_BROWSER_BIN=agent-browser.exe,
no separator, soresolveBinaryPathwalks instead of returning it verbatim —
is searched asagent-browser.exe.EXE,agent-browser.exe.CMD, … and never
as itself, giving the same false "not installed".whichunshifts''onto
pathExtwhen the command contains a.(itscmd.indexOf('.') !== -1
branch). That one predates this PR; what's new is the rule claiming it can't
happen.
Narrower, at the spawn itself: agentBrowserIsMissing returns false when
env.PATH is unset — "ambiguous, let the spawn decide" — and binaryPath ?? binary then spawns the bare name, which on Windows is the cwd-first path this
PR closes rather than the ENOENT the spec bullet says the fallback is for.
Treating "no PATH to search" as missing would make the fallback match its
stated purpose; today it is the one route back to the primitive.
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.
|
All four taken, and the first one was the important correction — I verified const pathExtExe = isWindows
? opt.pathExt || process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM'
: ''
const pathExt = isWindows ? pathExtExe.split(colon) : ['']
if (isWindows) {
if (cmd.indexOf('.') !== -1 && pathExt[0] !== '')
pathExt.unshift('')
}So in The The part worth flagging backYour three extension findings were unpinnable as I'd written them — every rule is So the ordering logic is now an exported
Baseline 145 pass / 1 skipped (the remaining skip is a genuinely |
dormouse-bot
left a comment
There was a problem hiding this comment.
Draft feedback on work in progress, not a merge verdict — mark it ready when you want the full review.
The which mirror holds on the extension rules now. Three things left, two of them in the prose rather than the code.
The new invariant states the opposite of the fix, in the one case that motivated it. "The walk must select the file which would" — which's Windows branch prepends process.cwd() to the search path, so the file it would select is the agent-browser.cmd in the cloned repository. An editor who takes the rule literally and makes the walk agree reintroduces exactly the primitive the bullet above it closes, and the spec is what they would be reading. Scope it to the PATH directories. The suggestion below is word-neutral against the 6500 budget when paired with the next one (the file is at 6496 today, so an unpaired addition needs a re-ratchet).
The bare-name fallback is unreachable now, so the spec and the comment both describe a path that cannot be taken. Making an unresolvable bare name "missing" on every route — including no PATH at all — means agentBrowserIsMissing returns true before the spawn in every case where binaryPath is undefined, and an explicit path comes back from resolveBinaryPath verbatim so it is never undefined. binaryPath ?? binary can only take the binary branch when a test injects execAgentBrowser and skips the check. Keeping the ?? as a type-level belt is fine; "absence still surfaces as ENOENT" and "which is the ENOENT path below" are not — absence surfaces as missingBinaryMessage, and isMissingBinaryError now only catches a binary that disappears between the walk and the spawn.
Half the executability rule is unpinned. The mutation table reads existsSync instead of the executability test → 144/1, but the directory shadow alone accounts for that: statSync(...).isFile() already rejects a directory. Delete only accessSync(candidate, constants.X_OK) and the suite stays green — so the non-executable-file half of "skips a directory or a non-executable file", the half that is not Windows-specific, is the unenforced claim that binaryCandidateNames's isWindows parameter exists to avoid. A second POSIX-only shadow in the same test pins it.
Same shape, smaller: isExecutableFile's Windows branch reads process.platform directly, so "on Windows the extension decides executability, so being a regular file is the whole test" is equally unreachable from Linux CI — the same isWindows parameter would enforce it.
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.
|
All three taken in Scope. The unreachable fallback. Correct, and I traced it the same way: with the The unpinned half. You were right that my table over-claimed:
Baseline 146 pass / 1 skipped; the skip is the Windows-only CI green at |
dormouse-bot
left a comment
There was a problem hiding this comment.
Draft feedback on work in progress, not a merge verdict.
Two on the prose; the three halves of the executability rule do separate cleanly, and I found nothing to add on the code.
isExecutableFile is now a rule-bearer and isn't in the pointer list. This commit exported it, gave it the isWindows parameter and its own test, so skips a directory or a non-executable file lives in it rather than in resolveBinaryPath — but the Source of truth: beside that rule still names only the other three, leaving a reader one hop from the code the bullet describes. Suggestion below; it costs one word against a budget the file currently sits 14 under.
The walk drops PATHEXT entries which keeps. binaryCandidateNames ends in .filter(Boolean), while the getPathInfo you quoted splits with no filter — so PATHEXT=.COM;.EXE;.BAT;.CMD;, with the trailing separator that is ordinary on Windows, hands which a final empty extension and the walk none, and which then tries the extension-less agent-browser as a last candidate where the walk never does. Nothing runs differently: Windows won't execute an extension-less file, so the divergence only makes the walk report missing where which would have returned something unrunnable. But the bullet states the parity without qualification, and every other divergence on this branch is written down — so it wants a half-clause, or the filter dropped.
… isExecutableFile `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.
|
Both taken in The 146 pass / 1 skipped, and the ten mutations on this branch each still land red. |
dormouse-bot
left a comment
There was a problem hiding this comment.
Draft feedback on work in progress, not a merge verdict.
The trailing-empty-extension fix reads right. Two divergences from getPathInfo/whichSync are still unlisted, and the first has the user-visible consequence the invariant names.
which strips a quoted PATH entry; the walk doesn't. Both which and whichSync run const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw on every entry before joining, ungated by platform. resolveBinaryPath joins dir verbatim, so a PATH written as C:\Windows\system32;"C:\Program Files\nodejs" — quoting an entry with spaces is ordinary on Windows, which is why which handles it — makes every candidate under that entry "C:\Program Files\nodejs"\agent-browser.cmd. A quote is not a legal filename character, statSync throws, isExecutableFile returns false, and the directory is skipped. If the only install lives there, agentBrowserIsMissing returns true and dor ab reports "agent-browser is not installed" against a working install that every other tool on the machine resolves. This one predates the branch, the same way the already-carries-an-extension case did; what's new is the invariant asserting the walk selects the file which would within the PATH directories, with reports a present install as missing named as the failure it rules out.
4af22b0e made the unshift diverge in the case the filter used to hide. getPathInfo guards it — if (cmd.indexOf('.') !== -1 && pathExt[0] !== '') pathExt.unshift('') — and with .filter(Boolean) in place exts[0] could never be empty, so the guard was moot. Without the filter, a leading separator (PATHEXT=';.EXE') plus a name carrying a . yields the empty extension twice where which yields it once. Nothing resolves differently; it costs one duplicate statSync and it makes the candidate list — which is asserted by exact equality in the Windows candidate list mirrors which(1) on every edge — no longer the list getPathInfo produces. Worth closing while the parity is literal rather than annotated.
Both fixes are below. Neither is pinned by the suite as it stands: the quoted-entry one wants a PATH entry wrapped in quotes in the walk test, and the guard one a PATHEXT: ';.EXE' case beside the '.EXE;' one this commit added — the same mutation-in-the-direction-it-points check as the other ten.
| 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, isWindows)) return candidate; | ||
| } | ||
| } |
There was a problem hiding this comment.
| 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, isWindows)) return candidate; | |
| } | |
| } | |
| for (const dir of pathVar.split(isWindows ? ';' : ':')) { | |
| if (!dir) continue; | |
| // `which` strips a surrounding pair of double quotes from a PATH entry | |
| // (`/^".*"$/`, in both its async and sync walks, ungated by platform): | |
| // quoting an entry that contains spaces is ordinary on Windows, and a quote | |
| // is not legal in a filename, so keeping them stats every candidate ENOENT. | |
| const entry = /^".*"$/.test(dir) ? dir.slice(1, -1) : dir; | |
| for (const name of names) { | |
| const candidate = `${entry}${isWindows ? '\\' : '/'}${name}`; | |
| if (isExecutableFile(candidate, isWindows)) return candidate; | |
| } | |
| } |
| // 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(''); |
There was a problem hiding this comment.
| if (binary.includes('.')) exts.unshift(''); | |
| // `which` skips the unshift when PATHEXT already begins with an empty entry | |
| // (`pathExt[0] !== ''`), so a leading separator yields the empty extension | |
| // once, not twice. | |
| if (binary.includes('.') && exts[0] !== '') exts.unshift(''); |
dor abresolvedagent-browserto an absolute path onPATH, forwarded thatpath to the host, and then spawned the bare name anyway at both of its call
sites.
spawnAndCaptureuses cross-spawn, whosewhichsearchesprocess.cwd()beforePATHon Windows — anddorinherits the pane'sworking directory. So an
agent-browser.cmdcommitted to a repository the usercloned executed on their next
dor ab, whichdor skillmandates for everypage view.
docs/specs/security.md-> "What is not defended" accepts "a process running asyou", and this is not that: nothing in the cloned repository was running.
docs/specs/dor-tool.md-> Trust already treats repo-controlled content as aboundary, keeping
dormouse.ymlinert until granted; this path crossed it withno gate.
What the branch does
0be792c3spawns the path already computed fifteen lines earlier. Three reviewrounds then closed the consequences of promoting that path from a hint to the
thing that executes — its resolution semantics now matter, and they diverged
from the resolver they replaced:
0be792c3binaryPathat both call sites.ce8ffd25PATHEXT.1618c9aewhich@2'sgetPathInfoexactly — npm's.EXE;.CMD;.BAT;.COMfallback (notcmd.exe's order), `9aa2c665PATHdirectories —whichitself searches the cwd, so an unscoped rule licenses the hijack — and pin the executability test's three halves separately.Verification
node --test dor/test/cli-output.test.mjs— 146 pass, 1 skipped (the skipis a
process.platform-gated Windows case, and is not the pin for any rule).Every rule mutation-checks red on Linux, which took a redesign: the Windows
ordering rules were Windows-only and this suite runs on Linux in CI, so the
first attempt at pinning them passed vacuously.
binaryCandidateNamesandisExecutableFiletakeisWindowsas an argument rather than readingprocess.platform, so both branches are reachable from the suite that runs.exec(binary, …)— spawn the bare namestatSync(candidate).isFile()accessSync(candidate, X_OK)isWindowsearly return??instead of `cmd.exe's.COM-first orderPATHreported as ambiguousnode scripts/spec-lint.mjsreports only the two pre-existingstandalone/sidecar/node_modulespath hits every dependency-free checkoutshows.
docs/specs/dor-cli.mdre-baselined 6250 -> 6550 across the fourcommits.
Scope
The host's
runWithBinaryFallback(lib/src/host/agent-browser-host.ts) stillends its candidate list with the bare
DEFAULT_AGENT_BROWSER_BIN. Its cwd isthe extension-host or Tauri-app directory rather than a directory users clone
into, and sharing the resolver means moving it from
dortodor-lib-common,so it is recorded under
docs/specs/dor-cli.md->## Futurerather than foldedin here. The audit also named bare-name spawns of system binaries in
standalone/sidecar/pty-core.jsandclipboard-ops.js; those sit outside thisspec section's "external/user-installed binary" scope, and
pty-core.js:373,419's%SystemRoot%\System32join is the pattern they want.Provenance: how this reached a PR instead of the audit issue
Found by the repo's own
security-auditrun35432996343 as
CA-13, the single BLOCKER deciding that run's
VERDICT: FAIL. It is notreadable on #598: the merged
report was 315,730 characters,
clamp-issue-body.mjskeeps the head, andaudit-application.mdis both last inAUDIT_FRAGMENTSand 90% of the body —so the comment's own "read that domain's section first" points at a section
truncation removed. Recovered from the run's
audit-transcriptartifact.Each link of the chain re-verified against this checkout before writing the fix:
exec(binary, ...)atdor/src/commands/agent-browser.ts:260,275(pre-fixline numbers) against
binaryPathat:245; the walk readingenv.PATHonly;spawnAndCapture->cross-spawnatdor-lib-common/src/spawn.ts:1,43;getPathInfoinwhich/which.jsat 2.0.2 for the cwd-first comment and thewhole extension contract; and
agentBrowserIsMissingrefusing to spawn when thewalk finds nothing, which is why the hijack needs a legitimate install rather
than being blocked by one.
Opened by the daily
review-runssweep; the reporting-path defect is recordedon #511 and described on
#598.