Merge PR A (install) under the autoplan wave; gstack-autoplan admission uses lib/install-verdict.ts (INSTALL_UNVERIFIED)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-13 01:37:10 +00:00
commit 79ee56af91
34 files changed
+2067 -214

No files matched your search

+8 -8
View File
@@ -28,19 +28,19 @@ describe('admission', () => {
const r = make({ verdict: null });
const { out, res } = first(r, 'none');
expect(res.status).toBe(3);
expect(res.stderr).toMatch(/install verdict.*not found.*\(INSTALL_VERDICT_FAILED\)$/m);
expect(res.stderr).toMatch(/no install verdict at .*\(INSTALL_UNVERIFIED\)$/m);
expect(res.stdout).toContain('GSTACK_RESULT: skill=autoplan status=refused');
expect(fs.existsSync(path.join(out, 'run.json'))).toBe(false);
});
test('a failing verdict, or one for another HEAD or checkout, refuses with the trailer', () => {
const r = make();
r.writeVerdict({ trailer: 'gstack: fail runtime', head: HEAD, checkout: fs.realpathSync(ROOT) });
expect(first(r, 'fail').res.stderr).toMatch(/gstack: fail runtime.*\(INSTALL_VERDICT_FAILED\)$/m);
r.writeVerdict({ trailer: 'gstack: ok 1.91.95.0 for=autoplan', head: '0'.repeat(40), checkout: fs.realpathSync(ROOT) });
expect(first(r, 'head').res.stderr).toMatch(/another HEAD.*\(INSTALL_VERDICT_FAILED\)$/m);
r.writeVerdict({ trailer: 'gstack: ok 1.91.95.0 for=autoplan', head: HEAD, checkout: '/elsewhere/gstack' });
expect(first(r, 'checkout').res.stderr).toMatch(/another checkout.*\(INSTALL_VERDICT_FAILED\)$/m);
r.writeVerdict({ trailer: 'gstack: fail runtime' });
expect(first(r, 'fail').res.stderr).toMatch(/gstack: fail runtime.*\(INSTALL_UNVERIFIED\)$/m);
r.writeVerdict({ head: '0'.repeat(40) });
expect(first(r, 'head').res.stderr).toMatch(/another HEAD.*\(INSTALL_UNVERIFIED\)$/m);
r.writeVerdict({ checkout: '/elsewhere/gstack' });
expect(first(r, 'checkout').res.stderr).toMatch(/is for checkout \/elsewhere\/gstack.*\(INSTALL_UNVERIFIED\)$/m);
});
test('an ok verdict admits; status and resume need none; every mutating verb is gated', () => {
@@ -53,7 +53,7 @@ describe('admission', () => {
for (const args of [['next'], ['submit', '--phase', 'ceo', '--voice', 'outside', '--unavailable', 'x'], ['answer', '--gate-rev', '1', '--reply', 'all'], ['revise', '--plan', path.join(r.host.base, 'ok', 'plan.md')], ['export']]) {
const res2 = r.ap([args[0]!, '--out', out, ...args.slice(1)]);
expect(res2.status, args[0]).toBe(3);
expect(res2.stderr, args[0]).toMatch(/\(INSTALL_VERDICT_FAILED\)$/m);
expect(res2.stderr, args[0]).toMatch(/\(INSTALL_UNVERIFIED\)$/m);
}
});
+244
View File
@@ -0,0 +1,244 @@
/**
* capy/skill/gstack/: the vendored Capy skill. scripts/install.sh is the
* frozen bootstrapper the Drive copy holds: it prints its wrapper revision
* first, learns the supported minimum from origin's default branch
* (`git show FETCH_HEAD:capy/support.env`), fast-forwards a clean, unpinned
* checkout below that minimum in install mode only (--check never moves the
* checkout), and execs the checkout's own full wrapper, bin/gstack-capy-session,
* handing over the held install lock (GSTACK_CAPY_LOCK_HELD=1). Pinned on the
* machine lines (`gstack-capy-skill: wrapper|upgraded|owner action`, the
* `gstack:` trailer as the last line), exit codes and HEAD before/after.
* Fixture: a bare remote whose main moves from A (1.0.0, no capy/ directory:
* an old checkout) to B (1.0.1, capy/support.env minimum 1.0.1, the vendored
* scripts and a stub bin/gstack-capy-install that logs argv and env), a
* depth-1 file:// clone at A, on the simulated Capy host.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { RESULT_CODES } from '../lib/result-codes';
import { capyHost, type CapyHost } from './helpers/capy-host';
const REPO = path.resolve(import.meta.dir, '..');
const SKILL = path.join(REPO, 'capy/skill/gstack');
const BOOT = path.join(SKILL, 'scripts/install.sh');
const hosts: CapyHost[] = [];
afterEach(() => { for (const h of hosts.splice(0)) h.cleanup(); });
const write = (file: string, text: string, mode = 0o644) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, text, { mode });
};
const envValue = (text: string, key: string) => text.match(new RegExp(`^${key}=([0-9.]+)`, 'm'))?.[1] ?? '';
const below = (a: string, b: string) => {
const x = a.split('.').map(Number), y = b.split('.').map(Number);
for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
return false;
};
const STUB_INSTALLER = [
'#!/usr/bin/env bash',
'echo "installer $* lock=${GSTACK_CAPY_LOCK_HELD:-} rev=${GSTACK_CAPY_WRAPPER_REV:-} min=${GSTACK_CAPY_REMOTE_MIN:-} from=${GSTACK_CAPY_UPGRADED_FROM:-}" >> "$WRAP_LOG"',
'cd "$(dirname "$0")/.."',
'if [ "${1:-}" != --check ] && [ -f "$WRAP_FAIL_ONCE" ]; then rm -f "$WRAP_FAIL_ONCE"; echo "gstack: fail claude"; exit 1; fi',
'echo "GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_CAPY=1"',
'echo "gstack: ok $(cat VERSION) for=autoplan"',
].join('\n') + '\n';
function fixture(opts: { minWrapper?: number } = {}) {
const host = capyHost({ systemBun: '1.4.2' });
hosts.push(host);
const git = (args: string[], cwd: string) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: host.env, timeout: 30_000 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const log = path.join(host.base, 'wrap.log');
const seed = path.join(host.base, 'seed');
const bare = path.join(host.base, 'remote.git');
fs.mkdirSync(seed, { recursive: true });
git(['init', '-q', '-b', 'main'], seed);
write(path.join(seed, 'VERSION'), '1.0.0\n');
write(path.join(seed, 'README.md'), 'old gstack\n');
write(path.join(seed, 'setup'), '#!/usr/bin/env bash\nexit 0\n', 0o755);
git(['add', '-A'], seed);
git(['commit', '-qm', 'A'], seed);
const a = git(['rev-parse', 'HEAD'], seed);
git(['init', '-q', '--bare', '-b', 'main', bare], host.base);
git(['push', '-q', bare, 'main'], seed);
const root = path.join(host.base, 'gstack');
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
write(path.join(seed, 'VERSION'), '1.0.1\n');
write(path.join(seed, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.1\nGSTACK_CAPY_MIN_WRAPPER=${opts.minWrapper ?? 1}\n`);
for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh']) {
if (fs.existsSync(path.join(REPO, rel))) write(path.join(seed, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
}
write(path.join(seed, 'bin/gstack-capy-install'), STUB_INSTALLER, 0o755);
git(['add', '-A'], seed);
git(['commit', '-qm', 'B'], seed);
const b = git(['rev-parse', 'HEAD'], seed);
git(['push', '-q', bare, 'main'], seed);
const env = { ...host.env, GSTACK_CAPY_ROOT: root, GSTACK_CAPY_REPO: `file://${bare}`, WRAP_LOG: log, WRAP_FAIL_ONCE: path.join(host.base, 'fail-once') };
const run = (args: string[] = [], extra: Record<string, string> = {}) => {
const r = spawnSync('bash', [BOOT, ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 120_000 });
const lines = r.stdout.trim().split('\n');
return { ...r, lines, last: lines.at(-1) ?? '' };
};
return {
host, root, bare, a, b, run, git: (args: string[]) => git(args, root),
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [],
};
}
describe('capy skill bootstrapper (capy/skill/gstack/scripts/install.sh)', () => {
test('the first line names the wrapper revision and path; bash -n passes for the bootstrapper and the full wrapper', () => {
for (const file of [BOOT, path.join(REPO, 'bin/gstack-capy-session')]) {
expect(fs.existsSync(file), file).toBe(true);
const r = spawnSync('bash', ['-n', file], { encoding: 'utf8' });
expect(r.status, `${file}: ${r.stderr}`).toBe(0);
}
const f = fixture();
const r = f.run(['--check', '--for', 'autoplan']);
expect(r.lines[0]).toMatch(new RegExp(`^gstack-capy-skill: wrapper \\d+ \\(${BOOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)$`));
});
test('below the remote minimum: --check is read-only and names the fast-forwarding command; install fast-forwards, installs and ends ok; the next session start is ok without moving', () => {
const f = fixture();
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
const check = f.run(['--check', '--for', 'autoplan']);
expect(check.status, check.stdout + check.stderr).toBe(1);
expect(check.last).toBe(`gstack: fail revision — installed 1.0.0 is below the supported minimum 1.0.1 (fast-forward available); fix: bash ${BOOT} --for autoplan (fast-forwards) (REVISION_BELOW_MINIMUM)`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
expect(f.calls()).toEqual([]);
const install = f.run(['--for', 'autoplan']);
expect(install.status, install.stdout + install.stderr).toBe(0);
expect(install.stdout).toContain('gstack-capy-skill: upgraded 1.0.0 -> 1.0.1');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan');
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=1.0.0`]);
const next = f.run(['--check', '--for', 'autoplan']);
expect(next.status, next.stdout + next.stderr).toBe(0);
expect(next.stdout).not.toContain('upgraded');
expect(next.last).toBe('gstack: ok 1.0.1 for=autoplan');
expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=1 min=1.0.1 from=`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
test('dirty, pinned, diverged or auto-upgrade disabled: no change, fail revision names the cause and the exact commands', () => {
const f = fixture();
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
const dirty = f.run(['--for', 'autoplan']);
expect(dirty.status).toBe(1);
expect(dirty.last).toMatch(/^gstack: fail revision — installed 1\.0\.0 is below the supported minimum 1\.0\.1 \(tracked files changed: README\.md\); fix: .*git -C \S+ status --short.*\(REVISION_BELOW_MINIMUM\)$/);
expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
f.git(['checkout', '-q', '--', 'README.md']);
const pinned = f.run(['--for', 'autoplan', '--revision', f.a]);
expect(pinned.status).toBe(1);
expect(pinned.last).toContain('(pinned by --revision');
f.git(['checkout', '-q', '--detach', 'HEAD']);
const detached = f.run(['--for', 'autoplan']);
expect(detached.last).toContain('(pinned: HEAD is detached');
f.git(['checkout', '-q', 'main']);
const disabled = f.run(['--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' });
expect(disabled.status).toBe(1);
expect(disabled.last).toContain('(auto-upgrade disabled)');
expect(disabled.last).toContain('GSTACK_CAPY_AUTO_UPGRADE');
fs.writeFileSync(path.join(f.root, 'README.md'), 'local commit\n');
f.git(['commit', '-qam', 'local']);
const head = f.git(['rev-parse', 'HEAD']);
const diverged = f.run(['--for', 'autoplan']);
expect(diverged.status).toBe(1);
expect(diverged.last).toContain('(diverged: ');
expect(f.git(['rev-parse', 'HEAD'])).toBe(head);
expect(f.git(['stash', 'list'])).toBe('');
expect(f.calls()).toEqual([]);
});
test('an interrupted install after the fast-forward leaves the trailer fail; the next install run completes it', () => {
const f = fixture();
write(path.join(f.host.base, 'fail-once'), '1');
const first = f.run(['--for', 'autoplan']);
expect(first.status).toBe(1);
expect(first.last).toBe('gstack: fail claude');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
const second = f.run(['--for', 'autoplan']);
expect(second.status, second.stdout + second.stderr).toBe(0);
expect(second.last).toBe('gstack: ok 1.0.1 for=autoplan');
});
test('offline: the remote minimum is unknown and said so; an old checkout is never ok', () => {
const f = fixture();
fs.renameSync(f.bare, `${f.bare}.gone`);
const old = f.run(['--check', '--for', 'autoplan']);
expect(old.status).toBe(1);
expect(old.stdout).toContain('remote minimum unknown (fetch failed)');
expect(old.last).toMatch(/^gstack: fail /);
fs.renameSync(`${f.bare}.gone`, f.bare);
expect(f.run(['--for', 'autoplan']).status).toBe(0);
fs.renameSync(f.bare, `${f.bare}.gone`);
const current = f.run(['--check', '--for', 'autoplan']);
expect(current.stdout).toContain('remote minimum unknown (fetch failed)');
expect(f.calls().at(-1)).toContain('min=unknown');
});
test('a stale bootstrapper still runs the checkout wrapper and names the owner action', () => {
const f = fixture({ minWrapper: 99 });
const r = f.run(['--for', 'autoplan']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toMatch(new RegExp(`^gstack-capy-skill: owner action — Drive copy is wrapper \\d+, minimum 99; copy ${f.root}/capy/skill/gstack/ \\(CAPY_WRAPPER_STALE\\)$`, 'm'));
expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan');
});
test('no checkout: --check records the not-installed trailer; install clones and runs the checkout wrapper', () => {
const f = fixture();
fs.rmSync(f.root, { recursive: true, force: true });
const check = f.run(['--check', '--for', 'autoplan']);
expect(check.status).toBe(1);
expect(check.last).toBe(`gstack: fail not-installed — no gstack checkout at ${f.root}; fix: bash ${BOOT} --for autoplan (installs)`);
const install = f.run(['--for', 'autoplan']);
expect(install.status, install.stdout + install.stderr).toBe(0);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan');
});
});
describe('capy skill: vendored text and support file', () => {
const support = fs.existsSync(path.join(REPO, 'capy/support.env')) ? fs.readFileSync(path.join(REPO, 'capy/support.env'), 'utf8') : '';
test('the wrapper revision meets the checkout minimum; GSTACK_MIN_REVISION <= VERSION', () => {
const wrapper = Number(fs.readFileSync(BOOT, 'utf8').match(/^GSTACK_CAPY_WRAPPER=(\d+)$/m)?.[1] ?? 0);
expect(wrapper).toBeGreaterThanOrEqual(Number(envValue(support, 'GSTACK_CAPY_MIN_WRAPPER') || 999));
const min = envValue(support, 'GSTACK_MIN_REVISION');
expect(min).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(below(fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim(), min)).toBe(false);
});
test('SKILL.md: frontmatter, line cap, the session protocol tokens, and every command it names exists', () => {
const text = fs.readFileSync(path.join(SKILL, 'SKILL.md'), 'utf8');
expect(text).toMatch(/^---\nname: gstack\ndescription: .+\n---\n/);
expect(text.split('\n').length).toBeLessThanOrEqual(90);
for (const token of ['--check', 'GSTACK_LAUNCH', '^gstack: ok', 'gstack: fail not-installed', 'gstack-pregate sealed', 'autoplan/DRIVER.md', 'first report']) {
expect(text, token).toContain(token);
}
const named = [...text.matchAll(/\b(?:bin\/)?(gstack-[a-z0-9-]+)\b/g)].map(m => m[1]).filter(n => n !== 'gstack-capy-skill');
const missing = [...new Set(named)].filter(n => !fs.existsSync(path.join(REPO, 'bin', n)) && n !== 'gstack-pregate');
expect(missing).toEqual([]);
expect(fs.existsSync(path.join(REPO, 'bin/gstack-pregate'))).toBe(true);
});
test('every (CODE) the install-side bash bins print resolves to a troubleshooting anchor', () => {
const bins = ['bin/gstack-capy-install', 'bin/gstack-upgrade', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', 'capy/skill/gstack/scripts/install.sh'];
const codes = new Set<string>();
for (const rel of bins) for (const m of fs.readFileSync(path.join(REPO, rel), 'utf8').matchAll(/\(([A-Z][A-Z0-9]+_[A-Z0-9_]+)[;)]/g)) codes.add(m[1]);
for (const m of fs.readFileSync(path.join(REPO, 'bin/gstack-install-verdict.sh'), 'utf8').matchAll(/code=([A-Z][A-Z0-9_]+)/g)) codes.add(m[1]);
expect([...codes].sort()).toEqual(expect.arrayContaining(['CAPY_WRAPPER_STALE', 'INSTALL_UNVERIFIED', 'REVISION_BELOW_MINIMUM', 'RUNTIME_BELOW_MINIMUM', 'UPGRADE_FETCH_FAILED', 'UPGRADE_REFUSED']));
expect([...codes].filter(c => !(c in RESULT_CODES))).toEqual([]);
});
});
+1
View File
@@ -8,6 +8,7 @@
"browse": 8961,
"browser-skills/hackernews-frontpage": 371,
"canary": 13520,
"capy/skill/gstack": 2225,
"careful": 1328,
"codex": 15598,
"context-restore": 10995,
+113 -2
View File
@@ -48,7 +48,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) {
const log = path.join(base, 'calls.log');
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(stub, { recursive: true });
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-doctor-components.sh']) {
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-install-verdict.sh']) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.copyFileSync(path.join(REPO, rel), path.join(root, rel));
fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode);
@@ -200,7 +200,7 @@ describe('gstack-capy-install', () => {
const before = sh('git rev-parse HEAD', f.root).stdout.trim();
const r = run(f, ['--revision', 'v9.9.9.9']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(lastLine(r.stdout)).toBe('gstack: ok 1.2.3 for=autoplan (requested v9.9.9.9; run gstack-upgrade to move)');
expect(lastLine(r.stdout)).toBe(`gstack: ok 1.2.3 for=autoplan (requested v9.9.9.9; run ${f.root}/bin/gstack-upgrade to move)`);
expect(sh('git rev-parse HEAD', f.root).stdout.trim()).toBe(before);
expect(run(f, ['--revision', '1.2.3']).stdout).not.toContain('requested');
expect(lastLine(run(f, ['--revision', before]).stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
@@ -221,6 +221,117 @@ describe('gstack-capy-install', () => {
});
});
// Field wave lane A (items 3 and 5): the launch line carries the verified Bun
// the trailer certified, an installed Bun is reused, a project's exact CI pin
// is installed alongside a different PATH Bun, ./setup is skipped when this
// HEAD was already set up, and a verdict file records what was certified.
describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verdict', () => {
const zipBun = (f: ReturnType<typeof fixture>, version: string, runsTs = false) => {
const target = sh('grep -q avx2 /proc/cpuinfo && echo linux-x64 || echo linux-x64-baseline').stdout.trim();
const zip = path.join(f.base, `bun-${version}.zip`);
fs.mkdirSync(path.join(f.base, `z/bun-${target}`), { recursive: true });
script(path.join(f.base, `z/bun-${target}/bun`), runsTs
? [`[ "$1" = --version ] && { echo ${version}; exit 0; }`, `exec "${process.execPath}" "$@"`]
: [`echo ${version}`]);
expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-${target}`).status).toBe(0);
const sha = sh(`sha256sum "${zip}" | cut -d' ' -f1`).stdout.trim();
const versions = path.join(f.root, 'bin/gstack-bun-version.sh');
fs.writeFileSync(versions, fs.readFileSync(versions, 'utf8').replace(new RegExp(`(\\s*${target}\\) echo )[0-9a-f]{64}`), `$1${sha}`));
sh('git add -A && git -c user.email=t@t -c user.name=t commit -qm sha', f.root);
return zip;
};
const verdicts = (f: ReturnType<typeof fixture>) => {
const dir = path.join(f.home, '.gstack-state/install-verdicts');
return fs.existsSync(dir) ? fs.readdirSync(dir).map(n => path.join(dir, n)) : [];
};
test.skipIf(process.platform !== 'linux')('after installing Bun the launch line carries its PATH; a second run reuses it (one download) and skips setup on the same HEAD', () => {
const f = fixture({ bun: '1.3.14' });
const zip = zipBun(f, '1.4.2');
const bunDir = path.join(f.home, '.bun/bin');
const first = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
expect(first.status, first.stdout + first.stderr).toBe(0);
expect(first.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=${path.join(f.home, '.gstack-state')} GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=`);
expect(first.stdout).toContain(`GSTACK_LAUNCH_PATH: ${bunDir}`);
const second = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
expect(second.status, second.stdout + second.stderr).toBe(0);
expect(second.stdout).toContain(`bun 1.4.2 at ${bunDir}/bun`);
expect(second.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH `);
expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(2);
expect(second.stdout).toContain('setup skipped');
expect(lastLine(second.stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
});
test('setup runs only on the first of two runs on one HEAD; a new HEAD, a failed doctor or --with-browser runs it again', () => {
const f = fixture();
expect(run(f).status).toBe(0);
expect(run(f).status).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(2);
expect(run(f, [], { STUB_DOCTOR_FAIL: '1' }).status).toBe(1);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(4);
write(path.join(f.root, 'NEW'), 'x\n');
sh('git add NEW && git -c user.email=t@t -c user.name=t commit -qm next', f.root);
expect(run(f).status).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(6);
expect(run(f, ['--with-browser']).status).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(8);
});
test('a setup that fails once leaves the trailer fail and records nothing; the next run sets up again and ends ok', () => {
const f = fixture();
const failed = run(f, [], { STUB_SETUP_EXIT: '1' });
expect(failed.status).toBe(1);
expect(lastLine(failed.stdout)).toMatch(/^gstack: fail setup/);
expect(run(f).status).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(3);
});
test.skipIf(process.platform !== 'linux')('PATH Bun 1.4.3 and a CI pin of 1.4.2: the pinned verified Bun is installed alongside and launched with its PATH', () => {
const f = fixture({ bun: '1.4.3' });
script(path.join(f.stub, 'bun'), ['[ "$1" = --version ] && { echo 1.4.3; exit 0; }', `exec "${process.execPath}" "$@"`]);
for (const rel of ['bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts']) write(path.join(f.root, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
const zip = zipBun(f, '1.4.2', true);
const project = path.join(f.base, 'project');
write(path.join(project, 'package.json'), '{ "engines": { "bun": ">=1.4.0" } }\n');
write(path.join(project, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.2\n');
const r = run(f, ['--for', 'review', '--project', project], { STUB_CURL_ZIP: zip });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1);
expect(f.calls().find(l => l.startsWith('curl '))).toContain('bun-v1.4.2/bun-');
expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(true);
expect(r.stdout).toContain(`GSTACK_LAUNCH: PATH=${path.join(f.home, '.bun/bin')}:$PATH `);
const other = fixture({ bun: '1.4.3' });
script(path.join(other.stub, 'bun'), ['[ "$1" = --version ] && { echo 1.4.3; exit 0; }', `exec "${process.execPath}" "$@"`]);
for (const rel of ['bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts']) write(path.join(other.root, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
write(path.join(project, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.1\n');
const conflict = run(other, ['--project', project]);
expect(conflict.status).toBe(3);
expect(conflict.stderr).toContain('pins exactly 1.4.1');
expect(conflict.stderr).toContain('(RUNTIME_PIN_CONFLICT; ');
});
test('the verdict file records checkout, HEAD, workflow and trailer, keyed by the checkout; --check certifies without setup or downloads', () => {
const f = fixture();
const r = run(f, ['--for', 'autoplan']);
expect(r.status, r.stdout + r.stderr).toBe(0);
const files = verdicts(f);
expect(files).toHaveLength(1);
expect(r.stdout).toContain(`GSTACK_INSTALL_VERDICT=${files[0]}`);
const v = JSON.parse(fs.readFileSync(files[0], 'utf8'));
expect(v).toMatchObject({ schema_version: 1, checkout: f.root, head: sh('git rev-parse HEAD', f.root).stdout.trim(), for: 'autoplan', ok: true, trailer: 'gstack: ok 1.2.3 for=autoplan' });
const setups = f.calls().filter(l => l.startsWith('setup ')).length;
const c = run(f, ['--check', '--for', 'autoplan']);
expect(c.status, c.stdout + c.stderr).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup ')).length).toBe(setups);
expect(c.stdout).toContain('GSTACK_LAUNCH: ');
expect(lastLine(c.stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
const failed = run(f, ['--check', '--for', 'autoplan'], { STUB_DOCTOR_FAIL: '1' });
expect(failed.status).toBe(1);
expect(JSON.parse(fs.readFileSync(files[0], 'utf8'))).toMatchObject({ ok: false, trailer: 'gstack: fail codex' });
});
});
function run(f: ReturnType<typeof fixture>, args: string[] = [], extra: Record<string, string> = {}, cwd?: string) {
return f.run(args, extra, cwd);
}
+66
View File
@@ -701,3 +701,69 @@ describe('gstack-doctor --check', () => {
expect(doctor(f, ['--help']).out).toContain('--check');
});
});
// Field wave lane A: the trailer agrees with the Bun workflows run (A3), a
// remote minimum fails the revision row (A2), and planning-only workflows do
// not fail on a repo test-pin mismatch (A3 amendment).
describe('gstack-doctor --check: launch Bun, supported minimum, planning-only pins', () => {
const VERSION = fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim();
const check = (f: Fixture, args: string[], extra: Record<string, string> = {}) => {
const r = doctor(f, ['--check', ...args], extra);
const lines = r.out.split('\n');
const rows = Object.fromEntries(lines.filter(l => /^(PASS|FAIL|SKIP) /.test(l)).map(l => {
const m = l.match(/^(PASS|FAIL|SKIP) ([a-z-]+)(?:\s(.*))?$/)!;
return [m[2], { status: m[1], rest: m[3] ?? '' }];
}));
return { status: r.status, out: r.out, rows, trailer: lines.filter(l => l.startsWith('gstack: ')).at(-1) ?? '' };
};
/** A verified Bun in $HOME/.bun/bin (off PATH) that answers --version and runs TypeScript. */
const verifiedBun = (f: Fixture, version = '1.4.2') =>
script(path.join(f.home, '.bun/bin/bun'), [`[ "$1" = --version ] && { echo "${version}"; exit 0; }`, `exec "${process.execPath}" "$@"`]);
test('PATH Bun 1.3.14 with a verified 1.4.2 off PATH: runtime FAILs naming where it was found; with the launch PATH it PASSes', () => {
const f = makeFixture();
verifiedBun(f);
const fresh = check(f, [], { STUB_BUN_VERSION: '1.3.14' });
expect(fresh.rows.runtime.status).toBe('FAIL');
expect(fresh.rows.runtime.rest).toContain(`bun 1.4.2 found at ${f.home}/.bun/bin (not on PATH)`);
expect(fresh.rows.runtime.rest).toContain(`fix: export PATH="${f.home}/.bun/bin:$PATH" or prefix commands with the GSTACK_LAUNCH line`);
expect(fresh.rows.runtime.rest).toContain('(RUNTIME_BELOW_MINIMUM; ');
const launched = check(f, [], { STUB_BUN_VERSION: '1.3.14', PATH: `${f.home}/.bun/bin${path.delimiter}${f.env.PATH}` });
expect(launched.rows.runtime.status, launched.out).toBe('PASS');
expect(launched.rows.runtime.rest).toContain(`at ${f.home}/.bun/bin/bun`);
});
test('--min-revision: below FAILs with REVISION_BELOW_MINIMUM naming the upgrade command; at or above PASSes; unknown says so and never claims the minimum', () => {
const f = makeFixture();
const below = check(f, ['--min-revision', '99.0.0.0']);
expect(below.status).toBe(1);
expect(below.rows.revision.status).toBe('FAIL');
expect(below.rows.revision.rest).toContain(`installed ${VERSION}`);
expect(below.rows.revision.rest).toContain('below the supported minimum 99.0.0.0');
expect(below.rows.revision.rest).toContain(`${bashPath(f.root)}/bin/gstack-upgrade`);
expect(below.rows.revision.rest).toContain('(REVISION_BELOW_MINIMUM; ');
expect(below.trailer).toBe('gstack: fail revision');
const wrapped = check(f, ['--min-revision', '99.0.0.0'], { GSTACK_CAPY_WRAPPER_PATH: '/drive/skills/gstack/scripts/install.sh' });
expect(wrapped.rows.revision.rest).toContain('fix: bash /drive/skills/gstack/scripts/install.sh (fast-forwards)');
const at = check(f, ['--min-revision', VERSION]);
expect(at.rows.revision.status, at.out).toBe('PASS');
expect(at.rows.revision.rest).toContain(`>= supported minimum ${VERSION}`);
const unknown = check(f, ['--min-revision', 'unknown']);
expect(unknown.rows.revision.status).toBe('PASS');
expect(unknown.rows.revision.rest).toContain('remote minimum unknown (fetch failed)');
expect(unknown.rows.revision.rest).not.toContain('>= supported minimum');
});
test('planning-only workflows SKIP a repo test-pin mismatch; test-running workflows and --require keep pins required', () => {
const f = makeFixture();
const project = path.join(f.base, 'project');
write(path.join(project, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.2\n');
const planning = check(f, ['--for', 'autoplan', '--project', project], { STUB_BUN_VERSION: '1.4.3' });
expect(planning.rows.pins.status, planning.out).toBe('SKIP');
expect(planning.rows.pins.rest).toMatch(/^\(planning-only; repo pins .*1\.4\.2.*, launch bun 1\.4\.3\)$/);
for (const args of [['--for', 'review'], ['--for', 'ship'], ['--require', 'claude']]) {
const tests = check(f, [...args, '--project', project], { STUB_BUN_VERSION: '1.4.3' });
expect(tests.rows.pins.status, args.join(' ')).toBe('FAIL');
}
});
});
+108
View File
@@ -394,6 +394,7 @@ describe('gstack-skill-start behavior', () => {
expect(out).not.toContain('UPGRADE_AVAILABLE');
const endOut = end({ GSTACK_SESSION_KIND: 'unattended' });
expect(endOut).toMatch(/^UPGRADE_AVAILABLE: 9\.9\.9; run \/gstack-upgrade$/m);
expect(endOut).toMatch(/^UPGRADE_COMMAND: \/.*\/bin\/gstack-upgrade$/m);
expect(endOut).toMatch(/^ARTIFACTS_SYNC: skipped \(unattended session\)$/m);
expect(fs.existsSync(log)).toBe(false);
expect(fs.existsSync(path.join(gh, 'analytics', 'skill-usage.jsonl'))).toBe(false);
@@ -764,3 +765,110 @@ describe('gstack-skill-end', () => {
expect(fs.existsSync(pending)).toBe(false);
});
});
// Field wave lane A (item 2): an unattended or Capy-spawned workflow refuses
// to start on an unsupported PATH Bun, and on a Capy machine (GSTACK_CAPY=1)
// without a matching install verdict for this checkout, HEAD and workflow.
describe('gstack-skill-start install admission', () => {
const { spawnSync } = require('child_process') as typeof import('child_process');
const { createHash } = require('crypto') as typeof import('crypto');
const { writeBunStub } = require('./helpers/capy-host') as typeof import('./helpers/capy-host');
const head = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
const real = fs.realpathSync(ROOT);
const scratch = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-admit-'));
fs.writeFileSync(path.join(dir, 'config.yaml'), 'update_check: false\n');
return dir;
};
const stubDir = (version: string) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-bun-'));
writeBunStub(path.join(dir, 'bun'), version);
return dir;
};
const verdict = (gh: string, over: Record<string, unknown> = {}, file?: string) => {
const target = file ?? path.join(gh, 'install-verdicts', `${createHash('sha256').update(real).digest('hex')}.json`);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, JSON.stringify({
schema_version: 1, checkout: real, head, for: 'autoplan',
required: ['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision'],
pins_planning_only: false, ok: true, trailer: 'gstack: ok 1.91.95.0 for=autoplan', min_revision: '1.91.95.0', launch_path: '', time: '2026-10-12T00:00:00Z', ...over,
}, null, 1) + '\n');
return target;
};
const start = (gh: string, skill: string, env: Record<string, string>, bin = START) => spawnSync(bin, ['--skill', skill], {
timeout: 30_000, encoding: 'utf-8', cwd: tmpHome,
env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: gh, ...env },
});
test('unattended with Bun 1.3.14 first on PATH: the runtime row with its fix, status=refused, exit 3; interactive keeps running', () => {
const gh = scratch();
const bun = stubDir('1.3.14');
const PATH = `${bun}${path.delimiter}${process.env.PATH}`;
const r = start(gh, 'autoplan', { GSTACK_SESSION_KIND: 'unattended', PATH });
expect(r.status, r.stdout + r.stderr).toBe(3);
expect(r.stdout).toMatch(/^SESSION_KIND: unattended$/m);
expect(r.stdout).toContain(`INSTALL: fail runtime — bun 1.3.14 at ${bun}/bun is below the supported minimum 1.4.2`);
expect(r.stdout).toContain('(RUNTIME_BELOW_MINIMUM)');
expect(r.stdout.trim().split('\n').at(-1)).toBe('GSTACK_RESULT: skill=autoplan status=refused run=-');
const human = start(gh, 'autoplan', { PATH });
expect(human.status).toBe(0);
expect(human.stdout).not.toContain('status=refused');
const openclaw = start(gh, 'autoplan', { OPENCLAW_SESSION: '1', PATH });
expect(openclaw.status).toBe(0);
expect(openclaw.stdout).toContain('INSTALL: warn runtime');
const overridden = start(gh, 'autoplan', { GSTACK_SESSION_KIND: 'unattended', PATH, GSTACK_ALLOW_UNSUPPORTED_RUNTIME: '1' });
expect(overridden.status).toBe(0);
expect(overridden.stdout).toContain('INSTALL_VERDICT: overridden (GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1)');
});
test('GSTACK_CAPY=1: a missing, failing, other-HEAD or narrower verdict refuses; a matching one admits; other unattended hosts need no verdict', () => {
const gh = scratch();
const capy = { GSTACK_SESSION_KIND: 'unattended', GSTACK_CAPY: '1' };
const missing = start(gh, 'autoplan', capy);
expect(missing.status, missing.stdout + missing.stderr).toBe(3);
expect(missing.stdout).toContain('INSTALL: fail verdict — no install verdict');
expect(missing.stdout).toContain('(INSTALL_UNVERIFIED)');
expect(start(gh, 'autoplan', { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0);
verdict(gh);
const ok = start(gh, 'autoplan', capy);
expect(ok.status, ok.stdout + ok.stderr).toBe(0);
expect(ok.stdout).toMatch(/^INSTALL_VERDICT: ok gstack: ok 1\.91\.95\.0 for=autoplan$/m);
const spawned = start(gh, 'autoplan', { GSTACK_SESSION_KIND: 'spawned', GSTACK_CAPY: '1' });
expect(spawned.status).toBe(0);
verdict(gh, { ok: false, trailer: 'gstack: fail runtime' });
const failed = start(gh, 'autoplan', capy);
expect(failed.status).toBe(3);
expect(failed.stdout).toContain('gstack: fail runtime');
verdict(gh, { head: '0'.repeat(40) });
const moved = start(gh, 'autoplan', capy);
expect(moved.status).toBe(3);
expect(moved.stdout).toContain('another HEAD');
verdict(gh, { pins_planning_only: true });
expect(start(gh, 'autoplan', capy).status).toBe(0);
const tests = start(gh, 'review', capy);
expect(tests.status).toBe(3);
expect(tests.stdout).toContain('planning-only');
verdict(gh);
const browser = start(gh, 'qa', capy);
expect(browser.status).toBe(3);
expect(browser.stdout).toContain('browse-bundle');
const elsewhere = verdict(gh, {}, path.join(gh, 'named.json'));
fs.rmSync(path.join(gh, 'install-verdicts'), { recursive: true, force: true });
expect(start(gh, 'autoplan', { ...capy, GSTACK_INSTALL_VERDICT: elsewhere }).status).toBe(0);
});
test('an installed runtime root follows its .source-path to the source checkout for the verdict and HEAD', () => {
const gh = scratch();
const runtime = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-runtime-'));
fs.symlinkSync(path.join(ROOT, 'bin'), path.join(runtime, 'bin'));
fs.writeFileSync(path.join(runtime, '.source-path'), `${ROOT}\n`);
fs.copyFileSync(path.join(ROOT, 'VERSION'), path.join(runtime, 'VERSION'));
verdict(gh);
const r = start(gh, 'autoplan', { GSTACK_SESSION_KIND: 'unattended', GSTACK_CAPY: '1' }, path.join(runtime, 'bin/gstack-skill-start'));
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain('INSTALL_VERDICT: ok');
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* bin/gstack-upgrade: the deterministic, unattended upgrade every fix line
* names. Pinned on its machine lines (`gstack-upgrade: current|behind|upgraded`),
* exit codes (0 ok, 1 behind on --check, 3 refused with nothing changed) and
* what it refuses: a dirty tracked byte, a diverged local commit, a pinned
* (detached or off-default) checkout, an unreachable remote. Never stash,
* never reset: HEAD, file bytes and the reflog are unchanged on refusal.
* Fixture: a bare remote with commits A (1.0.0) and B (1.0.1), a depth-1
* file:// clone at A, ./setup and bin/gstack-capy-install stubs that log argv,
* on the simulated Capy host (test/helpers/capy-host.ts).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { capyHost, type CapyHost } from './helpers/capy-host';
const REPO = path.resolve(import.meta.dir, '..');
const DEPS = [
'bin/gstack-upgrade', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh',
'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts',
];
const hosts: CapyHost[] = [];
afterEach(() => { for (const h of hosts.splice(0)) h.cleanup(); });
const write = (file: string, text: string, mode = 0o644) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, text, { mode });
};
function fixture() {
const host = capyHost({ systemBun: '1.4.2' });
hosts.push(host);
const git = (args: string[], cwd: string) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: host.env, timeout: 30_000 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const log = path.join(host.base, 'calls.log');
const seed = path.join(host.base, 'seed');
const bare = path.join(host.base, 'remote.git');
fs.mkdirSync(seed, { recursive: true });
git(['init', '-q', '-b', 'main'], seed);
for (const rel of DEPS) {
if (!fs.existsSync(path.join(REPO, rel))) continue;
fs.mkdirSync(path.dirname(path.join(seed, rel)), { recursive: true });
fs.copyFileSync(path.join(REPO, rel), path.join(seed, rel));
fs.chmodSync(path.join(seed, rel), fs.statSync(path.join(REPO, rel)).mode);
}
write(path.join(seed, 'VERSION'), '1.0.0\n');
write(path.join(seed, 'README.md'), 'gstack fixture\n');
write(path.join(seed, 'bin/gstack-config'), '#!/usr/bin/env bash\nexit 0\n', 0o755);
write(path.join(seed, 'setup'), '#!/usr/bin/env bash\necho "setup $*" >> "$UPGRADE_LOG"\nexit "${STUB_SETUP_EXIT:-0}"\n', 0o755);
git(['add', '-A'], seed);
git(['commit', '-qm', 'A'], seed);
const a = git(['rev-parse', 'HEAD'], seed);
git(['init', '-q', '--bare', '-b', 'main', bare], host.base);
git(['push', '-q', bare, 'main'], seed);
const root = path.join(host.base, 'gstack');
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
write(path.join(seed, 'VERSION'), '1.0.1\n');
git(['commit', '-qam', 'B'], seed);
const b = git(['rev-parse', 'HEAD'], seed);
git(['push', '-q', bare, 'main'], seed);
const env = { ...host.env, UPGRADE_LOG: log };
const run = (args: string[] = [], extra: Record<string, string> = {}) =>
spawnSync('bash', [path.join(root, 'bin/gstack-upgrade'), ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 60_000 });
return {
host, root, bare, a, b, run, git: (args: string[]) => git(args, root),
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [],
};
}
describe('gstack-upgrade', () => {
test('--check: behind prints both versions and the commit count, exit 1, writes nothing', () => {
const f = fixture();
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
const r = f.run(['--check']);
expect(r.status, r.stdout + r.stderr).toBe(1);
expect(r.stdout).toContain('gstack-upgrade: behind 1.0.0 -> 1.0.1 (1 commits)');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
expect(f.calls()).toEqual([]);
});
test('default: fast-forwards to the remote default branch and runs setup --refresh-registered; --check is then current', () => {
const f = fixture();
const r = f.run();
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(r.stdout).toContain('gstack-upgrade: upgraded 1.0.0 -> 1.0.1');
expect(f.calls()).toEqual(['setup --refresh-registered']);
const again = f.run(['--check']);
expect(again.status).toBe(0);
expect(again.stdout).toContain('gstack-upgrade: current 1.0.1');
});
test('a reinstall that fails after the fast-forward exits 1; the next run completes it instead of saying current', () => {
const f = fixture();
const failed = f.run([], { STUB_SETUP_EXIT: '1' });
expect(failed.status).toBe(1);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(failed.stderr).toContain('reinstall failed');
expect(f.run(['--check']).status).toBe(1);
const retry = f.run();
expect(retry.status, retry.stdout + retry.stderr).toBe(0);
expect(retry.stdout).toContain('reinstall pending from 1.0.0');
expect(f.calls()).toEqual(['setup --refresh-registered', 'setup --refresh-registered']);
expect(f.run().stdout).toContain('gstack-upgrade: current 1.0.1');
});
test('a Capy install (the installer marker) runs bin/gstack-capy-install instead of setup', () => {
const f = fixture();
write(path.join(f.root, 'bin/gstack-capy-install'), '#!/usr/bin/env bash\necho "capy-install $*" >> "$UPGRADE_LOG"\necho "gstack: ok $(cat VERSION) for=autoplan"\n', 0o755);
write(path.join(f.root, '.git/gstack-capy-install.state'), `head=${f.a}\n`);
const r = f.run();
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(f.calls()).toEqual(['capy-install']);
expect(r.stdout.trim().split('\n').at(-1)).toBe('gstack: ok 1.0.1 for=autoplan');
});
test('a dirty tracked byte refuses with UPGRADE_REFUSED: exit 3, HEAD, bytes and reflog unchanged', () => {
const f = fixture();
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
const reflog = f.git(['reflog']);
const r = f.run();
expect(r.status).toBe(3);
expect(r.stderr).toContain('(UPGRADE_REFUSED)');
expect(r.stderr).toContain('README.md');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n');
expect(f.git(['reflog'])).toBe(reflog);
expect(f.git(['stash', 'list'])).toBe('');
expect(f.calls()).toEqual([]);
});
test('a diverged local commit refuses with UPGRADE_REFUSED: never stash, never reset', () => {
const f = fixture();
fs.writeFileSync(path.join(f.root, 'README.md'), 'local commit\n');
f.git(['commit', '-qam', 'local']);
const head = f.git(['rev-parse', 'HEAD']);
const reflog = f.git(['reflog']);
const r = f.run();
expect(r.status).toBe(3);
expect(r.stderr).toContain('(UPGRADE_REFUSED)');
expect(f.git(['rev-parse', 'HEAD'])).toBe(head);
expect(f.git(['reflog'])).toBe(reflog);
expect(f.calls()).toEqual([]);
});
test('a detached (pinned) checkout refuses; an unreachable remote is UPGRADE_FETCH_FAILED (exit 3) for --check and default', () => {
const f = fixture();
f.git(['checkout', '-q', '--detach', 'HEAD']);
const pinned = f.run();
expect(pinned.status).toBe(3);
expect(pinned.stderr).toContain('(UPGRADE_REFUSED)');
expect(pinned.stderr).toContain('detached');
f.git(['checkout', '-q', 'main']);
fs.rmSync(f.bare, { recursive: true, force: true });
for (const args of [['--check'], []]) {
const offline = f.run(args);
expect(offline.status).toBe(3);
expect(offline.stderr).toContain('(UPGRADE_FETCH_FAILED)');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
}
});
test('every bin a trailer or fix line names exists in the repo', () => {
const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md',
'capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh'];
const named = new Set<string>();
for (const rel of sources) {
expect(fs.existsSync(path.join(REPO, rel)), rel).toBe(true);
const lines = fs.readFileSync(path.join(REPO, rel), 'utf8').split('\n').filter(l => /fix:|\brun |gstack: (ok|fail)/.test(l));
for (const m of lines.join('\n').matchAll(/(?:bin\/|\brun |\bfix: )(gstack-[a-z0-9-]+(?:\.sh|\.ts)?)\b/g)) named.add(m[1]);
}
expect(named.has('gstack-upgrade')).toBe(true);
const missing = [...named].filter(n => !fs.existsSync(path.join(REPO, 'bin', n)));
expect(missing).toEqual([]);
});
});
+13 -5
View File
@@ -9,6 +9,8 @@ import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { capyHost, type CapyHost } from './capy-host';
import { SKILL_REQUIREMENTS } from '../../lib/doctor-components';
import type { InstallVerdict } from '../../lib/install-verdict';
import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN } from './autoplan-run-fixture';
const SESSION_KIND_VAR = ['GSTACK', 'SESSION', 'KIND'].join('_');
@@ -20,23 +22,29 @@ export interface CapyRunner {
ap(args: string[], extra?: Record<string, string>): SpawnSyncReturns<string>;
review(prompt: string, extra?: Record<string, string>): SpawnSyncReturns<string>;
validate(out: string): SpawnSyncReturns<string>;
writeVerdict(fields: Record<string, string>): void;
writeVerdict(fields: Partial<InstallVerdict>): void;
cleanup(): void;
}
/** A launched command on the Capy host: GSTACK_LAUNCH PATH, unattended, GSTACK_CAPY=1, an ok verdict for this HEAD. */
export function capyRunner(o: { launched?: boolean; verdict?: Record<string, string> | null } = {}): CapyRunner {
export function capyRunner(o: { launched?: boolean; verdict?: Partial<InstallVerdict> | null } = {}): CapyRunner {
const host = capyHost({ verifiedBun: '1.4.2' });
const verdict = path.join(host.stateRoot, 'install-verdict');
const env: Record<string, string> = {
...host.env, PATH: o.launched === false ? host.env.PATH! : host.launchPath,
[SESSION_KIND_VAR]: RUNNER_KIND, GSTACK_CAPY: '1', GSTACK_INSTALL_VERDICT: verdict,
};
const writeVerdict = (fields: Record<string, string>) => {
/** lib/install-verdict.ts schema 1, an ok autoplan verdict for this checkout and HEAD unless overridden. */
const writeVerdict = (fields: Partial<InstallVerdict>) => {
const trailer = fields.trailer ?? 'gstack: ok 1.91.96.0 for=autoplan';
const full: InstallVerdict = {
schema_version: 1, checkout: fs.realpathSync(ROOT), head: HEAD, for: 'autoplan', required: [...(SKILL_REQUIREMENTS.autoplan?.requires ?? [])],
pins_planning_only: false, ok: trailer.startsWith('gstack: ok'), trailer, min_revision: '1.91.96.0', launch_path: host.bunInstallBin, time: new Date().toISOString(), ...fields,
};
fs.mkdirSync(path.dirname(verdict), { recursive: true });
fs.writeFileSync(verdict, JSON.stringify(fields) + '\n');
fs.writeFileSync(verdict, JSON.stringify(full) + '\n');
};
if (o.verdict !== null) writeVerdict(o.verdict ?? { trailer: 'gstack: ok 1.91.95.0 for=autoplan', head: HEAD, checkout: fs.realpathSync(ROOT), at: new Date().toISOString() });
if (o.verdict !== null) writeVerdict(o.verdict ?? {});
const run = (args: string[], extra: Record<string, string> = {}) =>
spawnSync('bun', args, { cwd: ROOT, env: { ...env, ...extra }, encoding: 'utf8', timeout: 120_000 });
return {
+81
View File
@@ -0,0 +1,81 @@
/**
* lib/install-verdict.ts and bin/gstack-install-verdict.sh: the one reader of
* the install verdict gstack-capy-install writes, shared by gstack-skill-start
* (Bash) and gstack-autoplan's mutating verbs (TypeScript). Pinned on the
* admission decision (who must hold a verdict, what a verdict admits), the
* verdict path keyed by the source checkout's realpath, and the source-root
* resolution through an installed root's .source-path.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { execFileSync, spawnSync } from 'child_process';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { checkInstallAdmission, installVerdictPath, readInstallVerdict, sourceRoot } from '../lib/install-verdict';
const ROOT = path.resolve(import.meta.dir, '..');
const REAL = fs.realpathSync(ROOT);
const HEAD = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const dirs: string[] = [];
afterEach(() => { for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); });
const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'install-verdict-')); dirs.push(d); return d; };
const writeVerdict = (stateRoot: string, over: Record<string, unknown> = {}) => {
const file = installVerdictPath(REAL, stateRoot);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({
schema_version: 1, checkout: REAL, head: HEAD, for: 'autoplan',
required: ['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision'],
pins_planning_only: false, ok: true, trailer: 'gstack: ok 9.9.9.9 for=autoplan', min_revision: 'unknown', launch_path: '', time: '2026-10-12T00:00:00Z', ...over,
}));
return file;
};
describe('install verdict (TypeScript)', () => {
test('the verdict path is keyed by sha256 of the checkout realpath under <state root>/install-verdicts', () => {
const sr = tmp();
expect(installVerdictPath(REAL, sr)).toBe(path.join(sr, 'install-verdicts', `${createHash('sha256').update(REAL).digest('hex')}.json`));
expect(readInstallVerdict(path.join(sr, 'nope.json'))).toBeNull();
expect(readInstallVerdict(writeVerdict(sr))?.head).toBe(HEAD);
});
test('admission: only unattended and Capy-spawned sessions apply; a runtime below the minimum refuses; GSTACK_CAPY=1 needs a matching verdict', () => {
const sr = tmp();
const base = { skill: 'autoplan', gstackRoot: ROOT, bunVersion: '1.4.2' };
expect(checkInstallAdmission({ ...base, env: {} }).applies).toBe(false);
const unattended = { GSTACK_SESSION_KIND: 'unattended', GSTACK_STATE_ROOT: sr };
expect(checkInstallAdmission({ ...base, env: unattended })).toMatchObject({ applies: true, refuse: false });
const oldBun = checkInstallAdmission({ ...base, env: unattended, bunVersion: '1.3.14' });
expect(oldBun).toMatchObject({ refuse: true, status: 'runtime', code: 'RUNTIME_BELOW_MINIMUM' });
expect(oldBun.line).toMatch(/^INSTALL: fail runtime — bun 1\.3\.14 .*below the supported minimum 1\.4\.2; fix: .+ \(RUNTIME_BELOW_MINIMUM\)$/);
const capy = { ...unattended, GSTACK_CAPY: '1' };
expect(checkInstallAdmission({ ...base, env: capy })).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED' });
writeVerdict(sr);
expect(checkInstallAdmission({ ...base, env: capy })).toMatchObject({ refuse: false, status: 'ok' });
expect(checkInstallAdmission({ ...base, skill: 'qa', env: capy })).toMatchObject({ refuse: true, status: 'verdict' });
writeVerdict(sr, { pins_planning_only: true });
expect(checkInstallAdmission({ ...base, skill: 'ship', env: capy }).refuse).toBe(true);
writeVerdict(sr, { head: 'f'.repeat(40) });
expect(checkInstallAdmission({ ...base, env: capy }).detail).toContain('another HEAD');
const over = checkInstallAdmission({ ...base, env: { ...capy, GSTACK_ALLOW_UNSUPPORTED_RUNTIME: '1' } });
expect(over).toMatchObject({ refuse: false, overridden: true });
expect(checkInstallAdmission({ ...base, env: { GSTACK_SESSION_KIND: 'spawned', GSTACK_CAPY: '1', GSTACK_STATE_ROOT: sr } }).refuse).toBe(true);
expect(checkInstallAdmission({ ...base, env: { OPENCLAW_SESSION: '1', GSTACK_STATE_ROOT: sr }, bunVersion: '1.3.14' })).toMatchObject({ applies: true, refuse: false, warn: true });
});
});
describe('source root and support file (one helper, direct and symlinked layouts)', () => {
const bash = (bin: string, fn: string) => spawnSync('bash', ['-c', `. "${path.join(ROOT, 'bin/gstack-install-verdict.sh')}" && ${fn}`, '_', bin], { encoding: 'utf8' });
test('a checkout bin resolves to the checkout; an installed root follows .source-path; both read capy/support.env', () => {
const runtime = tmp();
fs.symlinkSync(path.join(ROOT, 'bin'), path.join(runtime, 'bin'));
fs.writeFileSync(path.join(runtime, '.source-path'), `${ROOT}\n`);
const linked = path.join(tmp(), 'gstack');
fs.symlinkSync(ROOT, linked);
for (const bin of [path.join(ROOT, 'bin'), path.join(runtime, 'bin'), path.join(linked, 'bin')]) {
expect(bash(bin, 'gstack_source_root "$1"').stdout.trim(), bin).toBe(REAL);
expect(sourceRoot(path.dirname(bin))).toBe(REAL);
expect(bash(bin, 'gstack_support_env_get "$(gstack_source_root "$1")" GSTACK_MIN_REVISION').stdout.trim()).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
}
});
});