Files
gstack/test/pty-upgrade-isolation.test.ts
garrytan d807ef24a3 W1b: template and resolver prose resolve state through guarded gstack-paths; ratchet (a)
Every gstack-paths eval in templates and resolvers carries the fail-stop
guard; executable ~/.gstack paths in bash blocks (context recovery preamble,
eureka log, analytics, project artifacts, upgrade snooze, setup-gbrain lock,
retro snapshots, ship consent marker) use $GSTACK_STATE_ROOT, and the writer
prose that pairs with them points at the printed PROJECT_DIR / RETRO_FILE.
ship drops export GSTACK_STATE_ROOT. SKILL.md regenerated (claude + codex),
ship goldens re-pinned, parity and context-budget caps raised to the measured
sizes with notes. test/state-root-ratchet.test.ts enforces the rule with a
reasoned allowlist; W1 touchfile entries plus a superset golden.
2026-09-30 06:38:50 +00:00

116 lines
5.5 KiB
TypeScript

/** Free reproduction of upgrade prompts intercepting seeded PTY scope gates. */
import { describe, expect, test } from 'bun:test';
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { hermeticChildEnv, hermeticSkillsConfigDir } from './helpers/hermetic-env';
const ROOT = path.resolve(import.meta.dir, '..');
const OLD_VERSION = '1.72.0.0';
const NEW_VERSION = '1.81.0.0';
const UPGRADE = `UPGRADE_AVAILABLE ${OLD_VERSION} ${NEW_VERSION}\n`;
function extractPreamble(source: string): string {
const heading = /^## Preamble(?: \([^\r\n]*\))?[ \t]*\r?\n/m.exec(source);
if (!heading) throw new Error('Generated skill has no Preamble section');
const remaining = source.slice(heading.index + heading[0].length);
const nextHeading = remaining.search(/^#{1,2} /m);
const section = nextHeading < 0 ? remaining : remaining.slice(0, nextHeading);
const block = /^```bash\r?\n([\s\S]*?)\r?\n```[ \t]*\r?$/m.exec(section);
if (!block) throw new Error('Generated Preamble has no complete Bash block');
return block[1];
}
const preamble = extractPreamble(fs.readFileSync(path.join(ROOT, 'plan-eng-review', 'SKILL.md'), 'utf8'));
describe('generated Preamble extraction', () => {
test.each(['run first', 'after scope gate'])('allows guidance before the command (%s)', label => {
const source = `## Preamble (${label})\n\n**Before the command below:** resolve the Scope gate.\n\n\`\`\`bash\nprintf first\n\`\`\`\n\n\`\`\`bash\nprintf second\n\`\`\`\n\n## Next section\n`;
expect(extractPreamble(source)).toBe('printf first');
});
test.each([
'## Other section\n\n```bash\nprintf elsewhere\n```\n',
'## Preamble (after scope gate)\n\nNo command.\n\n## Next section\n```bash\nprintf elsewhere\n```\n',
'## Preamble (after scope gate)\n\n```sh\nprintf other-language\n```\n',
'## Preamble (after scope gate)\n\n```bash\nprintf unclosed\n\n## Next section\n```\n',
])('rejects absent or incomplete Preamble Bash without borrowing another section (%#)', source => {
expect(() => extractPreamble(source)).toThrow(/Preamble/);
});
});
function withOldInstall(check: (fixture: {
home: string;
cache: string;
version: string;
env: Record<string, string>;
networkLog: string;
}) => void): void {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-upgrade-home-'));
try {
const install = path.join(home, '.claude', 'skills', 'gstack');
const state = path.join(home, '.gstack');
const commandBin = path.join(home, 'commands');
fs.mkdirSync(install, { recursive: true });
fs.mkdirSync(state);
fs.mkdirSync(commandBin);
// Real runtime, older installed VERSION: the preamble resolves through HOME.
fs.symlinkSync(path.join(ROOT, 'bin'), path.join(install, 'bin'), 'dir');
const version = path.join(install, 'VERSION');
const cache = path.join(state, 'last-update-check');
const networkLog = path.join(home, 'unexpected-network');
fs.writeFileSync(version, `${OLD_VERSION}\n`);
fs.writeFileSync(cache, UPGRADE);
fs.writeFileSync(path.join(state, '.codex-desc-healed'), '');
fs.writeFileSync(path.join(commandBin, 'curl'), '#!/usr/bin/env bash\nprintf reached > "$PTY_UPGRADE_NETWORK_LOG"\nexit 1\n', { mode: 0o755 });
const env = hermeticChildEnv({
HOME: home,
PATH: `${commandBin}${path.delimiter}${process.env.PATH ?? ''}`,
PTY_UPGRADE_NETWORK_LOG: networkLog,
});
env.CLAUDE_CONFIG_DIR = hermeticSkillsConfigDir();
check({ home, cache, version, env, networkLog });
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}
function runPreamble(home: string, env: Record<string, string>): string {
return execFileSync('bash', ['-c', preamble], {
cwd: home, env, encoding: 'utf8', timeout: 20_000,
});
}
describe('seeded PTY update-check isolation', () => {
test('private default config prevents an old HOME install from intercepting the scope gate', () => {
withOldInstall(({ home, cache, version, env, networkLog }) => {
const output = runPreamble(home, env);
expect(output).toContain('SKILL_START_PROTO: 1');
expect(output).not.toContain('UPGRADE_AVAILABLE');
expect(output).not.toContain('GSTACK_INSTRUCTION_BEGIN: upgrade-flow');
expect(output).toContain('UPDATE_CHECK: false');
expect(fs.readFileSync(version, 'utf8')).toBe(`${OLD_VERSION}\n`);
expect(fs.readFileSync(cache, 'utf8')).toBe(UPGRADE);
expect(fs.existsSync(networkLog)).toBe(false);
});
});
test('an explicit per-test state override can still exercise the real upgrade flow', () => {
withOldInstall(({ home, cache, env, networkLog }) => {
const state = path.join(home, 'explicit-state');
fs.mkdirSync(state);
fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: true\nartifacts_sync_mode_prompted: true\n');
// gstack-update-check reads its cache from the same state root GSTACK_HOME selects (docs/state-root.md).
const stateCache = path.join(state, 'last-update-check');
fs.writeFileSync(stateCache, UPGRADE);
const output = runPreamble(home, { ...env, GSTACK_HOME: state });
expect(output).toContain(UPGRADE.trim());
expect(output).toContain('GSTACK_INSTRUCTION_BEGIN: upgrade-flow');
expect(output).toContain('UPDATE_CHECK: true');
expect(fs.readFileSync(cache, 'utf8')).toBe(UPGRADE);
expect(fs.readFileSync(stateCache, 'utf8')).toBe(UPGRADE);
expect(fs.existsSync(networkLog)).toBe(false);
});
});
});