mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-28 17:10:26 +02:00
v1.69.0.0 fix: the silent-failure wave — 6 fixes, 5 community PRs absorbed, tracker closed with receipts (#2666)
* test(wireup): make gbrain-missing PATH fixture hermetic The gbrain-missing test appended the host PATH (and a hardcoded /opt/homebrew/bin) to the fixture PATH, so on any machine with a real gbrain installed the 'missing' case saw it, exited 0 instead of 2, and could never fail where the bug exists — a false green for a whole machine class. The fixture now keeps only root-owned OS dirs on the child PATH, and a new determinism check plants a host-like gbrain to prove it is unreachable. Absorbed from PR #2615 with authorship preserved; the PR-thread liveness screenshot (docs/images/gstack-pr-liveness-2255.png) is dropped — referenced by nothing in the tree. Fixes #2255 Co-authored-by: CommandCodeBot <noreply@commandcode.ai> * fix(evidence): stop bun's dotenv autoload from reaching the spawned command `bin/gstack-evidence` has a `#!/usr/bin/env bun` shebang, and bun AUTO-LOADS `.env`, `.env.<NODE_ENV>` and `.env.local` from the cwd into `process.env`. The wrapper then spawned the command with no `env` override, so every command run through it inherited those variables — and a repo `.env.local` routinely holds production credentials. Two things go wrong, and the second is worse than the leak: 1. Secrets reach a child that would not otherwise have them. `npm test` run by hand in the same shell sees none of them; the same command through the wrapper sees all of them. 2. THE COMMAND UNDER TEST BEHAVES DIFFERENTLY, so the ledger certifies a run that is not the run CI performs. Observed in a Next.js repo on 2026-08-20: four tests failed 4/4 through the wrapper and passed 5/5 without it, because app code branched on env vars only the wrapper supplied. Nearly an hour went into chasing a "flake" that was the measuring instrument. The wrapper exists to record trustworthy evidence, so silently altering the environment defeats its purpose. The fix builds the child env from `process.env` minus the keys bun injected, and detection is exact rather than heuristic: verified on bun 1.3.11, a dotenv file does NOT override a variable the shell already exported (the shell's value wins). So a key whose live value equals the dotenv file's value was injected by bun, and dropping it restores the environment the user's own shell would have given the command. A key whose live value differs is genuinely the caller's and survives. `BUN_DOTENV_FILES()` mirrors bun's precedence, including that `.env.local` is skipped when NODE_ENV is "test" — scrubbing a key bun never loaded would strip a variable the caller legitimately provided. Escape hatch: GSTACK_EVIDENCE_KEEP_DOTENV=1 keeps the old behaviour. When keys are scrubbed the wrapper warns with the KEY NAMES ONLY, so the diagnostic cannot become the leak it prevents. Tests: 6 cases, mutation-verified — removing `env: spawnEnv` reddens exactly the two leak tests and restoring it gives 30/30. Every leak test asserts the scrub warning fired, because `bun test` sets NODE_ENV=test and the first version of these tests passed vacuously against a `.env.local` bun had never loaded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Absorbed from PR #2652 with authorship preserved. Wave additions: a doc-comment on the ${VAR}-expansion limitation (bun expands refs, the reader compares raw text — those keys are left in the child env, failing open) and a regression pin for the unreadable-.env fail-open path with a functional DAC-override skip guard. Fixes #2624 * fix(setup): reap dangling skill dirs when the payload is gone cleanup_old_claude_symlinks derived its work list from the payload directory, so when the payload was gone — precisely when orphans exist — the glob matched nothing and the loop never ran; the -f guard also followed symlinks, hiding dangling SKILL.md links even with a payload present. The cleanup now scans the DESTINATION skills dir (-e/-L, so dangling symlinks are visible) and anchors SKILL.md provenance to path segments (gstack/*, */gstack/*, */.gstack/render/claude/*) instead of a bare *gstack* substring that would eat a user skill under ~/tools/gstack-fork/. The Windows real-file arm stays payload-gated: a real file has no provable owner. Absorbed from PR #2634 (2 commits squashed) with authorship preserved. The symmetric cleanup_prefixed_claude_symlinks hole is filed as a TODOS.md residual in this wave. Fixes #2204 * fix(redact): tolerate EEXIST from recursive mkdir in install-prepush-hook on bun/Windows (#2635) fs.mkdirSync(dir, { recursive: true }) is a no-op on an existing directory in Node, but bun on Windows throws EEXIST - crashing hook install on any repo whose .git/hooks already existed, leaving the repo unprotected. Add lib/fs-utils.ts mkdirpSync: swallow EEXIST only when statSync confirms the path is an existing directory; a regular file occupying the path, a stat failure, or any other errno still rethrows. Use it in installPrepushHook(). The regression test emulates the Windows bun fs semantics via a bun --preload fixture, so the exact crash path runs (and fails on the old code) on any platform, including CI Linux. Absorbed from PR #2641 with authorship preserved. Fixes #2635 * fix(bin): route remaining Windows-reachable mkdirSync sites through mkdirpSync Sweep follow-up to #2641's lib/fs-utils.ts helper: bun on Windows throws EEXIST from a recursive mkdir on an existing dir, so every unguarded recursive mkdirSync on a Windows-reachable path is a latent crash. Converted: bin/gstack-decision-log (unguarded, runs on every decision log — the second call on any machine hits the pre-existing projects dir), bin/gstack-evidence logsDir + ledger dir sites, and bin/gstack-redact-prepush's skip-log site (already try-wrapped, so its failure mode was a silent skip-log loss rather than a crash — the fix makes the log survive). The ~15 remaining gbrain/mac-lane sites are deliberately left alone. Regression: fs-utils.test.ts drives gstack-decision-log twice, the second run under the bun-Windows EEXIST preload fixture — the pre-sweep code exits 1 with EEXIST there; verified red against v1.68.3.0. * fix(setup-gbrain): warn about the ZeroEntropy sunset before Sept 4 ZeroEntropy was acquired by Notion and sunsets its hosted API on September 4, 2026. A gbrain configured with the zeroentropyai embedding recipe keeps importing pages after that date but embedding silently fails — pages land structurally with no semantic search, this repo's tracker P1 (TODOS.md NEXT PRIORITY). Nothing in gstack ever recommended ZeroEntropy (the dependency is gbrain-internal), so the gstack side is detection + advisory: the wireup helper warns when ~/.gbrain/config.json names the recipe (fail-open grep — a missing, unreadable, or other-provider config stays silent and never blocks a working setup), the setup-gbrain provider-default comments say never to select the legacy recipe for a new brain, and USING_GBRAIN_WITH_GSTACK.md gains a troubleshooting entry. The gbrain-side provider migration stays open upstream. Refs #2365 * fix(gbrain-source-wireup): first sync targets the registered source, not --repo The wireup registered a federated source by id, then ran 'gbrain sync --repo $WORKTREE' — which resolves against the brain's DEFAULT source and (on gbrain 0.46.x) rewrites that source's local_path anchor to our worktree. Net effect: the user's primary knowledge source silently repointed at the gstack brain worktree while the just-registered source got zero pages, and pages_synced still reported success. The sync now targets the registered id ('gbrain sync --source $id', the same form the repo's own troubleshooting documents). Because the script's stated floor is gbrain >= 0.18.0 and nothing proves --source exists there, support is probed via 'gbrain sync --help' first: an older gbrain keeps the wrong-but-working --repo call with an upgrade warning instead of converting it into a hard failure. The probe sits after the GSTACK_BRAIN_NO_SYNC early-exit and is unreachable in --probe mode. Regression tests (fail on v1.68.3.0): a no-skip sync case asserting the call log shows 'sync --source gstack-brain-<id>' and never 'sync --repo', and an old-gbrain fallback case (fake sync --help without --source) asserting --repo plus the upgrade warning. Fixes #2662 * fix(setup): --host slate exits informatively instead of silently installing nothing slate passed --host validation (added to the accept-list in v1.64.1.0) but never got a dispatch arm, and the all-INSTALL_*-zero fallback lives inside the auto branch — so './setup --host slate' configured nothing and exited 0, a silent no-op strictly worse than the original hard rejection. slate is now an informational arm (per docs/designs/SLATE_HOST.md it is blocked on the host-config refactor; Slate reads .claude/skills as a compatibility fallback, so the arm points at './setup --host claude'), and a defensive guard after the dispatch chain errors loudly (naming the host, the missing arm, and the valid targets, exit 1) if a future host is ever accepted without being wired. Regression tests (fail on v1.68.3.0): a dispatch-arm ratchet asserting every accept-listed install target has a matching dispatch branch — the exact drift class; a registry cross-check deriving both sides from hosts/index.ts and setup's case arms; a behavioral slate probe (exit 0, points at --host claude, never reaches the installer — on unfixed code it fell through into the installer); and a static pin on the guard's shape. Fixes #2361 * fix(make-pdf): resolve the sibling browse binary from execPath, not argv[0] In a bun-compiled binary process.argv[0] is the raw invocation string — often relative ('./pdf', 'pdf') — so dirname(argv[0]) yielded '.' and the sibling candidates (../browse/dist/browse etc.) resolved against the CWD instead of the install dir. Resolution was cwd-dependent: correct-by-luck when the fallbacks rescued it, wrong when a cwd-relative path matched. process.execPath is always the absolute binary path. The resolution step takes an injectable selfPath (defaulted) because under bun test the process path is the bun runtime and the compiled-binary shapes are otherwise unreachable. The issue's other half — pdf setup failing on newtab('about:blank') — was already fixed on main in v1.64.0.0 (browse/src/url-validation.ts exact-match allows about:blank; its comment names this exact smoke). This commit closes what remains. Regression tests (the sibling-via-selfPath case fails on v1.68.3.0 — pre-fix code ignores the seam and either resolves the global install or throws): sibling resolution from an install-shaped tree, and a decoy-browse-DIRECTORY case pinning that a directory never wins resolution. Fixes #2156 * fix(memory-ingest): store the normalized git_remote so unattributed pages hit the policy filter buildTranscriptPage wrote the normalized '_unattributed' sentinel into the page FRONTMATTER but stored the raw resolved remote ('' when unresolvable) on the page object. The policy filter fast-paths !p.git_remote, so under --include-unattributed an explicit '_unattributed → deny' (or read-only) policy never applied to exactly the pages it names — they ingested unpoliced. The stored value now matches the frontmatter. Regression test (fails on v1.68.3.0): seeds the REAL bin/gstack-gbrain-repo-policy store with '_unattributed → deny' through its own set verb, ingests an unresolvable-remote session with --include-unattributed, and asserts nothing reaches gbrain — pre-fix the '' remote bypassed the filter and the import ran. A fake echoing tiers would pass on both sides of the fix; the real helper prints 'none' for unknown keys, so only a genuinely applied deny distinguishes the two. Fixes #2353 * fix(land-and-deploy): MERGED recovery reconciles and reports remote-branch cleanup Step 4's merge commands carry --delete-branch, and the success path tells the user 'The branch has been cleaned up.' When gh exits non-zero AFTER GitHub already merged (routine in worktree layouts: gh's local cleanup runs git checkout <base> and fails), the §4a-postfail MERGED recovery re-established everything EXCEPT the branch deletion — and said nothing about it, so the discrepancy was invisible. The MERGED path now reconciles: git ls-remote --heads distinguishes branch-already-gone (exit 0, empty → 'already cleaned up', idempotent on re-runs) from branch-survived (offer confirm-first deletion, matching the section's worktree posture; -d not -D for any local branch) from check-itself-failed (non-zero exit → 'couldn't verify', skip the offer — never read a failed check as a clean branch). Template + regenerated SKILL.md + test extensions land in one commit (the md-sync assertion goes red otherwise). Regression assertions (fail on v1.68.3.0: no delete-branch reconciliation existed in test/ at all) pin the ls-remote check, the confirm-first delete, and the absent-vs-failed distinction. Fixes #2656 * fix(scripts): stop heredoc bodies deadlocking under Homebrew bash `./setup --help` can hang forever on macOS, printing nothing, with no way to tell it apart from a slow install. Eleven scripts carry the same latent hang, `setup` itself being the one every user hits first. bash 5.2+ delivers a heredoc body of 64KiB or less through a pipe: the forked child writes the entire body before exec, and nothing reads the other end until the command starts. Under macOS pipe-KVA pressure the kernel hands a fresh pipe a 512-byte buffer instead of the usual 16-64KiB, so any body of 512 bytes or more blocks write() permanently. The capacity check bash would need to notice (F_GETPIPE_SZ) is Linux-only, so it never fires here. It is pressure-dependent, which is why it reads as "worked on my machine" — the same script runs fine all day and then wedges. Homebrew bash is what `#!/usr/bin/env bash` resolves to on a Mac with brew on PATH, which is most of them. Apple's /bin/bash 3.2 predates the pipe path and is unaffected, so the bug is invisible to anyone testing with the system shell. The fix is `BASH_COMPAT=50` in each affected script, which restores the pre-5.2 tempfile path: $ bash -c 'probe() { [ -p /dev/stdin ] && echo PIPE || echo TEMPFILE; } probe <<EOF $(printf "x%.0s" $(seq 1 1000)) EOF' PIPE $ BASH_COMPAT=50 bash -c '...same...' TEMPFILE - Not a `#!/bin/bash` shebang swap: that pins the script to whatever bash lives at /bin (3.2 on macOS, absent on some Linux distributions) and is bypassed entirely by `bash script.sh` call sites. The variable survives both. - Not exported, so child processes keep their own compat level. - Placed below any `--help` sed range that reads $0, so usage output is unchanged (verified on all eleven). - Every guarded script is bash-3.2-clean — no associative arrays, case conversion, or mapfile — so compat level 50 costs them nothing. test/heredoc-pipe-deadlock.test.ts scans every tracked shell script for a heredoc body in the 512B-64KiB window and fails without the guard, and proves the mechanism at runtime on bash 5.2+ by asserting the body moves from PIPE to TEMPFILE. On older bash the runtime half is skipped, since the pipe path does not exist there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Absorbed from PR #2640 with authorship preserved. Wave adaptations: the pipe-probe test skips on minimal-/dev environments without /dev/stdin (it would report OTHER for an unobservable fd), and one caveat verified during review: on bash 4.3/4.4 (e.g. Git Bash), assigning BASH_COMPAT=50 prints a non-fatal 'invalid value' warning to stderr — those bashes are already on tempfiles, so the guard is a no-op there; windows-setup-e2e exercises this empirically. * docs: TODOS.md v1.69 wave close-out Move the slate P4 entry and the ZeroEntropy P1's gstack-side half to Completed (v1.69.0.0); reframe the ZeroEntropy NEXT PRIORITY entry around the remaining gbrain-side work; file the wave's four residuals with rationale — the prefixed-cleanup symmetric conversion, the #2163 legacy-slug checkpoint heal, the invited #2657 --reconcile contribution, and the table-driven setup host dispatch behind the new cross-check ratchet. * chore: bump version and changelog (v1.69.0.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Som Samantray <som.samantray@gmail.com> Co-authored-by: CommandCodeBot <noreply@commandcode.ai> Co-authored-by: Connex Client Access <paul@paulkortman.com> Co-authored-by: y$un_ <forrest.sun527@gmail.com> Co-authored-by: Lockyer <135391289+Lockyer228@users.noreply.github.com> Co-authored-by: Benjamin D. Smith <benjamin.smith@binarysword.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Som Samantray
CommandCodeBot
Connex Client Access
y$un_
Lockyer
Benjamin D. Smith
parent
85fd9db554
commit
ad8400543c
@@ -1,5 +1,59 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.69.0.0] - 2026-08-22
|
||||||
|
|
||||||
|
**The silent-failure wave: tools that reported success while doing nothing —**
|
||||||
|
**or the wrong thing — now do what they say, or say loudly that they couldn't.**
|
||||||
|
|
||||||
|
Every fix in this wave closes the same failure shape. `gstack-evidence` — the tool other tools believe — certified runs whose environment differed from CI's, because bun auto-loaded the repo's `.env` files into every child it spawned. The gbrain wireup's first sync targeted the brain's *default* source, which could silently repoint a user's primary knowledge source at the gstack worktree while the just-registered source got zero pages — and still print a success line. `./setup --host slate` exited 0 having installed nothing. `land-and-deploy`'s merge recovery re-established everything except the `--delete-branch` half it had promised, and said nothing. A `_unattributed → deny` ingest policy never applied to exactly the pages it names. Skill-dir cleanup structurally could not find orphans. And a false-green test fixture meant the "gbrain missing" case could never fail on any machine with a real gbrain installed. Six fixes are new; five community PRs are absorbed with credit; ~17 tracker items close with receipts.
|
||||||
|
|
||||||
|
### The numbers that matter
|
||||||
|
|
||||||
|
Source: the regression tests named in each commit — every one verified to FAIL on a scratch worktree of v1.68.3.0 during the wave (the receipts standard), plus the live pre-fix probes quoted in the PR.
|
||||||
|
|
||||||
|
| Property | Before | After |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| `gstack-evidence run` in a repo with `.env`/`.env.local` | child inherits bun-injected vars; ledger certifies a run CI never performs | value-equality scrub (shell-exported overrides survive), names-only warning, `GSTACK_EVIDENCE_KEEP_DOTENV=1` opt-out. Contributed by @namtrok (#2652) |
|
||||||
|
| Wireup first sync | `sync --repo` resolves against the DEFAULT source; can repoint its anchor, registered source gets 0 pages, prints success | `sync --source <id>`; `--help`-probed with `--repo`+warning fallback for old gbrains |
|
||||||
|
| `./setup --host slate` | exit 0, installs nothing | explains Slate (use `--host claude`), exit 0 informational; any future accepted-but-unwired host exits 1 loudly; accept-list ⊆ dispatch-arms is test-pinned |
|
||||||
|
| land-and-deploy merge recovery | `--delete-branch` half silently dropped | `ls-remote` reconciliation: already-clean (idempotent) / confirm-first delete / "couldn't verify" — a failed check is never read as a clean branch |
|
||||||
|
| Orphaned skill dirs after the payload is gone | cleanup scanned the payload → structurally can't reap | destination scan, dangling-symlink aware, path-segment provenance. Contributed by @szsunyuan (#2634) |
|
||||||
|
| `gstack-redact install-prepush-hook` on bun/Windows | EEXIST crash — credential guard silently absent | `mkdirpSync` tolerates dir-EEXIST only. Contributed by @Lockyer228 (#2641); swept to decision-log, evidence, and the prepush skip-log |
|
||||||
|
| "gbrain missing" test on a box with real gbrain | saw the host's gbrain, exited 0 — could never fail where the bug exists | hermetic root-owned-dirs-only PATH + determinism check. Contributed by @SomSamantray (#2615) |
|
||||||
|
| Heredoc bodies ≥512B under Homebrew bash 5.2+ | child deadlocks (macOS 512-byte pipe buffer) | `BASH_COMPAT=50` guard in the 11 in-window scripts + a repo-wide scanner ratchet. Contributed by @BenjaminDSmithy (#2640) |
|
||||||
|
| `_unattributed → deny` policy under `--include-unattributed` | never applied — raw `""` remote bypassed the filter | stored remote matches the frontmatter sentinel; deny/read-only now bite |
|
||||||
|
| Brains on gbrain's ZeroEntropy recipe | embedding dies silently after Sept 4, 2026 | wireup warns on config detection (fail-open); setup-gbrain + docs advisories (#2365, gbrain-side migration stays open) |
|
||||||
|
| make-pdf sibling browse resolution | cwd-dependent (`dirname(argv[0])` is `.` in compiled binaries) | `process.execPath`-based; a decoy `browse/` directory can never win |
|
||||||
|
|
||||||
|
### What this means for you
|
||||||
|
|
||||||
|
Evidence verdicts are the run CI would perform — your shell-exported overrides still win, and the scrub tells you (key names only) what it removed. Revoking, cleaning up, and installing now either do the thing or name the thing they couldn't do. If your gbrain is on the dying ZeroEntropy recipe, gstack tells you before September 4 instead of letting search quietly rot. And five contributors' PRs are in this release with their authorship on the commits and their handles below.
|
||||||
|
|
||||||
|
### Itemized changes
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- Zero-dispatch guard in `setup`: a host that passes `--host` validation without an install arm errors loudly (names the host and the valid targets) instead of exiting 0 having configured nothing; cross-check test pins the accept-list against `hosts/index.ts` and every accept-listed host to a dispatch arm (#2361).
|
||||||
|
- ZeroEntropy sunset advisory: fail-open config detection in the wireup, provider-comment warnings in `/setup-gbrain`, and a troubleshooting entry in `USING_GBRAIN_WITH_GSTACK.md` (#2365 — refs; the gbrain-side migration remains open).
|
||||||
|
- `lib/fs-utils.ts` `mkdirpSync` (dir-confirmed EEXIST tolerance) with a bun-Windows-emulating preload fixture, applied to `gstack-redact`, `gstack-redact-prepush`, `gstack-decision-log`, and `gstack-evidence`. Contributed by @Lockyer228 (#2641; fixes #2635).
|
||||||
|
- Repo-wide heredoc scanner: any tracked shell script with an unguarded 512B–64KiB heredoc fails the free suite. Contributed by @BenjaminDSmithy (#2640).
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- `land-and-deploy` §4a-postfail MERGED recovery reconciles the remote branch (three-way: already-clean / confirm-first delete / couldn't-verify) and states the outcome instead of staying silent (#2656).
|
||||||
|
- `make-pdf` resolves the sibling browse binary from `process.execPath` with an injectable test seam; the `about:blank` half of #2156 was already fixed in v1.64.0.0 (`browse/src/url-validation.ts` exact-match allow).
|
||||||
|
- `./setup --host slate` is an informational exit pointing at `--host claude` (per `docs/designs/SLATE_HOST.md`, Slate reads `.claude/skills` as a compatibility fallback) (#2361).
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- `gstack-evidence` scrubs bun-auto-loaded dotenv vars from the child env by value equality — a shell-exported override with a different value survives, `NODE_ENV=test` semantics mirror bun's, and an unreadable `.env` fails open (test-pinned). Known limitation documented in-code: bun-expanded `${VAR}` values are left in place (fails open). Contributed by @namtrok (#2652; fixes #2624; @harjothkhara's #2630 credited for the parallel diagnosis).
|
||||||
|
- Wireup first sync targets the registered source id, never the default source (#2662); support-probed with a warning fallback so gbrains at the 0.18.0 floor keep working.
|
||||||
|
- `cleanup_old_claude_symlinks` reaps orphans from the DESTINATION skills dir (dangling symlinks included) with path-segment provenance instead of a bare `*gstack*` substring. Contributed by @szsunyuan (#2634; fixes #2204).
|
||||||
|
- `gstack-memory-ingest` stores the normalized `_unattributed` remote so repo policies keyed to it actually apply under `--include-unattributed` (#2353).
|
||||||
|
- Hermetic gbrain-missing PATH fixture kills a false green on every machine with a real gbrain install. Contributed by @SomSamantray (#2615; fixes #2255).
|
||||||
|
|
||||||
|
#### For contributors
|
||||||
|
- Tests: 8,036 → 8,078 (+42 across the wave; every behavior fix carries a regression test proven red on v1.68.3.0).
|
||||||
|
- The heredoc scanner now gates every tracked shell script — new scripts with 512B–64KiB heredoc bodies need the `BASH_COMPAT=50` guard (or smaller/file-based bodies).
|
||||||
|
- On bash 4.3/4.4 (e.g. Git Bash), `BASH_COMPAT=50` prints a non-fatal `invalid value` stderr warning; those bashes never took the pipe path, so the guard is a no-op there.
|
||||||
|
|
||||||
## [1.68.3.0] - 2026-08-20
|
## [1.68.3.0] - 2026-08-20
|
||||||
|
|
||||||
**Re-pairing a browser agent to narrow its access now revokes the old access on**
|
**Re-pairing a browser agent to narrow its access now revokes the old access on**
|
||||||
|
|||||||
@@ -5,13 +5,17 @@
|
|||||||
### P1: ZeroEntropy sunset — gbrain's default embedding provider dies Sept 4, 2026 (#2365)
|
### P1: ZeroEntropy sunset — gbrain's default embedding provider dies Sept 4, 2026 (#2365)
|
||||||
|
|
||||||
**What:** ZeroEntropy (acquired by Notion) shuts down September 4, 2026. gbrain's
|
**What:** ZeroEntropy (acquired by Notion) shuts down September 4, 2026. gbrain's
|
||||||
default embedding provider needs a migration path before then; gstack's
|
zeroentropyai recipe needs a migration path before then (the recipe + gateway
|
||||||
setup-gbrain flow should stop recommending it and detect/warn existing installs.
|
shim are gbrain-internal — nothing in gstack ever recommended the provider).
|
||||||
|
|
||||||
**Why:** Hard external deadline. After Sept 4, fresh setup-gbrain runs against the
|
**Why:** Hard external deadline. After Sept 4, brains on the recipe stop
|
||||||
default provider fail, and existing brains stop embedding new pages silently.
|
embedding new pages silently.
|
||||||
|
|
||||||
**Effort:** M (human ~2d, CC ~1h — mostly gbrain-side; gstack side is detect+warn).
|
**Done (gstack side, v1.69.0.0):** wireup warns when ~/.gbrain/config.json names
|
||||||
|
the recipe (fail-open grep), setup-gbrain provider comments say never to select
|
||||||
|
it, USING_GBRAIN_WITH_GSTACK.md gained a troubleshooting entry (#2365).
|
||||||
|
|
||||||
|
**Effort:** M (remaining work is gbrain-side provider support).
|
||||||
**Priority:** P1 (calendar-driven). **Depends on:** gbrain upstream provider support.
|
**Priority:** P1 (calendar-driven). **Depends on:** gbrain upstream provider support.
|
||||||
|
|
||||||
### P2: v1.67 fix-wave deferrals — next-wave queue
|
### P2: v1.67 fix-wave deferrals — next-wave queue
|
||||||
@@ -45,6 +49,30 @@ wave"). Each was explicitly deferred with rationale, not dropped:
|
|||||||
#2576 (fast-ship rework — re-evaluate against v1.66's CI speedup),
|
#2576 (fast-ship rework — re-evaluate against v1.66's CI speedup),
|
||||||
#2580 (land-and-deploy CI tiers — human-gate UX needs maintainer call).
|
#2580 (land-and-deploy CI tiers — human-gate UX needs maintainer call).
|
||||||
|
|
||||||
|
### P2: v1.69 fix-wave residuals (filed at wave time, each deferred with rationale)
|
||||||
|
|
||||||
|
- **`cleanup_prefixed_claude_symlinks` symmetric conversion** — PR #2634 fixed
|
||||||
|
`cleanup_old_claude_symlinks` (destination scan, dangling-symlink aware,
|
||||||
|
path-segment provenance); the prefixed-mode sibling still iterates the
|
||||||
|
payload dir (same structural hole: can't reap orphans once the payload is
|
||||||
|
gone) and still uses a bare `*gstack*` substring match the sibling's own
|
||||||
|
tests forbid. Kept out of the contributor's absorbed commit for scope
|
||||||
|
discipline. Effort S→S with CC. **Priority:** P2.
|
||||||
|
- **#2163 legacy-slug checkpoint heal** — the gstack-slug refactor unified
|
||||||
|
save/restore slugs, but checkpoints written under a pre-fix degraded slug
|
||||||
|
are still invisible; `bin/gstack-slug`'s own MIGRATION NOTE defers data
|
||||||
|
moves. Cheap heal: restore-side probe of the alternate slug dir before
|
||||||
|
printing NO_CHECKPOINTS. Effort S. **Priority:** P3.
|
||||||
|
- **#2657 developer-profile `--reconcile`** — office-hours tenure undercounts
|
||||||
|
~3x (Phase-4.5-only logging; no timeline.jsonl reconciliation). The
|
||||||
|
arithmetic reproduces; the reporter offered the PR — invited on the issue.
|
||||||
|
Track and review when it lands. Effort S (review). **Priority:** P3.
|
||||||
|
- **Table-driven setup host dispatch from `hosts/index.ts`** — root-cause fix
|
||||||
|
for the accept-list/dispatch drift class behind #2361; v1.69.0.0 ships the
|
||||||
|
interim ratchet (accept-list ⊆ dispatch-arms cross-check test + a loud
|
||||||
|
zero-dispatch guard). The refactor needs its own PR with bake time (setup is
|
||||||
|
the riskiest file in the repo). Effort M. **Priority:** P3.
|
||||||
|
|
||||||
### P2: v1.67 adversarial-review residuals (verified, deferred with rationale)
|
### P2: v1.67 adversarial-review residuals (verified, deferred with rationale)
|
||||||
|
|
||||||
Filed at v1.67 ship time from the Codex + Claude adversarial passes. Six of
|
Filed at v1.67 ship time from the Codex + Claude adversarial passes. Six of
|
||||||
@@ -849,14 +877,6 @@ TOML lookup.
|
|||||||
will not (the hint covers the second half today).
|
will not (the hint covers the second half today).
|
||||||
**Priority:** P3. **Effort:** S.
|
**Priority:** P3. **Effort:** S.
|
||||||
|
|
||||||
### P4: `./setup --host slate` accepted but installs nothing
|
|
||||||
|
|
||||||
**What:** `slate` passes the host-arg validation case but sets no INSTALL_* flag,
|
|
||||||
so the run configures nothing and exits successfully. Either wire a slate branch
|
|
||||||
or reject the value with guidance like openclaw/hermes/gbrain get.
|
|
||||||
**Why:** Silent success with zero effect is the worst failure shape.
|
|
||||||
**Priority:** P4. **Effort:** S.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## browse server: terminal-agent teardown follow-ups (filed v1.41 via /plan-eng-review)
|
## browse server: terminal-agent teardown follow-ups (filed v1.41 via /plan-eng-review)
|
||||||
@@ -2786,6 +2806,31 @@ needs one paid run to validate, so it didn't ride the ship.
|
|||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
|
|
||||||
|
### ✅ DONE (v1.69.0.0): `./setup --host slate` accepted but installs nothing
|
||||||
|
|
||||||
|
**Priority:** P4 (was filed as slate-only — shipped with the whole drift class gated)
|
||||||
|
|
||||||
|
**What:** `slate` passed host-arg validation but set no INSTALL_* flag, so the
|
||||||
|
run configured nothing and exited 0. Now an informational arm (points at
|
||||||
|
`--host claude`; per docs/designs/SLATE_HOST.md Slate reads `.claude/skills`
|
||||||
|
as a compatibility fallback), plus a zero-dispatch guard that errors loudly if
|
||||||
|
any future host is accepted without an install arm, plus a cross-check test
|
||||||
|
pinning accept-list ⊆ dispatch-arms against the hosts/index.ts registry.
|
||||||
|
|
||||||
|
**Completed:** v1.69.0.0 (2026-08-22)
|
||||||
|
|
||||||
|
### ✅ DONE (v1.69.0.0, gstack side): ZeroEntropy sunset detect + advisory
|
||||||
|
|
||||||
|
**Priority:** P1 (calendar-driven; gbrain-side migration remains open — see
|
||||||
|
NEXT PRIORITY)
|
||||||
|
|
||||||
|
**What:** Wireup warns when ~/.gbrain/config.json names the zeroentropyai
|
||||||
|
recipe (fail-open grep — never blocks a working setup); setup-gbrain provider
|
||||||
|
comments say never to select the legacy recipe; USING_GBRAIN_WITH_GSTACK.md
|
||||||
|
troubleshooting entry names the Sept 4, 2026 deadline and #2365.
|
||||||
|
|
||||||
|
**Completed:** v1.69.0.0 (2026-08-22)
|
||||||
|
|
||||||
### ✅ DONE (v1.68.1.0): Stop-hook registration pins the setup-time absolute path
|
### ✅ DONE (v1.68.1.0): Stop-hook registration pins the setup-time absolute path
|
||||||
|
|
||||||
**Priority:** P1 (was filed Effort S, scoped to the Stop hook — shipped as the full defect class)
|
**Priority:** P1 (was filed Effort S, scoped to the Stop hook — shipped as the full defect class)
|
||||||
|
|||||||
@@ -364,6 +364,10 @@ gbrain sync --source <source-id> --skip-failed
|
|||||||
|
|
||||||
Watermark advances past the offending commit. The same file fails again if it changes; re-skip when that happens.
|
Watermark advances past the offending commit. The same file fails again if it changes; re-skip when that happens.
|
||||||
|
|
||||||
|
### ZeroEntropy embeddings stop working after September 4, 2026
|
||||||
|
|
||||||
|
ZeroEntropy was acquired by Notion and sunsets its hosted API on **September 4, 2026** (new signups already disabled). A gbrain configured with the `zeroentropyai` embedding recipe keeps importing pages after that date, but embedding silently fails — pages land structurally with no semantic search. The wireup helper warns when your `~/.gbrain/config.json` names the recipe; migrate to another provider (Voyage via `VOYAGE_API_KEY`, or OpenAI via `OPENAI_API_KEY`) before the deadline. Details, self-hosting caveats, and migration discussion: [garrytan/gstack#2365](https://github.com/garrytan/gstack/issues/2365).
|
||||||
|
|
||||||
### Switching PGLite → Supabase hangs
|
### Switching PGLite → Supabase hangs
|
||||||
|
|
||||||
Another gstack session in a sibling Conductor workspace may be holding a lock on your local PGLite file via its preamble's `gstack-brain-sync` call. Close other workspaces, re-run `/setup-gbrain --switch`. The timeout is bounded at 180s so you'll never actually wait forever.
|
Another gstack session in a sibling Conductor workspace may be holding a lock on your local PGLite file via its preamble's `gstack-brain-sync` call. Close other workspaces, re-run `/setup-gbrain --switch`. The timeout is bounded at 180s so you'll never actually wait forever.
|
||||||
|
|||||||
@@ -34,6 +34,16 @@
|
|||||||
# GSTACK_HOME — override ~/.gstack
|
# GSTACK_HOME — override ~/.gstack
|
||||||
# USER — fallback for repo naming if $USER is unset
|
# USER — fallback for repo naming if $USER is unset
|
||||||
|
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
||||||
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
||||||
|
# bypass the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||||
|
|||||||
@@ -25,6 +25,16 @@
|
|||||||
# Env:
|
# Env:
|
||||||
# GSTACK_HOME — override ~/.gstack
|
# GSTACK_HOME — override ~/.gstack
|
||||||
|
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
||||||
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
||||||
|
# bypass the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||||
|
|||||||
@@ -17,6 +17,16 @@
|
|||||||
# Env:
|
# Env:
|
||||||
# GSTACK_HOME — override ~/.gstack (aligns with writers).
|
# GSTACK_HOME — override ~/.gstack (aligns with writers).
|
||||||
|
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
||||||
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
||||||
|
# bypass the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
* validateDecide; a rejected decision exits 1 with a message, nothing persisted.
|
* validateDecide; a rejected decision exits 1 with a message, nothing persisted.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mkdirSync } from "fs";
|
|
||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
|
import { mkdirpSync } from "../lib/fs-utils";
|
||||||
import { spawnSync } from "child_process";
|
import { spawnSync } from "child_process";
|
||||||
import {
|
import {
|
||||||
decisionPaths,
|
decisionPaths,
|
||||||
@@ -33,7 +33,9 @@ const HERE = import.meta.dir;
|
|||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const slug = resolveSlug(`${HERE}/gstack-slug`);
|
const slug = resolveSlug(`${HERE}/gstack-slug`);
|
||||||
const paths = decisionPaths(slug);
|
const paths = decisionPaths(slug);
|
||||||
mkdirSync(dirname(paths.log), { recursive: true });
|
// mkdirpSync, not bare mkdirSync: bun on Windows throws EEXIST from a
|
||||||
|
// recursive mkdir on an existing dir (#2635), and this runs on every log call.
|
||||||
|
mkdirpSync(dirname(paths.log));
|
||||||
|
|
||||||
function enqueue(): void {
|
function enqueue(): void {
|
||||||
// Fire-and-forget cross-machine sync (no-op when artifacts_sync is off).
|
// Fire-and-forget cross-machine sync (no-op when artifacts_sync is off).
|
||||||
|
|||||||
@@ -20,6 +20,16 @@
|
|||||||
# auditability via --status when you want it.
|
# auditability via --status when you want it.
|
||||||
# Per D6: Anthropic SDK direct call, fail-loud on missing ANTHROPIC_API_KEY.
|
# Per D6: Anthropic SDK direct call, fail-loud on missing ANTHROPIC_API_KEY.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
||||||
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
||||||
|
# bypass the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
||||||
|
|||||||
+102
-4
@@ -34,7 +34,8 @@
|
|||||||
* in the ledger; it cannot prove that an expected lane ever ran.
|
* in the ledger; it cannot prove that an expected lane ever ran.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mkdirSync, openSync, writeSync, closeSync, readdirSync, statSync, unlinkSync, chmodSync } from "fs";
|
import { openSync, writeSync, closeSync, readdirSync, statSync, unlinkSync, chmodSync, readFileSync } from "fs";
|
||||||
|
import { mkdirpSync } from "../lib/fs-utils";
|
||||||
import { join, dirname } from "path";
|
import { join, dirname } from "path";
|
||||||
import { spawnSync } from "child_process";
|
import { spawnSync } from "child_process";
|
||||||
import { appendJsonl, readJsonl } from "../lib/jsonl-store";
|
import { appendJsonl, readJsonl } from "../lib/jsonl-store";
|
||||||
@@ -137,7 +138,7 @@ function pruneOldLogs(logsDir: string): void {
|
|||||||
/** Exclusive-open a collision-safe log file. Returns undefined on failure. */
|
/** Exclusive-open a collision-safe log file. Returns undefined on failure. */
|
||||||
function openLog(logsDir: string, label: string, cmdSha: string): { fd: number; path: string } | undefined {
|
function openLog(logsDir: string, label: string, cmdSha: string): { fd: number; path: string } | undefined {
|
||||||
try {
|
try {
|
||||||
mkdirSync(logsDir, { recursive: true });
|
mkdirpSync(logsDir);
|
||||||
pruneOldLogs(logsDir);
|
pruneOldLogs(logsDir);
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const base = `${ts}-${label}-${process.pid}-${cmdSha.slice(0, 8)}`;
|
const base = `${ts}-${label}-${process.pid}-${cmdSha.slice(0, 8)}`;
|
||||||
@@ -154,6 +155,97 @@ function openLog(logsDir: string, label: string, cmdSha: string): { fd: number;
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bun AUTO-LOADS `.env`, `.env.<NODE_ENV>` and `.env.local` from the working
|
||||||
|
* directory into `process.env`. This file has a `#!/usr/bin/env bun` shebang, so
|
||||||
|
* every command it spawns inherits those variables — and a repo `.env.local`
|
||||||
|
* routinely holds PRODUCTION credentials.
|
||||||
|
*
|
||||||
|
* Two things go wrong, and the second one is worse than the leak:
|
||||||
|
*
|
||||||
|
* 1. Secrets reach a child process that would not have had them. `npm test` run
|
||||||
|
* by hand in the same shell sees none of this; run through the wrapper it sees
|
||||||
|
* all of it.
|
||||||
|
* 2. THE COMMAND UNDER TEST BEHAVES DIFFERENTLY, so the evidence ledger
|
||||||
|
* certifies a run that is not the run CI performs. Observed in a Next.js repo
|
||||||
|
* 2026-08-20: four tests failed 4/4 through the wrapper and passed 5/5 without
|
||||||
|
* it, because app code branched on env vars only the wrapper supplied. The
|
||||||
|
* wrapper exists to record trustworthy evidence, so silently changing the
|
||||||
|
* environment defeats its whole purpose.
|
||||||
|
*
|
||||||
|
* Verified against bun 1.3.11: a dotenv file does NOT override a variable the
|
||||||
|
* shell already exported (the shell's value wins). So a key whose live value is
|
||||||
|
* exactly the dotenv file's value was injected by bun, and dropping it restores
|
||||||
|
* the environment the user's own shell would have given the command.
|
||||||
|
*
|
||||||
|
* Escape hatch: GSTACK_EVIDENCE_KEEP_DOTENV=1 keeps the old behaviour for anyone
|
||||||
|
* who really does want the wrapper to supply .env values.
|
||||||
|
*/
|
||||||
|
const BUN_DOTENV_FILES = (): string[] => {
|
||||||
|
const nodeEnv = process.env.NODE_ENV;
|
||||||
|
// bun's documented precedence, lowest first. `.env.local` is skipped by bun
|
||||||
|
// when NODE_ENV is "test"; mirror that rather than guessing.
|
||||||
|
const files = [".env"];
|
||||||
|
if (nodeEnv) files.push(`.env.${nodeEnv}`);
|
||||||
|
if (nodeEnv !== "test") files.push(".env.local");
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Minimal dotenv reader: KEY=VALUE, one per line. Quotes stripped, comments and
|
||||||
|
* `export ` prefixes tolerated. Multi-line values are not parsed — a key we fail
|
||||||
|
* to parse is simply left in the child env, which is the safe direction.
|
||||||
|
* Known limitation: bun EXPANDS ${VAR} references inside dotenv values, but this
|
||||||
|
* reader compares the raw file text, so an expanded live value never matches and
|
||||||
|
* that key is left in the child env — the pre-scrub behavior persists for those
|
||||||
|
* keys (fails open, same safe direction as above). */
|
||||||
|
function parseDotenv(text: string): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const raw of text.split(/\r?\n/)) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
const eq = line.indexOf("=");
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
let key = line.slice(0, eq).trim();
|
||||||
|
if (key.startsWith("export ")) key = key.slice(7).trim();
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||||
|
let val = line.slice(eq + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"') && val.length > 1) ||
|
||||||
|
(val.startsWith("'") && val.endsWith("'") && val.length > 1)) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
out.set(key, val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** process.env minus the variables bun injected from the repo's dotenv files.
|
||||||
|
* Returns the scrubbed env and the KEY NAMES removed (never the values). */
|
||||||
|
function childEnv(cwd: string): { env: Record<string, string>, scrubbed: string[] } {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
|
||||||
|
if (process.env.GSTACK_EVIDENCE_KEEP_DOTENV === "1") return { env, scrubbed: [] };
|
||||||
|
|
||||||
|
const scrubbed: string[] = [];
|
||||||
|
for (const file of BUN_DOTENV_FILES()) {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = readFileSync(join(cwd, file), "utf-8");
|
||||||
|
} catch {
|
||||||
|
continue; // absent or unreadable — nothing to scrub from it
|
||||||
|
}
|
||||||
|
for (const [k, v] of parseDotenv(text)) {
|
||||||
|
// Only when the live value IS the file's value. A different live value means
|
||||||
|
// the shell exported its own and bun left it alone, so it is genuinely the
|
||||||
|
// user's environment and must survive.
|
||||||
|
if (env[k] !== undefined && env[k] === v) {
|
||||||
|
delete env[k];
|
||||||
|
if (!scrubbed.includes(k)) scrubbed.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { env, scrubbed };
|
||||||
|
}
|
||||||
|
|
||||||
async function cmdRun(argv: string[]): Promise<number> {
|
async function cmdRun(argv: string[]): Promise<number> {
|
||||||
let label = "default";
|
let label = "default";
|
||||||
const li = argv.indexOf("--label");
|
const li = argv.indexOf("--label");
|
||||||
@@ -175,7 +267,7 @@ async function cmdRun(argv: string[]): Promise<number> {
|
|||||||
let paths: ReturnType<typeof ledgerPath> | undefined;
|
let paths: ReturnType<typeof ledgerPath> | undefined;
|
||||||
try {
|
try {
|
||||||
paths = ledgerPath();
|
paths = ledgerPath();
|
||||||
mkdirSync(paths.dir, { recursive: true });
|
mkdirpSync(paths.dir);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
warn(`ledger setup failed (${e?.message ?? e}) — result will not be recorded`);
|
warn(`ledger setup failed (${e?.message ?? e}) — result will not be recorded`);
|
||||||
}
|
}
|
||||||
@@ -189,7 +281,13 @@ async function cmdRun(argv: string[]): Promise<number> {
|
|||||||
let exitCode: number;
|
let exitCode: number;
|
||||||
let proc: ReturnType<typeof Bun.spawn> | undefined;
|
let proc: ReturnType<typeof Bun.spawn> | undefined;
|
||||||
try {
|
try {
|
||||||
proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe" });
|
const { env: spawnEnv, scrubbed } = childEnv(process.cwd());
|
||||||
|
if (scrubbed.length > 0) {
|
||||||
|
// Names only. Printing values here would defeat the point.
|
||||||
|
warn(`scrubbed ${scrubbed.length} bun-injected dotenv var(s) from the child env: ${scrubbed.join(", ")} `
|
||||||
|
+ `(GSTACK_EVIDENCE_KEEP_DOTENV=1 to keep them)`);
|
||||||
|
}
|
||||||
|
proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe", env: spawnEnv });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127.
|
// Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127.
|
||||||
exitCode = 127;
|
exitCode = 127;
|
||||||
|
|||||||
@@ -316,6 +316,15 @@ do_wireup() {
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# ZeroEntropy sunset advisory (#2365): the provider shuts down Sept 4, 2026,
|
||||||
|
# after which brains on gbrain's zeroentropyai recipe stop embedding new
|
||||||
|
# pages silently. Detection is a fail-open grep of gbrain's config — any
|
||||||
|
# missing/unreadable/other-provider config stays silent (grep -qs), never
|
||||||
|
# blocking a working setup.
|
||||||
|
if grep -qsi 'zeroentropyai' "$GBRAIN_CONFIG" 2>/dev/null; then
|
||||||
|
warn "gbrain config appears to use the ZeroEntropy embedding recipe. ZeroEntropy sunsets on September 4, 2026 — after that, new pages stop embedding silently. Migration options: https://github.com/garrytan/gstack/issues/2365"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "${GSTACK_BRAIN_NO_SYNC:-0}" = "1" ]; then
|
if [ "${GSTACK_BRAIN_NO_SYNC:-0}" = "1" ]; then
|
||||||
echo "source_id=$id"
|
echo "source_id=$id"
|
||||||
echo "worktree=$WORKTREE"
|
echo "worktree=$WORKTREE"
|
||||||
@@ -323,8 +332,21 @@ do_wireup() {
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# #2662: `sync --repo <path>` resolves against the brain's DEFAULT source and
|
||||||
|
# can silently repoint that source's local_path anchor at our worktree while
|
||||||
|
# the source registered above gets nothing. Target the registered source by
|
||||||
|
# id. `--source` support is probed first (the documented floor is gbrain >=
|
||||||
|
# 0.18.0 and nothing proves the flag exists there): an older gbrain keeps the
|
||||||
|
# wrong-but-working --repo call with an upgrade warning, never a hard failure.
|
||||||
local sync_out sync_redacted
|
local sync_out sync_redacted
|
||||||
sync_out=$(gbrain sync --repo "$WORKTREE" 2>&1) || {
|
local -a sync_cmd
|
||||||
|
if gbrain sync --help 2>/dev/null | grep -q -- '--source'; then
|
||||||
|
sync_cmd=(gbrain sync --source "$id")
|
||||||
|
else
|
||||||
|
warn "this gbrain's sync lacks --source; falling back to 'sync --repo' (upgrade gbrain so the sync targets source $id directly — #2662)"
|
||||||
|
sync_cmd=(gbrain sync --repo "$WORKTREE")
|
||||||
|
fi
|
||||||
|
sync_out=$("${sync_cmd[@]}" 2>&1) || {
|
||||||
# Redact any postgres:// URLs from the error message in case gbrain logged
|
# Redact any postgres:// URLs from the error message in case gbrain logged
|
||||||
# a connection error containing the full DSN with password. The user sees
|
# a connection error containing the full DSN with password. The user sees
|
||||||
# "***REDACTED***" instead of credentials in their stderr or any log.
|
# "***REDACTED***" instead of credentials in their stderr or any log.
|
||||||
|
|||||||
@@ -24,6 +24,16 @@
|
|||||||
# 0 — merge succeeded, result written to <ours>
|
# 0 — merge succeeded, result written to <ours>
|
||||||
# 1 — error; git treats as conflict and stops the merge
|
# 1 — error; git treats as conflict and stops the merge
|
||||||
|
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
||||||
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
||||||
|
# bypass the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
if [ "$#" -lt 3 ]; then
|
if [ "$#" -lt 3 ]; then
|
||||||
|
|||||||
@@ -788,7 +788,11 @@ function buildTranscriptPage(path: string, session: ParsedSession): PageRecord {
|
|||||||
source_path: path,
|
source_path: path,
|
||||||
session_id: session.session_id,
|
session_id: session.session_id,
|
||||||
cwd: session.cwd,
|
cwd: session.cwd,
|
||||||
git_remote: remote,
|
// Store the normalized sentinel, matching the frontmatter above: a raw ""
|
||||||
|
// is falsy and slid through the policy filter's !p.git_remote fast-path,
|
||||||
|
// so under --include-unattributed a `_unattributed → deny` policy never
|
||||||
|
// applied to exactly the pages it names (#2353).
|
||||||
|
git_remote: remote || "_unattributed",
|
||||||
start_time: session.start_time,
|
start_time: session.start_time,
|
||||||
end_time: session.end_time,
|
end_time: session.end_time,
|
||||||
partial: session.partial,
|
partial: session.partial,
|
||||||
|
|||||||
+4
-1
@@ -37,6 +37,7 @@ import {
|
|||||||
type ScanOptions,
|
type ScanOptions,
|
||||||
type Finding,
|
type Finding,
|
||||||
} from "../lib/redact-engine";
|
} from "../lib/redact-engine";
|
||||||
|
import { mkdirpSync } from "../lib/fs-utils";
|
||||||
|
|
||||||
const MAX_STDIN_BYTES = 16 * 1024 * 1024; // hard ceiling before the engine cap
|
const MAX_STDIN_BYTES = 16 * 1024 * 1024; // hard ceiling before the engine cap
|
||||||
|
|
||||||
@@ -55,7 +56,9 @@ function hooksPath(): string {
|
|||||||
|
|
||||||
function installPrepushHook(): void {
|
function installPrepushHook(): void {
|
||||||
const dir = hooksPath();
|
const dir = hooksPath();
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
// mkdirpSync, not bare mkdirSync: bun on Windows throws EEXIST from a
|
||||||
|
// recursive mkdir when .git/hooks already exists (#2635).
|
||||||
|
mkdirpSync(dir);
|
||||||
const hookPath = path.join(dir, "pre-push");
|
const hookPath = path.join(dir, "pre-push");
|
||||||
const prepushBin = path.join(import.meta.dir, "gstack-redact-prepush");
|
const prepushBin = path.join(import.meta.dir, "gstack-redact-prepush");
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import * as fs from "fs";
|
|||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { scan, type Finding } from "../lib/redact-engine";
|
import { scan, type Finding } from "../lib/redact-engine";
|
||||||
|
import { mkdirpSync } from "../lib/fs-utils";
|
||||||
|
|
||||||
const ZERO = /^0+$/;
|
const ZERO = /^0+$/;
|
||||||
// The canonical empty-tree object; diffing against it yields all content as added.
|
// The canonical empty-tree object; diffing against it yields all content as added.
|
||||||
@@ -349,7 +350,10 @@ function logSkip(reason: string): void {
|
|||||||
try {
|
try {
|
||||||
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
|
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
|
||||||
const dir = path.join(home, "security");
|
const dir = path.join(home, "security");
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
// mkdirpSync, not bare mkdirSync: bun-on-Windows EEXIST (#2635). This site
|
||||||
|
// is try-wrapped by the caller, so the old failure was a silent skip-log
|
||||||
|
// loss rather than a crash — the fix makes the log survive, not un-crash.
|
||||||
|
mkdirpSync(dir);
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
path.join(dir, "prepush-skip.jsonl"),
|
path.join(dir, "prepush-skip.jsonl"),
|
||||||
JSON.stringify({ ts: new Date().toISOString(), reason }) + "\n",
|
JSON.stringify({ ts: new Date().toISOString(), reason }) + "\n",
|
||||||
|
|||||||
@@ -54,6 +54,16 @@
|
|||||||
# on disk. `rollback` is a single-step undo of the last real mutation.
|
# on disk. `rollback` is a single-step undo of the last real mutation.
|
||||||
# - writes are atomic: unique tmp file + rename (a fixed tmp name would let
|
# - writes are atomic: unique tmp file + rename (a fixed tmp name would let
|
||||||
# two concurrent writers rename a half-written file into place).
|
# two concurrent writers rename a half-written file into place).
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
||||||
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
||||||
|
# bypass the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ACTION="${1:-}"
|
ACTION="${1:-}"
|
||||||
|
|||||||
@@ -7,6 +7,16 @@
|
|||||||
#
|
#
|
||||||
# Run from the root of your team's repo (not from the gstack directory).
|
# Run from the root of your team's repo (not from the gstack directory).
|
||||||
|
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
||||||
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
||||||
|
# bypass the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
MODE="${1:-}"
|
MODE="${1:-}"
|
||||||
|
|||||||
@@ -24,6 +24,16 @@
|
|||||||
# the brain admin to run on the brain host
|
# the brain admin to run on the brain host
|
||||||
#
|
#
|
||||||
# All steps are idempotent. Re-running after partial completion is safe.
|
# All steps are idempotent. Re-running after partial completion is safe.
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
||||||
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
||||||
|
# bypass the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [ -z "${HOME:-}" ]; then
|
if [ -z "${HOME:-}" ]; then
|
||||||
|
|||||||
@@ -21,6 +21,16 @@
|
|||||||
# on completion. Re-running this script is silent if the touchfile exists,
|
# on completion. Re-running this script is silent if the touchfile exists,
|
||||||
# OR if local_code_index_offered=true.
|
# OR if local_code_index_offered=true.
|
||||||
|
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
||||||
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
||||||
|
# bypass the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [ -z "${HOME:-}" ]; then
|
if [ -z "${HOME:-}" ]; then
|
||||||
|
|||||||
@@ -1620,6 +1620,19 @@ Identify candidates: a worktree is stale if (a) it is checked out on the base br
|
|||||||
- If any candidate has uncommitted work: list the files, tell the user, and STOP worktree cleanup without removing anything.
|
- If any candidate has uncommitted work: list the files, tell the user, and STOP worktree cleanup without removing anything.
|
||||||
- Do NOT use `--force`. Do NOT remove the user's primary working tree.
|
- Do NOT use `--force`. Do NOT remove the user's primary working tree.
|
||||||
|
|
||||||
|
Remote-branch reconciliation — the failed `gh pr merge` carried `--delete-branch`, and this recovery path must not silently drop that half. The success path above says "The branch has been cleaned up"; this path states the branch outcome explicitly instead of staying silent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BRANCH=$(gh pr view --json headRefName -q .headRefName)
|
||||||
|
git ls-remote --heads origin "$BRANCH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Three outcomes — never read a failed check as a clean branch:
|
||||||
|
|
||||||
|
- **Exit 0, empty output** — the remote branch is already gone (GitHub's post-merge deletion or a concurrent actor got there). Tell the user: "The remote branch has already been cleaned up." This makes re-runs of the recovery idempotent.
|
||||||
|
- **Exit 0, one ref line** — the branch survived: the failed merge command never reached its `--delete-branch` half. OFFER deletion, confirm-first (matching the worktree-cleanup posture above): "The remote branch `<BRANCH>` still exists — the failed merge never ran its --delete-branch half. Delete it?" Only on confirmation: `git push origin --delete "$BRANCH"`. If a local branch of the same name exists, offer `git branch -d "$BRANCH"` alongside (`-d`, never `-D` — a non-fast-forwarded local branch is the user's call).
|
||||||
|
- **Non-zero exit** — the check ITSELF failed (network, auth). Tell the user: "Couldn't verify remote branch state — leaving it alone." and skip the deletion offer entirely; a failed check is unknown state, not a clean branch.
|
||||||
|
|
||||||
Record `MERGE_PATH=direct`, then continue to §4a (CI auto-deploy detection).
|
Record `MERGE_PATH=direct`, then continue to §4a (CI auto-deploy detection).
|
||||||
|
|
||||||
**If `state == "OPEN"`:**
|
**If `state == "OPEN"`:**
|
||||||
|
|||||||
@@ -700,6 +700,19 @@ Identify candidates: a worktree is stale if (a) it is checked out on the base br
|
|||||||
- If any candidate has uncommitted work: list the files, tell the user, and STOP worktree cleanup without removing anything.
|
- If any candidate has uncommitted work: list the files, tell the user, and STOP worktree cleanup without removing anything.
|
||||||
- Do NOT use `--force`. Do NOT remove the user's primary working tree.
|
- Do NOT use `--force`. Do NOT remove the user's primary working tree.
|
||||||
|
|
||||||
|
Remote-branch reconciliation — the failed `gh pr merge` carried `--delete-branch`, and this recovery path must not silently drop that half. The success path above says "The branch has been cleaned up"; this path states the branch outcome explicitly instead of staying silent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BRANCH=$(gh pr view --json headRefName -q .headRefName)
|
||||||
|
git ls-remote --heads origin "$BRANCH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Three outcomes — never read a failed check as a clean branch:
|
||||||
|
|
||||||
|
- **Exit 0, empty output** — the remote branch is already gone (GitHub's post-merge deletion or a concurrent actor got there). Tell the user: "The remote branch has already been cleaned up." This makes re-runs of the recovery idempotent.
|
||||||
|
- **Exit 0, one ref line** — the branch survived: the failed merge command never reached its `--delete-branch` half. OFFER deletion, confirm-first (matching the worktree-cleanup posture above): "The remote branch `<BRANCH>` still exists — the failed merge never ran its --delete-branch half. Delete it?" Only on confirmation: `git push origin --delete "$BRANCH"`. If a local branch of the same name exists, offer `git branch -d "$BRANCH"` alongside (`-d`, never `-D` — a non-fast-forwarded local branch is the user's call).
|
||||||
|
- **Non-zero exit** — the check ITSELF failed (network, auth). Tell the user: "Couldn't verify remote branch state — leaving it alone." and skip the deletion offer entirely; a failed check is unknown state, not a clean branch.
|
||||||
|
|
||||||
Record `MERGE_PATH=direct`, then continue to §4a (CI auto-deploy detection).
|
Record `MERGE_PATH=direct`, then continue to §4a (CI auto-deploy detection).
|
||||||
|
|
||||||
**If `state == "OPEN"`:**
|
**If `state == "OPEN"`:**
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { mkdirSync, statSync } from "fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mkdir -p that tolerates the target directory already existing.
|
||||||
|
*
|
||||||
|
* Node's mkdirSync(dir, { recursive: true }) is a no-op when dir already
|
||||||
|
* exists, but bun on Windows throws EEXIST in the same situation (#2635),
|
||||||
|
* which crashed `gstack-redact install-prepush-hook` on any repo whose
|
||||||
|
* .git/hooks already existed. Swallow EEXIST only when statSync confirms the
|
||||||
|
* path is an existing directory; anything else - a regular file occupying the
|
||||||
|
* path, a stat failure, a different errno - rethrows the original error, so a
|
||||||
|
* real collision still fails loudly.
|
||||||
|
*/
|
||||||
|
export function mkdirpSync(dir: string): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as NodeJS.ErrnoException | null)?.code === "EEXIST") {
|
||||||
|
try {
|
||||||
|
if (statSync(dir).isDirectory()) return;
|
||||||
|
} catch {
|
||||||
|
// stat failed - fall through and rethrow the original mkdir error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@
|
|||||||
* Binary resolution order (Codex round 2 #4, v1.24-aligned):
|
* Binary resolution order (Codex round 2 #4, v1.24-aligned):
|
||||||
* 1. $GSTACK_BROWSE_BIN env override (preferred, matches v1.24 GSTACK_*_BIN pattern)
|
* 1. $GSTACK_BROWSE_BIN env override (preferred, matches v1.24 GSTACK_*_BIN pattern)
|
||||||
* 2. $BROWSE_BIN env override (back-compat alias)
|
* 2. $BROWSE_BIN env override (back-compat alias)
|
||||||
* 3. sibling dir: dirname(argv[0])/../browse/dist/browse[.exe]
|
* 3. sibling dir: dirname(execPath)/../browse/dist/browse[.exe]
|
||||||
|
* (execPath, NOT argv[0]: in a bun-compiled binary argv[0] is the raw
|
||||||
|
* invocation string — often relative, so dirname() yields "." and the
|
||||||
|
* sibling candidates resolve against the CWD instead of the install
|
||||||
|
* dir; #2156. execPath is always the absolute binary path.)
|
||||||
* 4. ~/.claude/skills/gstack/browse/dist/browse[.exe]
|
* 4. ~/.claude/skills/gstack/browse/dist/browse[.exe]
|
||||||
* 5. PATH lookup via Bun.which('browse') — handles Windows PATHEXT natively
|
* 5. PATH lookup via Bun.which('browse') — handles Windows PATHEXT natively
|
||||||
* 6. error with setup hint
|
* 6. error with setup hint
|
||||||
@@ -101,14 +105,21 @@ export function findExecutable(base: string): string | null {
|
|||||||
* Locate the browse binary. Throws a BrowseClientError with a
|
* Locate the browse binary. Throws a BrowseClientError with a
|
||||||
* canonical setup message if not found. See header for resolution order.
|
* canonical setup message if not found. See header for resolution order.
|
||||||
*/
|
*/
|
||||||
export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env): string {
|
export function resolveBrowseBin(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
// Injectable for tests: under `bun test` the process path is the bun
|
||||||
|
// runtime, so the compiled-binary shapes are unreachable without a seam.
|
||||||
|
selfPath: string = process.execPath || process.argv[0],
|
||||||
|
): string {
|
||||||
// 1 + 2: env overrides (GSTACK_BROWSE_BIN preferred, BROWSE_BIN back-compat).
|
// 1 + 2: env overrides (GSTACK_BROWSE_BIN preferred, BROWSE_BIN back-compat).
|
||||||
const overrideRaw = env.GSTACK_BROWSE_BIN ?? env.BROWSE_BIN;
|
const overrideRaw = env.GSTACK_BROWSE_BIN ?? env.BROWSE_BIN;
|
||||||
const override = resolveOverride(overrideRaw, env);
|
const override = resolveOverride(overrideRaw, env);
|
||||||
if (override) return override;
|
if (override) return override;
|
||||||
|
|
||||||
// 3: sibling — make-pdf and browse co-located in dist/.
|
// 3: sibling — make-pdf and browse co-located in dist/. execPath, not
|
||||||
const selfDir = path.dirname(process.argv[0]);
|
// argv[0] (#2156): see the header — argv[0] in a compiled binary is the
|
||||||
|
// invocation string, and a relative one resolved candidates against CWD.
|
||||||
|
const selfDir = path.dirname(selfPath);
|
||||||
const siblingCandidates = [
|
const siblingCandidates = [
|
||||||
path.resolve(selfDir, "../browse/dist/browse"),
|
path.resolve(selfDir, "../browse/dist/browse"),
|
||||||
path.resolve(selfDir, "../../browse/dist/browse"),
|
path.resolve(selfDir, "../../browse/dist/browse"),
|
||||||
|
|||||||
@@ -168,3 +168,51 @@ describe("BrowseClientError", () => {
|
|||||||
expect(err.name).toBe("BrowseClientError");
|
expect(err.name).toBe("BrowseClientError");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveBrowseBin — sibling resolution from execPath (#2156)", () => {
|
||||||
|
// In a bun-compiled binary argv[0] is the raw invocation string (often
|
||||||
|
// relative), so the old dirname(argv[0]) built sibling candidates against
|
||||||
|
// the CWD. Under `bun test` the process path is the bun runtime, so these
|
||||||
|
// shapes are only reachable through the selfPath seam.
|
||||||
|
|
||||||
|
test("sibling browse next to the install dir is found via selfPath", () => {
|
||||||
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), "mkpdf-sib-"));
|
||||||
|
try {
|
||||||
|
const distDir = path.join(base, "browse", "dist");
|
||||||
|
fs.mkdirSync(distDir, { recursive: true });
|
||||||
|
const sibling = path.join(distDir, "browse");
|
||||||
|
fs.writeFileSync(sibling, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||||
|
const selfPath = path.join(base, "make-pdf", "dist", "pdf");
|
||||||
|
// Receipts: pre-fix code ignores the selfPath seam entirely, so it can
|
||||||
|
// never produce this sibling — it either finds a global install (wrong
|
||||||
|
// value) or throws (PATH is empty). Red on v1.68.3.0 either way.
|
||||||
|
const resolved = resolveBrowseBin({ PATH: "" }, selfPath);
|
||||||
|
expect(resolved).toBe(path.resolve(path.join(base, "make-pdf"), "../browse/dist/browse"));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(base, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a decoy browse DIRECTORY near selfPath never shadows a real PATH binary", () => {
|
||||||
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), "mkpdf-decoy-"));
|
||||||
|
try {
|
||||||
|
// The ~/.claude/skills/browse alias-directory shape from #2156: a
|
||||||
|
// directory named exactly like the third sibling candidate.
|
||||||
|
fs.mkdirSync(path.join(base, "browse"), { recursive: true });
|
||||||
|
const pathDir = path.join(base, "pathbin");
|
||||||
|
fs.mkdirSync(pathDir, { recursive: true });
|
||||||
|
const onPath = path.join(pathDir, "browse");
|
||||||
|
fs.writeFileSync(onPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||||
|
const selfPath = path.join(base, "tools", "pdf");
|
||||||
|
// os.homedir() ignores a $HOME override under bun, so the global-install
|
||||||
|
// probe may legitimately win on boxes with a real ~/.claude install. The
|
||||||
|
// invariant under test is narrower: the decoy DIRECTORY never wins, and
|
||||||
|
// whatever wins is a regular file.
|
||||||
|
const resolved = resolveBrowseBin({ PATH: pathDir }, selfPath);
|
||||||
|
expect(resolved).not.toBe(path.join(base, "browse"));
|
||||||
|
expect(fs.statSync(resolved).isFile()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(base, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.68.3",
|
"version": "1.69.0",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -13,6 +13,16 @@
|
|||||||
# ./scripts/build-app.sh # Build .app + DMG
|
# ./scripts/build-app.sh # Build .app + DMG
|
||||||
# ./scripts/build-app.sh --no-dmg # Build .app only
|
# ./scripts/build-app.sh --no-dmg # Build .app only
|
||||||
|
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
||||||
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
||||||
|
# bypass the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
set -e
|
set -e
|
||||||
umask 077 # Restrict new files to owner-only (0o600 files, 0o700 dirs)
|
umask 077 # Restrict new files to owner-only (0o600 files, 0o700 dirs)
|
||||||
|
|
||||||
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||||
|
# pipe in the forked child before exec, with no reader on the other end. On
|
||||||
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||||||
|
# body >=512B blocks write() forever — ./setup --help would hang with no
|
||||||
|
# output. Compat level 50 restores the tempfile path. This script is
|
||||||
|
# bash-3.2-clean, so the compat level costs it nothing. Not exported: the
|
||||||
|
# guard is per-script, and it survives `bash setup` call sites that bypass
|
||||||
|
# the shebang.
|
||||||
|
BASH_COMPAT=50
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
gstack setup — install gstack skills + build browse binary
|
gstack setup — install gstack skills + build browse binary
|
||||||
@@ -195,7 +205,17 @@ while [ $# -gt 0 ]; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
case "$HOST" in
|
case "$HOST" in
|
||||||
claude|codex|kiro|factory|opencode|cursor|slate|auto) ;;
|
claude|codex|kiro|factory|opencode|cursor|auto) ;;
|
||||||
|
slate)
|
||||||
|
echo ""
|
||||||
|
echo "Slate is not yet a first-class install target (docs/designs/SLATE_HOST.md —"
|
||||||
|
echo "blocked on the host-config refactor). Slate discovers skills from"
|
||||||
|
echo ".claude/skills as a compatibility fallback, so a Slate user is served by"
|
||||||
|
echo "the Claude install today:"
|
||||||
|
echo ""
|
||||||
|
echo " ./setup --host claude"
|
||||||
|
echo ""
|
||||||
|
exit 0 ;;
|
||||||
openclaw)
|
openclaw)
|
||||||
echo ""
|
echo ""
|
||||||
echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code"
|
echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code"
|
||||||
@@ -323,6 +343,14 @@ elif [ "$HOST" = "cursor" ]; then
|
|||||||
INSTALL_CURSOR=1
|
INSTALL_CURSOR=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# A host that passes --host validation but sets no INSTALL_* flag would
|
||||||
|
# silently configure nothing and exit 0 (the #2361 slate failure class).
|
||||||
|
# Fail loudly if a future host lands in the accept-list without a dispatch arm.
|
||||||
|
if [ "$HOST" != "auto" ] && [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ]; then
|
||||||
|
echo "Error: no install arm exists for host '$HOST' — it passed --host validation but sets no INSTALL_* flag, so setup would configure nothing and exit 0. This is a setup bug. Valid install targets: claude, codex, kiro, factory, opencode, cursor (informational: slate, openclaw, hermes, gbrain)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$MODEL_OVERRIDE_SET" -eq 1 ] && [ "$INSTALL_CODEX" -eq 0 ]; then
|
if [ "$MODEL_OVERRIDE_SET" -eq 1 ] && [ "$INSTALL_CODEX" -eq 0 ]; then
|
||||||
echo "Error: --model is supported only when Codex is selected (--host codex or --host auto with Codex installed)." >&2
|
echo "Error: --model is supported only when Codex is selected (--host codex or --host auto with Codex installed)." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -973,45 +1001,67 @@ link_claude_root_skill_alias() {
|
|||||||
# ─── Helper: remove old unprefixed Claude skill entries ───────────────────────
|
# ─── Helper: remove old unprefixed Claude skill entries ───────────────────────
|
||||||
# Migration: when switching from flat names to gstack- prefixed names,
|
# Migration: when switching from flat names to gstack- prefixed names,
|
||||||
# clean up stale symlinks or directories that point into the gstack directory.
|
# clean up stale symlinks or directories that point into the gstack directory.
|
||||||
|
# Scan $skills_dir (not $gstack_dir): orphans live next to the payload, so a
|
||||||
|
# missing payload must still be able to reap leftover flat names (#2204).
|
||||||
cleanup_old_claude_symlinks() {
|
cleanup_old_claude_symlinks() {
|
||||||
local gstack_dir="$1"
|
local gstack_dir="$1"
|
||||||
local skills_dir="$2"
|
local skills_dir="$2"
|
||||||
local removed=()
|
local removed=()
|
||||||
for skill_dir in "$gstack_dir"/*/; do
|
local old_target skill_name link_dest skill_dir
|
||||||
if [ -f "$skill_dir/SKILL.md" ]; then
|
# Destination scan. The glob already yields dangling dir symlinks; [ -e ]
|
||||||
skill_name="$(basename "$skill_dir")"
|
# alone would skip them, so [ -L ] keeps those entries. An unmatched `*`
|
||||||
[ "$skill_name" = "node_modules" ] && continue
|
# literal (empty skills_dir) is rejected by the same guard.
|
||||||
# Skip already-prefixed dirs (gstack-upgrade) — no old symlink to clean
|
for old_target in "$skills_dir"/*; do
|
||||||
case "$skill_name" in gstack-*) continue ;; esac
|
[ -e "$old_target" ] || [ -L "$old_target" ] || continue
|
||||||
old_target="$skills_dir/$skill_name"
|
skill_name="$(basename "$old_target")"
|
||||||
# Remove directory symlinks pointing into gstack/
|
[ "$skill_name" = "node_modules" ] && continue
|
||||||
if [ -L "$old_target" ]; then
|
[ "$skill_name" = "gstack" ] && continue
|
||||||
link_dest="$(readlink "$old_target" 2>/dev/null || true)"
|
# Skip already-prefixed dirs (gstack-upgrade) — no old symlink to clean
|
||||||
case "$link_dest" in
|
case "$skill_name" in gstack-*) continue ;; esac
|
||||||
gstack/*|*/gstack/*)
|
# Remove directory symlinks pointing into gstack/
|
||||||
rm -f "$old_target"
|
if [ -L "$old_target" ]; then
|
||||||
removed+=("$skill_name")
|
link_dest="$(readlink "$old_target" 2>/dev/null || true)"
|
||||||
;;
|
case "$link_dest" in
|
||||||
esac
|
gstack/*|*/gstack/*)
|
||||||
# Remove real directories with symlinked SKILL.md pointing into gstack/
|
rm -f "$old_target"
|
||||||
elif [ -d "$old_target" ] && [ -L "$old_target/SKILL.md" ]; then
|
removed+=("$skill_name")
|
||||||
link_dest="$(readlink "$old_target/SKILL.md" 2>/dev/null || true)"
|
;;
|
||||||
case "$link_dest" in
|
esac
|
||||||
*gstack*)
|
# Remove real directories with symlinked SKILL.md pointing into gstack/
|
||||||
rm -rf "$old_target"
|
elif [ -d "$old_target" ] && [ -L "$old_target/SKILL.md" ]; then
|
||||||
removed+=("$skill_name")
|
link_dest="$(readlink "$old_target/SKILL.md" 2>/dev/null || true)"
|
||||||
;;
|
# Anchored path segments (same as the dir-symlink arm and
|
||||||
esac
|
# gstack-uninstall #2563). A bare *gstack* substring would wipe a
|
||||||
# Windows install pattern: real dir with real-file SKILL.md (no symlink
|
# user skill under e.g. ~/tools/gstack-fork/. Also accept the #2569
|
||||||
# available, so we can't readlink to verify provenance). The outer loop
|
# render prefix (~/.gstack/render/claude/...), which is not `/gstack/`.
|
||||||
# iterates known gstack skill names from "$gstack_dir"/*, so a name match
|
case "$link_dest" in
|
||||||
# plus IS_WINDOWS is safe to treat as gstack-managed during a mode flip.
|
gstack/*|*/gstack/*|*/.gstack/render/claude/*)
|
||||||
elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$old_target" ] && [ -f "$old_target/SKILL.md" ]; then
|
rm -rf "$old_target"
|
||||||
rm -rf "$old_target"
|
removed+=("$skill_name")
|
||||||
removed+=("$skill_name")
|
;;
|
||||||
fi
|
esac
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
# Windows install pattern: real dir with real-file SKILL.md (no symlink
|
||||||
|
# available, so we can't readlink to verify provenance). Iterate known
|
||||||
|
# gstack skill names from "$gstack_dir"/*, so a name match plus IS_WINDOWS
|
||||||
|
# is safe to treat as gstack-managed during a mode flip. When the payload
|
||||||
|
# is gone this branch is a no-op — a real file has no proven owner.
|
||||||
|
if [ "${IS_WINDOWS:-0}" -eq 1 ] && [ -d "$gstack_dir" ]; then
|
||||||
|
for skill_dir in "$gstack_dir"/*/; do
|
||||||
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
||||||
|
skill_name="$(basename "$skill_dir")"
|
||||||
|
[ "$skill_name" = "node_modules" ] && continue
|
||||||
|
case "$skill_name" in gstack-*) continue ;; esac
|
||||||
|
old_target="$skills_dir/$skill_name"
|
||||||
|
if [ -d "$old_target" ] && [ ! -L "$old_target" ] \
|
||||||
|
&& [ -f "$old_target/SKILL.md" ] && [ ! -L "$old_target/SKILL.md" ]; then
|
||||||
|
rm -rf "$old_target"
|
||||||
|
removed+=("$skill_name")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
if [ ${#removed[@]} -gt 0 ]; then
|
if [ ${#removed[@]} -gt 0 ]; then
|
||||||
echo " cleaned up old entries: ${removed[*]}"
|
echo " cleaned up old entries: ${removed[*]}"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -916,6 +916,8 @@ mv "$HOME/.gbrain/config.json" "$BACKUP"
|
|||||||
# gstack default: voyage-code-3 (1024d) when VOYAGE_API_KEY is set — best for
|
# gstack default: voyage-code-3 (1024d) when VOYAGE_API_KEY is set — best for
|
||||||
# code retrieval. Without the key, fall back to gbrain's own auto-selected
|
# code retrieval. Without the key, fall back to gbrain's own auto-selected
|
||||||
# embedding provider chain (OpenAI 1536d when OPENAI_API_KEY is present, etc.).
|
# embedding provider chain (OpenAI 1536d when OPENAI_API_KEY is present, etc.).
|
||||||
|
# Never select gbrain's legacy zeroentropyai recipe for a new brain: the hosted
|
||||||
|
# API sunsets September 4, 2026 (#2365); the wireup helper warns existing installs.
|
||||||
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
|
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
|
||||||
if [ -n "${VOYAGE_API_KEY:-}" ]; then
|
if [ -n "${VOYAGE_API_KEY:-}" ]; then
|
||||||
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
||||||
@@ -1175,6 +1177,8 @@ Then follow the same secret-read + verify + init flow as Path 1.
|
|||||||
# gstack default: voyage-code-3 (1024d) when VOYAGE_API_KEY is set — code
|
# gstack default: voyage-code-3 (1024d) when VOYAGE_API_KEY is set — code
|
||||||
# retrieval beats general-purpose embeddings on real code queries (validated
|
# retrieval beats general-purpose embeddings on real code queries (validated
|
||||||
# A/B). Without the key, gbrain auto-selects (OpenAI 1536d when available).
|
# A/B). Without the key, gbrain auto-selects (OpenAI 1536d when available).
|
||||||
|
# Never select gbrain's legacy zeroentropyai recipe for a new brain: the hosted
|
||||||
|
# API sunsets September 4, 2026 (#2365); the wireup helper warns existing installs.
|
||||||
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
|
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
|
||||||
if [ -n "${VOYAGE_API_KEY:-}" ]; then
|
if [ -n "${VOYAGE_API_KEY:-}" ]; then
|
||||||
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ mv "$HOME/.gbrain/config.json" "$BACKUP"
|
|||||||
# gstack default: voyage-code-3 (1024d) when VOYAGE_API_KEY is set — best for
|
# gstack default: voyage-code-3 (1024d) when VOYAGE_API_KEY is set — best for
|
||||||
# code retrieval. Without the key, fall back to gbrain's own auto-selected
|
# code retrieval. Without the key, fall back to gbrain's own auto-selected
|
||||||
# embedding provider chain (OpenAI 1536d when OPENAI_API_KEY is present, etc.).
|
# embedding provider chain (OpenAI 1536d when OPENAI_API_KEY is present, etc.).
|
||||||
|
# Never select gbrain's legacy zeroentropyai recipe for a new brain: the hosted
|
||||||
|
# API sunsets September 4, 2026 (#2365); the wireup helper warns existing installs.
|
||||||
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
|
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
|
||||||
if [ -n "${VOYAGE_API_KEY:-}" ]; then
|
if [ -n "${VOYAGE_API_KEY:-}" ]; then
|
||||||
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
||||||
@@ -393,6 +395,8 @@ Then follow the same secret-read + verify + init flow as Path 1.
|
|||||||
# gstack default: voyage-code-3 (1024d) when VOYAGE_API_KEY is set — code
|
# gstack default: voyage-code-3 (1024d) when VOYAGE_API_KEY is set — code
|
||||||
# retrieval beats general-purpose embeddings on real code queries (validated
|
# retrieval beats general-purpose embeddings on real code queries (validated
|
||||||
# A/B). Without the key, gbrain auto-selects (OpenAI 1536d when available).
|
# A/B). Without the key, gbrain auto-selects (OpenAI 1536d when available).
|
||||||
|
# Never select gbrain's legacy zeroentropyai recipe for a new brain: the hosted
|
||||||
|
# API sunsets September 4, 2026 (#2365); the wireup helper warns existing installs.
|
||||||
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
|
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
|
||||||
if [ -n "${VOYAGE_API_KEY:-}" ]; then
|
if [ -n "${VOYAGE_API_KEY:-}" ]; then
|
||||||
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
||||||
|
|||||||
@@ -314,3 +314,96 @@ describe('gstack-evidence check', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('gstack-evidence run — bun dotenv autoload must not reach the child', () => {
|
||||||
|
// bun auto-loads .env / .env.<NODE_ENV> / .env.local from the cwd into
|
||||||
|
// process.env, and this binary has a bun shebang, so without scrubbing every
|
||||||
|
// spawned command inherits them. That leaks production credentials into a child
|
||||||
|
// that would not otherwise have them AND changes the behaviour of the command
|
||||||
|
// being certified, which is the worse half: the ledger would vouch for a run
|
||||||
|
// that differs from the one CI performs.
|
||||||
|
//
|
||||||
|
// ⚠️ `bun test` runs with NODE_ENV=test, and bun SKIPS .env.local in test mode
|
||||||
|
// (verified on bun 1.3.11: NODE_ENV=test loads .env but not .env.local). A
|
||||||
|
// .env.local fixture here therefore proves nothing unless NODE_ENV is cleared
|
||||||
|
// for the spawn — the first version of these tests passed for exactly that
|
||||||
|
// wrong reason. Every leak test below asserts the scrub WARNING fired, so a
|
||||||
|
// fixture bun never loaded fails instead of passing silently.
|
||||||
|
|
||||||
|
function runWith(env: Record<string, string | undefined>, cmd: string) {
|
||||||
|
return spawnSync(EVIDENCE, ['run', '--label', 'envprobe', '--', cmd], {
|
||||||
|
cwd: repoDir,
|
||||||
|
env: { ...process.env, GSTACK_HOME: gstackHome, ...env },
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a .env value is scrubbed, and the warning names the key but never the value', () => {
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.env'), 'ZZ_TOKEN_PROBE="s3cret-value"\n');
|
||||||
|
const r = run(['run', '--label', 'envprobe', '--', 'echo "saw=[${ZZ_TOKEN_PROBE:-absent}]"']);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr).toContain('ZZ_TOKEN_PROBE'); // positive control: the scrub ran
|
||||||
|
expect(r.stdout).toContain('saw=[absent]');
|
||||||
|
// The diagnostic must not become the leak it prevents.
|
||||||
|
expect(r.stderr).not.toContain('s3cret-value');
|
||||||
|
expect(r.stdout).not.toContain('s3cret-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a .env.local value is scrubbed when bun actually loads it (NODE_ENV cleared)', () => {
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.env.local'), 'ZZ_LOCAL_PROBE=leaked\n');
|
||||||
|
const r = runWith({ NODE_ENV: undefined }, 'echo "saw=[${ZZ_LOCAL_PROBE:-absent}]"');
|
||||||
|
expect(r.stderr ?? '').toContain('ZZ_LOCAL_PROBE'); // positive control
|
||||||
|
expect(r.stdout ?? '').toContain('saw=[absent]');
|
||||||
|
expect(r.stdout ?? '').not.toContain('leaked');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.env.local is left alone under NODE_ENV=test, because bun never loaded it', () => {
|
||||||
|
// Mirrors bun's own precedence. Scrubbing a key bun did not inject would strip
|
||||||
|
// a variable the caller's shell legitimately provided.
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.env.local'), 'ZZ_TESTMODE_PROBE=from_file\n');
|
||||||
|
const r = runWith({ NODE_ENV: 'test', ZZ_TESTMODE_PROBE: 'from_shell' },
|
||||||
|
'echo "saw=[${ZZ_TESTMODE_PROBE:-absent}]"');
|
||||||
|
expect(r.stdout ?? '').toContain('saw=[from_shell]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CONTROL — a var the shell exported with a different value SURVIVES', () => {
|
||||||
|
// bun does not override an already-exported var (verified on bun 1.3.11), so a
|
||||||
|
// live value that differs from the file is genuinely the user's environment.
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.env'), 'ZZ_KEEP_PROBE=from_file\n');
|
||||||
|
const r = runWith({ ZZ_KEEP_PROBE: 'from_shell' }, 'echo "saw=[${ZZ_KEEP_PROBE:-absent}]"');
|
||||||
|
expect(r.stdout ?? '').toContain('saw=[from_shell]');
|
||||||
|
expect(r.stderr ?? '').not.toContain('ZZ_KEEP_PROBE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GSTACK_EVIDENCE_KEEP_DOTENV=1 restores the old pass-through behaviour', () => {
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.env'), 'ZZ_OPTOUT_PROBE=kept\n');
|
||||||
|
const r = runWith({ GSTACK_EVIDENCE_KEEP_DOTENV: '1' }, 'echo "saw=[${ZZ_OPTOUT_PROBE:-absent}]"');
|
||||||
|
expect(r.stdout ?? '').toContain('saw=[kept]');
|
||||||
|
expect(r.stderr ?? '').not.toContain('scrubbed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no dotenv file means no scrub warning at all', () => {
|
||||||
|
const r = run(['run', '--label', 'envprobe', '--', 'echo hi']);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr).not.toContain('scrubbed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an UNREADABLE .env fails open: evidence still runs, nothing scrubbed', () => {
|
||||||
|
const envPath = path.join(repoDir, '.env');
|
||||||
|
fs.writeFileSync(envPath, 'ZZ_DENIED_PROBE=hidden\n');
|
||||||
|
fs.chmodSync(envPath, 0o000);
|
||||||
|
// chmod 000 cannot create unreadability for root or CAP_DAC_OVERRIDE
|
||||||
|
// environments (reads succeed regardless) — probe functionally and skip
|
||||||
|
// rather than assert a condition the fixture couldn't create.
|
||||||
|
try { fs.readFileSync(envPath); fs.chmodSync(envPath, 0o644); return; } catch {}
|
||||||
|
try {
|
||||||
|
const r = run(['run', '--label', 'envprobe', '--', 'echo hi']);
|
||||||
|
// The scrub must skip the unreadable file and the run must still be recorded.
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr ?? '').not.toContain('ZZ_DENIED_PROBE');
|
||||||
|
} finally {
|
||||||
|
fs.chmodSync(envPath, 0o644); // let afterEach rmSync succeed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* mkdirpSync + install-prepush-hook under bun-on-Windows EEXIST semantics
|
||||||
|
* (#2635).
|
||||||
|
*
|
||||||
|
* bun on Windows throws EEXIST from fs.mkdirSync(dir, { recursive: true })
|
||||||
|
* when dir already exists - Node treats it as a no-op - which crashed
|
||||||
|
* `gstack-redact install-prepush-hook` on any repo whose .git/hooks already
|
||||||
|
* existed. The CLI regression test below emulates those Windows semantics via
|
||||||
|
* a `bun --preload` fixture (test/helpers/emulate-bun-windows-eexist.ts), so
|
||||||
|
* the crash path runs on any platform, including CI Linux.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import { mkdirpSync } from "../lib/fs-utils";
|
||||||
|
|
||||||
|
const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
|
||||||
|
const EEXIST_PRELOAD = path.resolve(
|
||||||
|
import.meta.dir,
|
||||||
|
"helpers",
|
||||||
|
"emulate-bun-windows-eexist.ts",
|
||||||
|
);
|
||||||
|
|
||||||
|
function tmpdir(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), "fs-utils-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mkdirpSync", () => {
|
||||||
|
test("creates missing nested directories", () => {
|
||||||
|
const base = tmpdir();
|
||||||
|
try {
|
||||||
|
const dir = path.join(base, "a", "b", "c");
|
||||||
|
mkdirpSync(dir);
|
||||||
|
expect(fs.statSync(dir).isDirectory()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(base, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tolerates the directory already existing", () => {
|
||||||
|
const base = tmpdir();
|
||||||
|
try {
|
||||||
|
mkdirpSync(base); // exists -> must be a no-op, not EEXIST
|
||||||
|
mkdirpSync(base); // and idempotent on repeat calls
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(base, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still throws EEXIST when a regular file occupies the path", () => {
|
||||||
|
const base = tmpdir();
|
||||||
|
try {
|
||||||
|
const file = path.join(base, "occupied");
|
||||||
|
fs.writeFileSync(file, "x");
|
||||||
|
expect(() => mkdirpSync(file)).toThrow(/EEXIST/);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(base, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("swept mkdirp sites under bun-on-Windows EEXIST semantics (#2635)", () => {
|
||||||
|
const DECISION_LOG = path.resolve(import.meta.dir, "..", "bin", "gstack-decision-log");
|
||||||
|
|
||||||
|
test("decision-log still writes when its projects dir already exists", () => {
|
||||||
|
// Proves the sweep WIRING, not just the helper: the first call creates
|
||||||
|
// ~/.gstack/projects/<slug>/, the second hits the emulated Windows EEXIST
|
||||||
|
// on that pre-existing dir — bare mkdirSync crashed here before the sweep.
|
||||||
|
const base = tmpdir();
|
||||||
|
try {
|
||||||
|
const work = path.join(base, "work");
|
||||||
|
fs.mkdirSync(work, { recursive: true });
|
||||||
|
const payload = '{"decision":"eexist probe","rationale":"r","scope":"repo","source":"user"}';
|
||||||
|
const env = { ...process.env, HOME: base };
|
||||||
|
const first = spawnSync("bun", [DECISION_LOG, payload], { cwd: work, encoding: "utf8", env });
|
||||||
|
expect(first.status).toBe(0);
|
||||||
|
const second = spawnSync(
|
||||||
|
"bun", ["--preload", EEXIST_PRELOAD, DECISION_LOG, payload],
|
||||||
|
{ cwd: work, encoding: "utf8", env },
|
||||||
|
);
|
||||||
|
expect(second.status).toBe(0);
|
||||||
|
expect(second.stderr ?? "").not.toContain("EEXIST");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(base, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", () => {
|
||||||
|
test("install succeeds when .git/hooks already exists, existing hook preserved", () => {
|
||||||
|
const base = tmpdir();
|
||||||
|
try {
|
||||||
|
const repo = path.join(base, "repo");
|
||||||
|
spawnSync("git", ["init", "-q", repo]);
|
||||||
|
const hookDir = path.join(repo, ".git", "hooks");
|
||||||
|
fs.mkdirSync(hookDir, { recursive: true });
|
||||||
|
const hookPath = path.join(hookDir, "pre-push");
|
||||||
|
fs.writeFileSync(hookPath, "#!/usr/bin/env bash\necho mine\n", { mode: 0o755 });
|
||||||
|
|
||||||
|
// Under the emulated bun-on-Windows fs, the bare
|
||||||
|
// fs.mkdirSync(dir, { recursive: true }) in installPrepushHook() throws
|
||||||
|
// EEXIST (the #2635 crash). With mkdirpSync it must install cleanly.
|
||||||
|
const r = spawnSync("bun", ["--preload", EEXIST_PRELOAD, REDACT, "install-prepush-hook"], {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr ?? "").not.toContain("EEXIST");
|
||||||
|
expect(fs.readFileSync(hookPath, "utf8")).toContain("gstack-redact pre-push (managed)");
|
||||||
|
expect(fs.readFileSync(path.join(hookDir, "pre-push.local"), "utf8")).toContain("echo mine");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(base, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2515,9 +2515,12 @@ describe('setup script validation', () => {
|
|||||||
expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
|
expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setup supports --host auto|claude|codex|kiro|opencode|cursor|slate', () => {
|
test('setup supports --host auto|claude|codex|kiro|opencode|cursor; slate is informational', () => {
|
||||||
expect(setupContent).toContain('--host');
|
expect(setupContent).toContain('--host');
|
||||||
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|slate|auto');
|
// #2361: slate moved OUT of the install accept-list (it was accepted but
|
||||||
|
// never dispatched — a silent exit-0 no-op) into an informational arm.
|
||||||
|
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|auto');
|
||||||
|
expect(setupContent).toMatch(/^ {2}slate\)/m);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('auto mode detects claude, codex, kiro, and opencode binaries', () => {
|
test('auto mode detects claude, codex, kiro, and opencode binaries', () => {
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
|||||||
const BIN_DIR = path.join(ROOT, 'bin');
|
const BIN_DIR = path.join(ROOT, 'bin');
|
||||||
const WIREUP_BIN = path.join(BIN_DIR, 'gstack-gbrain-source-wireup');
|
const WIREUP_BIN = path.join(BIN_DIR, 'gstack-gbrain-source-wireup');
|
||||||
|
|
||||||
|
// Hermetic PATH base (#2255). The missing-gbrain fixtures must not see a
|
||||||
|
// user-installed gbrain on the host (e.g. macOS /opt/homebrew/bin), or the
|
||||||
|
// "missing" case exits 0 instead of 2. Base is root-owned OS dirs only
|
||||||
|
// (/usr/bin:/bin:/usr/sbin:/sbin — where git/python3/jq/coreutils resolve on
|
||||||
|
// macOS and Linux) plus BUN_ONLY_DIR, so no user-installed gbrain can be
|
||||||
|
// present. The scratch dir holds only a bun symlink, mirroring
|
||||||
|
// gbrain-detect-install.test.ts so spawned children can resolve bun on CI
|
||||||
|
// regardless of install dir.
|
||||||
|
const BUN_ONLY_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'wireup-bun-only-'));
|
||||||
|
fs.symlinkSync(process.execPath, path.join(BUN_ONLY_DIR, 'bun'));
|
||||||
|
const HERMETIC_PATH = `/usr/bin:/bin:/usr/sbin:/sbin:${BUN_ONLY_DIR}`;
|
||||||
|
|
||||||
let tmpHome: string;
|
let tmpHome: string;
|
||||||
let gstackHome: string;
|
let gstackHome: string;
|
||||||
let worktreeDir: string;
|
let worktreeDir: string;
|
||||||
@@ -30,10 +42,12 @@ let gbrainStateFile: string;
|
|||||||
function makeFakeGbrain(opts: {
|
function makeFakeGbrain(opts: {
|
||||||
version?: string | null; // null = "binary missing" (don't write the file)
|
version?: string | null; // null = "binary missing" (don't write the file)
|
||||||
syncFails?: boolean;
|
syncFails?: boolean;
|
||||||
|
syncHelpNoSource?: boolean; // simulate an older gbrain whose sync lacks --source
|
||||||
}) {
|
}) {
|
||||||
const version = opts.version ?? '0.18.2';
|
const version = opts.version ?? '0.18.2';
|
||||||
if (version === null) return; // simulate missing binary by NOT writing one
|
if (version === null) return; // simulate missing binary by NOT writing one
|
||||||
const syncFails = opts.syncFails ?? false;
|
const syncFails = opts.syncFails ?? false;
|
||||||
|
const syncHelpNoSource = opts.syncHelpNoSource ?? false;
|
||||||
|
|
||||||
// Stub gbrain reads/writes state from a JSON file. Fields:
|
// Stub gbrain reads/writes state from a JSON file. Fields:
|
||||||
// sources: [{id, local_path, federated}]
|
// sources: [{id, local_path, federated}]
|
||||||
@@ -97,6 +111,13 @@ json.dump(state, open('$STATE','w'), indent=2)
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# sync --repo <p> → records, optionally fails
|
# sync --repo <p> → records, optionally fails
|
||||||
|
# sync --help → advertise flags (the wireup probes this before choosing the
|
||||||
|
# sync form; the default fake mirrors a current gbrain, which HAS --source)
|
||||||
|
if [ "$1" = "sync" ] && [ "$2" = "--help" ]; then
|
||||||
|
echo "Usage: gbrain sync [--repo <path>]${syncHelpNoSource ? '' : ' [--source <id>]'}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$1" = "sync" ]; then
|
if [ "$1" = "sync" ]; then
|
||||||
${syncFails ? 'echo "sync failed: connection error" >&2; exit 1' : 'echo "1 page imported"; exit 0'}
|
${syncFails ? 'echo "sync failed: connection error" >&2; exit 1' : 'echo "1 page imported"; exit 0'}
|
||||||
fi
|
fi
|
||||||
@@ -113,7 +134,7 @@ function run(
|
|||||||
opts: { env?: Record<string, string> } = {}
|
opts: { env?: Record<string, string> } = {}
|
||||||
) {
|
) {
|
||||||
const env = {
|
const env = {
|
||||||
PATH: `${fakeBinDir}:${process.env.PATH || '/usr/bin:/bin:/opt/homebrew/bin'}`,
|
PATH: `${fakeBinDir}:${HERMETIC_PATH}`,
|
||||||
HOME: tmpHome,
|
HOME: tmpHome,
|
||||||
GSTACK_HOME: gstackHome,
|
GSTACK_HOME: gstackHome,
|
||||||
GSTACK_BRAIN_WORKTREE: worktreeDir,
|
GSTACK_BRAIN_WORKTREE: worktreeDir,
|
||||||
@@ -181,6 +202,31 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => {
|
|||||||
expect(state.sources[0].federated).toBe(true);
|
expect(state.sources[0].federated).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the real sync targets the REGISTERED source, never --repo (#2662)', () => {
|
||||||
|
// `sync --repo <path>` resolves against the brain's DEFAULT source and can
|
||||||
|
// silently repoint its local_path anchor at our worktree. This case runs
|
||||||
|
// WITHOUT GSTACK_BRAIN_NO_SYNC — the skip-mode cases never reach the sync,
|
||||||
|
// so asserting the sync argv there would be vacuous.
|
||||||
|
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||||
|
makeFakeGbrain({});
|
||||||
|
const r = run([]);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
const calls = gbrainCalls();
|
||||||
|
expect(calls.some((c) => c.startsWith('gbrain sync --source gstack-brain-user'))).toBe(true);
|
||||||
|
expect(calls.some((c) => c.includes('sync --repo'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('older gbrain without sync --source: falls back to --repo with an upgrade warning', () => {
|
||||||
|
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||||
|
makeFakeGbrain({ syncHelpNoSource: true });
|
||||||
|
const r = run([]);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
const calls = gbrainCalls();
|
||||||
|
expect(calls.some((c) => c.startsWith('gbrain sync --repo'))).toBe(true);
|
||||||
|
expect(calls.some((c) => c.includes('sync --source '))).toBe(false);
|
||||||
|
expect(r.stderr).toContain('#2662');
|
||||||
|
});
|
||||||
|
|
||||||
test('idempotent re-run after success: no new sources add call', () => {
|
test('idempotent re-run after success: no new sources add call', () => {
|
||||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||||
makeFakeGbrain({});
|
makeFakeGbrain({});
|
||||||
@@ -229,14 +275,52 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => {
|
|||||||
|
|
||||||
test('--strict + gbrain missing on PATH: exits 2', () => {
|
test('--strict + gbrain missing on PATH: exits 2', () => {
|
||||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||||
// Don't make a fake gbrain — fakeBinDir is empty. Keep system dirs on PATH
|
// Don't make a fake gbrain — fakeBinDir is empty. run() applies the
|
||||||
// so basic commands (git, awk, sed, etc.) work; only `gbrain` is absent.
|
// hermetic PATH base; only `gbrain` is absent.
|
||||||
const r = run(['--strict'], {
|
const r = run(['--strict']);
|
||||||
env: { PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin` },
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(2);
|
expect(r.status).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('--strict + gbrain present in controlled dir: exits 0 (positive control)', () => {
|
||||||
|
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||||
|
// Positive control for hermeticity: a gbrain stub in the test-controlled
|
||||||
|
// fakeBinDir (first on the hermetic PATH) IS found, so --strict proceeds
|
||||||
|
// (exit 0). This proves the fixture CAN supply gbrain when present; the
|
||||||
|
// determinism test below proves the host cannot leak one in (#2255).
|
||||||
|
makeFakeGbrain({});
|
||||||
|
const r = run(['--strict'], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(gbrainCalls().some((c) => c.startsWith('gbrain sources add'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--strict + gbrain present in a host-like dir: still exits 2 (determinism)', () => {
|
||||||
|
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||||
|
// Determinism check (#2255, plan TS2): plant a real-looking gbrain stub in
|
||||||
|
// a dir that the OLD fixture would have leaked via process.env.PATH or the
|
||||||
|
// hardcoded /opt/homebrew/bin list. The root-owned-only hermetic base
|
||||||
|
// excludes user-writable dirs, so the child never sees the stub and the
|
||||||
|
// missing case stays deterministic across dev machines. This test fails on
|
||||||
|
// the unpatched fixture (stub found -> exit 0) and passes on the fixed one.
|
||||||
|
const hostLikeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wireup-host-like-'));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(hostLikeDir, 'gbrain'),
|
||||||
|
'#!/bin/bash\necho "gbrain 0.18.2"\n',
|
||||||
|
{ mode: 0o755 }
|
||||||
|
);
|
||||||
|
// No env PATH override: run() applies the hermetic base. The stub exists
|
||||||
|
// only in a user-writable dir the base excludes.
|
||||||
|
const r = run(['--strict']);
|
||||||
|
expect(r.status).toBe(2);
|
||||||
|
// Sanity: the stub IS visible to a shell using the host-like PATH, so this
|
||||||
|
// test would catch the old leak if the base ever regressed.
|
||||||
|
const check = spawnSync('bash', ['-c', `command -v gbrain && gbrain --version`], {
|
||||||
|
env: { PATH: `${hostLikeDir}:${process.env.PATH || '/usr/bin:/bin'}` },
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
expect(check.status).toBe(0);
|
||||||
|
expect(check.stdout).toContain('gbrain 0.18.2');
|
||||||
|
});
|
||||||
|
|
||||||
test('source-id derived from origin URL', () => {
|
test('source-id derived from origin URL', () => {
|
||||||
setupGstackRepo('git@github.com:user/gstack-brain-alice.git');
|
setupGstackRepo('git@github.com:user/gstack-brain-alice.git');
|
||||||
makeFakeGbrain({});
|
makeFakeGbrain({});
|
||||||
@@ -291,6 +375,46 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('gstack-gbrain-source-wireup — ZeroEntropy sunset advisory (#2365)', () => {
|
||||||
|
// The hosted ZeroEntropy API dies Sept 4, 2026; a gbrain on the zeroentropyai
|
||||||
|
// recipe keeps importing but stops embedding SILENTLY. Detection is a
|
||||||
|
// fail-open grep of ~/.gbrain/config.json — missing/other-provider configs
|
||||||
|
// must stay silent and never block the wireup.
|
||||||
|
|
||||||
|
test('config naming zeroentropyai → sunset warning, wireup still succeeds', () => {
|
||||||
|
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||||
|
makeFakeGbrain({});
|
||||||
|
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpHome, '.gbrain', 'config.json'),
|
||||||
|
JSON.stringify({ embedding: { recipe: 'zeroentropyai' } }),
|
||||||
|
);
|
||||||
|
const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr).toContain('ZeroEntropy');
|
||||||
|
expect(r.stderr).toContain('2365');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config on another provider → no warning (fail-open, no false positive)', () => {
|
||||||
|
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||||
|
makeFakeGbrain({});
|
||||||
|
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpHome, '.gbrain', 'config.json'),
|
||||||
|
JSON.stringify({ embedding: { recipe: 'voyage:voyage-code-3' } }),
|
||||||
|
);
|
||||||
|
const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr).not.toContain('ZeroEntropy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('advisory docs entry exists (USING_GBRAIN_WITH_GSTACK.md content pin)', () => {
|
||||||
|
const doc = fs.readFileSync(path.join(ROOT, 'USING_GBRAIN_WITH_GSTACK.md'), 'utf-8');
|
||||||
|
expect(doc).toContain('September 4, 2026');
|
||||||
|
expect(doc).toContain('#2365');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('gstack-gbrain-source-wireup — --database-url lock (defends against external config rewrites)', () => {
|
describe('gstack-gbrain-source-wireup — --database-url lock (defends against external config rewrites)', () => {
|
||||||
test('--database-url flag is exported as GBRAIN_DATABASE_URL to child gbrain calls', () => {
|
test('--database-url flag is exported as GBRAIN_DATABASE_URL to child gbrain calls', () => {
|
||||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||||
@@ -388,9 +512,7 @@ describe('gstack-gbrain-source-wireup — uninstall mode', () => {
|
|||||||
expect(fs.existsSync(worktreeDir)).toBe(true);
|
expect(fs.existsSync(worktreeDir)).toBe(true);
|
||||||
// Now remove the fake gbrain so uninstall sees gbrain missing
|
// Now remove the fake gbrain so uninstall sees gbrain missing
|
||||||
fs.rmSync(path.join(fakeBinDir, 'gbrain'), { force: true });
|
fs.rmSync(path.join(fakeBinDir, 'gbrain'), { force: true });
|
||||||
const r = run(['--uninstall'], {
|
const r = run(['--uninstall']);
|
||||||
env: { PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin` },
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(0); // best-effort, never fails on gbrain absence
|
expect(r.status).toBe(0); // best-effort, never fails on gbrain absence
|
||||||
expect(fs.existsSync(worktreeDir)).toBe(false); // worktree still cleaned up
|
expect(fs.existsSync(worktreeDir)).toBe(false); // worktree still cleaned up
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -484,6 +484,44 @@ describe("gstack-memory-ingest writer (gbrain v0.20+ batch `import` interface)",
|
|||||||
expect(stagedList).toMatch(/^\.\/transcripts\/claude-code\/.+\.md$/m);
|
expect(stagedList).toMatch(/^\.\/transcripts\/claude-code\/.+\.md$/m);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2353: buildTranscriptPage stored the RAW resolved remote ("" when
|
||||||
|
// unresolvable) while the frontmatter wrote the normalized "_unattributed".
|
||||||
|
// The policy filter fast-paths !p.git_remote, so under --include-unattributed
|
||||||
|
// a `_unattributed → deny` policy never applied to exactly the pages it
|
||||||
|
// names. Uses the REAL bin/gstack-gbrain-repo-policy (resolved by the client
|
||||||
|
// relative to lib/, and seeded here through its own `set` verb) — a fake
|
||||||
|
// echoing tiers would pass on both sides of the fix.
|
||||||
|
it("a deny policy keyed _unattributed applies to unattributable transcripts (#2353)", () => {
|
||||||
|
const home = makeTestHome();
|
||||||
|
const gstackHome = join(home, ".gstack");
|
||||||
|
mkdirSync(gstackHome, { recursive: true });
|
||||||
|
const { binDir, logFile } = installFakeGbrain(home);
|
||||||
|
|
||||||
|
const POLICY = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
|
||||||
|
const seeded = spawnSync("bash", [POLICY, "set", "_unattributed", "deny"], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome },
|
||||||
|
});
|
||||||
|
expect(seeded.status).toBe(0);
|
||||||
|
expect(existsSync(join(gstackHome, "gbrain-repo-policy.json"))).toBe(true);
|
||||||
|
|
||||||
|
const session =
|
||||||
|
`{"type":"user","message":{"role":"user","content":"hi"},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/foo"}\n` +
|
||||||
|
`{"type":"assistant","message":{"role":"assistant","content":"hello"},"timestamp":"2026-05-01T00:00:01Z"}\n`;
|
||||||
|
writeClaudeCodeSession(home, "tmp-foo", "abc123", session);
|
||||||
|
|
||||||
|
const r = runScript(["--bulk", "--include-unattributed", "--quiet"], {
|
||||||
|
HOME: home,
|
||||||
|
GSTACK_HOME: gstackHome,
|
||||||
|
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The only candidate page is policy-denied, so nothing may reach gbrain:
|
||||||
|
// pre-fix, the "" remote bypassed the filter and gbrain import ran.
|
||||||
|
expect(r.exitCode).toBe(0);
|
||||||
|
expect(existsSync(logFile)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
// Silent-data-loss regression: gbrain accepts the import call, exits 0, and
|
// Silent-data-loss regression: gbrain accepts the import call, exits 0, and
|
||||||
// reports imported=0 because collect_files found nothing in the staging dir
|
// reports imported=0 because collect_files found nothing in the staging dir
|
||||||
// (real-world cause: gstack-artifacts-init writes `.gitignore = "*"` into
|
// (real-world cause: gstack-artifacts-init writes `.gitignore = "*"` into
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Bun --preload fixture that emulates bun-on-Windows fs.mkdirSync semantics
|
||||||
|
* (see #2635): a recursive mkdir on an already-existing directory throws
|
||||||
|
* EEXIST, where Node (and bun on Linux/macOS) treat it as a no-op success.
|
||||||
|
*
|
||||||
|
* Loaded into a child process with `bun --preload <this file> <script>`, it
|
||||||
|
* lets the #2635 regression test exercise the exact Windows crash path on any
|
||||||
|
* platform. The patch is deliberately transparent - it changes nothing except
|
||||||
|
* throwing EEXIST where Windows bun would.
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
const orig = fs.mkdirSync;
|
||||||
|
fs.mkdirSync = (p: string, opts: any) => {
|
||||||
|
if (opts?.recursive && fs.existsSync(p) && fs.statSync(p).isDirectory()) {
|
||||||
|
const e = new Error(`EEXIST: file already exists, mkdir '${p}'`);
|
||||||
|
(e as any).code = "EEXIST";
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return orig(p, opts);
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync, spawnSync } from 'child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bash 5.2+ delivers a heredoc body of 64KiB or less through a pipe: the
|
||||||
|
* forked child writes the whole body before exec, and nothing reads the other
|
||||||
|
* end until the command starts. On macOS under pipe-KVA pressure the kernel
|
||||||
|
* hands a fresh pipe a 512-byte buffer, so any body of 512 bytes or more
|
||||||
|
* blocks write() forever — the script hangs at startup, silently, with no
|
||||||
|
* output and no error. The runtime capacity check bash would need
|
||||||
|
* (F_GETPIPE_SZ) is Linux-only.
|
||||||
|
*
|
||||||
|
* Compat level 50 restores the pre-5.2 tempfile path. Every script that ships
|
||||||
|
* an in-window heredoc must set it, and this scanner fails the suite when a
|
||||||
|
* new one appears without the guard.
|
||||||
|
*
|
||||||
|
* The guard is deliberately not a `#!/bin/bash` shebang swap: that pins the
|
||||||
|
* script to whatever bash lives at /bin (3.2 on macOS, absent on some Linux
|
||||||
|
* distributions) and is bypassed entirely by `bash script.sh` call sites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
|
|
||||||
|
// Inclusive byte window where the pipe path is taken AND a starved pipe can
|
||||||
|
// block. Bodies over 64KiB fall back to a tempfile on their own.
|
||||||
|
const MIN_BODY = 512;
|
||||||
|
const MAX_BODY = 64 * 1024;
|
||||||
|
|
||||||
|
const GUARD_RE = /^\s*(?::\s*"\$\{)?BASH_COMPAT(?:[:=]|\}")/m;
|
||||||
|
|
||||||
|
function trackedShellScripts(): string[] {
|
||||||
|
const out = execSync('git ls-files', { cwd: ROOT, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
|
||||||
|
return out
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((f) => {
|
||||||
|
const abs = path.join(ROOT, f);
|
||||||
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return false;
|
||||||
|
if (f.endsWith('.sh')) return true;
|
||||||
|
const head = fs.readFileSync(abs).subarray(0, 64).toString('utf-8');
|
||||||
|
return /^#!.*\b(bash|sh)\b/.test(head);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heredocs in `content` whose body lands inside the deadlock window. */
|
||||||
|
function inWindowHeredocs(content: string): { line: number; tag: string; bytes: number }[] {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const hits: { line: number; tag: string; bytes: number }[] = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const m = /<<-?\s*'?([A-Za-z_][A-Za-z0-9_]*)'?/.exec(lines[i]);
|
||||||
|
if (!m) continue;
|
||||||
|
const tag = m[1];
|
||||||
|
let j = i + 1;
|
||||||
|
const body: string[] = [];
|
||||||
|
while (j < lines.length && lines[j].trim() !== tag) body.push(lines[j++]);
|
||||||
|
const bytes = Buffer.byteLength(body.join('\n')) + 1;
|
||||||
|
if (bytes >= MIN_BODY && bytes <= MAX_BODY) hits.push({ line: i + 1, tag, bytes });
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('heredoc pipe-deadlock guard', () => {
|
||||||
|
test('every script with an in-window heredoc sets BASH_COMPAT', () => {
|
||||||
|
const violations: string[] = [];
|
||||||
|
for (const rel of trackedShellScripts()) {
|
||||||
|
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||||
|
const hits = inWindowHeredocs(content);
|
||||||
|
if (hits.length === 0) continue;
|
||||||
|
if (GUARD_RE.test(content)) continue;
|
||||||
|
for (const h of hits) violations.push(`${rel}:${h.line} <<${h.tag} body=${h.bytes}B`);
|
||||||
|
}
|
||||||
|
if (violations.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Heredoc bodies in the ${MIN_BODY}-${MAX_BODY}B pipe window without a BASH_COMPAT guard:\n ` +
|
||||||
|
violations.join('\n ') +
|
||||||
|
`\n\nFix: add \`BASH_COMPAT=50\` near the top of the script (below any ` +
|
||||||
|
`\`--help\` sed range that reads $0), or shrink the body under ${MIN_BODY}B, ` +
|
||||||
|
`or pipe it in with printf so a live reader exists.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the guard actually moves the body off the pipe', () => {
|
||||||
|
const bash = spawnSync('bash', ['-c', 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
const version = (bash.stdout ?? '').trim();
|
||||||
|
const [maj, min] = version.split('.').map((n) => parseInt(n, 10));
|
||||||
|
// Only 5.2+ takes the pipe path at all; older bash is already on tempfiles.
|
||||||
|
if (!(maj > 5 || (maj === 5 && min >= 2))) {
|
||||||
|
expect(version).toBeTruthy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some sandboxes/containers ship a minimal /dev without /dev/stdin — the
|
||||||
|
// probe medium itself is absent there, so -p/-f both report false and the
|
||||||
|
// probe would answer OTHER for an unobservable fd. Skip rather than fail.
|
||||||
|
const devStdin = spawnSync('bash', ['-c', '[ -e /dev/stdin ] && echo yes || echo no'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
if ((devStdin.stdout ?? '').trim() !== 'yes') return;
|
||||||
|
|
||||||
|
const probe = (guard: string) => `#!/usr/bin/env bash
|
||||||
|
${guard}
|
||||||
|
body=$(printf 'x%.0s' $(seq 1 1000))
|
||||||
|
probe() { if [ -p /dev/stdin ]; then echo PIPE; elif [ -f /dev/stdin ]; then echo TEMPFILE; else echo OTHER; fi; }
|
||||||
|
probe <<EOF
|
||||||
|
$body
|
||||||
|
EOF
|
||||||
|
`;
|
||||||
|
const run = (guard: string) =>
|
||||||
|
(spawnSync('bash', ['-c', probe(guard)], { encoding: 'utf-8' }).stdout ?? '').trim();
|
||||||
|
|
||||||
|
expect(run('')).toBe('PIPE');
|
||||||
|
expect(run('BASH_COMPAT=50')).toBe('TEMPFILE');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,6 +87,26 @@ describe("PR #1620 §4a-postfail in land-and-deploy template", () => {
|
|||||||
expect(body).toMatch(/continue to §4a/);
|
expect(body).toMatch(/continue to §4a/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2656: the failed merge carried --delete-branch; the recovery path must
|
||||||
|
// reconcile the remote branch instead of silently dropping that half.
|
||||||
|
test("MERGED branch reconciles the remote branch (ls-remote, confirm-first delete)", () => {
|
||||||
|
const body = readTmpl();
|
||||||
|
expect(body).toMatch(/git ls-remote --heads origin "\$BRANCH"/);
|
||||||
|
expect(body).toMatch(/gh pr view --json headRefName -q \.headRefName/);
|
||||||
|
expect(body).toMatch(/git push origin --delete "\$BRANCH"/);
|
||||||
|
// Confirm-first: deletion is offered, never unilateral.
|
||||||
|
expect(body).toMatch(/Delete it\?/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MERGED branch reconciliation distinguishes branch-absent from check-failed", () => {
|
||||||
|
const body = readTmpl();
|
||||||
|
// exit 0 + empty output = already clean (idempotent re-runs)...
|
||||||
|
expect(body).toMatch(/already been cleaned up/);
|
||||||
|
// ...non-zero exit = unknown state, never read as a clean branch.
|
||||||
|
expect(body).toMatch(/Couldn't verify remote branch state/);
|
||||||
|
expect(body).toMatch(/never read a failed check as a clean branch/);
|
||||||
|
});
|
||||||
|
|
||||||
test("OPEN branch checks autoMergeRequest before treating as failure", () => {
|
test("OPEN branch checks autoMergeRequest before treating as failure", () => {
|
||||||
const body = readTmpl();
|
const body = readTmpl();
|
||||||
expect(body).toMatch(/gh pr view --json autoMergeRequest/);
|
expect(body).toMatch(/gh pr view --json autoMergeRequest/);
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* cleanup_old_claude_symlinks destination scan (#2204).
|
||||||
|
*
|
||||||
|
* The helper used to iterate the payload skill dirs. When the payload is
|
||||||
|
* gone the glob matches nothing, so leftover flat skill dirs in $skills_dir
|
||||||
|
* stay forever. This suite extracts the REAL function from setup and drives
|
||||||
|
* it against a temp skills tree — payload-missing orphans must go, user
|
||||||
|
* skills must stay.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
|
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||||
|
|
||||||
|
function extractFn(name: string): string {
|
||||||
|
const start = SETUP_SRC.indexOf(`${name}() {`);
|
||||||
|
const end = SETUP_SRC.indexOf('\n}\n', start);
|
||||||
|
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
|
||||||
|
return SETUP_SRC.slice(start, end + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupBody(): string {
|
||||||
|
return extractFn('cleanup_old_claude_symlinks');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('setup: cleanup_old_claude_symlinks — static (#2204)', () => {
|
||||||
|
test('scans the skills dir, not only the payload', () => {
|
||||||
|
const body = cleanupBody();
|
||||||
|
expect(body).toContain('for old_target in "$skills_dir"/*');
|
||||||
|
expect(body).toContain('[ "$skill_name" = "gstack" ] && continue');
|
||||||
|
expect(body).toContain('readlink');
|
||||||
|
expect(body).toContain('gstack/*');
|
||||||
|
expect(body).toContain('gstack-*) continue');
|
||||||
|
expect(body).toContain('-d "$old_target"');
|
||||||
|
expect(body).toContain('-L "$old_target/SKILL.md"');
|
||||||
|
expect(body).toContain('rm -rf "$old_target"');
|
||||||
|
// SKILL.md arm must use path-segment provenance, not a bare substring.
|
||||||
|
expect(body).toContain('gstack/*|*/gstack/*|*/.gstack/render/claude/*');
|
||||||
|
expect(body).not.toMatch(/\*gstack\*\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows real-file reap still requires a live payload name list', () => {
|
||||||
|
const body = cleanupBody();
|
||||||
|
expect(body).toContain('for skill_dir in "$gstack_dir"/*/');
|
||||||
|
expect(body).toContain('[ "${IS_WINDOWS:-0}" -eq 1 ] && [ -d "$gstack_dir" ]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlinks — behavior (#2204)', () => {
|
||||||
|
function runCleanup(opts: {
|
||||||
|
isWindows?: '0' | '1';
|
||||||
|
payload?: boolean;
|
||||||
|
plant: (skills: string, payload: string) => void;
|
||||||
|
}): { status: number; stdout: string; stderr: string; names: string[]; tmp: string } {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cleanup-orphans-'));
|
||||||
|
const skills = path.join(tmp, 'skills');
|
||||||
|
const payload = path.join(skills, 'gstack');
|
||||||
|
fs.mkdirSync(skills, { recursive: true });
|
||||||
|
if (opts.payload) {
|
||||||
|
fs.mkdirSync(payload, { recursive: true });
|
||||||
|
}
|
||||||
|
opts.plant(skills, payload);
|
||||||
|
const gstackArg = opts.payload ? payload : path.join(skills, 'missing-payload');
|
||||||
|
const script = [
|
||||||
|
'set -e',
|
||||||
|
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
|
||||||
|
extractFn('cleanup_old_claude_symlinks'),
|
||||||
|
`cleanup_old_claude_symlinks "${gstackArg}" "${skills}"`,
|
||||||
|
].join('\n');
|
||||||
|
const result = spawnSync('bash', ['-c', script], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
const names = fs.existsSync(skills)
|
||||||
|
? fs.readdirSync(skills).sort()
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
status: result.status ?? -1,
|
||||||
|
stdout: result.stdout ?? '',
|
||||||
|
stderr: result.stderr ?? '',
|
||||||
|
names,
|
||||||
|
tmp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function plantDanglingSkillMd(skills: string, name: string) {
|
||||||
|
const dir = path.join(skills, name);
|
||||||
|
fs.mkdirSync(dir);
|
||||||
|
fs.symlinkSync(`gstack/${name}/SKILL.md`, path.join(dir, 'SKILL.md'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function plantUserSkill(skills: string, name: string) {
|
||||||
|
const dir = path.join(skills, name);
|
||||||
|
fs.mkdirSync(dir);
|
||||||
|
fs.writeFileSync(path.join(dir, 'SKILL.md'), '---\nname: user-owned\n---\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('payload gone: dangling SKILL.md orphan is removed, user skill stays', () => {
|
||||||
|
const r = runCleanup({
|
||||||
|
payload: false,
|
||||||
|
plant(skills) {
|
||||||
|
plantDanglingSkillMd(skills, 'qa');
|
||||||
|
plantUserSkill(skills, 'my-own');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr).toBe('');
|
||||||
|
expect(r.stdout).toContain('cleaned up old entries: qa');
|
||||||
|
expect(r.names).toEqual(['my-own']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payload gone: whole-dir symlink into gstack/ is removed', () => {
|
||||||
|
const r = runCleanup({
|
||||||
|
payload: false,
|
||||||
|
plant(skills) {
|
||||||
|
fs.symlinkSync('gstack/qa', path.join(skills, 'qa'));
|
||||||
|
plantUserSkill(skills, 'my-own');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.names).toEqual(['my-own']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payload present: leftover flat name pointing at gstack is still removed', () => {
|
||||||
|
const r = runCleanup({
|
||||||
|
payload: true,
|
||||||
|
plant(skills, payload) {
|
||||||
|
const src = path.join(payload, 'qa');
|
||||||
|
fs.mkdirSync(src);
|
||||||
|
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: qa\n---\n');
|
||||||
|
plantDanglingSkillMd(skills, 'qa');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.names).toEqual(['gstack']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payload present: dangling name absent from the payload is still removed', () => {
|
||||||
|
// Unique dest-scan win: the old "$gstack_dir"/*/ loop only considered
|
||||||
|
// names that still exist in the payload. A retired leftover must go.
|
||||||
|
const r = runCleanup({
|
||||||
|
payload: true,
|
||||||
|
plant(skills, payload) {
|
||||||
|
const src = path.join(payload, 'ship');
|
||||||
|
fs.mkdirSync(src);
|
||||||
|
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: ship\n---\n');
|
||||||
|
plantDanglingSkillMd(skills, 'qa');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.names).toEqual(['gstack']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not remove a SKILL.md symlink that does not point at gstack', () => {
|
||||||
|
const r = runCleanup({
|
||||||
|
payload: false,
|
||||||
|
plant(skills) {
|
||||||
|
const dir = path.join(skills, 'elsewhere');
|
||||||
|
fs.mkdirSync(dir);
|
||||||
|
fs.symlinkSync('other/SKILL.md', path.join(dir, 'SKILL.md'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toBe('');
|
||||||
|
expect(r.names).toEqual(['elsewhere']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not remove a SKILL.md whose target merely contains the substring gstack', () => {
|
||||||
|
const r = runCleanup({
|
||||||
|
payload: false,
|
||||||
|
plant(skills) {
|
||||||
|
const dir = path.join(skills, 'notes');
|
||||||
|
fs.mkdirSync(dir);
|
||||||
|
fs.symlinkSync('../../archive/my-gstack-backup/SKILL.md', path.join(dir, 'SKILL.md'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toBe('');
|
||||||
|
expect(r.names).toEqual(['notes']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reaps a leftover whose SKILL.md points at the user render dir', () => {
|
||||||
|
const r = runCleanup({
|
||||||
|
payload: false,
|
||||||
|
plant(skills) {
|
||||||
|
const dir = path.join(skills, 'qa');
|
||||||
|
fs.mkdirSync(dir);
|
||||||
|
fs.symlinkSync('../../.gstack/render/claude/qa/SKILL.md', path.join(dir, 'SKILL.md'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain('cleaned up old entries: qa');
|
||||||
|
expect(r.names).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not remove prefixed gstack-* names or the payload dir', () => {
|
||||||
|
const r = runCleanup({
|
||||||
|
payload: true,
|
||||||
|
plant(skills, payload) {
|
||||||
|
fs.writeFileSync(path.join(payload, 'SKILL.md'), '---\nname: gstack\n---\n');
|
||||||
|
const prefixed = path.join(skills, 'gstack-qa');
|
||||||
|
fs.mkdirSync(prefixed);
|
||||||
|
fs.symlinkSync('gstack/qa/SKILL.md', path.join(prefixed, 'SKILL.md'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.names).toEqual(['gstack', 'gstack-qa']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows real-file orphan is left alone when the payload is gone', () => {
|
||||||
|
const r = runCleanup({
|
||||||
|
isWindows: '1',
|
||||||
|
payload: false,
|
||||||
|
plant(skills) {
|
||||||
|
plantUserSkill(skills, 'qa');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.names).toEqual(['qa']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows real-file leftover is removed when the payload still names it', () => {
|
||||||
|
const r = runCleanup({
|
||||||
|
isWindows: '1',
|
||||||
|
payload: true,
|
||||||
|
plant(skills, payload) {
|
||||||
|
const src = path.join(payload, 'qa');
|
||||||
|
fs.mkdirSync(src);
|
||||||
|
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: qa\n---\n');
|
||||||
|
plantUserSkill(skills, 'qa');
|
||||||
|
plantUserSkill(skills, 'my-own');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.names).toEqual(['gstack', 'my-own']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,3 +62,67 @@ describe('setup: --help flag (#1133)', () => {
|
|||||||
expect(res.stdout).toContain('Usage:');
|
expect(res.stdout).toContain('Usage:');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setup: host accept-list ↔ hosts/index.ts registry cross-check (#2361)', () => {
|
||||||
|
// The #2361 failure class: a host passes --host validation but has no
|
||||||
|
// install arm, so `./setup --host <it>` configures nothing and exits 0.
|
||||||
|
// This cross-check derives BOTH sides — the registry from hosts/index.ts
|
||||||
|
// and the case arms from setup — so adding a host to either place without
|
||||||
|
// the other goes red at the moment of the drift, not in a user report.
|
||||||
|
|
||||||
|
const content = fs.readFileSync(SETUP_SCRIPT, 'utf-8');
|
||||||
|
|
||||||
|
function hostCaseArms(): { installTargets: string[]; namedArms: string[] } {
|
||||||
|
const start = content.indexOf('case "$HOST" in');
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const block = content.slice(start, content.indexOf('\nesac', start));
|
||||||
|
// The pipe list is the install accept-list; single-name arms are informational.
|
||||||
|
const installTargets: string[] = [];
|
||||||
|
const namedArms: string[] = [];
|
||||||
|
for (const m of block.matchAll(/^ {2}([a-z|]+)\)/gm)) {
|
||||||
|
const names = m[1].split('|');
|
||||||
|
if (names.length > 1) installTargets.push(...names.filter((n) => n !== 'auto'));
|
||||||
|
else if (names[0] !== 'auto') namedArms.push(names[0]);
|
||||||
|
}
|
||||||
|
return { installTargets, namedArms };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('registry names == accept-list (minus auto) + informational arms', async () => {
|
||||||
|
const { ALL_HOST_CONFIGS } = await import('../hosts/index');
|
||||||
|
const registered = ALL_HOST_CONFIGS.map((c: { name: string }) => c.name).sort();
|
||||||
|
const { installTargets, namedArms } = hostCaseArms();
|
||||||
|
const covered = [...new Set([...installTargets, ...namedArms])].sort();
|
||||||
|
expect(covered).toEqual(registered);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every accept-listed install target has a dispatch arm (the exact #2361 hole)', () => {
|
||||||
|
// Set-membership alone would have passed while slate sat accepted-but-
|
||||||
|
// unwired: the invariant that bites is accept-list ⊆ dispatch arms.
|
||||||
|
const { installTargets } = hostCaseArms();
|
||||||
|
expect(installTargets.length).toBeGreaterThan(0);
|
||||||
|
for (const host of installTargets) {
|
||||||
|
expect(content).toMatch(new RegExp(`\\[ "\\$HOST" = "${host}" \\]`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slate informational arm: explains itself, points at --host claude, exit 0', () => {
|
||||||
|
const res = spawnSync('bash', [SETUP_SCRIPT, '--host', 'slate'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(0);
|
||||||
|
expect(res.stdout).toContain('./setup --host claude');
|
||||||
|
expect(res.stdout).toContain('.claude/skills');
|
||||||
|
// It must not fall through into the installer.
|
||||||
|
expect(res.stdout).not.toMatch(/Installing|bun install|Building/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero-dispatch guard exists: unwired host errors loudly instead of exit-0 no-op', () => {
|
||||||
|
// The guard is only reachable when a future host is accepted but unwired,
|
||||||
|
// so pin its presence and shape statically: it must name the host, call
|
||||||
|
// itself a setup bug, and exit 1.
|
||||||
|
const guard = content.match(/no install arm exists for host[^\n]*\n\s*exit 1/);
|
||||||
|
expect(guard).toBeTruthy();
|
||||||
|
expect(content).toContain("[ \"$HOST\" != \"auto\" ]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user