Merge remote-tracking branch 'origin/main' into garrytan/fix-for-gbrowser

# Conflicts:
#	CHANGELOG.md
#	VERSION
This commit is contained in:
Garry Tan
2026-05-21 21:56:17 -07:00
54 changed files with 2316 additions and 130 deletions
+17 -6
View File
@@ -59,6 +59,13 @@ export function isCustomChromium(): boolean {
*/
export function shouldEnableChromiumSandbox(): boolean {
if (process.platform === 'win32') return false;
// Explicit user override for Ubuntu/AppArmor and similar environments where
// unprivileged Chromium sandboxing is blocked even for normal users (the
// sandbox needs unprivileged user namespaces that the host policy denies,
// so /qa hangs without --no-sandbox). Setting GSTACK_CHROMIUM_NO_SANDBOX=1
// forces the sandbox off without changing the default for everyone else.
// See #1562.
if (process.env.GSTACK_CHROMIUM_NO_SANDBOX === '1') return false;
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
return !(process.env.CI || process.env.CONTAINER || isRoot);
}
@@ -300,12 +307,16 @@ export class BrowserManager {
}
if (extensionsDir) {
launchArgs.push(
`--disable-extensions-except=${extensionsDir}`,
`--load-extension=${extensionsDir}`,
'--window-position=-9999,-9999',
'--window-size=1,1',
);
// Skip --load-extension when running against a custom Chromium build that
// already bakes the extension in (e.g., GBrowser / GStack Browser.app).
// Loading it twice causes a ServiceWorkerState::SetWorkerId DCHECK crash.
if (!isCustomChromium()) {
launchArgs.push(
`--disable-extensions-except=${extensionsDir}`,
`--load-extension=${extensionsDir}`,
);
}
launchArgs.push('--window-position=-9999,-9999', '--window-size=1,1');
useHeadless = false; // extensions require headed mode; off-screen window simulates headless
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
}
+26 -27
View File
@@ -11,6 +11,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { spawn as nodeSpawn } from 'child_process';
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
@@ -217,8 +218,6 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
safeUnlink(config.stateFile);
safeUnlink(path.join(config.stateDir, 'browse-startup-error.log'));
let proc: any = null;
// Allow the caller to opt out of the parent-process watchdog by setting
// BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive
// shells, and short-lived Bash invocations that need the server to outlive
@@ -240,12 +239,22 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
`${extraEnvStr})}).unref()`;
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
} else {
// macOS/Linux: Bun.spawn + unref works correctly
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
stdio: ['ignore', 'pipe', 'pipe'],
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
// loop — it does NOT call setsid(), so the spawned server stays in the
// parent's process session. When the CLI runs inside a session-managed
// shell (e.g. Claude Code's per-command Bash sandbox, Conductor, CI
// step runners), the session leader's exit sends SIGHUP to every PID in
// the session, killing the bun server (and its Chromium grandchildren).
// Even with BROWSE_PARENT_PID=0 disabling the watchdog, SIGHUP still
// reaps the server. Use Node's child_process.spawn with detached:true,
// which calls setsid() so the server becomes its own session leader
// (PPID=1, STAT=Ss) and survives the spawning shell's exit. Mirrors
// the Windows path's rationale — same root cause, different OS API.
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
});
proc.unref();
}).unref();
}
// Wait for server to become healthy.
@@ -260,27 +269,17 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
await Bun.sleep(100);
}
// Server didn't start in time — try to get error details
if (proc?.stderr) {
// macOS/Linux: read stderr from the spawned process
const reader = proc.stderr.getReader();
const { value } = await reader.read();
if (value) {
const errText = new TextDecoder().decode(value);
throw new Error(`Server failed to start:\n${errText}`);
}
} else {
// Windows: check startup error log (server writes errors to disk since
// stderr is unavailable due to stdio: 'ignore' for detachment)
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
try {
const errorLog = fs.readFileSync(errorLogPath, 'utf-8').trim();
if (errorLog) {
throw new Error(`Server failed to start:\n${errorLog}`);
}
} catch (e: any) {
if (e.code !== 'ENOENT') throw e;
// 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).
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
try {
const errorLog = fs.readFileSync(errorLogPath, 'utf-8').trim();
if (errorLog) {
throw new Error(`Server failed to start:\n${errorLog}`);
}
} catch (e: any) {
if (e.code !== 'ENOENT') throw e;
}
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
}
+28
View File
@@ -29,17 +29,20 @@ describe('shouldEnableChromiumSandbox', () => {
const origPlatform = process.platform;
const origCI = process.env.CI;
const origContainer = process.env.CONTAINER;
const origNoSandbox = process.env.GSTACK_CHROMIUM_NO_SANDBOX;
const origGetuid = process.getuid;
beforeEach(() => {
delete process.env.CI;
delete process.env.CONTAINER;
delete process.env.GSTACK_CHROMIUM_NO_SANDBOX;
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: origPlatform });
if (origCI === undefined) delete process.env.CI; else process.env.CI = origCI;
if (origContainer === undefined) delete process.env.CONTAINER; else process.env.CONTAINER = origContainer;
if (origNoSandbox === undefined) delete process.env.GSTACK_CHROMIUM_NO_SANDBOX; else process.env.GSTACK_CHROMIUM_NO_SANDBOX = origNoSandbox;
process.getuid = origGetuid;
});
@@ -90,6 +93,31 @@ describe('shouldEnableChromiumSandbox', () => {
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
expect(shouldEnableChromiumSandbox()).toBe(false);
});
// #1562 — Ubuntu/AppArmor opt-in override
it('linux + GSTACK_CHROMIUM_NO_SANDBOX=1 → false (Ubuntu/AppArmor opt-out)', async () => {
setPlatform('linux');
process.env.GSTACK_CHROMIUM_NO_SANDBOX = '1';
process.getuid = (() => 1000) as typeof process.getuid;
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
expect(shouldEnableChromiumSandbox()).toBe(false);
});
it('darwin + GSTACK_CHROMIUM_NO_SANDBOX=1 → false (env override wins on any platform)', async () => {
setPlatform('darwin');
process.env.GSTACK_CHROMIUM_NO_SANDBOX = '1';
process.getuid = (() => 501) as typeof process.getuid;
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
expect(shouldEnableChromiumSandbox()).toBe(false);
});
it('GSTACK_CHROMIUM_NO_SANDBOX=0 → does NOT trigger override (must be exactly "1")', async () => {
setPlatform('linux');
process.env.GSTACK_CHROMIUM_NO_SANDBOX = '0';
process.getuid = (() => 1000) as typeof process.getuid;
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
expect(shouldEnableChromiumSandbox()).toBe(true);
});
});
// ─── resolveDisconnectCause ──────────────────────────────────────
+75
View File
@@ -0,0 +1,75 @@
/**
* Coverage for #1612 — macOS/Linux server must survive sandboxed-shell
* harnesses by becoming its own session leader (setsid).
*
* Pre-#1612, Bun.spawn().unref() removed the child from Bun's event loop
* but did NOT call setsid(). When the CLI ran inside Claude Code's
* per-command sandbox, Conductor, or CI step runners, the session leader's
* exit sent SIGHUP to every PID in the session, killing the bun server.
*
* The fix routes macOS/Linux spawn through Node's child_process.spawn with
* detached:true, which calls setsid() so the server becomes its own session
* leader (PPID=1 on Linux, similar reparenting on Darwin).
*
* The actual setsid syscall is hard to assert in a unit test without a
* real spawn — testing here is static: the cli.ts source must use the
* Node spawn path on macOS/Linux, with detached:true and .unref(). If a
* future refactor reverts to Bun.spawn().unref() on the macOS/Linux branch
* the regression returns and these tests fail.
*/
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
const ROOT = path.resolve(import.meta.dir, "..", "..");
const CLI = path.join(ROOT, "browse", "src", "cli.ts");
function read(): string {
return fs.readFileSync(CLI, "utf-8");
}
describe("#1612 macOS/Linux daemonize via Node setsid path", () => {
test("cli.ts imports nodeSpawn from child_process (Node spawn alias)", () => {
const body = read();
// The fix relies on Node's child_process.spawn (which calls setsid on
// detached:true), aliased to avoid name collision with Bun.spawn. Match
// either `nodeSpawn` or `spawn as nodeSpawn` to be flexible to the
// exact import style.
expect(body).toMatch(/(spawn as nodeSpawn|nodeSpawn\s*[,}])/);
expect(body).toMatch(/from\s+['"]child_process['"]/);
});
test("non-Windows branch uses nodeSpawn(...).unref() with detached:true", () => {
const body = read();
// Find the non-Windows branch and assert it uses the Node spawn alias
// with detached:true. Match the pattern `nodeSpawn(...) ... detached:true`.
expect(body).toMatch(/nodeSpawn\([\s\S]{0,500}detached:\s*true/);
expect(body).toMatch(/nodeSpawn\([\s\S]{0,500}\.unref\(\)/);
});
test("non-Windows branch comment documents setsid/SIGHUP root cause", () => {
const body = read();
// The comment block must mention setsid() so a future refactor sees the
// why before changing the spawn call.
expect(body).toMatch(/setsid/);
expect(body).toMatch(/SIGHUP/);
});
test("the spawn call on macOS/Linux is nodeSpawn, not Bun.spawn", () => {
const body = read();
// Strip line comments before regex matching, so the "Bun.spawn().unref()"
// mentions inside the explanatory comment don't trigger false positives.
const codeOnly = body
.split("\n")
.filter((line) => !line.trim().startsWith("//"))
.join("\n");
// Find the non-Windows branch. The `} else {` block following the
// Windows branch. We then require its first ~400 chars contain a
// nodeSpawn() call and NOT a Bun.spawn() call (excluding the comment).
const nonWindowsStart = codeOnly.indexOf("nodeSpawn('bun'");
expect(nonWindowsStart).toBeGreaterThan(-1);
const slice = codeOnly.slice(nonWindowsStart, nonWindowsStart + 400);
expect(slice).toMatch(/nodeSpawn\(/);
expect(slice).not.toMatch(/Bun\.spawn\(/);
});
});