From 97e716b01f2c11f34b492f22e2e5efe1bb456314 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 21 Jul 2026 14:31:37 -0700 Subject: [PATCH] fix(minions): subagent default client resolves config-stored Anthropic key (#2048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy subagent path constructed a bare new Anthropic() (env-only), so launchd/MCP workers whose key lives in gbrain config (anthropic_api_key) failed auth. anthropic-key.ts now exports resolveAnthropicKey() (env first, then config; hasAnthropicKey delegates) and makeSubagentHandler passes it as apiKey. Partial takeover of #2048 — only the auth patch; the path patches were superseded by the outputRoot mechanism (#2415). Co-authored-by: JiraiyaETH Co-Authored-By: Claude Fable 5 --- src/core/ai/anthropic-key.ts | 16 ++++++++++--- src/core/minions/handlers/subagent.ts | 6 ++++- test/ai/anthropic-key.test.ts | 34 ++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/core/ai/anthropic-key.ts b/src/core/ai/anthropic-key.ts index 62fe069f9..3a63aa695 100644 --- a/src/core/ai/anthropic-key.ts +++ b/src/core/ai/anthropic-key.ts @@ -18,12 +18,22 @@ import { loadConfig } from '../config.ts'; export function hasAnthropicKey(): boolean { - if (process.env.ANTHROPIC_API_KEY) return true; + return resolveAnthropicKey() !== undefined; +} + +/** + * Resolve the actual key value: env first, then the gbrain config file. + * Callers constructing an Anthropic client directly (e.g. the legacy + * subagent path) must pass this as `apiKey` — a bare `new Anthropic()` + * only sees env, so launchd/MCP workers with config-stored keys fail. + */ +export function resolveAnthropicKey(): string | undefined { + if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY; try { const cfg = loadConfig(); - if (cfg?.anthropic_api_key) return true; + if (cfg?.anthropic_api_key) return cfg.anthropic_api_key; } catch { // loadConfig may throw on first-run installs; treat as no key available. } - return false; + return undefined; } diff --git a/src/core/minions/handlers/subagent.ts b/src/core/minions/handlers/subagent.ts index 3852469ed..d9102dacc 100644 --- a/src/core/minions/handlers/subagent.ts +++ b/src/core/minions/handlers/subagent.ts @@ -48,6 +48,7 @@ import { logSubagentHeartbeat, } from './subagent-audit.ts'; import { resolveModel, isAnthropicProvider, TIER_DEFAULTS } from '../../model-config.ts'; +import { resolveAnthropicKey } from '../../ai/anthropic-key.ts'; import { buildSystemPrompt, DEFAULT_SUBAGENT_SYSTEM } from '../system-prompt.ts'; import { toolLoop as gatewayToolLoop } from '../../ai/gateway.ts'; import type { ChatToolDef, ChatMessage, ChatBlock, ChatResult, ToolHandler } from '../../ai/gateway.ts'; @@ -186,7 +187,10 @@ export function makeSubagentHandler(deps: SubagentDeps) { // lives at sdk.messages.create. Assigning sdk.messages directly gets the // right object; JS method-call semantics preserve `this` at the call // site (subagent.ts invokes client.create(...) with client === sdk.messages). - const makeAnthropic = deps.makeAnthropic ?? (() => new Anthropic()); + // Resolve the key env-first, then config (anthropic_api_key) — a bare + // new Anthropic() only reads env, so launchd/MCP workers whose key lives + // in the gbrain config file would fail auth (#2048). + const makeAnthropic = deps.makeAnthropic ?? (() => new Anthropic({ apiKey: resolveAnthropicKey() })); const client: MessagesClient = deps.client ?? makeAnthropic().messages; const config = deps.config ?? loadConfig() ?? ({ engine: 'postgres' } as GBrainConfig); const rateLeaseKey = deps.rateLeaseKey ?? DEFAULT_RATE_KEY; diff --git a/test/ai/anthropic-key.test.ts b/test/ai/anthropic-key.test.ts index a43ad7983..a06e9af90 100644 --- a/test/ai/anthropic-key.test.ts +++ b/test/ai/anthropic-key.test.ts @@ -10,7 +10,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { withEnv } from '../helpers/with-env.ts'; -import { hasAnthropicKey } from '../../src/core/ai/anthropic-key.ts'; +import { hasAnthropicKey, resolveAnthropicKey } from '../../src/core/ai/anthropic-key.ts'; const tmpDirs: string[] = []; function freshHome(withConfig?: Record): string { @@ -62,3 +62,35 @@ describe('hasAnthropicKey', () => { ); }); }); + +describe('resolveAnthropicKey (#2048 — subagent config-key auth)', () => { + test('env wins over config', async () => { + const home = freshHome({ anthropic_api_key: 'sk-from-config' }); + await withEnv( + { ANTHROPIC_API_KEY: 'sk-from-env', GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined }, + async () => { + expect(resolveAnthropicKey()).toBe('sk-from-env'); + }, + ); + }); + + test('config key returned when env unset', async () => { + const home = freshHome({ anthropic_api_key: 'sk-from-config' }); + await withEnv( + { ANTHROPIC_API_KEY: undefined, GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined }, + async () => { + expect(resolveAnthropicKey()).toBe('sk-from-config'); + }, + ); + }); + + test('neither → undefined', async () => { + const home = freshHome(); + await withEnv( + { ANTHROPIC_API_KEY: undefined, GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined }, + async () => { + expect(resolveAnthropicKey()).toBeUndefined(); + }, + ); + }); +});