diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index 571739c9ecaa..ea563fd4ce94 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -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 () => { diff --git a/src/cli/update-cli/restart-helper.ts b/src/cli/update-cli/restart-helper.ts index bb4d5219a536..681e511f27ac 100644 --- a/src/cli/update-cli/restart-helper.ts +++ b/src/cli/update-cli/restart-helper.ts @@ -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 { 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 { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index a96817e827a7..4b001f76710b 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -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 } : {}); } diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index ad855098a7fa..c44578645e6d 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -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(); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 1573f8608929..16b8900c80e3 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -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 { return; } - const child = spawn("cmd.exe", ["/d", "/c", scriptPath], { + const child = spawn(getWindowsCmdExePath(), ["/d", "/c", scriptPath], { detached: true, stdio: "ignore", windowsHide: true, diff --git a/src/infra/windows-encoding.ts b/src/infra/windows-encoding.ts index 14f7dc2d3088..c48625cabd2e 100644 --- a/src/infra/windows-encoding.ts +++ b/src/infra/windows-encoding.ts @@ -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 = { 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"], diff --git a/src/infra/windows-install-roots.test.ts b/src/infra/windows-install-roots.test.ts index b7101de29ee2..c13a14e7d299 100644 --- a/src/infra/windows-install-roots.test.ts +++ b/src/infra/windows-install-roots.test.ts @@ -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"]); diff --git a/src/infra/windows-install-roots.ts b/src/infra/windows-install-roots.ts index e69e48112c5e..93f9abf98b33 100644 --- a/src/infra/windows-install-roots.ts +++ b/src/infra/windows-install-roots.ts @@ -234,6 +234,12 @@ export function getWindowsProgramFilesRoots( return result; } +export function getWindowsCmdExePath( + env: Record = process.env, +): string { + return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", "cmd.exe"); +} + export function resetWindowsInstallRootsForTests( overrides: WindowsInstallRootsTestOverrides = {}, ): void { diff --git a/src/infra/windows-task-restart.test.ts b/src/infra/windows-task-restart.test.ts index 92021f3f18dc..1627839364e0 100644 --- a/src/infra/windows-task-restart.test.ts +++ b/src/infra/windows-task-restart.test.ts @@ -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); }); }); diff --git a/src/infra/windows-task-restart.ts b/src/infra/windows-task-restart.ts index 6d96534f94a9..82806a0c8390 100644 --- a/src/infra/windows-task-restart.ts +++ b/src/infra/windows-task-restart.ts @@ -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 { diff --git a/src/process/windows-command.ts b/src/process/windows-command.ts index 295f7c7a5f25..bfe1b7945f83 100644 --- a/src/process/windows-command.ts +++ b/src/process/windows-command.ts @@ -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(); } /**