src / execution / resolveExecutable.ts
src / execution / resolveExecutable.ts
import { constants } from "node:fs";
import { access, readFile, stat } from "node:fs/promises";
import { delimiter, dirname, extname, join, resolve } from "node:path";
import { AgenticError } from "../core/errors";
export interface ResolvedExecutable {
/** Absolute path handed to spawn(). */
file: string;
/** Arguments inserted before the caller's args (the JS entry of an npm shim). */
argsPrefix: string[];
via: "direct" | "node-shim";
}
export interface ResolveExecutableOptions {
cwd: string;
workspaceRoot: string;
env: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
/** Node binary used to run shims; defaults to the node next to the shim, then PATH, then this process. */
nodeExecutable?: string;
}
const WINDOWS_EXTENSIONS = [".com", ".exe", ".bat", ".cmd"];
/**
* A shim's interpreter is only ever a real binary. `.cmd`/`.bat` are excluded
* because Node cannot spawn them without a shell, and the bare name is excluded
* because spawn() would then have to search PATH itself.
*/
const INTERPRETER_EXTENSIONS = [".com", ".exe"];
/** npm shims are a few hundred bytes; anything larger is not one. */
const MAX_SHIM_BYTES = 1_000_000;
async function isFile(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile();
} catch {
return false;
}
}
async function isExecutableFile(path: string, windows: boolean): Promise<boolean> {
if (!(await isFile(path))) return false;
if (windows) return true;
try {
await access(path, constants.X_OK);
return true;
} catch {
return false;
}
}
function pathDirectories(env: NodeJS.ProcessEnv): string[] {
const raw = env.PATH ?? env.Path ?? env.path ?? "";
return raw
.split(delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
}
function candidateNames(
name: string,
windows: boolean,
env: NodeJS.ProcessEnv,
allowed: string[] = WINDOWS_EXTENSIONS,
): string[] {
if (!windows) return [name];
// An allowlist entry may already carry its extension ("node.exe"); try it as-is,
// but only when that extension is one this lookup permits — an interpreter
// search must not accept a name that already ends in ".cmd"/".bat".
if (allowed.includes(extname(name).toLowerCase())) return [name];
const configured = (env.PATHEXT ?? allowed.join(";"))
.split(";")
.map((extension) => extension.trim().toLowerCase())
.filter((extension) => allowed.includes(extension));
const order = configured.length > 0 ? configured : allowed;
return order.map((extension) => `${name}${extension}`);
}
async function findInDirectories(
name: string,
directories: string[],
windows: boolean,
env: NodeJS.ProcessEnv,
allowed?: string[],
): Promise<string | undefined> {
const names = candidateNames(name, windows, env, allowed);
for (const directory of directories) {
for (const candidate of names) {
const full = join(directory, candidate);
if (await isExecutableFile(full, windows)) return full;
}
}
return undefined;
}
/**
* Locates a Node interpreter for a shim. Restricted to `.com`/`.exe` so a
* planted `node.cmd`/`node.bat` can never become the interpreter.
*/
async function findNode(
directories: string[],
env: NodeJS.ProcessEnv,
): Promise<string | undefined> {
return await findInDirectories("node", directories, true, env, INTERPRETER_EXTENSIONS);
}
async function readShimText(
name: string,
shimPath: string,
extension: string,
): Promise<string> {
let size: number;
try {
size = (await stat(shimPath)).size;
} catch {
throw new AgenticError(
"EXECUTABLE_DENIED",
`'${name}' resolves to ${shimPath}, which could not be inspected.`,
);
}
if (size > MAX_SHIM_BYTES) {
throw new AgenticError(
"EXECUTABLE_DENIED",
`'${name}' resolves to ${shimPath}, a ${extension} file of ${size.toLocaleString()} bytes; npm-generated Node shims are a few hundred bytes.`,
);
}
try {
return await readFile(shimPath, "utf8");
} catch {
throw new AgenticError(
"EXECUTABLE_DENIED",
`'${name}' resolves to ${shimPath}, which could not be read.`,
);
}
}
/**
* Finds the script an npm-generated `.cmd` shim delegates to. Both formats
* npm writes are supported: the `SET "VAR=%~dp0\..."` style used by
* `npm.cmd`/`npx.cmd`, and the cmd-shim style used for `node_modules/.bin`.
* Only the quoted arguments on the final `%*` invocation line count, so
* helper scripts mentioned earlier (npm-prefix.js) are never chosen.
*/
export async function parseNodeShim(
shimPath: string,
shimText: string,
): Promise<string | undefined> {
const directory = dirname(shimPath);
const lines = shimText.split(/\r?\n/);
const invocation = [...lines].reverse().find((line) => line.includes("%*"));
if (!invocation) return undefined;
const assignments = new Map<string, string>();
for (const line of lines) {
const quoted = /^\s*SET\s+"([A-Za-z0-9_]+)=([^"]*)"/i.exec(line);
const bare = /^\s*SET\s+([A-Za-z0-9_]+)=(\S+)/i.exec(line);
const match = quoted ?? bare;
if (match && !assignments.has(match[1])) assignments.set(match[1], match[2]);
}
const expand = (value: string, depth = 0): string =>
depth > 5
? value
: value.replace(/%([A-Za-z0-9_~]+)%/g, (whole, name: string) => {
if (/^~?dp0$/i.test(name)) return "%~dp0";
const assigned = assignments.get(name);
return assigned === undefined ? whole : expand(assigned, depth + 1);
});
for (const [, token] of invocation.matchAll(/"([^"]+)"/g)) {
const expanded = expand(token);
const relative = expanded.replace(/^%~dp0[\\/]?/i, "");
if (relative === expanded || !relative) continue;
const normalized = relative.replace(/\\/g, "/");
if (/^node(\.exe)?$/i.test(normalized)) continue;
const target = resolve(directory, normalized);
if (await isFile(target)) return target;
}
return undefined;
}
export async function resolveExecutable(
name: string,
options: ResolveExecutableOptions,
): Promise<ResolvedExecutable> {
const windows = (options.platform ?? process.platform) === "win32";
const searchPath = pathDirectories(options.env);
const localBins = [
join(options.cwd, "node_modules", ".bin"),
join(options.workspaceRoot, "node_modules", ".bin"),
];
// PATH is searched first so a repository cannot shadow a global tool.
const fromPath = await findInDirectories(name, searchPath, windows, options.env);
const found =
fromPath ?? (await findInDirectories(name, localBins, windows, options.env));
if (!found) {
throw new AgenticError(
"EXECUTABLE_DENIED",
`Executable '${name}' was not found on PATH or in node_modules/.bin.`,
);
}
const extension = extname(found).toLowerCase();
if (windows && (extension === ".cmd" || extension === ".bat")) {
const script = await parseNodeShim(
found,
await readShimText(name, found, extension),
);
if (!script) {
throw new AgenticError(
"EXECUTABLE_DENIED",
`'${name}' resolves to ${found}, a ${extension} script that cannot run without a shell. Only npm-generated Node shims are supported.`,
);
}
// The directory beside the shim is only trusted when the shim itself came
// from PATH; a repository's node_modules/.bin must never supply the
// interpreter that runs its own shim.
const interpreterDirectories =
fromPath === undefined ? searchPath : [dirname(found), ...searchPath];
const node =
options.nodeExecutable ??
(await findNode(interpreterDirectories, options.env)) ??
process.execPath;
return { file: node, argsPrefix: [script], via: "node-shim" };
}
return { file: found, argsPrefix: [], via: "direct" };
}
import { constants } from "node:fs";
import { access, readFile, stat } from "node:fs/promises";
import { delimiter, dirname, extname, join, resolve } from "node:path";
import { AgenticError } from "../core/errors";
export interface ResolvedExecutable {
/** Absolute path handed to spawn(). */
file: string;
/** Arguments inserted before the caller's args (the JS entry of an npm shim). */
argsPrefix: string[];
via: "direct" | "node-shim";
}
export interface ResolveExecutableOptions {
cwd: string;
workspaceRoot: string;
env: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
/** Node binary used to run shims; defaults to the node next to the shim, then PATH, then this process. */
nodeExecutable?: string;
}
const WINDOWS_EXTENSIONS = [".com", ".exe", ".bat", ".cmd"];
/**
* A shim's interpreter is only ever a real binary. `.cmd`/`.bat` are excluded
* because Node cannot spawn them without a shell, and the bare name is excluded
* because spawn() would then have to search PATH itself.
*/
const INTERPRETER_EXTENSIONS = [".com", ".exe"];
/** npm shims are a few hundred bytes; anything larger is not one. */
const MAX_SHIM_BYTES = 1_000_000;
async function isFile(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile();
} catch {
return false;
}
}
async function isExecutableFile(path: string, windows: boolean): Promise<boolean> {
if (!(await isFile(path))) return false;
if (windows) return true;
try {
await access(path, constants.X_OK);
return true;
} catch {
return false;
}
}
function pathDirectories(env: NodeJS.ProcessEnv): string[] {
const raw = env.PATH ?? env.Path ?? env.path ?? "";
return raw
.split(delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
}
function candidateNames(
name: string,
windows: boolean,
env: NodeJS.ProcessEnv,
allowed: string[] = WINDOWS_EXTENSIONS,
): string[] {
if (!windows) return [name];
// An allowlist entry may already carry its extension ("node.exe"); try it as-is,
// but only when that extension is one this lookup permits — an interpreter
// search must not accept a name that already ends in ".cmd"/".bat".
if (allowed.includes(extname(name).toLowerCase())) return [name];
const configured = (env.PATHEXT ?? allowed.join(";"))
.split(";")
.map((extension) => extension.trim().toLowerCase())
.filter((extension) => allowed.includes(extension));
const order = configured.length > 0 ? configured : allowed;
return order.map((extension) => `${name}${extension}`);
}
async function findInDirectories(
name: string,
directories: string[],
windows: boolean,
env: NodeJS.ProcessEnv,
allowed?: string[],
): Promise<string | undefined> {
const names = candidateNames(name, windows, env, allowed);
for (const directory of directories) {
for (const candidate of names) {
const full = join(directory, candidate);
if (await isExecutableFile(full, windows)) return full;
}
}
return undefined;
}
/**
* Locates a Node interpreter for a shim. Restricted to `.com`/`.exe` so a
* planted `node.cmd`/`node.bat` can never become the interpreter.
*/
async function findNode(
directories: string[],
env: NodeJS.ProcessEnv,
): Promise<string | undefined> {
return await findInDirectories("node", directories, true, env, INTERPRETER_EXTENSIONS);
}
async function readShimText(
name: string,
shimPath: string,
extension: string,
): Promise<string> {
let size: number;
try {
size = (await stat(shimPath)).size;
} catch {
throw new AgenticError(
"EXECUTABLE_DENIED",
`'${name}' resolves to ${shimPath}, which could not be inspected.`,
);
}
if (size > MAX_SHIM_BYTES) {
throw new AgenticError(
"EXECUTABLE_DENIED",
`'${name}' resolves to ${shimPath}, a ${extension} file of ${size.toLocaleString()} bytes; npm-generated Node shims are a few hundred bytes.`,
);
}
try {
return await readFile(shimPath, "utf8");
} catch {
throw new AgenticError(
"EXECUTABLE_DENIED",
`'${name}' resolves to ${shimPath}, which could not be read.`,
);
}
}
/**
* Finds the script an npm-generated `.cmd` shim delegates to. Both formats
* npm writes are supported: the `SET "VAR=%~dp0\..."` style used by
* `npm.cmd`/`npx.cmd`, and the cmd-shim style used for `node_modules/.bin`.
* Only the quoted arguments on the final `%*` invocation line count, so
* helper scripts mentioned earlier (npm-prefix.js) are never chosen.
*/
export async function parseNodeShim(
shimPath: string,
shimText: string,
): Promise<string | undefined> {
const directory = dirname(shimPath);
const lines = shimText.split(/\r?\n/);
const invocation = [...lines].reverse().find((line) => line.includes("%*"));
if (!invocation) return undefined;
const assignments = new Map<string, string>();
for (const line of lines) {
const quoted = /^\s*SET\s+"([A-Za-z0-9_]+)=([^"]*)"/i.exec(line);
const bare = /^\s*SET\s+([A-Za-z0-9_]+)=(\S+)/i.exec(line);
const match = quoted ?? bare;
if (match && !assignments.has(match[1])) assignments.set(match[1], match[2]);
}
const expand = (value: string, depth = 0): string =>
depth > 5
? value
: value.replace(/%([A-Za-z0-9_~]+)%/g, (whole, name: string) => {
if (/^~?dp0$/i.test(name)) return "%~dp0";
const assigned = assignments.get(name);
return assigned === undefined ? whole : expand(assigned, depth + 1);
});
for (const [, token] of invocation.matchAll(/"([^"]+)"/g)) {
const expanded = expand(token);
const relative = expanded.replace(/^%~dp0[\\/]?/i, "");
if (relative === expanded || !relative) continue;
const normalized = relative.replace(/\\/g, "/");
if (/^node(\.exe)?$/i.test(normalized)) continue;
const target = resolve(directory, normalized);
if (await isFile(target)) return target;
}
return undefined;
}
export async function resolveExecutable(
name: string,
options: ResolveExecutableOptions,
): Promise<ResolvedExecutable> {
const windows = (options.platform ?? process.platform) === "win32";
const searchPath = pathDirectories(options.env);
const localBins = [
join(options.cwd, "node_modules", ".bin"),
join(options.workspaceRoot, "node_modules", ".bin"),
];
// PATH is searched first so a repository cannot shadow a global tool.
const fromPath = await findInDirectories(name, searchPath, windows, options.env);
const found =
fromPath ?? (await findInDirectories(name, localBins, windows, options.env));
if (!found) {
throw new AgenticError(
"EXECUTABLE_DENIED",
`Executable '${name}' was not found on PATH or in node_modules/.bin.`,
);
}
const extension = extname(found).toLowerCase();
if (windows && (extension === ".cmd" || extension === ".bat")) {
const script = await parseNodeShim(
found,
await readShimText(name, found, extension),
);
if (!script) {
throw new AgenticError(
"EXECUTABLE_DENIED",
`'${name}' resolves to ${found}, a ${extension} script that cannot run without a shell. Only npm-generated Node shims are supported.`,
);
}
// The directory beside the shim is only trusted when the shim itself came
// from PATH; a repository's node_modules/.bin must never supply the
// interpreter that runs its own shim.
const interpreterDirectories =
fromPath === undefined ? searchPath : [dirname(found), ...searchPath];
const node =
options.nodeExecutable ??
(await findNode(interpreterDirectories, options.env)) ??
process.execPath;
return { file: node, argsPrefix: [script], via: "node-shim" };
}
return { file: found, argsPrefix: [], via: "direct" };
}