Skip to content

Commit 7b366e1

Browse files
steipetewendy-chsy
andauthored
fix: Windows CLI backends fail through npm shims (#101378)
* fix(process): resolve Windows npm CLI shims directly Co-authored-by: wendy-chsy <[email protected]> * docs(changelog): note Windows CLI shim fix * docs(process): explain Windows shim boundary * chore: keep release changelog owner-only * test(process): isolate Windows shim resolution * fix(process): classify Windows forwarding shims safely --------- Co-authored-by: wendy-chsy <[email protected]>
1 parent 72bd74e commit 7b366e1

4 files changed

Lines changed: 172 additions & 11 deletions

File tree

src/plugin-sdk/windows-spawn.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,54 @@ describe("resolveWindowsSpawnProgram", () => {
7979
windowsHide: undefined,
8080
});
8181
});
82+
83+
it("preserves custom batch-wrapper behavior instead of bypassing its target", async () => {
84+
const dir = await createTempDir("openclaw-windows-spawn-test-");
85+
const targetPath = path.join(dir, "tool.exe");
86+
const wrapperPath = path.join(dir, "wrapper.cmd");
87+
await writeFile(targetPath, "", "utf8");
88+
await writeFile(
89+
wrapperPath,
90+
'@ECHO off\r\nSET WRAPPER_FLAG=1\r\n"%~dp0\\tool.exe" %*\r\n',
91+
"utf8",
92+
);
93+
94+
const resolved = resolveWindowsSpawnProgram({
95+
command: wrapperPath,
96+
platform: "win32",
97+
env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" },
98+
execPath: "C:\\node\\node.exe",
99+
allowShellFallback: true,
100+
});
101+
102+
expect(resolved).toEqual({
103+
command: wrapperPath,
104+
leadingArgv: [],
105+
resolution: "shell-fallback",
106+
shell: true,
107+
});
108+
});
109+
110+
it("does not reinterpret a forwarded batch wrapper as a Node script", async () => {
111+
const dir = await createTempDir("openclaw-windows-spawn-test-");
112+
const targetPath = path.join(dir, "inner.cmd");
113+
const wrapperPath = path.join(dir, "wrapper.cmd");
114+
await writeFile(targetPath, "@ECHO off\r\necho inner\r\n", "utf8");
115+
await writeFile(wrapperPath, '@ECHO off\r\n"%~dp0\\inner.cmd" %*\r\n', "utf8");
116+
117+
const resolved = resolveWindowsSpawnProgram({
118+
command: wrapperPath,
119+
platform: "win32",
120+
env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" },
121+
execPath: "C:\\node\\node.exe",
122+
allowShellFallback: true,
123+
});
124+
125+
expect(resolved).toEqual({
126+
command: wrapperPath,
127+
leadingArgv: [],
128+
resolution: "shell-fallback",
129+
shell: true,
130+
});
131+
});
82132
});

src/plugin-sdk/windows-spawn.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,25 @@ function resolveEntrypointFromCmdShim(wrapperPath: string): string | null {
181181

182182
try {
183183
const content = readFileSync(wrapperPath, "utf8");
184+
const normalizedContent = content.replaceAll("\r\n", "\n").toLowerCase();
185+
const significantLines = content
186+
.split(/\r?\n/u)
187+
.map((line) => line.trim())
188+
.filter(Boolean);
189+
const isNpmCmdShim =
190+
normalizedContent.includes("\ngoto start\n") &&
191+
normalizedContent.includes("\n:find_dp0\n") &&
192+
normalizedContent.includes("set dp0=%~dp0") &&
193+
normalizedContent.includes("call :find_dp0");
194+
const isDirectForwarder =
195+
significantLines.length === 2 &&
196+
/^@echo off$/iu.test(significantLines[0] ?? "") &&
197+
/^"%~?dp0%?[\\/][^"\r\n]+"\s+%\*$/iu.test(significantLines[1] ?? "");
198+
// Only known direct-forwarder shapes are safe to bypass; arbitrary batch
199+
// wrappers can depend on setup commands before dispatching their target.
200+
if (!isNpmCmdShim && !isDirectForwarder) {
201+
return null;
202+
}
184203
const candidates: string[] = [];
185204
for (const match of content.matchAll(/"([^"\r\n]*)"/g)) {
186205
const token = match[1] ?? "";
@@ -199,6 +218,12 @@ function resolveEntrypointFromCmdShim(wrapperPath: string): string | null {
199218
const base = normalizeLowercaseStringOrEmpty(path.basename(candidate));
200219
return base !== "node.exe" && base !== "node";
201220
});
221+
if (isDirectForwarder && nonNode) {
222+
const ext = normalizeLowercaseStringOrEmpty(path.extname(nonNode));
223+
if (ext !== ".exe" && ext !== ".js" && ext !== ".cjs" && ext !== ".mjs") {
224+
return null;
225+
}
226+
}
202227
return nonNode ?? null;
203228
} catch {
204229
return null;

src/process/supervisor/adapters/child.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
// Child adapter tests cover adapting child processes to supervisor runs.
22
import type { ChildProcess } from "node:child_process";
33
import { EventEmitter } from "node:events";
4+
import { mkdir, writeFile } from "node:fs/promises";
45
import path from "node:path";
56
import { PassThrough } from "node:stream";
67
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
8+
import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js";
79
import {
810
getWindowsInstallRoots,
911
resetWindowsInstallRootsForTests,
@@ -37,6 +39,7 @@ vi.mock("../../../infra/windows-encoding.js", () => ({
3739
}));
3840

3941
let createChildAdapter: typeof import("./child.js").createChildAdapter;
42+
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
4043

4144
function createStubChild(pid = 1234) {
4245
const child = new EventEmitter() as ChildProcess;
@@ -161,6 +164,25 @@ describe("createChildAdapter", () => {
161164
vi.useRealTimers();
162165
});
163166

167+
const createWindowsNpmShim = async (params: { command: string; packagePath: string[] }) => {
168+
const binDir = tempDirs.make("openclaw-child-shim-");
169+
const entrypoint = path.join(binDir, "node_modules", ...params.packagePath);
170+
await mkdir(path.dirname(entrypoint), { recursive: true });
171+
await writeFile(entrypoint, "", "utf8");
172+
const relativeEntrypoint = path.relative(binDir, entrypoint).replaceAll(path.sep, "\\");
173+
const shimHead =
174+
"@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n";
175+
const shimCommand = entrypoint.endsWith(".exe")
176+
? `"%dp0%\\${relativeEntrypoint}" %*\r\n`
177+
: `IF EXIST "%dp0%\\node.exe" (\r\n SET "_prog=%dp0%\\node.exe"\r\n) ELSE (\r\n SET "_prog=node"\r\n)\r\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${relativeEntrypoint}" %*\r\n`;
178+
await writeFile(
179+
path.join(binDir, `${params.command}.cmd`),
180+
`${shimHead}${shimCommand}`,
181+
"utf8",
182+
);
183+
return { binDir, entrypoint };
184+
};
185+
164186
it("uses process-tree kill for default SIGKILL", async () => {
165187
const { adapter, killMock } = await createAdapterHarness({ pid: 4321 });
166188

@@ -408,6 +430,7 @@ describe("createChildAdapter", () => {
408430
await createAdapterHarness({
409431
pid: 3335,
410432
argv: ["pnpm", "--version"],
433+
env: { PATH: "", PATHEXT: ".EXE;.CMD;.BAT" },
411434
});
412435

413436
const spawnArgs = firstSpawnWithFallbackParams();
@@ -424,6 +447,48 @@ describe("createChildAdapter", () => {
424447
expect(spawnArgs.fallbacks).toStrictEqual([]);
425448
});
426449

450+
it("unwraps Gemini's npm shim and preserves prompt argv on Windows", async () => {
451+
setPlatform("win32");
452+
const { binDir, entrypoint } = await createWindowsNpmShim({
453+
command: "gemini",
454+
packagePath: ["@google", "gemini-cli", "bundle", "gemini.js"],
455+
});
456+
const nodePath = path.join(binDir, "node.exe");
457+
await writeFile(nodePath, "", "utf8");
458+
const prompt = "explain A&B | C > D and 100% coverage";
459+
460+
await createAdapterHarness({
461+
pid: 3336,
462+
argv: ["gemini", "--prompt", prompt],
463+
env: { PATH: binDir, PATHEXT: ".EXE;.CMD;.BAT" },
464+
});
465+
466+
const spawnArgs = firstSpawnWithFallbackParams();
467+
expect(spawnArgs.argv?.[0]?.toLowerCase()).toBe(nodePath.toLowerCase());
468+
expect(spawnArgs.argv?.slice(1)).toEqual([entrypoint, "--prompt", prompt]);
469+
expect(spawnArgs.options?.windowsVerbatimArguments).toBeUndefined();
470+
expect(spawnArgs.fallbacks).toStrictEqual([]);
471+
});
472+
473+
it("unwraps Claude's npm shim to its native executable on Windows", async () => {
474+
setPlatform("win32");
475+
const { binDir, entrypoint } = await createWindowsNpmShim({
476+
command: "claude",
477+
packagePath: ["@anthropic-ai", "claude-code", "bin", "claude.exe"],
478+
});
479+
480+
await createAdapterHarness({
481+
pid: 3337,
482+
argv: ["claude", "--version"],
483+
env: { PATH: binDir, PATHEXT: ".EXE;.CMD;.BAT" },
484+
});
485+
486+
const spawnArgs = firstSpawnWithFallbackParams();
487+
expect(spawnArgs.argv).toEqual([entrypoint, "--version"]);
488+
expect(spawnArgs.options?.windowsVerbatimArguments).toBeUndefined();
489+
expect(spawnArgs.fallbacks).toStrictEqual([]);
490+
});
491+
427492
it("wraps Linux child spawns and strips shell-init env", async () => {
428493
const originalBashEnv = process.env.BASH_ENV;
429494
const originalEnv = process.env.ENV;

src/process/supervisor/adapters/child.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
import type { ChildProcessWithoutNullStreams, SpawnOptions } from "node:child_process";
33
import { toErrorObject } from "../../../infra/errors.js";
44
import { createWindowsOutputDecoder } from "../../../infra/windows-encoding.js";
5+
import {
6+
resolveWindowsExecutablePath,
7+
resolveWindowsSpawnProgramCandidate,
8+
} from "../../../plugin-sdk/windows-spawn.js";
59
import { signalProcessTree } from "../../kill-tree.js";
610
import { prepareOomScoreAdjustedSpawn } from "../../linux-oom-score.js";
711
import { spawnWithFallback } from "../../spawn-utils.js";
@@ -16,21 +20,37 @@ import { toStringEnv } from "./env.js";
1620

1721
const FORCE_KILL_WAIT_FALLBACK_MS = 4000;
1822
const WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS = 250;
23+
const WINDOWS_PACKAGE_MANAGER_SHIMS = ["npm", "pnpm", "yarn", "npx"] as const;
1924

20-
function resolveCommand(command: string): string {
21-
return resolveWindowsCommandShim({
22-
command,
23-
cmdCommands: ["npm", "pnpm", "yarn", "npx"],
24-
});
25-
}
26-
27-
function resolveChildInvocation(params: { argv: string[]; windowsVerbatimArguments?: boolean }): {
25+
function resolveChildInvocation(params: {
26+
argv: string[];
27+
env?: NodeJS.ProcessEnv;
28+
windowsVerbatimArguments?: boolean;
29+
}): {
2830
args: string[];
2931
command: string;
3032
windowsVerbatimArguments?: boolean;
3133
} {
32-
const resolvedCommand = resolveCommand(params.argv[0] ?? "");
33-
const args = params.argv.slice(1);
34+
const command = params.argv[0] ?? "";
35+
const candidate = resolveWindowsSpawnProgramCandidate({
36+
command,
37+
env: params.env,
38+
// npm shims invoke `node` from PATH; process.execPath may be a packaged OpenClaw executable.
39+
execPath:
40+
process.platform === "win32"
41+
? resolveWindowsExecutablePath("node", params.env ?? process.env)
42+
: undefined,
43+
});
44+
const args = [...candidate.leadingArgv, ...params.argv.slice(1)];
45+
// Keep the historical package-manager fallback when PATH probing cannot see
46+
// its shim; every resolved wrapper takes the direct Node/exe path above.
47+
const resolvedCommand =
48+
candidate.resolution === "direct" && candidate.command === command
49+
? resolveWindowsCommandShim({
50+
command,
51+
cmdCommands: WINDOWS_PACKAGE_MANAGER_SHIMS,
52+
})
53+
: candidate.command;
3454
if (!isWindowsBatchCommand(resolvedCommand)) {
3555
return {
3656
command: resolvedCommand,
@@ -59,11 +79,12 @@ export async function createChildAdapter(params: {
5979
input?: string;
6080
stdinMode?: "inherit" | "pipe-open" | "pipe-closed";
6181
}): Promise<ChildAdapter> {
82+
const baseEnv = params.env ? toStringEnv(params.env) : undefined;
6283
const invocation = resolveChildInvocation({
6384
argv: params.argv,
85+
env: baseEnv,
6486
windowsVerbatimArguments: params.windowsVerbatimArguments,
6587
});
66-
const baseEnv = params.env ? toStringEnv(params.env) : undefined;
6788
const preparedSpawn = prepareOomScoreAdjustedSpawn(invocation.command, invocation.args, {
6889
env: baseEnv,
6990
});

0 commit comments

Comments
 (0)