Skip to content

Commit 9ef3aae

Browse files
authored
Make dor / dor ab work on Windows (#188)
2 parents 1413777 + 9cb1afd commit 9ef3aae

18 files changed

Lines changed: 446 additions & 49 deletions

docs/specs/dor-browser.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@ The binary is resolved from `DORMOUSE_AGENT_BROWSER_BIN` or `PATH`. If present,
191191
`dor ab` resolves an absolute `binaryPath` and passes it to the host because GUI
192192
hosts may not share the terminal's shell PATH.
193193

194+
Both `dor ab` and the host spawn `agent-browser` through `cross-spawn`, never raw
195+
`child_process` — on Windows it ships as a `.cmd` shim that a bare-name spawn
196+
can't find (ENOENT) and Node ≥22 won't run directly (EINVAL), so even the
197+
absolute `binaryPath` must go through it. See docs/specs/dor-cli.md → "Spawning
198+
External Binaries".
199+
194200
Managed identity:
195201

196202
- Default is `--key default`.

docs/specs/dor-cli.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,56 @@ Public PTY env:
6060
`DORMOUSE_CLI_BIN` is host-internal spawn configuration. Terminals should rely
6161
on `PATH`, not on that variable.
6262

63+
On Windows, `DORMOUSE_CLI_BIN` and `DORMOUSE_CLI_JS` must be plain paths, never
64+
`\\?\` verbatim paths. The standalone host derives them from Tauri's
65+
`resource_dir()`, which returns a verbatim prefix in the bundled/dev layout; the
66+
host strips it once at the boundary (`resolve_sidecar_path`), so every derived
67+
path is plain. `dor.cmd` is reached through
68+
`DORMOUSE_CLI_BIN` on `PATH`, and cmd.exe cannot execute a batch file via a
69+
verbatim path — it fails with "The system cannot find the path specified."
70+
71+
## Spawning External Binaries
72+
73+
Any time Dormouse spawns an external/user-installed binary — `dor ab` driving
74+
`agent-browser`, the agent-browser host running tab/eval/screenshot commands, dev
75+
harnesses launching `pnpm`/`agent-browser` — it goes through **`cross-spawn`**,
76+
never raw `node:child_process` `spawn`. This is mandatory for correctness on
77+
Windows, where two distinct failures bite a naive spawn:
78+
79+
- **ENOENT on a bare name.** Node's `spawn` does not consult `PATHEXT`, so a bare
80+
`agent-browser` never resolves the `agent-browser.cmd` PATH shim that npm/vfox
81+
installs. (`agent-browser` works from a POSIX shell only because the file there
82+
is a real executable with a shebang; on Windows it is a `.cmd`.)
83+
- **EINVAL on a `.cmd` even by full path.** Node ≥22 refuses to spawn `.cmd`/
84+
`.bat` files without a shell (the CVE-2024-27980 hardening), so resolving the
85+
absolute `.cmd` path and spawning it directly still fails.
86+
87+
`cross-spawn` resolves the command via `PATH`/`PATHEXT` and routes `.cmd`/`.bat`
88+
through `cmd.exe` with correct argument escaping, and is a transparent passthrough
89+
on POSIX. Use it with the same `(command, args, options)` signature as
90+
`child_process.spawn`; it is bundled into `dist/dor.js` and the sidecar `.cjs`
91+
by esbuild.
92+
93+
Caveat: a literal `%VAR%` inside an argument can still be expanded by `cmd.exe`
94+
when it passes through a `.cmd` shim — an unavoidable Windows batch limitation, not
95+
something `cross-spawn` (or any wrapper) can fully prevent. Our forwarded
96+
arguments (URLs, selectors, and the host's hardcoded `eval` scripts) contain no
97+
`%VAR%` patterns, so this does not arise in practice.
98+
99+
### Resolve on `exit`, not `close`
100+
101+
When buffering a spawned command's output, resolve on the child's **`exit`**
102+
event, not `close`. `agent-browser open` launches a long-lived per-session daemon
103+
that on Windows inherits the parent's stdout/stderr pipes; those pipes never reach
104+
EOF while the daemon lives, so `close` (which waits for stdio to drain) never
105+
fires and the spawn hangs forever. `exit` fires when the foreground process ends
106+
regardless of the lingering pipe. The two spawn helpers
107+
(`dor/src/commands/agent-browser.ts`, `lib/src/host/agent-browser-host.ts`) wait
108+
for `close` but fall back to `exit` after a short grace (`CLOSE_GRACE_MS`), so a
109+
normal command's full output still flushes while the daemon case can't hang.
110+
(POSIX dodges this because the daemon double-forks and detaches from the inherited
111+
fds, closing the pipe — which is why this never surfaced on macOS.)
112+
63113
## Host Plumbing
64114

65115
### Standalone

dor/bin/dor

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
#!/bin/sh
22
if [ -n "${DORMOUSE_NODE:-}" ] && [ -n "${DORMOUSE_CLI_JS:-}" ]; then
3+
# DORMOUSE_NODE is the editor's Electron binary, which only behaves as Node
4+
# when ELECTRON_RUN_AS_NODE is set. Set it here rather than relying on the
5+
# ambient env to carry it: terminals routinely strip it (so Electron apps
6+
# launched from a shell don't misbehave), and without it Electron launches its
7+
# GUI, ignores the script, and exits 0 — so `dor` would silently do nothing.
8+
ELECTRON_RUN_AS_NODE=1
9+
export ELECTRON_RUN_AS_NODE
310
exec "$DORMOUSE_NODE" "$DORMOUSE_CLI_JS" "$@"
411
fi
512

dor/bin/dor.cmd

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
@echo off
22
setlocal
33
if not "%DORMOUSE_NODE%"=="" if not "%DORMOUSE_CLI_JS%"=="" (
4+
rem DORMOUSE_NODE is the editor's Electron binary; it only behaves as Node when
5+
rem ELECTRON_RUN_AS_NODE is set. Set it here rather than relying on the ambient
6+
rem env to carry it: without it Electron launches its GUI, ignores the script,
7+
rem and exits 0 — so `dor` would silently do nothing.
8+
set "ELECTRON_RUN_AS_NODE=1"
49
"%DORMOUSE_NODE%" "%DORMOUSE_CLI_JS%" %*
510
exit /b %ERRORLEVEL%
611
)

dor/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@
1313
],
1414
"scripts": {
1515
"prebuild": "node ../scripts/generate-dor-version.mjs",
16-
"build": "tsc -p tsconfig.json && esbuild src/dor.ts --bundle --format=esm --platform=node --outfile=dist/dor.js",
16+
"build": "tsc -p tsconfig.json && esbuild src/dor.ts --bundle --format=esm --platform=node --outfile=dist/dor.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"",
1717
"test": "pnpm run build && node --test test/*.test.mjs"
1818
},
1919
"devDependencies": {
2020
"esbuild": "^0.28.0",
2121
"typescript": "^6.0.3"
2222
},
2323
"dependencies": {
24-
"@stricli/core": "^1.2.7"
24+
"@stricli/core": "^1.2.7",
25+
"cross-spawn": "^7.0.6"
2526
}
2627
}

dor/src/commands/agent-browser.ts

Lines changed: 123 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616
*/
1717

1818
import { buildCommand } from '@stricli/core';
19-
import { spawn } from 'node:child_process';
19+
// cross-spawn, not node:child_process — on Windows a bare command name never
20+
// resolves a `.cmd`/`.bat` PATH shim (Node spawn ignores PATHEXT → ENOENT), and
21+
// Node >=22 refuses to spawn a `.cmd` directly even by full path (EINVAL, the
22+
// CVE-2024-27980 hardening). agent-browser ships as a `.cmd` shim, so both bite.
23+
// cross-spawn routes through cmd.exe with correct escaping and is a no-op
24+
// passthrough on POSIX. See docs/specs/dor-cli.md → "Spawning External Binaries".
25+
import spawn from 'cross-spawn';
2026
import { existsSync } from 'node:fs';
2127
import type {
2228
CliEnv,
@@ -33,6 +39,33 @@ import { fail, requireControlClient, stringParser } from './shared.js';
3339
const WORKSPACE_ID = '1';
3440

3541
const INSTALL_HINT = 'npm i -g agent-browser';
42+
const INSTALL_DOCS = 'https://agent-browser.dev';
43+
const BIN_ENV = 'DORMOUSE_AGENT_BROWSER_BIN';
44+
45+
// Extensions a bare command name can carry on Windows, in PATH-search order.
46+
// Shared by resolveBinaryPath (PATH walk) and existsCandidate (explicit path).
47+
const WINDOWS_BIN_EXTS = ['.cmd', '.exe', '.bat'];
48+
49+
/**
50+
* Clear, multi-line guidance shown when the user's agent-browser binary is
51+
* absent. `binary` is named only when it differs from the default, so a custom
52+
* DORMOUSE_AGENT_BROWSER_BIN that points nowhere still tells the user what was
53+
* looked for.
54+
*/
55+
function missingBinaryMessage(binary: string): string {
56+
const lookedFor = binary === 'agent-browser' ? '' : ` (looked for '${binary}')`;
57+
return [
58+
`agent-browser is not installed${lookedFor}.`,
59+
'',
60+
'dor ab drives your own agent-browser binary, which Dormouse never bundles.',
61+
'Install it, then re-run your command:',
62+
'',
63+
` ${INSTALL_HINT}`,
64+
'',
65+
`More: ${INSTALL_DOCS}`,
66+
`Already installed? Make sure it's on your PATH, or set ${BIN_ENV} to its full path.`,
67+
].join('\n');
68+
}
3669

3770
// agent-browser session names become filesystem paths (socket dir), so `/` is
3871
// not usable as a namespace separator — the daemon fails to start. Dots keep
@@ -160,12 +193,29 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P
160193
const binary = env.DORMOUSE_AGENT_BROWSER_BIN || 'agent-browser';
161194
const exec = options.execAgentBrowser ?? execAgentBrowserProcess;
162195

196+
// Resolve the binary to an absolute path once: it both proves the install
197+
// present (below) and travels to the host as `binaryPath` (a GUI host may not
198+
// share this terminal's PATH). undefined means "not found on PATH" — or, for
199+
// an explicit path, simply "returned verbatim", which agentBrowserIsMissing
200+
// re-checks on disk.
201+
const binaryPath = resolveBinaryPath(binary, env);
202+
203+
// Detect a missing install deterministically, before spawning. A failed spawn
204+
// on Windows emits BOTH 'error' (ENOENT) and 'close' (a libuv error code); if
205+
// 'close' wins that race the process resolves with a bogus exit code and no
206+
// output, so `dor ab` would print nothing at all. Checking the filesystem
207+
// ourselves sidesteps that ordering. Skipped when a stub exec is injected
208+
// (tests), which supplies its own ENOENT behavior via the catch below.
209+
if (options.execAgentBrowser === undefined && agentBrowserIsMissing(binary, env, binaryPath)) {
210+
return fail(missingBinaryMessage(binary));
211+
}
212+
163213
let result: AgentBrowserExecResult;
164214
try {
165215
result = await exec(binary, ['--session', session, ...rest]);
166216
} catch (error) {
167217
if (isMissingBinaryError(error)) {
168-
return fail(`agent-browser was not found (looked for '${binary}'). Install it with: ${INSTALL_HINT}`);
218+
return fail(missingBinaryMessage(binary));
169219
}
170220
return fail(error instanceof Error ? error.message : String(error));
171221
}
@@ -179,11 +229,8 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P
179229
try {
180230
const status = await exec(binary, ['--session', session, 'stream', 'status', '--json']);
181231
const wsPort = parseStreamPort(status.stdout);
182-
// The Dormouse host (e.g. a GUI-launched VS Code extension host) may
183-
// not share this terminal's PATH, so resolve the binary to an
184-
// absolute path here, where the user's environment is authoritative,
185-
// and pass it along for host-side tab/close commands.
186-
const binaryPath = resolveBinaryPath(binary, env);
232+
// Pass the absolute path resolved above so the host (which may not share
233+
// this terminal's PATH) can run host-side tab/close commands.
187234
await client.agentBrowserSurface({
188235
key,
189236
session,
@@ -217,7 +264,7 @@ export function resolveBinaryPath(binary: string, env: CliEnv): string | undefin
217264
const pathVar = env.PATH;
218265
if (!pathVar) return undefined;
219266
const isWindows = process.platform === 'win32';
220-
const names = isWindows ? [`${binary}.cmd`, `${binary}.exe`, `${binary}.bat`] : [binary];
267+
const names = isWindows ? WINDOWS_BIN_EXTS.map((ext) => `${binary}${ext}`) : [binary];
221268
for (const dir of pathVar.split(isWindows ? ';' : ':')) {
222269
if (!dir) continue;
223270
for (const name of names) {
@@ -242,19 +289,83 @@ function isMissingBinaryError(error: unknown): boolean {
242289
return !!error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT';
243290
}
244291

292+
/**
293+
* Whether the binary can be proven absent without spawning it, given the path
294+
* `resolveBinaryPath` already produced for it. Returns true only when the absence
295+
* is certain; ambiguous cases (no PATH to search) fall through to the spawn,
296+
* which still rejects with ENOENT.
297+
*/
298+
function agentBrowserIsMissing(binary: string, env: CliEnv, resolvedPath: string | undefined): boolean {
299+
// Explicit path (e.g. a DORMOUSE_AGENT_BROWSER_BIN override): resolveBinaryPath
300+
// hands such a path back verbatim without touching disk, so check it (and
301+
// Windows launcher extensions) directly.
302+
if (binary.includes('/') || binary.includes('\\')) {
303+
return !existsCandidate(binary, process.platform === 'win32');
304+
}
305+
// Bare name: resolvedPath is the PATH walk's result. Without a PATH to search
306+
// we can't prove anything, so let the spawn decide.
307+
if (!env.PATH) return false;
308+
return resolvedPath === undefined;
309+
}
310+
311+
function existsCandidate(path: string, isWindows: boolean): boolean {
312+
if (existsSync(path)) return true;
313+
if (!isWindows) return false;
314+
return WINDOWS_BIN_EXTS.some((ext) => existsSync(`${path}${ext}`));
315+
}
316+
245317
// agent-browser talks to a daemon, so forwarded commands return quickly;
246318
// buffering output until exit keeps this transport-agnostic with runCli's
247319
// captured stdout/stderr at the cost of not streaming long-running output.
320+
//
321+
// Grace window for 'close' to win after 'exit' before we resolve anyway. See
322+
// execAgentBrowserProcess: long enough that a normal command's stdio drains
323+
// (its output was written before the process exited), short enough that the
324+
// daemon-holds-the-pipe case doesn't feel like a hang.
325+
const CLOSE_GRACE_MS = 250;
326+
248327
function execAgentBrowserProcess(binary: string, args: string[]): Promise<AgentBrowserExecResult> {
249328
return new Promise((resolve, reject) => {
250-
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
329+
// windowsHide: cross-spawn runs `.cmd` shims through cmd.exe; without this
330+
// each spawn flashes a console window that steals focus. No-op off Windows.
331+
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
251332
let stdout = '';
252333
let stderr = '';
334+
// A failed spawn races 'error' against the exit events; latch on the first so
335+
// the loser can't overwrite the outcome (e.g. a stray exit code swallowing an
336+
// ENOENT). clearTimeout drops the grace timer so it can't keep the event loop
337+
// alive after we've already settled.
338+
let settled = false;
339+
let graceTimer: number | undefined;
340+
const settle = (apply: () => void): void => {
341+
if (settled) return;
342+
settled = true;
343+
if (graceTimer !== undefined) clearTimeout(graceTimer);
344+
apply();
345+
};
253346
child.stdout.on('data', (chunk: unknown) => { stdout += String(chunk); });
254347
child.stderr.on('data', (chunk: unknown) => { stderr += String(chunk); });
255-
child.on('error', reject);
256-
child.on('close', (code: number | null) => {
257-
resolve({ exitCode: code ?? 1, stdout, stderr });
348+
child.on('error', (error: Error) => settle(() => reject(error)));
349+
const finish = (code: number | null, out: string, err: string): void =>
350+
settle(() => resolve({ exitCode: code ?? 1, stdout: out, stderr: err }));
351+
// 'close' is the clean path: the process exited AND its stdio reached EOF, so
352+
// all output is captured. But `agent-browser open` leaves a detached daemon
353+
// that on Windows inherits our stdout/stderr pipes — they never reach EOF and
354+
// 'close' never fires, so waiting on it alone hangs forever. Fall back to
355+
// 'exit' (which fires when the foreground process ends regardless of the
356+
// lingering pipe), giving 'close' a short grace to win first so a normal
357+
// command's full output is still flushed before we resolve.
358+
child.on('close', (code: number | null) => finish(code, stdout, stderr));
359+
child.on('exit', (code: number | null) => {
360+
// Snapshot now: the foreground command has produced all its output. The
361+
// surviving daemon keeps our inherited pipes open and may scribble into
362+
// them during the grace below (this is how `dor ab open` printed a burst
363+
// of blank lines on Windows) — resolve with the exit-time snapshot so that
364+
// post-command noise is excluded. 'close', if it wins, still uses the live
365+
// buffers since without a lingering daemon there's nothing extra to drop.
366+
const out = stdout;
367+
const err = stderr;
368+
graceTimer = setTimeout(() => finish(code, out, err), CLOSE_GRACE_MS);
258369
});
259370
});
260371
}

dor/src/node-runtime.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,24 @@ declare module 'node:child_process' {
2626
stdout: ChildProcessStream;
2727
stderr: ChildProcessStream;
2828
on(event: 'error', listener: (error: Error) => void): void;
29+
on(event: 'exit', listener: (code: number | null) => void): void;
2930
on(event: 'close', listener: (code: number | null) => void): void;
3031
}
3132

3233
export function spawn(command: string, args: readonly string[], options: {
3334
stdio: readonly ['ignore', 'pipe', 'pipe'];
35+
windowsHide?: boolean;
36+
}): ChildProcess;
37+
}
38+
39+
// cross-spawn ships no types and dor avoids @types/node, so declare the one call
40+
// shape we use. Drop-in for the node:child_process spawn above; returns the same
41+
// minimal ChildProcess.
42+
declare module 'cross-spawn' {
43+
import type { ChildProcess } from 'node:child_process';
44+
export default function spawn(command: string, args: readonly string[], options: {
45+
stdio: readonly ['ignore', 'pipe', 'pipe'];
46+
windowsHide?: boolean;
3447
}): ChildProcess;
3548
}
3649

0 commit comments

Comments
 (0)