Files
gstack/docs/capy-install.md
T
Garry Tanandcapy-ai[bot] 8018ba52fe v1.91.243.0 fix(capy): source the launch environment from a 0600 file; no skill fence may print the environment (GSTA-104) (#3449)
* fix(capy): the launch environment is sourced from a 0600 file and no skill fence may print the environment (GSTA-104)

During GSTA-96's unattended /ship on #3425, the Step 14.5 docs-audit child
re-typed the GSTACK_LAUNCH assignments as `export $(...)`; the substitution
came back empty, the bare `export` printed the whole environment, and three
API key values landed in its tool output.

- bin/gstack-capy-install writes the assignments to <verdict>.env (mode 0600,
  idempotent PATH prepend) and prints GSTACK_LAUNCH_FILE: <path>.
- ship Step 14.5 prompt, document-release's spawned note, autoplan/DRIVER.md
  and gstack-skill-start's capy-asks block say: source the file; never
  export $(...), env, printenv or bare set in a tool fence.
- test/launch-env-echo-ratchet.test.ts greps every generated SKILL.md,
  sections, DRIVER.md, the Capy skill and bin/ shell helpers, with negative
  controls under test/fixtures/launch-env-echo/.
- lib/redact-engine.ts: env.value (HIGH) finds the value of any *_KEY,
  *_TOKEN, *_SECRET, *_PASSWORD variable in the writer's environment, so a
  leaked value cannot reach a PR body, receipt, report or Drive file.
- scripts/lib/shard-engine.ts scrubs the Capy launch markers from shard
  environments (GSTA-99's finding).

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

* fix(ci): DRIVER.md back to 159 lines; shard-engine recorded non-behavioral at HARNESS_VERSION 6; verdict-count test lists .json only

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

* test: register test/fixtures/launch-env-echo as a free fixture

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

* test(redact): env-value fixtures stay out of pregate's env.kv net

A credential-named assignment with an entropic literal on one line is what
the repo's own pregate secrets scan refuses in added lines, so the fixture
values move into plainly named constants and the scanned text is built from
them. The scan under test still sees the same values through processEnv.

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

* test(windows): the late-completion remote-caller case gives its second attempt a 2s budget (run 40263984171)

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

* fix(redact): env.value trims the value, keeps a prefix value's longer match, hides the preview and skips PUBLIC names

/ship Step 9.4 review fixes for the GSTA-104 env-value scan:

- a value whose environment copy ends in a newline (a key read from a file)
  is searched for trimmed, so its leaked form still matches (the length
  check already trimmed; the search used the raw value);
- when one credential value is a prefix of another, the dedup key carries the
  span length, so the longer value is redacted whole in either order instead
  of leaving its tail in the text and dropping its finding;
- the env.value preview is a fixed mask instead of the first characters of a
  live credential;
- framework-public names (NEXT_PUBLIC_*_KEY, *_PUBLIC_KEY) are not scanned,
  since those values are committed on purpose;
- the header and the processEnv docstring state the name rule (KEY, TOKEN,
  SECRET, PASSWORD, PASSWD, any case) and that process.env is the engine's
  one ambient input.

Step 7 coverage extensions and a regression test for each fix in
test/redact-env-value.test.ts; docs/capy-install.md names the full rule.

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

* refactor(capy-install): write_launch_file quotes through shq; header names the launch file

Review finding: _sq duplicated shq() from the same file. The launch file's
assignments now go through shq, and the header comment describes the
GSTACK_LAUNCH_PATH and GSTACK_LAUNCH_FILE lines beside GSTACK_LAUNCH.

Step 7 coverage: the verdict test sources the launch file and runs --check;
the single-quote path test sources the file too; a new case makes the
launch-file directory unwritable and checks the run says so, prints no
GSTACK_LAUNCH_FILE line, leaves no temp file and keeps its exit code and
trailer (linux, non-root).

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

* test(preload): strip the Capy launch markers so a direct bun test matches the shard sandbox

Red Team finding: the launch-marker scrub lived only in createShardSandbox,
so a direct `bun test <file>` run under an unattended Capy session inherited
GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL and GSTACK_INSTALL_VERDICT
(GSTA-99's symptom). The preload now deletes the same four names, and
test/shard-engine.test.ts imports CAPY_LAUNCH_MARKERS instead of re-typing them.

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

* test(ratchet): the launch-env echo scan catches backtick and $VAR exports, typeset -p, env flags and a set in any segment

Review findings 11 and 12: export-subst also trips on `export \`...\`` and
an unquoted `export $VAR` (both splat a NAME=value file; `export "$name"` is
one word and stays allowed); the export rule adds `typeset -p`; env trips
with only flags (`env -0`) and as `/usr/bin/env`; set is judged per segment
and with redirections, allowed only when piped straight into an anchored
grep filter. Quoted strings are blanked offset-preserving so a `#` inside
quotes no longer hides the rest of the line. The negative control covers
each new form on both sides.

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

* docs(ship): Step 14.5 names where the launch-file placeholder comes from

The /document-release prompt line read `. <file>` with no word on what fills
it. It now says the placeholder is the GSTACK_LAUNCH_FILE: path the parent's
own install check printed. Worded to stay under the ship skill's pinned
parity size (313,279 of 313,281 union bytes).

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

* fix(redact): env.value admits *_PASS, *_PAT, *_APIKEY and *_PWD, and skips path values and gstack's lock token

/ship Step 11 adversarial findings. The name rule also covers DB_PASS,
GITHUB_PAT, *_APIKEY and *_PWD (the shell's own PWD and OLDPWD stay out). A
credential-named variable whose value is a filesystem path (SSL_KEY=/etc/ssl/
private/x.key) names where the credential lives, so the path is no finding,
and GSTACK_BRAIN_LOCK_TOKEN (pid-random-epoch, written to the lock owner file)
is not a secret. Without these, an ordinary mention of that path or token in a
PR body, receipt or CSO report was a HIGH fail-closed blocker.

The adversarial pass also recommended an entropy floor so dev defaults such as
DB_PASSWORD=postgres stop matching prose; declined here on purpose, because the
scan's promise is to catch a leaked value whatever its shape, and a weak real
password is still a credential. Recorded as an open decision in the ship report.

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

* fix(capy-install): a failed launch-file write removes only its own temp file

The failure path ran rm -f "$LAUNCH_FILE".tmp.*, which also deleted the temp
file a concurrent installer run (two threads on one machine, same verdict
path) was about to mv, so that run lost its launch file too. write_launch_file
now cleans up its own $tmp and returns 1; the caller only reports. The file's
comment no longer embeds the state-root path (a newline in GSTACK_STATE_ROOT
could have ended the comment).

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

* test: the launch markers come from one list, and the echo ratchet scans setup and scripts/*.sh

The preload imports CAPY_LAUNCH_MARKERS instead of re-typing the four names,
and the ratchet asserts the installer exports every name on that list, so a
fifth marker cannot be added to one place only. The shell scan also covers
./setup and the scripts/*.sh install scripts callers run first.

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

* docs(changelog): the Unreleased entry states the review-fixed env.value rules, the ratchet forms and the one test-budget change

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

* fix(redact): the env.value path exemption asks whether the file exists instead of matching a shape

Second review cycle: a path-shaped rule also matched a base64 secret that
starts with "/" and holds another "/" (about 1 in 150 AWS-shaped keys), which
then went unscanned. The exemption now fires only when the value names a file
that exists on the scanning machine (SSL_KEY=/etc/ssl/private/x.key). The
module header names that stat as the engine's one filesystem read.
NON_SECRET_ENV_NAMES is exported so the test can prove bin/gstack-brain-lock.sh
still exports every name in it; the PWD/OLDPWD fixtures hold plain words so only
the name rule keeps them out.

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

* test: the installer's own-temp-file cleanup is exercised, and the ratchet coverage test names setup and scripts

An mv shim first on PATH refuses only the launch-file target, so the temp
file exists when the move fails: the run removes that file alone (a sibling
from a concurrent run survives), says so, prints no GSTACK_LAUNCH_FILE line
and keeps the earlier file, exit code and trailer. The ratchet coverage test
asserts setup, scripts/build.sh and scripts/sandbox-doctor.sh are scanned.

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

* fix(redact): env.value has no path exemption and matches the value in normalized form

Second adversarial pass: the existence check let the scanned agent exempt
any "/"- or "~/"-prefixed secret by creating a path of that name, a silent
bypass of the one guard this branch adds, so the exemption is gone; a
path-valued SSL_KEY that must appear in a text goes through the allowlist.
The engine is back to no I/O. The value is now normalized the way the text
is before indexOf, so a credential NFKC rewrites (fullwidth digits) still
finds its leaked ASCII form.

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

* fix(capy-install): the launch file is written only on a passing run; the ratchet checks the marker list both ways

A gstack: fail run no longer writes or announces a sourceable file whose PATH
prepend points at an unverified Bun. The ratchet also asserts every variable
the launch file exports is a CAPY_LAUNCH_MARKERS entry or GSTACK_STATE_ROOT,
so a sixth export cannot reach fixture children unstripped. The Capy skill's
prose no longer offers re-typing the assignments as an alternative to sourcing.

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

* fix(capy-install): the launch file is part of the ok verdict

Third adversarial pass: a passing run whose launch file could not be
written still said gstack: ok with exit 0 and printed only the GSTACK_LAUNCH
line, leaving a shell-evaluating caller with the re-typing path the branch
exists to remove. The file is now written before the verdict is recorded;
a write failure on a passing run turns the run into
"gstack: fail launch-file — could not write the launch file <path>; fix: …
(LAUNCH_FILE_UNWRITABLE)" with exit 1 and an ok:false verdict, and every
failing run removes the launch file an earlier passing run left beside the
verdict. The two failure tests assert the new contract, the doctor-FAIL
tests assert no launch file is announced or left, and the docs, Capy skill
and CHANGELOG say the same.

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

* fix(redact): the env.value allowlist and placeholder rules see the normalized value too

An allowlisted value in NFC did not suppress a match on its NFKC-composed
form, and a value that is a placeholder only after normalization was still
searched for. Fixture nits from the third review cycle: fixed directory
string instead of process.cwd(), the fullwidth assertion guards its own
vacuity, a zero-width-padded value under the floor is pinned, and the
ratchet comment names the one kept export and the PATH case line.

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

* fix(capy-install): LAUNCH_FILE_UNWRITABLE is a registered result code; a kept profile verdict keeps its launch file

Fourth review cycle. The new trailer code now has its row in
lib/result-codes-capy.ts (the owner module is at its size cap) and its
docs/troubleshooting.md anchor, so the install-side code scan resolves it.
A failing profile check (--for review-voice) that keeps a fuller ok verdict
for this HEAD no longer deletes that verdict's launch file: the fail branch
removes the file only when the run rewrites the verdict, and setup_failed
removes it too. The docs say "a failing run that rewrites the verdict",
and that an unwritable directory keeps both the old file and the old
verdict. A new test runs a passing install, then a failing profile check,
and asserts the file and the ok verdict both survive.

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

* test(redact): the normalized allowlist and placeholder rules each have an assertion that fails without them

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

* test(capy-install): a setup failure after a passing install removes the earlier launch file

Fifth review cycle: setup_failed's cleanup had no assertion. The setup
failure test now installs once (launch file present), commits a new HEAD so
setup runs again, fails that setup, and asserts the launch file is gone and
the verdict says ok:false before the next run sets up again and ends ok.

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

* docs: state-root, contributor reference and capy-install follow the launch-file contract (Step 14.5 audit)

The /document-release audit (docs-3449-1792992068) corrected three authored
docs: docs/state-root.md shows the GSTACK_LAUNCH_FILE line and says to apply
it with ". <that file>" instead of prefixing or exporting the assignments;
docs/CONTRIBUTOR_REFERENCE.md names scan()'s one ambient input instead of
calling it pure; docs/capy-install.md's bun-shim bullet and exit table name
the launch file and the fail launch-file trailer.

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 12:00:20 +00:00

29 KiB

Installing gstack on a Capy cloud machine

Three layers, each owned by gstack:

Layer Where Job
Bootstrapper capy/skill/gstack/scripts/install.sh (copied once into the owner's Drive skill folder; frozen) clone when absent; fetch origin's default branch and read the supported minimum from FETCH_HEAD:capy/support.env; in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper
Full wrapper bin/gstack-capy-session (moves with the checkout) map the session start onto the installer: --check certifies (and reports a checkout behind origin), otherwise fast-forward a checkout behind origin (bin/gstack-upgrade --ff-only-no-reinstall), then install or repair
Installer bin/gstack-capy-install Bun, both host profiles, the doctor, the install verdict, the launch line, the trailer

The vendored skill (capy/skill/gstack/SKILL.md) tells a Capy agent how to use them. The Drive folder is a mirror: the owner copies capy/skill/gstack/ there when asked; nothing writes the Drive automatically.

Direct use, without the Drive skill (a subagent or reviewer machine): clone into the root the bootstrapper resolves when nothing is there, then run the checkout's own bootstrapper by absolute path, from the project directory, so the floor check and fast-forward still run and the verdict is keyed by the caller's project. The same command serves every later session (an existing checkout is fast-forwarded, never re-cloned):

test -f "$HOME/.local/share/gstack/setup" || git clone --quiet --single-branch --depth 1 https://github.com/garrytan/gstack.git "$HOME/.local/share/gstack" && GSTACK_CAPY_ROOT="$HOME/.local/share/gstack" bash "$HOME/.local/share/gstack/capy/skill/gstack/scripts/install.sh" --for autoplan

The text comes from capyBootstrapCommand in lib/doctor-components.ts (Bash: gstack_capy_bootstrap_cmd); the not-installed fix lines print it with the root they checked.

Reviewer machines: --for review-voice

A dispatched reviewer (an /autoplan voice, a second opinion) reads a plan and reports; it runs no workflow. --for review-voice gives its machine a real trailer in seconds: clone or fast-forward, Bun at gstack's own supported minimum (the project's pins are not applied, since no repo tests run there; the trailer says pins: skipped (helper runtime)), no ./setup, no host profiles and no bun shim. The doctor certifies only the read-only helpers (PASS helpers: gstack-pregate sealed, gstack-plan-reality, gstack-artifact and the gstack-autoplan reads such as status and contract). Its verdict covers no host profile, so a workflow started there is refused (INSTALL: fail verdict — … (review-voice certifies only the read-only helpers); fix: run GSTACK_CAPY_ROOT=<root> bash <root>/capy/skill/gstack/scripts/install.sh --for <skill> …). A review-voice check never replaces an ok verdict that a fuller install wrote for the same HEAD and project. Every /autoplan reviewer prompt ends with this command (GSTACK TRAILER); the warm run's budget, under 20 s, is measured by test/capy-review-voice.test.ts.

Session start

  1. bash <skill>/scripts/install.sh --check --for <skill> (under a second when a checkout exists). It fetches refs only and never moves the checkout. With no checkout it prints gstack: fail not-installed — ... and the session records that line and continues when it runs no gstack workflow; install right before the first workflow.
  2. When the trailer is gstack: fail revision ... or gstack: fail runtime ... and a checkout exists, run the same command without --check. Below the supported minimum, a clean checkout on the remote default branch is fast-forwarded (gstack-capy-skill: upgraded <old> -> <new>), reinstalled and rechecked as one transaction under .gstack-capy-install.lock (the bootstrapper holds it and hands it over with GSTACK_CAPY_LOCK_HELD=1). A pinned (--revision, detached HEAD, another branch), dirty or diverged checkout is never moved; the trailer names the cause and the exact commands (REVISION_BELOW_MINIMUM). GSTACK_CAPY_AUTO_UPGRADE=0 disables the fast-forward.
  3. The minimum is the floor, not the trigger. Above it, a checkout behind origin's default branch still moves at install-mode session start: the full wrapper runs bin/gstack-upgrade --ff-only-no-reinstall (every refusal of gstack-upgrade applies), prints SELF_UPGRADE: done <old> -> <new> and re-execs the new checkout's wrapper, which installs from the new revision (GSTACK_CAPY_UPGRADED_FROM set, so setup is never skipped). A refusal prints SELF_UPGRADE: skipped (<reason>), and the session is never certified ok: the trailer is gstack: fail behind — installed <rev> is <n> commits behind origin (latest <ver>) and the session-start upgrade was skipped: <reason>; fix: <command> (UPGRADE_BUSY|UPGRADE_REFUSED), exit 1. SELF_UPGRADE: none (current) when nothing is ahead; GSTACK_CAPY_SELF_UPGRADE=off (or GSTACK_CAPY_AUTO_UPGRADE=0) prints SELF_UPGRADE: disabled (<variable>). --check never moves the checkout; behind origin it prints gstack-capy-session: behind origin/<branch>: <installed> -> <remote> (<n> commits); fix: bash <skill>/scripts/install.sh [--for <skill>] (fast-forwards) and the agent runs that fix. Whenever the session ends behind origin (--check, or a self-upgrade that was skipped or disabled), the ok trailer also says so, because lanes relay only the trailer: behind=<n> (latest: <ver>); fix: <command> (see the trailer grammar below).
  4. When the fetch fails (offline), the bootstrapper says remote minimum unknown (fetch failed) and checks the local install only; it never claims the minimum, and nothing is fast-forwarded.

The upgrade target is always the tip of the default branch: every commit there is a stamped release that passed CI. Recovery runs forward, never back: an install interrupted after a fast-forward leaves the trailer fail, and the next install or bin/gstack-upgrade run completes the pending install stages (setup is never skipped after a fast-forward) or refuses and names the cause. Checking out an older commit is not a supported rollback; it does not undo setup migrations that already ran.

Between minimums, bin/gstack-upgrade --check says current <v> or behind <installed> -> <remote> (<n> commits), and bin/gstack-upgrade fast-forwards and reinstalls (bin/gstack-capy-install on a Capy install, else ./setup --refresh-registered). It refuses a dirty, diverged or pinned checkout with UPGRADE_REFUSED and never stashes or resets.

Several agents sharing one checkout (eight Capy subagents on a machine) hold workflow leases on it: every bin/gstack-skill-start touches <git dir>/gstack-leases/<harness pid> (bin/gstack-lease touch; pruned after 120 minutes). Each lease records its process's start identity (Linux: boot id and /proc start time; elsewhere ps -o lstart), because a pid is reused after a reboot or a machine wake. bin/gstack-upgrade, and so the session-start self-upgrade, refuses with UPGRADE_BUSY while a lease held by a live process outside its own chain exists, naming each holder as pid <p> (<age>m <command>). The team-mode SessionStart hook (bin/gstack-session-update) honors the same leases: it logs SKIP leased pid=<p>/<age>m to analytics/session-update.log and tries again at the next session start, without writing its hourly throttle stamp. A lease whose pid now belongs to another process, or an identity-less lease from an older gstack past 120 minutes, is stale: bin/gstack-lease live reclaims it (gstack-lease: reclaimed stale lease …). Stale needs proof: two well-formed identities from the same backend that differ (ps is read under LC_ALL=C TZ=UTC0). An identity that cannot be read, a malformed record or a backend change keeps the lease. touch (publish by rename, prune) and reclaim (reread, then delete only the record judged stale) run under one lock in the lease directory, a .lock directory whose owner file names its holder's pid and start identity, so a renewal is never deleted and a live lease never disappears from a concurrent scan. The lock is a directory because mkdir is atomic on every host, while Git Bash on Windows cannot ln -s to a target that does not exist (#3330). A taker publishes a claim before its mkdir and renames it in as the owner after, so an ownerless lock is broken only once no claimant lives and the lock is at least 2 s old and unchanged while checked, which keeps a taker between its two steps safe. A claim that records no start identity is abandoned after 2 minutes, so a reused pid cannot keep an orphan forever; an owner that names no valid pid counts as a dead holder, and an owner file that cannot be read keeps the lock. A live holder that never lets go bounds the wait at 300 tries or 30 s. A lock that names a holder is broken only when that holder is dead or its pid was reused, never by age, and breakers serialize on a second lock and reread the lock before removing it. A symlink .lock left by an older gstack is honored the same way. Three known limits remain. An older gstack's ln -s onto a directory lock links inside it and succeeds, so a process that started before an upgrade of this checkout can share the lock with a newer one. And a breaker killed while holding the second lock, followed by two live breakers clearing it at once, can let a reclaim delete a renewal. On a host without start identities, a taker stalled over 2 minutes between its mkdir and its owner rename can be broken and then publish into its successor's lock. While a live process holds the lock, or it cannot be taken at all, touch exits 3 without writing (gstack-skill-start reports that the session holds no lease) and a reclaim keeps the lease. bin/gstack-lease list shows alive|dead|stale, and GSTACK_UPGRADE_FORCE=1 overrides.

What the installer does

  1. Reuses the checkout it lives in (or clones into --root <dir>), under the lock. It never resets or pulls; --revision <ref> pins a fresh clone, and an existing checkout at another revision is reused with the trailer note requested: <ref> (run <checkout>/bin/gstack-upgrade to move). A fresh clone prints cloned <repo> at <sha12> (<version>) (the bootstrapper's clone too, through GSTACK_CAPY_CLONED).

  2. Preflight: git, curl, node, jq, then Bun. The project's pins (--project <path>, default the git toplevel of the current directory) are read with the doctor's own parser (bin/gstack-runtime-pins.ts: engines, packageManager, .tool-versions, .bun-version and CI bun-version: lanes for this platform). CI exact pins for this platform are alternatives: a repository may test several Bun versions (gstack's own CI pins 1.4.2 and runs one job on 1.4.4), so a Bun equal to any one of them satisfies CI; CI ranges and every non-CI pin stay required. One resolver picks the Bun (gstack_bun_resolve in bin/gstack-bun-version.sh; resolveBun in lib/pregate/runtime.ts is its TypeScript twin), and the installer, --check, the doctor, the bun shim and install admission all use it. Candidates, first eligible wins: the store copy of the project's exact pin (rule pin), the store copy of the install default (default), $BUN_INSTALL/bin/bun, ~/.bun/bin/bun (home), then every bun on PATH. Eligible means it runs, reports a version at or above the supported minimum (1.4.2) and satisfies the project's pins. When nothing qualifies, the installer downloads one exact release archive, verifies its SHA-256 against bin/gstack-bun-version.sh and unpacks it into the versioned store <state root>/runtimes/bun/<version>/bun, under ~/.gstack-runtime.lock. Each version directory also holds bunx -> bun. A broken entry is repaired without a window (its files are renamed over their predecessors), and ~/.bun/bin is never written. The archive is the project's non-CI exact pin when gstack carries a verified archive for it (GSTACK_BUN_VERIFIED: 1.4.2, 1.4.3, 1.4.4); with only CI exact pins, the install default when it is one of them, else the highest verified one; else the install default GSTACK_BUN_DEFAULT (1.4.4, above the 1.4.2 minimum; one CI job runs the quick free tier on it). GSTACK_BUN_INSTALL_VERSION=<v> overrides the default; a version without a verified archive or below the minimum stops the install (BUN_INSTALL_VERSION_INVALID). With a non-CI exact pin, only that version satisfies it. Any other exact pin (below the minimum, two different non-CI ones, or one without a verified archive), or a range gstack's minimum does not satisfy, stops with both constraints (RUNTIME_PIN_CONFLICT, exit 3) before ./setup. With no Bun at or above the security floor, the default is installed first so the pins can be read.

    Node follows the same rule (bin/gstack-node-runtime.sh): engines.node, .nvmrc, .tool-versions and CI node-version: lanes, non-CI exact pins required and CI exact pins in different jobs alternatives. The first node that satisfies them wins: the node first on PATH (rule PATH), then for each wanted version gstack's store copy <state root>/runtimes/node/<version>/bin/node (store) and nvm's ${NVM_DIR:-~/.nvm}/versions/node/v<version>/bin/node (nvm), then every other node on PATH. When nothing qualifies and the project names an exact version, install mode downloads node-v<version>-<target>.tar.gz and SHASUMS256.txt from https://nodejs.org/dist/v<version>/ (GSTACK_NODE_DIST_URL overrides the base), checks the archive's SHA-256 against that list and publishes the tree in the store by rename. A CI-only pin set installs its highest version. /usr/local/bin and nvm are never written. Two different non-CI exact Node pins stop with RUNTIME_PIN_CONFLICT. --check keeps the recorded Node while it still reports its version and satisfies the pins, and never installs one; the doctor's pins row reads whatever node the launch PATH gives, and a Node mismatch names the node it read and the installer command that fixes it.

  3. ./setup --host claude --no-prefix, then ./setup --host codex --model gpt-6-astra, both with --no-browser. Skipped when this exact HEAD was set up with the same hosts, model and browser flag (recorded in the git dir after a successful setup) and gstack-doctor --check passes; a warm rerun takes about a second. Each setup ends with its own gstack ready (<host>); the installer then prints gstack ready (claude, codex).

  4. gstack-doctor --check for the requested workflow (--for <skill>, --only <skill>, or --require <id,id>; default claude,codex,patch), run with the launch PATH, so the trailer certifies the Bun workflows run.

  5. The install verdict (schema 2), written atomically to <state root>/install-verdicts/<sha256 of the checkout realpath>/<sha256 of the project realpath, or "none">.json: checkout, project, HEAD, the workflow and required components it certified, whether pins were skipped as planning-only, the pin it honoured, the resolved Bun (bun_path, bun_version, bun_rule, and bun_install when this run downloaded it), the resolved Node when the project pins one (node_path, node_version, node_rule), the trailer, the remote minimum, the launch PATH and the time. Admission refuses a workflow whose running Bun is not the one the verdict recorded (BUN_RESOLUTION_DRIFT), and so do the pregate's lanes and guards, which also refuse an invalid verdict (INSTALL_UNVERIFIED) under GSTACK_CAPY=1 or whenever a verdict exists. --check keeps the recorded Bun while it exists, reports the recorded version and satisfies the pins; a candidate that would now resolve first is a note: line, and only a recorded Bun that is gone, changed or no longer satisfies the pins fails the doctor's runtime row. A schema 1 verdict (keyed by checkout only) is still read.

  6. On a Capy machine (CAPY_MACHINE_CONFIG set or ~/.capy/system present), install mode writes the bun shims described below.

--check runs only steps 4 and 5 (no clone, Bun install, setup or shim).

The bun shim

A fresh shell on Capy finds the image's /usr/local/bin/bun (1.3.14) first, so a bun test typed without the GSTACK_LAUNCH prefix used to run the wrong Bun. Install mode therefore writes ~/.local/bin/bun and ~/.local/bin/bunx (~/.local/bin precedes /usr/local/bin on the Capy image's PATH). Each is a small POSIX script whose second line is the marker # gstack-bun-shim: gstack-owned; delete this file to remove it. At every call the bun shim finds the git toplevel around the working directory and runs, in order: the Bun that project's install verdict recorded (the none verdict outside any project and inside the gstack checkout), the store copy of the project's exact pin, the store copy of the install default, then $BUN_INSTALL/bin/bun, ~/.bun/bin/bun and every non-shim bun on PATH, first at or above the supported minimum, then at or above the security floor. A pin is used only when it is x.y.z and its store entry is a real binary inside the store (no .., symlink escape or shim-marked file). A project pinning a Bun the store lacks prints gstack bun shim: <root> pins bun <v>, which is not in gstack's runtime store; run ... --check here; an unrecorded, unpinned repo falls through quietly. bunx runs bun x through the bun shim beside it.

  • GSTACK_BUN_SHIM_EXPLAIN=1 bun --version prints gstack bun shim: project=<root> record=<file> chose=<bun> rule=<rule> (and names a missing record).
  • GSTACK_BUN_SHIM=0 at install time writes no shim.
  • Removing it: delete ~/.local/bin/bun and ~/.local/bin/bunx. gstack replaces only a missing file or a file carrying the marker; a foreign file or a dangling link there is left untouched with a note, and sourcing the launch file (. <GSTACK_LAUNCH_FILE>) stays the way to reach the certified Bun.
  • The doctor's runtime row notes the shim; the resolver never treats a shim-marked file as a Bun candidate.

The one line to grep

The last line is the trailer:

gstack: ok 1.91.96.0 for=autoplan
gstack: ok 1.91.96.0 for=review (requested: v1.91.90.0 (run <checkout>/bin/gstack-upgrade to move); latest: 1.91.101.0 available)
gstack: ok 1.91.96.0 for=autoplan (pins: skipped (planning-only))
gstack: ok 1.91.101.0 for=autoplan (behind=12 (latest: 1.91.107.0); fix: bash <skill>/scripts/install.sh --for autoplan)
gstack: fail runtime,pins

An ok trailer carries at most one parenthesised note group, ; -separated, in this order: requested: … (an existing checkout at another revision than --revision), pins: skipped (planning-only|no project|helper runtime), then either behind=<n> (latest: <ver>) followed by fix: <command> (the session ended commits behind origin's default branch after a fetch that succeeded in this run; bin/gstack-capy-session exports GSTACK_CAPY_BEHIND, _BEHIND_TO and _BEHIND_FIX, so this works with every bootstrapper revision), or latest: <ver> available (origin's default branch has a newer VERSION; the bootstrapper exports it as GSTACK_CAPY_REMOTE_VERSION only after a fetch that succeeded in this run). A fail trailer never carries notes. Match ^gstack: ok or ^gstack: fail ; never parse past the first space after the version.

Exit 0 only with gstack: ok. Read the exit status and the trailer directly:

out=$(bash <skill>/scripts/install.sh --check --for autoplan 2>&1); rc=$?
printf '%s\n' "$out" | grep '^gstack: '
[ "$rc" -eq 0 ]

Never pipe --check (or the installer) through tail, head or grep alone: the pipe's exit status replaces the doctor's, and a silent failure is the incident this installer exists to end. Every FAIL <id> row carries a fix: clause and a reason code with its troubleshooting anchor. A parent agent confirms each subagent machine with this grep; one machine's install never covers another, and a subagent's first report carries its own gstack: line.

SKIP pins (planning-only; repo pins differ: ..., launch bun <v>) (trailer note pins: skipped (planning-only)) is not a failure: planning workflows (--for autoplan, the plan reviews, office-hours) never run the target repo's tests. Workflows that do (review, ship, qa) and --require keep pins required.

When a lane reports to another thread (a handoff, a gstack-lane-check result, a coordinator contract), store and use the full thread id (jam_…) in the message, never the short display code: the code is a label the app renders, the id is what another agent can address and what the evidence bundle records. UBI_OWNER and lane ownership records take the same full id.

Browser

The default install prints SKIP browser (lazy; gstack-browser-ensure installs on first use) and downloads nothing. The first browser skill run (/browse, /qa, /design-review, ...) calls bin/gstack-browser-ensure from its preflight, which installs Chromium (~115 MiB, about 86 s on a Capy machine) under setup's lock and prints BROWSER_OK or BROWSER_UNAVAILABLE <reason>. --with-browser installs it during the install instead, and the installer then prints the planning-only install time and the browser bootstrap time separately.

The launch line

Every successful run, --check included, prints before the trailer:

GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>
GSTACK_LAUNCH_PATH: <dir>
GSTACK_LAUNCH_FILE: <verdict file without .json>.env

GSTACK_LAUNCH_FILE names the same assignments as a sourceable script (mode 0600, beside the verdict under the state root; the PATH prepend is idempotent). The file is part of the ok verdict: a passing run that cannot write it exits 1 with gstack: fail launch-file — could not write the launch file <path>; fix: make <dir> writable, or set GSTACK_STATE_ROOT to a writable directory (LAUNCH_FILE_UNWRITABLE), and a failing run prints no GSTACK_LAUNCH_FILE line and removes the file an earlier passing run left beside the verdict it rewrites (a profile check that keeps a fuller ok verdict leaves that verdict's file). . <that file> at the start of a shell applies them without ever printing them, which is the only way to hand them to a child agent: pass the path, never the values. None of the assignments is a secret, but during GSTA-96's unattended /ship a docs-audit child re-typed them as export $(...); the substitution came back empty, the bare export printed the whole environment, and three API key values landed in its tool output. Never export $(...), env, printenv, a bare set or a bare export in a tool fence (test/launch-env-echo-ratchet.test.ts keeps them out of every skill fence and shell helper), and the redaction engine now refuses the VALUE of any *_KEY, *_TOKEN, *_SECRET, *_PASSWORD, *_PASSWD, *_PASS, *_PAT, *_APIKEY or *_PWD variable in the writer's environment (any case; names carrying a PUBLIC word are framework-public and exempt, gstack's own GSTACK_BRAIN_LOCK_TOKEN is not a secret) (env.value, HIGH), so a leaked value cannot reach a PR body, receipt, report or Drive file. There is no path exemption: a path-valued SSL_KEY that must appear in a text goes through bin/gstack-redact --allowlist or the pre-push hook's committed .gstack-redact-allowlist; the other writers (gstack-post, receipts, reports) take no allowlist, so for one of those commands set the variable empty for that command alone (SSL_KEY= gstack-post …): an empty value is below the length floor.

PATH=<dir>:$PATH and GSTACK_LAUNCH_PATH appear only when the certified Bun, or the project's resolved Node, is not first on PATH (older Capy images ship /usr/local/bin/bun 1.3.14; 57 gstack commands start with #!/usr/bin/env bun). <dir> is never a shared bin directory: a Bun from gstack's store launches from its own version directory, and any other Bun, or a Node the launch needs, launches through <verdict file without .json>.launch, a gstack-owned directory holding only bun, bunx and, when needed, node, npm, npx and corepack symlinks. So a Bun found in /usr/local/bin or $BUN_INSTALL/bin never puts that directory's other tools (the image's node) in front of PATH. The pregate's lanes launch through the same directory. A child shell's export cannot configure the parent's later commands, so the parent supplies these assignments to every gstack command it launches by sourcing the launch file (. <GSTACK_LAUNCH_FILE>) at the start of the shell that runs it. A fresh shell without them keeps reporting the Bun it would really run: FAIL runtime — bun 1.3.14 at /usr/local/bin/bun ...; verified bun 1.4.2 found at ~/.bun/bin (not on PATH); fix: export PATH="$HOME/.bun/bin:$PATH" or prefix commands with the GSTACK_LAUNCH line.

GSTACK_EPHEMERAL=1 tells gstack the disk vanishes with the machine, so the doctor's state root row reads durable=no and unattended runs say learnings: skipped instead of writing silently. A durable root is opt-in: set GSTACK_STATE_ROOT to a directory that outlives the machine. The installer never writes to the Capy drive. See docs/state-root.md.

GSTACK_CAPY=1 turns on install admission. An unattended session (any host) refuses to start a workflow on a Bun below the supported minimum; with GSTACK_CAPY=1, an unattended or GSTACK_SESSION_KIND=spawned session also needs a passing verdict for this checkout, its current HEAD and a workflow at least as wide as the one starting. A refusal prints INSTALL: fail ... and GSTACK_RESULT: skill=<s> status=refused run=- and exits 3 (INSTALL_UNVERIFIED). Ambient spawned sessions (OpenClaw) only warn. GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits anyway and says so.

Bake into a Capy snapshot

Machines that never run a workflow should not pay the install (about 30 s cold). A project that runs gstack on most machines can bake it instead: the project Setup's initialize step runs

git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.local/share/gstack
~/.local/share/gstack/bin/gstack-capy-install --require claude,codex,patch

and snapshots capture $HOME (the checkout, ~/.bun/bin, both host profiles). Each session then pays only the --check. A baked revision still moves forward at install-mode session start whenever it is behind origin, and the baked ~/.bun/bin reaches workflows through the GSTACK_LAUNCH line. The project Setup is shared by every thread in the project, so changing it is the project's decision.

Working under a Captain

In a Capy session (GSTACK_CAPY=1, set by the launch line) every workflow prints CAPY: on and a capy-asks block: decisions go through Capy (a crew thread asks its Captain, otherwise the person), plan approvals are rendered with gstack-owner-brief render --captain, answers are recorded with gstack-approvals, and /ship's PR body opens with before/after images (gstack-pr-shots), Why and Approvals. The guide is capy/skill/gstack/captain.md; GSTACK_CAPY_ASKS=0 drops the block for one session.

Raising the supported minimum

capy/support.env holds GSTACK_MIN_REVISION and GSTACK_CAPY_MIN_WRAPPER. Raise GSTACK_MIN_REVISION only for install-honesty or security fixes, at stamp time, to the VERSION being released: it is the floor below which the trailer fails and the bootstrapper itself fast-forwards (a checkout above it that is behind origin is fast-forwarded by the checkout's own wrapper, so the floor is not what keeps machines current). It never exceeds VERSION (test/capy-skill-wrapper.test.ts). Raise GSTACK_CAPY_MIN_WRAPPER together with GSTACK_CAPY_WRAPPER in the bootstrapper when the bootstrapper itself changes in a way older copies cannot work with; older Drive copies keep working and print an owner action (CAPY_WRAPPER_STALE), and the doctor's wrapper row warns (an active copy below the minimum is a WARN, never a FAIL; bin/gstack-upgrade never leaves a pending reinstall for it). Wrapper 2 only adds optional exports (the trailer's latest: note and the cloned line), so the minimum stays 1.

Exit codes

Exit Meaning
0 gstack: ok — every required component passed
1 gstack: fail <ids> — a required component failed; each row has a fix. Also gstack: fail launch-file … when a passing run could not write its launch file (LAUNCH_FILE_UNWRITABLE)
2 usage error
3 preflight stopped before ./setup (INSTALL_PREFLIGHT_FAILED, RUNTIME_PIN_CONFLICT); nothing was installed