Five hardening items in the browse CLI and its tests:
- probeHealthWithBackoff's advertised ~8s budget could really run ~10s: the
final 2s probe could start 1ms before the deadline, and every call site
had JUST run a failed probe yet the loop re-probed immediately.
Iterations now start with the sleep and each probe's timeout clamps to
the remaining budget (isServerHealthy takes an injectable timeout).
- browse-daemon.log is append-mode across every respawn with no size cap,
so a crash-respawn loop fills the disk. The path is now built in one
place (daemonLogPath — the Unix fd path and the Windows launcher string
had two spellings) and daemon start rotates a >10MB log to
browse-daemon.log.1, single generation, matching the repo's 10MB
rotation convention. Rotation is exported + injectable and behaviorally
unit-tested.
- The two "healthy daemon already running" refusal blocks in connect had
already drifted (one lost the tabs/cookies/logins explainer) — extracted
refuseHeadedOverLiveDaemon as the single source.
- process-liveness: pinned the EPERM-means-alive contract (PID 1 on POSIX,
PID 4 on Windows — signalable-or-EPERM, both alive). A probe that reads
EPERM as dead is the false negative that leaked agents.
- runBoundedChromiumReinstall had zero coverage: now exercised end-to-end
against a stub bunx on a prepended PATH — exit 0, install-exit-N with
stderr tail, the detached group-kill timeout path (child of the child
dies too), and spawn-error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The detached daemon's stdout/stderr were wired to 'ignore' on every
platform, so every console.error('[browse] FATAL: ...') from a Chromium
crash, uncaughtException, or unhandledRejection was discarded at the OS
level — a crash-and-respawn looked identical to every other dropped
session, with nothing on disk recording why. Both spawn paths now redirect
to <stateDir>/browse-daemon.log (append mode, accumulates across respawns):
the Unix path via an fd from openDaemonLogSink(), the Windows path by
opening the fd INSIDE the node -e launcher string (an fd opened in cli.ts
would not cross the spawn boundary). Unwritable state dir falls back to
'ignore' rather than failing the launch.
Capturing daemon output is what surfaced the PR's second fix, still valid
on current main: bun-polyfill.cjs's Bun.spawn/spawnSync called Node's
child_process with a bare command name, which Windows can't resolve without
PATHEXT lookup ("spawn bun ENOENT" from the terminal-agent respawn path).
Routed through cross-spawn on win32 (now a direct dependency; already in
the tree transitively via @modelcontextprotocol/sdk) — the PR verified
empirically that shell:true does NOT neutralize cmd.exe metacharacters
reachable via `$B skill run` arg passthrough, and that Node refuses .cmd
spawns without a shell (CVE-2024-27980), so cross-spawn's combined PATHEXT
resolution + argument escaping is the only correct shape. The PR's third
fix (resolveDisconnectCause throwing "browser?.process is not a function")
already landed on main via the #2085 typeof guard — not re-applied.
F6 log hygiene (daemon-log-hygiene.test.ts): needle tests pin the log
wiring on both spawn paths (and that stdio 'ignore','ignore','ignore'
never returns), that bun-polyfill stays on cross-spawn with no shell:true,
that NO console.* call in src/ passes a token value (interpolated or bare
arg), and that the page-content carrier modules (tab-session, buffers,
content-security, activity) stay console-free — so neither AUTH_TOKEN nor
unsanitized page-derived strings can reach browse-daemon.log.
Tests: daemon-log-hygiene + bun-polyfill + windows-spawn-hide +
cli-setsid-daemonize 21 pass; stop-dead-daemon + busy-daemon-iron-rule
(exercises a REAL daemon boot through the new log-fd wiring) 10 pass.
Re-derived from PR #2461 by @phuttimatebenchanakatkul.
Co-authored-by: phuttimatebenchanakatkul <phuttimatebenchanakatkul@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>