1616 */
1717
1818import { 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' ;
2026import { existsSync } from 'node:fs' ;
2127import type {
2228 CliEnv ,
@@ -33,6 +39,33 @@ import { fail, requireControlClient, stringParser } from './shared.js';
3339const WORKSPACE_ID = '1' ;
3440
3541const 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+
248327function 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}
0 commit comments