Files
gbrain/src/commands/auth.ts
T
69bc37f745 fix(auth): admin-gated rescope surface for DCR clients stuck on default scope (#3299)
* fix(auth): admin-gated rescope surface for DCR clients stuck on default scope (#1914)

DCR clients self-register with source_id='default' + federated_read=['default']
and the registration comment promised 'rescope via the CLI later' — but no
rescope surface existed. Adds:

- GBrainOAuthProvider.rescopeClient(clientId, { sourceId?, federatedRead? }):
  single-statement COALESCE update, canonical source-id validation
  (assertValidSourceId), FK-backed existence check on the write source,
  friendly errors for pre-v60/v61 schemas and unknown clients. Takes effect
  for already-issued tokens because verifyAccessToken re-reads oauth_clients.
- gbrain auth rescope-client <client_id> [--source S] [--federated-read a,b]
  (trusted local CLI).
- POST /admin/api/rescope-client (requireAdmin), mirroring the existing
  register-client / revoke-client admin endpoints.

Deliberately does NOT let clients self-widen scope (options a/b from the
issue) — fail-closed trust invariant.

Fixes #1914

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

* fix(auth): rescope-client admin endpoint returns 400 (not 500) for nonexistent write source

The FK-translated 'Source "x" does not exist' error is a client error;
map it to 400 like the sibling validation failures. ('No OAuth client
found' is matched first, so the 404 path is unaffected.)

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

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:28:42 -07:00

667 lines
27 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);
}
}
/**
* Parse `gbrain auth register-client` argv. Walks the array once instead of
* the prior `indexOf`-based pattern which (a) silently took only the FIRST
* occurrence of a repeatable flag (defeated `--redirect-uri https://a
* --redirect-uri https://b` — only `https://a` made it through), and (b)
* accepted bare values via lookahead even when adjacent to another flag.
*
* v0.41.3 (T3): proper loop-based parser so `--redirect-uri` is repeatable,
* and `--token-endpoint-auth-method` is recognized. Repeatable flags
* accumulate into arrays. Unknown flags throw a usage error.
*/
interface RegisterClientArgs {
grantTypes: string[];
scopes: string;
sourceId: string;
federatedRead: string[] | undefined;
redirectUris: string[];
tokenEndpointAuthMethod: string | undefined;
boundTools: string[] | undefined;
boundSourceId: string | undefined;
boundBrainId: string | undefined;
boundSlugPrefixes: string[] | undefined;
boundMaxConcurrent: number | undefined;
budgetUsdPerDay: string | undefined;
}
export function parseRegisterClientArgs(args: string[]): RegisterClientArgs {
const out: RegisterClientArgs = {
grantTypes: ['client_credentials'],
scopes: 'read',
sourceId: 'default',
federatedRead: undefined,
redirectUris: [],
tokenEndpointAuthMethod: undefined,
boundTools: undefined,
boundSourceId: undefined,
boundBrainId: undefined,
boundSlugPrefixes: undefined,
boundMaxConcurrent: undefined,
budgetUsdPerDay: undefined,
};
let i = 0;
let grantTypesSet = false;
while (i < args.length) {
const flag = args[i];
const value = args[i + 1];
const requireValue = () => {
if (value === undefined || value.startsWith('--')) {
throw new Error(`${flag} requires a value`);
}
return value;
};
switch (flag) {
case '--grant-types': {
const v = requireValue();
out.grantTypes = v.split(',').map(s => s.trim()).filter(Boolean);
grantTypesSet = out.grantTypes.length > 0;
i += 2;
break;
}
case '--scopes': out.scopes = requireValue(); i += 2; break;
case '--source': out.sourceId = requireValue(); i += 2; break;
case '--federated-read': {
const v = requireValue();
out.federatedRead = v.split(',').map(s => s.trim()).filter(Boolean);
i += 2; break;
}
case '--redirect-uri':
out.redirectUris.push(requireValue());
i += 2; break;
case '--token-endpoint-auth-method':
out.tokenEndpointAuthMethod = requireValue();
i += 2; break;
case '--bound-tools': {
const v = requireValue();
out.boundTools = v.split(',').map(s => s.trim()).filter(Boolean);
i += 2; break;
}
case '--bound-source': out.boundSourceId = requireValue(); i += 2; break;
case '--bound-brain': out.boundBrainId = requireValue(); i += 2; break;
case '--bound-slug-prefixes': {
const v = requireValue();
out.boundSlugPrefixes = v.split(',').map(s => s.trim()).filter(Boolean);
i += 2; break;
}
case '--bound-max-concurrent': {
const v = Number(requireValue());
if (!Number.isInteger(v) || v < 1) {
throw new Error('--bound-max-concurrent must be a positive integer');
}
out.boundMaxConcurrent = v;
i += 2; break;
}
case '--budget-usd-per-day': {
const v = requireValue();
if (!/^\d+(?:\.\d{1,2})?$/.test(v)) {
throw new Error('--budget-usd-per-day must be a non-negative decimal with at most 2 decimal places');
}
out.budgetUsdPerDay = v;
i += 2; break;
}
default:
throw new Error(`Unknown flag: ${flag}`);
}
}
// v0.41.3: if --grant-types not explicitly set and any --redirect-uri was
// passed, infer authorization_code + refresh_token. The single-flag path
// (just --redirect-uri ...) is the SECURITY.md-recommended pre-registration
// pattern; making operators redundantly pass `--grant-types` is footgun.
if (!grantTypesSet && out.redirectUris.length > 0) {
out.grantTypes = ['authorization_code', 'refresh_token'];
}
return out;
}
async function registerClient(name: string, args: string[]) {
if (!name) {
console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...] [--redirect-uri URI ...] [--token-endpoint-auth-method client_secret_post|client_secret_basic|none] [--bound-tools T1,T2] [--bound-source SOURCE] [--bound-brain BRAIN] [--bound-slug-prefixes P1,P2] [--bound-max-concurrent N] [--budget-usd-per-day USD]');
process.exit(1);
}
let parsed: RegisterClientArgs;
try {
parsed = parseRegisterClientArgs(args);
} catch (e: any) {
console.error(`Error: ${e.message}`);
console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...] [--redirect-uri URI ...] [--token-endpoint-auth-method client_secret_post|client_secret_basic|none] [--bound-tools T1,T2] [--bound-source SOURCE] [--bound-brain BRAIN] [--bound-slug-prefixes P1,P2] [--bound-max-concurrent N] [--budget-usd-per-day USD]');
process.exit(1);
}
const { grantTypes, scopes, sourceId, federatedRead, redirectUris, tokenEndpointAuthMethod } = parsed;
const agentBindings = parsed.boundTools || parsed.boundSourceId || parsed.boundBrainId ||
parsed.boundSlugPrefixes || parsed.boundMaxConcurrent !== undefined || parsed.budgetUsdPerDay !== undefined
? {
boundTools: parsed.boundTools,
boundSourceId: parsed.boundSourceId,
boundBrainId: parsed.boundBrainId,
boundSlugPrefixes: parsed.boundSlugPrefixes,
boundMaxConcurrent: parsed.boundMaxConcurrent,
budgetUsdPerDay: parsed.budgetUsdPerDay,
}
: undefined;
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, redirectUris, sourceId, federatedRead, tokenEndpointAuthMethod, agentBindings,
);
const effectiveFederated = federatedRead && federatedRead.length > 0 ? federatedRead : [sourceId];
const effectiveAuthMethod = tokenEndpointAuthMethod || 'client_secret_post';
console.log(`OAuth client registered: "${name}"\n`);
console.log(` Client ID: ${clientId}`);
if (clientSecret) {
console.log(` Client Secret: ${clientSecret}\n`);
} else {
console.log(` Client Secret: <public client — none issued>\n`);
}
console.log(` Grant types: ${grantTypes.join(', ')}`);
console.log(` Scopes: ${scopes}`);
console.log(` Token auth method: ${effectiveAuthMethod}`);
if (redirectUris.length > 0) {
console.log(` Redirect URIs: ${redirectUris.join(', ')}`);
}
console.log(` Write source: ${sourceId}`);
console.log(` Federated reads: ${effectiveFederated.join(', ')}`);
if (agentBindings) {
console.log(` Bound tools: ${(parsed.boundTools ?? []).join(', ') || '<none>'}`);
console.log(` Bound source: ${parsed.boundSourceId ?? '<none>'}`);
console.log(` Bound brain: ${parsed.boundBrainId ?? '<none>'}`);
console.log(` Bound slug prefixes:${parsed.boundSlugPrefixes ? ' ' + parsed.boundSlugPrefixes.join(', ') : ' <none>'}`);
console.log(` Max concurrency: ${parsed.boundMaxConcurrent ?? 1}`);
console.log(` Daily budget USD: ${parsed.budgetUsdPerDay ?? '<none>'}`);
}
console.log('');
if (clientSecret) {
console.log('Save the client secret — it will not be shown again.');
} else {
console.log('Public client (PKCE-only) — no secret needed.');
}
console.log(`Revoke with: gbrain auth revoke-client "${clientId}"`);
});
} catch (e: any) {
console.error('Error:', e.message);
process.exit(1);
}
}
/**
* v0.42.x (#1914): rescope an existing OAuth client's write source and/or
* federated read scope. This is the operator surface the DCR registration
* comment promised ("rescope via the CLI later") — DCR clients land with
* source_id='default' / federated_read=['default'] and must not self-widen,
* so widening happens here (trusted local CLI) or via the requireAdmin
* /admin/api/rescope-client endpoint.
*/
async function rescopeClient(clientId: string, args: string[]) {
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...]';
if (!clientId) {
console.error(usage);
process.exit(1);
}
let sourceId: string | undefined;
let federatedRead: string[] | undefined;
for (let i = 0; i < args.length; i += 2) {
const flag = args[i];
const value = args[i + 1];
if (value === undefined || value.startsWith('--')) {
console.error(`Error: ${flag} requires a value`);
console.error(usage);
process.exit(1);
}
if (flag === '--source') sourceId = value;
else if (flag === '--federated-read') {
federatedRead = value.split(',').map(s => s.trim()).filter(Boolean);
} else {
console.error(`Error: Unknown flag: ${flag}`);
console.error(usage);
process.exit(1);
}
}
if (sourceId === undefined && federatedRead === undefined) {
console.error('Error: pass --source and/or --federated-read');
console.error(usage);
process.exit(1);
}
try {
await withConfiguredSql(async (sql) => {
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
const provider = new GBrainOAuthProvider({ sql });
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead });
console.log(`OAuth client rescoped: "${result.clientName}" (${result.clientId})\n`);
console.log(` Write source: ${result.sourceId}`);
console.log(` Federated reads: ${result.federatedRead.join(', ') || '<none>'}`);
console.log('\nTakes effect on the client\'s next request (existing tokens included).');
});
} 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.
*/
/**
* Parse `auth create` args into `{ name, takesHolders }`.
*
* 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
* resolved to `rest[0]` when `takesIdx === -1`, silently dropping the name on
* the bare `gbrain auth create <name>` form.
*/
export function parseAuthCreateArgs(rest: string[]): { name: string; takesHolders?: 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 };
}
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 parsed = parseAuthCreateArgs(rest);
await create(parsed.name, { takesHolders: parsed.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 'rescope-client': await rescopeClient(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;
auto-set to authorization_code,refresh_token
when --redirect-uri is passed)
--scopes "<read write admin>" (default: read)
--source <id> (default: default)
--federated-read <id1,id2,...> (default: [source])
--redirect-uri <https://...> (v0.41.3+; repeatable; required for authorization_code)
--token-endpoint-auth-method <method> (v0.41.3+; client_secret_post | client_secret_basic | none;
'none' = public PKCE-only client, no secret minted)
--bound-tools <tool1,tool2> Bind submit_agent to an allow-list of tools
--bound-source <id> Bind submit_agent jobs to a source id
--bound-brain <id> Bind submit_agent jobs to a brain id
--bound-slug-prefixes <prefix1,prefix2> Bind submit_agent writes to slug prefixes
--bound-max-concurrent <n> Bound submit_agent concurrency (default: 1)
--budget-usd-per-day <usd> Bound submit_agent daily spend cap
gbrain auth rescope-client <client_id> [options] Change an existing client's source scope (e.g. a DCR
client stuck on the 'default' source). Only the flags
you pass change; the other axis is left as-is.
--source <id> New write source
--federated-read <id1,id2,...> New read-scope source list
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));
}