fix: respect auto_update preference by disabling Claude auto-updater

This commit is contained in:
Tam Nhu Tran
2026-03-02 21:13:15 +07:00
parent 8c6913b0ed
commit 4cdb6594f8
4 changed files with 80 additions and 5 deletions
+8 -1
View File
@@ -44,7 +44,12 @@ import { handleShellCompletionCommand } from './commands/shell-completion-comman
import { handleUpdateCommand } from './commands/update-command'; import { handleUpdateCommand } from './commands/update-command';
// Import extracted utility functions // 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 { wireChildProcessSignals } from './utils/signal-forwarder';
// Import target adapter system // Import target adapter system
@@ -210,8 +215,10 @@ async function execClaudeWithProxy(
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli); const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
const webSearchEnv = getWebSearchHookEnv(); const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv(profileName); const imageAnalysisEnv = getImageAnalysisHookEnv(profileName);
const claudeLaunchEnv = getClaudeLaunchEnvOverrides();
const env = stripClaudeCodeEnv({ const env = stripClaudeCodeEnv({
...process.env, ...process.env,
...claudeLaunchEnv,
...envVars, ...envVars,
...webSearchEnv, ...webSearchEnv,
...imageAnalysisEnv, ...imageAnalysisEnv,
+5 -2
View File
@@ -17,7 +17,7 @@ import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
import { buildExecutionResult } from './executor/result-aggregator'; import { buildExecutionResult } from './executor/result-aggregator';
import { getCcsDir, getModelDisplayName } from '../utils/config-manager'; import { getCcsDir, getModelDisplayName } from '../utils/config-manager';
import { getProfileLookupCandidates } from '../utils/profile-compat'; import { getProfileLookupCandidates } from '../utils/profile-compat';
import { stripClaudeCodeEnv } from '../utils/shell-executor'; import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
// Re-export types for consumers // Re-export types for consumers
export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types'; 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 // Strip Claude Code nested session guard env var to allow CCS delegation
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) // (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, { const proc = spawn(claudeCli, args, {
cwd, cwd,
+20 -2
View File
@@ -8,6 +8,7 @@ import { spawn, spawnSync, ChildProcess } from 'child_process';
import { ErrorManager } from './error-manager'; import { ErrorManager } from './error-manager';
import { getWebSearchHookEnv } from './websearch-manager'; import { getWebSearchHookEnv } from './websearch-manager';
import { wireChildProcessSignals } from './signal-forwarder'; import { wireChildProcessSignals } from './signal-forwarder';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
/** /**
* Strip ANTHROPIC_* env vars from an environment object. * Strip ANTHROPIC_* env vars from an environment object.
@@ -40,6 +41,22 @@ export function stripClaudeCodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return result; 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) * Escape arguments for shell execution (cross-platform)
* *
@@ -84,6 +101,7 @@ export function execClaude(
// Get WebSearch hook config env vars // Get WebSearch hook config env vars
const webSearchEnv = getWebSearchHookEnv(); const webSearchEnv = getWebSearchHookEnv();
const claudeLaunchEnv = getClaudeLaunchEnvOverrides();
// For account/default profiles, strip ANTHROPIC_* from parent env to prevent // For account/default profiles, strip ANTHROPIC_* from parent env to prevent
// stale proxy config (e.g., from prior CLIProxy sessions) from interfering // 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) // Prepare environment (merge with base env if envVars provided)
const mergedEnv = envVars const mergedEnv = envVars
? { ...baseEnv, ...envVars, ...webSearchEnv } ? { ...baseEnv, ...claudeLaunchEnv, ...envVars, ...webSearchEnv }
: { ...baseEnv, ...webSearchEnv }; : { ...baseEnv, ...claudeLaunchEnv, ...webSearchEnv };
// Strip Claude Code nested session guard env var to allow CCS delegation // Strip Claude Code nested session guard env var to allow CCS delegation
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
@@ -1,6 +1,9 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import * as childProcess from 'child_process'; import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
type SpawnCall = { type SpawnCall = {
command: string; command: string;
@@ -13,6 +16,8 @@ const originalPlatform = process.platform;
let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; let baselineSigintListeners: Array<(...args: unknown[]) => void> = [];
let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; let baselineSigtermListeners: Array<(...args: unknown[]) => void> = [];
let baselineSighupListeners: 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 realSpawn = childProcess.spawn.bind(childProcess);
const realSpawnSync = childProcess.spawnSync.bind(childProcess); const realSpawnSync = childProcess.spawnSync.bind(childProcess);
const realExecSync = childProcess.execSync.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 execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv; let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor; let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
@@ -118,6 +135,9 @@ describe('CLAUDECODE environment stripping', () => {
beforeEach(() => { beforeEach(() => {
spawnCalls.length = 0; spawnCalls.length = 0;
process.env.CCS_QUIET = '1'; process.env.CCS_QUIET = '1';
originalCcsHome = process.env.CCS_HOME;
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
delete process.env.DISABLE_AUTOUPDATER;
baselineSigintListeners = process.listeners('SIGINT'); baselineSigintListeners = process.listeners('SIGINT');
baselineSigtermListeners = process.listeners('SIGTERM'); baselineSigtermListeners = process.listeners('SIGTERM');
baselineSighupListeners = process.listeners('SIGHUP'); baselineSighupListeners = process.listeners('SIGHUP');
@@ -128,6 +148,13 @@ describe('CLAUDECODE environment stripping', () => {
delete process.env.CLAUDECODE; delete process.env.CLAUDECODE;
delete process.env.claudecode; delete process.env.claudecode;
delete process.env.CCS_QUIET; 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')) { for (const listener of process.listeners('SIGINT')) {
if (!baselineSigintListeners.includes(listener)) { if (!baselineSigintListeners.includes(listener)) {
@@ -197,7 +224,26 @@ describe('CLAUDECODE environment stripping', () => {
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); 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 () => { it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
writeConfigWithAutoUpdatePreference(false);
process.env.CLAUDECODE = 'nested'; process.env.CLAUDECODE = 'nested';
process.env.claudecode = 'nested-lower'; process.env.claudecode = 'nested-lower';
@@ -237,5 +283,6 @@ describe('CLAUDECODE environment stripping', () => {
expect(spawnCalls.length).toBeGreaterThan(0); expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
expect(env.DISABLE_AUTOUPDATER).toBe('1');
}); });
}); });