import { execFile } from "node:child_process";
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { extname, isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import { CUSTOM_AGENT_LIMITS } from "@openbot/contracts/ipc";
import { sourceText } from "@openbot/i18n/source";
import { Effect } from "effect";
import { loginSearchPath } from "./cli";
import { pickWindowsExecutable } from "./mcp-provider-shapes";
import { providerCall, providerFailure } from "./provider-client-effects";
const execFileAsync = promisify(execFile);
const AGENT_COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
* What a `.cmd` or `.bat` argument may hold. Such a file runs only through `cmd.exe`, and
* `cliSpawnTarget` gives it one command line, so any other character could start a second command.
*/
const WINDOWS_SCRIPT_ARG_PATTERN = /^[A-Za-z0-9_.,:=@+/\\-]+$/;
export type AgentCommandForm = "path" | "home" | "name";
export function agentCommandForm(
command: string,
platform: NodeJS.Platform = process.platform,
): AgentCommandForm | null {
if (command.length === 0 || command.length > CUSTOM_AGENT_LIMITS.command) return null;
if (/[\0\r\n]/.test(command) || command !== command.trim()) return null;
if (command.startsWith("~/")) return "home";
if (isAbsolute(command) || (platform === "win32" && /^[A-Za-z]:[\\/]/.test(command))) return "path";
return AGENT_COMMAND_NAME_PATTERN.test(command) ? "name" : null;
}
export function assertAgentArgs(args: readonly string[]): void {
if (args.length > CUSTOM_AGENT_LIMITS.args) throw new Error(sourceText("error.provider.customAgentArgsInvalid"));
for (const arg of args) {
if (arg.length > CUSTOM_AGENT_LIMITS.arg || /[\0\r\n]/.test(arg)) {
throw new Error(sourceText("error.provider.customAgentArgsInvalid"));
}
}
}
* Throws when the resolved file is a Windows script and an argument could leave the one command line
* that `cmd.exe` gets. Called with the resolved file, because a bare name can resolve to `x.cmd`.
*/
export function assertWindowsScriptArgs(
executable: string,
args: readonly string[],
platform: NodeJS.Platform = process.platform,
): void {
if (platform !== "win32" || ![".cmd", ".bat"].includes(extname(executable).toLowerCase())) return;
if (executable.includes('"') || args.some((arg) => !WINDOWS_SCRIPT_ARG_PATTERN.test(arg))) {
throw new Error(sourceText("error.provider.customAgentWindowsScript"));
}
}
export interface ResolveAgentCommandOptions {
platform?: NodeJS.Platform;
home?: string;
searchPath?: readonly string[];
}
* The file that a command starts, or null when there is none now. Throws for a command text that is
* not an accepted form, so the form can say what is wrong rather than "not found".
*/
export const resolveAgentCommand = Effect.fn("AcpAgentCommand.resolve")(function* (
command: string,
options: ResolveAgentCommandOptions = {},
) {
const platform = options.platform ?? process.platform;
const form = agentCommandForm(command, platform);
if (!form) return yield* providerFailure(new Error(sourceText("error.provider.customAgentCommandInvalid")));
if (form === "path") return (yield* isExecutableFile(command, platform)) ? command : null;
if (form === "home") {
const path = join(options.home ?? homedir(), command.slice(2));
return (yield* isExecutableFile(path, platform)) ? path : null;
}
if (platform === "win32" && !options.searchPath) return yield* whereExe(command);
const folders = options.searchPath ?? (yield* loginSearchPath());
for (const folder of folders) {
if (!isAbsolute(folder)) continue;
const candidate = join(folder, command);
if (yield* isExecutableFile(candidate, platform)) return candidate;
}
return null;
});
const whereExe = Effect.fn("AcpAgentCommand.whereExe")((command: string) =>
providerCall(() => execFileAsync("where.exe", [command], { timeout: 5_000, maxBuffer: 64 * 1024 })).pipe(
Effect.map(({ stdout }) => pickWindowsExecutable(stdout, process.env.PATHEXT)),
Effect.catch(() => Effect.succeed(null)),
),
);
const isExecutableFile = Effect.fn("AcpAgentCommand.isExecutableFile")(
function* (path: string, platform: NodeJS.Platform) {
if (!(yield* providerCall(() => stat(path))).isFile()) return false;
yield* providerCall(() => access(path, platform === "win32" ? constants.F_OK : constants.X_OK));
return true;
},
Effect.catch(() => Effect.succeed(false)),
);