From d6b705e6a9e4215c45721f929f69a2680c1eb725 Mon Sep 17 00:00:00 2001 From: Wei He Date: Mon, 4 May 2026 20:22:13 -0400 Subject: [PATCH] fix(dispatcher): re-inject anthropic auth env for anthropic-compatible api profiles (#1181) * fix(dispatcher): preserve anthropic auth env for settings profiles on non-proxy path API profiles whose `ANTHROPIC_BASE_URL` is classified as `'anthropic'` (anthropic.com, paths containing `/anthropic`, ollama.com) skip the local OpenAI-compat proxy. The non-proxy launch path stripped `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_API_KEY` from the subprocess env without re-injecting them, so Claude Code launched with no routing/auth in `process.env` and failed with `Not logged in - Please run /login`. The `--settings` env block does not satisfy Claude Code's auth check. Pre-existing for `anthropic.com` and `/anthropic` profiles. Newly broken in v7.77.0 for Ollama Cloud profiles - PR #1175 reclassified `ollama.com` from `generic-chat-completion-api` to `anthropic`, moving it from the proxy path onto this broken non-proxy path. Fix by extending `stripAnthropicRoutingEnv` with an optional `preserveFrom` parameter. Routing keys present in `preserveFrom` survive the strip (with values from `preserveFrom`). Settings-type profiles pass their own `settings.env` as the preserve source so routing they explicitly supplied is kept while routing leaked from the parent shell or `global.env` is dropped. Wired into both call sites: - `headless-executor.ts` (the `-p` headless executor) - `settings-flow.ts` (the interactive flow, which then calls `execClaude` - whose own `stripAnthropicRoutingEnv` pass on the merged env now also takes `envVars` as the preserve source so the caller-supplied routing is not stripped a second time before spawn) Native Anthropic / Bedrock / Vertex profiles are unaffected (they don't put routing keys in `settings.env`). The OpenAI-compat proxy path is unaffected because `buildOpenAICompatProxyEnv` overrides `BASE_URL` / `AUTH_TOKEN` with localhost values and explicitly deletes `API_KEY` after this strip. Replaces the Apr 21 defensive double-strip test (which was the mechanism causing this regression on the interactive path) with a test asserting the new contract: caller-supplied routing in `envVars` survives, parent-process routing is still stripped. * test: harden anthropic settings env preservation coverage * fix: preserve explicit blank anthropic routing env --------- Co-authored-by: Tam Nhu Tran --- src/delegation/headless-executor.ts | 2 +- src/dispatcher/flows/settings-flow.ts | 2 +- src/utils/shell-executor.ts | 22 +++- .../settings-profile-browser-launch.test.ts | 11 +- .../utils/claudecode-env-stripping.test.ts | 109 +++++++++++++++++- 5 files changed, 136 insertions(+), 10 deletions(-) diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 2ceeaa62..bc3a0f55 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -228,7 +228,7 @@ export class HeadlessExecutor { } let runtimeEnvVars: NodeJS.ProcessEnv = { - ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }), + ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }, settingsEnv), ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), CCS_PROFILE_TYPE: 'settings', CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', diff --git a/src/dispatcher/flows/settings-flow.ts b/src/dispatcher/flows/settings-flow.ts index f54bbb41..07674e54 100644 --- a/src/dispatcher/flows/settings-flow.ts +++ b/src/dispatcher/flows/settings-flow.ts @@ -275,7 +275,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise { expect(result.PATH).toBe('/usr/bin'); }); + it('stripAnthropicRoutingEnv preserves routing/auth keys from preserveFrom on the non-proxy settings path', () => { + const settingsEnv: NodeJS.ProcessEnv = { + ANTHROPIC_BASE_URL: 'https://ollama.com', + ANTHROPIC_AUTH_TOKEN: 'profile-token', + ANTHROPIC_API_KEY: 'profile-api-key', + ANTHROPIC_MODEL: 'minimax-m2.7:cloud', + OTHER: 'keep', + }; + const globalEnv: NodeJS.ProcessEnv = { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:9999', + ANTHROPIC_AUTH_TOKEN: 'global-stale', + }; + + const merged = stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }, settingsEnv); + + expect(merged.ANTHROPIC_BASE_URL).toBe('https://ollama.com'); + expect(merged.ANTHROPIC_AUTH_TOKEN).toBe('profile-token'); + expect(merged.ANTHROPIC_API_KEY).toBe('profile-api-key'); + expect(merged.ANTHROPIC_MODEL).toBe('minimax-m2.7:cloud'); + expect(merged.OTHER).toBe('keep'); + }); + + it('stripAnthropicRoutingEnv with empty preserveFrom strips all routing keys', () => { + const result = stripAnthropicRoutingEnv( + { + ANTHROPIC_BASE_URL: 'http://inherited', + ANTHROPIC_AUTH_TOKEN: 'inherited-token', + ANTHROPIC_MODEL: 'claude-opus-4-7', + }, + { ANTHROPIC_MODEL: 'claude-opus-4-7' } + ); + expect(result.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-7'); + }); + + it('stripAnthropicRoutingEnv preserveFrom only emits keys present in preserveFrom', () => { + const env = { + ANTHROPIC_BASE_URL: 'http://inherited', + ANTHROPIC_AUTH_TOKEN: 'inherited-token', + }; + expect(stripAnthropicRoutingEnv(env, { ANTHROPIC_BASE_URL: 'https://ollama.com' })).toEqual({ + ANTHROPIC_BASE_URL: 'https://ollama.com', + }); + expect(stripAnthropicRoutingEnv(env, { ANTHROPIC_AUTH_TOKEN: 'profile-token' })).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'profile-token', + }); + expect(stripAnthropicRoutingEnv(env, { ANTHROPIC_API_KEY: 'profile-api-key' })).toEqual({ + ANTHROPIC_API_KEY: 'profile-api-key', + }); + expect(stripAnthropicRoutingEnv(env, { ANTHROPIC_BASE_URL: '' })).toEqual({ + ANTHROPIC_BASE_URL: '', + }); + }); + it('stripAnthropicRoutingEnv removes routing/auth env case-insensitively while preserving model vars', () => { const input: NodeJS.ProcessEnv = { anthropic_base_url: 'http://127.0.0.1:8317/api/provider/codex', @@ -451,7 +506,10 @@ describe('CLAUDECODE environment stripping', () => { expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5-codex-mini'); }); - it('execClaude strips routing env reintroduced by explicit settings-profile overrides', () => { + it('execClaude preserves routing env supplied via explicit settings-profile envVars', () => { + process.env.ANTHROPIC_BASE_URL = 'http://parent-leaked'; + process.env.ANTHROPIC_AUTH_TOKEN = 'parent-leaked-token'; + execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'settings', CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', @@ -464,9 +522,9 @@ describe('CLAUDECODE environment stripping', () => { expect(spawnCalls.length).toBeGreaterThan(0); const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; - expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); - expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); - expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('reintroduced-routing-token'); + expect(env.ANTHROPIC_API_KEY).toBe('reintroduced-api-key'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); }); @@ -650,6 +708,49 @@ describe('CLAUDECODE environment stripping', () => { expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345'); }); + it('headless executor preserves profile routing env for non-proxy settings-profile delegation', async () => { + writeConfigWithAutoUpdatePreference(false); + const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); + fs.writeFileSync( + path.join(ccsDir, 'ollama-cloud.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://ollama.com', + ANTHROPIC_AUTH_TOKEN: 'settings-ollama-token', + ANTHROPIC_API_KEY: '', + ANTHROPIC_MODEL: 'gemma4:31b-cloud', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + const projectDir = path.join(ccsDir, 'project-headless-ollama-cloud'); + fs.mkdirSync(projectDir, { recursive: true }); + process.env.CCS_CLAUDE_PATH = 'claude'; + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex'; + process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token'; + process.env.ANTHROPIC_API_KEY = 'parent-api-key'; + + const result = await HeadlessExecutor.execute('ollama-cloud', 'latest AI chip news', { + cwd: projectDir, + permissionMode: 'default', + timeout: 1000, + }); + + expect(result.success).toBe(true); + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.ANTHROPIC_BASE_URL).toBe('https://ollama.com'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('settings-ollama-token'); + expect(env.ANTHROPIC_API_KEY).toBe(''); + expect(env.ANTHROPIC_MODEL).toBe('gemma4:31b-cloud'); + expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345'); + }); + it('headless executor rebuilds OpenAI-compatible bridge env from settings instead of inheriting stale parent routing', async () => { writeConfigWithAutoUpdatePreference(false); const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');