v1.91.2.0 fix: consolidate gstack reliability wave (#2959)

* fix(memory-ingest): --scan-secrets scans the rendered page and fails closed

--scan-secrets ran gitleaks on the raw transcript .jsonl, then imported a
page rendered from it. gitleaks' assignment rules don't match across a
JSON-escaped quote (KEY=\"v\" on disk), so a secret the rendered page
shows as KEY="v" was imported unflagged. And the gate skipped a file only
on scanner "gitleaks" with findings, so a scan that errored (non-zero
exit, 16MB maxBuffer overflow on a file with many findings, unparseable
report) or could not run (gitleaks missing, slow-probe cooldown) imported
the file unscanned.

Scan the rendered page body, the exact bytes writeStaged() writes, via a
new secretScanText() helper, and skip the file whenever the scan did not
complete. Skipped files stay out of the state file, so the next run
retries them. Reword the helper warnings and setup-gbrain/memory.md,
which described the fail-open as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(test): reconcile Bun failure markers and footer counts

* fix(sync-gbrain): verify source-scoped reads without mutation

* fix(test): recognize grounded TTHW target choices structurally

* fix(aside): make the readiness probe work under zsh and report why it failed

The probe built its deadline into `_T` and expanded it unquoted, so
`$_T aside repl …` only worked in a shell that word-splits. zsh does not: it
looked for a command literally named "gtimeout 30", the probe answered
ASIDE_NOT_RUNNING with Aside installed and ready, and every browsing skill
fell back to the bundled Chromium in silence. zsh is the macOS default and
Aside is macOS-only, so on a stock Mac the probe could never report READY.

The deadline becomes a function, `_gs_d`. It receives the command as "$@",
already split, so sh, bash and zsh all behave the same, and the gtimeout →
timeout → perl alarm chain is unchanged. A 4th arm runs the call unbounded
when none of the three is present, which is what the empty `_T` did before.
Not `eval`: it re-parses the string, so the parens and `;` of the perl arm
become syntax and that arm dies in bash *and* zsh — on a stock Mac, the arm
that actually runs.

On failure the probe now prints the CLI's reason after ASIDE_NOT_RUNNING:,
the shape gstack-render already uses: the first line that starts with a
capital letter, i.e. the CLI's own sentence or Node's `Error:` line below its
loader frame. "Not running" covers states with different fixes — no window
open for the profile, a NODE_OPTIONS preload that kills the CLI — and a bare
verdict sent all of them to "open the Aside app". The BROWSER SETUP prose
quotes that reason before asking the user to open the app.

The text pin asserted the broken invocation verbatim, so it now pins the
function and asserts neither `$_T aside repl` nor an eval form comes back. A
second test executes the rendered probe in sh, bash and zsh on each of the
four deadline arms with stubbed binaries on a narrowed PATH, plus two failing
CLIs: one that prints its own sentence, one that crashes like Node with the
useful line below the frame.

The deadline function costs zero bytes against the lines it replaces; the
reason costs 53 per copy of the probe (44 where the reworded BROWSER SETUP
line gives 9 back). That moves four guards by the measured amount:
plan-devex-review's skeleton cap to 68,550 (measured 68,544), plan-ceo-review's
skeleton cap to 80,150 (measured 80,111) and union ratio to 1.081 (measured
1.0803), and plan-eng-review's union ratio to 1.151 (measured 1.1504).

Fixes #2842, #2941.

* Clarify engineering review startup and decision flow

* Fix Windows readiness fixture PATH and command shim

* fix(test): recognize grounded TTHW target choices structurally

* Clarify engineering review startup and decision flow

* fix(test): restrict QA-only fixture tools to its no-Edit contract

* v1.90.0.0 fix(sync-gbrain): guard readiness verdicts and refresh metadata

* fix(browse): validate canonical upload targets

* fix(gbrain): classify structured PGLite busy response

* fix(browse): preserve native extension runtime APIs

* Fix displayless browser handoff ownership

* Accept unique installed autoplan methodology aliases

* fix(skills): preserve positional literals during installation

* fix(browse): checksum installer contents through stdin

* fix(test): normalize Windows checksum fixture paths

* test: emulate unavailable shasum in Windows checksum fixture

* fix(investigate): preserve owned freeze lifecycle

* fix(review): preserve N+1 retry and Red Team completion

* fix: bound Aside readiness and preserve safe fallback

* test: exercise setup and Chromium on native ARM

* fix: preserve install ownership and ARM browser selection

* Fix gbrain ingest scan boundaries and seed observation

* Refresh managed ship hooks and supervise expanded paid census

* Reject resumed gbrain pages excluded by current policy

* Recover zombie agent locks safely and enable CI Python venv

* Repair paid actor declarations and Aside pitch assertions

* Bump consolidated wave to next free minor release

* Clarify CEO review admin choices and option tradeoffs

* Preserve CEO mode handoff anchors in clarified workflow

* Make Windows portability fixtures use shell-native paths

* Restore ARM Bun alias and clarify ship review gates

* Refresh ship workflow golden snapshots

* Fix Windows DX documentation controls without piped stdin

* Decode Codex child pipes without Bun's encoded-stream stall

* Bound DX pre-review audit before product questions

* Clarify trusted review-start read in paid revalidation

* Bump consolidated wave to next free minor release

* Clarify CEO review admin choices and option tradeoffs

* Preserve CEO mode handoff anchors in clarified workflow

* Make Windows portability fixtures use shell-native paths

* Restore ARM Bun alias and clarify ship review gates

* Refresh ship workflow golden snapshots

* Fix Windows DX documentation controls without piped stdin

* Decode Codex child pipes without Bun's encoded-stream stall

* Bound DX pre-review audit before product questions

* Clarify trusted review-start read in paid revalidation

* Reconcile new main planning flow and paid judge census

* fix: reconcile rebased planning and source-bound validation

* test: pin cookie workflow judge to scored Sonnet model

* fix: keep terminal agent boot out of module imports

* fix: preserve pending-question uncertainty in engineering review

* fix: stabilize Windows reliability-wave fixtures

* fix: clarify design consultation research workflow

* fix: preserve independent design consultation inputs

* fix: resolve design taste scope and browser research guidance

* fix: make consultation opt-in preflight unambiguous

* test: await native Edge owner readiness or terminal result

---------

Co-authored-by: Bruce Krysiak <brucek@alum.mit.edu>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Antonio Vitalic <antoninte99@gmail.com>
This commit is contained in:
Garry Tan
2026-09-26 18:57:53 -04:00
committed by GitHub
co-authored by Bruce Krysiak Claude Opus 5.5 Antonio Vitalic
parent 2a113ae7e6
commit 01593aa67c
204 changed files with 9958 additions and 1705 deletions
+2 -2
View File
@@ -25,8 +25,8 @@ describe('Adversarial security', () => {
path.join(import.meta.dir, '../../freeze/bin/check-freeze.sh'),
'utf-8',
);
// The boundary check must use "${FREEZE_DIR}/" with a trailing slash
// The boundary check must use "${FREEZE_DIR%/}/" with a trailing slash
// to prevent prefix collision (e.g., /app matching /application)
expect(source).toContain('"${FREEZE_DIR}/"');
expect(source).toContain('"${FREEZE_DIR%/}/"');
});
});
+89 -32
View File
@@ -283,10 +283,27 @@ function clearOwnedFixtureContents(fixture: string, identity: { path: string; de
}
}
function removeOwnedFixtureRootWithNode(directory: string, identity: { path: string; dev: bigint; ino: bigint }): void {
const node = Bun.which('node');
if (!node) throw new Error('Node is required for native fixture cleanup');
const result = spawnSync(node, [path.resolve(import.meta.dir, 'fixtures/native-cookie-remove-fixture.cjs'), Buffer.from(JSON.stringify({
root: directory, realpath: identity.path, dev: identity.dev.toString(), ino: identity.ino.toString(),
})).toString('base64')], { env: nativeCookieEnvironment(process.env), encoding: 'utf8', timeout: 5_000, windowsHide: true, maxBuffer: 65536 });
let receipt: { removed?: boolean; code?: string };
try { receipt = JSON.parse(result.stdout); }
catch { receipt = { removed: false }; }
if (result.status === 0 && receipt?.removed === true && !existsSync(directory)) return;
throw Object.assign(new Error('Native fixture root cleanup failed'), {
code: receipt?.code || 'EIO', syscall: 'rm', path: directory,
receipt: { ...receipt, exitCode: result.status },
});
}
afterAll(() => {
try {
if (existsSync(root) && realpathSync(root) !== resolvedRoot) throw new Error('Native fixture root ownership changed');
rmSync(root, { recursive: true, force: true });
if (process.platform === 'win32') removeOwnedFixtureRootWithNode(root, { path: resolvedRoot, dev: initialRootState.dev, ino: initialRootState.ino });
else rmSync(root, { recursive: true, force: true });
} catch (error) {
console.error(JSON.stringify({ nativeFixtureRemovalFailure: { stage: 'after_all', ...fixtureRemovalEvidence(error, root, initialRootState) } }));
const entries: { path: string; type: string; mode?: number; code?: string }[] = [];
@@ -310,18 +327,9 @@ afterAll(() => {
}
const lockedFile = entries.find(entry => entry.type === 'file' && /^[0-9a-f-]{36}\.tmp$/i.test(path.basename(entry.path)));
console.error(JSON.stringify({ nativeFixtureCleanup: { code: (error as NodeJS.ErrnoException).code, pendingChildCloses: fixtureChildren.size, rootVerified, rootMode, remaining: entries,
...(error && typeof error === 'object' && 'receipt' in error ? { nodeCleanup: error.receipt } : {}),
fileOwners: lockedFile ? { file: lockedFile.path, owners: fixtureFileOwners(path.join(root, lockedFile.path)) } : undefined,
} }));
const node = Bun.which('node');
if (rootVerified && node) {
const comparison = spawnSync(node, [path.resolve(import.meta.dir, 'fixtures/native-cookie-remove-fixture.cjs'), Buffer.from(JSON.stringify({
root, realpath: resolvedRoot, dev: initialRootState.dev.toString(), ino: initialRootState.ino.toString(),
})).toString('base64')], { env: nativeCookieEnvironment(process.env), encoding: 'utf8', timeout: 5_000, windowsHide: true, maxBuffer: 65536 });
let evidence: object;
try { evidence = JSON.parse(comparison.stdout); }
catch { evidence = { removed: false, reason: 'node_cleanup_no_receipt', exitCode: comparison.status }; }
console.error(JSON.stringify({ nativeFixtureNodeCleanupComparison: evidence }));
}
throw error;
}
}, 15_000);
@@ -826,6 +834,44 @@ describe('owned native-cookie lifecycle', () => {
expect(existsSync(root)).toBe(true);
}, 15_000);
test('native root teardown rejects a changed identity and removes nested owned contents', () => {
const fixture = mkdtempSync(path.join(root, 'root-cleanup-'));
const state = lstatSync(fixture, { bigint: true });
const identity = { path: realpathSync(fixture), dev: state.dev, ino: state.ino };
const marker = path.join(fixture, 'nested', 'marker');
mkdirSync(path.dirname(marker));
writeFileSync(marker, 'fixture-only');
expect(() => removeOwnedFixtureRootWithNode(fixture, { ...identity, ino: identity.ino + 1n })).toThrow('Native fixture root cleanup failed');
expect(readFileSync(marker, 'utf8')).toBe('fixture-only');
removeOwnedFixtureRootWithNode(fixture, identity);
expect(existsSync(fixture)).toBe(false);
});
test.skipIf(process.platform !== 'win32')('native root teardown refuses a real delete-sharing lock until its owner closes', () => {
const fixture = mkdtempSync(path.join(root, 'root-lock-'));
const identity = lstatSync(fixture, { bigint: true });
const file = path.join(fixture, 'held.tmp');
writeFileSync(file, 'fixture-only');
const kernel = dlopen('kernel32.dll', {
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
});
const name = Buffer.from(file + '\0', 'utf16le');
const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, 0x80, 0);
try {
expect(BigInt(handle)).not.toBe(0xffffffffffffffffn);
expect(BigInt(handle)).not.toBe(0n);
expect(() => removeOwnedFixtureRootWithNode(fixture, { path: realpathSync(fixture), dev: identity.dev, ino: identity.ino })).toThrow('Native fixture root cleanup failed');
expect(readFileSync(file, 'utf8')).toBe('fixture-only');
} finally {
try {
if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) expect(kernel.symbols.CloseHandle(handle)).toBe(1);
} finally { kernel.close(); }
}
removeOwnedFixtureRootWithNode(fixture, { path: realpathSync(fixture), dev: identity.dev, ino: identity.ino });
expect(existsSync(fixture)).toBe(false);
}, 15_000);
test('success is withheld until the entire job is empty and member exits', async () => {
const run = simulation({ reply: { cookies: [] }, exitAt: 200 });
expect(await run.run).toEqual({ cookies: [] });
@@ -1001,6 +1047,29 @@ function nativeSupervisor(input: NativeCookieRequest, env: NodeJS.ProcessEnv) {
return { child, done, cleanupDeadline, envelope: () => safeNativeEnvelope(output) };
}
async function waitForNativeOwnerMarker(marker: string, done: Promise<NativeCookieReply>): Promise<boolean> {
let finished = false;
void done.then(() => { finished = true; }, () => { finished = true; });
while (!finished && !existsSync(marker)) await Bun.sleep(20);
return existsSync(marker) && !finished;
}
test('held owner readiness follows its marker or terminal reply, not an earlier checkpoint', async () => {
const fixture = mkdtempSync(path.join(root, 'owner-readiness-'));
const marker = path.join(fixture, 'ready');
let finish!: (reply: NativeCookieReply) => void;
const done = new Promise<NativeCookieReply>(resolve => { finish = resolve; });
let observed = false;
const waiting = waitForNativeOwnerMarker(marker, done).then(ready => { observed = true; return ready; });
await Bun.sleep(50);
expect(observed).toBe(false);
writeFileSync(marker, 'fixture-only', { flag: 'wx' });
expect(await waiting).toBe(true);
finish({ error: 'native_timeout' });
expect(await waitForNativeOwnerMarker(path.join(fixture, 'missing'), Promise.resolve({ error: 'native_failed' }))).toBe(false);
expect(await waitForNativeOwnerMarker(path.join(fixture, 'missing'), Promise.reject(new Error('fixture-only')))).toBe(false);
});
describe('native Windows process qualification', () => {
test.skipIf(process.platform !== 'win32' || process.env.GSTACK_COOKIE_NATIVE_DEFAULT_FIXTURE !== '1')('an exclusively created default Edge profile persists v20 and reimports it through the owned Node worker', async () => {
if (process.env.GITHUB_ACTIONS !== 'true' || process.env.CI !== 'true') throw new Error('Default-profile qualification requires a disposable GitHub Actions Windows runner');
@@ -1070,38 +1139,26 @@ describe('native Windows process qualification', () => {
if (!node || !edge) throw new Error('Native qualification requires Node and installed Microsoft Edge');
const fixture = mkdtempSync(path.join(root, 'locked-edge-'));
const marker = path.join(fixture, 'owner-ready.json');
const ownerObservation = path.join(fixture, 'owner-launch.json');
const contenderObservation = path.join(fixture, 'contender-launch.json');
const contenderEntry = path.join(fixture, 'contender-playwright.cjs');
const playwrightEntry = path.join(fixture, 'held-playwright.cjs');
const require = createRequire(import.meta.url);
writeFileSync(contenderEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation: contenderObservation, playwrightEntry: require.resolve('playwright') })});`);
writeFileSync(playwrightEntry, `
const cp = require('node:child_process');
const spawn = cp.spawn;
let pid;
cp.spawn = function(command, args, options) {
if (args.some(arg => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))) throw new Error('Native owner fixture refuses a sandbox-disabled browser');
const child = spawn.call(this, command, args, options); pid = child.pid; return child;
};
const { chromium } = require(${JSON.stringify(require.resolve('playwright'))});
exports.chromium = { async launchPersistentContext(root, options) {
const context = await chromium.launchPersistentContext(root, options);
require('node:fs').writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ pid }));
context.cookies = () => new Promise(() => {});
return context;
} };
`);
writeFileSync(playwrightEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation: ownerObservation, playwrightEntry: require.resolve('playwright'), mode: 'held-owner', marker })});`);
const env = nativeFixtureEnvironment(fixture, node);
const input = { ...request, nodeExecutable: node, executablePath: edge, userDataDir: path.join(fixture, 'User Data'), playwrightEntry };
const owner = nativeSupervisor(input, env);
let contender: ReturnType<typeof nativeSupervisor> | undefined;
try {
const readyBy = Date.now() + 10_000;
while (!existsSync(marker) && Date.now() < readyBy) await Bun.sleep(20);
expect({ ready: existsSync(marker), reply: owner.envelope() }).toMatchObject({ ready: true });
const ready = await waitForNativeOwnerMarker(marker, owner.done);
expect({ ready, reply: owner.envelope(), launch: safeLaunchEvidence(ownerObservation), ownerExitCode: owner.child.exitCode }).toMatchObject({ ready: true });
const { pid } = JSON.parse(readFileSync(marker, 'utf8'));
const ownerLaunch = JSON.parse(readFileSync(ownerObservation, 'utf8'));
expect({ command: ownerLaunch.command, pid: ownerLaunch.pid, pipe: ownerLaunch.args?.includes('--remote-debugging-pipe') }).toEqual({ command: edge, pid, pipe: true });
expect(alive(pid)).toBe(true);
contender = nativeSupervisor({ ...input, playwrightEntry: contenderEntry }, env);
expect({ result: await contender.done, launch: safeLaunchEvidence(contenderObservation) }).toMatchObject({ result: { error: 'browser_running' } });
expect({ result: await contender.done, launch: safeLaunchEvidence(contenderObservation) }).toMatchObject({ result: { error: 'browser_running' }, launch: { spawned: true, pipe: true } });
expect(alive(pid)).toBe(true);
} finally {
contender?.child.kill();
@@ -1109,7 +1166,7 @@ describe('native Windows process qualification', () => {
await contender?.done.catch(() => {});
await owner.done.catch(() => {});
}
}, 35_000);
}, 65_000);
for (const mode of ['normal-close', 'stalled-close']) {
test.skipIf(process.platform !== 'win32')(`real Edge synthetic profile: ${mode} returns only after the owned browser exits`, async () => {
+9 -1
View File
@@ -3,11 +3,13 @@ const cp = require('node:child_process');
const { createHash } = require('node:crypto');
const path = require('node:path');
module.exports = ({ observation, playwrightEntry, mode = 'normal-close', inspectCommandLine = false, observerExecutable, seedCookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' } }) => {
module.exports = ({ observation, playwrightEntry, mode = 'normal-close', marker, inspectCommandLine = false, observerExecutable, seedCookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' } }) => {
if (inspectCommandLine && process.platform === 'win32' && typeof observerExecutable !== 'string') throw new Error('Native observer executable is required');
if (mode === 'held-owner' && typeof marker !== 'string') throw new Error('Native owner marker is required');
const originalSpawn = cp.spawn;
let inspected = Promise.resolve();
let folderEvidence;
let browserPid;
const directoryState = (env, root) => ({
requestedProfile: fs.existsSync(root),
localEnvironment: fs.existsSync(env.LOCALAPPDATA || ''),
@@ -48,6 +50,7 @@ module.exports = ({ observation, playwrightEntry, mode = 'normal-close', inspect
cp.spawn = function(command, args, options) {
if (args.some(arg => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))) throw new Error('Native fixture refuses a sandbox-disabled browser');
const child = originalSpawn.call(this, command, args, options);
browserPid = child.pid;
const evidence = {
command, args, pid: child.pid,
argsHash: createHash('sha256').update(JSON.stringify(args)).digest('hex'),
@@ -124,6 +127,11 @@ module.exports = ({ observation, playwrightEntry, mode = 'normal-close', inspect
const context = await chromium.launchPersistentContext(root, inspectCommandLine && process.platform === 'win32'
? { ...options, timeout: Math.max(1, options.timeout - (Date.now() - started)) } : options);
await inspected;
if (mode === 'held-owner') {
fs.writeFileSync(marker, JSON.stringify({ pid: browserPid }), { flag: 'wx' });
context.cookies = () => new Promise(() => {});
return context;
}
await context.addCookies([seedCookie]);
if (mode === 'stalled-close') context.close = () => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); };
return context;
+91
View File
@@ -0,0 +1,91 @@
import * as fs from 'fs';
import * as path from 'path';
import { chromium } from 'playwright';
import { BrowserManager } from '../../src/browser-manager';
import { TabSession } from '../../src/tab-session';
import { handleWriteCommand } from '../../src/write-commands';
import { SAFE_DIRECTORIES } from '../../src/path-security';
import { isPathWithin } from '../../src/platform';
const project = fs.realpathSync(process.cwd());
const root = path.dirname(project);
const outside = path.join(root, 'private', 'outside.txt');
const temp = path.join(root, 'tmp', 'temp.txt');
if (SAFE_DIRECTORIES.some(dir => isPathWithin(outside, dir))) {
throw new Error('Upload fixture outside target is inside the allowed directories');
}
for (const dir of [process.env.HOME!, process.env.GSTACK_HOME!, path.dirname(temp)]) {
if (!isPathWithin(fs.realpathSync(dir), root)) throw new Error('Upload fixture state escaped its private root');
}
fs.mkdirSync('nested');
fs.writeFileSync('allowed.txt', 'synthetic allowed bytes');
fs.writeFileSync('nested/allowed.txt', 'synthetic allowed bytes');
fs.writeFileSync(outside, 'synthetic outside bytes');
fs.writeFileSync(temp, 'synthetic temp bytes');
fs.symlinkSync(outside, 'linked.txt');
fs.symlinkSync(path.join(root, 'private'), 'outside-dir', 'dir');
fs.symlinkSync(path.join(project, 'allowed.txt'), 'safe-link.txt');
fs.symlinkSync(path.join(project, 'nested'), 'safe-dir', 'dir');
fs.symlinkSync(path.join(root, 'private', 'missing.txt'), 'broken.txt');
const scenarios: Record<string, { paths: string[]; directory?: boolean }> = {
'relative-file-link': { paths: ['linked.txt'] },
'absolute-file-link': { paths: [path.join(project, 'linked.txt')] },
'absolute-outside': { paths: [outside] },
'relative-traversal': { paths: [path.relative(project, outside)] },
'relative-directory-link': { paths: ['outside-dir/outside.txt'] },
'absolute-directory-link': { paths: [path.join(project, 'outside-dir', 'outside.txt')] },
'outside-directory-upload': { paths: ['outside-dir'], directory: true },
'mixed-valid-first': { paths: ['allowed.txt', 'linked.txt'] },
'mixed-invalid-first': { paths: ['linked.txt', 'allowed.txt'] },
'mixed-outside-absolute': { paths: ['allowed.txt', outside] },
'broken-link': { paths: ['broken.txt'] },
'missing-file': { paths: ['missing.txt'] },
'mixed-missing-file': { paths: ['allowed.txt', 'missing.txt'] },
'relative-allowed': { paths: ['allowed.txt'] },
'absolute-allowed': { paths: [path.join(project, 'allowed.txt')] },
'safe-file-link': { paths: ['safe-link.txt'] },
'safe-directory-link': { paths: ['safe-dir/allowed.txt'] },
'safe-directory-upload': { paths: ['safe-dir'], directory: true },
'multiple-allowed': { paths: ['allowed.txt', temp] },
};
const browser = await chromium.launch({ executablePath: process.argv[2], headless: true });
try {
const page = await browser.newPage();
page.setDefaultTimeout(5_000);
const session = new TabSession(page);
const bm = new BrowserManager();
const observations: Record<string, unknown> = {};
for (const selector of ['css', 'ref']) {
for (const [name, { paths, directory }] of Object.entries(scenarios)) {
await page.setContent(`<input id="upload" type="file" multiple ${directory ? 'webkitdirectory' : ''}>`);
await page.evaluate(() => {
document.body.dataset.inputEvents = '0';
document.querySelector('input')!.addEventListener('input', () => {
document.body.dataset.inputEvents = String(Number(document.body.dataset.inputEvents) + 1);
});
});
session.setRefMap(new Map([['e1', { locator: page.locator('#upload'), role: 'input', name: 'upload' }]]));
let error: string | null = null;
let result: string | null = null;
try {
result = await handleWriteCommand('upload', [selector === 'ref' ? '@e1' : '#upload', ...paths], session, bm);
} catch (err) {
error = (err as Error).message;
}
const delivered = await page.evaluate(async () => ({
files: await Promise.all(Array.from(document.querySelector('input')!.files!).map(async file => ({
name: file.name,
text: await file.text(),
}))),
inputEvents: Number(document.body.dataset.inputEvents),
}));
observations[`${selector}:${name}`] = { error, result, ...delivered };
}
}
console.log(JSON.stringify(observations));
} finally {
await browser.close();
}
+236 -1
View File
@@ -8,11 +8,12 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { afterAll, beforeAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager, type BrowserState } from '../src/browser-manager';
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
import { handleMetaCommand } from '../src/meta-commands';
import { spawnXvfb, pickFreeDisplay, isOurXvfb, type XvfbHandle } from '../src/xvfb';
// Per-FILE Chromium profile: this file launches an in-process persistent
// context (BrowserManager.launch()), and sharing a profile dir with the
@@ -25,6 +26,8 @@ beforeAll(() => {
CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-'));
process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR;
});
afterAll(() => {
if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE;
@@ -206,6 +209,59 @@ describe('handoff edge cases', () => {
const HEADED_BROKEN_ON_DARWIN = process.platform === 'darwin';
describe('handoff integration', () => {
test.skipIf(process.platform !== 'linux')('restore failure after candidate assignment leaves the original manager and pages usable', async () => {
const displayNum = pickFreeDisplay();
expect(displayNum).not.toBeNull();
const display = await spawnXvfb(displayNum!);
const originalDisplay = process.env.DISPLAY;
process.env.DISPLAY = display.display;
const hbm = new BrowserManager();
let originalBrowser: any;
try {
await hbm.launch();
originalBrowser = (hbm as any).browser;
const originalContext = (hbm as any).context;
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
await hbm.newTab(baseUrl + '/form.html', 'owner-control');
const oldPage = hbm.getPage();
const oldSession = hbm.getActiveSession();
const oldTabs = (hbm as any).pages;
const oldOwnership = new Map((hbm as any).tabOwnership);
const oldNextId = (hbm as any).nextTabId;
let promoted = 0;
hbm.onHeadedPromotion = () => { promoted++; };
const restore = hbm.restoreState.bind(hbm);
hbm.restoreState = async (state) => {
await restore(state);
expect((hbm as any).context).not.toBe(originalContext);
expect(hbm.getPage()).not.toBe(oldPage);
throw new Error('injected after actual restore');
};
const result = await hbm.handoff('rollback control');
expect(result).toContain('injected after actual restore');
expect((hbm as any).context).toBe(originalContext);
expect(hbm.getPage()).toBe(oldPage);
expect(hbm.getActiveSession()).toBe(oldSession);
expect((hbm as any).pages).toBe(oldTabs);
expect((hbm as any).tabOwnership).toEqual(oldOwnership);
expect((hbm as any).nextTabId).toBe(oldNextId);
expect(hbm.getConnectionMode()).toBe('launched');
expect(hbm.getIsHeaded()).toBe(false);
expect(promoted).toBe(0);
expect(await hbm.isHealthy()).toBe(true);
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
expect(hbm.getPage().url()).toBe(baseUrl + '/basic.html');
expect(isOurXvfb(display.pid, display.startTime)).toBe(true);
} finally {
await hbm.close();
await originalBrowser?.close().catch(() => {});
expect(isOurXvfb(display.pid, display.startTime)).toBe(true);
display.close();
if (originalDisplay === undefined) delete process.env.DISPLAY;
else process.env.DISPLAY = originalDisplay;
}
}, 30000);
test.skipIf(HEADED_BROKEN_ON_DARWIN)('full handoff: cookies preserved, headed mode active, commands work', async () => {
const hbm = new BrowserManager();
await hbm.launch();
@@ -269,3 +325,182 @@ describe('handoff integration', () => {
}
}, 45000);
});
describe.skipIf(process.platform !== 'linux')('lazy owned display lifecycle', () => {
let savedEnv: NodeJS.ProcessEnv;
let root: string;
let hbm: BrowserManager;
const displays = () => {
const result = Bun.spawnSync(['ps', '--ppid', String(process.pid), '-o', 'comm='], {
stdout: 'pipe', stderr: 'pipe', timeout: 2000,
});
return result.stdout.toString().split('\n').filter(line => line.trim() === 'Xvfb').length;
};
beforeEach(async () => {
savedEnv = { ...process.env };
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-handoff-display-'));
delete process.env.DISPLAY;
delete process.env.WAYLAND_DISPLAY;
delete process.env.BROWSE_HEADED;
process.env.CHROMIUM_PROFILE = path.join(root, 'profile');
hbm = new BrowserManager();
await hbm.launch();
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
});
afterEach(async () => {
await hbm?.close();
for (const key of ['DISPLAY', 'WAYLAND_DISPLAY', 'BROWSE_HEADED', 'CHROMIUM_PROFILE', 'PATH']) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
fs.rmSync(root, { recursive: true, force: true });
}, 15000);
test('ordinary headless commands allocate no display', async () => {
await hbm.newTab(baseUrl + '/form.html');
expect(hbm.getXvfbHandle()).toBeNull();
expect(displays()).toBe(0);
expect(await hbm.isHealthy()).toBe(true);
}, 15000);
test('shutdown cleans a display that finishes allocation after teardown starts', async () => {
const allocation = hbm.ensureHeadedDisplay();
const outcome = allocation.then(() => 'resolved', error => String(error));
await hbm.close();
expect(await outcome).toContain('Browser is shutting down');
expect(hbm.getXvfbHandle()).toBeNull();
expect(displays()).toBe(0);
}, 15000);
test('concurrent promotion owns one display, preserves commands, and cleans it on shutdown', async () => {
expect(displays()).toBe(0);
const results = await Promise.all([hbm.handoff('one promotion'), hbm.handoff('same promotion')]);
expect(results[0]).toBe(results[1]);
expect(results[0]).toContain('Off-screen Xvfb');
expect(results[0]).toContain('separate remote desktop');
const handle = hbm.getXvfbHandle()!;
expect(handle).not.toBeNull();
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(true);
expect(displays()).toBe(1);
expect(process.env.DISPLAY).toBeUndefined();
await hbm.newTab(baseUrl + '/form.html');
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
expect(hbm.getPage().url()).toBe(baseUrl + '/basic.html');
expect(await hbm.getPage().evaluate(() => typeof (window as any).chrome?.runtime?.sendMessage)).toBe('undefined');
expect(await handleMetaCommand('resume', [], hbm, () => {})).toContain('RESUMED');
expect(await hbm.handoff('again')).toContain('Already in headed mode');
expect(displays()).toBe(1);
await hbm.close();
expect(hbm.getXvfbHandle()).toBeNull();
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(false);
}, 30000);
for (const phase of ['before launch', 'after restore'] as const) {
test(`failure ${phase} rolls back and releases only the allocated display`, async () => {
const oldPage = hbm.getPage();
const oldContext = (hbm as any).context;
const oldSession = hbm.getActiveSession();
let handle: XvfbHandle | null = null;
const ensure = hbm.ensureHeadedDisplay.bind(hbm);
hbm.ensureHeadedDisplay = async () => { await ensure(); handle = hbm.getXvfbHandle(); };
if (phase === 'before launch') {
process.env.CHROMIUM_PROFILE = path.join(root, 'not-a-directory');
fs.writeFileSync(process.env.CHROMIUM_PROFILE, 'fixture');
} else {
const restore = hbm.restoreState.bind(hbm);
hbm.restoreState = async (state) => {
await restore(state);
expect((hbm as any).context).not.toBe(oldContext);
throw new Error('injected after restore');
};
}
let promotions = 0;
hbm.onHeadedPromotion = () => { promotions++; };
const result = await hbm.handoff('failure control');
expect(result).toStartWith('ERROR:');
expect(handle).not.toBeNull();
expect(isOurXvfb(handle!.pid, handle!.startTime)).toBe(false);
expect(hbm.getXvfbHandle()).toBeNull();
expect(hbm.getPage()).toBe(oldPage);
expect(hbm.getActiveSession()).toBe(oldSession);
expect((hbm as any).context).toBe(oldContext);
expect(hbm.getConnectionMode()).toBe('launched');
expect(hbm.getIsHeaded()).toBe(false);
expect(promotions).toBe(0);
expect(await hbm.isHealthy()).toBe(true);
await handleWriteCommand('goto', [baseUrl + '/form.html'], hbm);
expect(hbm.getPage().url()).toBe(baseUrl + '/form.html');
}, 30000);
}
for (const phase of ['capture', 'restore'] as const) {
test(`shutdown cancels stalled ${phase} without a late promotion`, async () => {
const entered = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const originalBrowser = (hbm as any).browser;
const oldPage = hbm.getPage();
(hbm as any).closeRaceMs = 100;
let handle: XvfbHandle | null = null;
let promotions = 0;
hbm.onHeadedPromotion = () => { promotions++; };
if (phase === 'capture') {
const save = hbm.saveState.bind(hbm);
hbm.saveState = async () => {
const state = await save();
entered.resolve();
await release.promise;
return state;
};
} else {
const restore = hbm.restoreState.bind(hbm);
hbm.restoreState = async (state) => {
await restore(state);
handle = hbm.getXvfbHandle();
entered.resolve();
await release.promise;
};
}
const promotion = hbm.handoff('stalled renderer');
await entered.promise;
const closing = hbm.close();
try {
expect(await Promise.race([closing.then(() => true), Bun.sleep(2000).then(() => false)])).toBe(true);
expect(oldPage.isClosed()).toBe(true);
if (handle) expect(isOurXvfb(handle.pid, handle.startTime)).toBe(false);
} finally {
release.resolve();
await promotion.catch(() => {});
await closing;
await originalBrowser.close().catch(() => {});
}
expect(promotions).toBe(0);
expect((hbm as any).browser).toBeNull();
expect(hbm.getXvfbHandle()).toBeNull();
}, 30000);
}
test('rollback retains original tab close and navigation events during candidate restore', async () => {
const remainingPage = hbm.getPage();
const remainingSession = hbm.getActiveSession();
remainingSession.setRefMap(new Map([['e1', { locator: remainingPage.locator('body'), role: 'document', name: '' }]]));
await hbm.newTab(baseUrl + '/form.html');
const closingPage = hbm.getPage();
const restore = hbm.restoreState.bind(hbm);
hbm.restoreState = async (state) => {
await restore(state);
await closingPage.close();
await remainingPage.goto(baseUrl + '/form.html');
throw new Error('rollback after original tab events');
};
expect(await hbm.handoff('event rollback')).toContain('rollback after original tab events');
expect(hbm.getTabCount()).toBe(1);
expect(hbm.getPage()).toBe(remainingPage);
expect(hbm.getActiveSession()).toBe(remainingSession);
expect(hbm.getRefCount()).toBe(0);
expect(await hbm.isHealthy()).toBe(true);
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
}, 30000);
});
+91 -17
View File
@@ -1,9 +1,10 @@
import { describe, it, expect } from 'bun:test';
import { beforeAll, describe, it, expect } from 'bun:test';
import { chromium } from 'playwright';
import { validateOutputPath } from '../src/meta-commands';
import { validateReadPath, SENSITIVE_COOKIE_NAME, SENSITIVE_COOKIE_VALUE } from '../src/read-commands';
import { BLOCKED_METADATA_HOSTS } from '../src/url-validation';
import { readFileSync, symlinkSync, unlinkSync, writeFileSync, realpathSync } from 'fs';
import { tmpdir } from 'os';
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync, realpathSync } from 'fs';
import { tmpdir, userInfo } from 'os';
import { join } from 'path';
describe('validateOutputPath', () => {
@@ -37,23 +38,96 @@ describe('validateOutputPath', () => {
});
describe('upload command path validation', () => {
const src = readFileSync(join(__dirname, '..', 'src', 'write-commands.ts'), 'utf-8');
let observations: Record<string, {
error: string | null;
result: string | null;
files: { name: string; text: string }[];
inputEvents: number;
}>;
it('validates upload paths with isPathWithin', () => {
const uploadBlock = src.slice(src.indexOf("case 'upload'"), src.indexOf("case 'dialog-accept'"));
expect(uploadBlock).toContain('isPathWithin');
});
beforeAll(() => {
const root = mkdtempSync(join(userInfo().homedir, 'gstack-upload-paths-'));
try {
for (const dir of ['home', 'state', 'project', 'private', 'tmp']) {
mkdirSync(join(root, dir), { mode: 0o700 });
}
const probe = Bun.spawnSync([
process.execPath, join(import.meta.dir, 'fixtures', 'upload-path-validation.ts'), chromium.executablePath(),
], {
cwd: join(root, 'project'),
env: {
...process.env,
HOME: join(root, 'home'),
USERPROFILE: join(root, 'home'),
GSTACK_HOME: join(root, 'state'),
CLAUDE_PLUGIN_DATA: '',
XDG_CONFIG_HOME: join(root, 'home', '.config'),
XDG_CACHE_HOME: join(root, 'home', '.cache'),
CHROMIUM_PROFILE: join(root, 'profile'),
TMPDIR: join(root, 'tmp'),
TMP: join(root, 'tmp'),
TEMP: join(root, 'tmp'),
},
timeout: 60_000,
});
expect(probe.exitCode, probe.stderr.toString()).toBe(0);
observations = JSON.parse(probe.stdout.toString());
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 70_000);
it('blocks path traversal in upload', () => {
const uploadBlock = src.slice(src.indexOf("case 'upload'"), src.indexOf("case 'dialog-accept'"));
expect(uploadBlock).toContain("'..'");
});
for (const selector of ['css', 'ref']) {
for (const scenario of [
'relative-file-link', 'absolute-file-link', 'absolute-outside', 'relative-traversal',
'relative-directory-link', 'absolute-directory-link', 'outside-directory-upload',
'mixed-valid-first', 'mixed-invalid-first', 'mixed-outside-absolute',
]) {
it(`${selector}: rejects ${scenario} before delivering any file`, () => {
const actual = observations[`${selector}:${scenario}`];
expect(actual, JSON.stringify(actual)).toEqual({
error: expect.stringMatching(/Path must be within|Path traversal/),
result: null,
files: [],
inputEvents: 0,
});
});
}
it('checks absolute paths against safe directories', () => {
const uploadBlock = src.slice(src.indexOf("case 'upload'"), src.indexOf("case 'dialog-accept'"));
expect(uploadBlock).toContain('path.isAbsolute');
expect(uploadBlock).toContain('SAFE_DIRECTORIES');
});
for (const scenario of ['broken-link', 'missing-file', 'mixed-missing-file']) {
it(`${selector}: rejects ${scenario} before delivering any file`, () => {
expect(observations[`${selector}:${scenario}`]).toEqual({
error: expect.stringContaining('File not found'),
result: null,
files: [],
inputEvents: 0,
});
});
}
for (const scenario of ['relative-allowed', 'absolute-allowed', 'safe-file-link', 'safe-directory-link', 'safe-directory-upload']) {
it(`${selector}: uploads checked target bytes for ${scenario}`, () => {
expect(observations[`${selector}:${scenario}`]).toEqual({
error: null,
result: expect.stringContaining('Uploaded:'),
files: [{ name: 'allowed.txt', text: 'synthetic allowed bytes' }],
inputEvents: 1,
});
});
}
it(`${selector}: preserves allowed temp and multi-file uploads`, () => {
expect(observations[`${selector}:multiple-allowed`]).toEqual({
error: null,
result: expect.stringContaining('Uploaded:'),
files: [
{ name: 'allowed.txt', text: 'synthetic allowed bytes' },
{ name: 'temp.txt', text: 'synthetic temp bytes' },
],
inputEvents: 1,
});
});
}
});
describe('validateReadPath', () => {
+40
View File
@@ -273,6 +273,46 @@ describe('buildFetchHandler factory contract', () => {
expect(fs.readFileSync(globalState, 'utf8')).toBe('unrelated daemon state');
});
test('headed promotion persists the factory instance state and preserves global state', () => {
const globalState = path.join(fixtureDir, 'promotion-global/browse.json');
const instanceState = path.join(fixtureDir, 'promotion-instance/browse.json');
fs.mkdirSync(path.dirname(globalState), { recursive: true });
fs.mkdirSync(path.dirname(instanceState), { recursive: true });
const script = `
import fs from 'node:fs';
import { buildFetchHandler, resolveConfigFromEnv, __testInternals__ } from ${JSON.stringify(path.resolve(__dirname, '../src/server.ts'))};
import { resolveConfig } from ${JSON.stringify(path.resolve(__dirname, '../src/config.ts'))};
const original = { pid: process.pid, instanceId: __testInternals__.serverInstanceId,
mode: 'launched', chromiumPid: 471, chromiumStartTime: 'old-start' };
fs.writeFileSync(${JSON.stringify(globalState)}, JSON.stringify(original));
fs.writeFileSync(${JSON.stringify(instanceState)}, JSON.stringify(original));
const manager = {
getConnectionMode: () => 'headed', isWatching: () => false,
getXvfbHandle: () => ({ pid: 8123, startTime: 'new-start', display: ':110' }),
onDisconnect: null,
};
buildFetchHandler({
...resolveConfigFromEnv(), browsePort: 34567,
config: resolveConfig({ BROWSE_STATE_FILE: ${JSON.stringify(instanceState)} }),
browserManager: manager, ownsTerminalAgent: false, startTime: Date.now(),
});
manager.onHeadedPromotion();
process.exit(0);
`;
const result = Bun.spawnSync([process.execPath, '--eval', script], {
env: { ...process.env, BROWSE_STATE_FILE: globalState },
stdout: 'pipe', stderr: 'pipe', timeout: 5000,
});
expect(result.exitCode, result.stderr.toString()).toBe(0);
expect(JSON.parse(fs.readFileSync(instanceState, 'utf8'))).toMatchObject({
mode: 'headed', xvfbPid: 8123, xvfbStartTime: 'new-start', xvfbDisplay: ':110',
});
expect(JSON.parse(fs.readFileSync(instanceState, 'utf8')).chromiumPid).toBeUndefined();
expect(JSON.parse(fs.readFileSync(globalState, 'utf8'))).toMatchObject({
mode: 'launched', chromiumPid: 471, chromiumStartTime: 'old-start',
});
});
test('2a. cfg.authToken authenticates /health (positive — bearer accepted)', async () => {
const cfg = makeMinimalConfig();
const handle = buildFetchHandler(cfg);
+2 -2
View File
@@ -25,7 +25,7 @@
* This source-level guard locks two invariants:
* 1. No remaining `stateFile + '.tmp'` literals in server.ts (regression
* catch — a future copy-paste or revert would re-introduce the bug)
* 2. The 4 known state-write call sites all use `tmpStatePath()`
* 2. The 5 known state-write call sites all use `tmpStatePath()`
* (positive coverage)
*
* Same pattern as terminal-agent.test.ts and dual-listener.test.ts:
@@ -92,7 +92,7 @@ describe('server.ts — state-file temp-path uniqueness', () => {
// Lock the suffix shape so a future contributor doesn't accidentally
// strip the uniqueness back out by simplifying the helper.
const declMatch = SERVER_TS.match(
/function tmpStatePath\(\)[^{]*\{([\s\S]*?)\n\}/,
/function tmpStatePath\(stateFile: string = config\.stateFile\)[^{]*\{([\s\S]*?)\n\}/,
);
expect(declMatch, 'tmpStatePath() declaration not found').not.toBeNull();
const body = declMatch![1]!;
+52 -3
View File
@@ -23,6 +23,8 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { EventEmitter } from 'node:events';
import { BrowserManager } from '../src/browser-manager';
const ROOT = path.resolve(__dirname, '..');
@@ -81,9 +83,56 @@ describe('browser→sidebar tab sync', () => {
});
test('page close handler removes tab from pages map', () => {
expect(bmSrc).toContain("page.on('close'");
expect(bmSrc).toContain('this.pages.delete(id)');
expect(bmSrc).toContain('Tab closed');
const manager = new BrowserManager() as any;
const closed = new EventEmitter();
const remaining = new EventEmitter();
manager.pages = new Map([[1, closed], [2, remaining]]);
manager.tabSessions = new Map([[1, { page: closed }], [2, { page: remaining }]]);
manager.activeTabId = 1;
manager.wirePageEvents(closed);
closed.emit('close');
expect(manager.pages.has(1)).toBe(false);
expect(manager.tabSessions.has(1)).toBe(false);
expect(manager.pages.get(2)).toBe(remaining);
expect(manager.tabSessions.get(2).page).toBe(remaining);
expect(manager.activeTabId).toBe(2);
});
test('old page close during handoff preserves the replacement browser tabs', () => {
const manager = new BrowserManager() as any;
const closed = new EventEmitter();
const remaining = new EventEmitter();
const replacement = new EventEmitter();
manager.pages = new Map([[1, closed], [2, remaining]]);
manager.tabSessions = new Map([[1, { page: closed }], [2, { page: remaining }]]);
manager.activeTabId = 1;
manager.wirePageEvents(closed);
manager.wirePageEvents(remaining);
const previous = { pages: manager.pages, tabSessions: manager.tabSessions, activeTabId: 1 };
manager.handoffPrevious = previous;
manager.pages = new Map([[1, replacement]]);
manager.tabSessions = new Map([[1, { page: replacement }]]);
closed.emit('close');
expect(previous.pages.has(1)).toBe(false);
expect(previous.tabSessions.has(1)).toBe(false);
expect(previous.pages.get(2)).toBe(remaining);
expect(previous.activeTabId).toBe(2);
expect(manager.pages.get(1)).toBe(replacement);
expect(manager.tabSessions.get(1).page).toBe(replacement);
expect(manager.activeTabId).toBe(1);
manager.handoffPrevious = null;
remaining.emit('close');
expect(previous.pages.size).toBe(0);
expect(previous.tabSessions.size).toBe(0);
expect(manager.pages.get(1)).toBe(replacement);
expect(manager.tabSessions.get(1).page).toBe(replacement);
expect(manager.activeTabId).toBe(1);
});
test('syncActiveTabByUrl skips when only 1 tab (no ambiguity)', () => {
+8 -7
View File
@@ -62,9 +62,8 @@ describe('buildStealthScript — T3 Layer C', () => {
expect(s).toContain('PlatformArch');
expect(s).toContain('PlatformOs');
expect(s).toContain('RequestUpdateCheckStatus');
// sendMessage / connect must throw native-shaped errors
expect(s).toContain('runtime.connect');
expect(s).toContain('runtime.sendMessage');
expect(s).not.toContain('function connect()');
expect(s).not.toContain('function sendMessage()');
});
test('chrome.csi and chrome.loadTimes provide method bodies', () => {
@@ -105,10 +104,12 @@ describe('buildStealthScript — T3 Layer C', () => {
const s = buildStealthScript(hw);
// Every getter (hardwareConcurrency, deviceMemory, webdriver, Notification.permission)
// should be wrapped through markNative so the toString Proxy covers it.
const markNativeMatches = s.match(/markNative\(/g) || [];
// At least 8 markNative wrappings (webdriver, csi, loadTimes, connect, sendMessage,
// notification permission, hwConcurrency, deviceMemory)
expect(markNativeMatches.length).toBeGreaterThanOrEqual(7);
for (const declaration of [
'const webdriverGetter', 'chrome.csi', 'chrome.loadTimes',
'const notificationPermissionGetter', 'const hwConcurrencyGetter', 'const deviceMemoryGetter',
]) {
expect(s).toContain(`${declaration} = markNative(`);
}
});
test('script does not include "GStackBrowser" branding string', () => {
+78 -9
View File
@@ -1,6 +1,9 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium, type Browser, type BrowserContext } from 'playwright';
import { applyStealth, STEALTH_LAUNCH_ARGS } from '../src/stealth';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { applyStealth, buildStealthScript, readHostProfile, STEALTH_LAUNCH_ARGS } from '../src/stealth';
let browser: Browser;
@@ -170,31 +173,97 @@ describe('applyStealth — context level', () => {
}
});
test('chrome.csi() and chrome.loadTimes() execute, runtime.connect() throws native-shaped', async () => {
// Presence (typeof === 'function') is not enough — a real detector calls
// them. loadTimes() dereferences performance.timing; connect() must throw
// the native "No matching signature" TypeError.
test('chrome.csi() and chrome.loadTimes() execute without inventing runtime messaging', async () => {
const page = await context.newPage();
try {
const r = await page.evaluate(() => {
const c = (window as any).chrome;
let connectErr = '';
try { c.runtime.connect(); } catch (e) { connectErr = String(e); }
return {
csiOk: typeof c.csi().onloadT === 'number',
loadTimesOk: typeof c.loadTimes().wasFetchedViaSpdy === 'boolean',
connectErr,
connect: typeof c.runtime.connect,
sendMessage: typeof c.runtime.sendMessage,
};
});
expect(r.csiOk).toBe(true);
expect(r.loadTimesOk).toBe(true);
expect(r.connectErr).toContain('No matching signature');
expect(r.connect).toBe('undefined');
expect(r.sendMessage).toBe('undefined');
} finally {
await page.close();
}
});
});
describe('extension messaging compatibility', () => {
test('plain Chromium and default stealth both select the ordinary web flow without an extension', async () => {
const plainBrowser = await chromium.launch({ headless: true });
try {
for (const stealth of [false, true]) {
const ctx = await plainBrowser.newContext();
try {
if (stealth) await applyStealth(ctx);
const page = await ctx.newPage();
await page.goto('data:text/html,<title>No extension</title>');
const result = await page.evaluate(() => {
const runtime = (window as any).chrome?.runtime;
return {
connect: typeof runtime?.connect,
sendMessage: typeof runtime?.sendMessage,
flow: typeof runtime?.sendMessage === 'function' ? 'companion-extension' : 'ordinary-web',
};
});
expect(result).toEqual({ connect: 'undefined', sendMessage: 'undefined', flow: 'ordinary-web' });
} finally {
await ctx.close();
}
}
} finally {
await plainBrowser.close();
}
}, 30000);
test('preserves native runtime methods and messages a genuinely installed extension', async () => {
const root = mkdtempSync(join(tmpdir(), 'gstack-stealth-extension-'));
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('<title>Extension control</title>', { headers: { 'Content-Type': 'text/html' } }) });
writeFileSync(join(root, 'manifest.json'), JSON.stringify({
manifest_version: 3, name: 'Stealth runtime control', version: '1.0',
background: { service_worker: 'worker.js' },
externally_connectable: { matches: ['http://127.0.0.1/*'] },
}));
writeFileSync(join(root, 'worker.js'), "chrome.runtime.onMessageExternal.addListener((message, sender, reply) => reply({ received: message.probe }));");
let ctx: BrowserContext | undefined;
try {
ctx = await chromium.launchPersistentContext(join(root, 'profile'), {
headless: true, channel: 'chromium',
args: [`--disable-extensions-except=${root}`, `--load-extension=${root}`],
});
await applyStealth(ctx);
const worker = ctx.serviceWorkers()[0] || await ctx.waitForEvent('serviceworker');
const extensionId = new URL(worker.url()).host;
const page = await ctx.newPage();
await page.goto(`http://127.0.0.1:${server.port}`);
const result = await page.evaluate(async ({ script, extensionId }) => {
const runtime = (window as any).chrome.runtime;
const connect = runtime.connect;
const sendMessage = runtime.sendMessage;
(0, eval)(script);
return {
sameRuntime: runtime === (window as any).chrome.runtime,
sameConnect: connect === runtime.connect,
sameSendMessage: sendMessage === runtime.sendMessage,
reply: await runtime.sendMessage(extensionId, { probe: 'native-runtime' }),
};
}, { script: buildStealthScript(readHostProfile()), extensionId });
expect(result).toEqual({ sameRuntime: true, sameConnect: true, sameSendMessage: true, reply: { received: 'native-runtime' } });
} finally {
await ctx?.close();
server.stop(true);
rmSync(root, { recursive: true, force: true });
}
}, 30000);
});
describe('applyStealth — per-install hardware from env', () => {
let ctx: BrowserContext;
let savedHw: string | undefined;
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, expect, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const roots: string[] = [];
const agent = new URL('../src/terminal-agent.ts', import.meta.url).href;
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
test('importing terminal-agent helpers does not boot the CLI or install process handlers', () => {
const root = mkdtempSync(join(tmpdir(), 'terminal-agent-import-'));
roots.push(root);
const result = Bun.spawnSync([process.execPath, '-e', `
const errors = process.listenerCount('uncaughtException');
const rejections = process.listenerCount('unhandledRejection');
await import(${JSON.stringify(agent)});
await Bun.sleep(2300);
if (process.listenerCount('uncaughtException') !== errors || process.listenerCount('unhandledRejection') !== rejections) process.exit(2);
console.log('imported without boot');
`], {
env: { ...process.env, HOME: root, BROWSE_STATE_FILE: join(root, 'browse.json'), BROWSE_AGENT_GEN: 'missing-record' },
timeout: 6000,
});
expect(result.exitCode).toBe(0);
expect(new TextDecoder().decode(result.stdout)).toContain('imported without boot');
expect(new TextDecoder().decode(result.stderr)).not.toContain('[terminal-agent]');
}, 8000);
test('direct terminal-agent execution still refuses an unconfirmed startup record', () => {
const root = mkdtempSync(join(tmpdir(), 'terminal-agent-direct-'));
roots.push(root);
const result = Bun.spawnSync([process.execPath, fileURLToPath(new URL('../src/terminal-agent.ts', import.meta.url))], {
env: { ...process.env, HOME: root, BROWSE_STATE_FILE: join(root, 'browse.json'), BROWSE_AGENT_GEN: 'missing-record' },
timeout: 6000,
});
expect(result.exitCode).toBe(1);
expect(new TextDecoder().decode(result.stderr)).toContain('terminal-agent startup record was not confirmed');
}, 8000);
+4 -1
View File
@@ -350,7 +350,10 @@ describe('terminal-agent owned lifecycle regression', () => {
expect(old.ownerPid).toBe(daemon.pid);
expect(isOurAgent(old, daemon.pid)).toBe(true);
expect(killAgentByRecord(old, 'SIGKILL')).toBe(true);
expect(await waitFor(() => !!readAgentRecord(stateDir) && readAgentRecord(stateDir)!.gen !== old.gen, 5000)).toBe(true);
expect(await waitFor(() => {
const record = readAgentRecord(stateDir);
return !!record && record.gen !== old.gen;
}, 5000)).toBe(true);
const successor = { ...JSON.parse(fs.readFileSync(stateFile, 'utf8')), pid: process.pid, instanceId: 'synthetic-successor' };
fs.writeFileSync(stateFile, JSON.stringify(successor));
expect(await waitFor(() => daemon.exitCode !== null, 5000)).toBe(true);
@@ -0,0 +1,289 @@
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { acquireAgentStateLock, agentRecordPath, readAgentStartTime, writeAgentRecord, type AgentRecord } from '../src/terminal-agent-control';
const roots: string[] = [];
const children: ReturnType<typeof Bun.spawn>[] = [];
const directory = () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'publication-lock-'));
roots.push(root);
return root;
};
const lockPath = (root: string) => path.join(root, 'terminal-agent-pid.lock');
const metadata = (record: AgentRecord) => ({ kind: 'agent-publication-v1', pid: record.pid, gen: record.gen,
startTime: record.startTime, ownerPid: record.ownerPid, ownerStartTime: record.ownerStartTime });
async function fixture(dead = true) {
const root = directory();
const child = Bun.spawn([process.execPath, '-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'ignore', 'ignore'] });
children.push(child);
const record: AgentRecord = { pid: child.pid, gen: 'publication-test-generation', startedAt: Date.now(),
startTime: readAgentStartTime(child.pid), ownerPid: process.pid, ownerStartTime: readAgentStartTime(process.pid) };
expect(record.startTime).not.toBe('');
expect(record.ownerStartTime).not.toBe('');
writeAgentRecord(root, record);
fs.writeFileSync(lockPath(root), JSON.stringify(metadata(record)), { mode: 0o600 });
if (dead) { child.kill('SIGKILL'); await child.exited; }
return { root, record };
}
afterEach(async () => {
for (const child of children.splice(0)) {
if (child.exitCode === null) try { child.kill('SIGKILL'); } catch {}
await child.exited;
}
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
describe('owned terminal-agent publication lock recovery', () => {
test('the exact daemon reclaims its dead agent lock and normal release cleans up', async () => {
const { root, record } = await fixture();
const release = acquireAgentStateLock(root, 0);
expect(fs.readFileSync(lockPath(root), 'utf8')).toBe('');
expect(JSON.parse(fs.readFileSync(agentRecordPath(root), 'utf8'))).toEqual(record);
release();
expect(fs.existsSync(lockPath(root))).toBe(false);
expect(fs.readdirSync(root).filter(name => name.includes('.tmp.'))).toEqual([]);
});
test.skipIf(process.platform !== 'linux')('a dead zombie with the exact recorded birth cannot retain its publication lock', async () => {
const root = directory();
const ready = path.join(root, 'zombie-pid');
const python = [
'import os,time',
'pid=os.fork()',
'if pid==0: os._exit(0)',
`with open(${JSON.stringify(ready)},'w') as f: f.write(str(pid))`,
'time.sleep(30)',
].join('\n');
const parent = Bun.spawn(['python3', '-c', python], { stdio: ['ignore', 'ignore', 'ignore'] });
children.push(parent);
for (let n = 0; n < 300 && !fs.existsSync(ready); n++) await Bun.sleep(10);
expect(fs.existsSync(ready)).toBe(true);
const pid = Number(fs.readFileSync(ready, 'utf8'));
let state = '';
for (let n = 0; n < 300; n++) {
state = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').match(/^\d+ \(.*\) ([A-Z])/u)?.[1] || '';
if (state === 'Z') break;
await Bun.sleep(10);
}
expect(state).toBe('Z');
expect(() => process.kill(pid, 0)).not.toThrow();
const record: AgentRecord = { pid, gen: 'zombie-generation', startedAt: Date.now(),
startTime: readAgentStartTime(pid), ownerPid: process.pid, ownerStartTime: readAgentStartTime(process.pid) };
writeAgentRecord(root, record);
fs.writeFileSync(lockPath(root), JSON.stringify(metadata(record)), { mode: 0o600 });
const release = acquireAgentStateLock(root, 0);
expect(fs.readFileSync(lockPath(root), 'utf8')).toBe('');
release();
expect(fs.existsSync(lockPath(root))).toBe(false);
}, 10000);
test('a live exact owner is never reclaimed', async () => {
const { root } = await fixture(false);
const before = fs.readFileSync(lockPath(root), 'utf8');
expect(() => acquireAgentStateLock(root, 0)).toThrow('state lock unavailable');
expect(fs.readFileSync(lockPath(root), 'utf8')).toBe(before);
});
test('a reused live PID with the old birth identity is never reclaimed', async () => {
const { root, record } = await fixture(false);
record.startTime = 'an earlier process birth';
writeAgentRecord(root, record);
const before = JSON.stringify(metadata(record));
fs.writeFileSync(lockPath(root), before);
expect(() => acquireAgentStateLock(root, 0)).toThrow('state lock unavailable');
expect(fs.readFileSync(lockPath(root), 'utf8')).toBe(before);
});
for (const code of ['EPERM', 'EIO']) {
test(`uncertain process liveness (${code}) retains the lock`, async () => {
const { root, record } = await fixture();
const original = process.kill;
const kill = spyOn(process, 'kill').mockImplementation(((pid: number, signal: any) => {
if (pid === record.pid && signal === 0) throw Object.assign(new Error('unavailable'), { code });
return original(pid, signal);
}) as typeof process.kill);
try { expect(() => acquireAgentStateLock(root, 0)).toThrow('state lock unavailable'); }
finally { kill.mockRestore(); }
expect(fs.existsSync(lockPath(root))).toBe(true);
});
}
test('an uncertain nested liveness probe retains a live agent lock', async () => {
const { root, record } = await fixture(false);
const before = fs.readFileSync(lockPath(root), 'utf8');
const original = process.kill;
let probes = 0;
const kill = spyOn(process, 'kill').mockImplementation(((pid: number, signal: any) => {
if (pid === record.pid && signal === 0 && ++probes === 3) {
throw Object.assign(new Error('unavailable'), { code: 'EIO' });
}
return original(pid, signal);
}) as typeof process.kill);
try { expect(() => acquireAgentStateLock(root, 0)).toThrow('state lock unavailable'); }
finally { kill.mockRestore(); }
expect(probes).toBeGreaterThanOrEqual(2);
expect(() => process.kill(record.pid, 0)).not.toThrow();
expect(fs.readFileSync(lockPath(root), 'utf8')).toBe(before);
});
test.skipIf(process.platform !== 'linux')('an unreadable zombie-state probe retains a live agent lock', async () => {
const { root, record } = await fixture(false);
const before = fs.readFileSync(lockPath(root), 'utf8');
const original = fs.readFileSync;
let probes = 0;
const read = spyOn(fs, 'readFileSync').mockImplementation(((file: any, options: any) => {
if (String(file) === `/proc/${record.pid}/stat`) {
probes++;
throw Object.assign(new Error('unavailable'), { code: 'EIO' });
}
return original(file, options);
}) as typeof fs.readFileSync);
try { expect(() => acquireAgentStateLock(root, 0)).toThrow('state lock unavailable'); }
finally { read.mockRestore(); }
expect(probes).toBeGreaterThan(0);
expect(() => process.kill(record.pid, 0)).not.toThrow();
expect(fs.readFileSync(lockPath(root), 'utf8')).toBe(before);
});
for (const variant of ['empty', 'invalid-json', 'unknown-kind', 'generation', 'pid', 'birth', 'daemon', 'daemon-birth', 'missing-record', 'record-replaced']) {
test(`foreign or ambiguous lock is retained: ${variant}`, async () => {
const { root, record } = await fixture();
const lock = metadata(record);
if (variant === 'unknown-kind') lock.kind = 'other-lock';
if (variant === 'generation') lock.gen = 'foreign-generation';
if (variant === 'pid') lock.pid++;
if (variant === 'birth') lock.startTime = 'foreign birth';
if (variant === 'daemon') {
record.ownerPid = 1;
lock.ownerPid = 1;
writeAgentRecord(root, record);
}
if (variant === 'daemon-birth') {
record.ownerStartTime = 'earlier daemon birth';
lock.ownerStartTime = record.ownerStartTime;
writeAgentRecord(root, record);
}
if (variant === 'missing-record') fs.unlinkSync(agentRecordPath(root));
if (variant === 'record-replaced') writeAgentRecord(root, { ...record, gen: 'successor' });
const before = variant === 'empty' ? '' : variant === 'invalid-json' ? '{invalid' : JSON.stringify(lock);
fs.writeFileSync(lockPath(root), before);
expect(() => acquireAgentStateLock(root, 0)).toThrow('state lock unavailable');
expect(fs.readFileSync(lockPath(root), 'utf8')).toBe(before);
});
}
test('a symlink lock is not followed or reclaimed', async () => {
const { root } = await fixture();
const target = path.join(root, 'foreign-lock');
fs.renameSync(lockPath(root), target);
fs.symlinkSync(target, lockPath(root));
expect(() => acquireAgentStateLock(root, 0)).toThrow('state lock unavailable');
expect(fs.lstatSync(lockPath(root)).isSymbolicLink()).toBe(true);
expect(fs.existsSync(target)).toBe(true);
});
test('an inode replacement during validation is retained', async () => {
const { root } = await fixture();
const original = fs.lstatSync;
let reads = 0;
const stat = spyOn(fs, 'lstatSync').mockImplementation(((file: any, options: any) => {
if (String(file) === lockPath(root) && ++reads === 2) {
fs.renameSync(lockPath(root), path.join(root, 'retired-lock'));
fs.writeFileSync(lockPath(root), 'replacement');
}
return original(file, options);
}) as typeof fs.lstatSync);
try { expect(() => acquireAgentStateLock(root, 0)).toThrow('state lock unavailable'); }
finally { stat.mockRestore(); }
expect(reads).toBe(2);
expect(fs.readFileSync(lockPath(root), 'utf8')).toBe('replacement');
});
test('a successor agent record appearing during validation retains the lock', async () => {
const { root, record } = await fixture();
const original = fs.readFileSync;
let reads = 0;
const read = spyOn(fs, 'readFileSync').mockImplementation(((file: any, options: any) => {
if (String(file) === agentRecordPath(root) && ++reads === 2) writeAgentRecord(root, { ...record, gen: 'successor' });
return original(file, options);
}) as typeof fs.readFileSync);
try { expect(() => acquireAgentStateLock(root, 0)).toThrow('state lock unavailable'); }
finally { read.mockRestore(); }
expect(fs.existsSync(lockPath(root))).toBe(true);
});
test('an unbound process cannot publish reclaimable owner metadata', () => {
const root = directory();
expect(() => acquireAgentStateLock(root, 0, 'unbound')).toThrow('publication lock identity');
expect(fs.existsSync(lockPath(root))).toBe(false);
});
test('metadata changed in place during validation is retained', async () => {
const { root } = await fixture();
const original = fs.readFileSync;
let reads = 0;
const read = spyOn(fs, 'readFileSync').mockImplementation(((file: any, options: any) => {
if (String(file) === agentRecordPath(root) && ++reads === 2) fs.writeFileSync(lockPath(root), 'foreign replacement');
return original(file, options);
}) as typeof fs.readFileSync);
try { expect(() => acquireAgentStateLock(root, 0)).toThrow('state lock unavailable'); }
finally { read.mockRestore(); }
expect(fs.readFileSync(lockPath(root), 'utf8')).toBe('foreign replacement');
});
test('the actual registered daemon watchdog respawns after a publication-lock crash', () => {
const root = directory();
const ready = path.join(root, 'held.json');
const preload = path.join(root, 'publication-preload.ts');
fs.writeFileSync(preload, `
const fs = require('node:fs');
const ready = ${JSON.stringify(ready)};
const spawn = Bun.spawn;
Bun.spawn = (argv, options) => {
if (Array.isArray(argv) && argv.some(value => typeof value === 'string' && (value.endsWith('/server.ts') || value.endsWith('/terminal-agent.ts')))) {
argv = [argv[0], argv[1], '--preload', import.meta.path, ...argv.slice(2)];
}
return spawn(argv, options);
};
const agent = process.argv.some(value => value.endsWith('/terminal-agent.ts'));
const daemon = process.argv.some(value => value.endsWith('/server.ts'));
const link = fs.linkSync;
fs.linkSync = (from, to) => {
link(from, to);
if (agent && String(to).endsWith('/terminal-agent-pid.lock') && !fs.existsSync(ready)) {
const owner = JSON.parse(fs.readFileSync(to, 'utf8'));
if (owner.kind !== 'agent-publication-v1' || owner.pid !== process.pid) throw new Error('Publication metadata was not atomic');
fs.writeFileSync(ready, JSON.stringify({ pid: process.pid, owner }));
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3000);
throw new Error('Expected the original fixture to kill the publication holder');
}
};
const kill = process.kill;
process.kill = (pid, signal) => {
if (!agent && !daemon && signal === 'SIGKILL') {
const deadline = Date.now() + 1500;
while (!fs.existsSync(ready) && Date.now() < deadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
if (!fs.existsSync(ready) || JSON.parse(fs.readFileSync(ready, 'utf8')).pid !== pid) throw new Error('Publication holder was not captured');
}
return kill(pid, signal);
};
`);
const result = spawnSync(process.execPath, ['test', '--preload', preload,
path.join(import.meta.dir, 'terminal-agent-lifecycle.test.ts'), '--test-name-pattern',
'daemon respawns after agent crash, then exits without deleting a successor state', '--timeout=30000'],
{ encoding: 'utf8', timeout: 20000, env: { ...process.env, BROWSE_HEADLESS_SKIP: '1' } });
expect(result.error, result.stderr).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toContain('1 pass');
expect(result.stderr).toContain('0 fail');
const held = JSON.parse(fs.readFileSync(ready, 'utf8'));
expect(held.owner.pid).toBe(held.pid);
expect(held.owner.gen).toBeTruthy();
expect(held.owner.startTime).toBeTruthy();
}, 25000);
});
+2 -2
View File
@@ -208,12 +208,12 @@ describe('headed parent-death shutdown is suppressed on runtime promotion', () =
test('the server binds that callback to the suppress-flag setter', () => {
const src = read('src/server.ts');
expect(src).toContain('function suppressHeadedParentShutdown()');
expect(src).toContain('function suppressHeadedParentShutdown(');
// Bound on BOTH the module-level manager and any embedder-supplied one; the
// watchdog reads activeBrowserManager, so binding only the default instance
// leaves embedders (e.g. gbrowser) promoting silently.
expect(src).toContain('browserManager.onHeadedPromotion = suppressHeadedParentShutdown');
expect(src).toContain('cfgBrowserManager.onHeadedPromotion = suppressHeadedParentShutdown');
expect(src).toContain('cfgBrowserManager.onHeadedPromotion = () => suppressHeadedParentShutdown(cfg.config, cfgBrowserManager)');
});
test('promotion must NOT clear the interval — the tick doubles as the tunnel-orphan reaper', () => {
+374
View File
@@ -1,4 +1,7 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
shouldSpawnXvfb,
isOurXvfb,
@@ -166,6 +169,13 @@ describe('xvfb spawn → cleanup round trip (Linux + Xvfb only)', () => {
expect(handle.startTime.length).toBeGreaterThan(0);
// Validation should pass.
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(true);
const lockPath = `/tmp/.X${display}-lock`;
const lock = fs.readFileSync(lockPath, 'utf8');
cleanupXvfb({ ...handle, startTime: 'stale-start-time' });
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(true);
await expect(spawnXvfb(display)).rejects.toThrow('already reserved');
expect(fs.readFileSync(lockPath, 'utf8')).toBe(lock);
expect(isOurXvfb(handle.pid, handle.startTime)).toBe(true);
} finally {
handle.close();
// After cleanup, our Xvfb should be gone.
@@ -174,3 +184,367 @@ describe('xvfb spawn → cleanup round trip (Linux + Xvfb only)', () => {
}
});
});
describe.skipIf(process.platform !== 'linux')('display allocation failure controls', () => {
test('missing ownership tooling fails before spawning Xvfb', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-xvfb-no-ps-'));
const marker = path.join(root, 'spawned');
fs.writeFileSync(path.join(root, 'Xvfb'), `#!/bin/sh\nprintf started > ${JSON.stringify(marker)}\nexit 0\n`, { mode: 0o755 });
try {
const display = pickFreeDisplay();
expect(display).not.toBeNull();
const child = Bun.spawnSync([process.execPath, '-e', `
import { spawnXvfb } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/xvfb.ts'))};
try { const handle = await spawnXvfb(${display}); handle.close(); process.exitCode = 1; }
catch (err) { console.log(err.message); }
`], { env: { ...process.env, PATH: root }, stdout: 'pipe', stderr: 'pipe', timeout: 10000 });
expect(child.exitCode).toBe(0);
expect(child.stdout.toString()).toContain('without process start-time ownership checks');
expect(fs.existsSync(marker)).toBe(false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('an unreachable reserved display is not free and its lock is not removed', async () => {
const display = pickFreeDisplay(20000, 20100);
expect(display).not.toBeNull();
const lockPath = `/tmp/.X${display}-lock`;
fs.writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx' });
const inode = fs.statSync(lockPath).ino;
try {
expect(isDisplayFree(display!)).toBe(false);
expect(pickFreeDisplay(display!, display!)).toBeNull();
const { spawnXvfb } = await import('../src/xvfb');
await expect(spawnXvfb(display!)).rejects.toThrow('already reserved');
expect(fs.statSync(lockPath).ino).toBe(inode);
expect(fs.readFileSync(lockPath, 'utf8')).toBe(`${process.pid}\n`);
} finally {
if (fs.statSync(lockPath).ino === inode) fs.unlinkSync(lockPath);
}
});
test('a dangling display lock remains reserved and is not replaced', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-display-link-'));
const display = pickFreeDisplay(21000, 21100);
expect(display).not.toBeNull();
const lockPath = `/tmp/.X${display}-lock`;
const target = path.join(root, 'missing-owner');
fs.symlinkSync(target, lockPath);
try {
expect(isDisplayFree(display!)).toBe(false);
const { spawnXvfb } = await import('../src/xvfb');
await expect(spawnXvfb(display!)).rejects.toThrow('already reserved');
expect(fs.readlinkSync(lockPath)).toBe(target);
} finally {
if (fs.readlinkSync(lockPath) === target) fs.unlinkSync(lockPath);
fs.rmSync(root, { recursive: true, force: true });
}
});
test('a failed Xvfb process reports startup failure without claiming a display', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-xvfb-failure-'));
fs.writeFileSync(path.join(root, 'Xvfb'), '#!/bin/sh\nexit 42\n', { mode: 0o755 });
try {
const display = pickFreeDisplay();
expect(display).not.toBeNull();
const child = Bun.spawnSync([process.execPath, '-e', `
import { spawnXvfb } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/xvfb.ts'))};
try { const handle = await spawnXvfb(${display}); handle.close(); process.exitCode = 1; }
catch (err) { console.log(err.message); }
`], { env: { ...process.env, PATH: `${root}:${process.env.PATH}` }, stdout: 'pipe', stderr: 'pipe', timeout: 10000 });
expect(child.exitCode).toBe(0);
expect(child.stdout.toString()).toContain('exited during startup (code 42)');
expect(isDisplayFree(display!)).toBe(true);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform !== 'linux')('daemon-owned display lifecycle', () => {
test('registered shutdown waits for owned display allocation before process exit', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-display-exit-'));
const marker = path.join(root, 'xvfb.pid');
const realXvfb = Bun.which('Xvfb');
expect(realXvfb).not.toBeNull();
fs.writeFileSync(path.join(root, 'Xvfb'), `#!/bin/sh\nprintf '%s' "$$" > ${JSON.stringify(marker)}\n/bin/sleep 0.8\nexec ${JSON.stringify(realXvfb)} "$@"\n`, { mode: 0o755 });
const script = path.join(root, 'shutdown.ts');
fs.writeFileSync(script, `
import { BrowserManager } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/browser-manager.ts'))};
import { buildFetchHandler, resolveConfigFromEnv } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))};
const manager = new BrowserManager();
await manager.launch();
manager.closeRaceMs = 50;
const handler = buildFetchHandler({ ...resolveConfigFromEnv(), browserManager: manager });
void manager.ensureHeadedDisplay().catch(() => {});
await handler.shutdown();
`);
const child = Bun.spawn([process.execPath, script], {
cwd: root,
env: {
...process.env, HOME: root, PATH: `${root}:${process.env.PATH}`,
DISPLAY: '', WAYLAND_DISPLAY: '', BROWSE_HEADED: '', BROWSE_PARENT_PID: '0',
GSTACK_HOME: path.join(root, 'home-state'), BROWSE_STATE_FILE: path.join(root, 'state', 'browse.json'),
CHROMIUM_PROFILE: path.join(root, 'profile'), GSTACK_CHROMIUM_NO_SANDBOX: '1',
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'),
}, stdin: 'ignore', stdout: 'ignore', stderr: 'ignore',
});
let pid = 0;
let startTime = '';
try {
const deadline = Date.now() + 10000;
while (!fs.existsSync(marker) && Date.now() < deadline) await Bun.sleep(20);
expect(fs.existsSync(marker)).toBe(true);
pid = Number(fs.readFileSync(marker, 'utf8'));
startTime = readPidStartTime(pid);
expect(startTime).not.toBe('');
expect(await Promise.race([child.exited, Bun.sleep(10000).then(() => 'timeout')])).toBe(0);
await Bun.sleep(1000);
expect(isOurXvfb(pid, startTime)).toBe(false);
} finally {
if (child.exitCode === null) { child.kill('SIGKILL'); await child.exited; }
if (pid && startTime) cleanupXvfb({ pid, startTime, display: '' });
fs.rmSync(root, { recursive: true, force: true });
}
}, 30000);
for (const mode of ['welcome', 'welcome failure', 'shutdown', 'restored page', 'headless'] as const) {
test(`${mode}: startup settles welcome before publishing daemon readiness`, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-welcome-ready-'));
const stateFile = path.join(root, 'state', 'browse.json');
const entered = path.join(root, 'entered');
const release = path.join(root, 'release');
const fixture = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('<h1>Requested page remains usable</h1>', { headers: { 'Content-Type': 'text/html' } }) });
const requestedUrl = `http://127.0.0.1:${fixture.port}/requested`;
const preload = path.join(root, 'preload.ts');
fs.writeFileSync(preload, `
import * as fs from 'node:fs';
import { BrowserManager } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/browser-manager.ts'))};
const getPage = BrowserManager.prototype.getPage;
const wrapped = new WeakSet();
let rejectWelcome;
BrowserManager.prototype.getPage = function(...args) {
const page = getPage.apply(this, args);
if (!wrapped.has(page)) {
wrapped.add(page);
const goto = page.goto.bind(page);
page.goto = async (url, options) => {
if (new URL(url).pathname === '/welcome') {
fs.writeFileSync(${JSON.stringify(entered)}, JSON.stringify(this.getXvfbHandle()));
if (${JSON.stringify(mode)} === 'shutdown') {
return new Promise((resolve, reject) => {
rejectWelcome = () => reject(new Error('Welcome interrupted during shutdown'));
});
}
const deadline = Date.now() + 15000;
while (!fs.existsSync(${JSON.stringify(release)})) {
if (Date.now() >= deadline) throw new Error('Welcome test barrier expired');
await Bun.sleep(10);
}
if (${JSON.stringify(mode)} === 'welcome failure') throw new Error('Welcome test navigation failure');
}
return goto(url, options);
};
}
return page;
};
if (${JSON.stringify(mode)} === 'shutdown') {
const close = BrowserManager.prototype.close;
BrowserManager.prototype.close = async function(...args) {
rejectWelcome?.();
await Bun.sleep(0);
return close.apply(this, args);
};
}
if (${JSON.stringify(mode)} === 'restored page') {
const launch = BrowserManager.prototype.launchHeaded;
BrowserManager.prototype.launchHeaded = async function(...args) {
await launch.apply(this, args);
await this.getPage().goto(${JSON.stringify(requestedUrl)});
};
}
`);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !/^(BROWSE_|GSTACK_|CHROMIUM_PROFILE$|CLAUDE_PLUGIN_DATA$|DISPLAY$|WAYLAND_DISPLAY$)/.test(key)) env[key] = value;
}
Object.assign(env, {
HOME: root, GSTACK_HOME: path.join(root, 'home-state'),
CHROMIUM_PROFILE: path.join(root, 'profile'), BROWSE_STATE_FILE: stateFile,
BROWSE_PORT: '0', BROWSE_PARENT_PID: '0', GSTACK_STATE_WATCH_MS: '0',
BROWSE_HEADED: mode === 'headless' ? '' : '1',
GSTACK_CHROMIUM_NO_SANDBOX: '1', GSTACK_SECURITY_OFF: '1',
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'),
});
const log = fs.openSync(path.join(root, 'daemon.log'), 'w', 0o600);
const child = Bun.spawn([process.execPath, '--preload', preload, path.resolve(import.meta.dir, '../src/server.ts')], {
cwd: root, env, stdin: 'ignore', stdout: log, stderr: log,
});
let owned: { pid: number; startTime: string; display: string } | undefined;
const waitUntil = async (check: () => boolean) => {
const deadline = Date.now() + 15000;
while (!check() && Date.now() < deadline) await Bun.sleep(20);
expect(check()).toBe(true);
};
try {
if (mode === 'welcome' || mode === 'welcome failure' || mode === 'shutdown') {
await waitUntil(() => fs.existsSync(entered));
owned = JSON.parse(fs.readFileSync(entered, 'utf8'));
expect(fs.existsSync(stateFile)).toBe(false);
if (mode === 'shutdown') {
const foreign = `${JSON.stringify({ pid: process.pid, instanceId: 'foreign-fixture-instance' })}\n`;
fs.writeFileSync(stateFile, foreign, { mode: 0o600 });
child.kill('SIGTERM');
await waitUntil(() => child.exitCode !== null);
expect(await child.exited).toBe(0);
expect(fs.readFileSync(stateFile, 'utf8') === foreign).toBe(true);
expect(owned).toBeDefined();
expect(isOurXvfb(owned!.pid, owned!.startTime)).toBe(false);
return;
}
fs.writeFileSync(release, 'release');
}
await waitUntil(() => fs.existsSync(stateFile));
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
if (state.xvfbPid) owned = { pid: state.xvfbPid, startTime: state.xvfbStartTime, display: state.xvfbDisplay };
const health = await fetch(`http://127.0.0.1:${state.port}/health`, { signal: AbortSignal.timeout(3000) });
expect((await health.json() as { status: string }).status).toBe('healthy');
const command = async (name: string, args: string[] = []) => {
const response = await fetch(`http://127.0.0.1:${state.port}/command`, {
method: 'POST', headers: { Authorization: `Bearer ${state.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ command: name, args }), signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error(`${name}: ${response.status}: ${await response.text()}`);
return response.text();
};
if (mode === 'welcome') expect(await command('url')).toContain('/welcome');
if (mode === 'welcome failure') {
expect(fs.readFileSync(path.join(root, 'daemon.log'), 'utf8')).toContain('Welcome test navigation failure');
expect(await command('url')).toContain('about:blank');
}
if (mode === 'restored page' || mode === 'headless') expect(fs.existsSync(entered)).toBe(false);
if (mode === 'restored page') expect(await command('text')).toContain('Requested page remains usable');
await command('goto', [requestedUrl]);
expect(await command('text')).toContain('Requested page remains usable');
expect(await command('url')).toContain(requestedUrl);
await command('stop');
await waitUntil(() => child.exitCode !== null);
expect(await child.exited).toBe(0);
if (owned) expect(isOurXvfb(owned.pid, owned.startTime)).toBe(false);
} finally {
fs.writeFileSync(release, 'release');
if (child.exitCode === null) {
child.kill('SIGTERM');
await Promise.race([child.exited, Bun.sleep(10000)]);
if (child.exitCode === null) child.kill('SIGKILL');
await child.exited;
}
if (owned) cleanupXvfb(owned);
fs.closeSync(log);
fixture.stop(true);
fs.rmSync(root, { recursive: true, force: true });
}
}, 60000);
}
for (const mode of ['lazy', 'existing', 'headed boot', 'exhausted'] as const) {
test(`${mode}: registered daemon commands and shutdown respect display ownership`, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-display-daemon-'));
const stateFile = path.join(root, 'state', 'browse.json');
const fixture = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('<title>Display fixture</title><h1>Promotion remains usable</h1>', { headers: { 'Content-Type': 'text/html' } }) });
let external: Awaited<ReturnType<typeof import('../src/xvfb').spawnXvfb>> | undefined;
let owned: { pid: number; startTime: string; display: string } | undefined;
let child: ReturnType<typeof Bun.spawn> | undefined;
const log = fs.openSync(path.join(root, 'daemon.log'), 'w', 0o600);
const waitUntil = async (check: () => boolean) => {
const deadline = Date.now() + 15000;
while (!check() && Date.now() < deadline) await Bun.sleep(50);
expect(check()).toBe(true);
};
try {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !/^(BROWSE_|GSTACK_|CHROMIUM_PROFILE$|CLAUDE_PLUGIN_DATA$|DISPLAY$|WAYLAND_DISPLAY$)/.test(key)) env[key] = value;
}
Object.assign(env, {
HOME: root, GSTACK_HOME: path.join(root, 'home-state'),
CHROMIUM_PROFILE: path.join(root, 'profile'), BROWSE_STATE_FILE: stateFile,
BROWSE_PORT: '0', BROWSE_PARENT_PID: '0', GSTACK_STATE_WATCH_MS: '0',
GSTACK_CHROMIUM_NO_SANDBOX: '1', GSTACK_SECURITY_OFF: '1',
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'),
});
if (mode === 'existing') {
const display = pickFreeDisplay();
expect(display).not.toBeNull();
external = await (await import('../src/xvfb')).spawnXvfb(display!);
env.DISPLAY = external.display;
}
if (mode === 'headed boot') env.BROWSE_HEADED = '1';
if (mode === 'exhausted') {
const bin = path.join(root, 'bin');
fs.mkdirSync(bin);
fs.writeFileSync(path.join(bin, 'xdpyinfo'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
env.PATH = `${bin}:${env.PATH}`;
}
child = Bun.spawn([process.execPath, path.resolve(import.meta.dir, '../src/server.ts')], {
cwd: root, env, stdin: 'ignore', stdout: log, stderr: log,
});
await waitUntil(() => fs.existsSync(stateFile));
const before = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
expect(before.mode).toBe(mode === 'headed boot' ? 'headed' : 'launched');
if (mode !== 'headed boot') expect(before.xvfbPid).toBeUndefined();
const command = async (name: string, args: string[] = []) => {
const response = await fetch(`http://127.0.0.1:${before.port}/command`, {
method: 'POST', headers: { Authorization: `Bearer ${before.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ command: name, args }), signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error(`${name}: ${response.status}: ${await response.text()}`);
return response.text();
};
await command('goto', [`http://127.0.0.1:${fixture.port}`]);
const handoff = await command('handoff', ['display fixture']);
const after = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
if (mode === 'exhausted') {
expect(handoff).toContain('no free X display');
expect(after.mode).toBe('launched');
expect(after.xvfbPid).toBeUndefined();
} else {
expect(handoff).toContain('HANDOFF:');
expect(handoff).not.toContain('ERROR:');
expect(after.mode).toBe('headed');
}
if (mode === 'existing') {
expect(after.xvfbPid).toBeUndefined();
expect(isOurXvfb(external!.pid, external!.startTime)).toBe(true);
} else if (mode !== 'exhausted') {
owned = { pid: after.xvfbPid, startTime: after.xvfbStartTime, display: after.xvfbDisplay };
expect(isOurXvfb(owned.pid, owned.startTime)).toBe(true);
if (mode === 'lazy') expect(handoff).toContain('Off-screen Xvfb');
else expect(after.xvfbPid).toBe(before.xvfbPid);
}
expect(await command('text')).toContain('Promotion remains usable');
expect(await command('resume')).toContain('RESUMED');
await command('handoff', ['idempotent']);
expect(JSON.parse(fs.readFileSync(stateFile, 'utf8')).xvfbPid).toBe(after.xvfbPid);
await command('stop');
await waitUntil(() => child!.exitCode !== null);
expect(await child.exited).toBe(0);
if (owned) expect(isOurXvfb(owned.pid, owned.startTime)).toBe(false);
if (external) expect(isOurXvfb(external.pid, external.startTime)).toBe(true);
} finally {
if (child && child.exitCode === null) {
child.kill('SIGTERM');
await Promise.race([child.exited, Bun.sleep(10000)]);
if (child.exitCode === null) child.kill('SIGKILL');
await child.exited;
}
if (owned) cleanupXvfb(owned);
external?.close();
fs.closeSync(log);
fixture.stop(true);
fs.rmSync(root, { recursive: true, force: true });
}
}, 60000);
}
});