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.
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type { PortableFile, SandboxProvider } from "@rakazo/adapter-kit";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { BoxSandboxEmulator } from "./box-emulator.js";
|
|
import { DaytonaSandboxEmulator } from "./daytona-emulator.js";
|
|
import { DesktopSandboxProvider } from "./desktop-sandbox.js";
|
|
import { ManagedSandboxEmulator } from "./e2b-emulator.js";
|
|
import { FakeSandboxProvider } from "./fake-sandbox.js";
|
|
import { provisionPrepared } from "./sandbox-test-support.js";
|
|
|
|
const context = {
|
|
operationId: "sandbox-fault-test",
|
|
traceId: "sandbox-fault-test",
|
|
spaceId: "workspace",
|
|
userId: "user",
|
|
signal: new AbortController().signal,
|
|
};
|
|
|
|
const temporaryRoots: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
describe.each([
|
|
["fake", () => Promise.resolve(new FakeSandboxProvider())],
|
|
["managed emulator", () => Promise.resolve(new ManagedSandboxEmulator())],
|
|
["Daytona emulator", () => Promise.resolve(new DaytonaSandboxEmulator())],
|
|
["Box emulator", () => Promise.resolve(new BoxSandboxEmulator())],
|
|
[
|
|
"desktop",
|
|
async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "rakazo-sandbox-faults-"));
|
|
temporaryRoots.push(root);
|
|
return new DesktopSandboxProvider({ root: await realpath(root) });
|
|
},
|
|
],
|
|
] as const)("%s sandbox negative contract", (_name, makeProvider) => {
|
|
it("rejects traversal through every portable workspace entry point", async () => {
|
|
const provider = await makeProvider();
|
|
const computer = await provisionPrepared(
|
|
provider,
|
|
{
|
|
botId: "traversal",
|
|
homePath: "/unused",
|
|
},
|
|
context,
|
|
);
|
|
const escapingFile = {
|
|
path: "safe/../../outside.txt",
|
|
content: new TextEncoder().encode("nope"),
|
|
};
|
|
|
|
await expect(provider.listFiles(computer, "../outside", context)).rejects.toThrow(
|
|
/escapes the computer workspace/i,
|
|
);
|
|
await expect(provider.readFile(computer, "..\\outside.txt", context)).rejects.toThrow(
|
|
/escapes the computer workspace/i,
|
|
);
|
|
await expect(provider.writeFile(computer, escapingFile, context)).rejects.toThrow(
|
|
/escapes the computer workspace/i,
|
|
);
|
|
await expect(
|
|
provider.importWorkspace(computer, portableFiles([escapingFile]), context),
|
|
).rejects.toThrow(/escapes the computer workspace/i);
|
|
});
|
|
|
|
it("reports missing and oversized reads without returning partial data", async () => {
|
|
const provider = await makeProvider();
|
|
const computer = await provisionPrepared(
|
|
provider,
|
|
{ botId: "reads", homePath: "/unused" },
|
|
context,
|
|
);
|
|
await provider.writeFile(
|
|
computer,
|
|
{ path: "payload.bin", content: Uint8Array.from([0, 1, 2, 3, 255]) },
|
|
context,
|
|
);
|
|
|
|
await expect(provider.readFile(computer, "missing.bin", context)).rejects.toThrow(
|
|
/not found|no such file/i,
|
|
);
|
|
await expect(
|
|
provider.readFile(computer, "payload.bin", context, { maxBytes: 4 }),
|
|
).rejects.toThrow("computer file exceeds 4 bytes");
|
|
expect(
|
|
await provider.readFile(computer, "payload.bin", context, {
|
|
maxBytes: 5,
|
|
}),
|
|
).toEqual(Uint8Array.from([0, 1, 2, 3, 255]));
|
|
});
|
|
|
|
it("rejects oversized action batches before applying any action", async () => {
|
|
const provider = await makeProvider();
|
|
const computer = await provisionPrepared(
|
|
provider,
|
|
{
|
|
botId: "actions",
|
|
homePath: "/unused",
|
|
},
|
|
context,
|
|
);
|
|
const before = await provider.observe(computer, context);
|
|
|
|
await expect(
|
|
provider.act(
|
|
computer,
|
|
{
|
|
actions: Array.from({ length: 25 }, (_, index) => ({
|
|
kind: "clipboard" as const,
|
|
text: `action-${index}`,
|
|
})),
|
|
observe: true,
|
|
},
|
|
context,
|
|
),
|
|
).rejects.toThrow("computer action batch exceeds 24 actions");
|
|
expect((await provider.observe(computer, context)).activeWindow).toEqual(before.activeWindow);
|
|
});
|
|
|
|
it("stops idempotently and reports whether provisioning reuses workspace files", async () => {
|
|
const provider = await makeProvider();
|
|
const request = { botId: "lifecycle", homePath: "/unused" };
|
|
const first = await provisionPrepared(provider, request, context);
|
|
await provider.writeFile(
|
|
first,
|
|
{ path: "state.txt", content: new TextEncoder().encode("preserved") },
|
|
context,
|
|
);
|
|
|
|
await provider.stop(first, context);
|
|
await provider.stop(first, context);
|
|
const resumed = await provisionPrepared(provider, request, context);
|
|
expect(resumed).toMatchObject({
|
|
id: first.id,
|
|
providerRef: first.providerRef,
|
|
fresh: false,
|
|
});
|
|
expect(new TextDecoder().decode(await provider.readFile(resumed, "state.txt", context))).toBe(
|
|
"preserved",
|
|
);
|
|
|
|
await provider.destroy(resumed, context);
|
|
await provider.destroy(resumed, context);
|
|
const replacement = await provisionPrepared(provider, request, context);
|
|
if (provider instanceof DesktopSandboxProvider) {
|
|
// A configured desktop root retains its workspace through destroy.
|
|
expect(replacement.fresh).toBe(false);
|
|
expect(
|
|
new TextDecoder().decode(await provider.readFile(replacement, "state.txt", context)),
|
|
).toBe("preserved");
|
|
} else {
|
|
expect(replacement.fresh).toBe(true);
|
|
await expect(provider.readFile(replacement, "state.txt", context)).rejects.toThrow(
|
|
/not found/i,
|
|
);
|
|
}
|
|
await provider.destroy(replacement, context);
|
|
});
|
|
|
|
it("surfaces injected lifecycle and transfer failures and remains retryable", async () => {
|
|
const provider = await makeProvider();
|
|
const faulted = faultOnce(provider, [
|
|
"provision",
|
|
"prepare",
|
|
"importWorkspace",
|
|
"exportWorkspace",
|
|
]);
|
|
const request = { botId: "faults", homePath: "/unused" };
|
|
|
|
await expect(faulted.provision(request, context)).rejects.toThrow("injected provision failure");
|
|
const computer = await faulted.provision(request, context);
|
|
expect(computer.fresh).toBe(true);
|
|
await expect(faulted.prepare(computer, context)).rejects.toThrow("injected prepare failure");
|
|
await faulted.prepare(computer, context);
|
|
|
|
const file = {
|
|
path: "retry.bin",
|
|
content: Uint8Array.from([0, 255, 128, 1]),
|
|
executable: true,
|
|
};
|
|
await expect(faulted.importWorkspace(computer, portableFiles([file]), context)).rejects.toThrow(
|
|
"injected importWorkspace failure",
|
|
);
|
|
await faulted.importWorkspace(computer, portableFiles([file]), context);
|
|
|
|
await expect(collect(faulted.exportWorkspace(computer, context))).rejects.toThrow(
|
|
"injected exportWorkspace failure",
|
|
);
|
|
const exported = await collect(faulted.exportWorkspace(computer, context));
|
|
expect(exported).toEqual([file]);
|
|
});
|
|
});
|
|
|
|
describe("portable workspace transfer", () => {
|
|
it("preserves binary bytes and executable metadata across every provider pairing", async () => {
|
|
const sourceProviders = await providerSet("source");
|
|
const targetProviders = await providerSet("target");
|
|
const binary = Uint8Array.from([0, 255, 1, 128, 2, 127]);
|
|
|
|
for (const [sourceName, source] of sourceProviders) {
|
|
const sourceComputer = await provisionPrepared(
|
|
source,
|
|
{
|
|
botId: `source-${sourceName}`,
|
|
homePath: "/unused",
|
|
},
|
|
context,
|
|
);
|
|
await source.writeFile(
|
|
sourceComputer,
|
|
{ path: "bin/tool", content: binary, executable: true },
|
|
context,
|
|
);
|
|
const exported = await collect(source.exportWorkspace(sourceComputer, context));
|
|
|
|
for (const [targetName, target] of targetProviders) {
|
|
const targetComputer = await provisionPrepared(
|
|
target,
|
|
{
|
|
botId: `${sourceName}-to-${targetName}`,
|
|
homePath: "/unused",
|
|
},
|
|
context,
|
|
);
|
|
await target.importWorkspace(targetComputer, portableFiles(exported), context);
|
|
|
|
expect(await target.readFile(targetComputer, "bin/tool", context)).toEqual(binary);
|
|
expect(await target.listFiles(targetComputer, "bin", context)).toEqual([
|
|
{
|
|
path: "bin/tool",
|
|
kind: "file",
|
|
size: binary.byteLength,
|
|
executable: true,
|
|
},
|
|
]);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
type FaultableMethod = "provision" | "prepare" | "importWorkspace" | "exportWorkspace";
|
|
|
|
function faultOnce(provider: SandboxProvider, methods: FaultableMethod[]): SandboxProvider {
|
|
const pending = new Set(methods);
|
|
return new Proxy(provider, {
|
|
get(target, property, receiver) {
|
|
const value = Reflect.get(target, property, receiver) as unknown;
|
|
if (typeof value !== "function") return value;
|
|
if (
|
|
property !== "provision" &&
|
|
property !== "prepare" &&
|
|
property !== "importWorkspace" &&
|
|
property !== "exportWorkspace"
|
|
) {
|
|
return value.bind(target);
|
|
}
|
|
return (...args: unknown[]) => {
|
|
if (!pending.delete(property)) return Reflect.apply(value, target, args);
|
|
const error = new Error(`injected ${property} failure`);
|
|
if (property !== "exportWorkspace") return Promise.reject(error);
|
|
return {
|
|
[Symbol.asyncIterator]() {
|
|
return { next: () => Promise.reject(error) };
|
|
},
|
|
} satisfies AsyncIterable<PortableFile>;
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
async function providerSet(label: string): Promise<Array<[string, SandboxProvider]>> {
|
|
const root = await mkdtemp(path.join(tmpdir(), `rakazo-sandbox-transfer-${label}-`));
|
|
temporaryRoots.push(root);
|
|
return [
|
|
["fake", new FakeSandboxProvider()],
|
|
["managed", new ManagedSandboxEmulator()],
|
|
["daytona", new DaytonaSandboxEmulator()],
|
|
["box", new BoxSandboxEmulator()],
|
|
["desktop", new DesktopSandboxProvider({ root: await realpath(root) })],
|
|
];
|
|
}
|
|
|
|
async function* portableFiles(files: Iterable<PortableFile>): AsyncIterable<PortableFile> {
|
|
yield* files;
|
|
}
|
|
|
|
async function collect(files: AsyncIterable<PortableFile>): Promise<PortableFile[]> {
|
|
const collected: PortableFile[] = [];
|
|
for await (const file of files) collected.push(file);
|
|
return collected;
|
|
}
|