From e9b694ef9cd8b7528a76d85b8be2a830b296a5e1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 21 Jun 2026 10:46:27 +0200 Subject: [PATCH] fix(windows): resolve process inspection tools --- src/cli/ports.ts | 5 +++- src/cli/program.force.test.ts | 6 ++++ src/infra/gateway-processes.test.ts | 37 +++++++++++++++++++++++++ src/infra/windows-install-roots.test.ts | 26 ++++++++++++++++- src/infra/windows-install-roots.ts | 33 +++++++++++++++++++++- src/infra/windows-port-pids.ts | 13 ++++++--- 6 files changed, 113 insertions(+), 7 deletions(-) diff --git a/src/cli/ports.ts b/src/cli/ports.ts index 80f597a6d8c8..cfd72f684422 100644 --- a/src/cli/ports.ts +++ b/src/cli/ports.ts @@ -4,6 +4,7 @@ import { createServer } from "node:net"; import { formatErrorMessage } from "../infra/errors.js"; import { resolveLsofCommandSync } from "../infra/ports-lsof.js"; import { tryListenOnPort } from "../infra/ports-probe.js"; +import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js"; import { resolvePositiveTimerTimeoutMs, resolveTimerTimeoutMs } from "../shared/number-coercion.js"; import { sleep } from "../utils.js"; @@ -165,7 +166,9 @@ export function parseLsofOutput(output: string): PortProcess[] { export function listPortListeners(port: number): PortProcess[] { if (process.platform === "win32") { try { - const out = execFileSync("netstat", ["-ano", "-p", "TCP"], { encoding: "utf-8" }); + const out = execFileSync(getWindowsSystem32ExePath("netstat.exe"), ["-ano", "-p", "TCP"], { + encoding: "utf-8", + }); const lines = out.split(/\r?\n/).filter(Boolean); const results: PortProcess[] = []; for (const line of lines) { diff --git a/src/cli/program.force.test.ts b/src/cli/program.force.test.ts index 63e1e774f7f4..53d44174ddac 100644 --- a/src/cli/program.force.test.ts +++ b/src/cli/program.force.test.ts @@ -16,6 +16,7 @@ vi.mock("../infra/ports-probe.js", () => ({ })); import { execFileSync } from "node:child_process"; +import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js"; import { forceFreePort, forceFreePortAndWait, @@ -301,6 +302,11 @@ describe("gateway --force helpers (Windows netstat path)", () => { it("parses PIDs from netstat output correctly", () => { (execFileSync as unknown as Mock).mockReturnValue(makeNetstatOutput(18789, 42, 99)); expect(listPortListeners(18789)).toEqual([{ pid: 42 }, { pid: 99 }]); + expect(execFileSync).toHaveBeenCalledWith( + getWindowsSystem32ExePath("netstat.exe"), + ["-ano", "-p", "TCP"], + { encoding: "utf-8" }, + ); }); it("does not incorrectly match a port that is a substring (e.g. 80 vs 8080)", () => { diff --git a/src/infra/gateway-processes.test.ts b/src/infra/gateway-processes.test.ts index 389927a40328..c7fae9acad67 100644 --- a/src/infra/gateway-processes.test.ts +++ b/src/infra/gateway-processes.test.ts @@ -1,6 +1,11 @@ // Covers gateway process discovery across platform process listings. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mockProcessPlatform } from "../test-utils/vitest-spies.js"; +import { + getWindowsPowerShellExePath, + getWindowsSystem32ExePath, + getWindowsWmicExePath, +} from "./windows-install-roots.js"; const spawnSyncMock = vi.hoisted(() => vi.fn()); const readFileSyncMock = vi.hoisted(() => vi.fn()); @@ -130,6 +135,8 @@ describe("gateway-processes", () => { parseCmdScriptCommandLineMock.mockReturnValue(["node.exe", "gateway", "run"]); expect(readGatewayProcessArgsSync(77)).toEqual(["node.exe", "gateway", "run"]); + expect(spawnSyncMock.mock.calls[0]?.[0]).toBe(getWindowsPowerShellExePath()); + expect(spawnSyncMock.mock.calls[1]?.[0]).toBe(getWindowsWmicExePath()); expect(parseCmdScriptCommandLineMock).toHaveBeenCalledWith("node.exe gateway run"); }); @@ -177,6 +184,36 @@ describe("gateway-processes", () => { expect(findVerifiedGatewayListenerPidsOnPortSync(18789)).toEqual([200]); }); + it("falls back from powershell to trusted netstat for windows listener pids", () => { + setPlatform("win32"); + spawnSyncMock + .mockReturnValueOnce({ + error: new Error("powershell missing"), + status: null, + stdout: "", + }) + .mockReturnValueOnce({ + error: null, + status: 0, + stdout: [ + "Proto Local Address Foreign Address State PID", + "TCP 0.0.0.0:18789 0.0.0.0:0 LISTENING 200", + ].join("\r\n"), + }) + .mockReturnValueOnce({ + error: null, + status: 0, + stdout: "node.exe gateway run", + }); + parseCmdScriptCommandLineMock.mockReturnValue(["node.exe", "gateway", "run"]); + isGatewayArgvMock.mockReturnValue(true); + + expect(findVerifiedGatewayListenerPidsOnPortSync(18789)).toEqual([200]); + expect(spawnSyncMock.mock.calls[0]?.[0]).toBe(getWindowsPowerShellExePath()); + expect(spawnSyncMock.mock.calls[1]?.[0]).toBe(getWindowsSystem32ExePath("netstat.exe")); + expect(spawnSyncMock.mock.calls[2]?.[0]).toBe(getWindowsPowerShellExePath()); + }); + it("formats pid lists as comma-separated output", () => { expect(formatGatewayPidList([1, 2, 3])).toBe("1, 2, 3"); }); diff --git a/src/infra/windows-install-roots.test.ts b/src/infra/windows-install-roots.test.ts index c13a14e7d299..f913209b5814 100644 --- a/src/infra/windows-install-roots.test.ts +++ b/src/infra/windows-install-roots.test.ts @@ -5,7 +5,10 @@ import { resetWindowsInstallRootsForTests, getWindowsCmdExePath, getWindowsInstallRoots, + getWindowsPowerShellExePath, getWindowsProgramFilesRoots, + getWindowsSystem32ExePath, + getWindowsWmicExePath, normalizeWindowsInstallRoot, } from "./windows-install-roots.js"; @@ -172,12 +175,33 @@ describe("getWindowsProgramFilesRoots", () => { }); }); -describe("getWindowsCmdExePath", () => { +describe("Windows system executable helpers", () => { it("resolves cmd.exe from the trusted Windows system root", () => { expect(getWindowsCmdExePath({ SystemRoot: "D:\\Windows" })).toBe( "D:\\Windows\\System32\\cmd.exe", ); }); + + it("resolves trusted Windows process-inspection tools", () => { + const env = { SystemRoot: "D:\\Windows" }; + + expect(getWindowsSystem32ExePath("netstat.exe", env)).toBe( + "D:\\Windows\\System32\\netstat.exe", + ); + expect(getWindowsPowerShellExePath(env)).toBe( + "D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + expect(getWindowsWmicExePath(env)).toBe("D:\\Windows\\System32\\wbem\\wmic.exe"); + }); + + it("rejects unsafe System32 executable names", () => { + expect(() => getWindowsSystem32ExePath("..\\netstat.exe")).toThrow( + /Invalid Windows System32 executable name/u, + ); + expect(() => getWindowsSystem32ExePath("netstat")).toThrow( + /Invalid Windows System32 executable name/u, + ); + }); }); describe("locateWindowsRegExe", () => { diff --git a/src/infra/windows-install-roots.ts b/src/infra/windows-install-roots.ts index 93f9abf98b33..1dd055a979e4 100644 --- a/src/infra/windows-install-roots.ts +++ b/src/infra/windows-install-roots.ts @@ -237,7 +237,38 @@ export function getWindowsProgramFilesRoots( export function getWindowsCmdExePath( env: Record = process.env, ): string { - return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", "cmd.exe"); + return getWindowsSystem32ExePath("cmd.exe", env); +} + +export function getWindowsSystem32ExePath( + executableName: string, + env: Record = process.env, +): string { + if ( + path.win32.basename(executableName) !== executableName || + !/^[A-Za-z0-9_.-]+\.exe$/u.test(executableName) + ) { + throw new Error(`Invalid Windows System32 executable name: ${executableName}`); + } + return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", executableName); +} + +export function getWindowsPowerShellExePath( + env: Record = process.env, +): string { + return path.win32.join( + getWindowsInstallRoots(env).systemRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + +export function getWindowsWmicExePath( + env: Record = process.env, +): string { + return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", "wbem", "wmic.exe"); } export function resetWindowsInstallRootsForTests( diff --git a/src/infra/windows-port-pids.ts b/src/infra/windows-port-pids.ts index b73596f223e6..bb1d0614f848 100644 --- a/src/infra/windows-port-pids.ts +++ b/src/infra/windows-port-pids.ts @@ -4,6 +4,11 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { parseCmdScriptCommandLine } from "../daemon/cmd-argv.js"; import { parseStrictPositiveInteger } from "./parse-finite-number.js"; +import { + getWindowsPowerShellExePath, + getWindowsSystem32ExePath, + getWindowsWmicExePath, +} from "./windows-install-roots.js"; const DEFAULT_TIMEOUT_MS = 5_000; @@ -21,7 +26,7 @@ export type WindowsProcessArgsResult = function readListeningPidsViaPowerShell(port: number, timeoutMs: number): number[] | null { const ps = spawnSync( - "powershell", + getWindowsPowerShellExePath(), [ "-NoProfile", "-Command", @@ -71,7 +76,7 @@ export function readWindowsListeningPidsResultSync( if (powershellPids != null) { return { ok: true, pids: powershellPids }; } - const netstat = spawnSync("netstat", ["-ano", "-p", "tcp"], { + const netstat = spawnSync(getWindowsSystem32ExePath("netstat.exe"), ["-ano", "-p", "tcp"], { encoding: "utf8", timeout: timeoutMs, windowsHide: true, @@ -115,7 +120,7 @@ export function readWindowsProcessArgsResultSync( timeoutMs = DEFAULT_TIMEOUT_MS, ): WindowsProcessArgsResult { const powershell = spawnSync( - "powershell", + getWindowsPowerShellExePath(), [ "-NoProfile", "-Command", @@ -132,7 +137,7 @@ export function readWindowsProcessArgsResultSync( return { ok: true, args: command ? parseCmdScriptCommandLine(command) : null }; } const wmic = spawnSync( - "wmic", + getWindowsWmicExePath(), ["process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/value"], { encoding: "utf8",