Skip to content

Commit 9eaff97

Browse files
committed
fix(windows): launch .cmd shims and platform shells correctly — win-exec launcher + 4 call sites
Windows npm installs expose claude/codex as .cmd shims, which Node/Bun refuse to spawn shell-less (CVE-2024-27980) and which bare spawn() never resolves via PATHEXT. New src/lib/win-exec.ts mirrors cross-spawn: PATHxPATHEXT resolution, direct spawn for .exe, cmd.exe /d /s /c + windowsVerbatimArguments with shim-gated double escaping for .cmd/.bat (escaping byte-verified against installed cross-spawn). Adopted at: ocx claude launch (+exit-9009 hint), ocx v2 on/off + management-api feature toggle (shared codexFeaturesInvocation), and the cursor desktop executor (sh -c analog via platform shell; command is platform-native shell syntax). POSIX invocations stay byte-identical. devlog: 260715_cross_platform_audit/020
1 parent fe1a5ea commit 9eaff97

9 files changed

Lines changed: 359 additions & 13 deletions

File tree

src/adapters/cursor/native-exec-desktop.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { spawn } from "node:child_process";
2+
import { shellInvocation } from "../../lib/win-exec";
23
import { create } from "@bufbuild/protobuf";
34
import {
45
ComputerUseErrorSchema,
@@ -121,14 +122,20 @@ function recordScreenFailure(error: string): RecordScreenResult {
121122
});
122123
}
123124

124-
/** Spawn `command` via the shell, write `payload` as JSON to stdin, return parsed stdout JSON. */
125+
/**
126+
* Spawn `command` via the platform shell (sh -c on POSIX, cmd.exe /d /s /c on win32 —
127+
* the configured command is platform-native shell syntax; devlog
128+
* 260715_cross_platform_audit/020), write `payload` as JSON to stdin, return parsed stdout JSON.
129+
*/
125130
function runExternalJson(command: string, payload: unknown, config: DesktopExecutorConfig): Promise<unknown> {
126131
const timeoutMs = config.timeoutMs ?? DEFAULT_DESKTOP_TIMEOUT_MS;
127132
return new Promise((resolve, reject) => {
128-
const child = spawn("sh", ["-c", command], {
133+
const inv = shellInvocation(command);
134+
const child = spawn(inv.file, inv.args, {
129135
cwd: config.cwd,
130136
env: config.env ? { ...process.env, ...config.env } : process.env,
131137
stdio: ["pipe", "pipe", "pipe"],
138+
...inv.options,
132139
});
133140
let stdout = "";
134141
let stderr = "";

src/cli/claude.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { loadConfig } from "../config";
1111
import { injectClaudeAgentDefs } from "../claude/agents-inject";
1212
import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows";
1313
import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache";
14+
import { commandInvocation } from "../lib/win-exec";
1415
import { findLiveProxy } from "../server/proxy-liveness";
1516
import type { OcxConfig } from "../types";
1617

@@ -141,6 +142,21 @@ async function ensureProxyForClaude(): Promise<number | null> {
141142
return null;
142143
}
143144

145+
const CLAUDE_INSTALL_HINT = "❌ `claude` CLI not found. Install it first: npm install -g @anthropic-ai/claude-code";
146+
147+
/**
148+
* cmd.exe reports command-not-found as exit 9009 (the win32 launcher routes `.cmd`
149+
* shims through cmd.exe, so ENOENT never fires there). Signal exits are not hints.
150+
* Devlog 260715_cross_platform_audit/020.
151+
*/
152+
export function claudeNotFoundHint(
153+
code: number | null,
154+
signal: NodeJS.Signals | null,
155+
platform: NodeJS.Platform = process.platform,
156+
): string | null {
157+
return platform === "win32" && code === 9009 && !signal ? CLAUDE_INSTALL_HINT : null;
158+
}
159+
144160
export async function cmdClaude(args: string[]): Promise<number> {
145161
const config = loadConfig();
146162
if (config.claudeCode?.enabled === false) {
@@ -176,16 +192,19 @@ export async function cmdClaude(args: string[]): Promise<number> {
176192
console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
177193
}
178194
return await new Promise<number>(resolve => {
179-
const child = spawn("claude", args, { stdio: "inherit", env: env as NodeJS.ProcessEnv });
195+
const inv = commandInvocation("claude", args);
196+
const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options });
180197
child.on("error", (err: NodeJS.ErrnoException) => {
181198
if (err.code === "ENOENT") {
182-
console.error("❌ `claude` CLI not found. Install it first: npm install -g @anthropic-ai/claude-code");
199+
console.error(CLAUDE_INSTALL_HINT);
183200
} else {
184201
console.error(`❌ Failed to launch claude: ${err.message}`);
185202
}
186203
resolve(1);
187204
});
188205
child.on("exit", (code, signal) => {
206+
const hint = claudeNotFoundHint(code, signal);
207+
if (hint) console.error(hint);
189208
resolve(signal ? 1 : code ?? 0);
190209
});
191210
});

src/cli/v2.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,38 @@
1313
import { execFileSync } from "node:child_process";
1414
import { getLogicalMaxThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
1515

16+
import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
1617
import { loadConfig, saveConfig } from "../config";
1718

1819
export interface V2CliDeps {
19-
execFile?: (file: string, args: string[]) => void;
20+
execFile?: (file: string, args: string[], options?: SpawnInvocation["options"]) => void;
2021
isEnabled?: typeof isMultiAgentV2Enabled;
2122
hasMaxThreads?: typeof hasAgentsMaxThreads;
2223
sync?: (port?: number) => Promise<unknown>;
2324
log?: Pick<Console, "log" | "error">;
2425
}
2526

27+
/**
28+
* Shared invocation for `codex features enable|disable multi_agent_v2` — the single
29+
* source of truth for the CLI and the management API fallback. Windows npm installs
30+
* expose `codex` as a `.cmd` shim, which needs the win-exec launcher
31+
* (devlog 260715_cross_platform_audit/020).
32+
*/
33+
export function codexFeaturesInvocation(
34+
action: "enable" | "disable",
35+
platform: NodeJS.Platform = process.platform,
36+
deps: Parameters<typeof commandInvocation>[3] = {},
37+
): SpawnInvocation {
38+
const command = (deps.env ?? process.env).CODEX_CLI_PATH?.trim() || "codex";
39+
return commandInvocation(command, ["features", action, "multi_agent_v2"], platform, deps);
40+
}
41+
2642
function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps): void {
27-
const exec = deps.execFile ?? ((file: string, args: string[]) => {
28-
execFileSync(file, args, { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true });
43+
const exec = deps.execFile ?? ((file: string, args: string[], options?: SpawnInvocation["options"]) => {
44+
execFileSync(file, args, { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, ...options });
2945
});
30-
const command = process.env.CODEX_CLI_PATH?.trim() || "codex";
31-
exec(command, ["features", action, "multi_agent_v2"]);
46+
const inv = codexFeaturesInvocation(action);
47+
exec(inv.file, inv.args, inv.options);
3248
}
3349

3450
export function v2StatusLine(enabled: boolean): string {

src/lib/win-exec.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Cross-platform command launching (devlog 260715_cross_platform_audit/020).
3+
*
4+
* Windows npm installs expose CLIs as `.cmd` shims, and Node/Bun refuse shell-less
5+
* `.cmd` spawns (CVE-2024-27980 hardening). Bare names like `spawn("claude")` also
6+
* skip PATHEXT resolution entirely, so they ENOENT even when `claude.cmd` is on PATH.
7+
* This module mirrors the battle-tested cross-spawn approach: resolve the real target
8+
* via PATH×PATHEXT, launch `.exe` targets directly (argument boundaries preserved by
9+
* the normal shell-less spawn), and route `.cmd`/`.bat` targets through
10+
* `cmd.exe /d /s /c "<escaped line>"` with `windowsVerbatimArguments: true`.
11+
*/
12+
import { existsSync } from "node:fs";
13+
import { win32 } from "node:path";
14+
15+
const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
16+
/** cross-spawn parse.js: only npm local-bin shims get double escaping. */
17+
const IS_CMD_SHIM = /node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i;
18+
19+
/** cross-spawn escape.js argument(): quote + escape one argument for cmd.exe /d /s /c. */
20+
export function escapeCmdArg(arg: string, doubleEscape = false): string {
21+
let out = String(arg).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
22+
out = `"${out}"`.replace(CMD_META, "^$1");
23+
return doubleEscape ? out.replace(CMD_META, "^$1") : out;
24+
}
25+
26+
/** cross-spawn escape.js command(): escape the command token itself (no quoting). */
27+
export function escapeCmdCommand(command: string): string {
28+
return command.replace(CMD_META, "^$1");
29+
}
30+
31+
export interface ResolveDeps {
32+
env?: Record<string, string | undefined>;
33+
exists?: (path: string) => boolean;
34+
}
35+
36+
/**
37+
* Resolve a bare command name to its first PATH×PATHEXT hit (win32 semantics).
38+
* Commands that already carry an extension, a separator, or an absolute prefix are
39+
* returned unchanged; unresolvable names fall back unchanged (spawn will surface it).
40+
*/
41+
export function resolveWindowsCommand(command: string, deps: ResolveDeps = {}): string {
42+
const env = deps.env ?? process.env;
43+
const exists = deps.exists ?? existsSync;
44+
if (win32.extname(command) || command.includes("\\") || command.includes("/") || win32.isAbsolute(command)) {
45+
return command;
46+
}
47+
const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
48+
for (const dir of (env.PATH ?? env.Path ?? "").split(win32.delimiter).filter(Boolean)) {
49+
for (const ext of exts) {
50+
const candidate = win32.join(dir, command + ext.toLowerCase());
51+
if (exists(candidate)) return candidate;
52+
}
53+
}
54+
return command;
55+
}
56+
57+
export interface SpawnInvocation {
58+
file: string;
59+
args: string[];
60+
options: { windowsVerbatimArguments?: boolean };
61+
}
62+
63+
/**
64+
* Platform-safe invocation preserving argument boundaries (cross-spawn parse.js).
65+
* POSIX: passthrough. win32 `.exe`: resolved direct spawn. win32 `.cmd`/`.bat`:
66+
* `ComSpec /d /s /c "<escaped command line>"` with verbatim args; npm local-bin
67+
* shims get cross-spawn's double escaping, all other batch targets single.
68+
*/
69+
export function commandInvocation(
70+
command: string,
71+
args: readonly string[],
72+
platform: NodeJS.Platform = process.platform,
73+
deps: ResolveDeps = {},
74+
): SpawnInvocation {
75+
if (platform !== "win32") return { file: command, args: [...args], options: {} };
76+
const resolved = resolveWindowsCommand(command, deps);
77+
if (!/\.(cmd|bat)$/i.test(resolved)) return { file: resolved, args: [...args], options: {} };
78+
const env = deps.env ?? process.env;
79+
const doubleEscape = IS_CMD_SHIM.test(resolved);
80+
const line = [escapeCmdCommand(resolved), ...args.map(a => escapeCmdArg(a, doubleEscape))].join(" ");
81+
return {
82+
file: env.ComSpec ?? "cmd.exe",
83+
args: ["/d", "/s", "/c", `"${line}"`],
84+
options: { windowsVerbatimArguments: true },
85+
};
86+
}
87+
88+
/**
89+
* `sh -c <command>` analog per platform. The configured command string is passed
90+
* VERBATIM in content; on win32 it gets the outer quotes `/s` requires, so
91+
* `"C:\Program Files\x.exe" --json` runs as `cmd.exe /d /s /c ""C:\Program Files\x.exe" --json"`.
92+
* Contract: the command is platform-native shell syntax (sh on POSIX, CMD on Windows).
93+
*/
94+
export function shellInvocation(
95+
command: string,
96+
platform: NodeJS.Platform = process.platform,
97+
env: Record<string, string | undefined> = process.env,
98+
): SpawnInvocation {
99+
if (platform !== "win32") return { file: "sh", args: ["-c", command], options: {} };
100+
return {
101+
file: env.ComSpec ?? "cmd.exe",
102+
args: ["/d", "/s", "/c", `"${command}"`],
103+
options: { windowsVerbatimArguments: true },
104+
};
105+
}

src/server/management-api.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -611,10 +611,11 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
611611
let toggle = deps.toggleCodexMultiAgentV2;
612612
if (!toggle) {
613613
const { execFileSync } = await import("node:child_process");
614+
const { codexFeaturesInvocation } = await import("../cli/v2");
614615
toggle = (enabled: boolean) => {
615-
const command = process.env.CODEX_CLI_PATH?.trim() || "codex";
616-
execFileSync(command, ["features", enabled ? "enable" : "disable", "multi_agent_v2"],
617-
{ stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true });
616+
const inv = codexFeaturesInvocation(enabled ? "enable" : "disable");
617+
execFileSync(inv.file, inv.args,
618+
{ stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, ...inv.options });
618619
};
619620
}
620621
const result = transitionMultiAgentV2(targetFlag, toggle, {

tests/claude-cli.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { describe, expect, test } from "bun:test";
2+
import { claudeNotFoundHint } from "../src/cli/claude";
3+
import { commandInvocation } from "../src/lib/win-exec";
24
import { buildClaudeEnv } from "../src/cli/claude";
35
import type { OcxConfig } from "../src/types";
46

@@ -175,3 +177,32 @@ describe("ocx claude env assembly", () => {
175177
});
176178

177179
});
180+
181+
describe("ocx claude Windows launch (devlog 260715_cross_platform_audit/020)", () => {
182+
test("win32 .cmd shim launches through cmd.exe with preserved arg boundaries", () => {
183+
const deps = {
184+
env: { PATH: "C:\\Users\\u\\AppData\\Roaming\\npm", ComSpec: "C:\\WINDOWS\\system32\\cmd.exe" },
185+
exists: (p: string) => p === "C:\\Users\\u\\AppData\\Roaming\\npm\\claude.cmd",
186+
};
187+
const inv = commandInvocation("claude", ["chat", "hello world", 'say "hi"', "50%"], "win32", deps);
188+
expect(inv.file).toBe("C:\\WINDOWS\\system32\\cmd.exe");
189+
expect(inv.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]);
190+
expect(inv.args[3]).toBe(
191+
'"C:\\Users\\u\\AppData\\Roaming\\npm\\claude.cmd ^"chat^" ^"hello^ world^" ^"say^ \\^"hi\\^"^" ^"50^%^""',
192+
);
193+
expect(inv.options).toEqual({ windowsVerbatimArguments: true });
194+
});
195+
196+
test("POSIX launch is byte-identical to the pre-launcher behavior", () => {
197+
expect(commandInvocation("claude", ["chat"], "darwin"))
198+
.toEqual({ file: "claude", args: ["chat"], options: {} });
199+
});
200+
201+
test("exit-9009 hint fires only for win32 non-signal not-found exits", () => {
202+
expect(claudeNotFoundHint(9009, null, "win32")).toContain("npm install -g @anthropic-ai/claude-code");
203+
expect(claudeNotFoundHint(9009, "SIGTERM", "win32")).toBeNull();
204+
expect(claudeNotFoundHint(9009, null, "darwin")).toBeNull();
205+
expect(claudeNotFoundHint(1, null, "win32")).toBeNull();
206+
expect(claudeNotFoundHint(0, null, "win32")).toBeNull();
207+
});
208+
});

tests/codex-v2-gate.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
setMaxConcurrentThreads,
1919
transitionMultiAgentV2,
2020
} from "../src/codex/features";
21-
import { cmdV2, v2StatusLine, multiAgentModeLine } from "../src/cli/v2";
21+
import { cmdV2, codexFeaturesInvocation, v2StatusLine, multiAgentModeLine } from "../src/cli/v2";
2222
import { handleManagementAPI } from "../src/server/management-api";
2323

2424
function template(): Record<string, unknown> {
@@ -408,6 +408,25 @@ describe("cli surface", () => {
408408
expect(v2StatusLine(false)).toContain("OFF");
409409
});
410410

411+
test("codexFeaturesInvocation: POSIX passthrough; win32 .cmd routed through cmd.exe (devlog 260715 020)", () => {
412+
expect(codexFeaturesInvocation("enable", "darwin", { env: {} }))
413+
.toEqual({ file: "codex", args: ["features", "enable", "multi_agent_v2"], options: {} });
414+
// Explicit CODEX_CLI_PATH pointing at a .cmd (npm-only Windows Codex install).
415+
const inv = codexFeaturesInvocation("disable", "win32", {
416+
env: { CODEX_CLI_PATH: "C:\\npm\\codex.cmd", ComSpec: "C:\\WINDOWS\\system32\\cmd.exe" },
417+
exists: () => { throw new Error("explicit path must not probe PATH"); },
418+
});
419+
expect(inv.file).toBe("C:\\WINDOWS\\system32\\cmd.exe");
420+
expect(inv.args).toEqual(["/d", "/s", "/c", '"C:\\npm\\codex.cmd ^"features^" ^"disable^" ^"multi_agent_v2^""']);
421+
expect(inv.options).toEqual({ windowsVerbatimArguments: true });
422+
// Bare `codex` resolving to codex.exe stays a direct spawn.
423+
const exe = codexFeaturesInvocation("enable", "win32", {
424+
env: { PATH: "C:\\bin" },
425+
exists: (p: string) => p === "C:\\bin\\codex.exe",
426+
});
427+
expect(exe).toEqual({ file: "C:\\bin\\codex.exe", args: ["features", "enable", "multi_agent_v2"], options: {} });
428+
});
429+
411430
test("mode v2/v1 preserves the same logical limit", async () => {
412431
const path = fixtureConfig("[agents]\nmax_threads = 100\n");
413432
const oldCodexHome = process.env.CODEX_HOME;

tests/cursor-desktop-exec.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "../src/adapters/cursor/gen/agent_pb";
99
import { handleCursorNativeExec } from "../src/adapters/cursor/native-exec";
1010
import { desktopDepsFromConfig } from "../src/adapters/cursor/native-exec-desktop";
11+
import { shellInvocation } from "../src/lib/win-exec";
1112

1213
function execMessage(message: Parameters<typeof create<typeof ExecServerMessageSchema>>[1]["message"]) {
1314
return create(ExecServerMessageSchema, { id: 3, execId: "exec-test", message });
@@ -121,3 +122,29 @@ describe("Cursor desktop executor hooks", () => {
121122
}
122123
});
123124
});
125+
126+
describe("desktop executor platform shell (devlog 260715_cross_platform_audit/020)", () => {
127+
test("POSIX invocation stays byte-identical to sh -c for both configured commands", () => {
128+
const computerUse = "cat >/dev/null; printf '%s' '{\"durationMs\":42}'";
129+
const recordScreen = "cat >/dev/null; printf '%s' '{\"startSuccess\":{}}'";
130+
expect(shellInvocation(computerUse, "linux")).toEqual({ file: "sh", args: ["-c", computerUse], options: {} });
131+
expect(shellInvocation(recordScreen, "darwin")).toEqual({ file: "sh", args: ["-c", recordScreen], options: {} });
132+
});
133+
134+
test("win32 computer-use command with quoted exe path gets the /s outer-quote wrapper", () => {
135+
const cmd = '"C:\\Program Files\\executor.exe" --computer-use --json';
136+
const inv = shellInvocation(cmd, "win32", { ComSpec: "C:\\WINDOWS\\system32\\cmd.exe" });
137+
expect(inv).toEqual({
138+
file: "C:\\WINDOWS\\system32\\cmd.exe",
139+
args: ["/d", "/s", "/c", `"${cmd}"`],
140+
options: { windowsVerbatimArguments: true },
141+
});
142+
});
143+
144+
test("win32 record-screen command with CMD metacharacters is passed verbatim (CMD-native contract)", () => {
145+
const cmd = "recorder.exe --start & echo %ERRORLEVEL%";
146+
const inv = shellInvocation(cmd, "win32", {});
147+
expect(inv.file).toBe("cmd.exe");
148+
expect(inv.args).toEqual(["/d", "/s", "/c", `"${cmd}"`]);
149+
});
150+
});

0 commit comments

Comments
 (0)