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.
506 lines
17 KiB
TypeScript
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;
|
|
}
|