From 80a84edf1f7a7514dfb3fbd8c3786b0571b528bf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 20 Feb 2026 23:01:09 +0700 Subject: [PATCH] test(delegation): add regression coverage for claudecode stripping - verify case-insensitive CLAUDECODE removal helper behavior - cover execClaude env merge, override, no-key, and Windows paths - cover headless executor spawn env sanitization behavior Refs #588 --- .../utils/claudecode-env-stripping.test.ts | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/unit/utils/claudecode-env-stripping.test.ts diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts new file mode 100644 index 00000000..5c6ba7bb --- /dev/null +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -0,0 +1,204 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { EventEmitter } from 'events'; + +type SpawnCall = { + command: string; + args: string[]; + options: Record | undefined; +}; + +const spawnCalls: SpawnCall[] = []; +const originalPlatform = process.platform; +let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; +let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; +let baselineSighupListeners: Array<(...args: unknown[]) => void> = []; + +function createMockChild(): EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + exitCode: number | null; + killed: boolean; + kill: () => boolean; +} { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + exitCode: number | null; + killed: boolean; + kill: () => boolean; + }; + + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.exitCode = null; + child.killed = false; + child.kill = () => { + child.killed = true; + child.exitCode = 1; + return true; + }; + + return child; +} + +mock.module('child_process', () => ({ + spawn: (...spawnArgs: unknown[]) => { + const command = String(spawnArgs[0] ?? ''); + const maybeArgs = spawnArgs[1]; + const args = Array.isArray(maybeArgs) ? (maybeArgs as string[]) : []; + const options = (Array.isArray(maybeArgs) ? spawnArgs[2] : spawnArgs[1]) as + | Record + | undefined; + + spawnCalls.push({ command, args, options }); + + const child = createMockChild(); + setTimeout(() => child.emit('close', 0), 0); + return child; + }, + spawnSync: () => ({ status: 0 }), + execSync: () => '', +})); + +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; + +beforeAll(async () => { + const shellExecutor = await import('../../../src/utils/shell-executor'); + execClaude = shellExecutor.execClaude; + stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv; + + const headless = await import('../../../src/delegation/headless-executor'); + HeadlessExecutor = headless.HeadlessExecutor; +}); + +afterAll(() => { + mock.restore(); +}); + +describe('CLAUDECODE environment stripping', () => { + beforeEach(() => { + spawnCalls.length = 0; + process.env.CCS_QUIET = '1'; + baselineSigintListeners = process.listeners('SIGINT'); + baselineSigtermListeners = process.listeners('SIGTERM'); + baselineSighupListeners = process.listeners('SIGHUP'); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + delete process.env.CLAUDECODE; + delete process.env.claudecode; + delete process.env.CCS_QUIET; + + for (const listener of process.listeners('SIGINT')) { + if (!baselineSigintListeners.includes(listener)) { + process.removeListener('SIGINT', listener as (...args: unknown[]) => void); + } + } + for (const listener of process.listeners('SIGTERM')) { + if (!baselineSigtermListeners.includes(listener)) { + process.removeListener('SIGTERM', listener as (...args: unknown[]) => void); + } + } + for (const listener of process.listeners('SIGHUP')) { + if (!baselineSighupListeners.includes(listener)) { + process.removeListener('SIGHUP', listener as (...args: unknown[]) => void); + } + } + }); + + it('stripClaudeCodeEnv removes CLAUDECODE case-insensitively', () => { + const input: NodeJS.ProcessEnv = { + CLAUDECODE: 'upper', + claudecode: 'lower', + ClAuDeCoDe: 'mixed', + PATH: '/usr/bin', + }; + + const result = stripClaudeCodeEnv(input); + expect(Object.keys(result).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); + 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'; + + execClaude('claude', ['--version'], { + CCS_PROFILE_TYPE: 'default', + CLAUDECODE: 'from-override', + CCS_WEBSEARCH_SKIP: '1', + }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env).toBeDefined(); + expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); + expect(env.CCS_WEBSEARCH_SKIP).toBe('1'); + }); + + it('execClaude keeps behavior when CLAUDECODE is absent', () => { + execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'default' }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env).toBeDefined(); + expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); + expect(env.CCS_PROFILE_TYPE).toBe('default'); + }); + + it('execClaude strips CLAUDECODE on Windows shell launch path', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + process.env.CLAUDECODE = 'set'; + + execClaude('claude.cmd', ['--version'], { CCS_PROFILE_TYPE: 'default' }); + + 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'); + }); + + it('headless executor spawn path strips CLAUDECODE before spawn', async () => { + process.env.CLAUDECODE = 'nested'; + process.env.claudecode = 'nested-lower'; + + const result = await ( + HeadlessExecutor as unknown as { + _spawnAndExecute: ( + claudeCli: string, + args: string[], + ctx: { + cwd: string; + profile: string; + timeout: number; + resumeSession: boolean; + sessionId: string | null; + sessionMgr: { + updateSession: (...args: unknown[]) => void; + storeSession: (...args: unknown[]) => void; + cleanupExpired: () => void; + }; + } + ) => Promise; + } + )._spawnAndExecute('claude', ['-p', 'test'], { + cwd: process.cwd(), + profile: 'glm', + timeout: 1000, + resumeSession: false, + sessionId: null, + sessionMgr: { + updateSession: () => {}, + storeSession: () => {}, + cleanupExpired: () => {}, + }, + }); + + expect(result).toBeDefined(); + 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'); + }); +});