Skip to content

Commit e9b694e

Browse files
committed
fix(windows): resolve process inspection tools
1 parent 3b332fd commit e9b694e

6 files changed

Lines changed: 113 additions & 7 deletions

File tree

src/cli/ports.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createServer } from "node:net";
44
import { formatErrorMessage } from "../infra/errors.js";
55
import { resolveLsofCommandSync } from "../infra/ports-lsof.js";
66
import { tryListenOnPort } from "../infra/ports-probe.js";
7+
import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js";
78
import { resolvePositiveTimerTimeoutMs, resolveTimerTimeoutMs } from "../shared/number-coercion.js";
89
import { sleep } from "../utils.js";
910

@@ -165,7 +166,9 @@ export function parseLsofOutput(output: string): PortProcess[] {
165166
export function listPortListeners(port: number): PortProcess[] {
166167
if (process.platform === "win32") {
167168
try {
168-
const out = execFileSync("netstat", ["-ano", "-p", "TCP"], { encoding: "utf-8" });
169+
const out = execFileSync(getWindowsSystem32ExePath("netstat.exe"), ["-ano", "-p", "TCP"], {
170+
encoding: "utf-8",
171+
});
169172
const lines = out.split(/\r?\n/).filter(Boolean);
170173
const results: PortProcess[] = [];
171174
for (const line of lines) {

src/cli/program.force.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ vi.mock("../infra/ports-probe.js", () => ({
1616
}));
1717

1818
import { execFileSync } from "node:child_process";
19+
import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js";
1920
import {
2021
forceFreePort,
2122
forceFreePortAndWait,
@@ -301,6 +302,11 @@ describe("gateway --force helpers (Windows netstat path)", () => {
301302
it("parses PIDs from netstat output correctly", () => {
302303
(execFileSync as unknown as Mock).mockReturnValue(makeNetstatOutput(18789, 42, 99));
303304
expect(listPortListeners(18789)).toEqual<PortProcess[]>([{ pid: 42 }, { pid: 99 }]);
305+
expect(execFileSync).toHaveBeenCalledWith(
306+
getWindowsSystem32ExePath("netstat.exe"),
307+
["-ano", "-p", "TCP"],
308+
{ encoding: "utf-8" },
309+
);
304310
});
305311

306312
it("does not incorrectly match a port that is a substring (e.g. 80 vs 8080)", () => {

src/infra/gateway-processes.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Covers gateway process discovery across platform process listings.
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
import { mockProcessPlatform } from "../test-utils/vitest-spies.js";
4+
import {
5+
getWindowsPowerShellExePath,
6+
getWindowsSystem32ExePath,
7+
getWindowsWmicExePath,
8+
} from "./windows-install-roots.js";
49

510
const spawnSyncMock = vi.hoisted(() => vi.fn());
611
const readFileSyncMock = vi.hoisted(() => vi.fn());
@@ -130,6 +135,8 @@ describe("gateway-processes", () => {
130135
parseCmdScriptCommandLineMock.mockReturnValue(["node.exe", "gateway", "run"]);
131136

132137
expect(readGatewayProcessArgsSync(77)).toEqual(["node.exe", "gateway", "run"]);
138+
expect(spawnSyncMock.mock.calls[0]?.[0]).toBe(getWindowsPowerShellExePath());
139+
expect(spawnSyncMock.mock.calls[1]?.[0]).toBe(getWindowsWmicExePath());
133140
expect(parseCmdScriptCommandLineMock).toHaveBeenCalledWith("node.exe gateway run");
134141
});
135142

@@ -177,6 +184,36 @@ describe("gateway-processes", () => {
177184
expect(findVerifiedGatewayListenerPidsOnPortSync(18789)).toEqual([200]);
178185
});
179186

187+
it("falls back from powershell to trusted netstat for windows listener pids", () => {
188+
setPlatform("win32");
189+
spawnSyncMock
190+
.mockReturnValueOnce({
191+
error: new Error("powershell missing"),
192+
status: null,
193+
stdout: "",
194+
})
195+
.mockReturnValueOnce({
196+
error: null,
197+
status: 0,
198+
stdout: [
199+
"Proto Local Address Foreign Address State PID",
200+
"TCP 0.0.0.0:18789 0.0.0.0:0 LISTENING 200",
201+
].join("\r\n"),
202+
})
203+
.mockReturnValueOnce({
204+
error: null,
205+
status: 0,
206+
stdout: "node.exe gateway run",
207+
});
208+
parseCmdScriptCommandLineMock.mockReturnValue(["node.exe", "gateway", "run"]);
209+
isGatewayArgvMock.mockReturnValue(true);
210+
211+
expect(findVerifiedGatewayListenerPidsOnPortSync(18789)).toEqual([200]);
212+
expect(spawnSyncMock.mock.calls[0]?.[0]).toBe(getWindowsPowerShellExePath());
213+
expect(spawnSyncMock.mock.calls[1]?.[0]).toBe(getWindowsSystem32ExePath("netstat.exe"));
214+
expect(spawnSyncMock.mock.calls[2]?.[0]).toBe(getWindowsPowerShellExePath());
215+
});
216+
180217
it("formats pid lists as comma-separated output", () => {
181218
expect(formatGatewayPidList([1, 2, 3])).toBe("1, 2, 3");
182219
});

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
resetWindowsInstallRootsForTests,
66
getWindowsCmdExePath,
77
getWindowsInstallRoots,
8+
getWindowsPowerShellExePath,
89
getWindowsProgramFilesRoots,
10+
getWindowsSystem32ExePath,
11+
getWindowsWmicExePath,
912
normalizeWindowsInstallRoot,
1013
} from "./windows-install-roots.js";
1114

@@ -172,12 +175,33 @@ describe("getWindowsProgramFilesRoots", () => {
172175
});
173176
});
174177

175-
describe("getWindowsCmdExePath", () => {
178+
describe("Windows system executable helpers", () => {
176179
it("resolves cmd.exe from the trusted Windows system root", () => {
177180
expect(getWindowsCmdExePath({ SystemRoot: "D:\\Windows" })).toBe(
178181
"D:\\Windows\\System32\\cmd.exe",
179182
);
180183
});
184+
185+
it("resolves trusted Windows process-inspection tools", () => {
186+
const env = { SystemRoot: "D:\\Windows" };
187+
188+
expect(getWindowsSystem32ExePath("netstat.exe", env)).toBe(
189+
"D:\\Windows\\System32\\netstat.exe",
190+
);
191+
expect(getWindowsPowerShellExePath(env)).toBe(
192+
"D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
193+
);
194+
expect(getWindowsWmicExePath(env)).toBe("D:\\Windows\\System32\\wbem\\wmic.exe");
195+
});
196+
197+
it("rejects unsafe System32 executable names", () => {
198+
expect(() => getWindowsSystem32ExePath("..\\netstat.exe")).toThrow(
199+
/Invalid Windows System32 executable name/u,
200+
);
201+
expect(() => getWindowsSystem32ExePath("netstat")).toThrow(
202+
/Invalid Windows System32 executable name/u,
203+
);
204+
});
181205
});
182206

183207
describe("locateWindowsRegExe", () => {

src/infra/windows-install-roots.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,38 @@ export function getWindowsProgramFilesRoots(
237237
export function getWindowsCmdExePath(
238238
env: Record<string, string | undefined> = process.env,
239239
): string {
240-
return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", "cmd.exe");
240+
return getWindowsSystem32ExePath("cmd.exe", env);
241+
}
242+
243+
export function getWindowsSystem32ExePath(
244+
executableName: string,
245+
env: Record<string, string | undefined> = process.env,
246+
): string {
247+
if (
248+
path.win32.basename(executableName) !== executableName ||
249+
!/^[A-Za-z0-9_.-]+\.exe$/u.test(executableName)
250+
) {
251+
throw new Error(`Invalid Windows System32 executable name: ${executableName}`);
252+
}
253+
return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", executableName);
254+
}
255+
256+
export function getWindowsPowerShellExePath(
257+
env: Record<string, string | undefined> = process.env,
258+
): string {
259+
return path.win32.join(
260+
getWindowsInstallRoots(env).systemRoot,
261+
"System32",
262+
"WindowsPowerShell",
263+
"v1.0",
264+
"powershell.exe",
265+
);
266+
}
267+
268+
export function getWindowsWmicExePath(
269+
env: Record<string, string | undefined> = process.env,
270+
): string {
271+
return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", "wbem", "wmic.exe");
241272
}
242273

243274
export function resetWindowsInstallRootsForTests(

src/infra/windows-port-pids.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
44
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
55
import { parseCmdScriptCommandLine } from "../daemon/cmd-argv.js";
66
import { parseStrictPositiveInteger } from "./parse-finite-number.js";
7+
import {
8+
getWindowsPowerShellExePath,
9+
getWindowsSystem32ExePath,
10+
getWindowsWmicExePath,
11+
} from "./windows-install-roots.js";
712

813
const DEFAULT_TIMEOUT_MS = 5_000;
914

@@ -21,7 +26,7 @@ export type WindowsProcessArgsResult =
2126

2227
function readListeningPidsViaPowerShell(port: number, timeoutMs: number): number[] | null {
2328
const ps = spawnSync(
24-
"powershell",
29+
getWindowsPowerShellExePath(),
2530
[
2631
"-NoProfile",
2732
"-Command",
@@ -71,7 +76,7 @@ export function readWindowsListeningPidsResultSync(
7176
if (powershellPids != null) {
7277
return { ok: true, pids: powershellPids };
7378
}
74-
const netstat = spawnSync("netstat", ["-ano", "-p", "tcp"], {
79+
const netstat = spawnSync(getWindowsSystem32ExePath("netstat.exe"), ["-ano", "-p", "tcp"], {
7580
encoding: "utf8",
7681
timeout: timeoutMs,
7782
windowsHide: true,
@@ -115,7 +120,7 @@ export function readWindowsProcessArgsResultSync(
115120
timeoutMs = DEFAULT_TIMEOUT_MS,
116121
): WindowsProcessArgsResult {
117122
const powershell = spawnSync(
118-
"powershell",
123+
getWindowsPowerShellExePath(),
119124
[
120125
"-NoProfile",
121126
"-Command",
@@ -132,7 +137,7 @@ export function readWindowsProcessArgsResultSync(
132137
return { ok: true, args: command ? parseCmdScriptCommandLine(command) : null };
133138
}
134139
const wmic = spawnSync(
135-
"wmic",
140+
getWindowsWmicExePath(),
136141
["process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/value"],
137142
{
138143
encoding: "utf8",

0 commit comments

Comments
 (0)