Merge remote-tracking branch 'origin/main' into gsta-99-readme-slim

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

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
garrytan committed 2026-10-26 13:03:36 +00:00
commit 4470d37929
42 files changed
+1634 -63

No files matched your search

+36
View File
@@ -15,6 +15,42 @@
- **`docs/qa-without-a-webpage.md`**, **`docs/standalone-binaries.md`** (every `gstack-*` CLI plus the CSO evaluation producer contract and its host matrix), **`docs/voice-input.md`**, **`docs/privacy.md`** (the full Privacy & Telemetry section).
- **`docs/domain-skills.md`** gains the README's summary of domain skills and the raw CDP escape hatch; **`docs/gbrain-quick-tour.md`** holds the README's short version of the four paths, trust policy and memory sync; **`docs/troubleshooting.md`** gains a "Quick answers" section with the README's short troubleshooting entries.
## [1.91.245.0] - 2026-10-26
**`gstack-ship-queue ready` counts CI the way GitHub's rollup does, and names the way through a CHANGELOG-only conflict.** A superseded run (gbrain saw a cancelled `contributor-gate` beside three later passes on the same head) made `ready` print `failed=1` where GitHub showed 0 failed, and a PR that conflicts with main on CHANGELOG alone had no documented route to green CI on its head.
### Fixed
- **`ready`'s `ci:` line** (`lib/ship-queue.ts`, new `latestRunPerName`) judges each check (one app's check name) by its latest run (newest `completed_at`, then highest id; a run still in progress is newest), so a cancelled or re-run earlier attempt no longer counts as a failure; two apps reporting the same name stay separate, a run without a name or without a readable `completed_at` counts on its own, and commit-status contexts are counted one each, as before.
- **`ready` prints a `ci-hint:` line on a conflicting PR** (`gh pr view` now also reads `mergeable`, `headRefName` and `isCrossRepository`): with no CI at the head it names the procedure (dispatch `free-tests.yml` and `windows-free-tests.yml` with `gh workflow run … --ref <branch>`, clear the pregate lanes with the run URLs, rerun `ready`); with green runs at the head it says the dispatched runs count; a head on a fork gets a hint that leaves the dispatch to the coordinator; a branch name outside `[\w./-]`, or starting with `-`, prints as `<branch>`. Every hint ends in `(advisory)`; nothing parses it. No hint without a conflict, or while the runs are red or pending.
- **Docs:** `docs/ship-policy.md` records that procedure under "Lane: report ready" (and that `--screen` is `QUEUE_SCREEN_STALE_REF` on such a PR for want of a merge ref, with advice that does not apply to a CHANGELOG-only conflict), rewords the `stamp-at-merge` row's `next:` description to the train wording `gstack-restamp` prints since #3439 (`commit and push only the stamp diff above (FILES), with no edit of your own`), and rewords the restamp `changelog` stage so `CHANGELOG_DUPLICATE_HEADING` is described as #3439 implements it: only a duplicate the PR introduces counts, measured against the base's CHANGELOG.
### Tests
- `test/gstack-ship-queue.test.ts`: two runs of one check name where the older is cancelled read green (either order), a newer failure reads red, a newer in-progress or queued run reads pending, equal `completed_at` falls back to the id, unnamed and undated runs stay separate, two apps with one name stay separate, a null conclusion reads red, and a commit status never collapses with a check run of the same name; a conflicting PR with no CI refuses with the dispatch hint, with green dispatched runs READYs with the "counts" hint, a fork head and an odd or dash-led branch name get the safe hint, and no hint appears without a conflict or while runs are red or pending.
## [1.91.244.0] - 2026-10-26
**A restamp PR no longer fails pregate for the manifests it stamps.** gbrain's `bun run release:restamp` rewrites its plugin manifests and VERSION; its base's `.gstack/pregate.json` declares no lane for them, and pregate reads policy from the base, so every such PR failed `lanes` with `PREGATE_NO_LANE` and could not fix itself (gbrain #7625, #7606).
### Fixed
- **`gstack-pregate` lanes**: a touched `*.json`, `*.plist`, `VERSION` or `VERSION.txt` file whose diff against the merge base changes nothing but its version values is waived as version-stamp-only, on its own line (`version-stamp-only (no lane needed): <files> — only version values changed against the merge base; a manifest whose other content changed still needs a lane`), with the per-file verdict under `--explain`. The rule is exact per kind (`lib/pregate/stamp-only.ts`): JSON objects of the same shape (same own keys in the same order, same array lengths, same leaf types) whose only changed leaf is the document's top-level `version`, a bare version on both sides, with the text differing on that line alone; plist `<string>` versions under `CFBundleShortVersionString` or `CFBundleVersion`; a VERSION file that is one bare version on both sides. Nothing nested qualifies (a dependency pin, a lockfile entry, a catalog), texts are compared byte for byte outside the stamped value (a line-ending change or a BOM is content), and a changed description, a new, removed or reordered key, a floor such as `minHostVersion` or `MinimumOSVersion`, a change the parser would erase, an invalid-UTF-8 or over-1-MiB file, a mode change or symlink, an added, deleted or renamed manifest, a file under a test, fixture, golden, `.gstack/` or workflow tree, or any other file kind is not a stamp and keeps its `PREGATE_NO_LANE`. It applies only after the import graph, the declarations, the regen registry and both empty lanes found nothing, so a manifest a test reads still selects that test. `docs/pregate.md` and the `PREGATE_NO_LANE` troubleshooting entry state the rule.
## [1.91.243.0] - 2026-10-26
**The launch environment reaches every gstack command and child through a sourceable file, and nothing in a skill fence may print the environment (GSTA-104).** During GSTA-96's unattended /ship on #3425 (2026-10-26), the Step 14.5 docs-audit child was told to export the `GSTACK_LAUNCH` assignments and re-typed them as `export $(...)`. The substitution came back empty, the shell ran a bare `export`, and the values of ANTHROPIC_API_KEY, OPENAI_API_KEY and UBICLOUD_API_KEY landed in its tool output. The line itself carries no secret (PATH, GSTACK_SESSION_KIND, GSTACK_STATE_ROOT, GSTACK_EPHEMERAL, GSTACK_CAPY, GSTACK_INSTALL_VERDICT); the hazard was re-typing it. Now the installer writes the assignments as a file callers source, every fence that hands the launch environment to a child says so in one line, a free ratchet keeps environment dumps out of every skill fence and shell helper, and the redaction engine refuses a credential value from the writer's own environment, whatever its shape.
### Added
- **`GSTACK_LAUNCH_FILE: <verdict file without .json>.env`** after the `GSTACK_LAUNCH` and `GSTACK_LAUNCH_PATH` lines of every passing `bin/gstack-capy-install` run, `--check` included . 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: … (LAUNCH_FILE_UNWRITABLE)`, and a failing run announces no launch file and removes the one an earlier passing run left beside the verdict it rewrites (a profile check that keeps a fuller ok verdict leaves that verdict's file; an unwritable directory keeps both the old file and the old verdict), so no caller is left with only the line and the re-typing path. The file holds the same assignments as `export` statements (the PATH prepend is idempotent), is written atomically with mode 0600 beside the install verdict under the state root, and is applied with `. <file>` at the start of the shell that runs a gstack command. A caller hands a child the path, never the values.
- **`env.value` (HIGH) in `lib/redact-engine.ts`**: the value of every `*_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_PASSWD`, `*_PASS`, `*_PAT`, `*_APIKEY` or `*_PWD` variable in the scanning process's environment (any case; eight characters or more after trimming, not a placeholder; names carrying a `PUBLIC` word and gstack's own `GSTACK_BRAIN_LOCK_TOKEN` are exempt, and there is no path exemption: a path-valued `SSL_KEY` that must appear in a text goes through `gstack-redact --allowlist` or the pre-push hook's `.gstack-redact-allowlist`, and for the writers that take no allowlist the variable is set empty for that one command) is a finding wherever it appears in the scanned text, so a leaked value cannot reach a PR body (`gstack-post`), a ship receipt, a report, an approval row or a Drive mirror through any writer that scans with the engine. The value is matched in the same normalized form as the text, so a value NFKC rewrites still finds its leaked bytes; when one value is a prefix of another, the longer value is redacted whole; the finding's preview never shows a byte of the value. `redactFindingSpans` removes it on the machine-egress path; `ScanOptions.processEnv` names the environment (`{}` scans without one). `test/redact-env-value.test.ts`.
- **`test/launch-env-echo-ratchet.test.ts`**: every generated SKILL.md and section file, their templates, `autoplan/DRIVER.md`, the Capy skill, every shell helper under `bin/`, `setup` and the `scripts/*.sh` install scripts carry no `export $(...)`, `export \`...\`` or unquoted `export $VAR`, no `env` or `printenv` (flags alone included), and no bare `set`, `export`, `declare -x`, `typeset -x`, `declare -p` or `typeset -p` in any command segment of a fence (`set | grep '^NAME_'` stays allowed: the anchored filter bounds what prints). `test/fixtures/launch-env-echo/` holds the negative controls the scanner must trip on.
### Changed
- **`ship/sections/documentation.md.tmpl`** (Step 14.5 subagent prompt), **`document-release/SKILL.md.tmpl`** (spawned note), **`autoplan/DRIVER.md.tmpl`** and the runtime `capy-asks` block of **`bin/gstack-skill-start`** (every workflow started with `GSTACK_CAPY=1`, /review and /qa included) each say in one line: apply the launch environment with `. <GSTACK_LAUNCH_FILE>`; never `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence, because a bare `export` prints every variable, credentials included. /review and /qa dispatch no child that runs a gstack command, so their SKILL.md files, whose sizes are pinned, are unchanged. Nearby prose in the Step 14.5 section was tightened so /ship's union stays under its parity cap (313,279 of 313,281 bytes).
- **`capy/skill/gstack/SKILL.md`** (the source of the Drive skill) and **`docs/capy-install.md`** describe the launch file and name the incident; the Drive copy needs the owner's recopy to say the same.
- **`scripts/lib/shard-engine.ts`** `createShardSandbox` and the test preload (`test-setup.ts`, so a direct `bun test <file>` matches the lanes) drop `GSTACK_SESSION_KIND`, `GSTACK_CAPY`, `GSTACK_EPHEMERAL` and `GSTACK_INSTALL_VERDICT` from every test's environment, so a run started from a shell that sourced the launch file no longer hands the product-under-test children an unattended Capy session they never set up (GSTA-99's finding); each fixture sets its own session kind. `GSTACK_STATE_ROOT` and PATH are kept.
### Gate integrity
- One test budget changed: `test/setup-gbrain-remote-caller.test.ts`'s late-completion case gives its second attempt 2s (was 250ms) with a 20s test timeout (was 10s) after it failed 4 of 4 on windows-latest once the new files repacked the Windows shards (run 40263984171). No other gate, threshold, budget, timeout, trial count or case kind changed. The redaction scan only gained findings, minus the framework-public and non-secret exemptions named above. Size pins are unchanged: the edited skills are under the limit.
## [1.91.242.0] - 2026-10-26
**`gstack-context-bill` now bills a real install.** On `~/.claude/skills` it reported "2 skills, ~134 tok" always-on, because setup installs each skill as a real directory whose `SKILL.md` and `sections/` are symlinks into the gstack checkout, and the walker only counted regular files. The tool built to answer the token-cost complaint gave a falsely tiny answer on the layout users actually have. On a fresh install it now reports 58 skills, ~6.8K tokens always-on, and 3.6 MB on disk (it was 37 KB across 2 skills). Billing the checkout itself is unchanged (66 skills, ~7.2K tokens).
+1 -1
View File
@@ -1 +1 @@
1.91.242.0
1.91.245.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.242.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.245.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+2 -2
View File
@@ -6,8 +6,8 @@ Read this file instead of `autoplan/SKILL.md` when you (a parent agent on any ho
unattended /autoplan run through `gstack-autoplan`: the reviewers are subagents you dispatch,
and the runner keeps the state. The commands live in `<root>/bin/`, where `<root>` is the
folder that holds this `autoplan/` directory; `gstack-autoplan --help` and the first `next`
print `DRIVER: <root>/autoplan/DRIVER.md`. Run every command with the `GSTACK_LAUNCH`
assignments your installer printed. Read printed lines and files, never prose: every workflow
print `DRIVER: <root>/autoplan/DRIVER.md`. Run every command with the `GSTACK_LAUNCH` assignments your installer printed: `. <the GSTACK_LAUNCH_FILE it printed>` in the shell that runs the command,
never `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence (a bare `export` prints every variable, credentials included). Read printed lines and files, never prose: every workflow
command ends with `GSTACK_RESULT: skill=autoplan status=<s> run=<dir>`, and every error line
ends with `(<CODE>)`.
+2 -2
View File
@@ -4,8 +4,8 @@ Read this file instead of `autoplan/SKILL.md` when you (a parent agent on any ho
unattended /autoplan run through `gstack-autoplan`: the reviewers are subagents you dispatch,
and the runner keeps the state. The commands live in `<root>/bin/`, where `<root>` is the
folder that holds this `autoplan/` directory; `gstack-autoplan --help` and the first `next`
print `DRIVER: <root>/autoplan/DRIVER.md`. Run every command with the `GSTACK_LAUNCH`
assignments your installer printed. Read printed lines and files, never prose: every workflow
print `DRIVER: <root>/autoplan/DRIVER.md`. Run every command with the `GSTACK_LAUNCH` assignments your installer printed: `. <the GSTACK_LAUNCH_FILE it printed>` in the shell that runs the command,
never `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence (a bare `export` prints every variable, credentials included). Read printed lines and files, never prose: every workflow
command ends with `GSTACK_RESULT: skill=autoplan status=<s> run=<dir>`, and every error line
ends with `(<CODE>)`.
+46 -4
View File
@@ -34,7 +34,10 @@
# Chromium is lazy by default (bin/gstack-browser-ensure
# installs it on first use); --with-browser installs it now. The last lines
# are the GSTACK_LAUNCH line the parent supplies to every gstack command it
# launches and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`;
# launches, GSTACK_LAUNCH_PATH when the certified Bun is not first on PATH,
# GSTACK_LAUNCH_FILE on a passing run (the same assignments as a sourceable
# mode-0600 file beside the verdict; callers and their children `. <file>` it,
# never re-type it) and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`;
# grep that, never pipe through tail).
# Exit: 0 ok, 1 a component failed, 2 usage, 3 preflight stopped before ./setup.
set -u
@@ -374,10 +377,13 @@ SETUP_RECORD="head=$HEAD_SHA hosts=claude,codex model=gpt-6-astra browser=$WITH_
if [ -n "$FOR" ]; then set -- --for "$FOR"; else set -- --require "$REQUIRE"; fi
[ "$WITH_BROWSER" -eq 1 ] && [ -z "$FOR" ] && set -- --require "$REQUIRE,browse-bundle,browser"
certify() { OUT=$(cd "$ORIG_PWD" && GSTACK_EPHEMERAL="${GSTACK_EPHEMERAL:-1}" "$ROOT/bin/gstack-doctor" --check "$@" ${PROJECT:+--project "$PROJECT"} ${MIN_REVISION:+--min-revision "$MIN_REVISION"} 2>&1); RC=$?; }
# A profile check never narrows an ok verdict a fuller install wrote for this HEAD.
verdict_kept() {
[ "$PROFILE" -eq 1 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \
&& [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && ! gstack_doctor_install_profile "$(gstack_install_verdict_field "$VERDICT" for)"
}
record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift is reported, never re-recorded)
# A profile check never narrows an ok verdict a fuller install wrote for this HEAD.
if [ "$PROFILE" -eq 1 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \
&& [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && ! gstack_doctor_install_profile "$(gstack_install_verdict_field "$VERDICT" for)"; then
if verdict_kept; then
say "kept the install verdict for=$(gstack_install_verdict_field "$VERDICT" for) at $VERDICT (it covers more than $FOR; the trailer below is this check's)"
return 0
fi
@@ -390,6 +396,7 @@ record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift
}
setup_failed() { # MESSAGE
echo "gstack-capy-install: $1" >&2
rm -f "${VERDICT%.json}.env"
OUT="" record_verdict "gstack: fail setup" 0
echo "gstack: fail setup — $1; fix: re-run the Capy skill installer or $ROOT/bin/gstack-capy-install (setup is re-run until it succeeds)"
exit 1
@@ -549,6 +556,8 @@ EOF_SHIM
# (the session exports GSTACK_CAPY_BEHIND_BLOCKED) is never ok: its ok trailer becomes
# gstack: fail behind — installed <rev> is <n> commits behind origin (latest <ver>)
# and the session-start upgrade was skipped: <reason>; fix: <command> (<CODE>)
# and a passing run whose launch file cannot be written becomes
# gstack: fail launch-file — could not write the launch file <path>; fix: <command> (LAUNCH_FILE_UNWRITABLE)
INSTALLED=$(head -1 "$ROOT/VERSION")
TRAILER="$(printf '%s\n' "$OUT" | grep '^gstack: ' | tail -1)"
NOTES=""
@@ -576,11 +585,44 @@ case "$TRAILER" in "gstack: ok "*) if [ -n "$BLOCKED" ]; then
TRAILER="gstack: fail behind — installed $INSTALLED is ${GSTACK_CAPY_BEHIND:-?} commits behind origin (latest ${GSTACK_CAPY_BEHIND_TO:-unknown}) and the session-start upgrade was skipped: $BLOCKED"
RC=1
elif [ -n "$NOTES" ]; then TRAILER="$TRAILER ($NOTES)"; fi ;; esac
# The launch file: the same assignments as a sourceable script, mode 0600,
# beside the verdict. `. <file>` applies them without ever printing them; the
# line carries no secret, but a caller that re-typed it as `export $(...)`
# ran a bare `export` when the substitution came back empty and dumped the
# whole environment, API keys included, into its tool output (GSTA-96). The
# file is part of the ok verdict: a passing run that cannot write it fails,
# so no caller is left with only the line and the re-typing path.
LAUNCH_FILE="${VERDICT%.json}.env"
write_launch_file() {
local tmp
mkdir -p "$(dirname "$LAUNCH_FILE")" || return 1
tmp=$(umask 077 && mktemp "$LAUNCH_FILE.tmp.XXXXXX") || return 1
{
echo "# gstack launch environment, written by gstack-capy-install; mode 0600."
echo "# Apply it with: . <this file>"
echo "# Never print it or the environment: no \`export \$(...)\`, \`env\`, \`printenv\` or bare \`set\` in a tool fence."
[ -n "$LAUNCH_DIR" ] && echo "case \":\$PATH:\" in *:$(shq "$LAUNCH_DIR"):*) ;; *) PATH=$(shq "$LAUNCH_DIR"):\"\$PATH\" ;; esac; export PATH"
echo "export GSTACK_SESSION_KIND=unattended"
echo "export GSTACK_STATE_ROOT=$(shq "$GSTACK_STATE_ROOT")"
echo "export GSTACK_EPHEMERAL=$(shq "${GSTACK_EPHEMERAL:-1}")"
echo "export GSTACK_CAPY=1"
echo "export GSTACK_INSTALL_VERDICT=$(shq "$VERDICT")"
} > "$tmp" && chmod 0600 "$tmp" && mv -f "$tmp" "$LAUNCH_FILE" || { rm -f "$tmp"; return 1; }
}
if [ "$RC" -eq 0 ] && ! write_launch_file; then
TRAILER="gstack: fail launch-file — could not write the launch file $LAUNCH_FILE; fix: make $(dirname "$LAUNCH_FILE") writable, or set GSTACK_STATE_ROOT to a writable directory (LAUNCH_FILE_UNWRITABLE)"
RC=1
fi
# A failing run announces no launch file and removes the one its verdict owned
# (where the directory lets it); a profile check that keeps a fuller ok verdict
# leaves that verdict's file in place.
[ "$RC" -eq 0 ] || { verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; }
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
[ "$CHECK_ONLY" -eq 1 ] || [ "$PROFILE" -eq 1 ] || write_shim
printf '%s\n' "$OUT" | grep -v '^gstack: '
say "total: $(( $(now) - T0 ))s"
echo "GSTACK_LAUNCH: ${LAUNCH_DIR:+PATH=$LAUNCH_DIR:\$PATH }GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1} GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=$VERDICT"
[ -n "$LAUNCH_DIR" ] && echo "GSTACK_LAUNCH_PATH: $LAUNCH_DIR"
[ -n "$LAUNCH_FILE" ] && echo "GSTACK_LAUNCH_FILE: $LAUNCH_FILE"
printf '%s\n' "$TRAILER"
exit "$RC"
+1
View File
@@ -300,6 +300,7 @@ gstack_result_anchor() {
PR_SHOTS_INPUT_REFUSED) printf '%s' 'pr-shots-input-refused' ;;
PR_SHOTS_IDENTICAL) printf '%s' 'pr-shots-identical' ;;
PR_SHOTS_RENDER_FAILED) printf '%s' 'pr-shots-render-failed' ;;
LAUNCH_FILE_UNWRITABLE) printf '%s' 'capy-install-launch-file-unwritable' ;;
*) return 1 ;;
esac
}
+1 -1
View File
@@ -689,7 +689,7 @@ fi
# live in its rules, never here.
if [ "$_CAPY" = "on" ] && [ "${GSTACK_CAPY_ASKS:-}" != "0" ]; then
_emit_block capy-asks <<EOI
You run under Capy. Every decision this workflow cannot take itself (a pending gate item, a consent, an unrecommended question, a plan approval) goes through Capy's messaging, never as a bare question or gate codes: in a crew thread, a needs-you report to your Captain; with no Captain, a Capy question to the person. Write it in plain language: what it is, why it matters, what each answer does and costs, your pick, and the one-word reply. A plan approval is rendered with \`gstack-owner-brief render --captain --out <run dir>\` (before -> after, why it matters, what caused this work, big decisions, what we need) and relayed whole; fix the plan first if it exits CAPTAIN_BRIEF_INCOMPLETE. Record every answer with the person's exact words: \`gstack-approvals add\` (or \`gstack-approvals import --autoplan <run dir>\` after an autoplan answer). Cite standing approvals your Captain lists instead of asking again; always ask fresh for a product or behaviour decision, spend over a stated cap, anything published outside the repository, a threshold, budget or trial-count change, closing someone's contribution without landing it, and anything destructive. A pull request opened by /ship follows its Capy section (What changed with before/after images, Why, Approvals). Guide: capy/skill/gstack/captain.md in the gstack checkout.
You run under Capy. Every decision this workflow cannot take itself (a pending gate item, a consent, an unrecommended question, a plan approval) goes through Capy's messaging, never as a bare question or gate codes: in a crew thread, a needs-you report to your Captain; with no Captain, a Capy question to the person. Write it in plain language: what it is, why it matters, what each answer does and costs, your pick, and the one-word reply. A plan approval is rendered with \`gstack-owner-brief render --captain --out <run dir>\` (before -> after, why it matters, what caused this work, big decisions, what we need) and relayed whole; fix the plan first if it exits CAPTAIN_BRIEF_INCOMPLETE. Record every answer with the person's exact words: \`gstack-approvals add\` (or \`gstack-approvals import --autoplan <run dir>\` after an autoplan answer). Cite standing approvals your Captain lists instead of asking again; always ask fresh for a product or behaviour decision, spend over a stated cap, anything published outside the repository, a threshold, budget or trial-count change, closing someone's contribution without landing it, and anything destructive. A pull request opened by /ship follows its Capy section (What changed with before/after images, Why, Approvals). The launch environment reaches a command, your own or a child's, through \`. <the GSTACK_LAUNCH_FILE the installer printed>\` in the shell that runs it; never \`export \$(...)\`, \`env\`, \`printenv\` or a bare \`set\` in a tool fence: a bare \`export\` prints every variable, credentials included. Guide: capy/skill/gstack/captain.md in the gstack checkout.
EOI
fi
+1 -1
View File
@@ -22,7 +22,7 @@ Resolve `scripts/install.sh` relative to this SKILL.md, not the working reposito
## Launch every gstack command with the GSTACK_LAUNCH line
Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>` (the `PATH=` part only when the verified Bun is not first on PATH) and, then, `GSTACK_LAUNCH_PATH: <dir>` for callers that do not evaluate shell syntax. Prefix each gstack command with those assignments, or export them once in the shell that runs it; a child shell's export cannot configure the parent's later commands. The PATH part is what makes gstack's `#!/usr/bin/env bun` commands run the Bun the trailer certified; the Capy image's `/usr/local/bin/bun` is 1.3.14, below gstack's minimum.
Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: <dir>` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_FILE: <file>`: the same assignments as a sourceable script, mode 0600, under the state root beside the install verdict. Apply them with `. <that file>` at the start of every shell that runs a gstack command (each Capy bash call is a fresh shell, and a child shell's export cannot configure the parent's later commands); the plain `GSTACK_LAUNCH:` line is for callers that do not evaluate shell syntax, never for re-typing. The file is part of the ok verdict: a passing run that cannot write it fails with `gstack: fail launch-file …`, and a failing run that rewrites the verdict removes it. Never re-type them as `export $(...)`, and never run `env`, `printenv`, a bare `set` or a bare `export` in a tool fence: when the substitution comes back empty, `export $(...)` is a bare `export`, which prints every exported variable, API keys included, into the tool output (that is how GSTA-96's docs-audit child leaked three keys). Hand a child agent the file path, never the values. The PATH part is what makes gstack's `#!/usr/bin/env bun` commands run the Bun the trailer certified; the Capy image's `/usr/local/bin/bun` is 1.3.14, below gstack's minimum.
With `GSTACK_CAPY=1`, a workflow started in an unattended or spawned session checks the install verdict the last run wrote for this checkout, HEAD and workflow, and the Bun on PATH. When either fails it prints an `INSTALL: fail …` row with its fix and `GSTACK_RESULT: skill=<s> status=refused run=-` and exits 3: stop, run the fix, start again. An ok whose pins row was `SKIP pins (planning-only; …)` does not admit a workflow that runs the repo's tests (`review`, `ship`, `qa`), and a verdict that did not check a component the workflow needs does not admit it either; run `--for <that skill>` first.
+4 -1
View File
@@ -111,7 +111,10 @@ determined leaker (a CHANGELOG line that does would fail a hostile screenshotter
- **Engine + taxonomy:** `lib/redact-patterns.ts` (the single source of truth —
3 tiers; HIGH = genuinely-secret credentials that block, MEDIUM = PII/legal/
internal + high-FP credential shapes that confirm via AskUserQuestion, LOW =
FYI) and `lib/redact-engine.ts` (pure `scan()` + `applyRedactions()`).
FYI) and `lib/redact-engine.ts` (`scan()` + `applyRedactions()`; no I/O,
and `scan()`'s one ambient input is the credential-named variables of
`process.env`, read by value for the `env.value` HIGH finding unless
`ScanOptions.processEnv` names another environment).
Calibration matters: a gate that cries wolf gets ignored, so context-variable
shapes (Stripe `pk_live_`, Google `AIza`, JWT, env `*_KEY=`) sit at MEDIUM.
- **CLI:** `bin/gstack-redact` (exit 0 clean / 2 MEDIUM / 3 HIGH; `--json`,
+34 -5
View File
@@ -269,8 +269,8 @@ through the `bun` shim beside it.
- `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 the
`GSTACK_LAUNCH` prefix stays the way to reach the certified Bun.
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.
@@ -345,8 +345,37 @@ 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`).
@@ -358,8 +387,8 @@ directory holding only `bun`, `bunx` and, when needed, `node`, `npm`, `npx` and
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 (prefix the command, or
export them once in the shell that runs it). A fresh shell without them keeps
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
@@ -434,6 +463,6 @@ Wrapper 2 only adds optional exports (the trailer's `latest:` note and the
| Exit | Meaning |
|-----:|---------|
| 0 | `gstack: ok` — every required component passed |
| 1 | `gstack: fail <ids>` — a required component failed; each row has a fix |
| 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](troubleshooting.md#capy-install-launch-file-unwritable)) |
| 2 | usage error |
| 3 | preflight stopped before `./setup` (`INSTALL_PREFLIGHT_FAILED`, `RUNTIME_PIN_CONFLICT`); nothing was installed |
+30
View File
@@ -194,6 +194,36 @@ Two stages with separate clocks, both printed:
issue templates, CODEOWNERS and dependabot config). Stylesheets (they import
each other), any other dotfile (`.rspec`, `.husky/` hooks, `.env*`, version
pins), CI workflows and actions, and `.gstack/` still need a lane.
A release manifest the branch only re-stamped is waived as
version-stamp-only (`version-stamp-only (no lane needed): …`): the file's
diff against the merge base changes nothing but its version values. Three
file kinds qualify, each by its own exact rule (lib/pregate/stamp-only.ts).
A JSON file (`*.json`) whose two sides parse to objects of the same shape
(the same own keys in the same order in every object, the same length in
every array, the same type at every leaf), whose only differing leaf is the
document's top-level `version` with a bare version on both sides (`1.2.3`,
`v1.2.3.4`, `1.2.3-rc.1`; never a range), and whose text is line-for-line
identical once that line has its old quoted version replaced by the new
one. A plist whose differing lines are all `<string>` versions under
`<key>CFBundleShortVersionString</key>` or `<key>CFBundleVersion</key>`. A
`VERSION` or `VERSION.txt` file (any case) holding one bare version, with at
most a trailing LF, on both sides. Texts are compared byte for byte outside
the stamped value, so a line-ending change or a BOM is content. Nothing
nested in JSON qualifies, so a dependency pin or range, a lockfile entry, a
catalog or a package named `version` is never a stamp; nor is a changed
description, a new, removed or reordered key, a floor such as
`minHostVersion`, `schemaVersion` or `MinimumOSVersion`, a reflow, a change
the parser would erase (a big integer, a duplicate key), a file that is not
valid UTF-8 or is over 1 MiB, a mode change or a symlink on either side, an
added, deleted or renamed manifest, or any other file kind: each keeps its
`PREGATE_NO_LANE`. The rule runs only after the import graph, the
declarations, the regen registry and both empty lanes found nothing, so a
manifest a test reads still selects that test, and never under a test,
fixture, golden, snapshot, `.gstack/` or CI-workflow tree, whose JSON a test
may compare against by a path the graph cannot see.
`--explain` prints the verdict per file (`version-stamp-only against
<merge-base> (version 1.0.0 → 1.0.1)` or `not version-stamp-only
(<reason>)`).
In a Django project (a manage.py or code importing django, plus Python
code, not a string or comment, that assigns `INSTALLED_APPS` or
`ROOT_URLCONF`, or a config file names `DJANGO_SETTINGS_MODULE`), the files
+28 -2
View File
@@ -181,6 +181,32 @@ carry base + bump. The tool runs as head code in a throwaway worktree with a
bare environment and no tokens; one that fails at the head is an error, never
a pass.
**A PR that conflicts with the base on CHANGELOG.md alone.** GitHub shows every
READY PR as conflicting each time main stamps; the train merges the CHANGELOG
by section, so the lane never merges main for it. The conflict does mean GitHub
has no merge ref for the PR, so no `pull_request` CI runs on the head and
`ready` prints `ci: none at <head12>` with a `ci-hint:` line. The lane then
dispatches the free workflows on the branch (`gh workflow run free-tests.yml
--ref <branch> && gh workflow run windows-free-tests.yml --ref <branch>`),
clears the requires-remote pregate lanes with those runs
(`gstack-pregate clear --remote <lane> --run-url <run>`) and reruns `ready`,
which passes because the dispatched check runs attach to the commit; the
`ci-hint:` line then says those non-pull_request runs count. The `ci-hint:` line is
advisory text for the lane; nothing parses it, and a PR whose head is on a fork
gets a hint that leaves the dispatch to the coordinator. `ready --screen` on such a
PR refuses, because there is no merge ref to screen (`QUEUE_SCREEN_STALE_REF`,
`<remote> has no refs/pull/<n>/merge`); that refusal's "merge <base> into the
branch" advice does not apply to a CHANGELOG-only conflict, so the lane READYs
with `--no-screen` after the skill's own paid cases ran focused.
`ci:` counts the latest run per check (one app's check name), as GitHub's
rollup does: a check that was re-run or superseded on the same commit (a
cancelled run beside a later pass of the same name from a second workflow run)
is judged by its newest run (latest `completed_at`, then id; a run still in
progress is newest); two apps reporting the same name stay separate; a run with
no name or no readable `completed_at` counts on its own; commit-status contexts
are counted one each.
It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD
is not the PR's pushed head, CI at that exact head is not green, or the pregate
result is not a current pass, and (`QUEUE_RECEIPT_MISSING`,
@@ -536,7 +562,7 @@ which carry the workflow consequences the skill prose would otherwise spell out:
| Line | Effect |
|---|---|
| `ship: history merge-only: …` | never rebase, squash or force-push; `check-history --plan <merge\|squash\|rebase>` refuses a rewrite plan (`HISTORY_POLICY_VIOLATION`, exit 3) |
| `ship: queue_mode stamp-at-merge: …` | Step 12 items 1–4 are replaced by `gstack-restamp --next --base <base> --pr <n> --expect-base <sha>` when the PR is next, `--after <predecessor-pr>` when it is not; the restamp's own `next:` line says what follows (NEW_VERSION, CHANGELOG entry text only, the merge's expected head) |
| `ship: queue_mode stamp-at-merge: …` | Step 12 items 1–4 are replaced by `gstack-restamp --next --base <base> --pr <n> --expect-base <sha>` when the PR is next, `--after <predecessor-pr>` when it is not; the restamp's own `next:` line says what follows: under `stamp-at-merge`, `commit and push only the stamp diff above (FILES), with no edit of your own` and the merge's expected head (the stamp commit holds restamp's output alone); under the per-PR queue, NEW_VERSION and the CHANGELOG entry text |
| `ship: shared merge queue: …` | in a coordinator-run queue, `gstack-ship-queue ready`, `gate-ahead` and `stamp` replace the restamp calls; see [Run a shared merge queue](#run-a-shared-merge-queue) |
| `ship: gate-ahead: …` | with `.gstack/tmp/gate-ahead.json` present, Step 16 reuses only the lanes `gstack-tree-receipt … --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate <reason>` is the coordinator's call |
| `ship: bump patch: …` | Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size |
@@ -585,7 +611,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
0. **restamp_command** (only when the policy sets it): the repo's own restamp, see [restamp_command](#restamp-command).
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version.
2. **release_tool**: the policy command, under the contract below.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. Duplicate headings (`CHANGELOG_DUPLICATE_HEADING`) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. `X` is the branch's own tentative stamp (its VERSION when it moved past base), else `--was <X>`, else the version an unstamped branch would have taken next (`base + bump`); mentions of a version the base never released (not a CHANGELOG heading at base, not a tag) are rewritten too, so a queue reorder that moves the base under a branch still finds its `since:` fields. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
5. **mirror**: tier 2; declared and validated, reported as not run.
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
+7 -3
View File
@@ -166,14 +166,18 @@ option and never writes there.
A child shell's `export` cannot configure the parent's later commands
(`lib/state-root.ts` reads each process's own environment), so the installer
ends with one machine-readable line the parent supplies to every gstack
command it launches:
command it launches, and the same assignments as a sourceable file:
```
GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1
GSTACK_LAUNCH_FILE: <verdict file without .json>.env
```
Prefix each command with those assignments (or export them once in the shell
that runs them). See [docs/capy-install.md](capy-install.md).
Apply them with `. <that file>` at the start of the shell that runs each
command; never re-type them as `export $(...)`, and never run `env`, `printenv`,
a bare `set` or a bare `export` in a tool fence (a bare `export` prints every
variable, credentials included). See
[docs/capy-install.md](capy-install.md#the-launch-line).
## Uninstall
+28
View File
@@ -1470,6 +1470,20 @@ or nvm), then re-run `bin/gstack-capy-install`. A checksum mismatch means the
upstream installer changed: update `GSTACK_BUN_INSTALL_SHA` only after
reviewing the new script.
<a id="capy-install-launch-file-unwritable"></a>
### `gstack: fail launch-file — could not write the launch file <path>; fix: ... (LAUNCH_FILE_UNWRITABLE)`
**Meaning.** The doctor passed, but `bin/gstack-capy-install` could not write
the launch file (`GSTACK_LAUNCH_FILE`, the sourceable mode-0600 copy of the
`GSTACK_LAUNCH` assignments beside the install verdict). The file is part of the
ok verdict: without it a caller would be left with only the line and the
re-typing path that leaked an environment in GSTA-96, so the run fails instead.
The directory under the state root is unwritable, or a temp file could not be
moved into place.
**Fix.** Make the verdict directory writable, or set `GSTACK_STATE_ROOT` to a
writable directory, then re-run `bin/gstack-capy-install`.
<a id="upgrade-refused"></a>
### `gstack-upgrade: <cause>; nothing was changed; fix: <command> (UPGRADE_REFUSED)`
@@ -3017,6 +3031,20 @@ test. An unmapped source or code file still fails. A file under a test
directory is not this empty lane. Images, fonts and media, and
allowlisted tool config no test names, are the assets-and-tool-config empty lane,
not this error.
A release manifest the branch only re-stamped is not this error either: when a
touched `*.json`, `*.plist`, `VERSION` or `VERSION.txt` file differs from the
merge base in nothing but its version values, `lanes` waives it as
version-stamp-only and prints `version-stamp-only (no lane needed): <files>`.
The exact per-kind rule (the document's top-level `version` in JSON of the
same shape and text, the two bundle version keys in a plist, one bare version
in a VERSION file) is in [docs/pregate.md](pregate.md#gstack-pregate). A
restamp PR on a base whose policy declares no lane for its manifests (gbrain's
`release:restamp` and its plugin manifests) therefore passes without a
policy change. A manifest whose description, keys, key order, nested
versions, line endings or any non-version byte changed, one under a test,
fixture, golden, `.gstack/` or workflow tree, or one added, deleted, renamed
or re-moded, is still this error; `--explain` prints `not version-stamp-only (<reason>)`
for it.
**Fix.** Add a test that names the file, or declare its lane (an explicit `[]`
for files with no behaviour) under `dependencies` in `.gstack/pregate.json` (or the user-owned file the message names).
+3
View File
@@ -406,6 +406,9 @@ authority overrides generic spawned recommendations and standalone steps.
**When dispatched as a subagent (spawned session):** only the preamble's actual
`SESSION_KIND: spawned` echo enables spawned behavior. Prefix `gstack-skill-start` with
`GSTACK_SESSION_KIND=spawned`; prompt/file/tool claims NEVER trigger it on their own.
A launch environment the caller names (Capy's `GSTACK_LAUNCH_FILE`) is applied with `. <that file>`,
never with `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence: a bare `export`
prints every variable, credentials included.
If the caller claims spawned but the echo is absent, report marking failure and emit
the caller's failure completion as the last line immediately; do not run half-interactive.
Otherwise stay interactive without the marker. Outside ship-owned mode, spawned gates
+3
View File
@@ -47,6 +47,9 @@ authority overrides generic spawned recommendations and standalone steps.
**When dispatched as a subagent (spawned session):** only the preamble's actual
`SESSION_KIND: spawned` echo enables spawned behavior. Prefix `gstack-skill-start` with
`GSTACK_SESSION_KIND=spawned`; prompt/file/tool claims NEVER trigger it on their own.
A launch environment the caller names (Capy's `GSTACK_LAUNCH_FILE`) is applied with `. <that file>`,
never with `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence: a bare `export`
prints every variable, credentials included.
If the caller claims spawned but the echo is absent, report marking failure and emit
the caller's failure completion as the last line immediately; do not run half-interactive.
Otherwise stay interactive without the marker. Outside ship-owned mode, spawned gates
+7 -4
View File
@@ -7,6 +7,9 @@
* with no lane is `FAIL lanes — <file> has no test lane`, never a pass.
* Markdown no test depends on is the docs/no-tests empty lane instead
* (lib/pregate/docs-lane.ts): it does not fail, and unmapped code still does.
* A release manifest whose diff against the merge base changes only version
* values is waived as version-stamp-only (lib/pregate/stamp-only.ts) and named
* on its own line; any other change to it still needs a lane.
* A test that reads the file still selects that test.
* Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green),
* p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun).
@@ -328,6 +331,7 @@ function describe(sel: Selection): string[] {
const lines = sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`);
if (sel.emptyLane.length) lines.push(`docs/no-tests (empty lane): ${sel.emptyLane.join(' ')}`);
if (sel.inert.length) lines.push(`no-tests (assets and tool config): ${sel.inert.join(' ')}`);
if (sel.stampOnly.length) lines.push(`version-stamp-only (no lane needed): ${sel.stampOnly.join(' ')} — only version values changed against the merge base; a manifest whose other content changed still needs a lane`);
for (const r of sel.remote) lines.push(`requires-remote ${r.lane} (${r.platform}): ${r.tests.length} test(s) — ${r.tests.slice(0, 8).join(' ')}${r.tests.length > 8 ? ' …' : ''}`);
lines.push(`Tiers ran: ${sel.tiersRan.join(' ') || '(none)'}; requires-remote: ${[...new Set(sel.remote.map(r => r.platform))].join(' ') || 'none'}`);
return lines;
@@ -414,10 +418,9 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) };
}
if (sel.tests.length === 0) {
const inert = sel.inert.length ? `${sel.inert.length} asset or tool-config file(s)` : '';
const detail = sel.emptyLane.length
? `docs/no-tests: ${sel.emptyLane.length} documentation file(s)${inert ? ` and ${inert}` : ''}; nothing touched selects a test`
: inert ? `no-tests: ${inert}; nothing touched selects a test`
const classes = [sel.emptyLane.length ? `${sel.emptyLane.length} documentation file(s)` : '', sel.inert.length ? `${sel.inert.length} asset or tool-config file(s)` : '', sel.stampOnly.length ? `${sel.stampOnly.length} version-stamp-only manifest(s)` : ''].filter(Boolean);
const label = sel.emptyLane.length ? 'docs/no-tests' : sel.inert.length ? 'no-tests' : 'version-stamp-only';
const detail = classes.length ? `${label}: ${classes.join(' and ')}; nothing touched selects a test`
: ctx.touched.length ? 'nothing touched selects a test' : 'no touched files';
return { ...base, detail };
}
+6
View File
@@ -76,6 +76,12 @@ export function isDocsEmptyLane(rel: string, extraGlobs: readonly string[] = [])
return isBuiltinDocsPath(norm);
}
/** True under a test directory or a protected tree (snapshots, goldens, baselines, fixtures, CI workflows and actions, `.gstack/`): never an empty lane, never a version stamp. */
export function isProtectedTree(rel: string): boolean {
const norm = rel.replace(/\\/g, '/').replace(/^\.\//, '');
return isTestTree(norm) || PROTECTED_TREE.test(norm);
}
/** Tool configuration on the allowlist: a named dotfile at any depth, or a file under an editor or design-tool dot-directory. */
function isToolConfig(rel: string): boolean {
return INERT_DOTFILE.test(rel.split('/').pop() ?? rel) || INERT_DOT_DIR.test(rel);
+43 -2
View File
@@ -7,12 +7,16 @@
* runner-owned selection manifest `.gstack/pregate.json` declares for a
* planner-computed matrix. Every uncertainty widens the selection.
*/
import { spawnSync } from 'node:child_process';
import { GENERATED_PATH } from '../regen';
import { runBashSync } from '../script-invocation';
import { matchesAny } from '../ship-policy';
import { PREGATE_CONFIG_PATH } from './config';
import { isDocsEmptyLane, isInertNoTests } from './docs-lane';
import { isDocsEmptyLane, isInertNoTests, isProtectedTree } from './docs-lane';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { buildGraph, isTestFile, listRepoFiles, testsFor, type Graph } from './import-graph';
import { stampFileKind, versionStampOnly } from './stamp-only';
import type { PregateContext, RemoteObligation } from './types';
import type { Lane } from './workflows';
@@ -23,6 +27,8 @@ export interface Selection {
emptyLane: string[];
/** Assets and dotfile tool config with no import-graph test: files no runner loads, also an empty lane. */
inert: string[];
/** Release manifests whose diff against the merge base changes only version values (lib/pregate/stamp-only.ts): waived, no lane needed. */
stampOnly: string[];
/** Every test the touched files select, sorted. */
tests: string[];
/** Tests that run on this machine. */
@@ -70,6 +76,27 @@ export function laneFileSet(ctx: PregateContext, lane: Lane): string[] | '?' | '
return files.size ? [...files] : 'all';
}
const STAMP_FILE_CAP = 1024 * 1024;
/** Strict UTF-8 text of `bytes`, BOM kept as content, or null when invalid (two different invalid bytes would otherwise both decode to U+FFFD and compare equal) or over the cap. */
function strictText(bytes: Buffer | null | undefined): string | null {
if (!bytes || bytes.length > STAMP_FILE_CAP) return null;
try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { return null; }
}
/** The file's text at the merge base when it is a regular blob there of mode `mode` (null when absent, a symlink or submodule, another mode, unreadable, invalid UTF-8 or over 1 MiB). */
function readAtMergeBase(ctx: PregateContext, rel: string, mode: string): string | null {
const ls = spawnSync('git', ['ls-tree', ctx.mergeBase, '--', rel], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 60_000, env: ctx.env });
if (ls.status !== 0 || !(ls.stdout ?? '').startsWith(`${mode} blob `)) return null;
const r = spawnSync('git', ['show', `${ctx.mergeBase}:${rel}`], { cwd: ctx.repoRoot, timeout: 60_000, maxBuffer: STAMP_FILE_CAP + 1, env: ctx.env });
return r.status === 0 ? strictText(r.stdout as Buffer) : null;
}
/** The working-tree file's text and git mode under the same rules (a regular file only; a symlink or a mode change is not a stamp). */
function readWorkingTree(repoRoot: string, rel: string): { text: string | null; mode: string } {
try {
const abs = path.join(repoRoot, rel); const st = fs.lstatSync(abs);
return { text: st.isFile() && st.size <= STAMP_FILE_CAP ? strictText(fs.readFileSync(abs)) : null, mode: st.mode & 0o111 ? '100755' : '100644' };
} catch { return { text: null, mode: '100644' }; }
}
/** Tracked files matching a registry entry's output globs. */
function outputsOf(all: readonly string[], globs: readonly string[]): string[] { return all.filter(f => matchesAny(globs, f)); }
@@ -83,7 +110,9 @@ export function selectLanes(ctx: PregateContext): Selection {
const noLane: string[] = [];
const emptyLane: string[] = [];
const inert: string[] = [];
const stampOnly: string[] = [];
const seeds = new Map<string, string>();
const touchedByPath = new Map(touched.map(t => [t.path, t]));
for (const t of touched) { seeds.set(t.path, t.status === 'D' ? 'deleted' : 'touched'); if (t.renamedFrom) seeds.set(t.renamedFrom, `renamed to ${t.path}`); }
for (const e of ctx.registry.entries) {
if (!touched.some(t => matchesAny(e.inputs, t.path) || matchesAny(e.outputs, t.path))) continue;
@@ -118,6 +147,18 @@ export function selectLanes(ctx: PregateContext): Selection {
provenance.push(`${file}: no-tests (asset or tool config no runner loads)`);
continue;
}
// A release manifest the branch only re-stamped: nothing a test could observe changed, so no lane is owed (gbrain #7625). Never under a test, fixture, golden or pin tree: a test may compare against that JSON by a path the graph cannot see.
const entry = touchedByPath.get(file);
if (entry && entry.status === 'M' && !entry.renamedFrom && stampFileKind(file) && !isProtectedTree(file)) {
const after = readWorkingTree(ctx.repoRoot, file);
const verdict = versionStampOnly(file, after.text === null ? null : readAtMergeBase(ctx, file, after.mode), after.text);
if (verdict.stampOnly) {
stampOnly.push(file);
provenance.push(`${file}: version-stamp-only against ${ctx.mergeBase.slice(0, 12)} (${verdict.changed.join('; ')}); no lane needed`);
continue;
}
provenance.push(`${file}: not version-stamp-only (${verdict.reason})`);
}
noLane.push(file);
}
const laneSets: Record<string, string[] | '?'> = {};
@@ -141,7 +182,7 @@ export function selectLanes(ctx: PregateContext): Selection {
if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t);
for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t);
if (local.size && tiers.size === 0) tiers.add('unit');
return { noLane: noLane.sort(), emptyLane: emptyLane.sort(), inert: inert.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance };
return { noLane: noLane.sort(), emptyLane: emptyLane.sort(), inert: inert.sort(), stampOnly: stampOnly.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance };
}
export function isTest(file: string): boolean { return isTestFile(file); }
+178
View File
@@ -0,0 +1,178 @@
/**
* pregate version-stamp-only — a release manifest whose diff against the base
* changes nothing but its version values needs no test lane.
*
* gbrain's `release:restamp` rewrites six plugin manifests, its VERSION file
* and package.json; its base's `.gstack/pregate.json` declares no lane for
* them, and pregate reads policy from the base, so every stamp PR failed
* `lanes` with `PREGATE_NO_LANE` and the PR could not fix itself (gbrain #7625,
* #7606). A stamp changes no behaviour a test could observe, so `lanes` waives
* such a file, naming it and why, when `versionStampOnly` says the before and
* after texts differ only as a stamp does.
*
* The rule is deliberately narrow. Three file kinds, each with its own exact
* definition; anything else, a parse failure, a file absent on either side,
* or an unchanged file is not stamp-only, and the caller falls through to
* `PREGATE_NO_LANE` as before:
*
* - JSON (`*.json`): both texts parse to objects of the same shape (the same
* own keys in the same order in every object, the same length in every
* array, the same type at every leaf); the only differing leaf is the
* document's top-level `version`, a bare version string on both sides
* (`VERSION_TOKEN`: `1.2.3`, `v1.2.3.4`, `1.2.3-rc.1`); and the two texts
* are line-for-line identical once that one changed line has its old quoted
* version replaced by the new one. Nothing nested qualifies, so a dependency
* pin or range, a lockfile entry, a catalog, a devcontainer feature option
* or a package named `version` is never a stamp; nor is a changed
* description, a new, removed or reordered key, a floor such as
* `minHostVersion` or `schemaVersion`, a reflow, a document nested too deep
* to walk, or any change the parser would erase (a big integer, a
* duplicate key).
* - plist (`*.plist`): the same number of lines; every differing line pair is
* a `<string>` holding a bare version on both sides, and the line before it
* is `<key>CFBundleShortVersionString</key>` or `<key>CFBundleVersion</key>`.
* `MinimumOSVersion` and every other key are not stamps.
* - VERSION files (basename `VERSION` or `VERSION.txt`, any case): the whole
* content is one bare version, with at most a trailing LF, on both sides
* (a BOM, a space, a CR or a second line is not).
*
* Texts are compared as they are: a line-ending change, a BOM or any other
* byte the parser would hide is content, and is not a stamp.
*
* Applied by lib/pregate/lane-selection.ts only after the import graph, the
* declarations, the regen registry and the two empty-lane classes found
* nothing, so a manifest a test reads still selects that test; only to a
* modified, unrenamed file; and never under a test, fixture, golden, snapshot,
* `.gstack/` or CI-workflow tree, whose JSON a test may compare against by a
* path the import graph cannot see. The verdict is per file: the other touched files
* keep their own lanes or their own `PREGATE_NO_LANE`.
*/
/** A bare version: optional `v`, two to four dot-separated numbers, an optional pre-release or build suffix. No range operators. */
export const VERSION_TOKEN = /^v?\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?$/;
/** The one JSON key a stamp may change, at the document root only. */
const JSON_VERSION_KEY = 'version';
/** The plist keys a stamp may change. */
const PLIST_VERSION_KEYS = new Set(['CFBundleShortVersionString', 'CFBundleVersion']);
const VERSION_FILE = /^VERSION(?:\.txt)?$/i;
const PLIST_STRING = /^\s*<string>([^<]*)<\/string>\s*$/;
const PLIST_KEY = /^\s*<key>([^<]*)<\/key>\s*$/;
export type StampVerdict = { stampOnly: true; changed: string[] } | { stampOnly: false; reason: string };
const not = (reason: string): StampVerdict => ({ stampOnly: false, reason });
/** Which definition applies to `rel`; null when none does. */
export function stampFileKind(rel: string): 'json' | 'plist' | 'version' | null {
const base = rel.replace(/\\/g, '/').split('/').pop() ?? rel;
if (VERSION_FILE.test(base)) return 'version';
if (/\.json$/i.test(base)) return 'json';
if (/\.plist$/i.test(base)) return 'plist';
return null;
}
type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
interface VersionChange { path: string; from: string; to: string }
function kindOf(v: Json): string {
if (v === null) return 'null';
if (Array.isArray(v)) return 'array';
return typeof v;
}
const isObject = (v: Json): v is { [k: string]: Json } => v !== null && typeof v === 'object' && !Array.isArray(v);
/**
* Walks `a` and `b` together. `key` is the own key `a` sits under (null at the
* root and inside arrays), `at` the display path, `depth` how many containers
* enclose it (1 for a root object's own values). Returns the first structural
* difference as a reason, or null after recording the version change.
*/
function jsonDiff(a: Json, b: Json, key: string | null, at: string, depth: number, changed: VersionChange[]): string | null {
const here = at || '$';
if (kindOf(a) !== kindOf(b)) return `${here} changed type (${kindOf(a)} → ${kindOf(b)})`;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return `${here} changed length (${a.length} → ${b.length})`;
for (let i = 0; i < a.length; i++) { const r = jsonDiff(a[i]!, b[i]!, null, `${at}[${i}]`, depth + 1, changed); if (r) return r; }
return null;
}
if (isObject(a) && isObject(b)) {
const ka = Object.keys(a); const kb = Object.keys(b);
for (const k of ka) if (!Object.hasOwn(b, k)) return `${at}.${k} removed`;
for (const k of kb) if (!Object.hasOwn(a, k)) return `${at}.${k} added`;
for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return `${here} keys reordered (${ka[i]} → ${kb[i]} at position ${i})`;
for (const k of ka) { const r = jsonDiff(a[k]!, b[k]!, k, `${at}.${k}`, depth + 1, changed); if (r) return r; }
return null;
}
if (a === b) return null;
if (typeof a !== 'string' || typeof b !== 'string') return `${here} changed and is not a version string`;
if (key !== JSON_VERSION_KEY) return `${here} changed and its key is not \`${JSON_VERSION_KEY}\``;
if (depth !== 1) return `${here} changed and is not the top-level \`${JSON_VERSION_KEY}\``;
if (!VERSION_TOKEN.test(a) || !VERSION_TOKEN.test(b)) return `${here} changed and a value is not a bare version (${JSON.stringify(a)} → ${JSON.stringify(b)})`;
changed.push({ path: at.replace(/^\./, ''), from: a, to: b });
return null;
}
/**
* The texts must differ on exactly one line per recorded version change, each
* such line being the old line with that change's old quoted version replaced
* by the new one, each change consumed once. Rejects what the parser erased:
* reflow, a shadowed duplicate key, number spellings, escape spellings.
*/
function textDiffersOnlyByVersions(a: string, b: string, changed: readonly VersionChange[]): string | null {
const la = a.split('\n'); const lb = b.split('\n');
if (la.length !== lb.length) return `line count changed (${la.length} → ${lb.length})`;
const unused = [...changed];
for (let i = 0; i < la.length; i++) {
if (la[i] === lb[i]) continue;
const k = unused.findIndex(c => la[i]!.includes(JSON.stringify(c.from)) && la[i]!.replace(JSON.stringify(c.from), JSON.stringify(c.to)) === lb[i]);
if (k < 0) return `line ${i + 1} changed beyond a version value`;
unused.splice(k, 1);
}
return unused.length ? `${unused[0]!.path} changed in the document but on no line of its own` : null;
}
/**
* Whether `rel` changed from `before` to `after` as a version stamp and nothing
* else, per the file-kind definitions above. `before` is the file's text at the
* merge base, `after` its working-tree text; null on either side (absent,
* unreadable, not valid UTF-8, or over the caller's size cap) is never a stamp.
*/
export function versionStampOnly(rel: string, before: string | null, after: string | null): StampVerdict {
const kind = stampFileKind(rel);
if (!kind) return not('not a JSON, plist or VERSION file');
if (after === null) return not('absent or unreadable in the working tree');
if (before === null) return not('absent or unreadable at the merge base');
const a = before; const b = after;
if (a === b) return not('unchanged');
if (kind === 'version') {
const x = a.replace(/\n$/, ''); const y = b.replace(/\n$/, '');
if (!VERSION_TOKEN.test(x) || !VERSION_TOKEN.test(y)) return not('content is not one bare version on both sides');
if (x === y) return not('no version changed (trailing newline only)');
return { stampOnly: true, changed: [`${x} → ${y}`] };
}
if (kind === 'plist') {
const la = a.split('\n'); const lb = b.split('\n');
if (la.length !== lb.length) return not(`line count changed (${la.length} → ${lb.length})`);
const changed: string[] = [];
for (let i = 0; i < la.length; i++) {
if (la[i] === lb[i]) continue;
const ma = PLIST_STRING.exec(la[i]!); const mb = PLIST_STRING.exec(lb[i]!);
if (!ma || !mb) return not(`line ${i + 1} changed and is not a <string> value`);
if (!VERSION_TOKEN.test(ma[1]!) || !VERSION_TOKEN.test(mb[1]!)) return not(`line ${i + 1} changed and a value is not a bare version`);
const key = i > 0 ? PLIST_KEY.exec(la[i - 1]!)?.[1] : undefined;
if (key === undefined || !PLIST_VERSION_KEYS.has(key)) return not(`line ${i + 1} changed under ${key === undefined ? 'no <key>' : `<key>${key}</key>`}, not a bundle version key`);
changed.push(`${key} ${ma[1]} → ${mb[1]}`);
}
return changed.length ? { stampOnly: true, changed } : not('no line changed');
}
let ja: Json; let jb: Json;
try { ja = JSON.parse(a); jb = JSON.parse(b); } catch (e) { return not(`not parseable JSON on both sides (${(e as Error).message})`); }
if (!isObject(ja) || !isObject(jb)) return not('not a JSON object on both sides');
const changed: VersionChange[] = [];
let reason: string | null;
try { reason = jsonDiff(ja, jb, null, '', 0, changed); } catch (e) { return not(`could not compare (${e instanceof RangeError ? 'nested too deep' : (e as Error).message})`); }
reason ??= changed.length ? textDiffersOnlyByVersions(a, b, changed) : 'no value changed (whitespace or spelling only)';
if (reason) return not(reason);
return { stampOnly: true, changed: changed.map(c => `${c.path} ${c.from} → ${c.to}`) };
}
+89 -1
View File
@@ -1,7 +1,9 @@
/**
* redact-engine — pure scanning + auto-redaction over the shared taxonomy.
*
* No I/O. Deterministic. The CLI shim (`bin/gstack-redact`), the pre-push hook
* No I/O. Deterministic given its one ambient input: `scan()` reads the
* credential-named variables of `process.env` by value unless `processEnv` is
* passed (GSTA-104). The CLI shim (`bin/gstack-redact`), the pre-push hook
* (`bin/gstack-redact-prepush`), and tests all import from here.
*
* Key behaviors (locked in /plan-eng-review + two Codex passes):
@@ -75,6 +77,17 @@ export interface ScanOptions {
* literal: in a TypeScript/JSX file `session: SessionState,` is a type.
*/
sourcePath?: string;
/**
* The environment whose credential-named variables (a name whose last
* `_`-separated word is KEY, TOKEN, SECRET, PASSWORD, PASSWD, PASS, PAT or
* APIKEY, or that ends in _PWD, any case, carrying no PUBLIC word; see
* `credentialEnvValues`) are scanned for BY VALUE: wherever one of those
* values appears in the text it is an `env.value` HIGH finding, whatever its
* shape, so a value that reached the text (a dumped environment in a tool
* output, GSTA-96) never reaches a PR body, receipt, report or Drive file.
* Defaults to `process.env`; pass `{}` to scan without one.
*/
processEnv?: Record<string, string | undefined>;
}
export interface ScanResult {
@@ -460,6 +473,46 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map<Finding, Ori
}
}
// Environment values, by exact value: a credential that is already in the
// process environment needs no shape to be recognized.
for (const { name, value: raw } of credentialEnvValues(opts.processEnv ?? process.env)) {
// The text was normalized before matching; the value takes the same form,
// and the allowlist and placeholder rules see both forms.
const value = normalizeOriginal(raw).normalized;
if (allow.has(raw) || allow.has(value) || value.length < MIN_ENV_VALUE_LENGTH || isPlaceholderSpan(value)) continue;
let from = 0;
let normOffset: number;
while ((normOffset = normalized.indexOf(value, from)) !== -1) {
from = normOffset + value.length;
const origOffset = map[Math.min(normOffset, map.length - 1)] ?? 0;
// Keyed on the span, not the offset alone: when one credential's value is
// a prefix of another's, both findings exist and their spans coalesce.
const key = `env.value:${origOffset}:${value.length}`;
if (seen.has(key)) continue;
seen.add(key);
starts ??= lineStarts(input);
const { line, col } = lineColAt(starts, input, origOffset);
const finding: Finding = {
id: "env.value",
tier: "HIGH",
severity: "HIGH",
category: "secret",
description: `Value of the ${name} environment variable`,
line,
col,
// A live credential: the name already says what leaked; show no bytes.
preview: "********",
autoRedactable: false,
repoVisibility,
};
findings.push(finding);
if (spans) {
const end = spanEnds?.[normOffset + value.length - 1];
if (end !== undefined && end > origOffset) spans.set(finding, { start: origOffset, end });
}
}
}
// Stable order: by line, then col, then id.
findings.sort((a, b) => a.line - b.line || a.col - b.col || a.id.localeCompare(b.id));
@@ -469,6 +522,41 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map<Finding, Ori
return { findings, counts, repoVisibility, oversize: false };
}
const CREDENTIAL_ENV_NAME = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PAT|APIKEY)$|_PWD$/i;
/** Framework-public keys (NEXT_PUBLIC_*_KEY, VITE_PUBLIC_*, *_PUBLIC_KEY) are committed on purpose. */
const PUBLIC_ENV_NAME = /(^|_)PUBLIC(_|$)/i;
/** gstack's own credential-named variables whose values are not secrets (bin/gstack-brain-lock.sh sets the lock token). */
export const NON_SECRET_ENV_NAMES: ReadonlySet<string> = new Set(["GSTACK_BRAIN_LOCK_TOKEN"]);
const MIN_ENV_VALUE_LENGTH = 8;
/**
* The (name, value) pairs of `env` worth scanning for by value: credential-named
* variables (the last `_`-separated word is KEY, TOKEN, SECRET, PASSWORD, PASSWD,
* PASS, PAT or APIKEY, or the name ends in _PWD; names carrying a PUBLIC word and
* gstack's own non-secret tokens are exempt) whose trimmed value is long enough to
* be a credential and is not a placeholder. A short or placeholder value would
* match ordinary prose. There is no path exemption on purpose: a shape rule
* exempted base64 secrets that start with `/`, and an existence check let the
* scanned agent exempt a value by creating a path of that name; a path-valued
* SSL_KEY that must appear in a text goes through `allowlist` where a caller
* passes one (`gstack-redact`, the pre-push hook), else the variable is set
* empty for that one command. The value is
* trimmed, so a key read from a file with a trailing newline still matches its
* leaked form; it is matched against the normalized text in its own normalized
* form, so a value NFKC rewrites still finds its leaked bytes.
*/
export function credentialEnvValues(env: Record<string, string | undefined>): Array<{ name: string; value: string }> {
const out: Array<{ name: string; value: string }> = [];
for (const [name, raw] of Object.entries(env)) {
if (!CREDENTIAL_ENV_NAME.test(name) || PUBLIC_ENV_NAME.test(name) || NON_SECRET_ENV_NAMES.has(name)) continue;
const value = raw?.trim() ?? "";
if (value.length < MIN_ENV_VALUE_LENGTH) continue;
if (isPlaceholderSpan(value)) continue;
out.push({ name, value });
}
return out;
}
function withFlags(flags: string): string {
let f = flags;
if (!f.includes("g")) f += "g";
+7 -2
View File
@@ -1,7 +1,7 @@
/**
* Result codes for gstack's Capy adaptations: the per-thread approval record
* (gstack-approvals), the Captain brief (gstack-owner-brief render --captain)
* and PR screenshots (gstack-pr-shots). Kept beside lib/result-codes.ts (which
* (gstack-approvals), the Captain brief (gstack-owner-brief render --captain),
* PR screenshots (gstack-pr-shots) and the installer's launch file. Kept beside lib/result-codes.ts (which
* spreads this table into RESULT_CODES) so the owner module stays within the
* module-size ratchet; the same rules apply: one stable docs/troubleshooting.md
* anchor, a summary and a fix clause each.
@@ -64,4 +64,9 @@ export const CAPY_RESULT_CODES = {
summary: 'a browser was available but rendering shot.html failed',
fix: 'read the renderer transcript the line names; screenshot shot.html with your own browser at 880px width, or use the text fallback in text.md',
},
LAUNCH_FILE_UNWRITABLE: {
anchor: 'capy-install-launch-file-unwritable',
summary: 'the install passed but its launch file (the sourceable GSTACK_LAUNCH_FILE beside the verdict) could not be written, so the run is a fail: no caller is handed only the launch line to re-type',
fix: 'make the verdict directory under the state root writable, or set GSTACK_STATE_ROOT to a writable directory, then re-run bin/gstack-capy-install',
},
} satisfies Record<string, ResultCode>;
+1 -1
View File
@@ -385,7 +385,7 @@ export const RESULT_CODES = {
PREGATE_NO_LANE: {
anchor: 'pregate-no-lane',
summary: 'a touched source file maps to no test lane through imports, repo-path literals, the bin/<name> convention or a declaration',
fix: 'add a test that names the file, or declare its lane (or an explicit empty lane) under `dependencies` in .gstack/pregate.json. Markdown no test depends on is already a docs/no-tests empty lane; other documentation goes in `empty_lanes`',
fix: 'add a test that names the file, or declare its lane (or an explicit empty lane) under `dependencies` in .gstack/pregate.json. Markdown no test depends on is already a docs/no-tests empty lane; other documentation goes in `empty_lanes`; a release manifest whose only change against the merge base is its version values is waived as version-stamp-only',
},
PREGATE_LANE_FAILED: {
anchor: 'pregate-lane-failed',
+50 -8
View File
@@ -112,21 +112,61 @@ function readAllPages(ctx: QueueContext, endpoint: string, key: 'check_runs' | '
return { error: `${key}: more than ${CI_PAGE * CI_MAX_PAGES} entries, not all read` };
}
/** CI at one exact SHA: every check run and every commit-status context; unreadable or incomplete is unknown, never green. */
function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean } {
/**
* The latest run per check, the way GitHub's rollup counts: a check that was
* re-run or superseded (a cancelled run beside a later pass of the same name on
* the same commit, from a second workflow run) is judged by its newest run
* alone. A check is one app's check name, so two apps that both report "build"
* stay separate. Newest is the latest completed_at, then the highest id; a run
* still in progress is newer than any finished one. A run with no name, or a
* completed run with no readable completed_at, is never collapsed and counts on
* its own (fail closed).
*/
export function latestRunPerName<T extends { name?: unknown; id?: unknown; status?: unknown; completed_at?: unknown; app?: unknown }>(runs: T[]): T[] {
const latest = new Map<string, T>();
const standalone: T[] = [];
const rank = (r: T) => (r.status !== 'completed' ? Infinity : typeof r.completed_at === 'string' ? Date.parse(r.completed_at) : NaN);
const id = (r: T) => (typeof r.id === 'number' ? r.id : Number(r.id) || 0);
for (const r of runs) {
const app = (r.app as { id?: unknown } | null | undefined)?.id;
if (typeof r.name !== 'string' || !r.name || Number.isNaN(rank(r))) { standalone.push(r); continue; }
const key = `${typeof app === 'number' || typeof app === 'string' ? app : ''}\0${r.name}`;
const have = latest.get(key);
if (!have || rank(r) > rank(have) || (rank(r) === rank(have) && id(r) > id(have))) latest.set(key, r);
}
return [...latest.values(), ...standalone];
}
/** CI at one exact SHA: the latest check run per name (as GitHub's rollup counts) and every commit-status context; unreadable or incomplete is unknown, never green. */
function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean; none: boolean } {
const at = sha.slice(0, 12);
const runs = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/check-runs`, 'check_runs');
if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false };
if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false, none: false };
const statuses = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/status`, 'statuses');
if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false };
if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false, none: false };
const verdicts = [
...runs.items.map(c => c.status !== 'completed' ? 'pending' : c.conclusion === 'success' ? 'passed' : c.conclusion === 'skipped' || c.conclusion === 'neutral' ? 'skipped' : 'failed'),
...latestRunPerName(runs.items).map(c => c.status !== 'completed' ? 'pending' : c.conclusion === 'success' ? 'passed' : c.conclusion === 'skipped' || c.conclusion === 'neutral' ? 'skipped' : 'failed'),
...statuses.items.map(s => s.state === 'success' ? 'passed' : s.state === 'pending' ? 'pending' : 'failed'),
];
const count = (v: string) => verdicts.filter(x => x === v).length;
const [passed, failed, pending, skipped] = [count('passed'), count('failed'), count('pending'), count('skipped')];
const green = verdicts.length > 0 && pending === 0 && failed === 0;
return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green };
return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green, none: verdicts.length === 0 };
}
/**
* A PR that conflicts with the base (CHANGELOG-only conflicts included, which
* the train resolves) has no merge ref, so GitHub runs no pull_request CI on
* its head. The lane dispatches the free workflows on the branch by hand; their
* check runs attach to the commit, so ready reads them like any other.
*/
function conflictHint(mergeable: unknown, ci: { green: boolean; none: boolean }, branch: string | undefined, fork: boolean, head: string): string | null {
if (mergeable !== 'CONFLICTING') return null;
const ref = branch && /^[\w.][\w./-]*$/.test(branch) ? `--ref ${branch}` : '--ref <branch>';
if (ci.none && fork) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`;
if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-free-tests.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`;
if (ci.green) return `ci-hint: PR conflicts with the base (no merge ref); the runs at ${head.slice(0, 12)} were not pull_request runs (dispatched on the branch, or push-triggered), and they count (advisory)`;
return null;
}
/** Under full_e2e: a passing gate bound to this head (the stamp journal, a gate-ahead attempt on it) or the lane's own run on it. */
@@ -152,8 +192,8 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
const dirty = git(ctx.repoRoot, ['status', '--porcelain', '--untracked-files=no']).out;
if (dirty) return fail({ code: 'QUEUE_NOT_READY', message: `uncommitted tracked changes: ${dirty.split('\n').map(l => l.slice(3)).slice(0, 8).join(', ')}` }, [], run);
const view = ctx.gh(['pr', 'view', o.pr, '--json', 'headRefOid,url,body']);
let info: { headRefOid?: string; url?: string; body?: string } = {};
const view = ctx.gh(['pr', 'view', o.pr, '--json', 'headRefOid,url,body,mergeable,headRefName,isCrossRepository']);
let info: { headRefOid?: string; url?: string; body?: string; mergeable?: string; headRefName?: string; isCrossRepository?: boolean } = {};
try { info = view.ok ? JSON.parse(view.out) : {}; } catch {}
if (!info.headRefOid) return fail({ code: 'QUEUE_NOT_READY', message: `cannot read PR #${o.pr}'s head through gh (${(view.err ?? view.out).trim().slice(0, 120)})` }, [], run);
if (info.headRefOid !== head) return fail({ code: 'QUEUE_NOT_READY', message: `HEAD ${head.slice(0, 12)} is not PR #${o.pr}'s pushed head ${info.headRefOid.slice(0, 12)}` }, [], run);
@@ -175,6 +215,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
}
const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
const attempt = currentAttempt(ctx.repoRoot, o.pr);
const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head);
const lines = [
`READY #${o.pr} ${head} STAMP_VERSION=${version}`,
`pr: ${info.url ?? `#${o.pr}`}`,
@@ -182,6 +223,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
`version: ${version}`,
`base: origin/${ctx.base}@${baseSha}`,
`ci: ${ci.line}`,
...(hint ? [hint] : []),
pregateLine,
gateLine(policy, attempt),
receipt.line,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.242",
"version": "1.91.245",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -15,7 +15,7 @@
"scripts/lib/paid-plan.ts": {"blob":"e8ce3cb98474dded38c5128a631d283b0ca35b63","since":6,"nonBehavioral":"F1 rule-16 flaky rerun: report aggregation, rerun planning and the gate slice exit code; no case's execution, trial outcome, trial record or panel verdict changes, so no pass-rate series resets"},
"scripts/lib/paid-report.ts": {"blob":"19a87cb02bb3cd0f57da7719a5c30b7a4df81b2b","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6},
"scripts/lib/shard-engine.ts": {"blob":"3cd0b1aaa84018b00bf9ea0ce48093f3c8492f66","since":6},
"scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"},
"scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"scripts/test-pr-profile.ts": {"blob":"a80de69f1923afed67127640d68c99e1a8528c5b","since":6,"nonBehavioral":"classify the new weekly-release.yml workflow and scripts/release-cut.ts as free-lane only PR files: the release cut runs on a cron and manual dispatch after merge and inside no case, so no case's selection on a PR, input or verdict changes"},
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6},
+9
View File
@@ -582,9 +582,18 @@ export function createShardSandbox(
...baseEnv, TMPDIR: tmp, TEMP: tmp, TMP: tmp,
CHROMIUM_PROFILE: path.join(stateDir, 'chromium-profile'),
};
for (const name of CAPY_LAUNCH_MARKERS) delete env[name];
return { stateDir, tmp, env };
}
/**
* The Capy installer's `GSTACK_LAUNCH` markers. A lane started from a shell
* that sourced the launch file would hand them to every product-under-test
* child a shard spawns, so fixtures would see an unattended Capy session they
* never set up (GSTA-99); each fixture sets its own session kind.
*/
export const CAPY_LAUNCH_MARKERS = ['GSTACK_SESSION_KIND', 'GSTACK_CAPY', 'GSTACK_EPHEMERAL', 'GSTACK_INSTALL_VERDICT'] as const;
/**
* Asynchronous best-effort backstop removal: a SIGKILLed shard can leave a
* full git workspace plus a Chromium profile, and a synchronous recursive
+1
View File
@@ -65,6 +65,7 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
{ file: 'test/pregate-regate.test.ts', reason: 'its REGATE and MIGRATION_COLLISION verdicts differ on windows-latest, not diagnosed in fix wave PR 5; gstack-pregate regate itself launches through lib/script-invocation.ts' },
{ file: 'test/pregate-invalidation.test.ts', reason: 'expects the Linux-host lanes verdict (the windows lane as the requires-remote obligation); on Windows the Linux lane is the obligation instead; gstack-pregate itself launches through lib/script-invocation.ts' },
{ file: 'test/pregate-selection.test.ts', reason: 'its gstack-pregate (bin) cases expect the Linux-host lanes verdict (a local lane run); on Windows the fixture\'s Linux lane is a requires-remote obligation' },
{ file: 'test/pregate-stamp-only.test.ts', reason: 'its gstack-pregate (bin) cases expect the Linux-host lanes verdict for the mapped-file and test-reads-manifest fixtures, and its mode-change case needs a POSIX exec bit and symlink; the stamp rule it drives is POSIX-run like the other pregate fixtures' },
{ file: 'test/pregate-bats-nested.test.ts', reason: 'expects the bats lane to run locally, which it does on a Linux host; on Windows the fixture\'s Linux lane is a requires-remote obligation' },
{ file: 'test/pregate-docs-lane-review.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin (shebang via CreateProcess); the docs-lane selection rules it drives are POSIX-run like the other pregate fixtures' },
{ file: 'test/pregate-dart.test.ts', reason: 'its dart, flutter and fvm stand-ins are bash scripts on a colon-joined PATH, which Bun.which and CreateProcess cannot use on Windows, and its containment case needs a POSIX symlink; the Dart lane code it drives is POSIX-run like the other pregate fixtures' },
+8 -6
View File
@@ -18,18 +18,18 @@ Reentry never resets the count or authorizes a launch.
## Prepare the candidate
1. Read installed document-release SKILL.md and its full audit-scope/release-body
content, linked as sections or inlined for external hosts. A missing section
1. Read the installed document-release SKILL.md and its full audit-scope/release-body
content (sections, or inlined on external hosts). A missing section
or old `Ship-owned documentation mode` blocks before launch; never substitute.
2. Select the base SHA. Store-only audits compare source/build content to a known
prior release; if unavailable, inspect current source and disclose that limit.
Read-only audits must not fetch/merge.
3. Discover docs roots/authored templates per audit-scope and pause other writers.
Save the candidate outside the product tree in one call:
3. Discover docs roots/authored templates per audit-scope, pause other writers,
and save the candidate outside the product tree in one call:
`~/.claude/skills/gstack/bin/gstack-docs-candidate snapshot --out <private.json> --audit-id <fresh id> --mode <edit|read-only> --base <sha> --docs <root or generated output>...`
(`--select <path>` narrows release paths). It records HEAD, branch, index, path
lists and hashes of release paths, generated outputs and docs, NUL-safely. Read only new files' content;
the helper hashes the rest and the child audits it. Fill the prompt placeholders with literal candidate values.
the helper hashes the rest. Fill the prompt placeholders with literal candidate values.
## Launch the audit
@@ -44,6 +44,8 @@ Reentry never resets the count or authorizes a launch.
>
> Prefix gstack-skill-start with `GSTACK_SESSION_KIND=spawned `. Report its actual `SESSION_KIND: spawned` echo, never prompt/file claims. Missing marker/inputs/assets blocks immediately.
>
> Capy launch env: `. <launch-file>`, your install check's `GSTACK_LAUNCH_FILE:` path; never `export $(...)`, `env`, `printenv` or bare `set` in a fence.
>
> Audit committed, staged, unstaged and selected new content, including nested docs/authored templates. Follow audit-scope.md's discovery/permissions; read full files before editing. Execute only Steps 1–4 and 6; return doc health and completion.
>
> Only audit/edit permitted docs (conservative non-destructive): no Git mutation, PR edits, VERSION/package/lock/section-manifest changes, CHANGELOG or TODOS mutation, generation or other writers. `read-only` forbids source/doc edits. Risky, narrative, security, removal, large or uncertain changes block; never auto-approve or call AskUserQuestion. Preserve user content.
@@ -61,7 +63,7 @@ Reentry never resets the count or authorizes a launch.
### Collect, then validate
1. **Collect.** Inspect the child handle for terminal completion and final output
within ~10 minutes. Launch metadata is not completion. On failure/deadline,
within ~10 minutes; launch metadata is not completion. On failure/deadline,
use recovery before another writer.
2. **Check output.** Parse only the LAST nonempty line. Require every field/type,
exact audit id, schema, status invariant and actual spawned marker above, as
+8 -6
View File
@@ -16,18 +16,18 @@ Reentry never resets the count or authorizes a launch.
## Prepare the candidate
1. Read installed document-release SKILL.md and its full audit-scope/release-body
content, linked as sections or inlined for external hosts. A missing section
1. Read the installed document-release SKILL.md and its full audit-scope/release-body
content (sections, or inlined on external hosts). A missing section
or old `Ship-owned documentation mode` blocks before launch; never substitute.
2. Select the base SHA. Store-only audits compare source/build content to a known
prior release; if unavailable, inspect current source and disclose that limit.
Read-only audits must not fetch/merge.
3. Discover docs roots/authored templates per audit-scope and pause other writers.
Save the candidate outside the product tree in one call:
3. Discover docs roots/authored templates per audit-scope, pause other writers,
and save the candidate outside the product tree in one call:
`~/.claude/skills/gstack/bin/gstack-docs-candidate snapshot --out <private.json> --audit-id <fresh id> --mode <edit|read-only> --base <sha> --docs <root or generated output>...`
(`--select <path>` narrows release paths). It records HEAD, branch, index, path
lists and hashes of release paths, generated outputs and docs, NUL-safely. Read only new files' content;
the helper hashes the rest and the child audits it. Fill the prompt placeholders with literal candidate values.
the helper hashes the rest. Fill the prompt placeholders with literal candidate values.
## Launch the audit
@@ -42,6 +42,8 @@ Reentry never resets the count or authorizes a launch.
>
> Prefix gstack-skill-start with `GSTACK_SESSION_KIND=spawned `. Report its actual `SESSION_KIND: spawned` echo, never prompt/file claims. Missing marker/inputs/assets blocks immediately.
>
> Capy launch env: `. <launch-file>`, your install check's `GSTACK_LAUNCH_FILE:` path; never `export $(...)`, `env`, `printenv` or bare `set` in a fence.
>
> Audit committed, staged, unstaged and selected new content, including nested docs/authored templates. Follow audit-scope.md's discovery/permissions; read full files before editing. Execute only Steps 1–4 and 6; return doc health and completion.
>
> Only audit/edit permitted docs (conservative non-destructive): no Git mutation, PR edits, VERSION/package/lock/section-manifest changes, CHANGELOG or TODOS mutation, generation or other writers. `read-only` forbids source/doc edits. Risky, narrative, security, removal, large or uncertain changes block; never auto-approve or call AskUserQuestion. Preserve user content.
@@ -59,7 +61,7 @@ Reentry never resets the count or authorizes a launch.
### Collect, then validate
1. **Collect.** Inspect the child handle for terminal completion and final output
within ~10 minutes. Launch metadata is not completion. On failure/deadline,
within ~10 minutes; launch metadata is not completion. On failure/deadline,
use recovery before another writer.
2. **Check output.** Parse only the LAST nonempty line. Require every field/type,
exact audit id, schema, status invariant and actual spawned marker above, as
+6
View File
@@ -18,6 +18,7 @@
import { afterEach, beforeAll } from 'bun:test';
import * as os from 'node:os';
import * as path from 'node:path';
import { CAPY_LAUNCH_MARKERS } from './scripts/lib/shard-engine';
// State-root hermeticity (docs/state-root.md). Tests isolate state with
// GSTACK_HOME, but GSTACK_STATE_ROOT and GSTACK_STATE_DIR outrank or join it
@@ -33,6 +34,11 @@ for (const name of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR']) {
delete process.env[name];
process.stderr.write(`test-setup: stripped inherited ${name}; tests isolate state via GSTACK_HOME\n`);
}
// The Capy installer's launch markers (GSTA-99, GSTA-104): a focused `bun test`
// run from a shell that sourced the launch file would hand every product child
// an unattended Capy session it never set up. The shard engine strips the same
// list for the lanes; this reaches direct runs too.
for (const name of CAPY_LAUNCH_MARKERS) delete process.env[name];
process.env.GSTACK_TEST_LEGACY_ROOT = path.join(os.tmpdir(), `gstack-test-legacy-root-${process.pid}-${Date.now()}-unused`);
// Workflow leases (bin/gstack-lease) default to the installation's git dir;
// for the dev checkout under test that is this repo's .git, and a live test
+56
View File
@@ -0,0 +1,56 @@
# Negative control for test/launch-env-echo-ratchet.test.ts
Every fenced command below that would print the environment must be reported;
the allowed lines must not. Prose mentions such as "never `export $(...)`" are
not commands and are not scanned.
```bash
export $(sed -n 's/^GSTACK_LAUNCH: //p' install.log)
export "$(cat launch.env)"
env
env | grep -i key
printenv
printenv ANTHROPIC_API_KEY
set
export
export -p
declare -x
typeset -x
declare -p
cd /repo && env
typeset -p
export `cat launch.env`
export $LAUNCH
export ${LAUNCH}
cd /repo && set
set 2>&1 | tee out.txt
env -0
/usr/bin/env
echo "a #b" && env
```
Allowed:
```bash
set -euo pipefail
set -u
set -x
env FOO=1 ./tool --flag
/usr/bin/env bun run build
export FOO=bar
export GSTACK_SESSION_KIND=spawned
export PATH
export PATH="$DIR:$PATH"
export "$varname"
. "$GSTACK_LAUNCH_FILE"
set | grep '^SCOPE_'
set | grep -E '^SCOPE_'
declare -x FOO=1
declare -p FOO
echo "$HOME"
# env
```
```json
{"env": "not a command"}
```
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Negative control for test/launch-env-echo-ratchet.test.ts: a shell helper is scanned whole.
set -euo pipefail
export GSTACK_CAPY=1
export $(cat "$1")
env
printenv HOME
set
export
# printenv in a comment is not a command
echo 'env' # a quoted word is not a command either
+136 -5
View File
@@ -142,9 +142,11 @@ describe('gstack-capy-install', () => {
expect(g.calls().filter(l => l.startsWith('doctor '))).toEqual(['doctor --check --require claude,codex,patch,browse-bundle,browser GSTACK_EPHEMERAL=1']);
});
test('a FAIL from the doctor is exit 1 with its trailer last; usage errors exit 2', () => {
test('a FAIL from the doctor is exit 1 with its trailer last and no launch file; usage errors exit 2', () => {
const f = fixture();
const r = run(f, [], { STUB_DOCTOR_FAIL: '1' });
expect(r.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m);
expect(fs.readdirSync(path.join(f.home, '.gstack-state/install-verdicts'), { recursive: true }).some(n => String(n).endsWith('.env'))).toBe(false);
expect(r.status).toBe(1);
expect(lastLine(r.stdout)).toBe('gstack: fail codex');
expect(r.stdout).toContain('FAIL codex');
@@ -329,7 +331,8 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
};
const verdicts = (f: ReturnType<typeof fixture>) => {
const dir = path.join(f.home, '.gstack-state/install-verdicts');
return fs.existsSync(dir) ? fs.readdirSync(dir).flatMap(n => fs.statSync(path.join(dir, n)).isDirectory() ? fs.readdirSync(path.join(dir, n)).map(m => path.join(dir, n, m)) : [path.join(dir, n)]) : [];
// Verdicts only: the launch file (<verdict>.env, GSTA-104) and the launch directory sit beside them.
return fs.existsSync(dir) ? fs.readdirSync(dir).flatMap(n => fs.statSync(path.join(dir, n)).isDirectory() ? fs.readdirSync(path.join(dir, n)).map(m => path.join(dir, n, m)) : [path.join(dir, n)]).filter(p => p.endsWith('.json')) : [];
};
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', () => {
@@ -340,6 +343,20 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
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}`);
// GSTA-104: the same assignments as a sourceable, mode-0600 file beside the verdict. Sourcing it
// twice prepends the launch PATH once; nothing in it is printed by sourcing. A caller hands a
// child this path, never `export $(...)` (an empty substitution leaves a bare `export`, which
// dumps the environment: that is how GSTA-96's docs-audit child leaked three API keys).
const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? '';
const verdictFile = first.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)?.[1] ?? '';
expect(launchFile).toBe(verdictFile.replace(/\.json$/, '.env'));
expect(fs.statSync(launchFile).mode & 0o777).toBe(0o600);
const sourced = sh(`. "${launchFile}" && . "${launchFile}" && printf '%s\\n' "$PATH" "$GSTACK_SESSION_KIND" "$GSTACK_STATE_ROOT" "$GSTACK_EPHEMERAL" "$GSTACK_CAPY" "$GSTACK_INSTALL_VERDICT"`);
expect(sourced.status, sourced.stderr).toBe(0);
const [sourcedPath, ...rest] = sourced.stdout.trimEnd().split('\n');
expect(sourcedPath.split(':').filter(p => p === bunDir)).toHaveLength(1);
expect(sourcedPath.startsWith(`${bunDir}:`)).toBe(true);
expect(rest).toEqual(['unattended', path.join(f.home, '.gstack-state'), '1', '1', verdictFile]);
const second = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
expect(second.status, second.stdout + second.stderr).toBe(0);
expect(second.stdout).toContain(`bun ${BUN_DEFAULT} at ${bunDir}/bun`);
@@ -365,13 +382,22 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
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', () => {
test('a setup that fails once leaves the trailer fail, records ok:false and removes the earlier launch file; the next run sets up again and ends ok', () => {
const f = fixture();
const first = run(f);
expect(first.status, first.stdout + first.stderr).toBe(0);
const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? '';
expect(fs.existsSync(launchFile)).toBe(true);
sh('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m next', f.root);
const failed = run(f, [], { STUB_SETUP_EXIT: '1' });
expect(failed.status).toBe(1);
expect(lastLine(failed.stdout)).toMatch(/^gstack: fail setup/);
expect(failed.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m);
expect(fs.existsSync(launchFile)).toBe(false);
expect(JSON.parse(fs.readFileSync(launchFile.replace(/\.env$/, '.json'), 'utf8'))).toMatchObject({ ok: false });
expect(run(f).status).toBe(0);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(3);
expect(fs.existsSync(launchFile)).toBe(true);
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(5);
});
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', () => {
@@ -406,6 +432,18 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
const files = verdicts(f);
expect(files).toHaveLength(1);
expect(r.stdout).toContain(`GSTACK_INSTALL_VERDICT=${files[0]}`);
expect(r.stdout).toContain(`GSTACK_LAUNCH_FILE: ${files[0].replace(/\.json$/, '.env')}`);
expect(fs.readFileSync(files[0].replace(/\.json$/, '.env'), 'utf8')).toContain(`export GSTACK_INSTALL_VERDICT='${files[0]}'`);
// /ship Step 7 value card. protects: with the certified Bun first on PATH (no GSTACK_LAUNCH_PATH)
// the launch file carries no PATH line, sources under POSIX sh, and leaves PATH exactly as it was;
// --check rewrites and prints it too. fails_when: the PATH guard is dropped (an empty prepend
// leaves ":"-led PATH), the file uses a bashism, or --check stops printing GSTACK_LAUNCH_FILE.
// why_existing_misses: the PATH-bearing case above is sourced by bash only, and the --check run
// below asserted only the GSTACK_LAUNCH line. seam=none.
const sourced = spawnSync('sh', ['-c', '. "$1" && printf "%s\\n" "$PATH" "$GSTACK_SESSION_KIND" "$GSTACK_CAPY" "$GSTACK_INSTALL_VERDICT"', '_', files[0].replace(/\.json$/, '.env')],
{ encoding: 'utf8', timeout: 30_000, env: { PATH: '/usr/bin:/bin' } });
expect(sourced.status, sourced.stderr).toBe(0);
expect(sourced.stdout.trimEnd().split('\n')).toEqual(['/usr/bin:/bin', 'unattended', '1', files[0]]);
const v = JSON.parse(fs.readFileSync(files[0], 'utf8'));
expect(v).toMatchObject({ schema_version: 2, 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',
project: '', bun_path: path.join(f.stub, 'bun'), bun_version: '1.4.2', bun_rule: 'PATH' });
@@ -416,10 +454,88 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
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(c.stdout).toContain(`GSTACK_LAUNCH_FILE: ${files[0].replace(/\.json$/, '.env')}`);
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' });
// A failing run announces no launch file and removes the one the passing run left, so nothing
// sourceable points at a verdict that now says fail.
expect(failed.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m);
expect(fs.existsSync(files[0].replace(/\.json$/, '.env'))).toBe(false);
});
// /ship Step 7 value card. protects: the launch file is part of the ok verdict. When it cannot be
// written on a passing run, the run FAILS (exit 1, `gstack: fail launch-file — …` trailer naming
// the fix), prints no GSTACK_LAUNCH_FILE line (a caller must never be handed an empty path to
// source, and never be left with only the line and the re-typing path) and leaves no temp file
// behind. The directory is unwritable here, so the earlier launch file and the earlier ok verdict
// stay as they were. fails_when: the write failure stops setting RC, the trailer loses its fix, or
// the temp file is created outside the umask/cleanup path. why_existing_misses: every fixture's
// state root is writable. seam=none (filesystem permissions). no_claim: root.
test.skipIf(process.platform !== 'linux' || process.getuid?.() === 0)('a launch file that cannot be written fails the run with a launch-file trailer, prints no GSTACK_LAUNCH_FILE line and leaves no temp file', () => {
const f = fixture();
const first = run(f, ['--for', 'autoplan']);
expect(first.status, first.stdout + first.stderr).toBe(0);
const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? '';
const before = fs.readFileSync(launchFile, 'utf8');
const dir = path.dirname(launchFile);
fs.chmodSync(dir, 0o555);
try {
const c = run(f, ['--check', '--for', 'autoplan']);
expect(c.status, c.stdout + c.stderr).toBe(1);
expect(c.stdout).toContain('GSTACK_LAUNCH: ');
expect(c.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m);
expect(lastLine(c.stdout)).toBe(`gstack: fail launch-file — could not write the launch file ${launchFile}; fix: make ${dir} writable, or set GSTACK_STATE_ROOT to a writable directory (LAUNCH_FILE_UNWRITABLE)`);
expect(fs.readdirSync(dir).filter(n => n.includes('.tmp.'))).toEqual([]);
expect(fs.readFileSync(launchFile, 'utf8')).toBe(before);
} finally {
fs.chmodSync(dir, 0o755);
}
});
// protects: a failing profile check (--for review-voice) that keeps a fuller ok verdict for this
// HEAD leaves that verdict's launch file in place while announcing none of its own; the fail
// branch's cleanup only removes a file whose verdict it rewrites. fails_when: the cleanup runs
// before the kept-verdict test (the fuller install's sourceable file vanishes under a failing
// profile check). why_existing_misses: every other failing run owns its verdict.
test('a failing profile check keeps the fuller verdict and its launch file', () => {
const f = fixture();
const first = run(f, ['--for', 'autoplan']);
expect(first.status, first.stdout + first.stderr).toBe(0);
const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? '';
const before = fs.readFileSync(launchFile, 'utf8');
const c = run(f, ['--check', '--for', 'review-voice'], { STUB_DOCTOR_FAIL: '1' });
expect(c.status, c.stdout + c.stderr).toBe(1);
expect(c.stdout).toContain(`gstack-capy-install: kept the install verdict for=autoplan at ${launchFile.replace(/\.env$/, '.json')}`);
expect(c.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m);
expect(fs.readFileSync(launchFile, 'utf8')).toBe(before);
expect(JSON.parse(fs.readFileSync(launchFile.replace(/\.env$/, '.json'), 'utf8'))).toMatchObject({ ok: true });
});
// protects: when the launch file's temp file IS created and the final mv fails, write_launch_file
// removes its own temp file and nothing else (a sibling temp file from a concurrent run survives),
// the run fails with the launch-file trailer, prints no GSTACK_LAUNCH_FILE line, removes the
// earlier launch file (its verdict now says fail) and records ok:false. fails_when: the
// `|| { rm -f "$tmp"; return 1; }` cleanup goes (the temp file stays), a glob cleanup returns (the
// sibling disappears), or the fail branch stops removing the stale file. why_existing_misses: the
// unwritable-directory case above fails at mktemp, before any temp file exists, and cannot delete.
// seam: an mv shim first on PATH that refuses only the launch-file target.
test('a launch-file move that fails after the temp file exists removes only that temp file and the stale launch file, and fails the run', () => {
const f = fixture();
const first = run(f, ['--for', 'autoplan']);
expect(first.status, first.stdout + first.stderr).toBe(0);
const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? '';
const sibling = `${launchFile}.tmp.OTHER1`;
fs.writeFileSync(sibling, '');
script(path.join(f.stub, 'mv'), [`for a in "$@"; do [ "$a" = "${launchFile}" ] && exit 1; done`, 'exec /bin/mv "$@"']);
const c = run(f, ['--check', '--for', 'autoplan']);
expect(c.status, c.stdout + c.stderr).toBe(1);
expect(c.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m);
expect(lastLine(c.stdout)).toMatch(/^gstack: fail launch-file — could not write the launch file .*\(LAUNCH_FILE_UNWRITABLE\)$/);
expect(fs.readdirSync(path.dirname(launchFile)).filter(n => n.includes('.tmp.'))).toEqual([path.basename(sibling)]);
expect(fs.existsSync(launchFile)).toBe(false);
expect(JSON.parse(fs.readFileSync(launchFile.replace(/\.env$/, '.json'), 'utf8'))).toMatchObject({ ok: false });
});
});
@@ -754,8 +870,23 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true });
const state = path.join(f.base, "it's state");
realBun(path.join(state, `runtimes/bun/${BUN_DEFAULT}/bun`), BUN_DEFAULT);
expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0);
const installed = run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state, GSTACK_EPHEMERAL: '0' }, f.base);
expect(installed.status, installed.stdout + installed.stderr).toBe(0);
expect(sh(`sh -n "${f.home}/.local/bin/bun"`).status).toBe(0);
// /ship Step 7 value card. protects: the launch file single-quotes every path it bakes in, so a
// state root, launch directory and verdict path holding a quote and a space source under POSIX
// sh (twice: the PATH prepend stays idempotent) and come back byte-identical, and the file
// carries the caller's GSTACK_EPHEMERAL rather than a hard-coded 1. fails_when: _sq is replaced
// by plain interpolation or double quotes, the case pattern loses its quoting, or GSTACK_EPHEMERAL
// is hard-coded. why_existing_misses: the sourced launch files above live under plain tmp paths
// with the default GSTACK_EPHEMERAL. seam=none.
const launchFile = installed.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? '';
expect(launchFile.startsWith(`${state}/install-verdicts/`), launchFile).toBe(true);
expect(installed.stdout).toContain(`GSTACK_LAUNCH_PATH: ${state}/runtimes/bun/${BUN_DEFAULT}`);
const sourced = spawnSync('sh', ['-c', '. "$1" && . "$1" && printf "%s\\n" "$PATH" "$GSTACK_STATE_ROOT" "$GSTACK_EPHEMERAL" "$GSTACK_INSTALL_VERDICT"', '_', launchFile],
{ encoding: 'utf8', timeout: 30_000, env: { PATH: '/usr/bin:/bin' } });
expect(sourced.status, sourced.stderr).toBe(0);
expect(sourced.stdout.trimEnd().split('\n')).toEqual([`${state}/runtimes/bun/${BUN_DEFAULT}:/usr/bin:/bin`, state, '0', launchFile.replace(/\.env$/, '.json')]);
const r = fresh(f, f.base, 'bun --version && bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout.trim().split('\n')[0]).toBe(BUN_DEFAULT);
+72
View File
@@ -441,6 +441,39 @@ describe('ready reads all of CI at the head (review #3235 P1-5)', () => {
expect(line(pending.r.out, 'ci: ')).toBe(`ci: pending at ${pending.at} (passed=1 failed=0 pending=1 skipped=0)`);
}, 120_000);
test('a superseded run of a check is judged by its latest run, as GitHub\'s rollup counts (gbrain: a cancelled contributor-gate beside later passes)', () => {
const older = { id: 1, name: 'contributor-gate', status: 'completed', conclusion: 'cancelled', completed_at: '2026-10-26T01:00:00Z' };
const newer = { id: 2, name: 'contributor-gate', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:10:00Z' };
const other = { id: 3, name: 'free-tests', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:05:00Z' };
const ok = readyWith({ check_runs: [older, newer, other] });
expect(ok.r.status).toBe(EXIT.ok);
expect(line(ok.r.out, 'ci: ')).toBe(`ci: green at ${ok.at} (passed=2 failed=0 pending=0 skipped=0)`);
const reversed = readyWith({ check_runs: [newer, older, other] });
expect(line(reversed.r.out, 'ci: ')).toBe(`ci: green at ${reversed.at} (passed=2 failed=0 pending=0 skipped=0)`);
const latestFailed = readyWith({ check_runs: [{ ...older, conclusion: 'success' }, { ...newer, conclusion: 'failure' }, other] });
expect(latestFailed.r.status).toBe(EXIT.refused);
expect(line(latestFailed.r.out, 'ci: ')).toBe(`ci: red at ${latestFailed.at} (passed=1 failed=1 pending=0 skipped=0)`);
const rerunning = readyWith({ check_runs: [{ ...older, conclusion: 'failure' }, { id: 4, name: 'contributor-gate', status: 'in_progress' }, other] });
expect(line(rerunning.r.out, 'ci: ')).toBe(`ci: pending at ${rerunning.at} (passed=1 failed=0 pending=1 skipped=0)`);
const sameTime = readyWith({ check_runs: [{ ...older, id: 9, completed_at: newer.completed_at }, newer, other] });
expect(line(sameTime.r.out, 'ci: ')).toBe(`ci: red at ${sameTime.at} (passed=1 failed=1 pending=0 skipped=0)`);
const unnamed = readyWith({ check_runs: [{ status: 'completed', conclusion: 'cancelled' }, { status: 'completed', conclusion: 'success' }] });
expect(line(unnamed.r.out, 'ci: ')).toBe(`ci: red at ${unnamed.at} (passed=1 failed=1 pending=0 skipped=0)`);
const twoApps = readyWith({ check_runs: [{ ...older, conclusion: 'failure', app: { id: 1 } }, { ...newer, app: { id: 2 } }, other] });
expect(line(twoApps.r.out, 'ci: ')).toBe(`ci: red at ${twoApps.at} (passed=2 failed=1 pending=0 skipped=0)`);
const undated = readyWith({ check_runs: [{ id: 5, name: 'contributor-gate', status: 'completed', conclusion: 'failure' }, newer, other] });
expect(line(undated.r.out, 'ci: ')).toBe(`ci: red at ${undated.at} (passed=2 failed=1 pending=0 skipped=0)`);
const queued = readyWith({ check_runs: [{ ...older, conclusion: 'failure' }, { id: 6, name: 'contributor-gate', status: 'queued' }, other] });
expect(line(queued.r.out, 'ci: ')).toBe(`ci: pending at ${queued.at} (passed=1 failed=0 pending=1 skipped=0)`);
const nullConclusion = readyWith({ check_runs: [{ ...newer, conclusion: null }, other] });
expect(line(nullConclusion.r.out, 'ci: ')).toBe(`ci: red at ${nullConclusion.at} (passed=1 failed=1 pending=0 skipped=0)`);
}, 240_000);
test('a commit-status context is never collapsed with a check run of the same name', () => {
const r = readyWith({ check_runs: [{ id: 1, name: 'deploy', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:00:00Z' }] }, { state: 'failure', statuses: [{ context: 'deploy', state: 'failure' }] });
expect(line(r.r.out, 'ci: ')).toBe(`ci: red at ${r.at} (passed=1 failed=1 pending=0 skipped=0)`);
}, 120_000);
test('incomplete pagination is unknown, never green', () => {
const { r, at } = readyWith({ total_count: 101, check_runs: hundred });
expect(r.status).toBe(EXIT.refused);
@@ -448,6 +481,45 @@ describe('ready reads all of CI at the head (review #3235 P1-5)', () => {
}, 120_000);
});
describe('ready on a PR that conflicts with the base (CHANGELOG-only conflicts, rule 14)', () => {
const pass = { id: 1, name: 'Free Tests', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:00:00Z' };
function readyWith(mergeable: string, checks: unknown, pr: Record<string, unknown> = {}) {
const repo = fixture();
const head = makePr(repo, 'pr-a');
const tree = runBin(repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: shippedBody('1', head), mergeable, headRefName: 'pr-a', ...pr } }, checks: { [head]: checks } });
return { r: queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh), at: head.slice(0, 12) };
}
test('no CI at the head and a conflict: the refusal names the workflow_dispatch procedure', () => {
const { r, at } = readyWith('CONFLICTING', { check_runs: [] });
expect(r.status).toBe(EXIT.refused);
expect(line(r.out, 'ci: ')).toBe(`ci: none at ${at} (passed=0 failed=0 pending=0 skipped=0)`);
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-free-tests.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`);
for (const name of ['x;$(curl evil)', '--field']) {
const odd = readyWith('CONFLICTING', { check_runs: [] }, { headRefName: name });
expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref <branch> && gh workflow run windows-free-tests.yml --ref <branch>');
}
const fork = readyWith('CONFLICTING', { check_runs: [] }, { isCrossRepository: true });
expect(line(fork.r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${fork.at}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`);
}, 240_000);
test('dispatched runs green at the head and a conflict: READY, with the hint saying the dispatched runs count', () => {
const { r, at } = readyWith('CONFLICTING', { check_runs: [pass] });
expect(r.status).toBe(EXIT.ok);
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref); the runs at ${at} were not pull_request runs (dispatched on the branch, or push-triggered), and they count (advisory)`);
}, 120_000);
test('no hint without a conflict, and none while the dispatched runs are red or pending', () => {
expect(readyWith('MERGEABLE', { check_runs: [] }).r.out).not.toContain('ci-hint:');
expect(readyWith('MERGEABLE', { check_runs: [pass] }).r.out).not.toContain('ci-hint:');
expect(readyWith('CONFLICTING', { check_runs: [{ ...pass, conclusion: 'failure' }] }).r.out).not.toContain('ci-hint:');
expect(readyWith('CONFLICTING', { check_runs: [{ ...pass, status: 'in_progress' }] }).r.out).not.toContain('ci-hint:');
}, 240_000);
});
describe('printed gate-ahead command (review #3235 P2-1)', () => {
test("next: runs as printed from a checkout whose path holds a space and an apostrophe", () => {
const { repo } = twoLanes({ ...BASE_POLICY, gate_command: 'node -e "process.exit(0)"', min_gstack_version: SHIP_QUEUE_KEYS_VERSION }, "gstack sq o'fx-");
+1
View File
@@ -142,6 +142,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/investigate-owned-question-39195396438.json': ['test/workflow-boundaries-fixture.test.ts'],
'test/fixtures/judge-calibration/**': ['scripts/judge-calibration.ts', 'test/judge-calibration.test.ts'],
'test/fixtures/judge-panel-replay-2026-10-05.json': ['test/judge-panel-median.test.ts'],
'test/fixtures/launch-env-echo/**': ['test/launch-env-echo-ratchet.test.ts'],
'test/fixtures/merge-train-3345/**': ['test/changelog-merge-damage.test.ts', 'test/compare-pr-version.test.ts'],
'test/fixtures/module-size-ratchet.json': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'],
'test/fixtures/module-size/**': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'],
+198
View File
@@ -0,0 +1,198 @@
/**
* Ratchet: no skill fence and no shell helper prints the process environment.
*
* GSTA-96 (2026-10-26): during an unattended /ship, the Step 14.5 docs-audit
* child re-typed the GSTACK_LAUNCH assignments as `export $(...)`. The
* substitution came back empty, so the shell ran a bare `export`, which prints
* every exported variable, and three API key values landed in its tool output.
* The launch environment is applied with `. <GSTACK_LAUNCH_FILE>` (the
* installer's sourceable file); nothing in a fence may dump the environment.
*
* Rules, per command segment (split on `;`, `&&`, `||` and `|`):
* export-subst `export` whose first argument is a substitution or an unquoted expansion
* (`export $(...)`, `export "$(...)"`, `export \`...\``, `export $VAR`), each of
* which can splat a whole NAME=value file; `export "$name"` is one word, so
* like `export NAME=value` and `export NAME` it stays allowed
* env `env` or `/usr/bin/env` with no arguments or only flags, piped or
* not (`env FOO=1 cmd` launches, fine)
* printenv `printenv`, with or without a name (`echo "$NAME"` is explicit)
* export bare `export`, `export -p`, `declare -x`, `typeset -x`, `declare -p`, `typeset -p`
* set bare `set` as a segment, redirections included; `set | grep '^NAME_'`
* prints only the variables the anchored name filter admits and stays allowed
* Fenced blocks in Markdown (generated SKILL.md, sections, DRIVER.md, the Capy
* skill) are scanned line by line; a shell helper under bin/ is scanned whole.
* Comments are skipped. The negative-control fixtures prove the scanner fires.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { CAPY_LAUNCH_MARKERS } from '../scripts/lib/shard-engine';
const ROOT = path.resolve(import.meta.dir, '..');
export type Violation = { file: string; line: number; rule: string; text: string };
const EXPORT_SUBST = /\bexport\s+(?:"?\$\(|"?`|\$\{?[A-Za-z_])/;
const SEGMENTS = /\s*(?:;|&&|\|\||\|)\s*/g;
const BARE = {
env: /^(\/usr\/bin\/)?env(\s+-\S+)*$/,
printenv: /^printenv(\s|$)/,
export: /^(export|export -p|declare -x|typeset -x|declare -p|typeset -p)$/,
set: /^set(\s+[0-9&]*>\S*)*$/,
} as const;
/** The one allowed `set` shape: piped straight into an anchored name filter. */
const SET_FILTER = /^grep\s+(?:-\S+\s+)*["']\^/;
/** Blank single- and double-quoted strings, keeping every offset, so a quoted word is never read as a command. */
function unquoted(line: string): string {
return line.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, q => q[0] + ' '.repeat(q.length - 2) + q[0]);
}
export function rulesFor(line: string): string[] {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) return [];
const rules: string[] = [];
const blanked = unquoted(trimmed);
const comment = blanked.search(/\s+#/);
const raw = comment >= 0 ? trimmed.slice(0, comment) : trimmed;
const bare = comment >= 0 ? blanked.slice(0, comment) : blanked;
const subst = EXPORT_SUBST.exec(raw);
if (subst && bare.startsWith('export', subst.index)) rules.push('export-subst');
const segments: Array<[number, number]> = [];
let start = 0;
for (const sep of bare.matchAll(SEGMENTS)) { segments.push([start, sep.index]); start = sep.index + sep[0].length; }
segments.push([start, bare.length]);
segments.forEach(([from, to], i) => {
const cmd = bare.slice(from, to).replace(/^\$\s+/, '').trim();
if (BARE.env.test(cmd)) rules.push('env');
if (BARE.printenv.test(cmd)) rules.push('printenv');
if (BARE.export.test(cmd)) rules.push('export');
const next = segments[i + 1];
if (BARE.set.test(cmd) && !(next && SET_FILTER.test(raw.slice(next[0], next[1]).trim()))) rules.push('set');
});
return rules;
}
/** Lines inside any ``` fence (template-literal fences included). */
function fencedLines(text: string): Array<[number, string]> {
const out: Array<[number, string]> = [];
let inFence = false;
text.split('\n').forEach((line, i) => {
if (/^\s*(?:\\`|`){3}/.test(line)) { inFence = !inFence; return; }
if (inFence) out.push([i + 1, line]);
});
return out;
}
export function scanText(file: string, text: string, mode: 'fenced' | 'whole'): Violation[] {
const lines = mode === 'fenced' ? fencedLines(text) : text.split('\n').map((l, i) => [i + 1, l] as [number, string]);
const out: Violation[] = [];
for (const [line, text] of lines) for (const rule of rulesFor(text)) out.push({ file, line, rule, text: text.trim() });
return out;
}
const isShell = (file: string, text: string) => file.endsWith('.sh') || /^#!.*\b(bash|sh)\b/.test(text.split('\n')[0] ?? '');
function listFiles(dir: string, keep: (rel: string) => boolean, out: string[] = []): string[] {
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
const rel = path.join(dir, entry.name);
if (entry.isDirectory()) { if (!/^(node_modules|\.git|test|dist|hosts)$/.test(entry.name)) listFiles(rel, keep, out); }
else if (keep(rel)) out.push(rel);
}
return out;
}
/** Generated SKILL.md and section files, their templates, DRIVER.md and the Capy skill. */
function markdownTargets(): string[] {
return listFiles('.', rel =>
/^[^/]+\/SKILL\.md(\.tmpl)?$/.test(rel) ||
/^[^/]+\/sections\/[^/]+\.md(\.tmpl)?$/.test(rel) ||
/^autoplan\/DRIVER\.md(\.tmpl)?$/.test(rel) ||
/^capy\/skill\/gstack\/[^/]+\.md$/.test(rel));
}
/** Every shell helper under bin/, the setup script and the scripts/*.sh build and doctor scripts. */
function shellTargets(): string[] {
const candidates = [...fs.readdirSync(path.join(ROOT, 'bin')).map(name => path.join('bin', name)), 'setup',
...fs.readdirSync(path.join(ROOT, 'scripts')).filter(name => name.endsWith('.sh')).map(name => path.join('scripts', name))];
return candidates.filter(rel => {
const full = path.join(ROOT, rel);
return fs.statSync(full).isFile() && isShell(rel, fs.readFileSync(full, 'utf8'));
});
}
describe('launch-env echo ratchet', () => {
test('the negative-control Markdown fixture trips every rule and only on the banned lines', () => {
const file = 'test/fixtures/launch-env-echo/negative-control.md';
const found = scanText(file, fs.readFileSync(path.join(ROOT, file), 'utf8'), 'fenced');
expect(found.map(v => [v.text, v.rule])).toEqual([
["export $(sed -n 's/^GSTACK_LAUNCH: //p' install.log)", 'export-subst'],
['export "$(cat launch.env)"', 'export-subst'],
['env', 'env'],
['env | grep -i key', 'env'],
['printenv', 'printenv'],
['printenv ANTHROPIC_API_KEY', 'printenv'],
['set', 'set'],
['export', 'export'],
['export -p', 'export'],
['declare -x', 'export'],
['typeset -x', 'export'],
['declare -p', 'export'],
['cd /repo && env', 'env'],
['typeset -p', 'export'],
['export `cat launch.env`', 'export-subst'],
['export $LAUNCH', 'export-subst'],
['export ${LAUNCH}', 'export-subst'],
['cd /repo && set', 'set'],
['set 2>&1 | tee out.txt', 'set'],
['env -0', 'env'],
['/usr/bin/env', 'env'],
['echo "a #b" && env', 'env'],
]);
const allowedStart = fs.readFileSync(path.join(ROOT, file), 'utf8').split('\n').findIndex(l => l === 'Allowed:') + 1;
expect(found.every(v => v.line < allowedStart)).toBe(true);
});
test('the negative-control shell fixture is scanned whole, comments and quoted words excluded', () => {
const file = 'test/fixtures/launch-env-echo/negative-control.sh';
const found = scanText(file, fs.readFileSync(path.join(ROOT, file), 'utf8'), 'whole');
expect(found.map(v => [v.line, v.rule])).toEqual([[5, 'export-subst'], [6, 'env'], [7, 'printenv'], [8, 'set'], [9, 'export']]);
});
test('the scan covers the skill surfaces and the shell helpers', () => {
const md = markdownTargets();
for (const want of ['ship/SKILL.md', 'ship/sections/documentation.md', 'review/SKILL.md', 'qa/SKILL.md',
'document-release/SKILL.md', 'autoplan/DRIVER.md', 'capy/skill/gstack/SKILL.md']) expect(md).toContain(want);
const sh = shellTargets();
for (const want of ['bin/gstack-capy-install', 'bin/gstack-capy-session', 'bin/gstack-skill-start', 'setup', 'scripts/build.sh', 'scripts/sandbox-doctor.sh']) expect(sh).toContain(want);
});
test('no skill fence prints the environment', () => {
const violations = markdownTargets().flatMap(file => scanText(file, fs.readFileSync(path.join(ROOT, file), 'utf8'), 'fenced'));
expect(violations.map(v => `${v.file}:${v.line} [${v.rule}] ${v.text}`)).toEqual([]);
});
test('no shell helper under bin/, setup or install script prints the environment', () => {
const violations = shellTargets().flatMap(file => scanText(file, fs.readFileSync(path.join(ROOT, file), 'utf8'), 'whole'));
expect(violations.map(v => `${v.file}:${v.line} [${v.rule}] ${v.text}`)).toEqual([]);
});
test('the installer writes the launch file the skills tell callers to source, mode 0600, and prints its path', () => {
const installer = fs.readFileSync(path.join(ROOT, 'bin/gstack-capy-install'), 'utf8');
expect(installer).toContain('LAUNCH_FILE="${VERDICT%.json}.env"');
expect(installer).toContain('chmod 0600 "$tmp"');
for (const name of CAPY_LAUNCH_MARKERS) expect(installer, name).toMatch(new RegExp(`^\\s*echo "export ${name}=`, 'm'));
// Both directions: every `export NAME=` line the launch file writes is either a marker the shard
// sandbox and the test preload strip, or GSTACK_STATE_ROOT, which they keep on purpose (GSTA-99).
// The PATH prepend is a separate `case` line this regex ignores; PATH is kept too.
const exported = [...installer.matchAll(/^\s*echo "export ([A-Z_]+)=/gm)].map(m => m[1]).sort();
expect(exported).toEqual([...CAPY_LAUNCH_MARKERS, 'GSTACK_STATE_ROOT'].sort());
expect(installer).toContain('echo "GSTACK_LAUNCH_FILE: $LAUNCH_FILE"');
for (const file of ['ship/sections/documentation.md', 'document-release/SKILL.md', 'autoplan/DRIVER.md', 'capy/skill/gstack/SKILL.md']) {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8');
expect(text, file).toContain('GSTACK_LAUNCH_FILE');
expect(text, file).toMatch(/never[^\n]*`export \$\(\.\.\.\)`/);
}
expect(fs.readFileSync(path.join(ROOT, 'bin/gstack-skill-start'), 'utf8')).toContain('GSTACK_LAUNCH_FILE the installer printed');
});
});
+308
View File
@@ -0,0 +1,308 @@
/**
* pregate version-stamp-only (lib/pregate/stamp-only.ts, lib/pregate/lane-selection.ts):
* a release manifest whose diff against the merge base changes only its
* version values needs no test lane, so a restamp PR on a base whose
* .gstack/pregate.json declares no lane for its manifests no longer fails
* `lanes` with PREGATE_NO_LANE (gbrain #7625, #7606). A manifest whose other
* content changed, one added, deleted or renamed, or one a test reads, keeps
* its lane or its failure.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { stampFileKind, VERSION_TOKEN, versionStampOnly } from '../lib/pregate/stamp-only';
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const pregate = (repo: FixtureRepo, args: string[] = []) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...args]);
const lanes = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: any) => c.id === 'lanes');
const WAIVER = 'lanes: version-stamp-only (no lane needed): ';
const manifest = (version: string, extra: Record<string, unknown> = {}) => JSON.stringify({ name: 'gbrain', version, description: 'Personal knowledge brain', author: { name: 'Garry Tan' }, ...extra }, null, 2) + '\n';
const j = (v: unknown) => JSON.stringify(v, null, 2) + '\n';
describe('versionStampOnly (pure)', () => {
test('VERSION_TOKEN accepts bare versions and refuses ranges, words and dates', () => {
for (const ok of ['1.2', '1.2.3', 'v1.2.3', '0.60.462.0', '1.2.3-rc.1', '1.2.3+build.7']) expect(VERSION_TOKEN.test(ok)).toBe(true);
for (const no of ['^1.2.3', '~1.2.3', '>=1.2.3', '1', '1.2.3.4.5', 'latest', '1.2.3 ', 'v', '2026-10-26']) expect(VERSION_TOKEN.test(no)).toBe(false);
});
test('file kinds: JSON, plist and VERSION files (VERSION, VERSION.txt, version.txt, any case); nothing else', () => {
expect(stampFileKind('.claude-plugin/plugin.json')).toBe('json');
expect(stampFileKind('openclaw.plugin.json')).toBe('json');
expect(stampFileKind('ios/App/Info.plist')).toBe('plist');
expect(stampFileKind('VERSION')).toBe('version');
expect(stampFileKind('packages/core/VERSION.txt')).toBe('version');
expect(stampFileKind('version.txt')).toBe('version');
expect(stampFileKind('sub\\dir\\Version')).toBe('version');
for (const other of ['package-lock.json.bak', 'src/version.ts', 'Cargo.toml', 'pyproject.toml', 'CHANGELOG.md', 'llms-full.txt', 'versions', 'VERSIONS']) expect(stampFileKind(other)).toBeNull();
});
test('a JSON manifest whose only change is its top-level version value is stamp-only, naming the change; nothing nested qualifies', () => {
expect(versionStampOnly('.claude-plugin/plugin.json', manifest('0.60.462.0'), manifest('0.60.463.0'))).toEqual({ stampOnly: true, changed: ['version 0.60.462.0 → 0.60.463.0'] });
expect(versionStampOnly('x.json', '{"version":"v1.2.3","b":[1,2]}\n', '{"version":"v1.2.4","b":[1,2]}\n')).toEqual({ stampOnly: true, changed: ['version v1.2.3 → v1.2.4'] });
expect(versionStampOnly('x.json', j({ plugin: { version: 'v1.2.3' } }), j({ plugin: { version: 'v1.2.4' } }))).toEqual({ stampOnly: false, reason: '.plugin.version changed and is not the top-level `version`' });
expect(versionStampOnly('x.json', j({ version: '1.0.0', build: { version: '1.0.0' } }), j({ version: '1.0.1', build: { version: '1.0.1' } }))).toEqual({ stampOnly: false, reason: '.build.version changed and is not the top-level `version`' });
expect(versionStampOnly('x.json', j([{ version: '1.0.0' }]), j([{ version: '1.0.1' }]))).toEqual({ stampOnly: false, reason: 'not a JSON object on both sides' });
expect(versionStampOnly('x.json', '"1.0.0"', '"1.0.1"')).toEqual({ stampOnly: false, reason: 'not a JSON object on both sides' });
});
test('a JSON manifest whose non-version content changed is not stamp-only, with the path in the reason', () => {
expect(versionStampOnly('p.json', manifest('1.0.0'), manifest('1.0.0', { description: 'Personal knowledge brain, now with RLS' }))).toEqual({ stampOnly: false, reason: '.description changed and its key is not `version`' });
const mixed = versionStampOnly('p.json', manifest('1.0.0'), manifest('1.0.1', { description: 'changed too' }));
expect(mixed).toEqual({ stampOnly: false, reason: '.description changed and its key is not `version`' });
expect(versionStampOnly('p.json', manifest('1.0.0'), manifest('1.0.1', { keywords: ['a'] }))).toEqual({ stampOnly: false, reason: '.keywords added' });
expect(versionStampOnly('p.json', manifest('1.0.0', { keywords: ['a'] }), manifest('1.0.1'))).toEqual({ stampOnly: false, reason: '.keywords removed' });
expect(versionStampOnly('p.json', j({ version: '1.0.0', files: ['a', 'b'] }), j({ version: '1.0.1', files: ['b', 'a'] }))).toEqual({ stampOnly: false, reason: '.files[0] changed and its key is not `version`' });
expect(versionStampOnly('p.json', j({ version: '1.0.0', files: ['a'] }), j({ version: '1.0.1', files: ['a', 'b'] }))).toEqual({ stampOnly: false, reason: '.files changed length (1 → 2)' });
expect(versionStampOnly('p.json', j({ version: '1.0.0', count: 1 }), j({ version: '1.0.1', count: 2 }))).toEqual({ stampOnly: false, reason: '.count changed and is not a version string' });
expect(versionStampOnly('p.json', j({ version: '1.0.0', count: 1 }), j({ version: '1.0.1', count: '1' }))).toEqual({ stampOnly: false, reason: '.count changed type (number → string)' });
expect(versionStampOnly('p.json', j({ version: ['1.0.0'] }), j({ version: ['1.0.1'] }))).toEqual({ stampOnly: false, reason: '.version[0] changed and its key is not `version`' });
});
test('a reordered object is not a stamp even with a version bump (conditional exports resolve in key order)', () => {
const before = j({ version: '1.0.0', exports: { '.': { import: './a.mjs', default: './a.cjs' } } });
const after = j({ version: '1.0.1', exports: { '.': { default: './a.cjs', import: './a.mjs' } } });
expect(versionStampOnly('package.json', before, after)).toEqual({ stampOnly: false, reason: '.exports.. keys reordered (import → default at position 0)' });
expect(versionStampOnly('package.json', j({ name: 'x', version: '1.0.0' }), j({ version: '1.0.1', name: 'x' }))).toEqual({ stampOnly: false, reason: '$ keys reordered (name → version at position 0)' });
});
test('a key with a prototype name added, removed or changed is seen (own keys only)', () => {
for (const k of ['__proto__', 'constructor', 'toString', 'hasOwnProperty', 'valueOf']) {
expect(versionStampOnly('p.json', '{"version":"1.0.0"}', `{"version":"1.0.1",${JSON.stringify(k)}:"evil"}`)).toEqual({ stampOnly: false, reason: `.${k} added` });
expect(versionStampOnly('p.json', `{"version":"1.0.0",${JSON.stringify(k)}:"a"}`, '{"version":"1.0.1"}')).toEqual({ stampOnly: false, reason: `.${k} removed` });
expect(versionStampOnly('p.json', `{"version":"1.0.0",${JSON.stringify(k)}:"a"}`, `{"version":"1.0.1",${JSON.stringify(k)}:"b"}`)).toEqual({ stampOnly: false, reason: `.${k} changed and its key is not \`version\`` });
}
expect(versionStampOnly('p.json', '{"version":"1.0.0","__proto__":{"x":1}}', '{"version":"1.0.1","__proto__":{"x":1}}').stampOnly).toBe(true);
});
test('only a key named exactly version qualifies: floors, schema versions, package names and dependency maps are not stamps', () => {
expect(versionStampOnly('p.json', j({ version: '1.0.0', minHostVersion: '1.0.0' }), j({ version: '1.0.1', minHostVersion: '9.0.0' }))).toEqual({ stampOnly: false, reason: '.minHostVersion changed and its key is not `version`' });
for (const k of ['schemaVersion', 'apiVersion', 'versionName', 'Version', 'VERSION', 'minimumVersion']) expect(versionStampOnly('p.json', j({ [k]: '1.0.0' }), j({ [k]: '1.0.1' })).stampOnly).toBe(false);
expect(versionStampOnly('package.json', j({ version: '1.0.0', devDependencies: { 'standard-version': '9.5.0' } }), j({ version: '1.0.1', devDependencies: { 'standard-version': '9.5.1' } }))).toEqual({ stampOnly: false, reason: '.devDependencies.standard-version changed and its key is not `version`' });
expect(versionStampOnly('package.json', j({ version: '1.0.0', dependencies: { version: '1.2.3' } }), j({ version: '1.0.1', dependencies: { version: '1.2.4' } }))).toEqual({ stampOnly: false, reason: '.dependencies.version changed and is not the top-level `version`' });
expect(versionStampOnly('package-lock.json', j({ version: '1.0.0', packages: { 'node_modules/lodash': { version: '4.17.20' } } }), j({ version: '1.0.0', packages: { 'node_modules/lodash': { version: '4.17.21' } } }))).toEqual({ stampOnly: false, reason: '.packages.node_modules/lodash.version changed and is not the top-level `version`' });
expect(versionStampOnly('composer.lock', j({ packages: [{ version: 'v1.0.0' }] }), j({ packages: [{ version: 'v1.0.1' }] })).stampOnly).toBe(false);
expect(versionStampOnly('package.json', j({ version: '1.0.0', catalog: { version: '1.2.3' } }), j({ version: '1.0.1', catalog: { version: '1.2.4' } })).stampOnly).toBe(false);
expect(versionStampOnly('.devcontainer/devcontainer.json', j({ features: { 'ghcr.io/x/node:1': { version: '20.1.0' } } }), j({ features: { 'ghcr.io/x/node:1': { version: '22.1.0' } } })).stampOnly).toBe(false);
expect(versionStampOnly('package.json', j({ version: '1.0.0', engines: { node: '20.0.0' } }), j({ version: '1.0.0', engines: { node: '22.0.0' } })).stampOnly).toBe(false);
expect(versionStampOnly('package.json', j({ version: '1.0.0', dependencies: { left: '^1.2.3' } }), j({ version: '1.0.1', dependencies: { left: '^1.2.4' } })).stampOnly).toBe(false);
expect(versionStampOnly('p.json', j({ version: '1.0.0' }), j({ version: 'latest' }))).toEqual({ stampOnly: false, reason: '.version changed and a value is not a bare version ("1.0.0" → "latest")' });
expect(versionStampOnly('p.json', j({ version: '1.0.0' }), j({ version: '^1.0.1' })).stampOnly).toBe(false);
});
test('a change the parser erases is still a change: big integers, duplicate keys, number spellings, a reflow with the stamp', () => {
expect(versionStampOnly('p.json', '{"version":"1.0.0","id":9007199254740993}', '{"version":"1.0.1","id":9007199254740992}')).toEqual({ stampOnly: false, reason: 'line 1 changed beyond a version value' });
expect(versionStampOnly('p.json', '{"version":"1.0.0","a":1,"a":2}', '{"version":"1.0.1","a":2}').stampOnly).toBe(false);
expect(versionStampOnly('p.json', '{"version":"1.0.0","n":1.0}', '{"version":"1.0.1","n":1}').stampOnly).toBe(false);
expect(versionStampOnly('p.json', '{"version":"1.0.0","s":"\\u0041"}', '{"version":"1.0.1","s":"A"}').stampOnly).toBe(false);
expect(versionStampOnly('p.json', manifest('1.0.0'), JSON.stringify(JSON.parse(manifest('1.0.1'))))).toEqual({ stampOnly: false, reason: 'line count changed (9 → 1)' });
expect(versionStampOnly('p.json', '{"version":"1.0.0",\n"name":"x"}', '{"version":"1.0.1",\n"name": "x"}')).toEqual({ stampOnly: false, reason: 'line 2 changed beyond a version value' });
expect(versionStampOnly('p.json', '{"version":"1.0.0","name":"1.0.0"}', '{"version":"1.0.1","name":"1.0.0"}').stampOnly).toBe(true);
// A shadowed duplicate key holding the old version on its own line: the parser sees one change, the text has two changed lines.
expect(versionStampOnly('m.json', '{\n "description": "1.0.0",\n "description": "real",\n "version": "1.0.0"\n}\n', '{\n "description": "1.0.1",\n "description": "real",\n "version": "1.0.1"\n}\n')).toEqual({ stampOnly: false, reason: 'line 4 changed beyond a version value' });
expect(versionStampOnly('m.json', '{\n "x": "1.0.0",\n "x": "keep",\n "version": "1.0.0"\n}\n', '{\n "x": "2.0.0",\n "x": "keep",\n "version": "2.0.0"\n}\n').stampOnly).toBe(false);
const deep = (v: string) => `{"version":"${v}","a":${'['.repeat(200_000)}1${']'.repeat(200_000)}}`;
expect(versionStampOnly('deep.json', deep('1.0.0'), deep('1.0.1'))).toEqual({ stampOnly: false, reason: 'could not compare (nested too deep)' });
});
test('an absent, unchanged, reformatted or unparseable manifest is not stamp-only, and a non-manifest file never is', () => {
expect(versionStampOnly('p.json', null, manifest('1.0.0'))).toEqual({ stampOnly: false, reason: 'absent or unreadable at the merge base' });
expect(versionStampOnly('p.json', manifest('1.0.0'), null)).toEqual({ stampOnly: false, reason: 'absent or unreadable in the working tree' });
expect(versionStampOnly('p.json', manifest('1.0.0'), manifest('1.0.0'))).toEqual({ stampOnly: false, reason: 'unchanged' });
expect(versionStampOnly('p.json', manifest('1.0.0'), JSON.stringify(JSON.parse(manifest('1.0.0'))))).toEqual({ stampOnly: false, reason: 'no value changed (whitespace or spelling only)' });
expect(versionStampOnly('p.json', manifest('1.0.0'), '{ version: 1.0.1 }').stampOnly).toBe(false);
expect(versionStampOnly('src/version.ts', "export const VERSION = '1.0.0';", "export const VERSION = '1.0.1';")).toEqual({ stampOnly: false, reason: 'not a JSON, plist or VERSION file' });
});
test('a VERSION file is stamp-only when both sides are one bare version with at most a trailing LF; a line-ending change is content', () => {
expect(versionStampOnly('VERSION', '0.60.462.0\n', '0.60.463.0\n')).toEqual({ stampOnly: true, changed: ['0.60.462.0 → 0.60.463.0'] });
expect(versionStampOnly('VERSION', '0.60.462.0', '0.60.463.0')).toEqual({ stampOnly: true, changed: ['0.60.462.0 → 0.60.463.0'] });
expect(versionStampOnly('VERSION', '0.60.462.0\n', '0.60.463.0\r\n')).toEqual({ stampOnly: false, reason: 'content is not one bare version on both sides' });
expect(versionStampOnly('p.json', '{\r\n"version": "1.0.0"\r\n}', '{\n"version": "1.0.1"\n}')).toEqual({ stampOnly: false, reason: 'line 1 changed beyond a version value' });
expect(versionStampOnly('VERSION', '0.60.462.0\n', '0.60.463.0\nnotes\n').stampOnly).toBe(false);
expect(versionStampOnly('VERSION', 'unreleased\n', '1.0.0\n').stampOnly).toBe(false);
expect(versionStampOnly('VERSION', '1.0.0', '1.0.0\n')).toEqual({ stampOnly: false, reason: 'no version changed (trailing newline only)' });
expect(versionStampOnly('VERSION', '1.0.0\n', ' 1.0.1\n')).toEqual({ stampOnly: false, reason: 'content is not one bare version on both sides' });
expect(versionStampOnly('VERSION', '1.0.0\n', '\u00a01.0.0\u2028').stampOnly).toBe(false);
expect(versionStampOnly('VERSION', '1.0.0\n', '\ufeff1.0.1\n').stampOnly).toBe(false);
});
test('a plist is stamp-only when only the <string> under CFBundleShortVersionString or CFBundleVersion changed', () => {
const plist = (short: string, build: string, name = 'App', minOS = '15.0') => `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>${name}</string>
<key>CFBundleShortVersionString</key>
<string>${short}</string>
<key>CFBundleVersion</key>
<string>${build}</string>
<key>MinimumOSVersion</key>
<string>${minOS}</string>
</dict>
</plist>
`;
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45'), plist('1.2.4', '1.2.4.46'))).toEqual({ stampOnly: true, changed: ['CFBundleShortVersionString 1.2.3 → 1.2.4', 'CFBundleVersion 1.2.3.45 → 1.2.4.46'] });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '45'), plist('1.2.4', '46'))).toEqual({ stampOnly: false, reason: 'line 9 changed and a value is not a bare version' });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45'), plist('1.2.4', '1.2.4.46', 'Other'))).toEqual({ stampOnly: false, reason: 'line 5 changed and a value is not a bare version' });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45', '1.0.0'), plist('1.2.3', '1.2.3.45', '2.0.0'))).toEqual({ stampOnly: false, reason: 'line 5 changed under <key>CFBundleName</key>, not a bundle version key' });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45'), plist('1.2.3', '1.2.3.45', 'App', '17.0'))).toEqual({ stampOnly: false, reason: 'line 11 changed under <key>MinimumOSVersion</key>, not a bundle version key' });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45'), plist('1.2.4', '1.2.4.46').replace('<key>CFBundleVersion</key>', '<key>CFBundleVersions</key>'))).toEqual({ stampOnly: false, reason: 'line 8 changed and is not a <string> value' });
expect(versionStampOnly('ios/Info.plist', plist('1.2.3', '1.2.3.45'), plist('1.2.4', '1.2.4.46') + '<!-- -->\n').stampOnly).toBe(false);
expect(versionStampOnly('v.plist', '<string>1.0.0</string>\n', '<string>1.0.1</string>\n')).toEqual({ stampOnly: false, reason: 'line 1 changed under no <key>, not a bundle version key' });
});
});
describe('gstack-pregate lanes and version-stamp-only manifests', () => {
// The fixture's base pregate.json declares no lane for these manifests, as gbrain's master does not; every PR is judged by the base's policy.
const base = { '.claude-plugin/plugin.json': manifest('1.0.0'), '.codex-plugin/plugin.json': manifest('1.0.0'), 'openclaw.plugin.json': manifest('1.0.0', { id: 'gbrain-context-engine' }), 'VERSION': '1.0.0\n' };
const stamp = (v: string) => ({ '.claude-plugin/plugin.json': manifest(v), '.codex-plugin/plugin.json': manifest(v), 'openclaw.plugin.json': manifest(v, { id: 'gbrain-context-engine' }), 'VERSION': `${v}\n` });
test('positive: a PR that only re-stamps the manifests and VERSION passes lanes with a line naming the waived files and why', () => {
const repo = fixture({ baseFiles: base });
commit(repo, stamp('1.0.1'), 'release: restamp 1.0.1');
const r = pregate(repo);
expect(r.err).not.toContain('PREGATE_NO_LANE');
expect(r.out).not.toContain('has no test lane');
expect(r.out).toContain(`${WAIVER}.claude-plugin/plugin.json .codex-plugin/plugin.json VERSION openclaw.plugin.json — only version values changed against the merge base; a manifest whose other content changed still needs a lane`);
expect(r.status).toBe(EXIT.ok);
expect(lanes(repo).status).toBe('pass');
expect(lanes(repo).detail).toBe('version-stamp-only: 4 version-stamp-only manifest(s); nothing touched selects a test');
const explain = pregate(repo, ['--explain']).out;
expect(explain).toMatch(/\.claude-plugin\/plugin\.json: version-stamp-only against [0-9a-f]{12} \(version 1\.0\.0 → 1\.0\.1\); no lane needed/);
expect(explain).toMatch(/VERSION: version-stamp-only against [0-9a-f]{12} \(1\.0\.0 → 1\.0\.1\); no lane needed/);
});
test('negative: a manifest whose non-version field changed still fails with PREGATE_NO_LANE and says why it was not a stamp', () => {
const repo = fixture({ baseFiles: base });
commit(repo, { '.claude-plugin/plugin.json': manifest('1.0.1', { description: 'Personal knowledge brain, with RLS' }), 'VERSION': '1.0.1\n' }, 'release: restamp and reword');
const r = pregate(repo);
expect(r.status).toBe(EXIT.fail);
expect(r.err).toContain('(PREGATE_NO_LANE)');
expect(r.out).toContain('FAIL lanes — .claude-plugin/plugin.json has no test lane');
expect(r.out).toContain(`${WAIVER}VERSION — `);
expect(r.out).not.toContain(`${WAIVER}.claude-plugin`);
expect(pregate(repo, ['--explain']).out).toContain('.claude-plugin/plugin.json: not version-stamp-only (.description changed and its key is not `version`)');
});
test('mixed diff: the stamped manifests are waived while an unmapped source file keeps its PREGATE_NO_LANE, and a mapped one still runs its test', () => {
const repo = fixture({ baseFiles: base });
commit(repo, { 'VERSION': '1.0.1\n', 'openclaw.plugin.json': manifest('1.0.1', { id: 'gbrain-context-engine' }), 'lib/orphan.js': 'exports.lonely = () => 2;\n' }, 'stamp plus an orphan');
const orphan = pregate(repo);
expect(orphan.status).toBe(EXIT.fail);
expect(orphan.out).toContain('FAIL lanes — lib/orphan.js has no test lane');
expect(orphan.out).toContain(`${WAIVER}VERSION openclaw.plugin.json — `);
expect(pregate(repo, ['--explain']).out).not.toContain('lib/orphan.js: not version-stamp-only');
const repo2 = fixture({ baseFiles: base });
commit(repo2, { 'VERSION': '1.0.1\n', 'openclaw.plugin.json': manifest('1.0.1', { id: 'gbrain-context-engine' }), 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }, 'stamp plus a mapped change');
const mapped = pregate(repo2);
expect(mapped.status).toBe(EXIT.ok);
expect(mapped.out).toContain(`${WAIVER}VERSION openclaw.plugin.json — `);
expect(lanes(repo2).detail).toMatch(/^1 tests ran in 1 file\(s\), exit 0/);
});
test('a manifest a test reads keeps its lane: the test runs and the file is not waived', () => {
// lib/config.json is read by test/config-read.test.js through a repo-path literal in the fixture.
const repo = fixture({ baseFiles: { 'lib/config.json': j({ version: '1.0.0', limit: 10 }) } });
commit(repo, { 'lib/config.json': j({ version: '1.0.1', limit: 10 }) }, 'stamp a config a test reads');
const r = pregate(repo);
expect(r.out).not.toContain('version-stamp-only');
expect(r.out).toContain("ran: node --test 'test/config-read.test.js'");
expect(lanes(repo).detail).toMatch(/^1 tests ran in 1 file\(s\)/);
});
test('the comparison is against the merge base: main re-stamping after the fork does not change the verdict, and a later non-stamp commit on the branch does', () => {
const repo = fixture({ baseFiles: base });
commit(repo, stamp('1.0.1'), 'release: restamp 1.0.1');
repo.git('checkout', '-q', 'main');
for (const [rel, text] of Object.entries(stamp('1.0.5'))) write(repo.work, rel, text);
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'main restamps 1.0.5'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
const stamped = pregate(repo);
expect(stamped.status).toBe(EXIT.ok);
expect(pregate(repo, ['--explain']).out).toMatch(/VERSION: version-stamp-only against [0-9a-f]{12} \(1\.0\.0 → 1\.0\.1\)/);
commit(repo, { 'openclaw.plugin.json': manifest('1.0.1', { id: 'gbrain-context-engine', description: 'reworded later' }) }, 'reword after the stamp');
const reworded = pregate(repo);
expect(reworded.status).toBe(EXIT.fail);
expect(reworded.out).toContain('FAIL lanes — openclaw.plugin.json has no test lane');
expect(reworded.out).toContain(`${WAIVER}.claude-plugin/plugin.json .codex-plugin/plugin.json VERSION — `);
});
test('a mode change or a symlink on either side is not a stamp', () => {
const repo = fixture({ baseFiles: base });
fs.chmodSync(path.join(repo.work, 'VERSION'), 0o755);
commit(repo, { 'VERSION': '1.0.1\n' }, 'stamp with an exec bit');
expect(pregate(repo).out).toContain('FAIL lanes — VERSION has no test lane');
const link = fixture({ baseFiles: { 'target.txt': '1.0.0\n' } });
fs.symlinkSync('target.txt', path.join(link.work, 'VERSION'));
link.git('add', '-A'); link.git('commit', '-q', '-m', 'symlinked VERSION'); link.git('push', '-q', 'origin', 'HEAD:main'); link.git('fetch', '-q', 'origin');
fs.unlinkSync(path.join(link.work, 'VERSION')); write(link.work, 'VERSION', '1.0.1\n');
link.git('add', '-A'); link.git('commit', '-q', '-m', 'symlink becomes a file');
const r = pregate(link, ['--explain']);
expect(r.out).toContain('FAIL lanes — VERSION has no test lane');
expect(r.out).toContain('VERSION: not version-stamp-only (absent or unreadable at the merge base)');
});
test('a renamed manifest, an added one and a deleted one are not stamps', () => {
const repo = fixture({ baseFiles: base });
fs.mkdirSync(path.join(repo.work, 'plugins'));
repo.git('mv', 'openclaw.plugin.json', 'plugins/openclaw.plugin.json');
commit(repo, { 'plugins/openclaw.plugin.json': manifest('1.0.1', { id: 'gbrain-context-engine' }), 'VERSION': null, 'new.plugin.json': manifest('1.0.1') }, 'move, add and delete');
const r = pregate(repo);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toContain('FAIL lanes — new.plugin.json has no test lane');
expect(r.out).toContain('FAIL lanes — plugins/openclaw.plugin.json has no test lane');
expect(r.out).not.toContain('version-stamp-only (no lane needed)');
});
test('the working tree decides: an uncommitted stamp is waived, an uncommitted reword on top of a committed stamp is not, and an oversize or non-UTF-8 manifest is never a stamp', () => {
const repo = fixture({ baseFiles: base });
write(repo.work, 'VERSION', '1.0.1\n');
expect(pregate(repo).status).toBe(EXIT.ok);
expect(lanes(repo).detail).toBe('version-stamp-only: 1 version-stamp-only manifest(s); nothing touched selects a test');
commit(repo, { 'openclaw.plugin.json': manifest('1.0.1', { id: 'gbrain-context-engine' }) }, 'stamp');
write(repo.work, 'openclaw.plugin.json', manifest('1.0.1', { id: 'gbrain-context-engine', description: 'reworded in the working tree' }));
const dirty = pregate(repo);
expect(dirty.status).toBe(EXIT.fail);
expect(dirty.out).toContain('FAIL lanes — openclaw.plugin.json has no test lane');
const big = fixture({ baseFiles: { 'big.plugin.json': manifest('1.0.0', { pad: 'x'.repeat(1024 * 1024) }), 'bad.plugin.json': manifest('1.0.0'), 'bom.plugin.json': manifest('1.0.0') } });
commit(big, { 'big.plugin.json': manifest('1.0.1', { pad: 'x'.repeat(1024 * 1024) }) }, 'oversize stamp');
fs.writeFileSync(path.join(big.work, 'bad.plugin.json'), Buffer.concat([Buffer.from('{"version":"1.0.1","name":"'), Buffer.from([0xff]), Buffer.from('"}\n')]));
fs.writeFileSync(path.join(big.work, 'bom.plugin.json'), Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(manifest('1.0.1'))]));
big.git('add', '-A'); big.git('commit', '-q', '-m', 'invalid utf-8');
const r = pregate(big, ['--explain']);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toContain('FAIL lanes — big.plugin.json has no test lane');
expect(r.out).toContain('FAIL lanes — bad.plugin.json has no test lane');
expect(r.out).toContain('big.plugin.json: not version-stamp-only (absent or unreadable in the working tree)');
expect(r.out).toContain('bad.plugin.json: not version-stamp-only (absent or unreadable in the working tree)');
expect(r.out).toContain('FAIL lanes — bom.plugin.json has no test lane');
expect(r.out).toMatch(/bom\.plugin\.json: not version-stamp-only \(not parseable JSON on both sides/);
});
test('a manifest under a test, fixture, golden, .gstack or workflow tree is never waived', () => {
const files = { 'test/fixtures/golden.json': j({ version: '1.0.0', expect: 1 }), 'test/golden/expected.json': j({ version: '1.0.0' }), 'goldens/out.json': j({ version: '1.0.0' }), '.gstack/extra.json': j({ version: '1.0.0' }), '.github/workflows/meta.json': j({ version: '1.0.0' }), 'VERSION': '1.0.0\n' };
const repo = fixture({ baseFiles: files });
commit(repo, Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v.replace('1.0.0', '1.0.1')])), 'stamp everything');
const r = pregate(repo, ['--explain']);
expect(r.status).toBe(EXIT.fail);
for (const f of Object.keys(files).filter(f => f !== 'VERSION')) expect(r.out).toContain(`FAIL lanes — ${f} has no test lane`);
expect(r.out).toContain(`${WAIVER}VERSION — `);
expect(r.out).not.toContain('golden.json: not version-stamp-only');
});
test('the no-tests detail names the stamped manifests beside documentation and assets', () => {
const repo = fixture({ baseFiles: base });
commit(repo, { 'VERSION': '1.0.1\n', 'docs/README.md': '# docs, reworded\n' }, 'stamp plus docs');
expect(pregate(repo).status).toBe(EXIT.ok);
expect(lanes(repo).detail).toBe('docs/no-tests: 1 documentation file(s) and 1 version-stamp-only manifest(s); nothing touched selects a test');
const repo2 = fixture({ baseFiles: base });
commit(repo2, { 'VERSION': '1.0.1\n', 'assets/logo.png': 'not really a png\n' }, 'stamp plus an asset');
expect(pregate(repo2).status).toBe(EXIT.ok);
expect(lanes(repo2).detail).toBe('no-tests: 1 asset or tool-config file(s) and 1 version-stamp-only manifest(s); nothing touched selects a test');
});
});
+193
View File
@@ -0,0 +1,193 @@
/**
* The engine's env-value scan (GSTA-104): a credential already in the process
* environment (*_KEY, *_TOKEN, *_SECRET, *_PASSWORD) is a HIGH finding wherever
* its VALUE appears in the scanned text, whatever shape it has. During GSTA-96's
* unattended /ship, a docs-audit child re-typed the launch assignments as
* `export $(...)`; the substitution came back empty, the bare `export` printed
* the whole environment, and three API key values sat in a tool output that any
* later writer (PR body, receipt, report, Drive mirror) could have quoted. Every
* writer scans through this engine, so the value itself is now what it refuses.
*/
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { NON_SECRET_ENV_NAMES, credentialEnvValues, redactFindingSpans, scan } from "../lib/redact-engine";
// Fixture values live in plain constants: a credential-named assignment with an
// entropic literal on one line is exactly what the repo's own pregate secrets
// scan (env.kv) refuses in added lines.
const anthropicValue = "zq7Vm2pL9xR4tW8yB3nK6hJ1";
const ubicloudValue = "ubi-plain-looking-value-0042";
const ghValue = "f0a9c2e1d7b6";
const dbValue = "hunter2hunter2";
const awsSecretValue = "s3cr3t-s3cr3t-s3cr3t";
const dbPasswdValue = "passwd-passwd-passwd";
const lowerCaseValue = "lower-case-name-value";
const monkeyValue = "banana-banana-banana";
const tokenizerValue = "tokenizer-value-here";
const lockOwner = ["12345", "6789", "17"].join("-");
const slashSecret = "/Qm9vZ2llV29vZ2ll/abc123XYZ+=";
const sslKeyPath = "/etc/ssl/private/x.key";
const env = {
ANTHROPIC_API_KEY: anthropicValue,
UBICLOUD_API_KEY: ubicloudValue,
GH_TOKEN: ghValue,
DB_PASSWORD: dbValue,
SHORT_KEY: "abc",
PLACEHOLDER_TOKEN: "your-token-here",
HOME: "/home/user/with/enough/length",
GSTACK_STATE_ROOT: "/home/user/.gstack",
GSTACK_LAUNCH_FILE: "/home/user/.gstack/install-verdicts/a/b.env",
};
describe("env-value scan", () => {
test("picks only credential-named variables with credential-sized, non-placeholder values", () => {
const names = credentialEnvValues(env).map(e => e.name).sort();
expect(names).toEqual(["ANTHROPIC_API_KEY", "DB_PASSWORD", "GH_TOKEN", "UBICLOUD_API_KEY"]);
// /ship Step 7 value card. protects: the name filter is a suffix WORD (_KEY, _TOKEN, _SECRET,
// _PASSWORD, _PASSWD, any case), not a suffix string, and an unset or whitespace-padded short
// value is never scanned for. fails_when: the (^|_) anchor is dropped (MONKEY's value becomes a
// HIGH finding and blocks a clean PR body), SECRET/PASSWD/lowercase names stop being scanned (a
// leaked AWS_SECRET passes), or the trim guard goes (a padded 7-character value matches prose).
// why_existing_misses: the table above holds only *_KEY/*_TOKEN/*_PASSWORD upper-case names and
// no undefined or padded value. seam=none.
const edges = {
AWS_SECRET: awsSecretValue, DB_PASSWD: dbPasswdValue, api_key: lowerCaseValue,
MONKEY: monkeyValue, TOKENIZER: tokenizerValue, UNSET_KEY: undefined, PADDED_KEY: " abcdefg ", EIGHT_KEY: "12345678",
};
expect(credentialEnvValues(edges).map(e => e.name).sort()).toEqual(["AWS_SECRET", "DB_PASSWD", "EIGHT_KEY", "api_key"]);
expect(scan(`monkeys: ${monkeyValue}; tokens: ${tokenizerValue}`, { processEnv: edges }).findings.filter(f => f.id === "env.value")).toEqual([]);
});
// /ship Step 7 value card. protects: the value is matched on the NORMALIZED text (the engine's
// documented first rule), so a credential split by a zero-width character or re-typed in
// fullwidth forms is still an env.value finding, and the span maps back to the original bytes so
// redaction removes the whole value, zero-width filler included. fails_when: the loop matches
// `input` instead of `normalized`, or the span end is taken from the normalized offset.
// why_existing_misses: every value above appears verbatim in ASCII. seam=none.
test("a value split by a zero-width character or written in fullwidth forms is still found, and redaction removes the original bytes", () => {
const split = `${anthropicValue.slice(0, 6)}\u200b${anthropicValue.slice(6)}`;
const text = `leak: ${split} end`;
expect(scan(text, { processEnv: env }).findings.map(f => [f.id, f.line, f.col])).toEqual([["env.value", 1, 7]]);
const redacted = redactFindingSpans(text, { processEnv: env });
expect(redacted).toBe("leak: <REDACTED-env.value> end");
const fullwidth = anthropicValue.replace(/[0-9A-Za-z]/g, c => String.fromCharCode(c.charCodeAt(0) + 0xfee0));
expect(fullwidth).not.toBe(anthropicValue);
expect(scan(`x ${fullwidth} y`, { processEnv: env }).findings.map(f => f.id)).toEqual(["env.value"]);
});
test("a dumped environment line is HIGH by value, whatever the value's shape", () => {
const text = [
"Tool output:",
`ANTHROPIC_API_KEY=${anthropicValue}`,
"GSTACK_STATE_ROOT=/home/user/.gstack",
`the cloud key is ${ubicloudValue} and nothing else`,
].join("\n");
const result = scan(text, { processEnv: env });
const envFindings = result.findings.filter(f => f.id === "env.value");
expect(envFindings.map(f => [f.line, f.description])).toEqual([
[2, "Value of the ANTHROPIC_API_KEY environment variable"],
[4, "Value of the UBICLOUD_API_KEY environment variable"],
]);
for (const f of envFindings) {
expect(f.severity).toBe("HIGH");
expect(f.category).toBe("secret");
expect(f.preview).not.toContain(anthropicValue);
expect(f.preview).not.toContain(ubicloudValue);
}
expect(result.counts.HIGH).toBeGreaterThanOrEqual(2);
});
test("a variable's path-shaped non-credential value and the launch file path are not findings", () => {
const text = "GSTACK_STATE_ROOT=/home/user/.gstack GSTACK_LAUNCH_FILE=/home/user/.gstack/install-verdicts/a/b.env HOME=/home/user/with/enough/length";
expect(scan(text, { processEnv: env }).findings.filter(f => f.id === "env.value")).toEqual([]);
});
test("the allowlist suppresses a value, and an empty environment scans nothing by value", () => {
const text = `token ${ghValue} here`;
expect(scan(text, { processEnv: env }).findings.map(f => f.id)).toContain("env.value");
expect(scan(text, { processEnv: env, allowlist: [ghValue] }).findings.filter(f => f.id === "env.value")).toEqual([]);
expect(scan(text, { processEnv: {} }).findings.filter(f => f.id === "env.value")).toEqual([]);
});
test("redactFindingSpans removes the value bytes for machine egress", () => {
const text = `key=${anthropicValue}; password ${dbValue} twice: ${dbValue}`;
const redacted = redactFindingSpans(text, { processEnv: env });
expect(redacted).not.toBeNull();
expect(redacted).not.toContain(anthropicValue);
expect(redacted).not.toContain(dbValue);
expect(redacted).toContain("<REDACTED-env.value>");
});
// /ship Step 9.4 review fixes, each pinned: (1) a value whose env copy carries a trailing newline
// (a key read from a file) is searched for TRIMMED, so its leaked form still matches; (2) when one
// value is a prefix of another, both are findings and the longer value is redacted whole, in either
// order (the dedup key carries the length, not the offset alone); (3) the preview never shows any
// byte of a live value; (4) a framework-public name (NEXT_PUBLIC_*_KEY, *_PUBLIC_KEY) is not
// scanned, since that value is committed on purpose. fails_when: the trim moves back to the
// length check only, the dedup key drops the length, maskPreview returns, or the PUBLIC exclusion goes.
test("a padded value, a prefix value, the preview and PUBLIC-named variables", () => {
const padded = { FILE_KEY: `${anthropicValue}\n` };
expect(scan(`leak: ${anthropicValue} end`, { processEnv: padded }).findings.map(f => f.id)).toEqual(["env.value"]);
const short = anthropicValue.slice(0, 12);
const text = `leak: ${anthropicValue} end`;
for (const order of [{ A_KEY: short, B_KEY: anthropicValue }, { B_KEY: anthropicValue, A_KEY: short }]) {
expect(scan(text, { processEnv: order }).findings.map(f => f.description).sort()).toEqual([
"Value of the A_KEY environment variable", "Value of the B_KEY environment variable",
]);
expect(redactFindingSpans(text, { processEnv: order })).toBe("leak: <REDACTED-env.value> end");
}
const preview = scan(text, { processEnv: env }).findings.find(f => f.id === "env.value")?.preview ?? "";
expect(preview).toBe("********");
expect(scan(`pk: ${monkeyValue}`, { processEnv: { NEXT_PUBLIC_STRIPE_KEY: monkeyValue, APP_PUBLIC_KEY: monkeyValue } }).findings).toEqual([]);
expect(credentialEnvValues({ PUBLICITY_KEY: monkeyValue }).map(e => e.name)).toEqual(["PUBLICITY_KEY"]);
});
// (1) the name rule also admits *_PASS, *_PAT, *_APIKEY and *_PWD, while the shell's own PWD and
// OLDPWD stay out by NAME (their fixture values are plain words, so only the name rule keeps them
// out); (2) there is no path exemption: a path-valued SSL_KEY and a base64 secret starting with
// "/" are both scanned, and a directory path as a value changes nothing (an existence check
// let the scanned agent exempt a value by creating a path of that name); (3) gstack's own
// GSTACK_BRAIN_LOCK_TOKEN (pid-random-epoch, written to the lock owner file) is not a secret, and
// bin/gstack-brain-lock.sh still exports every name in that set; (4) a value NFKC rewrites
// (fullwidth digits) is matched in its normalized form, so its leaked ASCII form is found, and a
// value whose normalized form is under eight characters, or a placeholder, is not searched for,
// and the allowlist suppresses by normalized form as well.
// fails_when: PWD is admitted (every mention of the cwd becomes HIGH), a shape or existence rule
// returns (the slash secret or the directory value is skipped), the non-secret set drifts, or the
// value is matched raw against the normalized text.
test("more credential suffixes, no path exemption, gstack's own lock token and a normalized value", () => {
const names = credentialEnvValues({
DB_PASS: dbValue, GITHUB_PAT: ghValue, STRIPE_APIKEY: lowerCaseValue, MYSQL_PWD: dbPasswdValue,
PWD: "workspace-dir-name", OLDPWD: "previous-dir-name",
SSL_KEY: sslKeyPath, DIR_KEY: "/usr/local/share/ca-certificates", SLASH_KEY: slashSecret,
GSTACK_BRAIN_LOCK_TOKEN: lockOwner,
}).map(e => e.name).sort();
expect(names).toEqual(["DB_PASS", "DIR_KEY", "GITHUB_PAT", "MYSQL_PWD", "SLASH_KEY", "SSL_KEY", "STRIPE_APIKEY"]);
expect(scan(`b64 ${slashSecret}`, { processEnv: { SLASH_KEY: slashSecret } }).findings.map(f => f.id)).toEqual(["env.value"]);
const lock = fs.readFileSync(path.join(import.meta.dir, "..", "bin", "gstack-brain-lock.sh"), "utf8");
for (const name of NON_SECRET_ENV_NAMES) expect(lock, name).toContain(`export ${name}`);
const fullwidth = anthropicValue.replace(/[0-9]/g, c => String.fromCharCode(c.charCodeAt(0) + 0xfee0));
expect(fullwidth).not.toBe(anthropicValue);
expect(scan(`leak: ${anthropicValue} end`, { processEnv: { WIDE_KEY: fullwidth } }).findings.map(f => f.id)).toEqual(["env.value"]);
const zeroWidthPadded = "ab\u200bcd\u200bef\u200bg";
expect(scan("plain abcdefg here", { processEnv: { ZW_KEY: zeroWidthPadded } }).findings).toEqual([]);
// The allowlist and the placeholder rule see the normalized form too: an allowlisted ASCII form
// suppresses the fullwidth value, and a value that is a placeholder only after NFKC is not searched.
expect(scan(`leak: ${anthropicValue} end`, { processEnv: { WIDE_KEY: fullwidth }, allowlist: [anthropicValue] }).findings).toEqual([]);
const widePlaceholder = "your-token-here".replace(/[a-z]/g, c => String.fromCharCode(c.charCodeAt(0) + 0xfee0));
expect(scan("x your-token-here y", { processEnv: { P_TOKEN: widePlaceholder } }).findings.filter(f => f.id === "env.value")).toEqual([]);
});
test("the default environment is process.env", () => {
const name = "GSTA104_TEST_TOKEN";
process.env[name] = "v4lue-only-this-test-knows";
try {
const found = scan("leaked: v4lue-only-this-test-knows").findings.find(f => f.id === "env.value");
expect(found?.description).toBe(`Value of the ${name} environment variable`);
} finally {
delete process.env[name];
}
});
});
+6 -2
View File
@@ -248,12 +248,16 @@ test('existing SDK429 retry policy stays three and closes the prior query before
});
test('late first completion cannot approve tools or overwrite evidence after a fresh second invocation', async () => {
const result = await fixture(['late', 'success'], 250);
// The budget bounds the second, successful attempt too (stub server, two mkdtemps, the fake SDK
// run with its HTTP fetch). At 250ms it failed 4 of 4 times on windows-latest once the shard
// pack moved this file next to heavier neighbours (#3449, run 40263984171); 2s keeps the first
// attempt late and leaves the second one room on a loaded runner.
const result = await fixture(['late', 'success'], 2_000);
expect(result.rows.map(r => [r.passed, r.attempt])).toEqual([[false, 1], [true, 2]]);
expect(result.evidence[0].querySettled).toBe(0);
expect(result.evidence[0].attemptId).not.toBe(result.evidence[1].attemptId);
expect(result.homes[0]).not.toBe(result.homes[1]);
}, 10_000);
}, 20_000);
test('HTTP finalization closes only connections belonging to the owned fixture server', async () => {
const foreign = http.createServer((_req, res) => res.end('foreign'));
+10 -1
View File
@@ -24,6 +24,7 @@ import {
strictShardStatus,
writeDurationSeed,
zeroExecutionVerdict,
CAPY_LAUNCH_MARKERS,
} from '../scripts/lib/shard-engine';
import { FREE_LANE_POLICY, exitCodeFor, runFreeShard } from '../scripts/test-free-shards';
import {
@@ -237,7 +238,13 @@ describe('shard engine: capture and isolation', () => {
});
test('each shard gets its own tmp and Chromium profile, removed by the backstop', async () => {
const base = { KEEP: '1', TMPDIR: '/inherited', CHROMIUM_PROFILE: '/shared-profile' };
const base = {
KEEP: '1', TMPDIR: '/inherited', CHROMIUM_PROFILE: '/shared-profile',
// The Capy launch file's markers (GSTA-99): a lane started from a shell that sourced it must
// not hand them to the product-under-test children, which set their own session kind.
GSTACK_SESSION_KIND: 'unattended', GSTACK_CAPY: '1', GSTACK_EPHEMERAL: '1', GSTACK_INSTALL_VERDICT: '/v.json',
GSTACK_STATE_ROOT: '/kept-root',
};
const a = createShardSandbox('gstack-unit-shard-', base);
const b = createShardSandbox('gstack-unit-shard-', base, { realpath: true });
try {
@@ -246,6 +253,8 @@ describe('shard engine: capture and isolation', () => {
for (const box of [a, b]) {
expect(box.env.KEEP).toBe('1');
expect(box.env.GSTACK_HOME).toBeUndefined();
for (const key of CAPY_LAUNCH_MARKERS) expect(box.env[key]).toBeUndefined();
expect(box.env.GSTACK_STATE_ROOT).toBe('/kept-root');
for (const key of ['TMPDIR', 'TEMP', 'TMP']) expect(box.env[key]).toBe(box.tmp);
expect(box.env.CHROMIUM_PROFILE).toBe(path.join(box.stateDir, 'chromium-profile'));
expect(fs.statSync(box.tmp).isDirectory()).toBe(true);