Files
gstack/browse/test/server-embedder-terminal-port.test.ts
T
Garry Tan b9706f3635 v1.88.1.0 fix: harden credential boundaries and owned state (#2942)
* fix(settings): preserve symlinked settings targets

Resolve the selected target for locking, mutation, backup, and rollback; refuse target changes and preserve private modes. Addresses #2830.

* fix(redact): bind masking to original detected spans

Inspired by #2929's anchored-span diagnosis; independently implemented using normalization offsets. Addresses #2930 and the relocation portion of #2912 without changing detection sensitivity.

* fix(evals): exclude operator credentials from prefix admission

Adapts the credential-suffix screen proposed in #2636, with real launched-child regression coverage and deliberate provider-auth exceptions.

* fix(artifacts): retain custom allowlist rules on reinitialization

Preserve the exact user-owned suffix and publish only a successfully assembled replacement. Independently implements the repair reported in #2907.

* test(cso): verify exact masked reads and unmaskable payload refusal

* fix(cso): preserve exact filesystem identities through lease recovery

Preserve 64-bit device/inode identity and nanosecond race checks. Add native NTFS lifecycle coverage for #2927; retain ambiguous legacy-state refusal without claiming Windows PID-reuse recovery is resolved.

* fix(redact): bind pre-push scans to destination and preserve seam context

Uses #2935 (bd07318) as source evidence for push-target range and slice-overlap defects. Independently implemented; no cherry-pick or release metadata adoption.

* test(ci): gate native agent ownership and settings links on macOS

* fix(browse): bind agent lifetimes and cleanup to owned generations

Uses #2931 by Chris Hutton / Claude Fable 5.1 as attributed design input; independently implemented without broad sweeps or copied code. Keep uncertain children and locks rather than deleting foreign state.

* test(ci): include concurrent shutdown controls in the native macOS gate

* v1.88.1.0 fix: harden credential boundaries and owned state

* fix(redact): preserve target provenance and scan boundary semantics

* test(artifacts): read managed rules from atomic allowlist assembly

* fix: preserve native exit observations and fixture prerequisites

* fix: preserve UTF-16 offsets through redaction normalization
2026-09-23 08:54:53 -04:00

302 lines
14 KiB
TypeScript

import { describe, test, expect, beforeEach, beforeAll, afterAll, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as crypto from 'crypto';
import {
buildFetchHandler,
__resetShuttingDown,
type ServerConfig,
} from '../src/server';
import { __resetRegistry } from '../src/token-registry';
import { BrowserManager } from '../src/browser-manager';
import { resolveConfig } from '../src/config';
// Tests for the v1.41+ ownsTerminalAgent flag.
//
// Embedders (gbrowser phoenix overlay) that run their own PTY server and write
// terminal-port / terminal-internal-token / terminal-agent-pid themselves were
// getting those files clobbered by gstack's shutdown(). The flag (default true)
// gates four side effects (v1.44+):
// 1. identity-based kill of the PID in <stateDir>/terminal-agent-pid
// 2. unlink terminal-port
// 3. unlink terminal-internal-token
// 4. unlink terminal-agent-pid
// False = embedder owns them, gstack stays hands-off.
//
// Pre-v1.44 used `pkill -f terminal-agent\.ts` which matched sibling gstack
// sessions on the same host — see browse/src/terminal-agent-control.ts header.
//
// CRITICAL: each test stubs process.exit (so shutdown's exit doesn't kill
// the test runner). The PID in the test agent-record is a guaranteed-dead
// PID (1 = init / launchd — exists but cannot be killed by an unprivileged
// process, so safeKill returns ESRCH-equivalent without affecting anything).
// Use isProcessAlive's false branch by also testing with a PID that does
// not exist (negative PID rejected by the OS).
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-server-embedder-'));
const fixtureConfig = resolveConfig({ BROWSE_STATE_FILE: path.join(fixtureDir, 'state/browse.json') });
const stateDir = fixtureConfig.stateDir;
const savedChromiumProfile = process.env.CHROMIUM_PROFILE;
beforeAll(() => { process.env.CHROMIUM_PROFILE = path.join(fixtureDir, 'chromium-profile'); });
const PORT_FILE = path.join(stateDir, 'terminal-port');
const TOKEN_FILE = path.join(stateDir, 'terminal-internal-token');
const AGENT_RECORD_FILE = path.join(stateDir, 'terminal-agent-pid');
const SENTINEL_PORT = 'sentinel-port-65432';
const SENTINEL_TOKEN = 'sentinel-token-abcdef1234567890';
// PID 2^31-1 is the Linux PID_MAX_LIMIT; macOS uses 99998. Either way, no
// real process will ever hold this PID on a developer machine. isProcessAlive
// returns false → killAgentByRecord no-ops without sending any signal.
const SENTINEL_DEAD_PID = 2147483646;
function makeMinimalConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
const token = 'embedder-test-' + crypto.randomBytes(16).toString('hex');
return {
authToken: token,
browsePort: 34568,
config: fixtureConfig,
browserManager: new BrowserManager(),
startTime: Date.now(),
...overrides,
};
}
function writeSentinels(): void {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(PORT_FILE, SENTINEL_PORT);
fs.writeFileSync(TOKEN_FILE, SENTINEL_TOKEN);
fs.writeFileSync(
AGENT_RECORD_FILE,
JSON.stringify({ pid: SENTINEL_DEAD_PID, gen: 'sentinel-gen', startedAt: Date.now() }),
);
}
function readIfExists(p: string): string | null {
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
}
/**
* Stubs process.exit so shutdown()'s process.exit(0) throws an __exit:N
* marker the test can swallow instead of killing the runner. Also stubs
* process.kill so an accidental kill (regression in killAgentByRecord
* that bypassed isProcessAlive) cannot reach a real PID on the developer
* machine. Returns the captured kill calls so tests can assert kill
* scope.
*/
// The TRUE process.exit, restored only in afterAll. withStubs used to restore
// it in its finally — but shutdown() schedules async work (timers,
// fire-and-forget promises) that can call process.exit AFTER the stub was
// restored, killing the entire bun test process mid-suite with exit 0 and no
// summary (the silent-truncation class the free-suite CI job guards against;
// this file was the killer). Between tests, exit stays a logging no-op so a
// late async exit is visible instead of fatal.
const TRUE_EXIT = process.exit;
const lateExitGuard = ((code: number) => {
console.error(`[test-guard] late process.exit(${code}) swallowed (async shutdown work after stub restore)`);
}) as any;
afterAll(async () => {
// Drain shutdown()'s pending async work before restoring the real exit:
// disposeSession escalates SIGINT -> SIGKILL on a 3s timer, and a timer
// firing after this file's afterAll would otherwise hit the REAL
// process.exit and kill the whole multi-file bun run (observed: the free
// suite died at file 47 with exit 0 and no summary — twice).
await new Promise((r) => setTimeout(r, 3500));
(process as any).exit = TRUE_EXIT;
if (savedChromiumProfile === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = savedChromiumProfile;
fs.rmSync(fixtureDir, { recursive: true, force: true });
});
async function withStubs(
cb: (killCalls: Array<[number, NodeJS.Signals | number]>) => Promise<void>
): Promise<Array<[number, NodeJS.Signals | number]>> {
const origKill = process.kill;
const killCalls: Array<[number, NodeJS.Signals | number]> = [];
(process as any).exit = ((code: number) => {
throw new Error(`__exit:${code}`);
}) as any;
(process as any).kill = ((pid: number, signal: NodeJS.Signals | number) => {
killCalls.push([pid, signal ?? 'SIGTERM']);
// signal 0 is a liveness probe — keep the existing 'process is dead'
// semantics so isProcessAlive(SENTINEL_DEAD_PID) returns false.
if (signal === 0) {
const err: any = new Error('No such process');
err.code = 'ESRCH';
throw err;
}
return true;
}) as any;
try {
await cb(killCalls);
} finally {
(process as any).exit = lateExitGuard;
(process as any).kill = origKill;
}
return killCalls;
}
async function runShutdown(handle: { shutdown: (code?: number) => Promise<void> }): Promise<void> {
try {
await handle.shutdown(0);
} catch (err: any) {
if (typeof err?.message !== 'string' || !err.message.startsWith('__exit:')) throw err;
}
}
// Filter out the signal=0 liveness probes; only count actual termination signals.
function terminationCalls(
calls: Array<[number, NodeJS.Signals | number]>,
): Array<[number, NodeJS.Signals | number]> {
return calls.filter(([, sig]) => sig !== 0);
}
describe('buildFetchHandler ownsTerminalAgent gate', () => {
beforeEach(() => {
__resetRegistry();
__resetShuttingDown();
// Clean any leftover sentinels from a prior failed run so the "preserved"
// assertion can't pass spuriously off a stale file.
try { fs.unlinkSync(PORT_FILE); } catch {}
try { fs.unlinkSync(TOKEN_FILE); } catch {}
try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {}
});
test('1. ownsTerminalAgent:false preserves all three files and sends no signal', async () => {
writeSentinels();
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: false }));
const calls = await withStubs(async () => {
await runShutdown(handle);
});
expect(readIfExists(PORT_FILE)).toBe(SENTINEL_PORT);
expect(readIfExists(TOKEN_FILE)).toBe(SENTINEL_TOKEN);
expect(readIfExists(AGENT_RECORD_FILE)).not.toBeNull();
expect(terminationCalls(calls).length).toBe(0);
});
test('2. ownsTerminalAgent:true deletes all three files; identity-based kill probes the recorded PID', async () => {
writeSentinels();
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: true }));
const calls = await withStubs(async () => {
await runShutdown(handle);
});
expect(readIfExists(PORT_FILE)).toBeNull();
expect(readIfExists(TOKEN_FILE)).toBeNull();
expect(readIfExists(AGENT_RECORD_FILE)).toBeNull();
// isProcessAlive sends signal 0; PID is the sentinel-dead PID, so the
// probe returns false and no SIGTERM is sent.
const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0);
expect(probes.length).toBeGreaterThan(0);
expect(terminationCalls(calls).length).toBe(0);
});
test('3. ownsTerminalAgent unset defaults to true (deletes all three; probes recorded PID)', async () => {
writeSentinels();
// Note: no ownsTerminalAgent in the overrides — uses the `?? true` default.
const handle = buildFetchHandler(makeMinimalConfig());
const calls = await withStubs(async () => {
await runShutdown(handle);
});
expect(readIfExists(PORT_FILE)).toBeNull();
expect(readIfExists(TOKEN_FILE)).toBeNull();
expect(readIfExists(AGENT_RECORD_FILE)).toBeNull();
const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0);
expect(probes.length).toBeGreaterThan(0);
});
test('4. CLI start() call site passes ownsTerminalAgent: true literally (static grep)', () => {
// Resolves browse/src/server.ts relative to this test file so the test
// works regardless of cwd. import.meta.url is the test file's URL.
const serverTsPath = path.resolve(
import.meta.path,
'..',
'..',
'src',
'server.ts',
);
const source = fs.readFileSync(serverTsPath, 'utf-8');
// Match the call site inside start()'s buildFetchHandler({...}) literal.
// The pattern looks for the trailing comma and trailing context so the
// match cannot be satisfied by the JSDoc reference earlier in the file.
expect(source).toMatch(/ownsTerminalAgent:\s*true,\s*\/\/\s*CLI spawns terminal-agent\.ts/);
});
test('5. shutdown cannot remove a successor published after its current-record read', async () => {
writeSentinels();
const ready = path.join(fixtureDir, 'competitor-ready');
const script = path.join(fixtureDir, 'competitor.ts');
fs.writeFileSync(script, `
import * as fs from 'fs';
import * as path from 'path';
import { acquireAgentStateLock } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/terminal-agent-control.ts'))};
const stateDir = process.argv[2];
fs.writeFileSync(${JSON.stringify(ready)}, 'ready');
const release = acquireAgentStateLock(stateDir);
try {
fs.writeFileSync(path.join(stateDir, 'terminal-port'), 'successor-port');
fs.writeFileSync(path.join(stateDir, 'terminal-internal-token'), 'synthetic-successor-token');
fs.writeFileSync(path.join(stateDir, 'terminal-agent-pid'), JSON.stringify({ pid: process.pid, gen: 'successor', startedAt: Date.now() }));
} finally { release(); }
`);
const originalRead = fs.readFileSync;
let recordReads = 0;
let actor: ReturnType<typeof Bun.spawn> | undefined;
const reader = spyOn(fs, 'readFileSync').mockImplementation(((file: fs.PathOrFileDescriptor, options?: any) => {
const result = originalRead(file as any, options);
if (String(file) === AGENT_RECORD_FILE && ++recordReads === 2) {
actor = Bun.spawn([process.execPath, script, stateDir], { stdio: ['ignore', 'ignore', 'ignore'] });
const deadline = Date.now() + 3000;
while (!fs.existsSync(ready) && Date.now() < deadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
if (!fs.existsSync(ready)) throw new Error('competitor never reached publication');
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150);
}
return result;
}) as typeof fs.readFileSync);
try {
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: true }));
await withStubs(async () => runShutdown(handle));
expect(recordReads).toBeGreaterThanOrEqual(2);
expect(actor).toBeDefined();
expect(await Promise.race([actor!.exited.then(() => true), Bun.sleep(5000).then(() => false)])).toBe(true);
expect(readIfExists(PORT_FILE)).toBe('successor-port');
expect(readIfExists(TOKEN_FILE)).toBe('synthetic-successor-token');
expect(JSON.parse(readIfExists(AGENT_RECORD_FILE)!)).toMatchObject({ gen: 'successor' });
} finally {
reader.mockRestore();
try { actor?.kill('SIGKILL'); } catch {}
fs.rmSync(ready, { force: true });
fs.rmSync(script, { force: true });
}
}, 15000);
test('6. unavailable state lock retains agent files rather than guessing ownership', async () => {
writeSentinels();
const originalOpen = fs.openSync;
const opened = spyOn(fs, 'openSync').mockImplementation(((file: fs.PathLike, flags: string | number, mode?: number) => {
if (String(file) === path.join(stateDir, 'terminal-agent-pid.lock')) {
throw Object.assign(new Error('synthetic lock denial'), { code: 'EACCES' });
}
return originalOpen(file, flags as any, mode);
}) as typeof fs.openSync);
try {
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: true }));
await withStubs(async () => runShutdown(handle));
expect(readIfExists(PORT_FILE)).toBe(SENTINEL_PORT);
expect(readIfExists(TOKEN_FILE)).toBe(SENTINEL_TOKEN);
expect(readIfExists(AGENT_RECORD_FILE)).not.toBeNull();
} finally { opened.mockRestore(); }
});
test('7. late state takeover is not removed after browser close', async () => {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(fixtureConfig.stateFile, JSON.stringify({ pid: process.pid }));
const successor = { pid: process.pid, instanceId: 'synthetic-late-successor' };
const browserManager = new BrowserManager();
browserManager.close = async () => { fs.writeFileSync(fixtureConfig.stateFile, JSON.stringify(successor)); };
try {
const handle = buildFetchHandler(makeMinimalConfig({ browserManager, ownsTerminalAgent: false }));
await withStubs(async () => runShutdown(handle));
expect(JSON.parse(fs.readFileSync(fixtureConfig.stateFile, 'utf8'))).toEqual(successor);
} finally { fs.rmSync(fixtureConfig.stateFile, { force: true }); }
});
});