From 9ae85b82c12c45e73f0819ca33ad5ffd2f3ac323 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Tue, 21 Apr 2026 16:14:10 +0900 Subject: [PATCH 1/5] fix(runtime): strip inherited Anthropic routing env selectively --- src/utils/shell-executor.ts | 40 +++++++++++++++---- .../utils/claudecode-env-stripping.test.ts | 31 ++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index b6f26ab2..3764f484 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -26,6 +26,28 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } +const ANTHROPIC_ROUTING_ENV_KEYS = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', +]); + +/** + * Strip inherited Anthropic routing/auth env while preserving model intent. + * 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. + */ +export function stripAnthropicRoutingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const key of Object.keys(env)) { + if (!ANTHROPIC_ROUTING_ENV_KEYS.has(key)) { + result[key] = env[key]; + } + } + return result; +} + /** * Strip Claude Code nested-session guard env var from a process environment. * @@ -137,14 +159,18 @@ export function execClaude( const webSearchEnv = getWebSearchHookEnv(); const claudeLaunchEnv = getClaudeLaunchEnvOverrides(); - // For account/default profiles, strip ANTHROPIC_* from parent env to prevent - // stale proxy config (e.g., from prior CLIProxy sessions) from interfering - // with native Claude API routing. Settings-based profiles explicitly inject - // their own ANTHROPIC_* values, so they don't need this protection. + // Strip inherited ANTHROPIC_* when the launch should not reuse parent routing. + // Account/default profiles need full isolation from prior proxy sessions. + // Settings profiles can selectively strip only routing/auth when `--settings` + // already carries the provider source of truth but the parent model intent + // should still flow into nested Team/subagent launches. const profileType = envVars?.CCS_PROFILE_TYPE; - const baseEnv = - profileType === 'account' || profileType === 'default' - ? stripAnthropicEnv(process.env) + const stripInheritedAnthropicEnv = profileType === 'account' || profileType === 'default'; + const stripInheritedAnthropicRoutingEnv = envVars?.CCS_STRIP_INHERITED_ANTHROPIC_ENV === '1'; + const baseEnv = stripInheritedAnthropicEnv + ? stripAnthropicEnv(process.env) + : stripInheritedAnthropicRoutingEnv + ? stripAnthropicRoutingEnv(process.env) : process.env; // Prepare environment (merge with base env if envVars provided) diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 5fb99ded..96ca54df 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -336,6 +336,37 @@ describe('CLAUDECODE environment stripping', () => { expect(normalizeSpy).toHaveBeenCalledWith(instancePath); }); + it('execClaude strips inherited ANTHROPIC routing env but keeps model intent for settings-profile Claude launches', () => { + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex'; + process.env.ANTHROPIC_AUTH_TOKEN = 'ccs-internal-managed'; + process.env.ANTHROPIC_API_KEY = 'stale-api-key'; + process.env.ANTHROPIC_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'gpt-5.4-mini'; + process.env.ANTHROPIC_SMALL_FAST_MODEL = 'gpt-5-codex-mini'; + + execClaude('claude', ['--help'], { + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'), + CCS_WEBSEARCH_SKIP: '1', + }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.CCS_PROFILE_TYPE).toBe('settings'); + expect(env.CLAUDE_CONFIG_DIR).toContain('ccs-settings-profile-instance'); + expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); + expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5-codex-mini'); + }); + it('headless executor spawn path strips CLAUDECODE before spawn', async () => { writeConfigWithAutoUpdatePreference(false); process.env.CLAUDECODE = 'nested'; From 5881a505d9b824aa9c7f8a9fb06d7122a94325d5 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Tue, 21 Apr 2026 16:14:23 +0900 Subject: [PATCH 2/5] fix(settings-profile): pass selective Anthropic env stripping to Claude launches --- src/ccs.ts | 18 ++++++-- .../settings-profile-browser-launch.test.ts | 44 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/ccs.ts b/src/ccs.ts index 1d8c5ad7..8832e6d2 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1359,8 +1359,20 @@ async function main(): Promise { console.error(info(`Global env: ${envNames}`)); } - // Explicitly inject effective settings env vars so stale ANTHROPIC_* - // values from prior sessions cannot leak into the active profile. + // For Claude target launches that already pass `--settings`, keep runtime + // env limited to CCS-managed flags so nested Team/subagent sessions do not + // inherit profile-specific ANTHROPIC_* routing from the parent process. + const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { + ...globalEnv, + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + ...webSearchEnv, + ...imageAnalysisEnv, + ...(browserRuntimeEnv || {}), + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + }; + + // Non-Claude targets still need effective credentials injected directly. const envVars: NodeJS.ProcessEnv = { ...globalEnv, ...settingsEnv, @@ -1472,7 +1484,7 @@ async function main(): Promise { settingsPath: expandedSettingsPath, }); - execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv }); + execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv }); } else if (profileInfo.type === 'account') { // NEW FLOW: Account-based profile (work, personal) // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index 1a343f62..df9a2f22 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -135,6 +135,12 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" + printf "stripAnthropic=%s\n" "$CCS_STRIP_INHERITED_ANTHROPIC_ENV" + printf "anthropicBaseUrl=%s\n" "$ANTHROPIC_BASE_URL" + printf "anthropicAuthToken=%s\n" "$ANTHROPIC_AUTH_TOKEN" + printf "anthropicApiKey=%s\n" "$ANTHROPIC_API_KEY" + printf "anthropicModel=%s\n" "$ANTHROPIC_MODEL" + printf "anthropicSonnet=%s\n" "$ANTHROPIC_DEFAULT_SONNET_MODEL" } > "${claudeEnvLogPath}" exit 0 `, @@ -179,6 +185,44 @@ exit 0 expect(fs.existsSync(claudeArgsLogPath)).toBe(true); }); + it('passes selective Anthropic env stripping to settings-profile Claude launches while preserving model defaults', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'profile-token', + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + }, + }, + null, + 2 + ) + '\n' + ); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'parent-routing-token', + ANTHROPIC_API_KEY: 'parent-api-key', + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + }); + + 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('anthropicApiKey='); + expect(launchedEnv).toContain('anthropicModel=gpt-5.4'); + expect(launchedEnv).toContain('anthropicSonnet=gpt-5.4'); + }); + it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => { if (process.platform === 'win32') return; From 1fd2fb83fbf08ef9ef9a03fb69d9c6cea5f8eb37 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Tue, 21 Apr 2026 16:52:19 +0900 Subject: [PATCH 3/5] fix(runtime): strip reintroduced Anthropic routing env Prevent settings-profile Claude launches from reintroducing routing/auth env after the final env merge. - re-strip ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, and ANTHROPIC_API_KEY after merging envVars - preserve model-selection env such as ANTHROPIC_MODEL and ANTHROPIC_DEFAULT_SONNET_MODEL - add a regression test covering explicit settings-profile overrides that try to reintroduce routing env Verification: - bun test tests/unit/utils/claudecode-env-stripping.test.ts - bun run validate --- src/utils/shell-executor.ts | 5 ++++- .../utils/claudecode-env-stripping.test.ts | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 3764f484..e6183349 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -177,10 +177,13 @@ export function execClaude( const mergedEnv = envVars ? { ...baseEnv, ...claudeLaunchEnv, ...envVars, ...webSearchEnv } : { ...baseEnv, ...claudeLaunchEnv, ...webSearchEnv }; + const effectiveMergedEnv = stripInheritedAnthropicRoutingEnv + ? stripAnthropicRoutingEnv(mergedEnv) + : mergedEnv; // Strip Claude Code nested session guard env var to allow CCS delegation // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) - const env = stripClaudeCodeEnv(mergedEnv); + const env = stripClaudeCodeEnv(effectiveMergedEnv); if (profileType !== 'account') { try { diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 96ca54df..afed6472 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -367,6 +367,26 @@ 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', () => { + execClaude('claude', ['--help'], { + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'reintroduced-routing-token', + ANTHROPIC_API_KEY: 'reintroduced-api-key', + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + }); + + 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_MODEL).toBe('gpt-5.4'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); + }); + it('headless executor spawn path strips CLAUDECODE before spawn', async () => { writeConfigWithAutoUpdatePreference(false); process.env.CLAUDECODE = 'nested'; From 2191f0a6c7ee9121bd28da6b56d58d9f50e119cc Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Tue, 21 Apr 2026 16:56:54 +0900 Subject: [PATCH 4/5] fix(settings-profile): preserve non-routing env for Claude launches Keep settings-profile Claude launches aligned with profile-scoped non-routing env while still stripping routing/auth values. - build claudeRuntimeEnvVars from filtered global + settings env - preserve CLAUDE_CODE_MAX_OUTPUT_TOKENS and model defaults for nested settings-profile launches - extend browser launch regression coverage to prove global_env cannot reintroduce routing/auth values --- src/ccs.ts | 9 +-- .../settings-profile-browser-launch.test.ts | 58 ++++++++++++++----- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/ccs.ts b/src/ccs.ts index 8832e6d2..a908fce3 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -80,7 +80,7 @@ import { handleError, runCleanup } from './errors'; import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions -import { execClaude } from './utils/shell-executor'; +import { execClaude, stripAnthropicRoutingEnv } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger } from './services/logging'; @@ -1360,10 +1360,11 @@ async function main(): Promise { } // For Claude target launches that already pass `--settings`, keep runtime - // env limited to CCS-managed flags so nested Team/subagent sessions do not - // inherit profile-specific ANTHROPIC_* routing from the parent process. + // env free of ANTHROPIC routing/auth while preserving non-routing profile + // env so nested Team/subagent sessions can still inherit model intent and + // other profile-scoped runtime flags. const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { - ...globalEnv, + ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }), ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index df9a2f22..fa63cbc6 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -141,6 +141,7 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "anthropicApiKey=%s\n" "$ANTHROPIC_API_KEY" printf "anthropicModel=%s\n" "$ANTHROPIC_MODEL" printf "anthropicSonnet=%s\n" "$ANTHROPIC_DEFAULT_SONNET_MODEL" + printf "maxOutputTokens=%s\n" "$CLAUDE_CODE_MAX_OUTPUT_TOKENS" } > "${claudeEnvLogPath}" exit 0 `, @@ -197,6 +198,7 @@ exit 0 ANTHROPIC_AUTH_TOKEN: 'profile-token', ANTHROPIC_MODEL: 'gpt-5.4', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', }, }, null, @@ -204,23 +206,47 @@ exit 0 ) + '\n' ); - const result = runCcs(['glm', 'smoke'], { - ...baseEnv, - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', - ANTHROPIC_AUTH_TOKEN: 'parent-routing-token', - ANTHROPIC_API_KEY: 'parent-api-key', - ANTHROPIC_MODEL: 'gpt-5.4', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', - }); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; - 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('anthropicApiKey='); - expect(launchedEnv).toContain('anthropicModel=gpt-5.4'); - expect(launchedEnv).toContain('anthropicSonnet=gpt-5.4'); + try { + mutateUnifiedConfig((config) => { + config.global_env = { + enabled: true, + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:9999/api/provider/global', + ANTHROPIC_AUTH_TOKEN: 'global-routing-token', + ANTHROPIC_API_KEY: 'global-api-key', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '54321', + }, + }; + }); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'parent-routing-token', + ANTHROPIC_API_KEY: 'parent-api-key', + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + }); + + 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('anthropicApiKey='); + expect(launchedEnv).toContain('anthropicModel=gpt-5.4'); + expect(launchedEnv).toContain('anthropicSonnet=gpt-5.4'); + expect(launchedEnv).toContain('maxOutputTokens=12345'); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } }); it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => { From f78c30ac50cb2351a77b840c483ecb94c6a9ab6d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 22:09:08 -0400 Subject: [PATCH 5/5] fix(runtime): isolate nested Anthropic env across delegation paths --- src/delegation/headless-executor.ts | 53 ++++- src/utils/shell-executor.ts | 66 ++++-- .../utils/claudecode-env-stripping.test.ts | 191 +++++++++++++++++- 3 files changed, 286 insertions(+), 24 deletions(-) diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 569302f2..76e4bcdd 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -16,8 +16,13 @@ import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from import { StreamBuffer, formatToolVerbose } from './executor/stream-parser'; import { buildExecutionResult } from './executor/result-aggregator'; import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager'; +import { getGlobalEnvConfig } from '../config/unified-config-loader'; import { getProfileLookupCandidates } from '../utils/profile-compat'; -import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor'; +import { + getClaudeLaunchEnvOverrides, + stripAnthropicRoutingEnv, + stripClaudeCodeEnv, +} from '../utils/shell-executor'; import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; import { appendThirdPartyImageAnalysisToolArgs, @@ -38,6 +43,11 @@ import { import { resolveCliproxyBridgeMetadata } from '../api/services'; import { ensureCliproxyService } from '../cliproxy'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; +import { + buildOpenAICompatProxyEnv, + resolveOpenAICompatProfileConfig, + startOpenAICompatProxy, +} from '../proxy'; import { appendThirdPartyWebSearchToolArgs, appendWebSearchTrace, @@ -129,6 +139,14 @@ export class HeadlessExecutor { syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); const settings = loadSettings(settingsPath); + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + const settingsEnv = settings.env || {}; + const openAICompatProfile = resolveOpenAICompatProfileConfig( + profile, + settingsPath, + settingsEnv + ); const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); let imageAnalysisFallbackHookReady: boolean | undefined; if (imageAnalysisMcpReady) { @@ -207,6 +225,33 @@ export class HeadlessExecutor { } } + let runtimeEnvVars: NodeJS.ProcessEnv = { + ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }), + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + }; + + if (openAICompatProfile) { + const proxyStart = await startOpenAICompatProxy(openAICompatProfile, { + insecure: openAICompatProfile.insecure, + }); + if (!proxyStart.success) { + throw new Error(proxyStart.error || 'Failed to start local OpenAI-compatible proxy'); + } + + runtimeEnvVars = { + ...runtimeEnvVars, + ...buildOpenAICompatProxyEnv( + openAICompatProfile, + proxyStart.port, + proxyStart.authToken || '', + inheritedClaudeConfigDir + ), + }; + delete runtimeEnvVars.ANTHROPIC_API_KEY; + } + // Smart slash command detection and preservation const processedPrompt = this._processSlashCommand(enhancedPrompt); @@ -321,6 +366,7 @@ export class HeadlessExecutor { sessionMgr, claudeConfigDir: inheritedClaudeConfigDir, imageAnalysisEnv, + runtimeEnvVars, traceEnv, }); } @@ -340,6 +386,7 @@ export class HeadlessExecutor { sessionMgr: SessionManager; claudeConfigDir?: string; imageAnalysisEnv?: Record; + runtimeEnvVars?: NodeJS.ProcessEnv; traceEnv?: Record; } ): Promise { @@ -352,6 +399,7 @@ export class HeadlessExecutor { sessionMgr, claudeConfigDir, imageAnalysisEnv = {}, + runtimeEnvVars = {}, traceEnv = {}, } = ctx; @@ -368,9 +416,10 @@ export class HeadlessExecutor { // Strip Claude Code nested session guard env var to allow CCS delegation // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) const cleanEnv = stripClaudeCodeEnv({ - ...process.env, + ...stripAnthropicRoutingEnv(process.env), ...getClaudeLaunchEnvOverrides(), ...getWebSearchHookEnv(), + ...runtimeEnvVars, ...imageAnalysisEnv, ...traceEnv, ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index e6183349..10482f19 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -26,11 +26,28 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } -const ANTHROPIC_ROUTING_ENV_KEYS = new Set([ +const ANTHROPIC_ROUTING_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', -]); +]; +const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS); +const ANTHROPIC_MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_SMALL_FAST_MODEL', +]; +const TMUX_SYNC_ENV_KEYS = [ + 'CLAUDE_CONFIG_DIR', + 'CCS_PROFILE_TYPE', + 'CCS_WEBSEARCH_SKIP', + 'CCS_STRIP_INHERITED_ANTHROPIC_ENV', + 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', + ...ANTHROPIC_MODEL_ENV_KEYS, + ...ANTHROPIC_ROUTING_ENV_KEYS, +]; /** * Strip inherited Anthropic routing/auth env while preserving model intent. @@ -41,13 +58,39 @@ const ANTHROPIC_ROUTING_ENV_KEYS = new Set([ export function stripAnthropicRoutingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const result: NodeJS.ProcessEnv = {}; for (const key of Object.keys(env)) { - if (!ANTHROPIC_ROUTING_ENV_KEYS.has(key)) { + if (!ANTHROPIC_ROUTING_ENV_KEY_SET.has(key.toUpperCase())) { result[key] = env[key]; } } return result; } +function syncTmuxNestedSessionEnv(env: NodeJS.ProcessEnv, profileType: string | undefined): void { + if (!process.env.TMUX) { + return; + } + + const nestedSessionEnv = + profileType === 'account' || profileType === 'default' + ? stripAnthropicEnv(env) + : profileType === 'settings' + ? stripAnthropicRoutingEnv(env) + : env; + + for (const key of TMUX_SYNC_ENV_KEYS) { + try { + const value = nestedSessionEnv[key]; + if (value !== undefined) { + spawnSync('tmux', ['setenv', key, value], { stdio: 'ignore' }); + } else { + spawnSync('tmux', ['setenv', '-u', key], { stdio: 'ignore' }); + } + } catch { + // tmux setenv can fail if not in a tmux session; safe to ignore + } + } +} + /** * Strip Claude Code nested-session guard env var from a process environment. * @@ -193,20 +236,9 @@ export function execClaude( } } - // propagate key env vars to tmux session so agent team teammates - // (spawned via tmux split-window) inherit the correct config dir - if (process.env.TMUX && envVars) { - const tmuxPropagateVars = ['CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', 'CCS_WEBSEARCH_SKIP']; - for (const key of tmuxPropagateVars) { - if (envVars[key]) { - try { - spawnSync('tmux', ['setenv', key, envVars[key] ?? ''], { stdio: 'ignore' }); - } catch { - // tmux setenv can fail if not in a tmux session; safe to ignore - } - } - } - } + // Keep tmux teammate panes aligned with the nested-safe Claude runtime env + // rather than the tmux server's original shell environment. + syncTmuxNestedSessionEnv(env, profileType); let child: ChildProcess; if (isPowerShellScript) { diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index afed6472..a4212c83 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -21,8 +21,16 @@ type SpawnCall = { options: Record | undefined; }; -const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches'; +type SpawnSyncCall = { + command: string; + args: string[]; + options: Record | undefined; +}; + +const STEERING_PROMPT_SNIPPET = + 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches'; const spawnCalls: SpawnCall[] = []; +const spawnSyncCalls: SpawnSyncCall[] = []; const originalPlatform = process.platform; let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; @@ -31,6 +39,7 @@ let originalCcsHome: string | undefined; let originalCcsClaudePath: string | undefined; let originalDisableAutoUpdater: string | undefined; let originalClaudeConfigDir: string | undefined; +let originalTmux: string | undefined; const realSpawn = childProcess.spawn.bind(childProcess); const realSpawnSync = childProcess.spawnSync.bind(childProcess); const realExecSync = childProcess.execSync.bind(childProcess); @@ -103,6 +112,18 @@ function registerChildProcessMock(): void { | Record | undefined; + if (command === 'tmux') { + spawnSyncCalls.push({ command, args, options }); + return { + pid: process.pid, + output: ['', '', ''], + stdout: '', + stderr: '', + status: 0, + signal: null, + }; + } + return realSpawnSync(command, args, options as Parameters[2]); }, execSync: (...execArgs: unknown[]) => @@ -140,6 +161,7 @@ ${yamlBody} } let execClaude: typeof import('../../../src/utils/shell-executor').execClaude; +let stripAnthropicRoutingEnv: typeof import('../../../src/utils/shell-executor').stripAnthropicRoutingEnv; let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv; let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor; let SharedManager: typeof import('../../../src/management/shared-manager').default; @@ -149,6 +171,7 @@ beforeAll(async () => { const shellExecutor = await import('../../../src/utils/shell-executor'); execClaude = shellExecutor.execClaude; + stripAnthropicRoutingEnv = shellExecutor.stripAnthropicRoutingEnv; stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv; const sharedManagerModule = await import('../../../src/management/shared-manager'); @@ -165,6 +188,7 @@ afterAll(() => { describe('CLAUDECODE environment stripping', () => { beforeEach(() => { spawnCalls.length = 0; + spawnSyncCalls.length = 0; process.env.CCS_QUIET = '1'; // Save original env values for restoration in afterEach @@ -172,10 +196,12 @@ describe('CLAUDECODE environment stripping', () => { originalCcsClaudePath = process.env.CCS_CLAUDE_PATH; originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER; originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalTmux = process.env.TMUX; // Clear CCS-managed env vars that leak from host sessions delete process.env.DISABLE_AUTOUPDATER; delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.TMUX; baselineSigintListeners = process.listeners('SIGINT'); baselineSigtermListeners = process.listeners('SIGTERM'); @@ -197,8 +223,20 @@ describe('CLAUDECODE environment stripping', () => { } else { delete process.env.DISABLE_AUTOUPDATER; } - if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + if (originalClaudeConfigDir !== undefined) + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; else delete process.env.CLAUDE_CONFIG_DIR; + if (originalTmux !== undefined) process.env.TMUX = originalTmux; + else delete process.env.TMUX; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_MODEL; + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL; + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL; + delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + delete process.env.ANTHROPIC_SMALL_FAST_MODEL; + delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS; for (const listener of process.listeners('SIGINT')) { if (!baselineSigintListeners.includes(listener)) { @@ -230,6 +268,25 @@ describe('CLAUDECODE environment stripping', () => { expect(result.PATH).toBe('/usr/bin'); }); + 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', + Anthropic_Auth_Token: 'parent-routing-token', + ANTHROPIC_API_KEY: 'parent-api-key', + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + PATH: '/usr/bin', + }; + + const result = stripAnthropicRoutingEnv(input); + expect(result.anthropic_base_url).toBeUndefined(); + expect(result.Anthropic_Auth_Token).toBeUndefined(); + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); + expect(result.PATH).toBe('/usr/bin'); + }); + it('execClaude strips CLAUDECODE from merged env (including overrides)', () => { process.env.CLAUDECODE = 'from-parent'; process.env.claudecode = 'from-parent-lower'; @@ -387,6 +444,46 @@ describe('CLAUDECODE environment stripping', () => { expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); }); + it('execClaude sanitizes tmux teammate env for bridge-backed settings launches while keeping the launched child on the runtime proxy', () => { + process.env.TMUX = 'session-1'; + 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'; + process.env.ANTHROPIC_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4'; + + execClaude('claude', ['--help'], { + CCS_PROFILE_TYPE: 'settings', + CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'), + ANTHROPIC_BASE_URL: 'http://127.0.0.1:3456', + ANTHROPIC_AUTH_TOKEN: 'fresh-runtime-token', + ANTHROPIC_MODEL: 'gpt-5.4', + }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const childEnv = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(childEnv.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:3456'); + expect(childEnv.ANTHROPIC_AUTH_TOKEN).toBe('fresh-runtime-token'); + + const unsetBaseUrlCall = spawnSyncCalls.find( + (call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_BASE_URL' + ); + const unsetAuthTokenCall = spawnSyncCalls.find( + (call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_AUTH_TOKEN' + ); + const modelCall = spawnSyncCalls.find( + (call) => + call.command === 'tmux' && + call.args[0] === 'setenv' && + call.args[1] === 'ANTHROPIC_MODEL' && + call.args[2] === 'gpt-5.4' + ); + + expect(unsetBaseUrlCall).toBeDefined(); + expect(unsetAuthTokenCall).toBeDefined(); + expect(modelCall).toBeDefined(); + }); + it('headless executor spawn path strips CLAUDECODE before spawn', async () => { writeConfigWithAutoUpdatePreference(false); process.env.CLAUDECODE = 'nested'; @@ -483,6 +580,92 @@ describe('CLAUDECODE environment stripping', () => { }); }); + it('headless executor strips inherited routing env for settings-profile delegation while preserving model intent', async () => { + writeConfigWithAutoUpdatePreference(false); + const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); + fs.writeFileSync( + path.join(ccsDir, 'glm.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + const projectDir = path.join(ccsDir, 'project-headless-settings'); + 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'; + process.env.ANTHROPIC_MODEL = 'gpt-5.4'; + + const result = await HeadlessExecutor.execute('glm', '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).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); + 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'); + fs.writeFileSync( + path.join(ccsDir, 'bridge.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'settings-bridge-token', + ANTHROPIC_MODEL: 'gpt-5.4', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + const projectDir = path.join(ccsDir, 'project-headless-bridge'); + 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('bridge', '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).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(env.ANTHROPIC_BASE_URL).not.toBe('http://127.0.0.1:8317/api/provider/codex'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).not.toBe('parent-routing-token'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345'); + }); + it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => { writeConfigWithAutoUpdatePreference(false); const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); @@ -519,9 +702,7 @@ describe('CLAUDECODE environment stripping', () => { args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], env: {}, }); - expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe( - false - ); + expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(false); expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false); });