From 4f8d818de63a02ffba7ae356b5936e63a0cc13b3 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 2 Jul 2026 08:02:24 -0700 Subject: [PATCH] fix(ai): drop empty-string env values before merge so they can't clobber config keys (#1249) Claude Code injects ANTHROPIC_API_KEY='' to neuter subprocess LLM calls; an unconditional process.env spread let that empty string override a valid config.json key, breaking every gateway op with NO_ANTHROPIC_API_KEY. Filter '' / undefined before the merge; '0' and 'false' are preserved. --- src/core/ai/build-gateway-config.ts | 14 +++++++++++- test/ai/build-gateway-config.test.ts | 32 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/core/ai/build-gateway-config.ts b/src/core/ai/build-gateway-config.ts index c2b3f84a9..1b5eb9613 100644 --- a/src/core/ai/build-gateway-config.ts +++ b/src/core/ai/build-gateway-config.ts @@ -61,6 +61,18 @@ export function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig { chat_model: c.chat_model, chat_fallback_chain: c.chat_fallback_chain, base_urls: { ...envBaseUrls, ...(c.provider_base_urls ?? {}) }, // config wins over env - env: { ...envFromConfig, ...process.env }, // process.env wins + // #1249: process.env still wins over the config-plane fallback, BUT only for + // keys that carry a real value. Claude Code (and some launchers) inject + // ANTHROPIC_API_KEY='' to neuter subprocess LLM calls; an unconditional + // `...process.env` lets that empty string clobber a valid config.json key, so + // every gateway op then throws NO_ANTHROPIC_API_KEY. Drop empty-string / + // undefined entries before the merge. Only '' and undefined are dropped — + // '0' and 'false' are legitimate values and survive. + env: { + ...envFromConfig, + ...Object.fromEntries( + Object.entries(process.env).filter(([, v]) => v !== undefined && v !== ''), + ), + }, }; } diff --git a/test/ai/build-gateway-config.test.ts b/test/ai/build-gateway-config.test.ts index 72bd146d1..5284c5bc8 100644 --- a/test/ai/build-gateway-config.test.ts +++ b/test/ai/build-gateway-config.test.ts @@ -85,3 +85,35 @@ describe('buildGatewayConfig env-baseURL passthrough', () => { ); }); }); + +describe('buildGatewayConfig env empty-string clobber guard (#1249)', () => { + test('an empty-string process.env value does NOT clobber a valid config-plane key', async () => { + // Claude Code injects ANTHROPIC_API_KEY='' to neuter subprocess LLM calls. + await withEnv({ ANTHROPIC_API_KEY: '' }, async () => { + const cfg = buildGatewayConfig({ + anthropic_api_key: 'sk-config-plane', + } as unknown as GBrainConfig); + expect(cfg.env.ANTHROPIC_API_KEY).toBe('sk-config-plane'); + }); + }); + + test('a real process.env value still wins over the config-plane fallback', async () => { + await withEnv({ ANTHROPIC_API_KEY: 'sk-env-plane' }, async () => { + const cfg = buildGatewayConfig({ + anthropic_api_key: 'sk-config-plane', + } as unknown as GBrainConfig); + expect(cfg.env.ANTHROPIC_API_KEY).toBe('sk-env-plane'); + }); + }); + + test("legitimate falsy-but-present values ('0' / 'false') are preserved, not dropped", async () => { + await withEnv( + { GBRAIN_TEST_ZERO_VAL: '0', GBRAIN_TEST_FALSE_VAL: 'false' }, + async () => { + const cfg = buildGatewayConfig(baseConfig); + expect(cfg.env.GBRAIN_TEST_ZERO_VAL).toBe('0'); + expect(cfg.env.GBRAIN_TEST_FALSE_VAL).toBe('false'); + }, + ); + }); +});