cb15c5e0d8
Release / Check for new version (push) Has been cancelled
Release / Build macOS ARM64 (push) Has been cancelled
Release / Build macOS x64 (push) Has been cancelled
Release / Build Linux ARM64 (push) Has been cancelled
Release / Build Linux musl ARM64 (push) Has been cancelled
Release / Build Linux musl x64 (push) Has been cancelled
Release / Build Linux x64 (push) Has been cancelled
Release / Build Windows x64 (push) Has been cancelled
Release / Publish to npm (push) Has been cancelled
Release / Publish sandbox package to npm (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
CI / Rust (windows-latest - x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Native E2E Tests (push) Has been cancelled
CI / Windows Integration Test (push) Has been cancelled
CI / Global Install (macos-latest) (push) Has been cancelled
CI / Global Install (ubuntu-latest) (push) Has been cancelled
CI / Version Sync Check (push) Has been cancelled
CI / Rust (push) Has been cancelled
CI / Dashboard (push) Has been cancelled
CI / Sandbox Package (push) Has been cancelled
CI / Rust (macos-latest - aarch64-apple-darwin) (push) Has been cancelled
CI / Rust (macos-latest - x86_64-apple-darwin) (push) Has been cancelled
CI / Global Install (windows-latest) (push) Has been cancelled
324 lines
8.2 KiB
TypeScript
324 lines
8.2 KiB
TypeScript
/**
|
|
* Run agent-browser inside a Vercel Sandbox.
|
|
*
|
|
* No external server needed -- a Linux microVM spins up on demand,
|
|
* runs agent-browser + headless Chrome, and shuts down when done.
|
|
*
|
|
* For production, create a snapshot with agent-browser and Chromium
|
|
* pre-installed so startup is sub-second instead of ~30s.
|
|
*/
|
|
|
|
import { Sandbox } from "@vercel/sandbox";
|
|
|
|
export type SandboxResult = {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
};
|
|
|
|
export type StepEvent = {
|
|
step: string;
|
|
status: "running" | "done" | "error";
|
|
elapsed?: number;
|
|
};
|
|
|
|
export type OnStep = (event: StepEvent) => void;
|
|
|
|
const SNAPSHOT_ID = process.env.AGENT_BROWSER_SNAPSHOT_ID;
|
|
|
|
const CHROMIUM_SYSTEM_DEPS = [
|
|
"nss",
|
|
"nspr",
|
|
"libxkbcommon",
|
|
"atk",
|
|
"at-spi2-atk",
|
|
"at-spi2-core",
|
|
"libXcomposite",
|
|
"libXdamage",
|
|
"libXrandr",
|
|
"libXfixes",
|
|
"libXcursor",
|
|
"libXi",
|
|
"libXtst",
|
|
"libXScrnSaver",
|
|
"libXext",
|
|
"mesa-libgbm",
|
|
"libdrm",
|
|
"mesa-libGL",
|
|
"mesa-libEGL",
|
|
"cups-libs",
|
|
"alsa-lib",
|
|
"pango",
|
|
"cairo",
|
|
"gtk3",
|
|
"dbus-libs",
|
|
];
|
|
|
|
/**
|
|
* Returns credentials to spread into Sandbox.create() calls.
|
|
* When explicit env vars are set they take precedence; otherwise returns
|
|
* an empty object so the SDK falls back to VERCEL_OIDC_TOKEN automatically.
|
|
*/
|
|
export function getSandboxCredentials():
|
|
| { token: string; teamId: string; projectId: string }
|
|
| Record<string, never> {
|
|
if (
|
|
process.env.VERCEL_TOKEN &&
|
|
process.env.VERCEL_TEAM_ID &&
|
|
process.env.VERCEL_PROJECT_ID
|
|
) {
|
|
return {
|
|
token: process.env.VERCEL_TOKEN,
|
|
teamId: process.env.VERCEL_TEAM_ID,
|
|
projectId: process.env.VERCEL_PROJECT_ID,
|
|
};
|
|
}
|
|
return {};
|
|
}
|
|
|
|
async function runStep<T>(
|
|
step: string,
|
|
fn: () => Promise<T>,
|
|
onStep?: OnStep,
|
|
): Promise<T> {
|
|
const start = Date.now();
|
|
onStep?.({ step, status: "running" });
|
|
try {
|
|
const result = await fn();
|
|
onStep?.({ step, status: "done", elapsed: Date.now() - start });
|
|
return result;
|
|
} catch (err) {
|
|
onStep?.({ step, status: "error", elapsed: Date.now() - start });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Install system dependencies + agent-browser + Chromium into a fresh sandbox.
|
|
* The sandbox base image is Amazon Linux (dnf).
|
|
*/
|
|
async function bootstrapSandbox(
|
|
sandbox: InstanceType<typeof Sandbox>,
|
|
onStep?: OnStep,
|
|
): Promise<void> {
|
|
await runStep("Installing system dependencies", async () => {
|
|
await sandbox.runCommand("sh", [
|
|
"-c",
|
|
`sudo dnf clean all 2>&1 && sudo dnf install -y --skip-broken ${CHROMIUM_SYSTEM_DEPS.join(" ")} 2>&1 && sudo ldconfig 2>&1`,
|
|
]);
|
|
}, onStep);
|
|
|
|
await runStep("Installing agent-browser", async () => {
|
|
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
|
|
await sandbox.runCommand("npx", ["agent-browser", "install"]);
|
|
}, onStep);
|
|
}
|
|
|
|
async function createSandbox(
|
|
onStep?: OnStep,
|
|
): Promise<InstanceType<typeof Sandbox>> {
|
|
const credentials = getSandboxCredentials();
|
|
|
|
return runStep(
|
|
SNAPSHOT_ID ? "Booting sandbox from snapshot" : "Creating sandbox",
|
|
async () => {
|
|
if (SNAPSHOT_ID) {
|
|
return Sandbox.create({
|
|
...credentials,
|
|
source: { type: "snapshot", snapshotId: SNAPSHOT_ID },
|
|
timeout: 120_000,
|
|
});
|
|
}
|
|
|
|
const sb = await Sandbox.create({
|
|
...credentials,
|
|
runtime: "node24",
|
|
timeout: 120_000,
|
|
});
|
|
await bootstrapSandbox(sb, onStep);
|
|
return sb;
|
|
},
|
|
onStep,
|
|
);
|
|
}
|
|
|
|
async function exec(
|
|
sandbox: InstanceType<typeof Sandbox>,
|
|
cmd: string,
|
|
args: string[],
|
|
onStep?: OnStep,
|
|
stepLabel?: string,
|
|
): Promise<SandboxResult> {
|
|
const label = stepLabel || `${cmd} ${args.join(" ")}`;
|
|
|
|
return runStep(label, async () => {
|
|
const result = await sandbox.runCommand(cmd, args);
|
|
const stdout = await result.stdout();
|
|
const stderr = await result.stderr();
|
|
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(
|
|
`Command "${cmd} ${args.join(" ")}" failed (exit ${result.exitCode}): ${stderr || stdout}`,
|
|
);
|
|
}
|
|
|
|
return { exitCode: result.exitCode, stdout, stderr };
|
|
}, onStep);
|
|
}
|
|
|
|
/**
|
|
* Screenshot a URL using agent-browser inside a Vercel Sandbox.
|
|
* Returns base64-encoded PNG.
|
|
*/
|
|
export async function screenshotUrl(
|
|
url: string,
|
|
opts: { fullPage?: boolean; onStep?: OnStep } = {},
|
|
): Promise<{ screenshot: string; title: string }> {
|
|
const { onStep } = opts;
|
|
const sandbox = await createSandbox(onStep);
|
|
|
|
try {
|
|
await exec(sandbox, "agent-browser", ["open", "about:blank"], onStep, "Starting browser");
|
|
await exec(sandbox, "agent-browser", ["open", url], onStep, `Navigating to ${url}`);
|
|
|
|
const titleResult = await exec(
|
|
sandbox,
|
|
"agent-browser",
|
|
["get", "title", "--json"],
|
|
onStep,
|
|
"Getting page title",
|
|
);
|
|
const title = tryParseJson(titleResult.stdout)?.data?.title || url;
|
|
|
|
const screenshotArgs = ["screenshot", "--json"];
|
|
if (opts.fullPage) screenshotArgs.push("--full");
|
|
const ssResult = await exec(
|
|
sandbox,
|
|
"agent-browser",
|
|
screenshotArgs,
|
|
onStep,
|
|
"Taking screenshot",
|
|
);
|
|
const ssData = tryParseJson(ssResult.stdout)?.data;
|
|
const screenshotPath = ssData?.path;
|
|
|
|
if (!screenshotPath) {
|
|
throw new Error(
|
|
`Screenshot returned no file path. Raw output: ${ssResult.stdout.slice(0, 500)}`,
|
|
);
|
|
}
|
|
|
|
const b64Result = await exec(
|
|
sandbox,
|
|
"base64",
|
|
["-w", "0", screenshotPath],
|
|
onStep,
|
|
"Encoding screenshot",
|
|
);
|
|
const screenshot = b64Result.stdout.trim();
|
|
|
|
if (!screenshot) {
|
|
throw new Error("Failed to read screenshot file from sandbox");
|
|
}
|
|
|
|
await exec(sandbox, "agent-browser", ["close"], onStep, "Closing browser");
|
|
|
|
return { screenshot, title };
|
|
} finally {
|
|
await runStep("Stopping sandbox", () => sandbox.stop(), onStep);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Snapshot a URL (accessibility tree) using agent-browser inside a Vercel Sandbox.
|
|
*/
|
|
export async function snapshotUrl(
|
|
url: string,
|
|
opts: { interactive?: boolean; compact?: boolean; onStep?: OnStep } = {},
|
|
): Promise<{ snapshot: string; title: string }> {
|
|
const { onStep } = opts;
|
|
const sandbox = await createSandbox(onStep);
|
|
|
|
try {
|
|
await exec(sandbox, "agent-browser", ["open", "about:blank"], onStep, "Starting browser");
|
|
await exec(sandbox, "agent-browser", ["open", url], onStep, `Navigating to ${url}`);
|
|
|
|
const titleResult = await exec(
|
|
sandbox,
|
|
"agent-browser",
|
|
["get", "title", "--json"],
|
|
onStep,
|
|
"Getting page title",
|
|
);
|
|
const title = tryParseJson(titleResult.stdout)?.data?.title || url;
|
|
|
|
const snapshotArgs = ["snapshot"];
|
|
if (opts.interactive) snapshotArgs.push("-i");
|
|
if (opts.compact) snapshotArgs.push("-c");
|
|
const snapResult = await exec(
|
|
sandbox,
|
|
"agent-browser",
|
|
snapshotArgs,
|
|
onStep,
|
|
"Taking accessibility snapshot",
|
|
);
|
|
|
|
if (!snapResult.stdout.trim()) {
|
|
throw new Error("Snapshot returned empty data");
|
|
}
|
|
|
|
await exec(sandbox, "agent-browser", ["close"], onStep, "Closing browser");
|
|
|
|
return { snapshot: snapResult.stdout, title };
|
|
} finally {
|
|
await runStep("Stopping sandbox", () => sandbox.stop(), onStep);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run arbitrary agent-browser commands inside a Vercel Sandbox.
|
|
* Each command is a string array like ["open", "https://example.com"].
|
|
*/
|
|
export async function runCommands(
|
|
commands: string[][],
|
|
): Promise<SandboxResult[]> {
|
|
const sandbox = await createSandbox();
|
|
|
|
try {
|
|
const results: SandboxResult[] = [];
|
|
for (const args of commands) {
|
|
const result = await exec(sandbox, "agent-browser", args);
|
|
results.push(result);
|
|
}
|
|
return results;
|
|
} finally {
|
|
await sandbox.stop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a reusable snapshot with agent-browser + Chromium pre-installed.
|
|
* Run this once, then set AGENT_BROWSER_SNAPSHOT_ID for fast startup.
|
|
*/
|
|
export async function createSnapshot(): Promise<string> {
|
|
const sandbox = await Sandbox.create({
|
|
...getSandboxCredentials(),
|
|
runtime: "node24",
|
|
timeout: 300_000,
|
|
});
|
|
|
|
await bootstrapSandbox(sandbox);
|
|
|
|
const snapshot = await sandbox.snapshot();
|
|
return snapshot.snapshotId;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function tryParseJson(str: string): any {
|
|
try {
|
|
return JSON.parse(str);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|