mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user