BangSo/packages/adapters/src/docker-sandbox.ts

410 lines
14 KiB
TypeScript
Raw Normal View History

2026-09-01 16:51:19 +00:00
import path from "node:path";
import type {
AdapterContext,
CommandRequest,
ComputerActionRequest,
ComputerFileEntry,
ComputerInput,
ComputerObservation,
ComputerRef,
ControlLeaseRef,
PortableFile,
ProcessEvent,
SandboxProvider,
ScreenRequest,
ScreenSession,
} from "@rakazo/adapter-kit";
2026-09-02 07:38:57 +00:00
import {
boundedSandboxCommandTimeoutMs,
COMPUTER_HOME,
computerHomeRelative,
isComputerHomeCwd,
resolveSupervisorToken,
} from "@rakazo/core";
2026-09-01 16:51:19 +00:00
import {
boundedComputerActions,
clampRounded,
computerObservation,
normalizeWorkspacePath,
} from "./computer-support.js";
async function safeBody(res: Response): Promise<string> {
try {
return (await res.text()).slice(0, 200);
} catch {
return "";
}
}
export class DockerSandboxProvider implements SandboxProvider {
private readonly supervisorToken: string;
constructor(
private readonly supervisorUrl: string,
supervisorToken?: string,
) {
this.supervisorToken = supervisorToken ?? resolveSupervisorToken(process.env);
}
describe() {
return {
id: "docker",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: {
graphical: true,
pty: true,
snapshots: true,
takeover: true,
persistentHome: true,
multiScreen: true,
},
};
}
private url(path: string) {
return `${this.supervisorUrl.replace(/\/$/, "")}${path}`;
}
// No x-rakazo-screen-id here: the supervisor keys a screen off
// x-rakazo-bot-id alone (the ComputerRef's homeKey — shared across every
// bot on a Team Computer, distinct per bot on a dedicated one). Keying it
// off the calling bot's own id instead would give each bot on a shared
// Team Computer its own Xvfb/Chromium/x11vnc stack — several times the RAM
// for one container, and each stack fighting the same Chromium profile dir
// (homeKey is shared too) for its SingletonLock, so only the first bot to
// grab it gets the real logged-in session and the rest boot to a blank
// profile. Screen VIEWING is safely shared already (x11vnc -shared serves
// any number of simultaneous viewers of the one desktop); who gets to
// actually drive it is gated separately by the execution lease.
private headers(context: AdapterContext, botId?: string) {
return {
authorization: `Bearer ${this.supervisorToken}`,
"x-rakazo-space-id": context.spaceId,
...(botId ? { "x-rakazo-bot-id": botId } : {}),
...(context.screenLeaseId ? { "x-rakazo-screen-lease-id": context.screenLeaseId } : {}),
};
}
async provision(
request: { botId: string; homePath: string },
context: AdapterContext,
): Promise<ComputerRef> {
const res = await fetch(this.url("/computers"), {
method: "POST",
headers: { ...this.headers(context, request.botId), "content-type": "application/json" },
body: JSON.stringify({
botId: request.botId,
homePath: request.homePath,
spaceId: context.spaceId,
}),
signal: context.signal,
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`sandbox provision failed: ${res.status} ${detail}`.trim());
}
const body = (await res.json()) as { id: string; resumed?: boolean };
return {
id: body.id,
botId: request.botId,
kind: "docker",
providerRef: body.id,
fresh: body.resumed !== true,
};
}
async prepare(_computer: ComputerRef, _context: AdapterContext): Promise<void> {}
async *execute(
computer: ComputerRef,
request: CommandRequest,
context: AdapterContext,
): AsyncIterable<ProcessEvent> {
const res = await fetch(this.url(`/computers/${computer.id}/exec`), {
method: "POST",
headers: { ...this.headers(context, computer.botId), "content-type": "application/json" },
body: JSON.stringify({
...request,
cwd: dockerCwd(request.cwd),
timeoutMs: boundedSandboxCommandTimeoutMs(request.timeoutMs),
}),
signal: context.signal,
});
if (!res.ok) {
yield { type: "stderr", data: `exec failed: ${res.status}` };
yield { type: "exit", code: 1 };
return;
}
const body = (await res.json()) as { stdout: string; stderr: string; code: number };
if (body.stdout) yield { type: "stdout", data: body.stdout };
if (body.stderr) yield { type: "stderr", data: body.stderr };
yield { type: "exit", code: body.code };
}
async connectScreen(
computer: ComputerRef,
request: ScreenRequest,
context: AdapterContext,
): Promise<ScreenSession> {
const res = await fetch(this.url(`/computers/${computer.id}/screen-mode`), {
method: "POST",
headers: { ...this.headers(context, computer.botId), "content-type": "application/json" },
body: JSON.stringify({
interactive: request.interactive === true,
controlToken: request.controlToken,
revokeControl: false,
}),
signal: context.signal,
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
if (/cannot allocate another screen/i.test(detail)) {
throw new Error("This Team Computer cannot allocate another screen.");
}
return { url: null, mimeType: "text/html", close: async () => undefined };
}
const body = (await res.json()) as { screenUrl?: string };
return {
url: body.screenUrl ?? this.url(`/computers/${computer.id}/screen`),
mimeType: "text/html",
close: async () => undefined,
};
}
async setScreenControl(
computer: ComputerRef,
interactive: boolean,
context: AdapterContext,
controlToken?: string,
) {
const res = await fetch(this.url(`/computers/${computer.id}/screen-mode`), {
method: "POST",
headers: { ...this.headers(context, computer.botId), "content-type": "application/json" },
body: JSON.stringify({ interactive, controlToken }),
signal: context.signal,
});
if (!res.ok) throw new Error(`sandbox screen mode failed: ${res.status}`);
}
async sendInput(
computer: ComputerRef,
input: ComputerInput,
lease: ControlLeaseRef,
context: AdapterContext,
): Promise<void> {
const res = await fetch(this.url(`/computers/${computer.id}/input`), {
method: "POST",
headers: { ...this.headers(context, computer.botId), "content-type": "application/json" },
body: JSON.stringify({ input, leaseId: lease.leaseId }),
signal: context.signal,
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`sandbox input failed: ${res.status} ${detail}`.trim());
}
}
async observe(computer: ComputerRef, context: AdapterContext): Promise<ComputerObservation> {
const res = await fetch(this.url(`/computers/${computer.id}/observe`), {
method: "POST",
headers: this.headers(context, computer.botId),
signal: context.signal,
});
if (!res.ok)
throw new Error(
`sandbox observation failed: ${res.status} ${await res.text().catch(() => "")}`.trim(),
);
const body = (await res.json()) as {
image: string;
mimeType: "image/png" | "image/jpeg";
width: number;
height: number;
cursor?: { x: number; y: number };
activeWindow?: { id: string; title?: string };
};
return computerObservation(Uint8Array.from(Buffer.from(body.image, "base64")), {
mimeType: body.mimeType,
width: body.width,
height: body.height,
cursor: body.cursor,
activeWindow: body.activeWindow,
});
}
async act(computer: ComputerRef, request: ComputerActionRequest, context: AdapterContext) {
const actions = boundedComputerActions(request.actions);
const res = await fetch(this.url(`/computers/${computer.id}/actions`), {
method: "POST",
headers: { ...this.headers(context, computer.botId), "content-type": "application/json" },
body: JSON.stringify({
actions,
observe: request.observe,
settleMs: clampRounded(request.settleMs ?? 0, 0, 5_000),
}),
signal: context.signal,
});
if (!res.ok)
throw new Error(
`sandbox action failed: ${res.status} ${await res.text().catch(() => "")}`.trim(),
);
const body = (await res.json()) as {
completed: number;
observation?: {
image: string;
mimeType: "image/png" | "image/jpeg";
width: number;
height: number;
cursor?: { x: number; y: number };
activeWindow?: { id: string; title?: string };
};
};
return {
completed: body.completed,
...(body.observation
? {
observation: computerObservation(
Uint8Array.from(Buffer.from(body.observation.image, "base64")),
body.observation,
),
}
: {}),
};
}
async listFiles(
computer: ComputerRef,
directory: string,
context: AdapterContext,
): Promise<ComputerFileEntry[]> {
const path = normalizeWorkspacePath(directory);
const res = await fetch(
this.url(`/computers/${computer.id}/files?path=${encodeURIComponent(path)}&mode=list`),
{ headers: this.headers(context, computer.botId), signal: context.signal },
);
if (!res.ok) throw new Error(`sandbox file listing failed: ${res.status}`);
return (await res.json()) as ComputerFileEntry[];
}
async readFile(
computer: ComputerRef,
filePath: string,
context: AdapterContext,
options?: { maxBytes?: number },
) {
const path = normalizeWorkspacePath(filePath);
const maxBytes = options?.maxBytes;
const res = await fetch(
this.url(
`/computers/${computer.id}/files?path=${encodeURIComponent(path)}&mode=read${maxBytes === undefined ? "" : `&maxBytes=${maxBytes}`}`,
),
{ headers: this.headers(context, computer.botId), signal: context.signal },
);
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`sandbox file read failed: ${res.status} ${detail}`.trim());
}
const body = (await res.json()) as { content: string };
return Uint8Array.from(Buffer.from(body.content, "base64"));
}
async writeFile(computer: ComputerRef, file: PortableFile, context: AdapterContext) {
const res = await fetch(this.url(`/computers/${computer.id}/files`), {
method: "POST",
headers: { ...this.headers(context, computer.botId), "content-type": "application/json" },
body: JSON.stringify({
path: normalizeWorkspacePath(file.path),
content: Buffer.from(file.content).toString("base64"),
executable: file.executable === true,
}),
signal: context.signal,
});
if (!res.ok) throw new Error(`sandbox file write failed: ${res.status}`);
}
async *exportWorkspace(computer: ComputerRef, context: AdapterContext) {
yield* this.walkWorkspace(computer, "", context);
}
async importWorkspace(
computer: ComputerRef,
files: AsyncIterable<PortableFile>,
context: AdapterContext,
) {
for await (const file of files) await this.writeFile(computer, file, context);
}
async snapshot(computer: ComputerRef, _context: AdapterContext) {
return { id: `docker-snap-${computer.id}`, createdAt: new Date().toISOString() };
}
async releaseScreen(computer: ComputerRef, context: AdapterContext): Promise<void> {
if (!context.botId) return;
const res = await fetch(this.url(`/computers/${computer.id}/screen`), {
method: "DELETE",
headers: this.headers(context, computer.botId),
});
if (!res.ok && res.status !== 404) {
throw new Error(`sandbox screen release failed: ${res.status}`);
}
}
async stop(computer: ComputerRef, context: AdapterContext): Promise<void> {
const res = await fetch(this.url(`/computers/${computer.id}/stop`), {
method: "POST",
headers: this.headers(context, computer.botId),
signal: context.signal,
});
// 404 means the supervisor no longer has the container, which is the state we want.
if (!res.ok && res.status !== 404) {
throw new Error(`sandbox stop failed: ${res.status} ${await safeBody(res)}`.trim());
}
}
async destroy(computer: ComputerRef, context: AdapterContext): Promise<void> {
const res = await fetch(this.url(`/computers/${computer.id}`), {
method: "DELETE",
headers: this.headers(context, computer.botId),
signal: context.signal,
});
if (!res.ok && res.status !== 404) {
throw new Error(`sandbox destroy failed: ${res.status} ${await safeBody(res)}`.trim());
}
}
private async *walkWorkspace(
computer: ComputerRef,
directory: string,
context: AdapterContext,
): AsyncIterable<PortableFile> {
const entries = await this.listFiles(computer, directory, context);
for (let index = 0; index < entries.length; ) {
const entry = entries[index]!;
if (entry.kind === "dir") {
yield* this.walkWorkspace(computer, entry.path, context);
index += 1;
continue;
}
const files = [];
while (index < entries.length && entries[index]?.kind === "file" && files.length < 8) {
files.push(entries[index]!);
index += 1;
}
const batch = await Promise.all(
files.map(async (file) => ({
path: file.path,
content: await this.readFile(computer, file.path, context),
executable: file.executable,
})),
);
for (const file of batch) yield file;
}
}
}
function dockerCwd(cwd: string | undefined) {
2026-09-02 07:38:57 +00:00
if (isComputerHomeCwd(cwd)) return COMPUTER_HOME;
const relative = computerHomeRelative(cwd!) ?? normalizeWorkspacePath(cwd!);
return path.posix.join(COMPUTER_HOME, relative);
2026-09-01 16:51:19 +00:00
}