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.
This commit is contained in:
Garry Tan
2026-07-02 08:02:24 -07:00
parent 814258dda6
commit 4f8d818de6
2 changed files with 45 additions and 1 deletions
+13 -1
View File
@@ -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 !== ''),
),
},
};
}
+32
View File
@@ -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');
},
);
});
});