mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:51:39 +00:00
* fix(core): propagate caller env PATHEXT through isExecutableFile on Windows isExecutableFile hardcoded undefined when calling resolveWindowsExecutableExtSet, ignoring any caller-provided custom env.PATHEXT. This meant resolveExecutablePath and resolveExecutableFromPathEnv would fall back to process.env.PATHEXT even when the caller supplied a different env with an extended PATHEXT (e.g. .PS1). - Add optional options.env parameter to isExecutableFile - resolveWindowsExecutableExtSet now reads from options?.env - All 3 callers pass their available env through Affects Windows deployments using sandbox/container environments where PATHEXT differs from process.env (Docker Windows containers, CI runners, tests). Fully backward compatible: undefined env falls back to process.env.PATHEXT. * fix(core): also propagate caller env PATHEXT in node-host invoke resolver - Fix sibling system.which resolver in src/node-host/invoke.ts:378 to use caller env PATHEXT instead of process.env only - Add comprehensive Windows-mocked tests for caller env PATHEXT propagation through isExecutableFile, resolveExecutableFromPathEnv, and resolveExecutablePath - Tests cover: custom env accepted, fallback to process.env, path-separator and PATH-based resolution paths * fix(core): also propagate caller env PATHEXT through node-host invoke resolver - Add env?.PathExt and process.env.PathExt casing to both resolveWindowsExecutableExtSet and resolveWindowsExecutableExtensions for compatibility with callers using PascalCase env keys - Isolate positive PATHEXT tests from process.env.PATHEXT by explicitly setting it to .TXT before each test, ensuring they prove caller env propagation rather than host env leak * fix(core): add env?.PathExt casing to node-host system.which resolver --------- Co-authored-by: wendy-chsy <wan.wenyan@xydigit.com>
179 lines
5.1 KiB
TypeScript
179 lines
5.1 KiB
TypeScript
// Resolves executable paths from PATH and platform-specific install locations.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
|
import { expandHomePrefix } from "./home-dir.js";
|
|
|
|
function isDriveLessWindowsRootedPath(value: string): boolean {
|
|
return process.platform === "win32" && /^:[\\/]/.test(value);
|
|
}
|
|
|
|
export function resolveExecutablePathCandidate(
|
|
rawExecutable: string,
|
|
options?: { cwd?: string; env?: NodeJS.ProcessEnv; requirePathSeparator?: boolean },
|
|
): string | undefined {
|
|
const expanded = rawExecutable.startsWith("~")
|
|
? expandHomePrefix(rawExecutable, { env: options?.env })
|
|
: rawExecutable;
|
|
if (isDriveLessWindowsRootedPath(expanded)) {
|
|
return undefined;
|
|
}
|
|
const hasPathSeparator = expanded.includes("/") || expanded.includes("\\");
|
|
if (options?.requirePathSeparator && !hasPathSeparator) {
|
|
return undefined;
|
|
}
|
|
if (!hasPathSeparator) {
|
|
return expanded;
|
|
}
|
|
if (path.isAbsolute(expanded)) {
|
|
return path.resolve(expanded);
|
|
}
|
|
const base = options?.cwd && options.cwd.trim() ? options.cwd.trim() : process.cwd();
|
|
return path.resolve(base, expanded);
|
|
}
|
|
|
|
function resolveWindowsExecutableExtensions(
|
|
executable: string,
|
|
env: NodeJS.ProcessEnv | undefined,
|
|
): string[] {
|
|
if (process.platform !== "win32") {
|
|
return [""];
|
|
}
|
|
if (path.extname(executable).length > 0) {
|
|
return [""];
|
|
}
|
|
return [
|
|
"",
|
|
...(
|
|
env?.PATHEXT ??
|
|
env?.PathExt ??
|
|
env?.Pathext ??
|
|
process.env.PATHEXT ??
|
|
process.env.PathExt ??
|
|
process.env.Pathext ??
|
|
".EXE;.CMD;.BAT;.COM"
|
|
)
|
|
.split(";")
|
|
.map((ext) => normalizeLowercaseStringOrEmpty(ext)),
|
|
];
|
|
}
|
|
|
|
function resolveWindowsExecutableExtSet(env: NodeJS.ProcessEnv | undefined): Set<string> {
|
|
return new Set(
|
|
(
|
|
env?.PATHEXT ??
|
|
env?.PathExt ??
|
|
env?.Pathext ??
|
|
process.env.PATHEXT ??
|
|
process.env.PathExt ??
|
|
process.env.Pathext ??
|
|
".EXE;.CMD;.BAT;.COM"
|
|
)
|
|
.split(";")
|
|
.map((ext) => normalizeLowercaseStringOrEmpty(ext))
|
|
.filter(Boolean),
|
|
);
|
|
}
|
|
|
|
export function isExecutableFile(filePath: string, options?: { env?: NodeJS.ProcessEnv }): boolean {
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
if (!stat.isFile()) {
|
|
return false;
|
|
}
|
|
if (process.platform === "win32") {
|
|
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
|
|
if (!ext) {
|
|
return true;
|
|
}
|
|
return resolveWindowsExecutableExtSet(options?.env).has(ext);
|
|
}
|
|
fs.accessSync(filePath, fs.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function resolveExecutableFromPathEnv(
|
|
executable: string,
|
|
pathEnv: string,
|
|
env?: NodeJS.ProcessEnv,
|
|
): string | undefined {
|
|
const delimiter = process.platform === "win32" ? ";" : path.delimiter;
|
|
const entries = pathEnv.split(delimiter).filter(Boolean);
|
|
const extensions = resolveWindowsExecutableExtensions(executable, env);
|
|
for (const entry of entries) {
|
|
for (const ext of extensions) {
|
|
const candidate = path.join(entry, executable + ext);
|
|
if (isExecutableFile(candidate, { env })) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function resolveExecutablePath(
|
|
rawExecutable: string,
|
|
options?: { cwd?: string; env?: NodeJS.ProcessEnv },
|
|
): string | undefined {
|
|
const candidate = resolveExecutablePathCandidate(rawExecutable, options);
|
|
if (!candidate) {
|
|
return undefined;
|
|
}
|
|
if (candidate.includes("/") || candidate.includes("\\")) {
|
|
return isExecutableFile(candidate, options) ? candidate : undefined;
|
|
}
|
|
const envPath =
|
|
options?.env?.PATH ?? options?.env?.Path ?? process.env.PATH ?? process.env.Path ?? "";
|
|
return resolveExecutableFromPathEnv(candidate, envPath, options?.env);
|
|
}
|
|
|
|
const KNOWN_PATHEXT = new Set([".com", ".exe", ".bat", ".cmd"]);
|
|
|
|
/**
|
|
* On Windows, resolves a bare command name to its full .cmd or .exe path by
|
|
* probing PATH/PATHEXT without executing another resolver. On non-Windows this
|
|
* is a no-op.
|
|
*/
|
|
export function resolveExecutable(cmd: string): string {
|
|
if (process.platform !== "win32") {
|
|
return cmd;
|
|
}
|
|
if (KNOWN_PATHEXT.has(normalizeLowercaseStringOrEmpty(path.extname(cmd)))) {
|
|
return cmd;
|
|
}
|
|
|
|
const envPath = process.env.PATH ?? process.env.Path ?? "";
|
|
const entries = envPath.split(";").filter(Boolean);
|
|
const extensions = resolveWindowsExecutableExtensions(cmd, process.env);
|
|
const matches: string[] = [];
|
|
for (const entry of entries) {
|
|
for (const ext of extensions) {
|
|
const candidate = path.join(entry, cmd + ext);
|
|
if (isExecutableFile(candidate, { env: process.env })) {
|
|
matches.push(candidate);
|
|
}
|
|
}
|
|
}
|
|
|
|
const cmdMatch = matches.find(
|
|
(match) => normalizeLowercaseStringOrEmpty(path.extname(match)) === ".cmd",
|
|
);
|
|
if (cmdMatch) {
|
|
return cmdMatch;
|
|
}
|
|
const exeMatch = matches.find(
|
|
(match) => normalizeLowercaseStringOrEmpty(path.extname(match)) === ".exe",
|
|
);
|
|
if (exeMatch) {
|
|
return exeMatch;
|
|
}
|
|
if (matches[0]) {
|
|
return matches[0];
|
|
}
|
|
|
|
return cmd;
|
|
}
|