mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-26 22:51:47 +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:
+18
-2
@@ -527,6 +527,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
// Parse as int so stray whitespace ("0\n") still opts out — matches the
|
||||
// server's own parseInt at server.ts:760.
|
||||
const parentPid = parseInt(process.env.BROWSE_PARENT_PID || '', 10) === 0 ? '0' : String(process.pid);
|
||||
let spawnedServer: { pid: number; startTime: string } | null = null;
|
||||
|
||||
if (IS_WINDOWS && NODE_SERVER_SCRIPT) {
|
||||
// Windows: Bun.spawn() + proc.unref() doesn't truly detach on Windows —
|
||||
@@ -561,12 +562,14 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
// (PPID=1, STAT=Ss) and survives the spawning shell's exit. Mirrors
|
||||
// the Windows path's rationale — same root cause, different OS API.
|
||||
const daemonLogFd = openDaemonLogSink();
|
||||
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
|
||||
const child = nodeSpawn('bun', ['run', SERVER_SCRIPT], {
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', daemonLogFd, daemonLogFd],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
|
||||
}).unref();
|
||||
});
|
||||
child.unref();
|
||||
if (child.pid) spawnedServer = { pid: child.pid, startTime: readPidStartTime(child.pid) };
|
||||
}
|
||||
|
||||
// Wait for server to become healthy.
|
||||
@@ -592,6 +595,17 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
return lateState;
|
||||
}
|
||||
|
||||
if (spawnedServer?.startTime) {
|
||||
const { pid, startTime } = spawnedServer;
|
||||
const stillOurs = () => readPidStartTime(pid) === startTime && readPidCmdline(pid).split(/\s+/).includes(SERVER_SCRIPT);
|
||||
if (stillOurs()) {
|
||||
safeKill(pid, 'SIGTERM');
|
||||
const deadline = Date.now() + 500;
|
||||
while (Date.now() < deadline && stillOurs()) await Bun.sleep(50);
|
||||
if (stillOurs()) safeKill(pid, 'SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
// Server didn't start in time — check the on-disk startup error log.
|
||||
// Both platforms now spawn with stdio: 'ignore', so the server writes
|
||||
// errors to disk for the CLI to read (see server.ts start().catch).
|
||||
@@ -1664,6 +1678,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
const newPid = spawnTerminalAgent({
|
||||
stateFile: config.stateFile,
|
||||
serverPort: newState.port,
|
||||
ownerPid: newState.pid,
|
||||
cwd: config.projectDir,
|
||||
});
|
||||
if (newPid) {
|
||||
@@ -1756,6 +1771,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
spawnTerminalAgent({
|
||||
stateFile: config.stateFile,
|
||||
serverPort: respawned.port,
|
||||
ownerPid: respawned.pid,
|
||||
cwd: config.projectDir,
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
||||
+106
-25
@@ -51,7 +51,7 @@ import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling';
|
||||
import {
|
||||
findAvailablePort, formatExplicitPortUnavailableError, formatRandomPortUnavailableError,
|
||||
} from './port-allocator';
|
||||
import { readAgentRecord, killAgentByRecord, agentRecordPath, spawnTerminalAgent } from './terminal-agent-control';
|
||||
import { acquireAgentStateLock, readAgentRecord, clearAgentRecord, isOurAgent, isAgentRecordLive, isAgentRecordGone, stopAgentByRecord, spawnTerminalAgent } from './terminal-agent-control';
|
||||
import { isProcessAlive } from './error-handling';
|
||||
import { sanitizeBody, stripLoneSurrogateEscapes, stripLoneSurrogates, sanitizeReplacer } from './sanitize';
|
||||
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
|
||||
@@ -75,6 +75,18 @@ import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const SERVER_INSTANCE_ID = crypto.randomUUID();
|
||||
|
||||
function removeOwnedDaemonStateQuiet(): void {
|
||||
try {
|
||||
const release = acquireAgentStateLock(path.dirname(config.stateFile), 0);
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(config.stateFile, 'utf8'));
|
||||
if (state.pid === process.pid && state.instanceId === SERVER_INSTANCE_ID) safeUnlinkQuiet(config.stateFile);
|
||||
} finally { release(); }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── Unicode Sanitization ───────────────────────────────────────
|
||||
// Unpaired UTF-16 surrogate halves (\uD800–\uDFFF) in page DOM text, OCR
|
||||
// output, and other CDP-sourced strings are rejected by JSON consumers
|
||||
@@ -466,11 +478,15 @@ async function startTunnel(opts: {
|
||||
console.log(`[browse] Tunnel listener bound on 127.0.0.1:${tunnelPort}, ngrok → ${tunnelUrl}`);
|
||||
|
||||
// Update state file
|
||||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||||
stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() };
|
||||
const tmpState = tmpStatePath();
|
||||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmpState, config.stateFile);
|
||||
const releaseStateLock = acquireAgentStateLock(config.stateDir);
|
||||
try {
|
||||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||||
if (stateContent.pid !== process.pid || stateContent.instanceId !== SERVER_INSTANCE_ID) throw new Error('daemon state was replaced');
|
||||
stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() };
|
||||
const tmpState = tmpStatePath();
|
||||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmpState, config.stateFile);
|
||||
} finally { releaseStateLock(); }
|
||||
|
||||
return { ok: true, url: tunnelUrl! };
|
||||
} catch (err: any) {
|
||||
@@ -735,6 +751,7 @@ const idleCheckInterval = setInterval(idleCheckTick, 60_000);
|
||||
// Production code must never import this — see `idle timer + onDisconnect
|
||||
// dual-instance fix` describe block for usage.
|
||||
export const __testInternals__ = {
|
||||
serverInstanceId: SERVER_INSTANCE_ID,
|
||||
idleCheckTick,
|
||||
// Watchdog seams (watchdog.test.ts): drive the 15s poll against an
|
||||
// arbitrary (dead) PID, trigger the handoff-promotion suppression exactly
|
||||
@@ -1432,9 +1449,7 @@ if (import.meta.main) {
|
||||
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
|
||||
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
||||
if (process.platform === 'win32') {
|
||||
process.on('exit', () => {
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
});
|
||||
process.on('exit', removeOwnedDaemonStateQuiet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1452,6 +1467,7 @@ function emergencyCleanup() {
|
||||
if (fs.existsSync(config.stateFile)) {
|
||||
const raw = fs.readFileSync(config.stateFile, 'utf-8');
|
||||
const state = JSON.parse(raw);
|
||||
if (state.pid !== process.pid || state.instanceId !== SERVER_INSTANCE_ID) return;
|
||||
if (state.xvfbPid && state.xvfbStartTime) {
|
||||
// Lazy import — emergencyCleanup may run on platforms where
|
||||
// ./xvfb's Linux-specific helpers fail to load. Best effort.
|
||||
@@ -1472,7 +1488,7 @@ function emergencyCleanup() {
|
||||
if (activeBrowserManager.getConnectionMode() === 'headed' || process.env.BROWSE_HEADED === '1') {
|
||||
cleanSingletonLocks(resolveChromiumProfile());
|
||||
}
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
removeOwnedDaemonStateQuiet();
|
||||
}
|
||||
// Same import.meta.main gate as SIGINT/SIGTERM — embedders register their
|
||||
// own crash handlers.
|
||||
@@ -1582,6 +1598,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
AGENT_WATCHDOG_TICK_MS * (RESPAWN_GUARD_MAX + 2),
|
||||
);
|
||||
let agentRespawnGuardTripped = false;
|
||||
let consecutiveSpawnFailures = 0;
|
||||
|
||||
if (ownsTerminalAgent) {
|
||||
agentWatchdogInterval = setInterval(() => {
|
||||
@@ -1594,7 +1611,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
// intentionally fall through here — split-brain is worse than
|
||||
// unresponsiveness, and slow recovery is handled by the user via
|
||||
// restart.
|
||||
if (record && isProcessAlive(record.pid)) return;
|
||||
if (record && !isAgentRecordGone(record)) return;
|
||||
// Either no record (never spawned, or cleaned up after crash) or
|
||||
// PID is dead. Try to respawn.
|
||||
const now = Date.now();
|
||||
@@ -1617,13 +1634,20 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
cwd: cfg.config.projectDir,
|
||||
});
|
||||
if (pid) {
|
||||
consecutiveSpawnFailures = 0;
|
||||
console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`);
|
||||
} else {
|
||||
consecutiveSpawnFailures++;
|
||||
console.warn('[browse] terminal-agent respawn skipped — script not found on disk');
|
||||
}
|
||||
} catch (err: any) {
|
||||
consecutiveSpawnFailures++;
|
||||
console.warn('[browse] terminal-agent respawn failed:', err?.message || err);
|
||||
}
|
||||
if (consecutiveSpawnFailures >= RESPAWN_GUARD_MAX) {
|
||||
agentRespawnGuardTripped = true;
|
||||
console.error('[browse] terminal-agent respawn guard tripped after repeated failed starts — manual restart required');
|
||||
}
|
||||
}, AGENT_WATCHDOG_TICK_MS);
|
||||
// Detach the watchdog timer from Node's event-loop ref count so a
|
||||
// healthy idle process can still exit cleanly if everything else is
|
||||
@@ -1654,23 +1678,44 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
// State and terminal files belong to this instance, including embedders
|
||||
// whose cfg differs from the standalone daemon's module-level config.
|
||||
const config = cfg.config;
|
||||
const stateOwner = (() => {
|
||||
try { return JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); } catch { return null; }
|
||||
})();
|
||||
const foreignState = Number.isSafeInteger(stateOwner?.pid) && stateOwner.pid > 0
|
||||
&& (stateOwner.pid !== process.pid || (stateOwner.instanceId && stateOwner.instanceId !== SERVER_INSTANCE_ID));
|
||||
|
||||
console.log('[browse] Shutting down...');
|
||||
if (ownsTerminalAgent) {
|
||||
if (ownsTerminalAgent && !foreignState) {
|
||||
// Identity-based kill (v1.44+). Replaces the v1.43- `pkill -f
|
||||
// terminal-agent\.ts` regex teardown which matched sibling gstack
|
||||
// sessions on the same host. Only the PID recorded in
|
||||
// `<stateDir>/terminal-agent-pid` by THIS daemon's agent is signaled.
|
||||
try {
|
||||
const stateDir = path.dirname(config.stateFile);
|
||||
const record = readAgentRecord(stateDir);
|
||||
if (record) killAgentByRecord(record, 'SIGTERM');
|
||||
const releaseAgentLock = acquireAgentStateLock(stateDir);
|
||||
try {
|
||||
let currentState: { pid?: number; instanceId?: string } | null = null;
|
||||
try { currentState = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); } catch {}
|
||||
if (currentState?.pid && (currentState.pid !== process.pid
|
||||
|| (currentState.instanceId && currentState.instanceId !== SERVER_INSTANCE_ID))) {
|
||||
console.warn('[browse] terminal-agent state now belongs to a successor; retaining its files');
|
||||
} else {
|
||||
const record = readAgentRecord(stateDir);
|
||||
const agentStopped = !record || (record.pid !== 0
|
||||
&& (!isAgentRecordLive(record) || (isOurAgent(record, process.pid) && stopAgentByRecord(record))));
|
||||
const current = readAgentRecord(stateDir);
|
||||
if (agentStopped && (!record || (current?.pid === record.pid && current.gen === record.gen))) {
|
||||
safeUnlinkQuiet(path.join(stateDir, 'terminal-port'));
|
||||
safeUnlinkQuiet(path.join(stateDir, 'terminal-internal-token'));
|
||||
if (record) clearAgentRecord(stateDir, record);
|
||||
} else if (!agentStopped) {
|
||||
console.warn('[browse] terminal-agent identity or exit could not be confirmed; retaining its record');
|
||||
}
|
||||
}
|
||||
} finally { releaseAgentLock(); }
|
||||
} catch (err: any) {
|
||||
console.warn('[browse] Failed to kill terminal-agent:', err.message);
|
||||
console.warn('[browse] Failed to stop terminal-agent; retaining its state:', err.message);
|
||||
}
|
||||
safeUnlinkQuiet(path.join(path.dirname(config.stateFile), 'terminal-port'));
|
||||
safeUnlinkQuiet(path.join(path.dirname(config.stateFile), 'terminal-internal-token'));
|
||||
safeUnlinkQuiet(agentRecordPath(path.dirname(config.stateFile)));
|
||||
}
|
||||
try { detachSession(); } catch (err: any) {
|
||||
console.warn('[browse] Failed to detach CDP session:', err.message);
|
||||
@@ -1712,7 +1757,17 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
if (cfgBrowserManager.getConnectionMode() === 'headed') {
|
||||
cleanSingletonLocks(resolveChromiumProfile());
|
||||
}
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
if (!foreignState) {
|
||||
try {
|
||||
const releaseStateLock = acquireAgentStateLock(path.dirname(config.stateFile));
|
||||
try {
|
||||
const currentState = JSON.parse(fs.readFileSync(config.stateFile, 'utf8'));
|
||||
if (currentState.pid === process.pid && currentState.instanceId === SERVER_INSTANCE_ID) safeUnlinkQuiet(config.stateFile);
|
||||
} finally { releaseStateLock(); }
|
||||
} catch (err: any) {
|
||||
if (fs.existsSync(config.stateFile)) console.warn('[browse] Daemon state cleanup could not confirm ownership:', err?.message || err);
|
||||
}
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
@@ -3182,6 +3237,7 @@ export async function start() {
|
||||
// Write state file (atomic: write .tmp then rename)
|
||||
const state: Record<string, unknown> = {
|
||||
pid: process.pid,
|
||||
instanceId: SERVER_INSTANCE_ID,
|
||||
port,
|
||||
token: envCfg.authToken,
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -3206,7 +3262,28 @@ export async function start() {
|
||||
};
|
||||
const tmpFile = tmpStatePath();
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmpFile, config.stateFile);
|
||||
try {
|
||||
const releaseStateLock = acquireAgentStateLock(config.stateDir);
|
||||
try { fs.renameSync(tmpFile, config.stateFile); } finally { releaseStateLock(); }
|
||||
} catch (err) {
|
||||
safeUnlinkQuiet(tmpFile);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const stateWatchMs = parseInt(process.env.GSTACK_STATE_WATCH_MS || '60000', 10);
|
||||
if (stateWatchMs > 0) {
|
||||
let missed = 0;
|
||||
const stateWatch = setInterval(() => {
|
||||
let owner: { pid?: number; instanceId?: string } | null = null;
|
||||
try { owner = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); } catch {}
|
||||
if (owner?.pid === process.pid && owner.instanceId === SERVER_INSTANCE_ID) { missed = 0; return; }
|
||||
if (++missed < 2) return;
|
||||
clearInterval(stateWatch);
|
||||
console.warn('[browse] daemon state is no longer reachable; shutting down this instance');
|
||||
handle.shutdown();
|
||||
}, stateWatchMs);
|
||||
(stateWatch as any).unref?.();
|
||||
}
|
||||
|
||||
browserManager.serverPort = port;
|
||||
|
||||
@@ -3338,11 +3415,15 @@ export async function start() {
|
||||
tunnelActive = true;
|
||||
const tunnelPort = boundTunnel.port;
|
||||
console.log(`[browse] Tunnel listener bound (local-only test mode) on 127.0.0.1:${tunnelPort}`);
|
||||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||||
stateContent.tunnelLocalPort = tunnelPort;
|
||||
const tmpState = tmpStatePath();
|
||||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmpState, config.stateFile);
|
||||
const releaseStateLock = acquireAgentStateLock(config.stateDir);
|
||||
try {
|
||||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||||
if (stateContent.pid !== process.pid || stateContent.instanceId !== SERVER_INSTANCE_ID) throw new Error('daemon state was replaced');
|
||||
stateContent.tunnelLocalPort = tunnelPort;
|
||||
const tmpState = tmpStatePath();
|
||||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmpState, config.stateFile);
|
||||
} finally { releaseStateLock(); }
|
||||
} catch (err: any) {
|
||||
console.error(`[browse] BROWSE_TUNNEL_LOCAL_ONLY=1 listener bind failed: ${err.message}`);
|
||||
}
|
||||
|
||||
@@ -16,9 +16,57 @@
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { safeUnlink, safeKill, isProcessAlive } from './error-handling';
|
||||
import * as crypto from 'crypto';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { safeUnlink, isProcessAlive } from './error-handling';
|
||||
import { restrictFilePermissions, mkdirSecure } from './file-permissions';
|
||||
import { atomicWriteSync } from '../../lib/fs-atomic';
|
||||
import { readPidCmdline, readPidStartTime } from './xvfb';
|
||||
|
||||
function agentProcessInfo(pid: number): { startTime: string; commandLine: string } {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' };
|
||||
if (process.platform !== 'win32') return { startTime: readPidStartTime(pid), commandLine: readPidCmdline(pid) };
|
||||
const script = `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object CreationDate,CommandLine | ConvertTo-Json -Compress)`;
|
||||
try {
|
||||
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true, timeout: 2000 });
|
||||
if (result.status !== 0 || !result.stdout) return { startTime: '', commandLine: '' };
|
||||
const processInfo = JSON.parse(result.stdout);
|
||||
return { startTime: processInfo?.CreationDate || '', commandLine: processInfo?.CommandLine || '' };
|
||||
} catch { return { startTime: '', commandLine: '' }; }
|
||||
}
|
||||
|
||||
export function readAgentStartTime(pid: number): string {
|
||||
return agentProcessInfo(pid).startTime;
|
||||
}
|
||||
|
||||
const pendingAgentExits = new Set<any>();
|
||||
|
||||
export function acquireAgentStateLock(stateDir: string, waitMs = 5000): () => void {
|
||||
mkdirSecure(stateDir);
|
||||
const lockPath = path.join(stateDir, 'terminal-agent-pid.lock');
|
||||
const deadline = Date.now() + waitMs;
|
||||
let fd: number;
|
||||
while (true) {
|
||||
try {
|
||||
fd = fs.openSync(lockPath, 'wx', 0o600);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'EEXIST' || Date.now() >= deadline) {
|
||||
throw new Error(`terminal-agent state lock unavailable at ${lockPath}: ${err?.code || err}; inspect the owning process before manual recovery`);
|
||||
}
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
||||
}
|
||||
}
|
||||
const owned = fs.fstatSync(fd, { bigint: true });
|
||||
return () => {
|
||||
try {
|
||||
const current = fs.statSync(lockPath, { bigint: true });
|
||||
if (current.dev === owned.dev && current.ino === owned.ino) fs.unlinkSync(lockPath);
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') throw err;
|
||||
} finally { fs.closeSync(fd); }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the terminal-agent script on disk. In dev (cli.ts running via
|
||||
@@ -41,11 +89,8 @@ export function resolveTerminalAgentScript(searchHints: { metaDir?: string; exec
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a fresh terminal-agent as a detached child. Handles the standard
|
||||
* three steps: kill any prior agent recorded at `<stateDir>/terminal-agent-pid`,
|
||||
* clear the stale record, then `Bun.spawn(['bun', 'run', script], ...)` with
|
||||
* env wiring. Returns the PID of the new agent on success, null when the
|
||||
* agent script can't be located.
|
||||
* Spawn an owned terminal-agent. A prior record is retained until its exact
|
||||
* process exits, and the new generation is recorded before it may bind.
|
||||
*
|
||||
* Used by both the CLI cold-start path (cli.ts) and the v1.44 watchdog in
|
||||
* server.ts. Centralizing here removes a copy-paste between them and means
|
||||
@@ -62,31 +107,82 @@ export function spawnTerminalAgent(opts: {
|
||||
/** Override script lookup for tests. */
|
||||
scriptPath?: string;
|
||||
}): number | null {
|
||||
if (!Number.isSafeInteger(opts.ownerPid) || opts.ownerPid <= 0) throw new Error('terminal-agent requires a daemon owner PID');
|
||||
const stateDir = path.dirname(opts.stateFile);
|
||||
const prior = readAgentRecord(stateDir);
|
||||
if (prior) {
|
||||
killAgentByRecord(prior, 'SIGTERM');
|
||||
clearAgentRecord(stateDir);
|
||||
}
|
||||
const script = opts.scriptPath || resolveTerminalAgentScript();
|
||||
if (!script || !fs.existsSync(script)) return null;
|
||||
const proc = (Bun as any).spawn(['bun', 'run', script], {
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_STATE_FILE: opts.stateFile,
|
||||
BROWSE_SERVER_PORT: String(opts.serverPort),
|
||||
BROWSE_OWNER_PID: String(opts.ownerPid),
|
||||
...(opts.extraEnv || {}),
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
// Explicit for the Node fallback path (dist/bun-polyfill.cjs), where the
|
||||
// host default is the opposite of Bun's. A visible console window on every
|
||||
// watchdog respawn is the symptom when this is missing.
|
||||
windowsHide: true,
|
||||
});
|
||||
proc.unref?.();
|
||||
return proc.pid ?? null;
|
||||
const release = acquireAgentStateLock(stateDir);
|
||||
try {
|
||||
const prior = readAgentRecord(stateDir);
|
||||
if (prior) {
|
||||
if (prior.pid === 0) {
|
||||
console.warn('[browse] terminal-agent startup or failed exit remains unconfirmed; retaining its reservation');
|
||||
return null;
|
||||
}
|
||||
if (isAgentRecordLive(prior) && !stopAgentByRecord(prior)) {
|
||||
console.warn(`[browse] terminal-agent PID ${prior.pid} is still running or its identity cannot be confirmed; refusing a second agent`);
|
||||
return null;
|
||||
}
|
||||
clearAgentRecord(stateDir, prior);
|
||||
safeUnlink(path.join(stateDir, 'terminal-port'));
|
||||
safeUnlink(path.join(stateDir, 'terminal-internal-token'));
|
||||
}
|
||||
const ownerStartTime = readAgentStartTime(opts.ownerPid);
|
||||
if (!ownerStartTime) throw new Error('terminal-agent owner identity is unavailable');
|
||||
const gen = crypto.randomBytes(16).toString('base64url');
|
||||
const reservation: AgentRecord = { pid: 0, gen, startedAt: Date.now(), ownerPid: opts.ownerPid, ownerStartTime };
|
||||
writeAgentRecord(stateDir, reservation);
|
||||
let proc: any;
|
||||
try {
|
||||
proc = (Bun as any).spawn(['bun', 'run', script, `--agent-gen=${gen}`], {
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...(opts.extraEnv || {}),
|
||||
BROWSE_STATE_FILE: opts.stateFile,
|
||||
BROWSE_SERVER_PORT: String(opts.serverPort),
|
||||
BROWSE_OWNER_PID: String(opts.ownerPid),
|
||||
BROWSE_OWNER_START_TIME: ownerStartTime,
|
||||
BROWSE_AGENT_GEN: gen,
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (err) {
|
||||
clearAgentRecord(stateDir, reservation);
|
||||
throw err;
|
||||
}
|
||||
const retainUntilExit = () => {
|
||||
pendingAgentExits.add(proc);
|
||||
proc.exited?.then(() => {
|
||||
try {
|
||||
const releasePending = acquireAgentStateLock(stateDir);
|
||||
try { clearAgentRecord(stateDir, reservation); } finally { releasePending(); }
|
||||
} catch (err) { console.warn('[browse] terminal-agent pending exit cleanup failed:', err); }
|
||||
pendingAgentExits.delete(proc);
|
||||
}, (err: unknown) => console.warn('[browse] terminal-agent exit remains unconfirmed:', err));
|
||||
};
|
||||
const pid = proc.pid;
|
||||
const startTime = pid ? readAgentStartTime(pid) : '';
|
||||
if (!pid || !startTime) {
|
||||
try { proc.kill('SIGTERM'); } catch {}
|
||||
retainUntilExit();
|
||||
throw new Error('terminal-agent process identity is unavailable');
|
||||
}
|
||||
const record: AgentRecord = { pid, gen, startedAt: Date.now(), startTime, ownerPid: opts.ownerPid, ownerStartTime };
|
||||
try {
|
||||
writeAgentRecord(stateDir, record);
|
||||
} catch (err) {
|
||||
if (!stopAgentByRecord(record)) {
|
||||
retainUntilExit();
|
||||
throw new Error(`terminal-agent record update failed and child ${pid} exit is unconfirmed: ${err}`);
|
||||
}
|
||||
clearAgentRecord(stateDir, reservation);
|
||||
throw err;
|
||||
}
|
||||
proc.unref?.();
|
||||
return pid;
|
||||
} finally { release(); }
|
||||
}
|
||||
|
||||
export interface AgentRecord {
|
||||
@@ -95,6 +191,9 @@ export interface AgentRecord {
|
||||
gen: string;
|
||||
/** ms since epoch. Reserved for future PID-reuse guards. */
|
||||
startedAt: number;
|
||||
startTime?: string;
|
||||
ownerPid?: number;
|
||||
ownerStartTime?: string;
|
||||
}
|
||||
|
||||
export function agentRecordPath(stateDir: string): string {
|
||||
@@ -124,27 +223,79 @@ export function writeAgentRecord(stateDir: string, record: AgentRecord): void {
|
||||
restrictFilePermissions(target);
|
||||
}
|
||||
|
||||
export function clearAgentRecord(stateDir: string): void {
|
||||
export function clearAgentRecord(stateDir: string, expected?: AgentRecord): void {
|
||||
if (expected) {
|
||||
const current = readAgentRecord(stateDir);
|
||||
if (!current || current.pid !== expected.pid || current.gen !== expected.gen) return;
|
||||
}
|
||||
safeUnlink(agentRecordPath(stateDir));
|
||||
}
|
||||
|
||||
export function isAgentRecordLive(record: AgentRecord): boolean {
|
||||
return Number.isSafeInteger(record.pid) && record.pid > 0 && isProcessAlive(record.pid);
|
||||
}
|
||||
|
||||
function agentStatus(record: AgentRecord, ownerPid?: number): 'owned' | 'gone' | 'unknown' {
|
||||
if (!isAgentRecordLive(record)) return 'gone';
|
||||
if (!record.startTime || !record.ownerPid || !record.ownerStartTime) return 'unknown';
|
||||
if (ownerPid !== undefined && (record.ownerPid !== ownerPid || record.ownerStartTime !== readAgentStartTime(ownerPid))) return 'unknown';
|
||||
const actual = agentProcessInfo(record.pid);
|
||||
if (!actual.startTime) return isAgentRecordLive(record) ? 'unknown' : 'gone';
|
||||
if (actual.startTime !== record.startTime) return 'gone';
|
||||
try {
|
||||
let state: string | undefined;
|
||||
if (process.platform === 'linux') {
|
||||
state = fs.readFileSync(`/proc/${record.pid}/stat`, 'utf8').match(/^\d+ \(.*\) ([A-Z])/u)?.[1];
|
||||
} else if (process.platform === 'darwin') {
|
||||
const result = spawnSync('ps', ['-p', String(record.pid), '-o', 'stat='], { encoding: 'utf8', windowsHide: true, timeout: 2000 });
|
||||
if (result.status === 0) state = result.stdout?.trim()?.[0];
|
||||
}
|
||||
if (state === 'Z') return 'gone';
|
||||
} catch {}
|
||||
if (!isAgentRecordLive(record)) return 'gone';
|
||||
return actual.commandLine.split(/\s+/).some(arg => arg.replace(/^['"]|['"]$/g, '') === `--agent-gen=${record.gen}`)
|
||||
? 'owned' : 'unknown';
|
||||
}
|
||||
|
||||
export function isOurAgent(record: AgentRecord, ownerPid?: number): boolean {
|
||||
return agentStatus(record, ownerPid) === 'owned';
|
||||
}
|
||||
|
||||
export function isAgentRecordGone(record: AgentRecord): boolean {
|
||||
return agentStatus(record) === 'gone';
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the agent identified by `record`. Signal defaults to SIGTERM (give
|
||||
* the agent a chance to run its own SIGTERM cleanup). Returns true if a
|
||||
* signal was actually sent to a live PID; false if the PID was already
|
||||
* dead (no-op). Never throws — ESRCH is swallowed by safeKill.
|
||||
*
|
||||
* Validates liveness BEFORE signaling so a PID-reuse race (the recorded
|
||||
* PID was reaped and a brand-new unrelated process now holds it) can't
|
||||
* cause us to kill the wrong process. This is a best-effort defense:
|
||||
* Linux/macOS don't expose process-start-time cheaply, and the gap
|
||||
* between record-write and watchdog-tick is small (60s max).
|
||||
* signal reached an exact-generation live process, false otherwise.
|
||||
*/
|
||||
export function killAgentByRecord(
|
||||
record: AgentRecord,
|
||||
signal: NodeJS.Signals = 'SIGTERM',
|
||||
): boolean {
|
||||
if (!isProcessAlive(record.pid)) return false;
|
||||
safeKill(record.pid, signal);
|
||||
return true;
|
||||
if (!isOurAgent(record)) return false;
|
||||
try { process.kill(record.pid, signal); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
export function stopAgentByRecord(record: AgentRecord, graceMs = 1000): boolean {
|
||||
const initial = agentStatus(record);
|
||||
if (initial === 'gone') return true;
|
||||
if (initial !== 'owned') return false;
|
||||
const waitForExit = (ms: number) => {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
const status = agentStatus(record);
|
||||
if (status === 'gone') return true;
|
||||
if (status === 'unknown') return false;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
||||
}
|
||||
return agentStatus(record) === 'gone';
|
||||
};
|
||||
if (!killAgentByRecord(record, 'SIGTERM')) return agentStatus(record) === 'gone';
|
||||
if (waitForExit(graceMs)) return true;
|
||||
const afterGrace = agentStatus(record);
|
||||
if (afterGrace !== 'owned') return afterGrace === 'gone';
|
||||
if (!killAgentByRecord(record, 'SIGKILL')) return agentStatus(record) === 'gone';
|
||||
return waitForExit(graceMs);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import * as crypto from 'crypto';
|
||||
import { writeSecureFile, restrictFilePermissions, mkdirSecure } from './file-permissions';
|
||||
import { atomicWriteSync, atomicWriteQuiet } from '../../lib/fs-atomic';
|
||||
import { safeUnlink } from './error-handling';
|
||||
import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control';
|
||||
import { writeAgentRecord, readAgentRecord, clearAgentRecord, readAgentStartTime, acquireAgentStateLock } from './terminal-agent-control';
|
||||
import { findAvailablePort } from './port-allocator';
|
||||
import { extractPtyCookie } from './pty-session-cookie';
|
||||
import {
|
||||
@@ -38,6 +38,7 @@ const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME |
|
||||
const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port');
|
||||
const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10);
|
||||
const BROWSE_OWNER_PID = parseInt(process.env.BROWSE_OWNER_PID || '0', 10);
|
||||
const BROWSE_OWNER_START_TIME = process.env.BROWSE_OWNER_START_TIME || (BROWSE_OWNER_PID > 0 ? readAgentStartTime(BROWSE_OWNER_PID) : '');
|
||||
const OWNER_WATCHDOG_MS = parseInt(
|
||||
process.env.GSTACK_TERMINAL_OWNER_WATCHDOG_MS || '15000',
|
||||
10,
|
||||
@@ -51,7 +52,7 @@ const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared w
|
||||
* header means "legacy caller" and is accepted (backward compat); a
|
||||
* present-but-mismatched header returns 409 stale generation.
|
||||
*/
|
||||
const CURRENT_GEN = crypto.randomBytes(16).toString('base64url');
|
||||
const CURRENT_GEN = process.env.BROWSE_AGENT_GEN || crypto.randomBytes(16).toString('base64url');
|
||||
|
||||
// In-memory attach-token registry. Parent posts /internal/grant after
|
||||
// /pty-session; we validate WS upgrades against this map.
|
||||
@@ -1004,6 +1005,25 @@ function readBrowseToken(): string {
|
||||
|
||||
// Boot.
|
||||
async function main() {
|
||||
const dir = path.dirname(PORT_FILE);
|
||||
if (process.env.BROWSE_AGENT_GEN) {
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline) {
|
||||
const pending = readAgentRecord(dir);
|
||||
if (pending?.gen === CURRENT_GEN && pending.pid === process.pid) break;
|
||||
if (pending && pending.gen !== CURRENT_GEN) throw new Error('terminal-agent startup record was replaced');
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
const recorded = readAgentRecord(dir);
|
||||
if (recorded?.pid !== process.pid || recorded.ownerPid !== BROWSE_OWNER_PID || recorded.ownerStartTime !== BROWSE_OWNER_START_TIME) {
|
||||
throw new Error('terminal-agent startup record was not confirmed');
|
||||
}
|
||||
}
|
||||
const pauseFile = process.env.NODE_ENV === 'test' ? process.env.GSTACK_TERMINAL_TEST_PUBLISH_BARRIER : undefined;
|
||||
if (pauseFile) {
|
||||
fs.writeFileSync(`${pauseFile}.ready`, 'ready');
|
||||
while (!fs.existsSync(pauseFile)) await Bun.sleep(10);
|
||||
}
|
||||
writeClaudeAvailable();
|
||||
// #2314: allocate from the shared fixed scan range, then bind. Probe-then-
|
||||
// bind has a TOCTOU window — a concurrent process can take the port between
|
||||
@@ -1032,17 +1052,21 @@ async function main() {
|
||||
|
||||
// Write port file atomically so the parent server can pick it up.
|
||||
// Throws on failure — a boot without a discoverable port file is broken.
|
||||
const dir = path.dirname(PORT_FILE);
|
||||
try { mkdirSecure(dir); } catch {}
|
||||
atomicWriteSync(PORT_FILE, String(port), { mode: 0o600 });
|
||||
restrictFilePermissions(PORT_FILE); // Windows ACL hardening
|
||||
|
||||
// Write identity-based agent record (pid + per-boot gen). Replaces the
|
||||
// v1.43- `pkill -f terminal-agent\.ts` regex teardown that could kill
|
||||
// sibling gstack sessions. Callers (cli.ts spawn site, server.ts
|
||||
// shutdown, the v1.44 watchdog) now route through killAgentByRecord in
|
||||
// terminal-agent-control.ts.
|
||||
writeAgentRecord(dir, { pid: process.pid, gen: CURRENT_GEN, startedAt: Date.now() });
|
||||
const releasePublication = acquireAgentStateLock(dir);
|
||||
let record;
|
||||
try {
|
||||
const current = readAgentRecord(dir);
|
||||
if (current && current.pid !== process.pid && current.pid > 0) throw new Error('terminal-agent record was replaced before bind');
|
||||
record = process.env.BROWSE_AGENT_GEN ? current : {
|
||||
pid: process.pid, gen: CURRENT_GEN, startedAt: Date.now(), startTime: readAgentStartTime(process.pid),
|
||||
ownerPid: BROWSE_OWNER_PID, ownerStartTime: BROWSE_OWNER_START_TIME,
|
||||
};
|
||||
if (!record || record.pid !== process.pid || record.gen !== CURRENT_GEN) throw new Error('terminal-agent record was replaced before bind');
|
||||
if (!process.env.BROWSE_AGENT_GEN) writeAgentRecord(dir, record);
|
||||
writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN);
|
||||
atomicWriteSync(PORT_FILE, String(port), { mode: 0o600 });
|
||||
restrictFilePermissions(PORT_FILE);
|
||||
} finally { releasePublication(); }
|
||||
|
||||
// Hand the parent the internal token so it can call /internal/grant.
|
||||
// Parent learns INTERNAL_TOKEN via env (TERMINAL_AGENT_INTERNAL_TOKEN below).
|
||||
@@ -1055,9 +1079,16 @@ async function main() {
|
||||
const cleanup = () => {
|
||||
if (cleaningUp) return;
|
||||
cleaningUp = true;
|
||||
safeUnlink(PORT_FILE);
|
||||
safeUnlink(INTERNAL_TOKEN_FILE);
|
||||
clearAgentRecord(dir);
|
||||
try {
|
||||
const releaseCleanup = acquireAgentStateLock(dir, 25);
|
||||
try {
|
||||
if (readAgentRecord(dir)?.gen === CURRENT_GEN) {
|
||||
safeUnlink(PORT_FILE);
|
||||
safeUnlink(INTERNAL_TOKEN_FILE);
|
||||
clearAgentRecord(dir, record);
|
||||
}
|
||||
} finally { releaseCleanup(); }
|
||||
} catch {}
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', cleanup);
|
||||
@@ -1070,11 +1101,8 @@ async function main() {
|
||||
// the same cleanup path as an intentional shutdown when it disappears.
|
||||
if (BROWSE_OWNER_PID > 0) {
|
||||
const ownerWatchdog = setInterval(() => {
|
||||
try {
|
||||
process.kill(BROWSE_OWNER_PID, 0);
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
if (!BROWSE_OWNER_START_TIME || readAgentStartTime(BROWSE_OWNER_PID) !== BROWSE_OWNER_START_TIME
|
||||
|| readAgentRecord(dir)?.gen !== CURRENT_GEN) cleanup();
|
||||
}, OWNER_WATCHDOG_MS);
|
||||
(ownerWatchdog as any)?.unref?.();
|
||||
}
|
||||
@@ -1087,10 +1115,6 @@ async function main() {
|
||||
// In practice, the agent generates INTERNAL_TOKEN once at boot and writes it
|
||||
// to a state file the parent reads. This avoids env-passing races. See main().
|
||||
const INTERNAL_TOKEN_FILE = path.join(path.dirname(STATE_FILE), 'terminal-internal-token');
|
||||
try {
|
||||
mkdirSecure(path.dirname(INTERNAL_TOKEN_FILE));
|
||||
writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN);
|
||||
} catch {}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`[terminal-agent] boot failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
Reference in New Issue
Block a user