mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 06:17:09 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user