mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-29 17:30:40 +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
@@ -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"');
|
||||
});
|
||||
|
||||
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('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', () => {
|
||||
|
||||
@@ -20,6 +20,18 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN_DIR = path.join(ROOT, 'bin');
|
||||
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 gstackHome: string;
|
||||
let worktreeDir: string;
|
||||
@@ -30,10 +42,12 @@ let gbrainStateFile: string;
|
||||
function makeFakeGbrain(opts: {
|
||||
version?: string | null; // null = "binary missing" (don't write the file)
|
||||
syncFails?: boolean;
|
||||
syncHelpNoSource?: boolean; // simulate an older gbrain whose sync lacks --source
|
||||
}) {
|
||||
const version = opts.version ?? '0.18.2';
|
||||
if (version === null) return; // simulate missing binary by NOT writing one
|
||||
const syncFails = opts.syncFails ?? false;
|
||||
const syncHelpNoSource = opts.syncHelpNoSource ?? false;
|
||||
|
||||
// Stub gbrain reads/writes state from a JSON file. Fields:
|
||||
// sources: [{id, local_path, federated}]
|
||||
@@ -97,6 +111,13 @@ json.dump(state, open('$STATE','w'), indent=2)
|
||||
fi
|
||||
|
||||
# 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
|
||||
${syncFails ? 'echo "sync failed: connection error" >&2; exit 1' : 'echo "1 page imported"; exit 0'}
|
||||
fi
|
||||
@@ -113,7 +134,7 @@ function run(
|
||||
opts: { env?: Record<string, string> } = {}
|
||||
) {
|
||||
const env = {
|
||||
PATH: `${fakeBinDir}:${process.env.PATH || '/usr/bin:/bin:/opt/homebrew/bin'}`,
|
||||
PATH: `${fakeBinDir}:${HERMETIC_PATH}`,
|
||||
HOME: tmpHome,
|
||||
GSTACK_HOME: gstackHome,
|
||||
GSTACK_BRAIN_WORKTREE: worktreeDir,
|
||||
@@ -181,6 +202,31 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => {
|
||||
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', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
@@ -229,14 +275,52 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => {
|
||||
|
||||
test('--strict + gbrain missing on PATH: exits 2', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
// Don't make a fake gbrain — fakeBinDir is empty. Keep system dirs on PATH
|
||||
// so basic commands (git, awk, sed, etc.) work; only `gbrain` is absent.
|
||||
const r = run(['--strict'], {
|
||||
env: { PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin` },
|
||||
});
|
||||
// Don't make a fake gbrain — fakeBinDir is empty. run() applies the
|
||||
// hermetic PATH base; only `gbrain` is absent.
|
||||
const r = run(['--strict']);
|
||||
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', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-alice.git');
|
||||
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)', () => {
|
||||
test('--database-url flag is exported as GBRAIN_DATABASE_URL to child gbrain calls', () => {
|
||||
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);
|
||||
// Now remove the fake gbrain so uninstall sees gbrain missing
|
||||
fs.rmSync(path.join(fakeBinDir, 'gbrain'), { force: true });
|
||||
const r = run(['--uninstall'], {
|
||||
env: { PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin` },
|
||||
});
|
||||
const r = run(['--uninstall']);
|
||||
expect(r.status).toBe(0); // best-effort, never fails on gbrain absence
|
||||
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);
|
||||
});
|
||||
|
||||
// #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
|
||||
// reports imported=0 because collect_files found nothing in the staging dir
|
||||
// (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/);
|
||||
});
|
||||
|
||||
// #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", () => {
|
||||
const body = readTmpl();
|
||||
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:');
|
||||
});
|
||||
});
|
||||
|
||||
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