mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-26 06:41:13 +02:00
* 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
102 lines
5.1 KiB
TypeScript
102 lines
5.1 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
// v1.44 terminal-agent watchdog — static-grep invariants.
|
|
//
|
|
// The watchdog respawns terminal-agent when its PID dies. Live process-tree
|
|
// tests would require spawning, killing, and observing across two real Bun
|
|
// processes — slow and flaky in the free tier. These tripwires defend the
|
|
// load-bearing properties: identity-based liveness check (not name match),
|
|
// crash-loop guard, gated on ownsTerminalAgent, and cleared on shutdown.
|
|
|
|
const SERVER_TS = path.resolve(import.meta.path, '..', '..', 'src', 'server.ts');
|
|
const CONTROL_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent-control.ts');
|
|
|
|
describe('terminal-agent watchdog (v1.44+)', () => {
|
|
test('1. spawnTerminalAgent helper exists with PID return type', () => {
|
|
const src = fs.readFileSync(CONTROL_TS, 'utf-8');
|
|
expect(src).toMatch(/export function spawnTerminalAgent\(/);
|
|
// Must clean up prior PID before spawning (no zombies).
|
|
expect(src).toContain('readAgentRecord(stateDir)');
|
|
expect(src).toContain('stopAgentByRecord(prior)');
|
|
expect(src).toContain('clearAgentRecord(stateDir, prior)');
|
|
});
|
|
|
|
test('2. watchdog is gated on ownsTerminalAgent', () => {
|
|
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
// Match the comment + the guard. The guard MUST be a positive check;
|
|
// an inverted check would respawn for embedders and trample their PTY.
|
|
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
|
expect(block).toMatch(/if \(ownsTerminalAgent\)/);
|
|
expect(block).toContain('agentWatchdogInterval = setInterval');
|
|
});
|
|
|
|
test('3. watchdog uses PID liveness, not process name probe', () => {
|
|
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
|
// The whole point of the v1.44 watchdog over v1.43- pkill teardown:
|
|
// identity-based liveness. Slow-but-alive agents must NOT trigger
|
|
// respawn (split-brain defense).
|
|
expect(block).toContain('readAgentRecord(stateDir)');
|
|
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.
|
|
expect(block).not.toMatch(/spawnSync\s*\(\s*['"]pkill/);
|
|
expect(block).not.toMatch(/Bun\.spawn\s*\(\s*\[\s*['"]pgrep/);
|
|
});
|
|
|
|
test('4. crash-loop guard with rolling window', () => {
|
|
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
|
// The window MUST be derived from the tick, not a fixed 60_000. It was
|
|
// hardcoded to 60_000 against a 60_000ms tick, so at most ONE respawn
|
|
// could ever sit inside the window and the `>= RESPAWN_GUARD_MAX` trip
|
|
// was unreachable — a steady one-respawn-per-tick leak ran unbounded
|
|
// instead of self-limiting after 3. Pinning the literal is what let that
|
|
// ship, so pin the relationship instead.
|
|
expect(block).toMatch(/RESPAWN_GUARD_WINDOW_MS =[\s\S]{0,200}AGENT_WATCHDOG_TICK_MS/);
|
|
expect(block).toContain('RESPAWN_GUARD_MAX = 3');
|
|
expect(block).toContain('respawnHistory');
|
|
expect(block).toContain('agentRespawnGuardTripped');
|
|
// Window pruning: old entries must be evicted before counting toward
|
|
// the limit. Otherwise a daemon up for a week with one crash a day
|
|
// would eventually trip the guard.
|
|
expect(block).toMatch(/respawnHistory\.shift\(\)/);
|
|
});
|
|
|
|
test('5. watchdog interval is cleared on shutdown', () => {
|
|
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
expect(src).toContain('if (agentWatchdogInterval) clearInterval(agentWatchdogInterval)');
|
|
});
|
|
|
|
test('6. tick interval is env-overridable for tests', () => {
|
|
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
expect(src).toContain('GSTACK_AGENT_WATCHDOG_TICK_MS');
|
|
});
|
|
|
|
test('7. CLI cold-start path uses the same spawnTerminalAgent helper', () => {
|
|
const cli = fs.readFileSync(
|
|
path.resolve(import.meta.path, '..', '..', 'src', 'cli.ts'),
|
|
'utf-8',
|
|
);
|
|
// Otherwise the CLI and watchdog could drift on spawn env/cwd, and
|
|
// teardown invariants tested against one would silently miss the other.
|
|
expect(cli).toContain('spawnTerminalAgent({');
|
|
expect(cli).toContain("from './terminal-agent-control'");
|
|
});
|
|
});
|
|
|
|
function sliceBetween(source: string, start: string, end: string): string {
|
|
const i = source.indexOf(start);
|
|
if (i === -1) throw new Error(`marker not found: ${start}`);
|
|
const j = source.indexOf(end, i + start.length);
|
|
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
|
return source.slice(i, j);
|
|
}
|