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 # Makefile
.PHONY: generate lint test test-integration check build .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" SPEC_PATH = "api/openapi.yaml"
generate: 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" openapi: "3.1.0"
info: info:
title: Wrenn API title: Wrenn API
description: MicroVM-based code execution platform API. description: AI agent execution platform API.
version: "0.1.4" version: "0.2.0"
servers: servers:
- url: http://localhost:8080 - url: http://localhost:8080
@ -866,8 +866,8 @@ paths:
schema: schema:
$ref: "#/components/schemas/CreateCapsuleRequest" $ref: "#/components/schemas/CreateCapsuleRequest"
responses: responses:
"201": "202":
description: Capsule created description: Capsule creation initiated (status will be "starting")
content: content:
application/json: application/json:
schema: schema:
@ -988,8 +988,8 @@ paths:
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
responses: responses:
"204": "202":
description: Capsule destroyed description: Capsule destruction initiated
/v1/capsules/{id}/exec: /v1/capsules/{id}/exec:
parameters: parameters:
@ -1260,8 +1260,8 @@ paths:
destroys all running resources. The capsule exists only as files on destroys all running resources. The capsule exists only as files on
disk and can be resumed later. disk and can be resumed later.
responses: responses:
"200": "202":
description: Capsule paused (snapshot taken, resources released) description: Capsule pause initiated (status will be "pausing")
content: content:
application/json: application/json:
schema: schema:
@ -1289,11 +1289,11 @@ paths:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
Restores a paused capsule from its snapshot using UFFD for lazy 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. network slot, and waits for envd to become ready.
responses: responses:
"200": "202":
description: Capsule resumed (new VM booted from snapshot) description: Capsule resume initiated (status will be "resuming")
content: content:
application/json: application/json:
schema: schema:
@ -2035,6 +2035,51 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $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: /v1/hosts/auth/refresh:
post: post:
summary: Refresh host JWT summary: Refresh host JWT
@ -2395,6 +2440,14 @@ paths:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
components: components:
responses:
BadRequest:
description: Invalid request parameters
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
securitySchemes: securitySchemes:
apiKeyAuth: apiKeyAuth:
type: apiKey type: apiKey
@ -2592,7 +2645,7 @@ components:
type: string type: string
status: status:
type: string type: string
enum: [pending, starting, running, paused, hibernated, stopped, missing, error] enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error]
template: template:
type: string type: string
vcpus: vcpus:
@ -3059,7 +3112,7 @@ components:
mem_bytes: mem_bytes:
type: integer type: integer
format: int64 format: int64
description: "Resident memory in bytes (VmRSS of Firecracker process)" description: "Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
disk_bytes: disk_bytes:
type: integer type: integer
format: int64 format: int64

View File

@ -225,19 +225,22 @@ export class HttpClient {
): Promise<T> { ): Promise<T> {
const res = await this.rawRequest(method, path, body, opts); const res = await this.rawRequest(method, path, body, opts);
if (res.status === 204) {
return undefined as T;
}
if (!res.ok) { if (!res.ok) {
await throwErrorFromResponse(res); await throwErrorFromResponse(res);
} }
if (res.status === 204 || res.headers.get("content-length") === "0") {
return undefined as T;
}
if (opts?.asText) { if (opts?.asText) {
return (await res.text()) as T; 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( private async rawRequest(

View File

@ -46,6 +46,11 @@ export class WsConnection {
this.ws.close(); 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. */ /** Indicates whether the connection has closed or failed. */
get isClosed(): boolean { get isClosed(): boolean {
return this.closed; return this.closed;

View File

@ -29,6 +29,7 @@ const DEFAULT_WAIT_INTERVAL_MS = 1_000;
const TERMINAL_STATUSES = new Set<CapsuleStatus>([ const TERMINAL_STATUSES = new Set<CapsuleStatus>([
"error", "error",
"missing", "missing",
"stopping",
"stopped", "stopped",
]); ]);
@ -93,6 +94,9 @@ function delay(ms: number, signal?: AbortSignal): Promise<void> {
/** Main user-facing handle for a Wrenn capsule. */ /** Main user-facing handle for a Wrenn capsule. */
export class Capsule { export class Capsule {
private disposed = false;
private ownsRemote = false;
/** Capsule identifier used for all instance operations. */ /** Capsule identifier used for all instance operations. */
readonly id: string; readonly id: string;
/** Low-level client backing this capsule handle. */ /** 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. * @returns Resolves when the capsule is destroyed.
* @throws WrennError subclasses for unsuccessful API responses. * @throws WrennError subclasses for unsuccessful API responses.
*/ */
destroy(): Promise<void> { async destroy(): Promise<void> {
return this.client.capsules.destroy(this.id); 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. */ /** Local cleanup hook. This does not mutate or destroy the remote capsule. */
close(): void {} 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> { async [Symbol.asyncDispose](): Promise<void> {
if (this.ownsRemote) {
await this.destroy();
return;
}
this.close(); this.close();
} }
private markOwned(): this {
this.ownsRemote = true;
return this;
}
} }
/** @deprecated Use {@link Capsule} instead. */ /** @deprecated Use {@link Capsule} instead. */

View File

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

View File

@ -87,4 +87,15 @@ export class CodeInterpreter {
static connect(id: string, opts?: ClientConfig): CodeInterpreter { static connect(id: string, opts?: ClientConfig): CodeInterpreter {
return new CodeInterpreter(Capsule.connect(id, opts)); 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 * Resume a paused capsule
* @description Restores a paused capsule from its snapshot using UFFD for lazy * @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. * network slot, and waits for envd to become ready.
*/ */
post: operations["resumeCapsule"]; post: operations["resumeCapsule"];
@ -1146,6 +1146,28 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/v1/hosts/auth/refresh": {
parameters: { parameters: {
query?: never; query?: never;
@ -1312,6 +1334,29 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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 type webhooks = Record<string, never>;
export interface components { export interface components {
@ -1408,7 +1453,7 @@ export interface components {
Capsule: { Capsule: {
id?: string; id?: string;
/** @enum {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; template?: string;
vcpus?: number; vcpus?: number;
memory_mb?: number; memory_mb?: number;
@ -1667,7 +1712,7 @@ export interface components {
cpu_pct?: number; cpu_pct?: number;
/** /**
* Format: int64 * Format: int64
* @description Resident memory in bytes (VmRSS of Firecracker process) * @description Resident memory in bytes (VmRSS of Cloud Hypervisor process)
*/ */
mem_bytes?: number; mem_bytes?: number;
/** /**
@ -1743,7 +1788,7 @@ export interface components {
}; };
}; };
responses: { responses: {
/** @description Invalid request */ /** @description Invalid request parameters */
BadRequest: { BadRequest: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@ -2746,8 +2791,8 @@ export interface operations {
}; };
}; };
responses: { responses: {
/** @description Capsule created */ /** @description Capsule creation initiated (status will be "starting") */
201: { 202: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
@ -2858,8 +2903,8 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
/** @description Capsule destroyed */ /** @description Capsule destruction initiated */
204: { 202: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
@ -3117,8 +3162,8 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
/** @description Capsule paused (snapshot taken, resources released) */ /** @description Capsule pause initiated (status will be "pausing") */
200: { 202: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
@ -3148,8 +3193,8 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
/** @description Capsule resumed (new VM booted from snapshot) */ /** @description Capsule resume initiated (status will be "resuming") */
200: { 202: {
headers: { headers: {
[name: string]: unknown; [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: { refreshHostToken: {
parameters: { parameters: {
query?: never; 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 { close(): void {
this.connection.close(); 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. */ /** Interactive terminal API bound to one capsule. */

View File

@ -109,8 +109,8 @@ describe("Capsule", () => {
capsuleResponse("cap_1"), capsuleResponse("cap_1"),
Response.json({ sandbox_id: "cap_1", range: "10m", points: [] }), Response.json({ sandbox_id: "cap_1", range: "10m", points: [] }),
new Response(null, { status: 204 }), new Response(null, { status: 204 }),
capsuleResponse("cap_1", "paused"), capsuleResponse("cap_1", "pausing"),
capsuleResponse("cap_1", "running"), capsuleResponse("cap_1", "resuming"),
new Response(null, { status: 204 }), new Response(null, { status: 204 }),
]); ]);
const capsule = new Capsule("cap_1", { const capsule = new Capsule("cap_1", {
@ -122,9 +122,9 @@ describe("Capsule", () => {
range: "10m", range: "10m",
}); });
await expect(capsule.ping()).resolves.toBeUndefined(); 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({ await expect(capsule.resume()).resolves.toMatchObject({
status: "running", status: "resuming",
}); });
await expect(capsule.destroy()).resolves.toBeUndefined(); await expect(capsule.destroy()).resolves.toBeUndefined();
@ -157,14 +157,19 @@ describe("Capsule", () => {
expect(calls).toHaveLength(3); expect(calls).toHaveLength(3);
}); });
it("fails waitForReady on terminal capsule states", async () => { it.each([
setupFetch([capsuleResponse("cap_1", "error")]); "error",
"missing",
"stopping",
"stopped",
])("fails waitForReady on terminal capsule state %s", async (status) => {
setupFetch([capsuleResponse("cap_1", status)]);
const capsule = new Capsule("cap_1", { const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com", baseUrl: "https://api.example.com",
}); });
await expect(capsule.waitForReady()).rejects.toThrow( 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; 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(); const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const capsule = new Capsule("cap_1", { const capsule = Capsule.connect("cap_1", {
baseUrl: "https://api.example.com", baseUrl: "https://api.example.com",
}); });
@ -201,6 +260,43 @@ describe("Capsule", () => {
expect(fetchMock).not.toHaveBeenCalled(); 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", () => { it("exports Sandbox as a deprecated Capsule alias", () => {
expect(Sandbox).toBe(Capsule); 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 { Capsule } from "../src/capsule.js";
import { CodeInterpreter } from "../src/code-interpreter/index.js"; import { CodeInterpreter } from "../src/code-interpreter/index.js";
describe("CodeInterpreter", () => { describe("CodeInterpreter", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("creates a capsule with the jupyter template by default", async () => { it("creates a capsule with the jupyter template by default", async () => {
const create = vi.spyOn(Capsule, "create").mockResolvedValue( const create = vi.spyOn(Capsule, "create").mockResolvedValue(
new Capsule("cap_1", { new Capsule("cap_1", {
@ -19,7 +23,6 @@ describe("CodeInterpreter", () => {
expect(create).toHaveBeenCalledWith("jupyter", { expect(create).toHaveBeenCalledWith("jupyter", {
baseUrl: "https://api.example.com", baseUrl: "https://api.example.com",
}); });
create.mockRestore();
}); });
it("connects to an existing capsule", () => { it("connects to an existing capsule", () => {
@ -53,4 +56,34 @@ describe("CodeInterpreter", () => {
timeoutSec: 30, 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 () => { it("streams command events over the exec WebSocket", async () => {
const capsule = new Capsule("cap_1", { const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com", 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 { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { AsyncQueue } from "../src/_shared/async-queue.js";
import { HttpClient } from "../src/_shared/http.js"; import { HttpClient } from "../src/_shared/http.js";
import { WsConnection } from "../src/_shared/websocket.js"; import { WsConnection } from "../src/_shared/websocket.js";
import { resolveConfig } from "../src/config.js"; import { resolveConfig } from "../src/config.js";
@ -233,6 +234,57 @@ describe("HttpClient", () => {
await assertion; 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", () => { describe("WsConnection", () => {
@ -263,7 +315,8 @@ describe("WsConnection", () => {
await expect(receivedByServer).resolves.toEqual({ type: "start" }); await expect(receivedByServer).resolves.toEqual({ type: "start" });
expect(messages).toEqual([{ type: "ready" }]); expect(messages).toEqual([{ type: "ready" }]);
connection.close(); await connection[Symbol.asyncDispose]();
expect(connection.isClosed).toBe(true);
server.close(); server.close();
}); });
@ -285,3 +338,37 @@ describe("WsConnection", () => {
vi.useRealTimers(); 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", "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, 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, 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[] = []; const sent: unknown[] = [];
let onMessage: ((message: unknown) => void) | undefined; let onMessage: ((message: unknown) => void) | undefined;
const close = vi.fn();
vi.spyOn(capsule.client.capsules, "ptySession").mockImplementation( vi.spyOn(capsule.client.capsules, "ptySession").mockImplementation(
async (_id, opts) => { async (_id, opts) => {
onMessage = opts.onMessage; onMessage = opts.onMessage;
return { return {
close: vi.fn(), close,
get isClosed() { get isClosed() {
return false; return false;
}, },
@ -49,6 +50,9 @@ describe("PtyManager", () => {
type: "output", type: "output",
}, },
}); });
await session[Symbol.asyncDispose]();
expect(close).toHaveBeenCalledOnce();
}); });
it("connects to an existing PTY tag", async () => { it("connects to an existing PTY tag", async () => {
@ -68,4 +72,60 @@ describe("PtyManager", () => {
expect(sent).toEqual([{ tag: "pty-tag", type: "connect" }]); 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",
},
]);
});
}); });