v1.91.96.0 fix: honest install and self-upgrade on Capy (field wave A, items 1-5) (#3164)

* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture

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

* feat(doctor): --min-revision, a launch Bun found off PATH, planning-only pins SKIP

gstack-doctor --check gains --min-revision <v>|unknown: below it the revision
row FAILs with REVISION_BELOW_MINIMUM naming the Capy wrapper or
bin/gstack-upgrade; unknown says so and never claims the minimum. The runtime
and pins rows name a supported Bun found in $BUN_INSTALL/bin (not on PATH)
with the export/GSTACK_LAUNCH fix (gstack_bun_launch_dir, one helper in
bin/gstack-bun-version.sh). --for a planning-only workflow (autoplan, the plan
reviews, office-hours) reports a repo test-pin mismatch as SKIP pins
(planning-only; ...). New result codes UPGRADE_REFUSED, UPGRADE_FETCH_FAILED,
REVISION_BELOW_MINIMUM, CAPY_WRAPPER_STALE, INSTALL_UNVERIFIED with
troubleshooting anchors; REVISION_UNMET's fix names bin/gstack-upgrade.

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

* feat(install): install verdict and install admission at skill start

bin/gstack-install-verdict.sh (Bash) and lib/install-verdict.ts (TypeScript)
are the one reader of the install verdict gstack-capy-install writes to
<state root>/install-verdicts/<sha256(checkout realpath)>.json (checkout,
HEAD, workflow and required components, planning-only pins, trailer). The
source checkout and capy/support.env resolve through gstack_source_root /
sourceRoot, which follow an installed root's .source-path.

gstack-skill-start, after SESSION_KIND: an unattended session (any host)
refuses on a PATH Bun below the supported minimum; with GSTACK_CAPY=1 an
unattended or explicit spawned session also refuses without a passing
verdict for this checkout, HEAD and workflow (INSTALL_UNVERIFIED). A refusal
prints the INSTALL: row, an install-refused instruction block bound to its
SESSION_ID, GSTACK_RESULT ... status=refused run=- and exits 3. Ambient
spawned sessions warn; GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and says so.
capy/support.env declares GSTACK_MIN_REVISION and GSTACK_CAPY_MIN_WRAPPER.

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

* feat(capy-install): launch PATH, Bun reuse and CI pins, setup skip on HEAD, verdict, --check

The GSTACK_LAUNCH line carries PATH=<dir>:$PATH when the certified Bun is not
first on PATH, plus GSTACK_CAPY=1 and GSTACK_INSTALL_VERDICT, and
GSTACK_LAUNCH_PATH: <dir>; the doctor runs with that PATH. A supported Bun in
$BUN_INSTALL/bin is reused instead of downloaded again. Project pins are read
with bin/gstack-runtime-pins.ts (CI lanes included): when every exact pin is
gstack's verified version the archive is installed alongside a different PATH
Bun; any other exact pin is RUNTIME_PIN_CONFLICT. Both ./setup runs are
skipped when this HEAD was set up with the same hosts, model and browser flag
and the check passes (never after a fast-forward); a failed setup ends with
gstack: fail setup and is retried next run. --check certifies only;
GSTACK_CAPY_LOCK_HELD=1 accepts the bootstrapper's held lock; result anchors
come from the generated components table; REV_NOTE names bin/gstack-upgrade.

Intentional pin change: test/gstack-capy-install.test.ts --revision trailer
now names <root>/bin/gstack-upgrade (the old text named a command that never
existed).

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

* feat(upgrade): bin/gstack-upgrade, the unattended fast-forward every fix line names

--check fetches and prints current|behind <installed> -> <remote> (<n>
commits); the default fast-forwards to the remote default branch tip and
reinstalls (bin/gstack-capy-install on a Capy install, else ./setup
--refresh-registered), completing a pending reinstall on the next run. It
refuses (UPGRADE_REFUSED, exit 3, nothing changed) a dirty tracked byte, a
diverged commit, a pinned checkout, the incoming Bun floor and a held
.setup-lock; never stashes or resets; an unreachable remote is
UPGRADE_FETCH_FAILED. Fetches are receipted (fail-open). The unattended
skill-end UPGRADE_AVAILABLE line is followed by UPGRADE_COMMAND naming it.

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

* feat(capy): vendored Capy skill, frozen bootstrapper and full wrapper bin/gstack-capy-session

capy/skill/gstack/ is the Drive skill's source of truth. scripts/install.sh
is a frozen bootstrapper: it prints gstack-capy-skill: wrapper <n> (<path>),
clones or fetches under .gstack-capy-install.lock, reads the remote
capy/support.env (FETCH_HEAD), fast-forwards a clean unpinned checkout below
GSTACK_MIN_REVISION (refusing dirty, diverged or pinned checkouts with the
exact commands; GSTACK_CAPY_AUTO_UPGRADE=0 disables), and execs the
checkout's own bin/gstack-capy-session with GSTACK_CAPY_LOCK_HELD=1 so the
held lock is never taken twice. Offline it says the remote minimum is
unknown. A Drive copy older than GSTACK_CAPY_MIN_WRAPPER prints an owner
action (CAPY_WRAPPER_STALE). SKILL.md: check at session start, install
lazily before the first workflow, the GSTACK_LAUNCH line, the trailer rule,
the subagent first-report line, gstack-pregate sealed before any push, and
the autoplan/DRIVER.md pointer. The context-budget fixture gains the skill's
entry.

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

* docs: Capy install layers, lazy install, snapshot bake, forward-only recovery, raising the minimum

docs/capy-install.md describes the three layers (Drive bootstrapper, full
wrapper, installer), the lazy install, the GSTACK_LAUNCH line, install
admission, baking gstack into a Capy snapshot, and that recovery after an
interrupted fast-forward completes or refuses the pending install stages
(checking out an older commit is not a rollback). docs/CHANGELOG_STYLE.md
(the release doc; there is no docs/RELEASING.md) says when
GSTACK_MIN_REVISION may be raised.

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

* refactor(capy): the stale-copy owner action moves from the frozen bootstrapper to bin/gstack-capy-session

The checkout-owned full wrapper reads GSTACK_CAPY_MIN_WRAPPER through
gstack_support_env_get(gstack_source_root) and compares it with the
bootstrapper revision it was handed, so the rule can change without
recopying the Drive skill. The bootstrapper keeps only what a stale checkout
cannot do: clone, fetch, read the remote minimum, fast-forward, exec.

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

* v1.91.96.0: honest install and self-upgrade on Capy; supported minimum 1.91.96.0

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

* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper

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

* test: bound the new sync spawns in the install-verdict and wrapper tests (spawnSync tripwire)

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

* ci(paid-pr-profile): the vendored Capy skill is free-only; free-only files are not prompts needing paid coverage

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

* chore(harness): non-behavioral HARNESS_VERSION decision for the PR-profile free-only rule

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

* test(doctor): lane A runtime and pins cases are platform-neutral (bash paths; a lane-free .bun-version pin)

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

* docs(designs): field wave plan with amendments and the gate rev 2-3 review record (ARTIFACT_VALID, 3 gate revisions)

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

* fix(capy-install): follow a project's exact Bun pin above the minimum when gstack has its verified archive (adds Bun 1.4.3)

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

* docs: the installer follows a verified exact project Bun pin (capy-install, troubleshooting, changelog)

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

* fix(capy-install): the install reason names the project's pin and gstack's real minimum

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-13 14:41:58 +00:00
1 parent dbf400ec3f
commit ada251bbc0
36 files changed
+2474 -137

No files matched your search

+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', timeout: 30_000 });
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,
+138 -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);
@@ -160,6 +160,31 @@ describe('gstack-capy-install', () => {
expect(conflictRange.stderr).toContain('pins >=2.0.0');
});
test('an exact project pin above the minimum with a verified archive is the Bun installed; an unverified one stops naming the verified versions', () => {
const f = fixture({ bun: '1.3.14' });
const project = path.join(f.base, 'project');
write(path.join(project, '.bun-version'), '1.4.3\n');
sh('git init -q', project);
const zip = path.join(f.base, 'bad.zip');
fs.mkdirSync(path.join(f.base, 'z/bun-linux-x64'), { recursive: true });
script(path.join(f.base, 'z/bun-linux-x64/bun'), ['echo 1.4.3']);
expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-linux-x64`).status).toBe(0);
const adopted = run(f, ['--project', project], { STUB_CURL_ZIP: zip });
expect(adopted.stdout).toContain('installing bun 1.4.3 from its release archive');
expect(adopted.stdout).not.toContain("gstack's minimum 1.4.3");
expect(f.calls().filter(l => l.startsWith('curl '))).toEqual([expect.stringContaining('https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux')]);
expect(adopted.status).toBe(3);
expect(adopted.stderr).toContain('SHA-256 mismatch');
const g = fixture({ bun: '1.3.14' });
write(path.join(project, '.bun-version'), '1.4.9\n');
const unverified = run(g, ['--project', project]);
expect(unverified.status).toBe(3);
expect(unverified.stderr).toContain(`${project}/.bun-version pins exactly 1.4.9`);
expect(unverified.stderr).toContain('verified archives: 1.4.2 1.4.3');
expect(unverified.stderr).toContain('(RUNTIME_PIN_CONFLICT; ');
expect(g.calls().some(l => l.startsWith('curl ') || l.startsWith('setup '))).toBe(false);
});
test('Bun below the minimum: the exact release archive is downloaded and SHA-256 verified; a mismatch installs nothing', () => {
const f = fixture({ bun: '1.3.14' });
const zip = path.join(f.base, 'bad.zip');
@@ -200,7 +225,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 +246,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);
}
+67
View File
@@ -701,3 +701,70 @@ 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');
const home = bashPath(f.home);
expect(fresh.rows.runtime.rest).toContain(`bun 1.4.2 found at ${home}/.bun/bin (not on PATH)`);
expect(fresh.rows.runtime.rest).toContain(`fix: export PATH="${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 ${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, '.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([]);
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* capy-host — one simulated Capy cloud host for every test that exercises
* install, upgrade, runtime or unattended-workflow behavior. The shape is the
* field host: Bun 1.3.14 first on PATH (the image's /usr/local/bin/bun), no
* Codex CLI, an ephemeral home, and optionally a verified Bun 1.4.2 in
* `$HOME/.bun/bin` that is NOT on PATH (what gstack-capy-install leaves
* behind). The stub Bun answers `--version` with the simulated version and
* forwards everything else to the Bun running the test, so TypeScript bins
* still run. Real git, jq, unzip, sha256sum and coreutils stay reachable.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
export interface CapyHost {
base: string; home: string; stateRoot: string; systemBin: string; bunInstallBin: string;
env: Record<string, string>;
/** PATH with the verified Bun first, the way a GSTACK_LAUNCH-prefixed command sees it. */
launchPath: string;
cleanup(): void;
}
export interface CapyHostOptions {
/** Version the PATH Bun reports (the image's /usr/local/bin/bun). Default 1.3.14. */
systemBun?: string;
/** Install a verified Bun reporting this version in $HOME/.bun/bin (off PATH). Default none. */
verifiedBun?: string;
/** Extra tool names to hide from the inherited PATH (bun and codex are always hidden). */
hide?: string[];
}
const writeScript = (file: string, lines: string[]) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, ['#!/usr/bin/env bash', ...lines].join('\n') + '\n', { mode: 0o755 });
};
/** The inherited PATH with every directory that holds a hidden tool replaced by a symlink farm without it. */
export function pathWithout(base: string, hide: string[]): string {
return (process.env.PATH ?? '').split(path.delimiter).filter(Boolean).map((dir, i) => {
if (!hide.some(n => fs.existsSync(path.join(dir, n)))) return dir;
const shim = path.join(base, `host-path-${i}`);
fs.mkdirSync(shim, { recursive: true });
for (const name of fs.readdirSync(dir)) {
if (hide.includes(name)) continue;
try { fs.symlinkSync(path.join(dir, name), path.join(shim, name)); } catch { /* unreadable entry */ }
}
return shim;
}).join(path.delimiter);
}
/** A Bun stand-in: `--version` prints `version`; anything else runs the real Bun under test. */
export function writeBunStub(file: string, version: string): void {
writeScript(file, [`[ "\${1:-}" = --version ] && { echo "${version}"; exit 0; }`, `exec "${process.execPath}" "$@"`]);
}
export function capyHost(opts: CapyHostOptions = {}): CapyHost {
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'capy-host-')));
const home = path.join(base, 'home');
const stateRoot = path.join(home, '.gstack');
const systemBin = path.join(base, 'usr-local-bin');
const bunInstallBin = path.join(home, '.bun', 'bin');
fs.mkdirSync(home, { recursive: true });
writeBunStub(path.join(systemBin, 'bun'), opts.systemBun ?? '1.3.14');
if (opts.verifiedBun) writeBunStub(path.join(bunInstallBin, 'bun'), opts.verifiedBun);
const inherited = pathWithout(base, ['bun', 'codex', ...(opts.hide ?? [])]);
const env: Record<string, string> = {
PATH: [systemBin, inherited].join(path.delimiter),
HOME: home, TMPDIR: base, BUN_INSTALL: path.join(home, '.bun'),
GSTACK_HOME: stateRoot, GSTACK_STATE_ROOT: stateRoot, GSTACK_EPHEMERAL: '1',
GIT_CONFIG_GLOBAL: path.join(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',
};
return {
base, home, stateRoot, systemBin, bunInstallBin, env,
launchPath: [bunInstallBin, env.PATH].join(path.delimiter),
cleanup: () => fs.rmSync(base, { recursive: true, force: true }),
};
}
+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', timeout: 30_000 }).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', timeout: 30_000 });
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+$/);
}
});
});
+10
View File
@@ -51,6 +51,16 @@ describe('fast PR coverage policy', () => {
expect(result.needsFullValidation).toBe(false);
});
test('the vendored Capy skill (capy/skill/**) is free-only: no paid case reads it, so it needs no full validation', () => {
const result = select({ selectedE2E: [], selectedJudges: [], changedFiles: ['capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh'], derived: null });
expect(result.needsFullValidation).toBe(false);
expect(result.missingCoverage).toEqual([]);
expect(result.mode).toBe('pr');
expect(result.e2e).toEqual([]);
const prompt = select({ selectedE2E: [], selectedJudges: [], changedFiles: ['plan-ceo-review/SKILL.md.tmpl'], derived: null });
expect(prompt.needsFullValidation || prompt.deferredPromptFiles.length > 0 || prompt.e2e.length > 0).toBe(true);
});
test('does not add unrelated short probes or drop selected quality judges', () => {
const result = select({ selectedJudges: ['browse quality', 'plan-ceo-review/SKILL.md modes'] });
expect(result.e2e).not.toContain('browse-smoke');