diff --git a/src/ccs.ts b/src/ccs.ts index 1d8c5ad7..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'; @@ -1359,8 +1359,21 @@ 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 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 = { + ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }), + ...(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 +1485,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/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 b6f26ab2..10482f19 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -26,6 +26,71 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } +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. + * 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_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. * @@ -137,24 +202,31 @@ 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) 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 { @@ -164,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/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index 1a343f62..fa63cbc6 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -135,6 +135,13 @@ 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" + printf "maxOutputTokens=%s\n" "$CLAUDE_CODE_MAX_OUTPUT_TOKENS" } > "${claudeEnvLogPath}" exit 0 `, @@ -179,6 +186,69 @@ 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', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', + }, + }, + null, + 2 + ) + '\n' + ); + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + 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 () => { if (process.platform === 'win32') return; diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 5fb99ded..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'; @@ -336,6 +393,97 @@ 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('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('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'; @@ -432,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'); @@ -468,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); });