Files
gstack/test/gstack-upgrade-fresh-shell.test.ts
T
Garry Tanandcapy-ai[bot] 5d647455f2 v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E) (#3123)
* v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(spec,review): fail closed when the coordinator contract cannot be read; restore the omitted-Army sentence

The spec --execute spawn captures `gstack-autoplan contract` before piping
the archive, so a lane never starts without the contract; the C9 fixture
installs its gstack-like checkout (bin, lib, a contract stub) at every
host's global root. Review Step 5.8 keeps the pinned
'without claiming specialist coverage' wording.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-11 03:10:06 +00:00

272 lines
15 KiB
TypeScript

/**
* C9: destructive gstack-upgrade and /spec fences in a fresh shell.
*
* Hosts run each fenced block in a new shell, so variables from Step 2 are
* gone. `cd ""` succeeds and stays put, which used to point
* `git checkout -- '*\/SKILL.md'`, `git stash` and `git reset --hard` at the
* user's own project. Every host's render is executed with the guarded
* variable unset, empty, missing, unreadable and pointing at the wrong
* repository: each must exit before any mutating git command and leave the
* scratch project byte-for-byte unchanged. Positive controls prove the guard
* still lets a real gstack checkout through.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { execFile, spawnSync } from 'child_process';
import { promisify } from 'util';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
import { createHash } from 'crypto';
import { runGeneration } from '../scripts/gen-skill-docs';
import { ALL_HOST_CONFIGS } from '../hosts/index';
const IS_WINDOWS = process.platform === 'win32';
let root = '';
let renders = '';
function sh(cwd: string, cmd: string, env: Record<string, string> = {}) {
return spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', timeout: 20_000, env: { ...process.env, ...fixtureEnv(), ...env } });
}
function fixtureEnv(): Record<string, string> {
return {
HOME: join(root, 'home'),
GIT_CONFIG_GLOBAL: join(root, 'home', '.gitconfig'),
GIT_CONFIG_NOSYSTEM: '1',
GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.test',
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.test',
};
}
function mustSh(cwd: string, cmd: string) {
const r = sh(cwd, cmd);
if (r.status !== 0) throw new Error(`${cmd}\n${r.stdout}\n${r.stderr}`);
return r.stdout;
}
/** A directory that looks like a gstack checkout (VERSION, setup, bin/gstack-config). */
function makeGstackLike(dir: string, git: boolean) {
mkdirSync(join(dir, 'bin'), { recursive: true });
mkdirSync(join(dir, 'lib'), { recursive: true });
writeFileSync(join(dir, 'VERSION'), '1.0.0.0\n');
writeFileSync(join(dir, 'setup'), '#!/bin/sh\necho SETUP_RAN "$@"\n', { mode: 0o755 });
writeFileSync(join(dir, 'bin', 'gstack-config'), '#!/bin/sh\necho false\n', { mode: 0o755 });
writeFileSync(join(dir, 'bin', 'gstack-autoplan'), '#!/bin/sh\necho "## Coordinator contract (fixture)"\n', { mode: 0o755 });
if (git) mustSh(dir, 'git init -q -b main && git add -A && git commit -q -m init');
}
function bashFences(text: string): string[] {
return [...text.matchAll(/^```bash\n([\s\S]*?)\n```$/gm)].map(m => m[1]);
}
function fence(fences: string[], marker: string): string {
const hits = fences.filter(f => f.includes(marker));
if (hits.length !== 1) throw new Error(`expected one fence containing ${marker}, found ${hits.length}`);
return hits[0];
}
const execFileAsync = promisify(execFile);
async function bashAsync(cwd: string, script: string, env: Record<string, string>) {
try {
const r = await execFileAsync('bash', ['-c', script], { cwd, timeout: 20_000, env: { ...process.env, ...fixtureEnv(), ...env } });
return { status: 0, stdout: r.stdout, stderr: r.stderr };
} catch (error) {
const e = error as { code?: number | string; stdout?: string; stderr?: string };
return { status: typeof e.code === 'number' ? e.code : -1, stdout: e.stdout ?? '', stderr: e.stderr ?? String(error) };
}
}
/** Tree snapshot of the scratch project: file bytes + git HEAD, index, status and stash. */
async function snapshot(dir: string): Promise<string> {
const h = createHash('sha256');
const walk = (d: string) => {
for (const name of readdirSync(d).sort()) {
if (name === '.git') continue;
const p = join(d, name);
const st = statSync(p);
if (st.isDirectory()) walk(p);
else h.update(`${p}\0`).update(readFileSync(p));
}
};
walk(dir);
const state = await bashAsync(dir, 'git rev-parse HEAD; git status --porcelain=v1 -uall; git stash list; git ls-files -s; git worktree list --porcelain', {});
h.update(state.stdout);
return h.digest('hex');
}
interface HostRender { host: string; upgrade: string[]; spec: string[]; localDir: string; globalRoot: string }
const hostRenders: HostRender[] = [];
beforeAll(async () => {
if (IS_WINDOWS) return;
root = mkdtempSync(join(tmpdir(), 'gstack-c9-'));
mkdirSync(join(root, 'home'), { recursive: true });
writeFileSync(join(root, 'home', '.gitconfig'), '[init]\n\tdefaultBranch = main\n[commit]\n\tgpgsign = false\n');
renders = join(root, 'render');
const result = await runGeneration({ host: 'all', outputRoot: renders });
if (result.exitCode !== 0) throw new Error(result.diagnostics.map(d => d.message).join('\n'));
for (const config of ALL_HOST_CONFIGS) {
const base = config.name === 'claude' ? renders : join(renders, config.hostSubdir, 'skills');
const upgradePath = config.name === 'claude' ? join(base, 'gstack-upgrade', 'SKILL.md') : join(base, 'gstack-upgrade', 'SKILL.md');
const specPath = config.name === 'claude' ? join(base, 'spec', 'sections', 'gate-and-file.md') : join(base, 'gstack-spec', 'SKILL.md');
const upgrade = bashFences(readFileSync(upgradePath, 'utf8'));
const spec = bashFences(readFileSync(specPath, 'utf8'));
const team = fence(upgrade, 'git rm -r --cached');
const localDir = team.match(/git rm -r --cached (\S+)\/ /)![1];
hostRenders.push({ host: config.name, upgrade, spec, localDir, globalRoot: config.globalRoot });
}
});
afterAll(() => {
if (!root) return;
spawnSync('chmod', ['-R', 'u+rwx', root], { timeout: 20_000 });
rmSync(root, { recursive: true, force: true });
});
/** Per-host world: a user project with a vendored copy and a dirty tracked file. */
function world(host: HostRender) {
const w = mkdtempSync(join(root, `${host.host}-`));
const project = join(w, 'project');
const stub = join(w, 'stub');
mkdirSync(join(project, 'app'), { recursive: true });
mkdirSync(stub);
makeGstackLike(join(project, host.localDir), false);
writeFileSync(join(project, 'app', 'SKILL.md'), 'committed\n');
mustSh(project, 'git init -q -b main && git add -A && git commit -q -m init');
writeFileSync(join(project, 'app', 'SKILL.md'), 'user edit in progress\n');
writeFileSync(join(project, 'notes.txt'), 'untracked user work\n');
const gitLog = join(w, 'git.log');
const realGit = mustSh(w, 'command -v git').trim();
// Logs every call; clone never reaches the network (the vendored fence would fetch GitHub).
writeFileSync(join(stub, 'git'), `#!/bin/sh\nprintf '%s\\n' "$*" >> "${gitLog}"\n[ "$1" = clone ] && exit 128\nexec "${realGit}" "$@"\n`, { mode: 0o755 });
writeFileSync(join(stub, 'claude'), `#!/bin/sh\ncat > /dev/null\necho spawned >> "${join(w, 'claude.log')}"\n`, { mode: 0o755 });
const gstack = join(w, 'gstack');
makeGstackLike(gstack, true);
mustSh(w, `git clone -q --bare gstack origin.git && cd gstack && git remote add origin "${join(w, 'origin.git')}"`);
const plainRepo = join(w, 'plain');
mkdirSync(join(plainRepo, 'bin'), { recursive: true });
writeFileSync(join(plainRepo, 'VERSION'), '0.1.0\n');
writeFileSync(join(plainRepo, 'setup'), '#!/bin/sh\n');
mustSh(plainRepo, 'git init -q -b main && git add -A && git commit -q -m init');
return { w, project, stub, gitLog, gstack, plainRepo };
}
async function run(wd: ReturnType<typeof world>, script: string, vars: Record<string, string | undefined>) {
writeFileSync(wd.gitLog, '');
const env: Record<string, string> = { PATH: `${wd.stub}:${process.env.PATH}`, GSTACK_ROOT: wd.gstack, GSTACK_BIN: join(wd.gstack, 'bin') };
const unset: string[] = [];
for (const [k, v] of Object.entries(vars)) {
if (v === undefined) unset.push(k); else env[k] = v;
}
const r = await bashAsync(wd.project, `unset ${unset.join(' ') || '_NONE_'}\n${script}`, env);
const gitCalls = readFileSync(wd.gitLog, 'utf8').split('\n').filter(Boolean);
return { ...r, gitCalls };
}
const BAD_VALUES = ['unset', 'empty', 'nonexistent', 'unreadable', 'wrong-repo', 'subdirectory'] as const;
function badValue(wd: ReturnType<typeof world>, kind: typeof BAD_VALUES[number]): string | undefined {
switch (kind) {
case 'unset': return undefined;
case 'empty': return '';
case 'nonexistent': return join(wd.w, 'missing');
case 'unreadable': {
const locked = join(wd.w, 'locked');
if (!existsSync(locked)) { mkdirSync(locked); chmodSync(locked, 0o000); }
return locked;
}
case 'wrong-repo': return wd.plainRepo;
case 'subdirectory': return join(wd.gstack, 'bin');
}
}
describe.skipIf(IS_WINDOWS)('C9: destructive upgrade/spec fences refuse bad paths on every host', () => {
type Kind = typeof BAD_VALUES[number];
const UNUSABLE: Kind[] = ['unset', 'empty', 'nonexistent', 'unreadable'];
const cases: Array<{ name: string; doc: 'upgrade' | 'spec'; marker: string; target: string; kinds?: Kind[]; others: (wd: ReturnType<typeof world>, h: HostRender) => Record<string, string> }> = [
{ name: 'ff-only upgrade', doc: 'upgrade', marker: 'git pull --ff-only', target: 'INSTALL_DIR', others: () => ({}) },
{ name: 'reset fallback', doc: 'upgrade', marker: 'git reset --hard origin/main', target: 'INSTALL_DIR', others: () => ({}) },
{ name: 'vendored swap', doc: 'upgrade', marker: 'git clone --depth 1', target: 'INSTALL_DIR', others: () => ({}) },
{ name: 'local copy detection', doc: 'upgrade', marker: '_RESOLVED_PRIMARY=', target: 'INSTALL_DIR', kinds: UNUSABLE, others: () => ({}) },
{ name: 'team-mode removal', doc: 'upgrade', marker: 'git rm -r --cached', target: 'LOCAL_GSTACK', others: () => ({}) },
{ name: 'vendored sync (local)', doc: 'upgrade', marker: 'LOCAL_SYNC_OK', target: 'LOCAL_GSTACK', others: (wd, h) => ({ INSTALL_DIR: wd.gstack }) },
{ name: 'vendored sync (primary)', doc: 'upgrade', marker: 'LOCAL_SYNC_OK', target: 'INSTALL_DIR', others: (wd, h) => ({ LOCAL_GSTACK: join(wd.project, h.localDir) }) },
{ name: 'spec worktree add', doc: 'spec', marker: 'git worktree add', target: 'SPAWN_PATH', kinds: ['unset', 'empty'], others: () => ({ SPAWN_BRANCH: 'spec/x-1', PIN_SHA: 'HEAD' }) },
{ name: 'spec spawn', doc: 'spec', marker: 'claude -p', target: 'SPAWN_PATH', kinds: [...UNUSABLE, 'subdirectory'], others: (wd) => ({ SPAWN_BRANCH: 'spec/x-1', ARCHIVE_PATH: join(wd.gstack, 'VERSION') }) },
];
async function refusals(h: HostRender): Promise<string[]> {
const wd = world(h);
const failures: string[] = [];
const baseline = await snapshot(wd.project);
for (const c of cases) {
const script = fence(c.doc === 'upgrade' ? h.upgrade : h.spec, c.marker);
for (const kind of c.kinds ?? BAD_VALUES) {
const r = await run(wd, script, { ...c.others(wd, h), [c.target]: badValue(wd, kind) });
const mutating = r.gitCalls.filter(call => !/^rev-parse\b/.test(call));
const changed = (await snapshot(wd.project)) !== baseline;
const spawned = existsSync(join(wd.w, 'claude.log'));
if (r.status === 0 || mutating.length > 0 || changed || spawned) {
failures.push(`${h.host}: ${c.name} with ${c.target} ${kind}: exit=${r.status} git=[${mutating.join(' | ')}] changed=${changed} spawned=${spawned}\n${r.stderr.slice(0, 400)}`);
}
}
}
return failures;
}
test('every host: each guarded fence exits before mutating git and leaves the project unchanged', async () => {
expect(hostRenders.length).toBe(ALL_HOST_CONFIGS.length);
const failures = (await Promise.all(hostRenders.map(refusals))).flat();
expect(failures).toEqual([]);
}, 120_000);
async function controls(h: HostRender): Promise<string[]> {
const wd = world(h);
const problems: string[] = [];
const ff = await run(wd, fence(h.upgrade, 'git pull --ff-only'), { INSTALL_DIR: wd.gstack });
if (!ff.stdout.includes('FF_OK') || !ff.gitCalls.some(c => c.startsWith('fetch'))) problems.push(`${h.host}: ff-only upgrade did not run: ${ff.stderr}`);
const local = join(wd.project, h.localDir);
const team = await run(wd, fence(h.upgrade, 'git rm -r --cached'), { LOCAL_GSTACK: local });
if (team.status !== 0 || existsSync(local)) problems.push(`${h.host}: team-mode removal did not run: ${team.stderr}`);
mustSh(wd.project, `git worktree add -q "${join(wd.w, 'wt')}" -b spec/x-1 HEAD`);
// The spawn reads the contract from this host's ~/<globalRoot>/bin: install the fixture there.
const installed = join(root, 'home', h.globalRoot);
mkdirSync(dirname(installed), { recursive: true });
if (!existsSync(installed)) symlinkSync(wd.gstack, installed);
const spawn = await run(wd, fence(h.spec, 'claude -p'), { SPAWN_PATH: join(wd.w, 'wt'), SPAWN_BRANCH: 'spec/x-1', ARCHIVE_PATH: join(wd.gstack, 'VERSION') });
const deadline = Date.now() + 5_000;
while (!existsSync(join(wd.w, 'claude.log')) && Date.now() < deadline) await Bun.sleep(50);
if (spawn.status !== 0 || !existsSync(join(wd.w, 'claude.log'))) problems.push(`${h.host}: spec spawn did not start claude: ${spawn.stderr}`);
return problems;
}
test('every host: positive controls still run on a real checkout', async () => {
const problems = (await Promise.all(hostRenders.map(controls))).flat();
expect(problems).toEqual([]);
}, 60_000);
});
describe.skipIf(IS_WINDOWS)('DX-7: Step 2 finds the registered source checkout for this host', () => {
test('codex: the README ~/gstack clone recorded in the install registry is the install dir', () => {
const step2 = fence(hostRenders.find(r => r.host === 'codex')!.upgrade, 'Install type: $INSTALL_TYPE at $INSTALL_DIR');
const w = mkdtempSync(join(root, 'dx7-'));
const home = join(w, 'home');
const state = join(w, 'state');
const clone = join(home, 'gstack');
makeGstackLike(clone, true);
mkdirSync(state, { recursive: true });
// Setup's Codex runtime root carries bin/ and lib/ linked from the clone; it has no .git.
const runtime = join(home, '.codex', 'skills', 'gstack');
mkdirSync(join(runtime, 'bin'), { recursive: true });
mkdirSync(join(runtime, 'lib'), { recursive: true });
writeFileSync(join(runtime, 'bin', 'gstack-paths'), `#!/bin/sh\necho "${state}"\n`, { mode: 0o755 });
writeFileSync(join(state, 'installs.tsv'), [
['claude', 'global', '-', '/x', '/x', '/elsewhere', '1.0', '-', 'committed', '0'].join('\t'),
['codex', 'global', '-', join(home, '.codex', 'skills'), runtime, clone, '1.0.0.0', 'false', 'committed', '0'].join('\t'),
].join('\n') + '\n');
const cwd = mkdtempSync(join(w, 'cwd-'));
const run = () => spawnSync('env', ['-i', `HOME=${home}`, `PATH=${process.env.PATH}`, 'bash', '-c', step2], { cwd, encoding: 'utf8', timeout: 20_000 });
const r = run();
expect(r.stdout.trim(), r.stderr).toBe(`Install type: global-git at ${clone}`);
rmSync(join(state, 'installs.tsv'));
expect(run().stdout).toContain('ERROR: gstack not found');
});
});