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 <kaitran.ntt@gmail.com>
This commit is contained in:
Wei He
2026-05-04 20:22:13 -04:00
committed by GitHub
co-authored by Tam Nhu Tran
parent 95ad454ed5
commit d6b705e6a9
5 changed files with 136 additions and 10 deletions
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -275,7 +275,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
// sessions can still inherit model intent and other profile-scoped runtime flags.
const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv });
const claudeRuntimeEnvVars: NodeJS.ProcessEnv = {
...stripAnthropicRoutingEnv(settingsRuntimeEnv),
...stripAnthropicRoutingEnv(settingsRuntimeEnv, settingsEnv),
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
+20 -2
View File
@@ -55,14 +55,32 @@ const TMUX_SYNC_ENV_KEYS = [
* Used for nested settings-profile Claude launches where `--settings` already
* defines the provider transport and the parent process should only lend model
* defaults or effort hints.
*
* `preserveFrom`: if provided, routing keys present in this source survive the
* strip (with their values from `preserveFrom`). Settings-type profiles use
* this to keep routing/auth supplied by their own `settings.env` while
* dropping any routing leaked from the parent shell or `global.env`.
*/
export function stripAnthropicRoutingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
export function stripAnthropicRoutingEnv(
env: NodeJS.ProcessEnv,
preserveFrom?: NodeJS.ProcessEnv
): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
for (const key of Object.keys(env)) {
if (!ANTHROPIC_ROUTING_ENV_KEY_SET.has(key.toUpperCase())) {
result[key] = env[key];
}
}
if (preserveFrom) {
for (const key of ANTHROPIC_ROUTING_ENV_KEYS) {
if (
Object.prototype.hasOwnProperty.call(preserveFrom, key) &&
preserveFrom[key] !== undefined
) {
result[key] = preserveFrom[key];
}
}
}
return result;
}
@@ -240,7 +258,7 @@ export function execClaude(
? { ...baseEnv, ...claudeLaunchEnv, ...envVars, ...webSearchEnv }
: { ...baseEnv, ...claudeLaunchEnv, ...webSearchEnv };
const effectiveMergedEnv = stripInheritedAnthropicRoutingEnv
? stripAnthropicRoutingEnv(mergedEnv)
? stripAnthropicRoutingEnv(mergedEnv, envVars ?? undefined)
: mergedEnv;
// Strip Claude Code nested session guard env var to allow CCS delegation
@@ -166,6 +166,7 @@ fi
printf "anthropicBaseUrl=%s\n" "$ANTHROPIC_BASE_URL"
printf "anthropicAuthToken=%s\n" "$ANTHROPIC_AUTH_TOKEN"
printf "anthropicApiKey=%s\n" "$ANTHROPIC_API_KEY"
printf "anthropicApiKeySet=%s\n" "\${ANTHROPIC_API_KEY+x}"
printf "anthropicModel=%s\n" "$ANTHROPIC_MODEL"
printf "anthropicSonnet=%s\n" "$ANTHROPIC_DEFAULT_SONNET_MODEL"
printf "maxOutputTokens=%s\n" "$CLAUDE_CODE_MAX_OUTPUT_TOKENS"
@@ -231,6 +232,7 @@ exit 0
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
ANTHROPIC_AUTH_TOKEN: 'profile-token',
ANTHROPIC_API_KEY: '',
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
@@ -269,9 +271,14 @@ exit 0
expect(result.status).toBe(0);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain('stripAnthropic=1');
expect(launchedEnv).toContain('anthropicBaseUrl=');
expect(launchedEnv).toContain('anthropicAuthToken=');
expect(launchedEnv).toContain('anthropicBaseUrl=https://api.z.ai/api/anthropic');
expect(launchedEnv).toContain('anthropicAuthToken=profile-token');
expect(launchedEnv).toContain('anthropicApiKey=');
expect(launchedEnv).toContain('anthropicApiKeySet=x');
expect(launchedEnv).not.toContain('global-routing-token');
expect(launchedEnv).not.toContain('parent-routing-token');
expect(launchedEnv).not.toContain('global-api-key');
expect(launchedEnv).not.toContain('parent-api-key');
expect(launchedEnv).toContain('anthropicModel=gpt-5.4');
expect(launchedEnv).toContain('anthropicSonnet=gpt-5.4');
expect(launchedEnv).toContain('maxOutputTokens=12345');
@@ -295,6 +295,61 @@ describe('CLAUDECODE environment stripping', () => {
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');