From a996e42856cc8b25c3a087b0c6671cd629d9b4ea Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 11 Aug 2026 23:01:22 -0700 Subject: [PATCH] v0.45.2.0 fix(bootstrap): create-repo-first repo adoption + hardening (#4024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) --- .github/workflows/test.yml | 33 +- BOOTSTRAP_FOR_AGENTS.md | 17 +- CHANGELOG.md | 18 ++ README.md | 8 +- TODOS.md | 13 + VERSION | 2 +- docs/architecture/KEY_FILES.md | 2 +- docs/designs/AGENT_BOOTSTRAP_PLAN.md | 9 +- docs/guides/bootstrap.md | 25 +- docs/mcp/CLAUDE_CODE.md | 2 + llms-full.txt | 8 +- package.json | 2 +- src/commands/bootstrap.ts | 13 +- src/commands/hook.ts | 62 +++- src/core/bootstrap/lock.ts | 10 +- src/core/bootstrap/repo.ts | 315 ++++++++++++++++---- src/core/bootstrap/status.ts | 2 +- templates/bootstrap/template-repo/README.md | 2 +- test/bootstrap-repo.test.ts | 231 ++++++++++++++ test/entity-card-perf.slow.test.ts | 21 +- test/hook-command.serial.test.ts | 62 ++++ 21 files changed, 764 insertions(+), 93 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f94d866c..6c32596d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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. diff --git a/BOOTSTRAP_FOR_AGENTS.md b/BOOTSTRAP_FOR_AGENTS.md index cc2adeaa5..b94c2897d 100644 --- a/BOOTSTRAP_FOR_AGENTS.md +++ b/BOOTSTRAP_FOR_AGENTS.md @@ -1,4 +1,4 @@ - + @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5594d94fa..373a69866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index ab8e108df..ef1a2c145 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/TODOS.md b/TODOS.md index 9f35066a1..6e4c6ddfc 100644 --- a/TODOS.md +++ b/TODOS.md @@ -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`, diff --git a/VERSION b/VERSION index 7ffa77d32..5f1db1c23 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.1.0 +0.45.2.0 diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index cf193085d..86e961a59 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -500,7 +500,7 @@ User-facing contract: `docs/guides/bootstrap.md`. Runbook the paste block fetche - `src/core/bootstrap/interview.ts` — interview state at `/state/interview.json` (committed; multi-device re-render source). Read-back confirm hash: `--confirm` must present the hash of the exact answer set shown to the human, and ANY later answer change clears the confirmation — the single-batch self-confirm attack is structurally impossible. Set-time enforcement: length caps, reject-lists, allowed-lists, control-char strip, `{{` escaping. Conflict-markered files return agent-readable errors, not stack traces. - `src/core/bootstrap/render.ts` — token substitution with interview values treated as data (line-leading `#`/` + This repository is the **"Use this template"** distribution artifact for a [gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same diff --git a/test/bootstrap-repo.test.ts b/test/bootstrap-repo.test.ts index 305463acd..f67e88e4d 100644 --- a/test/bootstrap-repo.test.ts +++ b/test/bootstrap-repo.test.ts @@ -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); + }); }); // --------------------------------------------------------------------------- diff --git a/test/entity-card-perf.slow.test.ts b/test/entity-card-perf.slow.test.ts index 831317be1..928bf1647 100644 --- a/test/entity-card-perf.slow.test.ts +++ b/test/entity-card-perf.slow.test.ts @@ -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); diff --git a/test/hook-command.serial.test.ts b/test/hook-command.serial.test.ts index 905f9652e..97499d112 100644 --- a/test/hook-command.serial.test.ts +++ b/test/hook-command.serial.test.ts @@ -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] ─────────────────────────────