fix(windows): resolve cmd handoff path

This commit is contained in:
Vincent Koc
2026-06-21 10:32:35 +02:00
parent 675c56692a
commit 7dd01d15c5
11 changed files with 56 additions and 22 deletions
+11 -6
View File
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getWindowsCmdExePath } from "../../infra/windows-install-roots.js";
import { prepareRestartScript, runRestartScript } from "./restart-helper.js";
vi.mock("node:child_process", async () => {
@@ -616,7 +617,7 @@ exit 0
await runRestartScript(scriptPath);
expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", scriptPath], {
expect(spawn).toHaveBeenCalledWith(getWindowsCmdExePath(), ["/d", "/s", "/c", scriptPath], {
detached: true,
stdio: "ignore",
windowsHide: true,
@@ -633,11 +634,15 @@ exit 0
await runRestartScript(scriptPath);
expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", `"${scriptPath}"`], {
detached: true,
stdio: "ignore",
windowsHide: true,
});
expect(spawn).toHaveBeenCalledWith(
getWindowsCmdExePath(),
["/d", "/s", "/c", `"${scriptPath}"`],
{
detached: true,
stdio: "ignore",
windowsHide: true,
},
);
});
it("does not throw when spawn fails synchronously", async () => {
+2 -1
View File
@@ -16,6 +16,7 @@ import {
resolveGatewayRestartLogPath,
shellEscapeRestartLogValue,
} from "../../daemon/restart-logs.js";
import { getWindowsCmdExePath } from "../../infra/windows-install-roots.js";
/**
* Shell-escape a string for embedding in single-quoted shell arguments.
@@ -404,7 +405,7 @@ exit $status
*/
export async function runRestartScript(scriptPath: string): Promise<void> {
const isWindows = process.platform === "win32";
const file = isWindows ? "cmd.exe" : "/bin/sh";
const file = isWindows ? getWindowsCmdExePath() : "/bin/sh";
const args = isWindows ? ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)] : [scriptPath];
try {
+2 -1
View File
@@ -8,6 +8,7 @@ import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-f
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js";
import { parseTcpPort } from "../infra/tcp-port.js";
import { getWindowsCmdExePath } from "../infra/windows-install-roots.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SERVICE_KIND,
@@ -287,7 +288,7 @@ async function execLaunchctl(
args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> {
const isWindows = process.platform === "win32";
const file = isWindows ? (process.env.ComSpec ?? "cmd.exe") : "launchctl";
const file = isWindows ? getWindowsCmdExePath() : "launchctl";
const fileArgs = isWindows ? ["/d", "/s", "/c", "launchctl", ...args] : args;
return await execFileUtf8(file, fileArgs, isWindows ? { windowsHide: true } : {});
}
+2 -1
View File
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getWindowsCmdExePath } from "../infra/windows-install-roots.js";
import "./test-helpers/schtasks-base-mocks.js";
import {
inspectPortUsage,
@@ -292,7 +293,7 @@ describe("Windows startup fallback", () => {
const startupEntryPath = resolveStartupEntryPath(env);
const startupScript = await fs.readFile(startupEntryPath, "utf8");
expect(result.scriptPath).toBe(resolveTaskScriptPath(env));
expect(startupScript).toContain('start "" /min cmd.exe /d /c');
expect(startupScript).toContain(`start "" /min ${getWindowsCmdExePath()} /d /c`);
expect(startupScript).toContain("gateway.cmd");
expectStartupFallbackSpawn();
expect(childUnref).toHaveBeenCalled();
+4 -3
View File
@@ -9,7 +9,7 @@ import { isGatewayArgv } from "../infra/gateway-process-argv.js";
import { findVerifiedGatewayListenerPidsOnPortSync } from "../infra/gateway-processes.js";
import { inspectPortUsage } from "../infra/ports.js";
import { parseTcpPort } from "../infra/tcp-port.js";
import { getWindowsInstallRoots } from "../infra/windows-install-roots.js";
import { getWindowsCmdExePath, getWindowsInstallRoots } from "../infra/windows-install-roots.js";
import { killProcessTree } from "../process/kill-tree.js";
import { sleep } from "../utils.js";
import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js";
@@ -414,7 +414,8 @@ function buildTaskScript({
}
function renderStartupLaunchCommand(scriptPath: string): string {
return `start "" /min cmd.exe /d /c ${quoteCmdScriptArg(scriptPath)}`;
const cmdExePath = quoteCmdScriptArg(getWindowsCmdExePath());
return `start "" /min ${cmdExePath} /d /c ${quoteCmdScriptArg(scriptPath)}`;
}
function buildStartupLauncherScript(params: { description?: string; scriptPath: string }): string {
@@ -497,7 +498,7 @@ async function launchFallbackTaskScript(env: GatewayServiceEnv): Promise<void> {
return;
}
const child = spawn("cmd.exe", ["/d", "/c", scriptPath], {
const child = spawn(getWindowsCmdExePath(), ["/d", "/c", scriptPath], {
detached: true,
stdio: "ignore",
windowsHide: true,
+2 -1
View File
@@ -1,6 +1,7 @@
// Detects and decodes Windows console output encodings.
import { spawnSync } from "node:child_process";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { getWindowsCmdExePath } from "./windows-install-roots.js";
const WINDOWS_CODEPAGE_ENCODING_MAP: Record<number, string> = {
65001: "utf-8",
@@ -49,7 +50,7 @@ export function resolveWindowsConsoleEncoding(): string | null {
return cachedWindowsConsoleEncoding;
}
try {
const result = spawnSync("cmd.exe", ["/d", "/s", "/c", "chcp"], {
const result = spawnSync(getWindowsCmdExePath(), ["/d", "/s", "/c", "chcp"], {
windowsHide: true,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
+9
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
privateTestApi,
resetWindowsInstallRootsForTests,
getWindowsCmdExePath,
getWindowsInstallRoots,
getWindowsProgramFilesRoots,
normalizeWindowsInstallRoot,
@@ -171,6 +172,14 @@ describe("getWindowsProgramFilesRoots", () => {
});
});
describe("getWindowsCmdExePath", () => {
it("resolves cmd.exe from the trusted Windows system root", () => {
expect(getWindowsCmdExePath({ SystemRoot: "D:\\Windows" })).toBe(
"D:\\Windows\\System32\\cmd.exe",
);
});
});
describe("locateWindowsRegExe", () => {
it("uses the fixed Windows system reg.exe candidate", () => {
expect(privateTestApi.getWindowsRegExeCandidates()).toEqual(["C:\\Windows\\System32\\reg.exe"]);
+6
View File
@@ -234,6 +234,12 @@ export function getWindowsProgramFilesRoots(
return result;
}
export function getWindowsCmdExePath(
env: Record<string, string | undefined> = process.env,
): string {
return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", "cmd.exe");
}
export function resetWindowsInstallRootsForTests(
overrides: WindowsInstallRootsTestOverrides = {},
): void {
+6 -4
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { captureFullEnv } from "../test-utils/env.js";
import { getWindowsCmdExePath } from "./windows-install-roots.js";
const spawnMock = vi.hoisted(() => vi.fn());
const resolvePreferredOpenClawTmpDirMock = vi.hoisted(() => vi.fn(() => os.tmpdir()));
@@ -100,13 +101,14 @@ describe("relaunchGatewayScheduledTask", () => {
});
const result = relaunchGatewayScheduledTask({ OPENCLAW_PROFILE: "work" });
const cmdExePath = getWindowsCmdExePath();
expect(result.ok).toBe(true);
expect(result.method).toBe("schtasks");
expect(result.tried).toContain('schtasks /Run /TN "OpenClaw Gateway (work)"');
expect(result.tried).toContain(`cmd.exe /d /s /c ${seenCommandArg}`);
expect(result.tried).toContain(`${cmdExePath} /d /s /c ${seenCommandArg}`);
const spawnCall = requireFirstMockCall(spawnMock, "restart helper spawn");
expect(spawnCall[0]).toBe("cmd.exe");
expect(spawnCall[0]).toBe(cmdExePath);
expect(spawnCall[1]).toStrictEqual(["/d", "/s", "/c", seenCommandArg]);
expect(spawnCall[2]).toStrictEqual({
detached: true,
@@ -200,7 +202,7 @@ describe("relaunchGatewayScheduledTask", () => {
if (typeof commandArg !== "string") {
throw new Error("expected quoted restart helper path");
}
expect(spawnCall[0]).toBe("cmd.exe");
expect(spawnCall[0]).toBe(getWindowsCmdExePath());
expect(commandArgs).toStrictEqual(["/d", "/s", "/c", commandArg]);
expect(commandArg.startsWith('"')).toBe(true);
expect(commandArg.endsWith('"')).toBe(true);
@@ -231,7 +233,7 @@ describe("relaunchGatewayScheduledTask", () => {
const script = fs.readFileSync(scriptPath, "utf8");
expect(script).toContain(`schtasks /Query /TN`);
expect(script).toContain(":fallback");
expect(script).toContain(`start "" /min cmd.exe /d /c`);
expect(script).toContain(`start "" /min ${getWindowsCmdExePath()} /d /c`);
expect(script).toContain(taskScriptPath);
});
});
+10 -3
View File
@@ -10,6 +10,7 @@ import { resolveTaskScriptPath } from "../daemon/schtasks.js";
import { formatErrorMessage } from "./errors.js";
import type { RestartAttempt } from "./restart.types.js";
import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
import { getWindowsCmdExePath } from "./windows-install-roots.js";
const TASK_RESTART_RETRY_LIMIT = 12;
const TASK_RESTART_RETRY_DELAY_SEC = 1;
@@ -61,7 +62,12 @@ function buildScheduledTaskRestartScript(params: {
];
if (taskScriptPath) {
const quotedScript = quoteCmdScriptArg(taskScriptPath);
lines.push(`if exist ${quotedScript} (`, ` start "" /min cmd.exe /d /c ${quotedScript}`, ")");
const quotedCmd = quoteCmdScriptArg(getWindowsCmdExePath());
lines.push(
`if exist ${quotedScript} (`,
` start "" /min ${quotedCmd} /d /c ${quotedScript}`,
")",
);
}
lines.push(
":cleanup",
@@ -91,7 +97,8 @@ export function relaunchGatewayScheduledTask(env: NodeJS.ProcessEnv = process.en
})}\r\n`,
"utf8",
);
const child = spawn("cmd.exe", ["/d", "/s", "/c", quotedScriptPath], {
const cmdExePath = getWindowsCmdExePath();
const child = spawn(cmdExePath, ["/d", "/s", "/c", quotedScriptPath], {
detached: true,
stdio: "ignore",
windowsHide: true,
@@ -100,7 +107,7 @@ export function relaunchGatewayScheduledTask(env: NodeJS.ProcessEnv = process.en
return {
ok: true,
method: "schtasks",
tried: [`schtasks /Run /TN "${taskName}"`, `cmd.exe /d /s /c ${quotedScriptPath}`],
tried: [`schtasks /Run /TN "${taskName}"`, `${cmdExePath} /d /s /c ${quotedScriptPath}`],
};
} catch (err) {
try {
+2 -2
View File
@@ -2,7 +2,7 @@
import path from "node:path";
import process from "node:process";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { getWindowsInstallRoots } from "../infra/windows-install-roots.js";
import { getWindowsCmdExePath } from "../infra/windows-install-roots.js";
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
@@ -41,7 +41,7 @@ export function resolveTrustedWindowsCmdExe(platform: NodeJS.Platform = process.
if (platform !== "win32") {
return "cmd.exe";
}
return path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe");
return getWindowsCmdExePath();
}
/**