import { type CommandResult, Sandbox, TimeoutError } from "@e2b/desktop"; import type { AdapterContext, CommandRequest, ComputerActionRequest, ComputerFileEntry, ComputerInput, ComputerObservation, ComputerRef, ControlLeaseRef, PortableFile, ProcessEvent, SandboxProvider, ScreenRequest, ScreenSession, } from "@rakazo/adapter-kit"; import { boundedSandboxCommandTimeoutMs } from "@rakazo/core"; import { sandboxIdleMs } from "./computer-idle.js"; import { normalizeWorkspacePath, shellQuote, workspacePath } from "./computer-support.js"; import { PORTABLE_TRANSFER_BATCH_BYTES, shouldSkipPortableWorkspaceFile, } from "./computer-workspace.js"; import { LinuxDesktop, PREPARE_LINUX_DESKTOP } from "./linux-desktop.js"; const E2B_WORKSPACE = "/home/user/rakazo-home"; const E2B_BROWSER_PROFILES = `${E2B_WORKSPACE}/.browser-profiles`; export interface E2BSandboxSdk { create(options: ReturnType): Promise; connect(id: string, options: { apiKey: string; timeoutMs: number }): Promise; pause(id: string, options: { apiKey: string }): Promise; } export function e2bCreateOptions(botId: string, apiKey: string) { return { apiKey, timeoutMs: sandboxIdleMs(), metadata: { botId, rakazo: "computer" }, resolution: [1280, 800] as [number, number], lifecycle: { onTimeout: "pause" as const, autoResume: false }, }; } // How the E2B SDK words a sandbox that no longer exists. It does not always say "not found": // an expired sandbox surfaces as a TimeoutError about the *sandbox* timeout (502 / Unavailable // from envd), which used to read as a live sandbox and left every later call throwing forever. const SANDBOX_GONE_MESSAGE = /probably not running anymore|likely due to sandbox timeout|killed or reached its end of life|sandbox [^:]{0,60}not found|sandbox [^:]{0,60}does not exist/i; // The same words from a live sandbox: a missing binary or a missing file inside it. const SHELL_MISSING_TARGET = /command not found|no such file|^path .* not found/i; /** Only provider-specific evidence of sandbox loss permits automatic replacement. */ export function isSandboxGoneError(error: unknown): boolean { const message = errorMessage(error); if (SHELL_MISSING_TARGET.test(message)) return false; if (SANDBOX_GONE_MESSAGE.test(message)) return true; for (let current: unknown = error; current instanceof Error; current = current.cause) { if (current.name === "SandboxNotFoundError") return true; } return false; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export class E2BSandboxProvider implements SandboxProvider { private readonly desktops = new LinuxDesktop({ environment: async () => ({ homeDir: "/home/user", workspaceDir: E2B_WORKSPACE, browserProfilesDir: E2B_BROWSER_PROFILES, displayStart: 20, portStart: 6100, }), run: async (computer, command, context) => { const result = await this.runSetupCommand(await this.box(computer), command, context.signal); return { code: result.exitCode, stdout: result.stdout, stderr: result.stderr }; }, screenUrl: async (computer, port) => `https://${(await this.box(computer)).getHost(port)}/vnc.html`, }); private readonly boxes = new Map(); private readonly connections = new Map>(); private readonly lastTouchedAt = new Map(); constructor( private readonly apiKey: string, private readonly sdk: E2BSandboxSdk = Sandbox as unknown as E2BSandboxSdk, ) {} describe() { return { id: "e2b", contractVersion: "1", adapterVersion: "0.1.0", capabilities: { graphical: true, pty: true, snapshots: true, takeover: true, persistentHome: true, multiScreen: true, }, }; } private async box(computer: ComputerRef): Promise { const id = computer.providerRef || computer.id; const existing = this.boxes.get(id); if (existing) { const lastTouched = this.lastTouchedAt.get(id) ?? 0; if (Date.now() - lastTouched < 60_000) return existing; // A cached handle to a sandbox E2B already killed keeps throwing on every call, and the // process never reconnects. The keepalive is the cheapest place to notice and drop it. const gone = await existing.setTimeout(sandboxIdleMs()).then( () => false, (error: unknown) => isSandboxGoneError(error), ); if (!gone) { this.lastTouchedAt.set(id, Date.now()); return existing; } if (this.boxes.get(id) === existing) this.forget(id); } const pending = this.connections.get(id); if (pending) return pending; let connection!: Promise; connection = this.sdk .connect(id, { apiKey: this.apiKey, timeoutMs: sandboxIdleMs() }) .then((connected) => { if (this.connections.get(id) !== connection) { throw new Error("computer connection stopped during teardown"); } this.boxes.set(connected.sandboxId, connected); this.lastTouchedAt.set(connected.sandboxId, Date.now()); return connected; }) .finally(() => { if (this.connections.get(id) === connection) this.connections.delete(id); }); this.connections.set(id, connection); return connection; } async provision( request: { botId: string; homePath: string; providerRef?: string; providerKind?: ComputerRef["kind"]; }, _context: AdapterContext, ): Promise { if (request.providerRef && request.providerKind === "e2b") { try { const desktop = await this.box({ id: request.providerRef, providerRef: request.providerRef, kind: "e2b", botId: request.botId, }); return { id: desktop.sandboxId, botId: request.botId, kind: "e2b", providerRef: desktop.sandboxId, fresh: false, }; } catch (error) { this.boxes.delete(request.providerRef); // A transport failure says nothing about the workspace still on the server. if (!isSandboxGoneError(error)) throw error; } } const desktop = await this.sdk.create(e2bCreateOptions(request.botId, this.apiKey)); this.boxes.set(desktop.sandboxId, desktop); this.lastTouchedAt.set(desktop.sandboxId, Date.now()); return { id: desktop.sandboxId, botId: request.botId, kind: "e2b", providerRef: desktop.sandboxId, fresh: true, }; } async prepare(computer: ComputerRef, _context: AdapterContext): Promise { const desktop = await this.box(computer); if (computer.fresh) await desktop.files.makeDir(E2B_WORKSPACE); const result = await this.runSetupCommand(desktop, PREPARE_LINUX_DESKTOP, _context.signal); if (result.exitCode !== 0) throw new Error(result.stderr || "could not prepare computer desktop tools"); } async *execute( computer: ComputerRef, request: CommandRequest, context: AdapterContext, ): AsyncIterable { const desktop = await this.box(computer); const cmd = request.argv.map(shellQuote).join(" "); const timeoutMs = boundedSandboxCommandTimeoutMs(request.timeoutMs); try { const result = await desktop.commands.run(cmd, { cwd: e2bCwd(request.cwd), envs: request.env, signal: context.signal, timeoutMs, }); if (result.stdout) yield { type: "stdout", data: result.stdout }; if (result.stderr) yield { type: "stderr", data: result.stderr }; yield { type: "exit", code: result.exitCode ?? 0 }; } catch (error) { if (error instanceof TimeoutError) { yield { type: "stderr", data: `command timed out after ${timeoutMs} ms\n`, }; yield { type: "exit", code: 124 }; return; } throw error; } } async connectScreen( computer: ComputerRef, request: ScreenRequest, context: AdapterContext, ): Promise { return this.desktops.connectScreen(computer, request, context); } async setScreenControl( computer: ComputerRef, interactive: boolean, context: AdapterContext, controlToken?: string, ): Promise { return this.desktops.setScreenControl(computer, interactive, context, controlToken); } async sendInput( computer: ComputerRef, input: ComputerInput, _lease: ControlLeaseRef, context: AdapterContext, ): Promise { return this.desktops.sendInput(computer, input, context); } async observe(computer: ComputerRef, context: AdapterContext): Promise { return this.desktops.observe(computer, context); } async act(computer: ComputerRef, request: ComputerActionRequest, context: AdapterContext) { return this.desktops.act(computer, request, context); } async listFiles( computer: ComputerRef, directory: string, context: AdapterContext, ): Promise { const desktop = await this.box(computer); const relative = normalizeWorkspacePath(directory); const entries = await desktop.files.list(workspacePath(E2B_WORKSPACE, relative), { signal: context.signal, }); return entries.flatMap((entry) => { if (entry.type !== "file" && entry.type !== "dir") return []; return [ { path: normalizeWorkspacePath(relative ? `${relative}/${entry.name}` : entry.name), kind: entry.type, size: entry.size, ...(entry.type === "file" && entry.mode & 0o100 ? { executable: true } : {}), }, ]; }); } async readFile( computer: ComputerRef, filePath: string, context: AdapterContext, options?: { maxBytes?: number }, ) { const desktop = await this.box(computer); const target = workspacePath(E2B_WORKSPACE, filePath); if (options?.maxBytes !== undefined) { const info = await desktop.files.getInfo(target, { signal: context.signal, }); if (info.size > options.maxBytes) { throw new Error(`computer file exceeds ${options.maxBytes} bytes`); } } return desktop.files.read(target, { format: "bytes", signal: context.signal, }); } async writeFile(computer: ComputerRef, file: PortableFile, context: AdapterContext) { const desktop = await this.box(computer); await writeE2BFiles(desktop, [file], context); } async *exportWorkspace( computer: ComputerRef, context: AdapterContext, ): AsyncIterable { const desktop = await this.box(computer); await this.desktops.stopBrowsers(computer, context); yield* walkE2BWorkspace(desktop, "", context); } async importWorkspace( computer: ComputerRef, files: AsyncIterable, context: AdapterContext, ): Promise { const desktop = await this.box(computer); await this.desktops.stopBrowsers(computer, context); let batch: PortableFile[] = []; let batchBytes = 0; const flush = async () => { if (!batch.length) return; await writeE2BFiles(desktop, batch, context); batch = []; batchBytes = 0; }; for await (const file of files) { if ( batch.length >= 32 || batchBytes + file.content.byteLength > PORTABLE_TRANSFER_BATCH_BYTES ) { await flush(); } batch.push(file); batchBytes += file.content.byteLength; } await flush(); // Imported browser profiles remain dormant until their owning bot needs a desktop. } async snapshot(computer: ComputerRef, _context: AdapterContext) { const observation = await this.observe(computer, _context); return { id: observation.frameId, createdAt: observation.capturedAt }; } async keepAlive(computer: ComputerRef): Promise { const desktop = await this.box(computer); try { await desktop.setTimeout(sandboxIdleMs()); } catch (error) { // Heartbeats refresh lastTouchedAt; if we swallow a gone error here, box() never // reaches its 60s probe and keeps handing back the dead cached handle. if (isSandboxGoneError(error)) { this.forget(desktop.sandboxId); return; } } this.lastTouchedAt.set(desktop.sandboxId, Date.now()); } async releaseScreen(computer: ComputerRef, context: AdapterContext): Promise { return this.desktops.releaseScreen(computer, context); } async stop(computer: ComputerRef, _context: AdapterContext): Promise { const id = computer.providerRef || computer.id; const desktop = this.boxes.get(id); this.forget(id); if (desktop) { await desktop.pause().catch(() => undefined); return; } await this.sdk.pause(id, { apiKey: this.apiKey }).catch(() => undefined); } async destroy(computer: ComputerRef, _context: AdapterContext): Promise { const id = computer.providerRef || computer.id; const desktop = this.boxes.get(id) ?? (await this.box(computer).catch(() => undefined)); this.forget(id); // The SDK returns false when the sandbox is already gone; teardown is complete. await desktop?.kill(); } private forget(id: string): void { this.boxes.delete(id); this.connections.delete(id); this.lastTouchedAt.delete(id); } /** Apply the deployment timeout (SDK default is 60s) and return failed results instead of throwing. */ private async runSetupCommand( desktop: Sandbox, command: string, signal?: AbortSignal, ): Promise { try { // E2B's outer login shell can fail its logout hook under `set -e`, even after `exit 0`. return await desktop.commands.run(`bash -c ${shellQuote(command)}`, { ...(signal ? { signal } : {}), timeoutMs: boundedSandboxCommandTimeoutMs(undefined), }); } catch (error) { if (error instanceof TimeoutError) { return { exitCode: 124, stdout: "", stderr: error.message, error: error.message, }; } const result = (error as { result?: CommandResult }).result; if (result) return result; throw error; } } } async function writeE2BFiles( desktop: Sandbox, files: readonly PortableFile[], context: AdapterContext, ) { if (!files.length) return; await desktop.files.write( files.map((file) => ({ path: workspacePath(E2B_WORKSPACE, file.path), data: toArrayBuffer(file.content), })), { signal: context.signal }, ); const executable = files .filter((file) => file.executable) .map((file) => shellQuote(workspacePath(E2B_WORKSPACE, file.path))); if (executable.length) { await desktop.commands.run(`chmod 700 -- ${executable.join(" ")}`, { signal: context.signal, }); } } async function* walkE2BWorkspace( desktop: Sandbox, directory: string, context: AdapterContext, ): AsyncIterable { const entries = await desktop.files.list(workspacePath(E2B_WORKSPACE, directory), { signal: context.signal, }); const files: Array<{ relative: string; mode: number }> = []; const directories: string[] = []; for (const entry of entries) { const relative = normalizeWorkspacePath(directory ? `${directory}/${entry.name}` : entry.name); if (shouldSkipPortableWorkspaceFile(relative)) continue; if (entry.type === "dir") directories.push(relative); else if (entry.type === "file") files.push({ relative, mode: entry.mode }); } for (let index = 0; index < files.length; index += 8) { const batch = await Promise.all( files.slice(index, index + 8).map(async ({ relative, mode }) => { const content = await desktop.files .read(workspacePath(E2B_WORKSPACE, relative), { format: "bytes", signal: context.signal, }) .catch((error) => { if (relative.startsWith(".browser-profiles/")) return undefined; throw error; }); if (!content) return undefined; return { path: relative, content, executable: Boolean(mode & 0o100) }; }), ); for (const file of batch) { if (file) yield file; } } for (const relative of directories) yield* walkE2BWorkspace(desktop, relative, context); } function e2bCwd(cwd: string | undefined): string { if ( !cwd || cwd === "." || cwd === "/" || cwd === "/home/rakazo" || cwd === "/home/user" || cwd === E2B_WORKSPACE ) { return E2B_WORKSPACE; } const relative = cwd.startsWith(`${E2B_WORKSPACE}/`) ? cwd.slice(E2B_WORKSPACE.length + 1) : cwd.startsWith("/home/rakazo/") ? cwd.slice("/home/rakazo/".length) : cwd; return workspacePath(E2B_WORKSPACE, relative); } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; }