* Allow ZIP uploads up to 5 GiB and stream them onto Docker computers Repository archives could not be picked at all: application/zip was not an allowed attachment type. ZIPs are now allowed with their own 5 GiB cap, upload as a stream, and reach the bot's Docker computer through a new streamed supervisor write that commits only after the body ended cleanly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Use a disallowed type for the invalid-attachment check now that ZIP is allowed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ --------- Co-authored-by: Claude <noreply@anthropic.com>
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
import { createServer } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import type { ProcessEvent } from "@rakazo/adapter-kit";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
DockerSandboxProvider,
|
|
MAX_SANDBOX_ERROR_RESPONSE_BYTES,
|
|
MAX_SANDBOX_SUCCESS_RESPONSE_BYTES,
|
|
SCREEN_RELEASE_TIMEOUT_MS,
|
|
} from "./docker-sandbox.js";
|
|
|
|
const context = {
|
|
operationId: "docker-test",
|
|
traceId: "docker-test",
|
|
spaceId: "workspace",
|
|
userId: "user",
|
|
botId: "bot",
|
|
screenLeaseId: "run-1:1",
|
|
signal: new AbortController().signal,
|
|
};
|
|
|
|
describe("Docker sandbox", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("sends the bounded timeout to the supervisor and preserves its honest result", async () => {
|
|
const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) =>
|
|
Response.json({
|
|
stdout: "partial output\n",
|
|
stderr: "command timed out after 75 ms\n",
|
|
code: 124,
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
const events: ProcessEvent[] = [];
|
|
|
|
for await (const event of provider.execute(
|
|
{ id: "computer", botId: "bot", kind: "docker", providerRef: "computer" },
|
|
{ argv: ["sleep", "10"], timeoutMs: 75 },
|
|
context,
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
|
|
argv: ["sleep", "10"],
|
|
cwd: "/home/rakazo",
|
|
timeoutMs: 75,
|
|
});
|
|
expect(events).toEqual([
|
|
{ type: "stdout", data: "partial output\n" },
|
|
{ type: "stderr", data: "command timed out after 75 ms\n" },
|
|
{ type: "exit", code: 124 },
|
|
]);
|
|
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
|
|
"x-rakazo-bot-id": "bot",
|
|
"x-rakazo-screen-id": "bot",
|
|
});
|
|
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
|
|
"x-request-id": expect.any(String),
|
|
traceparent: expect.stringMatching(/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/),
|
|
});
|
|
});
|
|
|
|
it("rejects a declared oversized success response without buffering it", async () => {
|
|
const cancel = vi.fn();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(
|
|
async () =>
|
|
new Response(new ReadableStream({ cancel }), {
|
|
headers: { "content-length": String(MAX_SANDBOX_SUCCESS_RESPONSE_BYTES + 1) },
|
|
}),
|
|
),
|
|
);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
|
|
await expect(
|
|
provider.provision({ botId: "bot", homePath: "/tmp/bot" }, context),
|
|
).rejects.toThrow(`sandbox response exceeds ${MAX_SANDBOX_SUCCESS_RESPONSE_BYTES} bytes`);
|
|
await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce());
|
|
});
|
|
|
|
it("translates a 429 computer limit reached error from the supervisor", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () =>
|
|
Response.json({ error: "Computer limit reached for space (max: 5)" }, { status: 429 }),
|
|
),
|
|
);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
|
|
await expect(
|
|
provider.provision({ botId: "bot", homePath: "/tmp/bot" }, context),
|
|
).rejects.toThrow("Computer limit reached for space (max: 5)");
|
|
});
|
|
|
|
it("stops a streamed file response at the caller-derived encoded limit", async () => {
|
|
const cancel = vi.fn();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(
|
|
async () =>
|
|
new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(1029));
|
|
},
|
|
cancel,
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
|
|
await expect(
|
|
provider.readFile(
|
|
{ id: "computer", botId: "bot", kind: "docker", providerRef: "computer" },
|
|
"small.txt",
|
|
context,
|
|
{ maxBytes: 1 },
|
|
),
|
|
).rejects.toThrow("sandbox response exceeds 1028 bytes");
|
|
await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce());
|
|
});
|
|
|
|
it("accepts a valid encoded envelope above the generic response cap", async () => {
|
|
const maxBytes = 13 * 1024 * 1024;
|
|
const encodedLimit = Math.ceil(maxBytes / 3) * 4 + 1024;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(
|
|
async () =>
|
|
new Response('{"content":""}', {
|
|
headers: { "content-length": String(encodedLimit) },
|
|
}),
|
|
),
|
|
);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
|
|
await expect(
|
|
provider.readFile(
|
|
{ id: "computer", botId: "bot", kind: "docker", providerRef: "computer" },
|
|
"large.bin",
|
|
context,
|
|
{ maxBytes },
|
|
),
|
|
).resolves.toEqual(new Uint8Array());
|
|
});
|
|
|
|
it("releases this bot's screen assignment through the supervisor", async () => {
|
|
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
|
|
await provider.releaseScreen(
|
|
{ id: "computer", botId: "home-bot", kind: "docker", providerRef: "computer" },
|
|
context,
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"http://supervisor.test/computers/computer/screen",
|
|
expect.objectContaining({
|
|
method: "DELETE",
|
|
headers: expect.objectContaining({
|
|
authorization: "Bearer test-token",
|
|
"x-rakazo-bot-id": "home-bot",
|
|
"x-rakazo-screen-id": "bot",
|
|
"x-rakazo-screen-lease-id": "run-1:1",
|
|
"x-rakazo-space-id": "workspace",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("still releases the screen after the run abort signal has fired", async () => {
|
|
const fetchMock = vi.fn(
|
|
async (_input: string | URL | Request, _init?: RequestInit) =>
|
|
new Response(null, { status: 404 }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
const abort = new AbortController();
|
|
abort.abort();
|
|
|
|
await expect(
|
|
provider.releaseScreen(
|
|
{ id: "computer", botId: "home-bot", kind: "docker", providerRef: "computer" },
|
|
{ ...context, signal: abort.signal },
|
|
),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
expect(fetchMock.mock.calls[0]?.[1]?.signal).not.toBe(abort.signal);
|
|
expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(false);
|
|
});
|
|
|
|
it("bounds screen release even when fetch ignores cancellation", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchMock = vi.fn(
|
|
async (_input: string | URL | Request, _init?: RequestInit) =>
|
|
new Promise<Response>(() => undefined),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
|
|
const pending = provider.releaseScreen(
|
|
{ id: "computer", botId: "home-bot", kind: "docker", providerRef: "computer" },
|
|
context,
|
|
);
|
|
const rejected = expect(pending).rejects.toThrow("sandbox screen release timed out");
|
|
await vi.advanceTimersByTimeAsync(SCREEN_RELEASE_TIMEOUT_MS);
|
|
|
|
await rejected;
|
|
expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
|
});
|
|
|
|
it("surfaces a supervisor failure from stop and destroy instead of swallowing it", async () => {
|
|
const fetchMock = vi.fn(async () => Response.json({ error: "unauthorized" }, { status: 401 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "stale-token");
|
|
const computer = {
|
|
id: "computer",
|
|
botId: "bot",
|
|
kind: "docker",
|
|
providerRef: "computer",
|
|
} as const;
|
|
|
|
await expect(provider.stop(computer, context)).rejects.toThrow(
|
|
'sandbox stop failed: 401 {"error":"unauthorized"}',
|
|
);
|
|
await expect(provider.destroy(computer, context)).rejects.toThrow(
|
|
'sandbox destroy failed: 401 {"error":"unauthorized"}',
|
|
);
|
|
|
|
fetchMock.mockImplementation(async () => new Response("boom", { status: 500 }));
|
|
await expect(provider.stop(computer, context)).rejects.toThrow("sandbox stop failed: 500 boom");
|
|
await expect(provider.destroy(computer, context)).rejects.toThrow(
|
|
"sandbox destroy failed: 500 boom",
|
|
);
|
|
});
|
|
|
|
it("does not buffer an oversized supervisor error body", async () => {
|
|
const cancel = vi.fn();
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
new Response(new ReadableStream({ cancel }), {
|
|
status: 500,
|
|
headers: { "content-length": String(MAX_SANDBOX_ERROR_RESPONSE_BYTES + 1) },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
const computer = {
|
|
id: "computer",
|
|
botId: "bot",
|
|
kind: "docker",
|
|
providerRef: "computer",
|
|
} as const;
|
|
|
|
await expect(provider.stop(computer, context)).rejects.toThrow("sandbox stop failed: 500");
|
|
await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce());
|
|
});
|
|
|
|
it("stops reading a streamed supervisor error at the byte limit", async () => {
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(MAX_SANDBOX_ERROR_RESPONSE_BYTES + 1));
|
|
},
|
|
}),
|
|
{ status: 500 },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
|
|
await expect(
|
|
provider.stop(
|
|
{ id: "computer", botId: "bot", kind: "docker", providerRef: "computer" },
|
|
context,
|
|
),
|
|
).rejects.toThrow("sandbox stop failed: 500");
|
|
});
|
|
|
|
it("treats a computer the supervisor no longer knows as already stopped and destroyed", async () => {
|
|
const fetchMock = vi.fn(async () =>
|
|
Response.json({ error: "computer not found" }, { status: 404 }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
const computer = {
|
|
id: "computer",
|
|
botId: "bot",
|
|
kind: "docker",
|
|
providerRef: "computer",
|
|
} as const;
|
|
|
|
await expect(provider.stop(computer, context)).resolves.toBeUndefined();
|
|
await expect(provider.destroy(computer, context)).resolves.toBeUndefined();
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
it("asks the supervisor to cancel orphaned run work when releasing a screen", async () => {
|
|
const fetchMock = vi.fn(async () => Response.json({ ok: true }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
await provider.releaseScreen(
|
|
{ id: "computer-1", botId: "bot", kind: "docker", providerRef: "computer-1" },
|
|
{ ...context, cancelRunWork: true },
|
|
);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"http://supervisor.test/computers/computer-1/screen",
|
|
expect.objectContaining({
|
|
method: "DELETE",
|
|
headers: expect.objectContaining({ "x-rakazo-cancel-run-work": "1" }),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Docker page browser", () => {
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it("uses the owned computer screen and lease instead of the generic exec endpoint", async () => {
|
|
const fetchMock = vi.fn(async () =>
|
|
Response.json({ ok: true, title: "Fixture", url: "https://example.test" }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new DockerSandboxProvider("http://supervisor.test", "test-token");
|
|
await provider.pageBrowser(
|
|
{ id: "computer", botId: "team-home", kind: "docker", providerRef: "computer" },
|
|
{ command: "snapshot" },
|
|
context,
|
|
);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"http://supervisor.test/computers/computer/browser",
|
|
expect.objectContaining({
|
|
redirect: "error",
|
|
signal: context.signal,
|
|
body: JSON.stringify({ command: "snapshot" }),
|
|
headers: expect.objectContaining({
|
|
"x-rakazo-bot-id": "team-home",
|
|
"x-rakazo-screen-lease-id": "run-1:1",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("streams file writes to the supervisor without buffering them", async () => {
|
|
const received: { url?: string; bytes: number; type?: string } = { bytes: 0 };
|
|
const server = createServer((request, response) => {
|
|
received.url = request.url;
|
|
received.type = request.headers["content-type"];
|
|
request.on("data", (chunk: Buffer) => {
|
|
received.bytes += chunk.byteLength;
|
|
});
|
|
request.on("end", () => {
|
|
response.setHeader("content-type", "application/json");
|
|
response.end('{"ok":true}');
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
try {
|
|
const { port } = server.address() as AddressInfo;
|
|
const provider = new DockerSandboxProvider(`http://127.0.0.1:${port}`, "test-token");
|
|
const chunk = new Uint8Array(1024 * 1024);
|
|
await provider.writeFileStream(
|
|
{ id: "computer", botId: "bot", kind: "docker", providerRef: "computer" },
|
|
{
|
|
path: "attachments/repo.zip",
|
|
stream: (async function* () {
|
|
for (let index = 0; index < 8; index += 1) yield chunk;
|
|
})(),
|
|
},
|
|
context,
|
|
);
|
|
expect(received).toEqual({
|
|
url: "/computers/computer/files/stream?path=attachments%2Frepo.zip",
|
|
bytes: 8 * 1024 * 1024,
|
|
type: "application/octet-stream",
|
|
});
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
});
|
|
});
|