diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index 9dc4f08a..d1e8c57c 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -9,7 +9,12 @@ import { spawn, ChildProcess } from 'child_process'; import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter'; import { detectClaudeCli, getClaudeCliInfo } from '../utils/claude-detector'; import type { ProfileType } from '../types/profile'; -import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from '../utils/shell-executor'; +import { + escapeShellArg, + getWindowsEscapedCommandShell, + stripAnthropicEnv, + stripClaudeCodeEnv, +} from '../utils/shell-executor'; import { ErrorManager } from '../utils/error-manager'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { appendBrowserToolArgs } from '../utils/browser'; @@ -111,7 +116,7 @@ export class ClaudeAdapter implements TargetAdapter { child = spawn(cmdString, { stdio: 'inherit', windowsHide: true, - shell: true, + shell: getWindowsEscapedCommandShell(), env, }); } else { diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index e7166fdd..3b74976b 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -4,7 +4,12 @@ import type { ProfileType } from '../types/profile'; import { runCleanup } from '../errors'; import { expandPath } from '../utils/helpers'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; -import { escapeShellArg, stripAnthropicEnv, stripCodexSessionEnv } from '../utils/shell-executor'; +import { + escapeShellArg, + getWindowsEscapedCommandShell, + stripAnthropicEnv, + stripCodexSessionEnv, +} from '../utils/shell-executor'; import type { TargetAdapter, TargetBinaryInfo, @@ -316,7 +321,7 @@ export class CodexAdapter implements TargetAdapter { child = spawn(cmdString, { stdio: 'inherit', windowsHide: true, - shell: true, + shell: getWindowsEscapedCommandShell(), env: launchEnv, }); } else { diff --git a/src/targets/codex-detector.ts b/src/targets/codex-detector.ts index eedc5ed8..73cd79e4 100644 --- a/src/targets/codex-detector.ts +++ b/src/targets/codex-detector.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as childProcess from 'child_process'; import { expandPath } from '../utils/helpers'; -import { escapeShellArg } from '../utils/shell-executor'; +import { escapeShellArg, getWindowsEscapedCommandShell } from '../utils/shell-executor'; import type { TargetBinaryInfo } from './target-adapter'; const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides'; @@ -58,7 +58,7 @@ function runCodexProbe(codexPath: string, args: string[]): string | undefined { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true, - shell: 'cmd.exe', + shell: getWindowsEscapedCommandShell(), }); return result.status === 0 ? result.stdout : undefined; } diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 2a108c00..c162f7f3 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -12,7 +12,11 @@ import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-d import type { ProfileType } from '../types/profile'; import { upsertCcsModel } from './droid-config-manager'; import { resolveDroidProvider } from './droid-provider'; -import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor'; +import { + escapeShellArg, + getWindowsEscapedCommandShell, + stripAnthropicEnv, +} from '../utils/shell-executor'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { runCleanup } from '../errors'; @@ -134,7 +138,7 @@ export class DroidAdapter implements TargetAdapter { child = spawn(cmdString, { stdio: 'inherit', windowsHide: true, - shell: true, + shell: getWindowsEscapedCommandShell(), env, }); } else { diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 6d90c428..191c004a 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -107,6 +107,16 @@ export function escapeShellArg(arg: string): string { } } +/** + * Return the Windows shell that matches escapeShellArg() quoting semantics. + * + * `shell: true` is not strict enough for npm `.cmd` wrappers because Node may + * route through a different quoting path than the one escapeShellArg() expects. + */ +export function getWindowsEscapedCommandShell(): string { + return 'cmd.exe'; +} + /** * Execute Claude CLI with unified spawn logic */ @@ -182,7 +192,7 @@ export function execClaude( child = spawn(cmdString, { stdio: 'inherit', windowsHide: true, - shell: true, + shell: getWindowsEscapedCommandShell(), env, }); } else { diff --git a/tests/unit/targets/codex-adapter-exec.test.ts b/tests/unit/targets/codex-adapter-exec.test.ts new file mode 100644 index 00000000..cf8017c8 --- /dev/null +++ b/tests/unit/targets/codex-adapter-exec.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import * as childProcess from 'child_process'; +import { EventEmitter } from 'events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const spawnCalls: Array<{ + command: string; + args: string[]; + options: Record | undefined; +}> = []; +const originalPlatform = process.platform; + +function createMockChild(): EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + exitCode: number | null; + killed: boolean; + pid: number; + unref: () => EventEmitter; + kill: () => boolean; +} { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + exitCode: number | null; + killed: boolean; + pid: number; + unref: () => EventEmitter; + kill: () => boolean; + }; + + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.exitCode = null; + child.killed = false; + child.pid = process.pid; + child.unref = () => child; + child.kill = () => { + child.killed = true; + child.exitCode = 1; + return true; + }; + + return child; +} + +mock.module('child_process', () => ({ + ...childProcess, + 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 }); + return createMockChild(); + }, +})); + +mock.module('../../../src/utils/signal-forwarder', () => ({ + wireChildProcessSignals: () => {}, +})); + +import { CodexAdapter } from '../../../src/targets/codex-adapter'; +import { buildCodexBrowserMcpOverrides } from '../../../src/utils/browser-codex-overrides'; + +describe('codex-adapter exec', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-adapter-exec-')); + spawnCalls.length = 0; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('launches Windows cmd wrappers via cmd.exe when runtime overrides include browser MCP args', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const fakeCodex = path.join(tmpDir, 'codex.cmd'); + fs.writeFileSync(fakeCodex, ''); + + const adapter = new CodexAdapter(); + const binaryInfo = { + path: fakeCodex, + needsShell: true, + features: ['config-overrides'], + }; + const args = adapter.buildArgs('default', ['--version'], { + profileType: 'default', + creds: { + profile: 'default', + baseUrl: '', + apiKey: '', + runtimeConfigOverrides: buildCodexBrowserMcpOverrides(), + }, + binaryInfo, + }); + + adapter.exec(args, {}, { binaryInfo }); + + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0]?.options?.shell).toBe('cmd.exe'); + expect(spawnCalls[0]?.command).toContain(fakeCodex); + expect(spawnCalls[0]?.command).toContain('mcp_servers.ccs_browser.args='); + expect(spawnCalls[0]?.command).toContain('@playwright/mcp@0.0.70'); + }); +}); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 49a7190f..5fb99ded 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -266,6 +266,7 @@ 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(spawnCalls[0].options?.shell).toBe('cmd.exe'); }); it('execClaude sets DISABLE_AUTOUPDATER=1 when preferences.auto_update is false', () => {