Files
openclaw/src/infra/executable-path.ts
Iroh f126f72d63 fix(windows): resolve Gmail helper PATHEXT shims
Resolve Gmail setup and watcher helper binaries through Windows PATH/PATHEXT before spawning, without executing where.exe during lookup. Cover gcloud, gog, and tailscale, including the documented CLI Gmail run path, and route long-lived gog .cmd/.bat shims through a pinned cmd.exe wrapper.

Co-authored-by: Iroh <175496729+Angfr95@users.noreply.github.com>
Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
2026-05-05 00:21:34 -05:00

174 lines
4.8 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
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 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 ??
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 ??
process.env.PATHEXT ??
process.env.Pathext ??
".EXE;.CMD;.BAT;.COM"
)
.split(";")
.map((ext) => normalizeLowercaseStringOrEmpty(ext))
.filter(Boolean),
);
}
export function isExecutableFile(filePath: string): 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(undefined).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)) {
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) ? 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)) {
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;
}