mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
422 lines
16 KiB
TypeScript
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));
|
|
}
|