mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
v0.45.2.0 fix(bootstrap): create-repo-first repo adoption + hardening (#4024)
* fix(bootstrap): harden create-repo-first repo adoption `gbrain bootstrap repo` adopts an empty, private, personally-owned GitHub repo the human created (create-repo-first), instead of only ever creating one. This hardens the existing adoption branch: - Empty-only adoption + pending_repo_url proof: a non-empty origin is refused (ORIGIN_NOT_EMPTY) unless it matches this workspace's pending marker (our own interrupted push). Never adopts a user's existing project from a git-ancestry guess, and never silently no-ops without pushing. - Repo-local git identity is set on the adopt path too (fresh-machine commits). - repo_url is recorded only AFTER a successful push (pending marker before); a failed push no longer looks "done" to `bootstrap status`. - Pre-push secret scan also covers an already-committed tree; ls-files failure fails closed. - assertOriginMatches binds BOTH the fetch URL and a configured push URL to the verified-private repo, so a foreign pushurl can't leak the workspace. - disposition: 'created' | 'adopted' | 'reused' replaces the overloaded flag. - Hook push-gate: the no-daemon session-end / recovery push is deferred until the repo phase records repo_url AND the current origin still matches it, so nothing is published to an unverified or redirected remote. Adds ORIGIN_NOT_EMPTY / REMOTE_CHECK_FAILED error codes. * docs(bootstrap): lead with the repo, document create-repo-first README (Claude Code + Codex) now opens with "the folder you open becomes your agent's private repo" and adds a "prefer to make the repo yourself?" callout for the create-repo-first path (empty, personal-account repo). Updates the bootstrap guide, the Claude Code MCP note, and the KEY_FILES / AGENT_BOOTSTRAP_PLAN invariants to describe adoption instead of "foreign origins refused". * v0.45.1.0 fix(bootstrap): create-repo-first repo adoption + hardening Bumps VERSION/package.json to 0.45.1.0, adds the CHANGELOG entry, refreshes the runbook + template-repo version stamps, and regenerates the llms bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(todos): file P2 follow-up — index-blob secret scan for bootstrap pushes * ci(gitleaks): run the free CLI instead of the license-gated v2 action gitleaks-action@v2 now enforces a paid GITLEAKS_LICENSE and fails the job ("missing gitleaks license") for accounts it can't validate over the API — blocking every PR's merge gate. Replace it with the open-source gitleaks CLI (pinned 8.30.1, checksum-verified against the release's own checksums file), scanning the PR/push commit range with the committed .gitleaks.toml allowlist. Same secret-scan coverage, no license wall. * v0.45.2.0 chore(release): re-bump 0.45.1.0 -> 0.45.2.0 Re-target the release version at the user's request. Updates VERSION, package.json, the CHANGELOG header + self-repair block, the runbook + template-repo version stamps, the TODOS follow-up reference, and the llms bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(perf): raise entity-card ratio ceiling 50x -> 100x (CI flake) The RATIO GUARD asserted entity p99 <= 50x max(getPage p50, 1ms). On a fast runner getPage p50 floors to 1ms and a normal entity p99 (~50ms) reads as ~52x, tripping the gate even though absolute p99 (52ms) is well under the 100ms budget — a p99 tail divided by a sub-ms median. At the 1ms floor, 50x also made the ratio STRICTER than the test's own 100ms absolute budget. Raise the ceiling to 100x: still far below the >=200x O(N)-regression signal the guard exists to catch, and consistent with (never stricter than) the absolute budget. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c2cc8b0207
commit
a996e42856
@@ -87,9 +87,36 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: gitleaks/gitleaks-action@dcedce43c6f43de0b836d1fe38946645c9c638dc # v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Use the open-source gitleaks CLI, not gitleaks-action@v2: the v2 action
|
||||
# now enforces a paid GITLEAKS_LICENSE (fails the job with "missing
|
||||
# gitleaks license" for accounts it can't validate). The CLI is free, uses
|
||||
# the committed .gitleaks.toml allowlist, and scans the same commit range.
|
||||
- name: Install gitleaks (pinned + checksum-verified)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER=8.30.1
|
||||
BASE="gitleaks_${VER}_linux_x64.tar.gz"
|
||||
URL="https://github.com/gitleaks/gitleaks/releases/download/v${VER}"
|
||||
curl -fsSL -o "/tmp/${BASE}" "${URL}/${BASE}"
|
||||
curl -fsSL -o /tmp/gitleaks_checksums.txt "${URL}/gitleaks_${VER}_checksums.txt"
|
||||
( cd /tmp && grep " ${BASE}\$" gitleaks_checksums.txt | sha256sum -c - )
|
||||
tar -xzf "/tmp/${BASE}" -C /tmp gitleaks
|
||||
install /tmp/gitleaks /usr/local/bin/gitleaks
|
||||
gitleaks version
|
||||
- name: Scan for secrets (gitleaks CLI, .gitleaks.toml)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
RANGE="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
BEFORE="${{ github.event.before }}"
|
||||
case "$BEFORE" in
|
||||
""|0000000000000000000000000000000000000000) RANGE="${{ github.sha }}~1..${{ github.sha }}" ;;
|
||||
*) RANGE="${BEFORE}..${{ github.sha }}" ;;
|
||||
esac
|
||||
fi
|
||||
echo "Scanning commit range: $RANGE"
|
||||
gitleaks detect --redact --no-banner --log-opts "$RANGE"
|
||||
|
||||
verify:
|
||||
# Pre-test gates: privacy/jsonb/source-id/etc + typecheck + admin-build.
|
||||
|
||||
+12
-5
@@ -1,4 +1,4 @@
|
||||
<!-- gbrain-runbook-stamp: 0.45.1.0 -->
|
||||
<!-- gbrain-runbook-stamp: 0.45.2.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. -->
|
||||
@@ -103,10 +103,17 @@ you needed; report the count at the end (it feeds the install-time measurement).
|
||||
- Codex: registers MCP (`codex mcp add`) and relies on the AGENTS.md protocol —
|
||||
say plainly that Codex gets pull-based context, not per-turn push.
|
||||
7. **Private repo.** `gbrain bootstrap repo` — creates a PRIVATE GitHub repo from
|
||||
the workspace, verifies the privacy bit through the API, pushes. 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: local-only
|
||||
mode with an honest warning; `bootstrap repo` can run any time later.
|
||||
the workspace, verifies the privacy bit through the API, pushes. If the human
|
||||
started from a repo they created themselves (create-repo-first: an EMPTY private
|
||||
repo under their own account, cloned and opened here), this ADOPTS that repo
|
||||
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:
|
||||
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.
|
||||
8. **Verify.** `gbrain bootstrap verify` — the whole contract: brain round-trip
|
||||
through the real write path, graph floor, token sweep, secret scan, repo
|
||||
privacy, hooks smoke, capability report (keyless or keyed). Exit 0 or it is not
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.45.2.0] - 2026-08-11
|
||||
|
||||
**Make your agent's repo yourself, then let it move in.** If you'd rather own the GitHub repo up front, create a new empty private repo under your own account, clone it, open it in Claude Code or Codex, and paste the bootstrap block — bootstrap now detects your empty repo and adopts it instead of creating one, verifying it is private before anything is pushed. The default (open an empty folder and let bootstrap make the repo) is unchanged and now stated plainly in the docs. Either way, the folder you open becomes your agent's durable, private body.
|
||||
|
||||
### Added
|
||||
- **Create-repo-first bootstrap.** `gbrain bootstrap repo` adopts an empty, private, personally-owned GitHub repo you created, instead of only ever creating one. The README (Claude Code + Codex), the bootstrap runbook, and the bootstrap guide now lead with the repo and document both paths (open an empty folder, or bring your own empty repo).
|
||||
|
||||
### Changed
|
||||
- Bootstrap now reports how the repo was set up — created, adopted, or already pushed.
|
||||
|
||||
### Fixed
|
||||
- Pointing bootstrap at a repo that already has content no longer reports success without pushing your workspace. It stops with a clear message: make an empty repo, or run `gbrain bootstrap attach` for an existing agent clone.
|
||||
- Adopting a repo on a fresh machine no longer fails at the first commit — a repo-local git identity is set on the adopt path, not just the create path.
|
||||
- A failed first push no longer looks "done" on the next run: the repo is recorded only after the push succeeds, so a re-run resumes instead of skipping.
|
||||
- The pre-push secret scan now also covers an already-committed tree, and a failure to enumerate files stops the push instead of passing silently.
|
||||
- Automatic per-turn and session-end pushes wait until the repo phase has verified the repo is private, so nothing is published to an unverified remote.
|
||||
|
||||
To take advantage of v0.45.2.0: upgrade with `bun install -g github:garrytan/gbrain#latest-stable`. Nothing to migrate. To use the new path, create an empty private repo under your own account, clone it, open it in your agent, and run the bootstrap block — it adopts your repo. If anything about the repo or push looks off, `gbrain doctor` names it with the exact fix.
|
||||
## [0.45.1.0] - 2026-08-11
|
||||
|
||||
**Your per-prompt brain hooks are now measurable and non-repetitive.** v0.45.0.0's paste-in agent install gave every prompt a context injection; this release makes that channel behave like a product instead of a firehose. The hook remembers what it already told you — a page it injected earlier in the session isn't re-injected every time the name comes up — and every delivery now lands in the same precision feedback loop the other push channels use, so `gbrain volunteer-context --stats` and a new doctor check show exactly which harnesses are firing and how useful their pushes are.
|
||||
|
||||
@@ -79,7 +79,7 @@ GBrain is designed to be installed and operated by an AI agent. **New to GBrain?
|
||||
|
||||
### For Codex — the recommended first step
|
||||
|
||||
Turn Codex into your persistent personal agent. Works in the **ChatGPT desktop app** (open Codex on a folder) and in the **Codex CLI** (`codex` in a terminal) — same install, same result. Pick the folder that will become your agent's home, and paste:
|
||||
Turn Codex into your persistent personal agent. Works in the **ChatGPT desktop app** (open Codex on a folder) and in the **Codex CLI** (`codex` in a terminal) — same install, same result. Open Codex in a **new, empty folder** (not an existing code project) — that folder becomes your agent's own **private GitHub repo**, which bootstrap creates and privacy-verifies for you. Then paste:
|
||||
|
||||
```
|
||||
Read and follow every step of:
|
||||
@@ -92,9 +92,11 @@ answers. Ask before anything destructive. You are not done until
|
||||
|
||||
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key (OpenAI, Anthropic, or Voyage) upgrades to semantic search and automatic fact extraction. Codex reads brain context through its tools each turn (pull-based).
|
||||
|
||||
> **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 Codex, and paste the same block — bootstrap detects your empty repo and adopts it instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused (create one under your account, or let bootstrap make it).
|
||||
|
||||
### For Claude Code — turn it into your persistent personal agent
|
||||
|
||||
Works in the **desktop app** and in the **CLI** (`claude` in a terminal) — identical harness, identical result. Open Claude Code in the folder that will become your agent's home, and paste the same block:
|
||||
Works in the **desktop app** and in the **CLI** (`claude` in a terminal) — identical harness, identical result. Open Claude Code in a **new, empty folder** (not an existing code project) — that folder becomes your agent's own **private GitHub repo**, created and privacy-verified for you. Then paste the same block:
|
||||
|
||||
```
|
||||
Read and follow every step of:
|
||||
@@ -107,6 +109,8 @@ answers. Ask before anything destructive. You are not done until
|
||||
|
||||
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).
|
||||
|
||||
> **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.
|
||||
|
||||
### For OpenClaw or Hermes — GBrain as intended, always on
|
||||
|
||||
This is GBrain used the way it was designed to be used: a server-hosted agent with 24/7 crons, continuous ingestion, and the overnight dream cycle that enriches your brain while you sleep — your agent works whether your laptop is open or not. It's also the highest-cost path: a deployed server (8GB+ RAM) plus raw API token usage that scales with how hard your agent runs, well beyond a chat subscription. Start here if you want the full experience from day one; start with Codex above if you want to feel it first. If you don't have a platform running yet, both deploy in one click:
|
||||
|
||||
@@ -5056,6 +5056,19 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
|
||||
|
||||
## Agent-bootstrap wave follow-ups (filed at build time)
|
||||
|
||||
- [ ] **P2 — bootstrap first-push secret scan reads the working tree, not the
|
||||
index blobs; fail-open on binary/large files.** `secretScanOrThrow` /
|
||||
`scanFiles` (src/core/bootstrap/repo.ts + src/core/secret-scan.ts) read
|
||||
working-tree bytes and silently skip unreadable, binary, and >25 MiB files, so
|
||||
a git clean filter could commit a secret whose working-tree copy scans clean,
|
||||
and a secret in a binary/large file is never seen. Pre-existing across ALL
|
||||
bootstrap pushes (create + adopt), not specific to create-repo-first. Fix:
|
||||
scan the staged index blobs (`git show :file` / `git cat-file`) fail-closed,
|
||||
or reuse the hardened scanner path from `workspacePush`. Filed from the
|
||||
v0.45.2.0 /ship Codex adversarial pass (P0 there; scoped to P2 here as a
|
||||
shared-scanner hardening that needs its own tests, deliberately out of the
|
||||
create-repo-first change).
|
||||
|
||||
- [x] **P2 — compiled `gbrain` binary can now `serve` a PGLite brain.** FIXED:
|
||||
`src/core/pglite-embedded-assets.ts` embeds PGLite's runtime payload
|
||||
(`pglite.wasm`, `initdb.wasm`, `pglite.data`, `vector.tar.gz`,
|
||||
|
||||
@@ -500,7 +500,7 @@ User-facing contract: `docs/guides/bootstrap.md`. Runbook the paste block fetche
|
||||
- `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), idempotency keyed off the remote URL; refuses foreign origins and points at attach. `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/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 (project scope default, `-e 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/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).
|
||||
|
||||
@@ -381,9 +381,12 @@ ChatGPT-app user). CLIs come along via shared machinery.
|
||||
persistence path anymore).
|
||||
- [G6] Verify + every push gate run `git ls-files` against a deny-glob list
|
||||
(`*.pglite`, `.env*`, keys) — a truncated or pre-existing .gitignore can't leak.
|
||||
- [G8] `bootstrap repo` refuses any pre-existing `origin` (always creates a dedicated
|
||||
repo); "couldn't verify visibility" is refuse-and-name-the-reason, never fail-open;
|
||||
idempotency keys off the remote URL, not the name probe.
|
||||
- [G8] `bootstrap repo` creates a dedicated repo, OR adopts a pre-existing `origin`
|
||||
when the authed gh user owns it, no `repo_url` is recorded yet, and it is empty (or
|
||||
already carries our history) — the create-repo-first path; a foreign-content or
|
||||
org-owned origin is refused and pointed at attach. "couldn't verify visibility" is
|
||||
refuse-and-name-the-reason, never fail-open; idempotency keys off the remote URL,
|
||||
not the name probe.
|
||||
- [G9] Workspace lockfile (pid+timestamp) makes concurrent `bootstrap` runs impossible;
|
||||
second run exits "bootstrap already running (pid N)".
|
||||
- [G13] Fixed verify probe slug; sweep any prior probe before writing; excluded from
|
||||
|
||||
@@ -23,13 +23,36 @@ follows is `BOOTSTRAP_FOR_AGENTS.md` at the repo root, fetched at the
|
||||
| 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 |
|
||||
| Private GitHub repo | your account, created by `bootstrap repo` | privacy verified via API |
|
||||
| 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 |
|
||||
|
||||
**What does NOT run:** anything while the harness is closed. Session-triggered
|
||||
schedules fire at turn/session boundaries only. True 24/7 operation is what a
|
||||
hosted brain provides — this is the honest desktop contract.
|
||||
|
||||
## Bring your own repo (create-repo-first)
|
||||
|
||||
By default bootstrap creates the private GitHub repo for you. If you prefer to own
|
||||
that step — pick the name/org-under-your-account, or just work the familiar way —
|
||||
create a new **empty** private repo **under your own GitHub account** (no
|
||||
README/.gitignore/license), clone it, open the clone in your harness, and run the
|
||||
bootstrap block. `gbrain bootstrap repo` detects the empty repo you created and
|
||||
**adopts** it: it verifies the repo is private, sets a repo-local git identity, and
|
||||
pushes your workspace. Two constraints, both enforced with a clear message rather
|
||||
than a silent failure:
|
||||
|
||||
- **Empty.** A repo that already has commits (a README, a license, an existing
|
||||
project) is refused — create it empty, or run `gbrain bootstrap attach` if it is
|
||||
an existing agent workspace. (A repo already carrying *this* workspace's history,
|
||||
e.g. from an interrupted run, is recognized as yours and resumed.)
|
||||
- **Personal account.** The repo must be owned by your authenticated GitHub user.
|
||||
Org-owned repos are refused today; create one under your own account, or let
|
||||
bootstrap make it.
|
||||
|
||||
Until the repo phase verifies the repo, the per-turn/session-end push stays
|
||||
deferred — bootstrap never publishes your workspace to an origin whose privacy it
|
||||
hasn't confirmed.
|
||||
|
||||
## The awake-when-you-are contract
|
||||
|
||||
Your agent is awake when your harness is. Laptop asleep = agent asleep. What this
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
> Want the **full agent** — identity, per-turn context, schedules, and a private
|
||||
> repo as its durable body — not just a memory? That's `gbrain bootstrap`:
|
||||
> see the paste block in the README and [docs/guides/bootstrap.md](../guides/bootstrap.md).
|
||||
> Open a new empty folder (bootstrap creates the private repo for you), or make an
|
||||
> empty private repo under your own account and open the clone — bootstrap adopts it.
|
||||
|
||||
## Option 1: Local (recommended, zero server needed)
|
||||
|
||||
|
||||
+6
-2
@@ -1630,7 +1630,7 @@ GBrain is designed to be installed and operated by an AI agent. **New to GBrain?
|
||||
|
||||
### For Codex — the recommended first step
|
||||
|
||||
Turn Codex into your persistent personal agent. Works in the **ChatGPT desktop app** (open Codex on a folder) and in the **Codex CLI** (`codex` in a terminal) — same install, same result. Pick the folder that will become your agent's home, and paste:
|
||||
Turn Codex into your persistent personal agent. Works in the **ChatGPT desktop app** (open Codex on a folder) and in the **Codex CLI** (`codex` in a terminal) — same install, same result. Open Codex in a **new, empty folder** (not an existing code project) — that folder becomes your agent's own **private GitHub repo**, which bootstrap creates and privacy-verifies for you. Then paste:
|
||||
|
||||
```
|
||||
Read and follow every step of:
|
||||
@@ -1643,9 +1643,11 @@ answers. Ask before anything destructive. You are not done until
|
||||
|
||||
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key (OpenAI, Anthropic, or Voyage) upgrades to semantic search and automatic fact extraction. Codex reads brain context through its tools each turn (pull-based).
|
||||
|
||||
> **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 Codex, and paste the same block — bootstrap detects your empty repo and adopts it instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused (create one under your account, or let bootstrap make it).
|
||||
|
||||
### For Claude Code — turn it into your persistent personal agent
|
||||
|
||||
Works in the **desktop app** and in the **CLI** (`claude` in a terminal) — identical harness, identical result. Open Claude Code in the folder that will become your agent's home, and paste the same block:
|
||||
Works in the **desktop app** and in the **CLI** (`claude` in a terminal) — identical harness, identical result. Open Claude Code in a **new, empty folder** (not an existing code project) — that folder becomes your agent's own **private GitHub repo**, created and privacy-verified for you. Then paste the same block:
|
||||
|
||||
```
|
||||
Read and follow every step of:
|
||||
@@ -1658,6 +1660,8 @@ answers. Ask before anything destructive. You are not done until
|
||||
|
||||
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).
|
||||
|
||||
> **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.
|
||||
|
||||
### For OpenClaw or Hermes — GBrain as intended, always on
|
||||
|
||||
This is GBrain used the way it was designed to be used: a server-hosted agent with 24/7 crons, continuous ingestion, and the overnight dream cycle that enriches your brain while you sleep — your agent works whether your laptop is open or not. It's also the highest-cost path: a deployed server (8GB+ RAM) plus raw API token usage that scales with how hard your agent runs, well beyond a chat subscription. Start here if you want the full experience from day one; start with Codex above if you want to feel it first. If you don't have a platform running yet, both deploy in one click:
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.45.1.0",
|
||||
"version": "0.45.2.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -94,8 +94,9 @@ Subcommands (run \`gbrain bootstrap status\` first — it is the resume entrypoi
|
||||
Register MCP (+ per-turn hooks on Claude Code,
|
||||
ON by default; --no-hooks opts out, GBRAIN_HOOKS=0
|
||||
disables at runtime).
|
||||
repo Create the dedicated PRIVATE GitHub repo, verify the
|
||||
privacy bit via the API, push.
|
||||
repo Create the dedicated PRIVATE GitHub repo (or adopt
|
||||
an EMPTY private repo you created under your own
|
||||
account), verify the privacy bit via the API, push.
|
||||
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.
|
||||
@@ -573,7 +574,13 @@ async function runRepo(ws: string, rest: string[], home: string, runner: ExecRun
|
||||
void rest;
|
||||
return withLock(ws, async () => {
|
||||
const result = await createPrivateRepo(ws, { runner, gbrainHomeDir: home });
|
||||
console.log(`${result.reused ? 'verified existing' : 'created'} private repo: ${result.url}`);
|
||||
const line =
|
||||
result.disposition === 'created'
|
||||
? `created private repo: ${result.url}`
|
||||
: result.disposition === 'adopted'
|
||||
? `adopted your existing empty private repo: ${result.url}`
|
||||
: `verified existing private repo: ${result.url}`;
|
||||
console.log(line);
|
||||
|
||||
// Derived-token refresh of GITHUB.md with the real URL (best-effort —
|
||||
// createPrivateRepo already replaced the placeholder in place).
|
||||
|
||||
+59
-3
@@ -63,7 +63,8 @@ import {
|
||||
toCorpusText,
|
||||
} from '../core/transcripts/claude-code-jsonl.ts';
|
||||
import { CLAUDE_HOOK_OUTPUT_CAP_CHARS } from '../core/bootstrap/host-specs.ts';
|
||||
import { readManifest } from '../core/bootstrap/format.ts';
|
||||
import { readManifest, readReceipt, type InstallReceipt } from '../core/bootstrap/format.ts';
|
||||
import { realpathOrResolve } from '../core/path-confine.ts';
|
||||
|
||||
// ── Tunables ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -632,6 +633,48 @@ 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). */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-daemon workspace push must NOT fire until the repo phase has verified
|
||||
* the origin's privacy and recorded `repo_url`. In a create-repo-first install
|
||||
* the origin exists (the clone) BEFORE the repo phase, and hooks are wired one
|
||||
* phase earlier — so an ungated session-end/recovery push could publish
|
||||
* workspace content to an as-yet-unverified (possibly public) remote. A recorded
|
||||
* `repo_url` means the repo phase completed against a verified-private remote.
|
||||
*
|
||||
* Bound to the recorded repo: the current origin — BOTH the fetch URL and the
|
||||
* push URL (`git push` uses the push URL when set) — must still resolve to the
|
||||
* same owner/name as `repo_url`, so a later `git remote set-url` can't redirect
|
||||
* the push to another (possibly public) repo. No receipt / no repo_url / a
|
||||
* changed origin → defer (fail-closed).
|
||||
*/
|
||||
async function repoPhaseComplete(root: string): Promise<boolean> {
|
||||
try {
|
||||
const receipt = readReceipt(resolveGbrainHome()) as (InstallReceipt & { repo_url?: string }) | null;
|
||||
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;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget `gbrain sources push --path <root>` as a DETACHED child.
|
||||
* workspacePush must never run inline in a hook — its git chain is fully
|
||||
@@ -668,7 +711,16 @@ async function dirtyTreePush(
|
||||
]);
|
||||
const dirty = (status ?? '') !== '';
|
||||
const ahead = aheadRaw !== null ? parseInt(aheadRaw, 10) || 0 : 0;
|
||||
if (!dirty && ahead === 0) return null;
|
||||
if (!dirty && ahead === 0) 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).
|
||||
if (!(await repoPhaseComplete(root))) {
|
||||
return {
|
||||
note: 'Unpushed work detected; will push after the repo phase completes (`gbrain bootstrap repo`).',
|
||||
reason: 'push_deferred_repo_pending',
|
||||
};
|
||||
}
|
||||
try {
|
||||
(io.spawnPush ?? spawnDetachedPush)(root);
|
||||
return {
|
||||
@@ -1016,13 +1068,17 @@ async function hookSessionEnd(io: HookIo): Promise<number> {
|
||||
try {
|
||||
if (ws) {
|
||||
const root = await resolveBootstrapWorkspaceRoot(ws);
|
||||
if (root) {
|
||||
if (root && (await repoPhaseComplete(root))) {
|
||||
try {
|
||||
(io.spawnPush ?? spawnDetachedPush)(root);
|
||||
if (outcome === 'ok' && !reason) reason = 'push_spawned';
|
||||
} catch {
|
||||
degrade('push_unavailable');
|
||||
}
|
||||
} else if (root && outcome === 'ok' && !reason) {
|
||||
// Repo phase not finished (create-repo-first, before `bootstrap repo`):
|
||||
// defer the push so we never publish to an unverified-privacy origin.
|
||||
reason = 'push_deferred_repo_pending';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -37,8 +37,16 @@ export type BootstrapErrorCode =
|
||||
| 'GH_MISSING'
|
||||
/** `gh auth status` failed — human must run `gh auth login` (exit 2). */
|
||||
| 'GH_AUTH'
|
||||
/** Workspace already has an `origin` remote bootstrap didn't create [G8]. */
|
||||
/** Workspace already has an `origin` remote bootstrap can neither adopt nor
|
||||
* claim (foreign owner / receipt mismatch) — pointed at attach [G8]. */
|
||||
| 'ORIGIN_EXISTS'
|
||||
/** A create-repo-first origin the authed user owns has FOREIGN content —
|
||||
* bootstrap adopts only an empty repo (or one carrying our own history), so
|
||||
* it never silently no-ops onto an existing project [G8]. */
|
||||
| 'ORIGIN_NOT_EMPTY'
|
||||
/** Could not reach the origin to confirm it is safe to adopt (git ls-remote
|
||||
* failed) — refuse and re-run; never adopt on uncertainty [G8]. */
|
||||
| 'REMOTE_CHECK_FAILED'
|
||||
/** `gh repo create` (or a required git step) failed. */
|
||||
| 'REPO_CREATE_FAILED'
|
||||
/** The pre-push secret scan found an unallowlisted secret in the workspace —
|
||||
|
||||
+257
-58
@@ -3,10 +3,20 @@
|
||||
* [G8, CX2-1, plan D6]. Library module: the CLI dispatcher wires it later.
|
||||
*
|
||||
* Invariants (G8):
|
||||
* - Refuses ANY pre-existing `origin` remote unless the manifest says
|
||||
* `initialized` AND this machine's receipt proves we created it (crash
|
||||
* re-run). An initialized clone with a foreign origin is attach territory.
|
||||
* - After create, repo privacy is verified via the GitHub API; the answer
|
||||
* - A pre-existing `origin` is accepted in exactly two shapes, else refused
|
||||
* (pointed at `attach`): (a) the receipt already recorded this exact
|
||||
* `repo_url` (idempotent re-run -> disposition 'reused'); or (b) the origin
|
||||
* is owned by the authed gh user with NO recorded `repo_url` AND is SAFE to
|
||||
* adopt -> disposition 'adopted'. SAFE means EMPTY (`assertAdoptableOrigin`),
|
||||
* or a non-empty remote that matches this workspace's `pending_repo_url`
|
||||
* proof (our own interrupted push, resumable). A non-empty remote with no
|
||||
* matching pending marker is refused (`ORIGIN_NOT_EMPTY`) — we never adopt a
|
||||
* user's existing project from a git-ancestry guess. Org-owned origins
|
||||
* (owner != login) are out of scope.
|
||||
* - Repo-local git identity (from the authed gh user) is set in BOTH the
|
||||
* create and adopt paths before any commit; `repo_url` is recorded only
|
||||
* AFTER a successful push (a push failure must never look "done" to status).
|
||||
* - Repo privacy is verified via the GitHub API; the answer
|
||||
* must be the literal `true`. "Couldn't verify" (rate limit / 5xx) is a
|
||||
* typed refuse-and-re-run, NEVER treated as private or public. The first
|
||||
* push happens only AFTER the verify passes (create runs without --push).
|
||||
@@ -75,6 +85,12 @@ export const defaultRunner: ExecRunner = async (argv: string[]): Promise<ExecRes
|
||||
* unknown fields, so this is a structural extension, not a format bump. */
|
||||
export interface RepoReceipt extends InstallReceipt {
|
||||
repo_url?: string;
|
||||
/** Proof-of-intent written BEFORE the first push and cleared once `repo_url`
|
||||
* is recorded. If a push lands but the run crashes before recording `repo_url`,
|
||||
* a re-run sees a non-empty origin that matches `pending_repo_url` and knows
|
||||
* the content is OURS (safe to resume) rather than a user's existing project
|
||||
* (which would carry no pending marker). See createPrivateRepo Gate 4. */
|
||||
pending_repo_url?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -143,6 +159,96 @@ async function fetchAuthedLogin(runner: ExecRunner): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** The authed gh user's login + id. Throws GH_AUTH (exit 2) on failure. */
|
||||
async function resolveGhIdentity(runner: ExecRunner): Promise<{ login: string; userId: number | string }> {
|
||||
const userRes = await runner(['gh', 'api', 'user']);
|
||||
if (userRes.code !== 0) {
|
||||
throw new BootstrapError(
|
||||
'GH_AUTH',
|
||||
`could not read the authenticated GitHub user (gh api user failed: ${userRes.stderr.trim() || `exit ${userRes.code}`}) — check \`gh auth status\``,
|
||||
{ exitCode: 2 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(userRes.stdout) as { login?: unknown; id?: unknown };
|
||||
if (typeof parsed.login !== 'string' || parsed.login.length === 0) throw new Error('missing login');
|
||||
const userId = typeof parsed.id === 'number' || typeof parsed.id === 'string' ? parsed.id : 0;
|
||||
return { login: parsed.login, userId };
|
||||
} catch (e) {
|
||||
throw new BootstrapError('GH_AUTH', `unexpected \`gh api user\` output (${(e as Error).message})`, { exitCode: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the repo-local git author identity from the authed gh user (never touches
|
||||
* global git config). Required before ANY commit — a freshly cloned repo on a
|
||||
* machine with no global `user.name`/`user.email` would otherwise fail at commit.
|
||||
* Runs in both the create AND adoption paths (the adoption path is why this is a
|
||||
* shared helper — a clone-then-adopt on a fresh machine hit exactly this).
|
||||
*/
|
||||
async function setRepoLocalIdentity(
|
||||
runner: ExecRunner,
|
||||
workspaceDir: string,
|
||||
ident: { login: string; userId: number | string },
|
||||
): Promise<void> {
|
||||
// Fail loudly if the config writes fail — otherwise a later commit falls back
|
||||
// to (possibly absent) global identity and dies with a misleading error.
|
||||
const nameRes = await runner(['git', '-C', workspaceDir, 'config', 'user.name', ident.login]);
|
||||
const emailRes = await runner([
|
||||
'git', '-C', workspaceDir, 'config', 'user.email',
|
||||
`${ident.userId}+${ident.login}@users.noreply.github.com`,
|
||||
]);
|
||||
const failed = nameRes.code !== 0 ? nameRes : emailRes.code !== 0 ? emailRes : null;
|
||||
if (failed) {
|
||||
throw new BootstrapError(
|
||||
'REPO_CREATE_FAILED',
|
||||
`could not set the repo-local git identity (git config failed: ${failed.stderr.trim() || `exit ${failed.code}`}) — commits would fail; fix the git state and re-run \`gbrain bootstrap repo\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for the create-repo-first / pre-record adoption path (an origin the human
|
||||
* created, with no recorded `repo_url` yet). The origin must be EMPTY to adopt.
|
||||
*
|
||||
* Empty-only is deliberate. A non-empty remote cannot be proven to be OURS from
|
||||
* git alone — "remote HEAD equals/precedes local HEAD" is ALSO true of a user's
|
||||
* own existing project that `render` happened to run inside, so an ancestor/SHA
|
||||
* heuristic would silently adopt (and later push identity/personal data into)
|
||||
* that project. We refuse instead. The happy path stays empty because the
|
||||
* no-daemon push is gated until the repo phase records `repo_url` (see
|
||||
* `repoPhaseComplete` in hook.ts), so nothing lands on the remote before this
|
||||
* runs. Genuinely-ours interrupted pushes are matched separately by
|
||||
* `pending_repo_url` at the call site — this function is the fallback for
|
||||
* everything else. Throws on refusal; never adopts on uncertainty.
|
||||
*/
|
||||
async function assertAdoptableOrigin(
|
||||
runner: ExecRunner,
|
||||
workspaceDir: string,
|
||||
owner: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const ls = await runner(['git', '-C', workspaceDir, 'ls-remote', 'origin']);
|
||||
if (ls.code !== 0) {
|
||||
throw new BootstrapError(
|
||||
'REMOTE_CHECK_FAILED',
|
||||
`could not list ${owner}/${name} to confirm it is safe to adopt (git ls-remote failed: ${ls.stderr.trim() || `exit ${ls.code}`}) — nothing was pushed; re-run \`gbrain bootstrap repo\``,
|
||||
{ details: { owner, name } },
|
||||
);
|
||||
}
|
||||
// All refs (not just --heads): a repo with only tags is not "empty".
|
||||
if (ls.stdout.trim().length === 0) return; // genuinely empty → safe to adopt
|
||||
|
||||
throw new BootstrapError(
|
||||
'ORIGIN_NOT_EMPTY',
|
||||
`${owner}/${name} already has content that bootstrap did not put there. ` +
|
||||
'`gbrain bootstrap repo` adopts only an EMPTY repo you just created. ' +
|
||||
'Create a new EMPTY private repo (no README/.gitignore/license) under your own account and point this workspace at it, ' +
|
||||
'or run `gbrain bootstrap attach` if this is an existing agent workspace.',
|
||||
{ details: { owner, name } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Privacy verify [G8]: `gh api repos/{owner}/{name} --jq .private` must print
|
||||
* the literal `true`. Any failure to VERIFY is VERIFY_UNAVAILABLE (refuse +
|
||||
@@ -200,9 +306,10 @@ function updateGithubMd(workspaceDir: string, url: string): void {
|
||||
if (next !== raw) writeFileSync(path, next, 'utf8');
|
||||
}
|
||||
|
||||
/** Record the created repo URL on the machine-local receipt. Creates a minimal
|
||||
* receipt when render's is missing (crash between render and receipt write). */
|
||||
function recordRepoInReceipt(gbrainHomeDir: string, workspaceDir: string, manifest: AgentManifest, url: string): void {
|
||||
/** Load-or-synthesize the machine-local receipt (minimal one when render's is
|
||||
* missing — crash between render and receipt write), with the bootstrap/ subdir
|
||||
* ensured and the overwrite guard run. Shared by the record helpers. */
|
||||
function loadReceiptForWrite(gbrainHomeDir: string, workspaceDir: string, manifest: AgentManifest): RepoReceipt {
|
||||
const existing = readReceipt(gbrainHomeDir) as RepoReceipt | null;
|
||||
const receipt: RepoReceipt = existing ?? {
|
||||
receipt_version: 1,
|
||||
@@ -215,7 +322,6 @@ function recordRepoInReceipt(gbrainHomeDir: string, workspaceDir: string, manife
|
||||
created_paths: [],
|
||||
registrations: [],
|
||||
};
|
||||
receipt.repo_url = url;
|
||||
// writeReceipt assumes the bootstrap/ subdir exists (render creates it);
|
||||
// repo may run first after a crash, so create it defensively.
|
||||
mkdirSync(join(gbrainHomeDir, 'bootstrap'), { recursive: true });
|
||||
@@ -223,6 +329,23 @@ function recordRepoInReceipt(gbrainHomeDir: string, workspaceDir: string, manife
|
||||
if (guard.brokenBackupPath) {
|
||||
console.error(`WARNING: the install receipt was unreadable; backed it up to ${guard.brokenBackupPath} and wrote a fresh one.`);
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
/** Proof-of-intent: record `pending_repo_url` BEFORE the first push, so a
|
||||
* post-push/pre-record crash is recognized as OURS on re-run (see Gate 4). */
|
||||
function recordPendingRepo(gbrainHomeDir: string, workspaceDir: string, manifest: AgentManifest, url: string): void {
|
||||
const receipt = loadReceiptForWrite(gbrainHomeDir, workspaceDir, manifest);
|
||||
receipt.pending_repo_url = url;
|
||||
writeReceipt(gbrainHomeDir, receipt);
|
||||
}
|
||||
|
||||
/** Record the created/adopted repo URL AFTER a successful push, clearing the
|
||||
* pending marker (the durable idempotency key). */
|
||||
function recordRepoInReceipt(gbrainHomeDir: string, workspaceDir: string, manifest: AgentManifest, url: string): void {
|
||||
const receipt = loadReceiptForWrite(gbrainHomeDir, workspaceDir, manifest);
|
||||
receipt.repo_url = url;
|
||||
delete receipt.pending_repo_url;
|
||||
writeReceipt(gbrainHomeDir, receipt);
|
||||
}
|
||||
|
||||
@@ -231,12 +354,32 @@ function recordRepoInReceipt(gbrainHomeDir: string, workspaceDir: string, manife
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Files git would include in a commit right now (staged + untracked, minus
|
||||
* ignored), relative to the workspace — the input to the pre-commit scan. */
|
||||
* ignored), relative to the workspace — the input to the pre-commit scan.
|
||||
* Fail-closed: a `git ls-files` failure is a hard refuse, NEVER a vacuous empty
|
||||
* set (an empty set would let the secret scan pass without seeing anything). */
|
||||
async function stagedAndUntrackedFiles(runner: ExecRunner, workspaceDir: string): Promise<string[]> {
|
||||
const res = await runner([
|
||||
'git', '-C', workspaceDir, 'ls-files', '--cached', '--others', '--exclude-standard', '-z',
|
||||
]);
|
||||
if (res.code !== 0) return [];
|
||||
if (res.code !== 0) {
|
||||
throw new BootstrapError(
|
||||
'REPO_CREATE_FAILED',
|
||||
`could not enumerate files for the pre-push secret scan (git ls-files failed: ${res.stderr.trim() || `exit ${res.code}`}) — nothing was committed or pushed; fix the git state and re-run \`gbrain bootstrap repo\``,
|
||||
);
|
||||
}
|
||||
return res.stdout.split('\0').filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
/** Tracked files in the current HEAD tree — the set that would be pushed when a
|
||||
* clean commit already exists. Fail-closed like stagedAndUntrackedFiles. */
|
||||
async function trackedFiles(runner: ExecRunner, workspaceDir: string): Promise<string[]> {
|
||||
const res = await runner(['git', '-C', workspaceDir, 'ls-files', '-z']);
|
||||
if (res.code !== 0) {
|
||||
throw new BootstrapError(
|
||||
'REPO_CREATE_FAILED',
|
||||
`could not enumerate tracked files for the pre-push secret scan (git ls-files failed: ${res.stderr.trim() || `exit ${res.code}`}) — nothing was pushed; fix the git state and re-run \`gbrain bootstrap repo\``,
|
||||
);
|
||||
}
|
||||
return res.stdout.split('\0').filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
@@ -274,7 +417,16 @@ async function ensureWorkspaceCommit(runner: ExecRunner, workspaceDir: string):
|
||||
const hasCommit = head.code === 0;
|
||||
const statusRes = await runner(['git', '-C', workspaceDir, 'status', '--porcelain']);
|
||||
const dirty = statusRes.code === 0 && statusRes.stdout.trim().length > 0;
|
||||
if (hasCommit && !dirty) return; // there is already something to push
|
||||
if (hasCommit && !dirty) {
|
||||
// A clean commit already exists (e.g. a re-run, or an adopted repo whose
|
||||
// tree was committed earlier). Do NOT return before scanning: the committed
|
||||
// tree is exactly what a `git push` will publish, so secret-scan it too —
|
||||
// the early return used to skip the scan entirely (a pre-existing commit
|
||||
// could push secrets unscanned).
|
||||
const tracked = await trackedFiles(runner, workspaceDir);
|
||||
secretScanOrThrow(workspaceDir, tracked);
|
||||
return; // there is already something to push
|
||||
}
|
||||
|
||||
const add = await runner(['git', '-C', workspaceDir, 'add', '-A']);
|
||||
if (add.code !== 0) {
|
||||
@@ -319,7 +471,10 @@ async function currentBranch(runner: ExecRunner, workspaceDir: string): Promise<
|
||||
* [FIX4/G8] Bind the just-verified privacy result to the ACTUAL push target.
|
||||
* A concurrent `git remote set-url origin …` between verify and push could
|
||||
* redirect content elsewhere; re-read origin and refuse unless it still parses
|
||||
* to the owner/name we verified private.
|
||||
* to the owner/name we verified private. Checks BOTH the fetch URL and the push
|
||||
* URL (`remote.origin.pushurl`) — `git push` uses the push URL when set, so a
|
||||
* verified-private fetch URL paired with a foreign/public push URL would
|
||||
* otherwise leak the workspace.
|
||||
*/
|
||||
async function assertOriginMatches(
|
||||
runner: ExecRunner,
|
||||
@@ -327,18 +482,37 @@ async function assertOriginMatches(
|
||||
owner: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const res = await runner(['git', '-C', workspaceDir, 'remote', 'get-url', 'origin']);
|
||||
const url = res.code === 0 ? res.stdout.trim() : '';
|
||||
const parsed = url ? parseGithubRemote(url) : null;
|
||||
if (!parsed || parsed.owner !== owner || parsed.name !== name) {
|
||||
// Fetch URL: must resolve to the verified-private owner/name (the primary bind).
|
||||
const fetchRes = await runner(['git', '-C', workspaceDir, 'remote', 'get-url', 'origin']);
|
||||
const fetchUrl = fetchRes.code === 0 ? fetchRes.stdout.trim() : '';
|
||||
const fetchParsed = fetchUrl ? parseGithubRemote(fetchUrl) : null;
|
||||
if (!fetchParsed || fetchParsed.owner !== owner || fetchParsed.name !== name) {
|
||||
throw new BootstrapError(
|
||||
'ORIGIN_EXISTS',
|
||||
`origin now resolves to ${url || '(unset)'}, not the verified-private ${owner}/${name} — refusing to push. ` +
|
||||
`origin now resolves to ${fetchUrl || '(unset)'}, not the verified-private ${owner}/${name} — refusing to push. ` +
|
||||
'The remote changed after the privacy verification; restore it (git remote set-url origin <the created repo>) ' +
|
||||
'and re-run `gbrain bootstrap repo`.',
|
||||
{ details: { expected: `${owner}/${name}`, actual: url } },
|
||||
{ details: { which: 'fetch', expected: `${owner}/${name}`, actual: fetchUrl } },
|
||||
);
|
||||
}
|
||||
// Push URL: `git push` uses `remote.origin.pushurl` when set, so a verified
|
||||
// fetch URL + a foreign push URL would leak. Read the config key directly (no
|
||||
// dash-flag): unset → exit != 0 → `git push` falls back to the fetch URL
|
||||
// (already validated). Only a DISTINCT, configured push URL is a refusal.
|
||||
const pushRes = await runner(['git', '-C', workspaceDir, 'config', 'remote.origin.pushurl']);
|
||||
const pushUrl = pushRes.code === 0 ? pushRes.stdout.trim() : '';
|
||||
if (pushUrl) {
|
||||
const pushParsed = parseGithubRemote(pushUrl);
|
||||
if (!pushParsed || pushParsed.owner !== owner || pushParsed.name !== name) {
|
||||
throw new BootstrapError(
|
||||
'ORIGIN_EXISTS',
|
||||
`origin push URL resolves to ${pushUrl}, not the verified-private ${owner}/${name} — refusing to push. ` +
|
||||
'A separate push URL (remote.origin.pushurl) points elsewhere; clear that config key ' +
|
||||
'and re-run `gbrain bootstrap repo`.',
|
||||
{ details: { which: 'push', expected: `${owner}/${name}`, actual: pushUrl } },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,8 +560,20 @@ export interface CreatePrivateRepoOptions {
|
||||
export interface CreatePrivateRepoResult {
|
||||
url: string;
|
||||
name: string;
|
||||
/** True when a pre-existing origin created by a prior run was adopted
|
||||
* (idempotent re-run) instead of creating a new repo. */
|
||||
/**
|
||||
* How the repo came to be:
|
||||
* - `created` — bootstrap ran `gh repo create` this run (brand-new repo).
|
||||
* - `adopted` — a pre-existing origin the human created (create-repo-first),
|
||||
* or one bootstrap created but crashed before recording, was verified and
|
||||
* pushed to for the first time this run.
|
||||
* - `reused` — a repo a prior run already recorded (`repo_url` match); this
|
||||
* run only re-verified privacy and completed any deferred push.
|
||||
* A single enum instead of overlapping booleans so invalid combinations
|
||||
* ('created'+'adopted') are unrepresentable.
|
||||
*/
|
||||
disposition: 'created' | 'adopted' | 'reused';
|
||||
/** Back-compat: true whenever an existing origin was used (adopted OR reused)
|
||||
* rather than freshly created. Derived from `disposition`. */
|
||||
reused: boolean;
|
||||
}
|
||||
|
||||
@@ -425,35 +611,42 @@ export async function createPrivateRepo(
|
||||
// Gate 3: manifest must say initialized (render ran) [CX2-1].
|
||||
const { manifest } = requireInitializedManifest(workspaceDir);
|
||||
|
||||
// Gate 4: pre-existing origin [G8]. The ONLY acceptable origin is one a
|
||||
// prior run on THIS machine created (receipt-keyed, matched by remote URL).
|
||||
// Gate 4: pre-existing origin [G8]. Two origins are acceptable:
|
||||
// (a) one a prior run recorded (receipt `repo_url` matches) — idempotent
|
||||
// re-run, disposition 'reused';
|
||||
// (b) an origin owned by the authed gh user with NO recorded `repo_url` —
|
||||
// either the human created it (create-repo-first) or we created it but
|
||||
// crashed before recording. Adopting it requires it be SAFE (empty, or
|
||||
// already carrying our history — see assertAdoptableOrigin), disposition
|
||||
// 'adopted'. Anything else is refused and pointed at attach.
|
||||
const originRes = await runner(['git', '-C', workspaceDir, 'remote', 'get-url', 'origin']);
|
||||
if (originRes.code === 0 && originRes.stdout.trim()) {
|
||||
const originUrl = originRes.stdout.trim();
|
||||
const receipt = readReceipt(gbrainHomeDir) as RepoReceipt | null;
|
||||
const sameWorkspace =
|
||||
receipt !== null && realpathOrResolve(receipt.workspace_dir) === realpathOrResolve(workspaceDir);
|
||||
// Adoption requires an EXACT repo_url match. A receipt without a recorded
|
||||
// repo_url (crash between create and record) may adopt ONLY when the
|
||||
// authenticated gh user owns the origin — undefined is never a wildcard.
|
||||
let adoptable = sameWorkspace && receipt.repo_url === originUrl;
|
||||
if (!adoptable && sameWorkspace && receipt.repo_url === undefined) {
|
||||
const viaUrlMatch = sameWorkspace && receipt.repo_url === originUrl;
|
||||
// A receipt without a recorded repo_url (crash between create and record, OR
|
||||
// a create-repo-first clone rendered in place) may adopt ONLY when the
|
||||
// authenticated gh user owns the origin — undefined is never a wildcard,
|
||||
// and org-owned repos (owner != login) are out of scope by design.
|
||||
let viaOwnedUndefined = false;
|
||||
if (!viaUrlMatch && sameWorkspace && receipt.repo_url === undefined) {
|
||||
const parsedOrigin = parseGithubRemote(originUrl);
|
||||
if (parsedOrigin) {
|
||||
const login = await fetchAuthedLogin(runner);
|
||||
adoptable = login !== null && login === parsedOrigin.owner;
|
||||
viaOwnedUndefined = login !== null && login === parsedOrigin.owner;
|
||||
}
|
||||
}
|
||||
if (!adoptable) {
|
||||
if (!viaUrlMatch && !viaOwnedUndefined) {
|
||||
throw new BootstrapError(
|
||||
'ORIGIN_EXISTS',
|
||||
`this workspace already has an \`origin\` remote (${originUrl}) that bootstrap did not create. ` +
|
||||
'`gbrain bootstrap repo` always creates a dedicated private repo. ' +
|
||||
`this workspace already has an \`origin\` remote (${originUrl}) that bootstrap can neither adopt nor claim. ` +
|
||||
'`gbrain bootstrap repo` creates a dedicated private repo, OR adopts an EMPTY private repo you created under your own account. ' +
|
||||
'If this is a clone of an existing agent workspace, run `gbrain bootstrap attach` instead.',
|
||||
{ details: { origin: originUrl } },
|
||||
);
|
||||
}
|
||||
// Idempotent re-run: adopt our own origin, re-verify privacy, re-record.
|
||||
const parsed = parseGithubRemote(originUrl);
|
||||
if (!parsed) {
|
||||
throw new BootstrapError(
|
||||
@@ -462,14 +655,33 @@ export async function createPrivateRepo(
|
||||
{ details: { origin: originUrl } },
|
||||
);
|
||||
}
|
||||
// Adopting a not-yet-recorded origin: it must be safe. Empty is always safe.
|
||||
// A NON-empty origin is safe only when it carries OUR interrupted push —
|
||||
// proven by `pending_repo_url` matching (a user's existing project has no
|
||||
// such marker). Everything else is refused (turns the old silent no-op into
|
||||
// a clear ORIGIN_NOT_EMPTY).
|
||||
if (viaOwnedUndefined) {
|
||||
const ours = sameWorkspace && receipt.pending_repo_url === originUrl;
|
||||
if (!ours) await assertAdoptableOrigin(runner, workspaceDir, parsed.owner, parsed.name);
|
||||
}
|
||||
// PRIVACY VERIFY [G8] — hard gate before any push.
|
||||
await verifyRepoPrivate(runner, parsed.owner, parsed.name);
|
||||
// Repo-local git identity so the (possibly first-ever) commit on a fresh
|
||||
// machine succeeds — this path used to skip it, breaking clone-then-adopt.
|
||||
await setRepoLocalIdentity(runner, workspaceDir, await resolveGhIdentity(runner));
|
||||
updateGithubMd(workspaceDir, originUrl);
|
||||
recordRepoInReceipt(gbrainHomeDir, workspaceDir, manifest, originUrl);
|
||||
// [FIX3] Don't report reused-success on an empty remote — a prior run may
|
||||
// have created the repo + set origin but failed to push. Verify the remote
|
||||
// actually has our branch; complete the (scan-gated) push if it doesn't.
|
||||
// Proof-of-intent BEFORE the push so a post-push/pre-record crash is
|
||||
// recognized as ours on re-run (see the `pending_repo_url` bypass above).
|
||||
recordPendingRepo(gbrainHomeDir, workspaceDir, manifest, originUrl);
|
||||
// [FIX3] Don't report success on an empty remote — a prior run may have set
|
||||
// origin but failed to push. Verify the remote has our branch; complete the
|
||||
// (scan-gated) push if it doesn't.
|
||||
await ensureRemoteHasWorkspace(runner, workspaceDir, parsed.owner, parsed.name);
|
||||
return { url: originUrl, name: parsed.name, reused: true };
|
||||
// Record repo_url AFTER a successful push (clears pending): recording before
|
||||
// push let a push failure look "done" to `bootstrap status` (repo_url
|
||||
// present), so a re-run skipped the repo phase and never pushed the workspace.
|
||||
recordRepoInReceipt(gbrainHomeDir, workspaceDir, manifest, originUrl);
|
||||
return { url: originUrl, name: parsed.name, disposition: viaUrlMatch ? 'reused' : 'adopted', reused: true };
|
||||
}
|
||||
|
||||
// Ensure a git repo exists (main branch on fresh init).
|
||||
@@ -482,26 +694,9 @@ export async function createPrivateRepo(
|
||||
}
|
||||
|
||||
// Repo-local identity from the authed gh user (never global git config).
|
||||
const userRes = await runner(['gh', 'api', 'user']);
|
||||
if (userRes.code !== 0) {
|
||||
throw new BootstrapError(
|
||||
'GH_AUTH',
|
||||
`could not read the authenticated GitHub user (gh api user failed: ${userRes.stderr.trim() || `exit ${userRes.code}`}) — check \`gh auth status\``,
|
||||
{ exitCode: 2 },
|
||||
);
|
||||
}
|
||||
let login: string;
|
||||
let userId: number | string;
|
||||
try {
|
||||
const parsed = JSON.parse(userRes.stdout) as { login?: unknown; id?: unknown };
|
||||
if (typeof parsed.login !== 'string' || parsed.login.length === 0) throw new Error('missing login');
|
||||
login = parsed.login;
|
||||
userId = typeof parsed.id === 'number' || typeof parsed.id === 'string' ? parsed.id : 0;
|
||||
} catch (e) {
|
||||
throw new BootstrapError('GH_AUTH', `unexpected \`gh api user\` output (${(e as Error).message})`, { exitCode: 2 });
|
||||
}
|
||||
await runner(['git', '-C', workspaceDir, 'config', 'user.name', login]);
|
||||
await runner(['git', '-C', workspaceDir, 'config', 'user.email', `${userId}+${login}@users.noreply.github.com`]);
|
||||
const ident = await resolveGhIdentity(runner);
|
||||
const login = ident.login;
|
||||
await setRepoLocalIdentity(runner, workspaceDir, ident);
|
||||
|
||||
// Name: slug(agent_name)-workspace, probed for availability, suffix -2..-100.
|
||||
// The probe is best-effort convenience; `gh repo create` remains the
|
||||
@@ -550,6 +745,10 @@ export async function createPrivateRepo(
|
||||
// verify → commit → push; the scan is never bypassed).
|
||||
await ensureWorkspaceCommit(runner, workspaceDir);
|
||||
|
||||
// Proof-of-intent before the push: if we crash after pushing but before
|
||||
// recording repo_url, a re-run recognizes the (now non-empty) repo as ours.
|
||||
recordPendingRepo(gbrainHomeDir, workspaceDir, manifest, url);
|
||||
|
||||
const branch = await currentBranch(runner, workspaceDir);
|
||||
// [FIX4] Bind the verified privacy result to the actual push target.
|
||||
await assertOriginMatches(runner, workspaceDir, login, name);
|
||||
@@ -564,5 +763,5 @@ export async function createPrivateRepo(
|
||||
|
||||
updateGithubMd(workspaceDir, url);
|
||||
recordRepoInReceipt(gbrainHomeDir, workspaceDir, manifest, url);
|
||||
return { url, name, reused: false };
|
||||
return { url, name, disposition: 'created', reused: false };
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ export const PHASES: PhaseSpec[] = [
|
||||
const repoUrl = (ctx.receipt as (InstallReceipt & { repo_url?: string }) | null)?.repo_url;
|
||||
if (repoUrl) return { state: 'done', detail: repoUrl };
|
||||
const origin = gitOriginUrl(ws);
|
||||
if (origin) return { state: 'partial', detail: `origin exists (${origin}) but no bootstrap receipt — a clone? see \`gbrain bootstrap attach\`` };
|
||||
if (origin) return { state: 'partial', detail: `origin exists (${origin}) but not yet pushed — run \`gbrain bootstrap repo\` (adopts an empty repo you created), or \`gbrain bootstrap attach\` for an existing agent clone` };
|
||||
return { state: 'pending' };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# gbrain agent workspace — template
|
||||
|
||||
<!-- gbrain-template-stamp: 0.45.1.0 -->
|
||||
<!-- gbrain-template-stamp: 0.45.2.0 -->
|
||||
|
||||
This repository is the **"Use this template"** distribution artifact for a
|
||||
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
|
||||
|
||||
@@ -391,6 +391,237 @@ describe('createPrivateRepo', () => {
|
||||
expect(err.exitCode).toBe(2);
|
||||
expect(err.message).toContain('gh auth login');
|
||||
});
|
||||
|
||||
// ── create-repo-first adoption (the human made the repo, opened it in Claude
|
||||
// Code / Codex, then ran bootstrap) + hardening of the adoption path ──────
|
||||
|
||||
/** The receipt `render` writes: this workspace, no repo_url yet. */
|
||||
function renderReceiptNoUrl(): void {
|
||||
writeReceipt(home, {
|
||||
receipt_version: 1,
|
||||
workspace_dir: ws,
|
||||
source_id: 'workspace',
|
||||
agent_name: 'Test Agent',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
created_by: '0.0.0-test',
|
||||
brain_created_by_bootstrap: false,
|
||||
created_paths: [],
|
||||
registrations: [],
|
||||
});
|
||||
}
|
||||
|
||||
test("create-repo-first: adopts an EMPTY private user-owned repo (disposition 'adopted', sets identity, pushes, records after push)", async () => {
|
||||
const url = 'https://github.com/alice/my-brain';
|
||||
renderReceiptNoUrl();
|
||||
const { runner, calls } = makeRunner(
|
||||
happyRules([
|
||||
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
||||
// assertAdoptableOrigin: all-refs ls-remote → empty (freshly created repo).
|
||||
{ key: 'ls-remote origin refs/heads/main', code: 0, stdout: '' },
|
||||
{ key: 'ls-remote origin', code: 0, stdout: '' },
|
||||
// ensureRemoteHasWorkspace: --heads empty → first push.
|
||||
{ key: 'ls-remote --heads origin', code: 0, stdout: '' },
|
||||
// Freshly rendered, uncommitted → stage + scan + commit, then push.
|
||||
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
||||
{ key: 'status --porcelain', code: 0, stdout: ' M GITHUB.md\n' },
|
||||
{ key: 'ls-files --cached --others', code: 0, stdout: 'GITHUB.md' },
|
||||
{ key: 'diff --cached --name-only', code: 0, stdout: 'GITHUB.md\n' },
|
||||
{ key: '--jq .private', code: 0, stdout: 'true\n' },
|
||||
]),
|
||||
);
|
||||
const result = await createPrivateRepo(ws, { runner, gbrainHomeDir: home });
|
||||
expect(result.disposition).toBe('adopted');
|
||||
expect(result.reused).toBe(true);
|
||||
expect(result.url).toBe(url);
|
||||
// Adopted the human's repo — never created one.
|
||||
expect(calls.some((c) => c.join(' ').includes('repo create'))).toBe(false);
|
||||
// Repo-local identity set on the ADOPTION path (fresh-machine commit safety).
|
||||
expect(calls).toContainEqual(['git', '-C', ws, 'config', 'user.name', 'alice']);
|
||||
expect(calls).toContainEqual(['git', '-C', ws, 'config', 'user.email', '123+alice@users.noreply.github.com']);
|
||||
// Workspace pushed, and repo_url recorded AFTER the push.
|
||||
expect(calls).toContainEqual(['git', '-C', ws, 'push', '-u', 'origin', 'main']);
|
||||
expect((readReceipt(home) as RepoReceipt).repo_url).toBe(url);
|
||||
});
|
||||
|
||||
test('[CRITICAL] create-repo-first pointed at a NON-empty repo → ORIGIN_NOT_EMPTY, never a silent no-op', async () => {
|
||||
const url = 'https://github.com/alice/existing-project';
|
||||
renderReceiptNoUrl();
|
||||
const { runner, calls } = makeRunner(
|
||||
happyRules([
|
||||
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
||||
{ key: 'ls-remote origin refs/heads/main', code: 0, stdout: '' },
|
||||
// Non-empty remote (foreign content) + no local commit → foreign, refuse.
|
||||
{ key: 'ls-remote origin', code: 0, stdout: 'cafe1234\trefs/heads/main\n' },
|
||||
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
||||
]),
|
||||
);
|
||||
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
||||
expect(err.code).toBe('ORIGIN_NOT_EMPTY');
|
||||
expect(err.message).toContain('EMPTY');
|
||||
// No silent no-op: nothing pushed, repo_url never recorded.
|
||||
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
||||
expect((readReceipt(home) as RepoReceipt).repo_url).toBeUndefined();
|
||||
});
|
||||
|
||||
test('[CRITICAL] non-empty repo with NO pending marker → ORIGIN_NOT_EMPTY (never adopt a user project from a git-ancestry guess)', async () => {
|
||||
// Even if the remote HEAD looks like ours, without a pending_repo_url proof
|
||||
// we cannot distinguish our push from a user's existing project → refuse.
|
||||
const url = 'https://github.com/alice/existing-project';
|
||||
renderReceiptNoUrl();
|
||||
const { runner, calls } = makeRunner(
|
||||
happyRules([
|
||||
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
||||
{ key: 'ls-remote origin', code: 0, stdout: 'abc123\trefs/heads/main\n' },
|
||||
]),
|
||||
);
|
||||
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
||||
expect(err.code).toBe('ORIGIN_NOT_EMPTY');
|
||||
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
||||
expect((readReceipt(home) as RepoReceipt).repo_url).toBeUndefined();
|
||||
});
|
||||
|
||||
test('interrupted push recovery: non-empty remote matching pending_repo_url → adopts (resumes)', async () => {
|
||||
const url = 'https://github.com/alice/my-brain';
|
||||
// A prior run pushed but crashed before recording repo_url — pending proves ours.
|
||||
writeReceipt(home, {
|
||||
receipt_version: 1,
|
||||
workspace_dir: ws,
|
||||
source_id: 'workspace',
|
||||
agent_name: 'Test Agent',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
created_by: '0.0.0-test',
|
||||
brain_created_by_bootstrap: false,
|
||||
created_paths: [],
|
||||
registrations: [],
|
||||
pending_repo_url: url,
|
||||
} as RepoReceipt);
|
||||
const { runner, calls } = makeRunner(
|
||||
happyRules([
|
||||
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
||||
// Remote already carries our branch (the interrupted push landed) → no re-push.
|
||||
{ key: 'ls-remote --heads origin', code: 0, stdout: 'abc123\trefs/heads/main\n' },
|
||||
{ key: '--jq .private', code: 0, stdout: 'true\n' },
|
||||
]),
|
||||
);
|
||||
const result = await createPrivateRepo(ws, { runner, gbrainHomeDir: home });
|
||||
expect(result.disposition).toBe('adopted');
|
||||
// pending bypassed the emptiness check — never probed all-refs ls-remote.
|
||||
expect(calls.some((c) => c.join(' ') === `git -C ${ws} ls-remote origin`)).toBe(false);
|
||||
const receipt = readReceipt(home) as RepoReceipt;
|
||||
expect(receipt.repo_url).toBe(url);
|
||||
expect(receipt.pending_repo_url).toBeUndefined(); // cleared on record
|
||||
});
|
||||
|
||||
test('create-repo-first under an ORG (owner != login) → ORIGIN_EXISTS (personal-account only, D2=A)', async () => {
|
||||
const url = 'https://github.com/acme-org/brain';
|
||||
renderReceiptNoUrl();
|
||||
const { runner, calls } = makeRunner(
|
||||
happyRules([{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` }]),
|
||||
);
|
||||
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
||||
expect(err.code).toBe('ORIGIN_EXISTS');
|
||||
// Ownership fails first — never even probes the remote for emptiness.
|
||||
expect(calls.some((c) => c.join(' ') === `git -C ${ws} ls-remote origin`)).toBe(false);
|
||||
});
|
||||
|
||||
test('create-repo-first pointed at a PUBLIC repo → REPO_NOT_PRIVATE', async () => {
|
||||
const url = 'https://github.com/alice/public-brain';
|
||||
renderReceiptNoUrl();
|
||||
const { runner } = makeRunner(
|
||||
happyRules([
|
||||
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
||||
{ key: 'ls-remote origin', code: 0, stdout: '' }, // empty → adoptable
|
||||
{ key: '--jq .private', code: 0, stdout: 'false\n' },
|
||||
]),
|
||||
);
|
||||
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
||||
expect(err.code).toBe('REPO_NOT_PRIVATE');
|
||||
});
|
||||
|
||||
test("create-repo-first when the origin can't be listed → REMOTE_CHECK_FAILED, nothing pushed", async () => {
|
||||
const url = 'https://github.com/alice/my-brain';
|
||||
renderReceiptNoUrl();
|
||||
const { runner, calls } = makeRunner(
|
||||
happyRules([
|
||||
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
||||
{ key: 'ls-remote origin', code: 1, stderr: 'fatal: could not read from remote repository' },
|
||||
]),
|
||||
);
|
||||
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
||||
expect(err.code).toBe('REMOTE_CHECK_FAILED');
|
||||
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
||||
});
|
||||
|
||||
test('adoption push fails → repo_url NOT recorded (status stays resumable) [finding 3]', async () => {
|
||||
const url = 'https://github.com/alice/my-brain';
|
||||
renderReceiptNoUrl();
|
||||
const { runner } = makeRunner(
|
||||
happyRules([
|
||||
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
||||
{ key: 'ls-remote origin', code: 0, stdout: '' },
|
||||
{ key: 'ls-remote --heads origin', code: 0, stdout: '' },
|
||||
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
||||
{ key: 'status --porcelain', code: 0, stdout: ' M GITHUB.md\n' },
|
||||
{ key: 'ls-files --cached --others', code: 0, stdout: 'GITHUB.md' },
|
||||
{ key: 'diff --cached --name-only', code: 0, stdout: 'GITHUB.md\n' },
|
||||
{ key: '--jq .private', code: 0, stdout: 'true\n' },
|
||||
{ key: 'push -u origin', code: 1, stderr: 'fatal: unable to access' },
|
||||
]),
|
||||
);
|
||||
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
||||
expect(err.code).toBe('REPO_CREATE_FAILED');
|
||||
// repo_url must NOT be recorded on push failure (else status false-reports done).
|
||||
expect((readReceipt(home) as RepoReceipt).repo_url).toBeUndefined();
|
||||
});
|
||||
|
||||
test('adoption secret-scans the workspace before pushing (SECRET_SCAN_BLOCKED) [finding 4]', async () => {
|
||||
const url = 'https://github.com/alice/my-brain';
|
||||
renderReceiptNoUrl();
|
||||
writeFileSync(join(ws, 'leak.md'), `token: sk-${'A1b2C3d4E5f6G7h8I9j0K1l2M3n4'}\n`, 'utf8');
|
||||
const { runner, calls } = makeRunner(
|
||||
happyRules([
|
||||
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
||||
{ key: 'ls-remote origin', code: 0, stdout: '' },
|
||||
{ key: 'ls-remote --heads origin', code: 0, stdout: '' },
|
||||
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
||||
{ key: 'status --porcelain', code: 0, stdout: '?? leak.md\n' },
|
||||
{ key: 'ls-files --cached --others', code: 0, stdout: 'leak.md' },
|
||||
{ key: '--jq .private', code: 0, stdout: 'true\n' },
|
||||
]),
|
||||
);
|
||||
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
||||
expect(err.code).toBe('SECRET_SCAN_BLOCKED');
|
||||
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
||||
});
|
||||
|
||||
test('[finding 4] a CLEAN committed tree is still secret-scanned before the deferred push', async () => {
|
||||
const url = 'https://github.com/alice/test-agent-workspace-2';
|
||||
writeReceipt(home, {
|
||||
receipt_version: 1,
|
||||
workspace_dir: ws,
|
||||
source_id: 'workspace',
|
||||
agent_name: 'Test Agent',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
created_by: '0.0.0-test',
|
||||
brain_created_by_bootstrap: false,
|
||||
created_paths: [],
|
||||
registrations: [],
|
||||
repo_url: url,
|
||||
} as RepoReceipt);
|
||||
writeFileSync(join(ws, 'secrets.md'), `token: sk-${'A1b2C3d4E5f6G7h8I9j0K1l2M3n4'}\n`, 'utf8');
|
||||
const { runner, calls } = makeRunner(
|
||||
happyRules([
|
||||
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
||||
{ key: 'ls-remote --heads origin', code: 0, stdout: '' }, // empty → deferred-push path
|
||||
{ key: 'rev-parse --verify HEAD', code: 0, stdout: 'abc\n' }, // has a commit
|
||||
{ key: 'status --porcelain', code: 0, stdout: '' }, // clean tree
|
||||
{ key: 'ls-files -z', code: 0, stdout: 'secrets.md' }, // committed tree to scan
|
||||
]),
|
||||
);
|
||||
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
||||
expect(err.code).toBe('SECRET_SCAN_BLOCKED');
|
||||
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,12 +12,15 @@
|
||||
* loosen in CI only with evidence of runner noise). The protocol DOC
|
||||
* promises this number; the bound is op-layer latency (transport
|
||||
* excluded, as documented).
|
||||
* 2. RATIO GUARD (machine-independent) — entity p99 ≤ 50× max(getPage p50,
|
||||
* 2. RATIO GUARD (machine-independent) — entity p99 ≤ 100× max(getPage p50,
|
||||
* 1ms) on the same corpus. Calibration: the card is ~7 indexed reads +
|
||||
* a keyword search on the miss path, measured ~21× a 1ms-floored
|
||||
* getPage at 20K pages — an O(N) scan regression lands at 200ms+
|
||||
* (≥200×), far past the ceiling even on a slow runner, while the
|
||||
* 2.4× headroom absorbs planner noise.
|
||||
* a keyword search on the miss path. It measures ~21× a getPage p50 of
|
||||
* ~2.5ms, but on a fast runner getPage p50 floors to 1ms and normal
|
||||
* entity p99 (~50ms) reads as ~50×. An O(N) scan regression lands at
|
||||
* 200ms+ (≥200×), far past the ceiling even on a slow runner. The ceiling
|
||||
* is 100× (not 50×) so the guard is never STRICTER than the 100ms absolute
|
||||
* budget when getPage floors to 1ms — the earlier 50× tripped on fast
|
||||
* runners (a p99 tail ÷ a sub-ms median) while p99 stayed well under budget.
|
||||
*
|
||||
* The 200K-page validation is a documented MANUAL recipe in
|
||||
* docs/protocol/MEMORY_VERBS_v1.md — not CI-gated (seed time would dominate).
|
||||
@@ -40,8 +43,12 @@ const MEASURED = 200;
|
||||
const TARGET_ENTITIES = 50; // pages the measured calls rotate over
|
||||
|
||||
const P99_BUDGET_MS = 100 * (Number(process.env.GBRAIN_PERF_BUDGET_MULTIPLIER) || 1);
|
||||
// entity p99 ≤ 50× max(getPage p50, 1ms) — see the calibration note above.
|
||||
const RATIO_CEILING = 50;
|
||||
// entity p99 ≤ 100× max(getPage p50, 1ms) — see the calibration note above.
|
||||
// (100×, not 50×: at the 1ms getPage floor, 50× would cap p99 at 50ms — stricter
|
||||
// than the 100ms absolute budget — and tripped on fast runners where a p99 tail
|
||||
// is divided by a sub-ms getPage median. 100× stays far below the ≥200× O(N)
|
||||
// regression signal.)
|
||||
const RATIO_CEILING = 100;
|
||||
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
type TurnContextRequest,
|
||||
} from '../src/core/context/resolve-ipc.ts';
|
||||
import { CLAUDE_HOOK_OUTPUT_CAP_CHARS } from '../src/core/bootstrap/host-specs.ts';
|
||||
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;
|
||||
@@ -756,6 +758,34 @@ const INITIALIZED_MANIFEST = {
|
||||
source_id: 'workspace',
|
||||
};
|
||||
|
||||
/** Simulate a COMPLETED repo phase: a receipt for this workspace carrying a
|
||||
* recorded repo_url. Without this the no-daemon push is deferred (a
|
||||
* create-repo-first install must not push to an unverified-privacy origin). */
|
||||
function markRepoPhaseComplete(repo: string): void {
|
||||
const toplevel = execFileSync('git', ['-C', repo, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
|
||||
const repoUrl = 'https://github.com/alice/boot-repo';
|
||||
// The push gate binds to the recorded repo: origin must resolve to repo_url.
|
||||
try {
|
||||
execFileSync('git', ['-C', repo, 'remote', 'remove', 'origin'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
/* no origin yet */
|
||||
}
|
||||
execFileSync('git', ['-C', repo, 'remote', 'add', 'origin', repoUrl]);
|
||||
mkdirSync(join(home(), 'bootstrap'), { recursive: true });
|
||||
writeReceipt(home(), {
|
||||
receipt_version: 1,
|
||||
workspace_dir: toplevel,
|
||||
source_id: 'workspace',
|
||||
agent_name: 'test-agent',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
created_by: 'test',
|
||||
brain_created_by_bootstrap: false,
|
||||
created_paths: [],
|
||||
registrations: [],
|
||||
repo_url: repoUrl,
|
||||
} as RepoReceipt);
|
||||
}
|
||||
|
||||
describe('bootstrap push gate [G4]', () => {
|
||||
test('git repo + dirty tree + NO agent.json: session-start and session-end never spawn a push, repo untouched', async () => {
|
||||
const repo = join(tmp, 'plain-repo');
|
||||
@@ -809,6 +839,7 @@ describe('bootstrap push gate [G4]', () => {
|
||||
const repo = join(tmp, 'boot-repo');
|
||||
initGitRepoWithDirtyTree(repo);
|
||||
writeFileSync(join(repo, 'agent.json'), JSON.stringify(INITIALIZED_MANIFEST, null, 2) + '\n');
|
||||
markRepoPhaseComplete(repo); // repo phase done → push is allowed
|
||||
const spawned: string[] = [];
|
||||
|
||||
const out = collectStdout();
|
||||
@@ -834,6 +865,7 @@ describe('bootstrap push gate [G4]', () => {
|
||||
const repo = join(tmp, 'boot-repo-end');
|
||||
initGitRepoWithDirtyTree(repo);
|
||||
writeFileSync(join(repo, 'agent.json'), JSON.stringify(INITIALIZED_MANIFEST, null, 2) + '\n');
|
||||
markRepoPhaseComplete(repo); // repo phase done → push is allowed
|
||||
const spawned: string[] = [];
|
||||
await runHook(['session-end'], {
|
||||
write: () => {},
|
||||
@@ -842,6 +874,36 @@ describe('bootstrap push gate [G4]', () => {
|
||||
});
|
||||
expect(spawned).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('create-repo-first BEFORE the repo phase (no repo_url yet): session-start defers the push, never publishes to an unverified origin', async () => {
|
||||
const repo = join(tmp, 'boot-repo-pending');
|
||||
initGitRepoWithDirtyTree(repo);
|
||||
writeFileSync(join(repo, 'agent.json'), JSON.stringify(INITIALIZED_MANIFEST, null, 2) + '\n');
|
||||
// NB: no markRepoPhaseComplete — the repo phase has not run yet.
|
||||
const spawned: string[] = [];
|
||||
const out = collectStdout();
|
||||
await runHook(['session-start'], {
|
||||
...out.io,
|
||||
spawnPush: (root: string) => { spawned.push(root); },
|
||||
stdin: '',
|
||||
cwd: repo,
|
||||
});
|
||||
expect(spawned).toEqual([]); // deferred, not spawned
|
||||
expect((await lastHeartbeat())?.reason).toBe('push_deferred_repo_pending');
|
||||
});
|
||||
|
||||
test('create-repo-first BEFORE the repo phase (no repo_url yet): session-end defers the backstop push', async () => {
|
||||
const repo = join(tmp, 'boot-repo-pending-end');
|
||||
initGitRepoWithDirtyTree(repo);
|
||||
writeFileSync(join(repo, 'agent.json'), JSON.stringify(INITIALIZED_MANIFEST, null, 2) + '\n');
|
||||
const spawned: string[] = [];
|
||||
await runHook(['session-end'], {
|
||||
write: () => {},
|
||||
spawnPush: (root: string) => { spawned.push(root); },
|
||||
stdin: JSON.stringify({ session_id: 'sess-boot-end-pending', cwd: repo }),
|
||||
});
|
||||
expect(spawned).toEqual([]); // deferred until `gbrain bootstrap repo`
|
||||
});
|
||||
});
|
||||
|
||||
// ── user-prompt deadline degradation [D5/ENG-1] ─────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user