feat: componentize GStack 2 runtime and release integrity

This commit is contained in:
Sinabina
2026-07-20 14:16:23 -07:00
parent b0ea2296d1
commit f14445bb00
270 changed files with 9681 additions and 51572 deletions
+19 -1
View File
@@ -16,6 +16,7 @@
*/
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
import { readdirSync } from 'node:fs';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
import { emitActivity } from './activity';
@@ -73,6 +74,21 @@ export function shouldEnableChromiumSandbox(): boolean {
return !(process.env.CI || process.env.CONTAINER || isRoot);
}
/** Select full Chromium only when a managed visible-only cache has no shell. */
export function managedHeadlessChannel(env: NodeJS.ProcessEnv = process.env): 'chromium' | undefined {
const root = env.PLAYWRIGHT_BROWSERS_PATH;
if (!root) return undefined;
try {
const names = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
if (names.some((name) => name.startsWith('chromium_headless_shell-'))) return undefined;
return names.some((name) => /^chromium-\d/.test(name)) ? 'chromium' : undefined;
} catch {
return undefined;
}
}
/**
* Resolve why the underlying Chromium ChildProcess is going away.
*
@@ -371,6 +387,7 @@ export class BrowserManager {
this.browser = await chromium.launch({
headless: useHeadless,
...(useHeadless && managedHeadlessChannel() ? { channel: 'chromium' as const } : {}),
// On Windows, Chromium's sandbox fails when the server is spawned through
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
@@ -585,7 +602,7 @@ export class BrowserManager {
args: launchArgs,
viewport: null, // Use browser's default viewport (real window size)
userAgent: this.customUserAgent || customUA,
...(executablePath ? { executablePath } : {}),
...(executablePath ? { executablePath } : { channel: 'chromium' }),
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
});
@@ -1588,6 +1605,7 @@ export class BrowserManager {
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
newContext = await chromium.launchPersistentContext(userDataDir, {
headless: false,
channel: 'chromium',
// Match the sandbox policy used by launchHeaded() / launch(). The
// handoff path is the headless→headed re-launch and shares the same
// anti-detection posture, including no spurious --no-sandbox infobar.
+14 -21
View File
@@ -80,12 +80,15 @@ export function resolveNodeServerScript(
return null;
}
const NODE_SERVER_SCRIPT = IS_WINDOWS ? resolveNodeServerScript() : null;
const NODE_SERVER_SCRIPT = resolveNodeServerScript();
const IS_COMPILED = import.meta.dir.includes('$bunfs');
// On Windows, hard-fail if server-node.mjs is missing — the Bun path is known broken.
if (IS_WINDOWS && !NODE_SERVER_SCRIPT) {
// Every installed/compiled client must use the adjacent Node-compatible daemon.
// Source development may fall back to `bun run server.ts` when dist has not
// been built yet, but an installed capability must never require host-global Bun.
if (IS_COMPILED && !NODE_SERVER_SCRIPT) {
throw new Error(
'server-node.mjs not found. Run `bun run build` to generate the Windows server bundle.'
'server-node.mjs not found. Rebuild the managed browser runtime and run `gstack doctor --skill-api 2.0`.'
);
}
@@ -314,30 +317,20 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
// server's own parseInt at server.ts:760.
const parentPid = parseInt(process.env.BROWSE_PARENT_PID || '', 10) === 0 ? '0' : String(process.pid);
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.
if (NODE_SERVER_SCRIPT) {
// Installed clients on every platform use the adjacent Node-compatible
// daemon. Besides correct Windows detachment, this means the base browser
// capability needs Node (already required by bootstrap) but no global Bun.
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'] });
Bun.spawnSync([process.env.GSTACK_NODE || 'node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
} else {
// 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.
// Reviewed source-development fallback only. Node's detached spawn still
// calls setsid() on macOS/Linux, so the Bun dev server survives SIGHUP.
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
+19 -10
View File
@@ -37,20 +37,29 @@ export function validateOutputPath(filePath: string): void {
// Without this, a symlink at /tmp/evil.png → /etc/crontab passes the
// parent-directory check (parent is /tmp, which is safe) but the actual
// write follows the symlink to /etc/crontab.
let stat: fs.Stats | undefined;
try {
const stat = fs.lstatSync(resolved);
if (stat.isSymbolicLink()) {
const realTarget = fs.realpathSync(resolved);
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realTarget, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
return; // symlink target verified, no need to check parent
}
stat = fs.lstatSync(resolved);
} catch (e: any) {
// ENOENT = file doesn't exist yet, fall through to parent-dir check
// ENOENT from lstat means the output file itself does not exist yet.
// Do not put realpathSync in this catch: ENOENT there means an existing
// dangling symlink, which must fail closed instead of being treated as a
// new file whose parent is safe.
if (e.code !== 'ENOENT') throw e;
}
if (stat?.isSymbolicLink()) {
let realTarget: string;
try {
realTarget = fs.realpathSync(resolved);
} catch {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realTarget, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
return; // symlink target verified, no need to check parent
}
// For new files (no existing symlink), verify the parent directory.
// The file itself may not exist yet (e.g., screenshot output).
+10 -9
View File
@@ -55,7 +55,7 @@ describe("#1612 macOS/Linux daemonize via Node setsid path", () => {
expect(body).toMatch(/SIGHUP/);
});
test("the spawn call on macOS/Linux is nodeSpawn, not Bun.spawn", () => {
test("installed clients prefer the adjacent Node daemon and source development keeps a detached Bun fallback", () => {
const body = read();
// Strip line comments before regex matching, so the "Bun.spawn().unref()"
// mentions inside the explanatory comment don't trigger false positives.
@@ -63,13 +63,14 @@ describe("#1612 macOS/Linux daemonize via Node setsid path", () => {
.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\(/);
expect(codeOnly).toContain("if (NODE_SERVER_SCRIPT)");
expect(codeOnly).toContain("spawn(process.execPath");
expect(codeOnly).toContain("nodeSpawn('bun', ['run', SERVER_SCRIPT]");
expect(codeOnly).not.toMatch(/Bun\.spawn\([^\n]*SERVER_SCRIPT/);
});
test("installed daemon detachment honors the bootstrap-selected Node executable", () => {
const body = read();
expect(body).toContain("process.env.GSTACK_NODE || 'node'");
});
});
+9 -3
View File
@@ -112,10 +112,16 @@ describe('validateReadPath', () => {
});
describe('validateOutputPath — symlink resolution', () => {
it('blocks symlink inside /tmp pointing outside safe dirs', () => {
const linkPath = join(tmpdir(), 'test-output-symlink-' + Date.now() + '.png');
it('blocks a dangling symlink inside /tmp pointing outside safe dirs', () => {
// Keep the link in the validator's canonical safe temp root instead of
// os.tmpdir(), which is /var/folders/... on default macOS test runs. The
// missing target makes this a regression test for realpathSync ENOENT.
const realTmp = realpathSync('/tmp');
const unique = `${process.pid}-${Date.now()}`;
const linkPath = join(realTmp, `test-output-dangling-${unique}.png`);
const missingTarget = `/etc/gstack-missing-output-${unique}`;
try {
symlinkSync('/etc/crontab', linkPath);
symlinkSync(missingTarget, linkPath);
expect(() => validateOutputPath(linkPath)).toThrow(/Path must be within/);
} finally {
try { unlinkSync(linkPath); } catch {}