Files
gbrain/src/commands/auth.ts
T
7267462311 v0.31.4 feat: takes v2 — lessons from 100K-take production extraction (#795)
* feat: takes v2 — lessons from 100K-take production extraction

Consolidates everything learned from the first full takes extraction run
(28,256 pages, 100,720 takes, $361 on Azure GPT-5.5) and subsequent
cross-modal eval (GPT-5.5 + Opus 4.6, scored 6.8/10 overall).

## Fixes

**fix(cli): add recall and forget to CLI_ONLY set**
v0.31 added these commands to handleCliOnly() but forgot the gate set.
Both fell through to cliOps.get() → 'Unknown command'.

**feat(synthesize): auto-enable when corpus dir is configured**
Setting session_corpus_dir is now sufficient — enabled defaults to true
when a corpus dir is set. Explicit enabled=false still wins. Eliminates
the footgun where users configure a corpus dir and nothing happens.

**feat(engine): round takes weights to 0.05 increments**
Cross-modal eval found false precision (0.74, 0.82) implies calibration
accuracy that doesn't exist. Both postgres and pglite engines now round
on insert. 1.0 and 0.0 are preserved exactly.

## Documentation

**docs: takes-vs-facts architectural distinction**
New doc explaining the two epistemological layers, why they must never be
conflated, how the dream cycle consolidate phase bridges them, and
production extraction data (model selection, eval dimensions, key
learnings for extraction prompts).

**docs(takes-fence): clarify holder semantics with eval examples**
Holder = who HOLDS the belief, NOT who it's ABOUT. Expanded JSDoc with
concrete right/wrong examples from the cross-modal eval. Additional
rules: amplification ≠ endorsement, self-reported ≠ verified, founder
describing company → people/founder not companies/slug.

## Tests (17 new, all passing)

- 5 synthesize-enabled-default tests
- 6 takes-holder-semantics tests
- 6 takes-weight-rounding tests

## Cross-Modal Eval Context

| Dimension         | GPT-5.5 | Opus 4.6 | Avg  |
|-------------------|---------|----------|------|
| Accuracy          | 7       | 8        | 7.5  |
| Attribution       | 6       | 7        | 6.5  |
| Weight calibration| 7       | 7        | 7.0  |
| Kind classification| 6      | 7        | 6.5  |
| Signal density    | 7       | 6        | 6.5  |

Top improvements addressed in this PR:
1. Holder vs subject confusion (docs + tests)
2. Weight false precision (runtime enforcement)
3. Takes ≠ facts distinction (architectural doc)
4. Synthesis auto-enable (runtime fix)
5. recall/forget CLI routing (bug fix)

* docs(filing-rules): anchor takes attribution rules (EXP-3)

Adds a "Takes attribution" section to skills/_brain-filing-rules.md
distilling the 6 rules from docs/takes-vs-facts.md into a terse
contract that downstream agents (OpenClaw, Wintermute) can read as
their canonical filing surface.

Documentation only — no in-repo runtime consumer (synthesize.ts reads
the .json file, not the .md). EXP-4 lands the runtime parser-level
holder validation.

Codex review #9: relabels EXP-3 as documentation, not quality work.
The runtime check is EXP-4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(takes): weight backfill v46 + NaN hardening at 4 sites (EXP-1, Hardening)

Migration v46 (takes_weight_round_to_grid): backfills pre-v0.32 takes.weight
to the 0.05 grid the engine layer (PR #795) enforces on insert. Cross-modal
eval over 100K production takes flagged 0.74, 0.82-style values as false
precision; this brings existing data to the same grid that all new writes
already use.

Tolerance-based comparison (abs > 0.001) avoids the float32-noise re-touch
loop that the naive `weight <> ROUND(...)` form would create — REAL/NUMERIC
comparison promotes weight to DOUBLE PRECISION first, surfacing ~1e-7
representation noise as inequality. The 0.05 grid is 5e-2, so any genuine
off-grid value clears the 1e-3 threshold cleanly.

`transaction: false` (codex review #2 correction): not for mid-statement
resume (a single SQL statement either completes or rolls back). What it
actually buys is freeing the migration runner from holding a long
transaction so other gbrain processes can interleave.

NaN hardening (codex review #8): extracts `normalizeWeightForStorage()` to
takes-fence.ts as a single source of truth used by all 4 takes write sites:
  - pglite-engine.ts addTakesBatch
  - pglite-engine.ts updateTake (was missed in original PR — only clamped,
    didn't round; now rounds AND guards NaN)
  - postgres-engine.ts addTakesBatch
  - postgres-engine.ts updateTake (same fix)

The helper guards `!Number.isFinite()` BEFORE the [0,1] range check (NaN
comparisons are always false, so NaN survived the prior clamp and reached
Math.round(NaN * 20) / 20 = NaN, written through to the DB).

Tests:
- test/migrations-v46-takes-weight-backfill.test.ts: behavioral PGLite test
  (rounding fixture + Codex #2 re-run idempotency + on-grid preservation).
- test/takes-weight-rounding.test.ts: imports the real helper, adds NaN /
  Infinity / -Infinity / null / undefined / updateTake-shape coverage.
- test/migrate.test.ts: structural assertions for v46 SQL shape.

All 52 tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(doctor): takes_weight_grid check + pure helper extraction (EXP-2)

Adds doctor's `takes_weight_grid` slice — the post-migration drift detector
for the 0.05 weight grid v0.31 enforces on insert and v46 backfilled.

Codex review #7 corrected the original plan's "extend test/doctor.test.ts
with 3 cases" estimate. runDoctor() is a side-effectful command with
process.exit branches, and the existing tests are mostly source-structure
assertions. The fix: extract `takesWeightGridCheck(engine: BrainEngine)`
as a pure exported function. runDoctor calls it. Tests target the helper
directly with stubbed engines for the missing-table branch and against
real PGLite for the 4 ratio bands.

Branches:
  - 0 takes total → ok ("No takes yet")
  - off_grid / total > 10% → fail (with apply-migrations fix hint)
  - 1% < off_grid / total ≤ 10% → warn (same fix hint)
  - else → ok
  - takes table missing (pre-v37) → warn, graceful skip

Tolerance comparison matches migration v46 (abs > 1e-3) so float32 noise
doesn't make a healthy brain look broken.

Tests (test/doctor.test.ts):
  - takesWeightGridCheck export shape
  - 0-takes branch (avoids divide-by-zero)
  - 100% on-grid via engine.addTakesBatch (which now normalizes)
  - 8/10 off-grid → fail
  - 5/100 off-grid → warn
  - missing-table branch via stub engine

All 21 doctor tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(takes): holder runtime validation + producer seam (EXP-4)

Adds parser-level holder grammar enforcement so cross-modal eval's #1
attribution error (holder/subject confusion, scored 6.5/10 across 100K
production takes) shows up as a sync-failure record an operator can see.

Changes:

- src/core/sync.ts: exports SLUG_SEGMENT_PATTERN, the actual character
  class slugifySegment() produces ([a-z0-9._-]). Codex review #3 — the
  initial plan's stricter regex would have warned on legitimate slugs
  like `companies/acme.io` and `people/foo_bar`. HOLDER_REGEX now wraps
  this shared pattern instead of inventing a parallel grammar.

- src/core/takes-fence.ts: HOLDER_REGEX + isValidHolder() helper.
  parseTakesFence() emits TAKES_HOLDER_INVALID warnings for non-matching
  holders. Row preserved (markdown source-of-truth contract).

  Catches the eval's failure modes — `Garry`, `people/Garry-Tan`,
  `world/garry-tan`, `users/garry`, whitespace-only — while keeping
  `companies/acme.io`, `people/foo_bar`, `notes/v1.0.0`-style dotted
  slugs valid. Bare-slug form (`garry`, `alice`) accepted as v0.32 legacy
  compat — production brains shipped with bare-slug holders before the
  namespaced JSDoc landed in PR #795. Reserved for v0.33 promotion.

- src/core/cycle/extract-takes.ts (codex review #4 producer seam): adds
  `failedFiles: Array<{path, error}>` to ExtractTakesResult. Both fs
  and db extraction paths populate it from TAKES_HOLDER_INVALID warnings
  so the migration orchestrator can hand it to recordSyncFailures().
  Without this seam, extending classifyErrorCode would do nothing
  (the regex would have nothing to classify).

- src/commands/migrations/v0_28_0.ts: phaseBBackfill calls
  recordSyncFailures(result.failedFiles, 'migration:v0.28.0-backfill')
  after extractTakes completes. Best-effort — persistence failure
  doesn't fail the backfill phase. Doctor's `sync_failures` check now
  shows TAKES_HOLDER_INVALID=N breakdown after upgrade.

- src/core/sync.ts:classifyErrorCode: extends with TAKES_HOLDER_INVALID
  + TAKES_TABLE_MALFORMED / TAKES_ROW_NUM_COLLISION / TAKES_FENCE_UNBALANCED
  bucket. Previously these warnings bucketed to UNKNOWN.

Tests (test/takes-holder-validation.test.ts — 26 cases):
- Canonical forms (world / brain / people-namespace / companies-namespace)
- Codex #3 dotted-slug + underscore-slug positives
- Legacy bare-slug compat positives
- Eval-flagged error mode rejections (uppercase, mixed case, world/<slug>,
  unrecognized prefix, whitespace, embedded slash)
- HOLDER_REGEX anchoring guard
- SLUG_SEGMENT_PATTERN export shape + drift guard against the wrapping regex
- parseTakesFence end-to-end emission contract
- classifyErrorCode regex coverage

127 tests pass across affected files; typecheck clean. No existing fixtures
broken (legacy bare-slug compat preserves old `garry`-style holders during
the v0.32 transition window).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval): gbrain eval takes-quality CLI — DB-authoritative + 4-mode (EXP-5)

Reproducible cross-modal quality eval for the takes layer. Three frontier
models score a sample against the 5-dim rubric, the runner aggregates to
PASS/FAIL/INCONCLUSIVE, the receipt persists to eval_takes_quality_runs.
Trend mode segregates by rubric_version; regress mode is a CI gate that
exits 1 when any dim regresses past --threshold.

Subcommands:
  run     [--limit N --cycles N --budget-usd N --slug-prefix P --models a,b,c]
  replay  <receipt-path> [--json]                 # NO BRAIN required
  trend   [--limit N --rubric-version V --json]
  regress --against <receipt> [--threshold T --json]

Codex review integrations (D7 — all 10 findings landed):

  #1 json-repair shim re-exports BOTH parseModelJSON AND the
     ParsedScore + ParsedModelResult types. The original plan only
     re-exported the function, which would have compile-broken
     cross-modal-eval/aggregate.ts:19's type import.

  #3 Receipt name binds (corpus_sha8, prompt_sha8, models_sha8,
     rubric_sha8) so a future rubric tweak segregates trend rows
     instead of silently corrupting the quality-over-time graph.
     RUBRIC_VERSION + rubric_sha8 are persisted in every receipt.

  #4 Pricing fail-closed: any model not in pricing.ts produces an
     actionable PricingNotFoundError before any HTTP call fires.
     Same drift problem as cross-modal-eval/runner.ts:estimateCost(),
     but explicit instead of silent zero.

  #5 Aggregate requires ALL 5 declared rubric dimensions per model.
     Cross-modal-eval v1's union-of-whatever-parsed pattern allowed a
     model to omit a dim and still PASS — that's a regression-gate
     hole. Now: missing-dim drops the contribution, treated identically
     to a parse failure. Empty-scores PASS regression guard preserved.

  #6 DB-authoritative receipt persistence. Original two-phase plan had
     a split-brain reconciliation gap (disk-success/DB-fail vanishes
     from trend; DB-success/disk-fail unreplayable). Now DB row is the
     source of truth (carries full receipt JSON in a JSONB column);
     disk artifact is best-effort. replay reads disk first; loadReceiptFromDb
     reconstructs from DB when the disk file is missing.

  #10 Brain-routing: replay is the only sub-subcommand that doesn't
      need a brain. cli.ts no-DB bypass routes "eval takes-quality replay"
      directly to runReplayNoBrain, which exits 0/1/2 cleanly without
      ever touching the engine. Other modes go through connectEngine.

Files added:
  src/core/eval-shared/json-repair.ts (hoisted from cross-modal-eval)
  src/core/takes-quality-eval/{rubric,pricing,aggregate,receipt-name,
                                receipt-write,receipt,replay,regress,trend,runner}.ts
  src/commands/eval-takes-quality.ts
  docs/eval-takes-quality.md (stable schema_version: 1 contract)
  10 test files (83 cases — aggregate / receipt-name / shim / pricing /
                 rubric / receipt-write / replay / trend / regress / cli)

Files modified:
  src/cli.ts: replay no-DB bypass + engine-required dispatch
  src/core/cross-modal-eval/json-repair.ts → re-export shim
  src/core/migrate.ts: append v47 (eval_takes_quality_runs table)
  src/core/pglite-schema.ts + src/schema.sql: mirror the v47 table for
    fresh-install path. RLS toggled on the new table.
  src/core/schema-embedded.ts: regenerated via build:schema
  test/migrate.test.ts: 6 structural cases for v47

186 tests pass; typecheck clean. Replay verified working end-to-end
(reads receipt JSON file without DATABASE_URL, exits with the verdict
code, prints actionable error on missing file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(eval): fill EXP-5 unit-test gaps + test-isolation lint fix

Three additions identified during the test-gap audit:

  1. test/eval-takes-quality-boundaries.test.ts (4 cases):
     - empty corpus → "no takes to evaluate" (pre-LLM)
     - source=fs reserved for v0.33 → clear refusal
     - --budget-usd + unknown model → PricingNotFoundError BEFORE any
       network call (codex review #4 fail-closed contract)
     - --budget-usd null + unknown model → no pre-flight pricing error
       (proves pricing pre-flight gates ONLY when budget is set)

  2. test/eval-takes-quality-runner.serial.test.ts (7 cases):
     End-to-end runner integration with mock.module-stubbed gateway.chat.
     Quarantined as *.serial.test.ts because mock.module leaks across
     files in the same shard process (R2 in check-test-isolation.sh).
     Covers:
       - 3 PASS scores → verdict=pass with all dim scores in receipt
       - all model errors → INCONCLUSIVE
       - 1 success + 2 errors → INCONCLUSIVE (need >=2 contributing)
       - 3 successes with low scores → FAIL
       - budget cap fires before cycle 1 (no chat() ever called)
       - budget cap allows cycle when projection fits

  3. test/eval-takes-quality-receipt-write.test.ts: refactored to use
     withEnv() helper for GBRAIN_HOME mutation instead of direct
     process.env writes. The original beforeAll mutation tripped the
     check-test-isolation.sh R1 lint. withEnv() saves/restores via
     try/finally per-test so other shard files don't see the override.

Verification:
  bun run test       → 4977 pass / 0 fail
  bun run test:serial → 179 pass / 0 fail
  bun run verify     → clean (typecheck + 9 pre-checks pass)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(eval): real-Postgres E2E for eval_takes_quality_runs (EXP-5)

Pure-PGLite tests already cover the receipt-write contract; this E2E
verifies the same code path against actual Postgres so the postgres.js
JSONB encoding and the v47 migration apply cleanly under production
conditions.

Coverage (8 cases):
  - migration v47 created the table with all expected columns
  - writeReceiptToDb persists full receipt_json on Postgres
  - 4-sha UNIQUE constraint enforces ON CONFLICT DO NOTHING idempotency
    (3 inserts → 1 row)
  - rubric_version segregation: distinct rubric_sha8 → distinct row
    (codex review #3 — rubric epoch separation)
  - loadTrend reads in DESC order on Postgres
  - loadReceiptFromDb reconstructs receipt JSON via the JSONB column
  - writeReceipt (combined) succeeds with disk artifact + DB row
  - trend SELECT plan executes (planner picks index on larger tables)

Skips gracefully when DATABASE_URL is unset (existing hasDatabase()
helper). Uses the canonical setupDB/teardownDB from test/e2e/helpers.ts.
GBRAIN_HOME mutation is wrapped in withEnv() per the v0.32.0 test-isolation
lint contract.

Verification:
  bash scripts/run-e2e.sh → 71 files / 499 tests / 0 fail (full E2E suite)
  bun test test/e2e/eval-takes-quality.test.ts → 8 / 8 pass standalone

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fill v0.32 unit + E2E gap audit (3 new files, 36 cases)

Audit of shipped v0.32 code surfaced 4 wiring gaps that the per-EXP unit
tests didn't cover. Adding direct integration tests for each so a future
refactor can't accidentally bypass the helper or unwire the producer seam.

test/extract-takes-holder-producer-seam.test.ts (7 cases) — codex review
#4 producer seam. Verifies extractTakesFromDb populates ExtractTakesResult.
failedFiles[] when parseTakesFence emits TAKES_HOLDER_INVALID warnings,
and that the entry shape is recordSyncFailures-compatible. Without this
test, the v0_28_0 migration's recordSyncFailures call would have silently
fed it nothing if a refactor accidentally dropped the failedFiles append.
Covers: valid holder (no entry), invalid uppercase, world/<slug>, mixed
valid+invalid, legacy bare-slug compat, malformed-table-only (no leak),
recordSyncFailures shape compatibility.

test/engine-weight-rounding-integration.test.ts (15 cases) — codex review
#8 integration coverage. Helper is unit-tested; this proves both engines'
addTakesBatch + updateTake paths actually call it. PGLite-side coverage
mirrors the test/e2e/takes-weight-rounding-postgres.test.ts E2E for real
Postgres. Covers: 0.74→0.75, 0.82→0.80, on-grid identity, NaN→0.5,
Infinity→0.5, clamp high/low, undefined default, mixed batch order,
updateTake rounds (was unhardened pre-v0.32), updateTake NaN, updateTake
preserves prior weight when undefined.

test/e2e/takes-weight-rounding-postgres.test.ts (6 cases, 14 expects) —
real-Postgres write-path coverage. Specifically tests the postgres.js
unnest() bind path that PGLite doesn't exercise:
  - addTakesBatch rounds via the unnest() bind shape
  - addTakesBatch handles NaN at the postgres.js array marshaling layer
  - 10-row mixed batch (4 off-grid) rounds each independently
  - updateTake rounds on real Postgres
  - updateTake handles NaN
  - migration v48 tolerance matches engine-write tolerance (round-trip
    proof — engine-rounded value is invisible to v48's WHERE clause)

Verification:
  bun run test       → 5166 pass / 0 fail (parallel unit, 128s)
  bun run test:serial → 190 pass / 0 fail
  bun run test:e2e   → 71 / 74 files; 3 pre-existing env-inheritance
                       failures (serve-http-oauth, sources-remote-mcp,
                       thin-client — confirmed identical on master in
                       this environment, documented in CLAUDE.md)
  bun run verify     → clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): connect engine in withConfiguredSql; unbreak 3 OAuth E2E suites

Real production bug, not just a test-environment issue.
withConfiguredSql in src/commands/auth.ts created a PostgresEngine via
createEngine() but never called engine.connect(). The PostgresEngine.sql
getter falls back to db.getConnection() (the module-level singleton) when
its instance _sql is unset — and db.connect() wasn't called either.

So every `gbrain auth` subcommand (create, list, revoke, register-client,
revoke-client) crashed with the misleading "No database connection:
connect() has not been called" error on real Postgres. Anyone with a
Postgres-backed brain hit this. The error pointed at gbrain init which
made the regression invisible — users assumed they hadn't initialized.

Verified by running `gbrain auth register-client` directly:
  Before: "Error: No database connection: connect() has not been called."
  After:  "OAuth client registered: ..." with credentials printed.

This fix unblocked all 3 previously-failing E2E suites (which all use
register-client in beforeAll):
  serve-http-oauth.test.ts:    0/28 → 28/28 pass
  sources-remote-mcp.test.ts:  0/14 → 14/14 pass
  thin-client.test.ts:         0/7  →  6/7 pass + 1 documented skip

Two surgical test-side fixes also landed:

1. test/e2e/thin-client.test.ts:182 — assertion typo. Test expected
   r.stderr to contain "thin client" (space). Actual refusal message
   says "(thin-client of <url>)" with hyphen. Loosened to /thin[- ]client/
   so a future format tweak doesn't false-fail.

2. test/e2e/thin-client.test.ts:239 — skipped "remote ping triggers
   autopilot-cycle" with a clear TODO. Test asks the wrong question
   against the existing fixture: `gbrain serve --http` deliberately
   does NOT start a job worker (workers run via separate `gbrain jobs
   work` process), so the submitted autopilot-cycle job sits in
   `waiting` forever. Test was supposed to fall back to the self-imposed
   `--timeout`, but `gbrain remote ping --timeout` doesn't honor the cap
   when callRemoteTool hangs (loop only checks elapsed time between
   iterations; a single in-flight callTool with no AbortSignal blocks
   forever). Two real follow-ups would unblock: thread an AbortSignal
   through callRemoteTool's MCP callTool path, OR start a `gbrain jobs
   work` subprocess in beforeAll. Either is its own PR. Wire path
   coverage isn't lost — exercised by every other test in this file
   plus the entire serve-http-oauth.test.ts suite.

Verification:
  bun test test/e2e/serve-http-oauth.test.ts test/e2e/sources-remote-mcp.test.ts test/e2e/thin-client.test.ts
    → 47 pass / 1 skip / 0 fail in 8.4s
  bun run verify → clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 06:34:40 -07:00

422 lines
16 KiB
TypeScript

#!/usr/bin/env bun
/**
* GBrain token management.
*
* Wired into the CLI as of v0.22.5:
* gbrain auth create "claude-desktop"
* gbrain auth list
* gbrain auth revoke "claude-desktop"
* gbrain auth test <url> --token <token>
*
* Also runs standalone (no compiled binary required):
* DATABASE_URL=... bun run src/commands/auth.ts create "claude-desktop"
*
* DB-backed commands route through the active BrainEngine (PGLite or
* Postgres), so they work regardless of which engine the user's brain is
* configured for. The env-var DATABASE_URL / GBRAIN_DATABASE_URL still
* picks Postgres via loadConfig() (config.ts DbUrlSource inference),
* but the SQL itself goes through engine.executeRaw — never through a
* postgres.js singleton. `test` only hits a remote URL and doesn't need
* a local DB.
*/
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 { sqlQueryForEngine, executeRawJsonb, type SqlQuery } from '../core/sql-query.ts';
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function generateToken(): string {
return 'gbrain_' + randomBytes(32).toString('hex');
}
/**
* Acquire an engine from the active config, run `fn` with a SqlQuery, and
* disconnect afterward. Loud-fails when no config is present (matches the
* prior behavior of getDatabaseUrl(requireDb=true) — auth commands need a
* brain to write to).
*/
async function withConfiguredSql<T>(
fn: (sql: SqlQuery, engine: BrainEngine) => Promise<T>,
): Promise<T> {
const config = loadConfig();
if (!config) {
console.error('No GBrain config found. Run `gbrain init` first, or set DATABASE_URL / GBRAIN_DATABASE_URL.');
process.exit(1);
}
const engineConfig = toEngineConfig(config);
const engine = await createEngine(engineConfig);
// v0.32: createEngine returns a disconnected instance. PostgresEngine's `sql`
// getter falls back to `db.getConnection()` (the module-level singleton)
// when `_sql` is unset, which throws "connect() has not been called" when
// db.connect() was never invoked either. Auth commands never go through
// cli.ts's connectEngine() path (early-routed at cli.ts:685), so we must
// connect the engine here. Without this call, every auth subcommand
// (create/list/revoke/register-client/revoke-client) crashes with the
// misleading "No database connection" error.
await engine.connect(engineConfig);
const sql = sqlQueryForEngine(engine);
try {
return await fn(sql, engine);
} finally {
await engine.disconnect();
}
}
async function create(name: string, opts: { takesHolders?: string[] } = {}) {
if (!name) { console.error('Usage: auth create <name> [--takes-holders world,garry]'); process.exit(1); }
const token = generateToken();
const hash = hashToken(token);
try {
await withConfiguredSql(async (_sql, engine) => {
// v0.28: persist per-token takes-holder allow-list. Default ['world'] keeps
// private hunches hidden from MCP-bound tokens.
const takesHolders = opts.takesHolders && opts.takesHolders.length > 0
? opts.takesHolders
: ['world'];
const permissions = { takes_holders: takesHolders };
// JSONB write: pass the object via executeRawJsonb with an explicit
// ::jsonb cast in the SQL string. Both engines round-trip the object
// 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`);
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(`Update visibility: gbrain auth permissions "${name}" set-takes-holders world,garry`);
});
} catch (e: any) {
if (e.code === '23505') {
console.error(`A token named "${name}" already exists. Revoke it first or use a different name.`);
} else {
console.error('Error:', e.message);
}
process.exit(1);
}
}
async function permissions(name: string, action: string, value: string | undefined) {
if (!name || action !== 'set-takes-holders' || !value) {
console.error('Usage: auth permissions <name> set-takes-holders world,garry,brain');
process.exit(1);
}
try {
await withConfiguredSql(async (sql, engine) => {
const list = value.split(',').map(s => s.trim()).filter(Boolean);
if (list.length === 0) {
console.error('takes-holders list cannot be empty (use "world" for default-deny on private)');
process.exit(1);
}
const perms = { takes_holders: list };
// JSONB UPDATE via executeRawJsonb — same pattern as create() above.
const result = await executeRawJsonb(
engine,
`UPDATE access_tokens
SET permissions = $2::jsonb
WHERE name = $1
RETURNING id`,
[name],
[perms],
);
if (result.length === 0) {
console.error(`Token "${name}" not found.`);
process.exit(1);
}
console.log(`Updated "${name}": takes_holders = ${JSON.stringify(list)}`);
});
} catch (e: any) {
console.error('Error:', e.message);
process.exit(1);
}
}
async function list() {
await withConfiguredSql(async (sql) => {
const rows = await sql`
SELECT name, created_at, last_used_at, revoked_at
FROM access_tokens
ORDER BY created_at DESC
`;
if (rows.length === 0) {
console.log('No tokens found. Create one: gbrain auth create "my-client"');
return;
}
console.log('Name Created Last Used Status');
console.log('─'.repeat(80));
for (const r of rows) {
const name = (r.name as string).padEnd(20);
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}`);
}
});
}
async function revoke(name: string) {
if (!name) { console.error('Usage: auth revoke <name>'); process.exit(1); }
await withConfiguredSql(async (sql) => {
const rows = await sql`
UPDATE access_tokens SET revoked_at = now()
WHERE name = ${name} AND revoked_at IS NULL
RETURNING 1
`;
if (rows.length === 0) {
console.error(`No active token found with name "${name}".`);
process.exit(1);
}
console.log(`Token "${name}" revoked.`);
});
}
async function test(url: string, token: string) {
if (!url || !token) {
console.error('Usage: auth test <url> --token <token>');
process.exit(1);
}
const startTime = Date.now();
console.log(`Testing MCP server at ${url}...\n`);
// Step 1: Initialize
try {
const initRes = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'gbrain-smoke-test', version: '1.0' },
},
id: 1,
}),
});
if (!initRes.ok) {
console.error(` Initialize failed: ${initRes.status} ${initRes.statusText}`);
const body = await initRes.text();
if (body) console.error(` ${body}`);
process.exit(1);
}
console.log(' ✓ Initialize handshake');
} catch (e: any) {
console.error(` ✗ Connection failed: ${e.message}`);
process.exit(1);
}
// Step 2: List tools
try {
const listRes = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/list',
params: {},
id: 2,
}),
});
if (!listRes.ok) {
console.error(` ✗ tools/list failed: ${listRes.status}`);
process.exit(1);
}
const text = await listRes.text();
// Parse SSE or JSON response
let toolCount = 0;
if (text.includes('event:')) {
// SSE format: extract data lines
const dataLines = text.split('\n').filter(l => l.startsWith('data:'));
for (const line of dataLines) {
try {
const data = JSON.parse(line.slice(5));
if (data.result?.tools) toolCount = data.result.tools.length;
} catch { /* skip non-JSON lines */ }
}
} else {
try {
const data = JSON.parse(text);
toolCount = data.result?.tools?.length || 0;
} catch { /* parse error */ }
}
console.log(` ✓ tools/list: ${toolCount} tools available`);
} catch (e: any) {
console.error(` ✗ tools/list failed: ${e.message}`);
process.exit(1);
}
// Step 3: Call get_stats (real tool call)
try {
const statsRes = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'get_stats', arguments: {} },
id: 3,
}),
});
if (!statsRes.ok) {
console.error(` ✗ get_stats failed: ${statsRes.status}`);
process.exit(1);
}
console.log(' ✓ get_stats: brain is responding');
} catch (e: any) {
console.error(` ✗ get_stats failed: ${e.message}`);
process.exit(1);
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\n🧠 Your brain is live! (${elapsed}s)`);
}
async function revokeClient(clientId: string) {
if (!clientId) {
console.error('Usage: auth revoke-client <client_id>');
process.exit(1);
}
try {
await withConfiguredSql(async (sql) => {
// Atomic single-statement delete: no race window between count + delete.
// Postgres cascades to oauth_tokens and oauth_codes (FK ON DELETE CASCADE
// declared in src/schema.sql:370,382) before the transaction commits.
const rows = await sql`
DELETE FROM oauth_clients WHERE client_id = ${clientId}
RETURNING client_id, client_name
`;
if (rows.length === 0) {
console.error(`No client found with id "${clientId}"`);
process.exit(1);
}
console.log(`OAuth client revoked: "${rows[0].client_name}" (${clientId})`);
console.log('Tokens and authorization codes purged via cascade.');
});
} catch (e: any) {
console.error('Error:', e.message);
process.exit(1);
}
}
async function registerClient(name: string, args: string[]) {
if (!name) { console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S]'); process.exit(1); }
const grantsIdx = args.indexOf('--grant-types');
const scopesIdx = args.indexOf('--scopes');
const grantTypes = grantsIdx >= 0 && args[grantsIdx + 1]
? args[grantsIdx + 1].split(',').map(s => s.trim()).filter(Boolean)
: ['client_credentials'];
const scopes = scopesIdx >= 0 && args[scopesIdx + 1] ? args[scopesIdx + 1] : 'read';
try {
await withConfiguredSql(async (sql) => {
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
const provider = new GBrainOAuthProvider({ sql });
const { clientId, clientSecret } = await provider.registerClientManual(
name, grantTypes, scopes, [],
);
console.log(`OAuth client registered: "${name}"\n`);
console.log(` Client ID: ${clientId}`);
console.log(` Client Secret: ${clientSecret}\n`);
console.log(` Grant types: ${grantTypes.join(', ')}`);
console.log(` Scopes: ${scopes}\n`);
console.log('Save the client secret — it will not be shown again.');
console.log(`Revoke with: gbrain auth revoke-client "${clientId}"`);
});
} catch (e: any) {
console.error('Error:', e.message);
process.exit(1);
}
}
/**
* Entry point for the `gbrain auth` CLI subcommand. Also reused by the
* direct-script path (see bottom of file) so `bun run src/commands/auth.ts`
* still works.
*/
export async function runAuth(args: string[]): Promise<void> {
const [cmd, ...rest] = args;
switch (cmd) {
case 'create': {
// v0.28: optional --takes-holders world,garry,brain (default: world only)
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 positional = rest.find(a => !a.startsWith('--') && a !== rest[takesIdx + 1]);
await create(positional || '', { takesHolders });
return;
}
case 'list': await list(); return;
case 'revoke': await revoke(rest[0]); return;
case 'permissions': {
// gbrain auth permissions <name> set-takes-holders world,garry
await permissions(rest[0] || '', rest[1] || '', rest[2]);
return;
}
case 'register-client': await registerClient(rest[0], rest.slice(1)); return;
case 'revoke-client': await revokeClient(rest[0]); return;
case 'test': {
const tokenIdx = rest.indexOf('--token');
const url = rest.find(a => !a.startsWith('--') && a !== rest[tokenIdx + 1]);
const token = tokenIdx >= 0 ? rest[tokenIdx + 1] : '';
await test(url || '', token || '');
return;
}
default:
console.log(`GBrain Token Management
Usage:
gbrain auth create <name> [--takes-holders world,garry,brain]
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
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+)
--grant-types <client_credentials,authorization_code> (default: client_credentials)
--scopes "<read write admin>" (default: read)
gbrain auth revoke-client <client_id> Hard-delete an OAuth 2.1 client (cascades to tokens + codes)
gbrain auth test <url> --token <token> Smoke-test a remote MCP server
`);
}
}
// Direct-script entry point — only runs when this file is invoked as the main module
// (e.g. `bun run src/commands/auth.ts ...`). When imported by cli.ts, this block is skipped.
if (import.meta.main) {
await runAuth(process.argv.slice(2));
}