Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion packages/agentos-sandbox/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,70 @@ export type SandboxAgentProviderOptions = Omit<
"sandbox" | "sandboxId"
>;

/** Adapt any sandbox-agent backend into a per-VM AgentOS sandbox provider. */
interface SandboxAgentTransportInternals {
baseUrl: string;
token?: string;
defaultHeaders?: HeadersInit;
fetcher?: typeof globalThis.fetch;
awaitHealthy?(signal?: AbortSignal): Promise<void>;
}

function validateTransportBaseUrl(raw: string): string {
const normalized = raw.trim().replace(/\/+$/, "");
if (!normalized) throw new Error("SandboxAgent baseUrl must not be empty");
const url = new URL(normalized);
if (!url.hostname || url.search || url.hash) {
throw new Error(
"SandboxAgent baseUrl must include a host without a query string or fragment",
);
}
const hostname = url.hostname
.replace(/^\[/, "")
.replace(/\]$/, "")
.toLowerCase();
const loopback =
hostname === "localhost" ||
hostname === "::1" ||
/^127(?:\.|$)/.test(hostname);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("SandboxAgent baseUrl must use http or https");
}
if (url.protocol !== "https:" && !loopback) {
throw new Error(
"SandboxAgent baseUrl must use https unless it targets localhost",
);
}
return normalized;
}

async function requestThroughSandboxAgent(
client: SandboxAgent,
path: string,
init: RequestInit = {},
): Promise<Response> {
const transport = client as unknown as SandboxAgentTransportInternals;
await transport.awaitHealthy?.(init.signal ?? undefined);
if (typeof transport.fetcher !== "function") {
throw new Error(
"SandboxAgent client does not expose the authenticated fetch transport required by agentOS",
);
}
const headers = new Headers(transport.defaultHeaders);
new Headers(init.headers).forEach((value, name) => headers.set(name, value));
if (transport.token) {
headers.set("authorization", `Bearer ${transport.token}`);
}
return await transport.fetcher(
new URL(`${validateTransportBaseUrl(transport.baseUrl)}${path}`),
{
...init,
headers,
redirect: "manual",
},
);
}

/** Adapt any sandbox-agent backend into a per-VM agentOS sandbox provider. */
export function sandboxAgentProvider(
backend: SandboxAgentBackend,
options: SandboxAgentProviderOptions = {},
Expand All @@ -26,6 +89,10 @@ export function sandboxAgentProvider(
if (property === "dispose") {
return target.destroySandbox.bind(target);
}
if (property === "request") {
return (path: string, init?: RequestInit) =>
requestThroughSandboxAgent(target, path, init);
}
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
Expand Down
22 changes: 22 additions & 0 deletions packages/agentos-sandbox/tests/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ describe("sandboxAgentProvider", () => {
test("starts a fresh client and destroys its backend on disposal", async () => {
const destroySandbox = vi.fn(async () => {});
const runProcess = vi.fn(async () => ({ stdout: "ok", exitCode: 0 }));
const awaitHealthy = vi.fn(async () => {});
const fetcher = vi.fn(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response("ok"),
);
const client = {
baseUrl: "https://sandbox.example",
token: "current-token",
defaultHeaders: { "x-sandbox-provider": "test" },
fetcher,
awaitHealthy,
destroySandbox,
runProcess,
};
Expand All @@ -25,6 +34,19 @@ describe("sandboxAgentProvider", () => {
stdout: "ok",
exitCode: 0,
});
await first.request?.("/v1/fs/stat?path=%2F", {
headers: { range: "bytes=0-3" },
});
expect(awaitHealthy).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledTimes(1);
const [requestUrl, requestInit] = fetcher.mock.calls[0] ?? [];
expect(String(requestUrl)).toBe(
"https://sandbox.example/v1/fs/stat?path=%2F",
);
const headers = new Headers(requestInit?.headers);
expect(headers.get("authorization")).toBe("Bearer current-token");
expect(headers.get("x-sandbox-provider")).toBe("test");
expect(headers.get("range")).toBe("bytes=0-3");
await first.dispose?.();
await second.dispose?.();
expect(destroySandbox).toHaveBeenCalledTimes(2);
Expand Down
48 changes: 44 additions & 4 deletions packages/agentos-sandbox/tests/vm-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { createSandboxBindings } from "../src/index.js";

let sandbox: MockSandboxAgentHandle;
let providerSandbox: MockSandboxAgentHandle;

const SANDBOX_TEST_PERMISSIONS = {
fs: "allow",
Expand All @@ -24,7 +25,8 @@ const SANDBOX_TEST_PERMISSIONS = {
} as const;

beforeAll(async () => {
sandbox = await startMockSandboxAgent();
sandbox = await startMockSandboxAgent({ token: "first-generation-token" });
providerSandbox = sandbox;
}, 150_000);

afterAll(async () => {
Expand All @@ -39,15 +41,17 @@ describe("VM integration", () => {
beforeEach(async () => {
providerStarts = 0;
providerDisposals = 0;
providerSandbox = sandbox;
vm = await AgentOs.create({
permissions: SANDBOX_TEST_PERMISSIONS,
software: [common],
sandbox: {
mountPath: "/sandbox",
idleTimeoutMs: 25,
provider: {
start: async () => {
providerStarts += 1;
return new Proxy(sandbox.client, {
return new Proxy(providerSandbox.client, {
get(target, property) {
if (property === "dispose") {
return () => {
Expand All @@ -62,12 +66,12 @@ describe("VM integration", () => {
},
},
});
expect(providerStarts).toBe(1);
expect(providerStarts).toBe(0);
}, 150_000);

afterEach(async () => {
if (vm) await vm.dispose();
expect(providerDisposals).toBe(1);
expect(providerDisposals).toBe(providerStarts);
});

// -- Filesystem mount tests --
Expand Down Expand Up @@ -105,6 +109,42 @@ describe("VM integration", () => {
expect(new TextDecoder().decode(content)).toBe("deep file");
});

it("keeps the mounted path live when the backing sandbox endpoint changes", async () => {
const replacement = await startMockSandboxAgent({
token: "second-generation-token",
});
try {
await sandbox.client.writeFsFile(
{ path: "/generation.txt" },
new TextEncoder().encode("first"),
);
expect(
new TextDecoder().decode(
await vm.readFile("/sandbox/generation.txt"),
),
).toBe("first");
expect(providerStarts).toBe(1);

providerSandbox = replacement;
await replacement.client.writeFsFile(
{ path: "/generation.txt" },
new TextEncoder().encode("second"),
);
for (let attempt = 0; attempt < 50 && providerDisposals === 0; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(providerDisposals).toBe(1);
expect(
new TextDecoder().decode(
await vm.readFile("/sandbox/generation.txt"),
),
).toBe("second");
expect(providerStarts).toBe(2);
} finally {
await replacement.stop();
}
}, 150_000);

// -- Bindings direct execution (host RPC, not via CLI shim) --

it("should execute the run-command binding directly via the binding collection", async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,6 @@ export {
createSandboxFs,
getSandboxDisposeHooks,
resolveSandboxOptions,
SandboxStartupError,
} from "./sandbox.js";
export type * from "./types.js";
Loading