Merge origin/main (v1.67.0.0) — reconcile convergent iOS Release-guard fixes

main's v1.67.0.0 independently landed the DebugBridgeTouch Release compile-out
with a stronger shape (`#if !defined(DEBUG)` short-circuit before the platform
gate, measured via nm -j on a real Release binary) than this branch's
`#if TARGET_OS_IOS && DEBUG`. Resolution: take main's templates/fixtures, keep
this branch's free-tier static tripwire and adapt it to pin main's shape
(short-circuit present, ordered before the platform branch, cSettings DEBUG
define intact, no bare platform-only gate). VERSION/package.json stay 1.67.1.0;
CHANGELOG keeps both entries with 1.67.1.0 on top, its iOS claims reworded to
the residual contribution (the tripwire, not the compile-out itself).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 20:18:43 -07:00
co-authored by Claude Fable 5
246 changed files with 13732 additions and 2289 deletions
@@ -382,3 +382,42 @@ describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
expect(result.stdout.length).toBeLessThanOrEqual(1024 * 1024);
}, 10_000);
});
describe('subprocess capture goes through temp files, not pipes', () => {
// Tripwire. Capturing a child's output through `stdout: 'pipe'` is lossy
// here: under a loaded parent, the first piped spawn in the process
// intermittently yields an empty stderr even though the child wrote it and
// exited 0. Neither draining before awaiting exit nor a manual getReader()
// loop avoids it — both were measured losing the same bytes. It flaked
// `$B skill test` (a dropped stderr left only bun's banner) and would blank
// a skill's JSON result on `$B skill run` while still reporting success.
//
// runToFiles() points the child's fds at temp files instead, so the kernel
// has flushed everything by the time the child exits. This test fails if a
// refactor reintroduces pipe capture in this module.
//
// Comments are stripped first, so the module's own prose — which names the
// banned pattern in order to explain it — doesn't trip checks meant for code.
const src = fs.readFileSync(
path.join(import.meta.dir, '..', 'src', 'browser-skill-commands.ts'), 'utf-8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
it("does not spawn with stdout/stderr: 'pipe'", () => {
expect(src).not.toMatch(/std(out|err):\s*'pipe'/);
});
it('does not read child output via Response(proc.stdout/stderr) or getReader', () => {
expect(src).not.toMatch(/new Response\(\s*proc\.(stdout|stderr)/);
expect(src).not.toMatch(/proc\.(stdout|stderr)[\s\S]{0,40}getReader\(/);
});
it('every spawn site routes through runToFiles', () => {
// The structural invariant: runToFiles owns the module's only Bun.spawn,
// so any present or future spawn site inherits the file-based capture.
// Counted rather than name-checked so adding a spawn site that bypasses
// the helper fails here instead of silently reintroducing the bug.
expect(src.match(/Bun\.spawn\(/g) ?? []).toHaveLength(1);
expect((src.match(/await runToFiles\(/g) ?? []).length).toBeGreaterThanOrEqual(2);
});
});
+12 -2
View File
@@ -85,7 +85,17 @@ describe('browser-skills E2E — bundled hackernews-frontpage', () => {
// It takes ~1s. Run it last so other assertions are quick.
test('$B skill test hackernews-frontpage runs script.test.ts and reports pass', async () => {
const result = await handleSkillCommand(['test', 'hackernews-frontpage'], { port: 0 });
// bun test prints summary to stderr; handleSkillCommand returns stderr || stdout
expect(result).toMatch(/13 pass|0 fail|tests passed/);
// `bun test` splits its report across streams: the version banner goes to
// stdout, the pass/fail summary to stderr. handleSkillCommand must return
// both, so assert on each stream's half.
//
// This used to flake under full-suite load: capturing the child through
// pipes dropped stderr on the first piped spawn in the process, so the
// result was just the banner. The old `13 pass|0 fail|tests passed` regex
// also had a `tests passed` alternative that matched a synthetic fallback
// string, which would have passed vacuously on an empty capture.
expect(result).toMatch(/bun test v/); // stdout half
expect(result).toMatch(/\b0 fail\b/); // stderr half
expect(result).toMatch(/Ran \d+ tests/);
}, 30_000);
});
+232
View File
@@ -0,0 +1,232 @@
/**
* #2219 IRON RULE regression tests (E5): an alive daemon pid is NEVER
* auto-killed. Killing a live daemon loses the session's tabs, cookies, and
* logins — strictly worse than a slow command. Only an explicit
* --force-restart may replace a live daemon.
*
* Integration legs follow the busy-daemon-recovery.test.ts pattern: a fake
* HTTP daemon + a live `sleep` child standing in for the daemon PID, wired
* through BROWSE_STATE_FILE. Unit legs pin the pure decision function.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import { spawn, type ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as http from 'http';
import { isProcessAlive } from '../src/error-handling';
import { decideDaemonRestart, HEALTH_PROBE_TOTAL_BUDGET_MS } from '../src/cli';
// ─── Unit: the pure restart decision (decision 9 / F10) ──────────────────
describe('decideDaemonRestart (pure)', () => {
test('healthy after probe → retry against the SAME daemon', () => {
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: true, forceRestart: false }))
.toBe('retry-command');
// Even with --force-restart in hand, a healthy daemon is retried, not killed.
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: true, forceRestart: true }))
.toBe('retry-command');
});
test('IRON RULE: alive + unhealthy + no flag → report busy, never kill', () => {
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: false, forceRestart: false }))
.toBe('report-busy');
});
test('alive + unhealthy + explicit --force-restart → force-restart', () => {
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: false, forceRestart: true }))
.toBe('force-restart');
});
test('dead pid → restart, with or without the flag', () => {
expect(decideDaemonRestart({ pidAlive: false, healthyAfterProbe: false, forceRestart: false }))
.toBe('restart-dead');
expect(decideDaemonRestart({ pidAlive: false, healthyAfterProbe: false, forceRestart: true }))
.toBe('restart-dead');
});
test('probe budget is ~8s (F10) — long enough for heavy-page busy windows', () => {
expect(HEALTH_PROBE_TOTAL_BUDGET_MS).toBeGreaterThanOrEqual(7_000);
expect(HEALTH_PROBE_TOTAL_BUDGET_MS).toBeLessThanOrEqual(10_000);
});
});
// ─── Integration: real spawned CLI vs fake daemons ───────────────────────
/** A daemon whose /health always answers healthy but never serves /command. */
async function startHealthyDaemon(): Promise<{ port: number; close: () => Promise<void> }> {
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'healthy' }));
return;
}
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok');
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('fake daemon: bad address');
return { port: addr.port, close: () => new Promise((r) => server.close(() => r())) };
}
/** A WEDGED daemon: alive socket, but /health always answers unhealthy. */
async function startWedgedDaemon(): Promise<{ port: number; close: () => Promise<void> }> {
const server = http.createServer((req, res) => {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'wedged' }));
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('fake daemon: bad address');
return { port: addr.port, close: () => new Promise((r) => server.close(() => r())) };
}
function runCli(args: string[], env: Record<string, string>, timeoutMs = 30_000):
Promise<{ code: number; stdout: string; stderr: string }> {
const cliPath = path.resolve(import.meta.dir, '../src/cli.ts');
return new Promise((resolve) => {
const proc = spawn('bun', ['run', cliPath, ...args], { timeout: timeoutMs, env });
let stdout = ''; let stderr = '';
proc.stdout.on('data', (d) => stdout += d.toString());
proc.stderr.on('data', (d) => stderr += d.toString());
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
function baseEnv(stateFile: string): Record<string, string> {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v;
}
env.BROWSE_STATE_FILE = stateFile;
return env;
}
let pidChild: ChildProcess | null = null;
afterEach(() => { pidChild?.kill('SIGKILL'); pidChild = null; });
describe('#2219 iron rule (CLI integration)', () => {
test('healthy daemon SURVIVES `browse connect` — refused with guidance, no kill', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
const stateFile = path.join(tmpDir, 'browse.json');
const daemon = await startHealthyDaemon();
try {
pidChild = spawn('sleep', ['60'], { stdio: 'ignore' });
const daemonPid = pidChild.pid!;
const stateContent = {
pid: daemonPid,
port: daemon.port,
token: 'iron-rule-token',
startedAt: new Date().toISOString(),
serverPath: '',
mode: 'launched' as const,
};
fs.writeFileSync(stateFile, JSON.stringify(stateContent, null, 2));
const result = await runCli(['connect'], baseEnv(stateFile));
expect(result.code).not.toBe(0);
expect(result.stderr).toContain('healthy daemon is already running');
expect(result.stderr).toContain('--force-restart');
// THE IRON RULE: the daemon process was not killed.
expect(isProcessAlive(daemonPid)).toBe(true);
// And the state file was not clobbered.
expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8'))).toEqual(stateContent);
} finally {
await daemon.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('wedged-alive daemon + plain command → busy report + nonzero exit, NO kill', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
const stateFile = path.join(tmpDir, 'browse.json');
const daemon = await startWedgedDaemon();
try {
pidChild = spawn('sleep', ['120'], { stdio: 'ignore' });
const daemonPid = pidChild.pid!;
fs.writeFileSync(stateFile, JSON.stringify({
pid: daemonPid,
port: daemon.port,
token: 'iron-rule-token',
startedAt: new Date().toISOString(),
serverPath: '',
mode: 'launched' as const,
}, null, 2));
const result = await runCli(['status'], baseEnv(stateFile));
expect(result.code).not.toBe(0);
expect(result.stderr).toContain('Daemon busy');
expect(result.stderr).toContain('--force-restart');
// Never killed, never restarted.
expect(isProcessAlive(daemonPid)).toBe(true);
expect(result.stderr).not.toContain('Restarting');
} finally {
await daemon.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 45_000);
test('wedged-alive daemon + --force-restart IS killed (explicit consent path)', async () => {
// Unix lanes only: the consent path must boot a REAL replacement daemon
// to answer the command, which the secretless/browserless Windows lane
// cannot do (no Chromium install), and the teardown relies on setsid
// process-group semantics Windows lacks. The Windows-relevant half of
// the iron rule — busy → refusal, never an implicit kill — runs above.
if (process.platform === 'win32') return;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
const stateFile = path.join(tmpDir, 'browse.json');
const daemon = await startWedgedDaemon();
try {
pidChild = spawn('sleep', ['120'], { stdio: 'ignore' });
const daemonPid = pidChild.pid!;
fs.writeFileSync(stateFile, JSON.stringify({
pid: daemonPid,
port: daemon.port,
token: 'iron-rule-token',
startedAt: new Date().toISOString(),
serverPath: '',
mode: 'launched' as const,
}, null, 2));
// `status` with --force-restart: the wedged "daemon" must be killed and
// a REAL daemon started in its place.
const result = await runCli(['--force-restart', 'status'], baseEnv(stateFile), 60_000);
// The wedged pid was killed — the explicit consent path.
expect(isProcessAlive(daemonPid)).toBe(false);
expect(result.stderr).toContain('--force-restart');
// The replacement daemon answered the command.
expect(result.code).toBe(0);
} finally {
// Kill the REAL daemon's whole PROCESS GROUP, not just its pid.
// startServer spawns the daemon detached (setsid — its own group
// leader), so a bare SIGKILL on the pid orphans its Chromium child,
// which then squats memory for the REST of the suite (~100 files) —
// enough pressure on a loaded box for the OS to kill a LATER test's
// in-process Chromium, whose disconnect handler process.exit(1)s the
// whole bun run mid-suite with no summary.
try {
const newState = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
if (newState?.pid && isProcessAlive(newState.pid)) {
try {
process.kill(-newState.pid, 'SIGKILL'); // group: daemon + Chromium
} catch {
process.kill(newState.pid, 'SIGKILL'); // fallback: pid only
}
}
} catch { /* state file gone — nothing started */ }
await daemon.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 90_000);
});
+12
View File
@@ -70,6 +70,18 @@ describe('CDP allowlist (T2: deny-default)', () => {
expect(isCdpMethodAllowed('Page.captureScreenshot')).toBe(true);
});
it('Emulation.setEmulatedMedia is allowed, tab-scoped, trusted (#2419)', () => {
// Media type/feature override (prefers-color-scheme, prefers-reduced-motion,
// prefers-contrast, forced-colors) so a11y and dark-mode CSS branches are
// testable via $B cdp. Returns an empty result — no page content, so
// trusted output is correct.
expect(isCdpMethodAllowed('Emulation.setEmulatedMedia')).toBe(true);
const e = lookupCdpMethod('Emulation.setEmulatedMedia');
expect(e).not.toBeNull();
expect(e!.scope).toBe('tab');
expect(e!.output).toBe('trusted');
});
it('untrusted-output methods cover the read-everything-attacker-controlled cases', () => {
// Anything that reads attacker-controlled strings (DOM/AX/CSS selectors)
// should be tagged untrusted so the envelope wraps the result.
+8
View File
@@ -18,6 +18,12 @@ import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
const TMP_HOME = path.join(os.tmpdir(), `gstack-cdp-e2e-${process.pid}-${Date.now()}`);
// Shard runs execute many test files in ONE bun process: a module-scope env
// mutation without restore leaks into every LATER file in the shard. This
// exact leak once pointed a sibling test's GSTACK_HOME at our temp dir,
// which then got baked into artifacts that outlived it (dangling symlinks
// into a deleted render dir). Save + restore in afterAll.
const ORIGINAL_GSTACK_HOME = process.env.GSTACK_HOME;
process.env.GSTACK_HOME = TMP_HOME;
process.env.GSTACK_TELEMETRY_OFF = '1'; // don't pollute analytics during tests
@@ -36,6 +42,8 @@ beforeAll(async () => {
});
afterAll(async () => {
if (ORIGINAL_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = ORIGINAL_GSTACK_HOME;
try { await bm.cleanup?.(); } catch {}
try { testServer.server.stop(); } catch {}
await fs.rm(TMP_HOME, { recursive: true, force: true });
+135
View File
@@ -0,0 +1,135 @@
/**
* #2461 daemon crash log + F6 log hygiene needles.
*
* The detached daemon's stdout/stderr now land in <stateDir>/browse-daemon.log
* (both spawn paths) instead of 'ignore'. That makes crashes diagnosable —
* and makes it load-bearing that NOTHING secret or page-derived reaches the
* daemon's console streams:
*
* - No console.* call anywhere in src/ may pass a token VALUE (AUTH_TOKEN,
* state.token, attachToken, INTERNAL_TOKEN, setup keys). Names like
* tokenInfo.clientId are fine — the needle targets expressions whose
* value IS a token.
* - The page-content carrier modules (tab-session, buffers,
* content-security, activity) stay console-free, so raw page-derived
* strings can't be echoed into the log unsanitized.
*
* Source-level, same style as windows-spawn-hide.test.ts.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const SRC_DIR = path.join(import.meta.dir, '../src');
const SRC = (f: string) => fs.readFileSync(path.join(SRC_DIR, f), 'utf-8');
describe('#2461 daemon log wiring', () => {
test('both daemon spawn paths capture stdout/stderr to browse-daemon.log', () => {
const cli = SRC('cli.ts');
// Unix path: fd from openDaemonLogSink wired into stdio.
expect(cli).toContain("stdio: ['ignore', daemonLogFd, daemonLogFd]");
expect(cli).toMatch(/openDaemonLogSink/);
// Windows path: the fd must be opened INSIDE the node -e launcher (an fd
// opened in cli.ts wouldn't cross the spawn boundary).
expect(cli).toContain("stdio:['ignore',logFd,logFd]");
expect(cli).toContain('browse-daemon.log');
// The old fully-discarded wiring must not come back on either daemon path.
expect(cli).not.toContain("stdio:['ignore','ignore','ignore']");
});
test('log sink is append-mode (accumulates across respawns)', () => {
const cli = SRC('cli.ts');
// Both spawn paths open through the single daemonLogPath() source (M4),
// which itself must build from the state dir.
expect(cli).toMatch(/openSync\(daemonLogPath\(\), 'a'\)/);
expect(cli).toMatch(/path\.join\(config\.stateDir, 'browse-daemon\.log'\)/);
expect(cli).toMatch(/openSync\(\$\{daemonLogPathStr\},'a'\)/);
});
test('append-mode log is growth-bounded: rotated at 10MB before daemon start', () => {
const cli = SRC('cli.ts');
expect(cli).toMatch(/DAEMON_LOG_MAX_BYTES = 10 \* 1024 \* 1024/);
expect(cli).toMatch(/rotateDaemonLogIfOversized\(\);/);
// Single generation: rename to .1, matching the repo's 10MB conventions.
expect(cli).toMatch(/renameSync\(p, `\$\{p\}\.1`\)/);
});
test('rotation behavior: oversized rotates to a single .1 generation, small/missing are no-ops', () => {
const os = require('os');
const { rotateDaemonLogIfOversized } = require('../src/cli');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-daemon-log-'));
try {
const p = path.join(tmp, 'browse-daemon.log');
// Missing log: no throw (first launch).
rotateDaemonLogIfOversized(p, 1024);
// Under the cap: untouched, no generation created.
fs.writeFileSync(p, 'x'.repeat(10));
rotateDaemonLogIfOversized(p, 1024);
expect(fs.existsSync(p)).toBe(true);
expect(fs.existsSync(`${p}.1`)).toBe(false);
// Over the cap: rotated out of the way so the daemon starts fresh.
fs.writeFileSync(p, 'y'.repeat(2048));
rotateDaemonLogIfOversized(p, 1024);
expect(fs.existsSync(p)).toBe(false);
expect(fs.readFileSync(`${p}.1`, 'utf-8')).toContain('y');
// Single generation: the next rotation REPLACES .1 (bounded at ~2x cap
// total, never a .2).
fs.writeFileSync(p, 'z'.repeat(2048));
rotateDaemonLogIfOversized(p, 1024);
expect(fs.readFileSync(`${p}.1`, 'utf-8')).toContain('z');
expect(fs.existsSync(`${p}.2`)).toBe(false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('bun-polyfill routes Windows spawns through cross-spawn (ENOENT + cmd.exe injection fix)', () => {
const polyfill = SRC('bun-polyfill.cjs');
expect(polyfill).toContain("require('cross-spawn')");
expect(polyfill).toMatch(/process\.platform === 'win32' \? crossSpawn\.sync : nodeSpawnSync/);
expect(polyfill).toMatch(/process\.platform === 'win32' \? crossSpawn : nodeSpawn/);
// The rejected-for-cause alternative must not creep back in: shell:true
// on Windows routes through cmd.exe and does NOT neutralize & | ^ % < >.
// (Strip comments — the header documents WHY shell:true was rejected.)
const code = polyfill.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
expect(code).not.toMatch(/shell:\s*true/);
});
});
describe('F6 log hygiene: nothing secret or page-derived reaches daemon console', () => {
const files = fs.readdirSync(SRC_DIR).filter((f) => f.endsWith('.ts') || f.endsWith('.cjs'));
test('no console.* call passes a token value', () => {
const offenders: string[] = [];
for (const file of files) {
const content = SRC(file);
for (const [idx, line] of content.split('\n').entries()) {
if (!/console\.(log|error|warn|info)\(/.test(line)) continue;
// Interpolated token values: ${...token} / ${...Token} — the
// expression ENDS in token, i.e. the value IS the token. Names like
// ${tokenInfo.clientId} don't match.
if (/\$\{[^}]*[tT]oken\s*\}/.test(line)) {
offenders.push(`${file}:${idx + 1}: ${line.trim().slice(0, 120)}`);
continue;
}
// Bare token args: console.log('x', token) / (..., authToken)
if (/console\.(log|error|warn|info)\([^)]*[^a-zA-Z_.][tT]oken\s*[,)]/.test(line)) {
offenders.push(`${file}:${idx + 1}: ${line.trim().slice(0, 120)}`);
}
}
}
expect(offenders).toEqual([]);
});
test('page-content carrier modules are console-free', () => {
// Page-derived strings flow through these modules. Keeping them
// console-free guarantees raw page content can't be echoed into
// browse-daemon.log without passing an egress sanitizer first.
for (const file of ['tab-session.ts', 'buffers.ts', 'content-security.ts', 'activity.ts']) {
const content = SRC(file);
const calls = content.match(/console\.(log|error|warn|info)\(/g) || [];
expect({ file, count: calls.length }).toEqual({ file, count: 0 });
}
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Static tripwire for #2220: every Playwright launch site must disable
* Playwright's process-level signal handlers (handleSIGINT / handleSIGTERM /
* handleSIGHUP), and server.ts must own the SIGHUP cleanup those flags
* remove.
*
* WHY handleSIGTERM:false is correct here: server.ts DELIBERATELY ignores
* SIGTERM in normal headless mode (the process.on('SIGTERM') handler —
* Claude Code's Bash sandbox fires SIGTERM when the parent shell exits
* between tool invocations, and the daemon must survive it). Playwright's
* default handleSIGTERM:true registers its OWN handler that closes Chromium
* on the same signal — so the daemon survived but its browser died out from
* under it. With the flag false, the daemon's signal policy is the only
* signal policy: the signals server.ts honors route through activeShutdown,
* which closes Chromium itself.
*
* WHY server.ts needs a SIGHUP handler (ENG-OV4): before #2220 the daemon
* had NO process-level SIGHUP handler — Playwright's default handleSIGHUP
* was the only thing closing Chromium on hangup. Flipping the flag without
* adding a handler would leak a live Chromium on every hangup.
*
* Source-level, same style as windows-spawn-hide.test.ts: cheap,
* deterministic, runs on every platform.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const SRC = (f: string) => fs.readFileSync(path.join(import.meta.dir, '../src', f), 'utf-8');
/** Every occurrence of `needle` must carry all three handleSIG* flags within
* the next `window` chars (the launch options object). */
function expectSignalFlagsNearEvery(src: string, needle: string, window = 1200): number {
let idx = src.indexOf(needle);
expect(idx).toBeGreaterThanOrEqual(0);
let count = 0;
while (idx !== -1) {
const slice = src.slice(idx, idx + window);
expect(slice).toMatch(/handleSIGINT:\s*false/);
expect(slice).toMatch(/handleSIGTERM:\s*false/);
expect(slice).toMatch(/handleSIGHUP:\s*false/);
count++;
idx = src.indexOf(needle, idx + needle.length);
}
return count;
}
describe('Playwright launch sites disable signal handlers (#2220)', () => {
test('all chromium.launch / launchPersistentContext sites carry the three flags', () => {
const src = SRC('browser-manager.ts');
const launchCount = expectSignalFlagsNearEvery(src, 'chromium.launch({');
const persistentCount = expectSignalFlagsNearEvery(src, 'chromium.launchPersistentContext(');
// Three launch sites today: headless launch(), headed launchHeaded(),
// and the handoff relaunch. A NEW launch site must carry the flags too —
// bump this only after adding them.
expect(launchCount + persistentCount).toBe(3);
});
test('server.ts owns SIGHUP cleanup now that Playwright does not (ENG-OV4)', () => {
const src = SRC('server.ts');
// The SIGHUP handler must route to the same shutdown path Chromium
// cleanup uses (activeShutdown), like SIGINT does.
expect(src).toMatch(/process\.on\('SIGHUP',\s*\(\)\s*=>\s*activeShutdown\?\.\(\)\)/);
});
test('the deliberate headless SIGTERM-ignore still exists (the reason handleSIGTERM:false is safe)', () => {
const src = SRC('server.ts');
// If this handler ever disappears, revisit handleSIGTERM:false — the
// flag is correct BECAUSE server.ts owns SIGTERM policy.
expect(src).toContain("process.on('SIGTERM'");
expect(src).toContain('Received SIGTERM (ignoring');
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* #2160/#1989: playwright-core is bun-patched to pass windowsHide at its two
* Windows-visible child_process sites — the browser launch spawn (Node
* defaults windowsHide to FALSE for spawn, so browser children could flash a
* console window) and the force-kill taskkill spawnSync. This is the repo's
* first patchedDependencies entry; these static checks pin the three-legged
* coherence (patch file ↔ package.json ↔ installed tree) so a playwright
* bump that forgets to re-target the patch fails CI instead of silently
* dropping it. NOTE: bumping playwright (c25-style) REQUIRES regenerating
* this patch against the new version — see the revert pairing in the wave
* plan (reverting the bump means dropping the patch too).
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..', '..');
function installedPlaywrightCoreVersion(): string {
const pkg = JSON.parse(fs.readFileSync(
path.join(ROOT, 'node_modules', 'playwright-core', 'package.json'), 'utf-8',
));
return pkg.version as string;
}
describe('playwright-core windowsHide patch (#2160, #1989)', () => {
test('package.json declares the patch for the installed version', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
const version = installedPlaywrightCoreVersion();
const key = `playwright-core@${version}`;
expect(pkg.patchedDependencies).toBeDefined();
// Version-keyed on purpose: if playwright is bumped without re-targeting
// the patch, this fails with the exact key that needs regenerating.
expect(pkg.patchedDependencies[key]).toBe(`patches/playwright-core@${version}.patch`);
});
test('the patch file exists and carries both windowsHide sites', () => {
const version = installedPlaywrightCoreVersion();
const patchPath = path.join(ROOT, 'patches', `playwright-core@${version}.patch`);
expect(fs.existsSync(patchPath)).toBe(true);
const patch = fs.readFileSync(patchPath, 'utf-8');
// Launch spawnOptions site.
expect(patch).toContain('+ windowsHide: true,');
// taskkill force-kill site.
expect(patch).toContain('shell: true, windowsHide: true');
});
test('bun.lock is coherent: the lockfile records the patched dependency', () => {
const lock = fs.readFileSync(path.join(ROOT, 'bun.lock'), 'utf-8');
const version = installedPlaywrightCoreVersion();
expect(lock).toContain(`patches/playwright-core@${version}.patch`);
});
test('the INSTALLED tree actually has the patch applied (bun install ran it)', () => {
const bundle = fs.readFileSync(
path.join(ROOT, 'node_modules', 'playwright-core', 'lib', 'coreBundle.js'), 'utf-8',
);
expect(bundle).toContain('gstack patch (#2160/#1989)');
expect(bundle).toContain('shell: true, windowsHide: true');
});
});
+22 -16
View File
@@ -54,16 +54,24 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
expect(isProcessAlive(2147483646)).toBe(false);
});
test('3. isProcessAlive spawns NO subprocess on POSIX (signal-0 path)', () => {
test('2b. EPERM means ALIVE: an unsignalable-but-existing PID is not dead (T2)', () => {
// signal-0 to a process we lack permission over throws EPERM — the
// process EXISTS, we just can't signal it. Treating EPERM as "dead" is
// the false negative that leaked agents. PID 1 (launchd/init) on POSIX
// and PID 4 (System) on Windows always exist and are either signalable
// or EPERM — both must read as alive.
expect(isProcessAlive(process.platform === 'win32' ? 4 : 1)).toBe(true);
});
test('3. isProcessAlive spawns NO subprocess on ANY platform (signal-0, #1952)', () => {
// The heart of the bug: a liveness probe that forks is slow enough to
// time out, and a timed-out probe silently answers "dead". Signal 0
// cannot time out because it never leaves the process.
//
// Merged design note: on win32 the helper DOES keep a single hardened
// tasklist probe (windowsHide, bounded timeout, quoted-CSV PID match)
// because Bun's process.kill(pid, 0) throws ESRCH for live Windows PIDs
// in compiled binaries. The POSIX path stays subprocess-free.
if (process.platform === 'win32') return;
// cannot time out because it never leaves the process. Node maps
// process.kill(pid, 0) to an OpenProcess existence check on Windows —
// and the Windows daemon runs under Node (server-node.mjs +
// bun-polyfill), so the POSIX idiom is portable and the win32 tasklist
// branch is GONE (it caused both the false negatives above and the
// per-tick console flash of #1952).
const origSpawn = (Bun as any).spawn;
const origSpawnSync = (Bun as any).spawnSync;
const spawns: string[] = [];
@@ -79,16 +87,14 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
}
});
test('4. no source file probes liveness via tasklist outside the central helper', () => {
// Static tripwire: ad-hoc tasklist existence checks scattered across src/
// resurrect the false-negative class (each call site re-invents the
// timeout/parse handling and gets it subtly wrong). The ONE sanctioned
// site is error-handling.ts's isProcessAlive win32 branch — centralized,
// windowsHide, bounded timeout, quoted-CSV `"${pid}"` match. Every other
// file must route through the helper.
test('4. no source file probes liveness via tasklist — signal-0 is the only probe (#1952)', () => {
// Static tripwire: a tasklist existence check ANYWHERE in src/
// resurrects both the false-negative class (#2414: a timed-out spawnSync
// still returns, with partial stdout, so a live process reads as dead)
// and the per-tick console flash (#1952). isProcessAlive uses
// process.kill(pid, 0) on every platform; nothing gets an exemption.
const offenders: string[] = [];
for (const { file, content } of readAllSourceFiles()) {
if (file === 'error-handling.ts') continue; // the canonical helper
const code = stripComments(content);
// `PID eq` is the existence-probe form specifically. Other tasklist
// uses (e.g. IMAGENAME filters for browser detection) are unaffected.
+7 -57
View File
@@ -10,18 +10,12 @@
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
THRESHOLDS,
combineVerdict,
generateCanary,
injectCanary,
checkCanaryInStructure,
writeSessionState,
readSessionState,
getStatus,
extractDomain,
type LayerSignal,
} from '../src/security';
@@ -244,57 +238,13 @@ describe('canary', () => {
// ─── Attack log + rotation ───────────────────────────────────
// ─── Session state (cross-process, atomic) ───────────────────
describe('session state', () => {
test('write + read round-trip', () => {
const state = {
sessionId: 'test-session-123',
canary: 'CANARY-TEST',
warnedDomains: ['example.com'],
classifierStatus: { testsavant: 'ok' as const },
lastUpdated: '2026-04-19T12:34:56Z',
};
writeSessionState(state);
const got = readSessionState();
expect(got).not.toBeNull();
expect(got!.sessionId).toBe('test-session-123');
expect(got!.canary).toBe('CANARY-TEST');
expect(got!.warnedDomains).toEqual(['example.com']);
});
test('tolerates stale transcript field from pre-rip on-disk state', () => {
// SessionState is a disk format. Files written before the Haiku
// transcript layer was removed carry classifierStatus.transcript —
// getStatus must read them fine, not require transcript for
// 'protected', and never leak the stale key into /health.
const stateFile = path.join(os.homedir(), '.gstack', 'security', 'session-state.json');
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, JSON.stringify({
sessionId: 'legacy-session',
canary: 'CANARY-LEGACY',
warnedDomains: [],
classifierStatus: { testsavant: 'ok', transcript: 'degraded' },
lastUpdated: '2026-04-19T12:34:56Z',
}));
const s = getStatus();
expect(s.status).toBe('protected');
expect('transcript' in s.layers).toBe(false);
});
});
// ─── Status reporting for shield icon ────────────────────────
describe('getStatus', () => {
test('returns a valid SecurityStatus shape', () => {
const s = getStatus();
expect(['protected', 'degraded', 'inactive']).toContain(s.status);
expect(s.layers).toBeDefined();
expect(['ok', 'degraded', 'off']).toContain(s.layers.testsavant);
expect(['ok', 'off']).toContain(s.layers.canary);
expect(s.lastUpdated).toBeTruthy();
});
});
// NOTE (#2557): the session-state + getStatus tests that lived here wrote
// REAL fixture data into ~/.gstack/security/session-state.json — after which
// /health reported a false-green 'protected' indefinitely. The surfaces they
// covered (SessionState, read/writeSessionState, getStatus, the /health
// security field, the sidepanel SEC shield) were dead since the PTY terminal
// rewrite and are now removed. server-security-surface.test.ts pins the
// removal + the live L4 wiring.
// ─── URL domain extraction ───────────────────────────────────
@@ -0,0 +1,86 @@
/**
* #2557 / ENG-OV9: pins the dead-shield removal AND the live L4 wiring.
*
* The removed surface: /health's `security` field read getStatus(), whose
* only data source (~/.gstack/security/session-state.json) lost its only
* writer when sidebar-agent.ts was ripped — so /health reported a permanent
* 'inactive' or, wherever an old state file survived, a stale FALSE-GREEN
* 'protected' ("no threats detected" when the real state was "not
* measured"). Same fail-open class as #2026.
*
* The kept surface (ENG-OV9): security.ts is NOT dead — server.ts's
* /pty-inject-scan path is the live L4 consumer (sidecar scan + URL
* blocklist + datamark envelope), and security.ts's pure combiner/canary
* exports stay. This test pins both directions so a future "cleanup" can't
* silently take the live half, and a future re-feed of /health.security
* from LIVE signals (isSidecarAvailable, content filters) must update this
* test deliberately rather than resurrect the state-file path.
*
* Source-level, same style as windows-spawn-hide.test.ts.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const SRC = (f: string) => fs.readFileSync(path.join(import.meta.dir, '../src', f), 'utf-8');
describe('#2557: dead shield surface stays dead', () => {
test('/health carries no security field and server.ts does not import getStatus', () => {
const server = SRC('server.ts');
expect(server).not.toMatch(/security:\s*getSecurityStatus\(\)/);
expect(server).not.toMatch(/getStatus as getSecurityStatus/);
// The SECURITY session-state file must not be read anywhere in src/ —
// that file has no writer, so any reader is a false-signal feed.
// (session-persist.ts's per-project <stateDir>/session-state.json is a
// different, live file — only the ~/.gstack/security/ one is dead.)
for (const f of fs.readdirSync(path.join(import.meta.dir, '../src')).filter((x) => x.endsWith('.ts'))) {
const code = SRC(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/^\s*\*.*$/gm, '');
const refs = /security[/'",\s][^\n]{0,80}session-state\.json/.test(code);
expect({ file: f, refs }).toEqual({ file: f, refs: false });
}
});
test('security.ts no longer exports the unfed status surface', () => {
const security = SRC('security.ts');
expect(security).not.toMatch(/export function getStatus/);
expect(security).not.toMatch(/export function (read|write)SessionState/);
expect(security).not.toMatch(/export interface SessionState/);
expect(security).not.toMatch(/export interface StatusDetail/);
});
test('the sidepanel shield markup is gone', () => {
const html = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.html'), 'utf-8');
const css = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.css'), 'utf-8');
expect(html).not.toContain('security-shield');
expect(css).not.toMatch(/\.security-shield\s*\{/);
});
});
describe('ENG-OV9: the LIVE L4 path is untouched', () => {
test('server.ts still consumes the sidecar on the inject-scan path', () => {
const server = SRC('server.ts');
expect(server).toContain("from './security-sidecar-client'");
expect(server).toMatch(/isSidecarAvailable/);
expect(server).toMatch(/scanWithSidecar\(/);
});
test('security.ts keeps the pure combiner + canary exports', () => {
const security = SRC('security.ts');
expect(security).toMatch(/export const THRESHOLDS/);
expect(security).toMatch(/export function combineVerdict/);
expect(security).toMatch(/export function generateCanary/);
expect(security).toMatch(/export function injectCanary/);
expect(security).toMatch(/export function checkCanaryInStructure/);
expect(security).toMatch(/export function extractDomain/);
});
test('/health stays liveness-only: no token in any mode (regression wall from v1.63)', () => {
const server = SRC('server.ts');
// The /health handler block must not interpolate a token.
const healthIdx = server.indexOf("url.pathname === '/health'");
expect(healthIdx).toBeGreaterThan(0);
const healthBlock = server.slice(healthIdx, healthIdx + 1500);
expect(healthBlock).not.toMatch(/token:\s*[^n]/i);
});
});
+7 -1
View File
@@ -315,5 +315,11 @@ describe('applyStealth — persistent context (headed + handoff parity)', () =>
await ctx.close();
fs.rmSync(userDataDir, { recursive: true, force: true });
}
});
}, 45000);
// ^ 45s: this is the one HEADED persistent-context launch in the free
// suite. A cold headed launch on macOS runs 8-25s — worse on the first
// launch of a freshly downloaded Chromium (XProtect scans the new bundle,
// the #2554 class) and under shard concurrency. bun's 5s default made this
// the suite's most reliable false-negative: it timed out on the pre-wave
// baseline run of main too, and passes at 15/15 with an honest budget.
});
+144
View File
@@ -0,0 +1,144 @@
/**
* #2254: `browse stop` against a daemon that isn't running must report
* success and exit 0 — WITHOUT starting a daemon just to stop it. The old
* flow routed stop through ensureServer(), which booted a fresh daemon +
* Chromium (multi-second, resource churn) and then shut it down — or
* crash-restarted on a stale state file.
*
* Integration pattern follows busy-daemon-recovery.test.ts: a scratch
* BROWSE_STATE_FILE + a real spawned CLI.
*/
import { describe, test, expect } from 'bun:test';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import { isProcessAlive } from '../src/error-handling';
function runCli(args: string[], env: Record<string, string>, timeoutMs = 30_000):
Promise<{ code: number; stdout: string; stderr: string }> {
const cliPath = path.resolve(import.meta.dir, '../src/cli.ts');
return new Promise((resolve) => {
const proc = spawn('bun', ['run', cliPath, ...args], { timeout: timeoutMs, env });
let stdout = ''; let stderr = '';
proc.stdout.on('data', (d) => stdout += d.toString());
proc.stderr.on('data', (d) => stderr += d.toString());
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
function baseEnv(stateFile: string): Record<string, string> {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v;
}
env.BROWSE_STATE_FILE = stateFile;
return env;
}
/** Grab a port that is definitely closed (bind, read, release). */
async function closedPort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address();
if (!addr || typeof addr === 'string') { reject(new Error('bad address')); return; }
const port = addr.port;
srv.close(() => resolve(port));
});
});
}
describe('#2254 stop on a dead daemon', () => {
test('no daemon state at all → exit 0, nothing spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['stop'], baseEnv(stateFile));
expect(result.code).toBe(0);
expect(result.stdout).toContain('nothing to stop');
// The load-bearing half: NO daemon was started to serve the stop —
// a spawned daemon would have written the state file.
expect(fs.existsSync(stateFile)).toBe(false);
expect(result.stderr).not.toContain('Starting server');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('stale state (dead pid + closed port) → exit 0, state cleaned, nothing spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
// A pid that is certainly not alive and a port nothing listens on.
const port = await closedPort();
fs.writeFileSync(stateFile, JSON.stringify({
pid: 2147483646,
port,
token: 'stale-token',
startedAt: new Date().toISOString(),
serverPath: '',
mode: 'launched' as const,
}, null, 2));
const result = await runCli(['stop'], baseEnv(stateFile));
expect(result.code).toBe(0);
expect(result.stdout).toContain('nothing to stop');
// Stale state cleaned, and no daemon spawned to replace it.
expect(fs.existsSync(stateFile)).toBe(false);
expect(result.stderr).not.toContain('Starting server');
expect(result.stderr).not.toContain('Restarting');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
});
describe('stop --force-restart on a LIVE daemon', () => {
test('kills it directly — never boots a fresh daemon just to stop it', async () => {
// A live-but-BUSY daemon: the pid is alive but nothing answers /health.
// Pre-fix, stop --force-restart fell through to ensureServer(), whose
// force-restart path killed the daemon and then STARTED a fresh one
// (daemon + Chromium) so sendCommand('stop') could stop it again —
// exactly what gstack-upgrade Step 4.8 triggers on a stale-busy daemon.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-force-'));
const stateFile = path.join(tmpDir, 'browse.json');
// Portable long-lived child standing in for the wedged daemon process.
const wedged = spawn('bun', ['-e', 'await Bun.sleep(300000)'], { stdio: 'ignore' });
try {
const port = await closedPort();
fs.writeFileSync(stateFile, JSON.stringify({
pid: wedged.pid,
port,
token: 'busy-token',
startedAt: new Date().toISOString(),
serverPath: '',
mode: 'launched' as const,
}, null, 2));
const result = await runCli(['stop', '--force-restart'], baseEnv(stateFile));
expect(result.code).toBe(0);
expect(result.stdout).toContain('Daemon stopped (forced');
// The load-bearing half: NO fresh daemon was booted to serve the stop.
// A spawned daemon would have re-written the state file.
expect(fs.existsSync(stateFile)).toBe(false);
expect(result.stderr).not.toContain('Starting server');
expect(result.stdout + result.stderr).not.toContain('Restarting');
// And the live pid is actually gone.
const deadline = Date.now() + 3000;
while (Date.now() < deadline && isProcessAlive(wedged.pid!)) {
await new Promise((r) => setTimeout(r, 100));
}
expect(isProcessAlive(wedged.pid!)).toBe(false);
} finally {
try { wedged.kill('SIGKILL'); } catch { /* already gone */ }
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
});
@@ -0,0 +1,80 @@
/**
* #2314: the terminal-agent must allocate its port from the SAME fixed
* 10000-49151 scan range the main server uses (port-allocator.ts,
* decision 8) — never `port: 0`. Binding 0 drew from the OS ephemeral range
* (49152-65535 on macOS), where the weeks-lived agent squatted ports that
* short-lived `app.listen(0)` test servers expected to receive, absorbing
* their traffic as phantom 404s across every Node test suite on the machine.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as net from 'net';
import * as path from 'path';
import {
findAvailablePort,
RANDOM_PORT_MIN,
RANDOM_PORT_MAX,
} from '../src/port-allocator';
const AGENT_TS = path.resolve(import.meta.dir, '..', 'src', 'terminal-agent.ts');
const SERVER_TS = path.resolve(import.meta.dir, '..', 'src', 'server.ts');
describe('shared port allocator (#2314)', () => {
test('allocates inside the fixed scan range, never the ephemeral range', async () => {
for (let i = 0; i < 5; i++) {
const port = await findAvailablePort();
expect(port).toBeGreaterThanOrEqual(RANDOM_PORT_MIN);
expect(port).toBeLessThan(RANDOM_PORT_MAX);
// The load-bearing property: the WHOLE range sits below the ephemeral
// floor (49152). The original 60000 cap left ~22% of picks inside the
// pool this allocator exists to avoid.
expect(RANDOM_PORT_MAX).toBeLessThan(49152);
expect(RANDOM_PORT_MIN).toBeGreaterThanOrEqual(1024);
}
});
test('explicit free port is honored', async () => {
// Find a free port by binding 0, then ask the allocator for exactly it.
const free = await new Promise<number>((resolve, reject) => {
const srv = net.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const p = (srv.address() as net.AddressInfo).port;
srv.close(() => resolve(p));
});
});
expect(await findAvailablePort(free)).toBe(free);
});
test('explicit occupied port throws an actionable error', async () => {
const srv = net.createServer();
await new Promise<void>((resolve, reject) => {
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => resolve());
});
const occupied = (srv.address() as net.AddressInfo).port;
try {
await expect(findAvailablePort(occupied)).rejects.toThrow(/in use/);
} finally {
await new Promise<void>((r) => srv.close(() => r()));
}
});
});
describe('terminal-agent uses the shared allocator (static tripwire)', () => {
test('terminal-agent.ts never binds port: 0', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// Strip comments so the explanatory history above the bind doesn't trip.
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
expect(code).not.toMatch(/port:\s*0\b/);
expect(src).toContain("from './port-allocator'");
expect(src).toContain('findAvailablePort');
});
test('server.ts routes findPort through the same allocator', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
expect(src).toContain("from './port-allocator'");
expect(src).toMatch(/findAvailablePort\(BROWSE_PORT\)/);
});
});
+68 -2
View File
@@ -39,8 +39,9 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => {
});
test('Windows-only process probes pass windowsHide', () => {
// tasklist in isProcessAlive — runs in polling loops.
expectHideNearEvery(SRC('error-handling.ts'), "'tasklist'");
// isProcessAlive no longer spawns anything (signal-0 on every platform,
// #1952) — process-liveness-windows.test.ts pins that it stays
// subprocess-free, which is stronger than hiding a window.
// powershell DPAPI + tasklist in cookie import.
const cookie = SRC('cookie-import-browser.ts');
expectHideNearEvery(cookie, "'powershell'");
@@ -61,4 +62,69 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => {
// spawn's options object carries the full env wiring before the flag.
expectHideNearEvery(SRC('terminal-agent-control.ts'), '(Bun as any).spawn(', 700);
});
test('SWEEP: every direct child_process call in src/ passes windowsHide (#2160, #2415)', () => {
// Full-census tripwire: a NEW child_process call site without windowsHide
// fails CI. Each exemption carries a reason — an interactive console
// child must NOT get CREATE_NO_WINDOW.
const EXEMPT: Array<{ file: string; needle: string; reason: string }> = [
{
file: 'domain-skill-commands.ts',
needle: 'spawnSync(editor',
reason: "interactive $EDITOR with stdio:'inherit' — windowsHide would detach a console editor into an invisible console",
},
];
const srcDir = path.join(import.meta.dir, '../src');
const offenders: string[] = [];
for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith('.ts'))) {
const raw = fs.readFileSync(path.join(srcDir, file), 'utf-8');
if (!raw.includes('child_process')) continue;
// Strip comments so documented history doesn't trip the census.
const code = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
// Collect the callable names this file binds to child_process:
// import { spawn as nodeSpawn } from 'child_process'
// const { execSync } = await import('child_process') / require(...)
// import * as cp from 'child_process' → cp.<fn>( pattern
const names = new Set<string>();
const namespaces = new Set<string>();
const importRe = /import\s*\{([^}]*)\}\s*from\s*['"](?:node:)?child_process['"]/g;
const dynRe = /(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:await\s+import\(|require\()['"](?:node:)?child_process['"]\)/g;
const nsRe = /import\s*\*\s*as\s*(\w+)\s*from\s*['"](?:node:)?child_process['"]/g;
for (const m of code.matchAll(importRe)) {
for (const part of m[1].split(',')) {
const alias = part.split(/\s+as\s+/).map((s) => s.trim()).filter(Boolean);
const name = alias[alias.length - 1];
if (name && /^(spawn|spawnSync|exec|execSync|execFile|execFileSync|nodeSpawn|cpSpawn)/.test(alias[0].trim())) names.add(name);
}
}
for (const m of code.matchAll(dynRe)) {
for (const part of m[1].split(',')) {
const alias = part.split(':').map((s) => s.trim()).filter(Boolean);
const name = alias[alias.length - 1];
if (name && /^(spawn|spawnSync|exec|execSync|execFile|execFileSync)/.test(alias[0].trim())) names.add(name);
}
}
for (const m of code.matchAll(nsRe)) namespaces.add(m[1]);
const patterns: RegExp[] = [];
for (const n of names) patterns.push(new RegExp(`(?<![.\\w'"\`])${n}\\(`, 'g'));
for (const ns of namespaces) {
patterns.push(new RegExp(`(?<![\\w'"\`])${ns}\\.(?:spawn|spawnSync|exec|execSync|execFile|execFileSync)\\(`, 'g'));
}
for (const re of patterns) {
for (const m of code.matchAll(re)) {
const slice = code.slice(m.index!, m.index! + 700);
const exempt = EXEMPT.some((e) => e.file === file && slice.startsWith(e.needle));
if (exempt) continue;
if (!/windowsHide:\s*true/.test(slice)) {
offenders.push(`${file}: ${slice.split('\n')[0].slice(0, 100)}`);
}
}
}
}
expect(offenders).toEqual([]);
});
});
+457
View File
@@ -0,0 +1,457 @@
/**
* XProtect launch-kill self-heal (P0 #2554) — unit tests.
*
* F9: the classifier is tested with POSITIVE signatures (sourced from the
* #2554 report + Playwright's launch-error format) AND NEGATIVES (missing
* executable, EPERM/EACCES, sandbox denial, plain crash) so a generic launch
* failure can never trigger a pointless reinstall.
*
* F4: the one-shot guard is pinned — at most one heal attempt per process,
* even when the heal fails.
*
* ENG-OV3/F9: the post-heal verification target is REGISTRY-derived (the
* revision playwright-core's browsers.json expects), not disk-derived, and
* the install-root finder rejects roots pinning a different revision.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { chromium } from 'playwright';
import {
isXProtectKillSignature,
findPlaywrightRevisionDir,
expectedChromiumRevision,
findGstackInstallRoot,
clearQuarantineOnPlaywrightCache,
maybeHealXProtectKill,
launchWithXProtectHeal,
resetXProtectHealForTests,
buildXProtectGuidance,
runBoundedChromiumReinstall,
} from '../src/xprotect-heal';
const REPO_ROOT = path.resolve(import.meta.dir, '..', '..');
// ─── Fixtures: POSITIVE signatures (real Playwright error shapes for an
// OS-level SIGKILL at spawn — what xprotectd does per the #2554 report) ────
const SIGKILL_BROWSER_CLOSED = `browserType.launch: Browser closed.
==================== Browser output: ====================
<launched> pid=48213
[pid=48213] <process did exit: exitCode=null, signal=SIGKILL>
[pid=48213] starting temporary directories cleanup
=========================== logs ===========================`;
const SIGKILL_PERSISTENT_CONTEXT = `browserType.launchPersistentContext: Target page, context or browser has been closed
Browser logs:
<launched> pid=9021
[pid=9021] <process did exit: exitCode=null, signal=SIGKILL>`;
// The #2554 report's visible symptom: the kill surfaces as a launch timeout
// where the process DID spawn (<launched>) but never became ready.
const LAUNCH_TIMEOUT_AFTER_SPAWN = `browserType.launch: Timeout 180000ms exceeded.
=========================== logs ===========================
<launched> pid=51677
============================================================`;
// ─── Fixtures: NEGATIVE signatures (F9) ──────────────────────────────────
const MISSING_EXECUTABLE = `browserType.launch: Executable doesn't exist at /Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-mac-arm64/headless_shell
╔═══════════════════════════════════════════════════════╗
║ Looks like Playwright was just installed or updated. ║
║ Please run the following command to download browsers:║
║ bunx playwright install ║
╚═══════════════════════════════════════════════════════╝`;
const SPAWN_EACCES = `browserType.launch: spawn /Users/dev/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing EACCES`;
const EPERM_FAILURE = `browserType.launch: Browser closed.
==================== Browser output: ====================
Error: EPERM: operation not permitted, open '/Users/dev/Library/Caches/ms-playwright/.links/lock'`;
const SANDBOX_DENIAL = `browserType.launch: Browser closed.
==================== Browser output: ====================
<launched> pid=7211
[pid=7211][err] Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
[pid=7211] <process did exit: exitCode=1, signal=null>`;
const PLAIN_CRASH_EXIT_1 = `browserType.launch: Browser closed.
==================== Browser output: ====================
<launched> pid=3300
[pid=3300] <process did exit: exitCode=1, signal=null>`;
// ─── Classifier ──────────────────────────────────────────────────────────
describe('isXProtectKillSignature — positives (darwin)', () => {
it('classifies SIGKILL in a Browser closed error', () => {
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'darwin')).toBe(true);
});
it('classifies SIGKILL in a launchPersistentContext error', () => {
expect(isXProtectKillSignature(SIGKILL_PERSISTENT_CONTEXT, 'darwin')).toBe(true);
});
it('classifies a launch timeout where the process spawned (<launched>)', () => {
expect(isXProtectKillSignature(LAUNCH_TIMEOUT_AFTER_SPAWN, 'darwin')).toBe(true);
});
});
describe('isXProtectKillSignature — negatives (F9)', () => {
it('rejects a missing executable', () => {
expect(isXProtectKillSignature(MISSING_EXECUTABLE, 'darwin')).toBe(false);
});
it('rejects spawn EACCES', () => {
expect(isXProtectKillSignature(SPAWN_EACCES, 'darwin')).toBe(false);
});
it('rejects EPERM failures', () => {
expect(isXProtectKillSignature(EPERM_FAILURE, 'darwin')).toBe(false);
});
it('rejects Linux sandbox denials even with a <launched> marker', () => {
expect(isXProtectKillSignature(SANDBOX_DENIAL, 'darwin')).toBe(false);
});
it('rejects a plain crash (exitCode=1, no signal)', () => {
expect(isXProtectKillSignature(PLAIN_CRASH_EXIT_1, 'darwin')).toBe(false);
});
it('rejects a bare timeout with no <launched> marker (process never spawned)', () => {
expect(isXProtectKillSignature('browserType.launch: Timeout 180000ms exceeded.', 'darwin')).toBe(false);
});
it('rejects empty messages', () => {
expect(isXProtectKillSignature('', 'darwin')).toBe(false);
});
it('is platform-gated: the SIGKILL signature on linux/win32 is NOT XProtect', () => {
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'linux')).toBe(false);
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'win32')).toBe(false);
});
});
// ─── Cache path helpers ──────────────────────────────────────────────────
describe('findPlaywrightRevisionDir', () => {
it('finds the revision dir for the headed bundle layout', () => {
const p = '/Users/dev/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
expect(findPlaywrightRevisionDir(p)).toBe('/Users/dev/Library/Caches/ms-playwright/chromium-1234');
});
it('finds the revision dir for the headless shell layout', () => {
const p = '/Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-mac-arm64/headless_shell';
expect(findPlaywrightRevisionDir(p)).toBe('/Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234');
});
it('returns null outside the Playwright cache layout', () => {
expect(findPlaywrightRevisionDir('/Applications/GStack Browser.app/Contents/MacOS/Chromium')).toBe(null);
});
});
describe('expectedChromiumRevision — registry-derived expectation (F9/ENG-OV3)', () => {
it('matches the revision playwright-core browsers.json declares for chromium', () => {
const browsersJson = JSON.parse(fs.readFileSync(
path.join(REPO_ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8',
));
const registryRevision = browsersJson.browsers.find((b: { name: string }) => b.name === 'chromium').revision;
// chromium.executablePath() is computed from the embedded registry (not
// read from disk) — the heal's post-install verification target is
// therefore the revision dir playwright-core EXPECTS, which is exactly
// what a wrong-revision heal would fail.
expect(expectedChromiumRevision(chromium.executablePath())).toBe(registryRevision);
});
});
describe('findGstackInstallRoot (ENG-OV3: revision-matched roots only)', () => {
let tmpRoot: string;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-root-'));
const pwCore = path.join(tmpRoot, 'node_modules', 'playwright-core');
fs.mkdirSync(pwCore, { recursive: true });
fs.writeFileSync(path.join(pwCore, 'browsers.json'), JSON.stringify({
browsers: [{ name: 'chromium', revision: '1234' }],
}));
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it('accepts a root whose pinned playwright-core expects the same revision', () => {
expect(findGstackInstallRoot('1234', [tmpRoot])).toBe(tmpRoot);
});
it('rejects a root pinning a DIFFERENT revision (wrong-revision heal guard)', () => {
expect(findGstackInstallRoot('9999', [tmpRoot])).toBe(null);
});
it('rejects roots without node_modules/playwright-core', () => {
const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-bare-'));
try {
expect(findGstackInstallRoot('1234', [bare])).toBe(null);
} finally {
fs.rmSync(bare, { recursive: true, force: true });
}
});
it('resolves the dev checkout by default (its node_modules pins our revision)', () => {
const browsersJson = JSON.parse(fs.readFileSync(
path.join(REPO_ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8',
));
const registryRevision = browsersJson.browsers.find((b: { name: string }) => b.name === 'chromium').revision;
const root = findGstackInstallRoot(registryRevision);
expect(root).not.toBe(null);
expect(fs.existsSync(path.join(root!, 'node_modules', 'playwright-core', 'browsers.json'))).toBe(true);
});
});
// ─── Quarantine-clear scope contract ─────────────────────────────────────
describe('clearQuarantineOnPlaywrightCache', () => {
let tmpCache: string;
let execPath: string;
const savedCustomPath = process.env.GSTACK_CHROMIUM_PATH;
beforeEach(() => {
tmpCache = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-cache-'));
for (const dir of ['chromium-1234', 'chromium_headless_shell-1234', 'firefox-5678', 'webkit-2222']) {
fs.mkdirSync(path.join(tmpCache, dir), { recursive: true });
}
execPath = path.join(tmpCache, 'chromium-1234', 'chrome-mac-arm64', 'App.app', 'Contents', 'MacOS', 'chromium');
delete process.env.GSTACK_CHROMIUM_PATH;
});
afterEach(() => {
fs.rmSync(tmpCache, { recursive: true, force: true });
if (savedCustomPath === undefined) delete process.env.GSTACK_CHROMIUM_PATH;
else process.env.GSTACK_CHROMIUM_PATH = savedCustomPath;
});
it('clears every chromium* revision dir, never firefox/webkit', () => {
const cleared: string[] = [];
const ok = clearQuarantineOnPlaywrightCache(execPath, (target) => {
cleared.push(path.basename(target));
return 0;
});
expect(ok).toBe(true);
expect(cleared.sort()).toEqual(['chromium-1234', 'chromium_headless_shell-1234']);
});
it('NEVER touches a GSTACK_CHROMIUM_PATH bundle (embedder scope contract)', () => {
process.env.GSTACK_CHROMIUM_PATH = execPath;
const cleared: string[] = [];
const ok = clearQuarantineOnPlaywrightCache(execPath, (target) => {
cleared.push(target);
return 0;
});
expect(ok).toBe(false);
expect(cleared).toEqual([]);
});
it('skips executables outside the Playwright cache layout', () => {
const cleared: string[] = [];
const ok = clearQuarantineOnPlaywrightCache('/Applications/Foo.app/Contents/MacOS/foo', (target) => {
cleared.push(target);
return 0;
});
expect(ok).toBe(false);
expect(cleared).toEqual([]);
});
});
// ─── One-shot heal orchestration (F4) ────────────────────────────────────
function makeDeps(counters: { installs: number; quarantines: number }, overrides: Record<string, unknown> = {}) {
return {
platform: 'darwin' as NodeJS.Platform,
executablePath: () => '/tmp/ms-playwright/chromium-1234/chrome-mac-arm64/App.app/Contents/MacOS/chromium',
clearQuarantine: () => { counters.quarantines++; return true; },
installRoot: () => '/tmp/fake-gstack-root',
runReinstall: async () => { counters.installs++; return { ok: true }; },
verifyInstalled: () => true,
...overrides,
};
}
describe('maybeHealXProtectKill', () => {
beforeEach(() => resetXProtectHealForTests());
it('heals a classified failure: quarantine-clear + reinstall + verify', async () => {
const counters = { installs: 0, quarantines: 0 };
const healed = await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters));
expect(healed).toBe(true);
expect(counters.quarantines).toBe(1);
expect(counters.installs).toBe(1);
});
it('F4: runs AT MOST ONCE per process, even across distinct errors', async () => {
const counters = { installs: 0, quarantines: 0 };
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(true);
expect(await maybeHealXProtectKill(new Error(LAUNCH_TIMEOUT_AFTER_SPAWN), {}, makeDeps(counters))).toBe(false);
expect(counters.installs).toBe(1);
});
it('F4: a FAILED heal also consumes the one-shot (no reinstall loops)', async () => {
const counters = { installs: 0, quarantines: 0 };
const failing = makeDeps(counters, { runReinstall: async () => { counters.installs++; return { ok: false, reason: 'timeout' }; } });
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, failing)).toBe(false);
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(false);
expect(counters.installs).toBe(1);
});
it('an unclassified error does NOT consume the one-shot', async () => {
const counters = { installs: 0, quarantines: 0 };
expect(await maybeHealXProtectKill(new Error(MISSING_EXECUTABLE), {}, makeDeps(counters))).toBe(false);
expect(counters.installs).toBe(0);
// Guard not consumed — a real signature afterwards still heals.
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(true);
});
it('never heals over a custom executable (GSTACK_CHROMIUM_PATH scope)', async () => {
const counters = { installs: 0, quarantines: 0 };
const healed = await maybeHealXProtectKill(
new Error(SIGKILL_BROWSER_CLOSED),
{ usesCustomExecutable: true },
makeDeps(counters),
);
expect(healed).toBe(false);
expect(counters.quarantines).toBe(0);
expect(counters.installs).toBe(0);
});
it('fails the heal when no install root pins our revision (ENG-OV3)', async () => {
const counters = { installs: 0, quarantines: 0 };
const deps = makeDeps(counters, { installRoot: () => null });
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, deps)).toBe(false);
expect(counters.installs).toBe(0);
});
it('fails the heal when post-install verification misses the expected revision dir (F9)', async () => {
const counters = { installs: 0, quarantines: 0 };
const deps = makeDeps(counters, { verifyInstalled: () => false });
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, deps)).toBe(false);
expect(counters.installs).toBe(1);
});
});
// ─── Launch wrapper ──────────────────────────────────────────────────────
describe('launchWithXProtectHeal', () => {
beforeEach(() => resetXProtectHealForTests());
it('retries the launch exactly once after a successful heal', async () => {
const counters = { installs: 0, quarantines: 0 };
let attempts = 0;
const result = await launchWithXProtectHeal(async () => {
attempts++;
if (attempts === 1) throw new Error(SIGKILL_BROWSER_CLOSED);
return 'browser';
}, {}, makeDeps(counters));
expect(result).toBe('browser');
expect(attempts).toBe(2);
expect(counters.installs).toBe(1);
});
it('surfaces the ORIGINAL error + manual guidance when the heal fails (E1)', async () => {
const counters = { installs: 0, quarantines: 0 };
const deps = makeDeps(counters, { runReinstall: async () => ({ ok: false, reason: 'timeout' }) });
let thrown: Error | null = null;
try {
await launchWithXProtectHeal(async () => { throw new Error(SIGKILL_BROWSER_CLOSED); }, {}, deps);
} catch (err) {
thrown = err as Error;
}
expect(thrown).not.toBe(null);
// Original launch error text preserved…
expect(thrown!.message).toContain('signal=SIGKILL');
// …plus the manual remediation.
expect(thrown!.message).toContain('bunx playwright install chromium');
});
it('passes unclassified failures through untouched', async () => {
const counters = { installs: 0, quarantines: 0 };
let thrown: Error | null = null;
try {
await launchWithXProtectHeal(async () => { throw new Error(MISSING_EXECUTABLE); }, {}, makeDeps(counters));
} catch (err) {
thrown = err as Error;
}
expect(thrown!.message).toBe(MISSING_EXECUTABLE);
expect(counters.installs).toBe(0);
});
});
describe('buildXProtectGuidance', () => {
it('carries both the original message and the manual command', () => {
const out = buildXProtectGuidance('original launch error');
expect(out).toContain('original launch error');
expect(out).toContain('bunx playwright install chromium');
expect(out).toContain('#2554');
});
});
// ─── runBoundedChromiumReinstall (T3) — previously zero coverage ──────────
//
// Exercised end-to-end against a STUB `bunx` on a prepended PATH: real spawn,
// real process group, real timer — only the binary is fake. Shell stubs
// don't exist on Windows, and the group-kill path is POSIX (`kill(-pid)`),
// so the suite is Unix-only like the shape it tests.
describe.skipIf(process.platform === 'win32')('runBoundedChromiumReinstall', () => {
let stubDir: string;
let savedPath: string | undefined;
function installStubBunx(script: string): void {
const stub = path.join(stubDir, 'bunx');
fs.writeFileSync(stub, `#!/bin/sh\n${script}\n`, { mode: 0o755 });
}
beforeEach(() => {
stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-stub-'));
savedPath = process.env.PATH;
process.env.PATH = `${stubDir}${path.delimiter}${process.env.PATH ?? ''}`;
});
afterEach(() => {
process.env.PATH = savedPath;
fs.rmSync(stubDir, { recursive: true, force: true });
});
it('resolves ok on exit 0', async () => {
installStubBunx('exit 0');
const result = await runBoundedChromiumReinstall(stubDir, 10_000);
expect(result).toEqual({ ok: true, exitCode: 0 });
});
it('reports install-exit-N with the stderr tail on a nonzero exit', async () => {
installStubBunx('echo "download failed: mirror unreachable" >&2\nexit 7');
const result = await runBoundedChromiumReinstall(stubDir, 10_000);
expect(result.ok).toBe(false);
expect(result.exitCode).toBe(7);
expect(result.reason).toStartWith('install-exit-7');
expect(result.reason).toContain('download failed: mirror unreachable');
});
it('group-kills a hung install at timeoutMs and reports timeout', async () => {
// The stub spawns its own child (like bunx → playwright CLI → download
// workers) and sleeps well past the bound; the detached process group
// must take BOTH down, and the result must arrive at ~timeoutMs, not
// after the sleep.
installStubBunx('sleep 30 &\nsleep 30');
const started = Date.now();
const result = await runBoundedChromiumReinstall(stubDir, 500);
const elapsed = Date.now() - started;
expect(result).toEqual({ ok: false, reason: 'timeout' });
expect(elapsed).toBeLessThan(5_000); // resolved at the bound, not the sleep
});
it('reports spawn-error when the binary cannot be executed', async () => {
// No stub installed and PATH reduced to the empty stub dir only.
process.env.PATH = stubDir;
const result = await runBoundedChromiumReinstall(stubDir, 10_000);
expect(result.ok).toBe(false);
expect(result.reason).toStartWith('spawn-error:');
});
});