From 2247540b4f8036d7569670b3cd8a526b607c983e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 21 Jul 2026 14:24:02 -0700 Subject: [PATCH] fix(init,mcp): seed init AI options from env on cold install; whoami reports stdio transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two backlog fixes: - init (#1058): loadConfig() returns null on a cold install (no config.json AND no DATABASE_URL), short-circuiting before its env merge — so GBRAIN_EMBEDDING_MODEL / GBRAIN_EMBEDDING_DIMENSIONS / GBRAIN_EXPANSION_MODEL / GBRAIN_CHAT_MODEL were silently ignored and Tier-3 detection auto-picked by API key instead. resolveAIOptions' config seed now falls back to those env vars directly when loadConfig() is null (new exported helper seedAIOptionsFromConfig, env-injectable for tests). - whoami (#1061): the stdio MCP dispatch is remote/untrusted by design but has no per-token auth (local pipe), so whoami threw unknown_transport on the primary stdio surface. The stdio dispatch now marks ctx.transport = 'stdio' and whoami returns {transport: 'stdio', scopes: []} for it. Trust posture unchanged: remote stays true, the marker is never used for trust decisions, and an unmarked auth-less remote context still throws (fail-closed preserved). Co-Authored-By: Claude Fable 5 --- src/commands/init.ts | 54 +++++++++++++++++++++++++-------- src/core/operations.ts | 22 ++++++++++++-- src/mcp/dispatch.ts | 7 +++++ src/mcp/server.ts | 4 +++ test/init-env-detection.test.ts | 46 +++++++++++++++++++++++++++- test/whoami.test.ts | 29 ++++++++++++++++++ 6 files changed, 146 insertions(+), 16 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 69e3d5b93..36d30fc4d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -161,7 +161,7 @@ interface ResolveAIOptionsArgs { nonInteractive: boolean; // --non-interactive (forces D3 fail-loud, no picker) } -interface ResolvedAIOptions { +export interface ResolvedAIOptions { embedding_model?: string; embedding_dimensions?: number; expansion_model?: string; @@ -170,6 +170,41 @@ interface ResolvedAIOptions { noEmbedding?: boolean; } +/** + * Seed init's AI options from persisted config, falling back to the raw env + * vars when loadConfig() returned null (#1058). On a cold install (no + * config.json AND no DATABASE_URL) loadConfig short-circuits BEFORE its env + * merge, so GBRAIN_EMBEDDING_MODEL / GBRAIN_EMBEDDING_DIMENSIONS / + * GBRAIN_EXPANSION_MODEL / GBRAIN_CHAT_MODEL were silently ignored by init + * and Tier-3 detection auto-picked by API key instead. Exported for unit + * tests (env injectable). + */ +export function seedAIOptionsFromConfig( + cfg: GBrainConfig | null, + env: NodeJS.ProcessEnv = process.env, +): ResolvedAIOptions { + const envDims = env.GBRAIN_EMBEDDING_DIMENSIONS + ? parseInt(env.GBRAIN_EMBEDDING_DIMENSIONS, 10) + : NaN; + const seed = cfg ?? { + embedding_disabled: undefined, + embedding_model: env.GBRAIN_EMBEDDING_MODEL, + embedding_dimensions: Number.isFinite(envDims) ? envDims : undefined, + expansion_model: env.GBRAIN_EXPANSION_MODEL, + chat_model: env.GBRAIN_CHAT_MODEL, + }; + const out: ResolvedAIOptions = {}; + if (seed.embedding_disabled) { + out.noEmbedding = true; + } else if (seed.embedding_model) { + out.embedding_model = seed.embedding_model; + if (seed.embedding_dimensions) out.embedding_dimensions = seed.embedding_dimensions; + } + if (seed.expansion_model) out.expansion_model = seed.expansion_model; + if (seed.chat_model) out.chat_model = seed.chat_model; + return out; +} + /** * Resolve AI provider options for `gbrain init`. * @@ -203,18 +238,13 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise { @@ -3727,6 +3737,12 @@ const whoami: Operation = { if (ctx.remote === false) { return { transport: 'local', scopes: [] }; } + // #1061: stdio MCP is remote/untrusted by design but has no per-token + // auth (local pipe) — a known transport, not a bug. Report it instead of + // throwing. Empty scopes: nothing here may be used to gate anything. + if (!ctx.auth && ctx.transport === 'stdio') { + return { transport: 'stdio', scopes: [] }; + } if (!ctx.auth) { throw new OperationError( 'unknown_transport', diff --git a/src/mcp/dispatch.ts b/src/mcp/dispatch.ts index 8501ec747..48e188bf3 100644 --- a/src/mcp/dispatch.ts +++ b/src/mcp/dispatch.ts @@ -32,6 +32,12 @@ export interface DispatchOpts { remote?: boolean; /** Override the default stderr logger (e.g. CLI uses console.* directly). */ logger?: OperationContext['logger']; + /** + * #1061: transport marker for auth-less remote surfaces. The stdio MCP + * server passes 'stdio' so identity ops (whoami) can report the transport + * instead of throwing unknown_transport. Never used for trust decisions. + */ + transport?: OperationContext['transport']; /** * v0.28: per-token allow-list for the takes.holder field. Threaded by * the HTTP/stdio transport from `access_tokens.permissions.takes_holders`. @@ -203,6 +209,7 @@ export function buildOperationContext( logger: opts.logger || stderrLogger, dryRun: !!params.dry_run, remote: opts.remote ?? true, + transport: opts.transport, takesHoldersAllowList: opts.takesHoldersAllowList, // v0.34 D4: sourceId is REQUIRED at the type level. Auto-fill 'default' // for single-source brains and any caller who didn't resolve a sourceId. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ae5ab4c3f..6f98b01dc 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -42,6 +42,10 @@ export async function startMcpServer(engine: BrainEngine) { // `gbrain call ` (sets remote=false in src/cli.ts). return dispatchToolCall(engine, name, params, { remote: true, + // #1061: mark the transport so whoami can report {transport: 'stdio'} + // instead of throwing unknown_transport. Trust posture unchanged — + // stdio stays remote/untrusted. + transport: 'stdio', takesHoldersAllowList: ['world'], // v0.31: source defaults to 'default' for stdio (no per-token scope). // Operators who want a different source on stdio MCP should set diff --git a/test/init-env-detection.test.ts b/test/init-env-detection.test.ts index ed1721773..8b4359a89 100644 --- a/test/init-env-detection.test.ts +++ b/test/init-env-detection.test.ts @@ -12,7 +12,7 @@ */ import { describe, test, expect } from 'bun:test'; -import { groupReadyByProvider, findEnvKeyTypos } from '../src/commands/init.ts'; +import { groupReadyByProvider, findEnvKeyTypos, seedAIOptionsFromConfig } from '../src/commands/init.ts'; describe('groupReadyByProvider — embedding touchpoint', () => { test('OPENAI_API_KEY alone → openai is ready', async () => { @@ -149,3 +149,47 @@ describe('findEnvKeyTypos', () => { expect(got.find(t => t.userSet === 'COMPLETELY_UNRELATED_KEY')).toBeUndefined(); }); }); + +describe('seedAIOptionsFromConfig — #1058 cold-install env fallback', () => { + test('null config (no config.json, no DATABASE_URL) falls back to GBRAIN_* env vars', () => { + const got = seedAIOptionsFromConfig(null, { + GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large', + GBRAIN_EMBEDDING_DIMENSIONS: '1024', + GBRAIN_EXPANSION_MODEL: 'openai:gpt-5-mini', + GBRAIN_CHAT_MODEL: 'anthropic:claude-sonnet-4-6', + }); + expect(got.embedding_model).toBe('voyage:voyage-3-large'); + expect(got.embedding_dimensions).toBe(1024); + expect(got.expansion_model).toBe('openai:gpt-5-mini'); + expect(got.chat_model).toBe('anthropic:claude-sonnet-4-6'); + }); + + test('null config + no env vars → empty seed (Tier-3 detection takes over)', () => { + const got = seedAIOptionsFromConfig(null, {}); + expect(got).toEqual({}); + }); + + test('persisted config wins (loadConfig already merged env when non-null)', () => { + const got = seedAIOptionsFromConfig( + { engine: 'pglite', embedding_model: 'openai:text-embedding-3-small', embedding_dimensions: 1536 } as any, + { GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large' }, + ); + expect(got.embedding_model).toBe('openai:text-embedding-3-small'); + expect(got.embedding_dimensions).toBe(1536); + }); + + test('embedding_disabled sentinel honored on re-init', () => { + const got = seedAIOptionsFromConfig({ engine: 'pglite', embedding_disabled: true } as any, {}); + expect(got.noEmbedding).toBe(true); + expect(got.embedding_model).toBeUndefined(); + }); + + test('non-numeric GBRAIN_EMBEDDING_DIMENSIONS ignored, model still seeds', () => { + const got = seedAIOptionsFromConfig(null, { + GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large', + GBRAIN_EMBEDDING_DIMENSIONS: 'not-a-number', + }); + expect(got.embedding_model).toBe('voyage:voyage-3-large'); + expect(got.embedding_dimensions).toBeUndefined(); + }); +}); diff --git a/test/whoami.test.ts b/test/whoami.test.ts index 53d0c150c..73aef6171 100644 --- a/test/whoami.test.ts +++ b/test/whoami.test.ts @@ -94,6 +94,35 @@ describe('whoami op contract', () => { expect(result.expires_at).toBeNull(); }); + // #1061: stdio MCP is remote/untrusted by design but has no per-token auth + // (local pipe). The stdio dispatch marks ctx.transport='stdio'; whoami + // reports it instead of throwing unknown_transport. + test('stdio transport (remote=true, no auth, transport marker) reports stdio', async () => { + const result = (await whoami.handler( + ctxWith({ remote: true, auth: undefined, transport: 'stdio' }), + {}, + )) as any; + expect(result.transport).toBe('stdio'); + expect(result.scopes).toEqual([]); + }); + + test('stdio marker does not mask real auth (auth still wins)', async () => { + const result = (await whoami.handler( + ctxWith({ + remote: true, + transport: 'stdio', + auth: { + token: 'gbrain_at_xxx', + clientId: 'gbrain_cl_abc', + scopes: ['read'], + expiresAt: 1, + } as AuthInfo, + }), + {}, + )) as any; + expect(result.transport).toBe('oauth'); + }); + // Q3: ambiguous transport — fail-closed. The footgun this guards against // is a future transport that lands without threading auth, where a buggy // caller might trust whoami's output to gate sensitive ops.