diff --git a/README.md b/README.md index 915e05a..55a64c2 100644 --- a/README.md +++ b/README.md @@ -1096,3 +1096,90 @@ neither requires API credentials or a Docker daemon. Live tests are opt-in: set `HYPERBROWSER_API_KEY` and `HYPERBROWSER_BASE_URL`, then run `yarn test:e2e`. They create remote resources and require a receiver supporting streamed process starts. + +### GPU sandboxes + +Build a GPU-ready image from a remote Dockerfile context, then select a GPU when +creating the sandbox. GPU publication happens after the ordinary build; +`enableGPUBuilds: true` makes the build helpers wait for `gpuImageReady` and +keeps CPU-only images out of GPU cache reuse. Local/prebuilt Docker image imports +do not support GPU publication. + +```typescript +import { Hyperbrowser } from "@hyperbrowser/sdk"; + +const client = new Hyperbrowser({ apiKey: process.env.HYPERBROWSER_API_KEY! }); +const image = await client.sandboxes.getOrBuildImage({ + contextPath: "./gpu-image", // Your CUDA-compatible Dockerfile and context + enableGPUBuilds: true, +}); +const sandbox = await client.sandboxes.create({ + imageName: image.imageName, + imageId: image.imageId, + gpu: "H100", + gpuCount: 2, + preempt: false, // Require on-demand capacity; account enablement required + region: "us", + cpu: 8, + memoryMiB: 32768, + diskMiB: 65536, + exposedPorts: [{ port: 8000, auth: true }], +}); +try { + // create() can return status='provisioning', runtime=null, and token=null. + await sandbox.waitUntilReady({ timeout: 20 * 60 }); + console.log((await sandbox.exec("nvidia-smi -L")).stdout); + await sandbox.updateNetwork({ allowOut: ["example.com"], denyOut: [] }); +} finally { + await sandbox.stop(); +} +``` + +`gpuCount` defaults to one and accepts 1–8; the API checks that the requested +GPU, CPU, and memory combination is available. Omitted `preempt` (or `true`) +allows spot capacity. `false` requires on-demand capacity. GPU launches require +`region: "us"`; `runtimeClass: "gvisor-cpu"` is inferred by the API. The disk is +quota-limited ephemeral storage, from 12 GiB (the default) up to 512 GiB, subject +to your plan and host capacity. Snapshots, volume mounts, and startup secrets are +not supported for GPU launches. + +`waitUntilReady()` returns the same handle, retries transient reads with backoff, +and reports failures with the sandbox ID and close reason. Its timeout and poll +interval are in seconds. The wait timeout is independent of `timeoutMinutes`, +which controls the sandbox lifetime. Pass an `AbortSignal` to cancel the wait. +Timeout or cancellation stops only the wait; use the saved ID to inspect or stop +the sandbox. `get()` and `list()` can also return provisioning sandboxes; the +list API's existing `status: "active"` filter includes provisioning requests. +`Sandbox.runtime` is now nullable, so check it before directly accessing its URL. + +GPU handles expose `gpuType`, `gpuCount`, `preempt`, `runtimeClass`, `capabilities`, +`recordingEnabled`, and `closeReason`. Existing files, commands, PTY, exposed-port +authentication, and network-update methods work after readiness. `creditsUsed` +and termination details remain available through `info()`. + +For a custom image that starts an X11 desktop and installs the receiver's input +and screenshot tools, use the sandbox runtime computer-action endpoint: + +```typescript +import { ComputerAction } from "@hyperbrowser/sdk/types"; + +const screenshot = await sandbox.computerAction({ action: ComputerAction.SCREENSHOT }); +await sandbox.computerAction({ action: ComputerAction.CLICK, x: 300, y: 200 }); +await sandbox.computerAction({ action: ComputerAction.TYPE_TEXT, text: "Hello" }); +``` + +These calls belong inside the sandbox's active lifetime (before `stop()`). They +use the sandbox runtime token, including token refresh, and the existing typed +computer-action payloads. The image owns display startup; runtime readiness does +not imply desktop readiness. Set `enableRecording: true` at creation to record a +supported image's display and retrieve the video through +`client.sessions.getVideoRecordingURL(sandbox.id)`. + +GPU cache lookup verifies the API's latest 100 completed builds. An older image +may be rebuilt when its GPU readiness cannot be verified from that window. +For live validation, set `HYPERBROWSER_API_KEY` and +`HYPERBROWSER_GPU_IMAGE_NAME` (an already published image containing `nvidia-smi`), +then run `yarn test:e2e tests/e2e/gpu.test.ts`. Optional `HYPERBROWSER_GPU_TYPE`, +`HYPERBROWSER_GPU_COUNT`, and `HYPERBROWSER_GPU_PREEMPT=false` select the GPU +configuration; `HYPERBROWSER_GPU_DESKTOP=1` also checks screenshots. These tests +create billable sandboxes and stop them in cleanup. diff --git a/src/sandbox/image-build/resolution.ts b/src/sandbox/image-build/resolution.ts index fced714..0ed5bb5 100644 --- a/src/sandbox/image-build/resolution.ts +++ b/src/sandbox/image-build/resolution.ts @@ -14,6 +14,7 @@ export interface ImageBuildNameOptions { platform?: string; imageInit?: SandboxImageInit; imageConfigUser?: string; + enableGPUBuilds?: boolean; } const normalizeInitEnv = (env: Record | undefined) => @@ -76,6 +77,7 @@ export const imageBuildName = (options: ImageBuildNameOptions): string => { throw new Error("source must be 'dockerfile' or 'prebuilt'"); } const identityOptions: Record = {}; + if (options.enableGPUBuilds) identityOptions.enableGPUBuilds = true; const initialization = normalizeImageInit(options.imageInit); if (initialization !== undefined && Object.keys(initialization).length > 0) { identityOptions.imageInit = initialization; @@ -109,7 +111,8 @@ const IMAGE_BUILD_STATUSES = new Set([ export const matchingImageBuild = ( error: HyperbrowserError, imageName: string, - inputFormat: string + inputFormat: string, + enableGPUBuilds: boolean = false ): SandboxImageBuild | null => { if (error.statusCode !== 409 || error.code !== "image_build_in_progress") { return null; @@ -138,13 +141,18 @@ export const matchingImageBuild = ( return null; } const build = data as unknown as SandboxImageBuild; + if (enableGPUBuilds && !build.enableGPUBuilds) return null; if (build.imageName !== imageName) { return null; } return build; }; -export const completedImageId = (build: SandboxImageBuild): string | undefined => { +export const completedImageId = ( + build: SandboxImageBuild, + enableGPUBuilds: boolean = false +): string | undefined => { + if ((enableGPUBuilds || build.enableGPUBuilds) && !build.gpuImageReady) return undefined; if (build.status !== "completed") { return undefined; } diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 1e86446..305002d 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -38,6 +38,7 @@ import { StartSandboxFromSnapshotParams, SandboxRuntimeSession, SandboxRuntimeTarget, + SandboxWaitOptions, GetOrBuildSandboxImageOptions, ReuseSandboxDockerImageParams, SandboxDockerImageReuseResult, @@ -70,6 +71,7 @@ import { SandboxSnapshotSummary, SandboxUnexposeResult, } from "../types/sandbox"; +import { ComputerActionParams, ComputerActionResponse } from "../types/computer-action"; import { retryDelay } from "../retry"; import { optionalInteger, optionalNumber } from "./normalize"; import { BaseService } from "./base"; @@ -110,7 +112,13 @@ const normalizeSandbox = (sandbox: WireSandbox): Sandbox => { creditsUsed: optionalNumber(rest.creditsUsed) ?? null, exposedPorts: rest.exposedPorts ?? [], containsSecrets: rest.containsSecrets ?? false, - network: rest.network ? { ...rest.network, allowOut: rest.network.allowOut ?? [], denyOut: rest.network.denyOut ?? [] } : rest.network, + network: rest.network + ? { + ...rest.network, + allowOut: rest.network.allowOut ?? [], + denyOut: rest.network.denyOut ?? [], + } + : rest.network, }; }; @@ -153,6 +161,46 @@ const serializeCreateSandboxParams = (params: CreateSandboxParams): Record 8) + ) + throw new HyperbrowserError("gpuCount must be an integer between 1 and 8"); + if (params.preempt !== undefined && typeof params.preempt !== "boolean") + throw new HyperbrowserError("preempt must be a boolean"); + if (params.diskMiB !== undefined && (params.diskMiB < 12288 || params.diskMiB > 524288)) + throw new HyperbrowserError("GPU diskMiB must be between 12288 and 524288 MiB"); + } if (typeof params.imageName === "string") { const secrets = serializeSecretReferences(params.secrets); const vault = params.vault; @@ -160,12 +208,15 @@ const serializeCreateSandboxParams = (params: CreateSandboxParams): Record { +const validateImageBuildFormat = (params: { + inputFormat?: string; + sourcePlatform?: string; +}): void => { if (params.sourcePlatform !== undefined && params.sourcePlatform !== "linux/amd64") throw new HyperbrowserError("sourcePlatform must be linux/amd64"); - if (params.inputFormat !== undefined && ![ - "rootfs_export_tar_gz", "dockerfile_context_tar_gz", - "dockerfile_context_manifest_v1", "docker_image_manifest_v1", - ].includes(params.inputFormat)) throw new HyperbrowserError("Unsupported image build inputFormat"); + if ( + params.inputFormat !== undefined && + ![ + "rootfs_export_tar_gz", + "dockerfile_context_tar_gz", + "dockerfile_context_manifest_v1", + "docker_image_manifest_v1", + ].includes(params.inputFormat) + ) + throw new HyperbrowserError("Unsupported image build inputFormat"); }; const serializeCreateImageBuildParams = ( params: CreateSandboxImageBuildParams ): Record => { validateImageBuildFormat(params); + if ( + params.enableGPUBuilds && + !["dockerfile_context_tar_gz", "dockerfile_context_manifest_v1"].includes( + params.inputFormat ?? "" + ) + ) + throw new HyperbrowserError("GPU image builds require a remote Dockerfile context"); for (const key of ["builderCpus", "builderMemoryMiB", "builderScratchMiB"] as const) { const value = params[key]; - if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) throw new HyperbrowserError(`${key} must be a positive integer`); - } - return ({ - imageName: params.imageName, - inputSha256: params.inputSha256, - inputSizeBytes: params.inputSizeBytes, - vcpus: params.builderCpus, - memMiB: params.builderMemoryMiB, - scratchMiB: params.builderScratchMiB, - inputFormat: params.inputFormat, - sourcePlatform: params.sourcePlatform, - imageConfigUser: params.imageConfigUser, - imageInit: params.imageInit, - dockerfilePath: params.dockerfilePath, - contextManifest: params.contextManifest, - dockerImageManifest: params.dockerImageManifest, -}); + if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) + throw new HyperbrowserError(`${key} must be a positive integer`); + } + return { + imageName: params.imageName, + enableGPUBuilds: params.enableGPUBuilds, + inputSha256: params.inputSha256, + inputSizeBytes: params.inputSizeBytes, + vcpus: params.builderCpus, + memMiB: params.builderMemoryMiB, + scratchMiB: params.builderScratchMiB, + inputFormat: params.inputFormat, + sourcePlatform: params.sourcePlatform, + imageConfigUser: params.imageConfigUser, + imageInit: params.imageInit, + dockerfilePath: params.dockerfilePath, + contextManifest: params.contextManifest, + dockerImageManifest: params.dockerImageManifest, + }; }; const normalizeImageBuildPlatform = (platform: string | undefined): string => { @@ -293,10 +362,7 @@ const normalizeImageBuildPlatform = (platform: string | undefined): string => { type SandboxRuntimeState = SandboxRuntimeSession; -const resolveSandboxRuntimeSessionHost = ( - runtime: SandboxDetail["runtime"], - baseUrl: URL -): string => { +const resolveSandboxRuntimeSessionHost = (runtime: SandboxRuntimeTarget, baseUrl: URL): string => { const sessionIdFromBasePath = runtimeSessionIdFromPath(baseUrl.pathname); if (sessionIdFromBasePath && baseUrl.hostname) { return `${sessionIdFromBasePath}.${baseUrl.hostname}`; @@ -322,6 +388,12 @@ const resolveSandboxRuntimeSessionHost = ( }; const buildSandboxExposedUrl = (runtime: SandboxDetail["runtime"], port: number): string => { + if (!runtime) + throw new HyperbrowserError("Sandbox runtime is not ready; call waitUntilReady() first", { + code: "sandbox_not_ready", + statusCode: 409, + service: "runtime", + }); const baseUrl = new URL(runtime.baseUrl); const sessionHost = resolveSandboxRuntimeSessionHost(runtime, baseUrl); const authority = baseUrl.port @@ -419,6 +491,34 @@ export class SandboxHandle { return this.detail.timeoutMinutes; } + get gpuType(): SandboxDetail["gpuType"] { + return this.detail.gpuType; + } + + get gpuCount(): SandboxDetail["gpuCount"] { + return this.detail.gpuCount; + } + + get preempt(): SandboxDetail["preempt"] { + return this.detail.preempt; + } + + get capabilities(): SandboxDetail["capabilities"] { + return this.detail.capabilities; + } + + get runtimeClass(): SandboxDetail["runtimeClass"] { + return this.detail.runtimeClass; + } + + get recordingEnabled(): SandboxDetail["recordingEnabled"] { + return this.detail.recordingEnabled; + } + + get closeReason(): SandboxDetail["closeReason"] { + return this.detail.closeReason; + } + get exposedPorts(): SandboxExposeResult[] { return (this.detail.exposedPorts ?? []).map((entry) => ({ ...entry })); } @@ -438,6 +538,98 @@ export class SandboxHandle { return this; } + /** Execute a desktop action with this sandbox's runtime token. The image owns display startup. */ + async computerAction(params: ComputerActionParams): Promise { + return this.transport.requestJSON("/computer-action", { + method: "POST", + body: JSON.stringify(params), + }); + } + + /** Wait for a runtime. Canceling/timing out only stops this caller's wait. */ + async waitUntilReady(options: SandboxWaitOptions = {}): Promise { + let delay = options.pollInterval ?? 3; + const timeout = options.timeout === undefined ? 20 * 60 : options.timeout; + if ( + !Number.isFinite(delay) || + delay < 0 || + (timeout !== null && (!Number.isFinite(timeout) || timeout < 0)) + ) + throw new HyperbrowserError("timeout and pollInterval must be nonnegative finite seconds"); + if (!this.id?.trim()) + throw new HyperbrowserError("Sandbox response is missing its ID", { service: "control" }); + delay = Math.min(delay, 15); + const controller = new AbortController(); + let timedOut = timeout === 0; + const timer = + timeout === null + ? undefined + : setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeout * 1000); + const cancel = () => controller.abort(); + options.signal?.addEventListener("abort", cancel, { once: true }); + if (options.signal?.aborted) cancel(); + try { + for (;;) { + if (options.signal?.aborted) + throw new HyperbrowserError( + `Stopped waiting for sandbox ${this.id}; the sandbox was not stopped`, + { + code: "wait_cancelled", + service: "control", + details: { sandboxId: this.id }, + } + ); + if (["closed", "error", "close-error"].includes(this.status)) + throw new HyperbrowserError( + `Sandbox ${this.id} ended before becoming ready: ${this.detail.closeReason || this.status}`, + { + code: "sandbox_not_running", + service: "control", + details: { + sandboxId: this.id, + status: this.status, + closeReason: this.detail.closeReason, + }, + } + ); + if (this.status === "active" && this.runtime && this.detail.token) return this; + if (timedOut) + throw new HyperbrowserError( + `Timed out waiting for sandbox ${this.id}; the sandbox was not stopped`, + { + code: "wait_timeout", + service: "control", + details: { sandboxId: this.id }, + } + ); + try { + const detail = await this.service.getDetail(this.id, { signal: controller.signal }); + if (detail.id !== this.id) + throw new HyperbrowserError("Sandbox response returned a different ID"); + this.hydrate(detail); + if ( + this.status !== "provisioning" && + (this.status !== "active" || (this.runtime && this.detail.token)) + ) + continue; + } catch (error) { + if (controller.signal.aborted) continue; + if (!(error instanceof HyperbrowserError) || !error.retryable) throw error; + } + await retryDelay(delay * 1000, controller.signal).catch((error) => { + if (!controller.signal.aborted) throw error; + }); + delay = Math.min(delay * 2, 15); + } + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", cancel); + } + } + async connect(): Promise { await this.ensureRuntimeSession(true); return this; @@ -611,6 +803,16 @@ export class SandboxHandle { } private assertRuntimeAvailable() { + if (this.detail.status === "provisioning") { + throw new HyperbrowserError( + `Sandbox ${this.id} is provisioning; call waitUntilReady() first`, + { + statusCode: 409, + code: "sandbox_not_ready", + service: "runtime", + } + ); + } if ( this.detail.status === "closed" || this.detail.status === "close-error" || @@ -626,7 +828,7 @@ export class SandboxHandle { } private static toRuntimeSession(detail: SandboxDetail): SandboxRuntimeState | null { - if (!detail.token) { + if (detail.status !== "active" || !detail.token || !detail.runtime) { return null; } @@ -663,7 +865,7 @@ export class SandboxesService extends BaseService { async getRuntimeSession(id: string): Promise { const detail = await this.getDetail(id); - if (!detail.token || ["closed", "close-error", "error"].includes(detail.status)) { + if (!detail.token || !detail.runtime || detail.status !== "active") { throw new HyperbrowserError(`Sandbox ${id} is not running`, { statusCode: 409, code: "sandbox_not_running", @@ -774,9 +976,12 @@ export class SandboxesService extends BaseService { } } - async getDetail(id: string): Promise { + async getDetail(id: string, options: { signal?: AbortSignal } = {}): Promise { try { - const detail = await this.request(`/sandbox/${id}`); + const detail = await this.request( + `/sandbox/${id}`, + options.signal ? { signal: options.signal } : undefined + ); return normalizeSandboxDetail(detail); } catch (error) { if (error instanceof HyperbrowserError) { @@ -810,7 +1015,7 @@ export class SandboxesService extends BaseService { async expose( id: string, params: SandboxExposeParams, - runtime?: SandboxRuntimeTarget + runtime?: SandboxRuntimeTarget | null ): Promise { try { const exposure = await this.request(`/sandbox/${id}/expose`, { @@ -964,7 +1169,20 @@ export class SandboxesService extends BaseService { } /** Find an exact ready team image, including revisions awaiting backup. */ - async findReadyImage(imageName: string): Promise { + async findReadyImage( + imageName: string, + options: { enableGPUBuilds?: boolean } = {} + ): Promise { + let gpuIds: Set | undefined; + if (options.enableGPUBuilds) { + const { builds } = await this.listImageBuilds({ status: "completed", limit: 100 }); + gpuIds = new Set( + builds + .filter((build) => build.imageName === imageName && build.gpuImageReady) + .map((build) => build.imageId) + ); + if (gpuIds.size === 0) return null; + } let page = 1; for (;;) { const response = await this.listImages({ @@ -974,7 +1192,11 @@ export class SandboxesService extends BaseService { limit: READY_IMAGE_PAGE_SIZE, }); for (const image of response.images) { - if (image.imageName === imageName && (image.uploaded || image.ready === true)) { + if ( + image.imageName === imageName && + (!gpuIds || gpuIds.has(image.id)) && + (image.uploaded || image.ready === true) + ) { return image; } } @@ -1031,8 +1253,17 @@ export class SandboxesService extends BaseService { }; try { for (;;) { - const build = await this.getImageBuild(buildId, { signal: controller.signal }).catch(waitFailure); - if (build.status === "completed") return build; + const build = await this.getImageBuild(buildId, { signal: controller.signal }).catch( + waitFailure + ); + const needsGPU = options.enableGPUBuilds || build.enableGPUBuilds; + if (needsGPU && build.metadata?.ociPublicationError) + throw new HyperbrowserError("GPU image publication failed. Please rebuild the image.", { + code: "gpu_image_publication_failed", + service: "control", + details: { buildId }, + }); + if (build.status === "completed" && (!needsGPU || build.gpuImageReady)) return build; if (build.status === "failed" || build.status === "canceled") { throw new HyperbrowserError( build.errorMessage || `Image build ${buildId} ${build.status}`, @@ -1052,6 +1283,8 @@ export class SandboxesService extends BaseService { options: BuildSandboxImageFromDockerImageOptions ): Promise { const platform = normalizeImageBuildPlatform(options.platform); + if (options.enableGPUBuilds) + throw new HyperbrowserError("GPU image builds require a remote Dockerfile context"); const source = await prepareDockerImageManifestSource(options.dockerImage, { platform }); try { if ( @@ -1132,6 +1365,8 @@ export class SandboxesService extends BaseService { ): Promise { options = { ...options, platform: normalizeImageBuildPlatform(options.platform) }; const remote = options.remote ?? true; + if (options.enableGPUBuilds && !remote) + throw new HyperbrowserError("GPU image builds require remote: true"); if (remote) { if ( options.dockerTag !== undefined || @@ -1196,6 +1431,8 @@ export class SandboxesService extends BaseService { const dockerfile = options.dockerfile ?? "Dockerfile"; const remoteFullContext = options.remoteFullContext ?? false; const { contextPath, dockerImage } = options; + if (options.enableGPUBuilds && dockerImage !== undefined) + throw new HyperbrowserError("GPU image builds require a remote Dockerfile context"); if ((contextPath === undefined) === (dockerImage === undefined)) { throw new HyperbrowserError("Supply exactly one of contextPath or dockerImage"); } @@ -1235,9 +1472,12 @@ export class SandboxesService extends BaseService { platform, imageInit: options.imageInit, imageConfigUser: options.imageConfigUser, + enableGPUBuilds: options.enableGPUBuilds, }); if (!options.forceBuild) { - const image = await this.findReadyImage(imageName); + const image = await this.findReadyImage(imageName, { + enableGPUBuilds: options.enableGPUBuilds, + }); if (image !== null) { return { outcome: "reused", imageName, imageId: image.id }; } @@ -1247,6 +1487,7 @@ export class SandboxesService extends BaseService { platform, imageInit: options.imageInit, imageConfigUser: options.imageConfigUser, + enableGPUBuilds: options.enableGPUBuilds, builderCpus: options.builderCpus, builderMemoryMiB: options.builderMemoryMiB, builderScratchMiB: options.builderScratchMiB, @@ -1276,7 +1517,7 @@ export class SandboxesService extends BaseService { if (!(error instanceof HyperbrowserError)) { throw error; } - const existing = matchingImageBuild(error, imageName, inputFormat); + const existing = matchingImageBuild(error, imageName, inputFormat, options.enableGPUBuilds); if (existing === null) { throw error; } @@ -1284,14 +1525,19 @@ export class SandboxesService extends BaseService { outcome = "joined"; } const wait = options.wait ?? true; - if (wait && build.status !== "completed") { + if ( + wait && + (build.status !== "completed" || + ((options.enableGPUBuilds || build.enableGPUBuilds) && !build.gpuImageReady)) + ) { build = await this.waitForImageBuild(build.id, { + enableGPUBuilds: options.enableGPUBuilds, pollInterval: options.pollInterval, timeout: options.waitTimeout, signal: options.signal, }); } - return { outcome, imageName, imageId: completedImageId(build), build }; + return { outcome, imageName, imageId: completedImageId(build, options.enableGPUBuilds), build }; } private async buildImageFromRemoteDockerfile( @@ -1307,6 +1553,7 @@ export class SandboxesService extends BaseService { return await this.submitImageBuild( { imageName: options.imageName, + enableGPUBuilds: options.enableGPUBuilds, inputSha256: packaged.artifact.sha256Hex, inputSizeBytes: packaged.artifact.sizeBytes, inputFormat: packaged.artifact.inputFormat, @@ -1336,6 +1583,7 @@ export class SandboxesService extends BaseService { artifacts: Record, label: string, options: { + enableGPUBuilds?: boolean; wait?: boolean; pollInterval?: number; waitTimeout?: number | null; @@ -1356,6 +1604,7 @@ export class SandboxesService extends BaseService { buildStarted = true; if (options.wait ?? true) { return await this.waitForImageBuild(build.id, { + enableGPUBuilds: options.enableGPUBuilds, pollInterval: options.pollInterval, timeout: options.waitTimeout, signal: options.signal, diff --git a/src/types/index.ts b/src/types/index.ts index 5e2b110..ff81904 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -170,6 +170,8 @@ export { } from "./session"; export { SandboxStatus, + SandboxGpuType, + SandboxWaitOptions, SandboxRuntimeTarget, SandboxNetworkPolicy, SandboxNetworkPolicyPatch, diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index 786335c..5ad80c5 100644 --- a/src/types/sandbox.ts +++ b/src/types/sandbox.ts @@ -3,7 +3,29 @@ import type { ReadableStream } from "node:stream/web"; import { SessionRegion } from "./constants"; import { SessionLaunchState, SessionStatus } from "./session"; -export type SandboxStatus = SessionStatus; +export type SandboxStatus = SessionStatus | "provisioning"; + +export type SandboxGpuType = + | "L4" + | "A10G" + | "L40S" + | "L40" + | "H100" + | "H200" + | "B200" + | "B300" + | "A100-40GB" + | "A100-80GB" + | "RTX-PRO-6000"; + +export interface SandboxWaitOptions { + /** Seconds to wait. Defaults to 20 minutes; null waits indefinitely. */ + timeout?: number | null; + /** Initial polling interval in seconds; backs off to at most 15 seconds. */ + pollInterval?: number; + /** Stops this caller's wait without stopping the sandbox. */ + signal?: AbortSignal; +} export interface SandboxNetworkPolicy { allowInternetAccess?: boolean | null; @@ -29,6 +51,10 @@ export interface SandboxRuntimeTarget { export interface Sandbox { runtimeClass?: "firecracker" | "gvisor-cpu"; + gpuType?: SandboxGpuType | null; + gpuCount?: number | null; + preempt?: boolean; + recordingEnabled?: boolean; capabilities?: { commands: boolean; files: boolean; @@ -38,7 +64,9 @@ export interface Sandbox { volumes: boolean; exposedPorts: boolean; internetAccess: boolean; - writableStorage: "memory"; + networkFiltering?: boolean; + liveNetworkUpdates?: boolean; + writableStorage: "memory" | "disk"; }; id: string; teamId: string; @@ -63,7 +91,7 @@ export interface Sandbox { diskMiB?: number | null; timeoutMinutes?: number | null; network?: SandboxNetworkPolicy | null; - runtime: SandboxRuntimeTarget; + runtime: SandboxRuntimeTarget | null; exposedPorts: SandboxExposeResult[]; /** * Secrets were delivered into the sandbox, or it was restored from a @@ -180,6 +208,9 @@ interface SandboxCreateCommonParams { export type CreateSandboxParams = | (SandboxCreateCommonParams & { snapshotName: string; + gpu?: never; + gpuCount?: never; + preempt?: never; snapshotId?: string; imageName?: never; imageId?: never; @@ -194,6 +225,10 @@ export type CreateSandboxParams = snapshotName?: never; snapshotId?: never; imageName: string; + gpu?: SandboxGpuType; + gpuCount?: number; + /** Omitted/true allows spot; false requests on-demand capacity. */ + preempt?: boolean; imageId?: string; cpu?: number; memoryMiB?: number; @@ -215,7 +250,7 @@ export type CreateSandboxParams = }); export interface SandboxListParams { - status?: SandboxStatus; + status?: SessionStatus; start?: number; end?: number; search?: string; @@ -342,6 +377,11 @@ export interface SandboxImageBuildUpload { } export interface SandboxImageBuild { + enableGPUBuilds?: boolean; + imageReady?: boolean; + uploaded?: boolean; + gpuUploaded?: boolean; + gpuImageReady?: boolean; id: string; teamId?: string | null; userId?: string | null; @@ -401,6 +441,8 @@ export interface SandboxDockerImageManifest { } export interface CreateSandboxImageBuildParams { + /** Also publish an image for GPU sandboxes. Requires a remote Dockerfile context. */ + enableGPUBuilds?: boolean; imageName: string; inputSha256: string; inputSizeBytes: number; @@ -460,6 +502,8 @@ export interface SandboxImageBuildResolution { } export interface SandboxImageBuildWaitOptions { + /** Require GPU publication as well as build completion. */ + enableGPUBuilds?: boolean; signal?: AbortSignal; /** Seconds between status polls. Defaults to 3. */ pollInterval?: number; @@ -468,6 +512,8 @@ export interface SandboxImageBuildWaitOptions { } interface SandboxImageBuildCommonOptions { + /** Publish a GPU image. Only remote Dockerfile builds support this. */ + enableGPUBuilds?: boolean; /** Cancel this caller's polling; accepted builds and uploads continue independently. */ signal?: AbortSignal; imageName: string; @@ -504,6 +550,8 @@ export interface BuildSandboxImageFromDockerfileOptions extends SandboxImageBuil } export interface GetOrBuildSandboxImageOptions { + /** Resolve a GPU-ready remote Dockerfile image. */ + enableGPUBuilds?: boolean; /** Cancel this caller's polling; accepted builds and uploads continue independently. */ signal?: AbortSignal; contextPath?: string; diff --git a/tests/e2e/gpu.test.ts b/tests/e2e/gpu.test.ts new file mode 100644 index 0000000..5b959e4 --- /dev/null +++ b/tests/e2e/gpu.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "vitest"; +import { HyperbrowserClient as Hyperbrowser } from "../../src/client"; +import { ComputerAction, SandboxGpuType } from "../../src/types"; +import type { SandboxHandle } from "../../src/services/sandboxes"; + +// Opt-in: creates a billable GPU sandbox from an already published CUDA image. +const enabled = Boolean( + process.env.HYPERBROWSER_API_KEY && process.env.HYPERBROWSER_GPU_IMAGE_NAME +); +test.skipIf(!enabled)( + "GPU provisioning, device visibility, files and connectivity", + async () => { + const client = new Hyperbrowser({ + apiKey: process.env.HYPERBROWSER_API_KEY!, + ...(process.env.HYPERBROWSER_BASE_URL ? { baseUrl: process.env.HYPERBROWSER_BASE_URL } : {}), + }); + let sandbox: SandboxHandle | undefined; + const gpuCount = Number(process.env.HYPERBROWSER_GPU_COUNT ?? 1); + try { + sandbox = await client.sandboxes.create({ + imageName: process.env.HYPERBROWSER_GPU_IMAGE_NAME!, + gpu: (process.env.HYPERBROWSER_GPU_TYPE ?? "L4") as SandboxGpuType, + gpuCount, + preempt: process.env.HYPERBROWSER_GPU_PREEMPT?.toLowerCase() !== "false", + region: "us", + diskMiB: 65536, + timeoutMinutes: 30, + }); + await sandbox.waitUntilReady({ timeout: 20 * 60 }); + const devices = await sandbox.exec("nvidia-smi --query-gpu=uuid --format=csv,noheader"); + expect(devices.exitCode).toBe(0); + expect(devices.stdout.trim().split("\n")).toHaveLength(gpuCount); + await sandbox.files.writeText("/tmp/hb-gpu-sdk.txt", "gpu-sdk-ok"); + expect(await sandbox.files.readText("/tmp/hb-gpu-sdk.txt")).toBe("gpu-sdk-ok"); + const port = await sandbox.expose({ port: 8000, auth: true }); + expect(port.auth).toBe(true); + expect(port.url).toBeTruthy(); + await sandbox.unexpose(8000); + await sandbox.updateNetwork({ allowOut: ["example.com"], denyOut: [] }); + await sandbox.clearNetwork(); + if (process.env.HYPERBROWSER_GPU_DESKTOP === "1") { + expect(await sandbox.computerAction({ action: ComputerAction.SCREENSHOT })).toMatchObject({ + success: true, + screenshot: expect.any(String), + }); + } + } finally { + if (sandbox) await sandbox.stop(); + } + }, + 25 * 60 * 1000 +); diff --git a/tests/e2e/lifecycle.test.ts b/tests/e2e/lifecycle.test.ts index 105aca4..7f7b8b2 100644 --- a/tests/e2e/lifecycle.test.ts +++ b/tests/e2e/lifecycle.test.ts @@ -149,7 +149,7 @@ describe.sequential("sandbox lifecycle e2e", () => { test("create response contains runtime auth", async () => { const detail = sandbox!.toJSON(); expect(detail.token).toBeTruthy(); - expect(detail.runtime.baseUrl).toBeTruthy(); + expect(detail.runtime?.baseUrl).toBeTruthy(); expect(detail.tokenExpiresAt).toBeTruthy(); }); @@ -157,7 +157,7 @@ describe.sequential("sandbox lifecycle e2e", () => { expect(staleHandle).toBeTruthy(); const detail = staleHandle!.toJSON(); expect(detail.token).toBeTruthy(); - expect(detail.runtime.baseUrl).toBe(sandbox!.runtime.baseUrl); + expect(detail.runtime?.baseUrl).toBe(sandbox!.runtime?.baseUrl); }); test("info and refresh update the sandbox handle", async () => { @@ -199,6 +199,7 @@ describe.sequential("sandbox lifecycle e2e", () => { }; }; const validDetail = await sandbox!.info(); + if (!validDetail.runtime) throw new Error("Expected an active sandbox runtime"); const invalidJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.invalid-signature"; let refreshCount = 0; diff --git a/tests/e2e/list.test.ts b/tests/e2e/list.test.ts index bf9edd8..d2106ce 100644 --- a/tests/e2e/list.test.ts +++ b/tests/e2e/list.test.ts @@ -86,8 +86,8 @@ describe.sequential("sandbox list e2e", () => { expect(listed.id).toBe(sandbox!.id); expect(listed.status).toBe("active"); expect(listed.region).toBe(sandbox!.region); - expect(listed.runtime.transport).toBe("regional_proxy"); - expect(listed.runtime.baseUrl).toBe(sandbox!.runtime.baseUrl); + expect(listed.runtime?.transport).toBe("regional_proxy"); + expect(listed.runtime?.baseUrl).toBe(sandbox!.runtime?.baseUrl); }); test("list forwards start, end, and search filters", async () => { diff --git a/tests/fixtures/gpu_contract.json b/tests/fixtures/gpu_contract.json new file mode 100644 index 0000000..6562752 --- /dev/null +++ b/tests/fixtures/gpu_contract.json @@ -0,0 +1,182 @@ +{ + "create": { + "imageName": "gpu-image", + "gpu": "H100", + "gpuCount": 2, + "preempt": false, + "region": "us", + "vcpus": 8, + "memMiB": 32768, + "diskSizeMiB": 524288, + "enableRecording": true, + "exposedPorts": [ + { + "port": 8000, + "auth": true + } + ], + "allowInternetAccess": true, + "allowOut": [ + "example.com" + ], + "denyOut": [] + }, + "provisioning": { + "id": "gpu-sandbox-1", + "teamId": "team-1", + "status": "provisioning", + "createdAt": "2026-10-22T00:00:00Z", + "updatedAt": "2026-10-22T00:00:00Z", + "region": "us", + "sessionUrl": "https://app.test/sandboxes/gpu-sandbox-1", + "duration": 0, + "runtimeClass": "gvisor-cpu", + "gpuType": "H100", + "gpuCount": 2, + "preempt": false, + "recordingEnabled": true, + "vcpus": 8, + "memMiB": 32768, + "diskSizeMiB": 524288, + "runtime": null, + "token": null, + "tokenExpiresAt": null, + "exposedPorts": [], + "network": { + "allowInternetAccess": true, + "allowOut": [], + "denyOut": [] + }, + "creditsUsed": 0, + "capabilities": { + "commands": true, + "files": true, + "pty": true, + "gpu": true, + "snapshots": false, + "volumes": false, + "exposedPorts": true, + "networkFiltering": true, + "liveNetworkUpdates": true, + "internetAccess": true, + "writableStorage": "disk" + } + }, + "active": { + "id": "gpu-sandbox-1", + "teamId": "team-1", + "status": "active", + "createdAt": "2026-10-22T00:00:00Z", + "updatedAt": "2026-10-22T00:00:00Z", + "region": "us", + "sessionUrl": "https://app.test/sandboxes/gpu-sandbox-1", + "duration": 0, + "runtimeClass": "gvisor-cpu", + "gpuType": "H100", + "gpuCount": 2, + "preempt": false, + "recordingEnabled": true, + "vcpus": 8, + "memMiB": 32768, + "diskSizeMiB": 524288, + "runtime": { + "transport": "regional_proxy", + "host": "https://runtime.test", + "baseUrl": "https://runtime.test/sandbox/gpu-sandbox-1" + }, + "token": "runtime-token", + "tokenExpiresAt": "2099-01-01T00:00:00Z", + "exposedPorts": [], + "network": { + "allowInternetAccess": true, + "allowOut": [], + "denyOut": [] + }, + "creditsUsed": 0, + "capabilities": { + "commands": true, + "files": true, + "pty": true, + "gpu": true, + "snapshots": false, + "volumes": false, + "exposedPorts": true, + "networkFiltering": true, + "liveNetworkUpdates": true, + "internetAccess": true, + "writableStorage": "disk" + } + }, + "closed": { + "id": "gpu-sandbox-1", + "teamId": "team-1", + "status": "closed", + "createdAt": "2026-10-22T00:00:00Z", + "updatedAt": "2026-10-22T00:00:00Z", + "region": "us", + "sessionUrl": "https://app.test/sandboxes/gpu-sandbox-1", + "duration": 0, + "runtimeClass": "gvisor-cpu", + "gpuType": "H100", + "gpuCount": 2, + "preempt": false, + "recordingEnabled": true, + "vcpus": 8, + "memMiB": 32768, + "diskSizeMiB": 524288, + "runtime": null, + "token": null, + "tokenExpiresAt": null, + "exposedPorts": [], + "network": { + "allowInternetAccess": true, + "allowOut": [], + "denyOut": [] + }, + "creditsUsed": 1.25, + "capabilities": { + "commands": true, + "files": true, + "pty": true, + "gpu": true, + "snapshots": false, + "volumes": false, + "exposedPorts": true, + "networkFiltering": true, + "liveNetworkUpdates": true, + "internetAccess": true, + "writableStorage": "disk" + }, + "closeReason": "preempted" + }, + "building": { + "id": "gpu-build-1", + "imageName": "gpu-image", + "imageId": "gpu-image-1", + "status": "completed", + "enableGPUBuilds": true, + "imageReady": true, + "uploaded": true, + "gpuUploaded": false, + "gpuImageReady": false, + "metadata": { + "inputFormat": "dockerfile_context_manifest_v1", + "sourcePlatform": "linux/amd64" + } + }, + "gpuReady": { + "id": "gpu-build-1", + "imageName": "gpu-image", + "imageId": "gpu-image-1", + "status": "completed", + "enableGPUBuilds": true, + "imageReady": true, + "uploaded": true, + "gpuUploaded": true, + "gpuImageReady": true, + "metadata": { + "inputFormat": "dockerfile_context_manifest_v1", + "sourcePlatform": "linux/amd64" + } + } +} diff --git a/tests/integration/gpu-sandboxes.test.ts b/tests/integration/gpu-sandboxes.test.ts new file mode 100644 index 0000000..83234e2 --- /dev/null +++ b/tests/integration/gpu-sandboxes.test.ts @@ -0,0 +1,365 @@ +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SandboxesService } from "../../src/services/sandboxes"; +import { HyperbrowserError } from "../../src/error"; +import { ComputerAction } from "../../src/types/computer-action"; +import { SandboxDetail, CreateSandboxParams, SandboxImageBuild } from "../../src/types/sandbox"; +import { imageBuildName } from "../../src/sandbox/image-build/resolution"; +import { localHTTP } from "../helpers/local-http"; + +const contract = JSON.parse( + readFileSync(path.join(__dirname, "../fixtures/gpu_contract.json"), "utf8") +); +const detail = (name: string): SandboxDetail => contract[name] as SandboxDetail; +const params: CreateSandboxParams = { + imageName: "gpu-image", + gpu: "H100", + gpuCount: 2, + preempt: false, + region: "us", + cpu: 8, + memoryMiB: 32768, + diskMiB: 524288, + enableRecording: true, + exposedPorts: [{ port: 8000, auth: true }], + allowInternetAccess: true, + allowOut: ["example.com"], + denyOut: [], +}; +const service = () => new SandboxesService("control-key", "https://control.test", 1_000); +const spyRequest = (sdk: SandboxesService) => + vi.spyOn( + sdk as unknown as { + request: ( + path: string, + init?: { method?: string; body?: string; signal?: AbortSignal }, + params?: unknown + ) => Promise; + }, + "request" + ); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("GPU sandbox contract", () => { + test("serializes launch options and retains provisioning metadata", async () => { + const sdk = service(); + const spy = spyRequest(sdk).mockResolvedValue(detail("provisioning")); + const sandbox = await sdk.create(params); + expect(JSON.parse(spy.mock.calls[0][1]!.body!)).toEqual(contract.create); + expect(sandbox.runtime).toBeNull(); + expect(sandbox.status).toBe("provisioning"); + expect(sandbox.gpuType).toBe("H100"); + expect(sandbox.gpuCount).toBe(2); + expect(sandbox.preempt).toBe(false); + expect(sandbox.recordingEnabled).toBe(true); + expect(sandbox.capabilities).toMatchObject({ + writableStorage: "disk", + liveNetworkUpdates: true, + }); + await expect(sandbox.exec("nvidia-smi")).rejects.toMatchObject({ code: "sandbox_not_ready" }); + expect(() => sandbox.getExposedUrl(8000)).toThrow("not ready"); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test.each([ + { gpu: undefined }, + { gpuCount: 0 }, + { gpuCount: 9 }, + { gpuCount: 1.5 }, + { gpuCount: true }, + { preempt: "false" }, + { gpu: "A100" }, + { runtimeClass: "firecracker" }, + { region: "us-east" }, + { diskMiB: 12287 }, + { diskMiB: 524289 }, + { imageName: undefined, snapshotName: "snapshot" }, + { mounts: { "/data": { id: "volume" } } }, + { secrets: { TOKEN: "token" } }, + ])("rejects invalid GPU options %j before sending", async (invalid) => { + const sdk = service(); + const spy = spyRequest(sdk); + await expect(sdk.create({ ...params, ...invalid } as CreateSandboxParams)).rejects.toThrow(); + expect(spy).not.toHaveBeenCalled(); + }); + + test("keeps omitted defaults server-owned", async () => { + const sdk = service(); + const spy = spyRequest(sdk).mockResolvedValue(detail("provisioning")); + await sdk.create({ imageName: "gpu", gpu: "B300" }); + expect(JSON.parse(spy.mock.calls[0][1]!.body!)).toEqual({ imageName: "gpu", gpu: "B300" }); + }); + + test("wait retries transient reads and hydrates runtime", async () => { + const sdk = service(); + const spy = spyRequest(sdk) + .mockRejectedValueOnce( + new HyperbrowserError("temporary", { statusCode: 503, retryable: true }) + ) + .mockResolvedValueOnce(detail("active")); + const sandbox = sdk.attach(detail("provisioning")); + expect(await sandbox.waitUntilReady({ pollInterval: 0 })).toBe(sandbox); + expect(sandbox.status).toBe("active"); + expect((await sandbox.createRuntimeSession()).token).toBe("runtime-token"); + expect(spy).toHaveBeenCalledTimes(2); + expect( + spy.mock.calls.every(([p, init]) => p === "/sandbox/gpu-sandbox-1" && !init?.method) + ).toBe(true); + }); + + test.each(["closed", "timeout", "cancelled", "forbidden", "wrong_id"])( + "wait failure %s never stops or recreates", + async (failure) => { + const sdk = service(); + const spy = spyRequest(sdk); + if (failure === "forbidden") + spy.mockRejectedValue(new HyperbrowserError("forbidden", { statusCode: 403 })); + else + spy.mockResolvedValue( + failure === "wrong_id" ? { ...detail("active"), id: "other" } : detail("closed") + ); + const sandbox = sdk.attach(detail("provisioning")); + const controller = new AbortController(); + if (failure === "cancelled") controller.abort(); + const result = sandbox.waitUntilReady({ + timeout: failure === "timeout" ? 0 : 1, + pollInterval: 0, + signal: controller.signal, + }); + const codes: Record = { + closed: "sandbox_not_running", + timeout: "wait_timeout", + cancelled: "wait_cancelled", + }; + if (codes[failure]) + await expect(result).rejects.toMatchObject({ + code: codes[failure], + details: { sandboxId: sandbox.id }, + }); + else if (failure === "forbidden") + await expect(result).rejects.toMatchObject({ statusCode: 403 }); + else await expect(result).rejects.toThrow("different ID"); + expect(spy).toHaveBeenCalledTimes(["timeout", "cancelled"].includes(failure) ? 0 : 1); + if (failure === "closed") { + expect(sandbox.closeReason).toBe("preempted"); + expect(sandbox.toJSON().creditsUsed).toBe(1.25); + } + } + ); + + test.each(["deadline", "abort"])("interrupts a pending read on %s", async (reason) => { + const sdk = service(); + const spy = spyRequest(sdk).mockImplementation( + (_p, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new HyperbrowserError("aborted")), { + once: true, + }); + }) + ); + const controller = new AbortController(); + const sandbox = sdk.attach(detail("provisioning")); + const result = sandbox.waitUntilReady({ + timeout: reason === "deadline" ? 0.01 : 1, + signal: controller.signal, + }); + if (reason === "abort") controller.abort(); + await expect(result).rejects.toMatchObject({ + code: reason === "deadline" ? "wait_timeout" : "wait_cancelled", + }); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test("computer actions use runtime auth and refresh it on 401", async () => { + const actions: Array<{ auth: string | undefined; body: unknown }> = []; + const server = await localHTTP(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + expect(req.url).toBe("/sandbox/gpu-sandbox-1/computer-action"); + actions.push({ + auth: req.headers.authorization, + body: JSON.parse(Buffer.concat(chunks).toString()), + }); + res.setHeader("Content-Type", "application/json"); + res.statusCode = actions.length === 1 ? 401 : 200; + res.end( + JSON.stringify( + actions.length === 1 ? { error: "expired" } : { success: true, screenshot: "cG5n" } + ) + ); + }); + try { + const sdk = service(); + const active = { + ...detail("active"), + runtime: { + transport: "regional_proxy" as const, + host: server.url, + baseUrl: `${server.url}/sandbox/gpu-sandbox-1`, + }, + }; + spyRequest(sdk).mockResolvedValue({ ...active, token: "fresh-token" }); + const sandbox = sdk.attach(active); + expect(await sandbox.computerAction({ action: ComputerAction.SCREENSHOT })).toMatchObject({ + screenshot: "cG5n", + }); + await sandbox.computerAction({ action: ComputerAction.CLICK, x: 4, y: 5, numClicks: 2 }); + expect(actions[0]).toEqual({ auth: "Bearer runtime-token", body: { action: "screenshot" } }); + expect(actions[1]).toEqual({ auth: "Bearer fresh-token", body: { action: "screenshot" } }); + expect(actions[2].body).toMatchObject({ numClicks: 2 }); + } finally { + await server.close(); + } + }); + + test("retains GPU network patches and authenticated port grants", async () => { + const sdk = service(); + const spy = spyRequest(sdk); + spy + .mockResolvedValueOnce({ + network: { allowInternetAccess: true, allowOut: ["example.com"], denyOut: [] }, + }) + .mockResolvedValueOnce({ + port: 8000, + auth: true, + url: "https://8000.runtime.test/", + browserUrl: "https://8000.runtime.test/auth", + }); + const sandbox = sdk.attach(detail("active")); + await sandbox.updateNetwork({ allowOut: ["example.com"], denyOut: [] }); + expect(JSON.parse(spy.mock.calls[0][1]!.body!)).toEqual({ + allowOut: ["example.com"], + denyOut: [], + }); + expect(sandbox.network?.allowOut).toEqual(["example.com"]); + expect(await sandbox.expose({ port: 8000, auth: true })).toMatchObject({ + auth: true, + browserUrl: "https://8000.runtime.test/auth", + }); + }); +}); + +describe("GPU images", () => { + test("waits for GPU publication after build completion", async () => { + const sdk = service(); + const spy = spyRequest(sdk) + .mockResolvedValueOnce({ build: contract.building }) + .mockResolvedValueOnce({ build: contract.gpuReady }); + expect((await sdk.waitForImageBuild("gpu-build-1", { pollInterval: 0 })).gpuImageReady).toBe( + true + ); + expect(spy).toHaveBeenCalledTimes(2); + }); + + test("surfaces publication failure without exposing internal errors", async () => { + const sdk = service(); + spyRequest(sdk).mockResolvedValue({ + build: { ...contract.building, metadata: { ociPublicationError: "internal-error" } }, + }); + await expect(sdk.waitForImageBuild("gpu-build-1")).rejects.toMatchObject({ + code: "gpu_image_publication_failed", + }); + }); + + test("separates GPU identity and rejects unsupported inputs before Docker work", async () => { + const identity = { source: "dockerfile" as const, fingerprint: "a".repeat(64) }; + expect(imageBuildName(identity)).not.toBe( + imageBuildName({ ...identity, enableGPUBuilds: true }) + ); + expect(imageBuildName(identity)).toBe(imageBuildName({ ...identity, enableGPUBuilds: false })); + const sdk = service(); + const spy = spyRequest(sdk); + await expect( + sdk.buildImageFromDockerImage({ + imageName: "gpu", + dockerImage: "cuda", + enableGPUBuilds: true, + }) + ).rejects.toThrow("remote Dockerfile"); + await expect( + sdk.buildImageFromDockerfile({ + imageName: "gpu", + contextPath: "/missing", + remote: false, + enableGPUBuilds: true, + }) + ).rejects.toThrow("remote: true"); + await expect( + sdk.createImageBuild({ + imageName: "gpu", + inputSha256: "a".repeat(64), + inputSizeBytes: 1, + inputFormat: "docker_image_manifest_v1", + enableGPUBuilds: true, + }) + ).rejects.toThrow("remote Dockerfile"); + expect(spy).not.toHaveBeenCalled(); + }); + + test.each(["created", "joined", "reused", "cpu_only", "detached"])( + "resolves GPU builds: %s", + async (mode) => { + const context = mkdtempSync(path.join(tmpdir(), "hb-gpu-")); + writeFileSync(path.join(context, "Dockerfile"), "FROM scratch\n"); + try { + const { dockerBuildContextFingerprint } = await import( + "../../src/sandbox/image-build/context" + ); + const name = imageBuildName({ + source: "dockerfile", + fingerprint: await dockerBuildContextFingerprint(context), + enableGPUBuilds: true, + }); + const build = (ready = false): SandboxImageBuild => ({ + ...(ready ? contract.gpuReady : contract.building), + imageName: name, + }); + const sdk = service(); + let polls = 0; + const spy = spyRequest(sdk).mockImplementation(async (url, init) => { + if (url === "/images/builds" && !init?.method) { + const rows = ["reused", "cpu_only"].includes(mode) ? [build(mode === "reused")] : []; + if (mode === "cpu_only") rows[0].enableGPUBuilds = false; + return { builds: rows }; + } + if (url === "/images") + return { images: [{ id: "gpu-image-1", imageName: name, uploaded: true }] }; + if (url === "/images/builds") { + expect(JSON.parse(init!.body!).enableGPUBuilds).toBe(true); + if (mode === "joined") + throw new HyperbrowserError("in progress", { + statusCode: 409, + code: "image_build_in_progress", + details: { build: build() }, + }); + return { build: build(), uploads: [] }; + } + if (url.endsWith("/complete")) return { build: build() }; + expect(url).toBe("/images/builds/gpu-build-1"); + polls += 1; + return { build: build(polls > 1) }; + }); + const result = await sdk.getOrBuildImage({ + contextPath: context, + enableGPUBuilds: true, + pollInterval: 0, + wait: mode !== "detached", + }); + expect(result.outcome).toBe( + ({ joined: "joined", reused: "reused" } as Record)[mode] ?? "created" + ); + expect(result.imageId).toBe(mode === "detached" ? undefined : "gpu-image-1"); + expect(polls).toBe(["reused", "detached"].includes(mode) ? 0 : 2); + expect(spy.mock.calls.some(([url]) => url.endsWith("/cancel"))).toBe(false); + } finally { + rmSync(context, { recursive: true, force: true }); + } + } + ); +}); diff --git a/tests/sandbox/start-stop.ts b/tests/sandbox/start-stop.ts index e4733cd..940b3a8 100644 --- a/tests/sandbox/start-stop.ts +++ b/tests/sandbox/start-stop.ts @@ -20,7 +20,7 @@ function summarizeSandbox(detail: SandboxDetail) { id: detail.id, status: detail.status, region: detail.region, - runtimeBaseUrl: detail.runtime.baseUrl, + runtimeBaseUrl: detail.runtime?.baseUrl, tokenExpiresAt: detail.tokenExpiresAt, sessionUrl: detail.sessionUrl, }; diff --git a/tests/unit/public-types-contract.test.ts b/tests/unit/public-types-contract.test.ts index edf750e..e2bdb4d 100644 --- a/tests/unit/public-types-contract.test.ts +++ b/tests/unit/public-types-contract.test.ts @@ -184,3 +184,23 @@ describe("public type compatibility", () => { expectTypeOf<"canceled">().toExtend(); }); }); + +test("GPU launch, readiness, and runtime types match the sandbox API", () => { + type GPU = import("../../src/types").SandboxGpuType; + type Status = import("../../src/types").SandboxStatus; + type ListParams = import("../../src/types").SandboxListParams; + type Create = import("../../src/types").CreateSandboxParams; + type Target = import("../../src/types").SandboxRuntimeTarget; + type Wait = import("../../src/types").SandboxWaitOptions; + expectTypeOf<"H100" | "B300" | "A100-80GB">().toExtend(); + expectTypeOf<"A100">().not.toExtend(); + expectTypeOf<"provisioning">().toExtend(); + expectTypeOf<"provisioning">().not.toExtend(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + const launch: Create = { imageName: "cuda", gpu: "H100", gpuCount: 2, preempt: false }; + // @ts-expect-error GPUs cannot launch from memory snapshots. + const invalid: Create = { snapshotName: "saved", gpu: "H100" }; + expect(launch.preempt).toBe(false); + expect(invalid.snapshotName).toBe("saved"); +}); diff --git a/tests/unit/sandbox-contract.test.ts b/tests/unit/sandbox-contract.test.ts index ae1a51a..4bba415 100644 --- a/tests/unit/sandbox-contract.test.ts +++ b/tests/unit/sandbox-contract.test.ts @@ -117,21 +117,22 @@ describe("sandbox control and runtime contract", () => { expect(sandbox.getExposedUrl(3000)).toBe("https://3000-sbx_123.runtime.example.com/"); }); - test("create forwards the explicit CPU runtime and retains its capabilities", async () => { + test("create forwards the explicit GPU runtime and retains its capabilities", async () => { const service = new SandboxesService("test-key", "https://api.example.com", 30_000); const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue({ ...wireSandboxDetail(), runtimeClass: "gvisor-cpu", - capabilities: { pty: true, gpu: false }, + capabilities: { pty: true, gpu: true }, }); - const sandbox = await service.create({ imageName: "cpu", runtimeClass: "gvisor-cpu" }); + const sandbox = await service.create({ imageName: "gpu", runtimeClass: "gvisor-cpu", gpu: "L4" }); expect(parseJsonRequestBody(requestSpy.mock.calls[0][1])).toEqual({ - imageName: "cpu", + imageName: "gpu", + gpu: "L4", runtimeClass: "gvisor-cpu", }); expect(sandbox.toJSON()).toMatchObject({ runtimeClass: "gvisor-cpu", - capabilities: { pty: true, gpu: false }, + capabilities: { pty: true, gpu: true }, }); }); diff --git a/tests/unit/sandbox-secrets.test.ts b/tests/unit/sandbox-secrets.test.ts index 043e19d..728339b 100644 --- a/tests/unit/sandbox-secrets.test.ts +++ b/tests/unit/sandbox-secrets.test.ts @@ -111,7 +111,7 @@ describe("sandbox create with secrets", () => { service.create({ imageName: "node", secrets: { A: { name: "x", version: 2 ** 31 } } }) ).rejects.toThrow(/integer from 1 to 2147483647/); await expect( - service.create({ imageName: "node", runtimeClass: "gvisor-cpu", secrets: { A: "x" } }) + service.create({ imageName: "node", runtimeClass: "gvisor-cpu", gpu: "L4", secrets: { A: "x" } }) ).rejects.toThrow(/not supported with runtimeClass gvisor-cpu/); expect(requestSpy).not.toHaveBeenCalled(); }); @@ -493,7 +493,7 @@ describe("vaults in sandboxes", () => { /only supported for image launches/ ); await expect( - service.create({ imageName: "node", runtimeClass: "gvisor-cpu", vault: "app-env" }) + service.create({ imageName: "node", runtimeClass: "gvisor-cpu", gpu: "L4", vault: "app-env" }) ).rejects.toThrow(/not supported with runtimeClass gvisor-cpu/); expect(requestSpy).not.toHaveBeenCalled(); }); @@ -578,7 +578,7 @@ describe("1Password in sandboxes", () => { /only supported for image launches/ ); await expect( - service.create({ imageName: "node", runtimeClass: "gvisor-cpu", onePassword }) + service.create({ imageName: "node", runtimeClass: "gvisor-cpu", gpu: "L4", onePassword }) ).rejects.toThrow(/not supported with runtimeClass gvisor-cpu/); expect(requestSpy).not.toHaveBeenCalled(); });