mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-06 08:07:53 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/fix-for-gbrowser
# Conflicts: # CHANGELOG.md # VERSION
This commit is contained in:
@@ -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
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
@@ -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\(/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user