From 60744dd26e959cfaffa6bafe97db5f932a8c61ba Mon Sep 17 00:00:00 2001 From: Matthew Teufel Date: Mon, 6 Jul 2026 10:14:46 -0700 Subject: [PATCH 1/2] feat(fork): create fork demo with multiple browser agents --- .env.example | 7 +- README.md | 68 ++++++ app/actions.ts | 132 +++++++++++ app/favicon.ico | Bin 4286 -> 0 bytes app/fork/page.tsx | 404 ++++++++++++++++++++++++++++++++ app/layout.tsx | 5 + app/page.tsx | 21 +- components/fork/agent-pane.tsx | 201 ++++++++++++++++ components/fork/fork-runner.tsx | 50 ++++ components/logo.tsx | 29 +-- lib/fork/config.ts | 85 +++++++ lib/fork/use-agent-run.ts | 234 ++++++++++++++++++ lib/streaming/openai.ts | 5 +- package-lock.json | 304 +++++++++++++++++++++--- package.json | 4 +- public/favicon.ico | Bin 0 -> 17136 bytes scripts/e2e-fork.mts | 102 ++++++++ scripts/smoke-fork.mts | 100 ++++++++ 18 files changed, 1698 insertions(+), 53 deletions(-) delete mode 100644 app/favicon.ico create mode 100644 app/fork/page.tsx create mode 100644 components/fork/agent-pane.tsx create mode 100644 components/fork/fork-runner.tsx create mode 100644 lib/fork/config.ts create mode 100644 lib/fork/use-agent-run.ts create mode 100644 public/favicon.ico create mode 100644 scripts/e2e-fork.mts create mode 100644 scripts/smoke-fork.mts diff --git a/.env.example b/.env.example index 26a24f3..063beb2 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,7 @@ E2B_API_KEY=your_e2b_api_key -OPENAI_API_KEY=your_openai_api_key \ No newline at end of file +OPENAI_API_KEY=your_openai_api_key + +# Fork demo (/fork) — a Hacker News account the primary agent logs in with. +# Sign up at https://news.ycombinator.com/login (no email required). +DEMO_SITE_USERNAME=your_hn_username +DEMO_SITE_PASSWORD=your_hn_password diff --git a/README.md b/README.md index 8367b06..86fcb09 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,72 @@ Navigate to [http://localhost:3000](http://localhost:3000) in your browser. - **Chat Interface**: Provides a conversational interface for interacting with the AI - **Example Prompts**: Offers pre-defined instructions to help users get started - **Dark/Light Mode**: Supports both dark and light themes +- **Fork an authenticated agent** (`/fork`): Demonstrates E2B snapshots for parallel agents — see below + +## Forking an authenticated agent (E2B Snapshots demo) + +The `/fork` route (linked as **"Fork demo"** in the header) showcases why E2B +snapshots are a compelling primitive for parallel agents that need to **share +work and state**. + +The flow has three stages: + +1. **Authenticate once** — A single agent completes a real browser login flow on + [Hacker News](https://news.ycombinator.com/login) using an account whose + credentials live in `.env.local`. +2. **Snapshot** — The agent's sandbox is snapshotted with + [`sandbox.createSnapshot()`](https://e2b.dev/docs). A snapshot captures the + sandbox's **full state — memory *and* filesystem — including the running + browser and its live authenticated session**. +3. **Fork & explore in parallel** — The snapshot is forked into + `FORK_COUNT` (default **3**) independent sandboxes via + `Sandbox.create(snapshotId)`. Every fork resumes **already logged in** and + immediately continues exploring different parts of the site — no fork ever + authenticates again. + +### Setup + +Add a Hacker News account's credentials to `.env.local` (any HN account works — +signup at https://news.ycombinator.com/login takes seconds and needs no email): + +```env +DEMO_SITE_USERNAME=your_hn_username +DEMO_SITE_PASSWORD=your_hn_password +``` + +These are read **only on the server** (via the `getForkDemoConfig` action), so +the password is never bundled into the client. To target a different site, +edit `lib/fork/config.ts` (`DEMO_SITE`, `buildAuthTask`, `buildForkTasks`). + +This makes the value proposition concrete: the slow, sensitive, rate-limited +step (auth) is done **once**, then cheaply and securely shared across many +isolated agents running concurrently. Each fork is a fully isolated micro-VM, so +parallel work never leaks between agents. + +Key files: + +- `lib/fork/config.ts` — demo constants: target site, public credentials, the + auth task, and the per-fork exploration tasks (`FORK_COUNT`, `FORK_TASKS`). +- `app/actions.ts` → `snapshotAndForkAction()` — snapshots the source sandbox + and forks it into N streaming desktop sandboxes. +- `lib/fork/use-agent-run.ts` — client hook that drives one agent against + `/api/chat` and exposes a compact live activity log. +- `components/fork/agent-pane.tsx` / `fork-runner.tsx` — the per-agent VNC + + activity-log panes. +- `app/fork/page.tsx` — the orchestrator page (authenticate → snapshot → fork). + +> **Note:** Snapshots/forking require `e2b >= 2.x` and `@e2b/desktop >= 2.x` +> (already pinned in `package.json`). + +Two standalone scripts under `scripts/` verify the mechanic without the UI +(they read keys from your shell env — `export E2B_API_KEY=… OPENAI_API_KEY=… +DEMO_SITE_USERNAME=… DEMO_SITE_PASSWORD=…`): + +- `npx tsx scripts/smoke-fork.mts` — fast, no OpenAI: snapshots a sandbox, forks + it ×3, and confirms the forks share filesystem + memory (screenshots to `/tmp`). +- `npx tsx scripts/e2e-fork.mts` — full flow with the real agent: logs into + Hacker News, snapshots, forks ×3, and each fork continues exploring while + authenticated (screenshots to `/tmp`). ## Technical Details @@ -132,6 +198,8 @@ See `package.json` for a complete list of dependencies. - **createSandbox**: Creates a new sandbox instance - **increaseTimeout**: Extends the sandbox timeout - **stopSandboxAction**: Stops a running sandbox instance +- **snapshotAndForkAction**: Snapshots an authenticated sandbox and forks it into N independent, streaming sandboxes (used by the `/fork` demo) +- **stopSandboxesAction**: Stops a set of sandboxes at once (tears down all forks) ## Troubleshooting diff --git a/app/actions.ts b/app/actions.ts index 2f5f831..b78bdb2 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -1,7 +1,19 @@ "use server"; import { SANDBOX_TIMEOUT_MS } from "@/lib/config"; +import { + buildAuthTask, + buildForkTasks, + DEMO_SITE, + ForkDemoConfig, + FORK_COUNT, + FORK_RESOLUTION, +} from "@/lib/fork/config"; import { Sandbox } from "@e2b/desktop"; +import { logError, logSuccess } from "@/lib/logger"; + +/** Port the desktop noVNC server listens on (see @e2b/desktop VNCServer). */ +const VNC_PORT = 6080; export async function increaseTimeout(sandboxId: string) { try { @@ -24,3 +36,123 @@ export async function stopSandboxAction(sandboxId: string) { return false; } } + +/** + * Returns the fork-demo tasks, built on the server from the credentials in + * .env.local. Keeps the password out of the client bundle. + */ +export async function getForkDemoConfig(): Promise { + const username = process.env.DEMO_SITE_USERNAME; + const password = process.env.DEMO_SITE_PASSWORD; + + if (!username || !password) { + throw new Error( + "DEMO_SITE_USERNAME / DEMO_SITE_PASSWORD are not set in .env.local" + ); + } + + return { + siteLabel: DEMO_SITE.label, + username, + authTask: buildAuthTask(username, password), + forkTasks: buildForkTasks(username), + }; +} + +export interface ForkInfo { + sandboxId: string; + vncUrl: string; +} + +export interface SnapshotAndForkResult { + /** Snapshot identifier that can be passed to Sandbox.create() to make more forks. */ + snapshotId: string; + /** The freshly created forks, each already streaming and sharing the snapshot's state. */ + forks: ForkInfo[]; +} + +/** + * Snapshot an authenticated sandbox and fork it into `count` independent sandboxes. + * + * The snapshot captures the full sandbox state — memory *and* filesystem — which + * includes the running browser and its live authenticated session. Every fork + * created from the snapshot therefore resumes already logged in, and can explore + * the site in parallel without ever authenticating again. + * + * This is the heart of the demo: one expensive/sensitive step (the auth flow) is + * done once, then cheaply shared across many parallel agents. + */ +export async function snapshotAndForkAction( + sandboxId: string, + count: number = FORK_COUNT +): Promise { + // Connect to the source sandbox and snapshot it. createSnapshot pauses the + // sandbox while it captures a persistent image of the full state. + const source = await Sandbox.connect(sandboxId); + const snapshot = await source.createSnapshot({ + name: `surf-fork-${sandboxId.slice(0, 8)}`, + }); + + logSuccess("Created snapshot for fork", { + sandboxId, + snapshotId: snapshot.snapshotId, + }); + + // Fork the snapshot into `count` independent sandboxes, in parallel. Each fork + // is a full desktop sandbox that resumes from the snapshot's state. + const forks = await Promise.all( + Array.from({ length: count }, async (_, index): Promise => { + const fork = await Sandbox.create(snapshot.snapshotId, { + resolution: FORK_RESOLUTION, + dpi: 96, + timeoutMs: SANDBOX_TIMEOUT_MS, + metadata: { + surfRole: "fork", + surfForkIndex: String(index), + surfSnapshot: snapshot.snapshotId, + }, + }); + + // The snapshot was taken with the VNC stream running, so each fork resumes + // with it already up. Try to start it (works if it isn't running); if it's + // already running we can't call start() again, so derive the stream URL + // directly from the fork's host — the noVNC server is already serving it. + let vncUrl: string; + try { + await fork.stream.start(); + vncUrl = fork.stream.getUrl(); + } catch { + vncUrl = `https://${fork.getHost(VNC_PORT)}/vnc.html?autoconnect=true&resize=scale`; + } + + return { sandboxId: fork.sandboxId, vncUrl }; + }) + ); + + logSuccess("Forked authenticated sandbox", { + sandboxId, + snapshotId: snapshot.snapshotId, + forkIds: forks.map((f) => f.sandboxId), + }); + + return { snapshotId: snapshot.snapshotId, forks }; +} + +/** + * Stop a set of sandboxes (used to tear down all forks at once). + */ +export async function stopSandboxesAction(sandboxIds: string[]) { + const results = await Promise.allSettled( + sandboxIds.map(async (id) => { + const desktop = await Sandbox.connect(id); + await desktop.kill(); + }) + ); + + const failed = results.filter((r) => r.status === "rejected"); + if (failed.length > 0) { + logError("Failed to stop some sandboxes", { failedCount: failed.length }); + } + + return failed.length === 0; +} diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 0e138eb9e43d44eab6b034ecbea961b26db543a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4286 zcmb_gZ-`V?6u;wsSX!|rOGy{$ehHR9nZ86pxkE@nip__h=tDkpu@8bffnUO4wm*=T zFhe3GB5!Dkf~X-df*^K-#FXG6ZK)7FiTt9lV-Oi>@7MXgo%8n1oA=(n3D5G|d+)jD zp5M9W+`EjWH7fQJJcM`cP!fl z6V@B1MqTkU(E*JrbWFLK4j%3xqU_ZP1-Z8%n0TwFxA z+eHurp(<%>#w^Ub>VV%K7dcf@_ugnULciQ2bI!VJ?m5An6Ds@m*Rg7%22*XL^sJB4 zi$G~hBa7edk+T-@w=A;fnb!O&N4#Zz2+GZWfm6Q#kIn*j912i+4KPza%xLbtAh#ok zIN8_x#eB}p%)}}C`f7Je(*NsqV0J;yobhnxN`Rx^b+P014mM8i!UKnV+;&Bj`(14PD8Pd!fbnC%=u@fOTu<)Q+S7Ml49cBs zcf8uh)K|bWp8-{gVM8sGoBWyF>UWqJ_Ppuh$T{HPw*mHj4QzfsmpjUza;3G&^T+#^ zW=Gvi2b=hCA;5b-0B@ZJ9^YMvKQjlzaBED{hh2R9bAaO>@b32kp4hW|Zqz2$lG}@o zqrK&E^XyeBmB@Bd#*b}XK7X&g#2_Hx-R4LExR*i~;}{kX-w8!g7xq`rgX<{KF0GTtBN{bdX{ZUWc-lDA3T!Uc)pi?cq~ z+?mQtKGvSxlz|$EE_!>JU!Hq@f2OCWNfiop?RHy-Xf~VJzO#lm4mUA>-p7wufxQP( z^G^O2iF;zD9moOC8FL(;<5-2?UP2{lJHE+c=W|Zq`j{_r#-%BPEZaUk&mM(&A+%p~ zz1Wc}DXfJr=;KNoGw&4UN58-PyCBUrMMn;;yL9`+%4o$Hcn)o`)N%8 diff --git a/app/fork/page.tsx b/app/fork/page.tsx new file mode 100644 index 0000000..649d71c --- /dev/null +++ b/app/fork/page.tsx @@ -0,0 +1,404 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { toast } from "sonner"; +import { + Camera, + GitFork, + LogIn, + MoonIcon, + Power, + SunIcon, +} from "lucide-react"; +import Frame from "@/components/frame"; +import Logo from "@/components/logo"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { AgentPane } from "@/components/fork/agent-pane"; +import { ForkRunner } from "@/components/fork/fork-runner"; +import { useAgentRun, AgentStatus } from "@/lib/fork/use-agent-run"; +import { + FORK_COUNT, + FORK_RESOLUTION, + DEMO_SITE, + ForkDemoConfig, +} from "@/lib/fork/config"; +import { + ForkInfo, + getForkDemoConfig, + snapshotAndForkAction, + stopSandboxesAction, +} from "@/app/actions"; +import { cn } from "@/lib/utils"; + +type Stage = + | "intro" + | "authenticating" + | "authenticated" + | "forking" + | "exploring"; + +const STEPS: { key: Stage[]; label: string; icon: typeof LogIn }[] = [ + { key: ["authenticating", "authenticated"], label: "1 · Authenticate", icon: LogIn }, + { key: ["forking"], label: "2 · Snapshot", icon: Camera }, + { key: ["exploring"], label: "3 · Fork & explore", icon: GitFork }, +]; + +export default function ForkDemoPage() { + const { theme, setTheme } = useTheme(); + const primary = useAgentRun(); + + const [stage, setStage] = useState("intro"); + const [forks, setForks] = useState([]); + const [snapshotId, setSnapshotId] = useState(null); + const [forkStatuses, setForkStatuses] = useState>( + {} + ); + const [demoConfig, setDemoConfig] = useState(null); + const [configError, setConfigError] = useState(null); + + // Load the demo tasks (built server-side from .env.local credentials). + useEffect(() => { + getForkDemoConfig() + .then(setDemoConfig) + .catch((e) => { + console.error(e); + setConfigError( + "Demo credentials missing. Set DEMO_SITE_USERNAME and DEMO_SITE_PASSWORD in .env.local." + ); + }); + }, []); + + const handleForkStatus = useCallback( + (sandboxId: string, status: AgentStatus) => { + setForkStatuses((prev) => + prev[sandboxId] === status ? prev : { ...prev, [sandboxId]: status } + ); + }, + [] + ); + + const allForksDone = useMemo( + () => + forks.length > 0 && + forks.every((f) => { + const s = forkStatuses[f.sandboxId]; + return s === "done" || s === "error"; + }), + [forks, forkStatuses] + ); + + const startDemo = async () => { + if (!demoConfig) return; + setStage("authenticating"); + const sandboxId = await primary.run({ + task: demoConfig.authTask, + resolution: FORK_RESOLUTION, + }); + if (sandboxId) { + setStage("authenticated"); + toast.success("Agent authenticated — ready to snapshot & fork"); + } else { + setStage("intro"); + toast.error("Could not start the authenticated agent"); + } + }; + + const forkAgent = async () => { + if (!primary.sandboxId) return; + setStage("forking"); + toast("Snapshotting sandbox & spawning forks…"); + try { + const result = await snapshotAndForkAction(primary.sandboxId, FORK_COUNT); + setSnapshotId(result.snapshotId); + setForks(result.forks); + setStage("exploring"); + toast.success(`${result.forks.length} forks resumed — already logged in`); + } catch (e) { + console.error(e); + setStage("authenticated"); + toast.error("Failed to snapshot & fork the sandbox"); + } + }; + + const reset = async () => { + primary.stop(); + const ids = [ + primary.sandboxId, + ...forks.map((f) => f.sandboxId), + ].filter(Boolean) as string[]; + if (ids.length > 0) { + // Fire and forget — don't block the UI reset on teardown. + void stopSandboxesAction(ids); + } + primary.reset(); + setForks([]); + setForkStatuses({}); + setSnapshotId(null); + setStage("intro"); + toast("Demo reset — sandboxes are being stopped"); + }; + + const hasStarted = stage !== "intro"; + + return ( +
+ + {/* Header */} +
+
+ + + +

+ Forking an authenticated agent + + E2B Snapshots + +

+
+ +
+ + + {hasStarted && ( + + )} +
+
+ + {/* Body */} +
+ {stage === "intro" ? ( + + ) : ( + <> + {/* Primary authenticated agent */} +
+
+ +

+ Primary agent — authenticating on{" "} + {DEMO_SITE.host} +

+
+ + + {stage === "authenticated" && ( +
+ +

+ Logged in. Snapshot its full state and fork into{" "} + {FORK_COUNT}{" "} + independent agents. +

+ +
+ )} +
+ + {/* Forks */} + {forks.length > 0 && ( +
+
+ +

+ {FORK_COUNT} forks exploring in parallel +

+ + snapshot {snapshotId?.slice(0, 18)}… + + {allForksDone && ( + All forks finished + )} +
+
+ {forks.map((fork, i) => ( + + ))} +
+
+ )} + + )} +
+ +
+ ); +} + +function Stepper({ stage }: { stage: Stage }) { + return ( +
+ {STEPS.map((step, i) => { + const active = step.key.includes(stage); + const done = + STEPS.findIndex((s) => s.key.includes(stage)) > i || stage === "exploring" && i < 2; + const Icon = step.icon; + return ( +
+ + {step.label} +
+ ); + })} +
+ ); +} + +function Intro({ + onStart, + ready, + configError, + username, +}: { + onStart: () => void; + ready: boolean; + configError: string | null; + username?: string; +}) { + return ( +
+
+ + + E2B Snapshot Fork Demo + +
+

+ One agent logs in on{" "} + {DEMO_SITE.label} + {username && ( + <> + {" "} + as {username} + + )} + , then{" "} + forks itself {FORK_COUNT}× to explore + in parallel — every fork already authenticated. +

+
+ + + +
+ + {configError ? ( +

{configError}

+ ) : ( +

+ Uses this project's existing E2B + OpenAI setup. Resolution{" "} + {FORK_RESOLUTION[0]}×{FORK_RESOLUTION[1]}. +

+ )} +
+ ); +} + +function IntroCard({ + icon: Icon, + title, + body, +}: { + icon: typeof LogIn; + title: string; + body: string; +}) { + return ( +
+ +

{title}

+

{body}

+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 7b816d5..a1319e0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -33,6 +33,11 @@ export const metadata: Metadata = { "sandbox", ], authors: [{ name: "E2B", url: "https://e2b.dev" }], + icons: { + icon: "/favicon.ico", + shortcut: "/favicon.ico", + apple: "/favicon.ico", + }, }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index 059ec0c..565b612 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,6 +9,7 @@ import { Menu, X, ArrowUpRight, + GitFork, } from "lucide-react"; import { useTheme } from "next-themes"; import { toast } from "sonner"; @@ -19,7 +20,7 @@ import { ChatInput } from "@/components/chat/input"; import { ExamplePrompts } from "@/components/chat/example-prompts"; import { useChat } from "@/lib/chat-context"; import Frame from "@/components/frame"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Loader, AssemblyLoader } from "@/components/loader"; import Link from "next/link"; import Logo from "@/components/logo"; @@ -212,7 +213,7 @@ export default function Home() { className="flex items-center gap-1 sm:gap-2" target="_blank" > - +

Surf - Computer Agent by

+ + + Fork demo + @@ -347,6 +356,14 @@ export default function Home() { >
+ + + Fork demo +
diff --git a/components/fork/agent-pane.tsx b/components/fork/agent-pane.tsx new file mode 100644 index 0000000..6467eff --- /dev/null +++ b/components/fork/agent-pane.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Loader } from "@/components/loader"; +import { AgentStatus, AgentLogEntry } from "@/lib/fork/use-agent-run"; +import { + CheckCircle2, + CircleDashed, + Cpu, + Lock, + MousePointerClick, + XCircle, +} from "lucide-react"; + +interface AgentPaneProps { + title: string; + subtitle?: string; + status: AgentStatus; + log: AgentLogEntry[]; + vncUrl: string | null; + error?: string | null; + /** Show an "authenticated / inherited session" marker on the pane. */ + authenticated?: boolean; + /** Dim + freeze the pane (e.g. the primary once it has been snapshotted). */ + frozen?: boolean; + frozenLabel?: string; + className?: string; +} + +function StatusBadge({ + status, + frozen, + frozenLabel, +}: { + status: AgentStatus; + frozen?: boolean; + frozenLabel?: string; +}) { + if (frozen) { + return ( + + + {frozenLabel ?? "Snapshotted"} + + ); + } + + switch (status) { + case "creating": + return ( + + Booting + + ); + case "running": + return ( + + Working + + ); + case "done": + return ( + + Done + + ); + case "error": + return ( + + Error + + ); + default: + return ( + + Idle + + ); + } +} + +function LogRow({ entry }: { entry: AgentLogEntry }) { + if (entry.kind === "reasoning") { + return ( +

+ + {entry.text} +

+ ); + } + + return ( +

+ + {entry.text} +

+ ); +} + +/** + * A single agent's live view: VNC stream on top, scrolling activity log below. + * Used for both the primary authenticated agent and each fork. + */ +export function AgentPane({ + title, + subtitle, + status, + log, + vncUrl, + error, + authenticated, + frozen, + frozenLabel, + className, +}: AgentPaneProps) { + const logRef = useRef(null); + + useEffect(() => { + logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); + }, [log]); + + return ( +
+
+
+
+ {title} + {authenticated && ( + + )} +
+ {subtitle && ( +

{subtitle}

+ )} +
+ +
+ +
+ {vncUrl ? ( +