diff --git a/package.json b/package.json index c4af5fc..abe5266 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,9 @@ "ai" ], "dependencies": { + "@types/node": "^22.9.1", + "@types/node-fetch": "^2.6.4", + "@types/ws": "^8.18.1", "form-data": "^4.0.1", "node-fetch": "2.7.0", "ws": "^8.19.0", @@ -48,9 +51,6 @@ "zod-to-json-schema": "^3.25.0" }, "devDependencies": { - "@types/node": "^22.9.1", - "@types/node-fetch": "^2.6.4", - "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "dotenv": "^17.3.1", diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 826b4c8..cde578e 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -6,6 +6,8 @@ import { SandboxProcessHandle, SandboxProcessesApi } from "../sandbox/process"; import { SandboxTerminalApi } from "../sandbox/terminal"; import { BasicResponse } from "../types/session"; import { + CompleteSandboxImageBuildParams, + CreateSandboxImageBuildParams, CreateSandboxParams, Sandbox, SandboxDetail, @@ -13,14 +15,23 @@ import { SandboxExposeResult, SandboxExecParams, SandboxExecOptions, + SandboxImageBuild, + SandboxImageBuildCreateResult, + SandboxImageBuildListParams, + SandboxImageBuildListResponse, + SandboxImageListParams, SandboxImageListResponse, SandboxListParams, SandboxListResponse, SandboxMemorySnapshotParams, SandboxMemorySnapshotResult, + SandboxNetworkPolicyPatch, + SandboxNetworkUpdateResult, SandboxProcessResult, + SandboxSnapshotDeleteResult, SandboxSnapshotListParams, SandboxSnapshotListResponse, + SandboxSnapshotSummary, SandboxUnexposeResult, } from "../types/sandbox"; import { BaseService } from "./base"; @@ -43,18 +54,6 @@ type WireSandboxListResponse = Omit & { sandboxes: WireSandbox[]; }; -const validateOptionalPositiveInteger = ( - value: number | undefined, - fieldName: "cpu" | "memoryMiB" | "diskMiB" -) => { - if (value === undefined) { - return; - } - if (!Number.isInteger(value) || value < 1) { - throw new HyperbrowserError(`${fieldName} must be a positive integer`, undefined); - } -}; - const normalizeSandbox = (sandbox: WireSandbox): Sandbox => { const { vcpus, memMiB, diskSizeMiB, ...rest } = sandbox; return { @@ -80,11 +79,7 @@ const normalizeSandboxListResponse = (response: WireSandboxListResponse): Sandbo }); const serializeCreateSandboxParams = (params: CreateSandboxParams): Record => { - if ("imageName" in params) { - validateOptionalPositiveInteger(params.cpu, "cpu"); - validateOptionalPositiveInteger(params.memoryMiB, "memoryMiB"); - validateOptionalPositiveInteger(params.diskMiB, "diskMiB"); - + if (typeof params.imageName === "string") { return { imageName: params.imageName, imageId: params.imageId, @@ -96,6 +91,9 @@ const serializeCreateSandboxParams = (params: CreateSandboxParams): Record ({ ...entry })); } @@ -309,6 +314,27 @@ export class SandboxHandle { return buildSandboxExposedUrl(this.runtime, port); } + get network(): SandboxDetail["network"] { + return this.detail.network; + } + + async updateNetwork(policy: SandboxNetworkPolicyPatch): Promise { + const result = await this.service.updateNetwork(this.id, policy); + this.detail = { + ...this.detail, + network: result.network, + }; + return result; + } + + async clearNetwork(): Promise { + return this.updateNetwork({ + allowInternetAccess: true, + allowOut: [], + denyOut: [], + }); + } + async exec(input: string, options?: SandboxExecOptions): Promise; async exec(input: SandboxExecParams): Promise; async exec( @@ -413,7 +439,11 @@ export class SandboxHandle { } private assertRuntimeAvailable() { - if (this.detail.status === "closed" || this.detail.status === "error") { + if ( + this.detail.status === "closed" || + this.detail.status === "close-error" || + this.detail.status === "error" + ) { throw new HyperbrowserError(`Sandbox ${this.id} is not running`, { statusCode: 409, code: "sandbox_not_running", @@ -484,9 +514,14 @@ export class SandboxesService extends BaseService { } } - async listImages(): Promise { + async listImages(params: SandboxImageListParams = {}): Promise { try { - return await this.request("/images"); + return await this.request("/images", undefined, { + source: params.source, + search: params.search, + page: params.page, + limit: params.limit, + }); } catch (error) { if (error instanceof HyperbrowserError) { throw error; @@ -502,6 +537,8 @@ export class SandboxesService extends BaseService { return await this.request("/snapshots", undefined, { status: params.status, imageName: params.imageName, + search: params.search, + page: params.page, limit: params.limit, }); } catch (error) { @@ -572,6 +609,133 @@ export class SandboxesService extends BaseService { } } + async getSnapshot(snapshot: string): Promise { + try { + const response = await this.request<{ snapshot: SandboxSnapshotSummary }>( + `/snapshots/${encodeURIComponent(snapshot)}` + ); + return response.snapshot; + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError(`Failed to get snapshot ${snapshot}`); + } + } + + async deleteSnapshot(snapshot: string): Promise { + try { + return await this.request( + `/snapshots/${encodeURIComponent(snapshot)}`, + { method: "DELETE" } + ); + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError(`Failed to delete snapshot ${snapshot}`); + } + } + + async createImageBuild( + params: CreateSandboxImageBuildParams + ): Promise { + try { + return await this.request("/images/builds", { + method: "POST", + body: JSON.stringify(params), + }); + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError("Failed to create image build"); + } + } + + async getImageBuild(buildId: string): Promise { + try { + const response = await this.request<{ build: SandboxImageBuild }>( + `/images/builds/${encodeURIComponent(buildId)}` + ); + return response.build; + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError(`Failed to get image build ${buildId}`); + } + } + + async listImageBuilds( + params: SandboxImageBuildListParams = {} + ): Promise { + try { + return await this.request("/images/builds", undefined, { + status: params.status, + limit: params.limit, + }); + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError("Failed to list image builds"); + } + } + + async completeImageBuild( + buildId: string, + params: CompleteSandboxImageBuildParams + ): Promise { + try { + const response = await this.request<{ build: SandboxImageBuild }>( + `/images/builds/${encodeURIComponent(buildId)}/complete`, + { + method: "POST", + body: JSON.stringify(params), + } + ); + return response.build; + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError(`Failed to complete image build ${buildId}`); + } + } + + async cancelImageBuild(buildId: string): Promise { + try { + const response = await this.request<{ build: SandboxImageBuild }>( + `/images/builds/${encodeURIComponent(buildId)}/cancel`, + { method: "POST" } + ); + return response.build; + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError(`Failed to cancel image build ${buildId}`); + } + } + + async updateNetwork( + id: string, + policy: SandboxNetworkPolicyPatch + ): Promise { + try { + return await this.request(`/sandbox/${id}/network`, { + method: "PUT", + body: JSON.stringify(policy), + }); + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError(`Failed to update network policy for sandbox ${id}`); + } + } + async unexpose(id: string, port: number): Promise { try { return await this.request(`/sandbox/${id}/unexpose`, { diff --git a/src/services/volumes.ts b/src/services/volumes.ts index d6eb709..fba07ab 100644 --- a/src/services/volumes.ts +++ b/src/services/volumes.ts @@ -1,5 +1,5 @@ import { HyperbrowserError } from "../client"; -import { CreateVolumeParams, Volume, VolumeListResponse } from "../types/volume"; +import { CreateVolumeParams, Volume, VolumeListParams, VolumeListResponse } from "../types/volume"; import { BaseService } from "./base"; export class VolumesService extends BaseService { @@ -23,9 +23,13 @@ export class VolumesService extends BaseService { /** * List sandbox volumes for the current team. */ - async list(): Promise { + async list(params: VolumeListParams = {}): Promise { try { - return await this.request("/volume"); + return await this.request("/volume", undefined, { + search: params.search, + page: params.page, + limit: params.limit, + }); } catch (error) { if (error instanceof HyperbrowserError) { throw error; diff --git a/src/types/constants.ts b/src/types/constants.ts index 681efd9..67c0cb4 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -104,6 +104,7 @@ export type GrokComputerUseLlm = "grok-4.5"; export type GrokReasoningEffort = "low" | "medium" | "high"; export type SessionRegion = + | "us" | "us-central" | "asia-south" | "us-dev" diff --git a/src/types/index.ts b/src/types/index.ts index 6d0a291..3777aaa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -143,18 +143,32 @@ export { export { SandboxStatus, SandboxRuntimeTarget, + SandboxNetworkPolicy, + SandboxNetworkPolicyPatch, + SandboxNetworkUpdateResult, Sandbox, SandboxDetail, SandboxVolumeMountType, SandboxVolumeMount, SandboxListParams, SandboxListResponse, + SandboxImageSource, SandboxImageSummary, + SandboxImageListParams, SandboxImageListResponse, SandboxSnapshotStatus, SandboxSnapshotSummary, SandboxSnapshotListParams, SandboxSnapshotListResponse, + SandboxSnapshotDeleteResult, + SandboxImageBuildStatus, + SandboxImageBuildUpload, + SandboxImageBuild, + CreateSandboxImageBuildParams, + CompleteSandboxImageBuildParams, + SandboxImageBuildCreateResult, + SandboxImageBuildListParams, + SandboxImageBuildListResponse, CreateSandboxParams, SandboxMemorySnapshotParams, SandboxMemorySnapshotResult, @@ -199,7 +213,7 @@ export { SandboxTerminalKillParams, SandboxTerminalEvent, } from "./sandbox"; -export { CreateVolumeParams, Volume, VolumeListResponse } from "./volume"; +export { CreateVolumeParams, Volume, VolumeListParams, VolumeListResponse } from "./volume"; export { CreateProfileParams, ForkProfileParams, diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index e50a65c..7155925 100644 --- a/src/types/sandbox.ts +++ b/src/types/sandbox.ts @@ -5,6 +5,22 @@ import { SessionLaunchState, SessionStatus } from "./session"; export type SandboxStatus = SessionStatus; +export interface SandboxNetworkPolicy { + allowInternetAccess: boolean; + allowOut: string[]; + denyOut: string[]; +} + +export interface SandboxNetworkPolicyPatch { + allowInternetAccess?: boolean; + allowOut?: string[]; + denyOut?: string[]; +} + +export interface SandboxNetworkUpdateResult { + network: SandboxNetworkPolicy; +} + export interface SandboxRuntimeTarget { transport: "regional_proxy"; host: string; @@ -33,6 +49,8 @@ export interface Sandbox { cpu?: number | null; memoryMiB?: number | null; diskMiB?: number | null; + timeoutMinutes?: number | null; + network?: SandboxNetworkPolicy; runtime: SandboxRuntimeTarget; exposedPorts: SandboxExposeResult[]; } @@ -56,6 +74,9 @@ interface SandboxCreateCommonParams { exposedPorts?: SandboxExposeParams[]; mounts?: Record; timeoutMinutes?: number; + allowInternetAccess?: boolean; + allowOut?: string[]; + denyOut?: string[]; } export type CreateSandboxParams = @@ -94,21 +115,31 @@ export interface SandboxListResponse { perPage: number; } +export type SandboxImageSource = "public" | "team"; + export interface SandboxImageSummary { id: string; imageName: string; namespace: string; + source?: SandboxImageSource; + imageInit?: Record | null; uploaded: boolean; createdAt: string; updatedAt: string; } +export interface SandboxImageListParams { + source?: SandboxImageSource | SandboxImageSource[]; + search?: string; + page?: number; + limit?: number; +} + export interface SandboxImageListResponse { images: SandboxImageSummary[]; - // TODO: add pagination metadata when /api/images supports it. - // totalCount?: number; - // page?: number; - // perPage?: number; + totalCount?: number; + page?: number; + perPage?: number; } export type SandboxSnapshotStatus = "creating" | "created" | "failed"; @@ -121,6 +152,9 @@ export interface SandboxSnapshotSummary { imageName: string; imageId: string; status: SandboxSnapshotStatus; + vcpus?: number | null; + memMiB?: number | null; + diskSizeMiB?: number | null; compatibilityTag: string; metadata: Record; uploaded: boolean; @@ -129,17 +163,98 @@ export interface SandboxSnapshotSummary { } export interface SandboxSnapshotListParams { - status?: SandboxSnapshotStatus; + status?: SandboxSnapshotStatus | SandboxSnapshotStatus[]; imageName?: string; + search?: string; + page?: number; limit?: number; } export interface SandboxSnapshotListResponse { snapshots: SandboxSnapshotSummary[]; - // TODO: add pagination metadata when /api/snapshots supports it. - // totalCount?: number; - // page?: number; - // perPage?: number; + totalCount?: number; + page?: number; + perPage?: number; +} + +export interface SandboxSnapshotDeleteResult { + deleted: boolean; +} + +export type SandboxImageBuildStatus = + | "awaiting_upload" + | "upload_verified" + | "dispatching" + | "building" + | "verifying" + | "completed" + | "failed" + | "canceled"; + +export interface SandboxImageBuildUpload { + url: string; + method: string; + headers: Record; + objectKey: string; + expiresInSeconds: number; + maxUploadBytes: number; +} + +export interface SandboxImageBuild { + id: string; + teamId?: string | null; + userId?: string | null; + namespace?: string | null; + imageName: string; + imageId?: string | null; + status: SandboxImageBuildStatus; + inputBucket?: string | null; + inputKey?: string | null; + inputSha256?: string | null; + inputSizeBytes?: number | null; + outputBucket?: string | null; + outputKey?: string | null; + vmId?: string | null; + errorCode?: string | null; + errorMessage?: string | null; + metadata?: Record | null; + completedAt?: string | null; + createdAt?: string | null; + updatedAt?: string | null; +} + +export interface CreateSandboxImageBuildParams { + imageName: string; + inputSha256: string; + inputSizeBytes: number; + inputFormat?: "rootfs_export_tar_gz"; + sourcePlatform?: "linux/amd64"; + imageConfigUser?: string; + imageInit?: { + env?: Record; + command?: string; + args?: string[]; + }; +} + +export interface CompleteSandboxImageBuildParams { + inputSha256: string; + inputSizeBytes: number; + inputFormat?: "rootfs_export_tar_gz"; +} + +export interface SandboxImageBuildCreateResult { + build: SandboxImageBuild; + upload: SandboxImageBuildUpload; +} + +export interface SandboxImageBuildListParams { + status?: SandboxImageBuildStatus; + limit?: number; +} + +export interface SandboxImageBuildListResponse { + builds: SandboxImageBuild[]; } export interface SandboxMemorySnapshotParams { @@ -191,7 +306,7 @@ export interface SandboxExecParams { timeoutMs?: number; timeoutSec?: number; runAs?: string; - /** @deprecated Ignored for process APIs. Commands always execute via `/bin/sh -lc` server-side. */ + /** @deprecated Ignored for process APIs. */ useShell?: boolean; } diff --git a/src/types/session.ts b/src/types/session.ts index 1b9581a..0298c28 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -11,7 +11,7 @@ import { State, } from "./constants"; -export type SessionStatus = "active" | "closed" | "error"; +export type SessionStatus = "active" | "closed" | "close-error" | "error"; export type BrowserMemorySize = "small" | "medium" | "large"; export interface BasicResponse { diff --git a/src/types/volume.ts b/src/types/volume.ts index 5831dc0..49e30fc 100644 --- a/src/types/volume.ts +++ b/src/types/volume.ts @@ -9,6 +9,15 @@ export interface Volume { transferAmount?: number; } +export interface VolumeListParams { + search?: string; + page?: number; + limit?: number; +} + export interface VolumeListResponse { volumes: Volume[]; + totalCount?: number; + page?: number; + perPage?: number; } diff --git a/tests/sandbox/e2e/image-build-contract.test.ts b/tests/sandbox/e2e/image-build-contract.test.ts new file mode 100644 index 0000000..2fa0dd5 --- /dev/null +++ b/tests/sandbox/e2e/image-build-contract.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SandboxesService } from "../../../src/services/sandboxes"; + +const parseJsonRequestBody = (init: unknown): Record => { + if (!init || typeof init !== "object" || !("body" in init) || typeof init.body !== "string") { + throw new TypeError("Expected a string request body"); + } + + const body = JSON.parse(init.body) as unknown; + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new TypeError("Expected a JSON object request body"); + } + return body as Record; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("sandbox image build request contract", () => { + const inputSha256 = "a".repeat(64); + + test("omits format and platform so the server can apply its defaults", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue({}); + + await service.createImageBuild({ + imageName: "node", + inputSha256, + inputSizeBytes: 1024, + }); + await service.completeImageBuild("build_123", { + inputSha256, + inputSizeBytes: 1024, + }); + + expect(parseJsonRequestBody(requestSpy.mock.calls[0][1])).toEqual({ + imageName: "node", + inputSha256, + inputSizeBytes: 1024, + }); + expect(parseJsonRequestBody(requestSpy.mock.calls[1][1])).toEqual({ + inputSha256, + inputSizeBytes: 1024, + }); + }); + + test("forwards the exact supported format and platform when explicitly provided", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue({}); + + await service.createImageBuild({ + imageName: "node", + inputSha256, + inputSizeBytes: 1024, + inputFormat: "rootfs_export_tar_gz", + sourcePlatform: "linux/amd64", + }); + await service.completeImageBuild("build_123", { + inputSha256, + inputSizeBytes: 1024, + inputFormat: "rootfs_export_tar_gz", + }); + + expect(parseJsonRequestBody(requestSpy.mock.calls[0][1])).toMatchObject({ + inputFormat: "rootfs_export_tar_gz", + sourcePlatform: "linux/amd64", + }); + expect(parseJsonRequestBody(requestSpy.mock.calls[1][1])).toMatchObject({ + inputFormat: "rootfs_export_tar_gz", + }); + }); +}); diff --git a/tests/sandbox/e2e/list-contract.test.ts b/tests/sandbox/e2e/list-contract.test.ts index a4edb82..f710dd5 100644 --- a/tests/sandbox/e2e/list-contract.test.ts +++ b/tests/sandbox/e2e/list-contract.test.ts @@ -83,13 +83,21 @@ describe("sandbox control list contract", () => { updatedAt: "2026-03-12T00:00:01Z", }, ], + totalCount: 1, + page: 1, + perPage: 20, }; const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(payload); const response = await service.listImages(); - expect(requestSpy).toHaveBeenCalledWith("/images"); + expect(requestSpy).toHaveBeenCalledWith("/images", undefined, { + source: undefined, + search: undefined, + page: undefined, + limit: undefined, + }); expect(response).toEqual(payload); }); @@ -112,6 +120,9 @@ describe("sandbox control list contract", () => { updatedAt: "2026-03-12T00:00:01Z", }, ], + totalCount: 1, + page: 1, + perPage: 20, }; const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(payload); @@ -125,6 +136,8 @@ describe("sandbox control list contract", () => { expect(requestSpy).toHaveBeenCalledWith("/snapshots", undefined, { status: "created", imageName: "node", + search: undefined, + page: undefined, limit: 10, }); expect(response).toEqual(payload); diff --git a/tests/sandbox/e2e/public-types-contract.test.ts b/tests/sandbox/e2e/public-types-contract.test.ts new file mode 100644 index 0000000..5fbc9eb --- /dev/null +++ b/tests/sandbox/e2e/public-types-contract.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, expectTypeOf, test } from "vitest"; +import type { + CompleteSandboxImageBuildParams, + CreateSandboxImageBuildParams, + Sandbox, + SandboxImageBuildListParams, + SandboxImageBuildStatus, + SandboxImageListResponse, + SandboxNetworkPolicy, + SandboxSnapshotListResponse, + SessionRegion, + SessionStatus, + VolumeListResponse, +} from "../../../src/types"; + +describe("public type compatibility", () => { + test("keeps newly available response data optional", () => { + const imageResponse: SandboxImageListResponse = { images: [] }; + const snapshotResponse: SandboxSnapshotListResponse = { snapshots: [] }; + const volumeResponse: VolumeListResponse = { volumes: [] }; + + expect(imageResponse.totalCount).toBeUndefined(); + expect(snapshotResponse.page).toBeUndefined(); + expect(volumeResponse.perPage).toBeUndefined(); + expectTypeOf().toEqualTypeOf(); + }); + + test("includes public server region and status values", () => { + const region: SessionRegion = "us"; + const status: SessionStatus = "close-error"; + + expect(region).toBe("us"); + expect(status).toBe("close-error"); + }); + + test("only accepts the image build format and platform supported by the server", () => { + expectTypeOf().toEqualTypeOf< + "rootfs_export_tar_gz" | undefined + >(); + expectTypeOf().toEqualTypeOf< + "linux/amd64" | undefined + >(); + expectTypeOf().toEqualTypeOf< + "rootfs_export_tar_gz" | undefined + >(); + expectTypeOf().toEqualTypeOf< + SandboxImageBuildStatus | undefined + >(); + }); +}); diff --git a/tests/sandbox/e2e/sandbox-contract.test.ts b/tests/sandbox/e2e/sandbox-contract.test.ts index c1e6541..bb23fd3 100644 --- a/tests/sandbox/e2e/sandbox-contract.test.ts +++ b/tests/sandbox/e2e/sandbox-contract.test.ts @@ -5,6 +5,13 @@ import * as wsModule from "../../../src/sandbox/ws"; import { SandboxesService } from "../../../src/services/sandboxes"; import type { SandboxExposeResult } from "../../../src/types"; +const parseJsonRequestBody = (init: unknown): unknown => { + if (!init || typeof init !== "object" || !("body" in init) || typeof init.body !== "string") { + throw new TypeError("Expected a string request body"); + } + return JSON.parse(init.body) as unknown; +}; + const wireSandboxDetail = (overrides: Record = {}): Record => ({ id: "sbx_123", teamId: "team_1", @@ -27,6 +34,12 @@ const wireSandboxDetail = (overrides: Record = {}): Record { method: "POST", }) ); - expect(JSON.parse(requestSpy.mock.calls[0][1].body)).toEqual({ + expect(parseJsonRequestBody(requestSpy.mock.calls[0][1])).toEqual({ imageName: "node", exposedPorts: [{ port: 3000, auth: true }], mounts: { @@ -98,7 +111,9 @@ describe("sandbox control and runtime contract", () => { cpu: 2, memoryMiB: 2048, diskMiB: 8192, + timeoutMinutes: 15, }); + expect(sandbox.timeoutMinutes).toBe(15); expect(sandbox.getExposedUrl(3000)).toBe("https://3000-sbx_123.runtime.example.com/"); }); @@ -122,7 +137,7 @@ describe("sandbox control and runtime contract", () => { method: "POST", }) ); - expect(JSON.parse(requestSpy.mock.calls[0][1].body)).toEqual({ + expect(parseJsonRequestBody(requestSpy.mock.calls[0][1])).toEqual({ snapshotName: "snapshot-1", mounts: { "/workspace/readonly": { @@ -133,6 +148,139 @@ describe("sandbox control and runtime contract", () => { }); }); + test("create leaves resource value validation to the server", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(wireSandboxDetail()); + + await service.create({ + imageName: "node", + cpu: 0, + memoryMiB: -1, + diskMiB: 1.5, + }); + + expect(parseJsonRequestBody(requestSpy.mock.calls[0][1])).toEqual({ + imageName: "node", + vcpus: 0, + memMiB: -1, + diskSizeMiB: 1.5, + }); + }); + + test("create treats an explicitly undefined imageName as a snapshot launch", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(wireSandboxDetail()); + + await service.create({ + snapshotName: "snapshot-1", + imageName: undefined, + }); + + expect(parseJsonRequestBody(requestSpy.mock.calls[0][1])).toEqual({ + snapshotName: "snapshot-1", + }); + }); + + test("create forwards network policy fields for image and snapshot launches", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(wireSandboxDetail()); + + await service.create({ + imageName: "node", + allowInternetAccess: false, + allowOut: ["github.com"], + denyOut: ["169.254.169.254"], + }); + await service.create({ + snapshotName: "snapshot-1", + allowInternetAccess: true, + denyOut: ["10.0.0.0/8"], + }); + + expect(parseJsonRequestBody(requestSpy.mock.calls[0][1])).toEqual({ + imageName: "node", + allowInternetAccess: false, + allowOut: ["github.com"], + denyOut: ["169.254.169.254"], + }); + expect(parseJsonRequestBody(requestSpy.mock.calls[1][1])).toEqual({ + snapshotName: "snapshot-1", + allowInternetAccess: true, + denyOut: ["10.0.0.0/8"], + }); + }); + + test("updateNetwork sends a PUT patch and clearNetwork restores defaults", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const detailSpy = vi.spyOn(service as any, "request").mockResolvedValue(wireSandboxDetail()); + const sandbox = await service.get("sbx_123"); + detailSpy.mockResolvedValue({ + network: { + allowInternetAccess: false, + allowOut: ["github.com"], + denyOut: [], + }, + }); + + const result = await sandbox.updateNetwork({ + allowInternetAccess: false, + allowOut: ["github.com"], + }); + + expect(detailSpy).toHaveBeenLastCalledWith( + "/sandbox/sbx_123/network", + expect.objectContaining({ method: "PUT" }) + ); + expect(parseJsonRequestBody(detailSpy.mock.calls.at(-1)![1])).toEqual({ + allowInternetAccess: false, + allowOut: ["github.com"], + }); + expect(result.network.allowInternetAccess).toBe(false); + expect(sandbox.network).toEqual(result.network); + + await sandbox.clearNetwork(); + expect(parseJsonRequestBody(detailSpy.mock.calls.at(-1)![1])).toEqual({ + allowInternetAccess: true, + allowOut: [], + denyOut: [], + }); + }); + + test("handles sandbox responses that omit network until a policy is updated", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const detail = wireSandboxDetail(); + delete detail.network; + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(detail); + const sandbox = await service.get("sbx_123"); + + expect(sandbox.network).toBeUndefined(); + + requestSpy.mockResolvedValue({ + network: { + allowInternetAccess: false, + allowOut: [], + denyOut: ["10.0.0.0/8"], + }, + }); + await sandbox.updateNetwork({ denyOut: ["10.0.0.0/8"] }); + + expect(sandbox.network).toEqual({ + allowInternetAccess: false, + allowOut: [], + denyOut: ["10.0.0.0/8"], + }); + }); + + test("treats close-error sandboxes as unavailable at runtime", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + vi.spyOn(service as any, "request").mockResolvedValue( + wireSandboxDetail({ status: "close-error" }) + ); + const sandbox = await service.get("sbx_123"); + + await expect(sandbox.connect()).rejects.toThrow("Sandbox sbx_123 is not running"); + }); + test("expose and unexpose preserve server fields and update cached exposed ports", async () => { const service = new SandboxesService("test-key", "https://api.example.com", 30_000); const requestSpy = vi.spyOn(service as any, "request"); diff --git a/tests/sandbox/e2e/volumes-contract.test.ts b/tests/sandbox/e2e/volumes-contract.test.ts index 6bac7bb..b813144 100644 --- a/tests/sandbox/e2e/volumes-contract.test.ts +++ b/tests/sandbox/e2e/volumes-contract.test.ts @@ -37,13 +37,20 @@ describe("volume control contract", () => { transferAmount: 0, }, ], + totalCount: 1, + page: 1, + perPage: 20, }; const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(payload); const response = await service.list(); - expect(requestSpy).toHaveBeenCalledWith("/volume"); + expect(requestSpy).toHaveBeenCalledWith("/volume", undefined, { + search: undefined, + page: undefined, + limit: undefined, + }); expect(response).toEqual(payload); });