2026-03-17 23:57:38 -07:00

353 lines
8.6 KiB
TypeScript

import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import type {
WindowsSpawnProgram,
WindowsSpawnProgramCandidate,
WindowsSpawnResolution,
} from "../../runtime-api.js";
import {
applyWindowsSpawnProgramPolicy,
listKnownProviderAuthEnvVarNames,
materializeWindowsSpawnProgram,
omitEnvKeysCaseInsensitive,
resolveWindowsSpawnProgramCandidate,
} from "../../runtime-api.js";
export type SpawnExit = {
code: number | null;
signal: NodeJS.Signals | null;
error: Error | null;
};
type ResolvedSpawnCommand = {
command: string;
args: string[];
shell?: boolean;
windowsHide?: boolean;
};
type SpawnRuntime = {
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
execPath: string;
};
export type SpawnCommandCache = {
key?: string;
candidate?: WindowsSpawnProgramCandidate;
};
export type SpawnResolution = WindowsSpawnResolution | "unresolved-wrapper";
export type SpawnResolutionEvent = {
command: string;
cacheHit: boolean;
strictWindowsCmdWrapper: boolean;
resolution: SpawnResolution;
};
export type SpawnCommandOptions = {
strictWindowsCmdWrapper?: boolean;
cache?: SpawnCommandCache;
onResolved?: (event: SpawnResolutionEvent) => void;
};
const DEFAULT_RUNTIME: SpawnRuntime = {
platform: process.platform,
env: process.env,
execPath: process.execPath,
};
function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean {
try {
const stat = statSync(filePath);
if (!stat.isFile()) {
return false;
}
if (platform === "win32") {
return true;
}
accessSync(filePath, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
function resolveExecutableFromPath(command: string, runtime: SpawnRuntime): string | undefined {
const pathEnv = runtime.env.PATH ?? runtime.env.Path;
if (!pathEnv) {
return undefined;
}
for (const entry of pathEnv.split(path.delimiter).filter(Boolean)) {
const candidate = path.join(entry, command);
if (isExecutableFile(candidate, runtime.platform)) {
return candidate;
}
}
return undefined;
}
function resolveNodeShebangScriptPath(command: string, runtime: SpawnRuntime): string | undefined {
const commandPath =
path.isAbsolute(command) || command.includes(path.sep)
? command
: resolveExecutableFromPath(command, runtime);
if (!commandPath || !isExecutableFile(commandPath, runtime.platform)) {
return undefined;
}
try {
const firstLine = readFileSync(commandPath, "utf8").split(/\r?\n/, 1)[0] ?? "";
if (/^#!.*(?:\/usr\/bin\/env\s+node\b|\/node(?:js)?\b)/.test(firstLine)) {
return commandPath;
}
} catch {
return undefined;
}
return undefined;
}
export function resolveSpawnCommand(
params: { command: string; args: string[] },
options?: SpawnCommandOptions,
runtime: SpawnRuntime = DEFAULT_RUNTIME,
): ResolvedSpawnCommand {
if (runtime.platform !== "win32") {
const nodeShebangScript = resolveNodeShebangScriptPath(params.command, runtime);
if (nodeShebangScript) {
options?.onResolved?.({
command: params.command,
cacheHit: false,
strictWindowsCmdWrapper: options?.strictWindowsCmdWrapper === true,
resolution: "direct",
});
return {
command: runtime.execPath,
args: [nodeShebangScript, ...params.args],
};
}
}
const strictWindowsCmdWrapper = options?.strictWindowsCmdWrapper === true;
const cacheKey = params.command;
const cachedProgram = options?.cache;
const cacheHit = cachedProgram?.key === cacheKey && cachedProgram.candidate != null;
let candidate =
cachedProgram?.key === cacheKey && cachedProgram.candidate
? cachedProgram.candidate
: undefined;
if (!candidate) {
candidate = resolveWindowsSpawnProgramCandidate({
command: params.command,
platform: runtime.platform,
env: runtime.env,
execPath: runtime.execPath,
packageName: "acpx",
});
if (cachedProgram) {
cachedProgram.key = cacheKey;
cachedProgram.candidate = candidate;
}
}
let program: WindowsSpawnProgram;
try {
program = applyWindowsSpawnProgramPolicy({
candidate,
allowShellFallback: !strictWindowsCmdWrapper,
});
} catch (error) {
options?.onResolved?.({
command: params.command,
cacheHit,
strictWindowsCmdWrapper,
resolution: candidate.resolution,
});
throw error;
}
const resolved = materializeWindowsSpawnProgram(program, params.args);
options?.onResolved?.({
command: params.command,
cacheHit,
strictWindowsCmdWrapper,
resolution: resolved.resolution,
});
return {
command: resolved.command,
args: resolved.argv,
shell: resolved.shell,
windowsHide: resolved.windowsHide,
};
}
function createAbortError(): Error {
const error = new Error("Operation aborted.");
error.name = "AbortError";
return error;
}
export function spawnWithResolvedCommand(
params: {
command: string;
args: string[];
cwd: string;
stripProviderAuthEnvVars?: boolean;
},
options?: SpawnCommandOptions,
): ChildProcessWithoutNullStreams {
const resolved = resolveSpawnCommand(
{
command: params.command,
args: params.args,
},
options,
);
const childEnv = omitEnvKeysCaseInsensitive(
process.env,
params.stripProviderAuthEnvVars ? listKnownProviderAuthEnvVarNames() : [],
);
childEnv.OPENCLAW_SHELL = "acp";
return spawn(resolved.command, resolved.args, {
cwd: params.cwd,
env: childEnv,
stdio: ["pipe", "pipe", "pipe"],
shell: resolved.shell,
windowsHide: resolved.windowsHide,
});
}
export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<SpawnExit> {
// Handle callers that start waiting after the child has already exited.
if (child.exitCode !== null || child.signalCode !== null) {
return {
code: child.exitCode,
signal: child.signalCode,
error: null,
};
}
return await new Promise<SpawnExit>((resolve) => {
let settled = false;
const finish = (result: SpawnExit) => {
if (settled) {
return;
}
settled = true;
resolve(result);
};
child.once("error", (err) => {
finish({ code: null, signal: null, error: err });
});
child.once("close", (code, signal) => {
finish({ code, signal, error: null });
});
});
}
export async function spawnAndCollect(
params: {
command: string;
args: string[];
cwd: string;
stripProviderAuthEnvVars?: boolean;
},
options?: SpawnCommandOptions,
runtime?: {
signal?: AbortSignal;
},
): Promise<{
stdout: string;
stderr: string;
code: number | null;
error: Error | null;
}> {
if (runtime?.signal?.aborted) {
return {
stdout: "",
stderr: "",
code: null,
error: createAbortError(),
};
}
const child = spawnWithResolvedCommand(params, options);
child.stdin.end();
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
let abortKillTimer: NodeJS.Timeout | undefined;
let aborted = false;
const onAbort = () => {
aborted = true;
try {
child.kill("SIGTERM");
} catch {
// Ignore kill races when child already exited.
}
abortKillTimer = setTimeout(() => {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
try {
child.kill("SIGKILL");
} catch {
// Ignore kill races when child already exited.
}
}, 250);
abortKillTimer.unref?.();
};
runtime?.signal?.addEventListener("abort", onAbort, { once: true });
try {
const exit = await waitForExit(child);
return {
stdout,
stderr,
code: exit.code,
error: aborted ? createAbortError() : exit.error,
};
} finally {
runtime?.signal?.removeEventListener("abort", onAbort);
if (abortKillTimer) {
clearTimeout(abortKillTimer);
}
}
}
export function resolveSpawnFailure(
err: unknown,
cwd: string,
): "missing-command" | "missing-cwd" | null {
if (!err || typeof err !== "object") {
return null;
}
const code = (err as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
return null;
}
return directoryExists(cwd) ? "missing-command" : "missing-cwd";
}
function directoryExists(cwd: string): boolean {
if (!cwd) {
return false;
}
try {
return existsSync(cwd);
} catch {
return false;
}
}