Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
E2B_API_KEY=your_e2b_api_key
OPENAI_API_KEY=your_openai_api_key
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
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
134 changes: 134 additions & 0 deletions app/actions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
"use server";

import { SANDBOX_TIMEOUT_MS } from "@/lib/config";
import {
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 {
Expand All @@ -24,3 +35,126 @@ export async function stopSandboxAction(sandboxId: string) {
return false;
}
}

/**
* Returns the *non-secret* fork-demo config for the client: the site label, the
* demo username, and the per-fork exploration tasks (which carry no password).
*
* The authentication prompt is intentionally NOT returned here — it embeds the
* password and is built server-side in /api/chat, triggered by FORK_AUTH_PROMPT_ID.
* We still validate the credentials exist so the intro can show a ready state.
*/
export async function getForkDemoConfig(): Promise<ForkDemoConfig> {
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,
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<SnapshotAndForkResult> {
// 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);
Comment thread
mattteufel-e2b marked this conversation as resolved.
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<ForkInfo> => {
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;
}
37 changes: 32 additions & 5 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import {
ComputerInteractionStreamerFacade,
createStreamingResponse,
} from "@/lib/streaming";
import { SANDBOX_TIMEOUT_MS } from "@/lib/config";
import { SANDBOX_TIMEOUT_MS, SANDBOX_TEMPLATE } from "@/lib/config";
import { OpenAIComputerStreamer } from "@/lib/streaming/openai";
import { buildAuthTask, FORK_AUTH_PROMPT_ID } from "@/lib/fork/config";
import { logError } from "@/lib/logger";

export const maxDuration = 800;
Expand All @@ -22,6 +23,7 @@ export async function POST(request: Request) {
messages,
sandboxId,
resolution,
promptId,
} = await request.json();

const apiKey = process.env.E2B_API_KEY;
Expand All @@ -30,13 +32,36 @@ export async function POST(request: Request) {
return new Response("E2B API key not found", { status: 500 });
}

// Resolve server-side prompts. A `promptId` lets the client trigger a run
// whose prompt is assembled here from server secrets, so the credential never
// leaves the server. Any client-supplied `messages` are ignored in that case.
let effectiveMessages = messages;
let redactSecrets: string[] = [];
if (promptId) {
if (promptId === FORK_AUTH_PROMPT_ID) {
const username = process.env.DEMO_SITE_USERNAME;
const password = process.env.DEMO_SITE_PASSWORD;
if (!username || !password) {
return new Response("Demo credentials not configured", { status: 500 });
}
effectiveMessages = [
{ role: "user", content: buildAuthTask(username, password) },
Comment thread
mattteufel-e2b marked this conversation as resolved.
];
// The agent types this password into the sandbox; redact it from the
// action stream so it is never echoed back to the browser.
redactSecrets = [password];
} else {
return new Response("Unknown promptId", { status: 400 });
}
}

let desktop: Sandbox | undefined;
let activeSandboxId = sandboxId;
let vncUrl: string | undefined;

try {
if (!activeSandboxId) {
const newSandbox = await Sandbox.create({
const newSandbox = await Sandbox.create(SANDBOX_TEMPLATE, {
resolution,
dpi: 96,
timeoutMs: SANDBOX_TIMEOUT_MS,
Expand All @@ -59,7 +84,7 @@ export async function POST(request: Request) {

try {
const streamer: ComputerInteractionStreamerFacade =
new OpenAIComputerStreamer(desktop, resolution);
new OpenAIComputerStreamer(desktop, resolution, { redactSecrets });

if (!sandboxId && activeSandboxId && vncUrl) {
async function* stream(): AsyncGenerator<SSEEvent> {
Expand All @@ -69,12 +94,14 @@ export async function POST(request: Request) {
vncUrl: vncUrl as string,
};

yield* streamer.stream({ messages, signal });
yield* streamer.stream({ messages: effectiveMessages, signal });
}

return createStreamingResponse(stream());
} else {
return createStreamingResponse(streamer.stream({ messages, signal }));
return createStreamingResponse(
streamer.stream({ messages: effectiveMessages, signal })
);
}
} catch (error) {
logError("Error from streaming service:", error);
Expand Down
Binary file removed app/favicon.ico
Binary file not shown.
Loading