mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-07 20:07:21 +02:00
v1.65.0.0 feat: fork port wave 2 — feature fixes, session persistence, Apple releases, supply-chain CI (#2577)
* fix(memory-ingest): pass --include-gitignored to gbrain import gstack-artifacts-init writes an ignore-everything .gitignore (a bare `*`, headed "Do not edit") at the root of ~/.gstack. The memory ingest stages pages into ~/.gstack/.staging-ingest-<pid>-<ts>/, which is inside that repo, and gbrain's markdown collector honours .gitignore. The collector therefore matches every staged file against `*` and collects zero. The failure is silent. gbrain import exits 0 having imported nothing while the ingest prints `written: N` from the STAGED count rather than the imported count, so a run that indexes nothing looks identical to a healthy one and the memory corpus quietly stops growing. Reproduction, using git's own ignore machinery (no gbrain needed): git init . mkdir -p .staging-ingest-12345/learnings echo x > .staging-ingest-12345/learnings/page.md printf '*\n' > .gitignore git ls-files --others --exclude-standard # -> empty Passing --include-gitignored makes the import independent of whatever .gitignore sits above the staging directory. Adding a negation to the generated .gitignore is the alternative, but that file is gstack-owned and marked "Do not edit", so any regeneration silently reintroduces the bug. Adds a regression pin in the shape of memory-ingest-no-put_page.test.ts, plus a behavioural test for the collision itself. Both source pins fail against the unpatched file. * fix(memory-ingest): GIT_CEILING_DIRECTORIES defense-in-depth on the import child (#2144) Second layer under #2560's --include-gitignored: a realpath'd ceiling at the staging dir's parent pushes any git-enumerating collector off the git fast path (which sees zero files under ~/.gstack's ignore-everything root) onto its plain FS walk, even on gbrain builds whose flag semantics drift. Ceiling is realpath'd because git compares canonicalized directories during discovery — a staging dir reached through a symlink (macOS /var -> /private/var, symlinked $GSTACK_HOME) otherwise never matches. Behavioral tests prove discovery stops at the ceiling from the staging dir, including through a symlinked path, using git itself — no gbrain required. Mechanism ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(autoplan): Phase 4 task aggregator emitted zero tasks on every run (#2018) The branch+commit jq filter piped to the split commit array and then referenced .commit — jq rebinds context across a pipe, so .commit indexed the ARRAY with a string, every input line errored into 2>/dev/null, and || true swallowed the exit. The aggregate table has been empty for every user since the feature shipped. Bind .commit to a variable before the pipe. Functional pin extracts the ACTUAL emitted jq program from the resolver and runs it against fixture JSONL (verified RED against the broken filter), plus a source-shape guard against reintroducing a context-rebinding reference. Fix mechanism from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(codex): BSD mktemp templates broke /codex on every macOS install (#2091) macOS mktemp requires the X's to end the template; the five "codex-*-XXXXXX.txt" sites failed with "mkstemp failed ... File exists" before Codex ever ran (reproduced live on this machine). Same class fixed in claude/SKILL.md.tmpl's three sites. bin/gstack-paths now strips macOS's trailing slash from TMPDIR so TMP_ROOT-built paths stop carrying "//". Static tripwire scans every tracked .tmpl for characters after the X-run in a mktemp template (longer X-runs stay valid), plus a live portability check of the emitted shape. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(decision-log): --supersede silently discarded the replacement decision The supersede/redact branch appended the retirement event and exited before the JSON argument was ever read — a user recording a reversal WITH its replacement lost the replacement, and the payload finder's first-non-flag-arg predicate would have mistaken the target id for JSON anyway. Payloads are now identified by their leading brace, validated BEFORE any write, and appended FIRST (retirement second), so the only visible interleaving under a crash is both-active — recoverable, never lost. The replacement carries supersedes:<old-id> provenance. Bare --supersede <id> (the documented reversal-without-replacement) stays legal; --redact with a payload now refuses instead of dropping it. Ported from time-attack/gstack (GStack 2), tests included. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(brain-context): cold-start probe latency permanently disabled gbrain context gbrainAvailable() spawned gbrain --version under a 500ms budget; a cold CLI start on a loaded machine blew the timeout, misclassified gbrain as missing, and every skill session silently ran brainless — plus the per-query re-probe burned 3x the budget before any real work. Replaced with a memoized stat-based PATH scan (PATHEXT-aware on Windows) and made the query timeout overridable via GSTACK_BRAIN_TIMEOUT_MS for loaded CI environments. Also picks up the fork's manifest-filter coverage (#1687 shape) against the fake-gbrain harness — passes against our existing filter support. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(setup-gbrain): voyage-code-3 flags were silently dropped under zsh (#1798) zsh does not word-split an unquoted $VAR, so all three PGLite-init sites passed the entire flag string as ONE argv word — gbrain ignored it and silently fell back to its default embedding model, downgrading code retrieval for every zsh user (macOS default shell). Flags now ride the positional params (set -- ...; "$@"). Tests run the shape under BOTH bash and zsh against the fake-gbrain argv recorder (per-word argc log distinguishes one-blob from split), include a demonstration of the zsh collision on the old shape, and pin the template's three sites statically. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(model-benchmark): recognize macOS Keychain auth in the claude adapter (#1890) The default macOS Claude Code install stores OAuth under the Keychain generic-password service "Claude Code-credentials" and never writes ~/.claude/.credentials.json, so available()'s file-or-env sniff reported "No Claude auth found" while claude -p worked fine. On darwin the sniff now also probes the Keychain entry — metadata only (no -w, the secret is never read), 5s timeout, any security(1) failure degrades to not-found. Verified live on this machine (subscription install, no creds file, Keychain entry present). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(upgrade): v1.27 migration no longer auto-proceeds without a TTY or records a failed rename as done (#1383) Two silent-failure shapes in one script. Non-interactive runs (Claude Code Bash tool, CI) blanket-auto-proceeded into a REMOTE repo rename — now they skip-for-now by default and ask again next upgrade; unattended runs opt in with GSTACK_MIGRATE_ASSUME_YES=1. And a failed gh rename was journaled as done and the done-touchfile written, permanently stranding a half-renamed install — the failed step now stays PENDING with the manual command printed, finalize refuses the done-marker while any step is unjournaled, and the migration exits 1 with a re-run pointer while completed steps still skip on retry. Harness updated to opt in explicitly; new tests pin the default-skip and failure-stays-pending-then-retry-succeeds contracts (13/13). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ship): REST fallback when gh pr edit hits the Projects-classic GraphQL deprecation (#1079) On repos where GitHub enforces the Projects-classic sunset, gh pr edit hard-errors on repository.pullRequest.projectCards and Step 19's PR body update dies. The template now names the error shape, says it is not an auth problem, and falls back to the REST endpoint (gh api pulls/N -X PATCH) with the SAME already-redaction-scanned temp file for body and title. Generated SKILL.md regen rides the cluster regen commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ship): test-command detection was blind to Django and config-less-but-tested projects The Test Framework Bootstrap detected Python only via requirements.txt or pyproject.toml and treated missing config files as no-tests, so a green 'python manage.py test' Django app, a Go project with *_test.go beside the source, in-source Rust #[test] blocks, or a package.json with only a test script all got offered a SECOND test framework over a working one. Detection now enumerates definitive per-ecosystem markers (manage.py, tox.ini/setup.cfg, pom.xml/gradle, Makefile test targets, a tracked-file test census, in-source Rust tests) as EVIDENCE for the question it asks — never a command to run blind — preserving the read-CLAUDE.md-or-ask contract, with a marker→candidate-command table and ask-once persistence. The shared coverage-audit detection block gains the same markers. Test runs the resolver's emitted detection bash against Django / Go / Rust / Node fixtures in throwaway git repos. Ported from time-attack/gstack commit e3259078 (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate SKILL.md files for cluster A (autoplan jq, codex mktemp, setup-gbrain zsh, ship detection + REST fallback) Atomic regen of the 9 generated files whose templates/resolvers changed in the A-cluster commits. bun run gen:skill-docs, no hand edits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: refresh ship goldens + parity ratios for cluster A growth Codex/Factory hosts render single-file ship skills whose committed goldens must track template changes; refreshed from the regenerated renders. Parity size guards bumped with the growth itemized — ship (carve-guards) 1.08 -> 1.10 for the detection-evidence + REST-fallback growth measured at 1.090x, qa (parity-harness monolith invariant) 1.07 -> 1.12 for the shared coverage-audit markers measured at 1.111x. Kept tight so the next growth is a deliberate decision, not drift; the Apple adapter raises ship again with its own justification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain-sync): enforce the per-repo policy at the code-import chokepoint (#2140 sync path) The deny/read-only tiers in ~/.gstack/gbrain-repo-policy.json were stored by gstack-gbrain-repo-policy but enforced only in /sync-gbrain skill prose — a direct or cron invocation of gstack-gbrain-sync ingested repo code regardless. Worse: the code stage's egress receipt has cited 'per-repo policy chokepoint (repoPolicyTier)' as its consent since v1.63 while no such function existed. repoPolicyTier() now gates the stage before the dry-run branch: deny → refused-policy-deny (exit 1, loud), read-only → clean skipped-policy-read-only (code ingest writes pages), unreadable store → fail-closed refused-policy-unreadable, no store → unchanged fail-open. Subprocess tests pin all four paths against real git repos and a permission-blocked store (verified RED against the ungated binary). The receipt's consent string is truthful from this commit. #2140's ingest-path source-isolation ask remains open — partial-progress comment at ship. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios-qa): /auth/sessions no longer hands raw bearer tokens to any local process The loopback sessions list echoed live tokens — a harvest-and-replay primitive for anything on the machine (same class as the /health token leak fixed in v1.63). The list now returns a device-salted 16-hex token_id plus metadata; the salt is shared with the attempts log so identifiers correlate. /auth/revoke keeps the list→revoke workflow alive by accepting token_id alongside the caller's own raw token and identity. saltedHash() is exported from audit.ts and writeAttempt now reuses it (was inlined). Integration tests pin raw-token absence, the id shape/metadata, and the token_id revoke round-trip (verified RED against the leaking handler). List fix ported from time-attack/gstack (GStack 2); token_id revoke is ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios-qa): boot token out of os_log entirely; IPv4 listener pinned to loopback at the socket The StateServer's bootstrap announce logged the live boot token with privacy: .public — and nothing consumed it: the daemon has read the token from the 0600 app-container file since the devicectl copy flow landed. The log line handed a credential to anything reading the unified log during the launch window. It now announces port/build only. The IPv4 listener bound the wildcard interface and relied on the per-connection peer check alone; IPv4 has no CoreDevice tunnel path, so it now binds 127.0.0.1 via requiredLocalEndpoint at the socket level. IPv6 keeps the wildcard bind for CoreDevice ULA peers by design. Static pins cover both the template and the fixture app copy. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(make-pdf): close the offline-gate bypass via raw-HTML fetch vectors With --allow-network off, the sanitizer stripped script/iframe/link but let Chromium fetch remote resources at print time through four raw-HTML vectors: <style> @import (any form), remote url() in <style> blocks and inline style attributes (incl. protocol-relative //), srcset with a remote candidate (Chromium prefers srcset over the inlined src), and remote src/poster on video/audio/source/track. All neutralized at the sanitizer; remote <img src> is deliberately left for the image inliner so its blocked-remote placeholder still fires, and url() mentions in prose/code spans stay untouched. Fork's test suite ported verbatim (12 cases incl. the end-to-end render assertion), verified RED against the old sanitizer. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pair-agent): tunnel activation is consent-gated — and the receipt's consent claim is now real The tunnel egress receipts have claimed consent: 'pair_agent=on' since v1.63 while no such key or gate existed — ngrok installed+authed was enough for the CLI to auto-start an internet-facing tunnel. isPairAgentEnabled() (fail- closed, env-overridable) now gates all three activation points: CLI auto-start, POST /tunnel/start (refuses with the enable hint), and the BROWSE_TUNNEL=1 startup bind. Consent-on-first-use, not silent breakage: the /pair-agent skill asks once (one-way-door posture), sets pair_agent via gstack-config (registered with on|off validation, default off), and never asks again; direct API callers get the same hint in the refusal. Adapted from the fork's gate: their reader targeted config.json, which on main would have made the gate silently un-enableable — ours reads the canonical ~/.gstack/config.yaml with the JSON shape as fallback, pinned by tests either way (11 cases, gate wiring tripwires included). Ported from time-attack/gstack (GStack 2), store adaptation ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate pair-agent SKILL.md for cluster B (consent gate) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): cancel the parent watchdog when handoff promotes a daemon to headed The parent-process watchdog assumes connection mode is fixed at boot: headless daemons outlive their parent, headed ones do not. The env guards (BROWSE_PARENT_PID=0, BROWSE_HEADED=1) only cover daemons that were headed when they started. handoff breaks that assumption. It swaps in a headed context on a RUNNING daemon and sets connectionMode = 'headed' without a restart, so a daemon that legitimately registered a watchdog lands on the fatal side of the branch. The parent is usually a short-lived shell, and Claude Code's Bash tool kills one after every invocation, so the next 15s poll shuts the daemon down. The user-visible effect is that handoff destroys the thing it just created. It exists so a human can log in, solve a CAPTCHA, or clear an MFA prompt; the browser disappears about fifteen seconds later and takes the session with it. Observed while driving two registrar control panels: five daemon deaths and three logins, each one discarding the authenticated session. BrowserManager now exposes onHeadedPromotion, fired only on runtime promotion and not on a headed boot, and the server binds it to a canceller for the interval it already owned but previously discarded. Bound on both the module-level manager and any embedder-supplied one, since the watchdog reads activeBrowserManager and binding only the default would let embedders promote silently. The binding sits next to the browserManager declaration rather than next to clearParentWatchdog. Placing it with the function, which lives with the watchdog it cancels, reads better but touches browserManager in its temporal dead zone, which aborts module evaluation and leaves every later const uninitialized. findport tests catch that immediately. Tests: watchdog.test.ts already noted in its header that its three cases all fix mode via env at spawn time, so none reaches the headed branch. Driving a real handoff needs a headed Chromium, so the wiring is pinned with static tripwires instead, matching cdp-session-cleanup.test.ts and server-auth.test.ts. Verified they fail when the notification call is removed and pass when restored. Full `bun test` shows the same 6 pre-existing failures on this branch and on main (gstack-gbrain-detect, gstack-artifacts-init), which pass in isolation on both, so they are test-order pollution rather than a regression here. * fix(browse): pass windowsHide so the daemon stops popping console windows On Windows, `browse` leaves empty black console windows on top of whatever the user is doing — they pop up every few minutes for as long as any browser skill is alive, and outlive the process that created them. Cause: `bun-polyfill.cjs` maps `Bun.spawn`/`Bun.spawnSync` onto node's `child_process`, and node defaults `windowsHide` to **false**. Bun never creates these windows, so nothing in the daemon's own code looks wrong — the behaviour only appears on the node fallback path. The one users notice is `spawnTerminalAgent()`, which launches `bun run terminal-agent.ts` through this shim. The daemon respawns it on a watchdog, so closing the window is not enough — a new one arrives shortly after. Ten `bun.exe` processes were live on the machine this was diagnosed on. Why they linger after the child exits: with the default terminal application set to "Let Windows decide", the console is brokered through Windows Terminal via svchost, and WT leaves the empty frame behind when its only child exits. The frame has no child process at all, which is why it looks like a dead terminal. Setting `windowsHide: true` on both wrappers fixes every console child routed through the shim — the bun agent plus the `tasklist`, `git` and `powershell` calls elsewhere in the daemon. No behaviour change on macOS or Linux, where the option is ignored. Not covered by this commit: `chromium.launch()` goes through playwright's own process launcher rather than this shim, so it still creates one window per daemon start. Worth a follow-up. * test(browse): make bun-polyfill tests runnable on Windows, and cover windowsHide `bun test browse/test/bun-polyfill.test.ts` was **0 pass / 4 fail on Windows** before this — every test in the file, on the platform the polyfill exists to support. Each test interpolates the polyfill's absolute path into a single-quoted JS string passed to `node -e`. On Windows that path has backslashes, so JS eats them as escapes: 'C:\Users\jwilk\dev\gstack-fork\browse\src\bun-polyfill.cjs' -> C:Usersjwilkdevgstack-forkrowsesrcun-polyfill.cjs (`\b` is a real escape, so it deletes a character too.) `require()` throws, the subprocess dies, stdout is empty, and every assertion compares against "". The tests pass on macOS and Linux purely because those paths have no backslashes. Fixed by interpolating with `JSON.stringify(polyfillPath)`, which quotes and escapes correctly on all platforms. Also adds a regression test for the windowsHide fix in the previous commit. It stubs `child_process.spawn`/`spawnSync` *before* the polyfill destructures them and asserts the captured options, so it is deterministic and needs no window — it verifies the contract on macOS and Linux too, where the option is a no-op. Verified on Windows: 5 pass / 0 fail with the fix, and the new test alone fails ("VISIBLE" instead of "HIDDEN") when the previous commit is reverted. * fix(browse): forward windowsHide through the Bun polyfill spawn shims The Node fallback shim accepts a Bun.spawn options object and forwards only stdio, env and cwd to child_process.spawn. windowsHide is dropped, and because Node defaults it to false while Bun.spawn hides the console window, the omission inverts the behavior on the one platform the shim exists to support. Symptom: the terminal-agent respawn in server.ts (60s watchdog ticker) pops a visible bun.exe console window on Windows every time it fires, so the window keeps coming back with no scheduled task or startup entry behind it. stdio:'ignore' silences the child's output but does not suppress its window. Both shims now forward the option and default it to true, matching the Bun API being emulated; an explicit windowsHide:false still passes through. spawnTerminalAgent also sets it explicitly at the call site. Tests: three cases in browse/test/bun-polyfill.test.ts assert the default for spawn and spawnSync and that an explicit false is honored. Each was confirmed to fail against the unpatched shim. Drive-by, required to run the suite at all on Windows: the tests interpolated an absolute path into a JS string literal, so backslashes were consumed as escapes and every require() failed with MODULE_NOT_FOUND. The path is now normalized to forward slashes. On Windows this file went from 0/4 passing to 7/7. * fix(browse): headed mode on macOS 26 — stop mutating the signed Chromium bundle, heal the ones we already broke (#2242, #2138, #2139) The in-place rebrand rewrote the Chrome-for-Testing bundle's Info.plist (global name replace — which also renamed CFBundleExecutable to a binary that doesn't exist) and overwrote its Resources/*.icns, breaking the codesign seal: GPU process exit_code=5, headed mode dead on macOS 26. The mutation lived in the SHARED Playwright cache, so it also poisoned the user's other Playwright projects. Three layers land together: (1) the rebrand block is gone — branding lives in the GStack Browser.app wrapper via GSTACK_CHROMIUM_PATH, with a tombstone and a static tripwire (no plist/icns writes into the bundle; the tripwire allows the read-only probe below); (2) a launch-time self-heal detects an already-poisoned cache bundle, removes it, and errors with the exact re-fetch command — covering deploy paths that never run migrations; (3) migration v1.64.0.0 sweeps every cached bundle, removes poisoned ones, and re-fetches clean Chromium immediately (migrations run after ./setup, so without the re-fetch an upgrade would end with zero working browser). Functionally verified against fixture caches: poisoned removed, clean untouched, rerun no-op. Migration filename tracks the final VERSION at ship. The #2242 watchdog half is the absorbed PR #2565 (thanks @Screddyice). Tombstone/tripwire ported from time-attack/gstack (GStack 2); self-heal and migration are ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): 'browse stop' no longer restarts the daemon it was asked to stop The stop handler awaited shutdown() — which ends in process.exit — before returning, so the acknowledgement never egressed. The CLI's fetch reset, which its crash path reasonably interpreted as a dead daemon: it relaunched Chromium, re-sent stop, watched the daemon exit again, and errored 'Server crashed twice in a row'. Every stop cost a wasted Chromium launch and a nonzero exit. The ack now returns first; shutdown fires on a 25ms unref'd timer. Same fix for restart. Fork's test pins ack-before-teardown for both. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): lock acquisition reports real errors instead of phantom contention (#1084) acquireServerLock's bare catch treated EVERY failure as 'another process holds the lock' — a missing state dir, EACCES, or ENOSPC read as permanent phantom contention with nothing to debug. Now only EEXIST is contention: ENOENT self-heals with one mkdirSecure retry, everything else throws ServerLockError carrying the real errno, and the stale-lock unlink/retry loop is depth-capped so it can't livelock. Fork's five-case test ported. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(browse): integration coverage for #1781 busy-vs-dead recovery Fork's wedged-daemon fixture: first /command connection drops, daemon PID stays alive. Pins the whole contract — CLI retries the same daemon instance without a kill, state file untouched, no restart, exactly two command requests. Message-text assertion adapted: our CLI retries silently at the probe layer where the fork announces on stderr; the behavior, not the message, is the invariant. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): windowsHide on every Windows-reachable spawn (#1835) Console windows flashed (and stole focus) on every daemon relaunch, taskkill, tasklist poll, and powershell DPAPI call — node-level spawns default windowsHide to false. Covered: the node -e launcher (outer spawnSync AND the inner detached daemon spawn inside the launcher string), the dev-mode bun fallback, killServer's taskkill, isProcessAlive's tasklist, and cookie-import's powershell + tasklist. The Bun-polyfill shims were covered by absorbed PRs #2523 + #2539 (thanks @jwilk-hrep, @jerrynicholsai); this closes the sites those PRs didn't reach. The icacls sites land with the #1605 DACL commit alongside the static tripwire that pins all of them. R8's planned spawnHidden() helper is deliberately NOT built: the polyfill default plus the tripwire achieve the no-drift goal without indirection over seven heterogeneous call shapes. The polyfill + spawn-hide tests join the Windows CI shard. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): self-repair broken Windows DACLs on state dirs (#1605) icacls '/inheritance:r /grant:r' can partially fail on localized or domain accounts: inheritance strips but the user grant doesn't resolve, leaving a machine-SID-only DACL the owner can't even list — the sidebar/PTY failure chain in #1605, caused by the very hardening call meant to protect the dir. mkdirSecure now verifies listability after hardening (a real readdir — fs.accessSync doesn't consult NTFS ACLs) and repairs via icacls /reset, re-hardens, and if hardening breaks access again leaves inherited ACLs: functional-but-unhardened beats hardened-but-unusable. The icacls calls carry windowsHide (#1835's last two sites) and the fork's static spawn-hide tripwire lands here, pinning every covered site. file-permissions.test.ts is already in the windows-free-tests curated shard, so the DACL contract executes on windows-latest. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(browse): opt-in session persistence — auth survives daemon restarts (#778, #2193) BROWSE_PERSIST_STATE=1 snapshots cookies + per-tab URL/localStorage/ sessionStorage to <stateDir>/session-state.json (0600) on a 30s unref'd interval and at clean shutdown, and restores on the next launch — killing the top-complained auth-lost-on-restart class (#778, #2193, #1128, #1129). Security invariants mirror state save|load: loadedHtml and owner are never persisted and never accepted from disk; restored cookies pass the same hygiene filter (localhost/.internal/metadata domains dropped); restoreState re-validates every URL. Default OFF; headed mode excluded (the persistent profile owns that state). Hardened past the fork's shape per review R3: corrupt state quarantines to .corrupt (forensic artifact, boots fresh, one log line), snapshot failures warn once and never kill the daemon, and the boot log reports restored counts or fresh-session status. Module + 10 tests ported (MIT header retained); server wiring at launch, interval, and shutdown; skill docs section added (regen rides the cluster regen commit). Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate browse SKILL.md for cluster C (session persistence docs) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): third-party web-actions contract — offer to drive vendor-site steps, never just dump a manual list When a workflow needs something done on an external website the user controls (register an API key, create a vendor account, configure a dashboard/webhook/OAuth app), five skills (ship, spec, office-hours, setup-deploy, land-and-deploy) now follow one contract: offer to drive it in a visible browser via gstack's own stack ($B headed + handoff/resume, GStack Browser) behind ONE per-task consent question naming the exact site and actions; passwords, payment, CAPTCHA, and identity stay user-performed; captured secrets go to owner-only files or the user's secret store, never chat/logs/history; and the credential is verified with one non-mutating API call before any success claim — dashboards show masked placeholders, and a 401 catches them. Declining yields manual steps and a blocked-on-user mark; nothing new is ever installed to close the gap. New resolver token {{THIRD_PARTY_ACTIONS}} (adapted from the fork's contract — their Aside-browser detection swapped for our own driver stack; MIT portions noted). Parity guards bumped with growth itemized (ship 1.10->1.12 at measured 1.103x; office-hours skeleton 101K / 1.09 at measured 1.079x); ship goldens refreshed. Ported from time-attack/gstack (GStack 2), driver adaptation ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(office-hours): design docs land in the repo, written as decision records (#703, #2000) Office-hours Phase 5 now dual-writes the design doc: the docs/designs/ copy is what teammates and plan reviews read (committable, visible), while the ~/.gstack copy keeps memory ingest and cross-session discovery working. The repo copy leaves the private store, so it passes the redaction scan-at-sink first (HIGH blocks the repo copy, MEDIUM confirms per finding), and any failure — read-only checkout, non-git dir, unconfirmed finding — degrades to the private copy with a one-line reason, never blocking the handoff. The doc itself is now a decision record, not a transcript: one bullet per decision with its why, ruled-out approaches collapsed to a single line with the rejection reason, settled/empty template sections omitted. No page cap; extra length must come from genuinely open questions. Plan reviews (ceo/eng/devex + the shared review resolver) prefer the repo-local doc (DESIGN.md, then newest docs/designs/*.md) when it's at least as fresh as the private copy — a stale old repo doc never shadows a newer session. Parity guards bumped with measured values (three plan-review skeletons +~0.7KB each; office-hours 1.092x). Judgment ported from time-attack/gstack (GStack 2); scan-at-sink and freshness-preference adaptations ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(office-hours): 'never show me these again' for the founder-resources pitch (#538) The Phase 6 resources offer (34 PG essays + Garry/YC videos) had no permanent decline — the reporter showed memory instructions kept being overridden on every update, so people who said no got re-pitched forever. The offer now closes with a standing choice; opting out runs gstack-config set founder_resources false (new key, default true, true|false validated), the write is VERIFIED before any promise (a failed write says so and skips this session only), and every future session skips the entire section silently — no resources, no 'skipped as requested' mention. Config outlives session context, so never means never. Re-enable anytime: gstack-config set founder_resources true. The pitch stays default-ON for everyone who never opted out. Tests pin the key's default/persistence/validation through the real config bin and the generated section's gate-before-content + write-verify contract. Approved as a promo-surface change (CEO review D3.4, 2026-08-14). Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ship): the Apple App Store release journey — working tree to Submit for Review Point /ship at a repo with an .xcodeproj, .xcworkspace, or app-product Swift package and ask to release: the adapter runs the whole journey with ONE authorization moment (membership + pricing + in-session sign-in, decision- store persisted so repeat releases ask nothing) and one store-assets question only when assets are missing. fastlane is the single tool (produce/cert/ sigh/gym/pilot/deliver/frameit); credential vocabulary never reaches the user. The adapter carries 21 live releases' worth of paid-for Apple knowledge: the web session mints the permanent upload key itself (iris POST /v1/apiKeys; privateKey is base64-of-PEM, downloadable only at creation) so nobody ever types an app-specific password; error -22938 is Transporter asking for a key, not a user task; errors are CLASSIFIED before credentials are touched (validation/UnexpectedResponse = metadata, incl. Apple's expanded age-rating attributes); pricing goes through POST /v1/appPriceSchedules because fastlane's price_tier is broken against the current API; and store distribution NEVER routes through the branch gate — a clean tree on main is the solo shipper's normal case (Step 0.9 loads the adapter BEFORE the gate, pinned by test with the non-Apple gate byte-unchanged and unique). Uploads/submissions follow an idempotency-log contract (inspect App Store Connect before any re-run). Non-Mac hosts get the honest split: build legs via a macOS CI runner with the minted key as a secret, API legs local. Browser use inside the journey is banned except the named paid-app banking/tax residue. Redaction dry-run clean. Ship's parity ratio raised 1.12 -> 1.22 deliberately: the 14.8KB section is on-demand (Apple store targets only), one manifest line otherwise. Ported from time-attack/gstack (GStack 2), refined across its 21 live releases; architecture adaptation (carved section, decision-store paths, idempotency log, third-party-actions handoff) ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(code-intelligence): provider contract Phase 1 — GBrain, Sourcebot, Graphify behind one ask-once offer Open a large repo (1,000+ tracked files) and gstack can offer code intelligence ONCE, with the trade-offs stated: GBrain (semantic memory + code, sends content to YOUR gbrain DB, per-repo consent), Sourcebot (self-hosted whole-repo search, local on localhost), Graphify (local tree-sitter graph, nothing leaves the machine, user-installed), or No indexing — a decline persists machine-wide so no skill ever asks again. Small repos never see the question; grep stays the always-working default and provider-OFF degrades silently (PROVIDER_UNAVAILABLE -> file-only). Ported: lib/code-intelligence/ (contract + 3 verified adapters + picker + selection + suggest, MIT headers), the gstack-code-intelligence CLI (suggest/select/consent/index/search/status), 31 offline tests (fake CLI shims + injected fetch), and the provider-contract design doc. Verified live on this repo: suggest fires at 1,233 files with real availability detail per provider. Hardened per review: the per-remote trust store is the SINGLE consent authority — a gstack-gbrain-repo-policy deny tier vetoes any recorded code-intelligence consent (fail-closed on an unreadable store, pinned by three tests); both send-capable adapters are registered as fail-closed MODULE_SINKS in the egress tripwire so a refactor can't drop their receipts; and local-compute vs remote-send consents are never bundled. setup-gbrain gains the provider-choice Step 0. The fork's Phases 2-4 glue-collapse is explicitly NOT ported. Ported from time-attack/gstack (GStack 2); consent unification ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): supply-chain hygiene — secret gate on every PR diff, dependency review, OSV, dependabot, evidence-bar PR template The repo owned a redaction engine and had zero CI-side secret scanning. quality-gate.yml now pipes every PR diff's ADDED lines through our own bin/gstack-redact (gate-secret-scan.mjs, taken from the fork — it dogfoods the engine): HIGH findings fail the check, MEDIUM prints an advisory count only (no human in CI to confirm), planted-bug fixtures excluded by pathspec. Live-verified both directions: PEM key fails, clean diff and MEDIUM shapes pass; ShellCheck (errors) covers the setup/build shell boundary and passes today; bun audit gates critical advisories. Trigger is pull_request, never pull_request_target. dependency-review.yml adopts the hardened never-merged prior-art branch (fail-on-severity high, workflow paths watched, tight perms) — verify the dependency graph parses bun.lock with a canary bump before trusting the gate. dependabot: weekly, grouped per ecosystem, capped PR counts; and evals.yml image build/push now skips dependabot actors, whose read-only GITHUB_TOKEN made every lockfile bump a permanently red check. OSV scans weekly with a reasoned ignore file. All new workflow actions SHA-pinned. Scorecard deliberately not taken (no consumer for the score). The PR template front-loads the evidence bar (live proof, liveness screenshot, no-ETHOS/voice-changes checklist); the unenforced DCO line is dropped. bin/gstack-verify-gate ships OPT-IN (never registered by ./setup — a Stop hook running the project's verify command after every turn is the user's call), with the fork's tests adapted to pin exactly that. Ported from time-attack/gstack (GStack 2) + our own prior-art branch. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: remove dead bins; extend the stale-ref scan to docs (the 36-release gap) bin/chrome-cdp, bin/gstack-open-url, and bin/gstack-platform-detect were referenced only by an audit test and CHANGELOG history — dead weight that the stale-ref scanner should police, which required removing them FIRST. The scanner now also sweeps docs/, README.md, and USING_GBRAIN_WITH_GSTACK — the deliberate exclusion that let a dead command survive ~36 releases as a command-not-found instruction. Scan is green on the extended surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bins): detect the default branch instead of hardcoding main gstack-diff-scope fell to an empty diff (all-false SCOPE_*) and gstack-next-version mis-based its bump math on any repo whose default branch isn't main (trunk, master, local-only). Both now resolve origin/HEAD -> origin/main -> origin/master -> main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: housekeeping sweep — telemetry integrity, persistent opt-out, context-bill accuracy, setup hang, dev-server discovery, model resolution (#2136 + v1.63 polish) Seven small fixes, one theme (claims matching code): - telemetry-sync strips local-only fields with jq del() (structural) instead of quote-fragile sed regexes; unparseable lines are dropped, never forwarded unstripped. Sed survives only as a jq-less fallback. - telemetry-log rejects non-integer durations BEFORE the range caps, whose test(1) comparisons silently no-op on non-numerics — a malformed duration spliced raw text into the JSONL stream. - browse's local telemetry honors the persistent tier (config.yaml telemetry: off), not just the preamble's env hint — direct $B use and embedders now respect the opt-out. - gstack-context-bill --exact sees GSTACK_-promoted keys inside Conductor (conductor-env-shim wired at the CLI entry), and the TOTAL line no longer double-counts every nested skill through the root skill's walk (v1.63 deferred polish; the telemetry-sync HTTP-status outcome deferred alongside it turned out already shipped). - setup's Chromium probe is deadline-bounded (90s, background + poll-kill — macOS has no GNU timeout) and prefers Node for the launch probe everywhere (the bun --eval hang family behind #2136); the install is single-flight behind a lock dir with an actionable stale-lock message. Probe verified live on this Mac. - the review resolver's dev-server check reads CLAUDE.md and the plan file before falling back to an expanded port probe, and says how to make itself smarter next time. - eval/harness model IDs resolve through lib/eval-model.ts (GSTACK_EVAL_MODEL[_KIND] env overrides, per-kind defaults, tested) at the SDK-capture and PTY-warmup sites; the bash-embedded distill snippet mirrors the resolution inline. - memory-ingest's silent-zero shape (staged>0, imported+unchanged==0, errors==0) warns even under --quiet — a run that indexes nothing must never look healthy again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: wire ios-qa/daemon/test into the free suite and shard runner (E2) The daemon's 5 test files (allowlist, audit, auth-mint, cli-mint, daemon-integration — now 6 with session hardening) were invisible to every runner: not in the bun test glob, not in TEST_ROOTS. The same silent-coverage-hole class as the tracked design/test P2 — and it meant B2's auth regression tests would never have gated. All files are hermetic (stub state-servers on ephemeral ports, no devices); verified green in the shard census. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): claimed limitations now require evidence, everywhere + wave follow-ups filed Every tier-2+ skill's preamble gains one directive distilled from nine live release failures in two days on the fork: a claimed limitation or requirement ('the API can't do this', 'X requires a credential', 'impossible on this platform') is a material claim, stated only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence, and a cheap probe runs BEFORE asking the user or declaring a step blocked. ONE directive adapted into the preamble resolver; the fork's full judgment contract is deliberately not imported. Full regen (46 files), ship goldens refreshed, parity guards bumped with the measured ~0.45KB/skill (investigate, autoplan, plan-design-review, office-hours), Step 0.9 registered as an intentional sub-step. Approved deferrals filed: persona-fleet hostile-user harness + answer-key methodology in TODOS; the fork's question-budget ACCOUNTING judgment (never its 5/8/12 constants) folded into the V1.1 pacing design doc; the Apple adapter added to #1882's coverage note. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(make-pdf): close offline-gate bypasses via unquoted style attrs, CSS-escape and HTML-entity obfuscation Three live vectors found by the ship review army, all red-first tested: unquoted style attributes skipped the remote-url neutralizer entirely; CSS ident/string escapes (@\69mport, url(\68ttps://…)) defeated the literal-match patterns Chromium happily decodes; and HTML entities in style attribute values (https) decoded to fetchable schemes before CSS parsing. Style-attr values are now entity-decoded in one browser- faithful pass, escape-bearing at-rules and function tokens are dropped fail-closed, and output is re-encoded double-quoted. 21 new test rows. * fix(migrations): v1.65 Chromium re-fetch actually re-downloads, and success is verified before .done The migration (renamed from the provisional v1.64.0.0 slot, which open PR #2564 claims) deleted only the poisoned .app while Playwright's INSTALLATION_COMPLETE marker survived in the revision dir — so the advertised 'bunx playwright install chromium' re-fetch no-opped and the user finished the upgrade with no browser and a success message. Now: the whole chromium-<rev> dir goes, bunx runs cwd-pinned to the install root, .done is gated on a verified executable, and a needs-refetch sentinel makes re-runs retry a failed download. Stranded rev dirs (markers without .app) also re-trigger. 6 hermetic tests, red-first. * fix(migrations): v1.27 remediation prints a real command instead of a fictional flag Every skip/failure path referenced '/setup-gbrain --rerun-migration', which is implemented nowhere, and promised the migration 'will ask again next upgrade', which the version-window runners make false. All five sites now print the direct GSTACK_MIGRATE_ASSUME_YES=1 bash invocation. Runner-side re-offer tracking is filed in TODOS. * fix(browse): poisoned-bundle self-heal removes the revision dir, probes handoff too, and throws typed Same marker flaw as the migration: rmSync of the .app alone left INSTALLATION_COMPLETE behind, so the error message's own remediation no-opped and the user was hard-stuck. The probe is now an exported, unit-tested helper (probePoisonedChromiumBundle) that removes the whole chromium-<rev> dir, never touches GSTACK_CHROMIUM_PATH custom bundles, throws PoisonedBundleError (instanceof, not string-match), and runs on BOTH headed entry points — launchHeaded and handoff. 7 tests. * fix(browse): session snapshots are atomic and the cookie filter drops loopback IP literals A crash mid-write destroyed the previous good snapshot — the exact scenario persistence exists to survive; writes now go tmp+rename. The internal-network cookie filter gains 127.*/::1/169.254.* (a tampered state file could previously hand loopback-service cookies back to the browser), and 'state load' imports the shared filter instead of maintaining a comment-synced copy. Test cleanup made exception-safe. * fix(browse): server runtime — restore off the boot path, shutdown that cannot hang, watchdog that still reaps tunnels Four review findings on the wave's own new wiring: session restore ran before Bun.serve with sequential 15s gotos while the CLI gives up at 8s (one slow saved URL bricked every $B command) — restore now runs in the background after bind; the shutdown snapshot gets a 2s deadline so a wedged page.evaluate can't hold the port forever behind the new ack-first stop; the persistence ticker gets in-flight + shutdown gates and is cleared before the final snapshot; and the absorbed #2565 handoff fix no longer clears the whole parent watchdog — a suppress flag keeps the tunnel-orphan reaper alive (handoff→resume→tunnel is no longer an unreapable internet-exposed daemon). pair-agent with consent off now names the real remedy instead of ngrok install instructions. Lock-acquisition edge branches (garbage pidfile, vanish-race depth cap) pinned. * fix(browse): telemetry defaults to off like every other surface The persistent tier defaulted ON when the config key was absent, while gstack-config's DEFAULTS table answers 'off' for the same question — preamble-spawned daemons and direct $B daemons disagreed about consent. Absent key/file now means disabled; community/anonymous enable; env kill-switch still beats everything. Both config.yaml consumers now share one readGstackConfigYamlKey reader. 12-case consent suite. * fix(code-intelligence): consent that means what it says — polarity, receipts, read-only veto Four review findings on the wave's own Phase 1 port, all red-first: 'consent <repo> no' recorded consent GRANTED (the CLI ignored the argument and always wrote true) — yes|no is now required and garbage records nothing; Sourcebot egress receipts claimed consented=true on paths that never checked consent — the actual consent state is threaded into every receipt, search is fail-closed on non-loopback, and the liveness probe's receipt says truthfully that it sends no repo content; repoPolicyVeto only honored the deny tier while gbrain refresh writes pages — write-class ops now veto on read-only too, matching the sync chokepoint, via one shared lib/gbrain-repo-policy-client.ts (win32 bash invocation, spawn-vs-unreadable error distinction) used by both call sites. Also: source ids get a host+path hash (same-name repos no longer collide), refresh timeout raised to 120s, availability probes run concurrently at 3s, graphify status stops JSON.parsing 100MB graphs for a count, and every ported file carries the fork MIT notice. +15 tests across the two suites. * fix(verify-gate): trust before eval, re-check on re-entry, audit every grant The opt-in Stop hook eval'd whatever command the first CLAUDE.md up the tree declared — any cloned repo got arbitrary shell at turn end. Now a per-repo trust store (path+command hash, 0600) gates execution: an untrusted or changed command never runs (exit 0 with the --trust invocation printed), stop_hook_active re-entry re-runs the trusted check instead of rubber-stamping (bounded at 3 blocks per episode), and every grant appends a forensic line to ~/.gstack/security/verify-gate-trust-grants.jsonl. 20 tests, red-first. * fix(setup): EXIT traps chain instead of clobbering; timed-out probes reap their whole tree The Playwright-lock trap replaced the copied-bun cleanup trap and then cleared ALL exit handling, leaking .tmp-bun-bin on every Chromium install; and _wait_with_deadline killed only the subshell, orphaning the wedged node→Chromium tree it exists to escape — re-creating the #2136 pile-up on every timed-out re-run. Traps now chain; timeouts walk pgrep -P descendants leaves-first. * refactor(resolvers): one source for the design-doc discovery block The #703 repo-doc-preference bash was pasted byte-identically into three plan-review templates and a fourth copy embedded in review.ts — drift there means plan reviews disagree about which design doc wins. Now a {{DESIGN_DOC_DISCOVERY}} resolver; generated output is byte-identical, so no SKILL.md changes ride along. * fix(ship): finish the Apple upload idempotency sentence The durable-effect contract dropped its consequence clause mid-sentence — the instruction for what to DO when the idempotency key already exists (treat the upload as possibly-done, never re-run it) was missing from the one rule governing whether a binary uploads twice. * fix(ci): SHA-pin dependency-review; the secret gate fails closed without a report dependency-review.yml rode mutable refs (@v4 resolves to a BRANCH on that repo) inside the one workflow whose job is supply-chain hygiene — now commit-pinned like its siblings, with dependabot keeping the pins fresh. gate-secret-scan.mjs crashed with an unhandled EPIPE on oversize diffs (the designed report.oversize branch was unreachable: the scanner emits no JSON on refusal) — the pipe write now tolerates early exit and a missing report is an explicit fail-closed exit 1. Oversize + broken-scanner legs pinned. * fix(bins): Windows-safe GIT_CEILING join; next-version probes the full default-base chain GIT_CEILING_DIRECTORIES was joined with ':' — git on Windows splits on ';' and drive letters contain ':', silently disabling the #2144 second-layer defense there; now path.delimiter. next-version's default-base detection only tried origin/HEAD then 'main', diverging from the canonical 4-step chain diff-scope uses — origin/main and origin/master probes added, pinned by fixture repos. * fix(eval-model): kinds are a literal union, not string Record<string,string> widened EvalModelKind to string, so a typo'd kind only failed at runtime; as const satisfies keeps the closed set the doc comment promises. * test: coverage backfill from the ship review The telemetry-strip invariant only validated the sed FALLBACK while the live jq path went unchecked — the jq del() lists are now held to the same every-emitted-field bar, plus a behavioral pipe-through. The context-bill nested-skill double-count fix gets a regression pin (a revert shipped green before). The windowsHide tripwire gains terminal-agent-control.ts — the exact file the fix commit names. The ios-qa revoke-by-token_id branch gets its negative case: unknown ids revoke nothing and leave live sessions alone. * docs: SLATE_HOST no longer cites the deleted platform-detect bin Host detection lives in the hosts/ registry via host-config-export.ts; the doc's known-gaps list now says so instead of pointing at a bin this branch removed. * test(e2e): headroom for the two plan-ceo-review budget-edge tests Both rode their 360s runner budget at the edge (main clears at 243s of 360s), and the wave legitimately adds work to the review: the evidence directive tells the agent to probe before claiming, and the design-doc discovery block adds bash steps. Under concurrent in-file children the API queuing tipped all retry attempts past the ceiling — the runner then reports $0.00/0 turns for a timed-out child, which reads like a dead spawn but is a healthy child killed at the deadline. 540s runner / 660s test for these two only; verified 2/2 green at 228s and 315s. * fix(code-intelligence): gbrain search/export are consent-gated and receipted The Sourcebot side got this in the last round; gbrain had the same hole — search() and export() sent repo-derived query text into a possibly-remote DATABASE_URL with no consent check and no egress receipt, bypassing the deny-tier veto. Both now assert consent before any bytes move, receipts record the actual consent state (never a hardcoded true), and search receipts carry the query's sha256. gbrain stays fail-closed: the adapter cannot see where DATABASE_URL points, so every send requires consent. 7 new tests, red-first. * fix(make-pdf): SVG remote refs and image-set can no longer fetch offline <svg><image href=https://…> and <use xlink:href=…> survived the gate (only javascript: schemes were stripped from svg hrefs), and bare-string image-set("https://…" 1x) dodged the url()-shaped neutralizer. Remote svg hrefs rewrite to '#' (entity-decode-aware, unclosed-svg smuggle closed) and remote image-set args neutralize to url(#). Local fragments, local image-set, and plain <a> links pinned intact. 12 new rows, red-first. * fix(browse): duplicate config keys read last-wins, matching gstack-config readGstackConfigYamlKey took the FIRST match while gstack-config's get takes the LAST — a duplicated pair_agent or telemetry line made the two consent surfaces disagree about what the user chose. * fix(setup): stale Chromium-install lock self-heals The mkdir mutex had no owner: a SIGKILL'd setup left the lock behind and every later run exited with manual rmdir instructions. The holder pid is recorded in the lock; a dead holder is reclaimed automatically. * fix(setup-gbrain): the code-intelligence offer gate skips when the bin is absent The new Step 1.7 told the agent to run gstack-code-intelligence before the path pick — on installs predating the CLI (and hermetic E2E children) the bin doesn't exist and setup derailed before doing any setup. The gate now probes for the bin and reports offer:false reason:bin-absent, with explicit instructions to proceed: the user asked for gbrain, so set up gbrain. Never block setup on an optional gate. * test(e2e): periodic-tier repairs from the failure triage Each fix traces to a receipt: brain-privacy-gate staged config never reached the hermetic child (ambient GSTACK_HOME is scrubbed) and the operator's remote-mode gbrain suppressed the gate — both now injected per-test; ship-idempotency threw away its evidence on the timeout path and ran a 600s budget its own subject can exceed (now 900s, evidence captured); auto-decide-preserved gets the same headroom its sibling plan-ceo tests got; context-skills' hides-checks scanned bash output where an ls legitimately names old checkpoints (final-text scope now); design names the missing section instead of a bare count and learns the easing/duration/micro-interaction synonyms; qa-workflow's collector afterAll gets an explicit 60s hook timeout. * fix(eval-harness): eng-review phase boundary fires on qid-tagged questions The Step 0 boundary only matched two prose phrases, but plan-eng-review may legitimately reach the review phase without either — every per-finding AskUserQuestion then counted as pre-review and the batching regression test read 0 questions while watching the agent ask them one by one. The boundary now also fires on the first answered question carrying a gstack-qid:eng-review- marker. Additive only; 119 runner unit tests green. * chore: bump version and changelog (v1.65.0.0) Fork port wave 2: the release-summary entry credits Sina Matian (time-attack/gstack) and the four absorbed community PRs. TODOS gains three review-round follow-ups (dual-write E2E, migration runner re-offer, gbrain-adapter op coverage). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(eval-harness): eng-review qid boundary matches the real skill-name prefix Live qids render as gstack-qid:plan-eng-review-<slug> ({skill}-{slug} convention); the boundary anchored eng-review- immediately after the colon and never matched, leaving the batching counter blind while the transcript showed per-finding questions being asked one by one. * fix(setup-gbrain): never ask the provider question inside /setup-gbrain Invoking /setup-gbrain IS the provider choice. Step 1.7 now records 'select gbrain' best-effort and proceeds straight to setup; the offer ceremony is reserved for entry points where no provider was named. On machines where the code-intelligence CLI exists, the offer:true path was hijacking setup into the provider ceremony and the E2E child never reached MCP registration. * chore: file the three documented-red periodic tests as structural-repair TODOs Sidebar trio exercises endpoints removed on every tree; ship-idempotency's PTY child never receives its typed command; brain-privacy-gate has never been green anywhere. Each carries its triage receipt in the entry. * test(e2e): setup-gbrain remote — hermetic env via opts, evidence on failure, output-scoped classifier Three separate defects stacked on this one test: the ambient GBRAIN_MCP_TOKEN/GSTACK_HOME/PATH mutations never reached the child (hermetic-env scrubs them by allowlist — broken since hermetic env landed; the child correctly stopped at Step 4c with NEEDS_CONTEXT), failures discarded the in-memory transcript so every triage started blind, and the wrote-findings-before-asking classifier scanned the full event stream where the child's own Read of the skill file always contains the review-report phrase. Env now goes via opts.env, failures dump bash commands + final text, and the classifier scans assistant output only. Green in 67s with all seven asserts. * test: final coverage pass — CLI rendering, revert traps, keychain probe, gbrain doc ops The user-directed third generation pass closes the audit's remaining tail: the code-intelligence CLI's options/status/suggest surfaces get behavioral coverage through the fake-shim chain; brain-context-load gains an argv-logging trap that goes red if anyone reverts the memoized PATH scan back to the spawn probe (receipt: simulated revert failed exactly these tests); the darwin Keychain auth branch (#1890) gets its first free-tier tests via a PATH-shimmed security binary; and the gbrain add/delete/export ops are pinned (body piped byte-for-byte, receipt sha256, stdin-EOF prompt guard, PROVIDER_UNAVAILABLE degradation) — retiring their TODOS entry. * test: assemble the planted PEM at runtime so the fixture never trips the prepush guard The repo's own credential guard scans pushed diffs and correctly blocked these fixtures: the engine flags any one-line BEGIN…END spelling regardless of body. Header, body, and footer are now joined at runtime, so the file and every diff of it stay clean while the scanner under test still receives the true live shape. * docs: update project documentation for v1.65.0.0 README gains the two wave-2 CLIs (gstack-code-intelligence, gstack-verify-gate) in the standalone-binaries table, BROWSER.md documents BROWSE_PERSIST_STATE next to manual state save/load, CONTRIBUTING's CI section lists the new supply-chain gates, and CLAUDE.md's project tree reflects lib/code-intelligence/ and the added workflows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: apply cross-model doc-review fixes for v1.65.0.0 Findings from the release doc review, verified against source: verify-gate's README row gains the actual install one-liner (setup never registers the Stop hook; test/verify-gate.test.ts pins that) and the 3-blocked-re-entries yield behavior; code-intelligence's row gains the suggest subcommand and the search-side consent gate; CONTRIBUTING scopes the SHA-pin claim to the supply-chain workflows and widens the dependency-review trigger; BROWSER.md's restore-time cookie drop list matches isInternalCookieDomain; CLAUDE.md's workflows comment stops implying six workflows are all of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: CHANGELOG accuracy pass — scope the SHA-pin claim, restore-time cookie filter, exact test counts * test: env restore runs per-test, not per-suite — the leak that failed 30 strangers gstack-memory-helpers saved HOME/GSTACK_HOME/PATH in beforeEach but restored in afterAll, so the last beforeEach's snapshot won and a gstack-test-engine temp dir leaked into every later file in the same process: gstack-config read the wrong store, make-pdf's child resolved Chromium under the temp cache, update-check and artifacts-init lost their real homes. afterAll is now afterEach; the config and update-check harnesses also strip GSTACK_HOME/GSTACK_STATE_ROOT from child env as a belt. * fix(browse): restore the #1846 start-timeout resolution the merge dropped The v1.64.1.0 merge kept this branch's lock design in cli.ts and silently lost main's resolveStartTimeout + late health re-check while their test survived — ported both back in alongside the kept design. * test: adapt main's diagnostics tests to the merged designs cli-lock asserts typed ServerLockError (errno + lock path) instead of the log-and-return shape the merge didn't keep, dropping only the one duplicate of server-lock-errors coverage; the liveness tripwire exempts error-handling.ts as the sanctioned tasklist site; snapshot and compare-board wrappers pass the now-mandatory browser-manager arg; background.js's test pins that the retired sidebar-command type is rejected pre-gate with no response fields. * chore: gitignore the gen-accessors tool's SPM build output skill-e2e-ios-swift-build compiles the Swift package in place, leaving .build/ (2,800+ files) and Package.resolved untracked after every periodic run — the workspace read as ~100 dirty changes with a clean tree. Same class as the dist/ binaries: build output, never committed. * test(browse): subprocess budget for the polyfill suite on Windows CI Every test here spawnSync's a node child; cold-start on the Windows runner (AV scan, first node.exe touch) blew bun's 5s default by 7ms on a 50ms sleep test. File-level 20s default — subprocess budget, not assertion looseness. * test: make the Darwin migration path and the query-timeout SKIP deterministic on Linux CI The v1.65 migration suite relied on the host being macOS — on the ubicloud runner the script's uname gate early-exited every test with empty output; a Darwin uname shim in the shared setup runs the real path everywhere (the non-Darwin test still overrides it with Linux). The 1ms-budget brain-context test assumed 1ms is always too short; the runner's fake gbrain answered in 0ms and no SKIP printed — the fake now sleeps 300ms so the timeout is a certainty, while --version stays instant for the detection assertion. --------- Co-authored-by: Gawie van Blerk <gawievanblerk@gmail.com> Co-authored-by: Sina Matian <sina@time-attack.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Shawn Reddy <19191746+Screddyice@users.noreply.github.com> Co-authored-by: Jake Wilk <jwilk@highlinerepartners.com> Co-authored-by: Jerry Nichols <jerrynicholsai@users.noreply.github.com>
This commit is contained in:
202 files changed
+11002
-1163
No files matched your search
@@ -109,12 +109,6 @@ describe('Audit compliance', () => {
|
||||
});
|
||||
|
||||
// Round 2 Fix 4: Chrome CDP binds to localhost only
|
||||
test('chrome-cdp binds to localhost only', () => {
|
||||
const cdp = readFileSync(join(ROOT, 'bin/chrome-cdp'), 'utf-8');
|
||||
expect(cdp).toContain('--remote-debugging-address=127.0.0.1');
|
||||
expect(cdp).toContain('--remote-allow-origins=');
|
||||
});
|
||||
|
||||
// Fix 2+6: All generated SKILL.md files with telemetry are conditional
|
||||
test('all generated SKILL.md files with telemetry calls use conditional pattern', () => {
|
||||
const skills = getAllSkillMds();
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Free unit tests for the ClaudeAdapter auth sniff in
|
||||
* test/helpers/providers/claude.ts — specifically the macOS Keychain branch
|
||||
* (#1890): the default subscription install stores OAuth under the
|
||||
* generic-password service "Claude Code-credentials" and never writes
|
||||
* ~/.claude/.credentials.json, so availability must consult
|
||||
* `security find-generic-password -s "Claude Code-credentials"` when neither
|
||||
* the creds file nor ANTHROPIC_API_KEY exists.
|
||||
*
|
||||
* The `security` spawn is darwin-gated in the adapter (the probe only runs
|
||||
* when process.platform === 'darwin'), so the keychain-branch tests skip
|
||||
* elsewhere; the creds-file / env-key / no-auth verdicts run on every
|
||||
* platform.
|
||||
*
|
||||
* Harness note: available() spawns `security` without an explicit `env:`, and
|
||||
* Bun resolves such spawns against the PROCESS STARTUP env — mutating
|
||||
* process.env.PATH in-test cannot shadow the real /usr/bin/security (verified;
|
||||
* same Bun property lib/gbrain-exec.ts documents for DATABASE_URL). So each
|
||||
* case runs available() in a child `bun -e` whose env (and PATH shim) the test
|
||||
* fully controls; the fake `security` logs its argv, so the operator's real
|
||||
* Keychain is never consulted and a logged-in machine can't false-pass the
|
||||
* "no auth" cases. cwd is the fake home so Bun doesn't autoload the repo .env
|
||||
* (which defines ANTHROPIC_API_KEY).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { spawnSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
const ADAPTER = path.join(import.meta.dir, "helpers", "providers", "claude.ts");
|
||||
|
||||
describe("ClaudeAdapter.available() — auth sniff incl. macOS Keychain branch (#1890)", () => {
|
||||
let fakeHome: string;
|
||||
let shimDir: string;
|
||||
let securityLog: string;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-home-"));
|
||||
shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-bin-"));
|
||||
securityLog = path.join(shimDir, "security-argv.log");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
fs.rmSync(shimDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Fake `security` that logs its argv and exits with `exitCode`. */
|
||||
function writeFakeSecurity(exitCode: number): void {
|
||||
fs.writeFileSync(
|
||||
path.join(shimDir, "security"),
|
||||
`#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "${securityLog}"
|
||||
exit ${exitCode}
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Run `new ClaudeAdapter().available()` in a child bun with a controlled env. */
|
||||
function runAvailable(opts: { anthropicKey?: string; bareShimPath?: boolean } = {}): { ok: boolean; reason?: string } {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
// Shim dir first so a fake `security` shadows /usr/bin/security; the
|
||||
// bare variant drops the inherited PATH entirely (no `claude` findable).
|
||||
PATH: opts.bareShimPath ? shimDir : `${shimDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
// os.homedir() honors $HOME (POSIX) / %USERPROFILE% (Windows), so the
|
||||
// adapter's ~/.claude/.credentials.json check reads the fake home.
|
||||
HOME: fakeHome,
|
||||
USERPROFILE: fakeHome,
|
||||
// Absolute-path override satisfies resolveClaudeCommand() without a real
|
||||
// claude install — available() never spawns it, only resolves it. The
|
||||
// bare-PATH case clears it to exercise the not-found branch.
|
||||
GSTACK_CLAUDE_BIN: opts.bareShimPath ? undefined : path.join(shimDir, "claude"),
|
||||
CLAUDE_BIN: undefined,
|
||||
GSTACK_CLAUDE_BIN_ARGS: undefined,
|
||||
CLAUDE_BIN_ARGS: undefined,
|
||||
ANTHROPIC_API_KEY: opts.anthropicKey,
|
||||
};
|
||||
for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k];
|
||||
const driver = `const { ClaudeAdapter } = await import(${JSON.stringify(ADAPTER)});
|
||||
const check = await new ClaudeAdapter().available();
|
||||
console.log(JSON.stringify(check));`;
|
||||
// process.execPath (absolute bun binary): the bare-PATH case strips the
|
||||
// inherited PATH, so a name-based "bun" lookup would ENOENT.
|
||||
const res = spawnSync(process.execPath, ["-e", driver], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
cwd: fakeHome, // no .env here — the repo root's would inject ANTHROPIC_API_KEY
|
||||
env: env as Record<string, string>,
|
||||
});
|
||||
if (res.status !== 0) throw new Error(`driver failed (${res.status}): ${res.stderr}`);
|
||||
return JSON.parse(res.stdout.trim()) as { ok: boolean; reason?: string };
|
||||
}
|
||||
|
||||
test("creds file present → available, and the Keychain is never consulted", () => {
|
||||
fs.mkdirSync(path.join(fakeHome, ".claude"), { recursive: true });
|
||||
fs.writeFileSync(path.join(fakeHome, ".claude", ".credentials.json"), "{}");
|
||||
// A fake security that would report NOT FOUND — if the probe ran anyway,
|
||||
// ok would still be true, so the no-spawn pin is the argv log staying empty.
|
||||
writeFakeSecurity(1);
|
||||
const check = runAvailable();
|
||||
expect(check.ok).toBe(true);
|
||||
expect(fs.existsSync(securityLog)).toBe(false);
|
||||
});
|
||||
|
||||
test("ANTHROPIC_API_KEY set → available without creds file or Keychain probe", () => {
|
||||
writeFakeSecurity(1);
|
||||
const check = runAvailable({ anthropicKey: "sk-ant-test-not-a-real-key" });
|
||||
expect(check.ok).toBe(true);
|
||||
expect(fs.existsSync(securityLog)).toBe(false);
|
||||
});
|
||||
|
||||
test("darwin: Keychain hit (security exit 0) → available, probed with the exact service name", () => {
|
||||
if (process.platform !== "darwin") return; // keychain branch is darwin-gated in the adapter
|
||||
writeFakeSecurity(0);
|
||||
const check = runAvailable();
|
||||
expect(check.ok).toBe(true);
|
||||
// Exactly one metadata-only probe, against the service subscription installs use.
|
||||
const argv = fs.readFileSync(securityLog, "utf-8").trim().split("\n");
|
||||
expect(argv).toEqual(["find-generic-password -s Claude Code-credentials"]);
|
||||
expect(argv[0]).not.toContain("-w"); // never reads the secret itself
|
||||
});
|
||||
|
||||
test("darwin: Keychain miss (security exit 1) → not available with the no-auth reason", () => {
|
||||
if (process.platform !== "darwin") return;
|
||||
writeFakeSecurity(1);
|
||||
const check = runAvailable();
|
||||
expect(check.ok).toBe(false);
|
||||
expect(check.reason).toContain("No Claude auth found");
|
||||
// The probe DID run (this is the miss path, not a skipped probe).
|
||||
expect(fs.readFileSync(securityLog, "utf-8")).toContain("find-generic-password");
|
||||
});
|
||||
|
||||
test("non-darwin: no creds file + no env key → not available (no Keychain to consult)", () => {
|
||||
if (process.platform === "darwin") return; // darwin covered by the shimmed miss case above
|
||||
const check = runAvailable();
|
||||
expect(check.ok).toBe(false);
|
||||
expect(check.reason).toContain("No Claude auth found");
|
||||
});
|
||||
|
||||
test("claude CLI unresolvable → not-found reason, before any auth sniff", () => {
|
||||
writeFakeSecurity(0); // even a keychain HIT can't rescue a missing binary
|
||||
const check = runAvailable({ bareShimPath: true });
|
||||
expect(check.ok).toBe(false);
|
||||
expect(check.reason).toContain("claude CLI not found on PATH");
|
||||
expect(fs.existsSync(securityLog)).toBe(false); // returned before the auth sniff
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -152,6 +152,40 @@ describe("upstream fix a: root-as-container + walkMd exclusions", () => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("totalMd: a container skill excludes nested child skills' bytes; the grand total sums per-skill with no overlap", () => {
|
||||
// Regression pin for the v1.63 double-count: totalMd on a skill dir that
|
||||
// CONTAINS other skill dirs (the gstack root wraps the whole tree) used to
|
||||
// swallow the children's .md bytes too, so the TOTAL line billed every
|
||||
// nested skill twice. A revert of the topSeg/SKILL.md skip in totalMd
|
||||
// (lib/context-bill.ts) must fail here.
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-nested-total-"));
|
||||
fs.writeFileSync(path.join(tmp, "SKILL.md"), "---\nname: parent\ndescription: p\n---\n# Parent\n");
|
||||
fs.writeFileSync(path.join(tmp, "NOTES.md"), "n".repeat(1_000));
|
||||
// A non-skill subdir (no SKILL.md) still belongs to the parent's total.
|
||||
fs.mkdirSync(path.join(tmp, "references"));
|
||||
fs.writeFileSync(path.join(tmp, "references", "GUIDE.md"), "g".repeat(2_000));
|
||||
// Nested child skill with a LARGE .md — the bytes a revert double-counts.
|
||||
fs.mkdirSync(path.join(tmp, "child"));
|
||||
fs.writeFileSync(path.join(tmp, "child", "SKILL.md"), "---\nname: child\ndescription: c\n---\n# Child\n");
|
||||
fs.writeFileSync(path.join(tmp, "child", "BIG.md"), "x".repeat(50_000));
|
||||
|
||||
const bill = buildBill(tmp);
|
||||
const parent = bill.skills.find((s) => s.name !== "child")!;
|
||||
const child = bill.skills.find((s) => s.name === "child")!;
|
||||
expect(bill.skills).toHaveLength(2);
|
||||
|
||||
const parentOwn =
|
||||
fileBytes(tmp, "SKILL.md") + fileBytes(tmp, "NOTES.md") + fileBytes(tmp, "references", "GUIDE.md");
|
||||
const childOwn = fileBytes(tmp, "child", "SKILL.md") + fileBytes(tmp, "child", "BIG.md");
|
||||
// Parent's total is its OWN files only — the child's 50KB is excluded.
|
||||
expect(parent.totalMdBytes).toBe(parentOwn);
|
||||
expect(child.totalMdBytes).toBe(childOwn);
|
||||
// Grand total = sum of per-skill figures, every byte billed exactly once.
|
||||
expect(bill.totals.totalMdBytes).toBe(parentOwn + childOwn);
|
||||
expect(bill.totals.totalMdBytes).toBe(parent.totalMdBytes + child.totalMdBytes);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("walkMd skips node_modules and dot-directories", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-walk-"));
|
||||
fs.writeFileSync(path.join(tmp, "real.md"), "x");
|
||||
|
||||
@@ -67,6 +67,13 @@ const MODULE_SINKS = [
|
||||
'bin/gstack-gbrain-sync.ts',
|
||||
'bin/gstack-memory-ingest.ts',
|
||||
'browse/src/server.ts',
|
||||
// Code-intelligence adapters (fork port wave 2): the gbrain adapter shells
|
||||
// repo content to the user's gbrain DB and the Sourcebot adapter POSTs
|
||||
// queries to a self-hosted HTTP endpoint — both sensitive-class
|
||||
// (repo-content) sinks, fail-closed. Registered so a refactor that drops
|
||||
// their writeReceipt calls fails CI, not just the tree-sweep scanner.
|
||||
'lib/code-intelligence/gbrain-adapter.ts',
|
||||
'lib/code-intelligence/sourcebot-adapter.ts',
|
||||
// Unconditional: context-bill ships in the same tree as this tripwire. A
|
||||
// missing file must fail loudly (a rename/move that drops its receipt wiring
|
||||
// is exactly what this pins), not silently soften the assertion.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** OV11: the host-neutral eval-model resolver's contract. */
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { resolveEvalModel } from "../lib/eval-model";
|
||||
|
||||
describe("resolveEvalModel", () => {
|
||||
test("explicit argument wins over everything", () => {
|
||||
expect(resolveEvalModel("capture", "my-model", { GSTACK_EVAL_MODEL: "x" } as never)).toBe("my-model");
|
||||
});
|
||||
test("per-kind env beats the global env", () => {
|
||||
expect(resolveEvalModel("warmup", null, { GSTACK_EVAL_MODEL_WARMUP: "w", GSTACK_EVAL_MODEL: "g" } as never)).toBe("w");
|
||||
});
|
||||
test("global env beats the default", () => {
|
||||
expect(resolveEvalModel("distill", null, { GSTACK_EVAL_MODEL: "g" } as never)).toBe("g");
|
||||
});
|
||||
test("defaults per kind", () => {
|
||||
expect(resolveEvalModel("capture", null, {} as never)).toBe("claude-opus-4-7");
|
||||
expect(resolveEvalModel("warmup", null, {} as never)).toBe("claude-haiku-4-5");
|
||||
expect(resolveEvalModel("distill", null, {} as never)).toBe("claude-haiku-4-5-20251001");
|
||||
});
|
||||
test("unknown kind throws instead of silently defaulting", () => {
|
||||
expect(() => resolveEvalModel("banana" as never, null, {} as never)).toThrow();
|
||||
});
|
||||
});
|
||||
+31
@@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
||||
|
||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||
|
||||
## Claimed Limitations Need Evidence
|
||||
|
||||
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||
|
||||
## Continuous Checkpoint Mode
|
||||
|
||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||
@@ -808,6 +812,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
|
||||
|
||||
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
|
||||
|
||||
## Third-Party Web Actions
|
||||
|
||||
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
|
||||
|
||||
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
|
||||
|
||||
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
|
||||
|
||||
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
|
||||
|
||||
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
|
||||
|
||||
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
|
||||
|
||||
## Step 0: Detect platform and base branch
|
||||
|
||||
First, detect the git hosting platform from the remote URL:
|
||||
@@ -895,6 +913,7 @@ sections. Read a section in full before doing its step; do not work from memory.
|
||||
|
||||
| When | Read this section |
|
||||
|------|-------------------|
|
||||
| the ship target is an Apple platform app (.xcodeproj, .xcworkspace, or an app-product Swift package) — read BEFORE Step 1's branch gate and any preflight; store distribution never routes through the branch/PR ceremony | `sections/apple-release.md` |
|
||||
| running the test suites and (if prompt files changed) the eval suites (Steps 4-6) | `sections/tests.md` |
|
||||
| auditing test coverage of the diff (Step 7) | `sections/test-coverage.md` |
|
||||
| auditing plan completion, verification, and scope drift (Step 8) | `sections/plan-completion.md` |
|
||||
@@ -906,6 +925,18 @@ sections. Read a section in full before doing its step; do not work from memory.
|
||||
|
||||
---
|
||||
|
||||
## Step 0.9: Apple target detection
|
||||
|
||||
Shipping to the App Store is not landing a PR. If the repository contains an
|
||||
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
|
||||
user's ask is store distribution (App Store, TestFlight, "release my app"),
|
||||
**STOP and Read `~/.claude/skills/gstack/ship/sections/apple-release.md` FIRST**
|
||||
— before the branch gate and any preflight below. Store distribution proceeds
|
||||
from whatever branch the user is on (a clean tree on the base branch is the
|
||||
solo developer's normal case, not an error) and follows the adapter end to
|
||||
end. The branch gate and repository-landing pipeline below apply ONLY to
|
||||
repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
|
||||
+98
-23
@@ -664,6 +664,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
||||
|
||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||
|
||||
## Claimed Limitations Need Evidence
|
||||
|
||||
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||
|
||||
## Continuous Checkpoint Mode
|
||||
|
||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||
@@ -794,6 +798,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
|
||||
|
||||
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
|
||||
|
||||
## Third-Party Web Actions
|
||||
|
||||
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
|
||||
|
||||
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
|
||||
|
||||
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
|
||||
|
||||
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
|
||||
|
||||
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
|
||||
|
||||
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
|
||||
|
||||
## Step 0: Detect platform and base branch
|
||||
|
||||
First, detect the git hosting platform from the remote URL:
|
||||
@@ -878,6 +896,18 @@ Never skip a verification step because a prior `/ship` run already performed it.
|
||||
|
||||
---
|
||||
|
||||
## Step 0.9: Apple target detection
|
||||
|
||||
Shipping to the App Store is not landing a PR. If the repository contains an
|
||||
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
|
||||
user's ask is store distribution (App Store, TestFlight, "release my app"),
|
||||
**STOP and Read `$GSTACK_ROOT/ship/sections/apple-release.md` FIRST**
|
||||
— before the branch gate and any preflight below. Store distribution proceeds
|
||||
from whatever branch the user is on (a clean tree on the base branch is the
|
||||
solo developer's normal case, not an error) and follows the adapter end to
|
||||
end. The branch gate and repository-landing pipeline below apply ONLY to
|
||||
repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
@@ -999,41 +1029,70 @@ git fetch origin <base> && git merge origin/<base> --no-edit
|
||||
|
||||
## Test Framework Bootstrap
|
||||
|
||||
**Detect existing test framework and project runtime:**
|
||||
**Read the project's AGENTS.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5.
|
||||
|
||||
**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse.
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
# Detect project runtime
|
||||
[ -f Gemfile ] && echo "RUNTIME:ruby"
|
||||
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
|
||||
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
|
||||
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
|
||||
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
|
||||
[ -f package.json ] && echo "RUNTIME:node"
|
||||
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
|
||||
[ -f go.mod ] && echo "RUNTIME:go"
|
||||
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
||||
[ -f composer.json ] && echo "RUNTIME:php"
|
||||
[ -f mix.exs ] && echo "RUNTIME:elixir"
|
||||
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
|
||||
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
|
||||
# Detect sub-frameworks
|
||||
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
|
||||
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
|
||||
# Check for existing test infrastructure
|
||||
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
|
||||
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
||||
# Existing test path — config files, declared scripts, AND test FILES.
|
||||
# A project with real tests and no config file is the common miss.
|
||||
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
|
||||
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
|
||||
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
|
||||
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
|
||||
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
|
||||
# Rust keeps unit tests inside src/, so file names alone miss them
|
||||
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
|
||||
# Check opt-out marker
|
||||
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
|
||||
```
|
||||
|
||||
**If test framework detected** (config files or test directories found):
|
||||
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
|
||||
Map the markers to the command you will OFFER — never to one you run on a guess:
|
||||
|
||||
| Marker | Ecosystem | Candidate command to offer |
|
||||
|--------|-----------|----------------------------|
|
||||
| `manage.py` | Django | `python manage.py test` (or `pytest` when pytest-django is in the deps) |
|
||||
| `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` |
|
||||
| `go.mod` (+ any `*_test.go`) | Go | `go test ./...` |
|
||||
| `Cargo.toml` | Rust | `cargo test` |
|
||||
| `pom.xml` | JVM (Maven) | `mvn test` |
|
||||
| `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` |
|
||||
| `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` |
|
||||
| `mix.exs` | Elixir | `mix test` |
|
||||
| `composer.json` | PHP | `composer test` or `./vendor/bin/phpunit` |
|
||||
| `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names |
|
||||
| `Makefile` with a `test:` target | any | `make test` |
|
||||
|
||||
**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — AGENTS.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to AGENTS.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one.
|
||||
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
|
||||
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
|
||||
|
||||
Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `<app>/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate.
|
||||
|
||||
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
|
||||
|
||||
**If NO runtime detected** (no config files found): Use AskUserQuestion:
|
||||
**If NO ecosystem marker matched:** Use AskUserQuestion:
|
||||
"I couldn't detect your project's language. What runtime are you using?"
|
||||
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
|
||||
If the runtime you need isn't listed, offer "Other" and take the runtime plus the test command as free text.
|
||||
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
|
||||
|
||||
**If runtime detected but no test framework — bootstrap:**
|
||||
**If an ecosystem matched but there is no existing-test evidence at all — bootstrap:**
|
||||
|
||||
### B2. Research best practices
|
||||
|
||||
@@ -1049,7 +1108,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
|
||||
| Node.js | vitest + @testing-library | jest + @testing-library |
|
||||
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
|
||||
| Python | pytest + pytest-cov | unittest |
|
||||
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
|
||||
| Go | stdlib testing + testify | stdlib only |
|
||||
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
|
||||
| Rust | cargo test (built-in) + mockall | — |
|
||||
| PHP | phpunit + mockery | pest |
|
||||
| Elixir | ExUnit (built-in) + ex_machina | — |
|
||||
@@ -1378,15 +1439,20 @@ Before analyzing coverage, detect the project's test framework:
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
# Detect project runtime
|
||||
[ -f Gemfile ] && echo "RUNTIME:ruby"
|
||||
# Detect project runtime (markers are evidence, not commands to run blind)
|
||||
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django"
|
||||
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
|
||||
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
|
||||
[ -f package.json ] && echo "RUNTIME:node"
|
||||
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
|
||||
[ -f go.mod ] && echo "RUNTIME:go"
|
||||
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
||||
# Check for existing test infrastructure
|
||||
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini phpunit.xml 2>/dev/null
|
||||
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
||||
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
|
||||
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
|
||||
# Check for existing test infrastructure — config files, scripts, AND test files
|
||||
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null
|
||||
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
|
||||
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
|
||||
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
|
||||
```
|
||||
|
||||
3. **If no framework detected:** falls through to the Test Framework Bootstrap step (Step 4) which handles full setup.
|
||||
@@ -1818,16 +1884,23 @@ Using the plan file already discovered in Step 8, look for a verification sectio
|
||||
|
||||
### 2. Check for running dev server
|
||||
|
||||
Before invoking browse-based verification, check if a dev server is reachable:
|
||||
Before invoking browse-based verification, find the dev-server URL the way the
|
||||
project declares it — never trust a hardcoded port list alone:
|
||||
|
||||
1. **AGENTS.md first:** look for a documented dev URL or dev command (a
|
||||
`## Development`/`## Testing` section naming a port or URL). Use it.
|
||||
2. **The plan file:** if the plan's verification section names a URL, use it.
|
||||
3. **Fallback probe** (common ports, only when 1-2 found nothing):
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:5173 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo "NO_SERVER"
|
||||
for _p in 3000 8080 5173 4000 4321 8000; do
|
||||
_code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$_p" 2>/dev/null)
|
||||
[ -n "$_code" ] && [ "$_code" != "000" ] && { echo "DEV_SERVER: http://localhost:$_p ($_code)"; break; }
|
||||
done
|
||||
[ -z "${_code:-}" ] || [ "${_code:-000}" = "000" ] && echo "NO_SERVER"
|
||||
```
|
||||
|
||||
**If NO_SERVER:** Skip with "No dev server detected — skipping plan verification. Run /qa separately after deploying."
|
||||
**If NO_SERVER:** Skip with "No dev server detected (checked AGENTS.md, the plan, and common ports) — skipping plan verification. Run /qa separately after deploying, or document the dev URL in AGENTS.md so this step finds it next time."
|
||||
|
||||
### 3. Invoke /qa-only inline
|
||||
|
||||
@@ -2579,6 +2652,8 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS"
|
||||
|
||||
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.**
|
||||
|
||||
**REST fallback (#1079):** on some repos `gh pr edit` hard-errors with a GraphQL deprecation mentioning `repository.pullRequest.projectCards` ("Projects (classic) is being deprecated..."). That is a `gh` GraphQL-path problem, not a permissions problem — do not re-ask for auth. Fall back to the REST endpoint, which never touches the deprecated field, using the SAME already-scanned temp file: `PR_NUMBER=$(gh pr view --json number -q .number)` then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"` for the body, and `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"` when the title edit below hits the same error. Verify with the same self-checks as the primary path.
|
||||
|
||||
**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule.
|
||||
|
||||
1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`).
|
||||
|
||||
+98
-23
@@ -666,6 +666,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
||||
|
||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||
|
||||
## Claimed Limitations Need Evidence
|
||||
|
||||
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||
|
||||
## Continuous Checkpoint Mode
|
||||
|
||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||
@@ -796,6 +800,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
|
||||
|
||||
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
|
||||
|
||||
## Third-Party Web Actions
|
||||
|
||||
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
|
||||
|
||||
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
|
||||
|
||||
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
|
||||
|
||||
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
|
||||
|
||||
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
|
||||
|
||||
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
|
||||
|
||||
## Step 0: Detect platform and base branch
|
||||
|
||||
First, detect the git hosting platform from the remote URL:
|
||||
@@ -880,6 +898,18 @@ Never skip a verification step because a prior `/ship` run already performed it.
|
||||
|
||||
---
|
||||
|
||||
## Step 0.9: Apple target detection
|
||||
|
||||
Shipping to the App Store is not landing a PR. If the repository contains an
|
||||
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
|
||||
user's ask is store distribution (App Store, TestFlight, "release my app"),
|
||||
**STOP and Read `$GSTACK_ROOT/ship/sections/apple-release.md` FIRST**
|
||||
— before the branch gate and any preflight below. Store distribution proceeds
|
||||
from whatever branch the user is on (a clean tree on the base branch is the
|
||||
solo developer's normal case, not an error) and follows the adapter end to
|
||||
end. The branch gate and repository-landing pipeline below apply ONLY to
|
||||
repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
@@ -1001,41 +1031,70 @@ git fetch origin <base> && git merge origin/<base> --no-edit
|
||||
|
||||
## Test Framework Bootstrap
|
||||
|
||||
**Detect existing test framework and project runtime:**
|
||||
**Read the project's CLAUDE.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5.
|
||||
|
||||
**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse.
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
# Detect project runtime
|
||||
[ -f Gemfile ] && echo "RUNTIME:ruby"
|
||||
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
|
||||
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
|
||||
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
|
||||
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
|
||||
[ -f package.json ] && echo "RUNTIME:node"
|
||||
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
|
||||
[ -f go.mod ] && echo "RUNTIME:go"
|
||||
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
||||
[ -f composer.json ] && echo "RUNTIME:php"
|
||||
[ -f mix.exs ] && echo "RUNTIME:elixir"
|
||||
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
|
||||
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
|
||||
# Detect sub-frameworks
|
||||
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
|
||||
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
|
||||
# Check for existing test infrastructure
|
||||
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
|
||||
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
||||
# Existing test path — config files, declared scripts, AND test FILES.
|
||||
# A project with real tests and no config file is the common miss.
|
||||
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
|
||||
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
|
||||
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
|
||||
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
|
||||
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
|
||||
# Rust keeps unit tests inside src/, so file names alone miss them
|
||||
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
|
||||
# Check opt-out marker
|
||||
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
|
||||
```
|
||||
|
||||
**If test framework detected** (config files or test directories found):
|
||||
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
|
||||
Map the markers to the command you will OFFER — never to one you run on a guess:
|
||||
|
||||
| Marker | Ecosystem | Candidate command to offer |
|
||||
|--------|-----------|----------------------------|
|
||||
| `manage.py` | Django | `python manage.py test` (or `pytest` when pytest-django is in the deps) |
|
||||
| `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` |
|
||||
| `go.mod` (+ any `*_test.go`) | Go | `go test ./...` |
|
||||
| `Cargo.toml` | Rust | `cargo test` |
|
||||
| `pom.xml` | JVM (Maven) | `mvn test` |
|
||||
| `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` |
|
||||
| `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` |
|
||||
| `mix.exs` | Elixir | `mix test` |
|
||||
| `composer.json` | PHP | `composer test` or `./vendor/bin/phpunit` |
|
||||
| `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names |
|
||||
| `Makefile` with a `test:` target | any | `make test` |
|
||||
|
||||
**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one.
|
||||
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
|
||||
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
|
||||
|
||||
Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `<app>/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate.
|
||||
|
||||
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
|
||||
|
||||
**If NO runtime detected** (no config files found): Use AskUserQuestion:
|
||||
**If NO ecosystem marker matched:** Use AskUserQuestion:
|
||||
"I couldn't detect your project's language. What runtime are you using?"
|
||||
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
|
||||
If the runtime you need isn't listed, offer "Other" and take the runtime plus the test command as free text.
|
||||
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
|
||||
|
||||
**If runtime detected but no test framework — bootstrap:**
|
||||
**If an ecosystem matched but there is no existing-test evidence at all — bootstrap:**
|
||||
|
||||
### B2. Research best practices
|
||||
|
||||
@@ -1051,7 +1110,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
|
||||
| Node.js | vitest + @testing-library | jest + @testing-library |
|
||||
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
|
||||
| Python | pytest + pytest-cov | unittest |
|
||||
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
|
||||
| Go | stdlib testing + testify | stdlib only |
|
||||
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
|
||||
| Rust | cargo test (built-in) + mockall | — |
|
||||
| PHP | phpunit + mockery | pest |
|
||||
| Elixir | ExUnit (built-in) + ex_machina | — |
|
||||
@@ -1380,15 +1441,20 @@ Before analyzing coverage, detect the project's test framework:
|
||||
|
||||
```bash
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
# Detect project runtime
|
||||
[ -f Gemfile ] && echo "RUNTIME:ruby"
|
||||
# Detect project runtime (markers are evidence, not commands to run blind)
|
||||
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django"
|
||||
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
|
||||
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
|
||||
[ -f package.json ] && echo "RUNTIME:node"
|
||||
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
|
||||
[ -f go.mod ] && echo "RUNTIME:go"
|
||||
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
||||
# Check for existing test infrastructure
|
||||
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini phpunit.xml 2>/dev/null
|
||||
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
||||
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
|
||||
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
|
||||
# Check for existing test infrastructure — config files, scripts, AND test files
|
||||
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null
|
||||
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
|
||||
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
|
||||
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
|
||||
```
|
||||
|
||||
3. **If no framework detected:** falls through to the Test Framework Bootstrap step (Step 4) which handles full setup.
|
||||
@@ -1820,16 +1886,23 @@ Using the plan file already discovered in Step 8, look for a verification sectio
|
||||
|
||||
### 2. Check for running dev server
|
||||
|
||||
Before invoking browse-based verification, check if a dev server is reachable:
|
||||
Before invoking browse-based verification, find the dev-server URL the way the
|
||||
project declares it — never trust a hardcoded port list alone:
|
||||
|
||||
1. **CLAUDE.md first:** look for a documented dev URL or dev command (a
|
||||
`## Development`/`## Testing` section naming a port or URL). Use it.
|
||||
2. **The plan file:** if the plan's verification section names a URL, use it.
|
||||
3. **Fallback probe** (common ports, only when 1-2 found nothing):
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:5173 2>/dev/null || \
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo "NO_SERVER"
|
||||
for _p in 3000 8080 5173 4000 4321 8000; do
|
||||
_code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$_p" 2>/dev/null)
|
||||
[ -n "$_code" ] && [ "$_code" != "000" ] && { echo "DEV_SERVER: http://localhost:$_p ($_code)"; break; }
|
||||
done
|
||||
[ -z "${_code:-}" ] || [ "${_code:-000}" = "000" ] && echo "NO_SERVER"
|
||||
```
|
||||
|
||||
**If NO_SERVER:** Skip with "No dev server detected — skipping plan verification. Run /qa separately after deploying."
|
||||
**If NO_SERVER:** Skip with "No dev server detected (checked CLAUDE.md, the plan, and common ports) — skipping plan verification. Run /qa separately after deploying, or document the dev URL in CLAUDE.md so this step finds it next time."
|
||||
|
||||
### 3. Invoke /qa-only inline
|
||||
|
||||
@@ -2995,6 +3068,8 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS"
|
||||
|
||||
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.**
|
||||
|
||||
**REST fallback (#1079):** on some repos `gh pr edit` hard-errors with a GraphQL deprecation mentioning `repository.pullRequest.projectCards` ("Projects (classic) is being deprecated..."). That is a `gh` GraphQL-path problem, not a permissions problem — do not re-ask for auth. Fall back to the REST endpoint, which never touches the deprecated field, using the SAME already-scanned temp file: `PR_NUMBER=$(gh pr view --json number -q .number)` then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"` for the body, and `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"` when the title edit below hits the same error. Verify with the same self-checks as the primary path.
|
||||
|
||||
**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule.
|
||||
|
||||
1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`).
|
||||
|
||||
@@ -97,10 +97,12 @@ public final class StateServer {
|
||||
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
|
||||
|
||||
// 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
|
||||
// The daemon will rotate immediately; this log line is dead within
|
||||
// seconds.
|
||||
logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
||||
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
|
||||
// token from the 0600 file above (copyFileFromAppContainer); the
|
||||
// os_log line that used to carry it had no consumer and handed a
|
||||
// live credential to anything reading the unified log during the
|
||||
// launch window. Port/build stay for diagnostics.
|
||||
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
||||
|
||||
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
|
||||
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
|
||||
@@ -149,7 +151,19 @@ public final class StateServer {
|
||||
let params = NWParameters.tcp
|
||||
params.allowLocalEndpointReuse = true
|
||||
|
||||
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
|
||||
// IPv4 has no CoreDevice tunnel path, so it binds strictly to
|
||||
// loopback at the socket level; IPv6 keeps the wildcard bind and
|
||||
// relies on the per-connection peer check below for tunnel peers.
|
||||
let listener: NWListener
|
||||
switch family {
|
||||
case .ipv4:
|
||||
params.requiredLocalEndpoint = NWEndpoint.hostPort(
|
||||
host: NWEndpoint.Host("127.0.0.1"),
|
||||
port: NWEndpoint.Port(rawValue: port)!)
|
||||
listener = try NWListener(using: params)
|
||||
case .ipv6:
|
||||
listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
|
||||
}
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
if case .ready = state {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* #538: the office-hours founder-resources pitch takes no for an answer.
|
||||
*
|
||||
* The reporter found that memory instructions telling the agent to stop
|
||||
* showing the 34-resource pool kept being overridden on every update. The
|
||||
* fix is a config key — `founder_resources` — that session context cannot
|
||||
* override: `false` skips the entire section silently, forever, until the
|
||||
* user re-enables it.
|
||||
*
|
||||
* Pins: (1) the config key's default, persistence, and validation through
|
||||
* the real bin/gstack-config subprocess; (2) the generated section gates on
|
||||
* the key BEFORE any resource content; (3) the opt-out write is verified
|
||||
* before the skill may promise "never again" (R6 — a failed write must not
|
||||
* produce a false promise).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { execFileSync } 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 CONFIG_BIN = path.join(ROOT, "bin", "gstack-config");
|
||||
const SECTION = path.join(ROOT, "office-hours", "sections", "design-and-handoff.md");
|
||||
|
||||
let tmpHome: string;
|
||||
beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-538-")); });
|
||||
afterEach(() => { fs.rmSync(tmpHome, { recursive: true, force: true }); });
|
||||
|
||||
function cfg(args: string[]): string {
|
||||
return execFileSync(CONFIG_BIN, args, {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
}).trim();
|
||||
}
|
||||
|
||||
describe("founder_resources config key (#538)", () => {
|
||||
test("defaults to true (pitch stays on for everyone who never opted out)", () => {
|
||||
expect(cfg(["get", "founder_resources"])).toBe("true");
|
||||
});
|
||||
|
||||
test("set false persists and reads back — the write-verify contract", () => {
|
||||
cfg(["set", "founder_resources", "false"]);
|
||||
expect(cfg(["get", "founder_resources"])).toBe("false");
|
||||
});
|
||||
|
||||
test("invalid values are rejected to the default, never persisted as-is", () => {
|
||||
execFileSync(CONFIG_BIN, ["set", "founder_resources", "banana"], {
|
||||
encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
});
|
||||
expect(cfg(["get", "founder_resources"])).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("office-hours section gates on the key (#538)", () => {
|
||||
const src = fs.readFileSync(SECTION, "utf-8");
|
||||
|
||||
test("the opt-out check precedes any resource content", () => {
|
||||
const gate = src.indexOf("gstack-config get founder_resources");
|
||||
const pool = src.indexOf("Resource Pool");
|
||||
expect(gate).toBeGreaterThan(-1);
|
||||
expect(pool).toBeGreaterThan(-1);
|
||||
expect(gate).toBeLessThan(pool);
|
||||
});
|
||||
|
||||
test("skip is silent and permanent — never means never", () => {
|
||||
expect(src).toContain("skip this entire section silently");
|
||||
expect(src).toContain("gstack-config set founder_resources true");
|
||||
});
|
||||
|
||||
test("the opt-out write is verified before any promise (R6)", () => {
|
||||
expect(src).toContain("VERIFY the write");
|
||||
expect(src).toMatch(/read back\s*\n?`false`/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* CI secret gate contract (R4/R9, fork port wave 2).
|
||||
*
|
||||
* .github/scripts/gate-secret-scan.mjs pipes a unified diff's ADDED lines
|
||||
* into bin/gstack-redact and enforces: HIGH fails (exit 1), MEDIUM is an
|
||||
* advisory count only (no human in CI to confirm, so it must never fail
|
||||
* the check), clean passes. The workflow-level pathspec excludes keep the
|
||||
* planted-bug fixtures out of the diff entirely; this pins the script's
|
||||
* own exit contract with live subprocess runs.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { spawnSync } from "child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const SCRIPT = join(ROOT, ".github", "scripts", "gate-secret-scan.mjs");
|
||||
|
||||
function scan(diff: string, cwd: string = ROOT): { code: number; out: string } {
|
||||
const res = spawnSync("node", [SCRIPT], {
|
||||
cwd,
|
||||
input: diff,
|
||||
encoding: "utf-8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
return { code: res.status ?? -1, out: `${res.stdout}${res.stderr}` };
|
||||
}
|
||||
|
||||
describe("gate-secret-scan.mjs exit contract", () => {
|
||||
test("clean added lines pass", () => {
|
||||
const r = scan("+const x = 1;\n+++ b/file.ts\n+// harmless\n");
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toContain("0 high");
|
||||
});
|
||||
|
||||
// The planted PEM is assembled at runtime — header split included — so this
|
||||
// FILE never carries a live-format private key: the repo's own prepush
|
||||
// credential guard scans pushed diffs and (correctly) blocks any one-line
|
||||
// BEGIN…END spelling regardless of body. The scanner under test still
|
||||
// receives the true live shape.
|
||||
const PEM_BEGIN = ["-----BEGIN RSA ", "PRIVATE KEY-----"].join("");
|
||||
const PEM_END = ["-----END RSA ", "PRIVATE KEY-----"].join("");
|
||||
const PLANTED_PEM_BODY = ["MIIEow", "IBAAKC", "AQEA"].join("");
|
||||
|
||||
test("a HIGH credential in an added line fails the gate", () => {
|
||||
const r = scan(
|
||||
`+${PEM_BEGIN}\n+${PLANTED_PEM_BODY}\n+${PEM_END}\n`,
|
||||
);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.out).toContain("1 high");
|
||||
});
|
||||
|
||||
test("removed lines and context are ignored — only additions are scanned", () => {
|
||||
const r = scan(
|
||||
`-${PEM_BEGIN}\n-${PLANTED_PEM_BODY}\n${PEM_END}\n+just an addition\n`,
|
||||
);
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
|
||||
test("MEDIUM findings are advisory only — never fail CI", () => {
|
||||
// A Stripe publishable-key shape sits at MEDIUM in the taxonomy
|
||||
// (context-variable; a human confirms interactively, CI cannot).
|
||||
const r = scan(`+const key = "pk_live_${"a".repeat(24)}";\n`);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/\d+ advisory/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gate-secret-scan.mjs fail-closed legs", () => {
|
||||
test("oversize diff (report.oversize) fails the gate", () => {
|
||||
// The script pins --max-bytes 16000000; bin/gstack-redact refuses to scan
|
||||
// anything larger and reports oversize:true (fail-closed). The gate must
|
||||
// exit 1 rather than pass unscanned bytes. ~17MB of added lines guarantees
|
||||
// the joined additions exceed the cap.
|
||||
const line = `+${"a".repeat(8190)}\n`;
|
||||
const r = scan(line.repeat(2100));
|
||||
expect(r.code).toBe(1);
|
||||
// Proves the failure came from the parsed report (the engine surfaces
|
||||
// oversize as a fail-closed HIGH), not from a crashed subprocess.
|
||||
expect(r.out).toContain("1 high");
|
||||
}, 60_000);
|
||||
|
||||
test("unexpected gstack-redact exit code fails the gate even when the report is clean", () => {
|
||||
// Stub bin/gstack-redact that emits a CLEAN JSON report but exits 1 —
|
||||
// not one of the contract codes (0 clean / 2 MEDIUM / 3 HIGH). The gate
|
||||
// must treat the unexpected exit as failure: a broken scanner reporting
|
||||
// "all clear" is exactly the fail-open shape this leg guards against.
|
||||
const dir = mkdtempSync(join(tmpdir(), "gate-secret-scan-stub-"));
|
||||
try {
|
||||
mkdirSync(join(dir, "bin"));
|
||||
writeFileSync(
|
||||
join(dir, "bin", "gstack-redact"),
|
||||
[
|
||||
"#!/usr/bin/env bun",
|
||||
'let input = "";',
|
||||
'process.stdin.setEncoding("utf8");',
|
||||
'process.stdin.on("data", (c) => { input += c; });',
|
||||
'process.stdin.on("end", () => {',
|
||||
' console.log(JSON.stringify({ findings: [], counts: { HIGH: 0, MEDIUM: 0, LOW: 0, WARN: 0 }, repoVisibility: "public", oversize: false }));',
|
||||
" process.exit(1);",
|
||||
"});",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const r = scan("+const x = 1;\n", dir);
|
||||
expect(r.out).toContain("0 high"); // the clean report WAS parsed...
|
||||
expect(r.code).toBe(1); // ...and the gate still failed on the exit code
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("missing gstack-redact (spawn crash, empty stdout) exits nonzero — never fail-open", () => {
|
||||
// cwd with no bin/gstack-redact at all: bun exits module-not-found with
|
||||
// empty stdout. Whatever the exact failure shape, the gate must not
|
||||
// report success.
|
||||
const dir = mkdtempSync(join(tmpdir(), "gate-secret-scan-absent-"));
|
||||
try {
|
||||
const r = scan("+const x = 1;\n", dir);
|
||||
expect(r.code).not.toBe(0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
chmodSync,
|
||||
} from "fs";
|
||||
@@ -39,24 +40,6 @@ interface FakeEnv {
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
interface ShellCase {
|
||||
name: "bash" | "zsh";
|
||||
command: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
function shellAvailable(command: string): boolean {
|
||||
const result = spawnSync(command, ["-c", "exit 0"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
const SHELLS: ShellCase[] = [
|
||||
{ name: "bash", command: "bash", available: shellAvailable("bash") },
|
||||
{ name: "zsh", command: "zsh", available: shellAvailable("zsh") },
|
||||
];
|
||||
|
||||
function makeFakeEnv(): FakeEnv {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gbrain-voyage-init-"));
|
||||
const home = join(tmp, "home");
|
||||
@@ -69,13 +52,8 @@ function makeFakeEnv(): FakeEnv {
|
||||
// succeeds on init (writes a sentinel pglite config), and returns canned
|
||||
// output for --version. Nothing else is needed for the shape test.
|
||||
const fake = `#!/bin/sh
|
||||
{
|
||||
echo "__CALL__"
|
||||
for arg in "$@"; do
|
||||
printf 'arg=%s\\n' "$arg"
|
||||
done
|
||||
echo "__END__"
|
||||
} >> "${argvLog}"
|
||||
echo "$@" >> "${argvLog}"
|
||||
echo "$#" >> "${argvLog}.argc"
|
||||
case "$1" in
|
||||
--version)
|
||||
echo "gbrain 0.37.1.0"
|
||||
@@ -104,11 +82,11 @@ exit 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim reimplementation of the skill template's voyage-code-3 conditional.
|
||||
* The template (setup-gbrain/SKILL.md.tmpl Path 3, Step 1.5 inside the
|
||||
* rollback wrapper, Step 4.5 Path 4 Yes branch) instructs the model to execute
|
||||
* this shell; we execute the same shell here and assert the argv passed to
|
||||
* gbrain matches the contract under both bash and zsh.
|
||||
* Verbatim reimplementation of the skill template's voyage-code-3
|
||||
* conditional. The template (setup-gbrain/SKILL.md.tmpl Path 3, Step 1.5
|
||||
* inside the rollback wrapper, Step 4.5 Path 4 Yes branch) instructs the
|
||||
* model to execute this bash; we execute the same bash here and assert the
|
||||
* argv passed to gbrain matches the contract.
|
||||
*
|
||||
* If the template changes the flag set or the env-var name, this test
|
||||
* should fail until the shell here is updated too — by design.
|
||||
@@ -116,15 +94,19 @@ exit 0
|
||||
function runInitWithVoyageGate(
|
||||
env: FakeEnv,
|
||||
voyageKey: string | undefined,
|
||||
shell: ShellCase,
|
||||
): string[][] {
|
||||
shell: "bash" | "zsh" = "bash",
|
||||
): string[] {
|
||||
// The template's #1798 shape: flags ride the positional params, because an
|
||||
// unquoted $VAR does NOT word-split under zsh — the whole flag string
|
||||
// arrived as ONE argv word and gbrain silently fell back to its default
|
||||
// embedding model.
|
||||
const script = `
|
||||
set -u
|
||||
set --
|
||||
if [ -n "\${VOYAGE_API_KEY:-}" ]; then
|
||||
gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
||||
else
|
||||
gbrain init --pglite --json
|
||||
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
|
||||
fi
|
||||
gbrain init --pglite --json "$@"
|
||||
`;
|
||||
const baseEnv: Record<string, string> = {
|
||||
...process.env,
|
||||
@@ -136,86 +118,111 @@ fi
|
||||
} else {
|
||||
baseEnv.VOYAGE_API_KEY = voyageKey;
|
||||
}
|
||||
const result = spawnSync(shell.command, ["-c", script], {
|
||||
const result = spawnSync(shell, ["-c", script], {
|
||||
encoding: "utf-8",
|
||||
env: baseEnv,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${shell.name} init script exited ${result.status}: ${result.stderr}`,
|
||||
);
|
||||
throw new Error(`init script exited ${result.status}: ${result.stderr}`);
|
||||
}
|
||||
return parseArgvLog(env.argvLog);
|
||||
return readFileSync(env.argvLog, "utf-8").trim().split("\n");
|
||||
}
|
||||
|
||||
function parseArgvLog(argvLog: string): string[][] {
|
||||
const calls: string[][] = [];
|
||||
let current: string[] | undefined;
|
||||
|
||||
for (const line of readFileSync(argvLog, "utf-8").trim().split("\n")) {
|
||||
if (line === "__CALL__") {
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
if (line === "__END__") {
|
||||
if (current) calls.push(current);
|
||||
current = undefined;
|
||||
continue;
|
||||
}
|
||||
if (current && line.startsWith("arg=")) {
|
||||
current.push(line.slice("arg=".length));
|
||||
}
|
||||
}
|
||||
|
||||
return calls;
|
||||
function lastArgc(env: FakeEnv): number {
|
||||
const lines = readFileSync(`${env.argvLog}.argc`, "utf-8").trim().split("\n");
|
||||
return parseInt(lines[lines.length - 1], 10);
|
||||
}
|
||||
|
||||
const HAVE_ZSH = spawnSync("zsh", ["-c", "true"]).status === 0;
|
||||
|
||||
describe("voyage-code-3 default for gstack-driven PGLite init", () => {
|
||||
for (const shell of SHELLS) {
|
||||
const describeShell = shell.available ? describe : describe.skip;
|
||||
it("passes voyage-code-3 flags when VOYAGE_API_KEY is set", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "vk_test_set");
|
||||
expect(calls.length).toBe(1);
|
||||
const argv = calls[0];
|
||||
expect(argv).toContain("init --pglite --json");
|
||||
expect(argv).toContain("--embedding-model voyage:voyage-code-3");
|
||||
expect(argv).toContain("--embedding-dimensions 1024");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describeShell(`under ${shell.name}`, () => {
|
||||
it("passes voyage-code-3 flags as four separate argv entries when VOYAGE_API_KEY is set", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "vk_test_set", shell);
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
"init",
|
||||
"--pglite",
|
||||
"--json",
|
||||
"--embedding-model",
|
||||
"voyage:voyage-code-3",
|
||||
"--embedding-dimensions",
|
||||
"1024",
|
||||
],
|
||||
]);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
it("omits voyage flags when VOYAGE_API_KEY is unset", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, undefined);
|
||||
expect(calls.length).toBe(1);
|
||||
const argv = calls[0];
|
||||
expect(argv).toContain("init --pglite --json");
|
||||
expect(argv).not.toContain("voyage");
|
||||
expect(argv).not.toContain("--embedding-model");
|
||||
expect(argv).not.toContain("--embedding-dimensions");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits voyage flags when VOYAGE_API_KEY is unset", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, undefined, shell);
|
||||
expect(calls).toEqual([["init", "--pglite", "--json"]]);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
it("zsh: flags arrive as SEPARATE argv words (#1798 — the shell that broke)", () => {
|
||||
if (!HAVE_ZSH) return; // zsh ships on macOS; skip quietly elsewhere
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "vk_test_set", "zsh");
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]).toContain("--embedding-model voyage:voyage-code-3");
|
||||
// init --pglite --json + 4 flag words = 7 argv entries. The pre-#1798
|
||||
// unquoted-var shape produced 4 under zsh (the whole flag string as one
|
||||
// word), and gbrain silently fell back to its default embedding model.
|
||||
expect(lastArgc(env)).toBe(7);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats empty-string VOYAGE_API_KEY the same as unset (no false positive)", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "", shell);
|
||||
expect(calls).toEqual([["init", "--pglite", "--json"]]);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
it("demonstrates the #1798 collision: an unquoted flags var is ONE word under zsh", () => {
|
||||
if (!HAVE_ZSH) return;
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const brokenShape = `
|
||||
set -u
|
||||
GBRAIN_EMBED_FLAGS="--embedding-model voyage:voyage-code-3 --embedding-dimensions 1024"
|
||||
gbrain init --pglite --json $GBRAIN_EMBED_FLAGS
|
||||
`;
|
||||
const result = spawnSync("zsh", ["-c", brokenShape], {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, HOME: env.home, PATH: `${env.bindir}:/usr/bin:/bin` },
|
||||
});
|
||||
});
|
||||
}
|
||||
expect(result.status).toBe(0);
|
||||
expect(lastArgc(env)).toBe(4); // init, --pglite, --json, "<entire flag string>"
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("template uses the positional-params shape, not an unquoted flags var", () => {
|
||||
const tmpl = readFileSync(
|
||||
join(import.meta.dir, "..", "setup-gbrain", "SKILL.md.tmpl"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(tmpl).not.toContain("$GBRAIN_EMBED_FLAGS");
|
||||
const sites = tmpl.match(/gbrain init --pglite --json "\$@"/g) || [];
|
||||
expect(sites.length).toBe(3);
|
||||
const setSites = tmpl.match(/set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024/g) || [];
|
||||
expect(setSites.length).toBe(3);
|
||||
});
|
||||
|
||||
it("treats empty-string VOYAGE_API_KEY the same as unset (no false positive)", () => {
|
||||
const env = makeFakeEnv();
|
||||
try {
|
||||
const calls = runInitWithVoyageGate(env, "");
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]).not.toContain("voyage");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("template alignment: the .tmpl actually contains the voyage gate", () => {
|
||||
@@ -225,13 +232,11 @@ describe("template alignment: the .tmpl actually contains the voyage gate", () =
|
||||
const TEMPLATE_PATH = join(import.meta.dir, "..", "setup-gbrain", "SKILL.md.tmpl");
|
||||
const tmpl = readFileSync(TEMPLATE_PATH, "utf-8");
|
||||
|
||||
it("setup-gbrain template gates the embedding-model flag on VOYAGE_API_KEY without word-splitting", () => {
|
||||
it("setup-gbrain template gates the embedding-model flag on VOYAGE_API_KEY", () => {
|
||||
// Should appear at least once (currently 3 init sites use the same gate).
|
||||
expect(tmpl).toContain('if [ -n "${VOYAGE_API_KEY:-}" ]; then');
|
||||
expect(tmpl).toContain(
|
||||
"gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024",
|
||||
);
|
||||
expect(tmpl).not.toContain("GBRAIN_EMBED_FLAGS");
|
||||
expect(tmpl).toContain("--embedding-model voyage:voyage-code-3");
|
||||
expect(tmpl).toContain("--embedding-dimensions 1024");
|
||||
});
|
||||
|
||||
it("setup-gbrain template uses the conditional gate at all 3 PGLite init sites", () => {
|
||||
@@ -239,10 +244,5 @@ describe("template alignment: the .tmpl actually contains the voyage gate", () =
|
||||
// init site, update this expectation deliberately.
|
||||
const matches = tmpl.match(/if \[ -n "\$\{VOYAGE_API_KEY:-\}" \]; then/g);
|
||||
expect(matches?.length).toBe(3);
|
||||
|
||||
const voyageInitMatches = tmpl.match(
|
||||
/gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024/g,
|
||||
);
|
||||
expect(voyageInitMatches?.length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -269,3 +269,105 @@ describe('get without arg (auto-detect from current dir)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2140 sync-path chokepoint ──────────────────────────────────────────────
|
||||
// The tier above is a STORE. This block pins the ENFORCEMENT: a direct
|
||||
// gstack-gbrain-sync invocation (skill prose bypassed — cron, curiosity,
|
||||
// automation) must honor deny/read-only at the code-import stage, and the
|
||||
// egress receipt's "per-repo policy chokepoint (repoPolicyTier)" consent
|
||||
// string must describe code that exists. Wave-1 shipped the receipt string
|
||||
// without the function; these tests make that impossible to repeat.
|
||||
|
||||
describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path)', () => {
|
||||
const SYNC = path.join(ROOT, 'bin', 'gstack-gbrain-sync.ts');
|
||||
const REPO_URL = 'https://github.com/acme/widget.git';
|
||||
|
||||
let repoDir: string;
|
||||
|
||||
function makeRepo(): void {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-policy-repo-'));
|
||||
const git = (...args: string[]) =>
|
||||
spawnSync('git', args, { cwd: repoDir, encoding: 'utf-8' });
|
||||
git('init', '-q', '.');
|
||||
git('remote', 'add', 'origin', REPO_URL);
|
||||
fs.writeFileSync(path.join(repoDir, 'README.md'), 'fixture\n');
|
||||
git('add', '-A');
|
||||
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture');
|
||||
}
|
||||
|
||||
function runSync(): { status: number; text: string; stages: any[] } {
|
||||
const res = spawnSync('bun', [SYNC, '--code-only', '--incremental'], {
|
||||
cwd: repoDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 60_000,
|
||||
// HOME also redirected so engine detection can't find a real ~/.gbrain.
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome, HOME: tmpHome },
|
||||
});
|
||||
let stages: any[] = [];
|
||||
try {
|
||||
stages = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, '.gbrain-sync-state.json'), 'utf-8'),
|
||||
).last_stages || [];
|
||||
} catch {
|
||||
// state file may be absent on early refusal paths — text asserts cover it
|
||||
}
|
||||
return {
|
||||
status: res.status ?? -1,
|
||||
text: `${res.stdout || ''}\n${res.stderr || ''}`,
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (repoDir) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('deny → code stage refuses loudly, exit 1, status refused-policy-deny', () => {
|
||||
makeRepo();
|
||||
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
|
||||
const r = runSync();
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.text).toContain('refused');
|
||||
expect(r.text).toContain('deny');
|
||||
const code = r.stages.find((s: any) => s.name === 'code');
|
||||
expect(code?.detail?.status).toBe('refused-policy-deny');
|
||||
});
|
||||
|
||||
test('read-only → clean skip (exit 0), status skipped-policy-read-only', () => {
|
||||
makeRepo();
|
||||
expect(run(['set', REPO_URL, 'read-only']).status).toBe(0);
|
||||
const r = runSync();
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.text).toContain('read-only');
|
||||
const code = r.stages.find((s: any) => s.name === 'code');
|
||||
expect(code?.detail?.status).toBe('skipped-policy-read-only');
|
||||
});
|
||||
|
||||
test('store exists but unreadable → fail-closed refusal, never bypassed', () => {
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod semantics differ
|
||||
makeRepo();
|
||||
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
|
||||
fs.chmodSync(policyFile(), 0o000);
|
||||
try {
|
||||
const r = runSync();
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.text).toContain('refus');
|
||||
const code = r.stages.find((s: any) => s.name === 'code');
|
||||
expect(code?.detail?.status).toBe('refused-policy-unreadable');
|
||||
} finally {
|
||||
fs.chmodSync(policyFile(), 0o600);
|
||||
}
|
||||
});
|
||||
|
||||
test('no policy store → fail-open, stage proceeds past the gate (no policy status)', () => {
|
||||
makeRepo();
|
||||
const r = runSync();
|
||||
const code = r.stages.find((s: any) => s.name === 'code');
|
||||
// With no engine in the redirected HOME the stage skips for ENGINE
|
||||
// reasons — what matters is that no policy refusal fired and the exit
|
||||
// is clean, preserving pre-policy behavior for every non-policy user.
|
||||
expect(r.status).toBe(0);
|
||||
expect(String(code?.detail?.status || '')).not.toContain('policy');
|
||||
expect(r.text).not.toContain('refused');
|
||||
});
|
||||
});
|
||||
@@ -1079,8 +1079,11 @@ describe('PLAN_VERIFICATION_EXEC placeholder', () => {
|
||||
expect(shipSkill).toContain('qa-only');
|
||||
});
|
||||
|
||||
test('contains localhost reachability check', () => {
|
||||
expect(shipSkill).toContain('localhost:3000');
|
||||
test('contains dev-server discovery (CLAUDE.md first, then a port probe)', () => {
|
||||
// Fork port wave 2: the hardcoded 4-port list became read-CLAUDE.md-or-
|
||||
// probe; the probe loops common ports instead of naming each once.
|
||||
expect(shipSkill).toContain('CLAUDE.md first');
|
||||
expect(shipSkill).toContain('http://localhost:$_p');
|
||||
expect(shipSkill).toContain('NO_SERVER');
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { chmodSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
||||
import { chmodSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { delimiter, join } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
@@ -52,10 +52,47 @@ fi
|
||||
chmodSync(fakeBin, 0o755);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like writeFakeGbrain, but every invocation appends its argv to `logFile`.
|
||||
* Still answers `--version` successfully ON PURPOSE: a revert from the
|
||||
* memoized PATH stat scan back to the old `gbrain --version` spawn probe
|
||||
* would pass every non-logging test — only the argv log catches it.
|
||||
*/
|
||||
function writeLoggingGbrain(binDir: string, logFile: string): void {
|
||||
if (process.platform === "win32") {
|
||||
writeFileSync(
|
||||
join(binDir, "gbrain.cmd"),
|
||||
`@echo off\r\necho %* >> "${logFile}"\r\nif "%1"=="--version" (\r\n echo gbrain 0.test\r\n) else (\r\n echo fake gbrain %*\r\n)\r\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fakeBin = join(binDir, "gbrain");
|
||||
writeFileSync(
|
||||
fakeBin,
|
||||
`#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "${logFile}"
|
||||
if [ "$1" = "--version" ]; then
|
||||
echo "gbrain 0.test"
|
||||
else
|
||||
echo "fake gbrain $*"
|
||||
fi
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(fakeBin, 0o755);
|
||||
}
|
||||
|
||||
function prependPath(binDir: string): Record<string, string> {
|
||||
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH";
|
||||
const currentPath = process.env[pathKey] || "";
|
||||
return { [pathKey]: `${binDir}${delimiter}${currentPath}` };
|
||||
return {
|
||||
[pathKey]: `${binDir}${delimiter}${currentPath}`,
|
||||
// Cold process spawns on a loaded machine can exceed the 500ms default
|
||||
// budget; the fake gbrain is instant once spawned, so give it headroom.
|
||||
GSTACK_BRAIN_TIMEOUT_MS: "10000",
|
||||
};
|
||||
}
|
||||
|
||||
describe("gstack-brain-context-load CLI", () => {
|
||||
@@ -252,6 +289,111 @@ describe("gstack-brain-context-load — graceful gbrain absence", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("manifest filter: blocks reach gbrain as --filter args with template vars resolved (#1687)", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
|
||||
const binDir = join(dir, "bin");
|
||||
mkdirSync(binDir);
|
||||
writeFakeGbrain(binDir);
|
||||
const skillFile = join(dir, "SKILL.md");
|
||||
writeFileSync(
|
||||
skillFile,
|
||||
`---
|
||||
name: x
|
||||
gbrain:
|
||||
schema: 1
|
||||
context_queries:
|
||||
- id: prior-sessions
|
||||
kind: list
|
||||
filter:
|
||||
type: ceo-plan
|
||||
tags_contains: "repo:{repo_slug}"
|
||||
sort: updated_at_desc
|
||||
limit: 5
|
||||
render_as: "## Prior sessions"
|
||||
---
|
||||
`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
try {
|
||||
const r = runScript(["--skill-file", skillFile, "--repo", "my-test-repo"], prependPath(binDir));
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain("fake gbrain list_pages");
|
||||
expect(r.stdout).toContain("--filter type=ceo-plan");
|
||||
expect(r.stdout).toContain("--filter tags_contains=repo:my-test-repo");
|
||||
expect(r.stdout).toContain("--sort updated_at_desc");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("gbrain detection never spawns gbrain — stat-based PATH scan, not a `--version` probe (revert trap)", () => {
|
||||
// The fix replaced a per-query `gbrain --version` spawn probe with a
|
||||
// memoized PATH stat scan. The plain writeFakeGbrain shim still ANSWERS
|
||||
// --version, so a revert to the spawn probe passes every other test in
|
||||
// this file. This fake logs its argv: detection must invoke gbrain zero
|
||||
// times, so the only invocations are the 3 default-manifest list_pages
|
||||
// queries — a revert adds `--version` lines (and re-probing adds one per
|
||||
// query) and fails exactly here.
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
|
||||
const binDir = join(dir, "bin");
|
||||
mkdirSync(binDir);
|
||||
const logFile = join(dir, "gbrain-argv.log");
|
||||
writeLoggingGbrain(binDir, logFile);
|
||||
|
||||
try {
|
||||
const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], prependPath(binDir));
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stderr).toContain("queries=3");
|
||||
const invocations = readFileSync(logFile, "utf-8").split("\n").filter(Boolean);
|
||||
expect(invocations.some((argv) => argv.includes("--version"))).toBe(false);
|
||||
// Exactly the 3 real queries — no extra availability spawns of any shape.
|
||||
expect(invocations).toHaveLength(3);
|
||||
for (const argv of invocations) expect(argv.startsWith("list_pages")).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("availability survives a 1ms query budget — detection is not subject to GSTACK_BRAIN_TIMEOUT_MS", () => {
|
||||
// The spawn probe ran under the same MCP_TIMEOUT_MS budget as the queries,
|
||||
// so a cold spawn slower than the budget misreported gbrain as MISSING.
|
||||
// With the stat scan, a 1ms budget kills the queries themselves (SKIP)
|
||||
// but detection still sees the CLI — "gbrain CLI missing" must not appear.
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
|
||||
const binDir = join(dir, "bin");
|
||||
mkdirSync(binDir);
|
||||
// A SLOW fake, not the shared instant one: on a fast CI runner the
|
||||
// instant fake answered inside even a 1ms budget (observed dur=0ms on
|
||||
// ubicloud) and no SKIP ever printed. Sleeping makes the timeout
|
||||
// deterministic on every machine; --version stays instant so detection
|
||||
// has nothing to wait on.
|
||||
const fakeBin = join(binDir, "gbrain");
|
||||
writeFileSync(
|
||||
fakeBin,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = "--version" ]; then
|
||||
echo "gbrain 0.test"
|
||||
else
|
||||
sleep 0.3
|
||||
echo "fake gbrain $*"
|
||||
fi
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(fakeBin, 0o755);
|
||||
|
||||
try {
|
||||
const env = { ...prependPath(binDir), GSTACK_BRAIN_TIMEOUT_MS: "1" };
|
||||
const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stderr).toContain("SKIP");
|
||||
expect(r.stderr).not.toContain("gbrain CLI missing");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("vector + list queries still complete (with SKIP) when gbrain CLI is missing", () => {
|
||||
// We can't easily un-install gbrain; rely on the helper's own missing-binary
|
||||
// detection. The default manifest uses kind: list which calls gbrain. If
|
||||
|
||||
@@ -63,6 +63,39 @@ describe("gstack-decision-log", () => {
|
||||
const r = log("not json", true);
|
||||
expect(r.code).toBe(1);
|
||||
});
|
||||
test("--supersede with a replacement body records the replacement, linked to the old id", () => {
|
||||
const id = log('{"decision":"old-call","scope":"repo","source":"user"}').out;
|
||||
const out = logFlag(
|
||||
`--supersede ${id} '{"decision":"new-call","rationale":"better","scope":"repo","source":"user"}'`,
|
||||
);
|
||||
expect(out).toContain(id);
|
||||
expect(search()).toContain("new-call"); // the replacement is NOT silently dropped
|
||||
expect(search()).not.toContain("old-call");
|
||||
const arr = JSON.parse(search("--json"));
|
||||
expect(arr.find((d: any) => d.decision === "new-call")?.supersedes).toBe(id);
|
||||
});
|
||||
test("--supersede with an INVALID replacement persists nothing (old stays active)", () => {
|
||||
const id = log('{"decision":"keep-me","scope":"repo","source":"user"}').out;
|
||||
let code = 0;
|
||||
try {
|
||||
logFlag(`--supersede ${id} '{"decision":""}'`);
|
||||
} catch (e: any) {
|
||||
code = e.status || 1;
|
||||
}
|
||||
expect(code).toBe(1);
|
||||
expect(search()).toContain("keep-me"); // not retired by a failed replacement
|
||||
});
|
||||
test("--redact refuses a replacement body instead of dropping it", () => {
|
||||
const id = log('{"decision":"redact-target","scope":"repo","source":"user"}').out;
|
||||
let code = 0;
|
||||
try {
|
||||
logFlag(`--redact ${id} '{"decision":"would-be-lost","scope":"repo","source":"user"}'`);
|
||||
} catch (e: any) {
|
||||
code = e.status || 1;
|
||||
}
|
||||
expect(code).toBe(1);
|
||||
expect(search()).toContain("redact-target"); // nothing happened at all
|
||||
});
|
||||
});
|
||||
|
||||
describe("gstack-decision-search", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* Free-tier (~50ms total). Runs in `bun test`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterAll } from "bun:test";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
@@ -338,7 +338,11 @@ describe("withErrorContext", () => {
|
||||
process.env.GSTACK_HOME = testHome;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// afterEach, not afterAll: the save happens in beforeEach, so an afterAll
|
||||
// restore would put back the PREVIOUS test's temp dir (the last beforeEach
|
||||
// overwrote savedHome) and leak a gstack-test-home-* dir into every test
|
||||
// file that runs after this one in the same bun process.
|
||||
afterEach(() => {
|
||||
if (savedHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = savedHome;
|
||||
});
|
||||
@@ -414,7 +418,13 @@ describe("detectEngineTier", () => {
|
||||
process.env.HOME = testHome;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// afterEach, not afterAll: the save happens in beforeEach, so an afterAll
|
||||
// restore would put back the PREVIOUS test's gstack-test-engine-* temp dir
|
||||
// (the last beforeEach overwrote the saved values). That leaked
|
||||
// HOME/GSTACK_HOME/PATH into every test file that ran after this one in the
|
||||
// same bun process — child processes then looked for Playwright's Chromium
|
||||
// cache and ~/.gstack config under a throwaway temp HOME.
|
||||
afterEach(() => {
|
||||
if (savedHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = savedHome;
|
||||
if (savedGbrainHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// when the relevant CLI isn't available).
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -221,6 +222,104 @@ describe("resolveVersionPath (monorepo VERSION-path support)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Fixture-repo coverage for the default-base detection chain in parseArgs:
|
||||
// origin/HEAD symbolic-ref → origin/main probe → origin/master probe →
|
||||
// literal "main". No --base and no --current-version are passed, so the
|
||||
// detected base is observable through base_version: each fixture branch
|
||||
// carries a distinct VERSION and readBaseVersion does
|
||||
// `git show origin/<detected-base>:VERSION`.
|
||||
describe("default-base detection (no --base)", () => {
|
||||
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-next-version");
|
||||
// Point git at a nonexistent global/system config so operator settings
|
||||
// (init.defaultBranch, commit.gpgsign, hooks, ...) can't leak into fixtures.
|
||||
const noCfg = join(mkdtempSync(join(tmpdir(), "nextver-gitcfg-")), "empty");
|
||||
const GIT_ENV = {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: noCfg,
|
||||
GIT_CONFIG_SYSTEM: noCfg,
|
||||
GIT_AUTHOR_NAME: "fixture",
|
||||
GIT_AUTHOR_EMAIL: "fixture@example.com",
|
||||
GIT_COMMITTER_NAME: "fixture",
|
||||
GIT_COMMITTER_EMAIL: "fixture@example.com",
|
||||
};
|
||||
|
||||
function git(cwd: string, ...args: string[]): void {
|
||||
execFileSync("git", args, { cwd, env: GIT_ENV, stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
// Origin repo whose branches each hold a distinct VERSION, plus a clone
|
||||
// (the clone is the repo the CLI runs in).
|
||||
function makeClone(branches: Array<[name: string, version: string]>): { root: string; clone: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "nextver-base-"));
|
||||
const origin = join(root, "origin");
|
||||
mkdirSync(origin);
|
||||
git(origin, "init", "-q", "-b", branches[0][0]);
|
||||
for (let i = 0; i < branches.length; i++) {
|
||||
const [name, version] = branches[i];
|
||||
if (i > 0) git(origin, "checkout", "-q", "-b", name);
|
||||
writeFileSync(join(origin, "VERSION"), `${version}\n`);
|
||||
git(origin, "add", "VERSION");
|
||||
git(origin, "commit", "-q", "--no-gpg-sign", "-m", `VERSION ${version}`);
|
||||
}
|
||||
git(root, "clone", "-q", "origin", "clone");
|
||||
return { root, clone: join(root, "clone") };
|
||||
}
|
||||
|
||||
function runWithoutBase(cwd: string): { exitCode: number; parsed: any } {
|
||||
const proc = Bun.spawnSync(
|
||||
["bun", "run", SCRIPT, "--bump", "patch", "--workspace-root", "null"],
|
||||
{ cwd },
|
||||
);
|
||||
const out = new TextDecoder().decode(proc.stdout);
|
||||
return { exitCode: proc.exitCode, parsed: JSON.parse(out) };
|
||||
}
|
||||
|
||||
test("origin/HEAD symbolic-ref wins — resolves trunk even though origin/main exists", () => {
|
||||
const { root, clone } = makeClone([["main", "1.1.1.1"], ["trunk", "2.2.2.2"]]);
|
||||
try {
|
||||
git(clone, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk");
|
||||
const { exitCode, parsed } = runWithoutBase(clone);
|
||||
expect(exitCode).toBe(0);
|
||||
// trunk's VERSION, not main's — the rev-parse probes never ran.
|
||||
expect(parsed.base_version).toBe("2.2.2.2");
|
||||
expect(parsed.version).toBe("2.2.3.0");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("no origin/HEAD, no origin/main: the origin/master probe resolves master", () => {
|
||||
const { root, clone } = makeClone([["master", "3.3.3.3"]]);
|
||||
try {
|
||||
// Plain clones that never ran `git remote set-head` have no origin/HEAD.
|
||||
git(clone, "remote", "set-head", "origin", "--delete");
|
||||
const { exitCode, parsed } = runWithoutBase(clone);
|
||||
expect(exitCode).toBe(0);
|
||||
// Read at origin/master. The "main" literal fallback would have warned
|
||||
// and assumed 0.0.0.0 instead.
|
||||
expect(parsed.base_version).toBe("3.3.3.3");
|
||||
expect(parsed.version).toBe("3.3.4.0");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("neither origin/HEAD nor main/master: falls back to the 'main' literal", () => {
|
||||
const { root, clone } = makeClone([["develop", "4.4.4.4"]]);
|
||||
try {
|
||||
git(clone, "remote", "set-head", "origin", "--delete");
|
||||
const { exitCode, parsed } = runWithoutBase(clone);
|
||||
expect(exitCode).toBe(0);
|
||||
// base = literal "main"; origin/main doesn't exist, so readBaseVersion
|
||||
// warns and assumes 0.0.0.0 — which pins WHICH base the fallback chose.
|
||||
expect(parsed.base_version).toBe("0.0.0.0");
|
||||
expect(parsed.warnings.join("\n")).toContain("origin/main");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// Integration smoke — only runs if gh is available and authenticated. Confirms
|
||||
// the CLI executes end-to-end against real APIs without crashing.
|
||||
describe("integration (smoke)", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* zero rendering loss. The TTY rendering layer is identical for fat and slim
|
||||
* skills, so it is not where token-reduction degradation can hide.
|
||||
*/
|
||||
import { resolveEvalModel } from '../../lib/eval-model';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
@@ -191,7 +192,7 @@ This is a capture test, not an interactive session. Skip any system-audit / envi
|
||||
timeout: 240_000,
|
||||
testName: opts.testName,
|
||||
runId: opts.runId,
|
||||
model: opts.model ?? 'claude-opus-4-7',
|
||||
model: resolveEvalModel('capture', opts.model),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -253,7 +254,7 @@ Rules for this run:
|
||||
timeout: opts.timeout ?? 300_000,
|
||||
testName: opts.testName,
|
||||
runId: opts.runId,
|
||||
model: opts.model ?? 'claude-opus-4-7',
|
||||
model: resolveEvalModel('capture', opts.model),
|
||||
});
|
||||
|
||||
const readSections = new Set<string>();
|
||||
@@ -334,7 +335,7 @@ Write the verbatim text of that AskUserQuestion (the full decision brief: title,
|
||||
timeout: 240_000,
|
||||
testName: opts.testName,
|
||||
runId: opts.runId,
|
||||
model: opts.model ?? 'claude-opus-4-7',
|
||||
model: resolveEvalModel('capture', opts.model),
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -99,6 +99,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
ship: {
|
||||
skill: 'ship',
|
||||
expectedSections: [
|
||||
'apple-release.md',
|
||||
'tests.md',
|
||||
'test-coverage.md',
|
||||
'plan-completion.md',
|
||||
@@ -129,7 +130,16 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
minUnionBytes: 120_000,
|
||||
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
|
||||
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
|
||||
maxSizeRatio: 1.08,
|
||||
// Fork port wave 2: multi-ecosystem test-detection evidence (Django/JVM
|
||||
// markers, test-file census — e3259078 port) + the #1079 gh pr edit REST
|
||||
// fallback grew the union to 1.090x; the third-party web-actions
|
||||
// contract (consent-gated browser drive for API-key registration etc.)
|
||||
// adds ~2.3KB inline judgment, measured 1.103x. The Apple release
|
||||
// adapter (14.8KB carved section, 21 live releases of judgment — the
|
||||
// wave's headline capability) grows the union to 1.195x. Deliberate:
|
||||
// the section is on-demand (loads only for Apple store targets), so
|
||||
// per-invocation cost for non-iOS ships is one manifest line.
|
||||
maxSizeRatio: 1.22,
|
||||
},
|
||||
'plan-ceo-review': {
|
||||
skill: 'plan-ceo-review',
|
||||
@@ -144,9 +154,10 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
},
|
||||
behavioral: 'external',
|
||||
externalTest: 'test/skill-e2e-plan-ceo-review-section-loading.test.ts',
|
||||
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
|
||||
// the skeleton at 90,280 B; +1 KB headroom.
|
||||
maxSkeletonBytes: 91_000,
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// Fork port wave 2 (#703): the repo-doc-preference block in the design
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
maxSkeletonBytes: 92_500, // v1.64+v1.65 merge: both waves' preamble growth; measured 92,004
|
||||
minUnionBytes: 80_000,
|
||||
mustContain: ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'],
|
||||
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
|
||||
@@ -167,9 +178,10 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
behavioral: 'plan',
|
||||
// v1.2.0 activation lift (shared first-run-guidance preamble) + #2077 ask-first scope gate.
|
||||
// +~1 KB: plan-mode auto-select-B scope-gate exceptions (2026-08).
|
||||
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
|
||||
// the skeleton at 68,163 B; +~1 KB headroom.
|
||||
maxSkeletonBytes: 69_000,
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// Fork port wave 2 (#703): the repo-doc-preference block in the design
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
maxSkeletonBytes: 70_000, // measured 68,780
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
|
||||
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
|
||||
@@ -180,7 +192,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// 1.08 → 1.10: the scope-gate exceptions block (+ its adversarial-review
|
||||
// hardening: host-anchored mode signal, precedence, passing-mention
|
||||
// guards) and the plan-mode preamble reword land the union at 1.092.
|
||||
maxSizeRatio: 1.10,
|
||||
maxSizeRatio: 1.12, // measured 1.103
|
||||
},
|
||||
'plan-design-review': {
|
||||
skill: 'plan-design-review',
|
||||
@@ -198,13 +210,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift (shared first-run-guidance preamble) + #2077 ask-first scope gate.
|
||||
// +~1.3 KB: plan-mode auto-select-B scope-gate exceptions (2026-08).
|
||||
// +~340 B: telemetry --error-message/--failed-step flags + prose in the
|
||||
// shared completion-status preamble (PR #769, 2026-08); this skill was the
|
||||
// closest to its ceiling (landed 89040 / ratio 1.072).
|
||||
maxSkeletonBytes: 89_400,
|
||||
// Fork port wave 2 (D1): evidence directive adds ~0.45KB to every
|
||||
// tier-2+ skeleton (measured 89,184). Main's v1.64.0.0 adds ~340 B more
|
||||
// (telemetry --error-message/--failed-step preamble prose, PR #769).
|
||||
// Budget covers the sum of both waves.
|
||||
maxSkeletonBytes: 91_000,
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['design', 'visual'],
|
||||
maxSizeRatio: 1.08,
|
||||
maxSizeRatio: 1.12, // D1 1.104 + main's ~0.008
|
||||
},
|
||||
'plan-devex-review': {
|
||||
skill: 'plan-devex-review',
|
||||
@@ -221,7 +234,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/destructive prose safety +
|
||||
// continuation protocol in the always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 80_000,
|
||||
// Fork port wave 2 (#703): the repo-doc-preference block in the design
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
maxSkeletonBytes: 82_000, // measured 80,493
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['developer experience', 'Getting Started'],
|
||||
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
|
||||
@@ -244,12 +259,15 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
behavioral: 'prompt',
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble,
|
||||
// plus the P1 office-hours closing handoff (AUQ that launches the next skill).
|
||||
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
|
||||
// the skeleton at 98,193 B; +~1 KB headroom.
|
||||
maxSkeletonBytes: 99_000,
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// Fork port wave 2: the third-party web-actions contract sits inline
|
||||
// (judgment must be visible before the workflow directs the user to a
|
||||
// vendor site), plus the #703 dual-write + repo-doc-preference block and
|
||||
// the #538 opt-out + D1 evidence directive — ratio 1.104 measured.
|
||||
maxSkeletonBytes: 101_000,
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['design doc', 'problem statement'],
|
||||
maxSizeRatio: 1.07,
|
||||
maxSizeRatio: 1.12,
|
||||
},
|
||||
'document-release': {
|
||||
skill: 'document-release',
|
||||
@@ -267,7 +285,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 56_000,
|
||||
maxSkeletonBytes: 56_500, // v1.64+v1.65 merge; measured 56,044
|
||||
minUnionBytes: 55_000,
|
||||
mustContain: ['CHANGELOG', 'Diataxis', 'coverage'],
|
||||
// Two intentional additions stack on this small skill: the AUQ-failure prose
|
||||
@@ -295,6 +313,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
|
||||
// the skeleton at 69,022 B; +~1 KB headroom.
|
||||
maxSkeletonBytes: 70_000,
|
||||
@@ -303,7 +322,8 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB +
|
||||
// the cross-session decision-memory nudge) lands this carved skeleton just over
|
||||
// the strict 1.05; headroom for the shared preamble additions.
|
||||
maxSizeRatio: 1.07,
|
||||
// v1.64+v1.65 merge sums both waves' preamble growth; measured 1.073.
|
||||
maxSizeRatio: 1.08,
|
||||
},
|
||||
cso: {
|
||||
skill: 'cso',
|
||||
@@ -336,13 +356,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
|
||||
// always-loaded AskUserQuestion Format section.
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
maxSkeletonBytes: 75_000,
|
||||
maxSkeletonBytes: 75_800, // v1.64+v1.65 merge; measured 75,364
|
||||
minUnionBytes: 72_000,
|
||||
mustContain: ['OWASP', 'STRIDE', 'daily', 'comprehensive', 'verif'],
|
||||
// cso keeps its mode-dispatch + FP-filtering phases always-loaded, so the
|
||||
// cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB + the
|
||||
// decision-memory nudge) lands it just over 1.05; headroom for the shared additions.
|
||||
maxSizeRatio: 1.07,
|
||||
// v1.64+v1.65 merge sums both waves' preamble growth; measured 1.073.
|
||||
maxSizeRatio: 1.08,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
* tests don't need it).
|
||||
*/
|
||||
|
||||
import { resolveEvalModel } from '../../lib/eval-model';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -459,7 +460,7 @@ ${tail}
|
||||
// below resolveClaudeBinary(), breaking under hermetic PATHs.
|
||||
const result = nodeSpawnSync(
|
||||
resolveClaudeBinary() ?? 'claude',
|
||||
['-p', '--model', 'claude-haiku-4-5', '--max-turns', '1'],
|
||||
['-p', '--model', resolveEvalModel('warmup'), '--max-turns', '1'],
|
||||
{
|
||||
input: prompt,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -1256,7 +1257,15 @@ export const ceoStep0Boundary: Step0BoundaryPredicate = (fp) =>
|
||||
export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
|
||||
/scope reduction recommendation|cross[\s-]?project learnings/i.test(
|
||||
fp.promptSnippet,
|
||||
);
|
||||
) ||
|
||||
// plan-eng-review's Step 0 may legitimately end with NO scope-reduction /
|
||||
// learnings AUQ. When it does, the first answered review-phase question —
|
||||
// tagged <gstack-qid:plan-eng-review-...> ({skill}-{slug} convention) —
|
||||
// must fire the boundary, or every per-finding AUQ stays classified
|
||||
// preReview and the multi-finding batching counter reads 0. Anchor allows
|
||||
// the skill-name prefix; live qids observed: plan-eng-review-jitter,
|
||||
// plan-eng-review-idempotency, plan-eng-review-todos-e2e-concurrent.
|
||||
/gstack-qid:\s*(?:plan-)?eng-review-/i.test(fp.promptSnippet);
|
||||
|
||||
export const designStep0Boundary: Step0BoundaryPredicate = (fp) =>
|
||||
/design system|design posture|design score|first dimension/i.test(
|
||||
|
||||
@@ -214,7 +214,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
|
||||
// codexPreflight() block (install + auth tri-state + CODEX_MODE branch prose),
|
||||
// landing ~6.3% over the v1.53.0.0 baseline. Intentional: it adds proper
|
||||
// not-installed vs not-authed handling, not slop.
|
||||
maxSizeRatio: 1.08,
|
||||
// v1.64+v1.65 merge: both waves grew the shared preamble (evidence
|
||||
// directive + telemetry failure flags); measured 1.094.
|
||||
maxSizeRatio: 1.10,
|
||||
minBytes: 70_000,
|
||||
},
|
||||
{
|
||||
@@ -223,7 +225,11 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
|
||||
mustHaveHeadings: ['## Preamble', '## When to invoke'],
|
||||
// v1.2.0 activation lift: the unified first-run-guidance section (P4 scaffold +
|
||||
// P3 loop tip) is added to every skill's shared preamble — intentional, ~1KB.
|
||||
maxSizeRatio: 1.07,
|
||||
// Fork port wave 2: the shared coverage-audit detection block gained the
|
||||
// multi-ecosystem markers (Django/JVM, script/target/test-file census —
|
||||
// e3259078 port); measured 1.111x. v1.64+v1.65 merge sums both waves'
|
||||
// preamble growth; measured 1.125.
|
||||
maxSizeRatio: 1.13,
|
||||
minBytes: 50_000,
|
||||
},
|
||||
{
|
||||
@@ -237,7 +243,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
|
||||
// 1.09 → 1.10: the plan-mode preamble reword (scope-gate auto-select-B
|
||||
// change) adds ~250 B to every skill's shared preamble; investigate was
|
||||
// the closest to its ceiling (landed 1.092).
|
||||
maxSizeRatio: 1.10,
|
||||
// Fork port wave 2 (D1): the evidence-before-claimed-limitations preamble
|
||||
// directive adds ~0.45KB to every tier-2+ skill. Measured values noted.
|
||||
maxSizeRatio: 1.12, // D1 measured
|
||||
minBytes: 30_000,
|
||||
},
|
||||
{
|
||||
@@ -245,7 +253,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
|
||||
mustContain: ['ceo', 'eng', 'design'],
|
||||
mustHaveHeadings: ['## Preamble', '## When to invoke'],
|
||||
// v1.2.0 activation lift: shared first-run-guidance preamble section.
|
||||
maxSizeRatio: 1.07,
|
||||
// Fork port wave 2 (D1): the evidence-before-claimed-limitations preamble
|
||||
// directive adds ~0.45KB to every tier-2+ skill. Measured values noted.
|
||||
maxSizeRatio: 1.09, // D1 measured
|
||||
minBytes: 70_000,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
|
||||
import { estimateCostUsd } from '../pricing';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { execFileSync, spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -25,11 +25,30 @@ export class ClaudeAdapter implements ProviderAdapter {
|
||||
if (!resolved) {
|
||||
return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code (or set GSTACK_CLAUDE_BIN)' };
|
||||
}
|
||||
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY
|
||||
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY OR (macOS)
|
||||
// the Keychain entry subscription installs use instead of the creds file.
|
||||
// #1890: the default macOS install stores OAuth under the generic-password
|
||||
// service "Claude Code-credentials" and never writes .credentials.json,
|
||||
// so the file-or-env sniff reported "No Claude auth found" while
|
||||
// `claude -p` worked fine. Metadata probe only (no -w — never reads the
|
||||
// secret), and any failure of `security` itself falls through to the
|
||||
// not-found reason rather than throwing.
|
||||
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const hasCreds = fs.existsSync(credsPath);
|
||||
const hasKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
if (!hasCreds && !hasKey) {
|
||||
let hasKeychain = false;
|
||||
if (!hasCreds && !hasKey && process.platform === 'darwin') {
|
||||
try {
|
||||
const probe = spawnSync('security', ['find-generic-password', '-s', 'Claude Code-credentials'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 5000,
|
||||
});
|
||||
hasKeychain = probe.status === 0;
|
||||
} catch {
|
||||
hasKeychain = false;
|
||||
}
|
||||
}
|
||||
if (!hasCreds && !hasKey && !hasKeychain) {
|
||||
return { ok: false, reason: 'No Claude auth found. Log in via `claude` interactive session, or export ANTHROPIC_API_KEY.' };
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* StateServer hardening pins (fork port wave 2, B3 + B5).
|
||||
*
|
||||
* B3: the boot token must never appear in an os_log statement. The daemon
|
||||
* reads it from the 0600 app-container file (copyFileFromAppContainer in
|
||||
* tunnel-bootstrap.ts); the old `token=\(self.bootToken, privacy: .public)`
|
||||
* announce line had NO consumer and handed a live credential to anything
|
||||
* reading the unified log during the launch window.
|
||||
*
|
||||
* B5: the IPv4 listener has no CoreDevice tunnel path, so it must bind to
|
||||
* loopback at the socket level (requiredLocalEndpoint 127.0.0.1), not rely
|
||||
* solely on the per-connection peer check. IPv6 keeps the wildcard bind for
|
||||
* CoreDevice ULA peers by design.
|
||||
*
|
||||
* Pinned on BOTH the generated template and the fixture app copy so neither
|
||||
* can drift back independently.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const COPIES = [
|
||||
"ios-qa/templates/StateServer.swift.template",
|
||||
"test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift",
|
||||
];
|
||||
|
||||
describe.each(COPIES)("StateServer hardening — %s", (rel) => {
|
||||
const src = readFileSync(join(ROOT, rel), "utf-8");
|
||||
|
||||
test("no os_log statement interpolates the boot token (B3)", () => {
|
||||
const logLines = src.split("\n").filter((l) => /logger\.(notice|info|error|debug|log)/.test(l));
|
||||
for (const line of logLines) {
|
||||
expect(line).not.toContain("bootToken");
|
||||
}
|
||||
// The bootstrap announce survives (diagnostics), token-free.
|
||||
expect(src).toContain('gstack-ios-qa-bootstrap port=');
|
||||
expect(src).not.toContain("gstack-ios-qa-bootstrap token=");
|
||||
});
|
||||
|
||||
test("IPv4 listener binds loopback at the socket level (B5)", () => {
|
||||
expect(src).toContain("requiredLocalEndpoint");
|
||||
expect(src).toContain('NWEndpoint.Host("127.0.0.1")');
|
||||
// IPv6 wildcard + peer-check path must survive (CoreDevice tunnel peers).
|
||||
expect(src).toMatch(/case \.ipv6:\s*\n\s*listener = try NWListener\(using: params, on:/);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync, realpathSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
@@ -45,11 +45,70 @@ describe("gstack-memory-ingest: gbrain import must not be filtered by .gitignore
|
||||
const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8"));
|
||||
// Match the spawn call's argument array and assert both the subcommand
|
||||
// and the flag live in it, so the flag can't drift onto another call.
|
||||
const call = stripped.match(/spawnGbrainAsync\(\s*\[[^\]]*"import"[^\]]*\]/s);
|
||||
// [\s\S]*? bridges the conditional-spread's nested brackets (main's
|
||||
// capability-probed --include-gitignored merged with our baseEnv defense).
|
||||
const call = stripped.match(/spawnGbrainAsync\(\s*\[[\s\S]*?"import"[\s\S]*?\]/s);
|
||||
expect(call).not.toBeNull();
|
||||
expect(call![0]).toContain("--include-gitignored");
|
||||
});
|
||||
|
||||
it("sets a realpath'd GIT_CEILING_DIRECTORIES on the import child (defense-in-depth)", () => {
|
||||
const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8"));
|
||||
// Second #2144 layer: the ceiling env must be built from the staging
|
||||
// dir's REAL parent path and merged into the spawn's baseEnv, so a
|
||||
// git-enumerating collector fails out of the git fast path even when
|
||||
// the flag's semantics drift, and symlinked staging paths still match.
|
||||
expect(stripped).toContain("GIT_CEILING_DIRECTORIES");
|
||||
expect(stripped).toMatch(/realpathSync\(dirname\(stagingDir\)\)/);
|
||||
const call = stripped.match(/spawnGbrainAsync\(\s*\[[\s\S]*?"import"[\s\S]*?\]\s*,\s*\{\s*baseEnv\s*\}/s);
|
||||
expect(call).not.toBeNull();
|
||||
});
|
||||
|
||||
it("proves the ceiling stops git discovery from the staging dir — including through a symlink", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-ceiling-"));
|
||||
try {
|
||||
const git = (args: string[], cwd: string, env?: NodeJS.ProcessEnv) =>
|
||||
execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
// ~/.gstack shape: a git repo whose root ignores everything, with the
|
||||
// staging dir as a direct child.
|
||||
const home = join(dir, "gstack-home");
|
||||
mkdirSync(home, { recursive: true });
|
||||
git(["init", "-q", "."], home);
|
||||
writeFileSync(join(home, ".gitignore"), "*\n", "utf-8");
|
||||
const staging = join(home, ".staging-ingest-12345-1700000000000");
|
||||
mkdirSync(staging, { recursive: true });
|
||||
|
||||
// Without a ceiling: discovery from the staging dir finds the repo —
|
||||
// this is the git fast path that collects zero files.
|
||||
const found = git(["rev-parse", "--show-toplevel"], staging).trim();
|
||||
expect(realpathSync(found)).toBe(realpathSync(home));
|
||||
|
||||
// With the ceiling at the staging dir's REAL parent: discovery fails,
|
||||
// which is exactly what pushes a collector onto its plain FS walk.
|
||||
const ceiling = realpathSync(home);
|
||||
expect(() =>
|
||||
git(["rev-parse", "--show-toplevel"], staging, { GIT_CEILING_DIRECTORIES: ceiling }),
|
||||
).toThrow();
|
||||
|
||||
// Symlink variant (the OV4 trap): reach the same staging dir through a
|
||||
// symlinked path. A realpath'd ceiling still stops discovery.
|
||||
const linked = join(dir, "linked-home");
|
||||
symlinkSync(home, linked);
|
||||
const stagingViaLink = join(linked, ".staging-ingest-12345-1700000000000");
|
||||
expect(() =>
|
||||
git(["rev-parse", "--show-toplevel"], stagingViaLink, {
|
||||
GIT_CEILING_DIRECTORIES: ceiling,
|
||||
}),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("demonstrates the collision: an ignore-everything root hides staged pages", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-gitignore-"));
|
||||
try {
|
||||
|
||||
@@ -71,7 +71,11 @@ function run(extraEnv: Record<string, string> = {}, input = ''): { code: number;
|
||||
PATH: `${fakeBinDir}:${path.join(ROOT, 'bin')}:/usr/bin:/bin:/opt/homebrew/bin`,
|
||||
HOME: tmpHome,
|
||||
USER: 'testuser',
|
||||
// Disable interactive prompt: empty stdin = treat as non-interactive.
|
||||
// Empty stdin = non-interactive. Since #1383 the script SKIPS by
|
||||
// default in that context (a remote repo rename is consent-shaped);
|
||||
// the harness opts in explicitly, simulating a consenting unattended
|
||||
// run. The default-skip contract has its own test below.
|
||||
GSTACK_MIGRATE_ASSUME_YES: '1',
|
||||
...extraEnv,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
@@ -168,6 +172,67 @@ describe('v1.27.0.0 migration — GitHub host (non-interactive)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1.27.0.0 migration — #1383 consent + failure-stays-pending contract', () => {
|
||||
beforeEach(() => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.gstack-brain-remote.txt'),
|
||||
'https://github.com/testuser/gstack-brain-testuser\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.gstack/config.yaml'),
|
||||
'gbrain_sync_mode: full\n'
|
||||
);
|
||||
makeFakeGh({});
|
||||
});
|
||||
|
||||
test('non-interactive without opt-in: skips for now, touches NOTHING', () => {
|
||||
const r = run({ GSTACK_MIGRATE_ASSUME_YES: '0' });
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stderr).toContain('skipping for now');
|
||||
expect(r.stderr).toContain('GSTACK_MIGRATE_ASSUME_YES=1');
|
||||
// The remediation must be REAL: a direct invocation of this script.
|
||||
// `/setup-gbrain --rerun-migration` never existed, and the runners'
|
||||
// version windows never re-select a passed migration, so "will ask
|
||||
// again next upgrade" was false.
|
||||
expect(r.stderr).toContain('v1.27.0.0.sh');
|
||||
expect(r.stderr).not.toContain('--rerun-migration');
|
||||
expect(r.stderr).not.toContain('ask again');
|
||||
// Old state untouched, nothing recorded as done.
|
||||
expect(fs.existsSync(path.join(tmpHome, '.gstack-brain-remote.txt'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.gstack-artifacts-remote.txt'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(false);
|
||||
});
|
||||
|
||||
test('gh rename failure: step stays pending, migration exits INCOMPLETE, retry succeeds', () => {
|
||||
makeFakeGh({ renameSucceeds: false });
|
||||
const r = run();
|
||||
// Failure must be loud and the migration visibly incomplete — the old
|
||||
// behavior marked the failed step done and wrote the done touchfile,
|
||||
// permanently stranding a half-renamed install (#1383).
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('PENDING');
|
||||
expect(r.stderr).toContain('INCOMPLETE');
|
||||
// Honest remediation: direct invocation, not the nonexistent
|
||||
// /setup-gbrain --rerun-migration flag.
|
||||
expect(r.stderr).toContain('Re-run manually with:');
|
||||
expect(r.stderr).toContain('GSTACK_MIGRATE_ASSUME_YES=1');
|
||||
expect(r.stderr).not.toContain('--rerun-migration');
|
||||
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(false);
|
||||
const journal = fs.readFileSync(
|
||||
path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.journal'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(journal).not.toContain('gh_repo_renamed');
|
||||
// Later steps DID run and are journaled (independent of step 1)...
|
||||
expect(journal).toContain('remote_txt_renamed');
|
||||
// ...so a retry with working gh only redoes step 1 and completes.
|
||||
makeFakeGh({});
|
||||
const r2 = run();
|
||||
expect(r2.code).toBe(0);
|
||||
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1.27.0.0 migration — interruption resume', () => {
|
||||
beforeEach(() => {
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* v1.65.0.0 migration — remove rebrand-poisoned Chromium bundles (#2242)
|
||||
* and re-fetch a clean one, gating .done on a VERIFIED end state.
|
||||
*
|
||||
* Exercises the migration in a hermetic temp HOME + temp Playwright cache
|
||||
* (HOME / GSTACK_HOME / PLAYWRIGHT_BROWSERS_PATH all redirected) with a
|
||||
* stubbed bunx on PATH — nothing touches the real cache. Covers:
|
||||
* - whole-revision-dir removal (INSTALLATION_COMPLETE marker included, so
|
||||
* `playwright install` can't no-op with "is already downloaded")
|
||||
* - clean bundles untouched
|
||||
* - re-fetch runs from the gstack install root (repo-pinned playwright)
|
||||
* - .done only written once a Chromium executable verifiably exists
|
||||
* - failed re-fetch → warning + retry on next run (needs-refetch sentinel)
|
||||
* - stranded revision dir (markers without .app) re-triggers the re-fetch
|
||||
* - idempotent re-run after success
|
||||
* - non-Darwin early exit
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.65.0.0.sh');
|
||||
|
||||
let tmpHome: string;
|
||||
let fakeBinDir: string;
|
||||
let pwCache: string;
|
||||
|
||||
const POISONED_PLIST =
|
||||
'<plist><dict><key>CFBundleName</key><string>GStack Browser</string></dict></plist>';
|
||||
const CLEAN_PLIST =
|
||||
'<plist><dict><key>CFBundleName</key><string>Google Chrome for Testing</string></dict></plist>';
|
||||
|
||||
/** Build a Playwright-cache-shaped bundle: <revDir>/chrome-mac/<name>.app */
|
||||
function makeBundle(
|
||||
revDir: string,
|
||||
opts: { plist: string; withExecutable?: boolean; withMarkers?: boolean }
|
||||
): string {
|
||||
const appDir = path.join(revDir, 'chrome-mac', 'Google Chrome for Testing.app');
|
||||
const contents = path.join(appDir, 'Contents');
|
||||
fs.mkdirSync(contents, { recursive: true });
|
||||
fs.writeFileSync(path.join(contents, 'Info.plist'), opts.plist);
|
||||
if (opts.withExecutable) {
|
||||
const macos = path.join(contents, 'MacOS');
|
||||
fs.mkdirSync(macos, { recursive: true });
|
||||
fs.writeFileSync(path.join(macos, 'Google Chrome for Testing'), '#!/bin/sh\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
}
|
||||
if (opts.withMarkers ?? true) {
|
||||
fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), '');
|
||||
fs.writeFileSync(path.join(revDir, 'DEPENDENCIES_VALIDATED'), '');
|
||||
}
|
||||
return appDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub bunx: records every invocation (args + cwd) and, unless told
|
||||
* otherwise, simulates a successful `playwright install chromium` by
|
||||
* creating a fresh revision dir with an executable in the temp cache.
|
||||
*/
|
||||
function makeFakeBunx(opts: { createsExecutable?: boolean } = {}): string {
|
||||
const creates = opts.createsExecutable ?? true;
|
||||
const callLog = path.join(fakeBinDir, 'bunx-calls.log');
|
||||
const script = `#!/bin/bash
|
||||
echo "bunx $@ (pwd=$(pwd))" >> "${callLog}"
|
||||
${
|
||||
creates
|
||||
? `FRESH="\${PLAYWRIGHT_BROWSERS_PATH}/chromium-9999/chrome-mac/Chromium.app/Contents/MacOS"
|
||||
mkdir -p "\${FRESH}"
|
||||
printf '#!/bin/sh\\n' > "\${FRESH}/Chromium"
|
||||
chmod 755 "\${FRESH}/Chromium"
|
||||
touch "\${PLAYWRIGHT_BROWSERS_PATH}/chromium-9999/INSTALLATION_COMPLETE"`
|
||||
: '# simulates an offline / failed download: no files created'
|
||||
}
|
||||
exit 0
|
||||
`;
|
||||
fs.writeFileSync(path.join(fakeBinDir, 'bunx'), script, { mode: 0o755 });
|
||||
return callLog;
|
||||
}
|
||||
|
||||
function run(extraEnv: Record<string, string> = {}): {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
} {
|
||||
const r = spawnSync(MIGRATION, [], {
|
||||
env: {
|
||||
PATH: `${fakeBinDir}:/usr/bin:/bin`,
|
||||
HOME: tmpHome,
|
||||
GSTACK_HOME: path.join(tmpHome, '.gstack'),
|
||||
PLAYWRIGHT_BROWSERS_PATH: pwCache,
|
||||
...extraEnv,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpHome,
|
||||
});
|
||||
return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
const doneFile = () => path.join(tmpHome, '.gstack', '.migrations', 'v1.65.0.0.done');
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.65-'));
|
||||
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.65-fake-'));
|
||||
pwCache = path.join(tmpHome, 'pw-cache', 'ms-playwright');
|
||||
fs.mkdirSync(pwCache, { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpHome, '.gstack'), { recursive: true });
|
||||
// The migration gates on `uname -s` = Darwin; on the Linux CI runner the
|
||||
// real uname made every test early-exit with empty output. Shim Darwin so
|
||||
// the Darwin-path tests run everywhere; the non-Darwin test overwrites
|
||||
// this shim with its own Linux uname.
|
||||
fs.writeFileSync(path.join(fakeBinDir, 'uname'), '#!/bin/bash\necho Darwin\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('v1.65.0.0 migration — poisoned bundle removal', () => {
|
||||
test('poisoned revision dir removed WHOLE (markers included); clean bundle untouched; verified re-fetch → .done', () => {
|
||||
const poisonedRev = path.join(pwCache, 'chromium-1234');
|
||||
makeBundle(poisonedRev, { plist: POISONED_PLIST });
|
||||
const cleanRev = path.join(pwCache, 'chromium-5678');
|
||||
const cleanApp = makeBundle(cleanRev, { plist: CLEAN_PLIST, withExecutable: true });
|
||||
const log = makeFakeBunx();
|
||||
|
||||
const r = run();
|
||||
expect(r.code).toBe(0);
|
||||
|
||||
// The WHOLE revision dir is gone — .app AND the INSTALLATION_COMPLETE /
|
||||
// DEPENDENCIES_VALIDATED markers. Removing only the .app leaves markers
|
||||
// that make `playwright install chromium` no-op ("is already
|
||||
// downloaded"), stranding the user with NO browser + a success message.
|
||||
expect(fs.existsSync(poisonedRev)).toBe(false);
|
||||
|
||||
// Clean bundle untouched.
|
||||
expect(fs.existsSync(path.join(cleanApp, 'Contents', 'Info.plist'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(cleanRev, 'INSTALLATION_COMPLETE'))).toBe(true);
|
||||
|
||||
// Re-fetch invoked...
|
||||
const calls = fs.readFileSync(log, 'utf-8');
|
||||
expect(calls).toContain('playwright install chromium');
|
||||
// ...from the gstack install root (repo-pinned playwright version), not
|
||||
// from the arbitrary cwd the migration runner happened to use.
|
||||
const realRoot = fs.realpathSync(ROOT);
|
||||
expect(calls.includes(`pwd=${ROOT}`) || calls.includes(`pwd=${realRoot}`)).toBe(true);
|
||||
|
||||
// Verified end state → .done written.
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
});
|
||||
|
||||
test('re-fetch produces no executable → WARNING, .done NOT written; next run retries and completes', () => {
|
||||
makeBundle(path.join(pwCache, 'chromium-1234'), { plist: POISONED_PLIST });
|
||||
const log = makeFakeBunx({ createsExecutable: false });
|
||||
|
||||
const r = run();
|
||||
expect(r.code).toBe(0); // non-fatal per the migration contract
|
||||
expect(r.stderr).toContain('WARNING');
|
||||
expect(r.stderr).toContain('bunx playwright install chromium');
|
||||
// Removal succeeded but the end state is "no browser": .done must NOT
|
||||
// be written so a re-run retries instead of recording success.
|
||||
expect(fs.existsSync(doneFile())).toBe(false);
|
||||
|
||||
// Re-run with a working fetch: the pending state re-triggers the
|
||||
// re-fetch even though the scans have nothing left to remove.
|
||||
fs.rmSync(log, { force: true });
|
||||
makeFakeBunx({ createsExecutable: true });
|
||||
const r2 = run();
|
||||
expect(r2.code).toBe(0);
|
||||
expect(fs.readFileSync(log, 'utf-8')).toContain('playwright install chromium');
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
});
|
||||
|
||||
test('stranded revision dir (install markers, no .app) re-triggers removal + re-fetch', () => {
|
||||
// The state an older version of this migration could leave behind:
|
||||
// .app removed, INSTALLATION_COMPLETE still present → `playwright
|
||||
// install` no-ops while the user has no browser at all.
|
||||
const strandedRev = path.join(pwCache, 'chromium-1234');
|
||||
fs.mkdirSync(path.join(strandedRev, 'chrome-mac'), { recursive: true });
|
||||
fs.writeFileSync(path.join(strandedRev, 'INSTALLATION_COMPLETE'), '');
|
||||
const log = makeFakeBunx();
|
||||
|
||||
const r = run();
|
||||
expect(r.code).toBe(0);
|
||||
expect(fs.existsSync(strandedRev)).toBe(false);
|
||||
expect(fs.readFileSync(log, 'utf-8')).toContain('playwright install chromium');
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
});
|
||||
|
||||
test('clean cache → no-op, no bunx call, .done written', () => {
|
||||
const cleanRev = path.join(pwCache, 'chromium-5678');
|
||||
const cleanApp = makeBundle(cleanRev, { plist: CLEAN_PLIST, withExecutable: true });
|
||||
const log = makeFakeBunx();
|
||||
|
||||
const r = run();
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stderr).toContain('no-op');
|
||||
expect(fs.existsSync(log)).toBe(false); // bunx never invoked
|
||||
expect(fs.existsSync(path.join(cleanApp, 'Contents', 'Info.plist'))).toBe(true);
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
});
|
||||
|
||||
test('second run after success → silent no-op (no rescan, no bunx)', () => {
|
||||
makeBundle(path.join(pwCache, 'chromium-1234'), { plist: POISONED_PLIST });
|
||||
const log = makeFakeBunx();
|
||||
const r1 = run();
|
||||
expect(r1.code).toBe(0);
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
|
||||
fs.rmSync(log, { force: true });
|
||||
const r2 = run();
|
||||
expect(r2.code).toBe(0);
|
||||
expect(r2.stderr).toBe('');
|
||||
expect(fs.existsSync(log)).toBe(false);
|
||||
});
|
||||
|
||||
test('non-Darwin → early exit, cache untouched, no bunx, .done written', () => {
|
||||
const poisonedApp = makeBundle(path.join(pwCache, 'chromium-1234'), {
|
||||
plist: POISONED_PLIST,
|
||||
});
|
||||
makeFakeBunx();
|
||||
// The script gates on `uname -s` != Darwin; shadow uname on PATH.
|
||||
fs.writeFileSync(path.join(fakeBinDir, 'uname'), '#!/bin/bash\necho Linux\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const r = run();
|
||||
expect(r.code).toBe(0);
|
||||
expect(fs.existsSync(poisonedApp)).toBe(true); // nothing removed
|
||||
expect(fs.existsSync(path.join(fakeBinDir, 'bunx-calls.log'))).toBe(false);
|
||||
expect(fs.existsSync(doneFile())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Regression pin for #2091: /codex was broken on every macOS install because
|
||||
* its mktemp templates carried a suffix after the X's ("codex-err-XXXXXX.txt").
|
||||
* BSD mktemp (macOS) requires the X's to be the trailing characters of the
|
||||
* template; with a suffix it fails ("mkstemp failed ... File exists"), the
|
||||
* temp file never exists, and the skill dies before Codex ever runs.
|
||||
*
|
||||
* GNU mktemp accepts a --suffix flag but ALSO rejects inline suffixes in the
|
||||
* template argument on BusyBox, so the portable form is: X's last, no suffix.
|
||||
*
|
||||
* This scans every .tmpl (the sources of truth — generated SKILL.md files
|
||||
* follow at regen time) for the broken shape.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
|
||||
function trackedTmplFiles(): string[] {
|
||||
const out = execFileSync("git", ["ls-files", "*.tmpl", "**/*.tmpl"], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
return out.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
describe("mktemp portability (#2091)", () => {
|
||||
it("no .tmpl file uses a mktemp template with characters after XXXXXX", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const rel of trackedTmplFiles()) {
|
||||
const src = readFileSync(join(ROOT, rel), "utf-8");
|
||||
src.split("\n").forEach((line, i) => {
|
||||
// Broken shape: the X-run followed by a non-quote, non-whitespace,
|
||||
// non-closing character inside a mktemp invocation. X{6,} is greedy,
|
||||
// so longer X-runs (spec's XXXXXXXX) stay valid — only a genuine
|
||||
// suffix after the final X trips it.
|
||||
if (/mktemp[^\n]*X{6,}[^"'\s)X]/.test(line)) {
|
||||
offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("BSD-portable form actually works on this platform", () => {
|
||||
// Live sanity: the exact template shape the skills now emit.
|
||||
const tmp = process.env.TMPDIR || "/tmp";
|
||||
const created = execFileSync("mktemp", [`${tmp.replace(/\/$/, "")}/gstack-portability-XXXXXX`], {
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
expect(created.length).toBeGreaterThan(0);
|
||||
execFileSync("rm", ["-f", created]);
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,12 @@ const SCAN_PATHS = [
|
||||
'review/SKILL.md.tmpl',
|
||||
'ship/SKILL.md.tmpl',
|
||||
'test/',
|
||||
// Fork port wave 2: docs were deliberately excluded and that exact gap let
|
||||
// a dead command (gstack-brain-init) survive ~36 releases as a
|
||||
// command-not-found instruction. Docs are user-facing surface; scan them.
|
||||
'docs/',
|
||||
'README.md',
|
||||
'USING_GBRAIN_WITH_GSTACK.md',
|
||||
];
|
||||
|
||||
function grepRefs(pattern: string): string[] {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* R2 pin (fork port wave 2): the Apple release adapter loads BEFORE ship's
|
||||
* branch gate, and the non-Apple gate is byte-unchanged.
|
||||
*
|
||||
* Two failure modes this prevents: a future ship-template refactor that
|
||||
* re-blocks store releases behind "ship from a feature branch" (the exact
|
||||
* live failure the fork hit — a solo dev with a clean tree on main shipping
|
||||
* to TestFlight got aborted over branch topology), and the reverse — the
|
||||
* Apple path accidentally weakening the branch gate for normal
|
||||
* repository-landing ships.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const SKELETON = readFileSync(join(ROOT, "ship", "SKILL.md"), "utf-8");
|
||||
|
||||
const GATE_TEXT =
|
||||
'If on the base branch or the repo\'s default branch, **abort**: "You\'re on the base branch. Ship from a feature branch."';
|
||||
|
||||
describe("ship Apple gate ordering (R2)", () => {
|
||||
test("the Apple adapter read directive precedes the branch gate", () => {
|
||||
const appleRead = SKELETON.indexOf("sections/apple-release.md");
|
||||
const gate = SKELETON.indexOf(GATE_TEXT);
|
||||
expect(appleRead).toBeGreaterThan(-1);
|
||||
expect(gate).toBeGreaterThan(-1);
|
||||
expect(appleRead).toBeLessThan(gate);
|
||||
});
|
||||
|
||||
test("store distribution explicitly bypasses the branch/PR ceremony", () => {
|
||||
expect(SKELETON).toContain("Store distribution proceeds");
|
||||
expect(SKELETON).toMatch(/branch gate and repository-landing pipeline below apply ONLY to\s*\n?repository-landing asks/);
|
||||
});
|
||||
|
||||
test("the non-Apple branch gate is byte-unchanged and appears exactly once", () => {
|
||||
const first = SKELETON.indexOf(GATE_TEXT);
|
||||
expect(first).toBeGreaterThan(-1);
|
||||
expect(SKELETON.indexOf(GATE_TEXT, first + 1)).toBe(-1);
|
||||
});
|
||||
|
||||
test("the adapter section exists in the union with its battle-tested spine", () => {
|
||||
const section = readFileSync(join(ROOT, "ship", "sections", "apple-release.md"), "utf-8");
|
||||
for (const anchor of [
|
||||
"one authorization moment",
|
||||
"fastlane spaceauth",
|
||||
"iris/v1/apiKeys",
|
||||
"appPriceSchedules",
|
||||
"CLASSIFY the error before touching credentials",
|
||||
"Never abort an App Store release over branch topology",
|
||||
]) {
|
||||
expect(section).toContain(anchor);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Regression: /ship Step 4 test-framework detection was blind to Django.
|
||||
*
|
||||
* A real Django project (manage.py + <app>/tests.py, green `python manage.py
|
||||
* test`, no pytest.ini and no tests/ directory) read as "no test framework",
|
||||
* so /ship bootstrapped pytest on top of a working suite. Same blindness hit
|
||||
* any config-less-but-tested project (Go *_test.go, in-source Rust #[test],
|
||||
* package.json with only a test script).
|
||||
*
|
||||
* These run the resolver's OWN emitted detection block against fixtures, so
|
||||
* the shell is checked, not just the prose — and the test is independent of
|
||||
* generated-SKILL.md regen state.
|
||||
*
|
||||
* Ported from time-attack/gstack commit e3259078 (GStack 2), adapted from the
|
||||
* fork's generated-markdown target to our resolver source of truth.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { generateTestBootstrap } from '../scripts/resolvers/testing';
|
||||
|
||||
const section = generateTestBootstrap({} as never);
|
||||
|
||||
/** The detection block the skill tells the agent to run (first bash fence). */
|
||||
function detectionScript(): string {
|
||||
const open = section.indexOf('```bash\n');
|
||||
expect(open).toBeGreaterThan(-1);
|
||||
const start = open + '```bash\n'.length;
|
||||
const end = section.indexOf('```', start);
|
||||
return section.slice(start, end);
|
||||
}
|
||||
|
||||
/** Run the detection block in a throwaway git repo laid out by `files`. */
|
||||
function detect(files: Record<string, string>): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-detect-'));
|
||||
try {
|
||||
for (const [rel, body] of Object.entries(files)) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body);
|
||||
}
|
||||
const script = path.join(dir, '.detect.sh');
|
||||
fs.writeFileSync(script, detectionScript());
|
||||
const git = (...args: string[]) => execFileSync('git', args, { cwd: dir });
|
||||
git('init', '-q', '.');
|
||||
git('add', '-A');
|
||||
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture');
|
||||
// The block's last line is a `[ -f marker ] && echo`, so a clean project
|
||||
// exits 1 by design — read stdout, don't trust the status.
|
||||
return execFileSync('bash', [script], { cwd: dir, encoding: 'utf-8' });
|
||||
} catch (err: unknown) {
|
||||
const e = err as { stdout?: string };
|
||||
if (typeof e.stdout === 'string') return e.stdout;
|
||||
throw err;
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const testFileCount = (out: string): number =>
|
||||
Number(/^TESTFILES:(\d+)$/m.exec(out)?.[1] ?? -1);
|
||||
|
||||
describe('/ship Step 4 detection is multi-ecosystem', () => {
|
||||
test('Django project reports manage.py and its existing tests', () => {
|
||||
const out = detect({
|
||||
'manage.py': '#!/usr/bin/env python\n',
|
||||
'requirements.txt': 'Django==5.0\n',
|
||||
'polls/tests.py': 'from django.test import TestCase\n',
|
||||
});
|
||||
expect(out).toContain('MARKER:manage.py');
|
||||
expect(out).toContain('FRAMEWORK:django');
|
||||
expect(testFileCount(out)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('config-less projects with tests still report test evidence', () => {
|
||||
expect(testFileCount(detect({
|
||||
'go.mod': 'module x\n',
|
||||
'x_test.go': 'package x\n',
|
||||
}))).toBeGreaterThan(0);
|
||||
|
||||
const node = detect({
|
||||
'package.json': '{"name":"n","scripts":{"test":"node --test"}}\n',
|
||||
});
|
||||
expect(node).toContain('SCRIPT:package.json test');
|
||||
|
||||
const rust = detect({
|
||||
'Cargo.toml': '[package]\nname="x"\n',
|
||||
'src/lib.rs': '#[cfg(test)]\nmod t{ #[test] fn x(){} }\n',
|
||||
});
|
||||
expect(rust).toContain('TESTS:rust in-source');
|
||||
});
|
||||
|
||||
test('a genuinely untested project reports no test evidence', () => {
|
||||
const out = detect({ 'go.mod': 'module x\n', 'x.go': 'package x\n' });
|
||||
expect(out).toContain('RUNTIME:go');
|
||||
expect(testFileCount(out)).toBe(0);
|
||||
expect(out).not.toContain('TESTS:');
|
||||
});
|
||||
|
||||
test('every ecosystem marker maps to a command to OFFER, not to run blind', () => {
|
||||
expect(section).toContain('never a command to run blind');
|
||||
for (const marker of ['manage.py', 'go.mod', 'Cargo.toml', 'pom.xml',
|
||||
'build.gradle', 'mix.exs', 'composer.json', 'Gemfile', 'pytest.ini']) {
|
||||
expect(section).toContain(marker);
|
||||
}
|
||||
// The ask-and-persist contract, not a hardcoded project command.
|
||||
expect(section).toContain('AskUserQuestion');
|
||||
expect(section).toContain('persist the answer to CLAUDE.md');
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,7 @@ describeE2E('AUTO_DECIDE opt-in preserved under Conductor flags (periodic)', ()
|
||||
skillName: 'plan-ceo-review',
|
||||
inPlanMode: true,
|
||||
extraArgs: ['--disallowedTools', 'AskUserQuestion'],
|
||||
timeoutMs: 300_000,
|
||||
timeoutMs: 540_000,
|
||||
env: { GSTACK_HOME: tmpHome, CONDUCTOR_WORKSPACE_PATH: tmpHome },
|
||||
});
|
||||
|
||||
@@ -135,5 +135,5 @@ describeE2E('AUTO_DECIDE opt-in preserved under Conductor flags (periodic)', ()
|
||||
} finally {
|
||||
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
}, 360_000);
|
||||
}, 660_000);
|
||||
});
|
||||
@@ -34,6 +34,12 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
// Stage a fresh GSTACK_HOME with artifacts_sync_mode_prompted=false.
|
||||
const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-gstack-'));
|
||||
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-bin-'));
|
||||
// Fresh HOME with NO ~/.claude.json: on a machine where gbrain is
|
||||
// registered type=http, the preamble's remote-mode detection reads the
|
||||
// operator's ~/.claude.json and echoes "ARTIFACTS_SYNC: remote-mode" —
|
||||
// and the local privacy gate legitimately never fires. An empty HOME
|
||||
// makes the detection find nothing.
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-home-'));
|
||||
|
||||
// Seed the config so the gate's condition passes.
|
||||
fs.writeFileSync(
|
||||
@@ -59,12 +65,16 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
|
||||
const binary = resolveClaudeBinary();
|
||||
|
||||
// Ambient env mutations — restored in finally so other tests in the file
|
||||
// don't inherit them.
|
||||
const origGstackHome = process.env.GSTACK_HOME;
|
||||
const origPath = process.env.PATH;
|
||||
process.env.GSTACK_HOME = gstackHome;
|
||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
|
||||
// Per-test env, merged LAST by the hermetic env builder (safe post-v1.39:
|
||||
// the runner always passes a COMPLETE hermetic env, so overrides can't
|
||||
// break auth). Ambient process.env.GSTACK_HOME mutation does NOT work
|
||||
// here — hermetic-env scrubs GSTACK_* and repoints GSTACK_HOME at its
|
||||
// own singleton dir, so the staged config would never reach the child.
|
||||
const childEnv = {
|
||||
GSTACK_HOME: gstackHome,
|
||||
HOME: tempHome,
|
||||
PATH: `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
|
||||
};
|
||||
|
||||
try {
|
||||
// Pick a small skill with the preamble and load it via Read to force
|
||||
@@ -85,12 +95,7 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
workingDirectory: gstackHome,
|
||||
maxTurns: 10,
|
||||
allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
|
||||
// NOTE: do NOT pass `env:` here. When the Agent SDK gets an explicit
|
||||
// env object, its auth pipeline doesn't pick up ANTHROPIC_API_KEY the
|
||||
// same way as when env is undefined (SDK-internal detail, verified
|
||||
// against the plan-mode-no-op test which passes no env and auths
|
||||
// cleanly). Instead, mutate process.env before the call so the SDK
|
||||
// inherits our overrides ambiently.
|
||||
env: childEnv,
|
||||
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
|
||||
canUseTool: async (toolName, input) => {
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
@@ -141,13 +146,9 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
// (The preamble is supposed to be idempotent within a session.)
|
||||
expect(privacyQuestions.length).toBe(1);
|
||||
} finally {
|
||||
// Restore ambient env before other tests.
|
||||
if (origGstackHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = origGstackHome;
|
||||
if (origPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = origPath;
|
||||
fs.rmSync(gstackHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
@@ -155,6 +156,10 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
// Same staging, but prompted=true this time. Gate should be silent.
|
||||
const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-'));
|
||||
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-bin-'));
|
||||
// Fresh HOME without a .claude.json — same rationale as the first test:
|
||||
// without it the operator's ~/.claude.json flips the preamble into
|
||||
// remote-mode and this negative test passes vacuously.
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-home-'));
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(gstackHome, 'config.yaml'),
|
||||
@@ -171,11 +176,13 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
|
||||
const binary = resolveClaudeBinary();
|
||||
|
||||
// Ambient env mutations (see note on the first test).
|
||||
const origGstackHome = process.env.GSTACK_HOME;
|
||||
const origPath = process.env.PATH;
|
||||
process.env.GSTACK_HOME = gstackHome;
|
||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
|
||||
// Per-test env, merged LAST by the hermetic env builder (see note on the
|
||||
// first test — ambient GSTACK_HOME mutation is scrubbed by hermetic-env).
|
||||
const childEnv = {
|
||||
GSTACK_HOME: gstackHome,
|
||||
HOME: tempHome,
|
||||
PATH: `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
|
||||
};
|
||||
|
||||
try {
|
||||
await runAgentSdkTest({
|
||||
@@ -185,6 +192,7 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
workingDirectory: gstackHome,
|
||||
maxTurns: 4,
|
||||
allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
|
||||
env: childEnv,
|
||||
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
|
||||
canUseTool: async (toolName, input) => {
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
@@ -216,12 +224,9 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
});
|
||||
expect(privacyQuestions.length).toBe(0);
|
||||
} finally {
|
||||
if (origGstackHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = origGstackHome;
|
||||
if (origPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = origPath;
|
||||
fs.rmSync(gstackHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -447,8 +447,13 @@ Do NOT use AskUserQuestion.`,
|
||||
// Match by filename timestamp (stable, unambiguous) plus a looser
|
||||
// prose check.
|
||||
const showsMain = /20260101-120000|main-work/.test(out);
|
||||
const hidesAlpha = !/20260202-120000/.test(out);
|
||||
const hidesBeta = !/20260303-120000/.test(out);
|
||||
// Hide checks scope to the FINAL text output only: fullOutputSurface
|
||||
// includes bash tool_results, and a legitimate `ls` of the checkpoint
|
||||
// dir lists every branch's filename. The filtering under test happens
|
||||
// in the user-facing list, not in the agent's intermediate reads.
|
||||
const finalText = result.output ?? '';
|
||||
const hidesAlpha = !/20260202-120000|LISTCURR_ALPHA_TOKEN/.test(finalText);
|
||||
const hidesBeta = !/20260303-120000|LISTCURR_BETA_TOKEN/.test(finalText);
|
||||
const routed = skillCalls(result).includes('context-save');
|
||||
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
|
||||
'Color': ['color', 'colour', 'palette', 'colors'],
|
||||
'Spacing': ['spacing', 'space', 'whitespace', 'gap'],
|
||||
'Layout': ['layout', 'grid', 'structure', 'composition'],
|
||||
'Motion': ['motion', 'animation', 'transition', 'movement'],
|
||||
'Motion': ['motion', 'animation', 'transition', 'movement', 'easing', 'duration', 'micro-interaction'],
|
||||
};
|
||||
const missingSections = Object.entries(sectionSynonyms).filter(
|
||||
([_, synonyms]) => !synonyms.some(s => designContent.toLowerCase().includes(s))
|
||||
@@ -152,7 +152,9 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
|
||||
expect(['success', 'error_max_turns']).toContain(result.exitReason);
|
||||
expect(designExists).toBe(true);
|
||||
if (designExists) {
|
||||
expect(missingSections).toHaveLength(0);
|
||||
// join() so a failure names the offending section(s) — a bare
|
||||
// toHaveLength(0) failure never prints WHICH synonym set missed.
|
||||
expect(missingSections.join(', ')).toBe('');
|
||||
}
|
||||
if (claudeExists) {
|
||||
const claude = fs.readFileSync(claudePath, 'utf-8');
|
||||
|
||||
@@ -81,7 +81,10 @@ Write your complete review directly to ${planDir}/review-output.md
|
||||
Focus on reviewing the plan content: architecture, error handling, security, and performance.`,
|
||||
workingDirectory: planDir,
|
||||
maxTurns: 15,
|
||||
timeout: 360_000,
|
||||
// 540s: the evidence-before-claimed-limitations directive and the
|
||||
// design-doc discovery block (fork port wave 2) add real probing turns;
|
||||
// main cleared this at 243s, the enriched skill needs more headroom.
|
||||
timeout: 540_000,
|
||||
testName: 'plan-ceo-review',
|
||||
runId,
|
||||
model: 'claude-opus-4-7',
|
||||
@@ -100,7 +103,7 @@ Focus on reviewing the plan content: architecture, error handling, security, and
|
||||
const review = fs.readFileSync(reviewPath, 'utf-8');
|
||||
expect(review.length).toBeGreaterThan(200);
|
||||
}
|
||||
}, 420_000);
|
||||
}, 660_000);
|
||||
});
|
||||
|
||||
// --- Plan CEO Review (SELECTIVE EXPANSION) E2E ---
|
||||
@@ -168,7 +171,7 @@ Write your complete review directly to ${planDir}/review-output-selective.md
|
||||
Focus on reviewing the plan content: architecture, error handling, security, and performance.`,
|
||||
workingDirectory: planDir,
|
||||
maxTurns: 15,
|
||||
timeout: 360_000,
|
||||
timeout: 540_000,
|
||||
testName: 'plan-ceo-review-selective',
|
||||
runId,
|
||||
model: 'claude-opus-4-7',
|
||||
@@ -185,7 +188,7 @@ Focus on reviewing the plan content: architecture, error handling, security, and
|
||||
const review = fs.readFileSync(reviewPath, 'utf-8');
|
||||
expect(review.length).toBeGreaterThan(200);
|
||||
}
|
||||
}, 420_000);
|
||||
}, 660_000);
|
||||
});
|
||||
|
||||
// --- Plan CEO Review SCOPE EXPANSION energy (V1.1 mode-posture regression gate) ---
|
||||
|
||||
@@ -406,7 +406,9 @@ Do NOT fix any bugs. Do NOT use AskUserQuestion — just pick vitest.`,
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
// Module-level afterAll — finalize eval collector after all tests complete
|
||||
// Module-level afterAll — finalize eval collector after all tests complete.
|
||||
// Explicit 60s timeout: finalize does a JSON save + cross-run comparison and
|
||||
// has been observed at 6.26s, past bun's 5s default hook timeout.
|
||||
afterAll(async () => {
|
||||
await finalizeEvalCollector(evalCollector);
|
||||
});
|
||||
}, 60_000);
|
||||
@@ -130,15 +130,16 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
|
||||
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
|
||||
const binary = resolveClaudeBinary();
|
||||
|
||||
// Ambient env mutations. Restored in finally.
|
||||
const orig = {
|
||||
gstackHome: process.env.GSTACK_HOME,
|
||||
pathEnv: process.env.PATH,
|
||||
mcpToken: process.env.GBRAIN_MCP_TOKEN,
|
||||
// Per-test child env, passed via opts.env (merges last over the complete
|
||||
// hermetic env). Ambient process.env mutations DO NOT reach children:
|
||||
// hermetic-env scrubs GBRAIN_*/GSTACK_* by allowlist — this test's token
|
||||
// silently never arrived from the day hermetic env landed, and the child
|
||||
// correctly stopped at Step 4c with NEEDS_CONTEXT.
|
||||
const childEnv = {
|
||||
GSTACK_HOME: gstackHome,
|
||||
GBRAIN_MCP_TOKEN: SECRET_TOKEN,
|
||||
PATH: `${fakeBinDir}:${path.join(path.resolve(import.meta.dir, '..'), 'bin')}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
|
||||
};
|
||||
process.env.GSTACK_HOME = gstackHome;
|
||||
process.env.PATH = `${fakeBinDir}:${path.join(path.resolve(import.meta.dir, '..'), 'bin')}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
|
||||
process.env.GBRAIN_MCP_TOKEN = SECRET_TOKEN;
|
||||
|
||||
let modelTextOutput = '';
|
||||
|
||||
@@ -146,6 +147,7 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
|
||||
const skillPath = path.resolve(import.meta.dir, '..', 'setup-gbrain', 'SKILL.md');
|
||||
const result = await runAgentSdkTest({
|
||||
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
||||
env: childEnv,
|
||||
userPrompt:
|
||||
`Read the skill file at ${skillPath} and follow Path 4 (Remote MCP) only. ` +
|
||||
`Use this MCP URL: ${stubServer.url}. ` +
|
||||
@@ -195,6 +197,19 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
|
||||
|
||||
// Assertion 2: claude mcp add was called with --transport http.
|
||||
const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, 'utf-8') : '';
|
||||
if (!/mcp add.*--transport http/.test(calls)) {
|
||||
// Failure evidence: without this, the transcript dies with the test
|
||||
// and every triage pass starts blind (three did).
|
||||
const bashCmds = result.toolCalls
|
||||
.filter((t) => t.tool === 'Bash')
|
||||
.map((t) => String((t.input as { command?: string })?.command ?? '').slice(0, 200));
|
||||
console.error(
|
||||
`[setup-gbrain-remote] mcp-add never hit the fake shim.\n` +
|
||||
`exitReason=${result.exitReason} turns=${result.turnsUsed}\n` +
|
||||
`--- bash commands (${bashCmds.length}) ---\n${bashCmds.join('\n')}\n` +
|
||||
`--- final text (last 1500 chars) ---\n${result.output.slice(-1500)}`,
|
||||
);
|
||||
}
|
||||
expect(calls).toMatch(/mcp add.*--transport http/);
|
||||
|
||||
// Assertion 3: the secret token NEVER appears in the final CLAUDE.md.
|
||||
@@ -207,14 +222,16 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
|
||||
// Assertion 5: classifier — the model didn't write findings before
|
||||
// asking. The Path 4 prose has 5 STOP gates; if any of them got
|
||||
// skipped, that's the wrote_findings_before_asking pattern.
|
||||
const wroteBefore = /## GSTACK REVIEW REPORT|critical_gaps/i.test(modelTextOutput);
|
||||
// Scan the ASSISTANT's text only: modelTextOutput serializes every
|
||||
// event including the child's Read of the skill file, whose generated
|
||||
// footer contains the literal "GSTACK REVIEW REPORT" — a guaranteed
|
||||
// false positive on both trees.
|
||||
const wroteBefore = /## GSTACK REVIEW REPORT|critical_gaps/i.test(result.output);
|
||||
// Setup-gbrain doesn't have a review report contract, so this is
|
||||
// a structural shape check, not a hard failure mode.
|
||||
expect(wroteBefore).toBe(false);
|
||||
} finally {
|
||||
if (orig.gstackHome === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = orig.gstackHome;
|
||||
if (orig.pathEnv === undefined) delete process.env.PATH; else process.env.PATH = orig.pathEnv;
|
||||
if (orig.mcpToken === undefined) delete process.env.GBRAIN_MCP_TOKEN; else process.env.GBRAIN_MCP_TOKEN = orig.mcpToken;
|
||||
// (no ambient process.env mutations to restore — env goes via opts.env)
|
||||
await stubServer.close();
|
||||
fs.rmSync(gstackHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
|
||||
@@ -158,7 +158,7 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
|
||||
const session = await launchClaudePty({
|
||||
permissionMode: 'plan',
|
||||
cwd: fixture.workTree,
|
||||
timeoutMs: 720_000,
|
||||
timeoutMs: 1_080_000,
|
||||
// Disable network-y pieces so the agent can't reach actual github.
|
||||
env: { GH_TOKEN: 'mock-not-real', NO_COLOR: '1' },
|
||||
seedSkills: true,
|
||||
@@ -172,7 +172,7 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
|
||||
const since = session.mark();
|
||||
session.send('/ship\r');
|
||||
|
||||
const budgetMs = 600_000;
|
||||
const budgetMs = 900_000;
|
||||
const start = Date.now();
|
||||
let lastPermSig = '';
|
||||
while (Date.now() - start < budgetMs) {
|
||||
@@ -234,6 +234,12 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Budget exhausted without a terminal signal: capture the tail NOW,
|
||||
// while the session is still alive. Only the break paths above set
|
||||
// evidence — without this, the timeout throw ships evidence: "".
|
||||
if (outcome === 'timeout') {
|
||||
evidence = session.visibleSince(since).slice(-3000);
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
@@ -273,6 +279,6 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
|
||||
try { fs.rmSync(path.dirname(fixture.workTree), { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
900_000, // 15 min wall clock
|
||||
1_200_000, // 20 min wall clock
|
||||
);
|
||||
});
|
||||
@@ -1216,9 +1216,11 @@ describe('Step 3.4 test coverage audit', () => {
|
||||
|
||||
describe('ship step numbering', () => {
|
||||
// Allowed sub-steps that are resolver-generated and intentionally nested:
|
||||
// 8.1 (Plan Verification), 8.2 (Scope Drift), 9.1 (Review Army), 9.2 (Findings Merge),
|
||||
// 9.3 (Cross-review dedup), 15.0 (WIP squash — continuous checkpoint), 15.1 (Bisectable commits).
|
||||
const ALLOWED_SUBSTEPS = new Set(['8.1', '8.2', '9.1', '9.2', '9.3', '15.0', '15.1']);
|
||||
// 0.9 (Apple target detection — MUST precede Step 1's branch gate, R2-pinned
|
||||
// by test/ship-apple-gate.test.ts), 8.1 (Plan Verification), 8.2 (Scope
|
||||
// Drift), 9.1 (Review Army), 9.2 (Findings Merge), 9.3 (Cross-review dedup),
|
||||
// 15.0 (WIP squash — continuous checkpoint), 15.1 (Bisectable commits).
|
||||
const ALLOWED_SUBSTEPS = new Set(['0.9', '8.1', '8.2', '9.1', '9.2', '9.3', '15.0', '15.1']);
|
||||
|
||||
test('ship/SKILL.md.tmpl contains no unexpected fractional step numbers', () => {
|
||||
const tmpl = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md.tmpl'), 'utf-8');
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Regression pin for #2018: /autoplan Phase 4's task aggregator emitted zero
|
||||
* tasks on every run, forever, for everyone.
|
||||
*
|
||||
* Root cause: the branch+commit filter in scripts/resolvers/tasks-section.ts
|
||||
* piped to the split commit array and THEN referenced `.commit` —
|
||||
*
|
||||
* select(.branch == $branch and ($commits | split("|") | index(.commit) != null))
|
||||
*
|
||||
* In jq, the pipe rebinds the context, so `.commit` was evaluated against the
|
||||
* split ARRAY ("Cannot index array with string \"commit\""), every input line
|
||||
* errored, stderr went to /dev/null, `|| true` swallowed the exit code, and
|
||||
* the aggregate was empty. A dead feature indistinguishable from "no tasks".
|
||||
*
|
||||
* These tests run the ACTUAL jq program extracted from the resolver source
|
||||
* against fixture JSONL, so they were RED against the broken filter and stay
|
||||
* red if anyone reintroduces a context-rebinding shape.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const SOURCE_PATH = join(import.meta.dir, "..", "scripts", "resolvers", "tasks-section.ts");
|
||||
|
||||
/**
|
||||
* Extract the emitted jq filter program from the resolver source. The
|
||||
* resolver builds bash inside a TS template literal, so `\\` in source is a
|
||||
* bash line-continuation `\` — strip it when unescaping. We match the
|
||||
* single-quoted jq program on the line that filters by $branch + $commits.
|
||||
*/
|
||||
function extractBranchCommitFilter(): string {
|
||||
const src = readFileSync(SOURCE_PATH, "utf-8");
|
||||
const m = src.match(/'([^']*select\(\.branch == \$branch[^']*)'/);
|
||||
if (!m) throw new Error("branch+commit jq filter not found in tasks-section.ts");
|
||||
return m[1].replace(/\\\\/g, "\\");
|
||||
}
|
||||
|
||||
function runJq(program: string, inputLines: string[], branch: string, commits: string): string[] {
|
||||
const out = execFileSync(
|
||||
"jq",
|
||||
["-c", "--arg", "branch", branch, "--arg", "commits", commits, program],
|
||||
{ input: inputLines.join("\n"), encoding: "utf-8" },
|
||||
);
|
||||
return out.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
const RECORD = (branch: string, commit: string) =>
|
||||
JSON.stringify({
|
||||
phase: "ceo-review",
|
||||
run_id: "20260814T000000Z-1",
|
||||
branch,
|
||||
commit,
|
||||
id: "T1",
|
||||
priority: "P1",
|
||||
component: "demo",
|
||||
files: ["a.ts"],
|
||||
effort_human: "~1h",
|
||||
effort_cc: "~5min",
|
||||
title: "demo task",
|
||||
source_finding: "demo finding",
|
||||
});
|
||||
|
||||
describe("tasks-section jq filter (#2018)", () => {
|
||||
it("matches a record whose branch and commit are in the window", () => {
|
||||
const program = extractBranchCommitFilter();
|
||||
const matched = runJq(
|
||||
program,
|
||||
[RECORD("feature/x", "abc123")],
|
||||
"feature/x",
|
||||
"abc123|def456",
|
||||
);
|
||||
// The broken filter returned [] here (every line errored) — the exact
|
||||
// #2018 symptom: reviews produced tasks, the aggregate table showed none.
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(JSON.parse(matched[0]).id).toBe("T1");
|
||||
});
|
||||
|
||||
it("filters out other branches and out-of-window commits", () => {
|
||||
const program = extractBranchCommitFilter();
|
||||
const matched = runJq(
|
||||
program,
|
||||
[
|
||||
RECORD("feature/x", "abc123"),
|
||||
RECORD("other-branch", "abc123"),
|
||||
RECORD("feature/x", "zzz999"),
|
||||
],
|
||||
"feature/x",
|
||||
"abc123|def456",
|
||||
);
|
||||
expect(matched).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("errors on no input line at all rather than fabricating output", () => {
|
||||
const program = extractBranchCommitFilter();
|
||||
expect(runJq(program, [], "feature/x", "abc123")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("source does not reference .commit after a context-rebinding pipe", () => {
|
||||
const src = readFileSync(SOURCE_PATH, "utf-8");
|
||||
// The bug shape: split("|") piped, then a bare `.commit` in the new array
|
||||
// context. Binding first (`.commit as $c`) is the required form.
|
||||
expect(src).not.toMatch(/split\("\|"\)\s*\|\s*index\(\.commit\)/);
|
||||
});
|
||||
});
|
||||
@@ -12,14 +12,20 @@
|
||||
* (bin/gstack-telemetry-log)
|
||||
*
|
||||
* gstack-telemetry-sync MUST strip every one of those fields before the remote
|
||||
* POST (bin/gstack-telemetry-sync). This test enforces that contract three ways:
|
||||
* POST (bin/gstack-telemetry-sync). The script has TWO strip paths — jq del()
|
||||
* is PRIMARY (structural, escape-proof), sed is the jq-less fallback — and
|
||||
* this test enforces the contract on both:
|
||||
*
|
||||
* 1. Coverage — every repo/branch field the producers emit is also stripped.
|
||||
* Catches "added a new repo field, forgot to strip it" (the rename-to-_repo
|
||||
* landmine, or any future producer drift).
|
||||
* 2. Behavior — run the ACTUAL sed strip expressions from the sync script over
|
||||
* a sample event line and assert no repo/branch field survives, while benign
|
||||
* fields do. Catches a broken/edited regex, not just a missing line.
|
||||
* 1. Coverage — every repo/branch field the producers emit is also stripped,
|
||||
* by every jq del() list AND by the sed expressions. Catches "added a new
|
||||
* repo field, forgot to strip it" (the rename-to-_repo landmine, or any
|
||||
* future producer drift) on whichever path a machine takes.
|
||||
* 2. Behavior — run the ACTUAL jq expression and the ACTUAL sed strip
|
||||
* expressions from the sync script over a sample event line and assert no
|
||||
* repo/branch field survives, while benign fields do. Catches a
|
||||
* broken/edited filter, not just a missing line. The jq leg also pins the
|
||||
* malformed-line contract: a line jq can't parse is dropped, never
|
||||
* forwarded unstripped.
|
||||
* 3. Floor — the three known fields are always in the stripped set, so deleting
|
||||
* a strip rule fails CI even if a producer also stops emitting it.
|
||||
*/
|
||||
@@ -45,6 +51,16 @@ function extractSedExprs(scriptText: string): string[] {
|
||||
return [...scriptText.matchAll(/-e\s+'(s\/[^']*)'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** Pull every `jq -c 'del(...)'` filter out of the sync script, verbatim. */
|
||||
function extractJqDelFilters(scriptText: string): string[] {
|
||||
return [...scriptText.matchAll(/jq -c '(del\([^']*\))'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** The JSON keys a jq del() filter removes, e.g. `del(._repo_slug, .repo)`. */
|
||||
function fieldsFromJqDel(filter: string): string[] {
|
||||
return [...filter.matchAll(/\.([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** The JSON key a strip expression targets, e.g. `,"repo":"[^"]*"` -> `repo`. */
|
||||
function fieldFromSedExpr(expr: string): string | null {
|
||||
const m = expr.match(/,"([A-Za-z_][A-Za-z0-9_]*)":/);
|
||||
@@ -73,6 +89,25 @@ describe('telemetry no-repo-identity-egress invariant', () => {
|
||||
const strippedFields = new Set(
|
||||
strippedRepoExprs.map(fieldFromSedExpr).filter((f): f is string => f !== null),
|
||||
);
|
||||
const jqFilters = extractJqDelFilters(syncText);
|
||||
|
||||
// Repo-identity fields the producers emit into the synced file — computed
|
||||
// once, asserted against BOTH strip paths (jq primary, sed fallback). Only
|
||||
// emission lines that target the synced file (skill-usage.jsonl) count: the
|
||||
// preamble appends directly; gstack-telemetry-log builds the synced event
|
||||
// with a `printf '{"v":1,...` line into $JSONL_FILE (= skill-usage.jsonl).
|
||||
const preambleSynced = fs
|
||||
.readFileSync(PREAMBLE, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('skill-usage.jsonl'));
|
||||
const telLogSynced = fs
|
||||
.readFileSync(TEL_LOG, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('"v":1') || l.includes('skill-usage'));
|
||||
const emitted = new Set<string>([
|
||||
...emittedRepoFields(preambleSynced),
|
||||
...emittedRepoFields(telLogSynced),
|
||||
]);
|
||||
|
||||
test('floor: the three known repo-identity fields are stripped', () => {
|
||||
for (const field of REPO_IDENTITY_FLOOR) {
|
||||
@@ -80,33 +115,34 @@ describe('telemetry no-repo-identity-egress invariant', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('coverage: every repo/branch field the producers emit into skill-usage.jsonl is stripped', () => {
|
||||
// Only emission lines that target the synced file (skill-usage.jsonl). The
|
||||
// preamble appends directly; gstack-telemetry-log builds the synced event
|
||||
// with a `printf '{"v":1,...` line into $JSONL_FILE (= skill-usage.jsonl).
|
||||
const preambleSynced = fs
|
||||
.readFileSync(PREAMBLE, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('skill-usage.jsonl'));
|
||||
const telLogSynced = fs
|
||||
.readFileSync(TEL_LOG, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('"v":1') || l.includes('skill-usage'));
|
||||
const emitted = new Set<string>([
|
||||
...emittedRepoFields(preambleSynced),
|
||||
...emittedRepoFields(telLogSynced),
|
||||
]);
|
||||
test('coverage: every repo/branch field the producers emit into skill-usage.jsonl is stripped (sed fallback path)', () => {
|
||||
// The preamble must emit "repo" — guards against the test silently passing
|
||||
// because a regex stopped matching the producer.
|
||||
expect(emitted.has('repo')).toBe(true);
|
||||
for (const field of emitted) {
|
||||
expect(
|
||||
strippedFields.has(field),
|
||||
`producer emits repo-identity field "${field}" but gstack-telemetry-sync does not strip it (would leak to remote)`,
|
||||
`producer emits repo-identity field "${field}" but gstack-telemetry-sync's sed fallback does not strip it (would leak to remote)`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('coverage: every jq del() list (the PRIMARY strip path) covers every emitted repo-identity field', () => {
|
||||
// Both tiers run a del() filter; each must strip full repo identity on its
|
||||
// own — a machine only ever takes one branch.
|
||||
expect(jqFilters.length).toBeGreaterThanOrEqual(2);
|
||||
expect(emitted.has('repo')).toBe(true); // producer-regex canary, as above
|
||||
for (const filter of jqFilters) {
|
||||
const delFields = new Set(fieldsFromJqDel(filter));
|
||||
for (const field of [...emitted, ...REPO_IDENTITY_FLOOR]) {
|
||||
expect(
|
||||
delFields.has(field),
|
||||
`jq filter "${filter}" does not del repo-identity field "${field}" (primary strip path would leak it to remote)`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('behavior: the real sed expressions remove repo identity, keep benign fields', () => {
|
||||
const sample =
|
||||
'{"v":1,"ts":"2026-06-02T00:00:00Z","skill":"design-shotgun",' +
|
||||
@@ -134,4 +170,51 @@ describe('telemetry no-repo-identity-egress invariant', () => {
|
||||
expect(cleaned).toContain('"sessions":3');
|
||||
expect(cleaned).toContain('"ts":"2026-06-02T00:00:00Z"');
|
||||
});
|
||||
|
||||
test('behavior: the real jq del() filters strip repo identity and drop malformed lines', () => {
|
||||
if (!Bun.which('jq')) return; // jq-less machine: the sed-fallback behavior test above is the live path
|
||||
|
||||
const sample =
|
||||
'{"v":1,"ts":"2026-06-02T00:00:00Z","skill":"design-shotgun",' +
|
||||
'"repo":"my-secret-repo","_repo_slug":"acme-my-secret-repo","_branch":"feature-x",' +
|
||||
'"sessions":3,"installation_id":"abc123"}';
|
||||
|
||||
// The identified-tier filter (no installation_id in its del list) and the
|
||||
// anonymous-tier filter (installation_id included) — run each verbatim.
|
||||
const identified = jqFilters.find((f) => !f.includes('installation_id'));
|
||||
const anonymous = jqFilters.find((f) => f.includes('installation_id'));
|
||||
expect(identified).toBeTruthy();
|
||||
expect(anonymous).toBeTruthy();
|
||||
|
||||
const runJq = (filter: string, input: string) => {
|
||||
const out = spawnSync(['jq', '-c', filter], { stdin: Buffer.from(input) });
|
||||
return { exitCode: out.exitCode, stdout: out.stdout.toString().trim() };
|
||||
};
|
||||
|
||||
const id = runJq(identified!, sample);
|
||||
expect(id.exitCode).toBe(0);
|
||||
// No repo/branch identity survives, value or key.
|
||||
expect(id.stdout).not.toContain('my-secret-repo');
|
||||
expect(id.stdout).not.toContain('feature-x');
|
||||
expect(id.stdout).not.toContain('"repo"');
|
||||
expect(id.stdout).not.toContain('_repo_slug');
|
||||
expect(id.stdout).not.toContain('_branch');
|
||||
// Benign fields are untouched; identified tier keeps installation_id.
|
||||
expect(id.stdout).toContain('"skill":"design-shotgun"');
|
||||
expect(id.stdout).toContain('"sessions":3');
|
||||
expect(id.stdout).toContain('"installation_id":"abc123"');
|
||||
|
||||
// Anonymous tier additionally drops installation_id.
|
||||
const anon = runJq(anonymous!, sample);
|
||||
expect(anon.exitCode).toBe(0);
|
||||
expect(anon.stdout).not.toContain('installation_id');
|
||||
expect(anon.stdout).not.toContain('my-secret-repo');
|
||||
|
||||
// Malformed line: jq fails and emits nothing — the sync script's
|
||||
// `|| CLEAN=""` + `[ -z "$CLEAN" ] && continue` drops it, so bytes the
|
||||
// strip never touched are never forwarded.
|
||||
const bad = runJq(identified!, '{"v":1,"repo":"my-secret-repo"');
|
||||
expect(bad.exitCode).not.toBe(0);
|
||||
expect(bad.stdout).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* gstack-verify-gate — Stop-hook enforcement tier.
|
||||
*
|
||||
* Pins the behaviours the gate exists for:
|
||||
* trust — a declared command NEVER runs until the user records it via
|
||||
* `gstack-verify-gate --trust` (per-repo command trust store).
|
||||
* block — trusted check fails, exit 2, turn cannot end.
|
||||
* allow — trusted check passes, exit 0.
|
||||
* fail open — nothing declared, exit 0. Absence never blocks.
|
||||
*
|
||||
* Plus the two safety branches: the Stop re-entry guard, and the static
|
||||
* opt-in contract (our adaptation): ./setup never registers the gate; the
|
||||
* settings-hook helper.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createHash } from 'crypto';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const GATE = path.join(ROOT, 'bin', 'gstack-verify-gate');
|
||||
|
||||
let dir: string;
|
||||
let gstackHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-'));
|
||||
gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-home-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.rmSync(gstackHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Declare a verification command in the project's CLAUDE.md (comment form). */
|
||||
function declareCheck(command: string): void {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), `# Fixture\n\n<!-- gstack:verify: ${command} -->\n`);
|
||||
}
|
||||
|
||||
/** Write the check script the declaration points at. Touches `check-ran` when executed. */
|
||||
function check(exitCode: number, message: string): void {
|
||||
const script = path.join(dir, 'check.sh');
|
||||
fs.writeFileSync(script, `#!/bin/sh\ntouch check-ran\necho "${message}"\nexit ${exitCode}\n`);
|
||||
fs.chmodSync(script, 0o755);
|
||||
}
|
||||
|
||||
/** Did the declared check actually execute? */
|
||||
function checkRan(): boolean {
|
||||
return fs.existsSync(path.join(dir, 'check-ran'));
|
||||
}
|
||||
|
||||
interface RunOpts {
|
||||
stopHookActive?: boolean;
|
||||
cwd?: string;
|
||||
/** When false, CLAUDE_PROJECT_DIR is removed from the child env (walk-up mode). */
|
||||
projectDirEnv?: boolean;
|
||||
/** Hook-input session id, keys the re-entry attempt counter. */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
function gateEnv(projectDirEnv: boolean): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_HOME: gstackHome };
|
||||
if (projectDirEnv) env.CLAUDE_PROJECT_DIR = dir;
|
||||
else delete env.CLAUDE_PROJECT_DIR;
|
||||
return env;
|
||||
}
|
||||
|
||||
function runGate(opts: RunOpts = {}): { code: number; stdout: string; stderr: string } {
|
||||
const input: Record<string, unknown> = { stop_hook_active: opts.stopHookActive ?? false };
|
||||
if (opts.sessionId) input.session_id = opts.sessionId;
|
||||
const r = spawnSync(GATE, {
|
||||
cwd: opts.cwd ?? dir,
|
||||
input: JSON.stringify(input),
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
env: gateEnv(opts.projectDirEnv ?? true),
|
||||
});
|
||||
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
/** Record the currently-declared command in the trust store. */
|
||||
function trust(opts: RunOpts = {}): { code: number; stdout: string; stderr: string } {
|
||||
const r = spawnSync(GATE, ['--trust'], {
|
||||
cwd: opts.cwd ?? dir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
env: gateEnv(opts.projectDirEnv ?? true),
|
||||
});
|
||||
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
describe('gstack-verify-gate trust store', () => {
|
||||
test('an untrusted declared command is NOT executed and does not block', () => {
|
||||
declareCheck('touch sentinel-ran');
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
|
||||
expect(r.stderr).toContain('--trust');
|
||||
expect(r.stderr).toContain('touch sentinel-ran');
|
||||
});
|
||||
|
||||
test('--trust records the command and prints a confirmation naming it', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
|
||||
const t = trust();
|
||||
|
||||
expect(t.code).toBe(0);
|
||||
expect(t.stdout).toContain('./check.sh');
|
||||
});
|
||||
|
||||
test('after --trust the hook executes the command and block semantics work', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'totals mismatch');
|
||||
|
||||
expect(trust().code).toBe(0);
|
||||
const r = runGate();
|
||||
|
||||
expect(checkRan()).toBe(true);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('FAILED');
|
||||
expect(r.stderr).toContain('totals mismatch');
|
||||
});
|
||||
|
||||
test('a changed command is not executed until re-trusted', () => {
|
||||
declareCheck('true');
|
||||
expect(trust().code).toBe(0);
|
||||
|
||||
// Attacker (or anyone) edits the declaration after trust was granted.
|
||||
declareCheck('touch sentinel-ran');
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
|
||||
expect(r.stderr).toContain('--trust');
|
||||
|
||||
// Re-trusting the new command restores execution.
|
||||
expect(trust().code).toBe(0);
|
||||
const r2 = runGate();
|
||||
expect(r2.code).toBe(0);
|
||||
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(true);
|
||||
});
|
||||
|
||||
test('walk-up: hook run from a nested subdir keys trust on the CLAUDE.md root', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
const nested = path.join(dir, 'a', 'b');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
|
||||
// No CLAUDE_PROJECT_DIR: both trust and hook must walk up from $PWD.
|
||||
expect(trust({ cwd: nested, projectDirEnv: false }).code).toBe(0);
|
||||
const r = runGate({ cwd: nested, projectDirEnv: false });
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('passed');
|
||||
expect(checkRan()).toBe(true);
|
||||
});
|
||||
|
||||
test('non-comment declaration form (gstack:verify: cmd without <!-- -->) is honored', () => {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\ngstack:verify: ./check.sh\n');
|
||||
check(0, 'all good');
|
||||
|
||||
expect(trust().stdout).toContain('./check.sh');
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('passed');
|
||||
expect(checkRan()).toBe(true);
|
||||
});
|
||||
|
||||
test('the trust store file is created 0600 under GSTACK_HOME', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
expect(trust().code).toBe(0);
|
||||
|
||||
const store = path.join(gstackHome, 'verify-gate-trust');
|
||||
expect(fs.existsSync(store)).toBe(true);
|
||||
expect(fs.statSync(store).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
test('--trust fails cleanly when nothing is declared', () => {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n');
|
||||
|
||||
const t = trust();
|
||||
|
||||
expect(t.code).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-verify-gate', () => {
|
||||
test('blocks the turn when the trusted check fails', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'totals mismatch');
|
||||
expect(trust().code).toBe(0);
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('FAILED');
|
||||
expect(r.stderr).toContain('totals mismatch');
|
||||
});
|
||||
|
||||
test('allows the turn when the trusted check passes', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
expect(trust().code).toBe(0);
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('passed');
|
||||
});
|
||||
|
||||
test('fails open when CLAUDE.md declares no check', () => {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n');
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
|
||||
});
|
||||
|
||||
test('fails open when there is no CLAUDE.md at all', () => {
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('no CLAUDE.md');
|
||||
});
|
||||
|
||||
test('never invents a command: an empty declaration fails open', () => {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '<!-- gstack:verify: -->\n');
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('gstack-verify-gate re-entry enforcement (no one-shot bypass)', () => {
|
||||
test('re-entry with a still-failing trusted check is blocked again', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'still failing');
|
||||
expect(trust().code).toBe(0);
|
||||
|
||||
const r = runGate({ stopHookActive: true, sessionId: 'sess-refail' });
|
||||
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('FAILED');
|
||||
expect(r.stderr).toContain('still failing');
|
||||
expect(checkRan()).toBe(true);
|
||||
});
|
||||
|
||||
test('re-entry after the check now passes is allowed', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'totals mismatch');
|
||||
expect(trust().code).toBe(0);
|
||||
expect(runGate({ sessionId: 'sess-fixed' }).code).toBe(2);
|
||||
|
||||
check(0, 'fixed now');
|
||||
const r = runGate({ stopHookActive: true, sessionId: 'sess-fixed' });
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('passed');
|
||||
});
|
||||
|
||||
test('attempt bound: repeated failing re-entries allow with a loud warning at the bound', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'never passing');
|
||||
expect(trust().code).toBe(0);
|
||||
const sid = 'sess-bound';
|
||||
|
||||
// First entry blocks and resets the episode counter.
|
||||
expect(runGate({ sessionId: sid }).code).toBe(2);
|
||||
|
||||
// Re-entries: bounded number of blocks, then allow-with-warning.
|
||||
const codes: number[] = [];
|
||||
let final: { code: number; stdout: string; stderr: string } | null = null;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const r = runGate({ stopHookActive: true, sessionId: sid });
|
||||
codes.push(r.code);
|
||||
if (r.code === 0) {
|
||||
final = r;
|
||||
break;
|
||||
}
|
||||
expect(r.code).toBe(2);
|
||||
}
|
||||
|
||||
expect(codes).toEqual([2, 2, 2, 0]);
|
||||
expect(final).not.toBeNull();
|
||||
expect(final!.stdout + final!.stderr).toContain('WARNING');
|
||||
|
||||
// A fresh first-entry run starts a new episode: blocked again, not allowed.
|
||||
expect(runGate({ sessionId: sid }).code).toBe(2);
|
||||
});
|
||||
|
||||
test('re-entry with an untrusted command keeps the exit-0-with-hint path', () => {
|
||||
declareCheck('touch sentinel-ran');
|
||||
|
||||
const r = runGate({ stopHookActive: true, sessionId: 'sess-untrusted' });
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
|
||||
expect(r.stderr).toContain('--trust');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-verify-gate trust-grant audit trail', () => {
|
||||
test('every --trust grant appends a JSON audit line (right sha256, 0600, verbatim cmd)', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
|
||||
const t = trust();
|
||||
expect(t.code).toBe(0);
|
||||
// --trust prints the VERBATIM command being trusted.
|
||||
expect(t.stdout).toContain('./check.sh');
|
||||
|
||||
const log = path.join(gstackHome, 'security', 'verify-gate-trust-grants.jsonl');
|
||||
expect(fs.existsSync(log)).toBe(true);
|
||||
expect(fs.statSync(log).mode & 0o777).toBe(0o600);
|
||||
|
||||
const lines = fs.readFileSync(log, 'utf-8').trim().split('\n');
|
||||
expect(lines.length).toBe(1);
|
||||
const entry = JSON.parse(lines[0]);
|
||||
expect(entry.cmd).toBe('./check.sh');
|
||||
expect(entry.cmd_sha256).toBe(createHash('sha256').update('./check.sh').digest('hex'));
|
||||
expect(entry.root).toBe(fs.realpathSync(dir));
|
||||
expect(typeof entry.tty).toBe('boolean');
|
||||
expect(entry.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
|
||||
// A second grant appends, never truncates.
|
||||
declareCheck('true');
|
||||
expect(trust().code).toBe(0);
|
||||
const lines2 = fs.readFileSync(log, 'utf-8').trim().split('\n');
|
||||
expect(lines2.length).toBe(2);
|
||||
expect(JSON.parse(lines2[1]).cmd).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('opt-in contract (adapted from the fork: NOT registered by default)', () => {
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
const gate = fs.readFileSync(GATE, 'utf-8');
|
||||
|
||||
test('./setup does NOT register the gate — a Stop hook running the verify command after every turn is opt-in', () => {
|
||||
expect(setup).not.toContain('verify-gate');
|
||||
});
|
||||
|
||||
test('the bin documents its own registration and removal commands', () => {
|
||||
expect(gate).toContain('remove-source --source verify-gate');
|
||||
expect(gate).toContain('gstack:verify:');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user