Skip to content

Commit 10c77be

Browse files
committed
fix(process): honor nonblank Windows env aliases
1 parent 7c896d7 commit 10c77be

5 files changed

Lines changed: 142 additions & 21 deletions

File tree

src/infra/executable-path.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,37 @@ function isDriveLessWindowsRootedPath(value: string): boolean {
88
return process.platform === "win32" && /^:[\\/]/.test(value);
99
}
1010

11-
function resolveEnvironmentValue(
11+
export function resolveWindowsEnvironmentValue(
1212
env: NodeJS.ProcessEnv | undefined,
1313
name: string,
1414
): string | undefined {
1515
if (!env) {
1616
return undefined;
1717
}
18-
const exactValue = env[name] ?? (name === "PATH" ? env.Path : undefined);
19-
if (exactValue !== undefined) {
20-
return exactValue;
18+
const titleCaseName = `${name.slice(0, 1)}${name.slice(1).toLowerCase()}`;
19+
const matches: Array<[string, string]> = [];
20+
for (const [key, value] of Object.entries(env)) {
21+
if (key.toUpperCase() === name && value !== undefined) {
22+
matches.push([key, value]);
23+
}
2124
}
22-
if (process.platform !== "win32") {
23-
return undefined;
25+
const priority = (key: string) => (key === name ? 0 : key === titleCaseName ? 1 : 2);
26+
matches.sort(([leftKey], [rightKey]) => priority(leftKey) - priority(rightKey));
27+
28+
// Plain ProcessEnv-shaped objects can contain duplicate Windows aliases.
29+
// Prefer the first usable value, but keep an all-blank set authoritative so
30+
// an intentionally empty child search path never falls back to the host.
31+
return matches.find(([, value]) => value.trim().length > 0)?.[1] ?? matches[0]?.[1];
32+
}
33+
34+
function resolveEnvironmentValue(
35+
env: NodeJS.ProcessEnv | undefined,
36+
name: string,
37+
): string | undefined {
38+
if (process.platform === "win32") {
39+
return resolveWindowsEnvironmentValue(env, name);
2440
}
25-
const normalizedName = name.toLowerCase();
26-
return Object.entries(env).find(([key]) => key.toLowerCase() === normalizedName)?.[1];
41+
return env?.[name] ?? (name === "PATH" ? env?.Path : undefined);
2742
}
2843

2944
export function resolveExecutablePathCandidate(

src/plugin-sdk/windows-spawn.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { writeFile } from "node:fs/promises";
55
import path from "node:path";
66
import { describe, expect, it } from "vitest";
77
import { createPluginSdkTestHarness } from "./test-helpers.js";
8-
import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram } from "./windows-spawn.js";
8+
import {
9+
materializeWindowsSpawnProgram,
10+
resolveWindowsExecutablePath,
11+
resolveWindowsSpawnProgram,
12+
} from "./windows-spawn.js";
913

1014
const { createTempDir } = createPluginSdkTestHarness({
1115
cleanup: {
@@ -15,6 +19,55 @@ const { createTempDir } = createPluginSdkTestHarness({
1519
});
1620

1721
describe("resolveWindowsSpawnProgram", () => {
22+
it("uses a nonblank arbitrary-case PATH alias", async () => {
23+
const dir = await createTempDir("openclaw-windows-spawn-test-");
24+
const shimPath = path.join(dir, "tool.CMD");
25+
await writeFile(shimPath, "@ECHO off\r\n", "utf8");
26+
27+
expect(
28+
resolveWindowsExecutablePath("tool", {
29+
PATH: "",
30+
pAtH: dir,
31+
PATHEXT: ".CMD",
32+
}),
33+
).toBe(shimPath);
34+
});
35+
36+
it("uses a nonblank arbitrary-case PATHEXT alias", async () => {
37+
const dir = await createTempDir("openclaw-windows-spawn-test-");
38+
const shimPath = path.join(dir, "tool.CMD");
39+
await writeFile(shimPath, "@ECHO off\r\n", "utf8");
40+
41+
expect(
42+
resolveWindowsExecutablePath("tool", {
43+
PATH: dir,
44+
PATHEXT: "",
45+
pAtHeXt: ".CMD",
46+
}),
47+
).toBe(shimPath);
48+
});
49+
50+
it("preserves canonical alias precedence across duplicate casings", async () => {
51+
const canonicalDir = await createTempDir("openclaw-windows-spawn-canonical-");
52+
const aliasDir = await createTempDir("openclaw-windows-spawn-alias-");
53+
const canonicalShimPath = path.join(canonicalDir, "tool.CMD");
54+
await writeFile(canonicalShimPath, "@ECHO off\r\n", "utf8");
55+
await writeFile(path.join(aliasDir, "tool.EXE"), "", "utf8");
56+
57+
expect(
58+
resolveWindowsExecutablePath("tool", {
59+
Path: aliasDir,
60+
PATH: canonicalDir,
61+
Pathext: ".EXE",
62+
PATHEXT: ".CMD",
63+
}),
64+
).toBe(canonicalShimPath);
65+
});
66+
67+
it("preserves an explicitly empty Windows search path", () => {
68+
expect(resolveWindowsExecutablePath("node", { PATH: "", PATHEXT: ".EXE" })).toBe("node");
69+
});
70+
1871
it("rejects node command strings that include inline entrypoint arguments on Windows", () => {
1972
expect(() =>
2073
resolveWindowsSpawnProgram({

src/plugin-sdk/windows-spawn.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,44 @@ function isFilePath(candidate: string): boolean {
9393
}
9494
}
9595

96+
function readCaseInsensitiveEnvironmentValue(
97+
env: NodeJS.ProcessEnv,
98+
uppercaseKey: string,
99+
): { found: boolean; value?: string } {
100+
const titleCaseKey = `${uppercaseKey.slice(0, 1)}${uppercaseKey.slice(1).toLowerCase()}`;
101+
const matchingEntries: Array<[string, string]> = [];
102+
for (const [candidateKey, value] of Object.entries(env)) {
103+
if (candidateKey.toUpperCase() === uppercaseKey && value !== undefined) {
104+
matchingEntries.push([candidateKey, value]);
105+
}
106+
}
107+
const priority = (candidateKey: string) =>
108+
candidateKey === uppercaseKey ? 0 : candidateKey === titleCaseKey ? 1 : 2;
109+
matchingEntries.sort(([leftKey], [rightKey]) => {
110+
return priority(leftKey) - priority(rightKey);
111+
});
112+
113+
let firstDefined: string | undefined;
114+
for (const [, value] of matchingEntries) {
115+
firstDefined ??= value;
116+
if (value.trim().length > 0) {
117+
return { found: true, value };
118+
}
119+
}
120+
return firstDefined === undefined ? { found: false } : { found: true, value: firstDefined };
121+
}
122+
123+
function resolveEnvironmentValue(
124+
env: NodeJS.ProcessEnv,
125+
fallbackEnv: NodeJS.ProcessEnv,
126+
key: string,
127+
): string | undefined {
128+
const configured = readCaseInsensitiveEnvironmentValue(env, key);
129+
return configured.found
130+
? configured.value
131+
: readCaseInsensitiveEnvironmentValue(fallbackEnv, key).value;
132+
}
133+
96134
function readCommandToken(command: string): { token: string; rest: string } | null {
97135
const trimmed = command.trim();
98136
if (!trimmed) {
@@ -143,15 +181,13 @@ export function resolveWindowsExecutablePath(command: string, env: NodeJS.Proces
143181
return command;
144182
}
145183

146-
const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? "";
184+
// Windows env keys are case-insensitive, but ProcessEnv objects can contain
185+
// multiple casings. Prefer a usable sibling without discarding an explicitly
186+
// empty search path when every matching entry is blank.
187+
const pathValue = resolveEnvironmentValue(env, process.env, "PATH") ?? "";
147188
const pathEntries = normalizeStringEntries(pathValue.split(";"));
148189
const hasExtension = path.extname(command).length > 0;
149-
const pathExtRaw =
150-
env.PATHEXT ??
151-
env.Pathext ??
152-
process.env.PATHEXT ??
153-
process.env.Pathext ??
154-
".EXE;.CMD;.BAT;.COM";
190+
const pathExtRaw = resolveEnvironmentValue(env, process.env, "PATHEXT") ?? ".EXE;.CMD;.BAT;.COM";
155191
const pathExt = hasExtension
156192
? [""]
157193
: normalizeStringEntries(pathExtRaw.split(";")).map((ext) =>

src/process/windows-command.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,27 @@ describe("Windows command helpers", () => {
138138
});
139139
});
140140

141+
it("uses nonblank PATH and PATHEXT aliases after blank canonical entries", async () => {
142+
await withTempDir("openclaw-windows-command-env-alias-", async (binDir) => {
143+
const executable = path.join(binDir, "tool.exe");
144+
await writeFile(executable, "");
145+
146+
await withMockedWindowsPlatform(async () => {
147+
expect(
148+
resolveSafeChildProcessInvocation({
149+
argv: ["tool"],
150+
env: {
151+
PATH: "",
152+
pAtH: binDir,
153+
PATHEXT: "",
154+
pAtHeXt: ".EXE",
155+
},
156+
}).command,
157+
).toBe(executable);
158+
});
159+
});
160+
});
161+
141162
it("accepts PATH executables with explicit extensions independently of PATHEXT", async () => {
142163
await withTempDir("openclaw-windows-command-path-extension-", async (binDir) => {
143164
const executable = path.join(binDir, "tool.exe");

src/process/windows-command.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
isRegularFile,
1010
resolveExecutableFromPathEnv,
1111
resolveExecutablePathCandidate,
12+
resolveWindowsEnvironmentValue,
1213
} from "../infra/executable-path.js";
1314
import { getWindowsCmdExePath } from "../infra/windows-install-roots.js";
1415

@@ -44,11 +45,6 @@ function createWindowsCommandNotFoundError(command: string): NodeJS.ErrnoExcepti
4445
return error;
4546
}
4647

47-
function resolveWindowsEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
48-
const normalizedName = name.toLowerCase();
49-
return Object.entries(env).find(([key]) => key.toLowerCase() === normalizedName)?.[1];
50-
}
51-
5248
function resolveWindowsCommandFromCwdOrPath(params: {
5349
command: string;
5450
cwd?: string;

0 commit comments

Comments
 (0)