558 lines
19 KiB
TypeScript
558 lines
19 KiB
TypeScript
import path from "node:path";
|
|
import { Readable } from "node:stream";
|
|
import type {
|
|
AdapterContext,
|
|
CommandRequest,
|
|
ComputerActionRequest,
|
|
ComputerFileEntry,
|
|
ComputerInput,
|
|
ComputerObservation,
|
|
ComputerRef,
|
|
ControlLeaseRef,
|
|
PageBrowserCommand,
|
|
PageBrowserResult,
|
|
PortableFile,
|
|
PortableFileStream,
|
|
ProcessEvent,
|
|
SandboxProvider,
|
|
ScreenRequest,
|
|
ScreenSession,
|
|
} from "@rakazo/adapter-kit";
|
|
import { boundedSandboxCommandTimeoutMs, resolveSupervisorToken } from "@rakazo/core";
|
|
import { outgoingCorrelationHeaders } from "@rakazo/logging";
|
|
import { byteChunks } from "./byte-streams.js";
|
|
import {
|
|
boundedComputerActions,
|
|
clampRounded,
|
|
computerObservation,
|
|
normalizeWorkspacePath,
|
|
} from "./computer-support.js";
|
|
import { readBodyCapped, withAbort } from "./web-ssrf.js";
|
|
|
|
export const MAX_SANDBOX_ERROR_RESPONSE_BYTES = 8 * 1024;
|
|
export const MAX_SANDBOX_SUCCESS_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
export const SCREEN_RELEASE_TIMEOUT_MS = 8_000;
|
|
const SANDBOX_ERROR_RESPONSE_TIMEOUT_MS = 1_000;
|
|
const SANDBOX_SUCCESS_RESPONSE_TIMEOUT_MS = 30_000;
|
|
|
|
async function safeBody(res: Response, signal?: AbortSignal): Promise<string> {
|
|
const declared = Number(res.headers.get("content-length") ?? 0);
|
|
if (Number.isFinite(declared) && declared > MAX_SANDBOX_ERROR_RESPONSE_BYTES) {
|
|
cancelResponseBody(res);
|
|
return "";
|
|
}
|
|
try {
|
|
const readSignal = signal
|
|
? AbortSignal.any([signal, AbortSignal.timeout(SANDBOX_ERROR_RESPONSE_TIMEOUT_MS)])
|
|
: AbortSignal.timeout(SANDBOX_ERROR_RESPONSE_TIMEOUT_MS);
|
|
const bytes = await readBodyCapped(res, MAX_SANDBOX_ERROR_RESPONSE_BYTES, readSignal);
|
|
return new TextDecoder().decode(bytes).slice(0, 200);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function cancelResponseBody(res: Response): void {
|
|
try {
|
|
void Promise.resolve(res.body?.cancel()).catch(() => undefined);
|
|
} catch {
|
|
// Error diagnostics are best-effort and must not delay the operation failure.
|
|
}
|
|
}
|
|
|
|
async function readSandboxJson<T>(
|
|
res: Response,
|
|
signal: AbortSignal,
|
|
maxBytes = MAX_SANDBOX_SUCCESS_RESPONSE_BYTES,
|
|
): Promise<T> {
|
|
const declared = Number(res.headers.get("content-length") ?? 0);
|
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
cancelResponseBody(res);
|
|
throw new Error(`sandbox response exceeds ${maxBytes} bytes`);
|
|
}
|
|
const readSignal = AbortSignal.any([
|
|
signal,
|
|
AbortSignal.timeout(SANDBOX_SUCCESS_RESPONSE_TIMEOUT_MS),
|
|
]);
|
|
let bytes: Uint8Array;
|
|
try {
|
|
bytes = await readBodyCapped(res, maxBytes, readSignal);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === "Response is too large") {
|
|
throw new Error(`sandbox response exceeds ${maxBytes} bytes`, { cause: error });
|
|
}
|
|
throw error;
|
|
}
|
|
return JSON.parse(new TextDecoder().decode(bytes)) as T;
|
|
}
|
|
|
|
function encodedFileResponseLimit(maxBytes: number | undefined): number {
|
|
if (maxBytes === undefined) return MAX_SANDBOX_SUCCESS_RESPONSE_BYTES;
|
|
// An explicit file limit is already the caller's memory-safety contract.
|
|
// Account for base64 expansion plus the small JSON envelope without applying
|
|
// the generic response cap, which would reject valid files above ~12 MiB.
|
|
return Math.ceil(maxBytes / 3) * 4 + 1024;
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
private headers(context: AdapterContext, botId?: string) {
|
|
return {
|
|
authorization: `Bearer ${this.supervisorToken}`,
|
|
"x-rakazo-space-id": context.spaceId,
|
|
...outgoingCorrelationHeaders(),
|
|
...(botId ? { "x-rakazo-bot-id": botId } : {}),
|
|
...(context.botId ? { "x-rakazo-screen-id": context.botId } : {}),
|
|
...(context.screenLeaseId ? { "x-rakazo-screen-lease-id": context.screenLeaseId } : {}),
|
|
...(context.cancelRunWork ? { "x-rakazo-cancel-run-work": "1" } : {}),
|
|
};
|
|
}
|
|
|
|
async provision(
|
|
request: { botId: string; homePath: string; projectWorkspacePath?: 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,
|
|
...(request.projectWorkspacePath
|
|
? { projectWorkspacePath: request.projectWorkspacePath }
|
|
: {}),
|
|
}),
|
|
signal: context.signal,
|
|
});
|
|
if (!res.ok) {
|
|
const detail = await safeBody(res, context.signal);
|
|
// Match team-screen style: regex on safeBody (already ≤200 chars), not JSON.parse.
|
|
const limitError = detail.match(/Computer limit reached for space \(max: \d+\)/i)?.[0];
|
|
if (res.status === 429 && limitError) {
|
|
throw new Error(limitError);
|
|
}
|
|
throw new Error(`sandbox provision failed: ${res.status} ${detail}`.trim());
|
|
}
|
|
const body = await readSandboxJson<{ id: string; resumed?: boolean }>(res, context.signal);
|
|
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 readSandboxJson<{ stdout: string; stderr: string; code: number }>(
|
|
res,
|
|
context.signal,
|
|
);
|
|
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 pageBrowser(
|
|
computer: ComputerRef,
|
|
request: PageBrowserCommand,
|
|
context: AdapterContext,
|
|
): Promise<PageBrowserResult> {
|
|
const res = await fetch(this.url(`/computers/${computer.id}/browser`), {
|
|
method: "POST",
|
|
headers: { ...this.headers(context, computer.botId), "content-type": "application/json" },
|
|
body: JSON.stringify(request),
|
|
redirect: "error",
|
|
signal: context.signal,
|
|
});
|
|
if (!res.ok) throw new Error(`page browser failed: ${res.status}`);
|
|
return readSandboxJson<PageBrowserResult>(res, context.signal, 512 * 1024);
|
|
}
|
|
|
|
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 safeBody(res, context.signal);
|
|
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 readSandboxJson<{ screenUrl?: string }>(res, context.signal);
|
|
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 safeBody(res, context.signal);
|
|
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 safeBody(res, context.signal)}`.trim(),
|
|
);
|
|
const body = await readSandboxJson<{
|
|
image: string;
|
|
mimeType: "image/png" | "image/jpeg";
|
|
width: number;
|
|
height: number;
|
|
cursor?: { x: number; y: number };
|
|
activeWindow?: { id: string; title?: string };
|
|
}>(res, context.signal);
|
|
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 safeBody(res, context.signal)}`.trim(),
|
|
);
|
|
const body = await readSandboxJson<{
|
|
completed: number;
|
|
observation?: {
|
|
image: string;
|
|
mimeType: "image/png" | "image/jpeg";
|
|
width: number;
|
|
height: number;
|
|
cursor?: { x: number; y: number };
|
|
activeWindow?: { id: string; title?: string };
|
|
};
|
|
}>(res, context.signal);
|
|
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 = dockerFilePath(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 readSandboxJson<ComputerFileEntry[]>(res, context.signal);
|
|
}
|
|
|
|
async readFile(
|
|
computer: ComputerRef,
|
|
filePath: string,
|
|
context: AdapterContext,
|
|
options?: { maxBytes?: number },
|
|
) {
|
|
const path = dockerFilePath(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 safeBody(res, context.signal);
|
|
throw new Error(`sandbox file read failed: ${res.status} ${detail}`.trim());
|
|
}
|
|
const body = await readSandboxJson<{ content: string }>(
|
|
res,
|
|
context.signal,
|
|
encodedFileResponseLimit(maxBytes),
|
|
);
|
|
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: dockerFilePath(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}`);
|
|
}
|
|
|
|
/** Streams the body to the supervisor, which writes it to disk without buffering it. */
|
|
async writeFileStream(computer: ComputerRef, file: PortableFileStream, context: AdapterContext) {
|
|
const query = new URLSearchParams({ path: dockerFilePath(file.path) });
|
|
if (file.executable) query.set("executable", "true");
|
|
const res = await fetch(this.url(`/computers/${computer.id}/files/stream?${query}`), {
|
|
method: "POST",
|
|
headers: {
|
|
...this.headers(context, computer.botId),
|
|
"content-type": "application/octet-stream",
|
|
},
|
|
body: Readable.toWeb(Readable.from(byteChunks(file.stream))) as unknown as BodyInit,
|
|
duplex: "half",
|
|
signal: context.signal,
|
|
} as RequestInit & { duplex: "half" });
|
|
if (!res.ok) {
|
|
const detail = await safeBody(res, context.signal);
|
|
throw new Error(`sandbox file write failed: ${res.status} ${detail}`.trim());
|
|
}
|
|
}
|
|
|
|
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;
|
|
// Run cancellation must not skip cleanup, but cleanup still needs its own deadline.
|
|
const deadline = requestDeadline(SCREEN_RELEASE_TIMEOUT_MS, "sandbox screen release timed out");
|
|
try {
|
|
const res = await withAbort(
|
|
fetch(this.url(`/computers/${computer.id}/screen`), {
|
|
method: "DELETE",
|
|
headers: this.headers(context, computer.botId),
|
|
signal: deadline.signal,
|
|
}),
|
|
deadline.signal,
|
|
);
|
|
if (!res.ok && res.status !== 404) {
|
|
throw new Error(`sandbox screen release failed: ${res.status}`);
|
|
}
|
|
} finally {
|
|
deadline.dispose();
|
|
}
|
|
}
|
|
|
|
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, context.signal)}`.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, context.signal)}`.trim(),
|
|
);
|
|
}
|
|
}
|
|
|
|
private async *walkWorkspace(
|
|
computer: ComputerRef,
|
|
directory: string,
|
|
context: AdapterContext,
|
|
): AsyncIterable<PortableFile> {
|
|
const res = await fetch(
|
|
this.url(
|
|
`/computers/${computer.id}/files?path=${encodeURIComponent(normalizeWorkspacePath(directory))}&mode=list&excludeSymlinks=1`,
|
|
),
|
|
{ headers: this.headers(context, computer.botId), signal: context.signal },
|
|
);
|
|
if (!res.ok) throw new Error(`sandbox file listing failed: ${res.status}`);
|
|
const entries = await readSandboxJson<ComputerFileEntry[]>(res, context.signal);
|
|
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 requestDeadline(timeoutMs: number, message: string) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(new Error(message)), timeoutMs);
|
|
return {
|
|
signal: controller.signal,
|
|
dispose() {
|
|
clearTimeout(timer);
|
|
},
|
|
};
|
|
}
|
|
|
|
const DIRECT_CONTAINER_PATH_PREFIX =
|
|
/^\/(?:Users|mnt|home|Volumes|tmp|var|etc|opt|srv|root|usr|bin|sbin|lib|run|proc|sys|dev)(?:\/|$)/;
|
|
|
|
function dockerFilePath(value: string) {
|
|
if (DIRECT_CONTAINER_PATH_PREFIX.test(value)) return path.posix.normalize(value);
|
|
if (value === "~") return "/home/rakazo";
|
|
if (value.startsWith("~/")) return path.posix.join("/home/rakazo", value.slice(2));
|
|
return normalizeWorkspacePath(value);
|
|
}
|
|
|
|
function dockerCwd(cwd: string | undefined) {
|
|
if (!cwd || cwd === "." || cwd === "/" || cwd === "/home/rakazo") return "/home/rakazo";
|
|
if (cwd.startsWith("/")) return path.posix.normalize(cwd);
|
|
if (cwd === "~") return "/home/rakazo";
|
|
if (cwd.startsWith("~/")) return path.posix.join("/home/rakazo", cwd.slice(2));
|
|
const relative = cwd.startsWith("/home/rakazo/")
|
|
? cwd.slice("/home/rakazo/".length)
|
|
: normalizeWorkspacePath(cwd);
|
|
return path.posix.join("/home/rakazo", relative);
|
|
}
|