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.
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
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",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|