fix(windows): resolve process inspection tools

This commit is contained in:
Vincent Koc
2026-06-21 10:46:27 +02:00
parent 3b332fd0a4
commit e9b694ef9c
6 changed files with 113 additions and 7 deletions
+4 -1
View File
@@ -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) {
+6
View File
@@ -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<PortProcess[]>([{ 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)", () => {
+37
View File
@@ -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");
});
+25 -1
View File
@@ -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", () => {
+32 -1
View File
@@ -237,7 +237,38 @@ export function getWindowsProgramFilesRoots(
export function getWindowsCmdExePath(
env: Record<string, string | undefined> = 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<string, string | undefined> = 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<string, string | undefined> = process.env,
): string {
return path.win32.join(
getWindowsInstallRoots(env).systemRoot,
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
);
}
export function getWindowsWmicExePath(
env: Record<string, string | undefined> = process.env,
): string {
return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", "wbem", "wmic.exe");
}
export function resetWindowsInstallRootsForTests(
+9 -4
View File
@@ -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",