diff --git a/src/ccs.ts b/src/ccs.ts index 0d735973..09b94e34 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -44,7 +44,12 @@ import { handleShellCompletionCommand } from './commands/shell-completion-comman import { handleUpdateCommand } from './commands/update-command'; // Import extracted utility functions -import { execClaude, escapeShellArg, stripClaudeCodeEnv } from './utils/shell-executor'; +import { + execClaude, + escapeShellArg, + stripClaudeCodeEnv, + getClaudeLaunchEnvOverrides, +} from './utils/shell-executor'; import { wireChildProcessSignals } from './utils/signal-forwarder'; // Import target adapter system @@ -210,8 +215,10 @@ async function execClaudeWithProxy( const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli); const webSearchEnv = getWebSearchHookEnv(); const imageAnalysisEnv = getImageAnalysisHookEnv(profileName); + const claudeLaunchEnv = getClaudeLaunchEnvOverrides(); const env = stripClaudeCodeEnv({ ...process.env, + ...claudeLaunchEnv, ...envVars, ...webSearchEnv, ...imageAnalysisEnv, diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index a4695560..85265534 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -17,7 +17,7 @@ import { StreamBuffer, formatToolVerbose } from './executor/stream-parser'; import { buildExecutionResult } from './executor/result-aggregator'; import { getCcsDir, getModelDisplayName } from '../utils/config-manager'; import { getProfileLookupCandidates } from '../utils/profile-compat'; -import { stripClaudeCodeEnv } from '../utils/shell-executor'; +import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor'; // Re-export types for consumers export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types'; @@ -210,7 +210,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); + const cleanEnv = stripClaudeCodeEnv({ + ...process.env, + ...getClaudeLaunchEnvOverrides(), + }); const proc = spawn(claudeCli, args, { cwd, diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index d8ea89d3..0d9cbd45 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -8,6 +8,7 @@ import { spawn, spawnSync, ChildProcess } from 'child_process'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; import { wireChildProcessSignals } from './signal-forwarder'; +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; /** * Strip ANTHROPIC_* env vars from an environment object. @@ -40,6 +41,22 @@ export function stripClaudeCodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } +/** + * Resolve CCS-managed environment overrides for Claude launch. + * - preferences.auto_update: false -> DISABLE_AUTOUPDATER=1 + */ +export function getClaudeLaunchEnvOverrides(): NodeJS.ProcessEnv { + try { + const config = loadOrCreateUnifiedConfig(); + if (config.preferences?.auto_update === false) { + return { DISABLE_AUTOUPDATER: '1' }; + } + } catch { + // Config read errors should never block Claude launch. + } + return {}; +} + /** * Escape arguments for shell execution (cross-platform) * @@ -84,6 +101,7 @@ export function execClaude( // Get WebSearch hook config env vars 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 @@ -97,8 +115,8 @@ export function execClaude( // Prepare environment (merge with base env if envVars provided) const mergedEnv = envVars - ? { ...baseEnv, ...envVars, ...webSearchEnv } - : { ...baseEnv, ...webSearchEnv }; + ? { ...baseEnv, ...claudeLaunchEnv, ...envVars, ...webSearchEnv } + : { ...baseEnv, ...claudeLaunchEnv, ...webSearchEnv }; // Strip Claude Code nested session guard env var to allow CCS delegation // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 3950ae52..64a2323a 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -1,6 +1,9 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; import { EventEmitter } from 'events'; import * as childProcess from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; type SpawnCall = { command: string; @@ -13,6 +16,8 @@ const originalPlatform = process.platform; let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; let baselineSighupListeners: Array<(...args: unknown[]) => void> = []; +let originalCcsHome: string | undefined; +let originalDisableAutoUpdater: string | undefined; const realSpawn = childProcess.spawn.bind(childProcess); const realSpawnSync = childProcess.spawnSync.bind(childProcess); const realExecSync = childProcess.execSync.bind(childProcess); @@ -95,6 +100,18 @@ function registerChildProcessMock(): void { })); } +function writeConfigWithAutoUpdatePreference(enabled: boolean): void { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auto-update-pref-')); + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const yaml = `version: 8 +preferences: + auto_update: ${enabled ? 'true' : 'false'} +`; + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8'); +} + let execClaude: typeof import('../../../src/utils/shell-executor').execClaude; let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv; let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor; @@ -118,6 +135,9 @@ describe('CLAUDECODE environment stripping', () => { beforeEach(() => { spawnCalls.length = 0; process.env.CCS_QUIET = '1'; + originalCcsHome = process.env.CCS_HOME; + originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER; + delete process.env.DISABLE_AUTOUPDATER; baselineSigintListeners = process.listeners('SIGINT'); baselineSigtermListeners = process.listeners('SIGTERM'); baselineSighupListeners = process.listeners('SIGHUP'); @@ -128,6 +148,13 @@ describe('CLAUDECODE environment stripping', () => { delete process.env.CLAUDECODE; delete process.env.claudecode; delete process.env.CCS_QUIET; + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalDisableAutoUpdater !== undefined) { + process.env.DISABLE_AUTOUPDATER = originalDisableAutoUpdater; + } else { + delete process.env.DISABLE_AUTOUPDATER; + } for (const listener of process.listeners('SIGINT')) { if (!baselineSigintListeners.includes(listener)) { @@ -197,7 +224,26 @@ describe('CLAUDECODE environment stripping', () => { expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); }); + it('execClaude sets DISABLE_AUTOUPDATER=1 when preferences.auto_update is false', () => { + writeConfigWithAutoUpdatePreference(false); + execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'default' }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.DISABLE_AUTOUPDATER).toBe('1'); + }); + + it('execClaude does not force DISABLE_AUTOUPDATER when preferences.auto_update is true', () => { + writeConfigWithAutoUpdatePreference(true); + execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'default' }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.DISABLE_AUTOUPDATER).toBeUndefined(); + }); + it('headless executor spawn path strips CLAUDECODE before spawn', async () => { + writeConfigWithAutoUpdatePreference(false); process.env.CLAUDECODE = 'nested'; process.env.claudecode = 'nested-lower'; @@ -237,5 +283,6 @@ describe('CLAUDECODE environment stripping', () => { expect(spawnCalls.length).toBeGreaterThan(0); const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); + expect(env.DISABLE_AUTOUPDATER).toBe('1'); }); });