mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 01:12:20 +00:00
Compare commits
1
Commits
v0.45.13.0
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd99e40c2a |
@@ -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. -->
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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,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; PreToolUse…SessionEnd — 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`;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']));
|
||||
|
||||
@@ -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] ─────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
@@ -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] ───────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user