mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-26 14:50:55 +02:00
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
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { describe, test, expect, spyOn } from 'bun:test';
|
||||
import * as childProcess from 'node:child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -103,34 +104,54 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('5. spawnTerminalAgent passes windowsHide so no console is shown', () => {
|
||||
test('5. spawnTerminalAgent passes windowsHide so no console is shown', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hide-'));
|
||||
const script = path.join(tmpDir, 'fake-agent.ts');
|
||||
fs.writeFileSync(script, '// no-op\n');
|
||||
const origSpawn = (Bun as any).spawn;
|
||||
const originalProbe = Bun.spawnSync;
|
||||
const originalWindowsProbe = childProcess.spawnSync;
|
||||
const probe = spyOn(Bun, 'spawnSync').mockImplementation(((command: string[], options: any) => {
|
||||
if (command[0] === 'ps' && command[2] === String(process.pid)) {
|
||||
return { exitCode: 0, stdout: Buffer.from('fixture-owner-start'), stderr: Buffer.alloc(0) };
|
||||
}
|
||||
return originalProbe(command, options);
|
||||
}) as typeof Bun.spawnSync);
|
||||
const windowsProbe = spyOn(childProcess, 'spawnSync').mockImplementation(((command: string, args: string[], options: any) => {
|
||||
if (command === 'powershell.exe') {
|
||||
const owner = args.join(' ').includes(`ProcessId = ${process.pid}'`);
|
||||
return { status: 0, stdout: JSON.stringify(owner ? { CreationDate: 'fixture-owner-start', CommandLine: 'test-owner' } : null), stderr: '' };
|
||||
}
|
||||
return originalWindowsProbe(command, args, options);
|
||||
}) as typeof childProcess.spawnSync);
|
||||
const exited = Promise.resolve(0);
|
||||
let captured: any = null;
|
||||
(Bun as any).spawn = (_cmd: any, opts: any) => {
|
||||
captured = opts;
|
||||
return { pid: 4242, unref() {} };
|
||||
return { pid: 2147483647, exited, kill() {}, unref() {} };
|
||||
};
|
||||
try {
|
||||
const pid = spawnTerminalAgent({
|
||||
expect(() => spawnTerminalAgent({
|
||||
stateFile: path.join(tmpDir, 'state.json'),
|
||||
serverPort: 12345,
|
||||
ownerPid: process.pid,
|
||||
cwd: tmpDir,
|
||||
scriptPath: script,
|
||||
});
|
||||
expect(pid).toBe(4242);
|
||||
})).toThrow('terminal-agent process identity is unavailable');
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured.windowsHide).toBe(true);
|
||||
// Owner-PID lifetime tie (#2019): the agent polls this and exits when
|
||||
// its owning browse server dies, so it can't be adopted by PID 1.
|
||||
expect(captured.env.BROWSE_OWNER_PID).toBe(String(process.pid));
|
||||
expect(captured.env.BROWSE_OWNER_START_TIME).toBe('fixture-owner-start');
|
||||
// Detached background daemon — must not inherit a terminal either.
|
||||
expect(captured.stdio).toEqual(['ignore', 'ignore', 'ignore']);
|
||||
await exited;
|
||||
expect(fs.existsSync(path.join(tmpDir, 'terminal-agent-pid'))).toBe(false);
|
||||
} finally {
|
||||
(Bun as any).spawn = origSpawn;
|
||||
windowsProbe.mockRestore();
|
||||
probe.mockRestore();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, beforeEach, beforeAll, afterAll } from 'bun:test';
|
||||
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';
|
||||
@@ -219,4 +219,83 @@ describe('buildFetchHandler ownsTerminalAgent gate', () => {
|
||||
// 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 }); }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,11 +246,11 @@ describe('buildFetchHandler factory contract', () => {
|
||||
fs.mkdirSync(path.dirname(globalState), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(instanceState), { recursive: true });
|
||||
fs.writeFileSync(globalState, 'unrelated daemon state');
|
||||
fs.writeFileSync(instanceState, 'owned instance state');
|
||||
const script = `
|
||||
import fs from 'node:fs';
|
||||
import { buildFetchHandler } from ${JSON.stringify(path.resolve(__dirname, '../src/server.ts'))};
|
||||
import { buildFetchHandler, __testInternals__ } from ${JSON.stringify(path.resolve(__dirname, '../src/server.ts'))};
|
||||
import { resolveConfig } from ${JSON.stringify(path.resolve(__dirname, '../src/config.ts'))};
|
||||
fs.writeFileSync(${JSON.stringify(instanceState)}, JSON.stringify({ pid: process.pid, instanceId: __testInternals__.serverInstanceId }));
|
||||
const handle = buildFetchHandler({
|
||||
authToken: 'factory-shutdown-ownership-test', browsePort: 34567,
|
||||
config: resolveConfig({ BROWSE_STATE_FILE: ${JSON.stringify(instanceState)} }),
|
||||
|
||||
@@ -235,7 +235,8 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => {
|
||||
'utf-8',
|
||||
);
|
||||
expect(CONTROL_SRC).toContain('terminal-agent.ts');
|
||||
expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*script\]/);
|
||||
expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*script,\s*`--agent-gen=\$\{gen\}`\]/);
|
||||
expect(CONTROL_SRC).toContain('BROWSE_OWNER_PID: String(opts.ownerPid)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -967,9 +967,10 @@ describe('shutdown cleanup (server.ts)', () => {
|
||||
// by browse/test/terminal-agent-pid-identity.test.ts).
|
||||
const shutdownFn = serverSrc.slice(
|
||||
serverSrc.indexOf('async function shutdown('),
|
||||
serverSrc.indexOf('async function shutdown(') + 1200,
|
||||
serverSrc.indexOf('try { detachSession()', serverSrc.indexOf('async function shutdown(')),
|
||||
);
|
||||
expect(shutdownFn).toContain('killAgentByRecord');
|
||||
expect(shutdownFn).toContain('stopAgentByRecord');
|
||||
expect(shutdownFn).toContain('isOurAgent(record, process.pid)');
|
||||
expect(shutdownFn).toContain('readAgentRecord');
|
||||
// No pkill CALL — the word may appear in the explanatory comment, so
|
||||
// match invocation shapes only. The repo-wide reintroduction tripwire
|
||||
@@ -994,4 +995,3 @@ describe('cookie import button (sidebar)', () => {
|
||||
expect(js).toContain('cookie-picker');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
acquireAgentStateLock, agentRecordPath, clearAgentRecord, isOurAgent, killAgentByRecord, readAgentRecord,
|
||||
readAgentStartTime, spawnTerminalAgent, stopAgentByRecord, type AgentRecord,
|
||||
writeAgentRecord,
|
||||
} from '../src/terminal-agent-control';
|
||||
|
||||
const sourceDir = path.join(import.meta.dir, '..', 'src');
|
||||
const dirs: string[] = [];
|
||||
const pids: number[] = [];
|
||||
const dir = () => {
|
||||
const value = fs.mkdtempSync(path.join(os.tmpdir(), 'g4-'));
|
||||
dirs.push(value);
|
||||
return value;
|
||||
};
|
||||
const waitFor = async (check: () => boolean, timeout = 3000) => {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
if (check()) return true;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
return check();
|
||||
};
|
||||
const spawn = (stateDir: string, ownerPid = process.pid) => {
|
||||
const pid = spawnTerminalAgent({ stateFile: path.join(stateDir, 'browse.json'), serverPort: 0, ownerPid,
|
||||
extraEnv: { GSTACK_TERMINAL_OWNER_WATCHDOG_MS: '25' } });
|
||||
if (pid) pids.push(pid);
|
||||
return pid;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
for (const pid of pids.splice(0)) {
|
||||
const record = dirs.map(readAgentRecord).find(value => value?.pid === pid);
|
||||
if (record) stopAgentByRecord(record, 200);
|
||||
}
|
||||
for (const stateDir of dirs.splice(0)) fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('terminal-agent owned lifecycle regression', () => {
|
||||
for (const field of ['dev', 'ino'] as const) {
|
||||
test(`lock release preserves a replacement with an adjacent 64-bit ${field}`, () => {
|
||||
const stateDir = dir();
|
||||
const lockPath = path.join(stateDir, 'terminal-agent-pid.lock');
|
||||
const identity = 1n << 63n;
|
||||
expect(Number(identity)).toBe(Number(identity + 1n));
|
||||
const originalFstat = fs.fstatSync;
|
||||
const originalStat = fs.statSync;
|
||||
const descriptor = spyOn(fs, 'fstatSync').mockImplementation(((fd: number, options?: any) => {
|
||||
const stat = originalFstat(fd, options);
|
||||
return Object.assign(stat, { [field]: options?.bigint ? identity : Number(identity) });
|
||||
}) as typeof fs.fstatSync);
|
||||
const pathname = spyOn(fs, 'statSync').mockImplementation(((file: fs.PathLike, options?: any) => {
|
||||
const stat = originalStat(file, options);
|
||||
return String(file) === lockPath
|
||||
? Object.assign(stat, { [field]: options?.bigint ? identity + 1n : Number(identity + 1n) })
|
||||
: stat;
|
||||
}) as typeof fs.statSync);
|
||||
try {
|
||||
acquireAgentStateLock(stateDir)();
|
||||
expect(fs.existsSync(lockPath)).toBe(true);
|
||||
} finally {
|
||||
descriptor.mockRestore();
|
||||
pathname.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('connect and supervisor pass the persistent daemon as owner', () => {
|
||||
const cli = fs.readFileSync(path.join(sourceDir, 'cli.ts'), 'utf8');
|
||||
const connect = cli.slice(cli.indexOf('// Auto-start terminal agent'), cli.indexOf('// ─── Outer Supervisor'));
|
||||
const supervisor = cli.slice(cli.indexOf('// ─── Outer Supervisor'), cli.indexOf('// ─── Headed Disconnect'));
|
||||
expect(connect).toMatch(/spawnTerminalAgent\(\{[^}]*ownerPid:\s*newState\.pid/s);
|
||||
expect(supervisor).toMatch(/spawnTerminalAgent\(\{[^}]*ownerPid:\s*respawned\.pid/s);
|
||||
});
|
||||
|
||||
test('owned agent starts, is replaced only after exit, and leaves a live sibling alone', async () => {
|
||||
const firstDir = dir();
|
||||
const siblingDir = dir();
|
||||
const first = spawn(firstDir);
|
||||
const sibling = spawn(siblingDir);
|
||||
expect(first).toBeGreaterThan(0);
|
||||
expect(sibling).toBeGreaterThan(0);
|
||||
expect(await waitFor(() => fs.existsSync(path.join(firstDir, 'terminal-port')))).toBe(true);
|
||||
const firstRecord = readAgentRecord(firstDir)!;
|
||||
const siblingRecord = readAgentRecord(siblingDir)!;
|
||||
expect(isOurAgent(firstRecord, process.pid)).toBe(true);
|
||||
const replacement = spawn(firstDir);
|
||||
expect(replacement).toBeGreaterThan(0);
|
||||
expect(replacement).not.toBe(first);
|
||||
expect(isOurAgent(firstRecord)).toBe(false);
|
||||
expect(isOurAgent(siblingRecord)).toBe(true);
|
||||
expect(readAgentRecord(firstDir)?.pid).toBe(replacement);
|
||||
});
|
||||
|
||||
test('child waits for its PID to replace the pre-spawn reservation', async () => {
|
||||
const stateDir = dir();
|
||||
const originalSpawn = Bun.spawn;
|
||||
(Bun as any).spawn = (...args: Parameters<typeof Bun.spawn>) => {
|
||||
const child = originalSpawn(...args);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 800);
|
||||
return child;
|
||||
};
|
||||
try {
|
||||
const pid = spawn(stateDir);
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
expect(await waitFor(() => fs.existsSync(path.join(stateDir, 'terminal-port')))).toBe(true);
|
||||
expect(readAgentRecord(stateDir)?.pid).toBe(pid);
|
||||
} finally { (Bun as any).spawn = originalSpawn; }
|
||||
});
|
||||
|
||||
test('failed signals retain the live record and prevent a duplicate spawn', () => {
|
||||
const stateDir = dir();
|
||||
const first = spawn(stateDir)!;
|
||||
const record = readAgentRecord(stateDir)!;
|
||||
const original = process.kill;
|
||||
(process as any).kill = ((pid: number, signal: NodeJS.Signals | number) => {
|
||||
if (pid === first && signal !== 0) throw Object.assign(new Error('denied'), { code: 'EPERM' });
|
||||
return original(pid, signal);
|
||||
}) as typeof process.kill;
|
||||
try {
|
||||
expect(spawn(stateDir)).toBeNull();
|
||||
expect(readAgentRecord(stateDir)).toEqual(record);
|
||||
} finally {
|
||||
(process as any).kill = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('a transient identity lookup failure after a signal is not confirmed exit', () => {
|
||||
const stateDir = dir();
|
||||
const first = spawn(stateDir)!;
|
||||
const record = readAgentRecord(stateDir)!;
|
||||
const originalKill = process.kill;
|
||||
const originalSpawnSync = Bun.spawnSync;
|
||||
let obscured = false;
|
||||
(process as any).kill = ((pid: number, signal: NodeJS.Signals | number) => {
|
||||
if (pid === first && signal !== 0) { obscured = true; return true; }
|
||||
return originalKill(pid, signal);
|
||||
}) as typeof process.kill;
|
||||
(Bun as any).spawnSync = (...args: Parameters<typeof Bun.spawnSync>) => {
|
||||
const command = args[0] as string[];
|
||||
if (obscured && command[0] === 'ps' && command[2] === String(first)) {
|
||||
return { exitCode: 1, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
|
||||
}
|
||||
return originalSpawnSync(...args);
|
||||
};
|
||||
try {
|
||||
expect(spawn(stateDir)).toBeNull();
|
||||
expect(readAgentRecord(stateDir)).toEqual(record);
|
||||
} finally {
|
||||
(process as any).kill = originalKill;
|
||||
(Bun as any).spawnSync = originalSpawnSync;
|
||||
}
|
||||
});
|
||||
|
||||
test('PID reuse and foreign records are never signaled', () => {
|
||||
const stateDir = dir();
|
||||
const forged: AgentRecord = {
|
||||
pid: process.pid, gen: 'synthetic-foreign-generation', startedAt: Date.now(),
|
||||
startTime: readAgentStartTime(process.pid), ownerPid: process.pid, ownerStartTime: readAgentStartTime(process.pid),
|
||||
};
|
||||
fs.writeFileSync(agentRecordPath(stateDir), JSON.stringify(forged));
|
||||
expect(isOurAgent(forged)).toBe(false);
|
||||
expect(killAgentByRecord(forged, 'SIGTERM')).toBe(false);
|
||||
expect(spawn(stateDir)).toBeNull();
|
||||
expect(readAgentRecord(stateDir)).toEqual(forged);
|
||||
clearAgentRecord(stateDir, { ...forged, gen: 'different' });
|
||||
expect(readAgentRecord(stateDir)).toEqual(forged);
|
||||
});
|
||||
|
||||
test('unwritable record path rejects before spawning any agent', () => {
|
||||
const stateDir = dir();
|
||||
const blocker = path.join(stateDir, 'blocker');
|
||||
fs.writeFileSync(blocker, 'block');
|
||||
const originalSpawn = Bun.spawn;
|
||||
let child: ReturnType<typeof Bun.spawn> | undefined;
|
||||
(Bun as any).spawn = (...args: Parameters<typeof Bun.spawn>) => {
|
||||
child = originalSpawn(...args);
|
||||
return child;
|
||||
};
|
||||
try {
|
||||
expect(() => spawnTerminalAgent({ stateFile: path.join(blocker, 'browse.json'), serverPort: 0, ownerPid: process.pid }))
|
||||
.toThrow();
|
||||
expect(child).toBeUndefined();
|
||||
expect(fs.readdirSync(stateDir)).toEqual(['blocker']);
|
||||
} finally {
|
||||
(Bun as any).spawn = originalSpawn;
|
||||
try { child?.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
test('a leftover exclusive lock refuses recovery without stealing ownership', () => {
|
||||
const stateDir = dir();
|
||||
const lock = path.join(stateDir, 'terminal-agent-pid.lock');
|
||||
fs.writeFileSync(lock, '');
|
||||
expect(() => acquireAgentStateLock(stateDir, 0)).toThrow('state lock unavailable');
|
||||
expect(fs.existsSync(lock)).toBe(true);
|
||||
expect(readAgentRecord(stateDir)).toBeNull();
|
||||
});
|
||||
|
||||
test('record update failure after spawn confirms child exit before dropping its handle', async () => {
|
||||
const stateDir = dir();
|
||||
const originalSpawn = Bun.spawn;
|
||||
const originalRename = fs.renameSync;
|
||||
let child: ReturnType<typeof Bun.spawn> | undefined;
|
||||
let writes = 0;
|
||||
const rename = spyOn(fs, 'renameSync').mockImplementation(((from: fs.PathLike, to: fs.PathLike) => {
|
||||
if (String(to) === agentRecordPath(stateDir) && ++writes === 2) {
|
||||
throw Object.assign(new Error('synthetic state write failure'), { code: 'EIO' });
|
||||
}
|
||||
return originalRename(from, to);
|
||||
}) as typeof fs.renameSync);
|
||||
(Bun as any).spawn = (...args: Parameters<typeof Bun.spawn>) => {
|
||||
child = originalSpawn(...args);
|
||||
return child;
|
||||
};
|
||||
try {
|
||||
expect(() => spawn(stateDir)).toThrow('synthetic state write failure');
|
||||
expect(writes).toBe(2);
|
||||
expect(child).toBeDefined();
|
||||
expect(await Promise.race([child!.exited.then(() => true), Bun.sleep(3000).then(() => false)])).toBe(true);
|
||||
expect(readAgentRecord(stateDir)).toBeNull();
|
||||
} finally {
|
||||
rename.mockRestore();
|
||||
(Bun as any).spawn = originalSpawn;
|
||||
try { child?.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
(process.platform === 'win32' ? test.skip : test)('unconfirmed post-write child keeps its reservation until it exits', async () => {
|
||||
const stateDir = dir();
|
||||
const originalSpawn = Bun.spawn;
|
||||
const originalRename = fs.renameSync;
|
||||
const originalKill = process.kill;
|
||||
let child: ReturnType<typeof Bun.spawn> | undefined;
|
||||
let writes = 0;
|
||||
let deniedSignals = 0;
|
||||
const rename = spyOn(fs, 'renameSync').mockImplementation(((from: fs.PathLike, to: fs.PathLike) => {
|
||||
if (String(to) === agentRecordPath(stateDir) && ++writes === 2) throw new Error('synthetic update refusal');
|
||||
return originalRename(from, to);
|
||||
}) as typeof fs.renameSync);
|
||||
(Bun as any).spawn = (...args: Parameters<typeof Bun.spawn>) => {
|
||||
child = originalSpawn(...args);
|
||||
originalKill(child.pid, 'SIGSTOP');
|
||||
return child;
|
||||
};
|
||||
(process as any).kill = ((pid: number, signal: NodeJS.Signals | number) => {
|
||||
if (child && pid === child.pid && signal !== 0) {
|
||||
deniedSignals++;
|
||||
throw Object.assign(new Error('signal denied'), { code: 'EPERM' });
|
||||
}
|
||||
return originalKill(pid, signal);
|
||||
}) as typeof process.kill;
|
||||
try {
|
||||
expect(() => spawn(stateDir)).toThrow('exit is unconfirmed');
|
||||
expect(deniedSignals).toBeGreaterThan(0);
|
||||
expect(readAgentRecord(stateDir)?.pid).toBe(0);
|
||||
expect(spawn(stateDir)).toBeNull();
|
||||
expect(readAgentRecord(stateDir)?.pid).toBe(0);
|
||||
originalKill(child!.pid, 'SIGCONT');
|
||||
expect(await Promise.race([child!.exited.then(() => true), Bun.sleep(4000).then(() => false)])).toBe(true);
|
||||
expect(await waitFor(() => readAgentRecord(stateDir) === null)).toBe(true);
|
||||
} finally {
|
||||
(process as any).kill = originalKill;
|
||||
(Bun as any).spawn = originalSpawn;
|
||||
rename.mockRestore();
|
||||
if (child) try { originalKill(child.pid, 'SIGCONT'); } catch {}
|
||||
try { child?.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
}, 6000);
|
||||
|
||||
test('owner death and record takeover shut down the old generation without deleting its successor', async () => {
|
||||
const stateDir = dir();
|
||||
const owner = Bun.spawn([process.execPath, '-e', 'process.stdin.resume()'], { stdio: ['pipe', 'ignore', 'ignore'] });
|
||||
try {
|
||||
const pid = spawn(stateDir, owner.pid)!;
|
||||
expect(await waitFor(() => fs.existsSync(path.join(stateDir, 'terminal-port')))).toBe(true);
|
||||
const record = readAgentRecord(stateDir)!;
|
||||
owner.kill('SIGTERM');
|
||||
await owner.exited;
|
||||
expect(await waitFor(() => !isOurAgent(record), 5000)).toBe(true);
|
||||
expect(readAgentRecord(stateDir)).toBeNull();
|
||||
} finally { try { owner.kill('SIGKILL'); } catch {} }
|
||||
|
||||
const first = spawn(stateDir)!;
|
||||
expect(await waitFor(() => fs.existsSync(path.join(stateDir, 'terminal-port')))).toBe(true);
|
||||
const original = readAgentRecord(stateDir)!;
|
||||
const successor = { ...original, pid: 2147483646, gen: 'synthetic-successor' };
|
||||
fs.writeFileSync(agentRecordPath(stateDir), JSON.stringify(successor));
|
||||
expect(await waitFor(() => !isOurAgent(original), 5000)).toBe(true);
|
||||
expect(readAgentRecord(stateDir)).toEqual(successor);
|
||||
expect(first).toBeGreaterThan(0);
|
||||
}, 12000);
|
||||
|
||||
test('losing concurrent startup cannot publish over the winning generation', async () => {
|
||||
const stateDir = dir();
|
||||
const stateFile = path.join(stateDir, 'browse.json');
|
||||
const barrier = path.join(stateDir, 'go');
|
||||
const ownerStartTime = readAgentStartTime(process.pid);
|
||||
const rawAgent = (gen: string, paused: boolean) => Bun.spawn(['bun', 'run', path.join(sourceDir, 'terminal-agent.ts'), `--agent-gen=${gen}`], {
|
||||
env: { ...process.env, BROWSE_STATE_FILE: stateFile, BROWSE_OWNER_PID: String(process.pid),
|
||||
BROWSE_OWNER_START_TIME: ownerStartTime, BROWSE_AGENT_GEN: gen, NODE_ENV: 'test',
|
||||
GSTACK_TERMINAL_OWNER_WATCHDOG_MS: '25',
|
||||
...(paused ? { GSTACK_TERMINAL_TEST_PUBLISH_BARRIER: barrier } : {}) },
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
const old = rawAgent('synthetic-old-generation', true);
|
||||
let winner: ReturnType<typeof Bun.spawn> | undefined;
|
||||
try {
|
||||
writeAgentRecord(stateDir, { pid: old.pid, gen: 'synthetic-old-generation', startedAt: Date.now(),
|
||||
startTime: readAgentStartTime(old.pid), ownerPid: process.pid, ownerStartTime });
|
||||
expect(await waitFor(() => fs.existsSync(`${barrier}.ready`))).toBe(true);
|
||||
winner = rawAgent('synthetic-new-generation', false);
|
||||
writeAgentRecord(stateDir, { pid: winner.pid, gen: 'synthetic-new-generation', startedAt: Date.now(),
|
||||
startTime: readAgentStartTime(winner.pid), ownerPid: process.pid, ownerStartTime });
|
||||
expect(await waitFor(() => fs.existsSync(path.join(stateDir, 'terminal-port')))).toBe(true);
|
||||
const port = fs.readFileSync(path.join(stateDir, 'terminal-port'), 'utf8');
|
||||
const token = fs.readFileSync(path.join(stateDir, 'terminal-internal-token'), 'utf8');
|
||||
fs.writeFileSync(barrier, 'continue');
|
||||
expect(await Promise.race([old.exited.then(() => true), Bun.sleep(3000).then(() => false)])).toBe(true);
|
||||
expect(readAgentRecord(stateDir)?.gen).toBe('synthetic-new-generation');
|
||||
expect(fs.readFileSync(path.join(stateDir, 'terminal-port'), 'utf8')).toBe(port);
|
||||
expect(fs.readFileSync(path.join(stateDir, 'terminal-internal-token'), 'utf8')).toBe(token);
|
||||
} finally {
|
||||
fs.writeFileSync(barrier, 'continue');
|
||||
try { old.kill('SIGKILL'); } catch {}
|
||||
try { winner?.kill('SIGKILL'); } catch {}
|
||||
await old.exited;
|
||||
if (winner) await winner.exited;
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
test('daemon respawns after agent crash, then exits without deleting a successor state', async () => {
|
||||
const stateDir = dir();
|
||||
const stateFile = path.join(stateDir, 'browse.json');
|
||||
const daemon = Bun.spawn(['bun', 'run', path.join(sourceDir, 'server.ts')], {
|
||||
env: { ...process.env, BROWSE_STATE_FILE: stateFile, BROWSE_HEADLESS_SKIP: '1', BROWSE_PARENT_PID: '0',
|
||||
GSTACK_AGENT_WATCHDOG_TICK_MS: '50', GSTACK_STATE_WATCH_MS: '50' },
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
try {
|
||||
expect(await waitFor(() => fs.existsSync(stateFile))).toBe(true);
|
||||
expect(await waitFor(() => {
|
||||
const record = readAgentRecord(stateDir);
|
||||
return !!record && record.pid > 0 && isOurAgent(record, daemon.pid);
|
||||
}, 5000)).toBe(true);
|
||||
const old = readAgentRecord(stateDir)!;
|
||||
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);
|
||||
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);
|
||||
expect(JSON.parse(fs.readFileSync(stateFile, 'utf8'))).toEqual(successor);
|
||||
} finally {
|
||||
try { daemon.kill('SIGKILL'); } catch {}
|
||||
await daemon.exited;
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as childProcess from 'node:child_process';
|
||||
import { isAgentRecordGone, isOurAgent, stopAgentByRecord } from '../src/terminal-agent-control';
|
||||
|
||||
describe('terminal-agent native exit observations', () => {
|
||||
const pid = 2147483645;
|
||||
const startTime = 'Tue Sep 22 23:39:21 2026';
|
||||
const gen = 'test-observed-generation';
|
||||
const ownedCommand = `bun run terminal-agent.ts --agent-gen=${gen}`;
|
||||
const cases = [
|
||||
{ name: 'Darwin zombie retains nonempty command text', command: '(bun)', state: 'Z', gone: true },
|
||||
{ name: 'zombie retains its generation argument', command: ownedCommand, state: 'Z', gone: true },
|
||||
{ name: 'process exits during start-time lookup', command: '', state: '', missingStart: true, reap: true, gone: true },
|
||||
{ name: 'process exits during command lookup', command: '', state: '', reap: true, gone: true },
|
||||
{ name: 'live process has a failed start-time lookup', command: '', state: 'S', missingStart: true, gone: false },
|
||||
{ name: 'live process has a failed command lookup', command: '', state: 'S', gone: false },
|
||||
{ name: 'live foreign generation is not ours', command: 'bun unrelated.ts', state: 'S', gone: false },
|
||||
{ name: 'live owned generation remains ours', command: ownedCommand, state: 'S', gone: false, owned: true },
|
||||
{ name: 'failed state probe cannot certify a zombie', command: '', state: 'Z', stateStatus: 1, gone: false },
|
||||
{ name: 'process exits before the owned signal', command: ownedCommand, state: 'S', gone: false, owned: true, reapOnSignal: true },
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
test(scenario.name, () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
let live = true;
|
||||
const signals: unknown[] = [];
|
||||
const kill = spyOn(process, 'kill').mockImplementation(((target: number, signal: unknown) => {
|
||||
expect(target).toBe(pid);
|
||||
if (signal !== 0) {
|
||||
signals.push(signal);
|
||||
if (scenario.reapOnSignal) { live = false; throw Object.assign(new Error('gone'), { code: 'ESRCH' }); }
|
||||
throw new Error('unexpected signal');
|
||||
}
|
||||
if (!live) throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
return true;
|
||||
}) as typeof process.kill);
|
||||
const probe = spyOn(Bun, 'spawnSync').mockImplementation(((command: string[]) => {
|
||||
expect(command.slice(0, 3)).toEqual(['ps', '-p', String(pid)]);
|
||||
const start = command[4] === 'lstart=';
|
||||
if (scenario.reap && (!start || scenario.missingStart)) live = false;
|
||||
return {
|
||||
exitCode: start && scenario.missingStart ? 1 : 0,
|
||||
stdout: Buffer.from(start ? scenario.missingStart ? '' : startTime : scenario.command),
|
||||
stderr: Buffer.alloc(0),
|
||||
};
|
||||
}) as typeof Bun.spawnSync);
|
||||
const state = spyOn(childProcess, 'spawnSync').mockReturnValue({
|
||||
status: scenario.stateStatus ?? 0, stdout: scenario.state, stderr: '',
|
||||
} as any);
|
||||
Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' });
|
||||
try {
|
||||
const record = { pid, gen, startTime, startedAt: 0, ownerPid: pid, ownerStartTime: startTime };
|
||||
expect(isAgentRecordGone(record)).toBe(scenario.gone);
|
||||
expect(isOurAgent(record)).toBe(scenario.owned ?? false);
|
||||
if (scenario.gone || scenario.reapOnSignal) expect(stopAgentByRecord(record, 0)).toBe(true);
|
||||
expect(signals).toEqual(scenario.reapOnSignal ? ['SIGTERM'] : []);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
state.mockRestore();
|
||||
probe.mockRestore();
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -19,8 +19,8 @@ describe('terminal-agent watchdog (v1.44+)', () => {
|
||||
expect(src).toMatch(/export function spawnTerminalAgent\(/);
|
||||
// Must clean up prior PID before spawning (no zombies).
|
||||
expect(src).toContain('readAgentRecord(stateDir)');
|
||||
expect(src).toContain('killAgentByRecord(prior');
|
||||
expect(src).toContain('clearAgentRecord(stateDir)');
|
||||
expect(src).toContain('stopAgentByRecord(prior)');
|
||||
expect(src).toContain('clearAgentRecord(stateDir, prior)');
|
||||
});
|
||||
|
||||
test('2. watchdog is gated on ownsTerminalAgent', () => {
|
||||
@@ -39,7 +39,11 @@ describe('terminal-agent watchdog (v1.44+)', () => {
|
||||
// identity-based liveness. Slow-but-alive agents must NOT trigger
|
||||
// respawn (split-brain defense).
|
||||
expect(block).toContain('readAgentRecord(stateDir)');
|
||||
expect(block).toContain('isProcessAlive(record.pid)');
|
||||
expect(block).toContain('isAgentRecordGone(record)');
|
||||
const control = fs.readFileSync(CONTROL_TS, 'utf-8');
|
||||
expect(control).toContain('if (result.status === 0) state = result.stdout?.trim()?.[0];');
|
||||
expect(control).toContain("if (state === 'Z') return 'gone'");
|
||||
expect(control.indexOf("if (state === 'Z') return 'gone'")).toBeLessThan(control.indexOf('return actual.commandLine.split'));
|
||||
// Negative: no executable name-based process lookup. Allow the strings
|
||||
// to appear in prose comments (the watchdog doc explains what it
|
||||
// replaces), reject only actual invocations.
|
||||
|
||||
Reference in New Issue
Block a user