diff --git a/Makefile b/Makefile index 2417899..224df4f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Makefile .PHONY: generate lint test test-integration check build -SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml" +SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/feat/migrate-to-ch/internal/api/openapi.yaml" SPEC_PATH = "api/openapi.yaml" generate: diff --git a/README.md b/README.md index 3370a5e..53cb53d 100644 --- a/README.md +++ b/README.md @@ -1 +1,553 @@ -# js-sdk +# Wrenn JavaScript SDK + +JavaScript and TypeScript client for the [Wrenn](https://wrenn.dev) microVM platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute Python code -- all from Node.js. + +Designed as an e2b-style SDK. If you're migrating, `Sandbox` is available as a deprecated alias for `Capsule`. + +## Installation + +```bash +npm install @wrenn/sdk +``` + +Requires Node.js 18+. + +## Authentication + +Set the `WRENN_API_KEY` environment variable: + +```bash +export WRENN_API_KEY="wrn_your_api_key_here" +``` + +Optionally override the API base URL: + +```bash +export WRENN_BASE_URL="https://app.wrenn.dev/api" # default +``` + +You can also pass credentials directly: + +```ts +import { Capsule } from "@wrenn/sdk"; + +const capsule = await Capsule.create("minimal", { + apiKey: "wrn_...", + baseUrl: "https://app.wrenn.dev/api", +}); +``` + +--- + +## Wrenn Capsules + +### Quick Start + +```ts +import { Capsule } from "@wrenn/sdk"; + +const capsule = await Capsule.create("minimal"); + +try { + await capsule.waitForReady(); + + const result = await capsule.commands.exec("echo", { args: ["hello"] }); + console.log(result.stdout); // "hello\n" +} finally { + await capsule.destroy(); +} +``` + +### Creating Capsules + +```ts +import { Capsule } from "@wrenn/sdk"; + +// Create with defaults: template="minimal", vcpus=1, memory_mb=512. +const capsule = await Capsule.create(); + +// Create with an explicit template. +const python = await Capsule.create("base-python"); + +// Create with resource and client options. +const larger = await Capsule.create("minimal", { + vcpus: 2, + memory_mb: 1024, + timeout_sec: 300, + apiKey: "wrn_...", +}); + +// Equivalent options-object form. +const fromOptions = await Capsule.create({ + template: "minimal", + vcpus: 2, + memory_mb: 1024, +}); +``` + +### Resource Cleanup + +Capsules created with `Capsule.create()` are owned by that SDK instance. When used with `await using`, the remote capsule is destroyed automatically when the block exits: + +```ts +await using capsule = await Capsule.create("minimal"); + +await capsule.waitForReady(); +await capsule.commands.exec("echo", { args: ["work"] }); +// capsule is automatically destroyed here +``` + +Capsules attached with `new Capsule(id)` or `Capsule.connect(id)` are not owned. `await using` a connected capsule only runs local cleanup and does not destroy the remote capsule. + +Use `destroy()` when you want to delete the remote capsule: + +```ts +const capsule = await Capsule.create("minimal"); + +try { + await capsule.waitForReady(); + await capsule.commands.exec("echo", { args: ["work"] }); +} finally { + await capsule.destroy(); +} +``` + +### Connecting to Existing Capsules + +Attach to an existing capsule by ID. This wraps the ID locally and does not fetch or validate it until you call an API method: + +```ts +const capsule = Capsule.connect("cl-abc123"); +const info = await capsule.getInfo(); + +if (info.status === "paused") { + await capsule.resume({ wait: true }); +} + +const result = await capsule.commands.exec("echo", { args: ["still running"] }); +console.log(result.stdout); +``` + +For code interpreter capsules: + +```ts +import { CodeInterpreter } from "@wrenn/sdk"; + +const interpreter = CodeInterpreter.connect("cl-abc123"); +const result = await interpreter.notebook.execCell("print('reconnected')"); +console.log(result.stdout); +``` + +### Lifecycle Management + +```ts +// Instance methods. +await capsule.pause(); // returns status like "pausing" +await capsule.resume(); // returns status like "resuming" +await capsule.resume({ wait: true }); +await capsule.destroy(); +await capsule.ping(); // reset inactivity timer +await capsule.waitForReady(); + +const info = await capsule.getInfo(); +console.log(info.status); // "running" + +const metrics = await capsule.getMetrics({ range: "10m" }); + +// Static helper. +await Capsule.destroy("cl-abc123", { apiKey: "wrn_..." }); +``` + +### Command Execution + +Commands are accessed via `capsule.commands`: + +```ts +// Foreground command. +const result = await capsule.commands.exec("python3", { + args: ["-c", "print(42)"], + timeoutSec: 30, + cwd: "/app", +}); + +console.log(result.stdout); // "42\n" +console.log(result.stderr); +console.log(result.exit_code); // 0 + +// Background process. +const process = await capsule.commands.start("python3", { + args: ["server.py"], + tag: "web-server", + envs: { PORT: "8000" }, + cwd: "/app", +}); + +console.log(process.pid); +console.log(process.tag); +``` + +#### Streaming Output + +```ts +// Stream a new command. +for await (const event of capsule.commands.stream("python3", { + args: ["-u", "train.py"], +})) { + if (event.type === "stdout") { + process.stdout.write(String(event.data ?? "")); + } + + if (event.type === "stderr") { + process.stderr.write(String(event.data ?? "")); + } + + if (event.type === "exit") { + console.log("exited", event.exit_code); + } +} + +// Connect to a running background process stream. +await using connection = await capsule.commands.streamProcess("web-server"); +connection.send({ type: "ping" }); +// connection is automatically closed here +``` + +#### Process Management + +```ts +const processes = await capsule.commands.list(); + +for (const proc of processes.processes ?? []) { + console.log(proc.pid, proc.tag); +} + +await capsule.commands.kill("web-server", "SIGTERM"); +``` + +### Filesystem + +Files are accessed via `capsule.files`: + +```ts +// Write and read files. +await capsule.files.write("/app/main.py", "print('hello')"); + +const content = await capsule.files.read("/app/main.py"); +console.log(content.toString()); + +// List directory. +const listing = await capsule.files.list("/app", { depth: 1 }); + +for (const entry of listing.entries ?? []) { + console.log(entry.name, entry.type, entry.size); +} + +// Create directory. +await capsule.files.mkdir("/app/data"); + +// Remove file or directory. +await capsule.files.remove("/app/old_data"); +``` + +#### Streaming Large Files + +```ts +await capsule.files.uploadStream( + "/data/large.txt", + Buffer.from("large file content"), +); + +const chunks: Buffer[] = []; + +for await (const chunk of capsule.files.downloadStream("/data/large.txt")) { + chunks.push(chunk); +} + +console.log(Buffer.concat(chunks).toString()); +``` + +### Git + +Git operations are accessed via `capsule.git`. All commands execute the real `git` binary inside the capsule: + +```ts +// Clone a repository. +await capsule.git.clone("https://github.com/org/repo.git", { + path: "/app/repo", + branch: "main", +}); + +// Use repository-scoped commands. +const status = await capsule.git.status({ cwd: "/app/repo" }); +console.log(status.stdout); + +const log = await capsule.git.log({ cwd: "/app/repo", maxCount: 5 }); +console.log(log.stdout); + +// Branches. +await capsule.git.checkout("feature", { cwd: "/app/repo", create: true }); +console.log((await capsule.git.branch({ cwd: "/app/repo" })).stdout); + +// Stage and commit. +await capsule.git.add(["README.md", "src/index.ts"], { cwd: "/app/repo" }); +await capsule.git.commit("initial commit", { cwd: "/app/repo" }); + +// Push and pull. +await capsule.git.pull({ cwd: "/app/repo", remote: "origin", branch: "main" }); +await capsule.git.push({ cwd: "/app/repo", remote: "origin", branch: "main" }); +``` + +Git helpers return command results. Check `exit_code`, `stdout`, and `stderr` for command-level failures. + +### Interactive Terminal (PTY) + +```ts +await using term = await capsule.pty.start({ + cmd: "/bin/bash", + cols: 120, + rows: 40, + cwd: "/home/user", +}); + +term.input("ls -la\n"); + +for await (const event of term.events) { + if (event.type === "data") { + process.stdout.write(String(event.data ?? "")); + } + + if (event.type === "exit") { + break; + } +} +// terminal WebSocket is automatically closed here +``` + +Reconnect to a tagged session: + +```ts +await using term = await capsule.pty.connect("my-session-tag"); +term.input("echo reconnected\n"); +// terminal WebSocket is automatically closed here +``` + +**PtySession methods:** + +| Method | Description | +|--------|-------------| +| `input(data)` | Send text or bytes to stdin | +| `resize(cols, rows)` | Resize the terminal | +| `kill()` | Send a kill control message | +| `close()` | Close the WebSocket connection | +| `events` | Async iterator of PTY events | + +--- + +## Code Interpreter + +`CodeInterpreter` creates or connects to a capsule intended for Python code execution. It uses the capsule command API to execute cells with `python3 -c`. + +### Quick Start + +```ts +import { CodeInterpreter } from "@wrenn/sdk"; + +await using interpreter = await CodeInterpreter.create(); + +await interpreter.capsule.waitForReady(); + +const result = await interpreter.notebook.execCell("print('hello')"); +console.log(result.stdout); // "hello\n" +// interpreter.capsule is automatically destroyed here +``` + +Interpreters created with `CodeInterpreter.create()` own their capsule and destroy it on `await using` disposal. Interpreters attached with `CodeInterpreter.connect(id)` follow connected capsule semantics and leave the remote capsule running. + +### Custom Templates + +By default, `CodeInterpreter.create()` uses the `jupyter` template. You can specify a custom template: + +```ts +const interpreter = await CodeInterpreter.create({ + template: "my-custom-python-template", + timeout_sec: 300, +}); + +const result = await interpreter.notebook.execCell("print('custom template')", { + timeoutSec: 60, +}); +``` + +### Code Interpreter + Commands/Files + +The code interpreter wrapper exposes the underlying standard capsule: + +```ts +const interpreter = await CodeInterpreter.create(); +const { capsule } = interpreter; + +await interpreter.notebook.execCell("open('/tmp/data.csv', 'w').write('a,b\\n1,2')"); + +const content = await capsule.files.read("/tmp/data.csv"); +console.log(content.toString()); + +const result = await capsule.commands.exec("wc", { args: ["-l", "/tmp/data.csv"] }); +console.log(result.stdout); +``` + +--- + +## Error Handling + +The SDK maps unsuccessful API responses to typed errors: + +```ts +import { + AuthenticationError, + BadRequestError, + ConflictError, + ForbiddenError, + HostHasCapsulesError, + NotFoundError, + PayloadTooLargeError, + ServerError, + TimeoutError, + WrennError, +} from "@wrenn/sdk"; + +try { + await Capsule.connect("missing").getInfo(); +} catch (error) { + if (error instanceof NotFoundError) { + console.log(error.code); + console.log(error.message); + console.log(error.statusCode); // 404 + } + + if (error instanceof WrennError) { + console.log(error.body); + } +} +``` + +All SDK errors inherit from `WrennError` and expose `statusCode`, `code`, `message`, and `body`. + +--- + +## Migrating from e2b + +Replace your imports and prefer `Capsule` for new code: + +```ts +// Before +import { Sandbox } from "e2b"; +const sandbox = await Sandbox.create(); + +// After +import { Capsule } from "@wrenn/sdk"; +const capsule = await Capsule.create(); +``` + +The `Sandbox` name is available as a deprecated alias: + +```ts +import { Sandbox } from "@wrenn/sdk"; + +const sandbox = await Sandbox.create("minimal"); +``` + +--- + +## Low-Level Client + +For direct API access, use `WrennClient`: + +```ts +import { WrennClient } from "@wrenn/sdk"; + +const client = new WrennClient({ apiKey: "wrn_..." }); + +const capsule = await client.capsules.create({ + template: "minimal", + vcpus: 1, + memory_mb: 512, + timeout_sec: 300, +}); + +await client.capsules.pause(capsule.id); +await client.capsules.resume(capsule.id); +await client.capsules.ping(capsule.id); +await client.capsules.destroy(capsule.id); + +const templates = await client.snapshots.list(); +console.log(templates); +``` + +Available resource groups: + +| Resource | Property | +|----------|----------| +| Auth | `client.auth` | +| Account | `client.account` | +| API keys | `client.apiKeys` | +| Users | `client.users` | +| Teams | `client.teams` | +| Capsules | `client.capsules` | +| Files | `client.files` | +| Snapshots | `client.snapshots` | +| Hosts | `client.hosts` | +| Channels | `client.channels` | + +Generated OpenAPI types are exported from the package root: + +```ts +import type { components, operations, paths } from "@wrenn/sdk"; + +type CapsuleSchema = components["schemas"]["Capsule"]; +type GetCapsuleOperation = operations["getCapsule"]; +type CapsulePath = paths["/v1/capsules/{id}"]; +``` + +--- + +## Development + +This project uses [Bun](https://bun.sh) for dependency management and script execution. The package build still uses `tsup`, and tests use Vitest. + +```bash +# Install dependencies +bun install + +# Run linting +make lint + +# Run unit tests +make test + +# Build CJS, ESM, and declaration files +make build + +# Run lint + unit tests +make check +``` + +### Running Integration Tests + +Integration tests require a live Wrenn server. Set credentials via environment variables: + +```bash +export WRENN_API_KEY="wrn_..." +export WRENN_BASE_URL="https://app.wrenn.dev/api" # optional +``` + +Then run: + +```bash +make test-integration +``` + +Tests are automatically skipped when `WRENN_API_KEY` is not available. + +## License + +MIT diff --git a/api/openapi.yaml b/api/openapi.yaml index 6501061..2597033 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,8 +1,8 @@ openapi: "3.1.0" info: title: Wrenn API - description: MicroVM-based code execution platform API. - version: "0.1.4" + description: AI agent execution platform API. + version: "0.2.0" servers: - url: http://localhost:8080 @@ -866,8 +866,8 @@ paths: schema: $ref: "#/components/schemas/CreateCapsuleRequest" responses: - "201": - description: Capsule created + "202": + description: Capsule creation initiated (status will be "starting") content: application/json: schema: @@ -988,8 +988,8 @@ paths: security: - apiKeyAuth: [] responses: - "204": - description: Capsule destroyed + "202": + description: Capsule destruction initiated /v1/capsules/{id}/exec: parameters: @@ -1260,8 +1260,8 @@ paths: destroys all running resources. The capsule exists only as files on disk and can be resumed later. responses: - "200": - description: Capsule paused (snapshot taken, resources released) + "202": + description: Capsule pause initiated (status will be "pausing") content: application/json: schema: @@ -1289,11 +1289,11 @@ paths: - apiKeyAuth: [] description: | Restores a paused capsule from its snapshot using UFFD for lazy - memory loading. Boots a fresh Firecracker process, sets up a new + memory loading. Boots a fresh Cloud Hypervisor process, sets up a new network slot, and waits for envd to become ready. responses: - "200": - description: Capsule resumed (new VM booted from snapshot) + "202": + description: Capsule resume initiated (status will be "resuming") content: application/json: schema: @@ -2035,6 +2035,51 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/hosts/sandbox-events: + post: + summary: Sandbox lifecycle event callback + operationId: sandboxEventCallback + tags: [hosts] + security: + - hostTokenAuth: [] + description: | + Receives autonomous lifecycle events from host agents (e.g. auto-pause + from the TTL reaper). The event is published to an internal Redis stream + for the control plane's event consumer to process. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [event, sandbox_id, host_id] + properties: + event: + type: string + enum: [sandbox.auto_paused] + sandbox_id: + type: string + host_id: + type: string + timestamp: + type: integer + format: int64 + responses: + "204": + description: Event accepted + "400": + description: Invalid request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Host ID mismatch + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/hosts/auth/refresh: post: summary: Refresh host JWT @@ -2395,6 +2440,14 @@ paths: $ref: "#/components/schemas/Error" components: + responses: + BadRequest: + description: Invalid request parameters + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + securitySchemes: apiKeyAuth: type: apiKey @@ -2592,7 +2645,7 @@ components: type: string status: type: string - enum: [pending, starting, running, paused, hibernated, stopped, missing, error] + enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error] template: type: string vcpus: @@ -3059,7 +3112,7 @@ components: mem_bytes: type: integer format: int64 - description: "Resident memory in bytes (VmRSS of Firecracker process)" + description: "Resident memory in bytes (VmRSS of Cloud Hypervisor process)" disk_bytes: type: integer format: int64 diff --git a/src/_shared/http.ts b/src/_shared/http.ts index 66d47c5..c1ecc66 100644 --- a/src/_shared/http.ts +++ b/src/_shared/http.ts @@ -225,19 +225,22 @@ export class HttpClient { ): Promise { const res = await this.rawRequest(method, path, body, opts); - if (res.status === 204) { - return undefined as T; - } - if (!res.ok) { await throwErrorFromResponse(res); } + if (res.status === 204 || res.headers.get("content-length") === "0") { + return undefined as T; + } + if (opts?.asText) { return (await res.text()) as T; } - return (await res.json()) as T; + const text = await res.text(); + if (!text) return undefined as T; + + return JSON.parse(text) as T; } private async rawRequest( diff --git a/src/_shared/websocket.ts b/src/_shared/websocket.ts index 292aea2..169f487 100644 --- a/src/_shared/websocket.ts +++ b/src/_shared/websocket.ts @@ -46,6 +46,11 @@ export class WsConnection { this.ws.close(); } + /** Closes the WebSocket when used with `await using`. */ + async [Symbol.asyncDispose](): Promise { + this.close(); + } + /** Indicates whether the connection has closed or failed. */ get isClosed(): boolean { return this.closed; diff --git a/src/capsule.ts b/src/capsule.ts index c329c6b..aedb951 100644 --- a/src/capsule.ts +++ b/src/capsule.ts @@ -29,6 +29,7 @@ const DEFAULT_WAIT_INTERVAL_MS = 1_000; const TERMINAL_STATUSES = new Set([ "error", "missing", + "stopping", "stopped", ]); @@ -93,6 +94,9 @@ function delay(ms: number, signal?: AbortSignal): Promise { /** Main user-facing handle for a Wrenn capsule. */ export class Capsule { + private disposed = false; + private ownsRemote = false; + /** Capsule identifier used for all instance operations. */ readonly id: string; /** Low-level client backing this capsule handle. */ @@ -161,7 +165,7 @@ export class Capsule { ); } - return new Capsule(capsule.id, clientConfig); + return new Capsule(capsule.id, clientConfig).markOwned(); } /** @@ -247,8 +251,10 @@ export class Capsule { * @returns Resolves when the capsule is destroyed. * @throws WrennError subclasses for unsuccessful API responses. */ - destroy(): Promise { - return this.client.capsules.destroy(this.id); + async destroy(): Promise { + if (this.disposed) return; + await this.client.capsules.destroy(this.id); + this.disposed = true; } /** @@ -299,10 +305,20 @@ export class Capsule { /** Local cleanup hook. This does not mutate or destroy the remote capsule. */ close(): void {} - /** Local async-disposal hook. This does not mutate or destroy the remote capsule. */ + /** Destroys capsules created by this SDK instance when used with `await using`. */ async [Symbol.asyncDispose](): Promise { + if (this.ownsRemote) { + await this.destroy(); + return; + } + this.close(); } + + private markOwned(): this { + this.ownsRemote = true; + return this; + } } /** @deprecated Use {@link Capsule} instead. */ diff --git a/src/client.ts b/src/client.ts index 6cdb35d..2857f1f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -589,7 +589,7 @@ export class CapsulesResource extends BaseResource { create( body: JsonBody<"createCapsule">, opts?: RequestOptions, - ): Promise> { + ): Promise> { return this.http.post("/v1/capsules", body, opts); } @@ -779,7 +779,7 @@ export class CapsulesResource extends BaseResource { pause( id: string, opts?: RequestOptions, - ): Promise> { + ): Promise> { return this.http.post( `/v1/capsules/${encodePath(id)}/pause`, undefined, @@ -798,7 +798,7 @@ export class CapsulesResource extends BaseResource { resume( id: string, opts?: RequestOptions, - ): Promise> { + ): Promise> { return this.http.post( `/v1/capsules/${encodePath(id)}/resume`, undefined, diff --git a/src/code-interpreter/index.ts b/src/code-interpreter/index.ts index 1d11394..43b74a3 100644 --- a/src/code-interpreter/index.ts +++ b/src/code-interpreter/index.ts @@ -87,4 +87,15 @@ export class CodeInterpreter { static connect(id: string, opts?: ClientConfig): CodeInterpreter { return new CodeInterpreter(Capsule.connect(id, opts)); } + + /** + * Disposes the underlying capsule according to its ownership semantics. + * + * Interpreters created with {@link CodeInterpreter.create} destroy their remote + * capsule on disposal. Interpreters attached with {@link CodeInterpreter.connect} + * leave the remote capsule running. + */ + async [Symbol.asyncDispose](): Promise { + await this.capsule[Symbol.asyncDispose](); + } } diff --git a/src/models/generated.ts b/src/models/generated.ts index 7fff265..e2a1f0d 100644 --- a/src/models/generated.ts +++ b/src/models/generated.ts @@ -718,7 +718,7 @@ export interface paths { /** * Resume a paused capsule * @description Restores a paused capsule from its snapshot using UFFD for lazy - * memory loading. Boots a fresh Firecracker process, sets up a new + * memory loading. Boots a fresh Cloud Hypervisor process, sets up a new * network slot, and waits for envd to become ready. */ post: operations["resumeCapsule"]; @@ -1146,6 +1146,28 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/hosts/sandbox-events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Sandbox lifecycle event callback + * @description Receives autonomous lifecycle events from host agents (e.g. auto-pause + * from the TTL reaper). The event is published to an internal Redis stream + * for the control plane's event consumer to process. + */ + post: operations["sandboxEventCallback"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/hosts/auth/refresh": { parameters: { query?: never; @@ -1312,6 +1334,29 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/admin/users/{id}/admin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Grant or revoke platform admin + * @description Sets the platform admin flag on a user. Cannot remove the last admin. + * Requires platform admin access (JWT + is_admin). + * The target user's JWT is not re-issued — their frontend will reflect the + * change on next login or team switch. + */ + put: operations["setUserAdmin"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1408,7 +1453,7 @@ export interface components { Capsule: { id?: string; /** @enum {string} */ - status?: "pending" | "starting" | "running" | "paused" | "hibernated" | "stopped" | "missing" | "error"; + status?: "pending" | "starting" | "running" | "pausing" | "paused" | "resuming" | "stopping" | "hibernated" | "stopped" | "missing" | "error"; template?: string; vcpus?: number; memory_mb?: number; @@ -1667,7 +1712,7 @@ export interface components { cpu_pct?: number; /** * Format: int64 - * @description Resident memory in bytes (VmRSS of Firecracker process) + * @description Resident memory in bytes (VmRSS of Cloud Hypervisor process) */ mem_bytes?: number; /** @@ -1743,7 +1788,7 @@ export interface components { }; }; responses: { - /** @description Invalid request */ + /** @description Invalid request parameters */ BadRequest: { headers: { [name: string]: unknown; @@ -2746,8 +2791,8 @@ export interface operations { }; }; responses: { - /** @description Capsule created */ - 201: { + /** @description Capsule creation initiated (status will be "starting") */ + 202: { headers: { [name: string]: unknown; }; @@ -2858,8 +2903,8 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Capsule destroyed */ - 204: { + /** @description Capsule destruction initiated */ + 202: { headers: { [name: string]: unknown; }; @@ -3117,8 +3162,8 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Capsule paused (snapshot taken, resources released) */ - 200: { + /** @description Capsule pause initiated (status will be "pausing") */ + 202: { headers: { [name: string]: unknown; }; @@ -3148,8 +3193,8 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Capsule resumed (new VM booted from snapshot) */ - 200: { + /** @description Capsule resume initiated (status will be "resuming") */ + 202: { headers: { [name: string]: unknown; }; @@ -3895,6 +3940,53 @@ export interface operations { }; }; }; + sandboxEventCallback: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + event: "sandbox.auto_paused"; + sandbox_id: string; + host_id: string; + /** Format: int64 */ + timestamp?: number; + }; + }; + }; + responses: { + /** @description Event accepted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Host ID mismatch */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; refreshHostToken: { parameters: { query?: never; @@ -4249,4 +4341,50 @@ export interface operations { }; }; }; + setUserAdmin: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description true to grant admin, false to revoke. */ + admin: boolean; + }; + }; + }; + responses: { + /** @description Admin status updated */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + /** @description Caller is not a platform admin */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; } diff --git a/src/pty.ts b/src/pty.ts index 89dd472..8eead72 100644 --- a/src/pty.ts +++ b/src/pty.ts @@ -79,6 +79,11 @@ export class PtySession { close(): void { this.connection.close(); } + + /** Closes the underlying WebSocket when used with `await using`. */ + async [Symbol.asyncDispose](): Promise { + this.close(); + } } /** Interactive terminal API bound to one capsule. */ diff --git a/tests/capsule.test.ts b/tests/capsule.test.ts index 47f993b..374ad8e 100644 --- a/tests/capsule.test.ts +++ b/tests/capsule.test.ts @@ -109,8 +109,8 @@ describe("Capsule", () => { capsuleResponse("cap_1"), Response.json({ sandbox_id: "cap_1", range: "10m", points: [] }), new Response(null, { status: 204 }), - capsuleResponse("cap_1", "paused"), - capsuleResponse("cap_1", "running"), + capsuleResponse("cap_1", "pausing"), + capsuleResponse("cap_1", "resuming"), new Response(null, { status: 204 }), ]); const capsule = new Capsule("cap_1", { @@ -122,9 +122,9 @@ describe("Capsule", () => { range: "10m", }); await expect(capsule.ping()).resolves.toBeUndefined(); - await expect(capsule.pause()).resolves.toMatchObject({ status: "paused" }); + await expect(capsule.pause()).resolves.toMatchObject({ status: "pausing" }); await expect(capsule.resume()).resolves.toMatchObject({ - status: "running", + status: "resuming", }); await expect(capsule.destroy()).resolves.toBeUndefined(); @@ -157,14 +157,19 @@ describe("Capsule", () => { expect(calls).toHaveLength(3); }); - it("fails waitForReady on terminal capsule states", async () => { - setupFetch([capsuleResponse("cap_1", "error")]); + it.each([ + "error", + "missing", + "stopping", + "stopped", + ])("fails waitForReady on terminal capsule state %s", async (status) => { + setupFetch([capsuleResponse("cap_1", status)]); const capsule = new Capsule("cap_1", { baseUrl: "https://api.example.com", }); await expect(capsule.waitForReady()).rejects.toThrow( - 'Capsule cap_1 reached terminal status "error"', + `Capsule cap_1 reached terminal status "${status}"`, ); }); @@ -188,10 +193,64 @@ describe("Capsule", () => { await assertion; }); - it("does not mutate the remote capsule when closed or disposed", async () => { + it("resumes and waits until the capsule is running", async () => { + vi.useFakeTimers(); + const { calls } = setupFetch([ + capsuleResponse("cap_1", "resuming"), + capsuleResponse("cap_1", "running"), + ]); + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + + const ready = capsule.resume({ + wait: true, + intervalMs: 100, + timeoutMs: 1_000, + }); + await vi.advanceTimersByTimeAsync(100); + + await expect(ready).resolves.toMatchObject({ status: "running" }); + expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([ + "POST https://api.example.com/v1/capsules/cap_1/resume", + "GET https://api.example.com/v1/capsules/cap_1", + ]); + }); + + it("aborts waitForReady when the signal is already aborted", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const controller = new AbortController(); + controller.abort(); + + await expect( + capsule.waitForReady({ signal: controller.signal }), + ).rejects.toThrow("Operation aborted"); + }); + + it("aborts waitForReady when the signal fires during polling", async () => { + vi.useFakeTimers(); + setupFetch([capsuleResponse("cap_1", "starting")]); + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const controller = new AbortController(); + + const ready = capsule.waitForReady({ + intervalMs: 100, + timeoutMs: 5_000, + signal: controller.signal, + }); + controller.abort(); + + await expect(ready).rejects.toThrow("Operation aborted"); + }); + + it("does not mutate the remote capsule when connected capsules are closed or disposed", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - const capsule = new Capsule("cap_1", { + const capsule = Capsule.connect("cap_1", { baseUrl: "https://api.example.com", }); @@ -201,6 +260,43 @@ describe("Capsule", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("destroys created capsules when async disposed", async () => { + const { calls } = setupFetch([ + capsuleResponse("cap_created"), + new Response(null, { status: 204 }), + ]); + + const capsule = await Capsule.create({ + baseUrl: "https://api.example.com", + }); + + await capsule[Symbol.asyncDispose](); + + expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([ + "POST https://api.example.com/v1/capsules", + "DELETE https://api.example.com/v1/capsules/cap_created", + ]); + }); + + it("does not destroy an already destroyed owned capsule twice", async () => { + const { calls } = setupFetch([ + capsuleResponse("cap_created"), + new Response(null, { status: 204 }), + ]); + + const capsule = await Capsule.create({ + baseUrl: "https://api.example.com", + }); + + await capsule.destroy(); + await capsule[Symbol.asyncDispose](); + + expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([ + "POST https://api.example.com/v1/capsules", + "DELETE https://api.example.com/v1/capsules/cap_created", + ]); + }); + it("exports Sandbox as a deprecated Capsule alias", () => { expect(Sandbox).toBe(Capsule); }); diff --git a/tests/code-interpreter.test.ts b/tests/code-interpreter.test.ts index 2ae0a47..46cd626 100644 --- a/tests/code-interpreter.test.ts +++ b/tests/code-interpreter.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Capsule } from "../src/capsule.js"; import { CodeInterpreter } from "../src/code-interpreter/index.js"; describe("CodeInterpreter", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("creates a capsule with the jupyter template by default", async () => { const create = vi.spyOn(Capsule, "create").mockResolvedValue( new Capsule("cap_1", { @@ -19,7 +23,6 @@ describe("CodeInterpreter", () => { expect(create).toHaveBeenCalledWith("jupyter", { baseUrl: "https://api.example.com", }); - create.mockRestore(); }); it("connects to an existing capsule", () => { @@ -53,4 +56,34 @@ describe("CodeInterpreter", () => { timeoutSec: 30, }); }); + + it("returns stderr and non-zero exit code on failure", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + vi.spyOn(capsule.commands, "exec").mockResolvedValue({ + exit_code: 1, + stderr: "SyntaxError: invalid syntax\n", + stdout: "", + }); + const interpreter = new CodeInterpreter(capsule); + + await expect(interpreter.notebook.execCell("invalid(")).resolves.toEqual({ + exitCode: 1, + stderr: "SyntaxError: invalid syntax\n", + stdout: "", + }); + }); + + it("disposes the wrapped capsule", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const dispose = vi.spyOn(capsule, Symbol.asyncDispose).mockResolvedValue(); + const interpreter = new CodeInterpreter(capsule); + + await interpreter[Symbol.asyncDispose](); + + expect(dispose).toHaveBeenCalledOnce(); + }); }); diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 628b4b0..5ce2ad6 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -71,6 +71,51 @@ describe("CommandManager", () => { }); }); + it("connects to an existing background process stream", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const mockConnection = { + close: vi.fn(), + get isClosed() { + return false; + }, + send: vi.fn(), + }; + const connectProcess = vi + .spyOn(capsule.client.capsules, "connectProcess") + .mockResolvedValue(mockConnection as never); + + const result = await capsule.commands.streamProcess("123"); + + expect(connectProcess).toHaveBeenCalledWith("cap_1", "123", { + onMessage: expect.any(Function), + }); + expect(result).toBe(mockConnection); + }); + + it("passes timeout to streamProcess", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const connectProcess = vi + .spyOn(capsule.client.capsules, "connectProcess") + .mockResolvedValue({ + close: vi.fn(), + get isClosed() { + return false; + }, + send: vi.fn(), + } as never); + + await capsule.commands.streamProcess("worker", { timeoutMs: 5000 }); + + expect(connectProcess).toHaveBeenCalledWith("cap_1", "worker", { + onMessage: expect.any(Function), + timeoutMs: 5000, + }); + }); + it("streams command events over the exec WebSocket", async () => { const capsule = new Capsule("cap_1", { baseUrl: "https://api.example.com", diff --git a/tests/foundation.test.ts b/tests/foundation.test.ts index cfec76a..f79a638 100644 --- a/tests/foundation.test.ts +++ b/tests/foundation.test.ts @@ -3,6 +3,7 @@ import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; +import { AsyncQueue } from "../src/_shared/async-queue.js"; import { HttpClient } from "../src/_shared/http.js"; import { WsConnection } from "../src/_shared/websocket.js"; import { resolveConfig } from "../src/config.js"; @@ -233,6 +234,57 @@ describe("HttpClient", () => { await assertion; }); + + it("downloads binary response bodies as ReadableStream", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(body, { status: 200 })), + ); + + const client = new HttpClient({ baseUrl: "https://api.example.com" }); + const stream = await client.download("/v1/file", { path: "/a.txt" }); + + expect(stream).toBeInstanceOf(ReadableStream); + const reader = stream.getReader(); + const { value } = await reader.read(); + expect(value).toEqual(new Uint8Array([1, 2, 3])); + }); + + it("handles content-length zero as empty response", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(null, { + status: 200, + headers: { "content-length": "0" }, + }), + ), + ); + + const client = new HttpClient({ baseUrl: "https://api.example.com" }); + const result = await client.get("/v1/empty"); + + expect(result).toBeUndefined(); + }); + + it("handles empty text body as undefined", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("", { status: 200 })), + ); + + const client = new HttpClient({ baseUrl: "https://api.example.com" }); + const result = await client.get("/v1/blank"); + + expect(result).toBeUndefined(); + }); }); describe("WsConnection", () => { @@ -263,7 +315,8 @@ describe("WsConnection", () => { await expect(receivedByServer).resolves.toEqual({ type: "start" }); expect(messages).toEqual([{ type: "ready" }]); - connection.close(); + await connection[Symbol.asyncDispose](); + expect(connection.isClosed).toBe(true); server.close(); }); @@ -285,3 +338,37 @@ describe("WsConnection", () => { vi.useRealTimers(); }); }); + +describe("AsyncQueue", () => { + it("rejects waiting consumers when failed", async () => { + const queue = new AsyncQueue(); + const pending = queue.next(); + + queue.fail(new Error("socket died")); + + await expect(pending).rejects.toThrow("socket died"); + await expect(queue.next()).rejects.toThrow("socket died"); + }); + + it("ends the queue from return and resolves as done", async () => { + const queue = new AsyncQueue(); + + const result = await queue.return(); + + expect(result).toEqual({ done: true, value: undefined }); + await expect(queue.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + }); + + it("propagates error through throw and rejects future reads", async () => { + const queue = new AsyncQueue(); + const error = new Error("consumer threw"); + + const result = queue.throw(error); + + await expect(result).rejects.toThrow("consumer threw"); + await expect(queue.next()).rejects.toThrow("consumer threw"); + }); +}); diff --git a/tests/git.test.ts b/tests/git.test.ts index 239969c..d9b9046 100644 --- a/tests/git.test.ts +++ b/tests/git.test.ts @@ -71,4 +71,36 @@ describe("Git", () => { "At least one file is required", ); }); + + it("checks out an existing branch without creating it", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({ + exit_code: 0, + stdout: "", + }); + + await capsule.git.checkout("main"); + + expect(exec).toHaveBeenCalledWith("git", { + args: ["checkout", "main"], + }); + }); + + it("normalizes a single string file argument in add", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({ + exit_code: 0, + stdout: "", + }); + + await capsule.git.add("src/a.ts"); + + expect(exec).toHaveBeenCalledWith("git", { + args: ["add", "src/a.ts"], + }); + }); }); diff --git a/tests/integration/capsule.integration.test.ts b/tests/integration/capsule.integration.test.ts index 206f4f1..6e39a85 100644 --- a/tests/integration/capsule.integration.test.ts +++ b/tests/integration/capsule.integration.test.ts @@ -90,4 +90,36 @@ describeWithApiKey("Capsule live integration", () => { }, waitTimeoutMs * 2 + 30_000, ); + + it( + "destroys an owned live capsule when async disposed", + async () => { + let capsuleId: string | undefined; + + try { + const capsule = await Capsule.create(template, { + ...clientOpts, + timeout_sec: 60, + }); + capsuleId = capsule.id; + + await capsule.waitForReady({ + intervalMs: 2_000, + timeoutMs: waitTimeoutMs, + }); + + await capsule[Symbol.asyncDispose](); + + await expect( + Capsule.connect(capsuleId, clientOpts).getInfo(), + ).resolves.toMatchObject({ id: capsuleId, status: "stopped" }); + capsuleId = undefined; + } finally { + if (capsuleId) { + await Capsule.destroy(capsuleId, clientOpts).catch(() => undefined); + } + } + }, + waitTimeoutMs + 30_000, + ); }); diff --git a/tests/integration/higher-level-abstractions.integration.test.ts b/tests/integration/higher-level-abstractions.integration.test.ts index ea6dbe6..8572c9d 100644 --- a/tests/integration/higher-level-abstractions.integration.test.ts +++ b/tests/integration/higher-level-abstractions.integration.test.ts @@ -200,4 +200,33 @@ describeWithApiKey("Higher-level abstractions live integration", () => { }, testTimeoutMs, ); + + it( + "destroys an owned code interpreter capsule when async disposed", + async () => { + let capsuleId: string | undefined; + + try { + const interpreter = await createCodeInterpreterWithRetry(); + capsuleId = interpreter.capsule.id; + + await interpreter.capsule.waitForReady({ + intervalMs: 2_000, + timeoutMs: waitTimeoutMs, + }); + + await interpreter[Symbol.asyncDispose](); + + await expect( + Capsule.connect(capsuleId, clientOpts).getInfo(), + ).resolves.toMatchObject({ id: capsuleId, status: "stopped" }); + capsuleId = undefined; + } finally { + if (capsuleId) { + await Capsule.destroy(capsuleId, clientOpts).catch(() => undefined); + } + } + }, + testTimeoutMs, + ); }); diff --git a/tests/pty.test.ts b/tests/pty.test.ts index cd029a0..799133d 100644 --- a/tests/pty.test.ts +++ b/tests/pty.test.ts @@ -9,11 +9,12 @@ describe("PtyManager", () => { }); const sent: unknown[] = []; let onMessage: ((message: unknown) => void) | undefined; + const close = vi.fn(); vi.spyOn(capsule.client.capsules, "ptySession").mockImplementation( async (_id, opts) => { onMessage = opts.onMessage; return { - close: vi.fn(), + close, get isClosed() { return false; }, @@ -49,6 +50,9 @@ describe("PtyManager", () => { type: "output", }, }); + + await session[Symbol.asyncDispose](); + expect(close).toHaveBeenCalledOnce(); }); it("connects to an existing PTY tag", async () => { @@ -68,4 +72,60 @@ describe("PtyManager", () => { expect(sent).toEqual([{ tag: "pty-tag", type: "connect" }]); }); + + it("closes the connection when close() is called directly", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const close = vi.fn(); + vi.spyOn(capsule.client.capsules, "ptySession").mockResolvedValue({ + close, + get isClosed() { + return false; + }, + send: vi.fn(), + } as never); + + const session = await capsule.pty.start(); + session.close(); + + expect(close).toHaveBeenCalledOnce(); + }); + + it("sends all PtyStartOptions fields in the start message", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const sent: unknown[] = []; + vi.spyOn(capsule.client.capsules, "ptySession").mockResolvedValue({ + close: vi.fn(), + get isClosed() { + return false; + }, + send: (message: unknown) => sent.push(message), + } as never); + + await capsule.pty.start({ + cmd: "/bin/bash", + args: ["--login"], + cols: 120, + rows: 40, + envs: { TERM: "xterm-256color" }, + cwd: "/home/user", + user: "user", + }); + + expect(sent).toEqual([ + { + type: "start", + cmd: "/bin/bash", + args: ["--login"], + cols: 120, + rows: 40, + envs: { TERM: "xterm-256color" }, + cwd: "/home/user", + user: "user", + }, + ]); + }); });