fix(targets): use cmd.exe for escaped Windows wrapper launches

Align Windows .cmd/.bat launch behavior with the Codex detector shell contract.

This keeps escaped -c overrides intact through wrapper execution.

Adds regression coverage for the Codex runtime path and the shared Windows shell launch path.
This commit is contained in:
Tam Nhu Tran
2026-04-19 14:09:12 -04:00
parent 9ccac9bf39
commit a945b0b104
7 changed files with 149 additions and 9 deletions
+7 -2
View File
@@ -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 {
+7 -2
View File
@@ -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 {
+2 -2
View File
@@ -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;
}
+6 -2
View File
@@ -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 {
+11 -1
View File
@@ -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 {
@@ -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<string, unknown> | 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<string, unknown>
| 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');
});
});
@@ -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', () => {