Merge remote-tracking branch 'origin/main' into prompt-token-load-reduction

This commit is contained in:
Garry Tan
2026-08-25 02:29:33 +00:00
39 changed files with 1460 additions and 74 deletions
+93
View File
@@ -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
}
});
});
+117
View File
@@ -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 });
}
});
});
+5 -2
View File
@@ -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', () => {
+131 -9
View File
@@ -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
});
+38
View File
@@ -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);
};
+122
View File
@@ -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');
});
});
+20
View File
@@ -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/);
+281
View File
@@ -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 });
}
});
});
+64
View File
@@ -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\" ]");
});
});