v0.45.9.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder (#4045)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(doctor): categorize bootstrap_durability_job (categories drift guard)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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/<branch> (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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-12 16:12:59 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 068f586128
commit 1ec6a6e842
60 changed files with 3574 additions and 293 deletions
+54 -4
View File
@@ -1,4 +1,4 @@
<!-- gbrain-runbook-stamp: 0.45.8.0 -->
<!-- gbrain-runbook-stamp: 0.45.9.0 -->
<!-- This stamp must equal the VERSION file at every release; CI enforces it
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
the installed binary and warns on skew. -->
@@ -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
+26 -6
View File
@@ -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 <n>`.
- **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.**
+2 -2
View File
@@ -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)
+42
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.45.8.0
0.45.9.0
+8 -6
View File
@@ -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 <sha>`, 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 <GIT_SSRF_FLAGS> clone <GIT_SSRF_SUBCOMMAND_FLAGS> --depth=1 [--branch X] -- <url> <dir>`. `pullRepo` argv: `git <GIT_SSRF_FLAGS> -C <dir> pull <GIT_SSRF_SUBCOMMAND_FLAGS> --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 <dir>` 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 <id|--all>` / `pull <id>|--path <dir>` / `unharden <id>`; 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 <dir>` 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 <id|--all>` / `pull <id>|--path <dir>` / `unharden <id>`; 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 `<home>/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 `<home>/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 (`<home>/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 `<ws>/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 `#`/`<!--`/fence escaping), hard-fail on unresolved tokens, never-clobber + timestamped backups on `--force`, blank-line collapse, byte floors scaled to answered count. `--minimal` is the deterministic placeholder mode the template-repo generator uses (byte-identical across runs; leaves required tokens as literal fill-me markers; writes `initialized:false`). `--only` never writes agent.json.
- `src/core/bootstrap/lock.ts` — the bootstrap-run mutex (atomic mkdir + pid liveness + age guard + ownership token; steal requires dead pid AND stale age) and the family's shared typed `BootstrapError` (GH_MISSING/GH_AUTH carry exit 2 = human action needed).
- `src/core/bootstrap/repo.ts` / `attach.ts` / `uninstall.ts` — private-repo lifecycle. `createPrivateRepo`: gh gates, slugified name probe, `gh repo create --private --source --push`, privacy verified via `gh api .private` (rate-limit/5xx is VERIFY_UNAVAILABLE, distinct from not-private) before any push, idempotency keyed off the remote URL. A pre-existing origin is adopted (disposition 'adopted') when the authed gh user owns it, there's no recorded `repo_url`, and it is SAFE — empty or already carrying our history (`assertAdoptableOrigin`; a foreign-content repo is refused `ORIGIN_NOT_EMPTY`, never a silent no-op); this is the create-repo-first path. Org-owned origins and anything else are refused and pointed at attach. Repo-local git identity is set in both create and adopt paths before commit; `repo_url` is recorded only after a successful push. `attachWorkspace` (machine two): requires an `initialized` manifest, writes this machine's receipt, returns structured wiring steps. `uninstallWorkspace`: receipt-keyed, refuses under a live serve (read-only lock probe — never opens the engine), removes exactly receipt-recorded paths + marker-keyed host entries, keeps the brain unless `--delete-brain` AND bootstrap created it; never wholesale-deletes the gbrain home. All gh/git through an injectable ExecRunner seam.
- `src/core/bootstrap/hooks.ts` + `host-specs.ts` — host wiring. `host-specs.ts` is the ONE module owning host-format assumptions (dated spec targets with verifiedAt + doc references: claude-code hooks/settings shapes incl. the 10,000-char hook-output cap; codex mcp-add argv; no-TOML-writer-in-v1 decision recorded). `writeClaudeHooks` does a structural JSON merge into `.claude/settings.local.json` keyed by a `_gbrain` marker — foreign hooks and permissions survive, re-runs dedupe, broken JSON is backed up loudly; `registerClaudeMcp`/`registerCodexMcp` build argv only (Claude Code takes `--scope`, project default; Codex has no scope flag — `codex mcp add` is always user-global; `-e`/`--env GBRAIN_SOURCE` so MCP writes land in the workspace source, and `serve --surface full` pinned so a pre-existing `mcp_surface: verbs` config row can't silently narrow the bootstrap op surface).
- `src/commands/hook.ts` — engine-free `gbrain hook {session-start,user-prompt,stop,session-end}` (zero engine modules in the import graph; a hook must NEVER contend for the PGLite writer lock). user-prompt: stdin hook JSON → transcript-path confinement → last-4-turns window + cross-turn dedupe (the transcript's `hook_additional_context` attachments — the blocks WE previously injected — ride `priorContextText`, deduplicated and capped at `PRIOR_CONTEXT_MAX_BYTES` (32KB, so the advisory payload can never blow the IPC message cap; one oversized block is skipped without evicting smaller ones), so a page is volunteered once per session, not once per mention; structured extraction only, never raw-turn substring matching) → IPC turn_context (with a feedback-loop `channel`, `--harness <claude-code|codex>`, default claude-code) → `hookSpecificOutput.additionalContext` under an 800ms self-deadline; every path fails open (exit 0, empty stdout) with a typed reason in the heartbeat. Listed in cli.ts's `STARTUP_HOOK_SKIP_COMMANDS` (per-prompt invocations must never spawn a detached check-update child; membership is pinned by a source grep — the runtime path no-ops under NODE_ENV=test). session-start: file-plane digest (allowlisted MEMORY.md sections, push staleness, prior failures) + crashed-session recovery push gated on an initialized manifest. session-end: confined full-transcript parse → redacted corpus write (session-id filename dedup, retention prune) → parser-drift detection (`bytes>0 && turns==0` is loud) → best-effort workspace push. session-start recovery + session-end pushes run in a DETACHED child so the hook returns immediately (a synchronous inline push previously blocked harness startup on a dirty tree); the corpus write is atomic and clears the stale ingested/in-progress sidecars so a resumed session re-ingests its appended transcript. Heartbeat JSONL is counters/reasons only by construction; `readHeartbeatTail` feeds doctor. `GBRAIN_HOOKS=0` kills all events.
- `src/core/bootstrap/repo.ts` / `attach.ts` / `uninstall.ts` — private-repo lifecycle. `createPrivateRepo`: gh gates, slugified name probe, `gh repo create --private --source` (the push happens separately via `ensureRemoteHasWorkspace`), privacy verified via REST `gh api repos/{o}/{r} --jq .private` (rate-limit/5xx is VERIFY_UNAVAILABLE, distinct from not-private; a proxy-classed 403 → a cloud-specific message) before any push, idempotency keyed off the remote URL. It shares repo-visibility.ts's URL parser + `isProxyBlocked403` classifier but runs REST directly (not the full ladder — creation needs the authed-owner REST answer). A pre-existing origin is adopted (disposition 'adopted') when the authed gh user owns it, there's no recorded `repo_url`, and it is SAFE — empty or already carrying our history (`assertAdoptableOrigin`; a foreign-content repo is refused `ORIGIN_NOT_EMPTY`, never a silent no-op); this is the create-repo-first path. Org-owned origins and anything else are refused and pointed at attach. Inside a `cloud-sandbox` (execution-env), `createPrivateRepo` fails fast with `CLOUD_SANDBOX_REPO` before any `gh repo create` — a mid-session repo is never proxy-attached, so creation there is a dead end; the message routes to create-elsewhere + `bootstrap attach`. Repo-local git identity is set in both create and adopt paths before commit; `repo_url` is recorded only after a successful push. `attachWorkspace` (machine two, and the ONLY install path in a cloud sandbox since `repo` is refused there): requires an `initialized` manifest, writes this machine's receipt — recording `repo_url` from the adopted github origin so the no-daemon push gate (`repoPhaseComplete`) recognizes the repo phase as done and the per-turn/session-end pushes actually fire (privacy is still enforced at push time by the ladder) — and returns structured wiring steps. `uninstallWorkspace`: receipt-keyed, refuses under a live serve (read-only lock probe — never opens the engine), removes exactly receipt-recorded paths + marker-keyed host entries, keeps the brain unless `--delete-brain` AND bootstrap created it; never wholesale-deletes the gbrain home. All gh/git through an injectable ExecRunner seam.
- `src/core/bootstrap/hooks.ts` + `host-specs.ts` — host wiring. `host-specs.ts` is the ONE module owning host-format assumptions (dated spec targets with verifiedAt + doc references: claude-code hooks/settings shapes incl. the 10,000-char hook-output cap; codex mcp-add argv; no-TOML-writer-in-v1 decision recorded). `writeClaudeHooks` does a structural JSON merge into `.claude/settings.local.json` keyed by a `_gbrain` marker — foreign hooks and permissions survive, re-runs dedupe, broken JSON is backed up loudly. `writeCommittedClaudeHooks` [D12] is the CLOUD carrier: a fresh cloud clone never sees the gitignored local file and hook config snapshots at session start, so cloud installs write the repo-COMMITTED `.claude/settings.json` with a PATH-resolved fail-open command (`command -v gbrain … || exit 0`; no absolute paths — the file travels between machines). The writers enforce a dedupe invariant — an event carried by the committed file is skipped by the local writer and vice-versa, matched on the EXACT portable-command shape (a bare marker + `gbrain hook` substring is spoofable), so one event never fires from both; `removeClaudeHooks` cleans both carriers; GBRAIN_HOME is refused in the committed carrier (machine-specific). `registerClaudeMcp`/`registerCodexMcp` build argv only (Claude Code takes `--scope`, project default; Codex has no scope flag — `codex mcp add` is always user-global; `-e`/`--env GBRAIN_SOURCE` so MCP writes land in the workspace source, and `serve --surface full` pinned so a pre-existing `mcp_surface: verbs` config row can't silently narrow the bootstrap op surface).
- `src/commands/hook.ts` — engine-free `gbrain hook {session-start,user-prompt,stop,session-end,compact}` (zero engine modules in the import graph; a hook must NEVER contend for the PGLite writer lock). user-prompt: stdin hook JSON → transcript-path confinement → last-4-turns window + cross-turn dedupe (the transcript's `hook_additional_context` attachments — the blocks WE previously injected — ride `priorContextText`, deduplicated and capped at `PRIOR_CONTEXT_MAX_BYTES` (32KB, so the advisory payload can never blow the IPC message cap; one oversized block is skipped without evicting smaller ones), so a page is volunteered once per session, not once per mention; structured extraction only, never raw-turn substring matching) → IPC turn_context (with a feedback-loop `channel`, `--harness <claude-code|codex>`, default claude-code) → `hookSpecificOutput.additionalContext` under an 800ms self-deadline; every path fails open (exit 0, empty stdout) with a typed reason in the heartbeat. Listed in cli.ts's `STARTUP_HOOK_SKIP_COMMANDS` (per-prompt invocations must never spawn a detached check-update child; membership is pinned by a source grep — the runtime path no-ops under NODE_ENV=test). session-start: file-plane digest (allowlisted MEMORY.md sections, push staleness, prior failures) + crashed-session recovery push gated on an initialized manifest. session-end: confined full-transcript parse → redacted corpus write (session-id filename dedup, retention prune) → parser-drift detection (`bytes>0 && turns==0` is loud) → best-effort workspace push. session-start recovery + session-end pushes run in a DETACHED child so the hook returns immediately (a synchronous inline push previously blocked harness startup on a dirty tree); the corpus write is atomic and clears the stale ingested/in-progress sidecars so a resumed session re-ingests its appended transcript. stop: also runs a per-turn debounced detached push [D3] (`stopPushIfDue` — per-workspace-root debounce state, 5-min default local + ephemeral-container / every turn in a cloud-sandbox, a failing status bypasses to a 60s retry floor, same repo-phase security gate as session-end), closing the `/exit` gap (SessionEnd never fires on `/exit`) and the VM-reclaim gap; `GBRAIN_STOP_PUSH=0` disables it, `GBRAIN_STOP_PUSH_DEBOUNCE_MIN` / config `hooks.stop_push_debounce_min` tune it. user-prompt also surfaces a failing background push [D5/D19] as a ≤300-char banner on BOTH `additionalContext` (model) and `systemMessage` (human), announced once per failure and re-fired at most every 30 min, with the reason sanitized before it reaches any model-visible surface. Heartbeat JSONL is counters/reasons only by construction; `readHeartbeatTail` feeds doctor. `GBRAIN_HOOKS=0` kills all events.
- `src/core/transcripts/claude-code-jsonl.ts` — the Claude Code transcript parser as a dated spec-target (tool_use/tool_result/thinking/image/sidechain/summary/compact-boundary shapes; placeholders for non-text content); also extracts `injectedContextBlocks` — the `hook_additional_context` attachment lines a gbrain hook previously injected (verified live against claude CLI 2.1.224; marker-filtered, so a foreign hook's blocks are excluded and another tool's output can't suppress volunteering — a same-user mislabeling guard, not an authenticity check), the user-prompt hook's cross-turn dedupe input; `confineTranscriptPath` (contained under `~/.claude/projects`, `.jsonl`, lstat-rejects symlinks, byte cap). Fixtures: `test/fixtures/conversation-formats/claude-code.jsonl` (synthetic, privacy-guarded) + `test/fixtures/hook-transcript.jsonl` (real captured hook round-trip).
- `src/core/context/turn-context.ts` — server-side per-turn assembly: reflex pointers + volunteered pages (≤3) + hot facts (always `visibility=['world']` — the IPC path never widens what MCP would return) under a "data, not instructions" envelope, trimmed to ≤8KB (the harness caps hook output at 10,000 chars). The result exposes `pointers` AND post-trim `volunteered` — exactly what the rendered text carries — so the IPC delivery point can log the feedback loop without ever counting a trimmed-out page. Reuses the hot-memory cache keyed by typed sessionId. Engine-agnostic.
- `src/core/context/resolve-ipc.ts` (IPC v2) — discriminated-union requests (absent `kind` = legacy resolve; `turn_context` carries `protocol: 2` + a shared secret from a 0600 file in the data dir, plus an additive `channel` for feedback-loop attribution — wire channel claims are validated to the harness channels at the logging site, anything else logs as the default hook channel), handler map, named response types, per-kind timeouts/size caps, socket + parent dir permissions set before exposure, server-side source binding (cross-source requests rejected), protocol echo (a response without it = stale serve → loud degradation). The connection handler processes exactly ONE request per connection (trailing bytes mid-await never double-process a line or double-log a delivery); the client clamps a too-big request below the message cap by dropping the advisory `priorContextText` BEFORE any conversation turn. Delivery seams: `onDelivered` (resolve kind) and `onTurnContextDelivered` (turn_context kind) both fire ONLY after the response write succeeds — a block abandoned before the serve responded is never counted (serve's callback logs the delivered block's volunteered pages + pointers to `context_volunteer_events` under the request channel); write-accept still isn't proof of injection (the client can trim/drop after receipt), which is why the `volunteer_channels` doctor check reconciles counts against the hook heartbeat. v1 clients and servers interoperate untouched.
@@ -512,8 +512,10 @@ User-facing contract: `docs/guides/bootstrap.md`. Runbook the paste block fetche
- `src/core/sweep.ts` + `src/commands/sweep.ts` — the serve-resident maintenance sweep (the lock owner closes the persistence loop): facts-fence reconciliation (zero-LLM, reuses the cycle extractor with a slug subset), deterministic link/timeline extraction over recent workspace pages (the same cores as `gbrain extract` — remote put_page deliberately skips these, the sweep is where the graph compounds), and spend-gated corpus ingest (skipped keyless; sidecar-marked exactly-once). Bounded, fail-soft, never throws; armed at serve startup (3s, best-effort) and on 10-min idle ticks through the injectable timer seam, everything unref'd; `GBRAIN_SWEEP=0` kills it. `gbrain sweep --once` is the trusted CLI seam `bootstrap verify` uses (CLI-only, never over MCP).
- `src/core/capability.ts` — config-plane keyless/keyed detection + the honest capability report (`keyless mode: keyword search, agent-authored memory; add ONE key to unlock…`) rendered by verify and the runbook.
- `src/core/secret-scan.ts` — pattern scanner for USER workspaces (own minimal allowlist + `<ws>/.gbrain-scan-allow` per-finding overrides — deliberately NOT the repo's `.gitleaks.toml`, which is a public-repo CI fixture policy); redacted previews only; `redactFindings` is the corpus-write mode.
- `src/core/workspace-push.ts``gbrain sources push`: deny-glob backstop (tracked `*.pglite`/`.env*` refused regardless of .gitignore state) → stage FIRST → secret-scan the STAGED index blobs via `git cat-file` (closes the scan-then-stage TOCTOU — scanned bytes == committed bytes) → commit FIRST → divergence-safe pull → push, under one cross-platform lock (mkdir-atomic; flock is not a dependency — macOS). The pre-push secret gate FAILS CLOSED: an unreadable, oversized (> `PUSH_MAX_SCAN_BYTES`), or otherwise unscannable staged blob returns `blocked_unscannable` (nothing committed) instead of sailing through — only a confirmed staged deletion is skipped, and binary/NUL-sniffed blobs are scanned anyway. Statuses map to exit codes in `src/commands/sources.ts`: `pushed`/`skipped_in_flight` → 0; `blocked_secrets`/`blocked_tracked_deny`/`blocked_unscannable`/`refused_visibility` → 5; `pull_conflict`/`push_failed`/other → 1. Refuses public AND unverifiable remotes (never fail-open); pushes even on clean trees; writes `<home>/bootstrap/push-status.json`. Parent-repo-aware (a source may be a subdirectory of the workspace repo).
- `src/core/workspace-push.ts``gbrain sources push`: deny-glob backstop (tracked `*.pglite`/`.env*` refused regardless of .gitignore state) → stage FIRST → secret-scan the STAGED index blobs via `git cat-file` (closes the scan-then-stage TOCTOU — scanned bytes == committed bytes) → commit FIRST → divergence-safe pull → push, under one cross-platform lock (mkdir-atomic; flock is not a dependency — macOS). The pre-push secret gate FAILS CLOSED: an unreadable, oversized (> `PUSH_MAX_SCAN_BYTES`), or otherwise unscannable staged blob returns `blocked_unscannable` (nothing committed) instead of sailing through — only a confirmed staged deletion is skipped, and binary/NUL-sniffed blobs are scanned anyway. Statuses map to exit codes in `src/commands/sources.ts`: `pushed`/`skipped_in_flight` → 0; `blocked_secrets`/`blocked_tracked_deny`/`blocked_unscannable`/`refused_visibility` → 5; `pull_conflict`/`push_failed`/other → 1. The privacy gate runs the shared `repo-visibility.ts` ladder (REST → git protocol) and refuses proven-public AND unverifiable remotes (never fail-open). Three escape hatches for self-hosted git you trust, each warns and downgrades ONLY `unverifiable` (never a proven-public push): CLI `--allow-unverified-remote` > env `GBRAIN_ALLOW_UNVERIFIED_REMOTE=1` > file-plane config `push.allow_unverified_remote` (the only channel a detached hook child can read). Pushes even on clean trees; writes per-workspace status `<home>/bootstrap/push-status-<roothash>.json` (a shared `readPushStatuses`/`summarizePushStatuses` reader; one workspace's success can't mask another's failure, and records for a since-deleted root are filtered). Parent-repo-aware (a source may be a subdirectory of the workspace repo).
- `src/core/gbrain-home.ts` — the single GBRAIN_HOME resolution choke point (delegates to config's parent-dir semantics; 0700 on create) — durability, push, hooks, and bootstrap all route through it so home semantics cannot drift.
- `src/core/execution-env.ts` — the third bootstrap axis (harness × engine × ENVIRONMENT). `detectExecutionEnvironment(signals?)` returns `local | cloud-sandbox | ephemeral-container` from injected signals: `CLAUDE_CODE_REMOTE=true` (official, never local) or a `cse_`-prefixed remote-session id or a credential-injecting proxy (`GH_TOKEN`/`GITHUB_TOKEN === 'proxy-injected'`, or an anthropic-egress `https_proxy`) → cloud-sandbox; RENDER/RAILWAY/FLY / `/.dockerenv` → ephemeral-container. `isCredentialInjectingProxy(env?)` is the exported proxy check (load-bearing for the repo-visibility ambiguity rule). `binaryOnPath(name)` moved here (live-PATH `Bun.which`) and is re-exported by `bootstrap/status.ts`; autopilot's `detectInstallTarget()` routes its ephemeral branch through the shared detector. Pure + signal-injected. Tests: `test/execution-env.test.ts`.
- `src/core/repo-visibility.ts` — ONE repo-privacy verdict shared by every consumer, replacing three drifted probes. The FULL ladder runs in `workspace-push.ts` (the push gate) and `bootstrap/verify.ts` (`checkRepoPrivacy`); `bootstrap/repo.ts` and `bootstrap/status.ts` reuse its URL parser + 403 classifier but call REST directly (both are sync-by-contract: repo creation needs the authed-owner answer, status runs inside the phase-detect chain). `verifyRepoVisibility({originUrl, repoDir, runner, fetchImpl, env})` runs a fail-closed ladder: REST (`gh api repos/{o}/{r} --jq .private` — NEVER `gh repo view`/GraphQL, which cloud proxies pin) → authed `git ls-remote` (SSRF flags via `durableSsrfFlags` + `--end-of-options`) → an anonymous smart-HTTP probe (`redirect:'manual'`, userinfo stripped). A 200 counts PUBLIC only with git-advertisement proof (content-type or pkt-line prefix); a 401/404 counts PRIVATE only with origin attribution (github.com needs `x-github-request-id` — a bare `WWW-Authenticate` is RFC-mandated on every 401 and a middlebox 401s identically, so it is NOT proof; non-github origins have no trustable signal → `unverifiable`). Everything else → `unverifiable`. `classifyGh403`/`isProxyBlocked403` distinguish a real GitHub 403 (JSON with `message`+`documentation_url`) from a sandbox-proxy 403. `parseGithubOwnerRepo` is the canonical URL parser (bootstrap/repo + hook re-adapt it). Private verdicts cache 1h (private-only, per-origin, userinfo-stripped key) at `<home>/bootstrap/visibility-cache.json`. Engine-free; every IO seam injected. Tests: `test/repo-visibility.test.ts`.
- `src/core/bootstrap/verify.ts` + `status.ts` — verify is the definition of done: fail-soft check suite over the REAL write path (put_page op → write-through file under `brain/` → in-process sweep → graph floor via link tables → recall), the keyless magic-moment check (`## Facts` fence → zero-LLM reconciliation → world-visibility read-back), source_id collision resolution (as the one bootstrap subcommand holding an engine: a manifest `source_id` already registered to a DIFFERENT checkout → derives a stable `workspace-<8char-path-hash>`, persists it to agent.json, names the re-register steps — every consumer reads `manifest.source_id`), token sweep, byte floors, secret scan, deny globs, repo privacy, hooks smoke (in-process IPC), capability report, first-run tour; snapshots kept last-5 under `<home>/bootstrap/`. status owns the ordered PHASES list (the runbook defers to it), artifact-first detection, install.jsonl, the runbook version-stamp skew check, and the support blob doctor/agents relay verbatim.
- `src/core/bootstrap/template-repo.ts` + `scripts/generate-template-repo.ts` — deterministic public-template generation (render `--minimal` + placeholder manifest + stamped README); published only by the release workflow after diffing against the vendored tree.
- `scripts/check-bootstrap-tag.sh` / `scripts/check-bootstrap-templates.sh` — CI guards: sanctioned distribution ref only (`latest-stable`; the release job advances it after assets publish) + runbook stamp == VERSION; template↔question-bank token bijection + placeholder-only assertion + offline generator↔vendored byte-diff + runbook-phase↔status.ts consistency + harness-scoping counter-signal pins (the MCP-scope consent is Claude Code only: the runbook must carry the Codex "Do NOT offer an MCP scope choice" bullet and the "Claude Code only" consent scoping, and the bank's MCP_SCOPE question must start with `(Claude Code only` with `phase: interview` — tripwires against deleting the load-bearing prose, not placement proofs). Both skip gracefully when their subjects are absent.
+39 -5
View File
@@ -20,9 +20,11 @@ follows is `BOOTSTRAP_FOR_AGENTS.md` at the repo root, fetched at the
| `agent.json` manifest + `brain/`, `memory/`, `skills/`, `state/` | workspace | — |
| Local brain (PGLite) | `~/.gbrain/` (never in the repo) | while a session's MCP serve is open |
| MCP registration (`gbrain serve`) | Claude Code: project scope by default; Codex: user-global (no scope flag) | spawned by your harness per session |
| Hooks (Claude Code, ON by default) | `.claude/settings.local.json` (gitignored) | each prompt; fail-open; `--no-hooks` opts out at install, `GBRAIN_HOOKS=0` disables at runtime |
| Session persistence | SessionEnd hook → scan-gated commit+push | at session end |
| Optional 15-min push job | launchd/cron (consent-gated) | while logged in |
| Hooks (Claude Code, ON by default) | local installs: `.claude/settings.local.json` (gitignored); cloud sandboxes: the COMMITTED `.claude/settings.json` (PATH-resolved, fail-open commands) | each prompt; fail-open; `--no-hooks` opts out at install, `GBRAIN_HOOKS=0` disables at runtime |
| Per-turn persistence | Stop hook → debounced, detached scan-gated push (per workspace; 5 min default, every turn in cloud sandboxes) | after each assistant turn; `GBRAIN_STOP_PUSH=0` disables; `GBRAIN_STOP_PUSH_DEBOUNCE_MIN` / config `hooks.stop_push_debounce_min` tune it |
| Session persistence | SessionEnd hook → scan-gated commit+push | at session end (note: the harness never fires SessionEnd on `/exit` — the per-turn push is what covers that) |
| Push-failure visibility | next turn's context + a user-visible notice; re-announces every 30 min while failing | whenever a background push fails |
| Optional background job (consent-gated) | git post-commit auto-push + launchd/cron 30-min pull (pull job skipped honestly on hosts without a scheduler) | while logged in |
| Private GitHub repo | your account, created by `bootstrap repo` (or an empty repo you made yourself, adopted) | privacy verified via API |
| Machine receipt | `~/.gbrain/bootstrap/receipt.json` | uninstall is keyed to it |
@@ -30,6 +32,36 @@ follows is `BOOTSTRAP_FOR_AGENTS.md` at the repo root, fetched at the
schedules fire at turn/session boundaries only. True 24/7 operation is what a
hosted brain provides — this is the honest desktop contract.
## Cloud sandboxes (claude.ai/code and similar)
Cloud sessions run in a reclaimed-after-inactivity VM behind a
credential-injecting egress proxy. `gbrain bootstrap status --json` reports
`execution_environment: "cloud-sandbox"` there, and the install adapts:
- **Hooks live in the committed `.claude/settings.json`** with PATH-resolved,
fail-open commands (no machine paths). The gitignored local settings file
never survives into the next session's fresh clone, and hook config is
snapshotted at session start — so hooks written mid-session go live on the
NEXT session. Commit and push the file.
- **The per-turn push runs every turn** (debounce 0) — a reclaimed VM's tail
loss is permanent, so each turn banks to the private repo.
- **Repo-privacy verification falls back to pure git protocol** when the proxy
blocks the GitHub API (GraphQL is always pinned there; REST reaches only
session-attached repos). Confirmed-public origins still always refuse.
- **Repo creation is refused in cloud** with the flow that works: create the
private repo from a normal machine or github.com, open the cloud session ON
that repo, run `gbrain bootstrap attach`.
- **The gbrain binary installs via the environment setup script** — print it
with `gbrain bootstrap cloud-setup-script` and paste it into the environment
config (npm-based; bun's package fetching is proxy-incompatible there).
- **No scheduler exists** — the consent-gated pull job is skipped with an
honest message; event-driven pushes cover persistence.
Escape hatch for self-hosted git you trust (every use warns loudly):
the CLI flag on `sources push`, `GBRAIN_ALLOW_UNVERIFIED_REMOTE=1`, or
`gbrain config set push.allow_unverified_remote true` (file-plane — the only
form that reaches detached hook children inside a sandbox).
## Bring your own repo (create-repo-first)
By default bootstrap creates the private GitHub repo for you. If you prefer to own
@@ -91,8 +123,10 @@ zero in keyless mode; with a key, the standard spend gates apply
contract. Retrieved brain context is injected under an explicit
"data, not instructions" envelope. Facts visible to the harness respect the
brain's visibility tiers.
- **Hooks:** live in gitignored local settings (absolute paths, machine-specific;
`bootstrap hooks --repair` regenerates on a new machine). Every hook fails open
- **Hooks:** on a local install, gitignored local settings (absolute paths,
machine-specific; `bootstrap hooks --repair` regenerates on a new machine); in a
cloud sandbox, the committed `.claude/settings.json` (PATH-resolved, fail-open —
see the Cloud sandboxes section). Every hook fails open
— a brain hiccup never blocks a prompt — and failures are visible: repeated
degradation prints a notice inside the context block, and `gbrain doctor` names
the cause.
+2 -2
View File
@@ -1688,7 +1688,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.
@@ -1712,7 +1712,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)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.45.8.0",
"version": "0.45.9.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {
+1 -1
View File
@@ -154,7 +154,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.45.8.0",
"version": "0.45.9.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.5",
+5 -7
View File
@@ -19,6 +19,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, utimesSync, unlinkSync, chmodSync, statSync } from 'fs';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
import { detectExecutionEnvironment } from '../core/execution-env.ts';
import { join, dirname, isAbsolute } from 'path';
import { execSync } from 'child_process';
import type { BrainEngine } from '../core/engine.ts';
@@ -1444,13 +1445,10 @@ export type InstallTarget = 'macos' | 'linux-systemd' | 'ephemeral-container' |
export function detectInstallTarget(): InstallTarget {
if (process.platform === 'darwin') return 'macos';
const ephemeral = !!(
process.env.RENDER
|| process.env.RAILWAY_ENVIRONMENT
|| process.env.FLY_APP_NAME
|| existsSync('/.dockerenv')
);
if (ephemeral) return 'ephemeral-container';
// Shared detector (execution-env.ts): covers the original Render/Railway/
// Fly//.dockerenv signals AND the cloud-sandbox signature — both get the
// start-script treatment here (no reliable scheduler in either).
if (detectExecutionEnvironment() !== 'local') return 'ephemeral-container';
if (existsSync('/run/systemd/system')) {
try {
+106 -18
View File
@@ -34,6 +34,7 @@ import { VERSION } from '../version.ts';
import { loadConfig, loadConfigFileOnly, toEngineConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
import { resolveGbrainHome } from '../core/gbrain-home.ts';
import { detectExecutionEnvironment } from '../core/execution-env.ts';
import { realpathOrResolve } from '../core/path-confine.ts';
import { loadQuestionBank } from '../core/bootstrap/assets.ts';
import {
@@ -56,6 +57,7 @@ import {
registerClaudeMcp,
registerCodexMcp,
writeClaudeHooks,
writeCommittedClaudeHooks,
removeClaudeHooks,
} from '../core/bootstrap/hooks.ts';
import {
@@ -100,6 +102,10 @@ Subcommands (run \`gbrain bootstrap status\` first — it is the resume entrypoi
verify [--json] The whole install contract (round-trip, graph floor,
magic moment, scans, hooks smoke). Exit 0 or not done.
attach [--harness H] Machine two: adopt a cloned agent workspace.
cloud-setup-script Print the paste-ready cloud environment setup
script (installs the gbrain binary into the
environment snapshot; npm-based bun fetching
is proxy-incompatible in cloud sandboxes).
uninstall [--delete-brain] [--home <dir>] [--yes]
Receipt-keyed removal. The repo stays yours.
@@ -607,37 +613,57 @@ async function runRepo(ws: string, rest: string[], home: string, runner: ExecRun
abortIfInjected('repo');
// PERSIST_CRON consent [D3 resolved]: opt-in 15-min scan-gated push job
// via the existing sources-harden machinery. Best-effort: a harden
// failure never fails the repo phase — the SessionEnd push backstop is
// always on.
// PERSIST_CRON consent [D3 resolved]: opt-in background persistence via
// the existing sources-harden machinery — a git post-commit auto-push plus
// a 30-minute scheduled pull that keeps multi-machine checkouts fresh
// (honest copy [D9]: the event-driven pushes do the durability work; the
// timer is the freshener). Best-effort: a harden failure never fails the
// repo phase — the per-turn and session-end pushes are always on.
const persist = (consentAnswer(ws, 'PERSIST_CRON') ?? 'no').toLowerCase();
const envKind = detectExecutionEnvironment();
if (persist === 'yes') {
try {
const { hardenBrainRepo } = await import('../core/brain-repo-durability.ts');
const state = readManifest(ws);
const sourceId = state.state === 'initialized' ? state.manifest.source_id : 'workspace';
// Containers/cloud sandboxes have no reliable scheduler — install the
// container-friendly half (post-commit hook + helper) and say so,
// instead of failing a crontab write that could never survive anyway.
const installCron = envKind === 'local';
const report = await hardenBrainRepo({
repoPath: ws,
sourceId,
installCron: true,
installCron,
verify: false,
logger: (l: string) => process.stderr.write(`[harden] ${l}\n`),
});
const attention = report.steps.filter((s) => s.status === 'needs_attention');
console.log(
attention.length === 0
? 'background persistence enabled (15-min scan-gated push job installed).'
: `background persistence partially enabled — needs attention: ${attention.map((s) => `${s.step}: ${s.detail}`).join('; ')}`,
);
if (attention.length > 0) {
console.log(
`background persistence partially enabled — needs attention: ${attention.map((s) => `${s.step}: ${s.detail}`).join('; ')}`,
);
} else if (installCron) {
console.log(
'background persistence enabled (post-commit auto-push + 30-min scheduled pull installed).',
);
} else {
console.log(
`background persistence enabled for this ${envKind === 'cloud-sandbox' ? 'cloud sandbox' : 'container'}: ` +
'post-commit auto-push installed; per-turn and session-end pushes are already on. ' +
'No scheduler exists in this environment, so the 30-min pull is skipped — run ' +
'`gbrain sources harden` on a persistent machine to add it.',
);
}
} catch (e) {
console.error(
`note: background-persistence install failed (${(e as Error).message}). ` +
'Session-end pushes still persist your work; re-try later with `gbrain sources harden`.',
'Per-turn and session-end pushes still persist your work; re-try later with `gbrain sources harden`.',
);
}
} else {
console.log('background persistence declined — the session-end push remains the persistence backstop.');
console.log(
'background persistence declined — the per-turn and session-end pushes remain the persistence backstop.',
);
}
return 0;
});
@@ -789,12 +815,28 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
let hooksWritten = false;
if (harness === 'claude-code') {
if (hooksConsent) {
const r = writeClaudeHooks(ws, {
gbrainBin,
env: { GBRAIN_SOURCE: sourceId, ...(gbrainHome ? { GBRAIN_HOME: gbrainHome } : {}) },
});
// Carrier choice [D12]: cloud sandboxes clone fresh and snapshot hook
// config at session start — only the repo-COMMITTED settings file
// exists there, so cloud installs write the committed carrier
// (PATH-resolved, fail-open commands; no machine paths). Local
// installs keep the gitignored settings.local.json with the absolute
// binary path. The writers enforce that one event never fires from
// both files.
const hookEnv = { GBRAIN_SOURCE: sourceId, ...(gbrainHome ? { GBRAIN_HOME: gbrainHome } : {}) };
const cloudCarrier = detectExecutionEnvironment() === 'cloud-sandbox';
const r = cloudCarrier
? writeCommittedClaudeHooks(ws, { env: hookEnv })
: writeClaudeHooks(ws, { gbrainBin, env: hookEnv });
hooksWritten = true;
console.log(`hooks installed (${r.installed.length} event(s)) in ${r.settingsPath}${repair ? ' [repair]' : ''} — your brain now loads every turn. Turn off any time with GBRAIN_HOOKS=0, or re-run with --no-hooks.`);
console.log(
`hooks installed (${r.installed.length} event(s)) in ${r.settingsPath}${repair ? ' [repair]' : ''} — your brain now loads every turn. Turn off any time with GBRAIN_HOOKS=0, or re-run with --no-hooks.`,
);
if (cloudCarrier) {
console.log(
'cloud sandbox: hooks written to the COMMITTED .claude/settings.json (fail-open, PATH-resolved) — ' +
'commit + push it so the next session starts with hooks live; hooks written mid-session activate on the NEXT session (startup snapshot).',
);
}
for (const note of r.notes) console.error(note);
} else {
console.log(
@@ -913,6 +955,10 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
'offered: export facts before deletion (`gbrain facts export`) — the brain DB is about to be removed and facts are not derived state',
);
}
// Read the source id BEFORE uninstallWorkspace removes rendered files —
// the durability teardown below needs it and the manifest may not survive.
const preState = readManifest(ws);
const durabilitySourceId = preState.state === 'initialized' ? preState.manifest.source_id : 'workspace';
const result = await uninstallWorkspace(ws, {
deleteBrain,
...(yes ? { confirm: async () => true } : {}),
@@ -935,6 +981,41 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
}
}
// Per-root hook-lane state [D13]: remove this workspace's push-status,
// debounce, and announce files — a dead root's failing record would
// otherwise re-fire the failure banner forever (it can never be cleared
// by a re-push once the workspace is gone).
try {
const { pushStatusPathForRoot, workspaceRootHash } = await import('../core/workspace-push.ts');
const { execFileSync } = await import('node:child_process');
let root = ws;
try {
root = execFileSync('git', ['-C', ws, 'rev-parse', '--show-toplevel'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 5_000, env: process.env,
}).toString().trim() || ws;
} catch { /* not a repo — use ws as-is */ }
const { rmSync } = await import('node:fs');
const statusFile = pushStatusPathForRoot(root);
for (const f of [statusFile, `${statusFile}.announced`, join(resolveGbrainHome(), 'bootstrap', `stop-push-${workspaceRootHash(root)}.json`)]) {
rmSync(f, { force: true });
}
} catch { /* best-effort */ }
// Durability teardown [B6]: uninstall previously left the launchd/cron
// job, the untracked post-commit hook, and the credential wiring behind.
// Best-effort — a teardown hiccup never fails the uninstall. The COMMITTED
// helper script and AGENTS.md rules stay (repo content is the user's).
try {
const { unhardenBrainRepo } = await import('../core/brain-repo-durability.ts');
const steps = await unhardenBrainRepo({ repoPath: ws, sourceId: durabilitySourceId });
const acted = steps.filter((s) => s.status === 'fixed');
if (acted.length > 0) {
console.log(`durability wiring removed: ${acted.map((s) => s.step).join(', ')} (committed helper + AGENTS rules stay — repo content is yours)`);
}
} catch (e) {
console.error(`note: durability teardown incomplete (${(e as Error).message}) — run \`gbrain sources unharden ${durabilitySourceId}\` by hand if a scheduled job lingers.`);
}
// The facts-export offer already printed BEFORE deletion (above); don't
// repeat it after the brain is gone.
for (const step of result.steps) {
@@ -972,7 +1053,7 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
const logCtx: LogCtx = { home, ws, ...(harnessForLog ? { harness: harnessForLog } : {}) };
const t0 = Date.now();
const KNOWN = new Set(['status', 'interview', 'render', 'repo', 'hooks', 'verify', 'attach', 'uninstall']);
const KNOWN = new Set(['status', 'interview', 'render', 'repo', 'hooks', 'verify', 'attach', 'uninstall', 'cloud-setup-script']);
if (!KNOWN.has(sub)) {
console.error(`unknown subcommand: ${sub}`);
console.error(BOOTSTRAP_HELP);
@@ -989,6 +1070,13 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
case 'status':
// status is the read surface — it does not log itself into install.jsonl.
return await runStatus(ws, rest, home);
case 'cloud-setup-script': {
// Pure print [D16]: the paste-ready cloud environment setup script.
// Read surface like status — no install log entry.
const { loadCloudSetupScript } = await import('../core/bootstrap/assets.ts');
console.log(loadCloudSetupScript().trimEnd());
return 0;
}
case 'interview':
code = await runInterview(ws, rest);
break;
+55 -1
View File
@@ -91,6 +91,21 @@ export async function runConfig(engine: BrainEngine, args: string[]) {
console.error('Usage: gbrain config unset <key> | --pattern <prefix>');
process.exit(1);
}
if (key === 'push.allow_unverified_remote' || key === 'hooks.stop_push_debounce_min') {
const { loadConfigFileOnly, saveConfig } = await import('../core/config.ts');
const cfg = loadConfigFileOnly();
const [top, leaf] = key.split('.') as ['push' | 'hooks', string];
const branch = cfg?.[top] as Record<string, unknown> | undefined;
if (cfg && branch && leaf in branch) {
delete branch[leaf];
saveConfig(cfg);
console.log(`Unset ${key} (file plane)`);
} else {
console.error(`Config key not found: ${key}`);
process.exit(1);
}
return;
}
const n = await engine.unsetConfig(key);
if (n > 0) {
console.log(`Unset ${key}`);
@@ -111,7 +126,14 @@ export async function runConfig(engine: BrainEngine, args: string[]) {
// overlays env onto the file) — and report which plane answered on
// stderr, keeping stdout a bare value for scripts.
const filePlane = loadConfig() as Record<string, unknown> | null;
const fileVal = filePlane?.[key];
// Dotted keys (push.allow_unverified_remote, hooks.stop_push_debounce_min)
// are stored NESTED by `set`; resolve the path so `get`/`unset` see them.
const resolveDotted = (obj: Record<string, unknown> | null, k: string): unknown => {
if (!obj) return undefined;
if (k in obj) return obj[k];
return k.split('.').reduce<unknown>((acc, seg) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[seg] : undefined), obj);
};
const fileVal = resolveDotted(filePlane, key);
const dbVal = await engine.getConfig(key);
const val = fileVal !== undefined && fileVal !== null ? fileVal : dbVal;
if (val !== null && val !== undefined) {
@@ -129,6 +151,38 @@ export async function runConfig(engine: BrainEngine, args: string[]) {
process.exit(1);
}
} else if (action === 'set' && key && value) {
// Bootstrap hook-lane keys are FILE-plane canonical: they are read by
// engine-free processes (the harness hook children and the detached
// `sources push` child) via loadConfigFileOnly, which never sees the DB
// plane — and the DB plane is unreadable anyway while a `gbrain serve`
// holds the single-writer lock. Route them to ~/.gbrain/config.json.
if (key === 'push.allow_unverified_remote' || key === 'hooks.stop_push_debounce_min') {
const { loadConfigFileOnly, saveConfig, isConfigTruthy } = await import('../core/config.ts');
const cfg = (loadConfigFileOnly() ?? { engine: 'pglite' }) as Parameters<typeof saveConfig>[0];
if (key === 'push.allow_unverified_remote') {
const on = isConfigTruthy(value);
cfg.push = { ...(cfg.push ?? {}), allow_unverified_remote: on };
saveConfig(cfg);
console.log(`Set ${key} = ${on} (file plane: ~/.gbrain/config.json)`);
if (on) {
console.log(
'WARNING: workspace pushes now SKIP repo-visibility verification. ' +
'This trusts the remote on your word — unset it once verification works: ' +
'gbrain config set push.allow_unverified_remote false',
);
}
} else {
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n) || n < 0) {
console.error(`[config] ${key} must be an integer >= 0 (minutes; 0 = push every turn)`);
process.exit(1);
}
cfg.hooks = { ...(cfg.hooks ?? {}), stop_push_debounce_min: n };
saveConfig(cfg);
console.log(`Set ${key} = ${n} (file plane: ~/.gbrain/config.json)`);
}
return;
}
// v0.37.11.0 fix wave (Lane C.2 + CDX2-13): refuse writes to schema-sizing
// fields unconditionally. These fields size the `content_chunks.embedding`
// column at init time and are file-plane canonical. `gbrain config set
+111 -27
View File
@@ -8409,9 +8409,12 @@ export async function bootstrapDoctorChecks(engine: BrainEngine | null): Promise
return [];
}
const receipt = readReceipt(home);
const pushStatusFile = join(home, 'bootstrap', 'push-status.json');
// One reader for every push-status surface [D8]; per-root files [D13].
const { readPushStatuses, pushStatusFilesExist } = await import('../core/workspace-push.ts');
const pushStatuses = readPushStatuses();
const statusFilesOnDisk = pushStatusFilesExist();
const heartbeatFile = join(home, 'integrations', 'hooks', 'heartbeat.jsonl');
const hasBootstrapState = receipt !== null || existsSync(pushStatusFile) || existsSync(heartbeatFile);
const hasBootstrapState = receipt !== null || statusFilesOnDisk || existsSync(heartbeatFile);
if (!hasBootstrapState) return [];
const ws = receipt?.workspace_dir ?? null;
@@ -8452,43 +8455,124 @@ export async function bootstrapDoctorChecks(engine: BrainEngine | null): Promise
}
// 2. Push staleness [B4]: fail when the last successful push is >48h old
// AND the workspace tree is dirty (recent work provably unpushed).
// AND the workspace tree is dirty (recent work provably unpushed). Per-root
// status files [D13]: the WORST entry decides, so one workspace's success
// can never mask another's failure.
try {
if (existsSync(pushStatusFile)) {
if (pushStatuses.length > 0) {
const { PUSH_STALE_MS } = await import('./hook.ts'); // hook.ts owns the threshold (single source)
const s = JSON.parse(readFileSync(pushStatusFile, 'utf8')) as { ts?: string; ok?: boolean; reason?: string };
const t = s.ts ? Date.parse(s.ts) : NaN;
const stale = Number.isFinite(t) && Date.now() - t > PUSH_STALE_MS;
let dirty = false;
if (ws) {
try {
dirty = execFileSync('git', ['-C', ws, 'status', '--porcelain'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000,
}).toString().trim() !== '';
} catch { dirty = false; }
}
if (s.ok === false) {
const failing = pushStatuses.filter((s) => s.ok === false);
if (failing.length > 0) {
const s = failing[0]!;
const target = s.repoRoot ?? ws ?? undefined;
const rest = failing.length > 1 ? ` [+${failing.length - 1} more workspace(s)]` : '';
checks.push({
name: 'bootstrap_push_health',
status: 'warn',
message: `last workspace push FAILED (${s.ts ?? 'unknown'}): ${s.reason ?? 'unknown'} — run \`gbrain sources push${ws ? ` --path ${ws}` : ''}\``,
message: `last workspace push FAILED${target ? ` for ${target}` : ''} (${s.ts ?? 'unknown'}): ${s.reason ?? 'unknown'}${rest} — run \`gbrain sources push${target ? ` --path ${target}` : ''}\``,
});
} else if (stale && dirty) {
checks.push({
name: 'bootstrap_push_health',
status: 'fail',
message: `last successful push ${s.ts} (>48h) with a DIRTY workspace tree — recent agent memory is unpushed [B4]. Run \`gbrain sources push --path ${ws}\`.`,
});
} else if (stale) {
checks.push({ name: 'bootstrap_push_health', status: 'warn', message: `last successful push ${s.ts} (>48h ago); tree clean — likely just idle` });
} else {
checks.push({ name: 'bootstrap_push_health', status: 'ok', message: `last push ok (${s.ts ?? 'unknown'})` });
const stamps = pushStatuses.map((s) => Date.parse(s.ts ?? '')).filter((t) => Number.isFinite(t));
const stalest = stamps.length > 0 ? Math.min(...stamps) : NaN;
const staleIso = Number.isFinite(stalest) ? new Date(stalest).toISOString() : 'unknown';
const stale = Number.isFinite(stalest) && Date.now() - stalest > PUSH_STALE_MS;
let dirty = false;
if (ws) {
try {
dirty = execFileSync('git', ['-C', ws, 'status', '--porcelain'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000,
}).toString().trim() !== '';
} catch { dirty = false; }
}
if (stale && dirty) {
checks.push({
name: 'bootstrap_push_health',
status: 'fail',
message: `last successful push ${staleIso} (>48h) with a DIRTY workspace tree — recent agent memory is unpushed [B4]. Run \`gbrain sources push --path ${ws}\`.`,
});
} else if (stale) {
checks.push({ name: 'bootstrap_push_health', status: 'warn', message: `last successful push ${staleIso} (>48h ago); tree clean — likely just idle` });
} else {
checks.push({ name: 'bootstrap_push_health', status: 'ok', message: `last push ok (${staleIso})` });
}
}
} else if (statusFilesOnDisk) {
// Files exist but none parsed — the tolerant reader skips corrupt
// records; doctor must not let that read as "no news is good news".
checks.push({ name: 'bootstrap_push_health', status: 'warn', message: 'push status unreadable' });
}
} catch {
checks.push({ name: 'bootstrap_push_health', status: 'warn', message: 'push-status.json unreadable' });
checks.push({ name: 'bootstrap_push_health', status: 'warn', message: 'push status unreadable' });
}
// 2b. Durability job [B7/D7]: presence + LIVENESS. A presence-only check
// certifies dead jobs as healthy (the autopilot-status failure mode), so
// this warns on plist-present-but-unloaded and stale pull logs. Only warns
// when the user actually consented to the job; containers/cloud sandboxes
// are expected to have none.
try {
if (ws !== null && receipt !== null) {
const { detectExecutionEnvironment } = await import('../core/execution-env.ts');
const envKind = detectExecutionEnvironment();
if (envKind !== 'local') {
// Answered BEFORE the subprocess probes — cloud/container doctor
// runs must not pay launchctl/crontab spawns for an answer that is
// discarded (no scheduler exists there by design).
checks.push({
name: 'bootstrap_durability_job',
status: 'ok',
message: `no scheduler in this environment (${envKind}) — expected; per-turn and session-end pushes cover persistence`,
});
} else {
const { durabilityJobStatus } = await import('../core/brain-repo-durability.ts');
const { readInterviewState } = await import('../core/bootstrap/interview.ts');
const sourceId = receipt.source_id ?? 'workspace';
const js = durabilityJobStatus(sourceId);
let consented = false;
try {
const iv = readInterviewState(ws);
consented = iv.ok && (iv.state.answers['PERSIST_CRON']?.value ?? '').toLowerCase() === 'yes';
} catch { consented = false; }
if (!consented) {
if (js.kind !== 'none') {
checks.push({ name: 'bootstrap_durability_job', status: 'ok', message: `${js.kind} pull job present (not required by consent — fine)` });
}
// no consent + no job → nothing to check; stay silent
} else if (js.kind === 'none') {
checks.push({
name: 'bootstrap_durability_job',
status: 'warn',
message: `background persistence was consented (PERSIST_CRON=yes) but no scheduled job exists — run \`gbrain sources harden ${sourceId}\``,
});
} else if (js.live === false) {
checks.push({
name: 'bootstrap_durability_job',
status: 'warn',
message: `${js.kind} job is on disk but NOT loaded — a dead job looks healthy to presence checks. Re-run \`gbrain sources harden ${sourceId}\` to reload it.`,
});
} else if (!js.wrapperPresent) {
checks.push({
name: 'bootstrap_durability_job',
status: 'warn',
message: `${js.kind} job exists but its wrapper script is missing — re-run \`gbrain sources harden ${sourceId}\``,
});
} else if (js.logFresh === false) {
checks.push({
name: 'bootstrap_durability_job',
status: 'warn',
message: `${js.kind} job present but the pull log is stale (no run within 2× the interval) — the job may be dead; re-run \`gbrain sources harden ${sourceId}\``,
});
} else if (js.kind === 'crontab' && js.logFresh === undefined) {
// The crontab LINE existing proves installation, not that the cron
// daemon runs it — with no pull log yet we can't claim liveness.
checks.push({ name: 'bootstrap_durability_job', status: 'ok', message: 'crontab pull job installed (no run logged yet — liveness confirmed once it first fires)' });
} else {
checks.push({ name: 'bootstrap_durability_job', status: 'ok', message: `${js.kind} pull job present and live` });
}
}
}
} catch { /* best-effort — durability probing never fails doctor */ }
// 3. One-live-serve / lock collision note. A live serve is the healthy
// shape (it provides hook IPC); the note names the v1 contract.
try {
+276 -34
View File
@@ -67,6 +67,15 @@ import {
} from '../core/transcripts/claude-code-jsonl.ts';
import { CLAUDE_HOOK_OUTPUT_CAP_CHARS } from '../core/bootstrap/host-specs.ts';
import { readManifest, readReceipt, type InstallReceipt } from '../core/bootstrap/format.ts';
import { githubOwnerRepoString } from '../core/repo-visibility.ts';
import { detectExecutionEnvironment } from '../core/execution-env.ts';
import {
readPushStatuses,
readPushStatusForRoot,
sanitizePushReason,
summarizePushStatuses,
workspaceRootHash,
} from '../core/workspace-push.ts';
import { realpathOrResolve } from '../core/path-confine.ts';
// ── Tunables ────────────────────────────────────────────────────────────────
@@ -112,6 +121,17 @@ const USER_PROMPT_WINDOW_TURNS = 4;
export const PRIOR_CONTEXT_MAX_BYTES = 32 * 1024;
/** user-prompt transcript parse budget (tail bytes — the window only needs the newest turns). */
const USER_PROMPT_TRANSCRIPT_MAX_BYTES = 2 * 1024 * 1024;
/** stop-hook push [D3]: hard budget for the debounce decision + detached spawn
* (the spawn itself is instant; the budget bounds the two 1s git probes). */
const STOP_PUSH_DEADLINE_MS = 3000;
/** stop-hook push debounce default (minutes) for local + ephemeral-container
* environments; cloud-sandbox defaults to 0 (every turn) a reclaimed VM's
* tail loss is permanent, everywhere else SessionStart recovery covers it [D17]. */
export const STOP_PUSH_DEBOUNCE_MIN_DEFAULT = 5;
/** failure banner [D19]: re-announce floor while the same failure persists. */
export const PUSH_ANNOUNCE_REFIRE_MS = 30 * 60 * 1000;
/** failure banner budget (well under ENG-1's whole-payload cap). */
const PUSH_BANNER_MAX_CHARS = 300;
// ── Test seam ───────────────────────────────────────────────────────────────
@@ -591,16 +611,19 @@ async function lastSessionLine(): Promise<string | null> {
async function pushStatusNote(): Promise<string | null> {
try {
const home = await resolveHome();
const p = join(home, 'bootstrap', 'push-status.json');
if (!existsSync(p)) return null;
const s = JSON.parse(readFileSync(p, 'utf8')) as { ts?: string; ok?: boolean; reason?: string };
if (s.ok === false) {
return `Workspace push is FAILING (since ${s.ts ?? 'unknown'}): ${s.reason ?? 'unknown reason'} — run gbrain doctor`;
// One reader + one aggregation for every status surface [D8]; per-root
// files [D13] so one workspace's success can't mask another's failure.
const entries = readPushStatuses();
if (entries.length === 0) return null;
const { failing, stalestTs } = summarizePushStatuses(entries);
if (failing.length > 0) {
const e = failing[0]!;
const which = e.repoRoot ? ` for ${e.repoRoot}` : '';
const rest = failing.length > 1 ? ` [+${failing.length - 1} more workspace(s)]` : '';
return `Workspace push${which} is FAILING (since ${e.ts ?? 'unknown'}): ${sanitizePushReason(e.reason)}${rest} — run gbrain doctor`;
}
const t = s.ts ? Date.parse(s.ts) : NaN;
if (Number.isFinite(t) && Date.now() - t > PUSH_STALE_MS) {
return `Workspace push: last success ${s.ts} (>48h ago) — recent work may be unpushed [B4]`;
if (stalestTs !== null && Date.now() - stalestTs > PUSH_STALE_MS) {
return `Workspace push: last success ${new Date(stalestTs).toISOString()} (>48h ago) — recent work may be unpushed [B4]`;
}
return null;
} catch {
@@ -677,13 +700,10 @@ async function resolveBootstrapWorkspaceRoot(ws: string): Promise<string | null>
return root;
}
/** owner/name from a github https/ssh remote URL, or null. Local mirror of
* repo.ts's parser (kept here so the engine-free hook doesn't import repo.ts). */
/** owner/name from a github https/ssh remote URL, or null. Canonical parser
* (repo-visibility.ts is engine-free, so the hook contract holds). */
function githubOwnerName(url: string): string | null {
const m =
/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim()) ??
/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url.trim());
return m ? `${m[1]}/${m[2]}` : null;
return githubOwnerRepoString(url);
}
/**
@@ -706,14 +726,22 @@ async function repoPhaseComplete(root: string): Promise<boolean> {
if (!receipt || typeof receipt.repo_url !== 'string' || receipt.repo_url.length === 0) return false;
if (realpathOrResolve(receipt.workspace_dir) !== realpathOrResolve(root)) return false;
const want = githubOwnerName(receipt.repo_url);
if (!want) return false;
const fetchUrl = await tryExecAsync('git', ['-C', root, 'remote', 'get-url', 'origin']);
if (githubOwnerName(fetchUrl ?? '') !== want) return false;
// Push URL (remote.origin.pushurl) via the config key directly (no dash-flag):
// unset → `git push` uses the fetch URL (already matched). Only a configured
// push URL that points elsewhere blocks the push.
const pushUrl = await tryExecAsync('git', ['-C', root, 'config', 'remote.origin.pushurl']);
return !pushUrl || githubOwnerName(pushUrl) === want;
if (want) {
if (githubOwnerName(fetchUrl ?? '') !== want) return false;
return !pushUrl || githubOwnerName(pushUrl) === want;
}
// Non-github repo_url (self-hosted / explicitly-trusted transports): bind
// by EXACT URL equality — the recorded url is what the repo phase (or the
// operator) verified, and a later remote redirect must still block the
// push. Without this branch, every non-github install's no-daemon push
// deferred forever (post-#4024 regression).
if ((fetchUrl ?? '').trim() !== receipt.repo_url) return false;
return !pushUrl || pushUrl.trim() === receipt.repo_url;
} catch {
return false;
}
@@ -749,13 +777,7 @@ async function dirtyTreePush(
try {
const root = await resolveBootstrapWorkspaceRoot(ws);
if (!root) return null;
const [status, aheadRaw] = await Promise.all([
tryExecAsync('git', ['-C', root, 'status', '--porcelain']),
tryExecAsync('git', ['-C', root, 'rev-list', '--count', '@{u}..HEAD']),
]);
const dirty = (status ?? '') !== '';
const ahead = aheadRaw !== null ? parseInt(aheadRaw, 10) || 0 : 0;
if (!dirty && ahead === 0) return null; // clean + up to date → nothing to recover
if (!(await treeNeedsPush(root))) return null; // clean + up to date → nothing to recover
// There IS unpushed work. Defer until the repo phase verified privacy +
// recorded repo_url — never recover-push to an unverified origin
// (create-repo-first race). Only fires when work actually exists (P2-1).
@@ -783,6 +805,181 @@ async function dirtyTreePush(
}
}
/** True when the workspace has uncommitted changes or commits ahead of
* upstream shared by the SessionStart recovery push and the stop-hook
* per-turn push. Two 1s-capped git probes; never throws. */
async function treeNeedsPush(root: string): Promise<boolean> {
// Dirty tree → always needs a push. For "ahead", measure against the SAME
// ref workspacePush targets (origin/<default-branch>), NOT @{u}: a branch
// with no upstream makes `@{u}..HEAD` error → 0, which would report a clean
// + committed-but-unpushed tree as push_clean and silently strand it (the
// exact tail-loss the per-turn push exists to prevent). When the origin ref
// doesn't resolve yet (never pushed), any commit past the empty tree counts
// as needs-push.
const status = await tryExecAsync('git', ['-C', root, 'status', '--porcelain']);
if ((status ?? '') !== '') return true;
const branch = await tryExecAsync('git', ['-C', root, 'branch', '--show-current']);
const b = (branch ?? '').trim();
if (b) {
const ahead = await tryExecAsync('git', ['-C', root, 'rev-list', '--count', `origin/${b}..HEAD`]);
if (ahead !== null) return (parseInt(ahead, 10) || 0) > 0;
// origin/<b> doesn't exist (never pushed) → any local commit needs pushing.
const have = await tryExecAsync('git', ['-C', root, 'rev-list', '--count', 'HEAD']);
return (parseInt(have ?? '0', 10) || 0) > 0;
}
// Detached HEAD / no branch name — fall back to the upstream measure.
const ahead = await tryExecAsync('git', ['-C', root, 'rev-list', '--count', '@{u}..HEAD']);
return ahead !== null && (parseInt(ahead, 10) || 0) > 0;
}
// ── stop-hook per-turn push [D3/D17/D20] ────────────────────────────────────
//
// SessionEnd never fires on /exit (upstream: closed not-planned), can't fire
// on crash, and a cloud sandbox VM may simply be reclaimed between turns —
// so the Stop boundary (fires after EVERY assistant turn) is the only cadence
// that always runs while work exists. Debounced per workspace root, detached
// spawn (instant), fail-open everywhere.
function stopPushStatePath(root: string): string {
return join(resolveGbrainHome(), 'bootstrap', `stop-push-${workspaceRootHash(root)}.json`);
}
/** Debounce resolution: env GBRAIN_STOP_PUSH_DEBOUNCE_MIN (minutes; 0 = every
* turn) file-plane config hooks.stop_push_debounce_min environment-kind
* default (cloud-sandbox: 0, everything else: 5). */
function stopPushDebounceMs(): number {
const env = process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN;
if (env !== undefined) {
const n = Number.parseInt(env, 10);
if (Number.isFinite(n) && n >= 0) return n * 60_000;
}
try {
const cfg = loadConfig();
const v = cfg?.hooks?.stop_push_debounce_min;
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number.parseInt(v, 10) : NaN;
if (Number.isFinite(n) && n >= 0) return n * 60_000;
} catch {
/* tolerant read — fall through to the default */
}
return detectExecutionEnvironment() === 'cloud-sandbox' ? 0 : STOP_PUSH_DEBOUNCE_MIN_DEFAULT * 60_000;
}
/** Floor for the [D20] failing-status retry cadence: a stuck push (e.g. gh
* unauthenticated for a day) must not re-run the full network ladder on every
* single turn one retry a minute keeps recovery fast without the storm. */
export const STOP_PUSH_FAILING_RETRY_FLOOR_MS = 60_000;
/** Decide + (maybe) spawn the per-turn push. Returns the heartbeat reason.
* Ordered cheapest-first: the debounce (two file reads) answers the common
* case before any git subprocess runs repoPhaseComplete's git probes only
* execute on turns that might actually spawn a push. */
async function stopPushIfDue(ws: string, io: HookIo): Promise<string> {
if (process.env.GBRAIN_STOP_PUSH === '0') return 'push_disabled';
const root = await resolveBootstrapWorkspaceRoot(ws);
if (!root) return 'push_skipped_not_bootstrap';
const stateP = stopPushStatePath(root);
let lastTs: number | null = null;
try {
const s = JSON.parse(readFileSync(stateP, 'utf8')) as { ts?: string };
const t = Date.parse(s.ts ?? '');
if (Number.isFinite(t)) lastTs = t;
} catch {
/* missing/corrupt state → due (fail-open) */
}
// [D20] a failing push bypasses the normal debounce so recovery is fast —
// but with a 60s floor so a persistently failing push can't re-run the
// network verification ladder on every turn (the push lock bounds
// concurrency, not cadence; the banner is already showing the failure).
const failing = readPushStatusForRoot(root)?.ok === false;
const now = Date.now();
// Healthy: the normal debounce (0 = every turn in cloud). Failing: a fixed
// 60s retry floor — faster than a long local debounce so a transient failure
// recovers within a turn or two, but NEVER every-turn (a Math.min against the
// cloud debounce of 0 was a re-run-the-ladder-every-turn storm; adversarial
// review caught it).
const windowMs = failing ? STOP_PUSH_FAILING_RETRY_FLOOR_MS : stopPushDebounceMs();
if (lastTs !== null && now - lastTs < windowMs) return 'push_debounced';
// Same privacy gate as SessionEnd: never push before the repo phase has
// verified the origin and recorded repo_url (create-repo-first race).
if (!(await repoPhaseComplete(root))) return 'push_deferred_repo_pending';
if (!(await treeNeedsPush(root))) return 'push_clean';
try {
// Written BEFORE the spawn so repeated fail-fast children stay debounced
// on the healthy path; the [D20] failing-status bypass handles retries.
mkdirSync(join(resolveGbrainHome(), 'bootstrap'), { recursive: true, mode: 0o700 });
const tmp = `${stateP}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify({ ts: new Date(now).toISOString(), root }) + '\n', { mode: 0o600 });
renameSync(tmp, stateP);
} catch {
/* state-write failure must not block the push itself */
}
try {
(io.spawnPush ?? spawnDetachedPush)(root);
return 'push_spawned';
} catch {
return 'push_unavailable';
}
}
// ── push-failure banner [D5/D13/D19] ────────────────────────────────────────
interface PushAnnounceState {
announced_ts?: string;
last_announce_at?: string;
}
/**
* The pending 300-char failure banner, or null. `record()` marks the due
* failures announced and is called ONLY after the banner actually reached
* stdout a deadline-suppressed banner must re-fire next turn. Announce
* state is a sidecar next to each per-root status file (`<file>.announced`):
* each new failure `ts` announces once, then re-announces at most every
* PUSH_ANNOUNCE_REFIRE_MS while the failure persists [D19].
*/
function pendingPushFailureBanner(): { text: string; record: () => void } | null {
try {
const failing = readPushStatuses().filter((e) => e.ok === false);
if (failing.length === 0) return null;
const now = Date.now();
const due = failing.filter((e) => {
try {
const s = JSON.parse(readFileSync(`${e.file}.announced`, 'utf8')) as PushAnnounceState;
if (s.announced_ts !== e.ts) return true;
const last = Date.parse(s.last_announce_at ?? '');
return !Number.isFinite(last) || now - last > PUSH_ANNOUNCE_REFIRE_MS;
} catch {
return true; // never announced (or unreadable state) → due
}
});
if (due.length === 0) return null;
const first = due[0]!;
const which = first.repoRoot ?? 'the workspace';
const more = due.length > 1 ? ` (+${due.length - 1} more workspace(s))` : '';
const text = (
`NOTICE: the background workspace push for ${which} is FAILING ` +
`(${sanitizePushReason(first.reason)})${more} — work is committed locally ` +
'but NOT on GitHub. Run gbrain doctor.'
).slice(0, PUSH_BANNER_MAX_CHARS);
const record = () => {
for (const e of due) {
try {
writeFileSync(
`${e.file}.announced`,
JSON.stringify({ announced_ts: e.ts, last_announce_at: new Date(now).toISOString() }) + '\n',
{ mode: 0o600 },
);
} catch {
/* fail-open — worst case the banner re-fires */
}
}
};
return { text, record };
} catch {
return null;
}
}
// ── user-prompt [ENG-1, S3#8, A9] ───────────────────────────────────────────
interface UserPromptOutcome {
@@ -798,7 +995,18 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
if (!expired) write(io, s);
};
// [D5/D19] Same-session failure surfacing: a refused/failed background push
// becomes visible on the NEXT turn — to the model via additionalContext AND
// to the human via systemMessage ("never silent" must not depend on the
// model choosing to relay its own tooling's failure). Embedded in the main
// payload when one is written; emitted alone on every degraded path.
// Computed INSIDE the deadline-raced closure: its sync file reads must be
// budgeted by the 800ms deadline, not free-ride before the race starts.
let banner: ReturnType<typeof pendingPushFailureBanner> = null;
let wrotePayload = false;
const work = (async (): Promise<UserPromptOutcome> => {
banner = pendingPushFailureBanner();
const j = await readStdinJson(io, 300);
if (!j) return { outcome: 'degraded', reason: 'no_stdin' };
@@ -890,19 +1098,27 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
// [ENG-1] The 10000-char harness cap applies to the WHOLE stdout payload;
// the block is budgeted ≤8KB server-side, but JSON escaping inflates, so
// trim defensively rather than letting the harness divert-and-drop.
// trim defensively rather than letting the harness divert-and-drop. The
// banner (≤300 chars, fixed) rides inside the same payload [D5] — only
// blockText is trimmed, so the failure notice survives the cap loop.
const bannerPrefix = banner ? `${banner.text}\n\n` : '';
const buildPayload = (block: string) =>
JSON.stringify({
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: bannerPrefix + block },
...(banner ? { systemMessage: banner.text } : {}),
});
let blockText = text;
let payload = JSON.stringify({
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: blockText },
});
let payload = buildPayload(blockText);
while (payload.length > CLAUDE_HOOK_OUTPUT_CAP_CHARS && blockText.length > 0) {
blockText = blockText.slice(0, Math.max(0, blockText.length - (payload.length - CLAUDE_HOOK_OUTPUT_CAP_CHARS) - 16));
payload = JSON.stringify({
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: blockText },
});
payload = buildPayload(blockText);
}
if (blockText.length === 0) return { outcome: 'degraded', reason: 'over_cap', turns: turns.length };
guardedWrite(payload + '\n');
if (!expired) {
wrotePayload = true;
banner?.record();
}
// Partial trim is delivery-count drift: the serve already logged the FULL
// post-budget set at the response write, but pages cut from the tail here
// were never injected. Record it so the doctor's heartbeat reconciliation
@@ -927,6 +1143,21 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
expired = true;
result = { outcome: 'error', reason: errorCode(e) };
}
// Banner-only emission [D5]: every path that did NOT write the main payload
// (no_serve, ipc_unavailable, no_pglite_path, empty windows, transcript
// aborts, …) still surfaces the push failure — unless the deadline expired,
// in which case record() was never called and the banner re-fires next turn.
// (Local copy: TS cannot track the closure-side assignment of `banner`.)
const pendingBanner = banner as { text: string; record: () => void } | null;
if (pendingBanner && !wrotePayload && !expired) {
guardedWrite(
JSON.stringify({
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: pendingBanner.text },
systemMessage: pendingBanner.text,
}) + '\n',
);
pendingBanner.record();
}
await writeHeartbeat({
ts: new Date().toISOString(),
event: 'user-prompt',
@@ -1031,8 +1262,9 @@ async function hookStop(io: HookIo): Promise<number> {
const t0 = Date.now();
let outcome: HookHeartbeatEntry['outcome'] = 'ok';
let reason: string | undefined;
let j: Record<string, unknown> | null = null;
try {
const j = await readStdinJson(io, 300);
j = await readStdinJson(io, 300);
const sessionId = sanitizeSessionId(j?.session_id);
const dir = await liveBufferDir();
const exchange = firstString(j, ['last_assistant_message', 'lastAssistantMessage', 'prompt']);
@@ -1047,11 +1279,21 @@ async function hookStop(io: HookIo): Promise<number> {
outcome = 'error';
reason = errorCode(e);
}
// Per-turn durability push [D3/D17/D20] — its own try/deadline so the
// buffer append above and the heartbeat below are never at risk.
let pushReason: string | undefined;
try {
const ws = io.cwd ?? (typeof j?.cwd === 'string' ? (j.cwd as string) : process.cwd());
const raced = await withDeadline(STOP_PUSH_DEADLINE_MS, stopPushIfDue(ws, io));
pushReason = raced === DEADLINE ? 'push_unavailable' : raced;
} catch {
pushReason = 'push_unavailable';
}
await writeHeartbeat({
ts: new Date().toISOString(),
event: 'stop',
outcome,
...(reason ? { reason } : {}),
...((reason ?? pushReason) ? { reason: reason ?? pushReason } : {}),
duration_ms: Date.now() - t0,
});
return 0;
+12 -4
View File
@@ -1556,10 +1556,18 @@ Subcommands:
per finding via .gbrain-scan-allow) and on
tracked deny-list files (*.pglite, .env*,
*.pem, *.key, .gbrain/**). Refuses remotes not
verifiably private via gh; single-flight (a
concurrent push exits 0 as "skipped"); pushes
even on a clean tree. Writes
~/.gbrain/bootstrap/push-status.json.
verifiably private verified via REST, falling
back to pure git protocol where gh is blocked
(cloud proxies); private verdicts cached 1h.
Unverified-remote overrides (self-hosted git
you trust; every use warns loudly): the flag
above, GBRAIN_ALLOW_UNVERIFIED_REMOTE=1, or
"gbrain config set push.allow_unverified_remote
true" (file-plane reaches detached hook
children). Single-flight (a concurrent push
exits 0 as "skipped"); pushes even on a clean
tree. Writes per-root status under
~/.gbrain/bootstrap/.
unharden <id> Remove durability cron/hook/credential wiring.
Source id: [a-z0-9-]{1,32}. Immutable citation key.
+10
View File
@@ -36,6 +36,8 @@ import T_ACCESS from '../../../templates/bootstrap/ACCESS_POLICY.md.template' wi
import T_MEMORY_README from '../../../templates/bootstrap/memory-README.md.template' with { type: 'file' };
// @ts-ignore
import T_GITIGNORE from '../../../templates/bootstrap/gitignore.template' with { type: 'file' };
// @ts-ignore
import T_CLOUD_SETUP from '../../../templates/bootstrap/cloud-setup-script.sh' with { type: 'file' };
/** Where each rendered file lands, relative to the workspace root. */
export interface BootstrapTemplate {
@@ -47,6 +49,14 @@ export interface BootstrapTemplate {
group: 'identity' | 'contract' | 'scaffold';
}
/** The cloud environment setup script [D16] NOT a rendered template (it is
* pasted into the cloud env config, never written into the workspace). Lives
* as a template file deliberately: inline script strings in src modules would
* bleed their dashed tokens into the CLI flag registry. */
export function loadCloudSetupScript(): string {
return readFileSync(T_CLOUD_SETUP as unknown as string, 'utf8');
}
export const BOOTSTRAP_TEMPLATES: BootstrapTemplate[] = [
{ assetPath: T_AGENTS as unknown as string, dest: 'AGENTS.md', group: 'contract' },
{ assetPath: T_CLAUDE as unknown as string, dest: 'CLAUDE.md', group: 'contract' },
+20 -1
View File
@@ -36,6 +36,8 @@ import {
type AgentManifest,
type InstallReceipt,
} from './format.ts';
import { gitOriginUrl } from './status.ts';
import { type RepoReceipt } from './repo.ts';
import { BootstrapError } from './lock.ts';
export type AttachStepKind = 'register_source' | 'hooks_repair' | 'mcp_add' | 'verify';
@@ -99,7 +101,23 @@ export function attachWorkspace(workspaceDir: string, opts: AttachWorkspaceOptio
const existing = readReceipt(gbrainHomeDir);
const sameWorkspace = existing !== null && realpathOrResolve(existing.workspace_dir) === resolvedWs;
const receipt: InstallReceipt = {
// Record repo_url from the adopted origin so the no-daemon push gate
// (repoPhaseComplete) recognizes the repo phase as done on this machine —
// otherwise the per-turn/session-end pushes defer FOREVER after an attach
// (the ONLY install path in a cloud sandbox, where `bootstrap repo` is
// refused). This does NOT bypass the privacy gate: workspacePush still runs
// the visibility ladder at push time and fails closed on a public/
// unverifiable origin. Preserve an existing repo_url on a same-workspace
// re-attach; else adopt the current github origin.
const existingRepoUrl = sameWorkspace ? (existing as RepoReceipt).repo_url : undefined;
const originUrl = gitOriginUrl(resolvedWs);
// Record the origin as repo_url whenever one exists — github or self-hosted.
// repoPhaseComplete binds by owner/name for github and by exact URL otherwise;
// either way workspacePush still runs the visibility ladder at push time, so a
// non-verifiable origin stays fail-closed (refused unless the operator sets the
// escape hatch). Recording it only clears the "repo phase ran" gate.
const adoptedRepoUrl = existingRepoUrl ?? (originUrl ?? undefined);
const receipt: RepoReceipt = {
receipt_version: 1,
workspace_dir: resolvedWs,
source_id: manifest.source_id,
@@ -111,6 +129,7 @@ export function attachWorkspace(workspaceDir: string, opts: AttachWorkspaceOptio
brain_created_by_bootstrap: sameWorkspace ? existing.brain_created_by_bootstrap : false,
created_paths: sameWorkspace ? existing.created_paths : [],
registrations: sameWorkspace ? existing.registrations : [],
...(adoptedRepoUrl ? { repo_url: adoptedRepoUrl } : {}),
};
// writeReceipt assumes the bootstrap/ subdir exists; attach runs on a fresh
// machine where nothing has created it yet.
+181 -2
View File
@@ -33,6 +33,7 @@ import {
} from 'node:fs';
import { dirname, isAbsolute, join } from 'node:path';
import {
CLAUDE_COMMITTED_SETTINGS_FILE_RELPATH,
CLAUDE_HOOK_DEFAULT_TIMEOUT_SECS,
CLAUDE_HOOK_EVENTS,
CLAUDE_HOOK_SUBCOMMAND,
@@ -125,6 +126,74 @@ export function buildClaudeHookCommand(
return parts.map(shellQuote).join(' ');
}
export function claudeCommittedSettingsPath(workspaceDir: string): string {
return join(workspaceDir, CLAUDE_COMMITTED_SETTINGS_FILE_RELPATH);
}
/**
* The COMMITTED carrier's command [D12]: PATH-resolved and fail-open. No
* absolute binary path the committed file travels between machines and
* cloud sessions; wherever gbrain is not installed the hook exits 0 silently
* instead of erroring every turn.
*/
export function buildPortableClaudeHookCommand(event: ClaudeHookEvent, env: ClaudeHookEnv): string {
const assignments: string[] = [`GBRAIN_SOURCE=${env.GBRAIN_SOURCE}`];
if (env.GBRAIN_HOME) assignments.push(`GBRAIN_HOME=${env.GBRAIN_HOME}`);
const invoke = ['env', ...assignments, 'gbrain', 'hook', CLAUDE_HOOK_SUBCOMMAND[event]]
.map(shellQuote)
.join(' ');
return `command -v gbrain >/dev/null 2>&1 && ${invoke} || exit 0`;
}
/** Events the COMMITTED settings file already carries with our marker [D12]
* the local writer skips these so one event never fires from both files. */
/** Pull the GBRAIN_SOURCE value out of a rendered portable hook command so the
* exact-match check is agnostic to the (operator-chosen) source id. Returns
* null when the command isn't shaped like ours. */
function extractHookSource(command: string): string | null {
const m = /command -v gbrain >\/dev\/null 2>&1 && env GBRAIN_SOURCE=('[^']*'|[^ ]+) gbrain hook /.exec(command);
if (!m) return null;
const raw = m[1]!;
return raw.startsWith("'") ? raw.slice(1, -1).replace(/'\\''/g, "'") : raw;
}
export function committedHookEvents(workspaceDir: string): Set<ClaudeHookEvent> {
const carried = new Set<ClaudeHookEvent>();
try {
const raw = readFileSync(claudeCommittedSettingsPath(workspaceDir), 'utf8');
const parsed = JSON.parse(raw) as { hooks?: Record<string, unknown> };
const hooks = parsed?.hooks;
if (typeof hooks !== 'object' || hooks === null) return carried;
for (const event of CLAUDE_HOOK_EVENTS) {
const groups = (hooks as Record<string, unknown>)[event];
if (!Array.isArray(groups)) continue;
const ours = groups.some(
(g) =>
typeof g === 'object' && g !== null &&
Array.isArray((g as HookMatcherGroup).hooks) &&
((g as HookMatcherGroup).hooks as unknown[]).some(
(h) =>
isOurs(h) &&
// A committed file is repo-contributor-writable: a marker + a
// bare `includes('gbrain hook')` substring is spoofable
// (`evil; # gbrain hook` suppresses the real local install AND
// runs attacker code). Require the EXACT portable-command shape
// this event would render — the anchored `command -v gbrain …`
// guard + `|| exit 0` structure a foreign command can't fake.
typeof (h as HookCommandEntry).command === 'string' &&
(h as HookCommandEntry).command === buildPortableClaudeHookCommand(event, {
GBRAIN_SOURCE: extractHookSource((h as HookCommandEntry).command as string) ?? '',
}),
),
);
if (ours) carried.add(event);
}
} catch {
/* absent/corrupt committed file → nothing carried */
}
return carried;
}
function isOurs(entry: unknown): boolean {
return (
typeof entry === 'object' &&
@@ -253,6 +322,11 @@ export function writeClaudeHooks(
}
const events = opts.events ?? [...CLAUDE_HOOK_EVENTS];
// [D12] Dedupe invariant: an event carried by the COMMITTED settings file
// never also fires from the local file. The local writer still strips its
// own prior entries for carried events (removing stale local copies), but
// re-adds nothing for them.
const carried = committedHookEvents(workspaceDir);
let removedPrior = 0;
const installed: Array<{ event: ClaudeHookEvent; command: string }> = [];
@@ -269,6 +343,13 @@ export function writeClaudeHooks(
const { kept, removed } = stripOurEntries(groups as unknown[]);
removedPrior += removed;
if (carried.has(event)) {
notes.push(`${event}: carried by the committed .claude/settings.json — local entry skipped [D12]`);
if (kept.length === 0) delete hooks[event];
else hooks[event] = kept;
continue;
}
const command = buildClaudeHookCommand(opts.gbrainBin, event, opts.env);
const timeout = opts.timeoutSecs?.[event] ?? CLAUDE_HOOK_DEFAULT_TIMEOUT_SECS[event];
const entry: HookCommandEntry = {
@@ -294,14 +375,112 @@ export function writeClaudeHooks(
return { settingsPath, installed, removedPrior, backupPath, brokenBackupPath, notes };
}
/**
* Write hooks into the COMMITTED `.claude/settings.json` [D12] the only
* carrier that survives into fresh cloud clones (hooks are snapshotted at
* session start; the gitignored local file never exists there). Commands are
* PATH-resolved and fail-open (buildPortableClaudeHookCommand). After the
* committed write, the same events are stripped from the LOCAL file so an
* event never fires from both carriers.
*/
export function writeCommittedClaudeHooks(
workspaceDir: string,
opts: { env: ClaudeHookEnv; events?: ClaudeHookEvent[]; timeoutSecs?: Partial<Record<ClaudeHookEvent, number>> },
): WriteClaudeHooksResult {
if (opts.env.GBRAIN_HOME) {
throw new Error(
'GBRAIN_HOME is machine-specific and must not be embedded in the COMMITTED hook carrier ' +
'(the file travels between machines) — isolated installs stay on the local carrier',
);
}
for (const [k, v] of Object.entries(opts.env)) {
if (typeof v === 'string' && /[\n\r\0]/.test(v)) {
throw new Error(`env ${k} contains control characters — refusing to embed in a hook command`);
}
}
const settingsPath = claudeCommittedSettingsPath(workspaceDir);
const { settings, existed, brokenBackupPath, notes } = loadSettings(settingsPath);
let hooks = settings.hooks as Record<string, unknown> | undefined;
if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) {
if (hooks !== undefined) {
notes.push(
`WARNING: existing "hooks" key was not an object (${JSON.stringify(hooks).slice(0, 80)}); ` +
`replaced — the original file is in the .bak backup.`,
);
}
hooks = {};
}
const events = opts.events ?? [...CLAUDE_HOOK_EVENTS];
let removedPrior = 0;
const installed: Array<{ event: ClaudeHookEvent; command: string }> = [];
for (const event of events) {
let groups = hooks[event];
if (!Array.isArray(groups)) {
if (groups !== undefined) {
notes.push(`WARNING: existing hooks.${event} was not an array; replaced — original in the .bak backup.`);
}
groups = [];
}
const { kept, removed } = stripOurEntries(groups as unknown[]);
removedPrior += removed;
const command = buildPortableClaudeHookCommand(event, opts.env);
const timeout = opts.timeoutSecs?.[event] ?? CLAUDE_HOOK_DEFAULT_TIMEOUT_SECS[event];
const entry: HookCommandEntry = {
type: 'command',
command,
timeout,
[GBRAIN_HOOK_MARKER_KEY]: GBRAIN_HOOK_MARKER_VALUE,
};
kept.push({ hooks: [entry] });
hooks[event] = kept;
installed.push({ event, command });
}
settings.hooks = hooks;
let backupPath: string | null = null;
if (existed && brokenBackupPath === null) {
backupPath = `${settingsPath}.bak`;
copyFileSync(settingsPath, backupPath);
}
atomicWriteJson(settingsPath, settings);
// [D12] dedupe: the committed carrier now owns these events — remove any
// local copies so nothing double-fires on this machine.
const localCleanup = removeHooksFromFile(claudeSettingsPath(workspaceDir));
if (localCleanup.removed > 0) {
notes.push(
`removed ${localCleanup.removed} local settings.local.json entr${localCleanup.removed === 1 ? 'y' : 'ies'} — the committed carrier owns the events now [D12]`,
);
}
return { settingsPath, installed, removedPrior, backupPath, brokenBackupPath, notes };
}
/**
* Remove ONLY marker-carrying entries [G5]. A parse-broken file is left
* untouched (removal must never destroy what it cannot read) the note says
* so. Event arrays we emptied lose their key; an emptied hooks object loses
* its key; foreign structure survives.
* its key; foreign structure survives. Cleans BOTH carriers (local +
* committed [D12]); the returned settingsPath/backup describe the local one,
* with committed-file actions reported via notes.
*/
export function removeClaudeHooks(workspaceDir: string): RemoveClaudeHooksResult {
const settingsPath = claudeSettingsPath(workspaceDir);
const local = removeHooksFromFile(claudeSettingsPath(workspaceDir));
const committed = removeHooksFromFile(claudeCommittedSettingsPath(workspaceDir));
const notes = [...local.notes];
if (committed.removed > 0) {
notes.push(`also removed ${committed.removed} entr${committed.removed === 1 ? 'y' : 'ies'} from the committed ${committed.settingsPath} [D12]`);
} else {
notes.push(...committed.notes.map((n) => `(committed carrier) ${n}`));
}
return {
settingsPath: local.settingsPath,
removed: local.removed + committed.removed,
backupPath: local.backupPath,
notes,
};
}
function removeHooksFromFile(settingsPath: string): RemoveClaudeHooksResult {
const notes: string[] = [];
if (!existsSync(settingsPath)) {
return { settingsPath, removed: 0, backupPath: null, notes: ['no settings file — nothing to remove'] };
+10
View File
@@ -99,6 +99,16 @@ export const TARGETS: Record<string, HostSpecTarget> = {
/** Settings file the hook writer targets, relative to the workspace root. */
export const CLAUDE_SETTINGS_FILE_RELPATH = join('.claude', 'settings.local.json');
/** The COMMITTED hook carrier [D12]. Cloud sessions clone the repo fresh and
* snapshot hook config at session start the gitignored settings.local.json
* never exists there, and hooks written mid-session never activate. Cloud and
* attach installs therefore write hooks into the repo-committed
* `.claude/settings.json` with a PATH-resolved fail-open command (no absolute
* paths the file travels between machines). Local installs keep
* settings.local.json; the writers enforce that one event never fires from
* both files. */
export const CLAUDE_COMMITTED_SETTINGS_FILE_RELPATH = join('.claude', 'settings.json');
/** Hook events bootstrap wires (plan D5 + hook events table).
* v0.45.7 ambient recall adds PreCompact: it BANKS the window's standing
* entities into session_context_state so the post-compaction SessionStart
+4
View File
@@ -57,6 +57,10 @@ export type BootstrapErrorCode =
/** Privacy verify could not complete (rate limit / 5xx) refuse and name
* the reason, never fail-open; the fix is re-running, not panic [G8]. */
| 'VERIFY_UNAVAILABLE'
/** Cloud sandbox: a repo created mid-session is never attached to the
* session's GitHub proxy scope (REST 403, push denied) creation must
* happen outside; the session is opened ON the repo, then attach [D-cloud]. */
| 'CLOUD_SANDBOX_REPO'
/** No agent.json — not an agent workspace. */
| 'NOT_A_WORKSPACE'
/** agent.json says `initialized: false` — an unrendered template clone [CX2-1]. */
+41 -31
View File
@@ -35,6 +35,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { configDir } from '../config.ts';
import { realpathOrResolve } from '../path-confine.ts';
import { defaultRunner, isProxyBlocked403, parseGithubOwnerRepo, type ExecRunner } from '../repo-visibility.ts';
import { detectExecutionEnvironment } from '../execution-env.ts';
import { loadWorkspaceAllowlist, scanFiles, SCAN_ALLOW_FILENAME } from '../secret-scan.ts';
import { GITHUB_URL_PLACEHOLDER } from './assets.ts';
import {
@@ -52,30 +54,10 @@ import { BootstrapError } from './lock.ts';
// Exec seam (shared by uninstall.ts; the dispatcher passes the real runner)
// ---------------------------------------------------------------------------
export interface ExecResult {
code: number;
stdout: string;
stderr: string;
}
/** Injectable subprocess seam. argv[0] is the binary; never a shell string. */
export type ExecRunner = (argv: string[]) => Promise<ExecResult>;
/** Default runner: Bun.spawn, both streams piped, spawn failure → code 127. */
export const defaultRunner: ExecRunner = async (argv: string[]): Promise<ExecResult> => {
try {
const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { code, stdout, stderr };
} catch (e) {
// Binary not on PATH (or unspawnable) — the conventional not-found code.
return { code: 127, stdout: '', stderr: (e as Error).message };
}
};
// Canonical definitions moved to repo-visibility.ts (the visibility ladder
// needs the same seam and must stay a leaf module); re-exported here so every
// existing consumer (uninstall.ts, bootstrap.ts, tests) keeps its import path.
export { defaultRunner, type ExecResult, type ExecRunner } from '../repo-visibility.ts';
// ---------------------------------------------------------------------------
// Receipt extension: the created repo URL [CX2-12 idempotency key]
@@ -97,6 +79,11 @@ export interface RepoReceipt extends InstallReceipt {
// Helpers
// ---------------------------------------------------------------------------
/** The one copy of the cloud repo-adoption instruction (two error sites). */
export const CLOUD_ATTACH_FLOW_HINT =
'Create the private repo from a normal machine (or github.com), open a cloud session ON that repo, ' +
'then run `gbrain bootstrap attach`.';
/** GitHub repo-name slug: lowercase, alnum runs joined by '-'. */
export function slugifyRepoName(name: string): string {
const slug = name
@@ -106,12 +93,11 @@ export function slugifyRepoName(name: string): string {
return slug || 'agent';
}
/** Parse owner/name out of an https or ssh GitHub remote URL. */
/** Parse owner/name out of an https or ssh GitHub remote URL. Thin adapter
* over the canonical repo-visibility parser (one grammar, three consumers). */
export function parseGithubRemote(url: string): { owner: string; name: string } | null {
const m =
/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim()) ??
/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url.trim());
return m ? { owner: m[1], name: m[2] } : null;
const p = parseGithubOwnerRepo(url);
return p ? { owner: p.owner, name: p.repo } : null;
}
/** Re-exported for existing consumers; the single definition lives in
@@ -277,11 +263,20 @@ async function verifyRepoPrivate(runner: ExecRunner, owner: string, name: string
}
const res = await runner(['gh', 'api', `repos/${owner}/${name}`, '--jq', '.private']);
if (res.code !== 0) {
const reason = isRateLimitOr5xx(res.stderr) ? 'GitHub API rate limit / server error' : `gh api failed: ${res.stderr.trim() || `exit ${res.code}`}`;
// Classify the failure so the operator gets the REAL fix: a sandbox
// egress proxy blocking REST for a repo not attached to the session is a
// different problem from a token/rate-limit failure [D14 messaging].
const proxyBlocked = isProxyBlocked403(res.stderr);
const reason = proxyBlocked
? "this sandbox's egress proxy blocks GitHub REST for repos not attached to the session"
: isRateLimitOr5xx(res.stderr)
? 'GitHub API rate limit / server error'
: `gh api failed: ${res.stderr.trim() || `exit ${res.code}`}`;
const nextStep = proxyBlocked ? CLOUD_ATTACH_FLOW_HINT : 'The repo may be fine — re-run `gbrain bootstrap repo` to verify.';
throw new BootstrapError(
'VERIFY_UNAVAILABLE',
`could not verify that ${owner}/${name} is private (${reason}). ` +
'The repo may be fine — re-run `gbrain bootstrap repo` to verify. Nothing is pushed to a repo whose privacy is unverified.',
`${nextStep} Nothing is pushed to a repo whose privacy is unverified.`,
{ details: { owner, name, stderr: res.stderr } },
);
}
@@ -684,6 +679,21 @@ export async function createPrivateRepo(
return { url: originUrl, name: parsed.name, disposition: viaUrlMatch ? 'reused' : 'adopted', reused: true };
}
// Cloud-sandbox guard: `gh repo create` inside a proxied cloud session
// makes a repo the session is NOT attached to — REST verification 403s and
// the proxy denies every push to it, so creation there is a dead end by
// construction. Fail fast with the flow that works instead. (Adoption of an
// EXISTING attached origin above is untouched — that is the sanctioned path.)
if (detectExecutionEnvironment() === 'cloud-sandbox') {
throw new BootstrapError(
'CLOUD_SANDBOX_REPO',
'this is a cloud sandbox session — a repo created from inside it would not be attached to the ' +
"session's GitHub scope (verification and pushes are blocked by the proxy). " +
CLOUD_ATTACH_FLOW_HINT,
{ exitCode: 2 },
);
}
// Ensure a git repo exists (main branch on fresh init).
const gitDir = await runner(['git', '-C', workspaceDir, 'rev-parse', '--git-dir']);
if (gitDir.code !== 0) {
+64 -22
View File
@@ -31,7 +31,10 @@ import { join } from 'node:path';
import { VERSION } from '../../version.ts';
import { loadConfigFileOnly } from '../config.ts';
import { binaryOnPath, detectExecutionEnvironment, type ExecutionEnvironment } from '../execution-env.ts';
import { resolveGbrainHome } from '../gbrain-home.ts';
import { githubOwnerRepoString, isProxyBlocked403 } from '../repo-visibility.ts';
import { BOOTSTRAP_TEMPLATES } from './assets.ts';
import {
readManifest,
@@ -40,7 +43,12 @@ import {
type ManifestState,
} from './format.ts';
import { interviewStatePath, status as interviewStatus } from './interview.ts';
import { CLAUDE_SETTINGS_FILE_RELPATH, GBRAIN_HOOK_MARKER_KEY, GBRAIN_HOOK_MARKER_VALUE } from './host-specs.ts';
import {
CLAUDE_COMMITTED_SETTINGS_FILE_RELPATH,
CLAUDE_SETTINGS_FILE_RELPATH,
GBRAIN_HOOK_MARKER_KEY,
GBRAIN_HOOK_MARKER_VALUE,
} from './host-specs.ts';
// ---------------------------------------------------------------------------
// The phase list [D5] — single TS source of truth
@@ -86,13 +94,6 @@ interface PhaseSpec {
detect: (ws: string, ctx: DetectCtx) => { state: PhaseState; detail?: string };
}
function binaryOnPath(name: string): boolean {
try {
return Bun.which(name) !== null;
} catch {
return false;
}
}
/** The workspace's `origin` remote URL, or null (not a repo / no origin).
* Shared with verify.ts's origin-probe sites one 5s-timeout idiom. */
@@ -119,15 +120,25 @@ export type OriginVisibility =
| { verdict: 'public'; detail: string }
| { verdict: 'unknown'; detail: string };
/** Probe an origin's visibility via `gh repo view --json isPrivate` (the same
* 5s timeout idiom as gitOriginUrl). gh missing / offline / non-GitHub
* 'unknown', never an invented answer. */
/** Probe an origin's visibility via REST — `gh api repos/{owner}/{repo}`
* NEVER `gh repo view` (GraphQL under the hood; cloud sandbox proxies pin
* GraphQL to a fixed operation set and 403 it even with a user token). Sync
* by contract: this runs inside the sync phase-detect chain, so the full
* async ladder lives in verify/push; status keeps the cheap REST answer.
* gh missing / offline / non-GitHub / 403 'unknown', never an invented
* answer; a proxy-shaped 403 names the real fix. */
export function probeOriginVisibility(origin: string): OriginVisibility {
const ownerRepo = githubOwnerRepoString(origin);
if (ownerRepo === null) {
// Never send a non-github origin string into a REST path — self-hosted
// hostnames (or URL-embedded credentials) must not reach api.github.com.
return { verdict: 'unknown', detail: 'origin is not a github.com URL — cannot verify via REST' };
}
try {
// env: process.env — Bun's execFileSync otherwise resolves the binary
// against the STARTUP env snapshot, making PATH-shimmed test fakes (and
// any runtime PATH change) invisible (the workspace-push.ts precedent).
const out = execFileSync('gh', ['repo', 'view', origin, '--json', 'isPrivate', '--jq', '.isPrivate'], {
const out = execFileSync('gh', ['api', `repos/${ownerRepo}`, '--jq', '.private'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5_000,
env: process.env,
@@ -138,21 +149,36 @@ export function probeOriginVisibility(origin: string): OriginVisibility {
if (out === 'false') return { verdict: 'public', detail: `${origin} is PUBLIC` };
return { verdict: 'unknown', detail: `gh returned unexpected output: ${out.slice(0, 80)}` };
} catch (e) {
const err = e as NodeJS.ErrnoException;
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
if (err?.code === 'ENOENT') {
return { verdict: 'unknown', detail: 'gh CLI not installed — cannot verify origin visibility' };
}
return { verdict: 'unknown', detail: `gh repo view failed (${(err?.message ?? 'unknown').slice(0, 120)})` };
const stderr = (err?.stderr ?? '').toString();
if (isProxyBlocked403(stderr)) {
return {
verdict: 'unknown',
detail: 'GitHub REST blocked by this sandbox\'s egress proxy (repo not attached to the session) — push-time verification falls back to git protocol',
};
}
return { verdict: 'unknown', detail: `gh api failed (${(err?.message ?? 'unknown').slice(0, 120)})` };
}
}
/** True when `<ws>/.claude/settings.local.json` carries a gbrain hook marker. */
/** True when either hook carrier the gitignored `settings.local.json` OR
* the committed `.claude/settings.json` [D12] carries a gbrain marker. */
export function hooksInstalled(ws: string): boolean {
try {
const raw = readFileSync(join(ws, CLAUDE_SETTINGS_FILE_RELPATH), 'utf8');
// Structural probe without a full merge: the marker key/value pair is the
// idempotency contract host-specs.ts pins, so a substring check is honest.
return raw.includes(`"${GBRAIN_HOOK_MARKER_KEY}"`) && raw.includes(`"${GBRAIN_HOOK_MARKER_VALUE}"`);
const probe = (relpath: string): boolean => {
try {
const raw = readFileSync(join(ws, relpath), 'utf8');
// Structural probe without a full merge: the marker key/value pair is
// the idempotency contract host-specs.ts pins — substring is honest.
return raw.includes(`"${GBRAIN_HOOK_MARKER_KEY}"`) && raw.includes(`"${GBRAIN_HOOK_MARKER_VALUE}"`);
} catch {
return false;
}
};
return probe(CLAUDE_SETTINGS_FILE_RELPATH) || probe(CLAUDE_COMMITTED_SETTINGS_FILE_RELPATH);
} catch {
return false;
}
@@ -416,6 +442,11 @@ export interface StatusReport {
phases: PhaseStatus[];
/** Resume hint of the first non-done phase; null when everything is done. */
next: string | null;
/** WHERE this install is running [D-cloud]: 'local' | 'cloud-sandbox' |
* 'ephemeral-container'. Installing agents branch on this cron is skipped
* in containers, repo creation is redirected in cloud sandboxes, and the
* runbook's cloud section keys off it. */
execution_environment: ExecutionEnvironment;
runbookSkew?: RunbookSkew;
/** Template-door privacy gate: set only for an UNINITIALIZED template clone
* whose origin exists and is not verifiably private. 'public' is a hard
@@ -491,11 +522,21 @@ export async function statusReport(ws: string, opts: StatusReportOpts = {}): Pro
}
}
// Support blob [B5].
// Support blob [B5]. Push status via the shared per-root reader [D8/D13]:
// a failing workspace wins over another's success; else the newest record.
let lastPush: StatusSupport['last_push'] = null;
try {
const p = join(gbrainHomeDir, 'bootstrap', 'push-status.json');
if (existsSync(p)) lastPush = JSON.parse(readFileSync(p, 'utf8')) as StatusSupport['last_push'];
const { readPushStatuses, summarizePushStatuses } = await import('../workspace-push.ts');
const entries = readPushStatuses();
const { failing } = summarizePushStatuses(entries);
const pick = failing[0] ?? entries.sort((a, b) => Date.parse(b.ts ?? '') - Date.parse(a.ts ?? ''))[0];
if (pick) {
lastPush = {
...(pick.ts !== undefined ? { ts: pick.ts } : {}),
...(pick.ok !== undefined ? { ok: pick.ok } : {}),
...(pick.reason !== undefined ? { reason: pick.reason } : {}),
};
}
} catch {
lastPush = null;
}
@@ -533,6 +574,7 @@ export async function statusReport(ws: string, opts: StatusReportOpts = {}): Pro
workspace: ws,
phases,
next,
execution_environment: detectExecutionEnvironment(),
...(runbookSkew ? { runbookSkew } : {}),
...(privacyGate ? { privacy_gate: privacyGate } : {}),
support,
+107 -9
View File
@@ -35,12 +35,13 @@ import { join } from 'node:path';
import type { BrainEngine } from '../engine.ts';
import { operations, type Operation, type OperationContext } from '../operations.ts';
import { loadConfigFileOnly, type GBrainConfig } from '../config.ts';
import { detectExecutionEnvironment } from '../execution-env.ts';
import { resolveGbrainHome } from '../gbrain-home.ts';
import { realpathOrResolve } from '../path-confine.ts';
import { runMaintenanceSweep } from '../sweep.ts';
import { detectCapabilities, renderCapabilityReport, type CapabilityReport } from '../capability.ts';
import { loadWorkspaceAllowlist, matchesGlob, scanFiles, type SecretFinding } from '../secret-scan.ts';
import { PUSH_DENY_GLOBS, verifyRemotePrivacy, pushStatusPath } from '../workspace-push.ts';
import { PUSH_DENY_GLOBS, verifyRemotePrivacy, readPushStatuses, summarizePushStatuses } from '../workspace-push.ts';
import { FACTS_DEFAULT_VISIBILITY_KEY } from '../facts/visibility.ts';
import { byteFloors } from './render.ts';
import { BOOTSTRAP_TEMPLATES, loadQuestionBank } from './assets.ts';
@@ -317,14 +318,69 @@ function checkDenyGlobs(ws: string): VerifyCheck {
}
}
function checkRepoPrivacy(ws: string): VerifyCheck {
/** Self-repair channel for existing installs [B8]: a git-TRACKED .mcp.json
* carries an absolute machine-specific binary path into the private repo.
* Fresh renders gitignore it; this warn heals pre-fix installs. Never gates. */
function checkMcpJsonHygiene(ws: string): VerifyCheck {
const id = 'mcp_json_hygiene';
try {
if (!existsSync(join(ws, '.mcp.json'))) return { id, ok: true, detail: 'no .mcp.json in the workspace' };
try {
execFileSync('git', ['-C', ws, 'ls-files', '--error-unmatch', '.mcp.json'], {
stdio: 'ignore', timeout: 5_000,
});
} catch {
return { id, ok: true, detail: '.mcp.json present but untracked (gitignored) — correct' };
}
return {
id,
ok: true, // warn-only: informational, never gating
detail:
'WARN: machine-specific .mcp.json is COMMITTED to the repo — run `git rm --cached .mcp.json` ' +
'(bootstrap now gitignores it; it regenerates via `claude mcp add` / `bootstrap hooks --repair`)',
};
} catch (e) {
return { id, ok: true, detail: `mcp.json hygiene probe failed (${(e as Error).message})` };
}
}
/** [D12 upgrade-path guard]: an event carried by BOTH hook files double-fires
* every turn (possible when the committed carrier arrives via git pull onto a
* machine whose local file predates the dedupe-aware writers). Warn-only. */
function checkHookCarrierOverlap(ws: string): VerifyCheck {
const id = 'hook_carrier_overlap';
try {
const events = (['SessionStart', 'UserPromptSubmit', 'Stop', 'SessionEnd'] as const).filter((event) => {
const has = (rel: string): boolean => {
try {
const parsed = JSON.parse(readFileSync(join(ws, rel), 'utf8')) as { hooks?: Record<string, unknown> };
const groups = parsed?.hooks?.[event];
return Array.isArray(groups) && JSON.stringify(groups).includes('"_gbrain"');
} catch {
return false;
}
};
return has(join('.claude', 'settings.json')) && has(join('.claude', 'settings.local.json'));
});
if (events.length === 0) return { id, ok: true, detail: 'no event fires from both hook carriers' };
return {
id,
ok: true, // warn-only self-repair channel
detail: `WARN: ${events.join(', ')} fire from BOTH .claude/settings.json and settings.local.json (double-fire) — run \`gbrain bootstrap hooks --repair\` to dedupe`,
};
} catch (e) {
return { id, ok: true, detail: `carrier overlap probe failed (${(e as Error).message})` };
}
}
async function checkRepoPrivacy(ws: string): Promise<VerifyCheck> {
const id = 'repo_privacy';
try {
const origin = gitOriginUrl(ws);
if (!origin) {
return { id, ok: true, detail: 'local-only (no origin remote) — run `gbrain bootstrap repo` any time to add the private body' };
}
const verdict = verifyRemotePrivacy(ws);
const verdict = await verifyRemotePrivacy(ws);
if (verdict.verdict === 'private') return { id, ok: true, detail: `origin verified private (${origin})` };
if (verdict.verdict === 'not_private') return { id, ok: false, detail: `origin is NOT private: ${verdict.detail} — make it private before pushing workspace contents` };
return { id, ok: false, detail: `origin visibility unverifiable (${verdict.detail}) — refusing to bless an unverified remote [G8]; re-run once gh works` };
@@ -333,6 +389,37 @@ function checkRepoPrivacy(ws: string): VerifyCheck {
}
}
/** Informational, NEVER gating [D-cloud]: name the detected execution
* environment and its expected degradations so an installing agent (and the
* pasted verify report) states them as facts instead of rediscovering them
* as mystery failures. */
function checkExecutionEnvironment(): VerifyCheck {
const id = 'execution_env';
try {
const env = detectExecutionEnvironment();
if (env === 'cloud-sandbox') {
return {
id,
ok: true,
detail:
'cloud sandbox detected — expected degradations: no crontab (scheduled pull skipped; per-turn/session-end pushes cover it); ' +
'GitHub GraphQL always blocked and REST scoped to session-attached repos (privacy verification falls back to git protocol); ' +
'pushes restricted to the session\'s working branch; only repo-committed files carry into the next session',
};
}
if (env === 'ephemeral-container') {
return {
id,
ok: true,
detail: 'container detected — no reliable scheduler (scheduled pull skipped); event-driven pushes cover persistence',
};
}
return { id, ok: true, detail: 'local machine — full persistence surface available' };
} catch (e) {
return { id, ok: true, detail: `environment detection failed (${(e as Error).message}) — treated as local` };
}
}
/** [CX-P1.1] Single-principal posture: facts written without an explicit
* visibility must be recallable by the owner's own sessions (the harness
* reads at visibility='world'). Set-IF-UNSET only, through the engine config
@@ -692,11 +779,19 @@ async function checkHooksSmoke(engine: BrainEngine, ws: string, sourceId: string
function checkPushProbe(ws: string): VerifyCheck {
const id = 'push_probe';
try {
const p = pushStatusPath();
if (existsSync(p)) {
const s = JSON.parse(readFileSync(p, 'utf8')) as { ts?: string; ok?: boolean; reason?: string };
if (s.ok === true) return { id, ok: true, detail: `last workspace push succeeded (${s.ts ?? 'unknown time'})` };
return { id, ok: true, warn: true, detail: `last workspace push FAILED (${s.ts ?? 'unknown'}): ${s.reason ?? 'unknown'} — run \`gbrain sources push --path ${ws}\`` };
// Read through the shared per-root reader [D8/D13] — a v0.45.8+ push
// writes push-status-<roothash>.json, not the legacy single file, so the
// old direct read reported "no push recorded" on every fresh install.
const entries = readPushStatuses();
if (entries.length > 0) {
const { failing } = summarizePushStatuses(entries);
if (failing.length > 0) {
const s = failing[0]!;
const rest = failing.length > 1 ? ` [+${failing.length - 1} more]` : '';
return { id, ok: true, warn: true, detail: `last workspace push FAILED (${s.ts ?? 'unknown'}): ${s.reason ?? 'unknown'}${rest} — run \`gbrain sources push --path ${s.repoRoot ?? ws}\`` };
}
const ok = entries.find((e) => e.ok === true);
return { id, ok: true, detail: `last workspace push succeeded (${ok?.ts ?? 'unknown time'})` };
}
const origin = gitOriginUrl(ws);
if (origin) return { id, ok: true, warn: true, detail: 'origin exists but no push recorded yet — run `gbrain sources push` once to prove the persistence path' };
@@ -813,7 +908,10 @@ export async function verifyWorkspace(
checks.push(checkByteFloors(ws));
checks.push(checkSecretScan(ws));
checks.push(checkDenyGlobs(ws));
checks.push(checkRepoPrivacy(ws));
checks.push(await checkRepoPrivacy(ws));
checks.push(checkExecutionEnvironment());
checks.push(checkMcpJsonHygiene(ws));
checks.push(checkHookCarrierOverlap(ws));
// Round-trip family only makes sense on a reachable engine.
if (engineHealthy) {
+110 -11
View File
@@ -39,6 +39,7 @@ import {
import { findResolverFile, RESOLVER_FILENAMES } from './resolver-filenames.ts';
import { redactSecretsInText } from './minions/handlers/shell-redact.ts';
import { ensureGbrainHome, resolveGbrainHome } from './gbrain-home.ts';
import { binaryOnPath } from './execution-env.ts';
// Static import → bundled into the --compile binary so the taxonomy never drifts
// and needs no runtime skills/ directory.
import filingRulesDoc from '../../skills/_brain-filing-rules.json';
@@ -312,6 +313,19 @@ function patchResolverFile(repoPath: string, dryRun: boolean): { status: StepSta
// ── Local untracked post-commit hook (D9) ───────────────────────────────────
/** Resolve the active hooks dir (honors a pre-existing core.hooksPath). */
/** Worktree-safe path inside the git dir [D6]: `.git` is a FILE in worktrees
* and submodules, so any path under it must come from git itself (rev-parse
* with the git-path query), never string-joined onto `<repo>/.git/`. */
function gitDirPath(repoPath: string, rel: string): string {
try {
const p = execFileSync('git', ['-C', repoPath, 'rev-parse', '--git-path', rel], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
if (p) return isAbsolute(p) ? p : join(repoPath, p);
} catch { /* fall through to the classic layout */ }
return join(repoPath, '.git', rel);
}
function resolveHooksDir(repoPath: string): { dir: string; tracked: boolean } {
let hooksPath = '';
try {
@@ -325,12 +339,12 @@ function resolveHooksDir(repoPath: string): { dir: string; tracked: boolean } {
const tracked = !dir.includes(`${join('.git', '')}`) && !dir.endsWith('.git/hooks');
return { dir, tracked };
}
return { dir: join(repoPath, '.git', 'hooks'), tracked: false };
return { dir: gitDirPath(repoPath, 'hooks'), tracked: false };
}
/** Ensure a repo-relative path is in .git/info/exclude so our hook stays untracked. */
/** Ensure a repo-relative path is in the git exclude file so our hook stays untracked. */
function ensureExcluded(repoPath: string, relPath: string): void {
const exclude = join(repoPath, '.git', 'info', 'exclude');
const exclude = gitDirPath(repoPath, 'info/exclude');
try {
mkdirSync(dirname(exclude), { recursive: true });
let body = existsSync(exclude) ? readFileSync(exclude, 'utf-8') : '';
@@ -525,6 +539,65 @@ function launchdPlistPath(sourceId: string): string {
return join(process.env.HOME || '', 'Library', 'LaunchAgents', `${cronLabel(sourceId)}.plist`);
}
/** Default scheduled-pull interval — ONE definition (harden + doctor probe). */
export const DEFAULT_PULL_INTERVAL_SEC = 1800;
export interface DurabilityJobStatus {
kind: 'launchd' | 'crontab' | 'none';
wrapperPresent: boolean;
/** Liveness rung [D7]: darwin = launchctl reports the label loaded; linux =
* the crontab line exists. undefined = probe unavailable on this host. */
live?: boolean;
/** Pull log fresher than 2× the interval. undefined = no log yet (fresh
* install / never fired) absence is not evidence of death. */
logFresh?: boolean;
}
/**
* Presence + LIVENESS of the scheduled-pull job [D7]. Presence-only checks
* (existsSync on the plist) certify dead jobs as healthy the documented
* autopilot-status failure mode so this probes whether the job is actually
* loaded/registered and whether its log shows recent life.
*/
export function durabilityJobStatus(
sourceId: string,
intervalSec = DEFAULT_PULL_INTERVAL_SEC,
platform: NodeJS.Platform = process.platform,
): DurabilityJobStatus {
const wrapperPresent = existsSync(cronWrapperPath(sourceId));
let kind: DurabilityJobStatus['kind'] = 'none';
let live: boolean | undefined;
if (platform === 'darwin') {
if (existsSync(launchdPlistPath(sourceId))) {
kind = 'launchd';
try {
execFileSync('launchctl', ['list', cronLabel(sourceId)], {
stdio: 'ignore', timeout: 5_000, env: process.env,
});
live = true;
} catch {
live = false; // plist on disk but not loaded — the dead-job shape
}
}
} else if (binaryOnPath('crontab')) {
try {
const tab = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8', env: process.env });
if (tab.includes(cronLabel(sourceId))) {
kind = 'crontab';
live = true; // the line exists; cron itself is the OS's liveness domain
}
} catch { /* no crontab for this user */ }
}
let logFresh: boolean | undefined;
try {
const log = join(process.env.HOME || '', '.gbrain', 'brain-pull.log');
if (existsSync(log)) {
logFresh = Date.now() - statSync(log).mtimeMs <= 2 * intervalSec * 1000;
}
} catch { /* leave undefined */ }
return { kind, wrapperPresent, ...(live !== undefined ? { live } : {}), ...(logFresh !== undefined ? { logFresh } : {}) };
}
/** Pure cron-wrapper renderer (DB-free pull; secret-free sources the shell
* profile rather than baking keys in). Exported for tests. */
export function renderCronWrapper(sourceId: string, repoPath: string, branch: string, cli: string, logPath: string): string {
@@ -534,9 +607,12 @@ export function renderCronWrapper(sourceId: string, repoPath: string, branch: st
# Sources the shell profile for secrets, then runs the hardened, DB-free pull.
[ -f ~/.zshenv ] && source ~/.zshenv 2>/dev/null
source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || true
# Self-disable if the captured checkout is gone (rename/relocation).
if [ ! -d '${q(repoPath)}/.git' ]; then
echo "$(date -u +%FT%TZ) [cron] path gone, skipping: ${q(repoPath)}" >> "${q(logPath)}" 2>/dev/null || true
# Self-disable when the captured checkout is no longer a git working tree:
# gone, OR its path reused by a non-git directory. git rev-parse recognizes
# both the classic .git-dir layout AND worktrees/submodules (where the git
# marker is a FILE), so a bare dir test would wrongly disable a live worktree.
if ! git -C '${q(repoPath)}' rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "$(date -u +%FT%TZ) [cron] not a git work tree, skipping: ${q(repoPath)}" >> "${q(logPath)}" 2>/dev/null || true
exit 0
fi
exec '${q(cli)}' sources pull --path '${q(repoPath)}' --branch '${q(branch)}'
@@ -566,10 +642,30 @@ export function generateBrainPullPlist(label: string, wrapperPath: string, home:
</plist>`;
}
function installDurabilityCron(sourceId: string, repoPath: string, branch: string, intervalSec: number, dryRun: boolean): { status: StepStatus; detail: string } {
export function installDurabilityCron(
sourceId: string,
repoPath: string,
branch: string,
intervalSec: number,
dryRun: boolean,
platform: NodeJS.Platform = process.platform,
): { status: StepStatus; detail: string } {
// Probe FIRST [D-cloud/B2]: containers and cloud sandboxes ship without
// crontab, and that is EXPECTED there — the honest answer is a skip that
// names what still covers persistence, not a needs_attention that reads
// like a bug (and no wrapper file is written for a job that can't exist).
if (platform !== 'darwin' && !binaryOnPath('crontab')) {
return {
status: 'skipped',
detail:
'no crontab on this host (container/cloud sandbox) — scheduled pull skipped; ' +
'the post-commit auto-push and per-turn/session-end pushes cover persistence here. ' +
'Run `gbrain sources harden <id>` on a persistent machine to add the scheduled pull.',
};
}
const wrapper = dryRun ? cronWrapperPath(sourceId) : writeCronWrapper(sourceId, repoPath, branch);
const home = process.env.HOME || '';
if (process.platform === 'darwin') {
if (platform === 'darwin') {
const plistPath = launchdPlistPath(sourceId);
if (dryRun) return { status: 'fixed', detail: `would install launchd ${cronLabel(sourceId)} every ${intervalSec}s (dry-run)` };
mkdirSync(dirname(plistPath), { recursive: true });
@@ -583,12 +679,15 @@ function installDurabilityCron(sourceId: string, repoPath: string, branch: strin
const marker = `# ${cronLabel(sourceId)}`;
const cronLine = `*/${minutes} * * * * ${wrapper} ${marker}`;
if (dryRun) return { status: 'fixed', detail: `would install crontab (every ${minutes}m) (dry-run)` };
// env: process.env on both calls — Bun otherwise resolves `crontab` against
// the STARTUP env snapshot, making runtime PATH changes (and PATH-shimmed
// test fakes) invisible (the workspace-push.ts / status.ts precedent).
let existingCron = '';
try { existingCron = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8' }); } catch { /* none */ }
try { existingCron = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8', env: process.env }); } catch { /* none */ }
const kept = existingCron.split('\n').filter(l => l && !l.includes(marker));
const next = [...kept, cronLine, ''].join('\n');
try {
execSync('crontab -', { input: next, stdio: ['pipe', 'ignore', 'ignore'] });
execSync('crontab -', { input: next, stdio: ['pipe', 'ignore', 'ignore'], env: process.env });
return { status: 'fixed', detail: `crontab every ${minutes}m` };
} catch (e) {
return { status: 'needs_attention', detail: `crontab install failed: ${(e as Error).message.slice(0, 120)}` };
@@ -701,7 +800,7 @@ export async function hardenBrainRepo(opts: HardenOpts): Promise<DurabilityRepor
const dryRun = !!opts.dryRun;
const installCron = opts.installCron !== false;
const verify = opts.verify !== false;
const intervalSec = opts.intervalSec ?? 1800;
const intervalSec = opts.intervalSec ?? DEFAULT_PULL_INTERVAL_SEC;
const redact = opts.pat ? (s: string) => redactSecretsInText(s, new Map([['github_pat', opts.pat!]])) : (s: string) => s;
const log = (l: string) => opts.logger?.(redact(l));
+5 -5
View File
@@ -17,7 +17,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'backfill': ['--aliases', '--all', '--batch-size', '--brain', '--concurrency', '--dry-run', '--fresh', '--help', '--include-null-signature', '--json', '--keep-index', '--list', '--max-errors', '--max-rows', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin'],
'bench': ['--baseline', '--brain', '--explain', '--force', '--from', '--help', '--json', '--label', '--lang', '--limit', '--markdown', '--multimodal', '--near-symbol', '--restore-only', '--source', '--stale', '--symbol-kind', '--thin', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-top1', '--to', '--tool'],
'book-mirror': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--author', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--budget-usd-per-day', '--by-mention', '--chapters-dir', '--content', '--context-file', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-turns', '--max-usd', '--mode', '--model', '--multimodal', '--no-confirm', '--no-embedding', '--no-extract', '--no-follow', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--timeout-ms', '--title', '--trusted-extraction', '--url', '--with-db', '--yes'],
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--all', '--brain', '--branch', '--cached', '--compile', '--confirm', '--delete-brain', '--env', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--harness', '--heads', '--help', '--home', '--http', '--init', '--isolated', '--jq', '--json', '--local', '--minimal', '--name-only', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--private', '--push', '--push-only', '--quiet', '--rebase', '--repair', '--scope', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--unset-all', '--verify', '--version', '--visibility', '--workspace', '--yes'],
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--all', '--allow-unverified-remote', '--brain', '--branch', '--cached', '--compile', '--confirm', '--count', '--delete-brain', '--diff-filter', '--env', '--error-unmatch', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--harness', '--heads', '--help', '--home', '--http', '--init', '--is-inside-work-tree', '--isolated', '--jq', '--json', '--local', '--minimal', '--name-only', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--private', '--push', '--push-only', '--quiet', '--rebase', '--repair', '--scope', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--unset-all', '--verify', '--version', '--visibility', '--workspace', '--yes'],
'brainstorm': ['--aliases', '--all', '--brain', '--chunker-debug', '--code', '--compile', '--fast', '--fix', '--force', '--force-rechunk', '--force-resume', '--from-pages', '--full', '--help', '--http', '--include-null-signature', '--json', '--judge-model', '--lang', '--limit', '--list-runs', '--markdown', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--model', '--no-embed', '--no-embedding', '--no-extract', '--no-save', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--retry-failed', '--retry-judge', '--save', '--source', '--stale', '--strict-budget', '--supersessions', '--surface', '--thin', '--timeout', '--yes'],
'cache': ['--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--source', '--surface', '--yes'],
'calibration': ['--ab', '--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--holder', '--http', '--image', '--include-null-signature', '--json', '--key-prefix', '--kind', '--lang', '--limit', '--markdown', '--max-usd', '--mode', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--phase', '--progress-interval', '--progress-json', '--quiet', '--regenerate', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scrub-gstack', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--trusted-extraction', '--undo-wave', '--url', '--with-calibration', '--with-db', '--yes'],
@@ -34,7 +34,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--embedding-dimensions', '--embedding-model', '--fast', '--federated-read', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--yes'],
'connect': ['--agent', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--force', '--grant-types', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--register', '--scopes', '--show-token', '--source', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'],
'conversation-parser': ['--aliases', '--all', '--brain', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
'doctor': ['--ab', '--abi', '--aliases', '--all', '--allow-shell-jobs', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--build-index', '--by-mention', '--by-type', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclusive', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--jq', '--json', '--lang', '--limit', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-mutate', '--oauth-client-secret', '--older-than', '--once', '--overwrite', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
'doctor': ['--ab', '--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--allow-shell-jobs', '--allow-unverified-remote', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--build-index', '--by-mention', '--by-type', '--cached', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclude-standard', '--exclusive', '--explain', '--fast', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--is-inside-work-tree', '--jq', '--json', '--lang', '--limit', '--local', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--progress-interval', '--progress-json', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--short', '--show-current', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
'dream': ['--against', '--aliases', '--all', '--allow-regression', '--anchor', '--asof', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--code', '--committed-baseline', '--compare', '--compile', '--concurrent', '--ctx-size', '--cycles', '--date', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--expansion', '--explain', '--fast', '--federated', '--fix', '--fixtures', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--format', '--from', '--from-db', '--from-pages', '--gold', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--install', '--json', '--judge-model', '--justification', '--keyword-only', '--lang', '--limit', '--llm', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-tokens', '--max-usd', '--mcp-only', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--name-only', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-llm', '--no-mutate', '--no-trajectory', '--once', '--out', '--output', '--output-dir', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--phase', '--priority', '--progress-interval', '--progress-json', '--pull', '--quiet', '--receipt-dir', '--remediate', '--repo', '--reranking', '--reset', '--resolve', '--restore-only', '--resume-from', '--retrieval-only', '--rounds', '--rubric-version', '--save', '--seed', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--top-k', '--undo', '--unsafe-bypass-dream-guard', '--update-baseline', '--verify', '--version', '--window', '--yes'],
'edges-backfill': ['--aliases', '--all', '--all-sources', '--brain', '--concurrency', '--federated', '--help', '--include-null-signature', '--json', '--max-age', '--max-chunks', '--max-cost-usd', '--no-extract', '--no-federated', '--older-than', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--workers'],
'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--serial', '--slugs', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--version'],
@@ -50,7 +50,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'friction': ['--agent', '--brain', '--help', '--hint', '--json', '--kind', '--message', '--no-redact', '--phase', '--redact', '--run-id', '--severity', '--source', '--transcript-path', '--transcripts'],
'frontmatter': ['--aliases', '--all', '--allow-catch-all', '--brain', '--cached', '--diff-filter', '--dry-run', '--exclude-standard', '--fast', '--fix', '--force', '--from-pages', '--get', '--help', '--http', '--include-catch-all', '--include-null-signature', '--json', '--name-only', '--name-status', '--no-embedding', '--no-extract', '--no-verify', '--others', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--strategy', '--supersessions', '--surface', '--thin', '--timeout', '--uninstall', '--write-back'],
'graph-query': ['--aliases', '--all', '--brain', '--depth', '--direction', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-foreign', '--include-null-signature', '--json', '--lang', '--markdown', '--mcp-only', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--type'],
'hook': ['--aliases', '--all', '--batch-limit', '--brain', '--budget-ms', '--count', '--delete-brain', '--detach', '--env', '--fast', '--force', '--from-pages', '--harness', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--once', '--path', '--pattern', '--pending', '--porcelain', '--reset', '--resolve', '--show-toplevel', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout'],
'hook': ['--aliases', '--all', '--allow-unverified-remote', '--batch-limit', '--brain', '--budget-ms', '--cached', '--count', '--delete-brain', '--detach', '--diff-filter', '--end-of-options', '--env', '--exclude-standard', '--fast', '--force', '--from-pages', '--get', '--harness', '--help', '--http', '--include-null-signature', '--jq', '--json', '--name-only', '--no-embedding', '--no-extract', '--once', '--others', '--path', '--pattern', '--pending', '--porcelain', '--quiet', '--reset', '--resolve', '--show-current', '--show-toplevel', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout'],
'import': ['--aliases', '--all', '--asof', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--cached', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--fix', '--follow', '--force', '--force-rechunk', '--fresh', '--from-pages', '--full', '--help', '--http', '--include-gitignored', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--multimodal', '--name-status', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--older-than', '--others', '--path', '--pattern', '--pending', '--pglite', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--since', '--skip-failed', '--source', '--source-id', '--stale', '--strategy', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--url', '--workers'],
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--target', '--to', '--touchpoint', '--url', '--version'],
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target'],
@@ -82,7 +82,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'remote': ['--all', '--background', '--brain', '--break-lock', '--by-mention', '--column', '--concurrency', '--content-audit', '--count', '--detach', '--dim', '--dir', '--drain', '--embedding-dimensions', '--embedding-model', '--fast', '--fix', '--force', '--force-retry', '--force-schema', '--from-pages', '--full', '--help', '--http', '--include-flagged', '--index-audit', '--json', '--locks', '--markdown', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--multimodal', '--nice', '--no-embedding', '--older-than', '--parallel', '--params', '--path', '--pglite', '--phase', '--pid-file', '--porcelain', '--progress-json', '--query', '--queue', '--rebuild-rollup', '--regenerate', '--remediate', '--remediation-plan', '--reset', '--resume', '--scope', '--scopes', '--since', '--skills-dir', '--skip-failed', '--slugs', '--source', '--source-id', '--stale', '--status', '--supabase', '--surface', '--target', '--target-score', '--timeout', '--to', '--top-k', '--url', '--window', '--workers', '--yes'],
'repair-jsonb': ['--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--json', '--lang', '--markdown', '--multimodal', '--near-symbol', '--no-embedding', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--restore-only', '--source', '--stale', '--supabase', '--surface', '--symbol-kind', '--thin', '--timeout', '--url'],
'report': ['--brain', '--content', '--dir', '--help', '--json', '--source', '--title', '--type'],
'repos': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--jq', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--unset-all', '--url', '--url-managed', '--yes'],
'repos': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--is-inside-work-tree', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--unset-all', '--url', '--url-managed', '--yes'],
'resolvers': ['--auto', '--backend', '--brain', '--cost', '--help', '--json', '--source'],
'retrieval-upgrade': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--catch-up', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--name', '--no', '--no-embed', '--no-embedding', '--no-extract', '--non-interactive', '--pace', '--pace-max-concurrency', '--parallel', '--pattern', '--pending', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reranking', '--reset', '--resolve', '--resume', '--slugs', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--to', '--undo', '--version', '--yes'],
'routing-eval': ['--brain', '--fix', '--help', '--json', '--llm', '--skills-dir', '--source', '--strict', '--verbose'],
@@ -95,7 +95,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'skillpack': ['--all', '--apply-clean-hunks', '--author', '--brain', '--dry-run', '--exit-code', '--fast', '--fix', '--force', '--force-unlock', '--format', '--from', '--from-pages', '--frontmatter', '--full', '--help', '--homepage', '--http', '--json', '--license', '--list', '--minimal', '--name-only', '--no-cache', '--no-embedding', '--no-lint', '--note', '--out', '--overwrite-local', '--push', '--quick', '--quiet', '--refresh', '--repo', '--schema-pack', '--short', '--since', '--skills-dir', '--skip-doctor', '--source', '--strict', '--surface', '--target', '--tier', '--trust', '--url', '--verbose', '--verify', '--workspace', '--yes'],
'skillpack-check': ['--background', '--brain', '--brain-wide-max-cost-usd', '--explain', '--fast', '--follow', '--help', '--json', '--list', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--source', '--stale', '--strict', '--timeout', '--yes'],
'smoke-test': ['--brain', '--help', '--json', '--source'],
'sources': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--jq', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--unset-all', '--url', '--url-managed', '--yes'],
'sources': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--is-inside-work-tree', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--unset-all', '--url', '--url-managed', '--yes'],
'status': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--budget-usd-per-day', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content', '--content-audit', '--count', '--date', '--days', '--deadline-ms', '--depth', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--image', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--install', '--interval', '--is-ancestor', '--json', '--judge-model', '--kind', '--lang', '--limit', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-runtime', '--max-sources', '--max-usd', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--offset', '--older-than', '--order', '--orphan', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--reenrich-after', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--scopes', '--section', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--to', '--top-k', '--trusted-extraction', '--type', '--types', '--url', '--url-managed', '--verbose', '--verify', '--version', '--watch', '--what', '--where', '--who', '--window', '--with-db', '--workers', '--yes'],
'storage': ['--aliases', '--all', '--brain', '--federated', '--fix', '--help', '--include-null-signature', '--json', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--to'],
'sweep': ['--aliases', '--all', '--batch-limit', '--brain', '--budget-ms', '--help', '--include-null-signature', '--json', '--no-extract', '--once', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
+8
View File
@@ -27,6 +27,10 @@ function getConfigPath() { return configPath(); }
export interface GBrainConfig {
engine: 'postgres' | 'pglite';
/** File-plane hook-lane keys (read by engine-free hook/push children).
* `gbrain config set` routes these two dotted keys here, not to the DB. */
push?: { allow_unverified_remote?: boolean };
hooks?: { stop_push_debounce_min?: number | string };
database_url?: string;
database_path?: string;
openai_api_key?: string;
@@ -984,6 +988,10 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'agent.use_gateway_loop',
// #2778: per-turn output-token cap for the subagent loop (default 8192).
'agent.max_output_tokens',
// File-plane bootstrap hook-lane keys (routed to ~/.gbrain/config.json by
// `config set` — engine-free hook/push children read loadConfigFileOnly).
'push.allow_unverified_remote',
'hooks.stop_push_debounce_min',
// DB-plane (v0.32.3 search modes + related)
'search.mode',
'search.cache.enabled',
+1
View File
@@ -152,6 +152,7 @@ export const OPS_CHECK_NAMES: ReadonlySet<string> = new Set([
'bootstrap_hooks_heartbeat',
'bootstrap_last_verify',
'bootstrap_push_health',
'bootstrap_durability_job',
'bootstrap_runbook_skew',
'bootstrap_serve_lock',
'batch_retry_health',
+93
View File
@@ -0,0 +1,93 @@
/**
* Execution-environment detection the third bootstrap axis.
*
* `detectHarness` (bootstrap.ts) answers WHICH AGENT HOST (claude-code vs
* codex). This module answers WHERE THAT HOST IS RUNNING:
*
* - `cloud-sandbox` a hosted, reclaimed-after-inactivity VM behind a
* credential-injecting egress proxy (Claude Code on
* the web and lookalikes). No crontab, no surviving
* background processes; GitHub REST is scoped to
* session-attached repos and GraphQL is pinned to a
* fixed operation set.
* - `ephemeral-container` Docker/Render/Railway/Fly-class containers.
* Long-lived enough for normal hook cadence, but
* schedulers (crontab/launchd) are unreliable or
* absent (wiped on deploy).
* - `local` a normal machine. Everything works.
*
* Consumers branch persistence strategy (cron install vs event-driven pushes),
* push-cadence defaults, and error messaging on this value. Detection is pure
* and signal-injected so tests never depend on the machine running them.
*/
import { existsSync } from 'node:fs';
export type ExecutionEnvironment = 'local' | 'cloud-sandbox' | 'ephemeral-container';
/** Injectable probe signals (production defaults: process.env + existsSync). */
export interface EnvProbeSignals {
env?: Record<string, string | undefined>;
fileExists?: (p: string) => boolean;
}
/**
* True when outbound credentials are substituted by a proxy rather than held
* locally the Claude Code cloud signature. Signals (any one suffices):
*
* - GH_TOKEN / GITHUB_TOKEN carry the documented literal `proxy-injected`
* placeholder (the proxy attaches real credentials on the wire).
* - The https proxy URL carries an anthropic-egress-control JWT.
*
* Load-bearing caveat for verification code: inside such an environment an
* "anonymous" HTTP probe may be silently authenticated by the proxy, so
* anonymous-probe results must be treated as ambiguous (see repo-visibility).
*/
export function isCredentialInjectingProxy(
env: Record<string, string | undefined> = process.env,
): boolean {
if (env.GH_TOKEN === 'proxy-injected' || env.GITHUB_TOKEN === 'proxy-injected') return true;
const proxy = env.https_proxy ?? env.HTTPS_PROXY ?? '';
return /anthropic-egress/i.test(proxy);
}
/**
* Detect where this process is running. Order matters: the cloud sandbox is
* ALSO a container, so its signals are checked first.
*
* 1. `CLAUDE_CODE_REMOTE === 'true'` official, never true locally.
* 2. `CLAUDE_CODE_REMOTE_SESSION_ID` with the documented `cse_` prefix.
* 3. A credential-injecting proxy signature (see above).
* 4. Container platforms: RENDER / RAILWAY_ENVIRONMENT / FLY_APP_NAME env,
* or the /.dockerenv marker file (same signal set autopilot's
* detectInstallTarget has used for its ephemeral branch).
* 5. Otherwise: local.
*/
export function detectExecutionEnvironment(signals: EnvProbeSignals = {}): ExecutionEnvironment {
const env = signals.env ?? process.env;
const fileExists = signals.fileExists ?? existsSync;
if (env.CLAUDE_CODE_REMOTE === 'true') return 'cloud-sandbox';
if ((env.CLAUDE_CODE_REMOTE_SESSION_ID ?? '').startsWith('cse_')) return 'cloud-sandbox';
if (isCredentialInjectingProxy(env)) return 'cloud-sandbox';
if (
env.RENDER ||
env.RAILWAY_ENVIRONMENT ||
env.FLY_APP_NAME ||
fileExists('/.dockerenv')
) {
return 'ephemeral-container';
}
return 'local';
}
/** Whether `name` resolves on PATH. Moved here from bootstrap/status.ts so
* environment-aware code (cron install, preflight) shares one probe. The
* LIVE process.env.PATH is passed explicitly: Bun otherwise resolves against
* the startup env snapshot, making runtime PATH changes (and PATH-shimmed
* test fakes) invisible the workspace-push.ts / status.ts precedent. */
export function binaryOnPath(name: string): boolean {
try {
return Bun.which(name, { PATH: process.env.PATH ?? '' }) !== null;
} catch {
return false;
}
}
+1 -1
View File
@@ -393,7 +393,7 @@ export function hasTrackedContent(path: string): boolean {
* transport. Default stays `never`. These ops act on an ALREADY-validated origin
* (set + checked at clone time); `http.followRedirects=false` is the live guard.
*/
function durableSsrfFlags(): string[] {
export function durableSsrfFlags(): string[] {
const fileAllow = process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT === '1' ? 'always' : 'never';
return [
'-c', 'http.followRedirects=false',
+449
View File
@@ -0,0 +1,449 @@
/**
* repo-visibility.ts ONE repo-visibility verdict for every consumer
* (workspace-push gate [G8], bootstrap repo/verify/status probes), replacing
* three drifted probes (TODOS ~5097). Engine-free; every IO seam injectable.
*
* Why a ladder and not one gh call: the previous probe (`gh repo view`, a
* GraphQL call) is structurally unavailable in cloud sandboxes their GitHub
* proxy pins GraphQL to a fixed operation set and 403s everything else, even
* with a user-supplied token which silently converted every hook push into
* `refused_visibility`. REST is the sanctioned surface, and pure git protocol
* works anywhere `git push` works.
*
* rung 1 REST `gh api repos/{owner}/{repo}` .private (github.com)
* rung 2 authed `git ls-remote <url> HEAD` exists + readable
* rung 3 anon GET <repo>/info/refs?service=git-upload-pack, no auth
*
* Verdict matrix (fail-closed BOTH directions see [D4]/[D14] below):
* rest true private/rest
* rest false public/rest (refuse)
* rung2 ok + rung3 attributed 401 private/git-protocol
* rung2 ok + rung3 proven 200 public/git-protocol (refuse) unless a
* credential-injecting proxy makes the
* "anonymous" probe ambiguous unverifiable
* anything else unverifiable (refuse, with rung log)
*
* [D4] A 200 counts as PUBLIC only with advertisement proof (content-type
* `application/x-git-upload-pack-advertisement` or the pkt-line body
* prefix). SSO-fronted self-hosted servers answer 200 with an HTML
* login page that must read `unverifiable`, never a false "PUBLIC".
* [D14] A 401/404 counts as PRIVATE-signal only with origin attribution
* (`www-authenticate` challenge; github.com additionally recognized by
* realm/request-id headers). The private verdict AUTHORIZES pushing, so
* an auth-demanding middlebox that 401s all anonymous traffic must not
* launder a public repo into "private".
*/
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { ensureGbrainHome } from './gbrain-home.ts';
import { durableSsrfFlags } from './git-remote.ts';
import { isCredentialInjectingProxy } from './execution-env.ts';
// ── Subprocess seam (canonical home; bootstrap/repo.ts re-exports) ─────────
export interface ExecResult {
code: number;
stdout: string;
stderr: string;
}
/** Injectable subprocess seam. argv[0] is the binary; never a shell string. */
export type ExecRunner = (argv: string[]) => Promise<ExecResult>;
/** Default runner: Bun.spawn, both streams piped, spawn failure code 127.
* GIT_TERMINAL_PROMPT=0 so an unauthenticated git can never hang on a prompt. */
export const defaultRunner: ExecRunner = async (argv: string[]): Promise<ExecResult> => {
try {
const proc = Bun.spawn(argv, {
stdout: 'pipe',
stderr: 'pipe',
stdin: 'ignore',
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { code, stdout, stderr };
} catch (e) {
return { code: 127, stdout: '', stderr: (e as Error).message };
}
};
/** Race a runner call against a wall-clock cap. On timeout the rung reports
* code 124 and the ladder degrades a hung network probe must never hang a
* push child. (The raced process is left to die on its own; every rung's
* default runner disables interactive prompts, so hangs are network-bound.) */
async function runWithTimeout(runner: ExecRunner, argv: string[], ms: number): Promise<ExecResult> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<ExecResult>((resolve) => {
timer = setTimeout(() => resolve({ code: 124, stdout: '', stderr: `timeout after ${ms}ms` }), ms);
});
try {
return await Promise.race([runner(argv), timeout]);
} finally {
clearTimeout(timer);
}
}
// ── Canonical remote-URL parser (union of the three prior grammars) ────────
/**
* Parse `{owner, repo}` from a github.com remote URL. Accepts the https form
* (with or without `.git` / trailing slash), the scp-like form
* (`git@github.com:o/r`), and `ssh://git@github.com/o/r`.
*/
export function parseGithubOwnerRepo(url: string): { owner: string; repo: string } | null {
const s = url.trim();
let m = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(s);
if (!m) m = /^(?:ssh:\/\/)?git@github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(s);
return m ? { owner: m[1]!, repo: m[2]! } : null;
}
/** Convenience: the combined `owner/repo` string, or null. */
export function githubOwnerRepoString(url: string): string | null {
const p = parseGithubOwnerRepo(url);
return p ? `${p.owner}/${p.repo}` : null;
}
/**
* The anonymous smart-HTTP probe URL for an https origin, or null when the
* origin has no https probe surface (scp/ssh non-github, file paths).
* github.com ssh/scp origins are converted to their https equivalent.
* URL userinfo is STRIPPED: an origin with embedded credentials would make
* the "anonymous" probe authenticated, misreading a private repo as public.
*/
export function anonProbeUrl(originUrl: string): string | null {
const gh = parseGithubOwnerRepo(originUrl);
if (gh) return `https://github.com/${gh.owner}/${gh.repo}.git/info/refs?service=git-upload-pack`;
const s = originUrl.trim().replace(/\/+$/, '');
if (!/^https:\/\//.test(s)) return null;
try {
const u = new URL(s);
u.username = '';
u.password = '';
return `${u.toString().replace(/\/+$/, '')}/info/refs?service=git-upload-pack`;
} catch {
return null; // unparseable https-looking string — no probe surface
}
}
/** True when a gh stderr/body is a 403 authored by a sandbox egress proxy
* (session scoping), as opposed to a real GitHub API 403. One predicate for
* the three call sites that must keep the HTTP-403 pre-check and the
* classification paired. */
export function isProxyBlocked403(text: string): boolean {
return /HTTP 403/i.test(text) && classifyGh403(text) === 'proxy';
}
// ── gh 403 classification ───────────────────────────────────────────────────
export type Gh403Class = 'github' | 'proxy' | 'unknown';
/**
* Classify a 403 body/stderr: a real GitHub API error is JSON carrying
* `message` + `documentation_url`; sandbox egress proxies answer with
* text/plain or non-GitHub JSON ("not enabled for this session",
* x-deny-reason). The class steers the user message "attach the repo to
* this session" vs "check your token" and MUST NOT gate the verdict.
*/
export function classifyGh403(body: string): Gh403Class {
const jsonStart = body.indexOf('{');
if (jsonStart >= 0) {
try {
const parsed = JSON.parse(body.slice(jsonStart)) as Record<string, unknown>;
if (typeof parsed.message === 'string') {
if ('documentation_url' in parsed) return 'github';
return 'proxy'; // JSON error without GitHub's shape — proxy-authored
}
} catch {
/* not JSON → fall through */
}
}
if (/not enabled for this session|x-deny-reason|host_not_allowed|access denied by the .*proxy/i.test(body)) {
return 'proxy';
}
return 'unknown';
}
// ── The ladder ──────────────────────────────────────────────────────────────
export type VisibilityVia = 'rest' | 'git-protocol';
export interface RungResult {
rung: 'rest' | 'authed-ls-remote' | 'anon-refs';
outcome: string;
}
export type RepoVisibilityVerdict =
| { verdict: 'private'; via: VisibilityVia; detail: string; rungs: RungResult[] }
| { verdict: 'public'; via: VisibilityVia; detail: string; rungs: RungResult[] }
| { verdict: 'unverifiable'; detail: string; rungs: RungResult[] };
export interface VerifyRepoVisibilityOpts {
originUrl: string;
/** Run git with this repo's config (credential helpers). Recommended. */
repoDir?: string;
runner?: ExecRunner;
fetchImpl?: typeof fetch;
/** Environment for the proxy-signature check (default process.env). */
env?: Record<string, string | undefined>;
/** Per-rung wall-clock cap (default 15s). */
timeoutMs?: number;
}
const ADVERTISEMENT_CONTENT_TYPE = 'application/x-git-upload-pack-advertisement';
/** pkt-line advertisement prefix: 4 hex length digits then the service line. */
const ADVERTISEMENT_BODY_RE = /^[0-9a-f]{4}# service=git-upload-pack/;
function rungLog(rungs: RungResult[]): string {
return rungs.map((r) => `${r.rung}: ${r.outcome}`).join('; ');
}
/** The actionable line for proxy-ambiguous / unverifiable outcomes. Spelled
* once so push, verify, and status degrade with the same instruction. */
export const UNVERIFIED_REMOTE_HINT =
'confirm the repo is private in the GitHub UI, then set GBRAIN_ALLOW_UNVERIFIED_REMOTE=1 ' +
'(or `gbrain config set push.allow_unverified_remote true`) to push anyway';
export async function verifyRepoVisibility(opts: VerifyRepoVisibilityOpts): Promise<RepoVisibilityVerdict> {
const runner = opts.runner ?? defaultRunner;
const fetchImpl = opts.fetchImpl ?? fetch;
const env = opts.env ?? process.env;
const timeoutMs = opts.timeoutMs ?? 15_000;
const rungs: RungResult[] = [];
const url = opts.originUrl.trim();
const gh = parseGithubOwnerRepo(url);
// Rung 1 — REST (github.com origins only). NEVER GraphQL: `gh repo view`
// rides GraphQL, which cloud proxies pin to a fixed operation set.
if (gh) {
const res = await runWithTimeout(runner, ['gh', 'api', `repos/${gh.owner}/${gh.repo}`, '--jq', '.private'], timeoutMs);
const out = res.stdout.trim();
if (res.code === 0 && out === 'true') {
rungs.push({ rung: 'rest', outcome: 'private' });
return { verdict: 'private', via: 'rest', detail: `${gh.owner}/${gh.repo} verified private via REST`, rungs };
}
if (res.code === 0 && out === 'false') {
rungs.push({ rung: 'rest', outcome: 'public' });
return { verdict: 'public', via: 'rest', detail: `${gh.owner}/${gh.repo} is PUBLIC (REST)`, rungs };
}
if (res.code === 127) {
rungs.push({ rung: 'rest', outcome: 'gh not installed' });
} else if (/HTTP 403/i.test(res.stderr)) {
rungs.push({
rung: 'rest',
outcome: isProxyBlocked403(res.stderr)
? 'blocked by an egress proxy (repo not attached to this session) — falling back to git protocol'
: `403 (${classifyGh403(res.stderr)}) — falling back to git protocol`,
});
} else {
rungs.push({ rung: 'rest', outcome: `failed (${(res.stderr.trim() || `exit ${res.code}`).slice(0, 120)})` });
}
} else {
rungs.push({ rung: 'rest', outcome: 'not a github.com origin — skipped' });
}
// Rung 2 — authed existence: the repo's own credential config answers
// "does this origin exist and can OUR credentials read it". Hardened like
// every other remote-touching git call: SSRF config flags (no ext helpers,
// no redirect-follow, file transport only behind the test/self-hosted env
// escape) and end-of-options so a dash-prefixed origin can never be parsed
// as an option (the upload-pack command-execution class).
const lsArgv = [
'git',
...(opts.repoDir ? ['-C', opts.repoDir] : []),
...durableSsrfFlags(),
'ls-remote',
'--end-of-options',
url,
'HEAD',
];
const ls = await runWithTimeout(runner, lsArgv, timeoutMs);
if (ls.code !== 0) {
rungs.push({ rung: 'authed-ls-remote', outcome: `failed (${(ls.stderr.trim() || `exit ${ls.code}`).slice(0, 120)})` });
return {
verdict: 'unverifiable',
detail: `origin not readable with current credentials — a push would fail anyway (${rungLog(rungs)})`,
rungs,
};
}
rungs.push({ rung: 'authed-ls-remote', outcome: 'ok (exists + readable)' });
// Rung 3 — anonymous probe.
const probeUrl = anonProbeUrl(url);
if (!probeUrl) {
rungs.push({ rung: 'anon-refs', outcome: 'no https probe surface for this origin' });
return { verdict: 'unverifiable', detail: `origin readable but privacy unprovable (${rungLog(rungs)}) — ${UNVERIFIED_REMOTE_HINT}`, rungs };
}
let status = 0;
let contentType = '';
let wwwAuthenticate = '';
let githubAttributed = false;
let bodyPrefix = '';
try {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
// redirect: manual — a redirected probe proves nothing about THIS origin
// (and following it would extend the request surface); 3xx falls through
// to the unexpected-status arm → unverifiable, fail-closed.
const res = await fetchImpl(probeUrl, { redirect: 'manual', signal: ctl.signal });
status = res.status;
contentType = res.headers.get('content-type') ?? '';
wwwAuthenticate = res.headers.get('www-authenticate') ?? '';
githubAttributed = res.headers.has('x-github-request-id');
bodyPrefix = (await res.text()).slice(0, 64);
} finally {
clearTimeout(timer);
}
} catch (e) {
rungs.push({ rung: 'anon-refs', outcome: `network error (${(e as Error).message.slice(0, 80)})` });
return { verdict: 'unverifiable', detail: `anonymous probe unreachable (${rungLog(rungs)}) — ${UNVERIFIED_REMOTE_HINT}`, rungs };
}
if (status === 401 || status === 404) {
// [D14] attribution required before the push-authorizing PRIVATE verdict.
// RFC 7235 makes www-authenticate mandatory on EVERY 401 — a middlebox's
// included — so the challenge alone is NEVER proof. github.com origins
// require the GitHub request-id header. For NON-github origins there is no
// trustable attribution signal at all (a spoofing/inspecting middlebox
// 401s identically to a real private server), so a 401 there is
// `unverifiable` — the operator confirms via the escape hatch rather than
// us laundering a possibly-public repo into a push authorization. Both
// adversarial reviewers flagged the prior "challenge ⇒ private" as a
// public-repo-exfil path; fail closed.
const attributed = gh !== null && githubAttributed;
if (attributed) {
rungs.push({ rung: 'anon-refs', outcome: `${status} with GitHub-attributed auth challenge (not anonymously readable)` });
return {
verdict: 'private',
via: 'git-protocol',
detail: `origin exists, reads with credentials, and refuses anonymous access (${rungLog(rungs)})`,
rungs,
};
}
const why = gh !== null
? `${status} without GitHub attribution (x-github-request-id) — possible middlebox`
: `${status} on a non-github origin — no trustable attribution signal`;
rungs.push({ rung: 'anon-refs', outcome: `${why}, not trusted as a privacy signal` });
return { verdict: 'unverifiable', detail: `un-attributed ${status} on the anonymous probe (${rungLog(rungs)}) — ${UNVERIFIED_REMOTE_HINT}`, rungs };
}
if (status === 200) {
// [D4] advertisement proof required before the "public" verdict.
const proven =
contentType.toLowerCase().includes(ADVERTISEMENT_CONTENT_TYPE) || ADVERTISEMENT_BODY_RE.test(bodyPrefix);
if (!proven) {
rungs.push({ rung: 'anon-refs', outcome: '200 without a git advertisement (SSO/login page?) — not trusted as a public signal' });
return { verdict: 'unverifiable', detail: `anonymous 200 without advertisement proof (${rungLog(rungs)}) — ${UNVERIFIED_REMOTE_HINT}`, rungs };
}
if (isCredentialInjectingProxy(env)) {
// The "anonymous" request may have been silently authenticated by the
// sandbox proxy — a private repo would ALSO read 200 here. Ambiguous.
rungs.push({ rung: 'anon-refs', outcome: '200 advertisement behind a credential-injecting proxy — ambiguous' });
return {
verdict: 'unverifiable',
detail: `cannot prove privacy from inside this sandbox's authenticated proxy (${rungLog(rungs)}) — ${UNVERIFIED_REMOTE_HINT}`,
rungs,
};
}
rungs.push({ rung: 'anon-refs', outcome: '200 with a git advertisement — anonymously clonable' });
return { verdict: 'public', via: 'git-protocol', detail: `origin is anonymously readable (${rungLog(rungs)})`, rungs };
}
rungs.push({ rung: 'anon-refs', outcome: `unexpected HTTP ${status}` });
return { verdict: 'unverifiable', detail: `anonymous probe answered HTTP ${status} (${rungLog(rungs)}) — ${UNVERIFIED_REMOTE_HINT}`, rungs };
}
// ── Verdict cache [D11] ─────────────────────────────────────────────────────
//
// PRIVATE verdicts only, 1h TTL, keyed on the exact origin URL. `public` and
// `unverifiable` are NEVER cached: failures must re-verify every push so a
// fixed repo unblocks on the next turn, while a private→public flip is
// honored at most one TTL late (deliberate owner action; the secret-scan gate
// still runs on every push). Kills the per-turn network cost of the
// cloud-sandbox debounce-0 push cadence.
export const VISIBILITY_CACHE_TTL_MS = 60 * 60 * 1000;
/** Cache key = origin URL with any userinfo (`user:pat@`) stripped a PAT in
* an https remote must never be persisted into the on-disk cache file. */
function cacheKey(originUrl: string): string {
try {
const u = new URL(originUrl);
u.username = '';
u.password = '';
return u.toString();
} catch {
return originUrl; // scp/ssh/file forms carry no URL userinfo
}
}
interface VisibilityCacheEntry {
verdict: 'private';
via: VisibilityVia;
verified_at: string;
}
export function visibilityCachePath(): string {
return join(ensureGbrainHome(), 'bootstrap', 'visibility-cache.json');
}
/** A fresh cached PRIVATE verdict for this origin, or null. */
export function readCachedPrivateVerdict(
originUrl: string,
opts: { now?: number; path?: string } = {},
): RepoVisibilityVerdict | null {
const now = opts.now ?? Date.now();
try {
const raw = readFileSync(opts.path ?? visibilityCachePath(), 'utf8');
const map = JSON.parse(raw) as Record<string, VisibilityCacheEntry>;
const entry = map[cacheKey(originUrl)];
if (!entry || entry.verdict !== 'private') return null;
const at = Date.parse(entry.verified_at);
if (!Number.isFinite(at) || now - at > VISIBILITY_CACHE_TTL_MS) return null;
return {
verdict: 'private',
via: entry.via,
detail: `verified private ${entry.via === 'rest' ? 'via REST' : 'via git protocol'} at ${entry.verified_at} (cached)`,
rungs: [],
};
} catch {
return null; // missing/corrupt cache is a miss, never an error
}
}
/** Record a PRIVATE verdict (no-op for any other verdict). Prunes expired
* entries; tolerant of a corrupt existing file (starts fresh). */
export function writeVisibilityCache(
originUrl: string,
verdict: RepoVisibilityVerdict,
opts: { now?: number; path?: string } = {},
): void {
if (verdict.verdict !== 'private') return;
const now = opts.now ?? Date.now();
const path = opts.path ?? visibilityCachePath();
let map: Record<string, VisibilityCacheEntry> = {};
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, VisibilityCacheEntry>;
for (const [k, v] of Object.entries(parsed)) {
const at = Date.parse(v?.verified_at ?? '');
if (v?.verdict === 'private' && Number.isFinite(at) && now - at <= VISIBILITY_CACHE_TTL_MS) map[k] = v;
}
} catch {
map = {};
}
map[cacheKey(originUrl)] = { verdict: 'private', via: verdict.via, verified_at: new Date(now).toISOString() };
try {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify(map, null, 2) + '\n', { mode: 0o600 });
renameSync(tmp, path);
} catch {
/* cache write failure is never an error — next push just re-verifies */
}
}
+198 -55
View File
@@ -38,21 +38,33 @@
* ahead?} on success AND failure [B4] (doctor + SessionStart digest
* read it).
*
* Remote-privacy gate [G8]: refuses a remote that is not VERIFIABLY private
* `gh repo view --json isPrivate` must answer `true`. Unverifiable
* visibility (gh missing/unauthed, non-GitHub host, path remote) is a
* refusal with a named reason, never fail-open. Escape hatch for self-hosted
* git: `allowUnverifiedRemote` (CLI `--allow-unverified-remote`), logged.
* Remote-privacy gate [G8]: refuses a remote that is not VERIFIABLY private.
* Verification routes through the repo-visibility LADDER (repo-visibility.ts):
* REST first (`gh api repos/...` never GraphQL, which cloud proxies pin),
* then pure git protocol (authed ls-remote + attributed anonymous probe), so
* the gate keeps working where gh is broken/blocked. Unverifiable visibility
* is a refusal with the per-rung reason, never fail-open. Escape hatches for
* self-hosted git you trust, loudly logged, most-portable last: CLI
* `allowUnverifiedRemote` > env GBRAIN_ALLOW_UNVERIFIED_REMOTE=1 > file-plane
* config `push.allow_unverified_remote` (readable by detached hook children
* that can never see a shell export or the DB plane).
*/
import {
existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
} from 'fs';
import { dirname, join } from 'path';
import { createHash, randomBytes } from 'crypto';
import { execFileSync } from 'child_process';
import { GIT_ENV, GIT_ENV_AUTH, GIT_SSRF_SUBCOMMAND_FLAGS, detectDefaultBranch, divergenceSafePull } from './git-remote.ts';
import { loadConfigFileOnly } from './config.ts';
import { ensureGbrainHome } from './gbrain-home.ts';
import {
githubOwnerRepoString,
readCachedPrivateVerdict,
verifyRepoVisibility,
writeVisibilityCache,
} from './repo-visibility.ts';
import { isProcessAlive } from './pglite-lock.ts';
import {
loadWorkspaceAllowlist, matchesGlob, pathAllowlisted, scanText,
@@ -307,62 +319,170 @@ export type RemotePrivacyVerdict =
| { verdict: 'not_private'; detail: string }
| { verdict: 'unverifiable'; detail: string };
/** Parse `owner/repo` out of a github.com remote URL (https or scp-like). */
/** Parse `owner/repo` out of a github.com remote URL (https, scp-like, or
* ssh://). Back-compat adapter over the canonical repo-visibility parser. */
export function parseGithubOwnerRepo(url: string): string | null {
let m = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url);
if (m) return `${m[1]}/${m[2]}`;
m = /^(?:ssh:\/\/)?git@github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url);
if (m) return `${m[1]}/${m[2]}`;
return null;
return githubOwnerRepoString(url);
}
/**
* Verify the origin remote is a PRIVATE repo via `gh repo view --json
* isPrivate`. Anything short of an affirmative `true` is either
* `not_private` (affirmative public) or `unverifiable` (gh missing/unauthed,
* non-GitHub host, path/file remote) the caller refuses both unless the
* escape hatch is set [G8: never fail-open].
* Verify the origin remote is a PRIVATE repo via the repo-visibility ladder
* (REST first, git-protocol fallback see repo-visibility.ts for the full
* verdict matrix). Anything short of a proven `private` is either
* `not_private` (proven public) or `unverifiable` the caller refuses both
* unless an escape hatch is set [G8: never fail-open]. Fresh `private`
* verdicts are cached (1h TTL, private-only) so the per-turn push cadence
* doesn't re-pay the network probes every turn [D11].
*/
export function verifyRemotePrivacy(root: string): RemotePrivacyVerdict {
export async function verifyRemotePrivacy(root: string): Promise<RemotePrivacyVerdict> {
const url = tryGit(root, ['remote', 'get-url', 'origin'], { timeoutMs: 10_000 });
if (!url) return { verdict: 'unverifiable', detail: 'no origin remote configured' };
const ownerRepo = parseGithubOwnerRepo(url);
if (!ownerRepo) {
return {
verdict: 'unverifiable',
detail: `origin is not a github.com URL — cannot verify visibility via gh`,
};
}
if (readCachedPrivateVerdict(url) !== null) return { verdict: 'private' };
const v = await verifyRepoVisibility({ originUrl: url, repoDir: root });
writeVisibilityCache(url, v);
if (v.verdict === 'private') return { verdict: 'private' };
if (v.verdict === 'public') return { verdict: 'not_private', detail: v.detail };
return { verdict: 'unverifiable', detail: v.detail };
}
/** File-plane escape hatch [D18]: `gbrain config set push.allow_unverified_remote true`.
* Read tolerantly from ~/.gbrain/config.json detached hook children never see
* a shell export, and the DB plane is unreadable while a serve holds the
* single-writer lock, so the file plane is the only channel that always works. */
export function configAllowsUnverifiedRemote(): boolean {
try {
const out = execFileSync(
'gh',
['repo', 'view', ownerRepo, '--json', 'isPrivate', '--jq', '.isPrivate'],
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000, env: process.env },
).toString().trim();
if (out === 'true') return { verdict: 'private' };
if (out === 'false') return { verdict: 'not_private', detail: `${ownerRepo} is PUBLIC` };
return { verdict: 'unverifiable', detail: `gh returned unexpected output: ${out.slice(0, 80)}` };
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err?.code === 'ENOENT') {
return { verdict: 'unverifiable', detail: 'gh CLI not installed — cannot verify repo visibility' };
}
return {
verdict: 'unverifiable',
detail: `gh repo view failed (${(err?.message ?? 'unknown').slice(0, 120)})`,
};
const cfg = loadConfigFileOnly();
const v = cfg?.push?.allow_unverified_remote as unknown;
return v === true || v === 'true' || v === '1';
} catch {
return false;
}
}
// ── push-status.json [B4] ───────────────────────────────────────────────────
/** Legacy single-file location (pre-per-root). Read-only compatibility: the
* reader consults it only when NO per-root files exist yet (fresh upgrade). */
export function pushStatusPath(): string {
return join(ensureGbrainHome(), 'bootstrap', 'push-status.json');
}
function writePushStatus(status: { ts: string; ok: boolean; reason?: string; ahead?: number }): void {
/** Stable short key for per-root state files [D3/D13]: one gbrain home serves
* many bootstrap workspaces, so every push/debounce/announce state file is
* keyed by workspace root a single shared file is last-writer-wins, which
* either masks one workspace's failure or defeats another's debounce. */
export function workspaceRootHash(root: string): string {
return createHash('sha256').update(root).digest('hex').slice(0, 12);
}
export function pushStatusPathForRoot(root: string): string {
return join(ensureGbrainHome(), 'bootstrap', `push-status-${workspaceRootHash(root)}.json`);
}
/** One parsed push-status record [D8: one reader, three formatters]. */
export interface PushStatusEntry {
ts?: string;
ok?: boolean;
reason?: string;
ahead?: number;
repoRoot?: string;
/** Absolute path of the status file (per-root announce-state keying). */
file: string;
}
const PUSH_STATUS_FILE_RE = /^push-status-[0-9a-f]{12}\.json$/;
/** Tolerant reader over every push-status file (banner, SessionStart note,
* and doctor all consume THIS one parse, one schema owner). Per-root files
* win; the legacy global file is consulted only when none exist, so a stale
* pre-upgrade record can't shadow live per-root state. */
export function readPushStatuses(): PushStatusEntry[] {
const dir = join(ensureGbrainHome(), 'bootstrap');
const parse = (file: string): PushStatusEntry | null => {
try {
const s = JSON.parse(readFileSync(file, 'utf8')) as Omit<PushStatusEntry, 'file'>;
return { ...s, file };
} catch {
return null; // torn/corrupt/missing → skip, never throw
}
};
let names: string[] = [];
try {
const p = pushStatusPath();
names = readdirSync(dir).filter((n) => PUSH_STATUS_FILE_RE.test(n));
} catch {
return [];
}
// The legacy file is consulted only when NO per-root FILES exist on disk —
// if per-root files exist but are all corrupt, an empty result is the honest
// answer (doctor pairs it with pushStatusFilesExist for the loud warn); a
// stale pre-upgrade record must never shadow live-but-unreadable state.
if (names.length === 0) {
const legacy = parse(pushStatusPath());
return legacy ? [legacy] : [];
}
return names
.map((n) => parse(join(dir, n)))
.filter((e): e is PushStatusEntry => e !== null)
// A record whose workspace no longer exists on disk is a ghost: it can
// never be cleared by a re-push (the root is gone), so it must not feed
// the failure banner / staleness note forever (deleted Conductor
// workspaces are routine). Entries without repoRoot (legacy shape in a
// per-root file) are kept — absence of evidence is not a ghost.
.filter((e) => e.repoRoot === undefined || existsSync(e.repoRoot));
}
/** True when any push-status file exists on disk (parseable or not). The
* reader skips corrupt files silently; doctor pairs this with an empty read
* to say "status present but unreadable" instead of saying nothing. */
export function pushStatusFilesExist(): boolean {
const dir = join(ensureGbrainHome(), 'bootstrap');
try {
if (readdirSync(dir).some((n) => PUSH_STATUS_FILE_RE.test(n))) return true;
} catch {
/* fall through */
}
return existsSync(pushStatusPath());
}
/** The per-root status for one workspace, or null. Direct keyed read first
* the scan is only a fallback for legacy records carrying a repoRoot field. */
export function readPushStatusForRoot(root: string): PushStatusEntry | null {
const keyedPath = pushStatusPathForRoot(root);
try {
const s = JSON.parse(readFileSync(keyedPath, 'utf8')) as Omit<PushStatusEntry, 'file'>;
return { ...s, file: keyedPath };
} catch {
/* fall through to the scan */
}
return readPushStatuses().find((e) => e.repoRoot === root) ?? null;
}
/** One aggregation for every status surface (SessionStart note, doctor,
* banner counting): the failing entries and the stalest success timestamp. */
/** Failure reasons carry remote-influenced text (git stderr rung log
* push-status.reason). Clamp to a safe printable charset + length before ANY
* model- or human-visible surface embeds it (banner, doctor, status blob) so
* it can't become an injection or formatting vector the free-form
* counterpart to hook.ts's reasonCode() for typed codes. */
export function sanitizePushReason(reason: string | undefined): string {
if (!reason) return 'unknown reason';
return reason.replace(/[^\x20-\x7E]/g, ' ').replace(/[`$\\]/g, "'").slice(0, 140);
}
export function summarizePushStatuses(entries: PushStatusEntry[]): {
failing: PushStatusEntry[];
stalestTs: number | null;
} {
const failing = entries.filter((e) => e.ok === false);
const stamps = entries.map((e) => Date.parse(e.ts ?? '')).filter((t) => Number.isFinite(t));
return { failing, stalestTs: stamps.length > 0 ? Math.min(...stamps) : null };
}
function writePushStatus(
status: { ts: string; ok: boolean; reason?: string; ahead?: number; repoRoot: string },
): void {
try {
const p = pushStatusPathForRoot(status.repoRoot);
mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
// tmp+rename (the writeReceipt pattern): a concurrent reader never sees a
// torn half-written status file.
@@ -409,12 +529,14 @@ export async function workspacePush(opts: WorkspacePushOpts): Promise<WorkspaceP
const finish = (r: WorkspacePushResult): WorkspacePushResult => {
// B4: written on success AND failure — only by the lock WINNER (a skip
// must not clobber the in-flight run's eventual status).
// must not clobber the in-flight run's eventual status). Keyed per root
// [D13] so one workspace's success can never mask another's failure.
writePushStatus({
ts: new Date().toISOString(),
ok: r.ok,
...(r.reason !== undefined ? { reason: r.reason } : {}),
...(r.ahead !== undefined ? { ahead: r.ahead } : {}),
repoRoot: root,
});
return r;
};
@@ -586,21 +708,42 @@ export async function workspacePush(opts: WorkspacePushOpts): Promise<WorkspaceP
reason: 'no origin remote configured — nothing to push to',
});
}
if (opts.allowUnverifiedRemote) {
log('WARN: --allow-unverified-remote set — skipping repo-visibility verification');
} else {
const privacy = verifyRemotePrivacy(root);
if (privacy.verdict !== 'private') {
const reason =
privacy.verdict === 'not_private'
? `origin is not private: ${privacy.detail} — refusing to push workspace contents`
: `cannot verify origin visibility (${privacy.detail}) — refusing to push. ` +
`Pass --allow-unverified-remote for self-hosted git you trust.`;
// Escape-hatch resolution [D18], most explicit first: CLI flag > env var >
// file-plane config. Every path warns loudly. The hatches downgrade ONLY
// the `unverifiable` verdict — the ladder still runs and an affirmatively
// PUBLIC origin refuses regardless, matching every piece of user-facing
// copy ("for self-hosted git you trust", never "push to public repos").
const unverifiedVia = opts.allowUnverifiedRemote
? 'the allow-unverified-remote flag'
: process.env.GBRAIN_ALLOW_UNVERIFIED_REMOTE === '1'
? 'GBRAIN_ALLOW_UNVERIFIED_REMOTE=1'
: configAllowsUnverifiedRemote()
? 'config push.allow_unverified_remote (sticky — unset it once verification works)'
: null;
{
const privacy = await verifyRemotePrivacy(root);
if (privacy.verdict === 'not_private') {
const reason = `origin is not private: ${privacy.detail} — refusing to push workspace contents` +
(unverifiedVia !== null ? ` (the unverified-remote override via ${unverifiedVia} does NOT cover proven-public origins)` : '');
log(`PUSH REFUSED: ${reason}`);
return finish({
ok: false, status: 'refused_visibility', repoRoot: root, branch, committed, reason,
});
}
if (privacy.verdict === 'unverifiable') {
if (unverifiedVia !== null) {
log(`WARN: origin visibility unverifiable — pushing anyway via ${unverifiedVia}; you are trusting this remote`);
} else {
const reason =
`cannot verify origin visibility (${privacy.detail}) — refusing to push. ` +
`Pass --allow-unverified-remote (or set GBRAIN_ALLOW_UNVERIFIED_REMOTE=1, or ` +
'`gbrain config set push.allow_unverified_remote true`) for self-hosted git you trust.';
log(`PUSH REFUSED: ${reason}`);
return finish({
ok: false, status: 'refused_visibility', repoRoot: root, branch, committed, reason,
});
}
}
}
// 6. pull AFTER commit [CX2-7] — a dirty tree is impossible here short of
+5 -1
View File
@@ -72,7 +72,11 @@ during long work reads as broken.
**Gate 2 — Recover missed context.** Scan the conversation for earlier messages that
never got processed. Before sending the final reply, rescan for anything that
arrived mid-turn.
arrived mid-turn. On a harness WITHOUT hooks (Codex — pull protocol), also run
`gbrain bootstrap status` once at the start of a conversation: it surfaces a
failing or stale workspace push that hook-carrying harnesses would have shown
automatically. If it reports the push FAILING, tell {{PRINCIPAL_NAME}} plainly —
their memory is landing locally but not in the durable repo.
**Gate 3 — Entity lookup (brain first).** For each real person, project, company, or
commitment named in the message, search the brain before answering: `recall` for hot
+4
View File
@@ -20,3 +20,7 @@ reach is a separate knob — see ACCESS_POLICY.md).
over file greps for anything about people, projects, or the past.
- Follow the per-message gates in AGENTS.md — especially Gate 3 (brain first) and
Gate 7 (write-back, same turn).
- Cloud sandbox sessions (fresh clones): if the gbrain MCP tools or hooks are
missing, the binary installs via the environment setup script (print it with
`gbrain bootstrap cloud-setup-script`), then run `gbrain bootstrap hooks
--repair` — committed hooks go live on the NEXT session (startup snapshot).
+9 -4
View File
@@ -4,12 +4,17 @@ This workspace is durably backed up to **{{GITHUB_REPO_URL}}**. The repository m
remain private.
- **Tracked:** identity files, memory, `brain/`, `skills/`, `state/interview.json`,
`state/mcp.json`, schedules.
- **Ignored:** credentials, local databases, caches, hook state, transcripts, and
schedules.
- **Ignored:** credentials, local databases, caches, hook state, transcripts,
machine-specific harness wiring (`.mcp.json`, `.claude/settings.local.json`), and
anything matching the deny list in `.gitignore`.
- **How it syncs:** `gbrain sources push` — a secret-scan-gated commit + push that
refuses public remotes. It runs at session end automatically; a 15-minute
background job does the same if enabled. Run it by hand after meaningful changes.
refuses public remotes. On Claude Code it runs automatically per turn
(debounced) and at session end via hooks; on Codex (no hook system) run it at
natural stopping points (the AGENTS.md gate reminds you). If background
persistence is enabled, a git post-commit hook auto-pushes each commit and a
30-minute pull job keeps multi-machine checkouts fresh. Run it by hand after
meaningful changes on any harness.
- **If a push is blocked:** the scan names the file and pattern out loud. Fix or
allowlist deliberately — never force past it silently.
- **Honest forget semantics:** git history is append-only. Deleting a line from a
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# gbrain — cloud environment setup script.
# Paste this into your cloud environment's setup script (it runs as root
# before the session starts; what it writes to disk is snapshot-cached and
# reused by later sessions). Printed by: gbrain bootstrap cloud-setup-script
set -eu
# 1. Bun runtime (gbrain runs on bun). Installed VIA npm — bun's own package
# fetching is proxy-incompatible in cloud sandboxes; npm's is not.
command -v bun >/dev/null 2>&1 || npm install -g bun
# 2. gbrain from the canonical GitHub source. NEVER `npm install -g gbrain`:
# the npm registry package with that name is unrelated squatter code.
GBRAIN_DIR=/opt/gbrain
# Pinned to latest-stable — the SAME ref the canonical local install uses.
if [ ! -d "$GBRAIN_DIR/.git" ]; then
git clone --depth 1 --branch latest-stable https://github.com/garrytan/gbrain "$GBRAIN_DIR"
else
# Fail loud (set -e) on a broken update — never npm-install + run stale code
# as root against a half-updated checkout.
git -C "$GBRAIN_DIR" fetch --depth 1 origin latest-stable
git -C "$GBRAIN_DIR" checkout -q FETCH_HEAD
fi
cd "$GBRAIN_DIR"
# npm (not bun) for dependency fetching — same proxy constraint as above.
npm install --no-audit --no-fund
# 3. PATH-resolved launcher: the repo-committed hook commands and MCP
# registration expect `gbrain` on PATH (they are fail-open where it isn't).
cat > /usr/local/bin/gbrain <<'LAUNCHER'
#!/bin/sh
exec bun /opt/gbrain/src/cli.ts "$@"
LAUNCHER
chmod +x /usr/local/bin/gbrain
gbrain --version
# After the session starts, finish wiring INSIDE the session:
# gbrain bootstrap status --json # confirms execution_environment: cloud-sandbox
# gbrain bootstrap attach # adopt the brain repo this session is opened on
# gbrain bootstrap hooks --harness claude-code # committed-carrier hooks (next session picks them up)
+7
View File
@@ -27,6 +27,13 @@ state/heartbeat-state.local.json
# Hook + harness wiring (machine-specific; regenerated by `bootstrap hooks --repair`)
.claude/settings.local.json
# Backups the hook writers drop next to the committed settings.json — git IS
# the backup for the committed carrier, so these must never be committed.
.claude/*.bak
.claude/*.broken-*
# Written by `claude mcp add` at project scope — carries an absolute,
# machine-specific binary path that must not land in the repo.
.mcp.json
# Caches, logs, scratch
logs/
+1 -1
View File
@@ -126,7 +126,7 @@
"PERSIST_CRON": {
"consent": true,
"phase": "repo",
"question": "Enable background persistence? This installs a 15-minute job that commits and pushes this workspace to your private repo (secret-scan-gated). Declining still persists at session end.",
"question": "Enable background persistence? This installs a git post-commit hook that auto-pushes each commit in the background, plus a 30-minute pull job that keeps multi-machine checkouts fresh (on machines with a scheduler). Declining still persists via the per-turn and session-end pushes.",
"default": "no",
"allowed": ["yes", "no"],
"maxLength": 4
@@ -27,6 +27,13 @@ state/heartbeat-state.local.json
# Hook + harness wiring (machine-specific; regenerated by `bootstrap hooks --repair`)
.claude/settings.local.json
# Backups the hook writers drop next to the committed settings.json — git IS
# the backup for the committed carrier, so these must never be committed.
.claude/*.bak
.claude/*.broken-*
# Written by `claude mcp add` at project scope — carries an absolute,
# machine-specific binary path that must not land in the repo.
.mcp.json
# Caches, logs, scratch
logs/
+5 -1
View File
@@ -76,7 +76,11 @@ during long work reads as broken.
**Gate 2 — Recover missed context.** Scan the conversation for earlier messages that
never got processed. Before sending the final reply, rescan for anything that
arrived mid-turn.
arrived mid-turn. On a harness WITHOUT hooks (Codex — pull protocol), also run
`gbrain bootstrap status` once at the start of a conversation: it surfaces a
failing or stale workspace push that hook-carrying harnesses would have shown
automatically. If it reports the push FAILING, tell {{PRINCIPAL_NAME}} plainly —
their memory is landing locally but not in the durable repo.
**Gate 3 — Entity lookup (brain first).** For each real person, project, company, or
commitment named in the message, search the brain before answering: `recall` for hot
@@ -20,3 +20,7 @@ reach is a separate knob — see ACCESS_POLICY.md).
over file greps for anything about people, projects, or the past.
- Follow the per-message gates in AGENTS.md — especially Gate 3 (brain first) and
Gate 7 (write-back, same turn).
- Cloud sandbox sessions (fresh clones): if the gbrain MCP tools or hooks are
missing, the binary installs via the environment setup script (print it with
`gbrain bootstrap cloud-setup-script`), then run `gbrain bootstrap hooks
--repair` — committed hooks go live on the NEXT session (startup snapshot).
+9 -4
View File
@@ -4,12 +4,17 @@ This workspace is durably backed up to **(not yet created — bootstrap repo set
remain private.
- **Tracked:** identity files, memory, `brain/`, `skills/`, `state/interview.json`,
`state/mcp.json`, schedules.
- **Ignored:** credentials, local databases, caches, hook state, transcripts, and
schedules.
- **Ignored:** credentials, local databases, caches, hook state, transcripts,
machine-specific harness wiring (`.mcp.json`, `.claude/settings.local.json`), and
anything matching the deny list in `.gitignore`.
- **How it syncs:** `gbrain sources push` — a secret-scan-gated commit + push that
refuses public remotes. It runs at session end automatically; a 15-minute
background job does the same if enabled. Run it by hand after meaningful changes.
refuses public remotes. On Claude Code it runs automatically per turn
(debounced) and at session end via hooks; on Codex (no hook system) run it at
natural stopping points (the AGENTS.md gate reminds you). If background
persistence is enabled, a git post-commit hook auto-pushes each commit and a
30-minute pull job keeps multi-machine checkouts fresh. Run it by hand after
meaningful changes on any harness.
- **If a push is blocked:** the scan names the file and pattern out loud. Fix or
allowlist deliberately — never force past it silently.
- **Honest forget semantics:** git history is append-only. Deleting a line from a
+1 -1
View File
@@ -1,6 +1,6 @@
# gbrain agent workspace — template
<!-- gbrain-template-stamp: 0.45.8.0 -->
<!-- gbrain-template-stamp: 0.45.9.0 -->
This repository is the **"Use this template"** distribution artifact for a
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
+12
View File
@@ -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();
+38 -1
View File
@@ -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');
});
});
+65
View File
@@ -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();
+111
View File
@@ -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<string, Array<{ hooks: Array<Record<string, unknown>> }>>;
};
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<string, unknown>;
expect(parsed.permissions).toEqual({ allow: ['Bash(ls:*)'] });
const stop = (parsed.hooks as Record<string, Array<{ hooks: Array<{ command?: string }> }>>).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);
});
});
+16
View File
@@ -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');
});
});
+32
View File
@@ -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);
});
});
+7 -2
View File
@@ -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);
+2
View File
@@ -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);
+71
View File
@@ -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<void>): Promise<string> {
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);
});
});
});
+116 -4
View File
@@ -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<ReturnType<typeof durabilityJobStatus>> {
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'), '<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);
});
});
+5
View File
@@ -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', () => {
@@ -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);
});
+92 -1
View File
@@ -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<typeof writeReceipt>[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);
});
+102
View File
@@ -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);
});
});
+278 -2
View File
@@ -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<string, string | undefined>;
@@ -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/<branch> == 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', () => {
+332
View File
@@ -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<typeof fakeFetch>[0];
env?: Record<string, string | undefined>;
}): Promise<RepoVisibilityVerdict> {
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: '<html>Sign in</html>' },
});
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<string, unknown>;
expect(Object.keys(map)).toEqual([URL_GH]);
});
});
+113 -15
View File
@@ -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);
});