feat: align SDK with updated OpenAPI spec and add missing unit tests

- Update generated types from new openapi.yaml (capsule stats, usage,
  metrics, pause/resume lifecycle, host/channel management, auth flow)
- Add Capsule pause/resume/ping/getMetrics lifecycle methods
- Add Capsule.waitForReady abort signal support
- Add PtyManager.connect and PtySession disposal
- Fix HttpClient empty-body response handling (content-length: 0)
- Add streamProcess() to CommandManager for background process streams
- Add integration tests for capsule lifecycle, git, and PTY features
- Add unit tests for AsyncQueue error paths, PtySession.close,
  Git.checkout without create, Git.add single string,
  Notebook.execCell error case, and PtyStartOptions fields
This commit is contained in:
Tasnim Kabir Sadik
2026-05-16 19:14:55 +06:00
parent a69118aa2d
commit 349b230913
18 changed files with 1249 additions and 52 deletions

View File

@ -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:

554
README.md
View File

@ -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

View File

@ -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

View File

@ -225,19 +225,22 @@ export class HttpClient {
): Promise<T> {
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(

View File

@ -46,6 +46,11 @@ export class WsConnection {
this.ws.close();
}
/** Closes the WebSocket when used with `await using`. */
async [Symbol.asyncDispose](): Promise<void> {
this.close();
}
/** Indicates whether the connection has closed or failed. */
get isClosed(): boolean {
return this.closed;

View File

@ -29,6 +29,7 @@ const DEFAULT_WAIT_INTERVAL_MS = 1_000;
const TERMINAL_STATUSES = new Set<CapsuleStatus>([
"error",
"missing",
"stopping",
"stopped",
]);
@ -93,6 +94,9 @@ function delay(ms: number, signal?: AbortSignal): Promise<void> {
/** 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<void> {
return this.client.capsules.destroy(this.id);
async destroy(): Promise<void> {
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<void> {
if (this.ownsRemote) {
await this.destroy();
return;
}
this.close();
}
private markOwned(): this {
this.ownsRemote = true;
return this;
}
}
/** @deprecated Use {@link Capsule} instead. */

View File

@ -589,7 +589,7 @@ export class CapsulesResource extends BaseResource {
create(
body: JsonBody<"createCapsule">,
opts?: RequestOptions,
): Promise<JsonResponse<"createCapsule", 201>> {
): Promise<JsonResponse<"createCapsule", 202>> {
return this.http.post("/v1/capsules", body, opts);
}
@ -779,7 +779,7 @@ export class CapsulesResource extends BaseResource {
pause(
id: string,
opts?: RequestOptions,
): Promise<JsonResponse<"pauseCapsule", 200>> {
): Promise<JsonResponse<"pauseCapsule", 202>> {
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<JsonResponse<"resumeCapsule", 200>> {
): Promise<JsonResponse<"resumeCapsule", 202>> {
return this.http.post(
`/v1/capsules/${encodePath(id)}/resume`,
undefined,

View File

@ -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<void> {
await this.capsule[Symbol.asyncDispose]();
}
}

View File

@ -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<string, never>;
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"];
};
};
};
};
}

View File

@ -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<void> {
this.close();
}
}
/** Interactive terminal API bound to one capsule. */

View File

@ -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);
});

View File

@ -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();
});
});

View File

@ -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",

View File

@ -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<string>();
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<string>();
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<string>();
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");
});
});

View File

@ -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"],
});
});
});

View File

@ -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,
);
});

View File

@ -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,
);
});

View File

@ -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",
},
]);
});
});