Files
gstack/test/plan-seed-submission.test.ts
T
Garry Tan a84b0b5b6d v1.90.0.0 feat: make browser cookie imports explicit and safe (#2964)
* fix(browse): prepare reliable cookie import wave for validation

* ci: sequence quality and behavior for validation branch

* fix(browse): isolate Windows qualification and preserve native diagnostics

* test(browse): cover cookie workflow quality and isolate Windows user paths

* test(browse): trace native member startup and initialize fresh folders

* fix(browse): keep Windows member stdin alive through EOF

* fix(browse): latch native timeouts and compare contained Edge startup

* test(browse): verify native version metadata and actual Windows argv

* test(browse): qualify Dia import on isolated macOS CI

* fix(browse): require picker origin for session mutations

* fix(browse): bound credential reads through stream completion

* test(browse): inspect owned Windows process arguments natively

* test(evals): preserve passing coverage during cookie repair reruns

* test(browse): isolate Dia qualification in a fresh macOS account

* test(browse): pass bounded integer timeouts to native Mac probes

* test(browse): distinguish Windows profile initialization from containment

* test(browse): await descendant pipe readiness before parent exit

* test(browse): initialize and restore isolated macOS Keychain state

* test(browse): initialize Windows fixture folders before qualification

* test(ci): pin the same Node runtime across Windows checks

* test(browse): distinguish native macOS browser preflight stages

* test(browse): isolate Windows descendant console lifetime

* test(browse): preserve native receipts and identify fixture lock holders

* test(browse): prepare dependency resolution before native Mac worker startup

* test(ci): include lock and close checks in native diagnostics

* test(browse): preserve native owner probe stages and subprocess deadlines

* fix(browse): classify Chromium profile-in-use exit precisely

* test(browse): retain Mac qualification evidence through cleanup failures

* test(browse): bound Mac fixture paths and retire its owned user domain

* test(browse): accept vanished fixture entries without weakening cleanup

* test(browse): identify probe-created macOS user domains safely

* test(browse): observe Mac user domains without targeting them first

* test(browse): use passive fresh-user ownership throughout Mac qualification

* test(browse): distinguish profile and registered-home Keychain lookups

* test(browse): qualify Dia under one registered account home

* test(browse): identify Dia startup and owned process-group failures

* test(browse): classify bounded Dia startup diagnostics without leaking output

* fix(test): preserve native Mac sandboxing and reap owned browser children

* fix(browse): preserve Chromium sandboxing for native profile imports

* test(browse): inspect signed Mach-O architecture without launching Xcode tools

* test(browse): sample pending Dia startup and reap on all cleanup paths

* test(browse): compare protected Dia launches in fresh Bun and Node accounts

* test(browse): inspect isolated Mac GUI readiness without browser access

* v1.90.0.0 fix: bind cookie picker actions to their document

* test: validate cookie guards and fit nested launch fixtures

* ci: configure the bundled Chromium sandbox helper

* fix(browse): classify Playwright authentication timeouts

* test: retain bounded Windows lifecycle diagnostics

* test(cso): reuse bounded NTFS precision candidates

* test(review): handle explicit preservation choices safely

* test(browse): remove owned fixture directories with explicit primitives

* test(review): distinguish descriptive reuse from edit commitments

* test: admit only the approved unscored cookie workflow refusal

* test: keep the Office Hours judge mock export-complete

* fix: keep dependency-free CI planners independent of the model SDK

* test: observe the exact holder after a native fixture unlink failure

* fix: start seeded PTY observations at owned readiness

* test: acquire identity-bound Windows deletion admission before profile resets

* test: preserve qualified Git index bits without authorizing mutations
2026-09-25 12:06:45 -04:00

211 lines
14 KiB
TypeScript

import { expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { submitPlanSeed } from './helpers/plan-seed-submission';
import { PtyCurrentScreen } from './helpers/pty-current-screen';
import { launchClaudePty, runPlanSkillObservation, isProseAUQVisible, isNumberedOptionListVisible, isPermissionDialogVisible } from './helpers/claude-pty-runner';
// A real PTY process consumes the actual paste/Enter/slash bytes and publishes
// its own PID status and transcript. No provider or runner hooks are installed.
const CLI = fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'plan-seed-cli.ts'), 'utf8');
for (const scenario of ['success', 'completed-tool', 'status-updating', 'history-empty-box',
'startup-placeholder', 'startup-placeholder-cursor', 'startup-placeholder-unicode',
'startup-typed-hint', 'startup-partial-dim', 'startup-prior-conversation', 'startup-missing-styles',
'startup-waiting', 'startup-prose-question', 'startup-permission', 'startup-fresh-waiting',
'no-ack', 'fused', 'duplicate', 'session-switch', 'foreign-cwd',
'pending-tool', 'question', 'prose-question', 'permission', 'no-end-turn', 'partial', 'wrong-pid',
'typed-current', 'multiline-current', 'history-box-typed-current', 'history-box-multiline-current',
'missing-current-top', 'missing-current-bottom', 'mismatched-current-rules', 'unframed-current',
'history-no-current', 'history-missing-current-top', 'history-missing-current-bottom',
'stray-prompt-after-current', 'stale-response-frame',
...(process.platform === 'linux' ? ['wrong-start', 'wrong-domain'] : [])]) {
test.skipIf(process.platform === 'win32')(`seed submission owns each protocol step: ${scenario}`, async () => {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-seed-')));
const config = path.join(dir, '.claude'); fs.mkdirSync(config);
const script = path.join(dir, 'cli.ts'); fs.writeFileSync(script, CLI);
const decoder = new PtyCurrentScreen({ cols: 120, rows: 40 });
let raw = '', exited = false;
const launchedAt = Date.now();
const proc = Bun.spawn([process.execPath, script], {
cwd: dir, env: { ...process.env, CLAUDE_CONFIG_DIR: config, SEED_CASE: scenario },
terminal: { cols: 120, rows: 40, data(_terminal, data) { const s = Buffer.from(data).toString(); raw += s; decoder.feed(s); } },
onExit() { exited = true; },
});
const sent: string[] = [];
const session = {
pid: () => proc.pid, exited: () => exited, hermeticConfigDir: config,
send(s: string) { sent.push(s); proc.terminal!.write(s); },
sendKey(key: string) { expect(key).toBe('Enter'); sent.push('\r'); proc.terminal!.write('\r'); },
mark: () => raw.length,
currentScreen: async () => { const mark = raw.length; const frame = await decoder.snapshot();
if (scenario === 'startup-fresh-waiting') {
const statusFile = path.join(config, 'sessions', `${proc.pid}.json`);
const status = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
fs.writeFileSync(statusFile, JSON.stringify({ ...status, waitingFor: 'permission prompt' }));
}
return { text: frame.text, rawEnd: scenario === 'stale-response-frame' && sent.includes('\r') ? mark - 1 : mark,
...(scenario === 'startup-missing-styles' ? {} : { styledText: frame.styledText }) }; },
};
const seed = 'Please review when I run the skill:\n\n# Plan\nKeep $HOME and `literal` text.\n';
const deadlineAt = launchedAt + 1100;
try {
let failure: unknown;
try { await submitPlanSeed(session, seed, { cwd: dir, launchedAt, deadlineAt,
isQuestionOrPermission: text => isProseAUQVisible(text) || isNumberedOptionListVisible(text) || isPermissionDialogVisible(text) }); }
catch (error) { failure = error; }
if (['success', 'completed-tool', 'status-updating', 'history-empty-box', 'startup-placeholder', 'startup-placeholder-cursor', 'startup-placeholder-unicode'].includes(scenario)) {
expect(failure).toBeUndefined();
session.send('/plan-eng-review\r');
await Bun.sleep(50);
const events = fs.readFileSync(path.join(config, 'events.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
expect(events.map(e => e.kind)).toEqual(['paste', 'enter', 'end_turn', 'slash']);
expect(events.slice(0, 3).every(e => e.value === seed)).toBe(true);
expect(sent).toEqual([`\x1b[200~${seed}\x1b[201~`, '\r', '/plan-eng-review\r']);
} else {
expect(failure).toBeInstanceOf(Error);
const expected = ({ fused: 'fused, duplicated, or changed', duplicate: 'fused, duplicated, or changed',
'session-switch': 'native session changed', 'foreign-cwd': 'Foreign cwd',
question: 'requires an answer', 'prose-question': 'requires an answer', 'wrong-pid': 'does not match this launch',
'wrong-start': 'native process identity changed', 'wrong-domain': 'native process identity changed' } as Record<string, string>)[scenario]
?? 'existing case budget';
expect((failure as Error).message).toContain(expected);
expect(sent.some(s => s === '/plan-eng-review\r')).toBe(false);
expect(sent.filter(s => s === '\r').length).toBeLessThanOrEqual(1);
if (scenario.startsWith('startup-')) expect(sent).toEqual([]);
}
expect(Date.now() - deadlineAt).toBeLessThan(500);
} finally {
if (!exited) proc.kill();
await proc.exited;
proc.terminal?.close(); decoder.dispose();
fs.rmSync(dir, { recursive: true, force: true });
}
}, 6000);
}
for (const inheritedTerm of ['dumb', '', 'xterm-256color']) test.skipIf(process.platform === 'win32')(`actual PTY launcher carries placeholder styling into owned seed submission: ${inheritedTerm || 'empty TERM'}`, async () => {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-seed-launcher-')));
const config = path.join(dir, '.claude'); fs.mkdirSync(config);
const script = path.join(dir, 'cli.ts'); fs.writeFileSync(script, `#!${process.execPath}\n${CLI}`, { mode: 0o700 });
const old = process.env.BROWSE_TERMINAL_BINARY; process.env.BROWSE_TERMINAL_BINARY = script;
const launchedAt = Date.now(); let session: Awaited<ReturnType<typeof launchClaudePty>> | undefined;
try {
session = await launchClaudePty({ cwd: dir, observeScreen: true, permissionMode: 'plan', timeoutMs: 4000, model: 'fixture',
env: { CLAUDE_CONFIG_DIR: config, SEED_CASE: 'startup-terminal-placeholder-cursor', TERM: inheritedTerm } });
const seed = '# Real launcher seed\nKeep this exact plan.';
await submitPlanSeed({...session, currentScreen: session.currentScreenFrame}, seed, { cwd: dir, launchedAt, deadlineAt: launchedAt + 2500,
isQuestionOrPermission: text => isProseAUQVisible(text) || isNumberedOptionListVisible(text) || isPermissionDialogVisible(text) });
session.send('/plan-eng-review\r'); await Bun.sleep(50);
const events = fs.readFileSync(path.join(config, 'events.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
expect(events.map(e => e.kind)).toEqual(['paste', 'enter', 'end_turn', 'slash']);
expect(events.slice(0, 3).every(e => e.value === seed)).toBe(true);
} finally {
try { await session?.close(); }
finally {
if (old === undefined) delete process.env.BROWSE_TERMINAL_BINARY; else process.env.BROWSE_TERMINAL_BINARY = old;
fs.rmSync(dir, { recursive: true, force: true });
}
}
}, 6000);
for (const mode of ['unseeded-deadline', 'seeded-deadline', 'protocol-error']) test.skipIf(process.platform === 'win32')(`actual observation caller preserves preflight outcome: ${mode}`, async () => {
const seeded = mode !== 'unseeded-deadline';
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-seed-budget-')));
const config = path.join(dir, '.claude'); fs.mkdirSync(config);
const script = path.join(dir, 'cli.ts');
fs.writeFileSync(script, `#!${process.execPath}\n${CLI}`, { mode: 0o700 });
const artifactRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'seed-timeout-artifacts-'));
const priorEval = { EVALS_RUN_ID: process.env.EVALS_RUN_ID, GSTACK_EVAL_DIR: process.env.GSTACK_EVAL_DIR };
process.env.EVALS_RUN_ID = 'seed-timeout'; process.env.GSTACK_EVAL_DIR = artifactRoot;
const old = process.env.BROWSE_TERMINAL_BINARY;
process.env.BROWSE_TERMINAL_BINARY = script;
try {
const run = runPlanSkillObservation({ skillName: 'plan-eng-review', cwd: dir,
...(seeded ? { initialPlanContent: '# Exact plan\nNo new work allowance.' } : {}), timeoutMs: mode === 'protocol-error' ? 10000 : 600, model: 'fixture',
env: { CLAUDE_CONFIG_DIR: config, SEED_CASE: mode === 'protocol-error' ? 'wrong-pid' : mode === 'seeded-deadline' ? 'no-ack' : 'success' } });
if (mode === 'protocol-error') {
await expect(run).rejects.toThrow('Plan seed PID status does not match this launch');
expect(fs.existsSync(path.join(config, 'events.jsonl'))).toBe(false);
return;
}
const obs = await run;
expect(obs.outcome).toBe('timeout');
expect(obs.summary).toContain('existing case budget');
expect(obs.scopeGateAutoSelectObserved).toBe(false);
expect(obs.elapsedMs).toBeLessThan(1600);
expect(obs.artifactDir).toStartWith(artifactRoot);
fs.rmSync(dir, { recursive: true, force: true });
const saved = JSON.parse(fs.readFileSync(path.join(obs.artifactDir!, 'observation.json'), 'utf8'));
expect(saved.state).toBe('plan_skill_preflight_timeout');
expect(saved.summary).toBe(obs.summary);
if (!seeded) expect(saved.viewportError).toContain('screen observation was not enabled');
else expect(saved.viewportError).toBeUndefined();
for (const name of ['terminal.raw.log', 'terminal.visible.log', ...(seeded ? ['terminal.screen.log'] : [])]) {
expect(fs.existsSync(path.join(obs.artifactDir!, name))).toBe(true);
expect(fs.statSync(path.join(obs.artifactDir!, name)).mode & 0o777).toBe(0o600);
}
expect(fs.existsSync(path.join(config, 'events.jsonl'))).toBe(false);
} finally {
if (old === undefined) delete process.env.BROWSE_TERMINAL_BINARY;
else process.env.BROWSE_TERMINAL_BINARY = old;
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(artifactRoot, { recursive: true, force: true });
for (const [key, value] of Object.entries(priorEval)) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
}
}, 15000);
for (const entry of [
{ name: 'plan seed', seeded: true, inPlanMode: true, extraArgs: [], env: {}, expectedHint: 'active' },
{ name: 'outside plan mode', inPlanMode: false, extraArgs: [], env: {}, expectedHint: null },
{ name: 'explicit inactive hint', inPlanMode: true, extraArgs: [], env: { GSTACK_PLAN_MODE: 'inactive' }, expectedHint: 'inactive' },
{ name: 'explicit outside hint', inPlanMode: false, extraArgs: [], env: { GSTACK_PLAN_MODE: 'active' }, expectedHint: 'active' },
{ name: 'separate CLI override', inPlanMode: true, extraArgs: ['--permission-mode', 'auto'], env: {}, expectedHint: null },
{ name: 'equals CLI override', inPlanMode: true, extraArgs: ['--permission-mode=default'], env: {}, expectedHint: null },
{ name: 'unrelated CLI option', inPlanMode: true, extraArgs: ['--disallowedTools', 'AskUserQuestion'], env: {}, expectedHint: 'active' },
]) test.skipIf(process.platform === 'win32')(`actual observation launch preserves initial plan hint: ${entry.name}`, async () => {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-mode-hint-')));
const config = path.join(dir, '.claude'); fs.mkdirSync(config);
const script = path.join(dir, 'cli.ts');
fs.writeFileSync(script, `#!${process.execPath}\n${CLI}`, { mode: 0o700 });
const old = process.env.BROWSE_TERMINAL_BINARY;
process.env.BROWSE_TERMINAL_BINARY = script;
try {
const plan = '# Existing plan\nKeep the review scope.';
const obs = await runPlanSkillObservation({ skillName: 'plan-eng-review', cwd: dir,
inPlanMode: entry.inPlanMode, extraArgs: entry.extraArgs,
...(entry.seeded ? { initialPlanContent: plan } : {}), timeoutMs: entry.seeded ? 12000 : 600, model: 'fixture',
env: { CLAUDE_CONFIG_DIR: config, SEED_CASE: entry.seeded ? 'observation-scope-hint' : 'success', ...entry.env } });
const launch = JSON.parse(fs.readFileSync(path.join(config, 'launch.json'), 'utf8'));
const sessionIndex = launch.argv.indexOf('--session-id');
if (entry.seeded) expect(launch.argv[sessionIndex + 1]).toMatch(/^[0-9a-f-]{36}$/);
else expect(sessionIndex).toBe(-1);
expect(entry.seeded ? launch.argv.slice(0, sessionIndex) : launch.argv).toEqual([
'--model', 'fixture', ...(entry.inPlanMode ? ['--permission-mode', 'plan'] : []),
'--strict-mcp-config', ...entry.extraArgs]);
expect(launch.planModeHint).toBe(entry.expectedHint);
expect(launch.planModeForce).toBeNull();
if (entry.seeded) {
expect(obs.outcome).toBe('asked');
// The fixture supplies no mode announcement: wiring proof is not native
// announcement-compliance proof and must not manufacture that flag.
expect(obs.scopeGateAutoSelectObserved).toBe(false);
const seed = `Keep this draft plan as context. Briefly acknowledge receipt, then wait for my next message containing a slash command. Do not start the review or call tools yet.\n\n${plan}`;
const events = fs.readFileSync(path.join(config, 'events.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
expect(events.map(e => e.kind)).toEqual(['paste', 'enter', 'end_turn', 'slash']);
expect(events.slice(0, 3).every(e => e.value === seed)).toBe(true);
expect(events[3].value).toBe('/plan-eng-review\r');
} else {
expect(obs.outcome).toBe('timeout');
expect(fs.existsSync(path.join(config, 'events.jsonl'))).toBe(false);
}
} finally {
if (old === undefined) delete process.env.BROWSE_TERMINAL_BINARY;
else process.env.BROWSE_TERMINAL_BINARY = old;
fs.rmSync(dir, { recursive: true, force: true });
}
}, 18000);