Merge remote-tracking branch 'origin/main' into garrytan/gbrain-code-smell-audit

# Conflicts:
#	CHANGELOG.md
#	browse/test/dual-listener.test.ts
#	browse/test/fixtures/security-bench-haiku-responses.json
#	browse/test/sidebar-tabs.test.ts
#	browse/test/sidebar-ux.test.ts
#	browse/test/terminal-agent.test.ts
#	claude/SKILL.md.tmpl
#	scripts/gen-skill-docs.ts
#	scripts/proactive-suggestions.json
#	spec/SKILL.md
#	test/gen-skill-docs.test.ts
#	test/host-config.test.ts
This commit is contained in:
Garry Tan
2026-08-15 07:31:02 -07:00
246 changed files with 8802 additions and 1257 deletions
+21 -5
View File
@@ -91,7 +91,11 @@ export function shouldEnableChromiumSandbox(): boolean {
* restarts on backoff.
*/
export async function resolveDisconnectCause(browser: Browser | null): Promise<'clean' | 'crash'> {
const proc = browser?.process();
// `.process()` only exists on browsers we launched ourselves. A browser
// obtained via connectOverCDP() (or a stub in tests) has no such method —
// calling it blind throws inside the disconnect handler, which killed the
// whole daemon with "browser?.process is not a function".
const proc = typeof browser?.process === 'function' ? browser.process() : null;
if (proc && proc.exitCode === null && proc.signalCode === null) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 1000);
@@ -798,19 +802,31 @@ export class BrowserManager {
const page = this.pages.get(tabId);
if (!page) throw new Error(`Tab ${tabId} not found`);
// Capture BEFORE close(): the page 'close' event handler wired in
// wirePageEvents() can fire while page.close() is awaited. It removes
// the tab from the maps and reassigns activeTabId (to 0 when no tabs
// remain), so a post-close `tabId === this.activeTabId` check is
// order-dependent — whether the event dispatches before or after
// close() resolves varies across Playwright/Chromium versions and
// machines, and losing the race means the last-tab auto-create below
// never runs, leaving the manager with zero tabs.
const wasActive = tabId === this.activeTabId;
await page.close();
this.pages.delete(tabId);
this.tabSessions.delete(tabId);
this.tabOwnership.delete(tabId);
// Switch to another tab if we closed the active one
if (tabId === this.activeTabId) {
if (wasActive) {
const remaining = [...this.pages.keys()];
if (remaining.length > 0) {
this.activeTabId = remaining[remaining.length - 1];
} else {
if (remaining.length === 0) {
// No tabs left — create a new blank one
await this.newTab();
} else if (!this.pages.has(this.activeTabId)) {
// The 'close' handler may have already switched to a valid tab;
// only reassign when activeTabId no longer points at a live tab.
this.activeTabId = remaining[remaining.length - 1];
}
}
}
+103 -2
View File
@@ -75,6 +75,10 @@ globalThis.Bun = {
timeout: options.timeout,
env: options.env,
cwd: options.cwd,
// Node defaults windowsHide to false; Bun.spawn hides the console
// window. Without this the shim silently inverts the behavior on the
// one platform it exists to serve. See the spawn() note below.
windowsHide: options.windowsHide !== false,
});
return {
@@ -91,13 +95,110 @@ globalThis.Bun = {
stdio,
env: options.env,
cwd: options.cwd,
// stdio:'ignore' silences a child's output but does not suppress its
// console window on Windows. The terminal-agent respawn (server.ts
// watchdog, 60s ticker) therefore popped a visible bun.exe window on
// every respawn until this was forwarded.
windowsHide: options.windowsHide !== false,
});
// Drain stdout/stderr eagerly into in-memory buffers. Bun's spawn buffers
// these for the consumer; Node's Readables are pull-based, so if the caller
// awaits `proc.exited` before reading, anything past the OS pipe buffer
// (~16-64 KB) back-pressures the child until it blocks in write() and
// `exit` never fires. Eager draining keeps the pipes flowing regardless
// of read order; replay below is via fresh Web ReadableStreams.
//
// Cap the buffer so a runaway child can't OOM the server. 16 MB is
// generous: DPAPI outputs are tiny, tasklist is <1 KB, and the
// browser-skill consumer has its own 1 MB readCapped. Once the cap is
// reached we keep draining the pipe (so the child never blocks) but
// discard further bytes. Override via GSTACK_SPAWN_MAX_BUFFER (bytes).
const MAX_BUFFER = Math.max(
0,
parseInt(process.env.GSTACK_SPAWN_MAX_BUFFER || '', 10) || 16 * 1024 * 1024,
);
const drain = (stream) => {
if (!stream) return { done: Promise.resolve(), chunks: [], truncated: false };
const state = { chunks: [], bytes: 0, truncated: false };
const done = new Promise((resolve) => {
stream.on('data', (chunk) => {
if (state.bytes >= MAX_BUFFER) { state.truncated = true; return; }
if (state.bytes + chunk.length <= MAX_BUFFER) {
state.chunks.push(chunk);
state.bytes += chunk.length;
} else {
const remaining = MAX_BUFFER - state.bytes;
state.chunks.push(chunk.subarray(0, remaining));
state.bytes = MAX_BUFFER;
state.truncated = true;
}
});
// Any terminal event resolves: 'end' on normal close, 'error' on a
// stream-level error, 'close' as the belt-and-suspenders for spawn
// failures where Node fires 'close' but neither 'end' nor 'error'.
stream.once('end', resolve);
stream.once('error', resolve);
stream.once('close', resolve);
});
return { done, chunks: state.chunks };
};
const stdoutDrain = drain(proc.stdout);
const stderrDrain = drain(proc.stderr);
// Bun's spawn exposes `proc.exited` as a Promise resolving to the exit
// code; several call sites — DPAPI decryption, isBrowserRunning,
// browser-skill-commands — `await proc.exited` directly or via
// Promise.race with a timeout. Without this, those awaits resolve to
// `undefined` immediately and the operation looks like a silent failure.
// Resolve only after both pipes have finished draining so consumers that
// read stdout AFTER awaiting exit see the full output, not a partial buffer.
const exited = new Promise((resolveExited) => {
let exitStatus;
proc.once('exit', (code, signal) => {
// Match Bun: exit code on normal exit; 128 + signal number on signal;
// 0 if neither was reported.
if (code !== null) exitStatus = code;
else if (signal) exitStatus = 128 + (require('os').constants.signals[signal] || 0);
else exitStatus = 0;
});
proc.once('error', () => {
if (exitStatus === undefined) exitStatus = 1;
});
// Wait for either 'exit' (normal child lifecycle) or 'error' (spawn
// failure — Node fires error without exit when the binary is missing).
// Either path resolves the lifecycle promise; without listening to both
// a spawn error hangs `await proc.exited` until the consumer's own
// timeout fires.
const lifecycle = new Promise((r) => {
proc.once('exit', r);
proc.once('error', r);
});
Promise.all([lifecycle, stdoutDrain.done, stderrDrain.done])
.then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0));
});
// Replay buffered output as a fresh Web ReadableStream. `start()` awaits
// the drain before enqueueing so `new Response(proc.stdout).text()` yields
// the complete output regardless of whether the consumer reads before or
// after awaiting `proc.exited`. Stream is single-shot (locked after one
// read), matching Bun's behavior.
const replay = (d) => new ReadableStream({
async start(controller) {
await d.done;
for (const chunk of d.chunks) {
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
}
controller.close();
},
});
return {
pid: proc.pid,
stdout: proc.stdout,
stderr: proc.stderr,
stdout: replay(stdoutDrain),
stderr: replay(stderrDrain),
stdin: proc.stdin,
exited,
unref() { proc.unref(); },
kill(signal) { proc.kill(signal); },
};
+99 -15
View File
@@ -21,7 +21,32 @@ import { spawnTerminalAgent } from './terminal-agent-control';
const config = resolveConfig();
const IS_WINDOWS = process.platform === 'win32';
const MAX_START_WAIT = IS_WINDOWS ? 15000 : (process.env.CI ? 30000 : 8000); // Node+Chromium takes longer on Windows
/**
* Startup health-probe budget (ms) for a freshly spawned server. The daemon is
* detached + unref'd, so it keeps booting regardless of how long the CLI is
* willing to poll — this constant only bounds how long `startServer` waits
* before reporting failure.
*
* Overridable via `BROWSE_START_TIMEOUT` (ms) for hosts where even the platform
* ceiling isn't enough — e.g. Windows under heavy load (#1846), where the 15s
* budget can still elapse before a busy box finishes booting Node+Chromium.
* Mirrors the `BROWSE_*` tunable convention used throughout server.ts
* (BROWSE_PORT, BROWSE_IDLE_TIMEOUT, ...). A non-positive or unparseable value
* falls back to the platform default. Pure + exported for tests.
*/
export function resolveStartTimeout(env: NodeJS.ProcessEnv = process.env): number {
// Cold Chromium launch measured ~5.7s at load avg 10 on a dev machine running
// many servers; at load 12+ it exceeds the old 8s budget, so the CLI gave up
// while the (detached) daemon was still booting → "Server failed to start
// within 8s". 15s matches the Windows budget and gives real headroom; the poll
// loop returns the instant the daemon is healthy, so this only costs time in a
// genuine-failure case.
const platformDefault = IS_WINDOWS ? 15000 : (env.CI ? 30000 : 15000); // Node+Chromium takes longer on Windows
const override = parseInt(env.BROWSE_START_TIMEOUT || '', 10);
return Number.isFinite(override) && override > 0 ? override : platformDefault;
}
const MAX_START_WAIT = resolveStartTimeout();
export function resolveServerScript(
env: Record<string, string | undefined> = process.env,
@@ -357,6 +382,17 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
await Bun.sleep(100);
}
// One last check before declaring failure. The daemon is detached + unref'd,
// so on a loaded machine it can become healthy in the gap between the poll
// loop's final tick and now — the probe timed out, the launch did not
// (#1846). Re-checking here turns that false negative into a success, and
// mirrors the post-loop recovery already done in ensureServer(). A genuinely
// failed server is still unhealthy, so this falls through to the error report.
const lateState = readState();
if (lateState && await isServerHealthy(lateState.port)) {
return lateState;
}
// Server didn't start in time — check the on-disk startup error log.
// Both platforms now spawn with stdio: 'ignore', so the server writes
// errors to disk for the CLI to read (see server.ts start().catch).
@@ -372,12 +408,31 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
}
function errorCode(err: unknown): string {
if (err && typeof err === 'object' && 'code' in err) {
const code = (err as { code?: unknown }).code;
if (typeof code === 'string' && code.length > 0) return code;
}
return 'UNKNOWN';
}
function errorMessage(err: unknown): string {
if (err && typeof err === 'object' && 'message' in err) {
const message = (err as { message?: unknown }).message;
if (typeof message === 'string' && message.length > 0) return message;
}
return String(err);
}
function logServerLockError(action: string, lockPath: string, err: unknown): void {
console.error(`[browse] acquireServerLock: unexpected ${errorCode(err)} while ${action} ${lockPath}: ${errorMessage(err)}`);
}
/**
* Acquire an exclusive lockfile to prevent concurrent ensureServer() races (TOCTOU).
* Returns a cleanup function that releases the lock.
*/
function acquireServerLock(): (() => void) | null {
const lockPath = `${config.stateFile}.lock`;
export function acquireServerLock(lockPath: string = `${config.stateFile}.lock`): (() => void) | null {
try {
// 'wx' — create exclusively, fails if file already exists (atomic check-and-create)
// Using string flag instead of numeric constants for Bun Windows compatibility
@@ -385,19 +440,36 @@ function acquireServerLock(): (() => void) | null {
fs.writeSync(fd, `${process.pid}\n`);
fs.closeSync(fd);
return () => { safeUnlink(lockPath); };
} catch {
// Lock already held — check if the holder is still alive
try {
const holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
if (holderPid && isProcessAlive(holderPid)) {
return null; // Another live process holds the lock
}
// Stale lock — remove and retry
fs.unlinkSync(lockPath);
return acquireServerLock();
} catch {
} catch (err) {
if (errorCode(err) !== 'EEXIST') {
logServerLockError('opening', lockPath, err);
return null;
}
// Lock already held — check if the holder is still alive
let holderPid: number;
try {
holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
} catch (readErr) {
if (errorCode(readErr) === 'ENOENT') {
return acquireServerLock(lockPath);
}
logServerLockError('reading holder PID from', lockPath, readErr);
return null;
}
if (holderPid && isProcessAlive(holderPid)) {
return null; // Another live process holds the lock
}
// Stale lock — remove and retry
try {
fs.unlinkSync(lockPath);
} catch (unlinkErr) {
logServerLockError('removing stale', lockPath, unlinkErr);
return null;
}
return acquireServerLock(lockPath);
}
}
@@ -584,7 +656,17 @@ async function sendCommand(state: ServerState, command: string, args: string[],
process.exit(1);
}
// Connection error — server may have crashed, OR may just be busy.
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
// The compiled CLI runs on Bun, whose fetch reports a refused/dropped
// socket as err.code 'ConnectionRefused' / 'ConnectionClosed' (message
// "Unable to connect. Is the computer able to access the url?"), NOT Node's
// ECONNREFUSED/ECONNRESET. Match both, or daemon crashes leak the raw Bun
// error and exit 1 instead of triggering the busy-check/restart below.
const isConnError =
err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' ||
err.code === 'ConnectionRefused' || err.code === 'ConnectionClosed' ||
err.message?.includes('fetch failed') ||
err.message?.includes('Unable to connect');
if (isConnError) {
const oldState = readState();
// #1781 busy-vs-dead: a single-threaded daemon under beacon/extension load
// can briefly stop answering HTTP while still alive. Before declaring a
@@ -1125,6 +1207,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
const newPid = spawnTerminalAgent({
stateFile: config.stateFile,
serverPort: newState.port,
ownerPid: newState.pid,
cwd: config.projectDir,
});
if (newPid) {
@@ -1217,6 +1300,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
spawnTerminalAgent({
stateFile: config.stateFile,
serverPort: respawned.port,
ownerPid: respawned.pid,
cwd: config.projectDir,
});
} catch (err: any) {
+23 -1
View File
@@ -34,7 +34,12 @@ export function getGitRoot(): string | null {
const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
stdout: 'pipe',
stderr: 'pipe',
timeout: 2_000, // Don't hang if .git is broken
// Raised from 2s: under heavy machine load `git rev-parse` routinely
// takes >2s (measured 6.3s spikes). Timing out here returns null →
// resolveConfig falls back to process.cwd() → state files scatter across
// cwds (split-brain daemons; `goto` and `url` hit different servers). 8s
// still bounds a genuinely broken .git from hanging the CLI forever.
timeout: 8_000,
});
if (proc.exitCode !== 0) return null;
return proc.stdout.toString().trim() || null;
@@ -78,6 +83,20 @@ export function resolveConfig(
};
}
function isIgnoredByGit(projectDir: string, relPath: string): boolean {
try {
const proc = Bun.spawnSync(['git', 'check-ignore', '-q', '--', relPath], {
cwd: projectDir, stdout: 'pipe', stderr: 'pipe',
timeout: 2_000,
});
return proc.exitCode === 0;
} catch {
// git not found, timed out, or not a repo (exit 128). Fall through to
// the text-check path — appending is the safe default when unsure.
return false;
}
}
/**
* Create the .gstack/ state directory if it doesn't exist.
* Throws with a clear message on permission errors.
@@ -96,6 +115,9 @@ export function ensureStateDir(config: BrowseConfig): void {
}
// Ensure .gstack/ is in the project's .gitignore
// First, check if git already ignores .gstack/ (via global excludes, .git/info/exclude, or parent .gitignore)
if (isIgnoredByGit(config.projectDir, '.gstack/')) return;
const gitignorePath = path.join(config.projectDir, '.gitignore');
try {
const content = fs.readFileSync(gitignorePath, 'utf-8');
+30 -16
View File
@@ -7,8 +7,6 @@
import * as fs from 'fs';
const IS_WINDOWS = process.platform === 'win32';
// ─── Filesystem ────────────────────────────────────────────────
/** Remove a file, ignoring ENOENT (already gone). Rethrows other errors. */
@@ -36,23 +34,39 @@ export function safeKill(pid: number, signal: NodeJS.Signals | number): void {
}
}
/** Check if a PID is alive. Pure boolean probe — returns false for ALL errors. */
/**
* Check if a PID is alive. Pure boolean probe — never throws.
*
* Signal 0 on every platform. Node and Bun both map `process.kill(pid, 0)` to
* an OpenProcess existence check on Windows, so the POSIX idiom is portable
* here — no shell-out needed.
*
* Windows used to shell out to `tasklist /FI "PID eq <pid>"` and string-match
* the CSV. That was wrong in two ways, both of which bit in production:
*
* 1. FALSE NEGATIVES UNDER LOAD. `tasklist` takes ~700-1700ms on an idle
* Windows box and far longer under memory pressure. A Bun.spawnSync that
* hits its `timeout` still RETURNS, carrying partial stdout — so the
* `.includes()` match came back false and a LIVE process was reported
* dead. Callers (killAgentByRecord, the terminal-agent watchdog) then
* skipped the kill and respawned around the survivor, leaking one
* terminal-agent per watchdog tick. The leak was self-reinforcing: every
* orphan added memory pressure, which made the next tasklist slower,
* which produced the next false negative.
* 2. A VISIBLE CONSOLE WINDOW per probe (no windowsHide), so a background
* watchdog strobed a terminal into the foreground every 60 seconds.
*
* Signal 0 is ~74,000x faster (0.004ms vs 270ms, measured), spawns nothing,
* and cannot time out.
*
* EPERM means the process EXISTS but we lack rights to signal it. That is
* alive; returning false there would reintroduce failure mode 1.
*/
export function isProcessAlive(pid: number): boolean {
if (IS_WINDOWS) {
try {
const result = Bun.spawnSync(
['tasklist', '/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
{ stdout: 'pipe', stderr: 'pipe', timeout: 3000 }
);
return result.stdout.toString().includes(`"${pid}"`);
} catch {
return false;
}
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
} catch (err: any) {
return err?.code === 'EPERM';
}
}
+48 -2
View File
@@ -42,6 +42,52 @@ import * as os from 'os';
let warnedOnce = false;
let cachedSid: string | null | undefined;
/**
* Resolve the current user's SID, cached for the process lifetime.
*
* Returns null if `whoami` is unavailable or its output cannot be parsed,
* in which case callers fall back to a domain-qualified account name.
*/
function currentUserSid(): string | null {
if (cachedSid !== undefined) return cachedSid;
try {
// Pin to the System32 binary. A bare `whoami` resolves to the MSYS/Git
// Bash build under a bash-flavoured PATH, which rejects `/user` — the
// lookup would then silently fail on one of the most common Windows
// setups for this tool.
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
const out = execFileSync(`${systemRoot}\\System32\\whoami.exe`, ['/user', '/fo', 'csv', '/nh'], {
encoding: 'utf8',
});
const match = out.match(/S-1-[\d-]+/);
cachedSid = match ? match[0] : null;
} catch {
cachedSid = null;
}
return cachedSid;
}
/**
* The principal to hand icacls for "the current user".
*
* An unqualified username is ambiguous: on a machine whose hostname equals
* the username, it fails to resolve to the user account and icacls silently
* writes an ACE for the machine SID instead. Combined with `/inheritance:r`
* that leaves a directory whose only ACE matches nobody — locking out the
* process that just created it.
*
* `*<SID>` is icacls' literal-SID form and is immune to that ambiguity.
* The domain-qualified name is the fallback.
*/
function currentUserPrincipal(): string {
const sid = currentUserSid();
if (sid) return `*${sid}`;
const domain = process.env.USERDOMAIN || os.hostname();
return `${domain}\\${os.userInfo().username}`;
}
function warnIcaclsFailure(fsPath: string, err: unknown): void {
if (warnedOnce) return;
warnedOnce = true;
@@ -67,7 +113,7 @@ function warnIcaclsFailure(fsPath: string, err: unknown): void {
export function restrictFilePermissions(filePath: string): void {
if (process.platform === 'win32') {
try {
const user = os.userInfo().username;
const user = currentUserPrincipal();
execFileSync(
'icacls',
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
@@ -97,7 +143,7 @@ export function restrictFilePermissions(filePath: string): void {
export function restrictDirectoryPermissions(dirPath: string): void {
if (process.platform === 'win32') {
try {
const user = os.userInfo().username;
const user = currentUserPrincipal();
execFileSync(
'icacls',
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
+14 -4
View File
@@ -421,15 +421,25 @@ export async function handleMetaCommand(
}
case 'stop': {
await shutdown();
// Defer shutdown so the response flushes before process.exit() (same
// reason as 'restart' below). Otherwise the CLI sees a dropped socket;
// and now that connection-loss triggers the crash-retry path, that would
// resurrect a fresh daemon only to stop it again. Send the 200, then exit.
setTimeout(() => { void shutdown(); }, 100);
return 'Server stopped';
}
case 'restart': {
// Signal that we want a restart — the CLI will detect exit and restart
// Signal that we want a restart — the CLI will detect exit and restart.
console.log('[browse] Restart requested. Exiting for CLI to restart.');
await shutdown();
return 'Restarting...';
// Defer shutdown one tick so this HTTP response actually flushes before
// process.exit(). shutdown() exits inline (server.ts), so the old
// `await shutdown(); return 'Restarting...'` never sent a response — the
// CLI saw a dropped socket and `browse restart` errored out. The daemon
// now exits ~100ms after the CLI gets its 200; the next browse command
// lazily cold-starts a fresh one.
setTimeout(() => { void shutdown(); }, 100);
return 'Restarting... (daemon exiting; next browse command starts a fresh one)';
}
// ─── Visual ────────────────────────────────────────
+12 -1
View File
@@ -1590,8 +1590,18 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
process.env.GSTACK_AGENT_WATCHDOG_TICK_MS || '60000',
10,
);
const RESPAWN_GUARD_WINDOW_MS = 60_000;
const RESPAWN_GUARD_MAX = 3;
// The guard window MUST span enough ticks for RESPAWN_GUARD_MAX respawns to
// land inside it. This was a fixed 60_000 against a 60_000 tick, so at most
// ONE respawn could ever be in the window and `respawnHistory.length >= 3`
// was unreachable — the guard could not fire at the default tick rate, and a
// steady one-per-tick leak ran unbounded instead of stopping after 3. Scale
// with the tick so the intent ("3 crashes in quick succession → stop") holds
// at any tick value: 3 respawns within 5 ticks trips it.
const RESPAWN_GUARD_WINDOW_MS = Math.max(
60_000,
AGENT_WATCHDOG_TICK_MS * (RESPAWN_GUARD_MAX + 2),
);
let agentRespawnGuardTripped = false;
if (ownsTerminalAgent) {
@@ -1624,6 +1634,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
const pid = spawnTerminalAgent({
stateFile: cfg.config.stateFile,
serverPort: cfg.browsePort,
ownerPid: process.pid,
cwd: cfg.config.projectDir,
});
if (pid) {
+8 -2
View File
@@ -49,12 +49,13 @@ export function resolveTerminalAgentScript(searchHints: { metaDir?: string; exec
*
* Used by both the CLI cold-start path (cli.ts) and the v1.44 watchdog in
* server.ts. Centralizing here removes a copy-paste between them and means
* future spawn-env additions (e.g. BROWSE_OWNER_PID for the generation
* counter rollout) land in one place.
* spawn-env additions (BROWSE_OWNER_PID being the first) land in one place.
*/
export function spawnTerminalAgent(opts: {
stateFile: string;
serverPort: number;
/** PID of the browse server that owns this agent. */
ownerPid: number;
cwd?: string;
/** Optional extra env vars to add to the agent's process env. */
extraEnv?: Record<string, string>;
@@ -75,9 +76,14 @@ export function spawnTerminalAgent(opts: {
...process.env,
BROWSE_STATE_FILE: opts.stateFile,
BROWSE_SERVER_PORT: String(opts.serverPort),
BROWSE_OWNER_PID: String(opts.ownerPid),
...(opts.extraEnv || {}),
},
stdio: ['ignore', 'ignore', 'ignore'],
// Explicit for the Node fallback path (dist/bun-polyfill.cjs), where the
// host default is the opposite of Bun's. A visible console window on every
// watchdog respawn is the symptom when this is missing.
windowsHide: true,
});
proc.unref?.();
return proc.pid ?? null;
+30 -7
View File
@@ -32,6 +32,11 @@ import { extractPtyCookie } from './pty-session-cookie';
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port');
const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10);
const BROWSE_OWNER_PID = parseInt(process.env.BROWSE_OWNER_PID || '0', 10);
const OWNER_WATCHDOG_MS = parseInt(
process.env.GSTACK_TERMINAL_OWNER_WATCHDOG_MS || '15000',
10,
);
const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check
const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn
/**
@@ -597,12 +602,10 @@ function buildServer() {
// first that matches a known token.
const protoHeader = req.headers.get('sec-websocket-protocol') || '';
let token: string | null = null;
let acceptedProtocol: string | null = null;
for (const raw of protoHeader.split(',').map(s => s.trim()).filter(Boolean)) {
const candidate = raw.startsWith('gstack-pty.') ? raw.slice('gstack-pty.'.length) : raw;
if (validTokens.has(candidate)) {
token = candidate;
acceptedProtocol = raw;
break;
}
}
@@ -627,13 +630,13 @@ function buildServer() {
// sessionsById so /internal/restart and (Commit 3) re-attach
// lookups can find it.
const sessionId = validTokens.get(token) ?? null;
// No explicit Sec-WebSocket-Protocol echo: Bun >= 1.3 auto-echoes the
// first offered protocol in the 101 response, so setting the header
// here produced a DUPLICATE header — strict clients (Chromium, python
// websockets) reject the handshake per RFC 6455 and the sidebar
// terminal could never connect. Verified on Bun 1.3.6.
const upgraded = server.upgrade(req, {
data: { cookie: token, sessionId },
// Echo the protocol back so the browser accepts the upgrade.
// Required when the client sends Sec-WebSocket-Protocol — the
// server MUST select one of the offered protocols, otherwise
// the browser closes the connection immediately.
...(acceptedProtocol ? { headers: { 'Sec-WebSocket-Protocol': acceptedProtocol } } : {}),
});
return upgraded ? undefined : new Response('upgrade failed', { status: 500 });
}
@@ -971,13 +974,33 @@ function main() {
console.log(`[terminal-agent] listening on 127.0.0.1:${port} pid=${process.pid} gen=${CURRENT_GEN}`);
// Cleanup port file + agent record on exit.
let cleaningUp = false;
const cleanup = () => {
if (cleaningUp) return;
cleaningUp = true;
safeUnlink(PORT_FILE);
safeUnlink(INTERNAL_TOKEN_FILE);
clearAgentRecord(dir);
process.exit(0);
};
process.on('SIGTERM', cleanup);
process.on('SIGINT', cleanup);
// The terminal agent is intentionally detached so it survives the short-lived
// CLI launcher, but its real owner is the persistent browse server. If that
// server crashes or is killed before running normal shutdown, the agent would
// otherwise be adopted by PID 1 and live forever. Poll the server PID and use
// the same cleanup path as an intentional shutdown when it disappears.
if (BROWSE_OWNER_PID > 0) {
const ownerWatchdog = setInterval(() => {
try {
process.kill(BROWSE_OWNER_PID, 0);
} catch {
cleanup();
}
}, OWNER_WATCHDOG_MS);
(ownerWatchdog as any)?.unref?.();
}
}
// Export the internal token so cli.ts can pass the SAME value to the parent
+16 -1
View File
@@ -269,9 +269,24 @@ export async function validateNavigationUrl(url: string): Promise<string> {
return pathToFileURL(fsPath).href + parsed.search + parsed.hash;
}
// about:blank ONLY — the canonical empty page, and the one the daemon opens its own
// first tab on. Blocking it meant `browse newtab about:blank` failed, which is what
// `make-pdf setup` runs as its Chromium smoke test: make-pdf reported "Chromium failed
// to launch" against a perfectly healthy Chromium, and any browse session whose daemon
// restarted could never recreate the blank tab it starts from.
//
// Deliberately not the whole `about:` scheme. about:blank has no origin, loads nothing
// and runs nothing; about:config, about:net-internals and friends are real surfaces.
// Exact href match, not a prefix test, so `about:blankfoo` stays blocked.
// Compared lower-cased: the URL parser normalises the PROTOCOL but not the opaque part,
// so `ABOUT:BLANK` parses to href `about:BLANK` and an exact === would reject it.
if (parsed.protocol === 'about:' && parsed.href.toLowerCase() === 'about:blank') {
return 'about:blank';
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(
`Blocked: scheme "${parsed.protocol}" is not allowed. Only http:, https:, and file: URLs are permitted.`
`Blocked: scheme "${parsed.protocol}" is not allowed. Only http:, https:, file:, and about:blank URLs are permitted.`
);
}
+10 -7
View File
@@ -249,11 +249,11 @@ export async function handleWriteCommand(
if (!filePath) throw new Error('Usage: browse load-html <file> [--wait-until load|domcontentloaded|networkidle] [--tab-id <N>] | load-html --from-file <payload.json> [--tab-id <N>]');
// Extension allowlist
const ALLOWED_EXT = ['.html', '.htm', '.xhtml', '.svg'];
const ALLOWED_EXT = ['.html', '.htm', '.xhtml'];
const ext = path.extname(filePath).toLowerCase();
if (!ALLOWED_EXT.includes(ext)) {
throw new Error(
`load-html: file does not appear to be HTML. Expected .html/.htm/.xhtml/.svg, got ${ext || '(no extension)'}. Rename the file if it's really HTML.`
`load-html: file does not appear to be HTML. Expected .html/.htm/.xhtml, got ${ext || '(no extension)'}. Rename the file if it's really HTML.`
);
}
@@ -377,11 +377,14 @@ export async function handleWriteCommand(
const value = valueParts.join(' ');
if (!selector || !value) throw new Error('Usage: browse fill <selector> <value>');
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.fill(value, { timeout: 5000 });
} else {
await target.locator(resolved.selector).fill(value, { timeout: 5000 });
}
const locator = 'locator' in resolved ? resolved.locator : target.locator(resolved.selector);
await locator.fill(value, { timeout: 5000 });
// Playwright's fill() only dispatches an `input` event. Frameworks that
// validate on `change` (AngularJS ng-change, debounced strength/match
// checks — e.g. cPanel's Jupiter theme) never see the update, so a value
// that's correct in the DOM can still fail the framework's own
// validation. Dispatch `change` too so those listeners fire.
await locator.dispatchEvent('change');
// Wait for network to settle (form validation XHRs)
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
return `Filled ${selector}`;