src / agentBrowser.ts
import { execFile, spawn } from "child_process";
import { existsSync } from "fs";
import { homedir } from "os";
import { delimiter, isAbsolute, join } from "path";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
export interface AgentBrowserSettings {
binCommand: string;
session: string;
headed: boolean;
timeoutMs: number;
screenshotDir: string;
userAgent: string;
viewport: string;
acceptLanguage: string;
colorScheme: string;
extraBrowserArgs: string;
}
function parseViewport(value: string): { width: number; height: number } | null {
const match = value.trim().match(/^(\d{2,5})\s*[x×]\s*(\d{2,5})$/i);
if (match === null) return null;
return { width: Number(match[1]), height: Number(match[2]) };
}
function buildLaunchArgs(settings: AgentBrowserSettings): string | null {
const parts: string[] = [];
const vp = parseViewport(settings.viewport);
if (vp !== null) {
parts.push(`--window-size=${vp.width},${vp.height}`);
}
const extra = settings.extraBrowserArgs.trim();
if (extra.length > 0) {
// The user's field is already comma-separated in the format agent-browser
// expects, so we just merge it in.
parts.push(extra);
}
return parts.length > 0 ? parts.join(",") : null;
}
export interface RunOptions {
signal?: AbortSignal;
json?: boolean;
includeSession?: boolean;
includeTimeout?: boolean;
cwd?: string;
}
export interface RunResult {
stdout: string;
stderr: string;
exitCode: number;
}
// LM Studio's plugin runtime does not inherit the user's shell PATH, so commands
// like `npx` and `agent-browser` cannot be located by name. We resolve them at
// runtime: first by scanning known install locations for Node toolchains and
// version managers, then (as a fallback) by asking a login shell where the
// command lives. The result is cached so we only pay this cost once.
function candidateBinDirs(): string[] {
const home = homedir();
const dirs: string[] = [];
if (process.platform === "darwin") {
dirs.push("/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin");
} else if (process.platform === "linux") {
dirs.push("/usr/local/bin", "/usr/bin", "/bin");
} else if (process.platform === "win32") {
const appData = process.env.APPDATA;
if (appData !== undefined) dirs.push(join(appData, "npm"));
const programFiles = process.env.ProgramFiles;
if (programFiles !== undefined) dirs.push(join(programFiles, "nodejs"));
}
// Common Node version managers / package manager prefixes
dirs.push(
join(home, ".volta", "bin"),
join(home, ".fnm", "current", "bin"),
join(home, ".nvm", "current", "bin"),
join(home, ".asdf", "shims"),
join(home, ".bun", "bin"),
join(home, ".local", "bin"),
join(home, ".npm-global", "bin"),
join(home, "node_modules", ".bin"),
);
return dirs;
}
let cachedEnrichedPath: string | null = null;
function enrichedPath(): string {
if (cachedEnrichedPath !== null) return cachedEnrichedPath;
const existing = (process.env.PATH ?? "").split(delimiter).filter((d) => d.length > 0);
const additions = candidateBinDirs().filter((d) => existsSync(d));
const seen = new Set<string>();
const merged: string[] = [];
for (const dir of [...additions, ...existing]) {
if (!seen.has(dir)) {
seen.add(dir);
merged.push(dir);
}
}
cachedEnrichedPath = merged.join(delimiter);
return cachedEnrichedPath;
}
const cachedAbsoluteCommand = new Map<string, string>();
async function resolveAbsolute(cmd: string): Promise<string | null> {
if (isAbsolute(cmd)) return existsSync(cmd) ? cmd : null;
const cached = cachedAbsoluteCommand.get(cmd);
if (cached !== undefined) return cached;
const exts = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
for (const dir of candidateBinDirs()) {
for (const ext of exts) {
const candidate = join(dir, cmd + ext);
if (existsSync(candidate)) {
cachedAbsoluteCommand.set(cmd, candidate);
return candidate;
}
}
}
if (process.platform !== "win32") {
try {
// -lc launches a login shell, which sources the user's profile so PATH
// matches what they see in their terminal.
const { stdout } = await execFileAsync("/bin/sh", ["-lc", `command -v ${cmd}`], {
timeout: 5000,
});
const found = stdout.trim();
if (found.length > 0 && existsSync(found)) {
cachedAbsoluteCommand.set(cmd, found);
return found;
}
} catch {
// fall through
}
}
return null;
}
async function resolveCommand(
binCommand: string,
): Promise<{ cmd: string; prefix: string[] }> {
const trimmed = binCommand.trim();
if (trimmed === "") {
throw new Error("agent-browser binCommand is empty.");
}
const parts = trimmed.split(/\s+/);
const head = parts[0];
const rest = parts.slice(1);
const absolute = await resolveAbsolute(head);
if (absolute !== null) {
return { cmd: absolute, prefix: rest };
}
throw new Error(
`Could not locate "${head}" on PATH or in any known install location. ` +
`Set the plugin's "agent-browser command" setting to an absolute path ` +
`(e.g. the output of \`which npx\` or \`which agent-browser\`).`,
);
}
export async function runAgentBrowser(
settings: AgentBrowserSettings,
args: string[],
opts: RunOptions = {},
): Promise<RunResult> {
const { cmd, prefix } = await resolveCommand(settings.binCommand);
// agent-browser parses the subcommand first and global flags after it, so
// the order is: <prefix> <subcommand-and-its-args> <global-flags>.
const fullArgs: string[] = [...prefix, ...args];
if (opts.includeSession !== false) {
fullArgs.push("--session", settings.session);
}
if (opts.includeTimeout !== false) {
fullArgs.push("--timeout", String(settings.timeoutMs));
}
if (settings.headed) {
fullArgs.push("--headed");
}
if (opts.json) {
fullArgs.push("--json");
}
// Human-emulation flags. agent-browser applies these when the daemon launches
// the browser; they are no-ops once a session is already running, so they're
// safe to send on every command. An empty string disables the corresponding
// field individually.
if (settings.userAgent.trim().length > 0) {
fullArgs.push("--user-agent", settings.userAgent);
}
if (settings.acceptLanguage.trim().length > 0) {
fullArgs.push(
"--headers",
JSON.stringify({ "Accept-Language": settings.acceptLanguage }),
);
}
// "no-preference" is agent-browser's own default, so omitting the flag is
// equivalent and avoids a redundant CLI argument.
if (
settings.colorScheme.length > 0 &&
settings.colorScheme !== "no-preference"
) {
fullArgs.push("--color-scheme", settings.colorScheme);
}
const launchArgs = buildLaunchArgs(settings);
if (launchArgs !== null) {
fullArgs.push("--args", launchArgs);
}
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(cmd, fullArgs, {
cwd: opts.cwd,
// npx itself shells out to find node/agent-browser; pass the enriched PATH
// so descendants can find the rest of the toolchain.
env: { ...process.env, PATH: enrichedPath() },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const onAbort = () => {
child.kill("SIGTERM");
};
if (opts.signal !== undefined) {
if (opts.signal.aborted) {
child.kill("SIGTERM");
} else {
opts.signal.addEventListener("abort", onAbort, { once: true });
}
}
child.on("error", (err) => {
if (opts.signal !== undefined) {
opts.signal.removeEventListener("abort", onAbort);
}
reject(err);
});
child.on("close", (code) => {
if (opts.signal !== undefined) {
opts.signal.removeEventListener("abort", onAbort);
}
resolve({ stdout, stderr, exitCode: code ?? -1 });
});
});
}
export function summarize(result: RunResult, fallback: string): string {
const out = result.stdout.trim();
const err = result.stderr.trim();
if (result.exitCode === 0) {
return out.length > 0 ? out : fallback;
}
const detail = err.length > 0 ? err : out;
return `Error (exit ${result.exitCode}): ${detail || fallback}`;
}
export function tryParseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}