Skip to content

Commit 7dd01d1

Browse files
committed
fix(windows): resolve cmd handoff path
1 parent 675c566 commit 7dd01d1

11 files changed

Lines changed: 56 additions & 22 deletions

src/cli/update-cli/restart-helper.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
44
import os from "node:os";
55
import path from "node:path";
66
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
7+
import { getWindowsCmdExePath } from "../../infra/windows-install-roots.js";
78
import { prepareRestartScript, runRestartScript } from "./restart-helper.js";
89

910
vi.mock("node:child_process", async () => {
@@ -616,7 +617,7 @@ exit 0
616617

617618
await runRestartScript(scriptPath);
618619

619-
expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", scriptPath], {
620+
expect(spawn).toHaveBeenCalledWith(getWindowsCmdExePath(), ["/d", "/s", "/c", scriptPath], {
620621
detached: true,
621622
stdio: "ignore",
622623
windowsHide: true,
@@ -633,11 +634,15 @@ exit 0
633634

634635
await runRestartScript(scriptPath);
635636

636-
expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", `"${scriptPath}"`], {
637-
detached: true,
638-
stdio: "ignore",
639-
windowsHide: true,
640-
});
637+
expect(spawn).toHaveBeenCalledWith(
638+
getWindowsCmdExePath(),
639+
["/d", "/s", "/c", `"${scriptPath}"`],
640+
{
641+
detached: true,
642+
stdio: "ignore",
643+
windowsHide: true,
644+
},
645+
);
641646
});
642647

643648
it("does not throw when spawn fails synchronously", async () => {

src/cli/update-cli/restart-helper.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
resolveGatewayRestartLogPath,
1717
shellEscapeRestartLogValue,
1818
} from "../../daemon/restart-logs.js";
19+
import { getWindowsCmdExePath } from "../../infra/windows-install-roots.js";
1920

2021
/**
2122
* Shell-escape a string for embedding in single-quoted shell arguments.
@@ -404,7 +405,7 @@ exit $status
404405
*/
405406
export async function runRestartScript(scriptPath: string): Promise<void> {
406407
const isWindows = process.platform === "win32";
407-
const file = isWindows ? "cmd.exe" : "/bin/sh";
408+
const file = isWindows ? getWindowsCmdExePath() : "/bin/sh";
408409
const args = isWindows ? ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)] : [scriptPath];
409410

410411
try {

src/daemon/launchd.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-f
88
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
99
import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js";
1010
import { parseTcpPort } from "../infra/tcp-port.js";
11+
import { getWindowsCmdExePath } from "../infra/windows-install-roots.js";
1112
import {
1213
GATEWAY_LAUNCH_AGENT_LABEL,
1314
GATEWAY_SERVICE_KIND,
@@ -287,7 +288,7 @@ async function execLaunchctl(
287288
args: string[],
288289
): Promise<{ stdout: string; stderr: string; code: number }> {
289290
const isWindows = process.platform === "win32";
290-
const file = isWindows ? (process.env.ComSpec ?? "cmd.exe") : "launchctl";
291+
const file = isWindows ? getWindowsCmdExePath() : "launchctl";
291292
const fileArgs = isWindows ? ["/d", "/s", "/c", "launchctl", ...args] : args;
292293
return await execFileUtf8(file, fileArgs, isWindows ? { windowsHide: true } : {});
293294
}

src/daemon/schtasks.startup-fallback.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import { PassThrough } from "node:stream";
55
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
import { getWindowsCmdExePath } from "../infra/windows-install-roots.js";
67
import "./test-helpers/schtasks-base-mocks.js";
78
import {
89
inspectPortUsage,
@@ -292,7 +293,7 @@ describe("Windows startup fallback", () => {
292293
const startupEntryPath = resolveStartupEntryPath(env);
293294
const startupScript = await fs.readFile(startupEntryPath, "utf8");
294295
expect(result.scriptPath).toBe(resolveTaskScriptPath(env));
295-
expect(startupScript).toContain('start "" /min cmd.exe /d /c');
296+
expect(startupScript).toContain(`start "" /min ${getWindowsCmdExePath()} /d /c`);
296297
expect(startupScript).toContain("gateway.cmd");
297298
expectStartupFallbackSpawn();
298299
expect(childUnref).toHaveBeenCalled();

src/daemon/schtasks.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { isGatewayArgv } from "../infra/gateway-process-argv.js";
99
import { findVerifiedGatewayListenerPidsOnPortSync } from "../infra/gateway-processes.js";
1010
import { inspectPortUsage } from "../infra/ports.js";
1111
import { parseTcpPort } from "../infra/tcp-port.js";
12-
import { getWindowsInstallRoots } from "../infra/windows-install-roots.js";
12+
import { getWindowsCmdExePath, getWindowsInstallRoots } from "../infra/windows-install-roots.js";
1313
import { killProcessTree } from "../process/kill-tree.js";
1414
import { sleep } from "../utils.js";
1515
import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js";
@@ -414,7 +414,8 @@ function buildTaskScript({
414414
}
415415

416416
function renderStartupLaunchCommand(scriptPath: string): string {
417-
return `start "" /min cmd.exe /d /c ${quoteCmdScriptArg(scriptPath)}`;
417+
const cmdExePath = quoteCmdScriptArg(getWindowsCmdExePath());
418+
return `start "" /min ${cmdExePath} /d /c ${quoteCmdScriptArg(scriptPath)}`;
418419
}
419420

420421
function buildStartupLauncherScript(params: { description?: string; scriptPath: string }): string {
@@ -497,7 +498,7 @@ async function launchFallbackTaskScript(env: GatewayServiceEnv): Promise<void> {
497498
return;
498499
}
499500

500-
const child = spawn("cmd.exe", ["/d", "/c", scriptPath], {
501+
const child = spawn(getWindowsCmdExePath(), ["/d", "/c", scriptPath], {
501502
detached: true,
502503
stdio: "ignore",
503504
windowsHide: true,

src/infra/windows-encoding.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Detects and decodes Windows console output encodings.
22
import { spawnSync } from "node:child_process";
33
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
4+
import { getWindowsCmdExePath } from "./windows-install-roots.js";
45

56
const WINDOWS_CODEPAGE_ENCODING_MAP: Record<number, string> = {
67
65001: "utf-8",
@@ -49,7 +50,7 @@ export function resolveWindowsConsoleEncoding(): string | null {
4950
return cachedWindowsConsoleEncoding;
5051
}
5152
try {
52-
const result = spawnSync("cmd.exe", ["/d", "/s", "/c", "chcp"], {
53+
const result = spawnSync(getWindowsCmdExePath(), ["/d", "/s", "/c", "chcp"], {
5354
windowsHide: true,
5455
encoding: "utf8",
5556
stdio: ["ignore", "pipe", "pipe"],

src/infra/windows-install-roots.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest";
33
import {
44
privateTestApi,
55
resetWindowsInstallRootsForTests,
6+
getWindowsCmdExePath,
67
getWindowsInstallRoots,
78
getWindowsProgramFilesRoots,
89
normalizeWindowsInstallRoot,
@@ -171,6 +172,14 @@ describe("getWindowsProgramFilesRoots", () => {
171172
});
172173
});
173174

175+
describe("getWindowsCmdExePath", () => {
176+
it("resolves cmd.exe from the trusted Windows system root", () => {
177+
expect(getWindowsCmdExePath({ SystemRoot: "D:\\Windows" })).toBe(
178+
"D:\\Windows\\System32\\cmd.exe",
179+
);
180+
});
181+
});
182+
174183
describe("locateWindowsRegExe", () => {
175184
it("uses the fixed Windows system reg.exe candidate", () => {
176185
expect(privateTestApi.getWindowsRegExeCandidates()).toEqual(["C:\\Windows\\System32\\reg.exe"]);

src/infra/windows-install-roots.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,12 @@ export function getWindowsProgramFilesRoots(
234234
return result;
235235
}
236236

237+
export function getWindowsCmdExePath(
238+
env: Record<string, string | undefined> = process.env,
239+
): string {
240+
return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", "cmd.exe");
241+
}
242+
237243
export function resetWindowsInstallRootsForTests(
238244
overrides: WindowsInstallRootsTestOverrides = {},
239245
): void {

src/infra/windows-task-restart.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from "node:os";
44
import path from "node:path";
55
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
66
import { captureFullEnv } from "../test-utils/env.js";
7+
import { getWindowsCmdExePath } from "./windows-install-roots.js";
78

89
const spawnMock = vi.hoisted(() => vi.fn());
910
const resolvePreferredOpenClawTmpDirMock = vi.hoisted(() => vi.fn(() => os.tmpdir()));
@@ -100,13 +101,14 @@ describe("relaunchGatewayScheduledTask", () => {
100101
});
101102

102103
const result = relaunchGatewayScheduledTask({ OPENCLAW_PROFILE: "work" });
104+
const cmdExePath = getWindowsCmdExePath();
103105

104106
expect(result.ok).toBe(true);
105107
expect(result.method).toBe("schtasks");
106108
expect(result.tried).toContain('schtasks /Run /TN "OpenClaw Gateway (work)"');
107-
expect(result.tried).toContain(`cmd.exe /d /s /c ${seenCommandArg}`);
109+
expect(result.tried).toContain(`${cmdExePath} /d /s /c ${seenCommandArg}`);
108110
const spawnCall = requireFirstMockCall(spawnMock, "restart helper spawn");
109-
expect(spawnCall[0]).toBe("cmd.exe");
111+
expect(spawnCall[0]).toBe(cmdExePath);
110112
expect(spawnCall[1]).toStrictEqual(["/d", "/s", "/c", seenCommandArg]);
111113
expect(spawnCall[2]).toStrictEqual({
112114
detached: true,
@@ -200,7 +202,7 @@ describe("relaunchGatewayScheduledTask", () => {
200202
if (typeof commandArg !== "string") {
201203
throw new Error("expected quoted restart helper path");
202204
}
203-
expect(spawnCall[0]).toBe("cmd.exe");
205+
expect(spawnCall[0]).toBe(getWindowsCmdExePath());
204206
expect(commandArgs).toStrictEqual(["/d", "/s", "/c", commandArg]);
205207
expect(commandArg.startsWith('"')).toBe(true);
206208
expect(commandArg.endsWith('"')).toBe(true);
@@ -231,7 +233,7 @@ describe("relaunchGatewayScheduledTask", () => {
231233
const script = fs.readFileSync(scriptPath, "utf8");
232234
expect(script).toContain(`schtasks /Query /TN`);
233235
expect(script).toContain(":fallback");
234-
expect(script).toContain(`start "" /min cmd.exe /d /c`);
236+
expect(script).toContain(`start "" /min ${getWindowsCmdExePath()} /d /c`);
235237
expect(script).toContain(taskScriptPath);
236238
});
237239
});

src/infra/windows-task-restart.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { resolveTaskScriptPath } from "../daemon/schtasks.js";
1010
import { formatErrorMessage } from "./errors.js";
1111
import type { RestartAttempt } from "./restart.types.js";
1212
import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
13+
import { getWindowsCmdExePath } from "./windows-install-roots.js";
1314

1415
const TASK_RESTART_RETRY_LIMIT = 12;
1516
const TASK_RESTART_RETRY_DELAY_SEC = 1;
@@ -61,7 +62,12 @@ function buildScheduledTaskRestartScript(params: {
6162
];
6263
if (taskScriptPath) {
6364
const quotedScript = quoteCmdScriptArg(taskScriptPath);
64-
lines.push(`if exist ${quotedScript} (`, ` start "" /min cmd.exe /d /c ${quotedScript}`, ")");
65+
const quotedCmd = quoteCmdScriptArg(getWindowsCmdExePath());
66+
lines.push(
67+
`if exist ${quotedScript} (`,
68+
` start "" /min ${quotedCmd} /d /c ${quotedScript}`,
69+
")",
70+
);
6571
}
6672
lines.push(
6773
":cleanup",
@@ -91,7 +97,8 @@ export function relaunchGatewayScheduledTask(env: NodeJS.ProcessEnv = process.en
9197
})}\r\n`,
9298
"utf8",
9399
);
94-
const child = spawn("cmd.exe", ["/d", "/s", "/c", quotedScriptPath], {
100+
const cmdExePath = getWindowsCmdExePath();
101+
const child = spawn(cmdExePath, ["/d", "/s", "/c", quotedScriptPath], {
95102
detached: true,
96103
stdio: "ignore",
97104
windowsHide: true,
@@ -100,7 +107,7 @@ export function relaunchGatewayScheduledTask(env: NodeJS.ProcessEnv = process.en
100107
return {
101108
ok: true,
102109
method: "schtasks",
103-
tried: [`schtasks /Run /TN "${taskName}"`, `cmd.exe /d /s /c ${quotedScriptPath}`],
110+
tried: [`schtasks /Run /TN "${taskName}"`, `${cmdExePath} /d /s /c ${quotedScriptPath}`],
104111
};
105112
} catch (err) {
106113
try {

0 commit comments

Comments
 (0)