Files
openclaw/src/plugin-sdk/windows-spawn.ts
2026-04-07 15:53:50 +01:00

315 lines
8.9 KiB
TypeScript

import { readFileSync, statSync } from "node:fs";
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
export type WindowsSpawnResolution =
| "direct"
| "node-entrypoint"
| "exe-entrypoint"
| "shell-fallback";
export type WindowsSpawnCandidateResolution = Exclude<WindowsSpawnResolution, "shell-fallback">;
export type WindowsSpawnProgramCandidate = {
command: string;
leadingArgv: string[];
resolution: WindowsSpawnCandidateResolution | "unresolved-wrapper";
windowsHide?: boolean;
};
export type WindowsSpawnProgram = {
command: string;
leadingArgv: string[];
resolution: WindowsSpawnResolution;
shell?: boolean;
windowsHide?: boolean;
};
export type WindowsSpawnInvocation = {
command: string;
argv: string[];
resolution: WindowsSpawnResolution;
shell?: boolean;
windowsHide?: boolean;
};
export type ResolveWindowsSpawnProgramParams = {
command: string;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
execPath?: string;
packageName?: string;
/** Trusted compatibility escape hatch for callers that intentionally accept shell-mediated wrapper execution. */
allowShellFallback?: boolean;
};
export type ResolveWindowsSpawnProgramCandidateParams = Omit<
ResolveWindowsSpawnProgramParams,
"allowShellFallback"
>;
function isFilePath(candidate: string): boolean {
try {
return statSync(candidate).isFile();
} catch {
return false;
}
}
/** Resolve a Windows command name through PATH and PATHEXT so wrapper inspection sees the real file. */
export function resolveWindowsExecutablePath(command: string, env: NodeJS.ProcessEnv): string {
if (command.includes("/") || command.includes("\\") || path.isAbsolute(command)) {
return command;
}
const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? "";
const pathEntries = pathValue
.split(";")
.map((entry) => entry.trim())
.filter(Boolean);
const hasExtension = path.extname(command).length > 0;
const pathExtRaw =
env.PATHEXT ??
env.Pathext ??
process.env.PATHEXT ??
process.env.Pathext ??
".EXE;.CMD;.BAT;.COM";
const pathExt = hasExtension
? [""]
: pathExtRaw
.split(";")
.map((ext) => ext.trim())
.filter(Boolean)
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`));
for (const dir of pathEntries) {
for (const ext of pathExt) {
const normalizedExt = normalizeLowercaseStringOrEmpty(ext);
const uppercaseExt = ext.toUpperCase();
for (const candidateExt of [ext, normalizedExt, uppercaseExt]) {
const candidate = path.join(dir, `${command}${candidateExt}`);
if (isFilePath(candidate)) {
return candidate;
}
}
}
}
return command;
}
function resolveEntrypointFromCmdShim(wrapperPath: string): string | null {
if (!isFilePath(wrapperPath)) {
return null;
}
try {
const content = readFileSync(wrapperPath, "utf8");
const candidates: string[] = [];
for (const match of content.matchAll(/"([^"\r\n]*)"/g)) {
const token = match[1] ?? "";
const relMatch = token.match(/%~?dp0%?\s*[\\/]*(.*)$/i);
const relative = relMatch?.[1]?.trim();
if (!relative) {
continue;
}
const normalizedRelative = relative.replace(/[\\/]+/g, path.sep).replace(/^[\\/]+/, "");
const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative);
if (isFilePath(candidate)) {
candidates.push(candidate);
}
}
const nonNode = candidates.find((candidate) => {
const base = normalizeLowercaseStringOrEmpty(path.basename(candidate));
return base !== "node.exe" && base !== "node";
});
return nonNode ?? null;
} catch {
return null;
}
}
function resolveBinEntry(
packageName: string | undefined,
binField: string | Record<string, string> | undefined,
): string | null {
if (typeof binField === "string") {
const trimmed = normalizeOptionalString(binField);
return trimmed || null;
}
if (!binField || typeof binField !== "object") {
return null;
}
if (packageName) {
const preferred = binField[packageName];
const normalizedPreferred =
typeof preferred === "string" ? normalizeOptionalString(preferred) : undefined;
if (normalizedPreferred) {
return normalizedPreferred;
}
}
for (const value of Object.values(binField)) {
const normalizedValue = typeof value === "string" ? normalizeOptionalString(value) : undefined;
if (normalizedValue) {
return normalizedValue;
}
}
return null;
}
function resolveEntrypointFromPackageJson(
wrapperPath: string,
packageName?: string,
): string | null {
if (!packageName) {
return null;
}
const wrapperDir = path.dirname(wrapperPath);
const packageDirs = [
path.resolve(wrapperDir, "..", packageName),
path.resolve(wrapperDir, "node_modules", packageName),
];
for (const packageDir of packageDirs) {
const packageJsonPath = path.join(packageDir, "package.json");
if (!isFilePath(packageJsonPath)) {
continue;
}
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
bin?: string | Record<string, string>;
};
const entryRel = resolveBinEntry(packageName, packageJson.bin);
if (!entryRel) {
continue;
}
const entryPath = path.resolve(packageDir, entryRel);
if (isFilePath(entryPath)) {
return entryPath;
}
} catch {
// Ignore malformed package metadata.
}
}
return null;
}
/** Resolve the safest direct spawn candidate for Windows wrappers, scripts, and binaries. */
export function resolveWindowsSpawnProgramCandidate(
params: ResolveWindowsSpawnProgramCandidateParams,
): WindowsSpawnProgramCandidate {
const platform = params.platform ?? process.platform;
const env = params.env ?? process.env;
const execPath = params.execPath ?? process.execPath;
if (platform !== "win32") {
return {
command: params.command,
leadingArgv: [],
resolution: "direct",
};
}
const resolvedCommand = resolveWindowsExecutablePath(params.command, env);
const ext = normalizeLowercaseStringOrEmpty(path.extname(resolvedCommand));
if (ext === ".js" || ext === ".cjs" || ext === ".mjs") {
return {
command: execPath,
leadingArgv: [resolvedCommand],
resolution: "node-entrypoint",
windowsHide: true,
};
}
if (ext === ".cmd" || ext === ".bat") {
const entrypoint =
resolveEntrypointFromCmdShim(resolvedCommand) ??
resolveEntrypointFromPackageJson(resolvedCommand, params.packageName);
if (entrypoint) {
const entryExt = normalizeLowercaseStringOrEmpty(path.extname(entrypoint));
if (entryExt === ".exe") {
return {
command: entrypoint,
leadingArgv: [],
resolution: "exe-entrypoint",
windowsHide: true,
};
}
return {
command: execPath,
leadingArgv: [entrypoint],
resolution: "node-entrypoint",
windowsHide: true,
};
}
return {
command: resolvedCommand,
leadingArgv: [],
resolution: "unresolved-wrapper",
};
}
return {
command: resolvedCommand,
leadingArgv: [],
resolution: "direct",
};
}
/** Apply shell-fallback policy when Windows wrapper resolution could not find a direct entrypoint. */
export function applyWindowsSpawnProgramPolicy(params: {
candidate: WindowsSpawnProgramCandidate;
allowShellFallback?: boolean;
}): WindowsSpawnProgram {
if (params.candidate.resolution !== "unresolved-wrapper") {
return {
command: params.candidate.command,
leadingArgv: params.candidate.leadingArgv,
resolution: params.candidate.resolution,
windowsHide: params.candidate.windowsHide,
};
}
if (params.allowShellFallback === true) {
return {
command: params.candidate.command,
leadingArgv: [],
resolution: "shell-fallback",
shell: true,
};
}
throw new Error(
`${path.basename(params.candidate.command)} wrapper resolved, but no executable/Node entrypoint could be resolved without shell execution.`,
);
}
/** Resolve the final Windows spawn program after candidate discovery and fallback policy. */
export function resolveWindowsSpawnProgram(
params: ResolveWindowsSpawnProgramParams,
): WindowsSpawnProgram {
const candidate = resolveWindowsSpawnProgramCandidate(params);
return applyWindowsSpawnProgramPolicy({
candidate,
allowShellFallback: params.allowShellFallback,
});
}
/** Combine a resolved Windows spawn program with call-site argv for actual process launch. */
export function materializeWindowsSpawnProgram(
program: WindowsSpawnProgram,
argv: string[],
): WindowsSpawnInvocation {
return {
command: program.command,
argv: [...program.leadingArgv, ...argv],
resolution: program.resolution,
shell: program.shell,
windowsHide: program.windowsHide,
};
}