From 1ec6a6e842a15f2bde2ebe8c3a686a6fa6b17aa5 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 12 Aug 2026 16:12:59 -0700 Subject: [PATCH] v0.45.9.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder (#4045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): execution-environment detection — local | cloud-sandbox | ephemeral-container detectExecutionEnvironment() + isCredentialInjectingProxy() with injected signals (CLAUDE_CODE_REMOTE, cse_ session-id prefix, proxy-injected token placeholder, anthropic-egress proxy JWT, container markers). binaryOnPath moves here as the canonical PATH probe. autopilot's detectInstallTarget ephemeral branch now routes through the shared detector. Co-Authored-By: Claude Fable 5 * feat(bootstrap): repo-visibility ladder + per-turn Stop push + same-session failure banner The durability lane. One repo-visibility verdict for every consumer (REST first — never GraphQL, which sandbox proxies pin; authed ls-remote + an attributed anonymous probe as the git-protocol fallback), replacing three drifted probes. A 200 counts as public only with advertisement proof; a 401/404 counts as private-signal only with an auth challenge — fail-closed in BOTH directions. Private verdicts cache 1h (private-only, per origin). gbrain hook stop now spawns a debounced detached push per turn (per-root state; cloud-sandbox defaults to every turn, elsewhere 5 min; a failing status bypasses the debounce), closing the /exit and VM-reclaim gaps. Push status is per workspace root, read through one shared reader by the user-prompt banner (additionalContext + systemMessage — visible to the human, not just the model), the SessionStart note, and doctor. Escape hatches for self-hosted git: flag > env > file-plane config key. Co-Authored-By: Claude Fable 5 * feat(bootstrap): environment-aware install — honest cron skip, cloud repo-create guard, execution_env surfaces installDurabilityCron probes for crontab before writing anything (containers and cloud sandboxes ship without one — expected, reported as an honest skip naming the event-driven pushes that still cover persistence). The repo phase installs the container-friendly harden half (post-commit hook, no scheduler) outside local machines. createPrivateRepo fails fast in cloud sandboxes with the flow that works (create outside, open the session ON the repo, attach). bootstrap verify gains a never-gating execution_env check; bootstrap status --json carries execution_environment for installing agents to branch on. Also fixes a live-PATH resolution class: binaryOnPath and the crontab execs now pass the current env explicitly (Bun resolves against the startup snapshot otherwise). Co-Authored-By: Claude Fable 5 * fix(bootstrap): lifecycle hygiene — uninstall teardown, doctor job liveness, .mcp.json out of the repo, honest persistence copy uninstall now tears down the durability wiring it installed (launchd/cron job, untracked post-commit hook, credential wiring — the committed helper and AGENTS rules stay). doctor gains bootstrap_durability_job: presence + LIVENESS (launchctl load-state, crontab line, pull-log freshness) — a plist on disk with a dead job no longer reads as healthy. Rendered .gitignore now covers .mcp.json (absolute machine paths must not land in the private repo); verify warns on pre-fix installs that committed it; the never-built state/mcp.json promise is gone from GITHUB.md. Persistence copy tells the truth everywhere: event-driven pushes do the durability work, the 30-min job is a multi-machine pull freshener. Post-commit hook install/removal is worktree-safe (git-path resolution; the git marker is a FILE there) and the cron wrapper's self-disable tests the repo dir, not its git marker. Two follow-up TODOs filed (plugin hook distribution; Channels push lane). Co-Authored-By: Claude Fable 5 * feat(bootstrap): committed hook carrier for cloud + cloud-setup-script emitter + cloud runbook Cloud sessions clone fresh and snapshot hook config at session start — the gitignored settings.local.json never exists there, so hooks never fired in cloud at all. Cloud installs now write the repo-COMMITTED .claude/settings.json with PATH-resolved, fail-open commands (no machine paths; a host without the binary no-ops); local installs keep settings.local.json; the writers enforce that one event never fires from both carriers, and removal cleans both. New: gbrain bootstrap cloud-setup-script prints the paste-ready environment setup script (npm transport — bun fetching is proxy-incompatible in cloud; never the unrelated npm-registry package). Runbook gains a NEVER FABRICATE TOOLING hard rule, a cloud-sandbox section (expected degradations as facts to relay, the attach-first flow), and failure-table rows for the proxy-403 and missing-crontab signatures. Codex lane stated honestly: AGENTS.md Gate 2 now has the pull-side push-health check. Guide documents the new knobs and the cloud contract; llms bundles regenerated. Co-Authored-By: Claude Fable 5 * test(bootstrap): per-turn push e2e chain, cloud-sandbox sim, per-root status reader migration + registry regen bootstrap-persistence e2e gains the Stop-hook chain (one stop banks the turn to the real bare remote; the debounce holds across stops; debounce-0 lands consecutive turns) and fixes a pre-existing post-#4024 break: repoPhaseComplete required a github-parseable origin, so session-end pushes deferred FOREVER for self-hosted/file-transport origins — non-github repo_urls now bind by exact URL equality (redirect protection preserved). Degraded-modes e2e gains the cloud-sandbox simulation (status reports the environment; repo creation refuses with the attach flow). workspace-push tests read per-root status through the shared reader. Flag registry regenerated (new git argv literals from the git-path/ls-files calls — the accepted argv-bleed class). Co-Authored-By: Claude Fable 5 * fix(doctor): categorize bootstrap_durability_job (categories drift guard) Co-Authored-By: Claude Fable 5 * fix(bootstrap): adversarial-review hardening — fail-closed privacy, no push-storm, no exfil paths Both Claude and Codex adversarial passes ran; 11 findings fixed (union): - Non-github 401+WWW-Authenticate no longer reads as private (RFC 7235 makes the header mandatory on every 401, so a middlebox 401s identically) — the top exfil path both models flagged; now unverifiable/fail-closed, operator confirms via the escape hatch. github.com still needs x-github-request-id. - Escape hatches downgrade ONLY 'unverifiable' — a PROVEN-public origin still refuses (hatches never authorize a public push). - treeNeedsPush measures against origin/ (the push's own ref), not @{u}: a no-upstream branch no longer reports a committed-but-unpushed tree as push_clean and silently strands it. - [D20] failing-retry uses a fixed 60s floor, not min(debounce,60s) — cloud debounce=0 no longer re-runs the network ladder every turn. - Committed hook carrier: dedupe/suppress only on the EXACT portable-command shape, not a 'gbrain hook' substring (blocks the suppress-local-run-evil supply-chain vector); GBRAIN_HOME refused in the committed carrier. - push-status reason sanitized (charset+length) at every surface (banner, doctor, status blob) so remote git stderr can't inject via the remediation. - Per-root state: ghost-root records (deleted workspaces) filtered so a dead failing record can't re-fire the banner forever; uninstall removes them. - statusReport support blob reads push status through the shared per-root reader; visibility cache strips URL userinfo (no PAT persisted). - anonProbe strips userinfo + redirect:manual + SSRF flags + --end-of-options on ls-remote; cron self-disable uses git rev-parse (worktree-safe both ways); cloud-setup-script fails loud on a broken update; durability liveness won't certify a never-run crontab as live; config get/unset resolve the dotted file-plane keys; .bak/.broken gitignored; typed config fields. Co-Authored-By: Claude Fable 5 * v0.45.8.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder VERSION + package.json + CHANGELOG + TODOS + llms bundles. Co-Authored-By: Claude Fable 5 * docs: sync KEY_FILES + README for the cloud-DX wave (v0.45.8.0) New KEY_FILES entries for execution-env.ts and repo-visibility.ts; updated the bootstrap repo/hooks, workspace-push, brain-repo-durability, and hook.ts entries to current behavior (ladder verification, committed cloud hook carrier, per-root push status, crontab probe + liveness, per-turn stop push + banner). README's Claude Code line now states per-turn + cloud persistence honestly. llms bundles regenerated. Co-Authored-By: Claude Fable 5 * fix(bootstrap): doc-review pass — attach records repo_url (enables cloud persistence), verify reads per-root push status, honest docs Cross-model doc review (Codex) caught that the headline cloud-persistence claim was hollow and two docs over-claimed: - attach now records repo_url from the adopted origin, so the no-daemon push gate (repoPhaseComplete) recognizes the repo phase as done — WITHOUT this, the per-turn/session-end pushes deferred forever after an attach, which is the ONLY install path in a cloud sandbox (repo is refused there). Privacy is still enforced at push time by the ladder. - bootstrap verify's push_probe reads the shared per-root reader [D8], not the legacy single file — a fresh v0.45.8 install no longer reports 'no push recorded' when per-root status exists. - Docs corrected to match code: runbook stamp → 0.45.8.0; README states the per-turn cadence honestly (debounced local, next-turn failure notice); GITHUB.md qualifies auto-push as Claude Code (Codex is pull); KEY_FILES says repo/status use REST (not the full ladder) and drops the stale --push; bootstrap.md hooks-location covers both carriers; README verbs surface says seven. llms bundles regenerated. Co-Authored-By: Claude Fable 5 * fix(bootstrap): regenerate vendored template-repo tree + isolate env-mutating tests (verify gate) The verify CI gate caught two things the piecemeal local runs missed: - templates/bootstrap/template-repo/ is a GENERATED tree; the source-template edits (AGENTS.md Gate 2, CLAUDE.md cloud note) plus the version stamp had drifted. Regenerated from source (check:bootstrap-templates green). - test/bootstrap-repo.test.ts + test/durability-cron.test.ts newly mutated process.env in non-serial files; converted to withEnv() (check:test-isolation green) — no .serial rename needed. Co-Authored-By: Claude Fable 5 * test(ambient-recall): pin a keyless gateway so delta/context_pack writes never fire a real embed Root cause of the shard-2 CI red after the 0.45.9.0 re-bump: adding this wave's test files reshuffled the weight-packed shards, moving ambient-recall.test.ts next to a neighbor that leaks CI's dummy OPENAI_API_KEY (sk-test-*) into the gateway singleton (the bunfig preload configures with env:{...process.env}, and a present-but-invalid key turns remember's keyless-degrade embed into a hard 401). The delta/context_pack tests exercise cursor + budget logic, not embedding quality, so this pins a keyless gateway (env:{}) in beforeAll — isAvailable('embedding') is false, writeSingleFact degrades (degraded_dedup) with no HTTP call, and the file is deterministic regardless of shard bin-packing. Verified: passes with OPENAI_API_KEY=sk-test-* set (the CI condition). Master's own comment in legacy-embedding-preload.ts already warns 'adding any test file reshuffles the mines'; this makes the file immune. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- BOOTSTRAP_FOR_AGENTS.md | 58 ++- CHANGELOG.md | 32 +- README.md | 4 +- TODOS.md | 42 ++ VERSION | 2 +- docs/architecture/KEY_FILES.md | 14 +- docs/guides/bootstrap.md | 44 +- llms-full.txt | 4 +- openclaw.plugin.json | 2 +- package.json | 2 +- src/commands/autopilot.ts | 12 +- src/commands/bootstrap.ts | 124 ++++- src/commands/config.ts | 56 ++- src/commands/doctor.ts | 138 ++++-- src/commands/hook.ts | 310 ++++++++++-- src/commands/sources.ts | 16 +- src/core/bootstrap/assets.ts | 10 + src/core/bootstrap/attach.ts | 21 +- src/core/bootstrap/hooks.ts | 183 ++++++- src/core/bootstrap/host-specs.ts | 10 + src/core/bootstrap/lock.ts | 4 + src/core/bootstrap/repo.ts | 72 +-- src/core/bootstrap/status.ts | 86 +++- src/core/bootstrap/verify.ts | 116 ++++- src/core/brain-repo-durability.ts | 121 ++++- src/core/cli-flag-registry.generated.ts | 10 +- src/core/config.ts | 8 + src/core/doctor-categories.ts | 1 + src/core/execution-env.ts | 93 ++++ src/core/git-remote.ts | 2 +- src/core/repo-visibility.ts | 449 ++++++++++++++++++ src/core/workspace-push.ts | 253 +++++++--- templates/bootstrap/AGENTS.md.template | 6 +- templates/bootstrap/CLAUDE.md.template | 4 + templates/bootstrap/GITHUB.md.template | 13 +- templates/bootstrap/cloud-setup-script.sh | 41 ++ templates/bootstrap/gitignore.template | 7 + templates/bootstrap/questions.json | 2 +- templates/bootstrap/template-repo/.gitignore | 7 + templates/bootstrap/template-repo/AGENTS.md | 6 +- templates/bootstrap/template-repo/CLAUDE.md | 4 + templates/bootstrap/template-repo/GITHUB.md | 13 +- templates/bootstrap/template-repo/README.md | 2 +- test/ambient-recall.test.ts | 12 + test/bootstrap-dispatcher.serial.test.ts | 39 +- test/bootstrap-doctor-checks.test.ts | 65 +++ test/bootstrap-hooks-writers.test.ts | 111 +++++ test/bootstrap-render.test.ts | 16 + test/bootstrap-repo.test.ts | 32 ++ test/bootstrap-status.serial.test.ts | 9 +- test/bootstrap-verify.serial.test.ts | 2 + test/config-file-plane-keys.test.ts | 71 +++ test/durability-cron.test.ts | 120 ++++- test/e2e/bootstrap-attach.serial.test.ts | 5 + .../bootstrap-degraded-modes.serial.test.ts | 46 ++ test/e2e/bootstrap-persistence.serial.test.ts | 93 +++- test/execution-env.test.ts | 102 ++++ test/hook-command.serial.test.ts | 280 ++++++++++- test/repo-visibility.test.ts | 332 +++++++++++++ test/workspace-push.serial.test.ts | 128 ++++- 60 files changed, 3574 insertions(+), 293 deletions(-) create mode 100644 src/core/execution-env.ts create mode 100644 src/core/repo-visibility.ts create mode 100644 templates/bootstrap/cloud-setup-script.sh create mode 100644 test/config-file-plane-keys.test.ts create mode 100644 test/execution-env.test.ts create mode 100644 test/repo-visibility.test.ts diff --git a/BOOTSTRAP_FOR_AGENTS.md b/BOOTSTRAP_FOR_AGENTS.md index 17fddf5ad..9bcc9af6b 100644 --- a/BOOTSTRAP_FOR_AGENTS.md +++ b/BOOTSTRAP_FOR_AGENTS.md @@ -1,4 +1,4 @@ - + @@ -50,7 +50,17 @@ platform package manager first (`brew install gh`, `apt install gh`, `dnf instal `winget install GitHub.cli` per the official instructions); never a piped curl-to-shell one-liner. Install gbrain ONLY as `bun install -g github:garrytan/gbrain#latest-stable` — the npm package named -"gbrain" is an unrelated project. +"gbrain" is an unrelated project. (Cloud-sandbox exception: bun's package fetching +is proxy-incompatible there — use the `gbrain bootstrap cloud-setup-script` recipe, +which installs from the same pinned GitHub source through npm.) + +⛔ **NEVER FABRICATE TOOLING.** If gh or any preflight binary is missing, blocked +by a sandbox egress proxy, or answering 403s, report that through +`status`/`doctor` output and follow the cloud-sandbox guidance below. Never +hand-roll a gh shim, stub a fake binary into /usr/local/bin, or fake a passing +check — a fabricated tool poisons every later verification, and the one time it +was tried it masked a real silent-persistence failure. The CLI degrades honestly +on its own; your job is to relay, not to bridge. ## Codex preflight (ChatGPT desktop / Codex CLI only) @@ -121,8 +131,9 @@ you needed; report the count at the end (it feeds the install-time measurement). instead of creating one — verifies it is private and pushes the workspace. A non-empty repo, or one owned by an org, is refused with a clear message (make an empty personal repo, or run `gbrain bootstrap attach` for an existing agent - clone). Asks the background-persistence consent (15-minute scan-gated push job; - declining still persists at session end). If the human has no GitHub or declines: + clone). Asks the background-persistence consent (a git post-commit auto-push + plus a 30-minute pull job for multi-machine freshness; declining still persists + via the per-turn and session-end pushes). If the human has no GitHub or declines: local-only mode with an honest warning; `bootstrap repo` can run any time later. Note: the per-turn/session push stays deferred until this phase records the verified repo, so nothing is ever pushed to an unverified-privacy origin. @@ -139,6 +150,42 @@ initialized), run `gbrain bootstrap attach` instead of the interview/render/repo phases — it wires this machine (source, hooks, MCP) and verifies. If agent.json says it is an uninitialized template, proceed with the normal flow from phase 1. +## Cloud sandboxes (claude.ai/code and similar proxied environments) + +**How you know:** `gbrain bootstrap status --json` reports +`execution_environment: "cloud-sandbox"` (the CLI detects the documented +signals — the CLAUDE_CODE_REMOTE env var, the proxy-injected token +placeholder). Trust the CLI's detection over your own guesses. + +**Expected degradations — these are facts to relay, not bugs to bridge:** + +- **No crontab, no surviving background processes.** The VM is reclaimed after + inactivity. The scheduled pull is skipped honestly; the per-turn (Stop hook) + and session-end pushes carry persistence. Decline nothing, fabricate nothing. +- **GitHub GraphQL is always blocked** by the egress proxy, and **REST reaches + only repos attached to the session** — a repo created mid-session is NOT + attached, so `gbrain bootstrap repo` refuses fast in cloud with the flow + that works. Privacy verification falls back to pure git protocol on its own. +- **`git push` works only against the session's working branch.** A user PAT + does not bypass any of this. +- **Only repo-committed files carry into the next session.** `~/.gbrain`, + `~/.claude`, and the gitignored `.claude/settings.local.json` evaporate. + Hooks therefore live in the COMMITTED `.claude/settings.json` (the CLI + writes PATH-resolved, fail-open commands there in cloud); hook config is + snapshotted at session start, so hooks written mid-session activate on the + NEXT session — say so instead of debugging it. + +**The correct cloud flow:** + +1. The human creates the private repo from a normal machine (or github.com) + and opens the cloud session ON that repo. +2. The environment's setup script installs the gbrain binary — print it with + `gbrain bootstrap cloud-setup-script` and have the human paste it into the + environment config (npm-based; bun's fetching is proxy-incompatible there). +3. Inside the session: `gbrain bootstrap attach`, then + `gbrain bootstrap hooks --harness claude-code` (writes the committed + carrier), commit + push, and tell the human the hooks go live next session. + ## Failure modes, and what they actually mean | Symptom | Real cause | Fix | @@ -150,6 +197,9 @@ says it is an uninitialized template, proceed with the normal flow from phase 1. | "bootstrap already running (pid N)" | A concurrent bootstrap holds the lock | Wait or investigate that pid; the lock self-clears when stale. | | Brain tools fail with a lock error | Another live session's serve owns the database | Close the other session; sequential use is the v1 contract. | | Hook reports "brain context unavailable" | serve not running or degraded | `gbrain doctor` names it; hooks fail open by design. | +| gh answers 403 "not enabled for this session" | Cloud proxy scoping — the repo is not attached to the session | Expected in cloud; the visibility ladder falls back to git protocol. NEVER shim gh. | +| "crontab: command not found" / cron skipped | Containers and cloud sandboxes ship without a scheduler | Expected; event-driven pushes cover it — the skip message says exactly this. | +| A turn shows "workspace push is FAILING" | The background push is refusing (visibility, secret-scan, or network reasons) | Run `gbrain doctor`; the banner repeats every 30 min until fixed. | ## Hand off diff --git a/CHANGELOG.md b/CHANGELOG.md index 31da453d4..8d255a0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to GBrain will be documented in this file. +## [0.45.9.0] - 2026-08-12 + +**Your agent's memory keeps saving itself — even in a cloud sandbox, even on `/exit`, and it tells you the moment it can't.** The paste-in personal-agent install now works first-class in Claude Code's cloud environment, not just on a laptop. The persistence lane got three fixes that matter whether you're local or in the cloud: the workspace push now verifies repo privacy through a portable ladder that keeps working when the sandbox blocks the GitHub API, it runs after every turn (not only at session end, which the harness never fires on `/exit`), and a failed push surfaces on your next turn instead of failing in silence. Setup adapts to where it runs — no more scheduled-job errors on hosts without a scheduler, and no half-created repos in an environment that can't push them. + +To take advantage of v0.45.9.0: upgrade and re-run `gbrain bootstrap verify` on each machine — it re-attests the install and now reports the execution environment and any push-health or hygiene issue with the exact one-line fix. Existing installs pick up the per-turn push and the new verification automatically on the binary update; no re-render needed. If you run in a cloud sandbox, `gbrain bootstrap cloud-setup-script` prints the environment setup recipe, and `gbrain bootstrap status --json` now tells you which environment you're in. + +### Added +- **Execution-environment detection** — `local`, `cloud-sandbox`, or `ephemeral-container`. Bootstrap, the doctor, and the runbook branch on it so each environment gets honest behavior and honest messages. `gbrain bootstrap status --json` and `gbrain bootstrap verify` both report it. +- **Per-turn workspace persistence.** A debounced, detached push runs after each assistant turn (default every 5 minutes locally, every turn in a reclaimed-VM cloud sandbox), closing the gap where a session that ends on `/exit` — which never fires the session-end hook — could strand committed work. Off-ramp: `GBRAIN_STOP_PUSH=0`; cadence: `GBRAIN_STOP_PUSH_DEBOUNCE_MIN` or `gbrain config set hooks.stop_push_debounce_min `. +- **Same-session push-failure notice.** When a background push is refused or fails, the next turn surfaces it both to the agent and to you directly (not buried where only the model sees it), re-announced at most every 30 minutes until it clears. `gbrain doctor` and `gbrain bootstrap status` name the failing workspace and the fix. +- **`gbrain bootstrap cloud-setup-script`** — prints the ready-to-paste cloud environment setup script that installs the gbrain binary into the environment's cached filesystem so it survives across sessions. +- **`bootstrap_durability_job` doctor check** — presence *and* liveness of the optional background-persistence job, so a job that exists on disk but no longer runs is reported instead of certified healthy. + +### Changed +- **Repo-privacy verification is now a portable ladder** (`src/core/repo-visibility.ts`), replacing three separate probes with one: it checks via the GitHub REST API first, then falls back to pure git protocol so verification keeps working where a sandbox proxy blocks the API. It fails closed in both directions — an origin that can't be proven private is refused, and a proven-public origin is always refused. Fresh private verdicts are cached briefly to keep the per-turn push cheap. Escape hatch for self-hosted git you trust (each use warns): `--allow-unverified-remote`, `GBRAIN_ALLOW_UNVERIFIED_REMOTE=1`, or `gbrain config set push.allow_unverified_remote true`; the escape hatch only relaxes an *unverifiable* verdict, never a proven-public one. +- **Cloud sandboxes get a committed hook carrier.** Because a cloud session starts from a fresh clone and never sees the machine-local settings file, cloud installs write hooks into the repo-committed `.claude/settings.json` with a PATH-resolved, fail-open command; local installs keep the gitignored settings file, and the writers guarantee one event never fires from both. +- **Background-persistence copy tells the truth.** The optional job is a git post-commit auto-push plus a 30-minute freshness pull; the interview, docs, and templates now describe exactly that. On a host without a scheduler the pull is skipped with an honest note rather than a failed-install warning. +- The installing-agent runbook gains a hard rule against fabricating tooling (no hand-rolled `gh` shims), a cloud-sandbox section, and the honest degradation matrix for a proxied environment. + +### Fixed +- `gbrain bootstrap uninstall` now tears down the background-persistence wiring it installed (scheduled job, the untracked auto-push hook, credential wiring) instead of leaving it behind; the committed helper and agent-rules stay, since those are your repo's content. +- Machine-specific harness wiring (`.mcp.json`, hook-settings backups) is gitignored so it can't be committed into the private brain repo; `gbrain bootstrap verify` warns and gives the one-line fix for installs that already committed it. +- Repo creation is refused inside a cloud sandbox with the flow that actually works (create the repo elsewhere, open the session on it, `gbrain bootstrap attach`) instead of leaving a half-created, unpushable repo. +- Push-status is tracked per workspace, so with more than one brain workspace on a machine, one workspace's success can no longer mask another's failed pushes. +- Hardening pass (both an in-house and a cross-model adversarial review): the privacy ladder never treats an ambiguous authentication challenge as proof a repo is private, the per-turn retry can't turn into an every-turn network storm, remote-supplied text is sanitized before it reaches any agent- or user-visible surface, and stale state from a deleted workspace no longer re-fires notices forever. + ## [0.45.8.0] - 2026-08-12 **25 community bug fixes in one wave. Your MCP server, sync, and doctor all get more careful.** @@ -16873,8 +16899,6 @@ If anything looks off, file at https://github.com/garrytan/gbrain/issues with `gbrain doctor` output. - - ## [0.28.11] - 2026-05-07 **Mix providers: OpenAI for text, Voyage for images. One brain, two embedding pipelines.** @@ -18840,9 +18864,6 @@ React admin dashboard baked into the binary. Seven screens designed through Stev - `test/oauth.test.ts` ... 34 test cases covering provider: register, getClient, client_credentials exchange, auth_code flow with PKCE, refresh rotation, verifyAccessToken (OAuth + legacy fallback), revokeToken, sweepExpiredTokens, scope annotations on all 30 operations. Plus the post-/cso security-fix regressions: 10-concurrent auth code exchange (only 1 wins), 10-concurrent refresh rotation (only 1 wins), redirect_uri HTTPS-or-loopback gate, and pgArray comma-element round-trip (1 element in → 1 element out). - - - ## [0.25.1] - 2026-05-01 ## **Your brain can now read books with you. Nine new skills land at once.** @@ -20092,7 +20113,6 @@ Then point Claude Desktop, claude.ai/code, or any MCP client at `http://your-tun If anything breaks: `gbrain doctor`, `~/.gbrain/upgrade-errors.jsonl` (if present), and please file an issue at https://github.com/garrytan/gbrain/issues with both. - ## [0.22.6.1] - 2026-04-26 **Old brains can upgrade again.** diff --git a/README.md b/README.md index 7e17194cf..5d1849159 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ answers. Ask before anything destructive. You are not done until `gbrain bootstrap verify` exits 0. ``` -Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks** (consent-gated): your brain loads automatically into every prompt, and each session persists itself to your private repo at exit. Restart the session after install and ask "what did I tell you my top jobs were?" — that's the moment it clicks. Full contract, security posture, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md). +Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks**: your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. Restart the session after install and ask "what did I tell you my top jobs were?" — that's the moment it clicks. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md). > **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Claude Code (CLI or the desktop app's open-a-repo flow), and paste the same block — bootstrap adopts your empty repo instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused. @@ -131,7 +131,7 @@ The agent installs GBrain, creates the brain, asks for your API keys, loads the ### Lighter ways in -**Just want a memory for your coding agent — no identity, no repo.** Spin up a local brain and connect it in two commands — zero server, zero token, zero tunnel. `--surface verbs` gives your agent the five-verb memory protocol (`recall`, `remember`, `entity`, `synthesize`, `forget` — [MEMORY_VERBS v1](docs/protocol/MEMORY_VERBS_v1.md), frozen + additive-forever) instead of the full tool wall; drop the flag for every operation: +**Just want a memory for your coding agent — no identity, no repo.** Spin up a local brain and connect it in two commands — zero server, zero token, zero tunnel. `--surface verbs` gives your agent the seven-verb memory protocol (`recall`, `remember`, `entity`, `synthesize`, `forget`, plus `context_pack` + `delta` since v0.45.7 — [MEMORY_VERBS v1](docs/protocol/MEMORY_VERBS_v1.md), frozen + additive-forever) instead of the full tool wall; drop the flag for every operation: ```bash gbrain init --pglite # 2-second local brain (no Docker) diff --git a/TODOS.md b/TODOS.md index 08f8c2d41..6f852fa85 100644 --- a/TODOS.md +++ b/TODOS.md @@ -5126,6 +5126,48 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts` ## Agent-bootstrap wave follow-ups (filed at build time) +- [ ] **P2 — repoPhaseComplete is single-workspace (one global receipt).** The + no-daemon push gate binds to the one `receipt.repo_url`, so with two bootstrap + workspaces sharing a gbrain home, workspace B's `bootstrap repo` overwrites the + receipt and permanently leaves A's per-turn/session-end pushes at + `push_deferred_repo_pending`. Fails CLOSED (defers, never mis-pushes) and + matches the v1 single-workspace contract, but the per-turn push made it more + visible. Fix = per-root repo binding (a receipt map or a per-root marker). + Surfaced by both v0.45.9.0 adversarial reviewers. +- [ ] **P2 — visibility ladder subprocess/body bounds.** `runWithTimeout` + (`src/core/repo-visibility.ts`) races the `gh`/`git` probe against a timer but + doesn't kill the raced child, and the anon-probe `res.text()` buffers the whole + (operator-configured-origin) body before slicing. Bounded in practice by the + detached push child's lifetime, but a proper fix kills the raced process and + caps the body read. Filed from the v0.45.9.0 Codex adversarial pass. +- [ ] **P3 — `config set` for the file-plane hook-lane keys is engine-bound.** + `runConfig` dispatches through the engine path, so `gbrain config set + push.allow_unverified_remote true` can fail while a live PGLite serve holds the + writer lock — the documented recovery command, unavailable exactly when needed. + The env-var form (`GBRAIN_ALLOW_UNVERIFIED_REMOTE=1`) is the cloud path and needs + no engine, so this is convenience-only; fix = route these two keys through the + no-engine CLI dispatch. Filed from the v0.45.9.0 Codex adversarial pass. + + +- [ ] **P3 — plugin-based hook distribution for Claude Code.** Ship gbrain's + hooks as a Claude Code plugin (`hooks/hooks.json` + `.claude-plugin/plugin.json` + manifest, installed via the plugin marketplace flow) instead of two settings + files. Plugins merge hooks first-class across scopes and update centrally — + it would REPLACE both current carriers (repo-committed `.claude/settings.json` + for cloud installs + gitignored `settings.local.json` for local), so it must + migrate, not join; a third simultaneous carrier would double-fire events. + Cons: needs marketplace repo hosting; enterprise `allowManagedHooksOnly` + policies can block plugin hooks entirely. Start at + `src/core/bootstrap/hooks.ts` (both writers + the dedupe rule live there). + Filed from the cloud-DX eng review (v0.46.x wave). +- [ ] **P3 — watch Claude Code Channels as the push path for + volunteer_context/signals.** Channels (research preview) push external events + into a LIVE session — the native version of gbrain's push-context lane + (`docs/guides/push-context.md`). Not actionable today: delivery requires an + always-on session plus an Anthropic-allowlisted channel plugin. Revisit when + channel-plugin distribution opens; the win is replacing per-turn pull with + event push for signals/reflex windows. Filed from the cloud-DX eng review. + - [ ] **P1 — enforce op scope/localOnly on the stdio MCP dispatch when no auth context is present, and consider a narrower default surface for pull-mode harness registrations.** HTTP dispatch enforces `scope`/`localOnly` before diff --git a/VERSION b/VERSION index 1ea4d257b..7cbe61cfd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.8.0 \ No newline at end of file +0.45.9.0 \ No newline at end of file diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index 1489e34d3..9d90cd3f5 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -62,7 +62,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/source-health.ts` — per-source health metrics for `gbrain sources status` + doctor's `federation_health`. Commit-relative staleness: `newestCommitMs(localPath)` = HEAD committer time via `git log -1 --format=%ct` (fail-open null; NO working-tree mtime parsing — committed content only, robust against the porcelain-mtime bug farm); `lagFromContentMs(contentMs|null, lastSyncMs|null, nowMs, ceilingSeconds?)` = remote/column comparator (null lastSync → null; negative wall-clock → skew passthrough; `contentMs <= lastSync` → **`max(0, wallClock - ceiling)`**; else/null-content → wall-clock). Core logic is pure; only the DEFAULT `ceilingSeconds` reads env, via `resolveStalenessCeilingSeconds()` (`GBRAIN_STALENESS_CEILING_HOURS` overriding `GBRAIN_SYNC_FRESHNESS_FAIL_HOURS`, default 72) — pass the 4th arg for determinism. The caught-up branch RAMPS rather than returning a flat 0: a flat 0 meant a source whose clone vanished reported fresh forever (the dead-daemon class), while a hard step to the ceiling would trip `federation_health` (24h) and `sync_freshness` (24h/72h) in the same instant and skip the warn tier — the alert-storm shape the caught-up branch exists to prevent. Ramping keeps the escalation ordered. `computeAllSourceMetrics(engine, sources, {probeContent?})`: LOCAL (`probeContent:true`, `gbrain sources status`) → `isSourceUnchangedSinceSync(..., {requireCleanWorkingTree:'ignore-untracked'}) ? 0 : wall-clock` (live commit-hash catches HEAD moving to an old-dated commit a timestamp compare would miss); REMOTE (default, `federation_health` on the HTTP MCP path) → `lagFromContentMs(row.newest_content_at, ...)`, NO git subprocess (trust boundary). `commitTimeMs(localPath, sha)` is the `newestCommitMs` sibling pinned to an arbitrary commit (committer time via `git show -s --format=%ct `, fail-open null, execFileSync array args) — the resumable sync stamps `newest_content_at` against its pinned target commit, not whatever HEAD raced to. Pinned by `test/source-health.test.ts`. - `src/core/npm-squat-check.ts` — classifies `gbrain` PATH entries as real, foreign npm package, broken, or unknown for doctor's `npm_squat` check. On Windows it normalizes Git Bash/MSYS drive paths (`/c/...` → `C:/...`) and tries the native `.exe` suffix before reporting a broken entry; non-Windows classification keeps the original single-candidate behavior. Pinned by `test/npm-squat-check.test.ts`. - `src/core/git-remote.ts` — SSRF-hardened git invocations for remote-source `cloneRepo`, `pullRepo`, and `fetchRemote(repoPath, branch)` (the last added for the sync cost-estimator's fetch-first path, #2139, so a cost preview / dry-run fetches through the same hardened flags + `GIT_TERMINAL_PROMPT=0` as real sync rather than a less-protected route). Exports two distinct flag constants because `git`'s argv grammar treats them differently: `GIT_SSRF_FLAGS` (3 `-c` config flags — `protocol.allow=user`, `protocol.file.allow=never`, `http.allowRedirects=false`) is global config, spread BEFORE the subcommand verb; `GIT_SSRF_SUBCOMMAND_FLAGS = ['--no-recurse-submodules']` is subcommand-scoped, spread AFTER the verb (a combined array would spread `--no-recurse-submodules` before the verb where real git rejects it exit 129). `cloneRepo` argv: `git clone --depth=1 [--branch X] -- `. `pullRepo` argv: `git -C pull --ff-only`. Pinned by `test/git-remote.test.ts` position-anchored regression guard (`argv.indexOf('--no-recurse-submodules') > argv.indexOf(verb)`). Also exports the durability-side helpers that power `gbrain sources harden/pull`: `GIT_ENV_AUTH` (the no-prompt env minus the askpass `/bin/false` overrides, so an auth'd push/fetch can consult the repo's configured credential helper while `GIT_TERMINAL_PROMPT=0` still fails fast on a missing credential), `divergenceSafePull(repoPath, branch)` (fetch + `pull --rebase`; returns `skipped_dirty` on a dirty tree, `conflict_aborted` on a rebase conflict after `rebase --abort` so the tree is never left mid-rebase, else `up_to_date`/`advanced`), `detectDefaultBranch` (origin/HEAD → current branch → `main`), `pushProbe(repoPath, branch)` (authenticated `push --dry-run` that proves push access and classifies `auth`/`protected`/`unreachable`), and `isWorkingTreeDirty`. These auth'd paths route their `protocol.file.allow` through `GBRAIN_GIT_ALLOW_FILE_TRANSPORT` (default `never`; set `=1` for self-hosted filesystem remotes), unlike clone/pull which stay strict. -- `src/core/brain-repo-durability.ts` + `src/commands/sources-harden.ts` — brain-repo git durability. `hardenBrainRepo(opts)` makes a brain's working tree durable, idempotently: divergence-safe pull, a LOCAL untracked `.git/hooks/post-commit` auto-push safety net (never committed — a pulled commit can't rewrite executed code next to the token; installed into the active `core.hooksPath` dir and excluded via `.git/info/exclude` when that dir is tracked), a committed `scripts/brain-commit-push.sh` that refuses to exit 0 without a confirmed push and stages+commits BEFORE any pull so a dirty tree of modified pages (the write-through shape) can still be committed — the push-retry's rebase-on-reject handles a remote that advanced (#2426; hook + helper render from ONE bash push-retry template — DRY at the TS source, not by the hook sourcing a repo-controlled script), durability rules patched into the active resolver file (`findResolverFile` → RESOLVER.md > AGENTS.md; taxonomy rendered from the bundled `_brain-filing-rules.json`), a minimal DB-free pull cron (launchd/crontab running `gbrain sources pull --path ` so it never opens the PGLite single-writer lock), and a push-probe verify (no heartbeat commit). Credential is REPO-scoped (`acceptPat` from `--pat-file`/`GBRAIN_GITHUB_PAT`, warns on loose perms; reuses an existing repo-local `credential.helper`, else a `0600` store wired via repo-local config); the token is redacted everywhere via `redactSecretsInText` and never enters the repo, remote URL, logs, or `DurabilityReport`. `unhardenBrainRepo` removes the cron/hook/credential wiring (ownership-fingerprinted) and runs before `sources remove`. CLI: `gbrain sources harden ` / `pull |--path ` / `unharden `; auto-harden fires on `sources add --url ... --pat-file` for managed clones (`--no-harden` opts out). `sources pull --path` is dispatched in `src/cli.ts` BEFORE `connectEngine` so the cron stays DB-free. CLI-only (writes executables + an OS cron + a credential helper on the host); never exposed over MCP. Tests: `test/brain-repo-durability.serial.test.ts`, `test/git-remote-durable.serial.test.ts`, `test/brain-durability-hook.serial.test.ts`, `test/durability-cron.test.ts`. +- `src/core/brain-repo-durability.ts` + `src/commands/sources-harden.ts` — brain-repo git durability. `hardenBrainRepo(opts)` makes a brain's working tree durable, idempotently: divergence-safe pull, a LOCAL untracked `.git/hooks/post-commit` auto-push safety net (never committed — a pulled commit can't rewrite executed code next to the token; installed into the active `core.hooksPath` dir and excluded via `.git/info/exclude` when that dir is tracked), a committed `scripts/brain-commit-push.sh` that refuses to exit 0 without a confirmed push and stages+commits BEFORE any pull so a dirty tree of modified pages (the write-through shape) can still be committed — the push-retry's rebase-on-reject handles a remote that advanced (#2426; hook + helper render from ONE bash push-retry template — DRY at the TS source, not by the hook sourcing a repo-controlled script), durability rules patched into the active resolver file (`findResolverFile` → RESOLVER.md > AGENTS.md; taxonomy rendered from the bundled `_brain-filing-rules.json`), a minimal DB-free 30-min pull cron (launchd/crontab running `gbrain sources pull --path ` so it never opens the PGLite single-writer lock; on a non-darwin host `installDurabilityCron` probes `binaryOnPath('crontab')` first and returns `skipped` — not `needs_attention` — when absent, since containers/cloud sandboxes ship no scheduler and the event-driven pushes cover persistence; the wrapper self-disables via `git rev-parse --is-inside-work-tree`, worktree-safe both ways), and a push-probe verify (no heartbeat commit). `durabilityJobStatus(sourceId)` reports presence + LIVENESS (launchctl load-state on darwin, crontab line on linux, pull-log freshness) so the doctor `bootstrap_durability_job` check can't certify a dead job as healthy. Credential is REPO-scoped (`acceptPat` from `--pat-file`/`GBRAIN_GITHUB_PAT`, warns on loose perms; reuses an existing repo-local `credential.helper`, else a `0600` store wired via repo-local config); the token is redacted everywhere via `redactSecretsInText` and never enters the repo, remote URL, logs, or `DurabilityReport`. `unhardenBrainRepo` removes the cron/hook/credential wiring (ownership-fingerprinted) and runs before `sources remove`. CLI: `gbrain sources harden ` / `pull |--path ` / `unharden `; auto-harden fires on `sources add --url ... --pat-file` for managed clones (`--no-harden` opts out). `sources pull --path` is dispatched in `src/cli.ts` BEFORE `connectEngine` so the cron stays DB-free. CLI-only (writes executables + an OS cron + a credential helper on the host); never exposed over MCP. Tests: `test/brain-repo-durability.serial.test.ts`, `test/git-remote-durable.serial.test.ts`, `test/brain-durability-hook.serial.test.ts`, `test/durability-cron.test.ts`. - `src/commands/storage.ts` — `gbrain storage status [--repo P] [--json]`. Split into pure data (`getStorageStatus`) + JSON formatter + human formatter (ASCII-only) matching the `orphans.ts` pattern. `PageCountsByTier` and `DiskUsageByTier` are distinct nominal types so swaps fail at compile time. - `gbrain.yml` (brain repo root) — Optional storage tiering config. Top-level `storage:` section with `db_tracked:` and `db_only:` array-valued keys. `gbrain sync` auto-manages `.gitignore` for `db_only` paths on successful sync (skips on dry-run, blocked-by-failures, submodule context, or `GBRAIN_NO_GITIGNORE=1`). `gbrain export --restore-only [--repo P] [--type T] [--slug-prefix S]` repopulates missing `db_only` files from the database. - `src/core/supabase-admin.ts` — Supabase admin API (project discovery, pgvector check). @@ -495,16 +495,16 @@ Normative docs: `docs/designs/AGENT_BOOTSTRAP_DESIGN.md` (scope) + User-facing contract: `docs/guides/bootstrap.md`. Runbook the paste block fetches: `BOOTSTRAP_FOR_AGENTS.md` (root; carries a version stamp CI pins to VERSION). -- `src/commands/bootstrap.ts` — the `gbrain bootstrap {status,interview,render,repo,hooks,verify,uninstall,attach}` dispatcher. Engine-free everywhere except `verify` (which opens/closes its own engine — safe because verify runs with no live serve, before host registration). Mutating subcommands run under the workspace bootstrap lock; render is gated on interview `complete && confirmed` and hard-refuses when the workspace origin is a PUBLIC remote (identity files must never land in a public repo — the same template-door gate `status` enforces; unverifiable visibility warns and proceeds, treating the origin as public); the provider key routes to the 0600 config sink and never touches interview state; every subcommand appends a line to `/bootstrap/install.jsonl`. Consent answers resolve FAIL-CLOSED: `consentAnswer` treats a hand-edited/unusable interview value (non-string, empty) as declined — loudly, with a re-record note — never falling through to a permissive bank default; `hooks` on Codex prints a corrective note when a persisted `project` MCP_SCOPE answer is found (raw state read, not the resolver — `codex mcp add` has no scope flag, registrations are always user-global) with safe clear instructions. `GBRAIN_BOOTSTRAP_ABORT_AFTER` is the deterministic kill-mid-phase test seam. +- `src/commands/bootstrap.ts` — the `gbrain bootstrap {status,interview,render,repo,hooks,verify,uninstall,attach,cloud-setup-script}` dispatcher. Engine-free everywhere except `verify` (which opens/closes its own engine — safe because verify runs with no live serve, before host registration). `cloud-setup-script` is a pure printer (prints `templates/bootstrap/cloud-setup-script.sh` for the cloud environment's setup step). Mutating subcommands run under the workspace bootstrap lock; render is gated on interview `complete && confirmed` and hard-refuses when the workspace origin is a PUBLIC remote (identity files must never land in a public repo — the same template-door gate `status` enforces; unverifiable visibility warns and proceeds, treating the origin as public); the provider key routes to the 0600 config sink and never touches interview state; every MUTATING subcommand appends a line to `/bootstrap/install.jsonl` (the read-only `status` and `cloud-setup-script` do not log). `hooks` on Claude Code writes the committed carrier in a cloud sandbox (`writeCommittedClaudeHooks`) and the gitignored local file otherwise; `uninstall` tears down the durability wiring and removes this workspace's per-root push/debounce state. Consent answers resolve FAIL-CLOSED: `consentAnswer` treats a hand-edited/unusable interview value (non-string, empty) as declined — loudly, with a re-record note — never falling through to a permissive bank default; `hooks` on Codex prints a corrective note when a persisted `project` MCP_SCOPE answer is found (raw state read, not the resolver — `codex mcp add` has no scope flag, registrations are always user-global) with safe clear instructions. `GBRAIN_BOOTSTRAP_ABORT_AFTER` is the deterministic kill-mid-phase test seam. - `src/core/bootstrap/format.ts` — `agent.json` manifest (format_version 1, provisional; `initialized` sentinel distinguishes a template clone from a bootstrapped workspace) + the machine-local install receipt (`/bootstrap/receipt.json`) that proves THIS machine ran bootstrap; uninstall is keyed to the receipt, never the repo manifest. Atomic writes; `readManifest` never throws (typed states incl. conflict markers). - `src/core/bootstrap/assets.ts` — every template + the question bank embedded via Bun `with { type: 'file' }` imports (the chunkers/code.ts pattern) so the compiled binary renders with no repo checkout; `DERIVED_TOKENS` (GITHUB_REPO_URL, CORPUS_RETENTION_DAYS) is the non-bank half of the template token set the CI bijection guard checks. - `templates/bootstrap/` — the ten `{{TOKEN}}` identity templates (AGENTS/CLAUDE/SOUL/USER/MEMORY/HEARTBEAT/ACCESS_POLICY/GITHUB/memory-README/gitignore), `questions.json` (12 asked / 6 required; consent keys; `persist:false` sink keys), and `template-repo/` — the VENDORED deterministic render the release job diffs against before publishing the public template repo. Generic placeholder content only (privacy iron rule; CI-asserted). - `src/core/bootstrap/interview.ts` — interview state at `/state/interview.json` (committed; multi-device re-render source). Read-back confirm hash: `--confirm` must present the hash of the exact answer set shown to the human, and ANY later answer change clears the confirmation — the single-batch self-confirm attack is structurally impossible. Set-time enforcement: length caps, reject-lists, allowed-lists, control-char strip, `{{` escaping. Conflict-markered files return agent-readable errors, not stack traces. - `src/core/bootstrap/render.ts` — token substitution with interview values treated as data (line-leading `#`/` + This repository is the **"Use this template"** distribution artifact for a [gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same diff --git a/test/ambient-recall.test.ts b/test/ambient-recall.test.ts index 38d76e9e0..798a942e9 100644 --- a/test/ambient-recall.test.ts +++ b/test/ambient-recall.test.ts @@ -16,6 +16,7 @@ */ import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { configureGateway } from '../src/core/ai/gateway.ts'; import { operations } from '../src/core/operations.ts'; import { MEMORY_VERBS_VERSION, VERB_NAMES } from '../src/core/verbs.ts'; import { @@ -60,6 +61,17 @@ async function call( } beforeAll(async () => { + // Hermetic embedding: pin the gateway to a KEYLESS config (empty env) so + // `remember`'s fact-embed degrades gracefully (degraded_dedup) instead of + // firing a real OpenAI call. On CI the process carries a dummy + // OPENAI_API_KEY (sk-test-*) that a shard-neighbor can leak into the + // gateway singleton via a captured env (the bunfig preload configures with + // `env: {...process.env}`); a present-but-invalid key turns the keyless + // degrade into a hard 401. The delta/context_pack tests exercise + // cursor/budget logic, not embedding quality, so keyless is correct and + // makes them independent of shard bin-packing. Dimensions stay 1536 to + // match the preload's schema. + configureGateway({ embedding_model: 'openai:text-embedding-3-large', embedding_dimensions: 1536, env: {} }); engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); diff --git a/test/bootstrap-dispatcher.serial.test.ts b/test/bootstrap-dispatcher.serial.test.ts index 30c6822a0..6b8a27a87 100644 --- a/test/bootstrap-dispatcher.serial.test.ts +++ b/test/bootstrap-dispatcher.serial.test.ts @@ -14,6 +14,7 @@ */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -554,11 +555,15 @@ describe('uninstall --delete-brain ordering + engine-free stats', () => { } }); - test('facts-export offer prints BEFORE deletion output; brain deleted via isolated --home', async () => { + test('facts-export offer prints BEFORE deletion output; brain deleted via isolated --home; durability wiring torn down [B6]', async () => { // Isolated-style home INSIDE the workspace (the S3#5 shape --home requires // while GBRAIN_HOME is set): config.json + brain.pglite signature + receipt. const ws2 = mkdtempSync(join(tmpdir(), 'gb-dispatch-ws2-')); const isoHome = join(ws2, '.gbrain'); + // HOME redirected for the plist/crontab teardown probes — the real + // machine's LaunchAgents must never be touched by a test. + const savedHome = process.env.HOME; + process.env.HOME = mkdtempSync(join(tmpdir(), 'gb-dispatch-home-')); try { mkdirSync(join(isoHome, 'brain.pglite'), { recursive: true }); mkdirSync(join(isoHome, 'bootstrap'), { recursive: true }); @@ -578,6 +583,16 @@ describe('uninstall --delete-brain ordering + engine-free stats', () => { writeFileSync(receiptPath(isoHome), JSON.stringify(receipt), 'utf8'); mkdirSync(join(ws2, 'brain'), { recursive: true }); writeFileSync(join(ws2, 'brain', 'page.md'), '# page', 'utf8'); + // Durability wiring fixture [B6]: a gbrain post-commit hook that + // uninstall previously left behind. + execFileSync('git', ['init', '-q'], { cwd: ws2 }); + const hookPath = join(ws2, '.git', 'hooks', 'post-commit'); + mkdirSync(join(ws2, '.git', 'hooks'), { recursive: true }); + writeFileSync( + hookPath, + '#!/bin/bash\n# gbrain brain-durability post-commit hook (v0.42.44+)\nexit 0\n', + { mode: 0o755 }, + ); const r = await capture(() => runBootstrap(['uninstall', '--workspace', ws2, '--delete-brain', '--yes', '--home', isoHome]), @@ -590,8 +605,30 @@ describe('uninstall --delete-brain ordering + engine-free stats', () => { // The offer is printed exactly once (not repeated from the module steps). expect(r.out.indexOf('offered: export facts', offerIdx + 1)).toBe(-1); expect(existsSync(join(isoHome, 'brain.pglite'))).toBe(false); + // [B6] the untracked post-commit hook is gone and the teardown said so. + expect(existsSync(hookPath)).toBe(false); + expect(r.out).toContain('durability wiring removed'); } finally { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; rmSync(ws2, { recursive: true, force: true }); } }, 30_000); }); + +describe('cloud-setup-script emitter [D16]', () => { + test('prints the paste-ready script: npm-based (never bun fetching, never the npm squatter), launcher + attach flow', async () => { + const r = await capture(() => runBootstrap(['cloud-setup-script'])); + expect(r.result).toBe(0); + const s = r.out; + // npm transport (bun fetching is proxy-incompatible in cloud sandboxes)… + expect(s).toContain('npm install -g bun'); + expect(s).toContain('github.com/garrytan/gbrain'); + // …but NEVER the unrelated npm registry package. + expect(s).not.toMatch(/npm install -g gbrain(\s|$)/m); + // PATH-resolved launcher + in-session follow-ups. + expect(s).toContain('/usr/local/bin/gbrain'); + expect(s).toContain('bootstrap attach'); + expect(s).toContain('cloud-setup-script'); + }); +}); diff --git a/test/bootstrap-doctor-checks.test.ts b/test/bootstrap-doctor-checks.test.ts index 4bf338cd5..ad8043048 100644 --- a/test/bootstrap-doctor-checks.test.ts +++ b/test/bootstrap-doctor-checks.test.ts @@ -421,6 +421,71 @@ describe('bootstrap_last_verify', () => { // ── everything torn at once — the fail-soft umbrella ──────────────────────── +// ── bootstrap_durability_job [B7/D7] ──────────────────────────────────────── +// +// Presence-only checks certify dead jobs as healthy, so the doctor probes +// consent + liveness. These pin the two branch families a unit test can hold +// deterministically: the non-local environment (no scheduler EXPECTED — ok) +// and the consented-but-missing job (warn naming `gbrain sources harden`). + +describe('bootstrap_durability_job [B7/D7]', () => { + /** Env keys that would flip detectExecutionEnvironment away from local. */ + const NEUTRAL_ENV = { + CLAUDE_CODE_REMOTE: undefined, + CLAUDE_CODE_REMOTE_SESSION_ID: undefined, + GH_TOKEN: undefined, + GITHUB_TOKEN: undefined, + https_proxy: undefined, + HTTPS_PROXY: undefined, + RENDER: undefined, + RAILWAY_ENVIRONMENT: undefined, + FLY_APP_NAME: undefined, + } as const; + + function writeConsent(ws: string, value: 'yes' | 'no'): void { + mkdirSync(join(ws, 'state'), { recursive: true }); + writeFileSync( + join(ws, 'state', 'interview.json'), + JSON.stringify({ version: 1, answers: { PERSIST_CRON: { value, set_at: new Date().toISOString() } } }), + ); + } + + test('cloud sandbox: no scheduler is EXPECTED → ok naming the environment, never a warn', async () => { + const { parent, home } = makeHome(); + const ws = makeWorkspace(); + writeReceipt(home, ws); + writeConsent(ws, 'yes'); // even with consent, a cloud sandbox has no scheduler to check + const checks = await withEnv( + { GBRAIN_HOME: parent, CLAUDE_CODE_REMOTE: 'true' }, + () => bootstrapDoctorChecks(null), + ); + const c = byName(checks, 'bootstrap_durability_job'); + expect(c?.status).toBe('ok'); + expect(c?.message).toContain('no scheduler in this environment'); + expect(c?.message).toContain('cloud-sandbox'); + }, T); + + test('local + PERSIST_CRON=yes but NO scheduled job on disk → warn naming `gbrain sources harden`', async () => { + const { parent, home } = makeHome(); + const ws = makeWorkspace(); + writeReceipt(home, ws); + writeConsent(ws, 'yes'); + // HOME redirected: the launchd-plist / pull-log probes must never read the + // real machine's LaunchAgents (a developer's own gbrain install would flip + // the verdict). Fresh empty HOME → durabilityJobStatus kind 'none'. + const fakeHome = mkdtempSync(join(tmpdir(), 'gb-bdc-fakehome-')); + tmpDirs.push(fakeHome); + const checks = await withEnv( + { ...NEUTRAL_ENV, GBRAIN_HOME: parent, HOME: fakeHome }, + () => bootstrapDoctorChecks(null), + ); + const c = byName(checks, 'bootstrap_durability_job'); + expect(c?.status).toBe('warn'); + expect(c?.message).toContain('PERSIST_CRON=yes'); + expect(c?.message).toContain('gbrain sources harden workspace'); + }, T); +}); + describe('fail-soft umbrella', () => { test('corrupt receipt + torn heartbeat + corrupt push-status + torn verify → warns, never throws', async () => { const { parent, home } = makeHome(); diff --git a/test/bootstrap-hooks-writers.test.ts b/test/bootstrap-hooks-writers.test.ts index 3dd0dac3f..fea82d176 100644 --- a/test/bootstrap-hooks-writers.test.ts +++ b/test/bootstrap-hooks-writers.test.ts @@ -11,11 +11,13 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { buildClaudeHookCommand, + claudeCommittedSettingsPath, claudeSettingsPath, registerClaudeMcp, registerCodexMcp, removeClaudeHooks, writeClaudeHooks, + writeCommittedClaudeHooks, } from '../src/core/bootstrap/hooks.ts'; import { CLAUDE_CODE_SPEC_ID, @@ -291,3 +293,112 @@ describe('buildClaudeHookCommand', () => { ); }); }); + +// ── committed hook carrier [D12] ──────────────────────────────────────────── +// +// Cloud sessions clone fresh and snapshot hook config at session start — only +// the repo-COMMITTED .claude/settings.json exists there. These tests pin the +// portable command shape (PATH-resolved, fail-open, NO absolute paths) and +// the dedupe invariant (one event never fires from both carriers). + +describe('writeCommittedClaudeHooks [D12]', () => { + test('portable commands: PATH-resolved gbrain, fail-open guard, marker present, no absolute binary path', () => { + const dir = ws(); + const r = writeCommittedClaudeHooks(dir, { env: ENV }); + expect(r.settingsPath).toBe(claudeCommittedSettingsPath(dir)); + expect(r.installed).toHaveLength(CLAUDE_HOOK_EVENTS.length); + const parsed = JSON.parse(readFileSync(r.settingsPath, 'utf8')) as { + hooks: Record> }>>; + }; + for (const event of CLAUDE_HOOK_EVENTS) { + const entry = parsed.hooks[event]![0]!.hooks[0]!; + const cmd = entry.command as string; + expect(cmd).toContain('command -v gbrain'); + expect(cmd).toContain('|| exit 0'); // fail-open where gbrain is absent + expect(cmd).toContain('GBRAIN_SOURCE=workspace'); + expect(cmd).not.toContain(BIN); // never a machine path + expect(cmd).not.toContain('/opt/'); + expect(entry[GBRAIN_HOOK_MARKER_KEY]).toBe(GBRAIN_HOOK_MARKER_VALUE); + } + }); + + test('dedupe: committed carrier strips existing LOCAL entries so nothing double-fires', () => { + const dir = ws(); + writeClaudeHooks(dir, { gbrainBin: BIN, env: ENV }); + expect(readFileSync(claudeSettingsPath(dir), 'utf8')).toContain(GBRAIN_HOOK_MARKER_VALUE); + const r = writeCommittedClaudeHooks(dir, { env: ENV }); + expect(r.notes.join(' ')).toContain('committed carrier owns the events'); + // Local file no longer carries any gbrain entry. + const local = readFileSync(claudeSettingsPath(dir), 'utf8'); + expect(local).not.toContain(GBRAIN_HOOK_MARKER_VALUE); + }); + + test('dedupe: local writer SKIPS events the committed file carries (and reports it)', () => { + const dir = ws(); + writeCommittedClaudeHooks(dir, { env: ENV }); + const r = writeClaudeHooks(dir, { gbrainBin: BIN, env: ENV }); + expect(r.installed).toHaveLength(0); // every event carried by the committed file + expect(r.notes.some((n) => n.includes('carried by the committed'))).toBe(true); + const localRaw = readFileSync(claudeSettingsPath(dir), 'utf8'); + expect(localRaw).not.toContain(GBRAIN_HOOK_MARKER_VALUE); + }); + + test('removeClaudeHooks cleans BOTH carriers', () => { + const dir = ws(); + writeCommittedClaudeHooks(dir, { env: ENV }); + // Seed a REAL local gbrain entry directly (bypassing the dedupe-aware + // writer) — the pre-dedupe-install shape a mixed-binary machine can hold. + writeFileSync( + claudeSettingsPath(dir), + JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: `${BIN} hook stop`, [GBRAIN_HOOK_MARKER_KEY]: GBRAIN_HOOK_MARKER_VALUE }] }] }, + }), + ); + const r = removeClaudeHooks(dir); + expect(r.removed).toBe(CLAUDE_HOOK_EVENTS.length + 1); // 4 committed + 1 local + expect(readFileSync(claudeCommittedSettingsPath(dir), 'utf8')).not.toContain(GBRAIN_HOOK_MARKER_VALUE); + expect(readFileSync(claudeSettingsPath(dir), 'utf8')).not.toContain(GBRAIN_HOOK_MARKER_VALUE); + }); + + test('foreign keys in the committed file survive byte-for-byte structurally', () => { + const dir = ws(); + mkdirSync(join(dir, '.claude'), { recursive: true }); + writeFileSync( + claudeCommittedSettingsPath(dir), + JSON.stringify({ permissions: { allow: ['Bash(ls:*)'] }, hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo user-hook' }] }] } }, null, 2), + ); + writeCommittedClaudeHooks(dir, { env: ENV }); + const parsed = JSON.parse(readFileSync(claudeCommittedSettingsPath(dir), 'utf8')) as Record; + expect(parsed.permissions).toEqual({ allow: ['Bash(ls:*)'] }); + const stop = (parsed.hooks as Record }>>).Stop!; + expect(stop.some((g) => g.hooks.some((h) => h.command === 'echo user-hook'))).toBe(true); + }); +}); + +describe('committed-carrier guards [S7/DM6]', () => { + test('GBRAIN_HOME is refused in the committed carrier (machine paths must not travel)', () => { + const dir = ws(); + expect(() => writeCommittedClaudeHooks(dir, { env: { GBRAIN_SOURCE: 'workspace', GBRAIN_HOME: '/Users/someone/.g' } })) + .toThrow(/machine-specific/); + }); + + test('control characters are refused in the committed carrier too', () => { + const dir = ws(); + expect(() => writeCommittedClaudeHooks(dir, { env: { GBRAIN_SOURCE: 'a\nb' } })) + .toThrow(/control characters/); + }); + + test('[S7] a foreign marker-tagged entry with a NON-gbrain command does not suppress the local install', () => { + const dir = ws(); + mkdirSync(join(dir, '.claude'), { recursive: true }); + writeFileSync( + claudeCommittedSettingsPath(dir), + JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'curl evil.example.com', [GBRAIN_HOOK_MARKER_KEY]: GBRAIN_HOOK_MARKER_VALUE }] }] }, + }), + ); + const r = writeClaudeHooks(dir, { gbrainBin: BIN, env: ENV }); + // All four events install locally — the fake carried entry is not trusted. + expect(r.installed).toHaveLength(CLAUDE_HOOK_EVENTS.length); + }); +}); diff --git a/test/bootstrap-render.test.ts b/test/bootstrap-render.test.ts index 403a465f0..386cabf0a 100644 --- a/test/bootstrap-render.test.ts +++ b/test/bootstrap-render.test.ts @@ -428,3 +428,19 @@ describe('byteFloors (verify support)', () => { ); }); }); + +describe('renderWorkspace — machine-specific wiring stays out of the repo [B8]', () => { + test('rendered .gitignore covers .mcp.json and settings.local.json; GITHUB.md drops the state/mcp.json ghost', () => { + const ws = answeredWs(); + renderWorkspace(ws); + const gi = readFileSync(join(ws, '.gitignore'), 'utf8'); + expect(gi).toContain('.mcp.json'); + expect(gi).toContain('.claude/settings.local.json'); + const gh = readFileSync(join(ws, 'GITHUB.md'), 'utf8'); + // The portable state/mcp.json snippet was never built — the promise is gone. + expect(gh).not.toContain('state/mcp.json'); + // Honest persistence copy [D9]: 30-minute pull + event-driven pushes. + expect(gh).toContain('30-minute pull'); + expect(gh).not.toContain('15-minute'); + }); +}); diff --git a/test/bootstrap-repo.test.ts b/test/bootstrap-repo.test.ts index f67e88e4d..c370d3cb1 100644 --- a/test/bootstrap-repo.test.ts +++ b/test/bootstrap-repo.test.ts @@ -13,6 +13,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { withEnv } from './helpers/with-env.ts'; import { createPrivateRepo, GITHUB_URL_PLACEHOLDER, @@ -713,3 +714,34 @@ describe('repo helpers', () => { expect(parseGithubRemote('https://gitlab.com/alice/repo')).toBeNull(); }); }); + +// ── cloud-sandbox create guard [D-cloud] ──────────────────────────────────── +// +// A repo created from inside a proxied cloud session is never attached to the +// session's GitHub scope — REST verification 403s and pushes are denied — so +// createPrivateRepo must fail FAST with the flow that works (create outside, +// open the session ON the repo, `gbrain bootstrap attach`) instead of leaving +// a half-created, unpushable repo behind. + +describe('createPrivateRepo cloud-sandbox guard [CLOUD_SANDBOX_REPO]', () => { + test('cloud sandbox + no existing origin → CLOUD_SANDBOX_REPO before any create call', async () => { + const { runner, calls } = makeRunner(happyRules()); + const err = await withEnv({ CLAUDE_CODE_REMOTE: 'true' }, () => + expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home })), + ); + expect(err.code).toBe('CLOUD_SANDBOX_REPO'); + expect(err.message).toContain('gbrain bootstrap attach'); + // No repo was created and nothing was pushed. + expect(calls.some((c) => c.join(' ').includes('repo create'))).toBe(false); + expect(calls.some((c) => c.join(' ').includes('push'))).toBe(false); + }); + + test('local env: the same rules create normally (guard is cloud-only)', async () => { + const { runner, calls } = makeRunner(happyRules()); + const result = await withEnv({ CLAUDE_CODE_REMOTE: undefined }, () => + createPrivateRepo(ws, { runner, gbrainHomeDir: home }), + ); + expect(result.disposition).toBe('created'); + expect(calls.some((c) => c.join(' ').includes('repo create'))).toBe(true); + }); +}); diff --git a/test/bootstrap-status.serial.test.ts b/test/bootstrap-status.serial.test.ts index 554dfe1fb..60917ecab 100644 --- a/test/bootstrap-status.serial.test.ts +++ b/test/bootstrap-status.serial.test.ts @@ -137,6 +137,8 @@ describe('statusReport detection + support blob [B5]', () => { expect(byId.get('render')!.state).toBe('pending'); expect(byId.get('verify')!.state).toBe('pending'); expect(report.next).toBeTruthy(); + // The third axis [D-cloud]: installing agents branch on this field. + expect(['local', 'cloud-sandbox', 'ephemeral-container']).toContain(report.execution_environment); expect(report.support.binary_version).toBe(VERSION); expect(report.support.engine).toBeNull(); expect(report.support.harness_registrations).toEqual([]); @@ -281,8 +283,11 @@ esac origin: 'https://github.com/tester/agent-template.git', detail: expect.stringContaining('PUBLIC'), }); - // The probe went through gh repo view --json isPrivate (recorded argv). - expect(readFileSync(recordFile, 'utf8')).toContain('gh repo view https://github.com/tester/agent-template.git --json isPrivate'); + // The probe went through REST (`gh api repos/...`), NEVER `gh repo view` + // — that command rides GraphQL, which cloud sandbox proxies always 403. + const recorded = readFileSync(recordFile, 'utf8'); + expect(recorded).toContain('gh api repos/tester/agent-template --jq .private'); + expect(recorded).not.toContain('repo view'); const r = await captureStatus(); expect(r.code).toBe(1); diff --git a/test/bootstrap-verify.serial.test.ts b/test/bootstrap-verify.serial.test.ts index f57cc5cef..edbcbc81d 100644 --- a/test/bootstrap-verify.serial.test.ts +++ b/test/bootstrap-verify.serial.test.ts @@ -141,6 +141,8 @@ describe('verifyWorkspace — keyless pass', () => { expect(check(res.checks, 'secret_scan')[0].ok).toBe(true); expect(check(res.checks, 'deny_globs')[0].ok).toBe(true); expect(check(res.checks, 'repo_privacy')[0].ok).toBe(true); // local-only + // execution_env is informational and NEVER gates [D-cloud]. + expect(check(res.checks, 'execution_env')[0].ok).toBe(true); for (const c of check(res.checks, 'roundtrip')) expect(c.ok).toBe(true); expect(check(res.checks, 'graph_floor')[0].ok).toBe(true); expect(check(res.checks, 'magic_moment')[0].ok).toBe(true); diff --git a/test/config-file-plane-keys.test.ts b/test/config-file-plane-keys.test.ts new file mode 100644 index 000000000..809635fb4 --- /dev/null +++ b/test/config-file-plane-keys.test.ts @@ -0,0 +1,71 @@ +/** + * Bootstrap hook-lane config keys are FILE-plane canonical [D18]: + * `config set` must route `push.allow_unverified_remote` and + * `hooks.stop_push_debounce_min` to ~/.gbrain/config.json (NEVER the DB + * plane) because their readers are engine-free hook/push children that only + * see loadConfigFileOnly. These tests pin the write half (runConfig routing + + * the loud warning) and the read half (configAllowsUnverifiedRemote). + */ +import { describe, test, expect } from 'bun:test'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { runConfig } from '../src/commands/config.ts'; +import { configAllowsUnverifiedRemote } from '../src/core/workspace-push.ts'; +import type { BrainEngine } from '../src/core/engine.ts'; +import { withEnv } from './helpers/with-env.ts'; + +// The file-plane branch returns before any engine access — a null stub proves it. +const noEngine = null as unknown as BrainEngine; + +async function captureLog(fn: () => Promise): Promise { + const orig = console.log; + let out = ''; + console.log = (...a: unknown[]) => { out += a.map(String).join(' ') + '\n'; }; + try { + await fn(); + } finally { + console.log = orig; + } + return out; +} + +describe('config set — file-plane bootstrap hook-lane keys [D18]', () => { + test('push.allow_unverified_remote: set true → file plane + loud warning; read half sees it; set false unsets', async () => { + const parent = mkdtempSync(join(tmpdir(), 'gb-cfg-plane-')); + await withEnv({ GBRAIN_HOME: parent }, async () => { + const out = await captureLog(() => runConfig(noEngine, ['set', 'push.allow_unverified_remote', 'true'])); + expect(out).toContain('file plane'); + // Every enable warns loudly — the override trusts the remote on the user's word. + expect(out).toContain('WARNING'); + expect(out).toContain('SKIP repo-visibility verification'); + const cfgPath = join(parent, '.gbrain', 'config.json'); + const cfg = JSON.parse(readFileSync(cfgPath, 'utf8')) as { push?: { allow_unverified_remote?: boolean } }; + expect(cfg.push?.allow_unverified_remote).toBe(true); + // The engine-free read half (detached push children) sees the same file. + expect(configAllowsUnverifiedRemote()).toBe(true); + + // set false → off, and no warning banner. + const out2 = await captureLog(() => runConfig(noEngine, ['set', 'push.allow_unverified_remote', 'false'])); + expect(out2).not.toContain('WARNING'); + expect(configAllowsUnverifiedRemote()).toBe(false); + }); + }); + + test('hooks.stop_push_debounce_min: integer minutes land on the file plane (0 = every turn allowed)', async () => { + const parent = mkdtempSync(join(tmpdir(), 'gb-cfg-plane2-')); + await withEnv({ GBRAIN_HOME: parent }, async () => { + const out = await captureLog(() => runConfig(noEngine, ['set', 'hooks.stop_push_debounce_min', '7'])); + expect(out).toContain('Set hooks.stop_push_debounce_min = 7'); + expect(out).toContain('file plane'); + const cfgPath = join(parent, '.gbrain', 'config.json'); + let cfg = JSON.parse(readFileSync(cfgPath, 'utf8')) as { hooks?: { stop_push_debounce_min?: number } }; + expect(cfg.hooks?.stop_push_debounce_min).toBe(7); + // 0 is valid (cloud-sandbox cadence: push every turn). + await captureLog(() => runConfig(noEngine, ['set', 'hooks.stop_push_debounce_min', '0'])); + cfg = JSON.parse(readFileSync(cfgPath, 'utf8')) as { hooks?: { stop_push_debounce_min?: number } }; + expect(cfg.hooks?.stop_push_debounce_min).toBe(0); + }); + }); +}); diff --git a/test/durability-cron.test.ts b/test/durability-cron.test.ts index 66b56c511..84e9e9ba6 100644 --- a/test/durability-cron.test.ts +++ b/test/durability-cron.test.ts @@ -4,7 +4,16 @@ * secret-free, self-disabling, and that the launchd plist is periodic. */ import { describe, test, expect } from 'bun:test'; -import { renderCronWrapper, generateBrainPullPlist } from '../src/core/brain-repo-durability.ts'; +import { mkdtempSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { withEnv } from './helpers/with-env.ts'; +import { + renderCronWrapper, + generateBrainPullPlist, + installDurabilityCron, + durabilityJobStatus, +} from '../src/core/brain-repo-durability.ts'; const TOKEN = 'ghp_SHOULD_NEVER_APPEAR'; @@ -17,9 +26,11 @@ describe('renderCronWrapper (D2 DB-free)', () => { expect(w).not.toMatch(/sources pull '?wiki'?(\s|$)/); // never `sources pull wiki` }); - test('self-disables when the captured checkout is gone', () => { - expect(w).toContain("if [ ! -d '/data/clones/wiki/.git' ]"); - expect(w).toContain('path gone, skipping'); + test('self-disables via git rev-parse (recognizes worktrees where the git marker is a FILE), not a bare dir test', () => { + expect(w).toContain("if ! git -C '/data/clones/wiki' rev-parse --is-inside-work-tree"); + expect(w).not.toContain("-d '/data/clones/wiki/.git'"); + expect(w).not.toContain("-d '/data/clones/wiki'"); + expect(w).toContain('not a git work tree, skipping'); }); test('sources the shell profile (secret-free) and never bakes a token', () => { @@ -42,3 +53,104 @@ describe('generateBrainPullPlist (D12 launchd)', () => { expect(plist.includes(TOKEN)).toBe(false); }); }); + + +describe('installDurabilityCron — crontab probe [B2/D-cloud]', () => { + test('crontab absent on a non-darwin host → skipped (expected in containers), never needs_attention', async () => { + const empty = mkdtempSync(join(tmpdir(), 'no-bin-')); + const home = mkdtempSync(join(tmpdir(), 'gb-cron-')); + await withEnv({ PATH: empty, GBRAIN_HOME: home }, async () => { + const r = installDurabilityCron('wiki', '/data/clones/wiki', 'main', 1800, false, 'linux'); + expect(r.status).toBe('skipped'); + expect(r.detail).toContain('no crontab on this host'); + expect(r.detail).toContain('post-commit auto-push'); + }); + }); + + test('crontab present but failing → needs_attention (a real breakage stays loud)', async () => { + const shim = mkdtempSync(join(tmpdir(), 'shim-cron-')); + // -l lists empty; writing the new tab (crontab -) fails. + writeFileSync(join(shim, 'crontab'), '#!/bin/sh\ncase "$1" in -l) exit 0;; esac\nexit 1\n', { mode: 0o755 }); + const home = mkdtempSync(join(tmpdir(), 'gb-cron2-')); + await withEnv({ PATH: `${shim}:${process.env.PATH ?? ''}`, GBRAIN_HOME: home }, async () => { + const r = installDurabilityCron('wiki', '/data/clones/wiki', 'main', 1800, false, 'linux'); + expect(r.status).toBe('needs_attention'); + expect(r.detail).toContain('crontab install failed'); + }); + }); + + test('dry-run on a crontab-less host still reports the honest skip', async () => { + const empty = mkdtempSync(join(tmpdir(), 'no-bin2-')); + await withEnv({ PATH: empty }, async () => { + const r = installDurabilityCron('wiki', '/data/clones/wiki', 'main', 1800, true, 'linux'); + expect(r.status).toBe('skipped'); + }); + }); +}); + +describe('durabilityJobStatus — presence + liveness [D7]', () => { + test('no scheduler binaries at all → kind none (never throws)', async () => { + const empty = mkdtempSync(join(tmpdir(), 'no-bin3-')); + const home = mkdtempSync(join(tmpdir(), 'jb-home-')); + await withEnv({ PATH: empty, HOME: home }, async () => { + const s = durabilityJobStatus('wiki', 1800, 'linux'); + expect(s.kind).toBe('none'); + expect(s.wrapperPresent).toBe(false); + }); + }); + + test('crontab line present (shim) → kind crontab, live', async () => { + const shim = mkdtempSync(join(tmpdir(), 'shim-jb-')); + writeFileSync( + join(shim, 'crontab'), + '#!/bin/sh\ncase "$1" in -l) echo "*/30 * * * * /x.sh # com.gbrain.brain-pull.wiki"; exit 0;; esac\nexit 1\n', + { mode: 0o755 }, + ); + const home = mkdtempSync(join(tmpdir(), 'jb-home2-')); + await withEnv({ PATH: shim, HOME: home }, async () => { + const s = durabilityJobStatus('wiki', 1800, 'linux'); + expect(s.kind).toBe('crontab'); + expect(s.live).toBe(true); + }); + }); + + test('stale pull log is reported (logFresh false)', async () => { + const empty = mkdtempSync(join(tmpdir(), 'no-bin4-')); + const home = mkdtempSync(join(tmpdir(), 'jb-home3-')); + const logDir = join(home, '.gbrain'); + // A log last touched 3 hours ago against a 30-min interval. + mkdirSync(logDir, { recursive: true }); + const log = join(logDir, 'brain-pull.log'); + writeFileSync(log, 'old\n'); + const old = new Date(Date.now() - 3 * 60 * 60 * 1000); + utimesSync(log, old, old); + await withEnv({ PATH: empty, HOME: home }, async () => { + const s = durabilityJobStatus('wiki', 1800, 'linux'); + expect(s.logFresh).toBe(false); + }); + }); +}); + +describe('durabilityJobStatus — darwin launchd liveness [D7]', () => { + async function darwinFixture(launchctlExit: number): Promise> { + const home = mkdtempSync(join(tmpdir(), 'jb-mac-')); + const plistDir = join(home, 'Library', 'LaunchAgents'); + mkdirSync(plistDir, { recursive: true }); + writeFileSync(join(plistDir, 'com.gbrain.brain-pull.wiki.plist'), ''); + const shim = mkdtempSync(join(tmpdir(), 'shim-lc-')); + writeFileSync(join(shim, 'launchctl'), `#!/bin/sh\nexit ${launchctlExit}\n`, { mode: 0o755 }); + return withEnv({ PATH: shim, HOME: home }, async () => durabilityJobStatus('wiki', 1800, 'darwin')); + } + + test('plist present + launchctl reports loaded → live', async () => { + const s = await darwinFixture(0); + expect(s.kind).toBe('launchd'); + expect(s.live).toBe(true); + }); + + test('plist present but NOT loaded (the dead-job shape [D7]) → live=false', async () => { + const s = await darwinFixture(1); + expect(s.kind).toBe('launchd'); + expect(s.live).toBe(false); + }); +}); diff --git a/test/e2e/bootstrap-attach.serial.test.ts b/test/e2e/bootstrap-attach.serial.test.ts index 58b57e8e3..9798f8c95 100644 --- a/test/e2e/bootstrap-attach.serial.test.ts +++ b/test/e2e/bootstrap-attach.serial.test.ts @@ -214,6 +214,11 @@ describe('bootstrap attach (machine-2 adoption, serial e2e)', () => { // Attach ADOPTS an existing brain — never claims brain ownership [CX2-12]. expect(receipt!.brain_created_by_bootstrap).toBe(false); expect(receipt!.created_paths).toEqual([]); + // Attach records repo_url from the adopted origin so the no-daemon push + // gate (repoPhaseComplete) recognizes the repo phase as done on this + // machine — without it the per-turn/session-end pushes defer forever, and + // attach is the ONLY install path in a cloud sandbox. + expect((receipt as { repo_url?: string }).repo_url).toContain('origin.git'); }, 60_000); test('attach steps are the ordered machine-2 todo list; re-attach preserves the same-workspace receipt', () => { diff --git a/test/e2e/bootstrap-degraded-modes.serial.test.ts b/test/e2e/bootstrap-degraded-modes.serial.test.ts index 01e3d3a98..13fdef4d5 100644 --- a/test/e2e/bootstrap-degraded-modes.serial.test.ts +++ b/test/e2e/bootstrap-degraded-modes.serial.test.ts @@ -261,3 +261,49 @@ describe('bootstrap degraded modes (decline-everything + keyless, serial e2e)', expect(report.phases.find((p) => p.id === 'verify')!.state).toBe('done'); }, 300_000); }); + +// ── cloud-sandbox simulation [D-cloud] ────────────────────────────────────── +// +// CLAUDE_CODE_REMOTE=true is the official cloud signal. This block pins the +// three cloud behaviors end-to-end through the real dispatcher: the status +// report names the environment, verify's execution_env check states the +// degradations, and repo creation refuses fast with the attach flow. + +describe('cloud-sandbox simulation (CLAUDE_CODE_REMOTE=true)', () => { + const K = 'CLAUDE_CODE_REMOTE'; + let saved: string | undefined; + beforeAll(() => { + saved = process.env[K]; + process.env[K] = 'true'; + }); + afterAll(() => { + if (saved === undefined) delete process.env[K]; + else process.env[K] = saved; + }); + + test('status --json reports execution_environment: cloud-sandbox', async () => { + const report = await statusReport(ws, { gbrainHomeDir: home }); + expect(report.execution_environment).toBe('cloud-sandbox'); + }); + + test('repo phase refuses creation with the attach flow (no half-created unpushable repo)', async () => { + // Fake runner: gh + auth fine, no origin — the CREATE path would begin. + const runner: ExecRunner = async (argv) => { + const joined = argv.join(' '); + if (joined.includes('gh --version') || joined.includes('auth status')) return { code: 0, stdout: 'ok', stderr: '' }; + if (joined.includes('remote get-url origin')) return { code: 2, stdout: '', stderr: 'error: No such remote' }; + return { code: 0, stdout: '', stderr: '' }; + }; + const origErr = console.error; + let err = ''; + console.error = (...a: unknown[]) => { err += a.map(String).join(' ') + '\n'; }; + try { + const code = await runBootstrap(['repo', '--workspace', ws], { runner }); + expect(code).toBe(2); + expect(err).toContain('cloud sandbox'); + expect(err).toContain('gbrain bootstrap attach'); + } finally { + console.error = origErr; + } + }, 60_000); +}); diff --git a/test/e2e/bootstrap-persistence.serial.test.ts b/test/e2e/bootstrap-persistence.serial.test.ts index b0e4f9da9..659bdd294 100644 --- a/test/e2e/bootstrap-persistence.serial.test.ts +++ b/test/e2e/bootstrap-persistence.serial.test.ts @@ -26,7 +26,7 @@ import { execFileSync } from 'node:child_process'; import { runHook } from '../../src/commands/hook.ts'; import { workspacePush, type WorkspacePushResult } from '../../src/core/workspace-push.ts'; -import { writeManifest } from '../../src/core/bootstrap/format.ts'; +import { writeManifest, writeReceipt } from '../../src/core/bootstrap/format.ts'; const OPENAI_KEY = 'sk-' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4'; const ENV_KEYS = ['HOME', 'GBRAIN_HOME', 'DATABASE_URL', 'GBRAIN_DATABASE_URL', 'GBRAIN_HOOKS', 'GBRAIN_GIT_ALLOW_FILE_TRANSPORT'] as const; @@ -87,6 +87,23 @@ beforeEach(() => { // The initialized manifest is the security boundary the hook gates on. writeManifest(work, MANIFEST); + // Repo phase complete: the receipt binds this workspace to its verified + // origin (exact-URL binding for non-github transports) — without it the + // no-daemon pushes defer by design (create-repo-first race protection). + const toplevel = git(work, 'rev-parse', '--show-toplevel'); + mkdirSync(join(process.env.HOME!, '.gbrain', 'bootstrap'), { recursive: true }); + writeReceipt(join(process.env.HOME!, '.gbrain'), { + receipt_version: 1, + workspace_dir: toplevel, + source_id: 'workspace', + agent_name: 'persist-test', + created_at: '2026-01-01T00:00:00.000Z', + created_by: 'test', + brain_created_by_bootstrap: false, + created_paths: [], + registrations: [], + repo_url: bare, + } as Parameters[1] & { repo_url: string }); pushes = []; }); @@ -161,3 +178,77 @@ describe('session-end → real workspace push', () => { expect(shipped).not.toContain('brain/safe.md'); // blocked atomically — nothing in the batch shipped }, 60_000); }); + +// ── per-turn Stop push [D3]: the /exit + VM-reclaim durability lane ───────── +// +// SessionEnd never fires on /exit; the Stop hook fires after EVERY assistant +// turn. This chain proves a turn's authored work physically lands on the real +// bare remote from a single `gbrain hook stop`, and that the per-root debounce +// holds across consecutive stops. + +describe('stop → real workspace push (per-turn durability)', () => { + test('one stop banks the turn to origin; the next stop inside the window debounces', async () => { + process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN = '60'; + try { + mkdirSync(join(work, 'brain'), { recursive: true }); + writeFileSync(join(work, 'brain', 'turn-note.md'), '# turn\n\nlearned during the turn\n'); + + const before = originHead(bare); + const code = await runHook(['stop'], { + write: () => {}, + cwd: work, + spawnPush: realSpawnPush, + stdin: JSON.stringify({ session_id: 'persist-stop', cwd: work }), + }); + expect(code).toBe(0); + expect(pushes).toHaveLength(1); + const res = await pushes[0]; + expect(res.ok).toBe(true); + expect(res.status).toBe('pushed'); + expect(originHead(bare)).not.toBe(before); + + // Second stop, same window, clean tree — debounced, no second spawn. + const code2 = await runHook(['stop'], { + write: () => {}, + cwd: work, + spawnPush: realSpawnPush, + stdin: JSON.stringify({ session_id: 'persist-stop-2', cwd: work }), + }); + expect(code2).toBe(0); + expect(pushes).toHaveLength(1); + } finally { + delete process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN; + } + }, 60_000); + + test('debounce 0 (the cloud-sandbox default): consecutive dirty turns both land', async () => { + process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN = '0'; + try { + mkdirSync(join(work, 'brain'), { recursive: true }); + writeFileSync(join(work, 'brain', 'a.md'), '# a\n'); + await runHook(['stop'], { + write: () => {}, + cwd: work, + spawnPush: realSpawnPush, + stdin: JSON.stringify({ session_id: 'ps-z1', cwd: work }), + }); + expect(pushes).toHaveLength(1); + expect((await pushes[0]).ok).toBe(true); + writeFileSync(join(work, 'brain', 'b.md'), '# b\n'); + await runHook(['stop'], { + write: () => {}, + cwd: work, + spawnPush: realSpawnPush, + stdin: JSON.stringify({ session_id: 'ps-z2', cwd: work }), + }); + expect(pushes).toHaveLength(2); + const res2 = await pushes[1]; + expect(res2.ok).toBe(true); + const shipped = git(bare, 'ls-tree', '-r', '--name-only', 'main'); + expect(shipped).toContain('brain/a.md'); + expect(shipped).toContain('brain/b.md'); + } finally { + delete process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN; + } + }, 60_000); +}); diff --git a/test/execution-env.test.ts b/test/execution-env.test.ts new file mode 100644 index 000000000..a8da5efc5 --- /dev/null +++ b/test/execution-env.test.ts @@ -0,0 +1,102 @@ +/** + * execution-env.ts — detection matrices for the third bootstrap axis. + * Pure signal-injected tests: nothing here reads the real process.env or + * the real filesystem, so results are identical on any CI host. + */ +import { describe, expect, test } from 'bun:test'; + +import { + detectExecutionEnvironment, + isCredentialInjectingProxy, + binaryOnPath, +} from '../src/core/execution-env.ts'; + +const noFile = () => false; + +describe('isCredentialInjectingProxy', () => { + test('GH_TOKEN placeholder literal → true', () => { + expect(isCredentialInjectingProxy({ GH_TOKEN: 'proxy-injected' })).toBe(true); + }); + + test('GITHUB_TOKEN placeholder literal → true', () => { + expect(isCredentialInjectingProxy({ GITHUB_TOKEN: 'proxy-injected' })).toBe(true); + }); + + test('anthropic-egress JWT in https_proxy (either casing) → true', () => { + expect( + isCredentialInjectingProxy({ https_proxy: 'http://user:eyJx.anthropic-egress-control@proxy:8080' }), + ).toBe(true); + expect( + isCredentialInjectingProxy({ HTTPS_PROXY: 'http://x.ANTHROPIC-EGRESS.y@proxy:8080' }), + ).toBe(true); + }); + + test('a real user token / ordinary proxy → false', () => { + expect(isCredentialInjectingProxy({ GH_TOKEN: 'ghp_realtoken123' })).toBe(false); + expect(isCredentialInjectingProxy({ https_proxy: 'http://corp-proxy:3128' })).toBe(false); + expect(isCredentialInjectingProxy({})).toBe(false); + }); +}); + +describe('detectExecutionEnvironment', () => { + test('plain env, no container markers → local', () => { + expect(detectExecutionEnvironment({ env: {}, fileExists: noFile })).toBe('local'); + }); + + test('CLAUDE_CODE_REMOTE=true → cloud-sandbox (primary official signal)', () => { + expect( + detectExecutionEnvironment({ env: { CLAUDE_CODE_REMOTE: 'true' }, fileExists: noFile }), + ).toBe('cloud-sandbox'); + }); + + test('CLAUDE_CODE_REMOTE with a non-true value is NOT the cloud signal', () => { + expect( + detectExecutionEnvironment({ env: { CLAUDE_CODE_REMOTE: '1' }, fileExists: noFile }), + ).toBe('local'); + }); + + test('cse_-prefixed remote session id → cloud-sandbox', () => { + expect( + detectExecutionEnvironment({ + env: { CLAUDE_CODE_REMOTE_SESSION_ID: 'cse_abc123' }, + fileExists: noFile, + }), + ).toBe('cloud-sandbox'); + }); + + test('proxy-injected token signature → cloud-sandbox', () => { + expect( + detectExecutionEnvironment({ env: { GH_TOKEN: 'proxy-injected' }, fileExists: noFile }), + ).toBe('cloud-sandbox'); + }); + + test.each([ + ['RENDER', { RENDER: 'true' }], + ['RAILWAY_ENVIRONMENT', { RAILWAY_ENVIRONMENT: 'production' }], + ['FLY_APP_NAME', { FLY_APP_NAME: 'my-app' }], + ] as const)('%s env → ephemeral-container', (_name, env) => { + expect(detectExecutionEnvironment({ env, fileExists: noFile })).toBe('ephemeral-container'); + }); + + test('/.dockerenv marker → ephemeral-container', () => { + expect( + detectExecutionEnvironment({ env: {}, fileExists: (p) => p === '/.dockerenv' }), + ).toBe('ephemeral-container'); + }); + + test('cloud signals win over container signals (a sandbox is also a container)', () => { + expect( + detectExecutionEnvironment({ + env: { CLAUDE_CODE_REMOTE: 'true', RENDER: 'true' }, + fileExists: (p) => p === '/.dockerenv', + }), + ).toBe('cloud-sandbox'); + }); +}); + +describe('binaryOnPath', () => { + test('a binary every host has resolves; a nonsense name does not', () => { + expect(binaryOnPath('sh')).toBe(true); + expect(binaryOnPath('definitely-not-a-real-binary-xyz')).toBe(false); + }); +}); diff --git a/test/hook-command.serial.test.ts b/test/hook-command.serial.test.ts index 97499d112..48878360d 100644 --- a/test/hook-command.serial.test.ts +++ b/test/hook-command.serial.test.ts @@ -8,7 +8,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, - rmSync, utimesSync, writeFileSync, + rmSync, statSync, utimesSync, writeFileSync, } from 'node:fs'; import net from 'node:net'; import { execFileSync } from 'node:child_process'; @@ -23,8 +23,10 @@ import { HEARTBEAT_MAX_LINES, DIGEST_MEMORY_CAP_BYTES, memoryDigest, + PUSH_ANNOUNCE_REFIRE_MS, type HookHeartbeatEntry, } from '../src/commands/hook.ts'; +import { pushStatusPathForRoot } from '../src/core/workspace-push.ts'; import { ensureIpcSecret, resolveSocketPath, @@ -36,7 +38,12 @@ import { writeReceipt } from '../src/core/bootstrap/format.ts'; import type { RepoReceipt } from '../src/core/bootstrap/repo.ts'; const FIXTURE = join(import.meta.dir, 'fixtures', 'conversation-formats', 'claude-code.jsonl'); -const ENV_KEYS = ['GBRAIN_HOME', 'DATABASE_URL', 'GBRAIN_DATABASE_URL', 'GBRAIN_SOURCE', 'GBRAIN_HOOKS'] as const; +const ENV_KEYS = [ + 'GBRAIN_HOME', 'DATABASE_URL', 'GBRAIN_DATABASE_URL', 'GBRAIN_SOURCE', 'GBRAIN_HOOKS', + // stop-push [D3/D17/D20] + banner [D5] + cloud detection knobs + 'GBRAIN_STOP_PUSH', 'GBRAIN_STOP_PUSH_DEBOUNCE_MIN', 'CLAUDE_CODE_REMOTE', + 'CLAUDE_CODE_REMOTE_SESSION_ID', 'GH_TOKEN', 'GITHUB_TOKEN', +] as const; let tmp: string; let saved: Record; @@ -906,6 +913,275 @@ describe('bootstrap push gate [G4]', () => { }); }); +// ── stop-hook per-turn push [D3/D17/D20] ──────────────────────────────────── +// +// SessionEnd never fires on /exit and a cloud VM can be reclaimed between +// turns; the Stop boundary is the only always-runs cadence. These tests pin: +// the security gate (same as session-end), per-root debounce isolation, the +// kill switch, the failing-status bypass, and fail-open state handling. + +function stopIo(repo: string, spawned: string[]) { + return { + write: () => {}, + spawnPush: (root: string) => { spawned.push(root); }, + stdin: JSON.stringify({ session_id: 'sess-stop-push', cwd: repo }), + }; +} + +function bootRepo(name: string, opts: { repoPhase?: boolean; clean?: boolean } = {}): string { + const repo = join(tmp, name); + initGitRepoWithDirtyTree(repo); + writeFileSync(join(repo, 'agent.json'), JSON.stringify(INITIALIZED_MANIFEST, null, 2) + '\n'); + if (opts.repoPhase !== false) markRepoPhaseComplete(repo); + if (opts.clean) { + execFileSync('git', ['-C', repo, 'add', '-A'], { stdio: 'ignore' }); + execFileSync('git', ['-C', repo, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init'], { stdio: 'ignore' }); + // Model a FULLY-PUSHED clean repo: origin/ == HEAD, so treeNeedsPush + // measures zero commits ahead (a committed-but-never-pushed repo correctly + // reports needs-push under the new origin-ref-based measure). + const branch = execFileSync('git', ['-C', repo, 'branch', '--show-current'], { encoding: 'utf8' }).trim(); + const head = execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + execFileSync('git', ['-C', repo, 'update-ref', `refs/remotes/origin/${branch}`, head], { stdio: 'ignore' }); + } + return repo; +} + +const stopPushStateFiles = () => { + try { + return readdirSync(join(home(), 'bootstrap')).filter((n) => n.startsWith('stop-push-')); + } catch { + return []; + } +}; + +describe('stop-hook per-turn push [D3]', () => { + test('dirty initialized workspace: stop spawns the detached push, records per-root state (0600), heartbeat push_spawned', async () => { + const repo = bootRepo('stop-boot'); + const spawned: string[] = []; + expect(await runHook(['stop'], stopIo(repo, spawned))).toBe(0); + expect(spawned).toHaveLength(1); + expect((await lastHeartbeat())?.reason).toBe('push_spawned'); + const states = stopPushStateFiles(); + expect(states).toHaveLength(1); + const mode = statSync(join(home(), 'bootstrap', states[0]!)).mode & 0o777; + expect(mode).toBe(0o600); + }); + + test('second stop inside the debounce window: push_debounced, no second spawn', async () => { + process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN = '5'; + const repo = bootRepo('stop-debounce'); + const spawned: string[] = []; + await runHook(['stop'], stopIo(repo, spawned)); + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toHaveLength(1); + expect((await lastHeartbeat())?.reason).toBe('push_debounced'); + }); + + test('GBRAIN_STOP_PUSH_DEBOUNCE_MIN=0 pushes every turn', async () => { + process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN = '0'; + const repo = bootRepo('stop-zero'); + const spawned: string[] = []; + await runHook(['stop'], stopIo(repo, spawned)); + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toHaveLength(2); + }); + + test('cloud-sandbox default is debounce 0 (CLAUDE_CODE_REMOTE=true, no explicit knob) [D17]', async () => { + process.env.CLAUDE_CODE_REMOTE = 'true'; + const repo = bootRepo('stop-cloud'); + const spawned: string[] = []; + await runHook(['stop'], stopIo(repo, spawned)); + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toHaveLength(2); + }); + + test('GBRAIN_STOP_PUSH=0 disables the per-turn push (buffer append still runs)', async () => { + process.env.GBRAIN_STOP_PUSH = '0'; + const repo = bootRepo('stop-disabled'); + const spawned: string[] = []; + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toEqual([]); + expect((await lastHeartbeat())?.reason).toBe('push_disabled'); + }); + + test('non-bootstrap git repo: never spawns (same security boundary as session-end)', async () => { + const repo = join(tmp, 'stop-plain'); + initGitRepoWithDirtyTree(repo); + const spawned: string[] = []; + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toEqual([]); + expect((await lastHeartbeat())?.reason).toBe('push_skipped_not_bootstrap'); + }); + + test('repo phase pending (no repo_url): defers, never publishes to an unverified origin', async () => { + const repo = bootRepo('stop-pending', { repoPhase: false }); + const spawned: string[] = []; + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toEqual([]); + expect((await lastHeartbeat())?.reason).toBe('push_deferred_repo_pending'); + }); + + test('clean tree with nothing ahead: push_clean, no spawn (CRITICAL regression: buffer append unchanged)', async () => { + const repo = bootRepo('stop-clean', { clean: true }); + const spawned: string[] = []; + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toEqual([]); + expect((await lastHeartbeat())?.reason).toBe('push_clean'); + // the live-buffer append still happened (stop's original contract) + const bufDir = join(home(), 'transcripts', 'live'); + expect(readdirSync(bufDir).some((n) => n.includes('sess-stop-push'))).toBe(true); + }); + + test('corrupt per-root state file is treated as due (fail-open)', async () => { + process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN = '5'; + const repo = bootRepo('stop-corrupt'); + const spawned: string[] = []; + await runHook(['stop'], stopIo(repo, spawned)); + const state = stopPushStateFiles()[0]!; + writeFileSync(join(home(), 'bootstrap', state), 'not json'); + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toHaveLength(2); + }); + + test('[D20] a failing push-status bypasses the debounce (retry next turn)', async () => { + process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN = '60'; + const repo = bootRepo('stop-retry'); + const toplevel = execFileSync('git', ['-C', repo, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); + const spawned: string[] = []; + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toHaveLength(1); + // Simulate the detached child recording a refusal for THIS root. + writeFileSync( + pushStatusPathForRoot(toplevel), + JSON.stringify({ ts: new Date().toISOString(), ok: false, reason: 'refused_visibility', repoRoot: toplevel }) + '\n', + { mode: 0o600 }, + ); + // Inside the 60s failing-retry floor: still debounced (no spawn storm)… + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toHaveLength(1); + // …but once the floor passes, the failing status bypasses the 60-MINUTE + // debounce window (age the state file past the floor). + const stateFile = join(home(), 'bootstrap', stopPushStateFiles()[0]!); + const aged = JSON.parse(readFileSync(stateFile, 'utf8')) as { ts: string; root: string }; + writeFileSync(stateFile, JSON.stringify({ ...aged, ts: new Date(Date.now() - 90_000).toISOString() }) + '\n'); + await runHook(['stop'], stopIo(repo, spawned)); + expect(spawned).toHaveLength(2); // floor passed → failing status bypassed the 60min window + }); + + test('[D3] two workspaces debounce independently (per-root state, no clobber)', async () => { + process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN = '60'; + const a = bootRepo('stop-ws-a'); + const b = join(tmp, 'stop-ws-b'); + initGitRepoWithDirtyTree(b); + writeFileSync(join(b, 'agent.json'), JSON.stringify(INITIALIZED_MANIFEST, null, 2) + '\n'); + // B gets its own receipt? One receipt per home — markRepoPhaseComplete + // overwrites. Root-binding means only the receipt's workspace pushes; the + // OTHER workspace must be treated as repo-phase-pending, not crash. + const spawned: string[] = []; + await runHook(['stop'], stopIo(a, spawned)); + expect(spawned).toHaveLength(1); + await runHook(['stop'], stopIo(b, spawned)); + expect(spawned).toHaveLength(1); // b defers (no receipt binding) — and does NOT clobber a's state + expect((await lastHeartbeat())?.reason).toBe('push_deferred_repo_pending'); + await runHook(['stop'], stopIo(a, spawned)); + expect(spawned).toHaveLength(1); // a still debounced — b's activity didn't reset a + expect((await lastHeartbeat())?.reason).toBe('push_debounced'); + }); +}); + +// ── push-failure banner [D5/D13/D19] ──────────────────────────────────────── + +describe('user-prompt push-failure banner [D5]', () => { + // repoRoot must EXIST on disk: entries for deleted workspaces are ghosts + // the reader filters out by design (they could never be cleared). + const bannerRoot = () => { + const r = join(tmp, 'banner-brain'); + mkdirSync(r, { recursive: true }); + return r; + }; + const failingStatus = (root: string, ts = new Date().toISOString()) => { + mkdirSync(join(home(), 'bootstrap'), { recursive: true }); + writeFileSync( + pushStatusPathForRoot(root), + JSON.stringify({ ts, ok: false, reason: 'refused_visibility: origin unverifiable', repoRoot: root }) + '\n', + { mode: 0o600 }, + ); + }; + + test('failing push-status → banner-only payload on a degraded path, with BOTH additionalContext and systemMessage', async () => { + const root = bannerRoot(); + failingStatus(root); + const out = collectStdout(); + // No config at all → degraded no_pglite_path; the banner must still land. + expect(await runHook(['user-prompt'], { ...out.io, stdin: JSON.stringify({ prompt: 'hi' }) })).toBe(0); + const payload = JSON.parse(out.get()) as { + hookSpecificOutput?: { additionalContext?: string }; + systemMessage?: string; + }; + expect(payload.hookSpecificOutput?.additionalContext).toContain('FAILING'); + expect(payload.hookSpecificOutput?.additionalContext).toContain('banner-brain'); + expect(payload.systemMessage).toContain('NOT on GitHub'); + }); + + test('banner announces once per failure ts, then stays quiet [D19]', async () => { + const root = bannerRoot(); + failingStatus(root); + const first = collectStdout(); + await runHook(['user-prompt'], { ...first.io, stdin: JSON.stringify({ prompt: 'hi' }) }); + expect(first.get()).toContain('FAILING'); + const second = collectStdout(); + await runHook(['user-prompt'], { ...second.io, stdin: JSON.stringify({ prompt: 'hi again' }) }); + expect(second.get()).toBe(''); // announced — no re-fire inside the floor + }); + + test('a NEW failure ts re-announces immediately; a persisting one re-fires after the 30-min floor [D19]', async () => { + const root = bannerRoot(); + failingStatus(root, '2026-08-12T00:00:00.000Z'); + const first = collectStdout(); + await runHook(['user-prompt'], { ...first.io, stdin: JSON.stringify({ prompt: 'x' }) }); + expect(first.get()).toContain('FAILING'); + // Same ts + fresh announce → quiet. Age the announce past the floor → re-fires. + const announced = `${pushStatusPathForRoot(root)}.announced`; + const state = JSON.parse(readFileSync(announced, 'utf8')) as { announced_ts: string }; + writeFileSync( + announced, + JSON.stringify({ announced_ts: state.announced_ts, last_announce_at: new Date(Date.now() - PUSH_ANNOUNCE_REFIRE_MS - 60_000).toISOString() }) + '\n', + ); + const third = collectStdout(); + await runHook(['user-prompt'], { ...third.io, stdin: JSON.stringify({ prompt: 'z' }) }); + expect(third.get()).toContain('FAILING'); + }); + + test('CRITICAL regression: ok push-status → NO banner, stdout empty on degraded paths', async () => { + mkdirSync(join(home(), 'bootstrap'), { recursive: true }); + const okRoot = bannerRoot(); + writeFileSync( + pushStatusPathForRoot(okRoot), + JSON.stringify({ ts: new Date().toISOString(), ok: true, repoRoot: okRoot }) + '\n', + ); + const out = collectStdout(); + await runHook(['user-prompt'], { ...out.io, stdin: JSON.stringify({ prompt: 'hi' }) }); + expect(out.get()).toBe(''); + }); + + test('banner rides INSIDE the main context payload when serve answers (one JSON doc, systemMessage present)', async () => { + const dataDir = join(tmp, 'data'); + writePgliteConfig(dataDir); + await startServer({ dataDir, blockText: 'BRAIN CONTEXT BLOCK' }); + const root = bannerRoot(); + failingStatus(root); + const out = collectStdout(); + await runHook(['user-prompt'], { ...out.io, stdin: JSON.stringify({ prompt: 'hi', session_id: 'sess-banner' }) }); + const payload = JSON.parse(out.get()) as { + hookSpecificOutput?: { additionalContext?: string }; + systemMessage?: string; + }; + expect(payload.hookSpecificOutput?.additionalContext).toContain('FAILING'); + expect(payload.hookSpecificOutput?.additionalContext).toContain('BRAIN CONTEXT BLOCK'); + expect(payload.systemMessage).toContain('FAILING'); + }); +}); + // ── user-prompt deadline degradation [D5/ENG-1] ───────────────────────────── describe('user-prompt deadline', () => { diff --git a/test/repo-visibility.test.ts b/test/repo-visibility.test.ts new file mode 100644 index 000000000..25f184180 --- /dev/null +++ b/test/repo-visibility.test.ts @@ -0,0 +1,332 @@ +/** + * repo-visibility.ts — parser union, gh-403 classification, the full ladder + * verdict matrix [D4]/[D14], and the private-only verdict cache [D11]. + * Everything injected (runner + fetchImpl + env) — no network, no gh, no git. + */ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + anonProbeUrl, + classifyGh403, + githubOwnerRepoString, + parseGithubOwnerRepo, + readCachedPrivateVerdict, + verifyRepoVisibility, + writeVisibilityCache, + type ExecRunner, + type RepoVisibilityVerdict, +} from '../src/core/repo-visibility.ts'; + +const URL_GH = 'https://github.com/acme-example/widget-co.git'; + +// ── Fake IO ───────────────────────────────────────────────────────────────── + +/** Runner keyed on the binary: gh → opts.gh, git ls-remote → opts.git. */ +function fakeRunner(opts: { + gh?: { code: number; stdout?: string; stderr?: string }; + git?: { code: number; stderr?: string }; +}): ExecRunner { + return async (argv: string[]) => { + if (argv[0] === 'gh') { + const r = opts.gh ?? { code: 127, stderr: 'ENOENT' }; + return { code: r.code, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; + } + const r = opts.git ?? { code: 0 }; + return { code: r.code, stdout: r.code === 0 ? 'abc123\tHEAD\n' : '', stderr: r.stderr ?? '' }; + }; +} + +function fakeFetch(res: { + status: number; + contentType?: string; + wwwAuthenticate?: string; + githubRequestId?: boolean; + body?: string; + throws?: boolean; +}): typeof fetch { + return (async () => { + if (res.throws) throw new Error('network unreachable'); + const headers = new Headers(); + if (res.contentType) headers.set('content-type', res.contentType); + if (res.wwwAuthenticate) headers.set('www-authenticate', res.wwwAuthenticate); + if (res.githubRequestId) headers.set('x-github-request-id', 'ABCD:1234'); + return new Response(res.body ?? '', { status: res.status, headers }); + }) as unknown as typeof fetch; +} + +const GH_ADVERTISEMENT = '001e# service=git-upload-pack\n0000'; +const PROXY_403 = 'gh: HTTP 403 {"message":"GitHub access to this repository is not enabled for this session. Use add_repo to request access."}'; +const GITHUB_403 = 'gh: HTTP 403 {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest"}'; + +// ── Parser union ──────────────────────────────────────────────────────────── + +describe('parseGithubOwnerRepo (canonical union grammar)', () => { + test.each([ + ['https://github.com/a/b', 'a', 'b'], + ['https://github.com/a/b.git', 'a', 'b'], + ['https://github.com/a/b/', 'a', 'b'], + ['git@github.com:a/b.git', 'a', 'b'], + ['git@github.com:a/b', 'a', 'b'], + ['ssh://git@github.com/a/b', 'a', 'b'], + ['ssh://git@github.com/a/b.git/', 'a', 'b'], + [' https://github.com/a/b.git ', 'a', 'b'], + ])('%s → %s/%s', (url, owner, repo) => { + expect(parseGithubOwnerRepo(url)).toEqual({ owner, repo }); + expect(githubOwnerRepoString(url)).toBe(`${owner}/${repo}`); + }); + + test.each([['https://gitlab.com/a/b'], ['/tmp/origin.git'], ['git@example.com:a/b.git'], ['']])( + '%s → null', + (url) => { + expect(parseGithubOwnerRepo(url)).toBeNull(); + }, + ); +}); + +describe('anonProbeUrl', () => { + test('github forms normalize to the canonical .git/info/refs URL', () => { + const want = 'https://github.com/a/b.git/info/refs?service=git-upload-pack'; + expect(anonProbeUrl('https://github.com/a/b')).toBe(want); + expect(anonProbeUrl('git@github.com:a/b.git')).toBe(want); + expect(anonProbeUrl('ssh://git@github.com/a/b')).toBe(want); + }); + test('non-github https appends info/refs; ssh/file get null', () => { + expect(anonProbeUrl('https://git.example.com/a/b.git/')).toBe( + 'https://git.example.com/a/b.git/info/refs?service=git-upload-pack', + ); + expect(anonProbeUrl('git@example.com:a/b.git')).toBeNull(); + expect(anonProbeUrl('/tmp/origin.git')).toBeNull(); + }); +}); + +// ── 403 classification ────────────────────────────────────────────────────── + +describe('classifyGh403', () => { + test('GitHub-shaped JSON (message + documentation_url) → github', () => { + expect(classifyGh403(GITHUB_403)).toBe('github'); + }); + test('session-scoping / proxy phrases → proxy', () => { + expect(classifyGh403(PROXY_403)).toBe('proxy'); + expect(classifyGh403('x-deny-reason: host_not_allowed')).toBe('proxy'); + expect(classifyGh403('remote: access denied by the git proxy: repo is not in this session')).toBe('proxy'); + }); + test('bare text → unknown', () => { + expect(classifyGh403('HTTP 403 Forbidden')).toBe('unknown'); + }); +}); + +// ── Ladder verdict matrix ─────────────────────────────────────────────────── + +async function ladder(opts: { + url?: string; + gh?: { code: number; stdout?: string; stderr?: string }; + git?: { code: number; stderr?: string }; + probe?: Parameters[0]; + env?: Record; +}): Promise { + return verifyRepoVisibility({ + originUrl: opts.url ?? URL_GH, + runner: fakeRunner({ ...(opts.gh ? { gh: opts.gh } : {}), ...(opts.git ? { git: opts.git } : {}) }), + fetchImpl: fakeFetch(opts.probe ?? { status: 500 }), + env: opts.env ?? {}, + timeoutMs: 2_000, + }); +} + +describe('verifyRepoVisibility — verdict matrix', () => { + test('rung 1 REST true → private/rest (CRITICAL regression parity with the old probe)', async () => { + const v = await ladder({ gh: { code: 0, stdout: 'true\n' } }); + expect(v.verdict).toBe('private'); + expect(v.verdict === 'private' && v.via).toBe('rest'); + }); + + test('rung 1 REST false → public/rest, refuse (CRITICAL regression parity)', async () => { + const v = await ladder({ gh: { code: 0, stdout: 'false\n' } }); + expect(v.verdict).toBe('public'); + }); + + test('gh missing (127) → git rungs: ls-remote ok + github-attributed 401 → private/git-protocol', async () => { + const v = await ladder({ + gh: { code: 127, stderr: 'ENOENT' }, + git: { code: 0 }, + probe: { status: 401, wwwAuthenticate: 'Basic realm="GitHub"', githubRequestId: true }, + }); + expect(v.verdict).toBe('private'); + expect(v.verdict === 'private' && v.via).toBe('git-protocol'); + }); + + test('[D14] github origin: a 401 with ONLY a www-authenticate challenge (RFC-mandated on every 401, middleboxes included) is NOT attribution → unverifiable', async () => { + const v = await ladder({ + gh: { code: 127 }, + git: { code: 0 }, + probe: { status: 401, wwwAuthenticate: 'Basic realm="corp-proxy"' }, + }); + expect(v.verdict).toBe('unverifiable'); + }); + + test('[D14] non-github origin: a 401 challenge is NOT trustable attribution (a middlebox 401s identically) → unverifiable, fail-closed', async () => { + const v = await ladder({ + url: 'https://git.example.com/team/brain.git', + git: { code: 0 }, + probe: { status: 401, wwwAuthenticate: 'Basic realm="git"' }, + }); + expect(v.verdict).toBe('unverifiable'); + }); + + test('proxy-403 REST → git rungs, and the rung log names the proxy', async () => { + const v = await ladder({ + gh: { code: 1, stderr: PROXY_403 }, + git: { code: 0 }, + probe: { status: 401, wwwAuthenticate: 'Basic realm="GitHub"', githubRequestId: true }, + }); + expect(v.verdict).toBe('private'); + expect(v.rungs.map((r) => r.outcome).join(' ')).toContain('egress proxy'); + }); + + test('[D14] un-attributed 401 (middlebox) → unverifiable, NEVER private', async () => { + const v = await ladder({ + gh: { code: 127 }, + git: { code: 0 }, + probe: { status: 401 }, // no www-authenticate, no github headers + }); + expect(v.verdict).toBe('unverifiable'); + expect(v.detail).toContain('un-attributed'); + }); + + test('404 with x-github-request-id counts as attributed for github origins', async () => { + const v = await ladder({ + gh: { code: 127 }, + git: { code: 0 }, + probe: { status: 404, githubRequestId: true }, + }); + expect(v.verdict).toBe('private'); + }); + + test('[D4] 200 with advertisement proof (content-type) → public/git-protocol', async () => { + const v = await ladder({ + gh: { code: 127 }, + git: { code: 0 }, + probe: { status: 200, contentType: 'application/x-git-upload-pack-advertisement' }, + }); + expect(v.verdict).toBe('public'); + }); + + test('[D4] 200 with pkt-line body proof (no content-type) → public', async () => { + const v = await ladder({ + gh: { code: 127 }, + git: { code: 0 }, + probe: { status: 200, body: GH_ADVERTISEMENT }, + }); + expect(v.verdict).toBe('public'); + }); + + test('[D4] 200 WITHOUT proof (SSO login HTML) → unverifiable, never a false PUBLIC', async () => { + const v = await ladder({ + url: 'https://git.example.com/team/brain.git', + git: { code: 0 }, + probe: { status: 200, contentType: 'text/html', body: 'Sign in' }, + }); + expect(v.verdict).toBe('unverifiable'); + expect(v.detail).toContain('advertisement'); + }); + + test('200 with proof behind a credential-injecting proxy → unverifiable + actionable message', async () => { + const v = await ladder({ + gh: { code: 1, stderr: PROXY_403 }, + git: { code: 0 }, + probe: { status: 200, contentType: 'application/x-git-upload-pack-advertisement' }, + env: { GH_TOKEN: 'proxy-injected' }, + }); + expect(v.verdict).toBe('unverifiable'); + expect(v.detail).toContain('GBRAIN_ALLOW_UNVERIFIED_REMOTE'); + }); + + test('ls-remote failure → unverifiable ("a push would fail anyway")', async () => { + const v = await ladder({ + gh: { code: 127 }, + git: { code: 128, stderr: 'fatal: could not read Username' }, + }); + expect(v.verdict).toBe('unverifiable'); + expect(v.detail).toContain('push would fail'); + }); + + test('anon probe network error → unverifiable with rung log', async () => { + const v = await ladder({ gh: { code: 127 }, git: { code: 0 }, probe: { status: 0, throws: true } }); + expect(v.verdict).toBe('unverifiable'); + expect(v.rungs.some((r) => r.rung === 'anon-refs' && r.outcome.includes('network error'))).toBe(true); + }); + + test('ssh non-github origin: readable but no probe surface → unverifiable', async () => { + const v = await ladder({ url: 'git@example.com:team/brain.git', git: { code: 0 }, probe: { status: 200 } }); + expect(v.verdict).toBe('unverifiable'); + expect(v.rungs.some((r) => r.outcome.includes('no https probe surface'))).toBe(true); + }); + + test('anon probe answering an unexpected status (500) → unverifiable, names the status', async () => { + const v = await ladder({ + gh: { code: 127 }, + git: { code: 0 }, + probe: { status: 500 }, + }); + expect(v.verdict).toBe('unverifiable'); + expect(v.detail).toContain('HTTP 500'); + expect(v.detail).toContain('GBRAIN_ALLOW_UNVERIFIED_REMOTE'); + }); + + test('rung timeout is recorded and degrades to unverifiable, never hangs', async () => { + const hangingRunner: ExecRunner = () => new Promise(() => {}); // never resolves + const v = await verifyRepoVisibility({ + originUrl: URL_GH, + runner: hangingRunner, + fetchImpl: fakeFetch({ status: 500 }), + env: {}, + timeoutMs: 50, + }); + expect(v.verdict).toBe('unverifiable'); + expect(v.rungs.some((r) => r.outcome.includes('timeout'))).toBe(true); + }); +}); + +// ── Verdict cache [D11] ───────────────────────────────────────────────────── + +describe('visibility cache (private-only, TTL)', () => { + const dir = mkdtempSync(join(tmpdir(), 'vis-cache-')); + const path = join(dir, 'visibility-cache.json'); + const privateVerdict: RepoVisibilityVerdict = { verdict: 'private', via: 'rest', detail: 'x', rungs: [] }; + const publicVerdict: RepoVisibilityVerdict = { verdict: 'public', via: 'rest', detail: 'x', rungs: [] }; + + test('private verdict round-trips within TTL; expired entry misses', () => { + const t0 = Date.now(); + writeVisibilityCache(URL_GH, privateVerdict, { now: t0, path }); + expect(readCachedPrivateVerdict(URL_GH, { now: t0 + 60_000, path })?.verdict).toBe('private'); + expect(readCachedPrivateVerdict(URL_GH, { now: t0 + 61 * 60_000, path })).toBeNull(); + }); + + test('public and unverifiable verdicts are NEVER written', () => { + rmSync(path, { force: true }); + writeVisibilityCache(URL_GH, publicVerdict, { path }); + writeVisibilityCache(URL_GH, { verdict: 'unverifiable', detail: 'x', rungs: [] }, { path }); + expect(readCachedPrivateVerdict(URL_GH, { path })).toBeNull(); + }); + + test('corrupt cache file is a miss, and the next write recovers it', () => { + writeFileSync(path, 'not json', { mode: 0o600 }); + expect(readCachedPrivateVerdict(URL_GH, { path })).toBeNull(); + const t0 = Date.now(); + writeVisibilityCache(URL_GH, privateVerdict, { now: t0, path }); + expect(readCachedPrivateVerdict(URL_GH, { now: t0, path })?.verdict).toBe('private'); + expect(() => JSON.parse(readFileSync(path, 'utf8'))).not.toThrow(); + }); + + test('expired entries are pruned on write', () => { + const t0 = Date.now(); + rmSync(path, { force: true }); + writeVisibilityCache('https://github.com/a/old.git', privateVerdict, { now: t0 - 2 * 60 * 60_000, path }); + writeVisibilityCache(URL_GH, privateVerdict, { now: t0, path }); + const map = JSON.parse(readFileSync(path, 'utf8')) as Record; + expect(Object.keys(map)).toEqual([URL_GH]); + }); +}); diff --git a/test/workspace-push.serial.test.ts b/test/workspace-push.serial.test.ts index 561e5560b..28fd96229 100644 --- a/test/workspace-push.serial.test.ts +++ b/test/workspace-push.serial.test.ts @@ -16,10 +16,12 @@ import { join } from 'path'; import { tmpdir } from 'os'; import { execFileSync, spawnSync } from 'child_process'; import { - workspacePush, acquirePushLock, pushLockDir, pushStatusPath, verifyRemotePrivacy, + workspacePush, acquirePushLock, pushLockDir, pushStatusPath, pushStatusPathForRoot, + readPushStatuses, summarizePushStatuses, verifyRemotePrivacy, parseGithubOwnerRepo, resolveWorkspaceRoot, PUSH_LOCK_STALE_MS, PUSH_DENY_GLOBS, } from '../src/core/workspace-push.ts'; import { SCAN_ALLOW_FILENAME } from '../src/core/secret-scan.ts'; +import { visibilityCachePath } from '../src/core/repo-visibility.ts'; const T = 60_000; // explicit per-test timeout — bun ignores bunfig.toml's key const OPENAI = 'sk-' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4'; @@ -97,14 +99,14 @@ describe('happy path', () => { expect(r.ahead).toBe(0); expect(originHead(bare)).toBe(git(work, 'rev-parse', 'HEAD')); // B4: push-status.json written on success - const status = JSON.parse(readFileSync(pushStatusPath(), 'utf-8')); + const status = readPushStatuses()[0]!; // [D13] per-root file, via the shared reader expect(status.ok).toBe(true); expect(status.ahead).toBe(0); expect(typeof status.ts).toBe('string'); // no leftover lock expect(existsSync(pushLockDir(work))).toBe(false); // status file is not world-readable - expect(statSync(pushStatusPath()).mode & 0o077).toBe(0); + expect(statSync(readPushStatuses()[0]!.file).mode & 0o077).toBe(0); }, T); test('CX2-3 — a subdirectory target resolves and pushes the repo ROOT', async () => { @@ -157,7 +159,7 @@ describe('deny-glob backstop [G6]', () => { expect(r.reason).toContain('.env'); expect(originHead(bare)).toBe(before); // nothing pushed // B4: status written on failure too - const status = JSON.parse(readFileSync(pushStatusPath(), 'utf-8')); + const status = readPushStatuses()[0]!; expect(status.ok).toBe(false); }, T); @@ -214,7 +216,7 @@ describe('secret-scan gate', () => { expect(JSON.stringify(r).includes(OPENAI)).toBe(false); // value never surfaces expect(lines.join('\n')).toContain('notes.md'); // B4: failure status written - expect(JSON.parse(readFileSync(pushStatusPath(), 'utf-8')).ok).toBe(false); + expect(readPushStatuses()[0]!.ok).toBe(false); // per-finding allowlist override [CX2-15] writeFileSync(join(work, SCAN_ALLOW_FILENAME), `${r.findings![0]!.fingerprint}\n`); @@ -276,7 +278,7 @@ describe('secret-scan gate — fails CLOSED on unscannable staged blobs', () => expect(originHead(bare)).toBe(before); expect(git(work, 'diff', '--cached', '--name-only')).toBe(''); // B4: failure status recorded - expect(JSON.parse(readFileSync(pushStatusPath(), 'utf-8')).ok).toBe(false); + expect(readPushStatuses()[0]!.ok).toBe(false); }, T); test('an oversized staged blob (> scan cap) BLOCKS the push', async () => { @@ -374,7 +376,7 @@ describe('commit-first-then-pull [CX2-7]', () => { // origin still holds the other clone's commit — nothing force-pushed expect(git(bare, 'log', '--format=%s', '-1', 'main')).toBe('remote change'); // B4: failure status written - expect(JSON.parse(readFileSync(pushStatusPath(), 'utf-8')).ok).toBe(false); + expect(readPushStatuses()[0]!.ok).toBe(false); }, T); }); @@ -390,20 +392,41 @@ describe('remote-privacy gate [G8]', () => { // the commit was made (local durability) but NOTHING left the machine expect(r.committed).toBe(true); expect(originHead(bare)).toBe(before); - expect(JSON.parse(readFileSync(pushStatusPath(), 'utf-8')).ok).toBe(false); + expect(readPushStatuses()[0]!.ok).toBe(false); }, T); - test('verifyRemotePrivacy: gh false → not_private; gh true → private (PATH-shimmed gh)', () => { + test('verifyRemotePrivacy: gh false → not_private; gh true → private (PATH-shimmed gh, REST rung)', async () => { const shim = mkdtempSync(join(root, 'shim-')); git(work, 'remote', 'set-url', 'origin', 'https://github.com/acme-example/widget-co.git'); writeFileSync(join(shim, 'gh'), '#!/bin/sh\necho false\n', { mode: 0o755 }); process.env.PATH = `${shim}:${saved.PATH}`; - expect(verifyRemotePrivacy(work).verdict).toBe('not_private'); + expect((await verifyRemotePrivacy(work)).verdict).toBe('not_private'); + // CRITICAL regression guard [D10]: rest-true must reproduce the pre-ladder + // verdict exactly (private → push allowed). writeFileSync(join(shim, 'gh'), '#!/bin/sh\necho true\n', { mode: 0o755 }); - expect(verifyRemotePrivacy(work).verdict).toBe('private'); - writeFileSync(join(shim, 'gh'), '#!/bin/sh\necho "not logged in" >&2\nexit 1\n', { mode: 0o755 }); - const v = verifyRemotePrivacy(work); - expect(v.verdict).toBe('unverifiable'); // unauthed gh = unverifiable, not fail-open + expect((await verifyRemotePrivacy(work)).verdict).toBe('private'); + // The private verdict is cached [D11]; clear it so the next case exercises + // the ladder, not the cache. + rmSync(visibilityCachePath(), { force: true }); + }, T); + + test('verifyRemotePrivacy: cached private verdict short-circuits; cache cleared → re-verifies', async () => { + const shim = mkdtempSync(join(root, 'shim-')); + git(work, 'remote', 'set-url', 'origin', 'https://github.com/acme-example/widget-co.git'); + writeFileSync(join(shim, 'gh'), '#!/bin/sh\necho true\n', { mode: 0o755 }); + process.env.PATH = `${shim}:${saved.PATH}`; + expect((await verifyRemotePrivacy(work)).verdict).toBe('private'); + // Break gh entirely: the fresh cache must still answer private... + writeFileSync(join(shim, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + expect((await verifyRemotePrivacy(work)).verdict).toBe('private'); + // ...and clearing it must force live re-verification. Point origin at a + // local path so the git-protocol rungs stay offline-fast: readable via + // ls-remote, no https probe surface → unverifiable, never fail-open. + rmSync(visibilityCachePath(), { force: true }); + git(work, 'remote', 'set-url', 'origin', bare); + const v = await verifyRemotePrivacy(work); + expect(v.verdict).toBe('unverifiable'); + git(work, 'remote', 'set-url', 'origin', 'https://github.com/acme-example/widget-co.git'); }, T); test('parseGithubOwnerRepo handles https/.git/scp forms; non-github → null', () => { @@ -499,8 +522,83 @@ describe('error paths', () => { expect(r.status).toBe('push_failed'); expect(r.ok).toBe(false); expect(r.committed).toBe(true); // commit survives locally - const status = JSON.parse(readFileSync(pushStatusPath(), 'utf-8')); + const status = readPushStatuses()[0]!; expect(status.ok).toBe(false); expect(existsSync(pushLockDir(work))).toBe(false); }, T); }); + +describe('unverified-remote escape hatches [D18/S3]', () => { + test('env hatch: unverifiable origin + GBRAIN_ALLOW_UNVERIFIED_REMOTE=1 → pushed (WARN path)', async () => { + process.env.GBRAIN_ALLOW_UNVERIFIED_REMOTE = '1'; + try { + writeFileSync(join(work, 'note.md'), 'env hatch\n'); + // file origin → ladder unverifiable; env hatch downgrades it to allowed. + const r = await workspacePush({ dir: work, branch: 'main' }); + expect(r.ok).toBe(true); + expect(r.status).toBe('pushed'); + } finally { + delete process.env.GBRAIN_ALLOW_UNVERIFIED_REMOTE; + } + }, T); + + test('config hatch: push.allow_unverified_remote=true in the file plane → pushed', async () => { + mkdirSync(join(process.env.HOME!, '.gbrain'), { recursive: true }); + writeFileSync( + join(process.env.HOME!, '.gbrain', 'config.json'), + JSON.stringify({ engine: 'pglite', push: { allow_unverified_remote: true } }), + ); + writeFileSync(join(work, 'note2.md'), 'config hatch\n'); + const r = await workspacePush({ dir: work, branch: 'main' }); + expect(r.ok).toBe(true); + expect(r.status).toBe('pushed'); + }, T); + + test('CRITICAL [S3]: a hatch never covers a PROVEN-PUBLIC origin — still refused', async () => { + const shim = mkdtempSync(join(root, 'shim-pub-')); + writeFileSync(join(shim, 'gh'), '#!/bin/sh\necho false\n', { mode: 0o755 }); + process.env.PATH = `${shim}:${saved.PATH}`; + process.env.GBRAIN_ALLOW_UNVERIFIED_REMOTE = '1'; + try { + git(work, 'remote', 'set-url', 'origin', 'https://github.com/acme-example/widget-co.git'); + writeFileSync(join(work, 'note3.md'), 'must not leave\n'); + const r = await workspacePush({ dir: work, branch: 'main' }); + expect(r.ok).toBe(false); + expect(r.status).toBe('refused_visibility'); + expect(r.reason).toContain('does NOT cover proven-public'); + } finally { + delete process.env.GBRAIN_ALLOW_UNVERIFIED_REMOTE; + git(work, 'remote', 'set-url', 'origin', bare); + } + }, T); +}); + +describe('per-root masking [D13]', () => { + test('one root failing + another succeeding: the failure is never masked (reader + summarize)', async () => { + const otherRoot = mkdtempSync(join(root, 'other-ws-')); + mkdirSync(join(process.env.HOME!, '.gbrain', 'bootstrap'), { recursive: true }); + writeFileSync( + pushStatusPathForRoot(otherRoot), + JSON.stringify({ ts: new Date().toISOString(), ok: false, reason: 'refused_visibility', repoRoot: otherRoot }) + '\n', + ); + writeFileSync(join(work, 'ok.md'), 'fine\n'); + const r = await push(); + expect(r.ok).toBe(true); // this workspace pushed fine… + const entries = readPushStatuses(); + const { failing } = summarizePushStatuses(entries); + expect(failing).toHaveLength(1); // …and the OTHER root's failure survives + expect(failing[0]!.repoRoot).toBe(otherRoot); + }, T); + + test('ghost roots are filtered: a failing record for a DELETED workspace stops feeding the surfaces', async () => { + const ghost = mkdtempSync(join(root, 'ghost-ws-')); + mkdirSync(join(process.env.HOME!, '.gbrain', 'bootstrap'), { recursive: true }); + writeFileSync( + pushStatusPathForRoot(ghost), + JSON.stringify({ ts: new Date().toISOString(), ok: false, reason: 'refused_visibility', repoRoot: ghost }) + '\n', + ); + rmSync(ghost, { recursive: true, force: true }); + const { failing } = summarizePushStatuses(readPushStatuses()); + expect(failing).toHaveLength(0); + }, T); +});