cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

506 lines
17 KiB
TypeScript

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<typeof e2bCreateOptions>): Promise<Sandbox>;
connect(id: string, options: { apiKey: string; timeoutMs: number }): Promise<Sandbox>;
pause(id: string, options: { apiKey: string }): Promise<void>;
}
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<string, Sandbox>();
private readonly connections = new Map<string, Promise<Sandbox>>();
private readonly lastTouchedAt = new Map<string, number>();
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<Sandbox> {
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<Sandbox>;
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<ComputerRef> {
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<void> {
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<ProcessEvent> {
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<ScreenSession> {
return this.desktops.connectScreen(computer, request, context);
}
async setScreenControl(
computer: ComputerRef,
interactive: boolean,
context: AdapterContext,
controlToken?: string,
): Promise<void> {
return this.desktops.setScreenControl(computer, interactive, context, controlToken);
}
async sendInput(
computer: ComputerRef,
input: ComputerInput,
_lease: ControlLeaseRef,
context: AdapterContext,
): Promise<void> {
return this.desktops.sendInput(computer, input, context);
}
async observe(computer: ComputerRef, context: AdapterContext): Promise<ComputerObservation> {
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<ComputerFileEntry[]> {
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<PortableFile> {
const desktop = await this.box(computer);
await this.desktops.stopBrowsers(computer, context);
yield* walkE2BWorkspace(desktop, "", context);
}
async importWorkspace(
computer: ComputerRef,
files: AsyncIterable<PortableFile>,
context: AdapterContext,
): Promise<void> {
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<void> {
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<void> {
return this.desktops.releaseScreen(computer, context);
}
async stop(computer: ComputerRef, _context: AdapterContext): Promise<void> {
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<void> {
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<CommandResult> {
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<PortableFile> {
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;
}