Files
gstack/test/pty-skill-seeding-wiring.test.ts
T
b3a27173fe fix(browse): absorb #2414 residuals — EPERM-alive liveness + Windows-dead test tripwires (re-derived)
Re-derive of PR #2414 (SYKhayyat) onto current main. Most of the PR already
landed in earlier waves: the tick-derived RESPAWN_GUARD_WINDOW_MS, the
spawnTerminalAgent windowsHide flag, the process-liveness regression tests,
and the browse/test import.meta.path sweep are all on main. Two pieces
remained:

1. isProcessAlive EPERM semantics (error-handling.ts): on the signal-0 path,
   EPERM means the process EXISTS but we lack rights to signal it — that is
   ALIVE. Returning false made callers that validate liveness before killing
   (killAgentByRecord, the terminal-agent watchdog) skip the kill and respawn
   around a survivor — the self-reinforcing one-leak-per-tick chain from
   #2414/#2295. Matters for cross-user PID checks.

2. Six test/ files ADDED SINCE the PR reintroduced the exact Windows bug its
   second commit fixed: `new URL(import.meta.url).pathname` yields
   `/C:/Users/...` on Windows, so path.resolve prepends the cwd drive and
   every tripwire ENOENTs instead of asserting anything (egress-receipt,
   egress-lib, egress-receipt-wiring, gstack-egress-cli,
   pty-skill-seeding-wiring, skill-census). All six now use
   import.meta.path — Bun's absolute native path, identical arity.

The remaining #2414 piece — replacing the Windows tasklist probe with
signal-0 — lands as its own commit (#1952) on top of this shape.

Tests: the 6 touched test files 47 pass; process-liveness-windows +
error-handling 13 pass.

Re-derived from PR #2414 by @SYKhayyat. Fixes the residual of #2295.

Co-authored-by: SYKhayyat <shaulyoelkhayyat@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:05 -07:00

78 lines
3.7 KiB
TypeScript

/**
* Static-grep tripwire for PTY slash-command skill seeding. Free tier — no API.
*
* The default hermetic config dir registers NO skills, so a PTY test that
* TYPES a /skill slash command against it gets "Unknown command" before any
* model turn — the test still runs, still spends money, and measures nothing.
* Every test file that sends a slash command over the PTY must therefore
* either route through a runPlanSkill* helper (which opts in for you) or pass
* `seedSkills: true` in its own launchClaudePty options.
*
* Pattern mirrors test/hermetic-wiring.test.ts: read sources as text, assert
* invariants on their contents. Brittle by design — changing the seeding
* wiring must force the author to look here.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(import.meta.path, '..', '..');
/** A PTY send whose payload starts with a slash command (`/name`, optionally
* followed by `\r`, whitespace, or the closing quote). A second slash right
* after the name (a file path like '/tmp/x') does NOT match. */
const SLASH_SEND = /\.send\(\s*(['"`])\/[a-z][a-z0-9-]*(\\r|\s|\1)/;
const RUN_PLAN_HELPER = /\brunPlanSkill(Observation|Counting|FloorCheck)\s*\(/;
function testFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...testFiles(full));
else if (entry.name.endsWith('.test.ts')) out.push(full);
}
return out;
}
describe('PTY skill-seeding tripwire', () => {
test('every slash-command PTY test seeds skills (helper or seedSkills: true)', () => {
const offenders: string[] = [];
for (const full of testFiles(path.join(ROOT, 'test'))) {
if (path.basename(full) === 'pty-skill-seeding-wiring.test.ts') continue;
const src = fs.readFileSync(full, 'utf-8');
const lines = src.split('\n');
const sendLine = lines.findIndex((l) => SLASH_SEND.test(l));
if (sendLine === -1) continue;
if (RUN_PLAN_HELPER.test(src)) continue;
if (src.includes('seedSkills: true')) continue;
offenders.push(`${path.relative(ROOT, full)}:${sendLine + 1}`);
}
expect(
offenders,
'These tests type a /skill slash command into a hermetic PTY child that has ' +
'no skills registered — claude rejects it as Unknown command and the test ' +
'measures nothing. Pass seedSkills: true to launchClaudePty (or route ' +
'through a runPlanSkill* helper): ' + offenders.join(', '),
).toEqual([]);
});
test('the runPlanSkill* helpers all opt in via seedSkills: true', () => {
// The helper family types slash commands on behalf of ~20 test files;
// dropping the opt-in there silently un-measures all of them at once.
const src = fs.readFileSync(path.join(ROOT, 'test/helpers/claude-pty-runner.ts'), 'utf-8');
const optIns = src.match(/seedSkills: true/g) ?? [];
expect(optIns.length).toBeGreaterThanOrEqual(3);
});
test('launchClaudePty wires seedSkills to hermeticSkillsConfigDir()', () => {
const src = fs.readFileSync(path.join(ROOT, 'test/helpers/claude-pty-runner.ts'), 'utf-8');
// Gated on hermetic (EVALS_HERMETIC=0 must keep the operator config) and
// on the per-test env override (explicit CLAUDE_CONFIG_DIR wins).
const wired =
/if\s*\(opts\.seedSkills && hermetic && !opts\.env\?\.CLAUDE_CONFIG_DIR\)\s*\{\s*\n\s*childEnv\.CLAUDE_CONFIG_DIR = hermeticSkillsConfigDir\(\);/.test(src);
expect(wired, 'launchClaudePty must set CLAUDE_CONFIG_DIR from hermeticSkillsConfigDir() when seedSkills && hermetic && no per-test override').toBe(true);
});
});