fix environments demo (#696)

* fix

* fixes

* fixes

* update docs

* fixes

* fixes

* sandbox tokens

* better logging
This commit is contained in:
Chris Tate
2026-03-09 17:00:30 -05:00
committed by GitHub
parent c0a525c9e4
commit 5bf9fedd58
42 changed files with 1366 additions and 1850 deletions
@@ -0,0 +1,15 @@
"use server";
export type EnvStatus = {
sandbox: {
hasSnapshot: boolean;
};
};
export async function getEnvStatus(): Promise<EnvStatus> {
return {
sandbox: {
hasSnapshot: !!process.env.AGENT_BROWSER_SNAPSHOT_ID,
},
};
}
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import * as sandbox from "@/lib/agent-browser-sandbox";
import type { StepEvent } from "@/lib/agent-browser-sandbox";
import { ALLOWED_URLS } from "@/lib/constants";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
export async function POST(req: NextRequest) {
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
const minute = await minuteRateLimit.limit(ip);
if (!minute.success) {
return NextResponse.json(
{ error: "Too many requests. Please wait a moment before trying again." },
{ status: 429 },
);
}
const daily = await dailyRateLimit.limit(ip);
if (!daily.success) {
return NextResponse.json(
{ error: "Daily limit reached. Please try again tomorrow." },
{ status: 429 },
);
}
const body = await req.json();
const { url, action } = body;
if (!url) {
return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 });
}
if (!(ALLOWED_URLS as readonly string[]).includes(url)) {
return NextResponse.json({ error: "URL not allowed" }, { status: 400 });
}
if (action !== "screenshot" && action !== "snapshot") {
return NextResponse.json(
{ error: "Provide 'action' as 'screenshot' or 'snapshot'" },
{ status: 400 },
);
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
);
};
const onStep = (step: StepEvent) => {
send("step", step);
};
try {
if (action === "screenshot") {
const result = await sandbox.screenshotUrl(url, {
fullPage: body.fullPage,
onStep,
});
send("result", { ok: true, ...result });
} else {
const result = await sandbox.snapshotUrl(url, {
interactive: true,
compact: true,
onStep,
});
send("result", { ok: true, ...result });
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
send("result", { ok: false, error: message });
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+139
View File
@@ -0,0 +1,139 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme {
--color-surface: #fafafa;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
}
html {
@apply font-sans;
}
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--surface: oklch(0.985 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.87 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--surface: oklch(0.205 0 0);
}
@theme inline {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, "SFMono-Regular",
"Roboto Mono", monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-surface: var(--surface);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import "./globals.css";
export const metadata: Metadata = {
title: "agent-browser Environments",
description: "Run agent-browser in different compute environments",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body className="min-h-screen font-sans antialiased">{children}</body>
</html>
);
}
+518
View File
@@ -0,0 +1,518 @@
"use client";
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import { getEnvStatus } from "./actions/browse";
import type { EnvStatus } from "./actions/browse";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { ALLOWED_URLS } from "@/lib/constants";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, Monitor, CircleX, Sun, Moon, Check } from "lucide-react";
const MOBILE_QUERY = "(max-width: 767px)";
const subscribe = (cb: () => void) => {
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", cb);
return () => mql.removeEventListener("change", cb);
};
const getMobileSnapshot = () => window.matchMedia(MOBILE_QUERY).matches;
const getServerSnapshot = () => false;
function useIsMobile() {
return useSyncExternalStore(subscribe, getMobileSnapshot, getServerSnapshot);
}
function useTheme() {
const [theme, setThemeState] = useState<"light" | "dark">("light");
useEffect(() => {
const stored = localStorage.getItem("theme");
const initial =
stored === "dark" ||
(!stored && window.matchMedia("(prefers-color-scheme: dark)").matches)
? "dark"
: "light";
setThemeState(initial);
document.documentElement.classList.toggle("dark", initial === "dark");
}, []);
const toggle = () => {
const next = theme === "dark" ? "light" : "dark";
setThemeState(next);
document.documentElement.classList.toggle("dark", next === "dark");
localStorage.setItem("theme", next);
};
return { theme, toggle };
}
type Action = "screenshot" | "snapshot";
type StepInfo = {
step: string;
status: "running" | "done" | "error";
elapsed?: number;
};
type BrowseResult = {
ok: boolean;
screenshot?: string;
snapshot?: string;
title?: string;
error?: string;
};
function formatError(raw: string): string {
let cleaned = raw.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
const match = cleaned.match(/(?:error|Error)[:\s]*(.{1,200})/);
if (match) cleaned = match[1].trim();
if (cleaned.length > 300) cleaned = cleaned.slice(0, 300) + "...";
return cleaned || raw.slice(0, 300);
}
function SegmentedControl<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
}) {
return (
<div className="inline-flex rounded-lg border border-input bg-muted p-0.5 w-full">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={`
flex-1 px-3 py-1.5 text-[13px] font-medium rounded-md transition-all cursor-pointer
${
value === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}
`}
>
{opt.label}
</button>
))}
</div>
);
}
function StepIndicator({ step }: { step: StepInfo }) {
return (
<div className="flex items-center gap-2.5 py-1">
<div className="size-4 flex items-center justify-center shrink-0">
{step.status === "running" ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : step.status === "done" ? (
<Check className="size-3.5 text-emerald-500" />
) : (
<CircleX className="size-3.5 text-destructive" />
)}
</div>
<span
className={`text-[13px] ${
step.status === "running"
? "text-foreground"
: step.status === "done"
? "text-muted-foreground"
: "text-destructive"
}`}
>
{step.step}
</span>
{step.elapsed != null && step.status !== "running" && (
<span className="text-[11px] text-muted-foreground/60 tabular-nums ml-auto">
{(step.elapsed / 1000).toFixed(1)}s
</span>
)}
</div>
);
}
function ErrorDisplay({ error }: { error: string }) {
const isHtml = /<[a-z][\s\S]*>/i.test(error);
const message = isHtml ? formatError(error) : error;
const showRaw = isHtml && error.length > 100;
return (
<div className="w-full max-w-2xl space-y-0">
<Alert variant="destructive">
<CircleX className="size-4" />
<AlertTitle>Request failed</AlertTitle>
<AlertDescription>{message}</AlertDescription>
</Alert>
{showRaw && (
<details className="border border-t-0 border-border rounded-b-lg overflow-hidden">
<summary className="px-4 py-2 text-[11px] font-medium text-muted-foreground cursor-pointer hover:bg-muted transition-colors">
Show raw response
</summary>
<pre className="px-4 py-3 text-[11px] leading-relaxed text-muted-foreground font-mono overflow-auto max-h-[200px] bg-muted/50">
{error}
</pre>
</details>
)}
</div>
);
}
async function streamBrowse(
url: string,
action: Action,
onStep: (step: StepInfo) => void,
): Promise<BrowseResult> {
const res = await fetch("/api/browse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, action }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
return { ok: false, error: body?.error || `HTTP ${res.status}` };
}
const reader = res.body?.getReader();
if (!reader) {
return { ok: false, error: "No response stream" };
}
const decoder = new TextDecoder();
let buffer = "";
let result: BrowseResult = { ok: false, error: "No result received" };
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
const eventMatch = part.match(/^event: (\w+)\ndata: ([\s\S]+)$/);
if (!eventMatch) continue;
const [, event, data] = eventMatch;
try {
const parsed = JSON.parse(data);
if (event === "step") {
onStep(parsed as StepInfo);
} else if (event === "result") {
result = parsed as BrowseResult;
}
} catch {
// skip malformed events
}
}
}
return result;
}
export default function Home() {
const isMobile = useIsMobile();
const { theme, toggle: toggleTheme } = useTheme();
const [url, setUrl] = useState<string>(ALLOWED_URLS[0]);
const [loading, setLoading] = useState(false);
const [action, setAction] = useState<Action>("screenshot");
const [result, setResult] = useState<BrowseResult | null>(null);
const [steps, setSteps] = useState<StepInfo[]>([]);
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null);
const stepsEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
getEnvStatus().then(setEnvStatus);
}, []);
function clearResults() {
setResult(null);
setSteps([]);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setResult(null);
setSteps([]);
try {
const browseResult = await streamBrowse(url, action, (step) => {
setSteps((prev) => {
const existing = prev.findIndex((s) => s.step === step.step);
if (existing >= 0) {
const updated = [...prev];
updated[existing] = step;
return updated;
}
return [...prev, step];
});
});
setResult(browseResult);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setResult({ ok: false, error: message });
} finally {
setLoading(false);
}
}
useEffect(() => {
stepsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [steps]);
const controlsForm = (
<form onSubmit={handleSubmit} className="p-5 space-y-5">
<div className="space-y-1.5">
<Label
htmlFor="url-select"
className="text-[11px] text-muted-foreground uppercase tracking-wider"
>
URL
</Label>
<Select
value={url}
onValueChange={(v) => {
if (v) setUrl(v);
clearResults();
}}
>
<SelectTrigger id="url-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALLOWED_URLS.map((u) => (
<SelectItem key={u} value={u}>
{u.replace("https://", "")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground uppercase tracking-wider">
Action
</Label>
<SegmentedControl<Action>
value={action}
onChange={(v) => {
setAction(v);
clearResults();
}}
options={[
{ value: "screenshot", label: "Screenshot" },
{ value: "snapshot", label: "Snapshot" },
]}
/>
<p className="text-[11px] text-muted-foreground">
{action === "screenshot"
? "Captures a full-page PNG image"
: "Returns the accessibility tree"}
</p>
</div>
{envStatus && !envStatus.sandbox.hasSnapshot && (
<Alert>
<AlertTitle className="text-[12px]">
Sandbox snapshot not configured
</AlertTitle>
<AlertDescription className="text-[11px]">
Without a sandbox snapshot, the VM installs agent-browser +
Chromium on every request (~30s). Create one with{" "}
<code className="text-[10px] bg-muted px-1 py-0.5 rounded">
npx tsx scripts/create-snapshot.ts
</code>{" "}
and set{" "}
<code className="text-[10px] bg-muted px-1 py-0.5 rounded">
AGENT_BROWSER_SNAPSHOT_ID
</code>{" "}
for sub-second startup.
</AlertDescription>
</Alert>
)}
<Button type="submit" disabled={loading} className="w-full" size="lg">
{loading && <Loader2 className="size-4 animate-spin" />}
{loading ? "Running..." : "Run"}
</Button>
</form>
);
const showSteps = loading || (steps.length > 0 && !result);
const hasResult = result && !loading;
const resultContent = showSteps ? (
<div className="p-6 lg:p-10">
<div className="max-w-xl mx-auto">
<div className="space-y-0.5">
{steps.map((s, i) => (
<StepIndicator key={`${s.step}-${i}`} step={s} />
))}
<div ref={stepsEndRef} />
</div>
</div>
</div>
) : hasResult ? (
<div className="flex flex-col items-center p-6 lg:p-10">
{result.ok && result.screenshot && (
<div className="w-full max-w-3xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold truncate mr-3">
{result.title}
</h2>
<Badge
variant="outline"
className="font-mono text-[11px] shrink-0"
>
screenshot
</Badge>
</div>
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
<img
src={`data:image/png;base64,${result.screenshot}`}
alt={result.title}
className="w-full block"
/>
</div>
<details className="mt-4">
<summary className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
Show steps ({steps.length})
</summary>
<div className="mt-2 space-y-0.5">
{steps.map((s, i) => (
<StepIndicator key={`${s.step}-${i}`} step={s} />
))}
</div>
</details>
</div>
)}
{result.ok && result.snapshot && (
<div className="w-full max-w-3xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold truncate mr-3">
{result.title}
</h2>
<Badge
variant="outline"
className="font-mono text-[11px] shrink-0"
>
snapshot
</Badge>
</div>
<pre className="bg-card rounded-xl border border-border p-5 overflow-auto text-[13px] leading-relaxed font-mono max-h-[calc(100vh-12rem)]">
{result.snapshot}
</pre>
<details className="mt-4">
<summary className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
Show steps ({steps.length})
</summary>
<div className="mt-2 space-y-0.5">
{steps.map((s, i) => (
<StepIndicator key={`${s.step}-${i}`} step={s} />
))}
</div>
</details>
</div>
)}
{!result.ok && (
<div className="w-full max-w-2xl space-y-4">
<ErrorDisplay error={result.error ?? "Unknown error"} />
{steps.length > 0 && (
<div className="space-y-0.5">
{steps.map((s, i) => (
<StepIndicator key={`${s.step}-${i}`} step={s} />
))}
</div>
)}
</div>
)}
</div>
) : (
<div className="min-h-[300px] md:h-full flex flex-col items-center justify-center text-muted-foreground">
<Monitor className="size-12 mb-4 opacity-30" strokeWidth={1} />
<p className="text-sm font-medium mb-1">No result yet</p>
<p className="text-[13px]">Pick a URL and click Run</p>
</div>
);
return (
<div className="h-screen flex flex-col">
<header className="border-b border-border shrink-0">
<div className="px-4 md:px-6 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold tracking-tight">
agent-browser
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleTheme}
className="size-8 inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun className="size-4" />
) : (
<Moon className="size-4" />
)}
</button>
<a
href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fenvironments&env=AGENT_BROWSER_SNAPSHOT_ID&envDescription=Sandbox%20snapshot%20ID%20for%20fast%20startup.%20Create%20with%20npx%20tsx%20scripts%2Fcreate-snapshot.ts&envLink=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fenvironments%23sandbox-snapshots&project-name=agent-browser-environments&repository-name=agent-browser-environments"
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://vercel.com/button"
alt="Deploy with Vercel"
className="h-8"
/>
</a>
</div>
</div>
</header>
{isMobile ? (
<div className="flex-1 overflow-auto">
<div className="border-b border-border">{controlsForm}</div>
<div className="bg-surface">{resultContent}</div>
</div>
) : (
<ResizablePanelGroup orientation="horizontal" className="flex-1">
<ResizablePanel defaultSize="30%" minSize="20%" maxSize="50%">
<aside className="h-full overflow-y-auto">{controlsForm}</aside>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="70%">
<main className="h-full overflow-auto bg-surface">
{resultContent}
</main>
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
);
}