Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 69 additions & 12 deletions browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import * as fs from 'fs';
import * as path from 'path';
import { spawnSync } from 'node:child_process';
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
Expand All @@ -19,7 +20,9 @@ import { redactProxyUrl } from './proxy-redact';

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
const DEFAULT_START_WAIT = IS_WINDOWS ? 45000 : (process.env.CI ? 30000 : 8000); // Node+Chromium takes longer on Windows
const MAX_START_WAIT = Number.parseInt(process.env.BROWSE_START_WAIT_MS || '', 10) || DEFAULT_START_WAIT;
let startedServerThisRun = false;

export function resolveServerScript(
env: Record<string, string | undefined> = process.env,
Expand Down Expand Up @@ -229,16 +232,15 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta

if (IS_WINDOWS && NODE_SERVER_SCRIPT) {
// Windows: Bun.spawn() + proc.unref() doesn't truly detach on Windows —
// when the CLI exits, the server dies with it. Use Node's child_process.spawn
// with { detached: true } instead, which is the gold standard for Windows
// process independence. Credit: PR #191 by @fqueiro.
// when the CLI exits, the server dies with it. Use a tiny Node launcher
// with { detached: true }, which is the reliable Windows detach path.
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...(extraEnv || {}) });
const launcherCode =
`const{spawn}=require('child_process');` +
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
`${extraEnvStr})}).unref()`;
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
spawnSync('node', ['-e', launcherCode], { stdio: 'ignore' });
} else {
// macOS/Linux: Bun.spawn + unref works correctly
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
Expand All @@ -255,6 +257,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
while (Date.now() - start < MAX_START_WAIT) {
const state = readState();
if (state && await isServerHealthy(state.port)) {
startedServerThisRun = true;
return state;
}
await Bun.sleep(100);
Expand Down Expand Up @@ -384,7 +387,10 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
const start = Date.now();
while (Date.now() - start < MAX_START_WAIT) {
const freshState = readState();
if (freshState && await isServerHealthy(freshState.port)) return freshState;
if (freshState && await isServerHealthy(freshState.port)) {
startedServerThisRun = true;
return freshState;
}
await Bun.sleep(200);
}
throw new Error('Timed out waiting for another instance to start the server');
Expand All @@ -394,6 +400,7 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
// Re-read state under lock in case another process just started the server
const freshState = readState();
if (freshState && await isServerHealthy(freshState.port)) {
startedServerThisRun = true;
return freshState;
}

Expand All @@ -405,8 +412,6 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
console.error(`[browse] Starting server with proxy ${flags.redactedProxyUrl}${flags.headed ? ' (headed)' : ''}...`);
} else if (flags?.headed) {
console.error('[browse] Starting server in headed mode...');
} else {
console.error('[browse] Starting server...');
}
return await startServer(extraEnv);
} finally {
Expand Down Expand Up @@ -469,10 +474,8 @@ async function sendCommand(state: ServerState, command: string, args: string[],
}

const text = await resp.text();

if (resp.ok) {
process.stdout.write(text);
if (!text.endsWith('\n')) process.stdout.write('\n');
await writeStdout(text);
} else {
// Try to parse as JSON error
try {
Expand All @@ -489,8 +492,15 @@ async function sendCommand(state: ServerState, command: string, args: string[],
console.error('[browse] Command timed out after 30s');
process.exit(1);
}
// Connection error — server may have crashed
// `stop` intentionally tears the daemon down. On Windows/Node the socket
// can close before the response body reaches the CLI; treat that as a
// successful stop instead of triggering the generic crash-restart path.
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
if (command === 'stop' && !(await isServerHealthy(state.port))) {
safeUnlinkQuiet(config.stateFile);
await writeStdout('Server stopped');
return;
}
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
console.error('[browse] Server connection lost. Restarting...');
// Kill the old server to avoid orphaned chromium processes
Expand All @@ -513,6 +523,32 @@ async function sendCommand(state: ServerState, command: string, args: string[],
}
}

async function writeStdout(text: string): Promise<void> {
const output = text.endsWith('\n') ? text : `${text}\n`;
fs.writeSync(1, output);
}

async function handleStopCommand(commandArgs: string[]): Promise<void> {
const state = readState();
if (!state) {
await writeStdout('Server not running');
return;
}

if (await isServerHealthy(state.port)) {
await sendCommand(state, 'stop', commandArgs);
return;
}

if (state.pid && isProcessAlive(state.pid)) {
await killServer(state.pid);
await writeStdout('Server stopped');
} else {
await writeStdout('Server not running');
}
safeUnlinkQuiet(config.stateFile);
}

// Module-level reference to the resolved global flags from main(). Used by
// sendCommand's crash-retry path so a daemon restart after ECONNRESET doesn't
// silently drop --proxy / --headed.
Expand Down Expand Up @@ -1144,6 +1180,15 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
process.exit(0);
}

// stop must never auto-start a daemon. The generic command path calls
// ensureServer(), which is correct for normal browser commands but wrong for
// shutdown: `browse stop` from a clean state should be a no-op, not a
// start-then-stop cycle that can leave a detached Windows process behind.
if (command === 'stop') {
await handleStopCommand(commandArgs);
process.exit(0);
}

// Special case: chain reads from stdin
if (command === 'chain' && commandArgs.length === 0) {
const stdin = await Bun.stdin.text();
Expand All @@ -1152,6 +1197,18 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:

let state = await ensureServer(globalFlags);

if (startedServerThisRun && process.env.BROWSE_SKIP_REEXEC_AFTER_START !== '1') {
const result = spawnSync(process.execPath, process.argv.slice(2), {
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
env: { ...process.env, BROWSE_SKIP_REEXEC_AFTER_START: '1' },
});
if (result.error) throw result.error;
if (result.stdout) fs.writeSync(1, result.stdout);
if (result.stderr) fs.writeSync(2, result.stderr);
process.exit(result.status ?? 1);
}

// ─── Pair-Agent (post-server, pre-dispatch) ──────────────
if (command === 'pair-agent') {
// Ensure headed mode — the user should see the browser window
Expand Down
2 changes: 1 addition & 1 deletion browse/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ beforeAll(async () => {

bm = new BrowserManager();
await bm.launch();
});
}, 30000);

afterAll(() => {
// Force kill browser instead of graceful close (avoids hang)
Expand Down
26 changes: 25 additions & 1 deletion browse/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,30 @@ describe('startup error log', () => {
});
});

describe('cli command dispatch', () => {
const cliSource = fs.readFileSync(path.resolve(__dirname, '../src/cli.ts'), 'utf-8');

test('handles stop before ensureServer so shutdown never auto-starts a daemon', () => {
const stopDispatch = cliSource.indexOf('await handleStopCommand(commandArgs)');
const ensureServerCall = cliSource.indexOf('let state = await ensureServer(globalFlags)');

expect(stopDispatch).toBeGreaterThan(-1);
expect(ensureServerCall).toBeGreaterThan(-1);
expect(stopDispatch).toBeLessThan(ensureServerCall);
});

test('cold-start re-exec preserves command stdout on stdout', () => {
expect(cliSource).toContain('if (result.stdout) fs.writeSync(1, result.stdout)');
expect(cliSource).not.toContain('IS_WINDOWS ? 2 : 1, result.stdout');
});

test('default headless cold-start does not print a delayed startup banner', () => {
expect(cliSource).not.toContain("console.error('[browse] Starting server...')");
expect(cliSource).toContain('Starting server in headed mode');
expect(cliSource).toContain('Starting server with proxy');
});
});

describe('resolveGstackHome', () => {
test('honors GSTACK_HOME env var when set', () => {
const orig = process.env.GSTACK_HOME;
Expand Down Expand Up @@ -367,7 +391,7 @@ describe('resolveChromiumProfile', () => {
delete process.env.CHROMIUM_PROFILE;
process.env.GSTACK_HOME = '/tmp/fallback-gstack';
try {
expect(resolveChromiumProfile()).toBe('/tmp/fallback-gstack/chromium-profile');
expect(resolveChromiumProfile()).toBe(path.join('/tmp/fallback-gstack', 'chromium-profile'));
} finally {
if (origEnv !== undefined) process.env.CHROMIUM_PROFILE = origEnv;
if (origHome === undefined) delete process.env.GSTACK_HOME;
Expand Down
40 changes: 34 additions & 6 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,39 @@ create_agents_sidecar() {
done
}

link_browse_runtime_assets() {
local gstack_dir="$1"
local runtime_root="$2"

mkdir -p "$runtime_root/browse" "$runtime_root/node_modules"

if [ -d "$gstack_dir/browse/dist" ]; then
_link_or_copy "$gstack_dir/browse/dist" "$runtime_root/browse/dist"
fi
# The compiled Windows CLI still resolves browse/src/server.ts at startup
# before it delegates to the Node-compatible server bundle.
if [ -d "$gstack_dir/browse/src" ]; then
_link_or_copy "$gstack_dir/browse/src" "$runtime_root/browse/src"
fi
if [ -d "$gstack_dir/browse/bin" ]; then
_link_or_copy "$gstack_dir/browse/bin" "$runtime_root/browse/bin"
fi

# server-node.mjs intentionally externalizes these runtime packages.
for dep in playwright playwright-core diff; do
if [ -d "$gstack_dir/node_modules/$dep" ]; then
_link_or_copy "$gstack_dir/node_modules/$dep" "$runtime_root/node_modules/$dep"
fi
done
if [ -d "$gstack_dir/node_modules/@ngrok" ]; then
mkdir -p "$runtime_root/node_modules/@ngrok"
for scoped_dep in "$gstack_dir/node_modules/@ngrok"/*; do
[ -e "$scoped_dep" ] || continue
_link_or_copy "$scoped_dep" "$runtime_root/node_modules/@ngrok/$(basename "$scoped_dep")"
done
fi
}

# ─── Helper: create a minimal ~/.codex/skills/gstack runtime root ───────────
# Codex scans ~/.codex/skills recursively. Exposing the whole repo here causes
# duplicate skills because source SKILL.md files and generated Codex skills are
Expand All @@ -673,12 +706,7 @@ create_codex_runtime_root() {
if [ -d "$gstack_dir/bin" ]; then
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
fi
if [ -d "$gstack_dir/browse/dist" ]; then
_link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
fi
if [ -d "$gstack_dir/browse/bin" ]; then
_link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin"
fi
link_browse_runtime_assets "$gstack_dir" "$codex_gstack"
if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md"
fi
Expand Down
17 changes: 17 additions & 0 deletions test/gen-skill-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2224,6 +2224,23 @@ describe('setup script validation', () => {
expect(setupContent).toContain('create_agents_sidecar "$SOURCE_GSTACK_DIR"');
});

test('Codex runtime root includes browse source and externalized Node deps', () => {
const buildScript = fs.readFileSync(path.join(ROOT, 'browse', 'scripts', 'build-node-server.sh'), 'utf-8');
const externals = [...buildScript.matchAll(/--external\s+"?([^"\\\s]+)"?/g)]
.map((match) => match[1])
.filter((dep) => dep !== 'bun:sqlite');
const fnStart = setupContent.indexOf('link_browse_runtime_assets()');
const fnEnd = setupContent.indexOf('# ─── Helper: create a minimal ~/.codex/skills/gstack runtime root', fnStart);
const fnBody = setupContent.slice(fnStart, fnEnd);

expect(fnBody).toContain('browse/src');
for (const dep of externals) {
const root = dep.startsWith('@') ? dep.split('/')[0] : dep;
expect(fnBody).toContain(root);
}
expect(setupContent).toContain('link_browse_runtime_assets "$gstack_dir" "$codex_gstack"');
});

test('link_codex_skill_dirs reads from .agents/skills/', () => {
// The Codex link function must reference .agents/skills for generated Codex skills
const fnStart = setupContent.indexOf('link_codex_skill_dirs()');
Expand Down