Compare commits

...
1 Commits
Author SHA1 Message Date
Garry TanandClaude Fable 5 dd99e40c2a v0.45.14.0 feat(bootstrap): harness mode — wire framework-spawned agents to a running serve (#4043) (#4106)
* feat(bootstrap): harness-lane settings writers — marker/path params, permissions.allow, CODEX_HOME (#4043 step 1)

writeClaudeHooksAt/removeClaudeHooksAt with marker VALUE parameterization
(bootstrap-v1 and bootstrap-harness-v1 coexist; each removal strips only its
own), onBrokenJson relocate|abort policy (user-scope files must never be
relocated over a stray comment), refuseOnForeignGbrainMarker double-fire
guard, and addPermissionsAllowEntry/removePermissionsAllowEntry (set
semantics, no marker, foreign entries preserved). Atomic writes hardened:
realpath-resolved targets (dotfile symlinks survive), mode preservation,
random tmp suffix, timestamped backup strategy. codexConfigPath now honors
CODEX_HOME (config dir itself — pinned by the real-codex e2e convention).
Legacy wrappers keep byte-identical behavior; existing writer suite untouched
and green.

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

* feat(bootstrap): codex-toml managed block writer — the fired CX2-17 revisit (#4043 step 2)

One [mcp_servers.<name>] table with inline bearer_token between full-line
markers; everything outside survives byte-for-byte. Foreign-server detection
parses the config (Bun.TOML.parse, no new dependency) with our block
stripped, so inline-table/dotted/quoted spellings can't false-negative into
a codex-bricking duplicate table. Rewrites re-anchor at EOF; renders are
parse-validated with an ours-keys-exactly assert before rename; damaged
markers refuse. Secrets hygiene: 0600 tmp/target/.bak, group-readable
configs tightened with a note. CRLF preserved, missing trailing newline
repaired. TARGETS['codex-2026-08'] flipped to verified (codex-cli 0.147.0:
serde field scan; codex hooks existence recorded; CODEX_HOME resolution).

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

* feat(auth): least-privilege legacy tokens — adopt the scopes TEXT[] column (#4043 step 3)

The dormant original-schema access_tokens.scopes column becomes THE scope
store: verifyAccessToken's legacy branch honors it (NULL = grandfathered
full access, so every existing token is byte-identical; a filtered-empty
array is deny, so typos fail closed), and a column is structurally immune
to the permissions-object-replacement wipe class. That class gets fixed at
its known site too: auth permissions set-takes-holders now MERGES into the
permissions JSONB instead of replacing it (a routine visibility edit would
have silently deleted the source_id federation grant and re-escalated).

New surface: gbrain auth create --scopes read,write (comma/whitespace,
mint-time validation); auth list shows id + scopes columns (grandfathered
rendered honestly); auth revoke --id <uuid> for precise revocation (names
are not unique — bulk revoke-by-name now says when it hit several). New
src/core/token-mint.ts (mintLegacyToken with federation source grant +
RETURNING id; revokeLegacyTokenById never touches same-name siblings) for
the harness rotation contract. The admin dashboard's agents endpoint stops
hardcoding full access for every legacy key and reads the real grant.

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

* feat(bootstrap): harness receipt + mcp-registration core extraction (#4043 step 4)

HarnessReceipt is a machine-level sibling of receipt.json (the install
receipt is workspace-keyed; a harness-only box has no workspace) with the
same CX2-12 discipline: typed read states, newer-format refusal, broken-file
backup-aside, atomic 0600 writes. Write-ahead contract: targets persist as
pending at mint time and confirm as wiring lands, and token.previous_id
carries the prior token through the mint-first rotation, so a crash at any
step leaves a receipt --remove can consume.

The pure MCP-registration helpers (normalizeMcpUrl, argv builders,
redactToken, validateToken, shellQuote/cmdString) move from
src/commands/connect.ts to src/core/mcp-registration.ts — the harness lane
lives in core and core must not import from commands. connect.ts re-exports
(surface + tests unchanged). buildClaudeMcpAddArgv gains an optional scope
param (claude's default is local; harness must pass user); loopback helper
exported for the harness --url guard.

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

* feat(bootstrap): `gbrain bootstrap harness` — wire framework-spawned sessions to a running serve (#4043 step 5)

The orchestrator (src/core/bootstrap/harness.ts): consent block in the
#4029 honesty register (reach stated as fact, transcript capture its own
numbered item, off-ramps in the same breath; non-TTY requires --yes),
/health probe with a loopback guard (remote brains are gbrain connect's
charter), mint-first rotation (previous token revoked BY ID only after
every target confirms and the smoke passes), write-ahead harness receipt
(crash at any step leaves consumable state), registration ownership checks
(--force to replace a foreign-url server; --remove skips what it no longer
owns), user-XOR-project hook scopes with the double-fire refusal, the
GBRAIN_HOOK_LANE=harness runtime defer guard in `gbrain hook` (workspace
bootstrap installs win), --no-capture context-only wiring, Postgres
degradation + version-skew honesty lines, --status with host-config token
recovery and honest degrades, and engine-free-first --remove that defers
the revoke under a live PGLite serve.

Dispatcher wiring: `bootstrap harness` subcommand (home-dir lock, own
install-log phase), uninstall runs harness removal FIRST (revoke needs the
DB alive; --delete-brain would destroy harness.json) and treats
NO_RECEIPT/HOME_GUARD/RECEIPT_MISMATCH as "no workspace install" once
harness wiring is cleared; runHooks' codex stdio lane defers to a
harness-managed server name (one owner per name); the stale "Codex has no
hook system" line now states the truth. Flag registry regenerated.

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

* feat(bootstrap): harness doctor check + lifecycle e2e; CLAUDE_CONFIG_DIR-safe user-settings path (#4043 step 6)

Doctor gains bootstrap_harness_health inside the existing bootstrap check
group (no new status phase): skip when not a harness box / warn when the
serve is down (a normal transient) or the receipt is unreadable / fail when
targets are failed-or-pending or a rotation never converged — and a
harness-only box now opens the bootstrap check gate at all (it previously
got ZERO checks).

E2E lifecycle against a real `serve --http` on a hermetic PGLite brain:
pre-minted scoped token (the documented PGLite escape), real /health +
bearer smoke, both harness lanes wired, --status with token recovery from
the codex block, --remove leaving the codex config byte-identical, the
mint-under-live-serve refusal, and a live insufficient_scope refusal of an
admin op — least privilege proven end to end.

Root-cause fix the e2e caught: Bun's homedir() reads the password database
and ignores a remapped HOME, so claudeUserSettingsPath now resolves via
CLAUDE_CONFIG_DIR (Claude Code's own override) then $HOME explicitly —
without it, sandboxed runs write into the operator's REAL settings file
(the write-ahead receipt's remove path self-healed the one incident).

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

* docs(bootstrap): v0.45.9.0 — local harness mode docs, scoped-token honesty pins, TODOS follow-ups (#4043 step 7)

docs/guides/bootstrap.md gains the "Local harness mode" section + the
missing Postgres row in the degradation matrix; DEPLOY.md/CODEX.md stop
claiming the token grandfather is unconditional and distinguish the connect
lane (token in env) from the harness lane (inline, 0600, consented);
KEY_FILES.md bootstrap cluster describes the parameterized writers,
codex-toml.ts, harness.ts, token-mint.ts, and mcp-registration.ts in
current-state voice; RESOLVER.md routes "wire this box's coding agents"
to bootstrap harness; setup skill points at it. Seven follow-ups filed in
TODOS.md (serve port record, http-transport scope asymmetry, unique token
names, codex hook lane, PGLite admin-lane minting, OpenClaw setup hook —
self-demoted: plugin installs run with lifecycle scripts disabled and the
manifest has no setup field — and federated-drift visibility). Doctor's
harness messages spell flags without leading dashes (the flag-registry
prose-bleed class; registry stays fresh). VERSION/package.json/CHANGELOG/
openclaw.plugin.json → 0.45.9.0 (0.45.8.0 is claimed by an open PR);
template stamp + runbook stamp refreshed; llms bundles rebuilt.

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

* fix(doctor): categorize bootstrap_harness_health + de-flag harness message prose (#4043 triage)

The full-suite triage against a pristine-master baseline surfaced the two
in-branch failures: the doctor-categories drift guard (new check name not
in OPS_CHECK_NAMES) and the flag-registry freshness guard (doctor's harness
messages carried bare id/http flag tokens, which the generator harvests
into every importing command's allowlist — the known prose-bleed class;
messages now spell flags without leading dashes). Every other failing file
(18) fails identically on master with this environment — pre-existing,
not this wave's.

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

* test(hook): pin the harness-lane yield guard + honor the io.cwd seam (#4043 eng review E5)

The GBRAIN_HOOK_LANE=harness defer guard (workspace bootstrap installs win
over user-scope harness wiring — the C6 double-fire defense) was the one
new branch with no direct test. Two serial cases now pin it: lane +
bootstrap-v1 markers in the cwd → every event yields silently (exit 0, no
output, no heartbeat); lane without markers — including a harness-marker-
only settings file — runs normally and heartbeats. The guard now resolves
the cwd through the same io.cwd test seam the handlers use.

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

* fix(bootstrap): harness convergence + rotation hardening — eng-review outside-voice X-batch (#4043)

Fifteen codex outside-voice findings, thirteen accepted and fixed, one
accepted as an advisory line, one kept as the settled PGLite deferral:

- X1: explicit --harness codex FORCES wiring (the TOML writer needs no
  codex CLI — the exact no-CLI box the issue filed); detection heuristics
  gate only the `all` default.
- X2: --source now reaches the mint as a scalar write-floor grant; it was
  written to the receipt and hook env but never scoped the token.
- X3: re-runs converge — writeClaudeHooksAt strips our marker across ALL
  events before wiring the requested subset (--no-capture now unwires
  Stop/SessionEnd), and apply unwires prior-receipt targets the new plan
  drops (changed --project sets no longer strand live wiring).
- X4: token.previous_ids is an array — a failed rotation accumulates every
  unrevoked id and the next converge (or --remove) revokes them ALL; the
  --token lane carries them too.
- X5: real rollback — the previous claude registration (url + bearer from
  mcp get) is restored on add-failure or failed smoke, and the codex .bak
  is restored on failed smoke, so "old clients keep working" is true in
  the registration sense, not just the token sense.
- X6: the receipt guard + write-ahead write now precede the mint — a crash
  or newer-format refusal can no longer strand an unrecorded live token.
- X7: consent copy tells the truth — supplied tokens are "written only
  into the host registrations", and the reach paragraph matches the actual
  harness/hook/capture selection.
- X8: a pre-existing permissions.allow entry is recorded as pre-existing
  and never deleted by remove.
- X9: codex wiring prints the experimental_use_rmcp_client advisory.
- X10: an unknown-tool tool_error counts as verified (auth + dispatch
  succeeded) — a --surface verbs serve is no longer declared broken by
  smoke or --status.
- X11: user-scope writes run under a config-dir lock and fresh files are
  created 0600.
- X12: --status is genuinely read-only (no home mkdir, no lock).
- X13: registrar mode (non-loopback --url + --token) wires MCP only —
  hooks talk to the LOCAL brain and would split-brain the box; the http-
  bearer warning is no longer discarded.
- X14: flag parsing fails closed (missing values, --url+--port,
  --status+--remove all error instead of resolving by precedence).

12 new serial cases pin the batch; 463 wave tests green incl. the live e2e.

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

* fix(harness): ship-review hardening — pre-landing review army + coverage/plan audits (#4043)

Review fixes (6 specialists + coverage + plan-completion audits at ship):
- Smoke-fail rollback symmetry: a FRESH claude registration is removed on a
  failed smoke (previously only replacements were restored); an unrecoverable
  replacement fails the target honestly instead of staying green.
- [X14] --project with a missing/flag-like value errors instead of silently
  widening hook wiring to user scope; auth create --scopes/--takes-holders
  missing values error instead of minting a grandfathered full-access token.
- normalizeTokenScopes fails CLOSED on representation drift: only never-written
  NULL grandfathers; undecoded '{a,b}' array-literal strings parse; any other
  non-null shape denies.
- set-takes-holders merge guards the left operand with jsonb_typeof so
  historically damaged (scalar/array) permissions rows repair on edit instead
  of compounding into a jsonb array; e2e updates pin the REAL auth.ts SQL
  shape + the source_id-survives-merge regression.
- codexBlockOwnsName scopes the name check to INSIDE the managed block and
  shares the writer's marker constants (parseCodexBlockBearer too).
- Refuse-rather-than-guess on unverifiable URLs: --remove and stale-target
  cleanup skip claude registrations whose URL cannot be parsed.
- [X11] parity: codex config.toml writes/removes serialize under a lock on
  the config's own dir; runUninstall takes the HOME lock around harness
  removal (same key as runHarness).
- [D12] the harness-lane hook yield guard also honors the committed
  .claude/settings.json carrier — checking only settings.local.json would
  double-fire events owned by the committed carrier.
- token-mint uses isUndefinedColumnError (message-shaped variants included);
  TOKEN_ID_RE shared with the auth revoke --id CLI gate.
- Stale 'Codex has no hook system' copy in the real-codex e2e + bootstrap
  guide updated to the honest 'gbrain does not wire Codex hooks yet'.
- New tests: runUninstall harness-first composition (harness-only box +
  abort-before-teardown), DATABASE_URL-gated mintLegacyToken Postgres parity,
  renderTokenScopes, isServeOlderThanScopes matrix, codexBlockOwnsName,
  fresh/unrecoverable smoke-fail rollback, committed-carrier yield.
- TODOS: smoke identity-verification hardening, lock.ts message polish,
  auth-create/doctor dedupe follow-ups; docs: binary-downgrade scoping note.

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

* fix(harness): adversarial-review hardening — codex + red-team convergence at ship (#4043)

Cross-model adversarial passes (Codex exec + red-team subagent) on top of the
review-army batch; three-reviewer convergence upgraded the loopback-impostor
class from TODO to fixed:

- CANARY before the smoke: a random same-format bearer must FAIL auth before
  the real token is verified — an impostor squatting the loopback port cannot
  distinguish the canary from the real token, so it is caught whichever way
  it answers; on ANY failed smoke the fresh mint is revoked immediately
  (nothing live is ever left with an unverified endpoint).
- Pre-approval integrity: the permissions.allow entry is gated on the MCP
  registration actually landing (a failed/ownership-refused registration must
  not bless a foreign server) and is rolled back with a failed smoke.
- --status recovers a bearer ONLY from a registration whose URL matches the
  receipt ([C8] everywhere) — never transmits another install's credential.
- Half-removed receipts (zero targets, minted token awaiting deferred revoke)
  FAIL doctor + exit 1 from --status instead of reading vacuously green;
  --status also exits 1 on failed/pending targets and unconverged rotations.
- [X3] stale-target cleanup deferred until AFTER the smoke passes (mint-first
  applies to removals too: a mint/lock failure no longer strands a box that
  had working wiring); stale-remove exit codes checked, not assumed.
- oauth-provider's pre-v38 fallback SELECT keeps the ORIGINAL-schema scopes
  column — a failed permissions projection no longer grandfathers scoped
  tokens to full admin.
- Hook yield guard PARSES settings and requires a live bootstrap-v1 entry for
  THIS event (both carriers) — a repo committing marker-lookalike strings can
  no longer disable the machine-wide capture lane, and unwired events run.
- permissions writers fail closed on policy shapes they don't understand;
  auth list renders through the SAME normalizer the verify path uses;
  isServeOlderThanScopes pins the first scope-aware release (no cry-wolf on
  the next CLI bump); harness receipts shape-validate before consumers
  dereference; rollback bearers are validated before re-registration.
- [X11] lock parity on every remaining path: removeHarness host-removals,
  stale cleanup, codex rollback; runUninstall holds the HOME lock across the
  whole teardown (no mint window between harness removal and rm of
  <home>/bootstrap); --project hook writes carry the [D12] committed-carrier
  events; cross-home user-scope-vs-project double-fire refused where knowable.
- applyHarness --json emits ONLY the JSON document on stdout (prose → stderr).
- Registry prose-bleed swept (a comment's literal typo'd flag would have made
  the typo VALID); TODOS updated: canary+fresh-revoke landed, identity
  comparison + orphan-mint reconciliation filed as residuals.

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

* docs(release): sync harness docs with the shipped adversarial-review behavior (v0.45.14.0)

/document-release drift pass against the final #4043 diff:
- KEY_FILES.md: drop the stale duplicate src/commands/hook.ts entry left by
  the mid-wave append; the surviving entry now describes the per-event yield
  guard that parses BOTH workspace settings carriers. harness.ts entry gains
  the canary-gated smoke + symmetric rollback (fresh mint revoked on any
  failed smoke), the registration-gated pre-approval, post-smoke stale
  cleanup, the --status exit contract (incl. half-removed receipts), the
  URL-matched bearer recovery, the SCOPES_MIN_SERVE_VERSION pin, apply
  --json stdout-only discipline, and codex config-dir lock parity. hooks.ts
  entry notes the permissions writers fail closed on policy shapes they
  don't understand.
- docs/guides/bootstrap.md: the mint-first bullet states the failed-smoke
  rollback + fresh-mint retirement guarantee; the --status bullet spells out
  the cron exit contract and the install-level --json contract.
- README.md: the bootstrap guide link mentions local harness mode.
- CHANGELOG.md: restore the blank line before the 0.45.12.0 header
  (formatting only; no entry content touched).
- llms bundles regenerated (bun run build:llms; freshness test green).

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

* docs(release): cross-model doc-review fixes — honest scoping + two filed residuals (v0.45.14.0)

Independent doc-review pass (Claude subagent; codex session init failed on an
unrelated local MCP timeout) against the shipped #4043 diff. Verified 10
findings against the code; applied 8, filed 2:

- bootstrap.md + KEY_FILES.md: scope the --status bearer-recovery URL-match
  claim to the Claude Code lane (the codex managed block is read at the
  receipt-recorded path; its url key is not yet compared — filed in TODOS);
  add the no-install exit-0/plain vs exit-2/--json distinction and the
  unreachable-serve / failed-verify exit-1 trips; document the wider flag
  surface + registrar mode (--url/--port/--force/--name/--no-hooks); state
  that a supplied --token is never revoked by --remove or rotation.
- KEY_FILES.md: permissions-writer clause corrected — the add path fails
  closed on alien policy shapes, removal leaves what it can't read untouched;
  token-mint.ts entry gains the required takesHolders option and TOKEN_ID_RE.
- DEPLOY.md: revoke-by-name hits every same-name token; auth revoke --id +
  the id/scopes columns in auth list are the precise path.
- RESOLVER.md: restore an "install gbrain into this agent workspace" trigger.
- CHANGELOG (factual drift only): damaged permissions rows are reset to a
  clean object, not recovered; scope-display claim narrowed to the verify +
  CLI display paths.
- TODOS.md: filed the codex-lane [C8] URL-match residual and the admin
  dashboard scope-display normalizer residual.
- llms bundles regenerated; guards green.

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

* fix(harness): close the two doc-review residuals — codex-lane bearer ownership + dashboard scope honesty (#4043)

- parseCodexBlockBearer takes an expectedUrl: --status only recovers the
  managed block's bearer when the block's url matches the receipt ([C8]
  parity with the claude lane — two GBRAIN_HOMEs sharing the one user-global
  codex config could otherwise hand install A install B's credential).
- The admin dashboard renders legacy-token scopes through the SAME
  normalizeTokenScopes the verify path uses (NULL = grandfathered full
  access; damaged/deny rows show what the serve actually enforces) instead
  of raw array_to_string.
- Both TODOS residual entries removed (fixed, not filed).

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

* chore(skills): regenerate skills.lock.json — RESOLVER.md + setup/SKILL.md edits from the #4043 wave

The wave's skill-routing updates (harness routing row in RESOLVER.md, the
harness pointer in setup/SKILL.md) landed without the manifest-lock chaser;
CI's check:skills-manifest caught the stale hashes. Full verify suite green
locally (39/39).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:51:01 -07:00
48 changed files with 5701 additions and 313 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- gbrain-runbook-stamp: 0.45.13.0 -->
<!-- gbrain-runbook-stamp: 0.45.14.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. -->
+22
View File
@@ -2,6 +2,28 @@
All notable changes to GBrain will be documented in this file.
## [0.45.14.0] - 2026-08-14
**The box that already has a brain: framework-spawned coding agents get brain access by default.** The bootstrap door built in v0.45.0.0 was for a human at a laptop. A growing share of Claude Code and Codex sessions are spawned by an agent framework — your OpenClaw, or anything that shells out to headless sessions — on a machine that already hosts a brain and a running `gbrain serve --http`. Until now those sessions got nothing unless someone hand-replicated settings writers across every project directory. One command fixes that:
gbrain bootstrap harness --yes
### Added
- **`gbrain bootstrap harness`** — machine-level wiring, no agent workspace or interview required. Mints a least-privilege bearer token, registers a user-scope HTTP MCP server for Claude Code, pre-approves its tools for headless runs (the `permissions.allow` gate that otherwise blocks `claude -p`), wires the five lifecycle hooks (user scope by default, or exactly the dirs you pass with repeatable `--project`), and writes Codex's config block directly — with the token inline, because framework-spawned codex inherits no shell profile for an env var to live in. Everything is stated before it happens (reach, plainly: read AND write, every session on the machine; transcript capture is its own consent line with `--no-capture` as its off-ramp), non-interactive runs require `--yes`, re-runs are idempotent, and `--remove` tears down exactly what the machine-level receipt records.
- **Scoped bearer tokens.** `gbrain auth create --scopes read,write` narrows a token to exactly those operations; tokens created without scopes keep their historical full access, byte for byte. The harness token uses this by default, and its reads span the brain's federated sources — the same reach a local session gets. `gbrain auth list` now shows each token's id and honest scope; `gbrain auth revoke --id <uuid>` revokes precisely one token (names were never unique). The admin dashboard shows real grants instead of assuming full access.
- **Safe rotation by construction.** Re-running harness wiring mints the new token first, wires and verifies everything, and only then revokes the previous token by id — a failed re-wire leaves the old credential fully working. A wiring crash at any step leaves a consumable receipt: `--remove` and `gbrain bootstrap uninstall` clean up partial states instead of stranding them.
- **`gbrain bootstrap harness --status`** probes the live truth — serve health, token validity (recovered from the host's own registration, redacted), per-target states, and honest degrades — with cron-friendly exit codes. `gbrain doctor` gains a `bootstrap_harness_health` check that distinguishes "serve is down (normal transient)" from "wiring incomplete" from "rotation never converged".
- **Honesty on Postgres brains.** Per-turn hook injection is PGLite-only today; harness mode says so plainly at install time, wires the hooks anyway (they light up when the engine-uniform listener lands), and names MCP as the active seam.
### Fixed
- A routine `gbrain auth permissions set-takes-holders` edit silently deleted a token's other stored grants (whole-object replace); it now merges — and resets rows whose stored grants were damaged by a historical encoding bug to a clean object instead of compounding them.
- Registration ownership on multi-brain machines: harness wiring refuses to replace an MCP registration that points at a different brain's serve without `--force`, and removal skips registrations it no longer owns — or whose ownership it cannot verify — instead of deleting another install's wiring.
- User-scope settings writers resolve Claude Code's config location the way Claude Code does (`CLAUDE_CONFIG_DIR`, then `$HOME`) — sandboxed environments previously risked writing to the operator's real settings file.
- Ship-review hardening (three adversarial passes at ship): the post-wiring verification now sends a deliberately invalid credential first — an endpoint that accepts it is not a real serve, wiring rolls back, and the fresh token is retired immediately on ANY failed verification; a failed verification also rolls a fresh Claude Code registration (and its headless pre-approval) back to the pre-run state; the pre-approval never lands when the registration itself failed; prior wiring is only cleaned up after the replacement verifies; `--status` and `gbrain doctor` report honestly on partially-applied or partially-removed installs instead of reading vacuously green, and `--status` only recovers a bearer from a registration it can verify as its own; token-scope reads fail closed on damaged rows across the verify and CLI display paths (`auth list` shows exactly what the serve enforces); a value-less `--project` or `--scopes` flag errors loudly instead of silently widening scope or minting a full-access token; settings writers refuse to rewrite permission policy shapes they don't understand; config writes serialize under cross-install locks on every path (apply, remove, cleanup, rollback); and per-turn hooks defer per-event to workspaces that carry their hook wiring in committed settings.
To take advantage of v0.45.14.0: upgrade, then on any agent-framework box run `gbrain bootstrap harness --yes` against your running `gbrain serve --http`. On PGLite brains, pre-mint with `gbrain auth create bootstrap-harness --scopes read,write` while the serve is stopped and pass `--token`. Restart your serve after upgrading so token scoping is enforced by the new verify path — the install says this too, exactly when it applies. See the "Local harness mode" section of docs/guides/bootstrap.md.
## [0.45.13.0] - 2026-08-13
**The Truthful Surface wave: your agent's MCP catalog now tells the truth. What's listed is callable, empty answers explain themselves, and new clients start with a focused ~26-tool surface they can widen on demand.**
+1 -1
View File
@@ -466,7 +466,7 @@ the page PK, soft-delete-filtered, source-safe) and completes in seconds.
## Docs
- [`docs/INSTALL.md`](docs/INSTALL.md) — every install path, end to end
- [`docs/guides/bootstrap.md`](docs/guides/bootstrap.md) — the persistent-personal-agent bootstrap contract (interview, identity files, hooks, private repo, security posture, uninstall)
- [`docs/guides/bootstrap.md`](docs/guides/bootstrap.md) — the persistent-personal-agent bootstrap contract (interview, identity files, hooks, private repo, security posture, uninstall), plus local harness mode (`gbrain bootstrap harness`) for wiring framework-spawned Claude Code/Codex sessions to a running serve
- [`docs/what-schemas-unlock.md`](docs/what-schemas-unlock.md) — why schemas matter: 7 killer use cases, the structural argument for typed page kinds, the agent-co-curates pattern (v0.40.7.0)
- [`docs/schema-author-tutorial.md`](docs/schema-author-tutorial.md) — 5-minute walkthrough: fork the bundled pack, add a custom type, backfill existing pages, prove the wiring via `gbrain whoknows`
- [`docs/architecture/`](docs/architecture/) — system design, topologies, retrieval theory
+89 -1
View File
@@ -800,7 +800,10 @@ events at the IPC delivery point and dedupes via the transcript's
0700 dir) and (b) a secret-file home for `turn_context` auth (same hash-keyed run dir).
The cathedral-3 branch prototyped (a) as `resolveSocketPathForConfig` (see branch
history at commit 2350294c) before the convergence dropped it pending the secret
design. **Trigger:** a Postgres-brain user asking why hooks stay silent. **Start:**
design. **Trigger:** a Postgres-brain user asking why hooks stay silent — and as of
#4043, every `gbrain bootstrap harness` install on a Postgres brain: harness mode
pre-wires all five hooks and states the degradation plainly, so this listener is what
lights them up. **Start:**
`src/core/context/resolve-ipc.ts` socket-path helpers + `src/mcp/server.ts` listener gate
+ `src/commands/hook.ts:no_pglite_path` branch.
- [ ] **P3 — thin-client remote push route.** Thin-client installs (remote_mcp) have no
@@ -5408,6 +5411,91 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
**Depends on:** nothing.
## Harness-mode follow-ups (#4043, filed at build time)
- [ ] **P2 — serve-side port/pid record for discovery.** `gbrain bootstrap harness`
and its `--status` probe `/health` at 127.0.0.1:3131 (or an explicit `--url`/`--port`);
a serve on a non-default port is invisible without flags. Write a record (port, pid,
started_at) from `runServeHttp`'s `app.listen` callback into `~/.gbrain/run/`,
mtime-as-heartbeat like `src/core/autopilot-paths.ts` — the stale-record semantics
(crashed serve, multi-serve boxes) are why this deferred; a wrong record misdirecting
probes is worse than no record. **Trigger:** a harness box running serve on a custom
port asking why discovery misses it. **Start:** `src/commands/serve-http.ts` listen
callback + `src/core/bootstrap/harness.ts` url resolution.
- [ ] **P3 — legacy HTTP transport scope asymmetry.** `src/mcp/http-transport.ts` is
test-only (no production caller; `serve --http` uses serve-http.ts) and hardcodes
`scopes: []` with no per-op scope gate — if it is ever revived, a scoped legacy token
is fully UNSCOPED there. Mirror the `scopes TEXT[]` honor + `hasScope` dispatch gate
before any revival. **Trigger:** any production caller of `startHttpTransport`.
- [ ] **P3 — partial unique index on active `access_tokens.name`.** Names are not
unique; `auth revoke <name>` clears every active row and the 23505 handler in
`auth create` is dead code for name collisions. Harness mode sidesteps this with
revoke-by-id + receipt-carried ids, but a
`CREATE UNIQUE INDEX ... ON access_tokens (name) WHERE revoked_at IS NULL` would
make names honest for humans too. Needs a dedup pass first on brains that already
carry twins. **Start:** `src/core/migrate.ts` (CONCURRENTLY + `transaction: false`).
- [ ] **P2 — codex hook lane.** codex-cli 0.147.0 ships a real hook system (hooks.json;
PreToolUse…SessionEnd — recorded on `TARGETS['codex-2026-08']` in
`src/core/bootstrap/host-specs.ts`), falsifying the old "codex has no hooks" premise.
Wiring SessionEnd transcript capture (+ SessionStart context) would give codex
sessions the same memory loop Claude Code gets, and supersedes the FF2 notify-sweeper
idea. Needs its own dated spec-target verification (payload shapes, deny-unknown-fields
config) + e2e before any writer lands. **Trigger:** first user asking why codex
sessions don't persist; **Start:** `host-specs.ts` TARGETS + a codex sibling of
`writeClaudeHooksAt`.
- [ ] **P3 — PGLite admin-lane scoped minting.** `gbrain bootstrap harness` refuses to
mint under a live PGLite serve (single-writer) and points at pre-mint + `--token`.
Auto-driving `POST /admin/login` + `POST /admin/api/api-keys` (when
GBRAIN_ADMIN_BOOTSTRAP_TOKEN is present) would erase that friction — BLOCKED ON
extending that admin route to carry a scopes/permissions payload (today it inserts
only id/name/token_hash, so it can only mint full-access tokens, defeating the
harness lane's least-privilege default). **Start:** `src/commands/serve-http.ts`
api-keys route + `src/core/bootstrap/harness.ts` mint seam.
- [ ] **P3 — OpenClaw plugin setup hook (self-demoted from the #4043 wave).** The
issue's closing ask is "frameworks call `gbrain bootstrap harness` at setup time".
The in-repo `openclaw.plugin.json` cannot express it: OpenClaw installs plugins with
lifecycle scripts disabled (`--ignore-scripts`) and the manifest schema has no
setup/command field (verified against the OpenClaw plugin docs, 2026-08-12). When the
plugin API grows a setup surface, add `gbrain bootstrap harness --yes` AND remove the
manifest's static stdio `mcpServers.gbrain` entry in the same commit (one owner per
server name). **Trigger:** OpenClaw plugin-API setup/command support shipping.
- [ ] **P3 — harness federated-drift visibility.** The harness token's
`permissions.source_id` federation array is a mint-time snapshot of the
`federated=true` sources; sources added later are invisible to wired sessions until
a re-run rotates the token. `--status` could diff the snapshot against the live
config and suggest a re-run — needs either an engine open (breaks status's
engine-free posture under a live PGLite serve) or a sources probe over MCP with the
recovered token. **Start:** `src/core/bootstrap/harness.ts:statusHarness`.
- [ ] **P3 — harness smoke: add BRAIN-IDENTITY comparison on top of the canary
(ship-review residual).** The ship-review batch landed the two cheap layers: an
apply-time CANARY (a random same-format bearer must fail auth before the real smoke —
an impostor cannot tell the canary from the real token, so it is caught whichever way
it answers) and immediate revocation of the fresh mint on any failed smoke. The
remaining hardening is comparing the smoke's returned identity against the local
brain's (the default mint path already opens the engine and could capture it);
registrar mode (`--token` + remote url) has no engine and would state the weaker
guarantee honestly. **Start:** `src/core/bootstrap/harness.ts` steps 5+8.
- [ ] **P3 — harness orphan-mint reconciliation (red-team finding).** A hard crash in
the window between the mint INSERT committing and the `receipt.token.id` save leaves
an ACTIVE token no receipt records — `--remove` cannot revoke it and doctor never
flags it. On apply, when the prior receipt has `minted: true` but no id, list active
`access_tokens` rows matching `token.name` created after `receipt.created_at` and
fold them into `previous_ids` (or surface them loudly). **Start:**
`src/core/bootstrap/harness.ts` step 5 + `src/core/token-mint.ts`.
- [ ] **P3 — bootstrap lock.ts error-path polish (plan micro-item, deferred at ship).**
Non-EEXIST mkdir errors (EACCES/EROFS) misreport as BOOTSTRAP_IN_PROGRESS, and the
missing-dir message says "workspace directory" even when the lock target is the
gbrain HOME (harness lane) or a host config dir. Add an accurate message path.
**Start:** `src/core/bootstrap/lock.ts:acquireBootstrapLock`.
- [ ] **P3 — dedupe `auth create` against `mintLegacyToken`.** `src/commands/auth.ts`
create() re-implements the INSERT + `{a,b}` text[]-literal trick that token-mint.ts
owns (the extraction note says so); routing create() through `mintLegacyToken` (the
engine is in scope inside `withConfiguredSql`) would leave one canonical mint. Same
for the doctor's inline `/health` probe vs `probeServeHealth`, which also wants an
injectable fetch seam so `bootstrap_harness_health` tests stop making real TEST-NET
calls (3s each). **Start:** `src/commands/auth.ts:create`, `src/commands/doctor.ts`
bootstrap_harness_health.
## Agent-bootstrap wave follow-ups (filed at build time)
- [ ] **P2 — repoPhaseComplete is single-workspace (one global receipt).** The
+1 -1
View File
@@ -1 +1 @@
0.45.13.0
0.45.14.0
File diff suppressed because one or more lines are too long
+76 -2
View File
@@ -154,8 +154,82 @@ you'd apply to any journal: write what you'd be comfortable persisting.
| API keys | everything (keyless mode) | semantic search, auto-extraction |
| GitHub / `gh` | full local agent | off-machine durability (repo re-runnable later) |
| Hooks (Claude Code) | pull protocol via AGENTS.md gates | automatic per-turn context + session-end persistence |
| Codex (no hook system, no MCP scope flag) | pull protocol + MCP tools | per-turn push (stated plainly; not oversold) + the ability to confine MCP reach to one folder (`codex mcp add` is always user-global) |
| Codex (no wired hooks, no MCP scope flag) | pull protocol + MCP tools | per-turn push (stated plainly; not oversold — codex 0.147+ ships a hook system, but gbrain does not wire it yet) + the ability to confine MCP reach to one folder (`codex mcp add` is always user-global) |
| Second simultaneous session | first session unaffected | second session's brain tools fail politely (one live serve per brain — v1 contract) |
| Postgres brain (incl. harness mode) | MCP tools every session + pull protocol | per-turn hook injection (`no_pglite_path`: the hook IPC socket is PGLite-only today; hooks stay pre-wired and light up when the engine-uniform listener lands) |
## Local harness mode (`gbrain bootstrap harness`, #4043)
The workspace install above is built for a human's laptop. A box run by an
agent framework (your OpenClaw, or anything that shells out to `claude -p` /
codex exec) already hosts a brain and a running `gbrain serve --http` — and
those framework-spawned sessions get zero brain access by default. Harness
mode wires them in one command, with no `agent.json` and no interview:
gbrain bootstrap harness --yes
- Mints a **least-privilege** bearer token (scopes `read+write`, stored in the
`access_tokens.scopes` column; reads span the brain's federated sources).
Re-runs rotate mint-first: the previous token is revoked by id only after
the new one is wired and smoke-tested, so clients are never dead mid-swap.
The smoke sends a deliberately invalid credential first — an endpoint that
accepts anything is not this brain's serve — and a failed smoke rolls the
wiring back (fresh registrations removed, replaced ones restored, the
headless pre-approval stripped) and retires the fresh mint immediately, so
nothing live is ever left pointed at an unverified endpoint. Prior wiring
is only cleaned up after the replacement verifies.
- Claude Code: user-scope HTTP MCP registration, `mcp__gbrain` pre-approved in
user-scope `permissions.allow` (headless `claude -p` blocks MCP tools
without it), and the five lifecycle hooks — user scope by default, or
exactly the dirs you pass with repeatable `--project` (never both; the two
would double-fire every event). `--no-capture` wires context injection only
and skips the transcript-capture events.
- Codex: one managed `[mcp_servers.gbrain]` block with the bearer token
INLINE in the codex config (0600) — framework-spawned codex inherits no
shell profile, so the env-var lane the `connect` path uses would never
reach it.
- Honesty on Postgres brains: per-turn injection is degraded (the matrix row
above); MCP is the active seam and the summary says so.
- `--status [--json]` probes the live truth (serve health, token validity via
host-config recovery — the Claude Code lane only recovers a bearer from a
registration whose URL matches the receipt; the codex managed block is read
from the exact path the receipt records — and per-target states) with a
cron-honest exit contract: 0 only when the serve, token, and every target
verify and the rotation has converged (honest degrades count as OK); 1 on
an unreachable serve, a failed token verify, failed or pending targets, an
unconverged rotation, or a half-removed install whose token still awaits
revocation. With no install at all it says so and exits 0 (2 under
`--json`, so machine callers can tell absence apart). `gbrain doctor`
carries a matching `bootstrap_harness_health` check. `--json` on the
install itself emits a single machine-readable document on stdout (prose
goes to stderr).
- The full flag surface lives in `gbrain bootstrap --help`: `--url`/`--port`
point at a non-default serve (a non-loopback `--url` is refused unless you
also pass `--token`, which flips into registrar mode — MCP wiring only, no
hooks, nothing minted), `--force` replaces a foreign same-name MCP
registration, `--name` renames the server, `--harness` picks the hosts,
and `--no-hooks` skips hook wiring entirely.
- `--remove` tears down exactly what the machine-level receipt
(`<home>/bootstrap/harness.json`) records — host removals are engine-free
and run even while a serve is live; the token revoke defers with exact
instructions if a live PGLite serve holds the brain. `gbrain bootstrap
uninstall` removes harness wiring first, automatically.
- Everything is stated before it happens; non-interactive runs require
`--yes`. Close active Claude Code sessions for the cleanest user-scope
settings writes (the host also writes that file).
PGLite note: minting needs the single-writer lock, so on a PGLite brain
either pre-mint (`gbrain auth create bootstrap-harness --scopes read,write`
while the serve is stopped) and pass `--token`, or stop/re-run/restart.
Postgres brains mint fine while the serve runs. A token you supply is never
revoked by `--remove` or rotation (it is not the harness's to revoke) —
retire it yourself with `gbrain auth revoke` when you're done with it.
Binary-downgrade note: token scoping is data-only (no migration), so a gbrain
binary OLDER than the release that shipped it verifies every scoped token as
FULL-ACCESS — the old verify path never reads the scopes column. If you
downgrade after a harness install, revoke the scoped tokens first
(`gbrain auth revoke` with the id flag) and re-mint once you upgrade again.
## Multi-device
@@ -195,7 +269,7 @@ that changed shape, a harness that stopped calling our MCP server):
keyless-`init` → interview → render → `gbrain bootstrap hooks --harness codex`
path (executing the real `codex mcp add` into a hermetic `~/.codex/config.toml`),
asserts the rendered `AGENTS.md` carries the Gate-3 brain-first pull protocol
(Codex has no hook system, so the pull protocol is its per-turn seam), then
(gbrain does not wire Codex hooks yet, so the pull protocol is its per-turn seam), then
spends one live `codex exec` turn to prove real codex → gbrain MCP → brain →
a seeded, brain-only fact (falling back to a shell `gbrain query` if headless
stdio-MCP is unavailable).
+6 -1
View File
@@ -11,7 +11,12 @@
Recent versions of the Codex CLI (`@openai/codex`) support remote
streamable-HTTP MCP servers with a bearer token read from an environment
variable. The token lives in your shell env, not in Codex's config file.
variable. On THIS page's `gbrain connect` path the token lives in your shell
env, not in Codex's config file. The exception is `gbrain bootstrap harness`
(local agent-framework boxes): framework-spawned codex inherits no shell
profile, so that lane writes the token INLINE into a managed, 0600
`[mcp_servers.gbrain]` block in the codex config — stated in its consent
block, removable with `gbrain bootstrap harness --remove`.
## Fastest path: `gbrain connect`
+15 -8
View File
@@ -4,9 +4,11 @@
> PKCE, refresh rotation, optional DCR), an embedded React admin dashboard at
> `/admin`, scoped operations, and a live SSE activity feed. Legacy bearer
> tokens still work — `verifyAccessToken` falls back to the `access_tokens`
> table and grandfathers tokens to `read+write+admin`. Both the legacy fallback
> and the OAuth tables work on PGLite and Postgres (both engine schemas carry
> `access_tokens`). See [SECURITY.md](../../SECURITY.md) for env vars and
> table; tokens with no `scopes` grant are grandfathered to `read+write+admin`,
> while tokens minted with `gbrain auth create --scopes …` (or by
> `gbrain bootstrap harness`) are honored at exactly their granted scopes.
> Both the legacy fallback and the OAuth tables work on PGLite and Postgres
> (both engine schemas carry `access_tokens`). See [SECURITY.md](../../SECURITY.md) for env vars and
> tunable defaults.
Access your brain from any device, any AI client. GBrain ships two transports:
@@ -68,8 +70,9 @@ This requires:
2. A public tunnel (ngrok, Tailscale, or cloud host)
3. A bearer token created via `gbrain auth create <name>`
Existing bearer tokens are grandfathered as `read+write+admin` scopes on the
OAuth-capable HTTP server, so no migration is required.
Existing bearer tokens (no `scopes` grant) are grandfathered as
`read+write+admin` on the OAuth-capable HTTP server, so no migration is
required; `gbrain auth create --scopes read,write` mints narrowed tokens.
## OAuth 2.1 Setup
@@ -223,7 +226,8 @@ Write ops can additionally be fenced per client with `--bound-slug-prefixes`
## Legacy Bearer Token Setup
Bearer tokens are the simple path when you don't need per-client scoping.
They grandfather to `read+write+admin` scopes on the HTTP server.
Without a `--scopes` grant they grandfather to `read+write+admin` on the
HTTP server; pass `--scopes read,write` at creation to narrow one.
### 1. Set up the tunnel
@@ -249,8 +253,11 @@ gbrain auth list
gbrain auth revoke "claude-desktop"
```
Tokens are per-client. Create one for each device/app. Revoke individually
if compromised. Tokens are stored SHA-256 hashed in your database.
Tokens are per-client. Create one for each device/app. Names are not
unique: `gbrain auth revoke "<name>"` revokes EVERY active token carrying
that name — use `gbrain auth list` (shows each token's id and scopes) and
`gbrain auth revoke --id <uuid>` to revoke exactly one. Tokens are stored
SHA-256 hashed in your database.
### 3. Connect your AI client
+18 -10
View File
@@ -1521,7 +1521,8 @@ wins; fix the row.
|---------|-------|
| "Set up GBrain", first boot | `skills/setup/SKILL.md` |
| "Now what?", "fill my brain", "cold start", "bootstrap my data", "import my data", "what should I import first" | `skills/cold-start/SKILL.md` |
| "Install gbrain into this agent/harness", "agent workspace bootstrap", "gbrain bootstrap", "wire gbrain hooks", "set up the maintenance sweep" | Run `gbrain bootstrap` (paste-in harness install: hooks + sweep + config). See `docs/guides/bootstrap.md` |
| "agent workspace bootstrap", "install gbrain into this agent workspace", "gbrain bootstrap", "paste-in install", "set up the maintenance sweep" | Run `gbrain bootstrap` (paste-in workspace install: interview + identity files + hooks + sweep). See `docs/guides/bootstrap.md` |
| "wire this box's coding agents to the brain", "framework-spawned sessions need brain access", "wire gbrain hooks without a workspace", "hook Claude Code/Codex to the running serve" | Run `gbrain bootstrap harness --yes` (machine-level wiring to a running `serve --http`: scoped token + user-scope MCP + headless pre-approval + hooks; no agent.json). See the "Local harness mode" section of `docs/guides/bootstrap.md` |
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
@@ -2058,7 +2059,7 @@ the page PK, soft-delete-filtered, source-safe) and completes in seconds.
## Docs
- [`docs/INSTALL.md`](docs/INSTALL.md) — every install path, end to end
- [`docs/guides/bootstrap.md`](docs/guides/bootstrap.md) — the persistent-personal-agent bootstrap contract (interview, identity files, hooks, private repo, security posture, uninstall)
- [`docs/guides/bootstrap.md`](docs/guides/bootstrap.md) — the persistent-personal-agent bootstrap contract (interview, identity files, hooks, private repo, security posture, uninstall), plus local harness mode (`gbrain bootstrap harness`) for wiring framework-spawned Claude Code/Codex sessions to a running serve
- [`docs/what-schemas-unlock.md`](docs/what-schemas-unlock.md) — why schemas matter: 7 killer use cases, the structural argument for typed page kinds, the agent-co-curates pattern (v0.40.7.0)
- [`docs/schema-author-tutorial.md`](docs/schema-author-tutorial.md) — 5-minute walkthrough: fork the bundled pack, add a custom type, backfill existing pages, prove the wiring via `gbrain whoknows`
- [`docs/architecture/`](docs/architecture/) — system design, topologies, retrieval theory
@@ -3981,9 +3982,11 @@ Source: https://raw.githubusercontent.com/garrytan/gbrain/master/docs/mcp/DEPLOY
> PKCE, refresh rotation, optional DCR), an embedded React admin dashboard at
> `/admin`, scoped operations, and a live SSE activity feed. Legacy bearer
> tokens still work — `verifyAccessToken` falls back to the `access_tokens`
> table and grandfathers tokens to `read+write+admin`. Both the legacy fallback
> and the OAuth tables work on PGLite and Postgres (both engine schemas carry
> `access_tokens`). See [SECURITY.md](../../SECURITY.md) for env vars and
> table; tokens with no `scopes` grant are grandfathered to `read+write+admin`,
> while tokens minted with `gbrain auth create --scopes …` (or by
> `gbrain bootstrap harness`) are honored at exactly their granted scopes.
> Both the legacy fallback and the OAuth tables work on PGLite and Postgres
> (both engine schemas carry `access_tokens`). See [SECURITY.md](../../SECURITY.md) for env vars and
> tunable defaults.
Access your brain from any device, any AI client. GBrain ships two transports:
@@ -4045,8 +4048,9 @@ This requires:
2. A public tunnel (ngrok, Tailscale, or cloud host)
3. A bearer token created via `gbrain auth create <name>`
Existing bearer tokens are grandfathered as `read+write+admin` scopes on the
OAuth-capable HTTP server, so no migration is required.
Existing bearer tokens (no `scopes` grant) are grandfathered as
`read+write+admin` on the OAuth-capable HTTP server, so no migration is
required; `gbrain auth create --scopes read,write` mints narrowed tokens.
## OAuth 2.1 Setup
@@ -4200,7 +4204,8 @@ Write ops can additionally be fenced per client with `--bound-slug-prefixes`
## Legacy Bearer Token Setup
Bearer tokens are the simple path when you don't need per-client scoping.
They grandfather to `read+write+admin` scopes on the HTTP server.
Without a `--scopes` grant they grandfather to `read+write+admin` on the
HTTP server; pass `--scopes read,write` at creation to narrow one.
### 1. Set up the tunnel
@@ -4226,8 +4231,11 @@ gbrain auth list
gbrain auth revoke "claude-desktop"
```
Tokens are per-client. Create one for each device/app. Revoke individually
if compromised. Tokens are stored SHA-256 hashed in your database.
Tokens are per-client. Create one for each device/app. Names are not
unique: `gbrain auth revoke "<name>"` revokes EVERY active token carrying
that name — use `gbrain auth list` (shows each token's id and scopes) and
`gbrain auth revoke --id <uuid>` to revoke exactly one. Tokens are stored
SHA-256 hashed in your database.
### 3. Connect your AI client
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.45.13.0",
"version": "0.45.14.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {
+1 -1
View File
@@ -155,7 +155,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.45.13.0",
"version": "0.45.14.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.5",
+2 -1
View File
@@ -102,7 +102,8 @@ wins; fix the row.
|---------|-------|
| "Set up GBrain", first boot | `skills/setup/SKILL.md` |
| "Now what?", "fill my brain", "cold start", "bootstrap my data", "import my data", "what should I import first" | `skills/cold-start/SKILL.md` |
| "Install gbrain into this agent/harness", "agent workspace bootstrap", "gbrain bootstrap", "wire gbrain hooks", "set up the maintenance sweep" | Run `gbrain bootstrap` (paste-in harness install: hooks + sweep + config). See `docs/guides/bootstrap.md` |
| "agent workspace bootstrap", "install gbrain into this agent workspace", "gbrain bootstrap", "paste-in install", "set up the maintenance sweep" | Run `gbrain bootstrap` (paste-in workspace install: interview + identity files + hooks + sweep). See `docs/guides/bootstrap.md` |
| "wire this box's coding agents to the brain", "framework-spawned sessions need brain access", "wire gbrain hooks without a workspace", "hook Claude Code/Codex to the running serve" | Run `gbrain bootstrap harness --yes` (machine-level wiring to a running `serve --http`: scoped token + user-scope MCP + headless pre-approval + hooks; no agent.json). See the "Local harness mode" section of `docs/guides/bootstrap.md` |
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
+4 -1
View File
@@ -19,7 +19,10 @@ Set up GBrain from scratch. Target: working brain in under 5 minutes.
> **Installing into an agent harness?** (Claude Code, Codex, OpenClaw, etc.)
> `gbrain bootstrap` is the paste-in install path — it wires hooks, the
> maintenance sweep, and harness config in one command. See
> maintenance sweep, and harness config in one command. On a box that already
> hosts a brain + a running `gbrain serve --http` (agent-framework boxes),
> `gbrain bootstrap harness --yes` wires framework-spawned Claude Code/Codex
> sessions instead — no agent workspace needed. See
> `docs/guides/bootstrap.md`. This skill covers the brain-side setup
> (database, sync, first import); the two are complementary.
+2 -2
View File
@@ -1,5 +1,5 @@
{
"RESOLVER.md": "3d32ec5cd8c15d83b18469277a5efc269bb6b60e67f4542de5a28a76760c36cd",
"RESOLVER.md": "8e54195c109c764d2954186ee92a30a62cd91e961233be53f828db5a25ebe710",
"_AGENT_README.md": "62613f7f1e061576b6c1b18844f59bd35f2df96ca5c45c8c41fae0772b9ce4d3",
"_brain-filing-rules.json": "cf850df6a7425464c6d63b3ace71991cc93497fa0cc8cd21acd31883e17939c6",
"_brain-filing-rules.md": "2d2d75b7c76081c56f41b2c0a5a978c355ce957300f9b0a5575dc4079ef1f877",
@@ -150,7 +150,7 @@
"resolve-before-asking/routing-eval.jsonl": "bac1bcf30337f5255ef4ce1a2a8a2b38d58ebcd576503c483190c79ec6e69489",
"schema-author/SKILL.md": "09d69ee45970191bb2592a764685f67196cabf350db40b4c0c5ffc19ea9e2df3",
"schema-unify/SKILL.md": "14ddc0f8bc7d8b11eb03dc4eb35621d140fba71bd35779835d85e297acfb0177",
"setup/SKILL.md": "68dde0de48bb4c93b13d9e19c3a20ff82155985ad09f509669aff8ece0a6f4fb",
"setup/SKILL.md": "322faf7099afd71f9add0f6dbe4d2bf9686df54c521a29e018f4ab25608e3dc0",
"signal-detector/SKILL.md": "64e4547f5a8624c53d875001b423d240ec73ee9fd026a96c7b799d287c5fb6e4",
"skill-autobench/SKILL.md": "144572ec76f3784a97645dfde587ab13d77e804f50b00dc7fbe678204de6ff21",
"skill-autobench/routing-eval.jsonl": "8d961ed6403b7e2f690948e4c18529d40f26f6f21064befc56d465966b1a9ec0",
+142 -31
View File
@@ -23,6 +23,9 @@ import { createHash, randomBytes } from 'crypto';
import { loadConfig, toEngineConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
import type { BrainEngine } from '../core/engine.ts';
import { assertAllowedScopes } from '../core/scope.ts';
import { TOKEN_ID_RE } from '../core/token-mint.ts';
import { normalizeTokenScopes } from '../core/legacy-token-scope.ts';
import { sqlQueryForEngine, executeRawJsonb, type SqlQuery } from '../core/sql-query.ts';
function hashToken(token: string): string {
@@ -66,8 +69,20 @@ async function withConfiguredSql<T>(
}
}
async function create(name: string, opts: { takesHolders?: string[] } = {}) {
if (!name) { console.error('Usage: auth create <name> [--takes-holders world,garry]'); process.exit(1); }
async function create(name: string, opts: { takesHolders?: string[]; scopes?: string[] } = {}) {
if (!name) { console.error('Usage: auth create <name> [--takes-holders world,garry] [--scopes read,write]'); process.exit(1); }
// #4043 least-privilege: validate scopes at mint time — the verify path
// treats a filtered-empty scopes array as DENY, so a typo must fail loudly
// here, never silently brick (or widen) the token.
if (opts.scopes !== undefined) {
try {
if (opts.scopes.length === 0) throw new Error('at least one scope is required');
assertAllowedScopes(opts.scopes);
} catch (e: any) {
console.error(`Invalid --scopes: ${e.message}`);
process.exit(1);
}
}
const token = generateToken();
const hash = hashToken(token);
@@ -84,17 +99,35 @@ async function create(name: string, opts: { takesHolders?: string[] } = {}) {
// through the wire-protocol type oid without the v0.12.0 double-encode
// bug class (verified by test/e2e/auth-permissions.test.ts:67 on
// Postgres and test/sql-query.test.ts on PGLite).
await executeRawJsonb(
engine,
`INSERT INTO access_tokens (name, token_hash, permissions)
VALUES ($1, $2, $3::jsonb)`,
[name, hash],
[permissions],
);
console.log(`Token created for "${name}" (takes_holders=${JSON.stringify(takesHolders)}):\n`);
//
// Scopes (when given) land in the original-schema scopes TEXT[] column
// via an array literal through a TEXT param — values are allowlisted,
// so the literal needs no quoting and runs identically on both engines.
// Omitted → NULL → the historical grandfathered full-access grant.
if (opts.scopes !== undefined) {
await executeRawJsonb(
engine,
`INSERT INTO access_tokens (name, token_hash, permissions, scopes)
VALUES ($1, $2, $4::jsonb, $3::text[])`,
[name, hash, `{${opts.scopes.join(',')}}`],
[permissions],
);
} else {
await executeRawJsonb(
engine,
`INSERT INTO access_tokens (name, token_hash, permissions)
VALUES ($1, $2, $3::jsonb)`,
[name, hash],
[permissions],
);
}
const scopeLine = opts.scopes !== undefined
? `scopes=${JSON.stringify(opts.scopes)}`
: 'scopes=full access (grandfathered — pass --scopes read,write to narrow)';
console.log(`Token created for "${name}" (takes_holders=${JSON.stringify(takesHolders)}, ${scopeLine}):\n`);
console.log(` ${token}\n`);
console.log('Save this token — it will not be shown again.');
console.log(`Revoke with: gbrain auth revoke "${name}"`);
console.log(`Revoke with: gbrain auth revoke "${name}" (or gbrain auth revoke --id <id> from auth list)`);
console.log(`Update visibility: gbrain auth permissions "${name}" set-takes-holders world,garry`);
});
} catch (e: any) {
@@ -121,10 +154,19 @@ async function permissions(name: string, action: string, value: string | undefin
}
const perms = { takes_holders: list };
// JSONB UPDATE via executeRawJsonb — same pattern as create() above.
// MERGE, never whole-object replace: `SET permissions = $2::jsonb`
// would silently DELETE every other grant key (source_id federation,
// and any future key) on a routine takes-holders edit — the grant-wipe
// class the #4043 review caught.
// The jsonb_typeof guard repairs rows carrying historical double-encode
// damage (a jsonb string/array scalar): `scalar || object` would produce
// a jsonb ARRAY and silently strand every grant, so a damaged left
// operand is reset to '{}' on edit — the old whole-replace semantics for
// damaged rows, merge semantics for healthy object rows.
const result = await executeRawJsonb(
engine,
`UPDATE access_tokens
SET permissions = $2::jsonb
SET permissions = (CASE WHEN jsonb_typeof(permissions) = 'object' THEN permissions ELSE '{}'::jsonb END) || $2::jsonb
WHERE name = $1
RETURNING id`,
[name],
@@ -142,10 +184,20 @@ async function permissions(name: string, action: string, value: string | undefin
}
}
/** Render a token row's scope grant honestly (#4043: NULL = grandfathered).
* Routes through the SAME normalizer the verify path uses — the ops surface
* must never claim admin on a row the serve actually scopes or denies. */
export function renderTokenScopes(scopes: unknown): string {
const normalized = normalizeTokenScopes(scopes);
if (normalized === undefined) return 'admin (grandfathered)';
if (normalized.length === 0) return '(deny-all)';
return normalized.join(',');
}
async function list() {
await withConfiguredSql(async (sql) => {
const rows = await sql`
SELECT name, created_at, last_used_at, revoked_at
SELECT id, name, scopes, created_at, last_used_at, revoked_at
FROM access_tokens
ORDER BY created_at DESC
`;
@@ -153,20 +205,22 @@ async function list() {
console.log('No tokens found. Create one: gbrain auth create "my-client"');
return;
}
console.log('Name Created Last Used Status');
console.log('─'.repeat(80));
console.log('ID Name Scopes Created Last Used Status');
console.log('─'.repeat(126));
for (const r of rows) {
const id = String(r.id).padEnd(36);
const name = (r.name as string).padEnd(20);
const scopes = renderTokenScopes(r.scopes).padEnd(21);
const created = new Date(r.created_at as string).toISOString().slice(0, 19);
const lastUsed = r.last_used_at ? new Date(r.last_used_at as string).toISOString().slice(0, 19) : 'never'.padEnd(19);
const status = r.revoked_at ? 'REVOKED' : 'active';
console.log(`${name} ${created} ${lastUsed} ${status}`);
console.log(`${id} ${name} ${scopes} ${created} ${lastUsed} ${status}`);
}
});
}
async function revoke(name: string) {
if (!name) { console.error('Usage: auth revoke <name>'); process.exit(1); }
if (!name) { console.error('Usage: auth revoke <name> | auth revoke --id <uuid>'); process.exit(1); }
await withConfiguredSql(async (sql) => {
const rows = await sql`
UPDATE access_tokens SET revoked_at = now()
@@ -177,10 +231,36 @@ async function revoke(name: string) {
console.error(`No active token found with name "${name}".`);
process.exit(1);
}
if (rows.length > 1) {
console.log(`Note: ${rows.length} active tokens carried the name "${name}" — all revoked. Use revoke --id for precision.`);
}
console.log(`Token "${name}" revoked.`);
});
}
/** #4043: names are not unique — revoke-by-id is the precise path. The
* revocation semantics are canonical in src/core/token-mint.ts
* (revokeLegacyTokenById); this CLI wrapper keeps its own UPDATE only to
* RETURN the name for the confirmation line — keep the two in lockstep. */
async function revokeById(id: string) {
if (!id || !TOKEN_ID_RE.test(id)) {
console.error('Usage: auth revoke --id <uuid> (ids are shown by `gbrain auth list`)');
process.exit(1);
}
await withConfiguredSql(async (sql) => {
const rows = await sql`
UPDATE access_tokens SET revoked_at = now()
WHERE id = ${id}::uuid AND revoked_at IS NULL
RETURNING name
`;
if (rows.length === 0) {
console.error(`No active token found with id "${id}".`);
process.exit(1);
}
console.log(`Token "${rows[0].name}" (${id}) revoked.`);
});
}
async function test(url: string, token: string) {
if (!url || !token) {
console.error('Usage: auth test <url> --token <token>');
@@ -781,22 +861,40 @@ async function clientsCmd(args: string[]) {
* still works.
*/
/**
* Parse `auth create` args into `{ name, takesHolders }`.
* Parse `auth create` args into `{ name, takesHolders, scopes }`.
*
* Exported + pure so the positional-vs-flag logic is unit-testable. Only
* excludes the --takes-holders VALUE from the positional search when the flag
* is present — the pre-v0.41 inline version used `rest[takesIdx + 1]` which
* excludes flag VALUES from the positional search when their flag is
* present — the pre-v0.41 inline version used `rest[takesIdx + 1]` which
* resolved to `rest[0]` when `takesIdx === -1`, silently dropping the name on
* the bare `gbrain auth create <name>` form.
*
* --scopes accepts comma- and/or whitespace-separated input (the
* register-client #3990 normalization precedent). Validation against the
* allowed scope set happens in create() so the error path exits cleanly.
*/
export function parseAuthCreateArgs(rest: string[]): { name: string; takesHolders?: string[] } {
export function parseAuthCreateArgs(rest: string[]): { name: string; takesHolders?: string[]; scopes?: string[]; error?: string } {
const takesIdx = rest.indexOf('--takes-holders');
const takesHolders = takesIdx >= 0 && rest[takesIdx + 1]
? rest[takesIdx + 1].split(',').map(s => s.trim()).filter(Boolean)
: undefined;
const takesValue = takesIdx >= 0 ? rest[takesIdx + 1] : undefined;
const positional = rest.find(a => !a.startsWith('--') && a !== takesValue);
return { name: positional || '', takesHolders };
// Fail closed on a missing/flag-like value: `--scopes` as the last arg
// silently minting a grandfathered FULL-ACCESS token is the exact
// fail-open-by-silent-precedence class the harness parser rejects [X14].
if (takesIdx >= 0 && (takesValue === undefined || takesValue.startsWith('--'))) {
return { name: '', error: 'the takes-holders flag requires a value (e.g. world,garry)' };
}
const takesHolders = takesValue !== undefined
? takesValue.split(',').map(s => s.trim()).filter(Boolean)
: undefined;
const scopesIdx = rest.indexOf('--scopes');
const scopesValue = scopesIdx >= 0 ? rest[scopesIdx + 1] : undefined;
if (scopesIdx >= 0 && (scopesValue === undefined || scopesValue.startsWith('--'))) {
return { name: '', error: 'the scopes flag requires a value (e.g. read,write) — omitting it would mint a full-access token' };
}
const scopes = scopesValue !== undefined
? scopesValue.split(/[\s,]+/).map(s => s.trim()).filter(Boolean)
: undefined;
const positional = rest.find(a => !a.startsWith('--') && a !== takesValue && a !== scopesValue);
return { name: positional || '', takesHolders, ...(scopes !== undefined ? { scopes } : {}) };
}
export async function runAuth(args: string[]): Promise<void> {
@@ -804,12 +902,21 @@ export async function runAuth(args: string[]): Promise<void> {
switch (cmd) {
case 'create': {
// v0.28: optional --takes-holders world,garry,brain (default: world only)
// #4043: optional --scopes read,write (default: full access, grandfathered)
const parsed = parseAuthCreateArgs(rest);
await create(parsed.name, { takesHolders: parsed.takesHolders });
if (parsed.error) {
console.error(`Error: ${parsed.error}`);
process.exit(1);
}
await create(parsed.name, { takesHolders: parsed.takesHolders, scopes: parsed.scopes });
return;
}
case 'list': await list(); return;
case 'revoke': await revoke(rest[0]); return;
case 'revoke': {
if (rest[0] === '--id') { await revokeById(rest[1] || ''); return; }
await revoke(rest[0]);
return;
}
case 'permissions': {
// gbrain auth permissions <name> set-takes-holders world,garry
await permissions(rest[0] || '', rest[1] || '', rest[2]);
@@ -830,13 +937,17 @@ export async function runAuth(args: string[]): Promise<void> {
console.log(`GBrain Token Management
Usage:
gbrain auth create <name> [--takes-holders world,garry,brain]
gbrain auth create <name> [--takes-holders world,garry,brain] [--scopes read,write]
Create a legacy bearer token. v0.28: --takes-holders
sets the per-token allow-list for the takes.holder
field (default: ["world"]). MCP-bound calls to
takes_list / takes_search / query filter by this.
gbrain auth list List all tokens
gbrain auth revoke <name> Revoke a legacy token
--scopes narrows the token to the listed op scopes
(comma or space separated; omit = full access,
grandfathered).
gbrain auth list List all tokens (id, scopes, usage)
gbrain auth revoke <name> Revoke a legacy token (ALL active rows with that name)
gbrain auth revoke --id <uuid> Revoke exactly one token by id (names are not unique)
gbrain auth permissions <name> set-takes-holders <h1,h2,h3>
Update visibility for an existing token
gbrain auth register-client <name> [options] Register an OAuth 2.1 client (v0.26+)
+131 -9
View File
@@ -62,11 +62,23 @@ import {
} from '../core/bootstrap/hooks.ts';
import {
guardReceiptOverwrite,
readHarnessReceiptState,
readManifest,
readReceipt,
writeReceipt,
type InstallReceipt,
} from '../core/bootstrap/format.ts';
import {
applyHarness,
codexBlockOwnsName,
ensureHarnessHome,
parseHarnessArgs,
removeHarness,
statusHarness,
type HarnessDeps,
} from '../core/bootstrap/harness.ts';
import { codexConfigPath } from '../core/bootstrap/host-specs.ts';
import { promptLine } from '../core/cli-util.ts';
import {
appendInstallLog,
gitOriginUrl,
@@ -102,6 +114,15 @@ Subcommands (run \`gbrain bootstrap status\` first — it is the resume entrypoi
verify [--json] The whole install contract (round-trip, graph floor,
magic moment, scans, hooks smoke). Exit 0 or not done.
attach [--harness H] Machine two: adopt a cloned agent workspace.
harness [--harness claude-code|codex|all] [--url U | --port N] [--source ID]
[--token-name NAME | --token TOK] [--name MCPNAME] [--project DIR]...
[--no-hooks] [--no-capture] [--force] [--status] [--remove] [--yes] [--json]
Wire framework-spawned Claude Code / Codex sessions to a
RUNNING \`gbrain serve --http\` on this box (#4043): scoped
bearer token, user-scope MCP + headless pre-approval,
lifecycle hooks (user scope, or per --project dir), codex
config block. No agent.json needed. Idempotent; --remove
tears it down. (--local is an accepted no-op alias.)
cloud-setup-script Print the paste-ready cloud environment setup
script (installs the gbrain binary into the
environment snapshot; npm-based — bun fetching
@@ -812,6 +833,19 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
const hooksConsent = !noHooks && (consentAnswer(ws, 'HOOKS_CONSENT') ?? 'yes').toLowerCase() === 'yes';
const gbrainHome = process.env.GBRAIN_HOME?.trim() || undefined;
// One owner per codex server name: if the harness lane's managed TOML block
// owns [mcp_servers.gbrain], this stdio registration must not fight it —
// the FIX7 mismatch path would `codex mcp remove` the harness's server and
// strand orphan marker comments (#4043 ownership rule).
if (harness === 'codex' && codexBlockOwnsName(codexConfigPath(), 'gbrain')) {
console.log(
"the 'gbrain' codex MCP server is managed by `gbrain bootstrap harness` (marker block in the codex " +
'config) — skipping the stdio registration. Run `gbrain bootstrap harness --remove` first if you ' +
'want this workspace-lane stdio registration instead.',
);
return 0;
}
return withLock(ws, async () => {
// 0. source_id visibility seam: `hooks` is the last ENGINE-FREE phase
// before `verify` (which alone can detect a source_id collision — the
@@ -1011,7 +1045,7 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
);
}
} else {
console.log('Codex has no hook system — per-turn context is the AGENTS.md pull protocol (stated plainly, not a bug).');
console.log('gbrain does not wire Codex hooks yet — per-turn context is the AGENTS.md pull protocol (stated plainly; the codex hook lane is a filed follow-up).');
}
// 4. Receipt registration record [CX2-12]. Detail records what actually
@@ -1111,11 +1145,74 @@ export function workspaceBrainStats(ws: string): { sources: string[]; pages: num
return { sources, pages };
}
/** `gbrain bootstrap harness` (#4043) — machine-level, no workspace, no
* agent.json. Locks on the gbrain HOME (there is no workspace to lock). */
async function runHarness(rest: string[], home: string, runner: ExecRunner): Promise<number> {
const flags = parseHarnessArgs(rest);
if (flags.error) {
console.error(flags.error);
return 2;
}
const deps: HarnessDeps = {
runner,
gbrainHome: home,
// Fallback only — the flag itself is parsed (and error-checked) once, by
// parseHarnessArgs; flags.gbrainBin wins inside applyHarness.
gbrainBin: resolveGbrainBin(),
isTTY: process.stdout.isTTY === true,
prompt: promptLine,
};
// [X12] --status is READ-ONLY: no home mkdir, no lock — it must work (and
// stay side-effect-free) even while an apply/remove holds the mutex.
if (flags.status) {
return statusHarness(flags, deps);
}
ensureHarnessHome(home);
return withLock(home, async () => {
if (flags.remove) {
const code = await removeHarness(flags, deps);
abortIfInjected('harness');
return code;
}
const code = await applyHarness(flags, deps);
abortIfInjected('harness');
return code;
});
}
async function runUninstall(ws: string, rest: string[], home: string, runner: ExecRunner): Promise<number> {
const deleteBrain = rest.includes('--delete-brain');
const yes = rest.includes('--yes');
const homeFlag = flagValue(rest, '--home');
const effectiveHome = homeFlag ? resolve(homeFlag) : home;
return withLock(ws, async () => {
// The HOME lock (runHarness's mutex) is held across the ENTIRE uninstall
// body — not just the harness-removal step — so a concurrent
// `bootstrap harness` apply can never mint+wire in the window between
// harness removal and the workspace teardown's rm of <home>/bootstrap
// (which would strand a fresh receipt + live wiring). Consistent order
// (ws → home), distinct dirs, so no deadlock; same-dir configs skip the
// nested acquire (the lock is non-reentrant).
const body = async (): Promise<number> => {
// Harness wiring is removed FIRST (#4043 ordering, load-bearing twice
// over: the token revoke needs the DB alive, and --delete-brain rmSyncs
// <home>/bootstrap — which would destroy harness.json unconsumed).
const harnessState = readHarnessReceiptState(effectiveHome);
let harnessRemoved = false;
if (harnessState.state !== 'absent') {
console.log('harness wiring detected — removing it first (token revoke needs the brain alive).');
const flags = parseHarnessArgs(['--remove', ...(yes ? ['--yes'] : [])]);
const code = await removeHarness(flags, { runner, gbrainHome: effectiveHome });
if (code !== 0) {
console.error(
'harness removal did not fully converge — stopping BEFORE workspace teardown so the harness ' +
'receipt is never stranded. Fix the reported issue (or stop the live serve) and re-run.',
);
return 1;
}
harnessRemoved = true;
}
if (deleteBrain) {
// Facts-export offer BEFORE any deletion can run — facts are user
// knowledge, not derived state; after the rm there is nothing to export.
@@ -1127,13 +1224,31 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
// the durability teardown below needs it and the manifest may not survive.
const preState = readManifest(ws);
const durabilitySourceId = preState.state === 'initialized' ? preState.manifest.source_id : 'workspace';
const result = await uninstallWorkspace(ws, {
deleteBrain,
...(yes ? { confirm: async () => true } : {}),
gbrainHomeDir: homeFlag ? resolve(homeFlag) : home,
homeExplicit: homeFlag !== undefined,
brainStats: async () => workspaceBrainStats(ws),
});
let result;
try {
result = await uninstallWorkspace(ws, {
deleteBrain,
...(yes ? { confirm: async () => true } : {}),
gbrainHomeDir: effectiveHome,
homeExplicit: homeFlag !== undefined,
brainStats: async () => workspaceBrainStats(ws),
});
} catch (e) {
// A harness-only box has machine-level wiring but no workspace install:
// the pre-teardown refusals that mean "this workspace isn't the
// bootstrapped one" end the run as success once harness removal ran.
// LIVE_SERVE and everything else stay hard refusals.
if (
harnessRemoved &&
e instanceof BootstrapError &&
(e.code === 'NO_RECEIPT' || e.code === 'HOME_GUARD' || e.code === 'RECEIPT_MISMATCH')
) {
console.log(`no workspace install on this machine (naming the refusal: ${e.code}); harness wiring removed.`);
abortIfInjected('uninstall');
return 0;
}
throw e;
}
// Execute the structured host-registration removals the module returned.
for (const reg of result.registration_removals) {
@@ -1196,6 +1311,8 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
console.log('The workspace repo and its files remain yours — the body is portable by design.');
abortIfInjected('uninstall');
return 0;
};
return resolve(effectiveHome) === resolve(ws) ? body() : withLock(effectiveHome, body);
});
}
@@ -1221,7 +1338,7 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
const logCtx: LogCtx = { home, ws, ...(harnessForLog ? { harness: harnessForLog } : {}) };
const t0 = Date.now();
const KNOWN = new Set(['status', 'interview', 'render', 'repo', 'hooks', 'verify', 'attach', 'uninstall', 'cloud-setup-script']);
const KNOWN = new Set(['status', 'interview', 'render', 'repo', 'hooks', 'verify', 'attach', 'uninstall', 'harness', 'cloud-setup-script']);
if (!KNOWN.has(sub)) {
console.error(`unknown subcommand: ${sub}`);
console.error(BOOTSTRAP_HELP);
@@ -1240,6 +1357,8 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
// The install log records the PHASE name, and the hooks subcommand is the
// 'wire' phase (status.ts phase list) — one mapping, used at every log site.
// 'harness' is its own log phase (NOT a status.ts phase — that list is
// CI-pinned; install.jsonl phase names are free-form telemetry).
const logPhaseName = sub === 'hooks' ? 'wire' : sub;
try {
@@ -1276,6 +1395,9 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
case 'uninstall':
code = await runUninstall(ws, rest, home, runner);
break;
case 'harness':
code = await runHarness(rest, home, runner);
break;
default:
return 2; // unreachable
}
+34 -139
View File
@@ -36,14 +36,44 @@ import { execFileSync } from 'child_process';
import type { ConnectProbeResult } from '../core/connect-probe.ts';
import { probeBrainIdentity, DEFAULT_PROBE_TIMEOUT_MS } from '../core/connect-probe.ts';
import { promptLine } from '../core/cli-util.ts';
import {
NAME_RE,
REDACTED,
buildClaudeMcpAddArgv,
buildCodexMcpAddArgv,
cmdString,
isValidName,
issuerFromMcpUrl,
normalizeMcpUrl,
redactToken,
shellQuote,
validateToken,
} from '../core/mcp-registration.ts';
// The pure registration helpers moved to src/core/mcp-registration.ts for
// #4043 (the bootstrap harness lane consumes them; core must not import from
// commands). Re-exported so this module's public surface — and every test
// that imports from it — is unchanged.
export {
REDACTED,
buildClaudeMcpAddArgv,
buildCodexMcpAddArgv,
cmdString,
isLinkLocalOrMetadata,
issuerFromMcpUrl,
isValidName,
normalizeMcpUrl,
redactToken,
validateToken,
type TokenValidation,
type UrlResult,
} from '../core/mcp-registration.ts';
export const ENV_VAR = 'GBRAIN_REMOTE_TOKEN';
export const PLACEHOLDER_TOKEN = '<paste-your-token>';
export const PLACEHOLDER_SECRET = '<paste-your-client-secret>';
export const REDACTED = '***';
export const DEFAULT_NAME = 'gbrain';
export const DEFAULT_SCOPES = 'read write';
const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
// Single source of truth shared with the probe (was a duplicated 15_000 literal).
const DEFAULT_TIMEOUT_MS = DEFAULT_PROBE_TIMEOUT_MS;
@@ -134,108 +164,10 @@ Examples:
`;
// ---------------------------------------------------------------------------
// Pure helpers (unit-tested in test/connect.test.ts)
// Pure helpers (unit-tested in test/connect.test.ts; registration helpers
// live in src/core/mcp-registration.ts and are re-exported above)
// ---------------------------------------------------------------------------
export type UrlResult =
| { ok: true; url: string; warning?: string }
| { ok: false; error: string };
/**
* Block link-local / cloud-metadata addresses — the one class of host that is
* never a legitimate brain endpoint but IS a token-exfil target (e.g. the AWS/
* GCP metadata service at 169.254.169.254). Deliberately does NOT block
* localhost or RFC1918/LAN ranges: self-hosted brains on a private network are
* a documented, supported topology (`gbrain serve --http --bind`).
*/
export function isLinkLocalOrMetadata(hostname: string): boolean {
const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(h)) return true; // IPv4 link-local incl. cloud metadata
if (h.startsWith('fe80:')) return true; // IPv6 link-local
if (h === 'fd00:ec2::254') return true; // AWS IMDSv2 over IPv6
// IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254 dotted, or ::ffff:a9fe:xxxx
// hex where a9fe == 169.254) must not slip past the dotted-IPv4 check.
const mapped = h.match(/^::ffff:(.+)$/);
if (mapped) {
if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(mapped[1])) return true;
if (mapped[1].startsWith('a9fe:')) return true;
}
return false;
}
/**
* Normalize an MCP URL to a canonical `<scheme>//<host><path>` ending in /mcp.
* Explicit spec (not best-effort) — see plan D-codex findings.
*/
export function normalizeMcpUrl(input: string): UrlResult {
const raw = (input ?? '').trim();
if (!raw) {
return { ok: false, error: 'Missing MCP URL. Usage: gbrain connect <https://host/mcp> --token <bearer>' };
}
// Require an explicit scheme. A bare `host:3131` parses as scheme `host:`
// under WHATWG URL, so reject anything without `://`.
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
const guess = raw.replace(/^\/+/, '');
return { ok: false, error: `Add an explicit scheme, e.g. https://${guess} (a bare host:port is ambiguous).` };
}
let u: URL;
try {
u = new URL(raw);
} catch {
return { ok: false, error: `Invalid URL: ${raw}` };
}
const scheme = u.protocol.toLowerCase();
if (scheme !== 'http:' && scheme !== 'https:') {
return { ok: false, error: `Only http(s) URLs are supported (got ${u.protocol}).` };
}
if (u.username || u.password) {
return { ok: false, error: 'Remove credentials from the URL (user:pass@host is not supported); pass the token via --token.' };
}
if (u.search) {
return { ok: false, error: 'Remove the query string from the MCP URL.' };
}
if (isLinkLocalOrMetadata(u.hostname)) {
return { ok: false, error: `Refusing to target a link-local / cloud-metadata address (${u.hostname}). Point the MCP URL at the brain host's real address.` };
}
const host = u.host; // host:port; hostname already lowercased by URL
const path = u.pathname;
const trimmed = path.replace(/\/+$/, '');
const lower = trimmed.toLowerCase();
let finalPath: string;
if (path === '' || path === '/') {
finalPath = '/mcp';
} else if (lower === '/mcp') {
finalPath = '/mcp';
} else {
return {
ok: false,
error: `Unexpected path '${path}'. Pass the full /mcp URL, e.g. ${scheme}//${host}${trimmed}/mcp`,
};
}
const url = `${scheme}//${host}${finalPath}`;
const hn = u.hostname.toLowerCase();
const isLocal = hn === 'localhost' || hn === '127.0.0.1' || hn === '::1' || hn === '[::1]';
if (scheme === 'http:' && !isLocal) {
return { ok: true, url, warning: 'Warning: http:// sends your bearer token unencrypted. Use https:// unless this is localhost.' };
}
return { ok: true, url };
}
/** The OAuth issuer is the server base — the /mcp endpoint's URL minus /mcp. */
export function issuerFromMcpUrl(url: string): string {
return url.replace(/\/mcp$/, '');
}
export type TokenValidation = { ok: true } | { ok: false; error: string };
/** Reject empty/whitespace/control-char tokens (a newline is a header-injection vector). */
export function validateToken(token: string): TokenValidation {
if (!token || !token.trim()) return { ok: false, error: 'Token is empty.' };
if (/\s/.test(token)) return { ok: false, error: 'Token contains whitespace (space/tab/newline) — refusing (header-injection risk).' };
if (/[\x00-\x1f\x7f]/.test(token)) return { ok: false, error: 'Token contains control characters — refusing (header-injection risk).' };
return { ok: true };
}
export type TokenResolution =
| { kind: 'literal'; token: string }
| { kind: 'placeholder' }
@@ -255,43 +187,6 @@ export function resolveToken(opts: { tokenFlag?: string | null; env?: string | n
};
}
export function isValidName(name: string): boolean {
return NAME_RE.test(name);
}
export function buildClaudeMcpAddArgv(p: { name: string; url: string; headerToken: string }): string[] {
return ['mcp', 'add', p.name, '-t', 'http', p.url, '-H', `Authorization: Bearer ${p.headerToken}`];
}
/** Codex reads the bearer from an env var at runtime — the token is NOT in argv. */
export function buildCodexMcpAddArgv(p: { name: string; url: string; envVar: string }): string[] {
return ['mcp', 'add', p.name, '--url', p.url, '--bearer-token-env-var', p.envVar];
}
/**
* POSIX single-quote any arg that isn't already shell-safe, so `$()`, backticks,
* etc. in a token are inert literals when the block is pasted into a shell
* (double-quoting would still allow command substitution).
*/
function shellQuote(arg: string): string {
if (/^[A-Za-z0-9_.:/@-]+$/.test(arg)) return arg;
return `'${arg.replace(/'/g, "'\\''")}'`;
}
/** Render `<binary> <argv...>` as a copy-pasteable, shell-safe command string. */
export function cmdString(binary: string, argv: string[]): string {
return `${binary} ${argv.map(shellQuote).join(' ')}`;
}
export function redactToken(s: string, token: string | null): string {
// Exact-substring scrub of the known token, plus a defense-in-depth pass over
// any `Bearer <value>` shape the SDK/CLI might echo in a transformed form the
// exact match would miss. Both run on the --install error paths only.
let out = token ? s.split(token).join(REDACTED) : s;
out = out.replace(/Bearer\s+\S+/gi, `Bearer ${REDACTED}`);
return out;
}
export interface OAuthCreds {
issuer: string;
clientId: string;
+75 -2
View File
@@ -72,7 +72,7 @@ import { escapeLikePattern, buildVisibilityClause } from '../core/search/sql-ran
import { unverifiedExtractionFragment } from '../core/extraction-review.ts';
import { hnswIndexExpected, hnswMaxDimsForType } from '../core/vector-index.ts';
// Agent-bootstrap doctor group (plan B2/B4/ENG-4 + one-live-serve note).
import { readReceipt } from '../core/bootstrap/format.ts';
import { readHarnessReceiptState, readReceipt } from '../core/bootstrap/format.ts';
import { probeLivePgliteHolder, resolveBrainDataDir } from '../core/bootstrap/uninstall.ts';
import { readRunbookStamp, hooksInstalled, listVerifyRuns } from '../core/bootstrap/status.ts';
import { resolveGbrainHome } from '../core/gbrain-home.ts';
@@ -8774,11 +8774,84 @@ export async function bootstrapDoctorChecks(engine: BrainEngine | null): Promise
const pushStatuses = readPushStatuses();
const statusFilesOnDisk = pushStatusFilesExist();
const heartbeatFile = join(home, 'integrations', 'hooks', 'heartbeat.jsonl');
const hasBootstrapState = receipt !== null || statusFilesOnDisk || existsSync(heartbeatFile);
// #4043: a harness-only box (bootstrap harness, no workspace install) is
// bootstrap state too — without this, such a machine gets ZERO checks.
const harnessState = readHarnessReceiptState(home);
const hasBootstrapState =
receipt !== null || statusFilesOnDisk || existsSync(heartbeatFile) || harnessState.state !== 'absent';
if (!hasBootstrapState) return [];
const ws = receipt?.workspace_dir ?? null;
// 0. Harness registration health (#4043): three states so it neither cries
// wolf nor goes silent — skip (not a harness box) / warn (serve unreachable,
// a normal transient; or receipt unreadable) / fail (a target failed, or a
// prior rotation never converged). Token liveness needs the bearer (only
// recoverable from host config) — that's `gbrain bootstrap harness
// --status`'s job; doctor stays offline-cheap.
if (harnessState.state === 'ok') {
const hr = harnessState.receipt;
const failed = hr.targets.filter((t) => t.state === 'failed');
const pending = hr.targets.filter((t) => t.state === 'pending');
if (failed.length > 0 || pending.length > 0) {
checks.push({
name: 'bootstrap_harness_health',
status: 'fail',
message:
`harness wiring incomplete: ${failed.length} failed / ${pending.length} pending target(s)` +
` — re-run \`gbrain bootstrap harness\` to converge (details: gbrain bootstrap harness --status).`,
});
} else if (hr.token.previous_ids && hr.token.previous_ids.length > 0) {
checks.push({
name: 'bootstrap_harness_health',
status: 'fail',
message: `${hr.token.previous_ids.length} previous harness token(s) were never revoked (ids ${hr.token.previous_ids.join(', ')}) — re-run \`gbrain bootstrap harness\`, or run \`gbrain auth revoke\` with the id flag per id.`,
});
} else if (hr.targets.length === 0 && hr.token.minted && hr.token.id !== undefined) {
// Half-removed state: a remove under a live PGLite serve strips every
// host target but defers the revoke — the wiring is gone yet the minted
// token stays ACTIVE. A vacuous all-confirmed must not read green.
// (Flag names spelled without dashes here: the flag-registry generator
// harvests bare flag tokens from comments one import level deep.)
checks.push({
name: 'bootstrap_harness_health',
status: 'fail',
message: `harness removal pending: host wiring removed but the minted token (id ${hr.token.id}) is not yet revoked — stop the serve and re-run \`gbrain bootstrap harness\` with the remove flag, or run \`gbrain auth revoke\` with the id flag.`,
});
} else {
try {
const base = hr.url.replace(/\/mcp$/, '');
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
const body = res.ok ? ((await res.json()) as { status?: string }) : null;
if (body?.status === 'ok') {
checks.push({
name: 'bootstrap_harness_health',
status: 'ok',
message: `harness wired to ${hr.url} (serve healthy; token check: gbrain bootstrap harness --status)`,
});
} else {
checks.push({
name: 'bootstrap_harness_health',
status: 'warn',
message: `harness wired to ${hr.url} but the serve is not answering /health — start \`gbrain serve\` in http mode (a down serve is a normal transient, sessions just lose brain access until it returns).`,
});
}
} catch {
checks.push({
name: 'bootstrap_harness_health',
status: 'warn',
message: `harness wired to ${hr.url} but the serve is unreachable — start \`gbrain serve\` in http mode.`,
});
}
}
} else if (harnessState.state !== 'absent') {
checks.push({
name: 'bootstrap_harness_health',
status: 'warn',
message: `the harness receipt is unreadable (${harnessState.state}) — see \`gbrain bootstrap harness --status\`.`,
});
}
// 1. Hook heartbeat failure rate [B3 read side]. Hard errors only —
// degraded entries are DESIGNED fallbacks (pull-mode, no serve).
let hooksSeen = false;
+45
View File
@@ -211,6 +211,51 @@ export async function runHook(args: string[], io: HookIo = {}): Promise<number>
// for silence, and a disabled hook writing telemetry would be a lie).
if (process.env.GBRAIN_HOOKS === '0') return 0;
// #4043 harness-lane defer guard: Claude Code MERGES user- and
// project-scope hook settings, so a machine wired by `bootstrap harness`
// (user scope) plus a real workspace bootstrap install (settings.local.json,
// bootstrap-v1 marker) would fire the same event twice. The workspace
// install wins; the harness lane yields silently (exit 0, no output, no
// heartbeat). Same cwd resolution as the handlers (io.cwd is the test
// seam; the harness runs hooks in the session's working dir). Fail-open:
// any read hiccup means run normally.
if (process.env.GBRAIN_HOOK_LANE === 'harness') {
try {
// BOTH workspace carriers count: settings.local.json (local installs)
// and the committed .claude/settings.json ([D12] — an event owned by
// the committed carrier is stripped from local, so checking only local
// would double-fire it against the user-scope harness wiring). The
// check PARSES the settings and requires a live bootstrap-v1 hook entry
// wiring THIS event — a raw substring match would let any repo disable
// the machine-wide capture lane by committing the two marker strings in
// an unrelated field (ship-review P1), and would over-yield events the
// workspace does not actually wire.
const eventKey = {
'session-start': 'SessionStart',
'user-prompt': 'UserPromptSubmit',
stop: 'Stop',
'session-end': 'SessionEnd',
compact: 'PreCompact',
}[event];
const dotClaude = join(io.cwd ?? process.cwd(), '.claude');
for (const file of ['settings.local.json', 'settings.json']) {
const p = join(dotClaude, file);
if (!existsSync(p)) continue;
const settings = JSON.parse(readFileSync(p, 'utf8')) as {
hooks?: Record<string, Array<{ hooks?: Array<Record<string, unknown>> }>>;
};
const groups = settings.hooks?.[eventKey ?? ''];
if (!Array.isArray(groups)) continue;
for (const g of groups) {
if (!Array.isArray(g?.hooks)) continue;
if (g.hooks.some((e) => e?._gbrain === 'bootstrap-v1')) return 0;
}
}
} catch {
/* fail-open */
}
}
switch (event) {
case 'session-start':
return hookSessionStart(io);
+13 -2
View File
@@ -36,6 +36,7 @@ import {
} from '../core/oauth-provider.ts';
import type { SqlQuery } from '../core/oauth-provider.ts';
import { hasScope, ALLOWED_SCOPES_LIST, normalizeScopesInput } from '../core/scope.ts';
import { normalizeTokenScopes } from '../core/legacy-token-scope.ts';
import { normalizeSourceInput, normalizeFederatedReadInput } from '../core/source-id.ts';
import { summarizeMcpParams, dispatchToolCall, requestLogStatusForResult } from '../mcp/dispatch.ts';
import { resolveStrictParamsMode } from '../mcp/validate-params.ts';
@@ -1365,7 +1366,9 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
`;
const legacyKeys = await sql`
SELECT a.id, a.name, 'api_key' as auth_type,
'{"bearer"}' as grant_types, 'read write admin' as scope, a.created_at, null as token_ttl,
'{"bearer"}' as grant_types,
a.scopes,
a.created_at, null as token_ttl,
CASE WHEN a.revoked_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status,
a.last_used_at,
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = a.name) as total_requests,
@@ -1374,7 +1377,15 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
`;
res.json([
...oauthClients,
...legacyKeys.map((key) => ({ ...key, source_id: null, federated_read: [] })),
...legacyKeys.map(({ scopes, ...key }) => ({
...key,
// The SAME normalizer the verify path uses — the dashboard must
// never display a grant the serve doesn't enforce (NULL =
// grandfathered full access; damaged/deny rows show empty).
scope: normalizeTokenScopes(scopes)?.join(' ') ?? 'read write admin',
source_id: null,
federated_read: [],
})),
]);
} catch (e) {
res.status(503).json({ error: 'service_unavailable' });
+287
View File
@@ -0,0 +1,287 @@
/**
* codex-toml.ts managed marker-block writer for Codex's config.toml
* (#4043, the fired CX2-17 revisit; see TARGETS['codex-2026-08'] in
* host-specs.ts for the verified format assumptions).
*
* Why a direct writer exists at all: `codex mcp add` cannot express an
* inline `bearer_token` (only `--bearer-token-env-var`, which reintroduces
* the shell-profile dependency framework-spawned codex lacks), so the
* harness lane owns exactly one `[mcp_servers.<name>]` table between two
* full-line comment markers. Everything outside the markers survives
* byte-for-byte.
*
* Safety invariants [C10 + adversarial-verify corrections]:
* - Foreign-server detection parses the file (Bun.TOML.parse) with our block
* stripped a header-only regex false-negatives inline tables, dotted
* keys, and quoted headers, and a duplicate table definition is a HARD
* TOML parse error that bricks codex outright.
* - Rewrites REMOVE the old block and RE-ANCHOR at EOF (refusing on trailing
* content would let any later legitimate codex write permanently block
* update/removal; at EOF our table can never absorb someone else's keys).
* - The rendered output is parse-validated BEFORE rename, and our table's
* keys are asserted to be exactly what we wrote; on any failure the
* original file is kept.
* - Exactly one begin/end pair, in order, full-line match anything else is
* a hand-edit; refuse rather than guess.
* - Secrets hygiene: tmp file created 0600 with a random suffix, the target
* is chmod'd 0600 (it carries a bearer token), and the .bak is chmod'd
* 0600 (on re-runs it carries the PREVIOUS token).
* - CRLF configs are scanned with `\r` stripped and re-emitted with their
* dominant EOL; a missing trailing newline is repaired before append so a
* marker can never glue onto the last line (the run-2 duplicate-append
* brick).
*/
import { randomBytes } from 'node:crypto';
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
renameSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname } from 'node:path';
import { CODEX_TOML_BLOCK_BEGIN, CODEX_TOML_BLOCK_END } from './host-specs.ts';
export interface CodexHttpServerBlock {
/** MCP server name — bare-key charset only (goes into a table header). */
name: string;
/** Streamable-HTTP MCP endpoint url (normalized upstream). */
url: string;
/** Bearer token written inline (the whole point of the direct writer). */
bearerToken: string;
}
export interface WriteCodexBlockResult {
configPath: string;
/** True when a prior managed block was replaced (idempotent re-run). */
replacedPrior: boolean;
backupPath: string | null;
notes: string[];
}
export interface RemoveCodexBlockResult {
configPath: string;
removed: boolean;
backupPath: string | null;
notes: string[];
}
/** Escape a value for a TOML basic string. */
export function tomlString(value: string): string {
if (/[\n\r\0\t]/.test(value)) {
throw new Error('control characters are not allowed in codex config values');
}
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function assertBareKeyName(name: string): void {
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
throw new Error(
`MCP server name "${name}" is not a bare TOML key ([A-Za-z0-9_-]+) — pick a simpler --name`,
);
}
}
interface BlockSpan {
/** Line index of the begin marker, or -1 when absent. */
begin: number;
/** Line index of the end marker, or -1 when absent. */
end: number;
}
/**
* Locate the managed block in \n-normalized lines. Throws on marker
* anomalies (duplicates, out of order, one without the other) those are
* hand-edits we must not guess through.
*/
function findBlock(lines: string[]): BlockSpan {
const begins: number[] = [];
const ends: number[] = [];
lines.forEach((line, i) => {
if (line === CODEX_TOML_BLOCK_BEGIN) begins.push(i);
if (line === CODEX_TOML_BLOCK_END) ends.push(i);
});
if (begins.length === 0 && ends.length === 0) return { begin: -1, end: -1 };
if (begins.length !== 1 || ends.length !== 1 || begins[0] > ends[0]) {
throw new Error(
`the gbrain-managed block markers in this config.toml are damaged ` +
`(${begins.length} begin / ${ends.length} end${begins.length === 1 && ends.length === 1 ? ', out of order' : ''}) — ` +
`the file was hand-edited inside the managed region. Fix the markers (or delete the whole block) and re-run.`,
);
}
return { begin: begins[0], end: ends[0] };
}
function stripBlock(lines: string[]): { remainder: string[]; hadBlock: boolean } {
const span = findBlock(lines);
if (span.begin === -1) return { remainder: lines, hadBlock: false };
const remainder = [...lines.slice(0, span.begin), ...lines.slice(span.end + 1)];
return { remainder, hadBlock: true };
}
function parseToml(text: string): Record<string, unknown> {
// Bun.TOML is available in this runtime (no dependency added).
const parsed = (Bun as unknown as { TOML: { parse(t: string): unknown } }).TOML.parse(text);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('config.toml did not parse to a table');
}
return parsed as Record<string, unknown>;
}
/**
* True when `[mcp_servers.<name>]` is defined OUTSIDE our managed block, in
* ANY toml spelling (header, inline table, dotted key, quoted header,
* array-of-tables). Throws when the surrounding config does not parse
* appending to a broken file could only deepen the damage.
*/
export function detectForeignCodexServer(configText: string, name: string): boolean {
const lines = configText.replace(/\r\n/g, '\n').split('\n');
const { remainder } = stripBlock(lines);
const parsed = parseToml(remainder.join('\n'));
const servers = parsed.mcp_servers;
if (typeof servers !== 'object' || servers === null) return false;
return (servers as Record<string, unknown>)[name] !== undefined;
}
function renderBlock(block: CodexHttpServerBlock): string[] {
return [
CODEX_TOML_BLOCK_BEGIN,
`[mcp_servers.${block.name}]`,
`url = ${tomlString(block.url)}`,
`bearer_token = ${tomlString(block.bearerToken)}`,
CODEX_TOML_BLOCK_END,
];
}
/** Atomic 0600 write preserving symlinks and the file's dominant EOL. */
function atomicWriteToml(configPath: string, unixText: string, crlf: boolean): void {
const target = existsSync(configPath) ? realpathSync(configPath) : configPath;
mkdirSync(dirname(target), { recursive: true });
const tmp = `${target}.tmp-${randomBytes(6).toString('hex')}`;
const out = crlf ? unixText.replace(/\n/g, '\r\n') : unixText;
writeFileSync(tmp, out, { encoding: 'utf8', mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, target);
}
/**
* Idempotently write the managed `[mcp_servers.<name>]` block: strip any
* prior managed block, re-anchor the fresh one at EOF, validate the render,
* then swap atomically. The token lands inline, so the file is forced 0600.
*/
export function writeCodexHttpServerBlock(
configPath: string,
block: CodexHttpServerBlock,
): WriteCodexBlockResult {
assertBareKeyName(block.name);
const notes: string[] = [];
let rawText = '';
let existed = false;
if (existsSync(configPath)) {
existed = true;
rawText = readFileSync(configPath, 'utf8');
}
const crlf = rawText.includes('\r\n');
const lines = rawText.replace(/\r\n/g, '\n').split('\n');
const { remainder, hadBlock } = stripBlock(lines);
// Foreign ownership guard — a duplicate table definition is a hard TOML
// parse error that would brick codex entirely.
const remainderText = remainder.join('\n');
let parsedRemainder: Record<string, unknown>;
try {
parsedRemainder = parseToml(remainderText);
} catch (e) {
throw new Error(
`${configPath} does not parse as TOML outside the gbrain-managed block ` +
`(${(e as Error).message}) — fix the config (codex itself cannot read it either) and re-run.`,
);
}
const servers = parsedRemainder.mcp_servers;
if (typeof servers === 'object' && servers !== null && (servers as Record<string, unknown>)[block.name] !== undefined) {
throw new Error(
`[mcp_servers.${block.name}] is already defined in ${configPath} outside the gbrain-managed block — ` +
`refusing to double-define it (that is a hard TOML parse error). ` +
`Remove the existing entry (codex mcp remove ${block.name}) or pick another name (--name).`,
);
}
// Assemble: remainder (trailing newline repaired, trailing blank run
// collapsed to one separator line) + block at EOF.
const trimmed = [...remainder];
while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') trimmed.pop();
const body = trimmed.length > 0 ? [...trimmed, ''] : [];
const nextLines = [...body, ...renderBlock(block), ''];
const nextText = nextLines.join('\n');
// Post-render validation: parse + assert OUR table's keys are exactly ours.
const rendered = parseToml(nextText);
const renderedServers = rendered.mcp_servers as Record<string, unknown> | undefined;
const ours = renderedServers?.[block.name];
const ourKeys = typeof ours === 'object' && ours !== null ? Object.keys(ours as object).sort() : [];
if (ourKeys.join(',') !== 'bearer_token,url') {
throw new Error(
`post-render validation failed: [mcp_servers.${block.name}] keys are [${ourKeys.join(', ')}], ` +
`expected exactly [bearer_token, url] — original file left untouched.`,
);
}
let backupPath: string | null = null;
if (existed) {
backupPath = `${configPath}.bak`;
copyFileSync(configPath, backupPath);
chmodSync(backupPath, 0o600); // on re-runs the .bak carries the previous token
const mode = statSync(configPath).mode & 0o777;
if ((mode & 0o077) !== 0) {
notes.push(
`${configPath} was group/other-readable (mode ${mode.toString(8)}); tightened to 0600 — it now carries a bearer token.`,
);
}
}
atomicWriteToml(configPath, nextText, crlf);
return { configPath, replacedPrior: hadBlock, backupPath, notes };
}
/**
* Remove the managed block (marker-keyed; content outside survives
* byte-for-byte). Absent file / absent block are calm no-ops. Damaged
* markers refuse via findBlock removal never guesses.
*/
export function removeCodexHttpServerBlock(
configPath: string,
name: string,
): RemoveCodexBlockResult {
assertBareKeyName(name);
const notes: string[] = [];
if (!existsSync(configPath)) {
return { configPath, removed: false, backupPath: null, notes: ['no config.toml — nothing to remove'] };
}
const rawText = readFileSync(configPath, 'utf8');
const crlf = rawText.includes('\r\n');
const lines = rawText.replace(/\r\n/g, '\n').split('\n');
const { remainder, hadBlock } = stripBlock(lines);
if (!hadBlock) {
return { configPath, removed: false, backupPath: null, notes: ['no gbrain-managed block — nothing to remove'] };
}
// Validate what we are about to write back — never leave codex unreadable.
const trimmed = [...remainder];
while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') trimmed.pop();
const nextText = trimmed.length > 0 ? `${trimmed.join('\n')}\n` : '';
if (nextText !== '') parseToml(nextText);
const backupPath = `${configPath}.bak`;
copyFileSync(configPath, backupPath);
chmodSync(backupPath, 0o600);
atomicWriteToml(configPath, nextText, crlf);
return { configPath, removed: true, backupPath, notes };
}
+142 -2
View File
@@ -19,8 +19,8 @@
* manifest but not the receipt.
*/
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
export const FORMAT_VERSION = 1;
export const AGENT_MANIFEST_FILENAME = 'agent.json';
@@ -202,3 +202,143 @@ export function writeReceipt(gbrainHomeDir: string, receipt: InstallReceipt): vo
writeFileSync(tmp, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
renameSync(tmp, path);
}
// ---------------------------------------------------------------------------
// Machine-level harness receipt (#4043 `gbrain bootstrap harness`)
//
// A SEPARATE file from receipt.json on purpose: the install receipt is
// workspace-keyed (workspace_dir/source_id/agent_name are required — a
// harness-only box has none of those), and the two lifecycles differ
// (workspace uninstall vs harness --remove). Same CX2-12 discipline: typed
// read states, newer-format refusal, broken-file backup-aside, atomic write.
//
// WRITE-AHEAD contract [F1/C7]: the apply path persists this receipt right
// after minting — every planned target starts `pending` and flips to
// `confirmed` (or records its failure) as the wiring lands, and
// `token.previous_ids` carries every not-yet-revoked prior token until rotation
// completes. A crash at any step leaves a receipt --remove can consume.
// ---------------------------------------------------------------------------
export type HarnessTargetKind = 'mcp' | 'permission' | 'hooks';
export type HarnessTargetState = 'pending' | 'confirmed' | 'failed';
export interface HarnessTarget {
host: 'claude-code' | 'codex';
kind: HarnessTargetKind;
state: HarnessTargetState;
/** user scope or a --project dir (hooks); user for mcp/permission. */
scope: string;
/** Settings/config file the target writes (absent for CLI-mediated mcp). */
path?: string;
/** MCP server name (mcp targets). */
name?: string;
/** permissions.allow entry (permission targets). */
entry?: string;
/** Hook marker value (hooks targets). */
marker?: string;
/** Write mechanism note, e.g. 'toml-block' vs 'claude-cli'. */
mechanism?: string;
/** One-line failure reason when state === 'failed'. */
error?: string;
}
export interface HarnessReceipt {
harness_receipt_version: 1;
created_at: string;
created_by: string;
/** Normalized MCP endpoint the box is wired to. */
url: string;
/** Engine reported by /health at apply time (postgres → degraded per-turn). */
engine?: string;
/** Serve version at apply time — --status re-checks for skew [F7]. */
serve_version?: string;
source_id: string;
token: {
name: string;
/** Row id — the only safe revocation key. */
id?: string;
/** False when --token supplied a pre-minted token (never revoke those). */
minted: boolean;
/**
* EVERY prior minted-token id still awaiting revocation (mint-first
* rotation [C7]). An array, not a slot [X4]: a failed rotation must not
* forget the token before last, and a --token re-run must keep carrying
* ids minted by earlier runs until they are revoked.
*/
previous_ids?: string[];
};
targets: HarnessTarget[];
}
export function harnessReceiptPath(gbrainHomeDir: string): string {
return join(gbrainHomeDir, 'bootstrap', 'harness.json');
}
export type HarnessReceiptReadState =
| { state: 'absent' }
| { state: 'ok'; receipt: HarnessReceipt }
| { state: 'newer'; receiptVersion: number }
| { state: 'invalid' };
export function readHarnessReceiptState(gbrainHomeDir: string): HarnessReceiptReadState {
const path = harnessReceiptPath(gbrainHomeDir);
if (!existsSync(path)) return { state: 'absent' };
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as HarnessReceipt;
if (parsed.harness_receipt_version === 1) {
// Version alone is not enough: every consumer dereferences targets[]
// and token.name unchecked, so a hand-damaged version-1 receipt must
// take the designed 'invalid' → backup-aside path, not a TypeError.
const shapeOk =
Array.isArray(parsed.targets) &&
typeof parsed.token === 'object' &&
parsed.token !== null &&
typeof parsed.token.name === 'string' &&
typeof parsed.url === 'string';
return shapeOk ? { state: 'ok', receipt: parsed } : { state: 'invalid' };
}
if (typeof parsed.harness_receipt_version === 'number' && parsed.harness_receipt_version > 1) {
return { state: 'newer', receiptVersion: parsed.harness_receipt_version };
}
return { state: 'invalid' };
} catch {
return { state: 'invalid' };
}
}
/**
* Pre-write guard (mirror of guardReceiptOverwrite): newer-format refuses
* with an upgrade-first error; an unreadable receipt is backed up loudly so
* the recorded targets remain recoverable.
*/
export function guardHarnessReceiptOverwrite(gbrainHomeDir: string): { brokenBackupPath?: string } {
const state = readHarnessReceiptState(gbrainHomeDir);
if (state.state === 'newer') {
throw new Error(
`the harness receipt at ${harnessReceiptPath(gbrainHomeDir)} was written by a newer gbrain ` +
`(harness_receipt_version ${state.receiptVersion}) — upgrade gbrain before re-running bootstrap harness.`,
);
}
if (state.state === 'invalid') {
const path = harnessReceiptPath(gbrainHomeDir);
const backup = `${path}.broken-${Date.now()}`;
renameSync(path, backup);
return { brokenBackupPath: backup };
}
return {};
}
/** Atomic write, 0600 (records wiring topology + token ids, not plaintext). */
export function writeHarnessReceipt(gbrainHomeDir: string, receipt: HarnessReceipt): void {
const path = harnessReceiptPath(gbrainHomeDir);
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, `${JSON.stringify(receipt, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
renameSync(tmp, path);
}
/** Consume (delete) the harness receipt after a fully successful --remove. */
export function deleteHarnessReceipt(gbrainHomeDir: string): void {
const path = harnessReceiptPath(gbrainHomeDir);
if (existsSync(path)) rmSync(path);
}
File diff suppressed because it is too large Load Diff
+323 -54
View File
@@ -24,12 +24,16 @@
* (and GBRAIN_HOME when isolated) ride the registration itself.
*/
import { randomBytes } from 'node:crypto';
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
renameSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, isAbsolute, join } from 'node:path';
@@ -52,6 +56,12 @@ export interface ClaudeHookEnv {
GBRAIN_SOURCE: string;
/** Set only for --isolated installs (PARENT dir; config appends `.gbrain`). */
GBRAIN_HOME?: string;
/**
* 'harness' on #4043 harness-mode wiring: `gbrain hook` yields when the
* lane is harness AND the cwd carries a workspace bootstrap install, so
* the same event never fires twice (Claude Code merges settings scopes).
*/
GBRAIN_HOOK_LANE?: string;
}
export interface WriteClaudeHooksOpts {
@@ -60,8 +70,40 @@ export interface WriteClaudeHooksOpts {
env: ClaudeHookEnv;
/** Per-event timeout override (SECONDS — the settings-file unit). */
timeoutSecs?: Partial<Record<ClaudeHookEvent, number>>;
/** Subset of events to wire; default all four. */
/** Subset of events to wire; default every event in CLAUDE_HOOK_EVENTS. */
events?: ClaudeHookEvent[];
/**
* Marker VALUE stamped on (and stripped from) our entries. Default is the
* workspace-install marker; harness mode passes GBRAIN_HARNESS_MARKER_VALUE
* so the two installs coexist and each removal strips only its own.
*/
marker?: string;
/**
* [D12] Events already owned by the COMMITTED settings carrier the writer
* strips stale local copies of these but re-adds nothing for them, so one
* event never fires from both files. The workspace wrapper derives it from
* committedHookEvents(ws); path-parameterized callers pass their own.
*/
carriedEvents?: Set<ClaudeHookEvent>;
/**
* Backup strategy for the pre-write copy. 'fixed' (default) keeps the
* historical `.bak` sibling; 'timestamped' avoids the shared-slot problem
* when two writers touch the same file in one transaction.
*/
backupStrategy?: 'fixed' | 'timestamped';
/**
* Refuse when a gbrain entry with a DIFFERENT marker already wires one of
* our target events in this file (harness lane: a workspace install or a
* differently-scoped harness install owns it double-wiring would fire the
* same hook twice per event) [C6].
*/
refuseOnForeignGbrainMarker?: boolean;
/**
* Mode for a FRESHLY-CREATED settings file (existing files keep their mode
* via the atomic writer). Harness user-scope writes pass 0o600 to match
* Claude Code's own convention for that file [X11].
*/
freshMode?: number;
}
export interface WriteClaudeHooksResult {
@@ -125,6 +167,7 @@ export function buildClaudeHookCommand(
): string {
const assignments: string[] = [`GBRAIN_SOURCE=${env.GBRAIN_SOURCE}`];
if (env.GBRAIN_HOME) assignments.push(`GBRAIN_HOME=${env.GBRAIN_HOME}`);
if (env.GBRAIN_HOOK_LANE) assignments.push(`GBRAIN_HOOK_LANE=${env.GBRAIN_HOOK_LANE}`);
const parts = ['env', ...assignments, gbrainBin, 'hook', CLAUDE_HOOK_SUBCOMMAND[event]];
return parts.map(shellQuote).join(' ');
}
@@ -197,20 +240,30 @@ export function committedHookEvents(workspaceDir: string): Set<ClaudeHookEvent>
return carried;
}
function isOurs(entry: unknown): boolean {
function isOurs(entry: unknown, marker: string = GBRAIN_HOOK_MARKER_VALUE): boolean {
return (
typeof entry === 'object' &&
entry !== null &&
(entry as Record<string, unknown>)[GBRAIN_HOOK_MARKER_KEY] === GBRAIN_HOOK_MARKER_VALUE
(entry as Record<string, unknown>)[GBRAIN_HOOK_MARKER_KEY] === marker
);
}
/** True when the entry carries the gbrain marker KEY with any OTHER value. */
function isForeignGbrainMarked(entry: unknown, marker: string): boolean {
if (typeof entry !== 'object' || entry === null) return false;
const v = (entry as Record<string, unknown>)[GBRAIN_HOOK_MARKER_KEY];
return typeof v === 'string' && v !== marker;
}
/**
* Strip marker-carrying command entries from one event's matcher-group array.
* Groups EMPTIED by the removal are dropped; groups that were already empty
* (foreign) survive untouched. Returns the surviving groups + removal count.
* The group-drop rule is marker-independent: it fires only when THIS call's
* filter emptied a previously non-empty group, so it can never drop a group a
* different marker still owns.
*/
function stripOurEntries(groups: unknown[]): { kept: unknown[]; removed: number } {
function stripOurEntries(groups: unknown[], marker: string = GBRAIN_HOOK_MARKER_VALUE): { kept: unknown[]; removed: number } {
const kept: unknown[] = [];
let removed = 0;
for (const group of groups) {
@@ -220,7 +273,7 @@ function stripOurEntries(groups: unknown[]): { kept: unknown[]; removed: number
}
const g = group as HookMatcherGroup;
const before = g.hooks!.length;
const filtered = g.hooks!.filter((h) => !isOurs(h));
const filtered = g.hooks!.filter((h) => !isOurs(h, marker));
removed += before - filtered.length;
if (filtered.length === 0 && before > 0 && filtered.length !== before) {
continue; // we emptied it → drop the husk
@@ -234,12 +287,33 @@ function stripOurEntries(groups: unknown[]): { kept: unknown[]; removed: number
return { kept, removed };
}
/** Atomic write (tmp + rename), creating parent dirs. */
function atomicWriteJson(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
renameSync(tmp, path);
/**
* Atomic write (tmp + rename), creating parent dirs. Hardened for shared
* user-scope targets [C10]: the SYMLINK TARGET is resolved first so a
* dotfile-manager-linked settings file survives as a link (a bare rename
* would replace the link with a regular file); the tmp file uses a random
* suffix and inherits the existing file's mode (a 0600 file stays 0600
* the pid-suffixed umask-default tmp was fine for gitignored workspace files
* but not for user-global config).
*/
function atomicWriteJson(path: string, value: unknown, freshMode?: number): void {
const target = existsSync(path) ? realpathSync(path) : path;
mkdirSync(dirname(target), { recursive: true });
let mode: number | undefined;
try {
mode = statSync(target).mode & 0o777;
} catch {
mode = freshMode; // fresh file: caller's convention (user-scope → 0600) [X11]
}
const tmp = `${target}.tmp-${randomBytes(6).toString('hex')}`;
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', ...(mode !== undefined ? { mode } : {}) });
if (mode !== undefined) chmodSync(tmp, mode); // writeFileSync mode applies only on create
renameSync(tmp, target);
}
/** Pre-write backup path per strategy; timestamped avoids the shared-slot loss. */
function backupPathFor(settingsPath: string, strategy: 'fixed' | 'timestamped'): string {
return strategy === 'timestamped' ? `${settingsPath}.bak-${Date.now()}` : `${settingsPath}.bak`;
}
interface LoadedSettings {
@@ -291,13 +365,15 @@ function loadSettings(path: string): LoadedSettings {
// ── Writers [G5, CX2-17] ────────────────────────────────────────────────────
/**
* Structural-merge gbrain's hook entries into `<ws>/.claude/settings.local.json`.
* Idempotent: prior marker-carrying entries are removed before the fresh set
* is appended (run twice one entry per event). Foreign hooks, permissions,
* and every other key survive byte-for-byte at the structural level.
* Structural-merge gbrain's hook entries into an EXPLICIT settings file
* (workspace settings.local.json or user-scope ~/.claude/settings.json).
* Idempotent: prior same-marker entries are removed before the fresh set is
* appended (run twice one entry per event). Foreign hooks, other-marker
* gbrain entries, permissions, and every other key survive byte-for-byte at
* the structural level.
*/
export function writeClaudeHooks(
workspaceDir: string,
export function writeClaudeHooksAt(
settingsPath: string,
opts: WriteClaudeHooksOpts,
): WriteClaudeHooksResult {
if (!isAbsolute(opts.gbrainBin)) {
@@ -308,8 +384,9 @@ export function writeClaudeHooks(
throw new Error(`env ${k} contains control characters — refusing to embed in a hook command`);
}
}
const marker = opts.marker ?? GBRAIN_HOOK_MARKER_VALUE;
const backupStrategy = opts.backupStrategy ?? 'fixed';
const settingsPath = claudeSettingsPath(workspaceDir);
const { settings, existed, brokenBackupPath, notes } = loadSettings(settingsPath);
// hooks key: merge into an object; a structurally-foreign value is backed
@@ -326,14 +403,57 @@ export function writeClaudeHooks(
}
const events = opts.events ?? [...CLAUDE_HOOK_EVENTS];
// [C6] Same command double-fire guard: refuse when a gbrain entry carrying a
// DIFFERENT marker already wires one of our target events in this file —
// Claude Code would run both.
if (opts.refuseOnForeignGbrainMarker) {
for (const event of events) {
const groups = hooks[event];
if (!Array.isArray(groups)) continue;
for (const group of groups) {
const g = group as HookMatcherGroup;
if (!Array.isArray(g?.hooks)) continue;
for (const entry of g.hooks) {
if (isForeignGbrainMarked(entry, marker)) {
const foreign = (entry as Record<string, unknown>)[GBRAIN_HOOK_MARKER_KEY];
throw new Error(
`${settingsPath} already wires hooks.${event} under gbrain marker "${String(foreign)}" — ` +
`refusing to double-wire the same hook (both entries would fire every event). ` +
`Remove the other install first (gbrain bootstrap harness --remove, or gbrain bootstrap uninstall).`,
);
}
}
}
}
}
// [D12] Dedupe invariant: an event carried by the COMMITTED settings file
// never also fires from the local file. The local writer still strips its
// own prior entries for carried events (removing stale local copies), but
// re-adds nothing for them.
const carried = committedHookEvents(workspaceDir);
// never also fires from the local file. The caller supplies the carried set
// (the workspace wrapper reads it from committedHookEvents(ws); the harness
// --project lane does the same for its dirs) — this path-parameterized
// writer has no workspace to derive it from.
const carried = opts.carriedEvents ?? new Set<ClaudeHookEvent>();
let removedPrior = 0;
const installed: Array<{ event: ClaudeHookEvent; command: string }> = [];
// [X3] Convergence: strip OUR marker from EVERY event in the file first —
// not just the requested subset — so a re-run with fewer events (e.g.
// --no-capture dropping Stop/SessionEnd) removes the ones no longer wanted
// instead of leaving them live. Foreign and other-marker entries survive.
for (const event of Object.keys(hooks)) {
const groups = hooks[event];
if (!Array.isArray(groups)) continue; // structurally foreign — never touch
const { kept, removed } = stripOurEntries(groups, marker);
removedPrior += removed;
if (removed === 0) continue;
if (kept.length === 0) {
delete hooks[event]; // emptied by OUR removal — drop the key
} else {
hooks[event] = kept;
}
}
for (const event of events) {
let groups = hooks[event];
if (!Array.isArray(groups)) {
@@ -344,8 +464,7 @@ export function writeClaudeHooks(
}
groups = [];
}
const { kept, removed } = stripOurEntries(groups as unknown[]);
removedPrior += removed;
const kept = [...(groups as unknown[])];
if (carried.has(event)) {
notes.push(`${event}: carried by the committed .claude/settings.json — local entry skipped [D12]`);
@@ -360,7 +479,7 @@ export function writeClaudeHooks(
type: 'command',
command,
timeout,
[GBRAIN_HOOK_MARKER_KEY]: GBRAIN_HOOK_MARKER_VALUE,
[GBRAIN_HOOK_MARKER_KEY]: marker,
};
kept.push({ hooks: [entry] });
hooks[event] = kept;
@@ -370,15 +489,30 @@ export function writeClaudeHooks(
settings.hooks = hooks;
let backupPath: string | null = null;
if (existed) {
backupPath = `${settingsPath}.bak`;
if (existed && brokenBackupPath === null) {
backupPath = backupPathFor(settingsPath, backupStrategy);
copyFileSync(settingsPath, backupPath);
}
atomicWriteJson(settingsPath, settings);
atomicWriteJson(settingsPath, settings, opts.freshMode);
return { settingsPath, installed, removedPrior, backupPath, brokenBackupPath, notes };
}
/**
* Workspace-lane wrapper (historical signature: `<ws>/.claude/settings.local.json`,
* bootstrap-v1 marker, fixed `.bak`). Supplies the [D12] carried-events set so
* an event owned by the committed carrier never also fires locally.
*/
export function writeClaudeHooks(
workspaceDir: string,
opts: WriteClaudeHooksOpts,
): WriteClaudeHooksResult {
return writeClaudeHooksAt(claudeSettingsPath(workspaceDir), {
carriedEvents: committedHookEvents(workspaceDir),
...opts,
});
}
/**
* Write hooks into the COMMITTED `.claude/settings.json` [D12] the only
* carrier that survives into fresh cloud clones (hooks are snapshotted at
@@ -449,7 +583,7 @@ export function writeCommittedClaudeHooks(
// [D12] dedupe: the committed carrier now owns these events — remove any
// local copies so nothing double-fires on this machine.
const localCleanup = removeHooksFromFile(claudeSettingsPath(workspaceDir));
const localCleanup = removeClaudeHooksAt(claudeSettingsPath(workspaceDir));
if (localCleanup.removed > 0) {
notes.push(
`removed ${localCleanup.removed} local settings.local.json entr${localCleanup.removed === 1 ? 'y' : 'ies'} — the committed carrier owns the events now [D12]`,
@@ -460,31 +594,16 @@ export function writeCommittedClaudeHooks(
}
/**
* Remove ONLY marker-carrying entries [G5]. A parse-broken file is left
* untouched (removal must never destroy what it cannot read) the note says
* so. Event arrays we emptied lose their key; an emptied hooks object loses
* its key; foreign structure survives. Cleans BOTH carriers (local +
* committed [D12]); the returned settingsPath/backup describe the local one,
* with committed-file actions reported via notes.
* Remove ONLY entries carrying the given marker [G5]. A parse-broken file is
* left untouched (removal must never destroy what it cannot read) the note
* says so. Event arrays we emptied lose their key; an emptied hooks object
* loses its key; foreign structure (including other-marker gbrain entries)
* survives.
*/
export function removeClaudeHooks(workspaceDir: string): RemoveClaudeHooksResult {
const local = removeHooksFromFile(claudeSettingsPath(workspaceDir));
const committed = removeHooksFromFile(claudeCommittedSettingsPath(workspaceDir));
const notes = [...local.notes];
if (committed.removed > 0) {
notes.push(`also removed ${committed.removed} entr${committed.removed === 1 ? 'y' : 'ies'} from the committed ${committed.settingsPath} [D12]`);
} else {
notes.push(...committed.notes.map((n) => `(committed carrier) ${n}`));
}
return {
settingsPath: local.settingsPath,
removed: local.removed + committed.removed,
backupPath: local.backupPath,
notes,
};
}
function removeHooksFromFile(settingsPath: string): RemoveClaudeHooksResult {
export function removeClaudeHooksAt(
settingsPath: string,
marker: string = GBRAIN_HOOK_MARKER_VALUE,
): RemoveClaudeHooksResult {
const notes: string[] = [];
if (!existsSync(settingsPath)) {
return { settingsPath, removed: 0, backupPath: null, notes: ['no settings file — nothing to remove'] };
@@ -521,7 +640,7 @@ function removeHooksFromFile(settingsPath: string): RemoveClaudeHooksResult {
for (const event of Object.keys(hooks)) {
const groups = hooks[event];
if (!Array.isArray(groups)) continue; // structurally foreign — never touch
const { kept, removed: n } = stripOurEntries(groups);
const { kept, removed: n } = stripOurEntries(groups, marker);
removed += n;
if (n === 0) continue;
if (kept.length === 0) {
@@ -543,6 +662,156 @@ function removeHooksFromFile(settingsPath: string): RemoveClaudeHooksResult {
return { settingsPath, removed, backupPath, notes };
}
/**
* Workspace-lane removal cleans BOTH carriers (local + committed [D12]);
* the returned settingsPath/backup describe the local one, with
* committed-file actions reported via notes.
*/
export function removeClaudeHooks(workspaceDir: string): RemoveClaudeHooksResult {
const local = removeClaudeHooksAt(claudeSettingsPath(workspaceDir));
const committed = removeClaudeHooksAt(claudeCommittedSettingsPath(workspaceDir));
const notes = [...local.notes];
if (committed.removed > 0) {
notes.push(`also removed ${committed.removed} entr${committed.removed === 1 ? 'y' : 'ies'} from the committed ${committed.settingsPath} [D12]`);
} else {
notes.push(...committed.notes.map((n) => `(committed carrier) ${n}`));
}
return {
settingsPath: local.settingsPath,
removed: local.removed + committed.removed,
backupPath: local.backupPath,
notes,
};
}
// ── permissions.allow writers (harness lane, #4043) ────────────────────────
export interface PermissionsAllowResult {
settingsPath: string;
/** add: entry appended this run. remove: number of occurrences removed. */
added?: boolean;
removed?: number;
backupPath: string | null;
notes: string[];
}
/**
* Append one entry to `permissions.allow` (set semantics present means
* no-op). Stamps NO marker: permissions.allow is an array of plain strings,
* so ownership is recorded on the harness receipt (exact-string removal),
* never in the file and a marker object here would false-positive
* status.ts's whole-file `hooksInstalled` substring probe. Foreign entries
* and every other settings key survive. Broken JSON aborts (user-scope
* discipline this writer only ever targets user-scope files).
*/
export function addPermissionsAllowEntry(
settingsPath: string,
entry: string,
): PermissionsAllowResult {
const { settings, existed, notes } = loadSettings(settingsPath);
let permissions = settings.permissions as Record<string, unknown> | undefined;
if (typeof permissions !== 'object' || permissions === null || Array.isArray(permissions)) {
if (permissions !== undefined) {
// Fail CLOSED (same stance as broken JSON): "permissions" is the host's
// security policy — replacing a shape we don't understand could erase
// deny/ask rules or a future settings schema (ship-review P2).
throw new Error(
`${settingsPath}: existing "permissions" key is not an object ` +
`(${JSON.stringify(permissions).slice(0, 80)}) — refusing to rewrite security policy this writer ` +
`does not understand. Fix the file by hand, then re-run.`,
);
}
permissions = {};
}
let allow = permissions.allow as unknown[] | undefined;
if (!Array.isArray(allow)) {
if (allow !== undefined) {
throw new Error(
`${settingsPath}: existing permissions.allow is not an array — refusing to rewrite security policy ` +
`this writer does not understand. Fix the file by hand, then re-run.`,
);
}
allow = [];
}
if (allow.some((e) => e === entry)) {
return { settingsPath, added: false, backupPath: null, notes: [...notes, `${entry} already allowed — no change`] };
}
allow.push(entry);
permissions.allow = allow;
settings.permissions = permissions;
let backupPath: string | null = null;
if (existed) {
backupPath = backupPathFor(settingsPath, 'timestamped');
copyFileSync(settingsPath, backupPath);
}
atomicWriteJson(settingsPath, settings, 0o600); // user-scope-only writer [X11]
return { settingsPath, added: true, backupPath, notes };
}
/**
* Remove EXACTLY the given string from `permissions.allow`. Parse-broken file
* is left untouched (the removeClaudeHooks precedent removal never destroys
* what it cannot read). Keys are dropped only when OUR removal emptied them.
* Honest edge (stated in consent copy): if the user had independently allowed
* the same string, this removes it too set semantics carry no provenance.
*/
export function removePermissionsAllowEntry(
settingsPath: string,
entry: string,
): PermissionsAllowResult {
if (!existsSync(settingsPath)) {
return { settingsPath, removed: 0, backupPath: null, notes: ['no settings file — nothing to remove'] };
}
let loaded: LoadedSettings;
try {
loaded = loadSettings(settingsPath);
} catch (e) {
return {
settingsPath,
removed: 0,
backupPath: null,
notes: [
`WARNING: ${settingsPath} is not valid JSON (${(e as Error).message}); ` +
`left untouched — remove the "${entry}" permissions.allow entry by hand or fix the JSON and re-run.`,
],
};
}
const { settings, notes } = loaded;
const permissions = settings.permissions as Record<string, unknown> | undefined;
if (typeof permissions !== 'object' || permissions === null || Array.isArray(permissions)) {
return { settingsPath, removed: 0, backupPath: null, notes: ['no permissions object — nothing to remove'] };
}
const allow = permissions.allow as unknown[] | undefined;
if (!Array.isArray(allow)) {
return { settingsPath, removed: 0, backupPath: null, notes: ['no permissions.allow array — nothing to remove'] };
}
const kept = allow.filter((e) => e !== entry);
const removed = allow.length - kept.length;
if (removed === 0) {
return { settingsPath, removed: 0, backupPath: null, notes: [...notes, `${entry} not present — nothing to remove`] };
}
if (kept.length > 0) {
permissions.allow = kept;
} else {
delete permissions.allow; // emptied by OUR removal — drop the key
}
if (Object.keys(permissions).length === 0) {
delete settings.permissions;
}
const backupPath = backupPathFor(settingsPath, 'timestamped');
copyFileSync(settingsPath, backupPath);
atomicWriteJson(settingsPath, settings, 0o600); // user-scope-only writer [X11]
return { settingsPath, removed, backupPath, notes };
}
// ── MCP registration argv builders [G1, CX-P1.4, ENG-7] ────────────────────
export interface ClaudeMcpRegistration {
+76 -13
View File
@@ -76,21 +76,32 @@ export const TARGETS: Record<string, HostSpecTarget> = {
},
[CODEX_SPEC_ID]: {
id: CODEX_SPEC_ID,
status: 'provisional',
verifiedAt: '2026-08-08',
status: 'verified',
verifiedAt: '2026-08-12',
references: [
'docs/mcp/CODEX.md',
'https://developers.openai.com/codex/mcp',
'codex-cli 0.147.0 (binary serde field scan + live inline bearer_token wiring, issue #4043)',
],
note:
'Codex has NO hook system — the pull-protocol AGENTS.md gates are the ' +
'per-turn seam (plan D5). Local stdio MCP registration: ' +
'`codex mcp add <name> [--env K=V]... -- <command> [args...]`, which ' +
'writes [mcp_servers.<name>] into ~/.codex/config.toml. A TOML-aware ' +
'config.toml writer [CX2-17] is deliberately NOT needed in v1: ' +
'`codex mcp add` owns the config.toml write end-to-end, so gbrain never ' +
'edits the file directly. Revisit only if a v1.1 feature (notify ' +
'sweeper, FF2) must write keys `codex mcp add` cannot express.',
'Local stdio MCP registration: `codex mcp add <name> [--env K=V]... -- ' +
'<command> [args...]`, which writes [mcp_servers.<name>] into ' +
'(CODEX_HOME || ~/.codex)/config.toml — codex resolves CODEX_HOME as ' +
'the config dir itself. Streamable-HTTP servers are configured with ' +
'`url` plus `bearer_token` (inline) or `bearer_token_env_var`; the ' +
'config parser uses deny-unknown-fields, so writers must emit ONLY ' +
'verified keys and `KEY = "value"` spacing. The CX2-17 revisit trigger ' +
'FIRED (#4043): `codex mcp add` cannot express an inline bearer_token ' +
'(verified against codex-cli 0.147.0 --help), so the harness lane owns ' +
'a managed marker-delimited TOML block (codex-toml.ts) — the ONE ' +
'direct config.toml writer. One owner per server name: `codex mcp ' +
'remove` rewrites config.toml wholesale and drops comments, so the ' +
'stdio lane (runHooks) must never manage a name the harness block ' +
'owns, and vice versa. Codex 0.147.0 also ships a real hook system ' +
'(hooks.json; PreToolUse…SessionEnd) — CODEX_HAS_HOOKS=false means ' +
'"gbrain does not wire codex hooks yet" (follow-up filed), NOT "codex ' +
'has no hooks". Some codex builds gate HTTP MCP servers behind ' +
'`experimental_use_rmcp_client = true` — probe at wiring time.',
},
};
@@ -163,6 +174,36 @@ export const CLAUDE_HOOK_DEFAULT_TIMEOUT_SECS: Record<ClaudeHookEvent, number> =
export const GBRAIN_HOOK_MARKER_KEY = '_gbrain';
export const GBRAIN_HOOK_MARKER_VALUE = 'bootstrap-v1';
/**
* Marker VALUE for harness-mode installs (`gbrain bootstrap harness`, #4043).
* Same `_gbrain` key, distinct value: a box can carry a workspace bootstrap
* install (bootstrap-v1 in settings.local.json) AND a harness install
* (bootstrap-harness-v1 in user settings.json or a project settings.local.json)
* each removal path strips only its own entries.
*/
export const GBRAIN_HARNESS_MARKER_VALUE = 'bootstrap-harness-v1';
/**
* User-scope Claude Code settings file (harness-mode hook + permissions
* target). Resolution mirrors Claude Code itself: CLAUDE_CONFIG_DIR (its
* documented config-dir override, which the real-claude e2e harness sets)
* else $HOME/.claude checked explicitly because Bun's homedir() reads the
* password database, NOT the HOME env var, so a sandboxed test that remaps
* HOME would otherwise write into the operator's REAL settings file (this
* bit us; the write-ahead receipt's remove path self-healed it).
*/
export function claudeUserSettingsPath(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
if (configDir) return join(configDir, 'settings.json');
const home = process.env.HOME?.trim();
return join(home || homedir(), '.claude', 'settings.json');
}
/** permissions.allow entry that pre-approves an MCP server's tools for headless runs. */
export function mcpPermissionEntry(serverName: string): string {
return `mcp__${serverName}`;
}
/** Where Claude Code stores session transcripts — the confinement root [S3#8]. */
export function claudeProjectsDir(): string {
return join(homedir(), '.claude', 'projects');
@@ -170,10 +211,32 @@ export function claudeProjectsDir(): string {
// ── Codex shapes ────────────────────────────────────────────────────────────
/** Codex CLI config file (user-global; written by `codex mcp add`, never by us). */
/**
* Codex CLI config file (user-global). Real codex resolves CODEX_HOME as the
* config DIRECTORY itself (config.toml sits directly inside it) pinned by
* test/e2e/bootstrap-real-codex.serial.test.ts and connect-bearer.test.ts,
* which set CODEX_HOME to a temp dir and assert the real binary wrote there.
* A homedir-only resolution here would make any CODEX_HOME-isolated test
* clobber the operator's real ~/.codex/config.toml.
*/
export function codexConfigPath(): string {
return join(homedir(), '.codex', 'config.toml');
const codexHome = process.env.CODEX_HOME?.trim();
return join(codexHome || join(homedir(), '.codex'), 'config.toml');
}
/** Codex has no hook system — per-turn context is pull-protocol (plan D5). */
/**
* Whether gbrain WIRES codex hooks. False = not yet: codex 0.147.0 ships a
* real hook system (hooks.json; PreToolUseSessionEnd see the TARGETS
* note), but gbrain's codex hook lane is a filed follow-up; per-turn context
* on codex remains the pull-protocol AGENTS.md gates (plan D5).
*/
export const CODEX_HAS_HOOKS = false;
/**
* Managed-block markers for the harness lane's direct config.toml writes
* (codex-toml.ts the CX2-17 revisit, fired by #4043). Full-line exact
* match at column 0; the writer requires exactly one begin/end pair.
*/
export const CODEX_TOML_BLOCK_BEGIN =
`# gbrain:${GBRAIN_HARNESS_MARKER_VALUE} begin - managed by \`gbrain bootstrap harness\`; do not edit inside`;
export const CODEX_TOML_BLOCK_END = `# gbrain:${GBRAIN_HARNESS_MARKER_VALUE} end`;
+5 -5
View File
@@ -12,12 +12,12 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'agent': ['--aliases', '--all', '--brain', '--detach', '--fanout-manifest', '--federated', '--flag', '--flags', '--follow', '--help', '--include-null-signature', '--json', '--max-turns', '--model', '--no-extract', '--no-federated', '--no-follow', '--note', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--since', '--source', '--stale', '--subagent-def', '--supersessions', '--thin', '--timeout-ms', '--tools', '--word'],
'anomalies': ['--aliases', '--all', '--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lookback-days', '--mcp-only', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--sigma', '--since', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
'apply-migrations': ['--ab', '--all', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dry-run', '--exclusive', '--fast', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--from-meetings', '--from-pages', '--help', '--history', '--host-dir', '--http', '--json', '--lang', '--list', '--locks', '--markdown', '--max-age', '--migrate-only', '--migration', '--mode', '--multimodal', '--no-autopilot-install', '--no-embedding', '--no-extract', '--non-interactive', '--phase', '--priority', '--refresh-unqualified', '--remediate', '--rollback', '--skip-verify', '--source', '--stale', '--surface', '--token-ttl', '--undo-wave', '--use-captured-snapshot', '--with-calibration', '--yes'],
'auth': ['--aliases', '--all', '--bound-brain', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--days', '--enable-dcr', '--enable-dcr-insecure', '--fast', '--federated-read', '--force', '--from-pages', '--grant-types', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--redirect-uri', '--reset', '--resolve', '--scopes', '--source', '--stale', '--supersessions', '--surface', '--takes-holders', '--thin', '--token', '--token-endpoint-auth-method', '--token-ttl', '--usage', '--yes'],
'auth': ['--aliases', '--all', '--bound-brain', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--days', '--enable-dcr', '--enable-dcr-insecure', '--fast', '--federated-read', '--force', '--from-pages', '--grant-types', '--help', '--http', '--id', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--redirect-uri', '--reset', '--resolve', '--scopes', '--source', '--stale', '--supersessions', '--surface', '--takes-holders', '--thin', '--token', '--token-endpoint-auth-method', '--token-ttl', '--usage', '--yes'],
'autopilot': ['--aliases', '--all', '--auto-fix', '--batch', '--brain', '--break-lock', '--by-type', '--check', '--dim', '--dimensions', '--dir', '--drain', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--ff-only', '--fix', '--force', '--force-break-lock', '--force-retry', '--from-pages', '--help', '--http', '--include-null-signature', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--json', '--markdown', '--max-age', '--max-rss', '--max-usd', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-inject', '--no-mutate', '--no-worker', '--non-interactive', '--now', '--once', '--output', '--path', '--pattern', '--pending', '--phase', '--refresh-cache', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--source', '--stale', '--status', '--supersessions', '--surface', '--swap-only', '--target', '--target-score', '--thin', '--timeout', '--to', '--token-ttl', '--uninstall', '--unsafe-bypass-dream-guard', '--user', '--version', '--yes'],
'backfill': ['--aliases', '--all', '--batch-size', '--brain', '--concurrency', '--dry-run', '--fresh', '--help', '--include-null-signature', '--json', '--keep-index', '--list', '--max-errors', '--max-rows', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin'],
'bench': ['--baseline', '--brain', '--explain', '--force', '--from', '--help', '--json', '--label', '--lang', '--limit', '--markdown', '--multimodal', '--near-symbol', '--restore-only', '--source', '--stale', '--symbol-kind', '--thin', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-top1', '--to', '--tool'],
'book-mirror': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--author', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--budget-usd-per-day', '--by-mention', '--chapters-dir', '--content', '--context-file', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-turns', '--max-usd', '--mode', '--model', '--multimodal', '--no-confirm', '--no-embedding', '--no-extract', '--no-follow', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--timeout-ms', '--title', '--token-ttl', '--trusted-extraction', '--url', '--with-db', '--yes'],
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--active', '--all', '--allow-unverified-remote', '--brain', '--branch', '--cached', '--compile', '--confirm', '--count', '--delete-brain', '--diff-filter', '--env', '--error-unmatch', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--harness', '--heads', '--help', '--home', '--hostname', '--http', '--init', '--is-inside-work-tree', '--isolated', '--jq', '--json', '--local', '--minimal', '--name-only', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--private', '--push', '--push-only', '--quiet', '--rebase', '--repair', '--scope', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--token-ttl', '--unset-all', '--verify', '--version', '--visibility', '--workspace', '--yes'],
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--active', '--all', '--allow-unverified-remote', '--brain', '--branch', '--cached', '--compile', '--confirm', '--count', '--delete-brain', '--diff-filter', '--env', '--error-unmatch', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--harness', '--heads', '--help', '--home', '--hostname', '--http', '--id', '--init', '--install', '--is-inside-work-tree', '--isolated', '--jq', '--json', '--local', '--minimal', '--name', '--name-only', '--no-capture', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--port', '--private', '--project', '--push', '--push-only', '--quiet', '--rebase', '--remove', '--repair', '--scope', '--scopes', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--token', '--token-name', '--token-ttl', '--unset-all', '--url', '--user-hooks', '--verify', '--version', '--visibility', '--workspace', '--yes'],
'brainstorm': ['--aliases', '--all', '--brain', '--chunker-debug', '--code', '--compile', '--fast', '--file', '--fix', '--force', '--force-rechunk', '--force-resume', '--from-pages', '--full', '--help', '--http', '--include-null-signature', '--json', '--judge-model', '--lang', '--limit', '--list-runs', '--markdown', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--model', '--no-embed', '--no-embedding', '--no-extract', '--no-save', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--retry-failed', '--retry-judge', '--save', '--source', '--stale', '--strict-budget', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--yes'],
'cache': ['--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--source', '--surface', '--token-ttl', '--yes'],
'calibration': ['--ab', '--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--holder', '--http', '--image', '--include-null-signature', '--json', '--key-prefix', '--kind', '--lang', '--limit', '--markdown', '--max-usd', '--mode', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--phase', '--progress-interval', '--progress-json', '--quiet', '--regenerate', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scrub-gstack', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl', '--trusted-extraction', '--undo-wave', '--url', '--with-calibration', '--with-db', '--yes'],
@@ -32,9 +32,9 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'code-def': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--pretty', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'],
'code-refs': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'],
'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--detail', '--embedding-dimensions', '--embedding-model', '--fast', '--federated-read', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--yes'],
'connect': ['--agent', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--force', '--grant-types', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--register', '--scopes', '--show-token', '--source', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'],
'connect': ['--agent', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--force', '--grant-types', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--register', '--scope', '--scopes', '--show-token', '--source', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'],
'conversation-parser': ['--aliases', '--all', '--brain', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
'doctor': ['--ab', '--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--allow-shell-jobs', '--allow-unverified-remote', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--build-index', '--by-mention', '--by-type', '--cached', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--detail', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclude-standard', '--exclusive', '--explain', '--fast', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--is-inside-work-tree', '--jq', '--json', '--lang', '--limit', '--local', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--short', '--show-current', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--token-ttl', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
'doctor': ['--ab', '--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--allow-shell-jobs', '--allow-unverified-remote', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--build-index', '--by-mention', '--by-type', '--cached', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--detail', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclude-standard', '--exclusive', '--explain', '--fast', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--is-inside-work-tree', '--jq', '--json', '--lang', '--limit', '--local', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--project', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--remove', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--short', '--show-current', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--token', '--token-ttl', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
'dream': ['--against', '--aliases', '--all', '--allow-regression', '--anchor', '--asof', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--code', '--committed-baseline', '--compare', '--compile', '--concurrent', '--ctx-size', '--cycles', '--date', '--detail', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--expansion', '--explain', '--fast', '--federated', '--fix', '--fixtures', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--format', '--from', '--from-db', '--from-pages', '--gold', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--install', '--json', '--judge-model', '--justification', '--keyword-only', '--lang', '--limit', '--llm', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-tokens', '--max-usd', '--mcp-only', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--name-only', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-llm', '--no-mutate', '--no-trajectory', '--once', '--out', '--output', '--output-dir', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--phase', '--priority', '--progress-interval', '--progress-json', '--pull', '--quiet', '--receipt-dir', '--remediate', '--repo', '--reranking', '--reset', '--resolve', '--restore-only', '--resume-from', '--retrieval-only', '--rounds', '--rubric-version', '--save', '--seed', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--token-ttl', '--top-k', '--undo', '--unsafe-bypass-dream-guard', '--update-baseline', '--verify', '--version', '--window', '--yes'],
'edges-backfill': ['--aliases', '--all', '--all-sources', '--brain', '--concurrency', '--federated', '--help', '--include-null-signature', '--json', '--max-age', '--max-chunks', '--max-cost-usd', '--no-extract', '--no-federated', '--older-than', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--workers'],
'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--serial', '--slugs', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--version'],
@@ -50,7 +50,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'friction': ['--agent', '--base', '--brain', '--compare', '--help', '--hint', '--json', '--kind', '--message', '--no-redact', '--phase', '--redact', '--run-id', '--severity', '--source', '--transcript-path', '--transcripts'],
'frontmatter': ['--aliases', '--all', '--allow-catch-all', '--brain', '--cached', '--diff-filter', '--dry-run', '--exclude-standard', '--fast', '--fix', '--force', '--from-pages', '--get', '--help', '--http', '--include-catch-all', '--include-null-signature', '--json', '--name-only', '--name-status', '--no-embedding', '--no-extract', '--no-verify', '--others', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--strategy', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--uninstall', '--write-back'],
'graph-query': ['--aliases', '--all', '--brain', '--depth', '--direction', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-foreign', '--include-null-signature', '--json', '--lang', '--markdown', '--mcp-only', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--type'],
'hook': ['--aliases', '--all', '--allow-unverified-remote', '--batch-limit', '--brain', '--budget-ms', '--cached', '--count', '--delete-brain', '--detach', '--diff-filter', '--end-of-options', '--env', '--exclude-standard', '--fast', '--force', '--from-pages', '--get', '--harness', '--help', '--http', '--include-null-signature', '--jq', '--json', '--name-only', '--no-embedding', '--no-extract', '--once', '--others', '--path', '--pattern', '--pending', '--porcelain', '--quiet', '--reset', '--resolve', '--show-current', '--show-toplevel', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
'hook': ['--aliases', '--all', '--allow-unverified-remote', '--batch-limit', '--brain', '--budget-ms', '--cached', '--count', '--delete-brain', '--detach', '--diff-filter', '--end-of-options', '--env', '--exclude-standard', '--fast', '--force', '--from-pages', '--get', '--harness', '--help', '--http', '--include-null-signature', '--jq', '--json', '--name-only', '--no-embedding', '--no-extract', '--once', '--others', '--path', '--pattern', '--pending', '--porcelain', '--project', '--quiet', '--remove', '--reset', '--resolve', '--show-current', '--show-toplevel', '--source', '--stale', '--stats', '--status', '--supersessions', '--surface', '--thin', '--timeout', '--token', '--token-ttl'],
'import': ['--aliases', '--all', '--asof', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--cached', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--fix', '--follow', '--force', '--force-rechunk', '--fresh', '--from-pages', '--full', '--help', '--http', '--include-gitignored', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--multimodal', '--name-status', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--older-than', '--others', '--path', '--pattern', '--pending', '--pglite', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--since', '--skip-failed', '--source', '--source-id', '--stale', '--strategy', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--url', '--workers'],
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--flag', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--to', '--token-ttl', '--touchpoint', '--url', '--version'],
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target', '--token-ttl'],
+1
View File
@@ -150,6 +150,7 @@ export const OPS_CHECK_NAMES: ReadonlySet<string> = new Set([
'autopilot_fanout_concurrency',
'autopilot_lock_scope',
'bootstrap_hook_schema_pairing',
'bootstrap_harness_health',
'bootstrap_hooks_heartbeat',
'bootstrap_last_verify',
'bootstrap_push_health',
+40
View File
@@ -1,3 +1,5 @@
import { ALLOWED_SCOPES } from './scope.ts';
/**
* Derive a legacy bearer token's source scope from its stored
* `access_tokens.permissions.source_id` grant.
@@ -40,6 +42,44 @@ export function parseTakesHoldersAllowList(raw: unknown): string[] | undefined {
return (raw as unknown[]).filter((h): h is string => typeof h === 'string');
}
/**
* Normalize a legacy token's `access_tokens.scopes TEXT[]` column value
* (#4043 least-privilege: the dormant original-schema column is THE scope
* store a dedicated column is structurally immune to the whole
* permissions-object-replacement bug class).
*
* NULL / undefined undefined: the caller grandfathers to the historical
* full-access grant, so every existing token (scopes was never written
* before this feature) behaves byte-identically. An ARRAY input is filtered
* to known scope names and returned AS IS including `[]`: an explicit
* array that yields nothing is a deny (a typo'd scope fails closed at verify
* time; mint-time validation refuses unknown scopes loudly, so `[]` here
* means the row was set deliberately or is damaged either way, deny).
*
* A STRING in the Postgres array-literal shape (`{read,write}` some
* driver/pooler paths hand TEXT[] back undecoded) is parsed and filtered
* like an array. Any OTHER non-null value is representation drift on a row
* that WAS written that must fail CLOSED (deny), never silently widen to
* the grandfather grant: only the never-written NULL earns full access.
*
* Named away from the one-char-apart `parseLegacyTokenScope` (SINGULAR
* the source-isolation grant above) on purpose.
*/
export function normalizeTokenScopes(raw: unknown): string[] | undefined {
if (raw === null || raw === undefined) return undefined;
const filterKnown = (items: unknown[]): string[] =>
items.filter(
(s): s is string => typeof s === 'string' && (ALLOWED_SCOPES as ReadonlySet<string>).has(s as never),
);
if (Array.isArray(raw)) return filterKnown(raw as unknown[]);
if (typeof raw === 'string' && /^\{.*\}$/.test(raw.trim())) {
const inner = raw.trim().slice(1, -1);
if (inner === '') return [];
return filterKnown(inner.split(',').map((s) => s.trim().replace(/^"|"$/g, '')));
}
return []; // damaged/drifted representation on a written row → deny
}
/**
* Coerce a legacy token's raw `access_tokens.permissions` column value to a
* plain object for grant extraction. A well-formed jsonb column reads back as
+166
View File
@@ -0,0 +1,166 @@
/**
* mcp-registration.ts shared MCP-registration helpers for HTTP+bearer
* wiring (extracted from src/commands/connect.ts for #4043 [F5]: the harness
* lane in src/core/bootstrap/ needs these, and core must not import from
* commands). connect.ts re-exports everything here, so its public surface
* and tests are unchanged.
*/
export const REDACTED = '***';
export const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
export type UrlResult =
| { ok: true; url: string; warning?: string }
| { ok: false; error: string };
/**
* Block link-local / cloud-metadata addresses the one class of host that is
* never a legitimate brain endpoint but IS a token-exfil target (e.g. the AWS/
* GCP metadata service at 169.254.169.254). Deliberately does NOT block
* localhost or RFC1918/LAN ranges: self-hosted brains on a private network are
* a documented, supported topology (`gbrain serve --http --bind`).
*/
export function isLinkLocalOrMetadata(hostname: string): boolean {
const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(h)) return true; // IPv4 link-local incl. cloud metadata
if (h.startsWith('fe80:')) return true; // IPv6 link-local
if (h === 'fd00:ec2::254') return true; // AWS IMDSv2 over IPv6
// IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254 dotted, or ::ffff:a9fe:xxxx
// hex where a9fe == 169.254) must not slip past the dotted-IPv4 check.
const mapped = h.match(/^::ffff:(.+)$/);
if (mapped) {
if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(mapped[1])) return true;
if (mapped[1].startsWith('a9fe:')) return true;
}
return false;
}
/** True for loopback hosts — the harness lane's `--url` guard [F3]. */
export function isLoopbackHostname(hostname: string): boolean {
const h = hostname.toLowerCase();
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]';
}
/**
* Normalize an MCP URL to a canonical `<scheme>//<host><path>` ending in /mcp.
* Explicit spec (not best-effort) see plan D-codex findings.
*/
export function normalizeMcpUrl(input: string): UrlResult {
const raw = (input ?? '').trim();
if (!raw) {
return { ok: false, error: 'Missing MCP URL. Usage: gbrain connect <https://host/mcp> --token <bearer>' };
}
// Require an explicit scheme. A bare `host:3131` parses as scheme `host:`
// under WHATWG URL, so reject anything without `://`.
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
const guess = raw.replace(/^\/+/, '');
return { ok: false, error: `Add an explicit scheme, e.g. https://${guess} (a bare host:port is ambiguous).` };
}
let u: URL;
try {
u = new URL(raw);
} catch {
return { ok: false, error: `Invalid URL: ${raw}` };
}
const scheme = u.protocol.toLowerCase();
if (scheme !== 'http:' && scheme !== 'https:') {
return { ok: false, error: `Only http(s) URLs are supported (got ${u.protocol}).` };
}
if (u.username || u.password) {
return { ok: false, error: 'Remove credentials from the URL (user:pass@host is not supported); pass the token via --token.' };
}
if (u.search) {
return { ok: false, error: 'Remove the query string from the MCP URL.' };
}
if (isLinkLocalOrMetadata(u.hostname)) {
return { ok: false, error: `Refusing to target a link-local / cloud-metadata address (${u.hostname}). Point the MCP URL at the brain host's real address.` };
}
const host = u.host; // host:port; hostname already lowercased by URL
const path = u.pathname;
const trimmed = path.replace(/\/+$/, '');
const lower = trimmed.toLowerCase();
let finalPath: string;
if (path === '' || path === '/') {
finalPath = '/mcp';
} else if (lower === '/mcp') {
finalPath = '/mcp';
} else {
return {
ok: false,
error: `Unexpected path '${path}'. Pass the full /mcp URL, e.g. ${scheme}//${host}${trimmed}/mcp`,
};
}
const url = `${scheme}//${host}${finalPath}`;
if (scheme === 'http:' && !isLoopbackHostname(u.hostname)) {
return { ok: true, url, warning: 'Warning: http:// sends your bearer token unencrypted. Use https:// unless this is localhost.' };
}
return { ok: true, url };
}
/** The OAuth issuer is the server base — the /mcp endpoint's URL minus /mcp. */
export function issuerFromMcpUrl(url: string): string {
return url.replace(/\/mcp$/, '');
}
export type TokenValidation = { ok: true } | { ok: false; error: string };
/** Reject empty/whitespace/control-char tokens (a newline is a header-injection vector). */
export function validateToken(token: string): TokenValidation {
if (!token || !token.trim()) return { ok: false, error: 'Token is empty.' };
if (/\s/.test(token)) return { ok: false, error: 'Token contains whitespace (space/tab/newline) — refusing (header-injection risk).' };
if (/[\x00-\x1f\x7f]/.test(token)) return { ok: false, error: 'Token contains control characters — refusing (header-injection risk).' };
return { ok: true };
}
export function isValidName(name: string): boolean {
return NAME_RE.test(name);
}
/**
* `claude mcp add` argv for an HTTP+bearer registration. `scope` is optional
* and appended only when given the claude CLI's default is `local`, so the
* harness lane MUST pass 'user' explicitly; the connect lane's historical
* argv (no scope) is byte-identical when omitted.
*/
export function buildClaudeMcpAddArgv(p: {
name: string;
url: string;
headerToken: string;
scope?: 'local' | 'user' | 'project';
}): string[] {
return [
'mcp', 'add', p.name,
...(p.scope ? ['--scope', p.scope] : []),
'-t', 'http', p.url,
'-H', `Authorization: Bearer ${p.headerToken}`,
];
}
/** Codex reads the bearer from an env var at runtime — the token is NOT in argv. */
export function buildCodexMcpAddArgv(p: { name: string; url: string; envVar: string }): string[] {
return ['mcp', 'add', p.name, '--url', p.url, '--bearer-token-env-var', p.envVar];
}
/**
* POSIX single-quote any arg that isn't already shell-safe, so `$()`, backticks,
* etc. in a token are inert literals when the block is pasted into a shell
* (double-quoting would still allow command substitution).
*/
export function shellQuote(arg: string): string {
if (/^[A-Za-z0-9_.:/@-]+$/.test(arg)) return arg;
return `'${arg.replace(/'/g, "'\\''")}'`;
}
/** Render `<binary> <argv...>` as a copy-pasteable, shell-safe command string. */
export function cmdString(binary: string, argv: string[]): string {
return `${binary} ${argv.map(shellQuote).join(' ')}`;
}
export function redactToken(s: string, token: string | null): string {
// Exact-substring scrub of the known token, plus a defense-in-depth pass over
// any `Bearer <value>` shape the SDK/CLI might echo in a transformed form the
// exact match would miss.
let out = token ? s.split(token).join(REDACTED) : s;
out = out.replace(/Bearer\s+\S+/gi, `Bearer ${REDACTED}`);
return out;
}
+27 -8
View File
@@ -28,7 +28,7 @@ import { hashToken, generateToken, isUndefinedColumnError } from './utils.ts';
import { assertValidSourceId } from './source-id.ts';
import { hasScope, assertAllowedScopes, parseScopeString, InvalidScopeError } from './scope.ts';
import type { AuthInfo as CoreAuthInfo } from './operations.ts';
import { parseLegacyTokenScope, parseTakesHoldersAllowList, coerceLegacyPermissions } from './legacy-token-scope.ts';
import { parseLegacyTokenScope, parseTakesHoldersAllowList, coerceLegacyPermissions, normalizeTokenScopes } from './legacy-token-scope.ts';
/**
* A slug-prefix write binding is only meaningful if every entry actually
@@ -903,22 +903,36 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
let legacyRows: Record<string, unknown>[];
try {
legacyRows = await this.sql`
SELECT name, permissions FROM access_tokens
SELECT name, permissions, scopes FROM access_tokens
WHERE token_hash = ${tokenHash} AND revoked_at IS NULL
`;
} catch (err) {
if (isUndefinedColumnError(err, 'permissions')) {
legacyRows = await this.sql`
SELECT name FROM access_tokens
WHERE token_hash = ${tokenHash} AND revoked_at IS NULL
`;
// Pre-v38 brain: no permissions column. scopes is ORIGINAL schema, so
// it must stay in the degraded SELECT — dropping it here would route
// normalizeTokenScopes(undefined) into the grandfather branch and
// silently promote a scoped token to full admin on any brain whose
// permissions projection fails (ship-review P1). Only if scopes
// ITSELF is missing (out-of-tree schema) does the ladder fall to
// name-only — and that brain predates scoped minting entirely.
try {
legacyRows = await this.sql`
SELECT name, scopes FROM access_tokens
WHERE token_hash = ${tokenHash} AND revoked_at IS NULL
`;
} catch (err2) {
if (!isUndefinedColumnError(err2, 'scopes')) throw err2;
legacyRows = await this.sql`
SELECT name FROM access_tokens
WHERE token_hash = ${tokenHash} AND revoked_at IS NULL
`;
}
} else {
throw err;
}
}
if (legacyRows.length > 0) {
// Legacy tokens get full admin access (grandfather in).
// For legacy tokens, name = clientId = clientName (single identifier).
// Update last_used_at
await this.sql`
@@ -934,11 +948,16 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
// dispatch site defaults to the fail-closed ['world']. An explicit []
// grant is preserved as deny-all.
const takesHoldersAllowList = parseTakesHoldersAllowList(permissions?.takes_holders);
// #4043 least-privilege: the original-schema `scopes TEXT[]` column is
// the scope store. NULL/absent (every token minted before this feature)
// → grandfathered full access, byte-identical behavior. An array is
// filtered to known scopes and honored as-is — including [] as deny.
const grantedScopes = normalizeTokenScopes(legacyRows[0].scopes);
return {
token,
clientId: name,
clientName: name,
scopes: ['read', 'write', 'admin'],
scopes: grantedScopes ?? ['read', 'write', 'admin'],
expiresAt: Math.floor(Date.now() / 1000) + 365 * 24 * 3600, // Legacy tokens never expire — set 1yr future
// Legacy tokens without an explicit permissions.source_id grant keep
// the historical 'default' source floor. Array grants become
+120
View File
@@ -0,0 +1,120 @@
/**
* token-mint.ts legacy bearer-token mint/revoke for programmatic callers
* (#4043 `gbrain bootstrap harness`; extracted from src/commands/auth.ts's
* private logic, using the canonical hashToken/generateToken from utils.ts).
*
* Least-privilege by construction: scopes land in the original-schema
* `access_tokens.scopes TEXT[]` column (structurally immune to the
* permissions-object-replacement wipe class), and the federation grant rides
* `permissions.source_id` as an array (element 0 = write floor the
* parseLegacyTokenScope contract).
*
* Rotation contract [C7]: mint FIRST, revoke the previous token BY ID only
* after the new one is wired and smoke-tested. revokeLegacyTokenById never
* touches same-name siblings names are not unique and may belong to
* hand-minted tokens.
*/
import type { BrainEngine } from './engine.ts';
import { ALLOWED_SCOPES_LIST, assertAllowedScopes } from './scope.ts';
import { executeRawJsonb, type SqlQuery } from './sql-query.ts';
import { generateToken, hashToken, isUndefinedColumnError } from './utils.ts';
/** Canonical token-id shape — shared with the `auth revoke --id` CLI gate. */
export const TOKEN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export interface MintLegacyTokenOpts {
name: string;
/** Per-token takes-holder allow-list; harness default ['world']. */
takesHolders: string[];
/** Scope grant → the scopes TEXT[] column. Must be non-empty known scopes. */
scopes: string[];
/**
* Federation grant permissions.source_id (array; element 0 = write
* floor). Omit for the historical default-source floor.
*/
sourceGrant?: string[];
}
export interface MintedLegacyToken {
/** Plaintext token — shown/wired once, never stored. */
token: string;
/** Row id — the ONLY safe revocation key (names are not unique). */
id: string;
name: string;
scopes: string[];
}
/**
* Mint a scoped legacy bearer token. Throws on unknown/empty scopes (typos
* fail loudly at mint time the verify path treats a filtered-empty array
* as deny, so a silent bad write would brick the token, not widen it).
*/
export async function mintLegacyToken(
engine: BrainEngine,
opts: MintLegacyTokenOpts,
): Promise<MintedLegacyToken> {
if (!opts.name || !opts.name.trim()) {
throw new Error('token name is required');
}
if (opts.scopes.length === 0) {
throw new Error(`token scopes must be a non-empty subset of: ${ALLOWED_SCOPES_LIST.join(', ')}`);
}
assertAllowedScopes(opts.scopes);
const takesHolders = opts.takesHolders.length > 0 ? opts.takesHolders : ['world'];
const token = generateToken('gbrain_');
const hash = hashToken(token);
const permissions: Record<string, unknown> = { takes_holders: takesHolders };
if (opts.sourceGrant && opts.sourceGrant.length > 0) {
permissions.source_id = opts.sourceGrant;
}
// Scopes bind as a Postgres array literal through a TEXT param + ::text[]
// cast — values are allowlisted ([a-z_]+), so the literal needs no quoting,
// and the same SQL runs on both engines (a bare JS array param would bind
// engine-dependently; the JSONB object goes through executeRawJsonb per the
// repo invariant).
const scopesLiteral = `{${opts.scopes.join(',')}}`;
let rows: Array<{ id: string }>;
try {
rows = await executeRawJsonb<{ id: string }>(
engine,
`INSERT INTO access_tokens (name, token_hash, permissions, scopes)
VALUES ($1, $2, $4::jsonb, $3::text[])
RETURNING id`,
[opts.name, hash, scopesLiteral],
[permissions],
);
} catch (e) {
// isUndefinedColumnError also matches message-shaped variants — some
// driver-wrapped errors drop the SQLSTATE code.
if (isUndefinedColumnError(e, 'scopes') || isUndefinedColumnError(e, 'permissions')) {
throw new Error(
'this brain is missing token columns (undefined column on access_tokens) — ' +
'run `gbrain apply-migrations` and retry.',
);
}
throw e;
}
const id = rows[0]?.id;
if (!id) throw new Error('token insert returned no id');
return { token, id, name: opts.name, scopes: [...opts.scopes] };
}
/**
* Revoke exactly one token by row id. Returns false when no ACTIVE row with
* that id exists (already revoked or never existed) callers treat that as
* already-done, not failure.
*/
export async function revokeLegacyTokenById(sql: SqlQuery, id: string): Promise<boolean> {
if (!TOKEN_ID_RE.test(id)) {
throw new Error(`not a token id (expected a UUID): ${id}`);
}
const rows = await sql`
UPDATE access_tokens SET revoked_at = now()
WHERE id = ${id}::uuid AND revoked_at IS NULL
RETURNING 1 AS ok
`;
return rows.length > 0;
}
+1 -1
View File
@@ -1,6 +1,6 @@
# gbrain agent workspace — template
<!-- gbrain-template-stamp: 0.45.13.0 -->
<!-- gbrain-template-stamp: 0.45.14.0 -->
This repository is the **"Use this template"** distribution artifact for a
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
+46 -1
View File
@@ -1,5 +1,5 @@
import { test, expect, describe } from 'bun:test';
import { parseAuthCreateArgs, parseAuthClientsArgs, parseRescopeSurfaceValue } from '../src/commands/auth.ts';
import { parseAuthCreateArgs, parseAuthClientsArgs, parseRescopeSurfaceValue, renderTokenScopes } from '../src/commands/auth.ts';
describe('parseAuthCreateArgs', () => {
test('bare name (no flag) resolves the name — regression for the dropped-name bug', () => {
@@ -35,6 +35,51 @@ describe('parseAuthCreateArgs', () => {
test('takes-holders trims + drops empties', () => {
expect(parseAuthCreateArgs(['n', '--takes-holders', ' world , , garry ']).takesHolders).toEqual(['world', 'garry']);
});
test('--scopes: comma and/or whitespace separated, value excluded from positional search (#4043)', () => {
expect(parseAuthCreateArgs(['harness', '--scopes', 'read,write']).scopes).toEqual(['read', 'write']);
expect(parseAuthCreateArgs(['--scopes', 'read write', 'harness'])).toMatchObject({
name: 'harness',
scopes: ['read', 'write'],
});
expect(parseAuthCreateArgs(['harness', '--scopes', ' read , write ']).scopes).toEqual(['read', 'write']);
});
test('--scopes with both flags present still resolves the name', () => {
expect(
parseAuthCreateArgs(['--takes-holders', 'world', '--scopes', 'read,write', 'harness']).name,
).toBe('harness');
});
test('--scopes absent → no scopes key (grandfather lane); empty value → empty array for create() to refuse', () => {
expect('scopes' in parseAuthCreateArgs(['n'])).toBe(false);
expect(parseAuthCreateArgs(['n', '--scopes', ',']).scopes).toEqual([]);
});
test('missing/flag-like values fail closed — a dropped --scopes would mint a FULL-ACCESS token', () => {
expect(parseAuthCreateArgs(['n', '--scopes']).error).toMatch(/scopes flag requires a value/);
expect(parseAuthCreateArgs(['n', '--scopes', '--takes-holders', 'world']).error).toMatch(/scopes flag requires a value/);
expect(parseAuthCreateArgs(['n', '--takes-holders']).error).toMatch(/takes-holders flag requires a value/);
expect(parseAuthCreateArgs(['n', '--takes-holders', '--scopes', 'read']).error).toMatch(/takes-holders flag requires a value/);
});
});
describe('renderTokenScopes', () => {
test('NULL grandfathers, [] denies, arrays filter to strings', () => {
expect(renderTokenScopes(null)).toBe('admin (grandfathered)');
expect(renderTokenScopes(undefined)).toBe('admin (grandfathered)');
expect(renderTokenScopes([])).toBe('(deny-all)');
expect(renderTokenScopes(['read', 'write'])).toBe('read,write');
expect(renderTokenScopes(['read', 7, 'write'])).toBe('read,write');
});
test('rendering matches the ENFORCEMENT path (normalizeTokenScopes), never claims admin on scoped/denied rows', () => {
// Undecoded TEXT[] string form: the serve scopes this — list must not say admin.
expect(renderTokenScopes('{read,write}')).toBe('read,write');
// Representation drift on a written row: the serve DENIES this — list must not say admin.
expect(renderTokenScopes('weird')).toBe('(deny-all)');
expect(renderTokenScopes(42)).toBe('(deny-all)');
});
});
// WP4: `auth rescope-client --surface` value parsing. 'clear' → null (clears
+102 -1
View File
@@ -21,7 +21,15 @@ import { join } from 'node:path';
import { runBootstrap, workspaceBrainStats } from '../src/commands/bootstrap.ts';
import type { ExecRunner } from '../src/core/bootstrap/repo.ts';
import { attachWorkspace } from '../src/core/bootstrap/attach.ts';
import { readReceipt, receiptPath, writeManifest, type InstallReceipt } from '../src/core/bootstrap/format.ts';
import {
harnessReceiptPath,
readReceipt,
receiptPath,
writeHarnessReceipt,
writeManifest,
type HarnessReceipt,
type InstallReceipt,
} from '../src/core/bootstrap/format.ts';
import { GBRAIN_HOOK_MARKER_KEY, GBRAIN_HOOK_MARKER_VALUE } from '../src/core/bootstrap/host-specs.ts';
import { deriveWorkspaceSourceId } from '../src/core/bootstrap/verify.ts';
import { initState, setAnswer, skipAnswer, confirm, readBackHash } from '../src/core/bootstrap/interview.ts';
@@ -891,6 +899,99 @@ describe('uninstall --delete-brain ordering + engine-free stats', () => {
}, 30_000);
});
describe('uninstall × harness receipt (#4043 harness-first composition)', () => {
function harnessReceiptFor(overrides: Partial<HarnessReceipt> = {}): HarnessReceipt {
return {
harness_receipt_version: 1,
created_at: new Date().toISOString(),
created_by: 'gbrain@test',
url: 'http://127.0.0.1:19999/mcp',
source_id: 'default',
token: { name: 'bootstrap-harness', minted: false },
targets: [{ host: 'claude-code', kind: 'mcp', state: 'confirmed', scope: 'user', name: 'gbrain' }],
...overrides,
};
}
test('harness-only box: harness removed FIRST, NO_RECEIPT tolerated, exit 0', async () => {
const ws3 = mkdtempSync(join(tmpdir(), 'gb-harness-only-ws-'));
const isoHome = join(ws3, '.gbrain');
mkdirSync(join(isoHome, 'bootstrap'), { recursive: true });
// removeHarness locks the user-settings dir [X11] — sandbox it so the
// test never touches (or contends on) the operator's real ~/.claude.
const savedCfgDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = join(ws3, 'claude-cfg');
try {
writeHarnessReceipt(isoHome, harnessReceiptFor());
const { runner } = makeRunner();
const r = await capture(() =>
runBootstrap(['uninstall', '--workspace', ws3, '--yes', '--home', isoHome], { runner }),
);
expect(r.result).toBe(0);
expect(r.out).toContain('harness wiring detected — removing it first');
// GBRAIN_HOME points elsewhere in this suite, so the refusal lands as
// HOME_GUARD here; NO_RECEIPT fires when homes align. Both are in the
// tolerated set — pin that one of them is named.
expect(r.out).toMatch(/no workspace install on this machine \(naming the refusal: (NO_RECEIPT|HOME_GUARD)\)/);
// Ordering: harness removal output precedes the no-workspace message.
expect(r.out.indexOf('harness wiring detected')).toBeLessThan(r.out.indexOf('no workspace install'));
expect(existsSync(harnessReceiptPath(isoHome))).toBe(false); // receipt consumed
} finally {
if (savedCfgDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = savedCfgDir;
rmSync(ws3, { recursive: true, force: true });
}
}, 30_000);
test('harness removal that does not converge ABORTS before workspace teardown (exit 1)', async () => {
const ws4 = mkdtempSync(join(tmpdir(), 'gb-harness-abort-ws-'));
const isoHome = join(ws4, '.gbrain');
mkdirSync(join(isoHome, 'bootstrap'), { recursive: true });
const savedCfgDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = join(ws4, 'claude-cfg');
try {
// A hooks target whose settings file is parse-broken: the remove path
// refuses to touch what it cannot read → target stays failed → exit 1.
const brokenSettings = join(ws4, 'settings.json');
writeFileSync(brokenSettings, '{ not json', 'utf8');
writeHarnessReceipt(
isoHome,
harnessReceiptFor({
targets: [
{ host: 'claude-code', kind: 'hooks', state: 'confirmed', scope: 'user', path: brokenSettings, marker: 'bootstrap-harness-v1' },
],
}),
);
// A workspace receipt that WOULD be torn down if the abort failed.
const receipt: InstallReceipt = {
receipt_version: 1,
workspace_dir: ws4,
source_id: 'workspace',
agent_name: 'Dispatch',
created_at: new Date().toISOString(),
created_by: 'test',
brain_created_by_bootstrap: false,
created_paths: [],
registrations: [],
};
writeFileSync(receiptPath(isoHome), JSON.stringify(receipt), 'utf8');
const { runner } = makeRunner();
const r = await capture(() =>
runBootstrap(['uninstall', '--workspace', ws4, '--yes', '--home', isoHome], { runner }),
);
expect(r.result).toBe(1);
expect(r.err).toContain('harness removal did not fully converge');
// Aborted BEFORE teardown: both receipts survive for the retry.
expect(existsSync(harnessReceiptPath(isoHome))).toBe(true);
expect(existsSync(receiptPath(isoHome))).toBe(true);
} finally {
if (savedCfgDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = savedCfgDir;
rmSync(ws4, { recursive: true, force: true });
}
}, 30_000);
});
describe('cloud-setup-script emitter [D16]', () => {
test('prints the paste-ready script: npm-based (never bun fetching, never the npm squatter), launcher + attach flow', async () => {
const r = await capture(() => runBootstrap(['cloud-setup-script']));
+79
View File
@@ -20,6 +20,7 @@ import { join } from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
import { bootstrapDoctorChecks, type Check } from '../src/commands/doctor.ts';
import { writeHarnessReceipt } from '../src/core/bootstrap/format.ts';
import { LATEST_VERSION } from '../src/core/migrate.ts';
import { VERSION } from '../src/version.ts';
import type { BrainEngine } from '../src/core/engine.ts';
@@ -135,6 +136,84 @@ describe('no-bootstrap-state gate', () => {
const checks = await run(parent);
expect(byName(checks, 'bootstrap_hooks_heartbeat')?.status).toBe('ok');
}, T);
test('a harness receipt ALONE opens the gate (#4043 — harness-only boxes get checks)', async () => {
const { parent, home } = makeHome();
writeHarnessReceipt(home, harnessReceiptFixture([]));
const checks = await run(parent);
expect(checks.length).toBeGreaterThan(0);
expect(byName(checks, 'bootstrap_harness_health')).toBeDefined();
}, T);
});
// ── 0. harness registration health (#4043) ─────────────────────────────────
function harnessReceiptFixture(
targets: Array<{ state: 'pending' | 'confirmed' | 'failed' }>,
extra: Record<string, unknown> = {},
): never {
return {
harness_receipt_version: 1,
created_at: new Date().toISOString(),
created_by: `gbrain@${VERSION}`,
// an unroutable TEST-NET address so the /health probe fails fast + offline
url: 'http://192.0.2.1:9/mcp',
source_id: 'default',
token: { name: 'bootstrap-harness', id: '33333333-3333-3333-3333-333333333333', minted: true },
targets: targets.map((t) => ({ host: 'claude-code', kind: 'mcp', scope: 'user', name: 'gbrain', ...t })),
...extra,
} as never;
}
describe('bootstrap_harness_health (#4043)', () => {
test('failed/pending targets → fail with the converge instruction', async () => {
const { parent, home } = makeHome();
writeHarnessReceipt(home, harnessReceiptFixture([{ state: 'failed' }, { state: 'pending' }]));
const checks = await run(parent);
const c = byName(checks, 'bootstrap_harness_health');
expect(c?.status).toBe('fail');
expect(c?.message).toMatch(/1 failed \/ 1 pending/);
}, T);
test('unconverged rotation (previous_id) → fail naming the revoke command', async () => {
const { parent, home } = makeHome();
const receipt = harnessReceiptFixture([{ state: 'confirmed' }]) as Record<string, unknown>;
(receipt.token as Record<string, unknown>).previous_ids = ['44444444-4444-4444-4444-444444444444'];
writeHarnessReceipt(home, receipt as never);
const checks = await run(parent);
const c = byName(checks, 'bootstrap_harness_health');
expect(c?.status).toBe('fail');
expect(c?.message).toMatch(/never revoked/);
}, T);
test('healthy receipt + unreachable serve → WARN (normal transient), never fail', async () => {
const { parent, home } = makeHome();
writeHarnessReceipt(home, harnessReceiptFixture([{ state: 'confirmed' }]));
const checks = await run(parent);
const c = byName(checks, 'bootstrap_harness_health');
expect(c?.status).toBe('warn');
expect(c?.message).toMatch(/serve is unreachable|not answering/);
}, T);
test('half-removed receipt (zero targets, minted token still live) → FAIL, never a vacuous green (red-team)', async () => {
const { parent, home } = makeHome();
writeHarnessReceipt(home, harnessReceiptFixture([]));
const checks = await run(parent);
const c = byName(checks, 'bootstrap_harness_health');
expect(c?.status).toBe('fail');
expect(c?.message).toMatch(/removal pending/);
expect(c?.message).toContain('33333333-3333-3333-3333-333333333333');
}, T);
test('unreadable harness receipt → warn, never a throw (fail-soft umbrella)', async () => {
const { parent, home } = makeHome();
mkdirSync(join(home, 'bootstrap'), { recursive: true });
writeFileSync(join(home, 'bootstrap', 'harness.json'), 'not json{{{');
const checks = await run(parent);
const c = byName(checks, 'bootstrap_harness_health');
expect(c?.status).toBe('warn');
expect(c?.message).toMatch(/unreadable/);
}, T);
});
// ── 1. hook heartbeat failure rate [B3] ─────────────────────────────────────
+354
View File
@@ -0,0 +1,354 @@
/**
* bootstrap-harness-writers.test.ts harness-lane additions to the
* settings writers (#4043: writeClaudeHooksAt/removeClaudeHooksAt marker
* parameterization [C6], permissions.allow writers,
* atomic-write hardening [C10], CODEX_HOME-aware codexConfigPath).
*
* Behavior-preservation for the workspace lane is pinned by the untouched
* test/bootstrap-hooks-writers.test.ts this file covers only what harness
* mode adds.
*/
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync, lstatSync, realpathSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
addPermissionsAllowEntry,
removePermissionsAllowEntry,
removeClaudeHooksAt,
writeClaudeHooksAt,
} from '../src/core/bootstrap/hooks.ts';
import {
CLAUDE_HOOK_EVENTS,
GBRAIN_HARNESS_MARKER_VALUE,
GBRAIN_HOOK_MARKER_KEY,
GBRAIN_HOOK_MARKER_VALUE,
codexConfigPath,
mcpPermissionEntry,
} from '../src/core/bootstrap/host-specs.ts';
import {
deleteHarnessReceipt,
guardHarnessReceiptOverwrite,
harnessReceiptPath,
readHarnessReceiptState,
writeHarnessReceipt,
} from '../src/core/bootstrap/format.ts';
import { withEnv } from './helpers/with-env.ts';
const BIN = '/opt/fake/gbrain';
function tmp(): string {
return mkdtempSync(join(tmpdir(), 'gb-harness-writers-'));
}
function readJson(path: string): Record<string, unknown> {
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
}
function markerEntries(settings: Record<string, unknown>, marker: string): number {
let n = 0;
const hooks = (settings.hooks ?? {}) as Record<string, unknown>;
for (const groups of Object.values(hooks)) {
if (!Array.isArray(groups)) continue;
for (const g of groups) {
const entries = (g as { hooks?: unknown[] }).hooks;
if (!Array.isArray(entries)) continue;
for (const e of entries) {
if ((e as Record<string, unknown>)[GBRAIN_HOOK_MARKER_KEY] === marker) n++;
}
}
}
return n;
}
describe('two-marker coexistence [C6]', () => {
test('harness entries coexist with bootstrap-v1; each removal strips only its own', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
writeClaudeHooksAt(path, { gbrainBin: BIN, env: { GBRAIN_SOURCE: 'ws' } });
writeClaudeHooksAt(path, {
gbrainBin: BIN,
env: { GBRAIN_SOURCE: 'default' },
marker: GBRAIN_HARNESS_MARKER_VALUE,
});
let settings = readJson(path);
expect(markerEntries(settings, GBRAIN_HOOK_MARKER_VALUE)).toBe(CLAUDE_HOOK_EVENTS.length);
expect(markerEntries(settings, GBRAIN_HARNESS_MARKER_VALUE)).toBe(CLAUDE_HOOK_EVENTS.length);
const removed = removeClaudeHooksAt(path, GBRAIN_HARNESS_MARKER_VALUE);
expect(removed.removed).toBe(CLAUDE_HOOK_EVENTS.length);
settings = readJson(path);
expect(markerEntries(settings, GBRAIN_HOOK_MARKER_VALUE)).toBe(CLAUDE_HOOK_EVENTS.length);
expect(markerEntries(settings, GBRAIN_HARNESS_MARKER_VALUE)).toBe(0);
});
test('harness re-run dedupes on the harness marker only', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
const opts = {
gbrainBin: BIN,
env: { GBRAIN_SOURCE: 'default' },
marker: GBRAIN_HARNESS_MARKER_VALUE,
};
writeClaudeHooksAt(path, opts);
const second = writeClaudeHooksAt(path, opts);
expect(second.removedPrior).toBe(CLAUDE_HOOK_EVENTS.length);
expect(markerEntries(readJson(path), GBRAIN_HARNESS_MARKER_VALUE)).toBe(CLAUDE_HOOK_EVENTS.length);
});
test('refuseOnForeignGbrainMarker: refuses when another gbrain marker wires a target event', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
writeClaudeHooksAt(path, { gbrainBin: BIN, env: { GBRAIN_SOURCE: 'ws' } }); // bootstrap-v1
expect(() =>
writeClaudeHooksAt(path, {
gbrainBin: BIN,
env: { GBRAIN_SOURCE: 'default' },
marker: GBRAIN_HARNESS_MARKER_VALUE,
refuseOnForeignGbrainMarker: true,
}),
).toThrow(/already wires hooks\..* under gbrain marker "bootstrap-v1"/);
// untouched: still exactly the bootstrap-v1 entries
expect(markerEntries(readJson(path), GBRAIN_HOOK_MARKER_VALUE)).toBe(CLAUDE_HOOK_EVENTS.length);
expect(markerEntries(readJson(path), GBRAIN_HARNESS_MARKER_VALUE)).toBe(0);
});
test('refuseOnForeignGbrainMarker: foreign UNMARKED hooks never trip the refusal', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
writeFileSync(
path,
JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] } }),
);
const res = writeClaudeHooksAt(path, {
gbrainBin: BIN,
env: { GBRAIN_SOURCE: 'default' },
marker: GBRAIN_HARNESS_MARKER_VALUE,
refuseOnForeignGbrainMarker: true,
});
expect(res.installed.length).toBe(CLAUDE_HOOK_EVENTS.length);
const groups = (readJson(path).hooks as Record<string, unknown[]>).SessionStart;
const flat = groups.flatMap((g) => ((g as { hooks?: unknown[] }).hooks ?? []) as unknown[]);
expect(flat.some((e) => (e as { command?: string }).command === 'echo hi')).toBe(true);
});
});
describe('broken JSON is fail-closed', () => {
test('write throws and leaves the file untouched (never relocate a config we cannot read)', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
const broken = '{ "hooks": { // JSONC comment\n } }';
writeFileSync(path, broken);
expect(() =>
writeClaudeHooksAt(path, {
gbrainBin: BIN,
env: { GBRAIN_SOURCE: 'default' },
marker: GBRAIN_HARNESS_MARKER_VALUE,
}),
).toThrow(/refusing to rewrite/);
expect(readFileSync(path, 'utf8')).toBe(broken);
});
test('workspace-lane writes are fail-closed too', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
const broken = 'not json at all';
writeFileSync(path, broken);
expect(() => writeClaudeHooksAt(path, { gbrainBin: BIN, env: { GBRAIN_SOURCE: 'ws' } })).toThrow(
/refusing to rewrite/,
);
expect(readFileSync(path, 'utf8')).toBe(broken);
});
});
describe('permissions.allow writers', () => {
test('fresh file: permissions.allow created with exactly our entry', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
const res = addPermissionsAllowEntry(path, mcpPermissionEntry('gbrain'));
expect(res.added).toBe(true);
const settings = readJson(path);
expect((settings.permissions as { allow: string[] }).allow).toEqual(['mcp__gbrain']);
});
test('foreign allow entries + other keys survive; re-add is a no-op', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
writeFileSync(
path,
JSON.stringify({
permissions: { allow: ['Bash(ls:*)'], deny: ['WebFetch'] },
model: 'opus',
}),
);
addPermissionsAllowEntry(path, 'mcp__gbrain');
const again = addPermissionsAllowEntry(path, 'mcp__gbrain');
expect(again.added).toBe(false);
const settings = readJson(path);
expect((settings.permissions as { allow: string[] }).allow).toEqual(['Bash(ls:*)', 'mcp__gbrain']);
expect((settings.permissions as { deny: string[] }).deny).toEqual(['WebFetch']);
expect(settings.model).toBe('opus');
});
test('removal removes exactly our string; foreign entries survive', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
writeFileSync(path, JSON.stringify({ permissions: { allow: ['Bash(ls:*)', 'mcp__gbrain'] } }));
const res = removePermissionsAllowEntry(path, 'mcp__gbrain');
expect(res.removed).toBe(1);
expect((readJson(path).permissions as { allow: string[] }).allow).toEqual(['Bash(ls:*)']);
});
test('removal drops keys only when OUR removal emptied them', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
writeFileSync(path, JSON.stringify({ permissions: { allow: ['mcp__gbrain'] } }));
removePermissionsAllowEntry(path, 'mcp__gbrain');
const settings = readJson(path);
expect(settings.permissions).toBeUndefined();
});
test('removal of a missing entry / missing file is a calm no-op', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
expect(removePermissionsAllowEntry(path, 'mcp__gbrain').removed).toBe(0);
writeFileSync(path, JSON.stringify({ permissions: { allow: ['Bash(ls:*)'] } }));
expect(removePermissionsAllowEntry(path, 'mcp__gbrain').removed).toBe(0);
});
test('broken JSON: add aborts, remove leaves untouched with a note', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
const broken = '{ broken';
writeFileSync(path, broken);
expect(() => addPermissionsAllowEntry(path, 'mcp__gbrain')).toThrow(/refusing to rewrite/);
const res = removePermissionsAllowEntry(path, 'mcp__gbrain');
expect(res.removed).toBe(0);
expect(res.notes.join(' ')).toMatch(/left untouched/);
expect(readFileSync(path, 'utf8')).toBe(broken);
});
test('non-object permissions / non-array allow FAIL CLOSED — security policy is never rewritten', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
const weird = JSON.stringify({ permissions: 'weird' });
writeFileSync(path, weird);
expect(() => addPermissionsAllowEntry(path, 'mcp__gbrain')).toThrow(/refusing to rewrite security policy/);
expect(readFileSync(path, 'utf8')).toBe(weird); // untouched
const badAllow = JSON.stringify({ permissions: { allow: 'not-an-array', deny: ['WebFetch'] } });
writeFileSync(path, badAllow);
expect(() => addPermissionsAllowEntry(path, 'mcp__gbrain')).toThrow(/refusing to rewrite security policy/);
expect(readFileSync(path, 'utf8')).toBe(badAllow);
});
});
describe('atomic-write hardening [C10]', () => {
test('0600 file stays 0600 after write (mode preservation)', () => {
const dir = tmp();
const path = join(dir, 'settings.json');
writeFileSync(path, '{}', { mode: 0o600 });
addPermissionsAllowEntry(path, 'mcp__gbrain');
expect(statSync(path).mode & 0o777).toBe(0o600);
});
test('symlinked settings file survives as a symlink (realpath write)', () => {
const dir = tmp();
const real = join(dir, 'dotfiles', 'claude-settings.json');
mkdirSync(join(dir, 'dotfiles'), { recursive: true });
writeFileSync(real, JSON.stringify({ model: 'opus' }));
const link = join(dir, 'settings.json');
symlinkSync(real, link);
addPermissionsAllowEntry(link, 'mcp__gbrain');
expect(lstatSync(link).isSymbolicLink()).toBe(true);
expect(realpathSync(link)).toBe(realpathSync(real));
expect((readJson(real).permissions as { allow: string[] }).allow).toEqual(['mcp__gbrain']);
expect(readJson(real).model).toBe('opus');
});
test('timestamped backup strategy leaves distinct backups for sequential writes', async () => {
const dir = tmp();
const path = join(dir, 'settings.json');
writeFileSync(path, '{}');
const first = writeClaudeHooksAt(path, {
gbrainBin: BIN,
env: { GBRAIN_SOURCE: 'a' },
marker: GBRAIN_HARNESS_MARKER_VALUE,
backupStrategy: 'timestamped',
});
await new Promise((r) => setTimeout(r, 2));
const second = writeClaudeHooksAt(path, {
gbrainBin: BIN,
env: { GBRAIN_SOURCE: 'b' },
marker: GBRAIN_HARNESS_MARKER_VALUE,
backupStrategy: 'timestamped',
});
expect(first.backupPath).not.toBeNull();
expect(second.backupPath).not.toBeNull();
expect(first.backupPath).not.toBe(second.backupPath);
});
});
describe('harness receipt (format.ts, CX2-12 discipline)', () => {
test('write-ahead round trip: 0600 file, typed read states, guard semantics', () => {
const home = tmp();
const receipt = {
harness_receipt_version: 1 as const,
created_at: '2026-08-12T00:00:00Z',
created_by: 'gbrain@test',
url: 'http://127.0.0.1:3131/mcp',
engine: 'postgres',
serve_version: '0.45.7.0',
source_id: 'default',
token: { name: 'bootstrap-harness', id: '00000000-0000-0000-0000-000000000000', minted: true },
targets: [
{ host: 'claude-code' as const, kind: 'mcp' as const, state: 'pending' as const, scope: 'user', name: 'gbrain' },
],
};
expect(readHarnessReceiptState(home)).toEqual({ state: 'absent' });
writeHarnessReceipt(home, receipt);
expect(statSync(harnessReceiptPath(home)).mode & 0o777).toBe(0o600);
const state = readHarnessReceiptState(home);
expect(state.state).toBe('ok');
expect((state as { receipt: typeof receipt }).receipt.targets[0].state).toBe('pending');
// newer-format refusal (never silently clobber a newer gbrain's receipt)
writeFileSync(harnessReceiptPath(home), JSON.stringify({ harness_receipt_version: 2 }));
expect(readHarnessReceiptState(home).state).toBe('newer');
expect(() => guardHarnessReceiptOverwrite(home)).toThrow(/newer gbrain/);
// broken receipt → backed up aside, never silently discarded
writeFileSync(harnessReceiptPath(home), 'not json');
expect(readHarnessReceiptState(home).state).toBe('invalid');
const guard = guardHarnessReceiptOverwrite(home);
expect(guard.brokenBackupPath).toMatch(/\.broken-/);
expect(readHarnessReceiptState(home)).toEqual({ state: 'absent' });
// consume
writeHarnessReceipt(home, receipt);
deleteHarnessReceipt(home);
expect(readHarnessReceiptState(home)).toEqual({ state: 'absent' });
});
});
describe('codexConfigPath honors CODEX_HOME', () => {
test('CODEX_HOME set → config.toml directly inside it; unset → ~/.codex/config.toml', async () => {
const dir = tmp();
await withEnv({ CODEX_HOME: dir }, async () => {
expect(codexConfigPath()).toBe(join(dir, 'config.toml'));
});
await withEnv({ CODEX_HOME: undefined }, async () => {
expect(codexConfigPath()).toContain(join('.codex', 'config.toml'));
});
await withEnv({ CODEX_HOME: ' ' }, async () => {
expect(codexConfigPath()).toContain(join('.codex', 'config.toml')); // blank trims to falsy
});
});
});
+854
View File
@@ -0,0 +1,854 @@
/**
* bootstrap-harness.serial.test.ts `gbrain bootstrap harness` (#4043)
* orchestration contracts, against applyHarness/removeHarness/statusHarness
* with fully injected deps (no engine, no network, no real CLIs):
*
* - consent gate: non-TTY without --yes refuses BEFORE any mutation
* - full apply: claude argv carries --scope user + bearer; codex wiring is
* the managed TOML block (codex CLI never execed); permissions.allow +
* five hooks with the harness lane env
* - write-ahead receipt [F1]: pending targets exist the moment minting
* completes; failures are per-target and --remove consumes the receipt
* - mint-first rotation [C7]: the previous token is revoked BY ID only
* after all targets confirm + smoke passes; a wiring failure leaves it
* - ownership [C8]: a foreign-url registration refuses without --force;
* --remove skips url-mismatched registrations with a note
* - user-XOR-project hooks [C6]; loopback guard [F3]; --project validation
* [F4]; --no-capture subset [C5]; version-skew note [F7]; idempotent
* not-found removals [F2]; PGLite live-serve revoke deferral [C9]
* - statusHarness: absent-receipt json exit contract, token recovery from
* the codex block, revoked-token failure, serve-down failure
*
* Serial: dispatcher-level cases set GBRAIN_HOME.
*/
import { describe, test, expect } from 'bun:test';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
applyHarness,
buildConsentBlock,
codexBlockOwnsName,
isServeOlderThanScopes,
SCOPES_MIN_SERVE_VERSION,
parseClaudeMcpGetBearer,
parseClaudeMcpGetUrl,
parseCodexBlockBearer,
parseHarnessArgs,
removeHarness,
statusHarness,
type HarnessDeps,
type HarnessFlags,
} from '../src/core/bootstrap/harness.ts';
import { readHarnessReceiptState, harnessReceiptPath } from '../src/core/bootstrap/format.ts';
import {
CLAUDE_HOOK_EVENTS,
CODEX_TOML_BLOCK_BEGIN,
CODEX_TOML_BLOCK_END,
GBRAIN_HARNESS_MARKER_VALUE,
} from '../src/core/bootstrap/host-specs.ts';
import type { ExecRunner } from '../src/core/bootstrap/repo.ts';
import type { ConnectProbeResult } from '../src/core/connect-probe.ts';
import { VERSION } from '../src/version.ts';
const TOKEN_A = `gbrain_${'a'.repeat(64)}`;
const TOKEN_B = `gbrain_${'b'.repeat(64)}`;
const ID_A = '11111111-1111-1111-1111-111111111111';
const ID_B = '22222222-2222-2222-2222-222222222222';
const URL = 'http://127.0.0.1:3131/mcp';
interface Fake {
deps: HarnessDeps;
calls: string[][];
revoked: string[];
mintCalls: Array<{ name: string; scopes: string[]; sourceGrant?: string[] }>;
out: string[];
err: string[];
home: string;
userSettings: string;
codexConfig: string;
}
function makeFake(opts: {
mcpGet?: (name: string) => { code: number; stdout: string; stderr: string };
mcpAddCode?: number;
health?: { ok: boolean; version?: string; engine?: string };
probeOk?: boolean;
mintQueue?: Array<{ token: string; id: string }>;
pgliteLive?: boolean;
} = {}): Fake {
const dir = mkdtempSync(join(tmpdir(), 'gb-harness-'));
const home = join(dir, '.gbrain');
mkdirSync(home, { recursive: true });
const userSettings = join(dir, 'claude-settings.json');
const codexConfig = join(dir, 'codex-config.toml');
const calls: string[][] = [];
const revoked: string[] = [];
const mintCalls: Array<{ name: string; scopes: string[]; sourceGrant?: string[] }> = [];
const out: string[] = [];
const err: string[] = [];
const mintQueue = opts.mintQueue ?? [{ token: TOKEN_A, id: ID_A }, { token: TOKEN_B, id: ID_B }];
let mintIdx = 0;
const health = opts.health ?? { ok: true, version: VERSION, engine: 'postgres' };
const runner: ExecRunner = async (argv: string[]) => {
calls.push(argv);
if (argv[0] === 'claude' && argv[2] === 'get') {
return opts.mcpGet ? opts.mcpGet(argv[3]) : { code: 1, stdout: '', stderr: 'No MCP server found' };
}
if (argv[0] === 'claude' && argv[2] === 'add') {
return { code: opts.mcpAddCode ?? 0, stdout: '', stderr: opts.mcpAddCode ? 'add failed' : '' };
}
return { code: 0, stdout: '', stderr: '' };
};
const deps: HarnessDeps = {
runner,
gbrainHome: home,
isTTY: false,
fetchFn: (async () => {
if (!health.ok) throw new Error('ECONNREFUSED');
return new Response(JSON.stringify({ status: 'ok', version: health.version, engine: health.engine }), {
status: 200,
});
}) as unknown as typeof fetch,
probeIdentity: async (_url: string, probeToken: string): Promise<ConnectProbeResult> => {
// Token-aware like a REAL serve: only tokens this fixture knows about
// authenticate — the apply-time canary (a random same-format token)
// must fail with reason 'auth' or every apply trips the impostor guard.
const known = new Set([TOKEN_A, TOKEN_B, ...mintQueue.map((m) => m.token)]);
if (!known.has(probeToken)) return { ok: false, reason: 'auth', message: 'HTTP 401' };
return (opts.probeOk ?? true)
? { ok: true, identity: 'brain "test" (source default)' }
: { ok: false, reason: 'auth', message: 'HTTP 401' };
},
userSettingsPath: userSettings,
codexConfig,
mint: async (o) => {
mintCalls.push(o as { name: string; scopes: string[]; sourceGrant?: string[] });
const m = mintQueue[Math.min(mintIdx++, mintQueue.length - 1)];
return { token: m.token, id: m.id, name: 'bootstrap-harness', scopes: ['read', 'write'] };
},
revokeById: async (id: string) => {
revoked.push(id);
return true;
},
pgliteLiveServe: () => opts.pgliteLive ?? false,
detectClaude: () => true,
detectCodex: () => true,
gbrainBin: '/opt/fake/gbrain',
log: (l) => out.push(l),
logError: (l) => err.push(l),
};
return { deps, calls, revoked, mintCalls, out, err, home, userSettings, codexConfig };
}
function flags(extra: string[] = []): HarnessFlags {
return parseHarnessArgs(['--yes', ...extra]);
}
function readJson(path: string): Record<string, unknown> {
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
}
describe('parseHarnessArgs', () => {
test('defaults + aliases: --local and --user-hooks are accepted no-ops', () => {
const f = parseHarnessArgs(['--local', '--user-hooks']);
expect(f.error).toBeUndefined();
expect(f.harness).toBe('all');
expect(f.tokenName).toBe('bootstrap-harness');
expect(f.name).toBe('gbrain');
});
test('unknown --harness refused; bad --port refused; bad --name refused', () => {
expect(parseHarnessArgs(['--harness', 'cursor']).error).toMatch(/unknown --harness/);
expect(parseHarnessArgs(['--port', 'nope']).error).toMatch(/invalid --port/);
expect(parseHarnessArgs(['--name', 'My Server']).error).toMatch(/invalid --name/);
});
test('--project is repeatable and resolved', () => {
const f = parseHarnessArgs(['--project', '/a', '--project', '/b']);
expect(f.projects).toEqual(['/a', '/b']);
});
});
describe('consent gate', () => {
test('non-TTY without --yes refuses BEFORE any mutation (no mint, no files)', async () => {
const f = makeFake();
const code = await applyHarness(parseHarnessArgs([]), f.deps);
expect(code).toBe(2);
expect(f.err.join('\n')).toMatch(/pass --yes/);
expect(existsSync(f.userSettings)).toBe(false);
expect(existsSync(f.codexConfig)).toBe(false);
expect(readHarnessReceiptState(f.home)).toEqual({ state: 'absent' });
});
test('consent copy: capture is its own numbered item with the no-capture off-ramp', () => {
const block = buildConsentBlock({
tokenName: 'bootstrap-harness',
tokenSupplied: false,
scopes: ['read', 'write'],
url: URL,
wireClaude: true,
wireCodex: true,
hooks: true,
capture: true,
hookScope: 'user scope',
name: 'gbrain',
userSettingsPath: '/u/settings.json',
codexConfig: '/u/config.toml',
});
expect(block).toMatch(/Session-transcript capture: every Claude Code session/);
expect(block).toMatch(/no-capture/);
expect(block).toMatch(/read AND write/);
expect(block).toMatch(/five lifecycle hooks/);
});
});
describe('full apply', () => {
test('claude scope-user argv + permissions + five lane-tagged hooks + codex block + receipt', async () => {
const f = makeFake();
const code = await applyHarness(flags(), f.deps);
expect(code).toBe(0);
// claude mcp add with --scope user and the bearer header
const add = f.calls.find((c) => c[0] === 'claude' && c[2] === 'add');
expect(add).toBeDefined();
expect(add!.join(' ')).toContain('--scope user');
expect(add!.join(' ')).toContain(`Authorization: Bearer ${TOKEN_A}`);
// codex CLI NEVER execed — the TOML block is the single write mechanism
expect(f.calls.some((c) => c[0] === 'codex')).toBe(false);
const toml = readFileSync(f.codexConfig, 'utf8');
expect(toml).toContain(CODEX_TOML_BLOCK_BEGIN);
expect(toml).toContain(`bearer_token = "${TOKEN_A}"`);
const settings = readJson(f.userSettings);
expect((settings.permissions as { allow: string[] }).allow).toEqual(['mcp__gbrain']);
const hooks = settings.hooks as Record<string, unknown[]>;
expect(Object.keys(hooks).sort()).toEqual([...CLAUDE_HOOK_EVENTS].sort());
const cmd = ((hooks.SessionStart[0] as { hooks: Array<{ command: string }> }).hooks[0]).command;
expect(cmd).toContain('GBRAIN_HOOK_LANE=harness');
expect(cmd).toContain('GBRAIN_SOURCE=default');
const state = readHarnessReceiptState(f.home);
expect(state.state).toBe('ok');
const receipt = (state as { receipt: { targets: Array<{ state: string }>; token: { id?: string } } }).receipt;
expect(receipt.targets.every((t) => t.state === 'confirmed')).toBe(true);
expect(receipt.token.id).toBe(ID_A);
// first run: nothing to rotate
expect(f.revoked).toEqual([]);
});
test('--no-capture wires the context events only [C5]', async () => {
const f = makeFake();
const code = await applyHarness(flags(['--no-capture', '--harness', 'claude-code']), f.deps);
expect(code).toBe(0);
const hooks = readJson(f.userSettings).hooks as Record<string, unknown>;
expect(Object.keys(hooks).sort()).toEqual(['PreCompact', 'SessionStart', 'UserPromptSubmit']);
});
test('re-run rotates mint-first [C7]: one entry per event, previous token revoked by id AFTER confirm', async () => {
const f = makeFake();
expect(await applyHarness(flags(), f.deps)).toBe(0);
// second run: existing registration points at OUR url → remove+re-add
const f2deps: HarnessDeps = {
...f.deps,
runner: async (argv: string[]) => {
f.calls.push(argv);
if (argv[0] === 'claude' && argv[2] === 'get') {
return { code: 0, stdout: `Scope: User\nType: http\nURL: ${URL}\nHeaders:\n Authorization: Bearer ${TOKEN_A}`, stderr: '' };
}
return { code: 0, stdout: '', stderr: '' };
},
};
expect(await applyHarness(flags(), f2deps)).toBe(0);
expect(f.revoked).toEqual([ID_A]); // previous id, only after full confirm
const settings = readJson(f.userSettings);
const groups = (settings.hooks as Record<string, unknown[]>).SessionStart;
const entries = groups.flatMap((g) => ((g as { hooks?: unknown[] }).hooks ?? []) as unknown[]);
expect(entries.length).toBe(1); // marker dedupe, not accumulation
expect((settings.permissions as { allow: string[] }).allow).toEqual(['mcp__gbrain']);
expect(readFileSync(f.codexConfig, 'utf8').split(CODEX_TOML_BLOCK_BEGIN).length - 1).toBe(1);
expect(readFileSync(f.codexConfig, 'utf8')).toContain(TOKEN_B);
});
test('wiring failure leaves the OLD token unrevoked and the receipt retryable [C7/F1]', async () => {
const f = makeFake();
expect(await applyHarness(flags(), f.deps)).toBe(0);
const f2 = makeFake({ mcpAddCode: 1, mintQueue: [{ token: TOKEN_B, id: ID_B }] });
// same home so the prior receipt is visible
const deps: HarnessDeps = { ...f2.deps, gbrainHome: f.home, userSettingsPath: f.userSettings, codexConfig: f.codexConfig };
const code = await applyHarness(flags(), deps);
expect(code).toBe(1);
expect(f2.revoked).toEqual([]); // old token still live
const state = readHarnessReceiptState(f.home);
const receipt = (state as { receipt: { targets: Array<{ state: string; kind: string }>; token: { previous_ids?: string[] } } }).receipt;
expect(receipt.token.previous_ids).toEqual([ID_A]); // kept for the next converge [X4]
expect(receipt.targets.some((t) => t.state === 'failed' && t.kind === 'mcp')).toBe(true);
});
test('ownership [C8]: foreign-url registration refuses without --force, replaces with it', async () => {
const foreign = { code: 0, stdout: 'Scope: User\nType: http\nURL: http://127.0.0.1:9999/mcp', stderr: '' };
const f = makeFake({ mcpGet: () => foreign });
const code = await applyHarness(flags(['--harness', 'claude-code', '--no-hooks']), f.deps);
expect(code).toBe(1);
expect(f.err.join('\n')).toMatch(/points at http:\/\/127\.0\.0\.1:9999\/mcp/);
expect(f.err.join('\n')).toMatch(/--force/);
expect(f.calls.some((c) => c[0] === 'claude' && c[2] === 'add')).toBe(false);
const f2 = makeFake({ mcpGet: () => foreign });
const code2 = await applyHarness(flags(['--harness', 'claude-code', '--no-hooks', '--force']), f2.deps);
expect(code2).toBe(0);
expect(f2.calls.some((c) => c[0] === 'claude' && c[2] === 'remove')).toBe(true);
expect(f2.calls.some((c) => c[0] === 'claude' && c[2] === 'add')).toBe(true);
});
test('user-XOR-project hooks [C6]: --project after a user-scope install refuses', async () => {
const f = makeFake();
expect(await applyHarness(flags(), f.deps)).toBe(0);
const proj = mkdtempSync(join(tmpdir(), 'gb-proj-'));
const code = await applyHarness(flags(['--project', proj]), f.deps);
expect(code).toBe(2);
expect(f.err.join('\n')).toMatch(/USER-scope harness hooks.*double-fire/s);
});
test('loopback guard [F3]: non-loopback --url without --token refused; allowed with --token', async () => {
const f = makeFake();
const code = await applyHarness(flags(['--url', 'http://192.168.1.50:3131/mcp']), f.deps);
expect(code).toBe(2);
expect(f.err.join('\n')).toMatch(/gbrain connect/);
const f2 = makeFake();
const code2 = await applyHarness(
flags(['--url', 'http://192.168.1.50:3131/mcp', '--token', TOKEN_A, '--harness', 'codex']),
f2.deps,
);
expect(code2).toBe(0);
expect(readFileSync(f2.codexConfig, 'utf8')).toContain('http://192.168.1.50:3131/mcp');
});
test('--project must exist [F4]', async () => {
const f = makeFake();
const code = await applyHarness(flags(['--project', '/definitely/not/a/dir']), f.deps);
expect(code).toBe(2);
expect(f.err.join('\n')).toMatch(/does not exist/);
});
test('health failure is a hard stop with no writes', async () => {
const f = makeFake({ health: { ok: false } });
const code = await applyHarness(flags(), f.deps);
expect(code).toBe(1);
expect(f.err.join('\n')).toMatch(/no healthy gbrain serve/);
expect(readHarnessReceiptState(f.home)).toEqual({ state: 'absent' });
});
test('version-skew note [F7] when the serve predates token scoping', async () => {
const f = makeFake({ health: { ok: true, version: '0.1.0', engine: 'pglite' } });
const code = await applyHarness(flags(['--harness', 'codex']), f.deps);
expect(code).toBe(0);
expect(f.out.join('\n')).toMatch(/predates token scoping.*FULL-ACCESS/s);
});
test('postgres engine prints the degradation note; smoke auth-fail names the wrong-brain cause', async () => {
const f = makeFake({ probeOk: false });
const code = await applyHarness(flags(['--harness', 'codex']), f.deps);
expect(code).toBe(1);
expect(f.out.join('\n')).toMatch(/no_pglite_path.*MCP tools are the active seam/s);
expect(f.err.join('\n')).toMatch(/GBRAIN_HOME \/ DATABASE_URL/);
});
});
describe('--remove', () => {
test('full remove: ours-by-url removed, token revoked by id, receipt consumed; foreign entries survive', async () => {
const f = makeFake();
expect(await applyHarness(flags(), f.deps)).toBe(0);
// seed a foreign allow entry AFTER apply — must survive removal
const s = readJson(f.userSettings);
(s.permissions as { allow: string[] }).allow.push('Bash(ls:*)');
writeFileSync(f.userSettings, JSON.stringify(s, null, 2));
const removeDeps: HarnessDeps = {
...f.deps,
runner: async (argv: string[]) => {
f.calls.push(argv);
if (argv[0] === 'claude' && argv[2] === 'get') {
return { code: 0, stdout: `Type: http\nURL: ${URL}`, stderr: '' };
}
return { code: 0, stdout: '', stderr: '' };
},
};
const code = await removeHarness(parseHarnessArgs(['--remove', '--yes']), removeDeps);
expect(code).toBe(0);
expect(f.revoked).toContain(ID_A);
expect(readHarnessReceiptState(f.home)).toEqual({ state: 'absent' });
const after = readJson(f.userSettings);
expect((after.permissions as { allow: string[] }).allow).toEqual(['Bash(ls:*)']);
expect(after.hooks).toBeUndefined();
expect(readFileSync(f.codexConfig, 'utf8')).toBe('');
});
test('not-found counts as removed [F2]; url-mismatch skipped with a note [C8]', async () => {
const f = makeFake();
expect(await applyHarness(flags(['--harness', 'claude-code', '--no-hooks']), f.deps)).toBe(0);
const removeDeps: HarnessDeps = {
...f.deps,
runner: async (argv: string[]) => {
if (argv[0] === 'claude' && argv[2] === 'get') {
return { code: 0, stdout: 'Type: http\nURL: http://127.0.0.1:7777/mcp', stderr: '' };
}
if (argv[0] === 'claude' && argv[2] === 'remove') throw new Error('must not remove a foreign registration');
return { code: 0, stdout: '', stderr: '' };
},
};
const code = await removeHarness(parseHarnessArgs(['--remove', '--yes']), removeDeps);
expect(code).toBe(0);
expect(f.out.join('\n')).toMatch(/owned by another install/);
});
test('PGLite live serve defers the revoke [C9]: host wiring removed, receipt keeps the token remainder', async () => {
const f = makeFake();
expect(await applyHarness(flags(), f.deps)).toBe(0);
const removeDeps: HarnessDeps = {
...f.deps,
pgliteLiveServe: () => true,
runner: async (argv: string[]) => {
if (argv[0] === 'claude' && argv[2] === 'get') return { code: 0, stdout: `URL: ${URL}`, stderr: '' };
return { code: 0, stdout: '', stderr: '' };
},
};
const code = await removeHarness(parseHarnessArgs(['--remove', '--yes']), removeDeps);
expect(code).toBe(1);
expect(f.revoked).toEqual([]);
expect(f.err.join('\n')).toMatch(/token\(s\) NOT revoked/);
const state = readHarnessReceiptState(f.home);
expect(state.state).toBe('ok');
expect((state as { receipt: { targets: unknown[] } }).receipt.targets).toEqual([]);
// Red-team CRITICAL: the half-removed remainder (zero targets, minted
// token still live) must NOT read green — cron sees exit 1 until the
// deferred revoke lands.
const statusCode = await statusHarness(parseHarnessArgs(['--status']), removeDeps);
expect(statusCode).toBe(1);
expect(f.err.join('\n')).toMatch(/removal pending/);
});
test('absent receipt is a calm exit 0', async () => {
const f = makeFake();
expect(await removeHarness(parseHarnessArgs(['--remove']), f.deps)).toBe(0);
expect(f.out.join('\n')).toMatch(/nothing harness-installed/);
});
});
describe('--status', () => {
test('absent receipt: human exit 0, --json exit 2 (cron contract)', async () => {
const f = makeFake();
expect(await statusHarness(parseHarnessArgs(['--status']), f.deps)).toBe(0);
expect(await statusHarness(parseHarnessArgs(['--status', '--json']), f.deps)).toBe(2);
});
test('green path: token recovered from the codex block, identity verified, exit 0', async () => {
const f = makeFake({ mcpGet: () => ({ code: 1, stdout: '', stderr: 'nope' }) });
expect(await applyHarness(flags(), f.deps)).toBe(0);
const code = await statusHarness(parseHarnessArgs(['--status']), f.deps);
expect(code).toBe(0);
expect(f.out.join('\n')).toMatch(/token: OK .*codex config block/);
expect(f.out.join('\n')).toMatch(/degraded on Postgres/);
});
test('revoked-under-a-green-receipt: token verify fails → exit 1', async () => {
const f = makeFake();
expect(await applyHarness(flags(), f.deps)).toBe(0);
const statusDeps: HarnessDeps = {
...f.deps,
probeIdentity: async () => ({ ok: false, reason: 'auth', message: 'HTTP 401' }),
};
const code = await statusHarness(parseHarnessArgs(['--status']), statusDeps);
expect(code).toBe(1);
expect(f.out.join('\n')).toMatch(/token: FAILED \(auth\)/);
});
test('serve down → exit 1 with honest token line', async () => {
const f = makeFake();
expect(await applyHarness(flags(), f.deps)).toBe(0);
const statusDeps: HarnessDeps = {
...f.deps,
fetchFn: (async () => {
throw new Error('ECONNREFUSED');
}) as unknown as typeof fetch,
};
const code = await statusHarness(parseHarnessArgs(['--status']), statusDeps);
expect(code).toBe(1);
expect(f.out.join('\n')).toMatch(/serve: UNREACHABLE/);
expect(f.out.join('\n')).toMatch(/not verified \(serve unreachable\)/);
});
});
describe('outside-voice hardening (X-batch)', () => {
const ID_C = '33333333-3333-3333-3333-333333333333';
const TOKEN_C = `gbrain_${'c'.repeat(64)}`;
test('[X1] explicit --harness codex FORCES wiring with zero detection signals', async () => {
const f = makeFake();
const deps: HarnessDeps = { ...f.deps, detectCodex: () => false, detectClaude: () => false };
const code = await applyHarness(flags(['--harness', 'codex']), deps);
expect(code).toBe(0);
expect(readFileSync(f.codexConfig, 'utf8')).toContain(CODEX_TOML_BLOCK_BEGIN);
});
test('[X2] --source reaches the mint as a scalar write-floor grant; absent → federation default', async () => {
const f = makeFake();
expect(await applyHarness(flags(['--harness', 'codex', '--source', 'wiki']), f.deps)).toBe(0);
expect(f.mintCalls[0].sourceGrant).toEqual(['wiki']);
const f2 = makeFake();
expect(await applyHarness(flags(['--harness', 'codex']), f2.deps)).toBe(0);
expect(f2.mintCalls[0].sourceGrant).toBeUndefined();
});
test('[X3] --no-capture RE-RUN unwires the capture events it previously wired', async () => {
const f = makeFake();
expect(await applyHarness(flags(['--harness', 'claude-code']), f.deps)).toBe(0);
expect(Object.keys(readJson(f.userSettings).hooks as object).length).toBe(5);
expect(await applyHarness(flags(['--harness', 'claude-code', '--no-capture']), f.deps)).toBe(0);
const hooks = readJson(f.userSettings).hooks as Record<string, unknown>;
expect(Object.keys(hooks).sort()).toEqual(['PreCompact', 'SessionStart', 'UserPromptSubmit']);
});
test('[X3] a changed --project set unwires the dropped dir and the receipt stays honest', async () => {
const projA = mkdtempSync(join(tmpdir(), 'gb-proj-a-'));
const projB = mkdtempSync(join(tmpdir(), 'gb-proj-b-'));
const f = makeFake();
expect(await applyHarness(flags(['--harness', 'claude-code', '--project', projA]), f.deps)).toBe(0);
const settingsA = join(projA, '.claude', 'settings.local.json');
expect(readFileSync(settingsA, 'utf8')).toContain(GBRAIN_HARNESS_MARKER_VALUE);
expect(await applyHarness(flags(['--harness', 'claude-code', '--project', projB]), f.deps)).toBe(0);
// A unwired; B wired; receipt records only B
expect(readFileSync(settingsA, 'utf8')).not.toContain(GBRAIN_HARNESS_MARKER_VALUE);
expect(readFileSync(join(projB, '.claude', 'settings.local.json'), 'utf8')).toContain(GBRAIN_HARNESS_MARKER_VALUE);
const state = readHarnessReceiptState(f.home);
const targets = (state as { receipt: { targets: Array<{ kind: string; scope: string }> } }).receipt.targets;
expect(targets.filter((t) => t.kind === 'hooks').map((t) => t.scope)).toEqual([projB]);
});
test('[X4] a failed rotation accumulates unrevoked ids; the next converge revokes them ALL', async () => {
const f = makeFake({ mintQueue: [{ token: TOKEN_A, id: ID_A }, { token: TOKEN_B, id: ID_B }, { token: TOKEN_C, id: ID_C }] });
expect(await applyHarness(flags(['--harness', 'codex']), f.deps)).toBe(0); // mints A
// run 2 fails post-mint (codex config made unwritable via foreign damage)
writeFileSync(f.codexConfig, `${CODEX_TOML_BLOCK_BEGIN}\ndamaged`); // one marker only → writer refuses
expect(await applyHarness(flags(['--harness', 'codex']), f.deps)).toBe(1); // mints B, wiring fails
expect(f.revoked).toEqual([]);
// repair the config, run 3 converges: A AND B both revoked
writeFileSync(f.codexConfig, '');
expect(await applyHarness(flags(['--harness', 'codex']), f.deps)).toBe(0); // mints C
expect([...f.revoked].sort()).toEqual([ID_A, ID_B].sort());
const state = readHarnessReceiptState(f.home);
expect((state as { receipt: { token: { previous_ids?: string[] } } }).receipt.token.previous_ids).toBeUndefined();
});
test('[X5] claude add-failure restores the previous registration (old clients stay connected)', async () => {
// the OLD registration carries TOKEN_B; the fresh mint is TOKEN_A —
// fail the new-token add, succeed the restore of the old one.
const oursGet = {
code: 0,
stdout: `Scope: User\nType: http\nURL: ${URL}\nHeaders:\n Authorization: Bearer ${TOKEN_B}`,
stderr: '',
};
const f = makeFake({ mcpGet: () => oursGet });
const deps: HarnessDeps = {
...f.deps,
runner: async (argv: string[]) => {
f.calls.push(argv);
if (argv[0] === 'claude' && argv[2] === 'get') return oursGet;
if (argv[0] === 'claude' && argv[2] === 'add' && argv.join(' ').includes(`Bearer ${TOKEN_A}`)) {
return { code: 1, stdout: '', stderr: 'add failed' };
}
return { code: 0, stdout: '', stderr: '' };
},
};
const code = await applyHarness(flags(['--harness', 'claude-code', '--no-hooks']), deps);
expect(code).toBe(1);
const adds = f.calls.filter((c) => c[0] === 'claude' && c[2] === 'add');
expect(adds.length).toBe(2); // failed new add + restore of the old registration
expect(adds[1].join(' ')).toContain(`Bearer ${TOKEN_B}`);
expect(f.out.join('\n')).toMatch(/previous MCP registration restored/);
});
test('[X5] smoke failure rolls the codex block back to the pre-run config', async () => {
const f = makeFake();
writeFileSync(f.codexConfig, 'model = "o5"\n');
expect(await applyHarness(flags(['--harness', 'codex']), f.deps)).toBe(0); // block with TOKEN_A
const preRun = readFileSync(f.codexConfig, 'utf8');
const f2deps: HarnessDeps = {
...f.deps,
probeIdentity: async () => ({ ok: false, reason: 'unreachable', message: 'boom' }),
};
expect(await applyHarness(flags(['--harness', 'codex']), f2deps)).toBe(1); // mints B, smoke fails
expect(readFileSync(f.codexConfig, 'utf8')).toBe(preRun); // TOKEN_A block restored
// The OLD token (ID_A) stays live and wired; the FRESH mint (ID_B) — which
// was sent to the unverified endpoint — is retired immediately.
expect(f.revoked).toEqual([ID_B]);
});
test('permission pre-approval never lands when the MCP registration itself failed (red-team CRITICAL)', async () => {
const f = makeFake({ mcpAddCode: 1 }); // `claude mcp add` fails
const code = await applyHarness(flags(['--harness', 'claude-code', '--no-hooks']), f.deps);
expect(code).toBe(1);
// The pre-approval string must not bless whatever server owns the name.
expect(existsSync(f.userSettings) ? JSON.stringify(readJson(f.userSettings)) : '{}').not.toContain('mcp__gbrain');
const state = readHarnessReceiptState(f.home);
const perm = (state as { receipt: { targets: Array<{ kind: string; state: string; error?: string }> } }).receipt.targets.find(
(t) => t.kind === 'permission',
);
expect(perm?.state).toBe('failed');
expect(perm?.error).toMatch(/foreign server/);
});
test('a freshly-added pre-approval is removed by the smoke-failure rollback (red-team CRITICAL)', async () => {
const f = makeFake({ probeOk: false });
const code = await applyHarness(flags(['--harness', 'claude-code', '--no-hooks']), f.deps);
expect(code).toBe(1);
const allow = ((readJson(f.userSettings).permissions as { allow?: string[] })?.allow) ?? [];
expect(allow).not.toContain('mcp__gbrain');
});
test('status never recovers a bearer from a registration owned by ANOTHER install (red-team CRITICAL)', async () => {
const f = makeFake();
expect(await applyHarness(flags(['--harness', 'claude-code', '--no-hooks']), f.deps)).toBe(0);
const statusDeps: HarnessDeps = {
...f.deps,
runner: async (argv: string[]) => {
if (argv[0] === 'claude' && argv[2] === 'get') {
// The name now points at a DIFFERENT install's serve — its bearer
// is a foreign credential and must not be transmitted anywhere.
return {
code: 0,
stdout: `gbrain:\n Scope: User config\n Type: http\n URL: http://127.0.0.1:9999/mcp\n Headers:\n Authorization: Bearer ${TOKEN_B}\n`,
stderr: '',
};
}
return { code: 0, stdout: '', stderr: '' };
},
probeIdentity: async () => {
throw new Error('status must NOT probe with a foreign bearer');
},
};
const code = await statusHarness(parseHarnessArgs(['--status']), statusDeps);
expect(f.out.join('\n')).toMatch(/verify unavailable/);
expect(code).toBe(0); // honest degrade, all targets confirmed
});
test('canary impostor guard: an endpoint that accepts an INVALID credential fails the apply and the fresh mint is revoked', async () => {
const f = makeFake();
// Impostor behavior: accept EVERY bearer (it cannot validate any).
const deps: HarnessDeps = {
...f.deps,
probeIdentity: async () => ({ ok: true, identity: 'brain "faked" (source default)' }),
};
const code = await applyHarness(flags(['--harness', 'codex']), deps);
expect(code).toBe(1);
expect(f.err.join('\n')).toMatch(/accepted an INVALID credential/);
expect(f.revoked).toEqual([ID_A]); // the fresh mint never stays live
// Nothing left wired: the fresh codex block was rolled back.
expect(existsSync(f.codexConfig) ? readFileSync(f.codexConfig, 'utf8') : '').not.toContain('bearer_token');
});
test('[X5] smoke failure on a FRESH claude add removes the registration (pre-run state) and fails the target', async () => {
const f = makeFake({ probeOk: false }); // fresh box: mcp get → not found
const code = await applyHarness(flags(['--harness', 'claude-code', '--no-hooks']), f.deps);
expect(code).toBe(1);
const removes = f.calls.filter((c) => c[0] === 'claude' && c[2] === 'remove');
expect(removes.length).toBe(1); // the fresh add is rolled back
const state = readHarnessReceiptState(f.home);
expect(state.state).toBe('ok');
const mcp = (state as { receipt: { targets: Array<{ kind: string; state: string; error?: string }> } }).receipt.targets.find(
(t) => t.kind === 'mcp',
);
expect(mcp?.state).toBe('failed');
expect(mcp?.error).toMatch(/fresh registration removed/);
});
test('[X5] smoke failure after replacing an UNRECOVERABLE registration keeps the new wiring but fails the target honestly', async () => {
// `claude mcp get` shows our URL but no Authorization header — the old
// bearer cannot be recovered, so there is nothing to restore.
const f = makeFake({
probeOk: false,
mcpGet: () => ({
code: 0,
stdout: `gbrain:\n Scope: User config\n Type: http\n URL: ${URL}\n`,
stderr: '',
}),
});
const code = await applyHarness(flags(['--harness', 'claude-code', '--no-hooks']), f.deps);
expect(code).toBe(1);
const state = readHarnessReceiptState(f.home);
const mcp = (state as { receipt: { targets: Array<{ kind: string; state: string; error?: string }> } }).receipt.targets.find(
(t) => t.kind === 'mcp',
);
expect(mcp?.state).toBe('failed');
expect(mcp?.error).toMatch(/could not be recovered/);
});
test('[X6] a mint crash leaves a write-ahead receipt (pending targets, no token id)', async () => {
const f = makeFake();
const deps: HarnessDeps = {
...f.deps,
mint: async () => {
throw new Error('simulated crash mid-mint');
},
};
await expect(applyHarness(flags(['--harness', 'codex']), deps)).rejects.toThrow(/simulated crash/);
const state = readHarnessReceiptState(f.home);
expect(state.state).toBe('ok');
const receipt = (state as { receipt: { targets: Array<{ state: string }>; token: { id?: string } } }).receipt;
expect(receipt.token.id).toBeUndefined();
expect(receipt.targets.every((t) => t.state === 'pending')).toBe(true);
});
test('[X7] consent reach matches the actual wiring (codex-only, no-hooks)', () => {
const block = buildConsentBlock({
tokenName: 'bootstrap-harness',
tokenSupplied: true,
scopes: ['read', 'write'],
url: URL,
wireClaude: false,
wireCodex: true,
hooks: false,
capture: true,
hookScope: 'user scope',
name: 'gbrain',
userSettingsPath: '/u/settings.json',
codexConfig: '/u/config.toml',
});
expect(block).toMatch(/EVERY Codex session/);
expect(block).not.toMatch(/EVERY Claude Code and Codex session/);
expect(block).toMatch(/No hooks are wired by this invocation/);
expect(block).toMatch(/written ONLY into the host registrations/);
expect(block).not.toMatch(/never stored/);
});
test('[X8] a pre-existing permissions.allow entry is recorded as such and SURVIVES --remove', async () => {
const f = makeFake();
mkdirSync(join(f.userSettings, '..'), { recursive: true });
writeFileSync(f.userSettings, JSON.stringify({ permissions: { allow: ['mcp__gbrain'] } }));
expect(await applyHarness(flags(['--harness', 'claude-code', '--no-hooks']), f.deps)).toBe(0);
const state = readHarnessReceiptState(f.home);
const perm = (state as { receipt: { targets: Array<{ kind: string; mechanism?: string }> } }).receipt.targets.find(
(t) => t.kind === 'permission',
);
expect(perm?.mechanism).toBe('pre-existing');
const removeDeps: HarnessDeps = {
...f.deps,
runner: async (argv: string[]) => {
if (argv[0] === 'claude' && argv[2] === 'get') return { code: 0, stdout: `URL: ${URL}`, stderr: '' };
return { code: 0, stdout: '', stderr: '' };
},
};
expect(await removeHarness(parseHarnessArgs(['--remove', '--yes']), removeDeps)).toBe(0);
expect((readJson(f.userSettings).permissions as { allow: string[] }).allow).toEqual(['mcp__gbrain']);
expect(f.out.join('\n')).toMatch(/predates the harness install — left in place/);
});
test('[X10] a narrowed --surface serve (unknown-tool tool_error) is verified, not broken', async () => {
const f = makeFake();
const deps: HarnessDeps = {
...f.deps,
// Token-aware like a real narrowed serve: bearer auth still happens
// BEFORE tool dispatch, so the canary (unknown token) gets 401 and only
// a VALID token reaches the unknown-tool tool_error.
probeIdentity: async (_url: string, probeToken: string) =>
probeToken === TOKEN_A || probeToken === TOKEN_B
? { ok: false, reason: 'tool_error' as const, message: 'Unknown tool: get_brain_identity' }
: { ok: false, reason: 'auth' as const, message: 'HTTP 401' },
};
expect(await applyHarness(flags(['--harness', 'codex']), deps)).toBe(0);
expect(f.out.join('\n')).toMatch(/narrowed --surface.*Counted as verified/s);
expect(await statusHarness(parseHarnessArgs(['--status']), deps)).toBe(0);
expect(f.out.join('\n')).toMatch(/token: OK .*narrowed --surface/);
});
test('[X13] registrar mode (non-loopback url + token) wires MCP only — no hooks', async () => {
const f = makeFake();
const code = await applyHarness(
flags(['--url', 'http://192.168.1.50:3131/mcp', '--token', TOKEN_A]),
f.deps,
);
expect(code).toBe(0);
expect(f.out.join('\n')).toMatch(/registrar mode.*hooks are NOT wired/s);
expect(readJson(f.userSettings).hooks).toBeUndefined();
// the discarded-warning fix: http-bearer warning surfaces
expect(f.err.join('\n')).toMatch(/unencrypted/);
});
test('[X14] conflicting or value-less invocations fail closed', () => {
expect(parseHarnessArgs(['--url', 'http://h/mcp', '--port', '3131']).error).toMatch(/not both/);
expect(parseHarnessArgs(['--status', '--remove']).error).toMatch(/not both/);
expect(parseHarnessArgs(['--token']).error).toMatch(/requires a value/);
expect(parseHarnessArgs(['--url', '--yes']).error).toMatch(/requires a value/);
// A dropped --project would silently WIDEN hook wiring to user scope.
expect(parseHarnessArgs(['--project']).error).toMatch(/requires a directory value/);
expect(parseHarnessArgs(['--project', '--no-capture', '--yes']).error).toMatch(/requires a directory value/);
});
});
describe('parse helpers', () => {
test('parseClaudeMcpGetUrl + parseClaudeMcpGetBearer read the live-verified shape', () => {
const out = 'gbrain:\n Scope: User config\n Type: http\n URL: http://127.0.0.1:3131/mcp\n Headers:\n Authorization: Bearer gbrain_abc123\n';
expect(parseClaudeMcpGetUrl(out)).toEqual({ found: true, url: 'http://127.0.0.1:3131/mcp' });
expect(parseClaudeMcpGetBearer(out)).toBe('gbrain_abc123');
expect(parseClaudeMcpGetUrl('nothing here')).toEqual({ found: false });
});
test('parseCodexBlockBearer reads only OUR managed block', () => {
const ours = [
'[mcp_servers.other]',
'bearer_token = "not_ours"',
CODEX_TOML_BLOCK_BEGIN,
'[mcp_servers.gbrain]',
'url = "http://127.0.0.1:3131/mcp"',
`bearer_token = "${TOKEN_A}"`,
`# gbrain:${GBRAIN_HARNESS_MARKER_VALUE} end`,
'',
].join('\n');
expect(parseCodexBlockBearer(ours)).toBe(TOKEN_A);
expect(parseCodexBlockBearer('[mcp_servers.x]\nbearer_token = "y"\n')).toBeNull();
// [C8] parity with the claude lane: with an expected url, a block pointing
// at another brain's serve must NOT hand its bearer over.
expect(parseCodexBlockBearer(ours, URL)).toBe(TOKEN_A);
expect(parseCodexBlockBearer(ours, 'http://127.0.0.1:9999/mcp')).toBeNull();
});
test('codexBlockOwnsName: only a table INSIDE the managed block counts as ours', () => {
const dir = mkdtempSync(join(tmpdir(), 'gb-owns-'));
const cfg = join(dir, 'config.toml');
// Foreign stdio-lane table OUTSIDE the block + our block owning a
// different name: the foreign name must NOT read as harness-owned.
writeFileSync(
cfg,
[
'[mcp_servers.gbrain]',
'command = "gbrain"',
CODEX_TOML_BLOCK_BEGIN,
'[mcp_servers.other]',
'url = "http://127.0.0.1:3131/mcp"',
CODEX_TOML_BLOCK_END,
'',
].join('\n'),
);
expect(codexBlockOwnsName(cfg, 'other')).toBe(true);
expect(codexBlockOwnsName(cfg, 'gbrain')).toBe(false);
expect(codexBlockOwnsName(join(dir, 'absent.toml'), 'gbrain')).toBe(false);
writeFileSync(cfg, '[mcp_servers.gbrain]\nurl = "http://x/mcp"\n'); // no block at all
expect(codexBlockOwnsName(cfg, 'gbrain')).toBe(false);
});
test('isServeOlderThanScopes boundary matrix — pinned to the first scope-aware release, NOT the moving CLI version', () => {
expect(isServeOlderThanScopes(SCOPES_MIN_SERVE_VERSION)).toBe(false); // equal — not older
expect(isServeOlderThanScopes(VERSION)).toBe(false); // the current CLI's own serve is always scope-aware
expect(isServeOlderThanScopes('999.0.0')).toBe(false); // newer serve
expect(isServeOlderThanScopes('0.1.0')).toBe(true); // clearly pre-scopes
// The pin is what keeps the NEXT release honest: a 0.45.14.0 serve must
// never be flagged pre-scopes once the CLI moves past it.
expect(isServeOlderThanScopes('0.45.14.0')).toBe(false);
expect(isServeOlderThanScopes('0.45.13.0')).toBe(true);
// 3-segment historical form pads with 0: X.Y.Z === X.Y.Z.0.
expect(isServeOlderThanScopes('0.45.14')).toBe(false);
});
});
+4 -3
View File
@@ -66,9 +66,10 @@ describe('host-specs [ENG-7]', () => {
}
expect(TARGETS[CLAUDE_CODE_SPEC_ID].status).toBe('verified');
expect(TARGETS[CLAUDE_CODE_SPEC_ID].references.join(' ')).toContain('code.claude.com');
// Codex mcp-add shape is docs-derived, not live-host-verified.
expect(TARGETS[CODEX_SPEC_ID].status).toBe('provisional');
// The v1 "no TOML writer" decision is recorded where the format lives.
// Verified against codex-cli 0.147.0 (#4043: serde field scan + live
// inline bearer_token wiring); the fired CX2-17 revisit is recorded
// where the format lives, and 'TOML' stays the load-bearing word.
expect(TARGETS[CODEX_SPEC_ID].status).toBe('verified');
expect(TARGETS[CODEX_SPEC_ID].note).toContain('TOML');
});
});
+206
View File
@@ -0,0 +1,206 @@
/**
* codex-toml.test.ts the managed marker-block writer for Codex's
* config.toml (#4043, fired CX2-17 revisit). Pins the safety invariants:
* foreign-server refusal via real TOML parse, byte-preservation outside the
* block, re-anchor-to-EOF rewrites, marker-anomaly refusals, CRLF/no-eol
* repair, post-render key assertion, and 0600 secrets hygiene.
*/
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, statSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
detectForeignCodexServer,
removeCodexHttpServerBlock,
tomlString,
writeCodexHttpServerBlock,
} from '../src/core/bootstrap/codex-toml.ts';
import { CODEX_TOML_BLOCK_BEGIN, CODEX_TOML_BLOCK_END } from '../src/core/bootstrap/host-specs.ts';
const BLOCK = { name: 'gbrain', url: 'http://127.0.0.1:3131/mcp', bearerToken: 'gbrain_deadbeef' };
function tmpConfig(): string {
return join(mkdtempSync(join(tmpdir(), 'gb-codex-toml-')), 'config.toml');
}
describe('writeCodexHttpServerBlock', () => {
test('fresh file: created 0600 with exactly the block, KEY = "value" spacing', () => {
const path = tmpConfig();
const res = writeCodexHttpServerBlock(path, BLOCK);
expect(res.replacedPrior).toBe(false);
expect(res.backupPath).toBeNull();
expect(statSync(path).mode & 0o777).toBe(0o600);
const text = readFileSync(path, 'utf8');
expect(text).toContain(CODEX_TOML_BLOCK_BEGIN);
expect(text).toContain('[mcp_servers.gbrain]');
expect(text).toContain('url = "http://127.0.0.1:3131/mcp"');
expect(text).toContain('bearer_token = "gbrain_deadbeef"');
expect(text).toContain(CODEX_TOML_BLOCK_END);
expect(text.endsWith('\n')).toBe(true);
});
test('foreign content survives byte-for-byte; block appended at EOF', () => {
const path = tmpConfig();
const foreign = '# my codex config\nmodel = "o5"\n\n[mcp_servers.other]\ncommand = "npx"\n';
writeFileSync(path, foreign);
writeCodexHttpServerBlock(path, BLOCK);
const text = readFileSync(path, 'utf8');
expect(text.startsWith('# my codex config\nmodel = "o5"\n\n[mcp_servers.other]\ncommand = "npx"\n')).toBe(true);
expect(text.indexOf(CODEX_TOML_BLOCK_BEGIN)).toBeGreaterThan(foreign.indexOf('[mcp_servers.other]'));
});
test('idempotent re-run: exactly one block; token rotates in place', () => {
const path = tmpConfig();
writeCodexHttpServerBlock(path, BLOCK);
const res = writeCodexHttpServerBlock(path, { ...BLOCK, bearerToken: 'gbrain_rotated' });
expect(res.replacedPrior).toBe(true);
const text = readFileSync(path, 'utf8');
expect(text.split(CODEX_TOML_BLOCK_BEGIN).length - 1).toBe(1);
expect(text).toContain('gbrain_rotated');
expect(text).not.toContain('gbrain_deadbeef');
});
test('re-anchor: user content added AFTER the block moves above it on rewrite', () => {
const path = tmpConfig();
writeCodexHttpServerBlock(path, BLOCK);
// a later legitimate write appends another server after our block
writeFileSync(path, `${readFileSync(path, 'utf8')}\n[mcp_servers.late]\ncommand = "bunx"\n`);
writeCodexHttpServerBlock(path, { ...BLOCK, bearerToken: 'gbrain_v2' });
const text = readFileSync(path, 'utf8');
expect(text.indexOf('[mcp_servers.late]')).toBeLessThan(text.indexOf(CODEX_TOML_BLOCK_BEGIN));
// trailing keys were NOT re-parented into our table (post-render assert)
expect(text.trimEnd().endsWith(CODEX_TOML_BLOCK_END)).toBe(true);
});
test('foreign [mcp_servers.gbrain] outside our block refused — any TOML spelling', () => {
for (const spelling of [
'[mcp_servers.gbrain]\ncommand = "npx"\n',
'[mcp_servers]\ngbrain = { command = "npx" }\n',
'[mcp_servers."gbrain"]\ncommand = "npx"\n',
'[mcp_servers]\ngbrain.command = "npx"\n',
]) {
const path = tmpConfig();
writeFileSync(path, spelling);
expect(() => writeCodexHttpServerBlock(path, BLOCK)).toThrow(/already defined/);
expect(readFileSync(path, 'utf8')).toBe(spelling); // untouched
}
});
test('unparseable config outside the block refused, file untouched', () => {
const path = tmpConfig();
const broken = '[mcp_servers\nnot toml';
writeFileSync(path, broken);
expect(() => writeCodexHttpServerBlock(path, BLOCK)).toThrow(/does not parse as TOML/);
expect(readFileSync(path, 'utf8')).toBe(broken);
});
test('damaged markers refused (duplicate begin / missing end / out of order)', () => {
for (const text of [
`${CODEX_TOML_BLOCK_BEGIN}\n${CODEX_TOML_BLOCK_BEGIN}\n${CODEX_TOML_BLOCK_END}\n`,
`${CODEX_TOML_BLOCK_BEGIN}\n[mcp_servers.gbrain]\nurl = "x"\n`,
`${CODEX_TOML_BLOCK_END}\n${CODEX_TOML_BLOCK_BEGIN}\n`,
]) {
const path = tmpConfig();
writeFileSync(path, text);
expect(() => writeCodexHttpServerBlock(path, BLOCK)).toThrow(/damaged/);
expect(readFileSync(path, 'utf8')).toBe(text);
}
});
test('no trailing newline on existing content is repaired (no marker gluing)', () => {
const path = tmpConfig();
writeFileSync(path, 'model = "o5"'); // no trailing \n
writeCodexHttpServerBlock(path, BLOCK);
const text = readFileSync(path, 'utf8');
expect(text).toContain(`model = "o5"\n`);
// run 2 must still find exactly one pair (idempotent, not duplicate-append)
writeCodexHttpServerBlock(path, BLOCK);
expect(readFileSync(path, 'utf8').split(CODEX_TOML_BLOCK_BEGIN).length - 1).toBe(1);
});
test('CRLF config: scan works and dominant EOL is preserved', () => {
const path = tmpConfig();
writeFileSync(path, 'model = "o5"\r\n');
writeCodexHttpServerBlock(path, BLOCK);
const text = readFileSync(path, 'utf8');
expect(text).toContain('\r\n');
expect(text.replace(/\r\n/g, '\n')).toContain(`${CODEX_TOML_BLOCK_BEGIN}\n[mcp_servers.gbrain]`);
// idempotent under CRLF too
writeCodexHttpServerBlock(path, BLOCK);
expect(readFileSync(path, 'utf8').split(CODEX_TOML_BLOCK_BEGIN).length - 1).toBe(1);
});
test('pre-existing group-readable file tightened to 0600 with a note; .bak is 0600', () => {
const path = tmpConfig();
writeFileSync(path, 'model = "o5"\n', { mode: 0o644 });
const res = writeCodexHttpServerBlock(path, BLOCK);
expect(statSync(path).mode & 0o777).toBe(0o600);
expect(res.notes.join(' ')).toMatch(/tightened to 0600/);
expect(res.backupPath).not.toBeNull();
expect(statSync(res.backupPath!).mode & 0o777).toBe(0o600);
});
test('bad server names refused before any I/O', () => {
const path = tmpConfig();
expect(() => writeCodexHttpServerBlock(path, { ...BLOCK, name: 'my server' })).toThrow(/bare TOML key/);
expect(existsSync(path)).toBe(false);
});
});
describe('removeCodexHttpServerBlock', () => {
test('removes exactly the block; foreign content byte-identical; .bak 0600', () => {
const path = tmpConfig();
const foreign = '# keep me\nmodel = "o5"\n\n[mcp_servers.other]\ncommand = "npx"\n';
writeFileSync(path, foreign);
writeCodexHttpServerBlock(path, BLOCK);
const res = removeCodexHttpServerBlock(path, 'gbrain');
expect(res.removed).toBe(true);
expect(statSync(res.backupPath!).mode & 0o777).toBe(0o600);
expect(readFileSync(path, 'utf8')).toBe(foreign);
});
test('block-only file removes to empty; absent file / absent block are no-ops', () => {
const path = tmpConfig();
writeCodexHttpServerBlock(path, BLOCK);
expect(removeCodexHttpServerBlock(path, 'gbrain').removed).toBe(true);
expect(readFileSync(path, 'utf8')).toBe('');
expect(removeCodexHttpServerBlock(path, 'gbrain').removed).toBe(false);
expect(removeCodexHttpServerBlock(join(tmpdir(), 'nope', 'config.toml'), 'gbrain').removed).toBe(false);
});
test('damaged markers refuse removal, file untouched', () => {
const path = tmpConfig();
const text = `${CODEX_TOML_BLOCK_BEGIN}\nurl = "x"\n`; // begin without end
writeFileSync(path, text);
expect(() => removeCodexHttpServerBlock(path, 'gbrain')).toThrow(/damaged/);
expect(readFileSync(path, 'utf8')).toBe(text);
});
});
describe('detectForeignCodexServer', () => {
test('sees through every spelling; ignores our own managed block', () => {
expect(detectForeignCodexServer('[mcp_servers.gbrain]\ncommand = "npx"\n', 'gbrain')).toBe(true);
expect(detectForeignCodexServer('[mcp_servers]\ngbrain = { command = "npx" }\n', 'gbrain')).toBe(true);
expect(detectForeignCodexServer('[mcp_servers.other]\ncommand = "npx"\n', 'gbrain')).toBe(false);
expect(detectForeignCodexServer('', 'gbrain')).toBe(false);
const ours = [
CODEX_TOML_BLOCK_BEGIN,
'[mcp_servers.gbrain]',
'url = "http://127.0.0.1:3131/mcp"',
'bearer_token = "t"',
CODEX_TOML_BLOCK_END,
'',
].join('\n');
expect(detectForeignCodexServer(ours, 'gbrain')).toBe(false);
});
});
describe('tomlString', () => {
test('escapes quotes and backslashes; refuses control chars', () => {
expect(tomlString('plain')).toBe('"plain"');
expect(tomlString('a"b')).toBe('"a\\"b"');
expect(tomlString('a\\b')).toBe('"a\\\\b"');
expect(() => tomlString('a\nb')).toThrow(/control characters/);
});
});
+5
View File
@@ -188,6 +188,11 @@ describe('argv + command string', () => {
'mcp', 'add', 'gbrain', '-t', 'http', 'https://h/mcp', '-H', 'Authorization: Bearer TOK',
]);
});
test('claude argv with scope — inserted after the name (#4043 harness lane; claude default is local)', () => {
expect(buildClaudeMcpAddArgv({ name: 'gbrain', url: 'https://h/mcp', headerToken: 'TOK', scope: 'user' })).toEqual([
'mcp', 'add', 'gbrain', '--scope', 'user', '-t', 'http', 'https://h/mcp', '-H', 'Authorization: Bearer TOK',
]);
});
test('codex argv shape — env-var bearer, no token in argv', () => {
expect(buildCodexMcpAddArgv({ name: 'gbrain', url: 'https://h/mcp', envVar: ENV_VAR })).toEqual([
'mcp', 'add', 'gbrain', '--url', 'https://h/mcp', '--bearer-token-env-var', ENV_VAR,
+31
View File
@@ -139,3 +139,34 @@ d('access_tokens.permissions.takes_holders end-to-end', () => {
expect(rows).toHaveLength(0);
});
});
d('mintLegacyToken parity on real Postgres (#4043 — PGLite hides positional-binding divergence)', () => {
test('scopes TEXT[] + permissions jsonb round-trip through the exact mint SQL shape', async () => {
const engine = getEngine();
const { mintLegacyToken, revokeLegacyTokenById } = await import('../../src/core/token-mint.ts');
const { sqlQueryForEngine } = await import('../../src/core/sql-query.ts');
const minted = await mintLegacyToken(engine, {
name: `parity-${Date.now()}`,
takesHolders: ['world'],
scopes: ['read', 'write'],
sourceGrant: ['wiki', 'essays'],
});
const rows = await engine.executeRaw<{ scopes: unknown; permissions: Record<string, unknown>; kind: string }>(
`SELECT scopes, permissions, jsonb_typeof(permissions) AS kind FROM access_tokens WHERE id = $1::uuid`,
[minted.id],
);
expect(rows).toHaveLength(1);
expect(rows[0].kind).toBe('object'); // never a double-encoded string scalar
expect(rows[0].scopes).toEqual(['read', 'write']); // TEXT[] decodes as a JS array
expect(rows[0].permissions.takes_holders).toEqual(['world']);
expect(rows[0].permissions.source_id).toEqual(['wiki', 'essays']);
// And the verify-side normalizer sees exactly the granted scopes.
const { normalizeTokenScopes } = await import('../../src/core/legacy-token-scope.ts');
expect(normalizeTokenScopes(rows[0].scopes)).toEqual(['read', 'write']);
const sql = sqlQueryForEngine(engine);
expect(await revokeLegacyTokenById(sql, minted.id)).toBe(true);
expect(await revokeLegacyTokenById(sql, minted.id)).toBe(false); // already revoked
});
});
+60 -2
View File
@@ -83,11 +83,13 @@ describe('auth takes-holders + mcp_request_log JSONB on PGLite (v0.31)', () => {
[{ takes_holders: ['world'] }],
);
// The exact shape auth.ts:permissions uses (set-takes-holders).
// The exact shape auth.ts:permissions uses (set-takes-holders) — a
// MERGE, never a whole-object replace (#4043 grant-wipe fix), with a
// jsonb_typeof guard that resets damaged non-object rows.
const result = await executeRawJsonb(
engine,
`UPDATE access_tokens
SET permissions = $2::jsonb
SET permissions = (CASE WHEN jsonb_typeof(permissions) = 'object' THEN permissions ELSE '{}'::jsonb END) || $2::jsonb
WHERE name = $1
RETURNING id`,
[name],
@@ -103,6 +105,62 @@ describe('auth takes-holders + mcp_request_log JSONB on PGLite (v0.31)', () => {
expect(perms.takes_holders).toEqual(['world', 'garry', 'brain']);
});
test('set-takes-holders MERGES: the source_id federation grant survives the edit (#4043 grant-wipe fix)', async () => {
const sql = sqlQueryForEngine(engine);
const name = `tok-merge-${Math.random().toString(36).slice(2, 8)}`;
const hash = `hash-${name}`;
await executeRawJsonb(
engine,
`INSERT INTO access_tokens (name, token_hash, permissions)
VALUES ($1, $2, $3::jsonb)`,
[name, hash],
[{ takes_holders: ['world'], source_id: ['wiki', 'essays'] }],
);
await executeRawJsonb(
engine,
`UPDATE access_tokens
SET permissions = (CASE WHEN jsonb_typeof(permissions) = 'object' THEN permissions ELSE '{}'::jsonb END) || $2::jsonb
WHERE name = $1
RETURNING id`,
[name],
[{ takes_holders: ['world', 'garry'] }],
);
const rows = await sql`SELECT permissions FROM access_tokens WHERE token_hash = ${hash}`;
const perms = (rows[0] as { permissions: { takes_holders: string[]; source_id: string[] } }).permissions;
expect(perms.takes_holders).toEqual(['world', 'garry']);
expect(perms.source_id).toEqual(['wiki', 'essays']); // the grant the old whole-replace silently wiped
});
test('set-takes-holders on a DAMAGED (string-scalar) permissions row repairs to a clean object', async () => {
// The historical #2339 double-encode class: jsonb string scalar. A bare
// `scalar || object` would produce a jsonb ARRAY — the CASE guard resets
// the damaged operand so the edit lands as a real object.
const name = `tok-damaged-${Math.random().toString(36).slice(2, 8)}`;
const hash = `hash-${name}`;
await engine.executeRaw(
`INSERT INTO access_tokens (name, token_hash, permissions)
VALUES ($1, $2, to_jsonb($3::text))`,
[name, hash, JSON.stringify({ takes_holders: ['world'] })],
);
await executeRawJsonb(
engine,
`UPDATE access_tokens
SET permissions = (CASE WHEN jsonb_typeof(permissions) = 'object' THEN permissions ELSE '{}'::jsonb END) || $2::jsonb
WHERE name = $1
RETURNING id`,
[name],
[{ takes_holders: ['world', 'garry'] }],
);
const typed = await engine.executeRaw<{ kind: string; first_holder: string }>(
`SELECT jsonb_typeof(permissions) AS kind,
permissions->'takes_holders'->>0 AS first_holder
FROM access_tokens WHERE token_hash = $1`,
[hash],
);
expect(typed[0].kind).toBe('object');
expect(typed[0].first_holder).toBe('world');
});
test('mcp_request_log.params: object writes round-trip as JSONB object', async () => {
// The serve-http.ts INSERT shape after the v0.31 migration.
const summary = { redacted: true, declared_keys: ['query', 'limit'], approx_bytes: 1024 };
@@ -0,0 +1,239 @@
/**
* E2E: `gbrain bootstrap harness` lifecycle against a REAL `serve --http` on
* a hermetic PGLite brain (#4043).
*
* The real seams the unit suite injects away: an actual /health probe, the
* actual StreamableHTTP + bearer smoke (probeBrainIdentity), the PGLite
* single-writer reality (mint refused under a live serve the --token lane
* is the documented escape), and the dispatcher path (runBootstrap
* home-lock apply/status/remove) with real receipt/install-log files.
*
* The claude CLI is a recording fake (runBootstrap's runner seam); the codex
* lane writes the real managed TOML block into a temp CODEX_HOME; HOME is
* remapped so user-scope writes land in the sandbox.
*
* Serial: env remapping (HOME / CODEX_HOME / GBRAIN_HOME) + a spawned serve.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawn, execFileSync, type ChildProcess } from 'child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runBootstrap } from '../../src/commands/bootstrap.ts';
import type { ExecRunner } from '../../src/core/bootstrap/repo.ts';
import { readHarnessReceiptState } from '../../src/core/bootstrap/format.ts';
import { CODEX_TOML_BLOCK_BEGIN } from '../../src/core/bootstrap/host-specs.ts';
import { withEnv } from '../helpers/with-env.ts';
const PORT = 19741; // unique to this suite (19735 = connect-bearer, 19131 = oauth)
const BASE = `http://127.0.0.1:${PORT}`;
describe('bootstrap harness lifecycle E2E (PGLite + real serve --http)', () => {
let parent: string; // GBRAIN_HOME parent for the brain
let sandboxHome: string; // HOME for user-scope writes
let codexHome: string;
let server: ChildProcess | null = null;
let token = '';
let serverReady = false;
const userSettings = () => join(sandboxHome, '.claude', 'settings.json');
const codexConfig = () => join(codexHome, 'config.toml');
function makeClaudeRunner(): { runner: ExecRunner; calls: string[][] } {
const calls: string[][] = [];
const runner: ExecRunner = async (argv: string[]) => {
calls.push(argv);
if (argv[0] === 'claude' && argv[2] === 'get') return { code: 1, stdout: '', stderr: 'No MCP server found' };
return { code: 0, stdout: '', stderr: '' };
};
return { runner, calls };
}
async function capture<T>(fn: () => Promise<T>): Promise<{ result: T; out: string; err: string }> {
const origLog = console.log;
const origErr = console.error;
let out = '';
let err = '';
console.log = (...a: unknown[]) => { out += a.map(String).join(' ') + '\n'; };
console.error = (...a: unknown[]) => { err += a.map(String).join(' ') + '\n'; };
try {
return { result: await fn(), out, err };
} finally {
console.log = origLog;
console.error = origErr;
}
}
// CLAUDE_CONFIG_DIR is the load-bearing override: Bun's homedir() ignores a
// remapped HOME (it reads the password database), so HOME alone does NOT
// sandbox user-scope writes — that leak is exactly why claudeUserSettingsPath
// honors CLAUDE_CONFIG_DIR/HOME explicitly now.
const envFor = () => ({
GBRAIN_HOME: parent,
HOME: sandboxHome,
CLAUDE_CONFIG_DIR: join(sandboxHome, '.claude'),
CODEX_HOME: codexHome,
});
beforeAll(async () => {
parent = mkdtempSync(join(tmpdir(), 'gb-harness-e2e-'));
sandboxHome = mkdtempSync(join(tmpdir(), 'gb-harness-home-'));
codexHome = mkdtempSync(join(tmpdir(), 'gb-harness-codex-'));
// codex "installed" for detection purposes: the config file exists.
writeFileSync(codexConfig(), '# preexisting codex config\nmodel = "o5"\n');
const env: Record<string, string | undefined> = { ...process.env, GBRAIN_HOME: parent };
delete env.DATABASE_URL;
delete env.GBRAIN_DATABASE_URL;
execFileSync('bun', ['run', 'src/cli.ts', 'init', '--pglite', '--no-embedding', '--non-interactive'], {
cwd: process.cwd(), env, stdio: 'ignore',
});
// Pre-mint the scoped token BEFORE serve starts — PGLite is single-writer,
// which is exactly the documented reason the --token lane exists.
const authOut = execFileSync(
'bun',
['run', 'src/cli.ts', 'auth', 'create', 'bootstrap-harness', '--scopes', 'read,write'],
{ cwd: process.cwd(), env, encoding: 'utf8' },
);
token = (authOut.match(/gbrain_[a-f0-9]{64}/) ?? [''])[0];
if (!token) throw new Error(`auth create did not yield a token:\n${authOut}`);
expect(authOut).toContain('scopes=["read","write"]');
server = spawn('bun', [
'run', 'src/cli.ts', 'serve', '--http',
'--bind', '127.0.0.1', '--port', String(PORT),
'--public-url', BASE,
], { cwd: process.cwd(), env, stdio: ['ignore', 'pipe', 'pipe'] });
let serr = '';
server.stderr?.on('data', (d: Buffer) => { serr += d.toString(); });
for (let i = 0; i < 60; i++) {
try {
const res = await fetch(`${BASE}/health`);
if (res.ok) { serverReady = true; break; }
} catch { /* not up yet */ }
await new Promise((r) => setTimeout(r, 500));
}
if (!serverReady) throw new Error(`serve --http did not become ready:\n${serr}`);
}, 90_000);
afterAll(() => {
if (server) { try { server.kill('SIGTERM'); } catch { /* best-effort */ } }
for (const d of [parent, sandboxHome, codexHome]) {
try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
}
});
test('mint under the live PGLite serve refuses with the two escape hatches', async () => {
expect(serverReady).toBe(true);
const { runner } = makeClaudeRunner();
const { result, err } = await withEnv(envFor(), () =>
capture(() =>
runBootstrap(['harness', '--yes', '--port', String(PORT), '--harness', 'codex'], { runner }),
),
);
expect(result).toBe(1);
expect(err).toMatch(/pre-mint|--token/);
expect(err).toMatch(/stop the serve/);
}, 60_000);
test('apply with --token: real health + real bearer smoke; both harnesses wired; receipt + install log', async () => {
expect(serverReady).toBe(true);
const { runner, calls } = makeClaudeRunner();
const { result, out } = await withEnv(envFor(), () =>
capture(() =>
runBootstrap(
[
'harness', '--yes',
'--port', String(PORT),
'--token', token,
'--gbrain-bin', '/opt/fake/gbrain',
],
{ runner },
),
),
);
expect(result).toBe(0);
// real smoke against the live serve — identity round-tripped over bearer
expect(out).toMatch(/smoke test:/);
expect(out).toMatch(/"engine": "pglite"/);
// the sandbox held: nothing touched the operator's real user scope
expect(out).not.toContain(`${process.env.HOME}/.claude/settings.json`);
// claude lane: exec'd through the runner with user scope + our token
const add = calls.find((c) => c[0] === 'claude' && c[2] === 'add');
expect(add).toBeDefined();
expect(add!.join(' ')).toContain('--scope user');
expect(add!.join(' ')).toContain(`Bearer ${token}`);
// user-scope sandbox writes
const settings = JSON.parse(readFileSync(userSettings(), 'utf8')) as Record<string, unknown>;
expect((settings.permissions as { allow: string[] }).allow).toContain('mcp__gbrain');
expect(Object.keys(settings.hooks as object).length).toBe(5);
// codex lane: managed block with the inline token; preexisting content intact
const toml = readFileSync(codexConfig(), 'utf8');
expect(toml).toContain('# preexisting codex config');
expect(toml).toContain(CODEX_TOML_BLOCK_BEGIN);
expect(toml).toContain(`bearer_token = "${token}"`);
// receipt: all confirmed; supplied token → minted:false
const home = join(parent, '.gbrain');
const state = readHarnessReceiptState(home);
expect(state.state).toBe('ok');
const receipt = (state as { receipt: { targets: Array<{ state: string }>; token: { minted: boolean } } }).receipt;
expect(receipt.targets.every((t) => t.state === 'confirmed')).toBe(true);
expect(receipt.token.minted).toBe(false);
// install log carries the harness phase
const log = readFileSync(join(home, 'bootstrap', 'install.jsonl'), 'utf8');
expect(log).toMatch(/"phase":"harness".*"outcome":"ok"/);
}, 60_000);
test('--status: live probes, token recovered from the codex block, exit 0', async () => {
const { runner } = makeClaudeRunner();
const { result, out } = await withEnv(envFor(), () =>
capture(() => runBootstrap(['harness', '--status'], { runner })),
);
expect(result).toBe(0);
expect(out).toMatch(/serve: OK/);
expect(out).toMatch(/token: OK .*codex config block/);
}, 60_000);
test('--remove: host wiring cleared, codex config byte-identical to pre-apply, receipt consumed', async () => {
const { runner } = makeClaudeRunner();
const { result } = await withEnv(envFor(), () =>
capture(() => runBootstrap(['harness', '--remove', '--yes'], { runner })),
);
expect(result).toBe(0);
expect(readFileSync(codexConfig(), 'utf8')).toBe('# preexisting codex config\nmodel = "o5"\n');
const settings = JSON.parse(readFileSync(userSettings(), 'utf8')) as Record<string, unknown>;
expect(settings.permissions).toBeUndefined();
expect(settings.hooks).toBeUndefined();
expect(readHarnessReceiptState(join(parent, '.gbrain'))).toEqual({ state: 'absent' });
}, 60_000);
test('scoped token is honored end-to-end: an admin-scope op is refused over MCP', async () => {
// The minted token carries scopes {read,write}; get_stats is an
// admin-scope MCP op — the per-op hasScope gate must refuse it while
// read/write ops keep working (proven by the smoke above).
const res = await fetch(`${BASE}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'get_stats', arguments: {} },
}),
});
const text = await res.text();
expect(text).toMatch(/insufficient_scope|requires .*admin|not permitted|scope/i);
}, 30_000);
});
+4 -4
View File
@@ -9,7 +9,7 @@
* get gbrain` + the temp config.toml carry our server + GBRAIN_SOURCE),
* `gbrain bootstrap verify` exits 0, and the rendered AGENTS.md carries the
* Gate-3 brain-first pull protocol Codex's ONLY per-turn mechanism (it
* has no hook system).
* hooks are not wired by gbrain yet).
*
* 2. SMOKE a live `codex exec` turn. gbrain is registered as a Codex stdio
* MCP server (`bun run <repo>/src/cli.ts serve --surface full`) pinned to a
@@ -200,7 +200,7 @@ async function interviewAndRender(ws: string): Promise<void> {
}
// ── 0. RENDERED PROTOCOL PIN (always runs — needs NO codex binary) ──────────
// Codex has no hook system, so ambient recall (v0.45.7) reaches it ONLY via
// gbrain does not wire Codex hooks yet, so ambient recall (v0.45.7) reaches it ONLY via
// the rendered pull protocol. Pin the WORKSPACE-RENDERED chain the codex door
// reads: AGENTS.md's session startup routes through HEARTBEAT.md's due-job
// list, and the rendered ambient-delta row binds both boundary verbs to their
@@ -308,7 +308,7 @@ describe.skipIf(!CAN_RUN)('bootstrap real-codex door (serial e2e)', () => {
expect(readManifest(ws).state).toBe('initialized');
// AGENTS.md carries the Gate-3 brain-first pull protocol (Codex's only
// per-turn mechanism — it has no hook system).
// per-turn mechanism — gbrain does not wire Codex hooks yet).
const agents = readFileSync(join(ws, 'AGENTS.md'), 'utf8');
expect(agents).toContain('Gate 3');
expect(agents.toLowerCase()).toContain('brain first');
@@ -322,7 +322,7 @@ describe.skipIf(!CAN_RUN)('bootstrap real-codex door (serial e2e)', () => {
);
expect(hooksCode).toBe(0);
// Codex has no hooks — the pull protocol is the per-turn seam, stated plainly.
expect(hooksOut).toContain('Codex has no hook system');
expect(hooksOut).toContain('gbrain does not wire Codex hooks yet');
// Real `codex mcp get gbrain` shows our server (env values are masked in
// the human view, so the source binding is asserted on config.toml below).
+97
View File
@@ -135,6 +135,103 @@ describe('dispatch', () => {
expect(await runHook(['--help'], out.io)).toBe(0);
expect(out.get()).toContain('session-start');
});
test('harness lane yields to a workspace bootstrap install: exit 0, no output, no heartbeat (#4043 C6)', async () => {
// Claude Code merges user- and project-scope hook settings; a harness
// (user-scope) entry plus a workspace bootstrap-v1 entry would fire the
// same event twice. The workspace install wins — the harness lane must
// yield SILENTLY on every event.
process.env.GBRAIN_HOOK_LANE = 'harness';
try {
const ws = mkdtempSync(join(tmpdir(), 'gb-lane-ws-'));
mkdirSync(join(ws, '.claude'), { recursive: true });
// A real workspace install wires all five events — the guard is now
// PER-EVENT (an event the workspace does not wire must run normally),
// so the fixture mirrors the full install.
const entry = (sub: string) => [
{ hooks: [{ type: 'command', command: `env GBRAIN_SOURCE=ws /opt/g hook ${sub}`, _gbrain: 'bootstrap-v1' }] },
];
writeFileSync(
join(ws, '.claude', 'settings.local.json'),
JSON.stringify({
hooks: {
SessionStart: entry('session-start'),
UserPromptSubmit: entry('user-prompt'),
Stop: entry('stop'),
SessionEnd: entry('session-end'),
PreCompact: entry('compact'),
},
}),
);
for (const event of ['session-start', 'user-prompt', 'stop', 'session-end', 'compact']) {
const out = collectStdout();
expect(await runHook([event], { ...out.io, stdin: '{}', cwd: ws })).toBe(0);
expect(out.get()).toBe('');
}
expect(existsSync(join(home(), 'integrations', 'hooks', 'heartbeat.jsonl'))).toBe(false);
} finally {
delete process.env.GBRAIN_HOOK_LANE;
}
});
test('harness lane runs normally when the cwd has no workspace install (fail-open both ways)', async () => {
process.env.GBRAIN_HOOK_LANE = 'harness';
try {
// plain dir: no .claude/settings.local.json at all
const plain = mkdtempSync(join(tmpdir(), 'gb-lane-plain-'));
const out = collectStdout();
// session-start in a plain dir runs the normal handler (exit 0, and it
// WRITES a heartbeat — proof the guard did not swallow the event).
expect(await runHook(['session-start'], { ...out.io, stdin: '', cwd: plain })).toBe(0);
expect(existsSync(join(home(), 'integrations', 'hooks', 'heartbeat.jsonl'))).toBe(true);
// harness-marker-only settings (its OWN entries) must NOT trigger the
// yield — only the workspace bootstrap-v1 marker does.
const harnessOnly = mkdtempSync(join(tmpdir(), 'gb-lane-harness-'));
mkdirSync(join(harnessOnly, '.claude'), { recursive: true });
writeFileSync(
join(harnessOnly, '.claude', 'settings.local.json'),
JSON.stringify({
hooks: {
SessionStart: [{ hooks: [{ type: 'command', command: 'env GBRAIN_SOURCE=default /opt/g hook session-start', _gbrain: 'bootstrap-harness-v1' }] }],
},
}),
);
const out2 = collectStdout();
expect(await runHook(['session-start'], { ...out2.io, stdin: '', cwd: harnessOnly })).toBe(0);
// still ran: a fresh heartbeat line was appended for this invocation
const hb = readFileSync(join(home(), 'integrations', 'hooks', 'heartbeat.jsonl'), 'utf8').trim().split('\n');
expect(hb.length).toBeGreaterThanOrEqual(2);
} finally {
delete process.env.GBRAIN_HOOK_LANE;
}
});
test('harness lane yields to the COMMITTED settings.json carrier too ([D12] — local strips carried events)', async () => {
// A D12 workspace can carry its bootstrap-v1 hooks ONLY in the committed
// .claude/settings.json (the local writer skips carried events). Checking
// settings.local.json alone would double-fire those events against the
// user-scope harness wiring.
process.env.GBRAIN_HOOK_LANE = 'harness';
try {
const ws = mkdtempSync(join(tmpdir(), 'gb-lane-committed-'));
mkdirSync(join(ws, '.claude'), { recursive: true });
writeFileSync(
join(ws, '.claude', 'settings.json'),
JSON.stringify({
hooks: {
SessionStart: [{ hooks: [{ type: 'command', command: 'gbrain hook session-start', _gbrain: 'bootstrap-v1' }] }],
},
}),
);
const out = collectStdout();
expect(await runHook(['session-start'], { ...out.io, stdin: '{}', cwd: ws })).toBe(0);
expect(out.get()).toBe('');
expect(existsSync(join(home(), 'integrations', 'hooks', 'heartbeat.jsonl'))).toBe(false);
} finally {
delete process.env.GBRAIN_HOOK_LANE;
}
});
});
// ── user-prompt [ENG-1, S3#8, A9] ───────────────────────────────────────────
+45
View File
@@ -589,6 +589,51 @@ describe('verifyAccessToken', () => {
});
});
// ---------------------------------------------------------------------------
// #4043 — legacy access_tokens honor the scopes TEXT[] column (least
// privilege). NULL (every pre-feature token) grandfathers to full access —
// pinned above by 'legacy access_tokens fallback works'.
// ---------------------------------------------------------------------------
describe('#4043 legacy token scopes column', () => {
async function insertLegacyTokenWithScopes(name: string, scopesLiteral: string | null): Promise<string> {
const token = generateToken('gbrain_');
const hash = hashToken(token);
await sql`
INSERT INTO access_tokens (id, name, token_hash, scopes)
VALUES (${crypto.randomUUID()}, ${name}, ${hash}, ${scopesLiteral}::text[])
`;
return token;
}
test("scopes ['read','write'] verifies with exactly those scopes (no admin)", async () => {
const token = await insertLegacyTokenWithScopes('scoped-harness-agent', '{read,write}');
const authInfo = await provider.verifyAccessToken(token) as CoreAuthInfo;
expect(authInfo.scopes).toEqual(['read', 'write']);
});
test('explicit empty scopes array is preserved as deny-all', async () => {
const token = await insertLegacyTokenWithScopes('deny-all-agent', '{}');
const authInfo = await provider.verifyAccessToken(token) as CoreAuthInfo;
expect(authInfo.scopes).toEqual([]);
});
test('unknown scope strings are filtered; all-unknown collapses to deny, not grandfather', async () => {
const token = await insertLegacyTokenWithScopes('typo-agent', '{reed,write}');
const authInfo = await provider.verifyAccessToken(token) as CoreAuthInfo;
expect(authInfo.scopes).toEqual(['write']);
const token2 = await insertLegacyTokenWithScopes('all-typo-agent', '{reed,wright}');
const authInfo2 = await provider.verifyAccessToken(token2) as CoreAuthInfo;
expect(authInfo2.scopes).toEqual([]);
});
test('NULL scopes keeps the grandfathered full-access grant (byte-identical legacy behavior)', async () => {
const token = await insertLegacyTokenWithScopes('null-scopes-agent', null);
const authInfo = await provider.verifyAccessToken(token) as CoreAuthInfo;
expect(authInfo.scopes).toEqual(['read', 'write', 'admin']);
});
});
// ---------------------------------------------------------------------------
// Token Revocation
// ---------------------------------------------------------------------------
+144
View File
@@ -0,0 +1,144 @@
/**
* token-mint.test.ts mintLegacyToken / revokeLegacyTokenById (#4043) +
* the normalizeTokenScopes decode matrix. Pins the rotation contract: mint
* validates scopes loudly, RETURNING id feeds revoke-by-id, and revoke-by-id
* never touches same-name siblings (names are not unique).
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { sqlQueryForEngine, type SqlQuery } from '../src/core/sql-query.ts';
import { mintLegacyToken, revokeLegacyTokenById } from '../src/core/token-mint.ts';
import { normalizeTokenScopes } from '../src/core/legacy-token-scope.ts';
import { hashToken } from '../src/core/utils.ts';
let engine: PGLiteEngine;
let sql: SqlQuery;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
sql = sqlQueryForEngine(engine);
// Just the table under test — the migration v4 shape (scopes TEXT[] is
// original schema) + the v38 permissions column.
await engine.executeRaw(`
CREATE TABLE IF NOT EXISTS access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scopes TEXT[],
permissions JSONB NOT NULL DEFAULT '{"takes_holders":["world"]}'::jsonb,
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
)
`);
}, 60_000);
afterAll(async () => {
if (engine) await engine.disconnect();
});
describe('normalizeTokenScopes', () => {
test('NULL / non-array → undefined (caller grandfathers)', () => {
expect(normalizeTokenScopes(null)).toBeUndefined();
expect(normalizeTokenScopes(undefined)).toBeUndefined();
});
test('non-NULL representation drift fails CLOSED (deny), never grandfathers', () => {
// Only the never-written NULL earns the historical full-access grant — a
// WRITTEN row whose value reads back in an unexpected shape must deny.
expect(normalizeTokenScopes('read')).toEqual([]);
expect(normalizeTokenScopes(42)).toEqual([]);
expect(normalizeTokenScopes({ read: true })).toEqual([]);
});
test('undecoded Postgres array-literal strings parse like arrays', () => {
expect(normalizeTokenScopes('{read,write}')).toEqual(['read', 'write']);
expect(normalizeTokenScopes('{"read","write"}')).toEqual(['read', 'write']);
expect(normalizeTokenScopes('{}')).toEqual([]);
expect(normalizeTokenScopes('{read,bogus}')).toEqual(['read']);
});
test('array filtered to known scopes; [] and all-unknown preserved as deny', () => {
expect(normalizeTokenScopes(['read', 'write'])).toEqual(['read', 'write']);
expect(normalizeTokenScopes(['read', 'bogus', 7, null])).toEqual(['read']);
expect(normalizeTokenScopes([])).toEqual([]);
expect(normalizeTokenScopes(['bogus'])).toEqual([]);
expect(normalizeTokenScopes(['admin', 'sources_admin'])).toEqual(['admin', 'sources_admin']);
});
});
describe('mintLegacyToken', () => {
test('round-trip: scopes column + permissions + RETURNING id', async () => {
const minted = await mintLegacyToken(engine, {
name: 'bootstrap-harness',
takesHolders: ['world'],
scopes: ['read', 'write'],
sourceGrant: ['default', 'wiki'],
});
expect(minted.token).toMatch(/^gbrain_[0-9a-f]{64}$/);
expect(minted.id).toMatch(/^[0-9a-f-]{36}$/);
const rows = await sql`
SELECT name, scopes, permissions FROM access_tokens WHERE id = ${minted.id}::uuid
`;
expect(rows.length).toBe(1);
expect(rows[0].scopes).toEqual(['read', 'write']);
const perms = rows[0].permissions as { takes_holders: string[]; source_id: string[] };
expect(perms.takes_holders).toEqual(['world']);
expect(perms.source_id).toEqual(['default', 'wiki']);
// stored hash matches the plaintext (verify-path contract)
const hashRows = await sql`
SELECT 1 AS ok FROM access_tokens WHERE token_hash = ${hashToken(minted.token)}
`;
expect(hashRows.length).toBe(1);
});
test('no sourceGrant → permissions carries takes_holders only (historical default floor)', async () => {
const minted = await mintLegacyToken(engine, {
name: 'floor-agent',
takesHolders: ['world'],
scopes: ['read'],
});
const rows = await sql`SELECT permissions FROM access_tokens WHERE id = ${minted.id}::uuid`;
expect(rows[0].permissions).toEqual({ takes_holders: ['world'] });
});
test('unknown or empty scopes refused loudly at mint time', async () => {
await expect(
mintLegacyToken(engine, { name: 'x', takesHolders: ['world'], scopes: ['reed'] }),
).rejects.toThrow(/Unknown scope|scope/i);
await expect(
mintLegacyToken(engine, { name: 'x', takesHolders: ['world'], scopes: [] }),
).rejects.toThrow(/non-empty/);
await expect(
mintLegacyToken(engine, { name: ' ', takesHolders: ['world'], scopes: ['read'] }),
).rejects.toThrow(/name/);
});
});
describe('revokeLegacyTokenById [C7]', () => {
test('revokes exactly one of two same-name rows; re-revoke reports already-done', async () => {
const a = await mintLegacyToken(engine, { name: 'twin', takesHolders: ['world'], scopes: ['read', 'write'] });
const b = await mintLegacyToken(engine, { name: 'twin', takesHolders: ['world'], scopes: ['read', 'write'] });
expect(await revokeLegacyTokenById(sql, a.id)).toBe(true);
const rows = await sql`
SELECT id, revoked_at FROM access_tokens WHERE name = ${'twin'} ORDER BY created_at
`;
const revoked = rows.filter((r: Record<string, unknown>) => r.revoked_at !== null);
expect(revoked.length).toBe(1);
expect((revoked[0] as { id: string }).id).toBe(a.id);
const alive = rows.filter((r: Record<string, unknown>) => r.revoked_at === null);
expect((alive[0] as { id: string }).id).toBe(b.id);
// second revoke of the same id → already-done, not an error
expect(await revokeLegacyTokenById(sql, a.id)).toBe(false);
});
test('malformed id refused before touching the database', async () => {
await expect(revokeLegacyTokenById(sql, 'not-a-uuid')).rejects.toThrow(/UUID/);
});
});