Files
fusion/plugins/fusion-plugin-agent-browser/src/probe.ts
gsxdsm 3f2197be20 feat(agent-browser): real navigate/interact/observe driver (U8)
Replaces the metadata/probe stub with a playwright-core driver (no bundled
browser download; uses a probe-discovered Chrome) exposing navigate/click/type/
observe + idempotent dispose. Browser-unavailable and un-exercisable assertions
return inconclusive (never a false pass/fail); a missing selector returns a
distinct 'absent' negative observation. Driver is a direct export scoped to the
verification run, not registered as a coding-agent tool.
2026-06-12 02:11:04 -07:00

204 lines
6.9 KiB
TypeScript

import { spawn } from "node:child_process";
import { access, constants } from "node:fs/promises";
export interface AgentBrowserProbeResult {
available: boolean;
binaryPath?: string;
version?: string;
reason?: string;
notFound?: boolean;
}
export async function probeAgentBrowserBinary(opts?: { binaryPath?: string; timeoutMs?: number }): Promise<AgentBrowserProbeResult> {
const binary = opts?.binaryPath?.trim() || "agent-browser";
const timeoutMs = opts?.timeoutMs ?? 2000;
const resolvedPath = await tryResolveBinaryPath(binary);
return new Promise((resolve) => {
let settled = false;
const child = spawn(resolvedPath ?? binary, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
resolve({ available: false, binaryPath: resolvedPath, reason: `Probe timed out after ${timeoutMs}ms` });
}, timeoutMs);
child.stdout?.on("data", (c: Buffer) => (stdout += c.toString("utf-8")));
child.stderr?.on("data", (c: Buffer) => (stderr += c.toString("utf-8")));
child.on("error", (err: NodeJS.ErrnoException) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
available: false,
binaryPath: resolvedPath,
reason: err.code === "ENOENT" ? "`agent-browser` not found on PATH" : err.message,
notFound: err.code === "ENOENT",
});
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 0) {
resolve({ available: true, binaryPath: resolvedPath, version: stdout.trim() || undefined });
} else {
resolve({
available: false,
binaryPath: resolvedPath,
reason: stderr.trim() || `agent-browser --version exited with code ${String(code)}`,
});
}
});
});
}
// ── Chromium/Chrome executable discovery (for the verification driver) ─────────
//
// The app/browser driver (U8) drives a Chromium engine via playwright-core, which
// does NOT bundle or download a browser. It launches an EXISTING Chrome/Chromium
// discovered on the host. When no executable can be found the driver must report
// itself unavailable so the verification run resolves the assertion to
// INCONCLUSIVE (never a false pass/fail).
export interface BrowserExecutableProbeResult {
/** True only when a usable Chrome/Chromium executable was located. */
available: boolean;
/** Absolute (or PATH-resolvable) executable path, when found. */
executablePath?: string;
/** Human-readable reason the executable is unavailable. */
reason?: string;
}
/**
* Well-known Chrome/Chromium executable locations, by platform. Checked in
* order; the first that exists wins. Env overrides take precedence over these.
*/
function candidateBrowserPaths(env: NodeJS.ProcessEnv): string[] {
const fromEnv = [env.FUSION_BROWSER_EXECUTABLE, env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, env.CHROME_PATH]
.map((v) => v?.trim())
.filter((v): v is string => !!v && v.length > 0);
if (process.platform === "darwin") {
return [
...fromEnv,
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
];
}
if (process.platform === "win32") {
const programFiles = env["PROGRAMFILES"] ?? "C:\\Program Files";
const programFilesX86 = env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
return [
...fromEnv,
`${programFiles}\\Google\\Chrome\\Application\\chrome.exe`,
`${programFilesX86}\\Google\\Chrome\\Application\\chrome.exe`,
`${programFilesX86}\\Microsoft\\Edge\\Application\\msedge.exe`,
];
}
return [
...fromEnv,
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
];
}
/** PATH-resolvable executable names to fall back to when no fixed path exists. */
const BROWSER_BINARY_NAMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"];
/**
* Locate a Chrome/Chromium executable the verification driver can launch.
*
* Resolution order: explicit `opts.executablePath` → env overrides
* (`FUSION_BROWSER_EXECUTABLE` / `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` /
* `CHROME_PATH`) → well-known platform paths → PATH lookup of common binary
* names. Returns `available: false` (with a reason) when nothing is found, so
* the caller degrades to INCONCLUSIVE rather than failing the assertion.
*/
export async function probeBrowserExecutable(opts?: {
executablePath?: string;
env?: NodeJS.ProcessEnv;
}): Promise<BrowserExecutableProbeResult> {
const env = opts?.env ?? process.env;
const explicit = opts?.executablePath?.trim();
if (explicit) {
if (await isExecutableFile(explicit)) return { available: true, executablePath: explicit };
return { available: false, reason: `configured browser executable not found: ${explicit}` };
}
for (const candidate of candidateBrowserPaths(env)) {
if (await isExecutableFile(candidate)) return { available: true, executablePath: candidate };
}
for (const name of BROWSER_BINARY_NAMES) {
const resolved = await tryResolveBinaryPath(name);
if (resolved && (await isExecutableFile(resolved))) {
return { available: true, executablePath: resolved };
}
}
return {
available: false,
reason:
"no Chrome/Chromium executable found (checked FUSION_BROWSER_EXECUTABLE / CHROME_PATH, well-known paths, and PATH)",
};
}
async function isExecutableFile(p: string): Promise<boolean> {
try {
await access(p, constants.X_OK);
return true;
} catch {
return false;
}
}
async function tryResolveBinaryPath(binary: string): Promise<string | undefined> {
return new Promise((resolvePromise) => {
const which = process.platform === "win32" ? "where" : "which";
const child = spawn(which, [binary], { stdio: ["ignore", "pipe", "ignore"] });
let out = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try {
child.kill("SIGKILL");
} catch {
// ignored
}
resolvePromise(undefined);
}, 2000);
child.stdout?.on("data", (chunk: Buffer) => {
out += chunk.toString("utf-8");
});
child.on("error", () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolvePromise(undefined);
});
child.on("close", (code: number | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 0) {
const first = out.trim().split(/\r?\n/)[0];
resolvePromise(first?.length ? first : undefined);
} else {
resolvePromise(undefined);
}
});
});
}