From 7eebcd3066c4521dc9b262bfafddc8883e6c24aa Mon Sep 17 00:00:00 2001 From: Aniket Maurya Date: Thu, 16 Apr 2026 00:50:00 +0100 Subject: [PATCH 1/2] feat(js): add Computers client with interactive terminal support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the JS SDK to feature-parity with the Python SDK for the Computers service: all 7 HTTP methods plus an interactive WebSocket terminal. Bumps @celestoai/sdk to 0.2.0. What's new - js/src/computers/client.ts — ComputersClient with create, list, get, exec, stop, start, delete. Public API is camelCase (cpus, memory, ramMb, exitCode); wire DTOs map to snake_case (vcpus, ram_mb, exit_code) to match the Python SDK and backend. - js/src/computers/terminal.ts — openTerminalConnection() and a Terminal class extending EventEmitter with typed on/once overloads. Emits "data" (string | Buffer), "close" (code, reason), "error". write(), resize(cols, rows), close() round out the control surface. - ComputersClient.openTerminal(nameOrId) resolves name → canonical ID via GET before the WebSocket handshake (CLAUDE.md rule: the WS endpoint does not resolve names). Sends Authorization header on connect AND the legacy first-message {"token": ...} JSON for backend compat, matching src/celesto/computer.py. - CelestoClient now composes both gatekeeper and computers. Tests - js/tests/computers.test.ts — 7 unit tests mocking fetch via ClientConfig.fetch. Covers payload shape, defaults, wire-format mapping, error surfacing (CelestoApiError on 404). - js/tests/terminal.test.ts — 7 unit tests using a real in-process WebSocketServer on 127.0.0.1:0. Covers handshake (URL, auth header), first-message token, text + binary data, write/resize, close code/reason propagation, post-close write guard, missing token rejection. - Runner: node:test via tsx (new devDep). Zero framework ceremony. - .github/workflows/js-test.yml now runs `npm test` after build. Dependencies - Runtime: ws ^8.18.0 (first runtime dep — only for the terminal) - Dev: @types/node, @types/ws, tsx Package plumbing - New ./computers subpath export in package.json (mirrors ./gatekeeper). tsup builds computers/index entry. - tsconfig.json now includes tests/ so lint covers them. - README rewritten to cover both Gatekeeper and Computers with copy-paste quickstart snippets. - CLAUDE.md JS section documents the new scope, terminal rules, camelCase ↔ snake_case mapping, and testing approach. What's deliberately out of scope - No auto-resume for stopped computers in openTerminal(). That's application logic in the Python CLI, not the SDK class — strict parity. Users can call start() and poll themselves. - No release workflow changes — publish is still manual. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/js-test.yml | 3 + CLAUDE.md | 12 +- js/README.md | 99 ++++++++++++--- js/package-lock.json | 101 ++++++++++++++- js/package.json | 18 ++- js/src/computers/client.ts | 210 +++++++++++++++++++++++++++++++ js/src/computers/index.ts | 12 ++ js/src/computers/terminal.ts | 182 +++++++++++++++++++++++++++ js/src/computers/types.ts | 57 +++++++++ js/src/index.ts | 14 +++ js/tests/computers.test.ts | 231 ++++++++++++++++++++++++++++++++++ js/tests/terminal.test.ts | 174 +++++++++++++++++++++++++ js/tsconfig.json | 2 +- js/tsup.config.ts | 1 + 14 files changed, 1091 insertions(+), 25 deletions(-) create mode 100644 js/src/computers/client.ts create mode 100644 js/src/computers/index.ts create mode 100644 js/src/computers/terminal.ts create mode 100644 js/src/computers/types.ts create mode 100644 js/tests/computers.test.ts create mode 100644 js/tests/terminal.test.ts diff --git a/.github/workflows/js-test.yml b/.github/workflows/js-test.yml index 1344c03..f8bd115 100644 --- a/.github/workflows/js-test.yml +++ b/.github/workflows/js-test.yml @@ -37,3 +37,6 @@ jobs: - name: Build run: npm run build + + - name: Unit tests + run: npm test diff --git a/CLAUDE.md b/CLAUDE.md index ebe5e81..9ff226f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,10 +54,18 @@ celesto-sdk/ - Location: [js/](js/) - Package name: `@celestoai/sdk` (published to npm with public access) -- Scope: **Gatekeeper only** today — not feature-parity with the Python SDK +- Scope: **Gatekeeper** + **Computers**. `CelestoClient` composes both: `celesto.gatekeeper.*` and `celesto.computers.*`. Individual clients are also importable via subpath exports (`@celestoai/sdk/gatekeeper`, `@celestoai/sdk/computers`). +- Computers parity with Python: all 7 HTTP methods (`create`, `list`, `get`, `exec`, `stop`, `start`, `delete`) plus `openTerminal()` which returns an `EventEmitter`-based `Terminal` handle. Terminal uses the `ws` package — the only runtime dependency. +- Terminal rules (mirror [src/celesto/computer.py](src/celesto/computer.py)): + - Always resolve name → ID via `get()` before the WebSocket handshake — the WS endpoint does not resolve names. `openTerminal()` does this internally. + - WebSocket handshake sends `Authorization: Bearer` header; the legacy first-message `{"token": ...}` JSON is also sent after connect for backend compat. + - Resize frames are `{"type": "resize", "cols": N, "rows": N}`. + - Does **not** auto-resume stopped computers — that's application logic, not SDK default. +- Public API is camelCase; wire DTOs are snake_case (`vcpus`, `ram_mb`, `exit_code`, etc.) mapped in the client file. Same pattern as Gatekeeper. - Build: `cd js && npm install && npm run build` (tsup → ESM + CJS + DTS under `js/dist/`) - Lint / typecheck: `cd js && npm run lint` (runs `tsc --noEmit`) -- Smoke test: `cd js && node test.mjs` (requires `CELESTO_API_KEY` and hits the live API) +- Unit tests: `cd js && npm test`. Uses Node's built-in `node:test` runner via `tsx`. Tests live in `js/tests/`. HTTP tests mock `fetch` via `ClientConfig.fetch`; terminal tests start a real in-process `WebSocketServer` from the `ws` package on `127.0.0.1:0` to avoid mocking the library. +- Smoke test (manual, needs live API key): `cd js && node test.mjs` - No workspace plumbing, no `package.json` at the repo root — treat `js/` as a self-contained project. - Publish process is manual and independent from the Python release: bump `js/package.json` version, `npm run build`, `npm publish` from inside `js/`. diff --git a/js/README.md b/js/README.md index ae9f17f..5188a1a 100644 --- a/js/README.md +++ b/js/README.md @@ -1,6 +1,9 @@ -# Celesto SDK (Gatekeeper) +# @celestoai/sdk -Node-only TypeScript SDK for Celesto's Gatekeeper API (`/v1/gatekeeper`). +Node-only TypeScript SDK for the [Celesto](https://celesto.ai) platform. Covers: + +- **Gatekeeper** (`/v1/gatekeeper`) — delegated access to user resources +- **Computers** (`/v1/computers`) — create, manage, and interact with sandboxed virtual machines ## Install @@ -11,15 +14,84 @@ npm install @celestoai/sdk ## Quickstart ```ts -import { GatekeeperClient } from "@celestoai/sdk/gatekeeper"; +import { CelestoClient } from "@celestoai/sdk"; -const client = new GatekeeperClient({ - baseUrl: "https://api.celesto.ai", +const celesto = new CelestoClient({ token: process.env.CELESTO_API_KEY, - // If using JWT and multiple orgs, set the org context: - // organizationId: "org_123", + // organizationId: "org_123", // optional, for JWTs with multiple orgs }); +// Gatekeeper +const connect = await celesto.gatekeeper.connect({ + subject: "customer_123", + provider: "google_drive", + projectName: "Default", +}); + +// Computers +const computer = await celesto.computers.create({ cpus: 2, memory: 2048 }); +const result = await celesto.computers.exec(computer.id, "uname -a"); +console.log(result.stdout); +await celesto.computers.delete(computer.id); +``` + +## Computers + +### Lifecycle + +```ts +const computer = await celesto.computers.create({ + cpus: 2, + memory: 2048, + image: "ubuntu-desktop-24.04", +}); + +await celesto.computers.stop(computer.id); +await celesto.computers.start(computer.id); +await celesto.computers.delete(computer.id); + +const { computers, count } = await celesto.computers.list(); +``` + +### Running commands + +```ts +const result = await celesto.computers.exec(computer.id, "ls -la", { timeout: 60 }); +console.log(result.exitCode, result.stdout, result.stderr); +``` + +### Interactive terminal + +`openTerminal()` returns an event-driven handle backed by a WebSocket. It accepts either a +computer ID or a human-readable name — the name is resolved to the canonical ID before the +WebSocket handshake. `openTerminal()` does **not** auto-resume stopped computers; call +`start()` yourself and poll for `status === "running"` if you need that. + +```ts +const terminal = await celesto.computers.openTerminal(computer.id); + +terminal.on("data", (chunk) => process.stdout.write(chunk)); +terminal.on("close", (code, reason) => { + console.log(`terminal closed: ${code} ${reason}`); +}); +terminal.on("error", (err) => { + console.error(err); +}); + +terminal.write("ls -la\n"); +terminal.resize(120, 40); + +// ...later +await terminal.close(); +``` + +## Gatekeeper + +```ts +import { GatekeeperClient } from "@celestoai/sdk/gatekeeper"; + +const client = new GatekeeperClient({ token: process.env.CELESTO_API_KEY }); + const connect = await client.connect({ subject: "customer_123", provider: "google_drive", @@ -31,21 +103,14 @@ if (connect.status === "redirect") { } ``` -## Documentation - -``` -https://docs.celesto.ai/celesto-sdk/gatekeeper -``` +Full docs: https://docs.celesto.ai/celesto-sdk/gatekeeper ## Notes - `token` accepts either a Celesto API key or a JWT. - `organizationId` adds the `X-Current-Organization` header. -- Requires Node 18+ for built-in `fetch`. +- Requires Node 18+ for built-in `fetch`. The `ws` package is used for WebSocket terminal support. ## License -Apache-2.0. The SDK is open source; use of the Celesto platform is governed by the Celesto Terms of Service: -``` -https://celesto.ai/legal/terms -``` +Apache-2.0. The SDK is open source; use of the Celesto platform is governed by the Celesto Terms of Service: https://celesto.ai/legal/terms diff --git a/js/package-lock.json b/js/package-lock.json index 065d707..f20c64b 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,15 +1,21 @@ { "name": "@celestoai/sdk", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@celestoai/sdk", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0" + }, "devDependencies": { + "@types/node": "^20.14.0", + "@types/ws": "^8.5.12", "tsup": "^8.0.2", + "tsx": "^4.19.0", "typescript": "^5.5.4" }, "engines": { @@ -854,6 +860,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1048,6 +1074,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1256,6 +1295,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -1451,6 +1500,26 @@ } } }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1471,6 +1540,34 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "dev": true, "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/js/package.json b/js/package.json index 35ab731..d6799cc 100644 --- a/js/package.json +++ b/js/package.json @@ -1,7 +1,7 @@ { "name": "@celestoai/sdk", - "version": "0.1.0", - "description": "Celesto SDK (Gatekeeper client)", + "version": "0.2.0", + "description": "Celesto SDK — Gatekeeper and Computers clients for Node.js", "license": "Apache-2.0", "type": "module", "main": "./dist/index.cjs", @@ -17,6 +17,11 @@ "types": "./dist/gatekeeper/index.d.ts", "import": "./dist/gatekeeper/index.js", "require": "./dist/gatekeeper/index.cjs" + }, + "./computers": { + "types": "./dist/computers/index.d.ts", + "import": "./dist/computers/index.js", + "require": "./dist/computers/index.cjs" } }, "files": [ @@ -32,10 +37,17 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "lint": "tsc -p tsconfig.json --noEmit" + "lint": "tsc -p tsconfig.json --noEmit", + "test": "tsx --test tests/computers.test.ts tests/terminal.test.ts" + }, + "dependencies": { + "ws": "^8.18.0" }, "devDependencies": { + "@types/node": "^20.14.0", + "@types/ws": "^8.5.12", "tsup": "^8.0.2", + "tsx": "^4.19.0", "typescript": "^5.5.4" } } diff --git a/js/src/computers/client.ts b/js/src/computers/client.ts new file mode 100644 index 0000000..b0143ec --- /dev/null +++ b/js/src/computers/client.ts @@ -0,0 +1,210 @@ +import { buildRequestContext, ClientConfig, RequestOverrides } from "../core/config"; +import { request } from "../core/http"; +import { openTerminalConnection, Terminal } from "./terminal"; +import { + ComputerConnectionInfo, + ComputerExecResponse, + ComputerInfo, + ComputerListResponse, + ComputerStatus, + CreateComputerParams, + ExecParams, + OpenTerminalOptions, +} from "./types"; + +interface ComputerConnectionInfoWire { + ssh?: string | null; + access_url?: string | null; +} + +interface ComputerInfoWire { + id: string; + name: string; + status: ComputerStatus; + vcpus: number; + ram_mb: number; + image: string; + connection?: ComputerConnectionInfoWire | null; + last_error?: string | null; + created_at: string; + stopped_at?: string | null; +} + +interface ComputerListResponseWire { + computers: ComputerInfoWire[]; + count: number; +} + +interface ComputerExecResponseWire { + exit_code: number; + stdout: string; + stderr: string; +} + +const toConnection = ( + payload: ComputerConnectionInfoWire | null | undefined, +): ComputerConnectionInfo | undefined => { + if (!payload) { + return undefined; + } + const out: ComputerConnectionInfo = {}; + if (payload.ssh != null) { + out.ssh = payload.ssh; + } + if (payload.access_url != null) { + out.accessUrl = payload.access_url; + } + return out; +}; + +const toComputerInfo = (payload: ComputerInfoWire): ComputerInfo => ({ + id: payload.id, + name: payload.name, + status: payload.status, + vcpus: payload.vcpus, + ramMb: payload.ram_mb, + image: payload.image, + connection: toConnection(payload.connection), + lastError: payload.last_error ?? null, + createdAt: payload.created_at, + stoppedAt: payload.stopped_at ?? null, +}); + +const toExecResponse = (payload: ComputerExecResponseWire): ComputerExecResponse => ({ + exitCode: payload.exit_code, + stdout: payload.stdout, + stderr: payload.stderr, +}); + +const computersPath = (path: string): string => `/v1/computers${path}`; + +const pickOverrides = (options?: RequestOverrides): RequestOverrides => ({ + headers: options?.headers, + signal: options?.signal, +}); + +/** + * Client for managing sandboxed computers (AI sandboxes). + * + * Mirrors the Python `Computers` class: create/list/get/exec/stop/start/delete + * over HTTP, plus `openTerminal()` for an interactive WebSocket session. + * + * @example + * ```ts + * const celesto = new CelestoClient({ token: process.env.CELESTO_API_KEY }); + * const computer = await celesto.computers.create({ cpus: 2, memory: 2048 }); + * const result = await celesto.computers.exec(computer.id, "uname -a"); + * console.log(result.stdout); + * await celesto.computers.delete(computer.id); + * ``` + */ +export class ComputersClient { + private readonly config: ClientConfig; + + constructor(config: ClientConfig) { + this.config = config; + } + + async create(params: CreateComputerParams = {}, options?: RequestOverrides): Promise { + const ctx = buildRequestContext(this.config); + const data = await request(ctx, { + method: "POST", + path: computersPath(""), + body: { + vcpus: params.cpus ?? 1, + ram_mb: params.memory ?? 1024, + image: params.image ?? "ubuntu-desktop-24.04", + }, + ...pickOverrides(options), + }); + return toComputerInfo(data); + } + + async list(options?: RequestOverrides): Promise { + const ctx = buildRequestContext(this.config); + const data = await request(ctx, { + method: "GET", + path: computersPath(""), + ...pickOverrides(options), + }); + return { + computers: data.computers.map(toComputerInfo), + count: data.count, + }; + } + + async get(computerId: string, options?: RequestOverrides): Promise { + const ctx = buildRequestContext(this.config); + const data = await request(ctx, { + method: "GET", + path: computersPath(`/${encodeURIComponent(computerId)}`), + ...pickOverrides(options), + }); + return toComputerInfo(data); + } + + async exec( + computerId: string, + command: string, + params: ExecParams = {}, + options?: RequestOverrides, + ): Promise { + const ctx = buildRequestContext(this.config); + const data = await request(ctx, { + method: "POST", + path: computersPath(`/${encodeURIComponent(computerId)}/exec`), + body: { + command, + timeout: params.timeout ?? 30, + }, + ...pickOverrides(options), + }); + return toExecResponse(data); + } + + async stop(computerId: string, options?: RequestOverrides): Promise { + const ctx = buildRequestContext(this.config); + const data = await request(ctx, { + method: "POST", + path: computersPath(`/${encodeURIComponent(computerId)}/stop`), + ...pickOverrides(options), + }); + return toComputerInfo(data); + } + + async start(computerId: string, options?: RequestOverrides): Promise { + const ctx = buildRequestContext(this.config); + const data = await request(ctx, { + method: "POST", + path: computersPath(`/${encodeURIComponent(computerId)}/start`), + ...pickOverrides(options), + }); + return toComputerInfo(data); + } + + async delete(computerId: string, options?: RequestOverrides): Promise { + const ctx = buildRequestContext(this.config); + const data = await request(ctx, { + method: "DELETE", + path: computersPath(`/${encodeURIComponent(computerId)}`), + ...pickOverrides(options), + }); + return toComputerInfo(data); + } + + /** + * Open an interactive terminal session on a computer. + * + * Accepts either a computer ID (e.g. `cmp_xxx`) or a human-readable name. + * The name is resolved to the canonical ID via a GET call before the + * WebSocket handshake — the backend's WebSocket endpoint does not resolve + * names on its own. + * + * Does **not** auto-resume stopped computers. Call `start()` yourself and + * poll until the status is `"running"` if you need that. + */ + async openTerminal(computerIdOrName: string, options?: OpenTerminalOptions): Promise { + const info = await this.get(computerIdOrName); + return openTerminalConnection(this.config, info.id, options); + } +} diff --git a/js/src/computers/index.ts b/js/src/computers/index.ts new file mode 100644 index 0000000..e22786b --- /dev/null +++ b/js/src/computers/index.ts @@ -0,0 +1,12 @@ +export { ComputersClient } from "./client"; +export { Terminal, openTerminalConnection } from "./terminal"; +export type { + ComputerConnectionInfo, + ComputerExecResponse, + ComputerInfo, + ComputerListResponse, + ComputerStatus, + CreateComputerParams, + ExecParams, + OpenTerminalOptions, +} from "./types"; diff --git a/js/src/computers/terminal.ts b/js/src/computers/terminal.ts new file mode 100644 index 0000000..fe9a661 --- /dev/null +++ b/js/src/computers/terminal.ts @@ -0,0 +1,182 @@ +import { EventEmitter } from "node:events"; +import WebSocket from "ws"; + +import { buildRequestContext, ClientConfig } from "../core/config"; +import { OpenTerminalOptions } from "./types"; + +const toWsUrl = (baseUrl: string): string => + baseUrl.replace(/^https:/i, "wss:").replace(/^http:/i, "ws:"); + +/** + * Interactive terminal session on a running computer. + * + * Emits: + * - `"data"` with a string or `Buffer` for each chunk of server output + * - `"close"` with `(code: number, reason: string)` when the session ends + * - `"error"` with an `Error` on socket failures + * + * Call `write()` to send keystrokes, `resize()` to update the PTY size, and + * `close()` to cleanly terminate the session. + */ +export class Terminal extends EventEmitter { + readonly #ws: WebSocket; + #closed = false; + + constructor(ws: WebSocket) { + super(); + this.#ws = ws; + + ws.on("message", (raw, isBinary) => { + if (isBinary) { + this.emit("data", raw as Buffer); + } else { + this.emit("data", (raw as Buffer).toString("utf-8")); + } + }); + + ws.on("close", (code, reason) => { + this.#closed = true; + this.emit("close", code, reason.toString("utf-8")); + }); + + ws.on("error", (err) => { + this.emit("error", err); + }); + } + + /** True once the underlying WebSocket has closed. */ + get closed(): boolean { + return this.#closed; + } + + /** Send raw keystrokes to the remote PTY. */ + write(data: string | Uint8Array): void { + if (this.#closed) { + throw new Error("Terminal is closed"); + } + this.#ws.send(data); + } + + /** Resize the remote PTY. Server expects `{ type: "resize", cols, rows }`. */ + resize(cols: number, rows: number): void { + if (this.#closed) { + throw new Error("Terminal is closed"); + } + this.#ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + + /** Close the session. Resolves after the socket has fully closed. */ + close(code: number = 1000, reason?: string): Promise { + if (this.#closed) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const done = (): void => { + this.#ws.removeListener("close", done); + resolve(); + }; + this.#ws.once("close", done); + this.#ws.close(code, reason); + }); + } + + // Typed event overloads — keeps consumer callsites type-safe while letting + // the underlying EventEmitter handle dispatch. The implementation signature + // uses `any` to satisfy TypeScript's overload-compatibility check, matching + // the pattern used in @types/node. + on(event: "data", listener: (data: string | Buffer) => void): this; + on(event: "close", listener: (code: number, reason: string) => void): this; + on(event: "error", listener: (err: Error) => void): this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + + once(event: "data", listener: (data: string | Buffer) => void): this; + once(event: "close", listener: (code: number, reason: string) => void): this; + once(event: "error", listener: (err: Error) => void): this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + once(event: string, listener: (...args: any[]) => void): this { + return super.once(event, listener); + } +} + +/** + * Open an interactive terminal WebSocket against a computer. + * + * Caller is responsible for passing a resolved computer ID (not a name) — + * the WebSocket endpoint does not resolve names. The high-level + * `ComputersClient.openTerminal()` handles resolution for you. + */ +export const openTerminalConnection = async ( + config: ClientConfig, + resolvedComputerId: string, + options?: OpenTerminalOptions, +): Promise => { + const ctx = buildRequestContext(config); + if (!ctx.token) { + throw new Error("A token is required to open a terminal session"); + } + + const wsBase = toWsUrl(ctx.baseUrl); + const fullUrl = `${wsBase}/v1/computers/${encodeURIComponent(resolvedComputerId)}/terminal`; + + const headers: Record = { + Authorization: `Bearer ${ctx.token}`, + }; + if (ctx.organizationId) { + headers["X-Current-Organization"] = ctx.organizationId; + } + + const ws = new WebSocket(fullUrl, { headers }); + + if (options?.signal) { + const signal = options.signal; + if (signal.aborted) { + ws.terminate(); + throw new Error("Aborted before terminal connect"); + } + const onAbort = (): void => { + ws.terminate(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + ws.once("close", () => signal.removeEventListener("abort", onAbort)); + } + + await new Promise((resolve, reject) => { + const cleanup = (): void => { + ws.removeListener("open", onOpen); + ws.removeListener("error", onError); + ws.removeListener("close", onClose); + }; + const onOpen = (): void => { + cleanup(); + resolve(); + }; + const onError = (err: Error): void => { + cleanup(); + reject(err); + }; + const onClose = (code: number, reason: Buffer): void => { + cleanup(); + reject( + new Error( + `Terminal WebSocket closed before open (code=${code}${ + reason.length ? ` reason=${reason.toString("utf-8")}` : "" + })`, + ), + ); + }; + ws.once("open", onOpen); + ws.once("error", onError); + ws.once("close", onClose); + }); + + // Match the Python CLI: send the token as a first message too. Backend + // currently authenticates via the Authorization header (see CLAUDE.md), + // but sending both preserves compatibility with the legacy first-message + // token pattern. + ws.send(JSON.stringify({ token: ctx.token })); + + return new Terminal(ws); +}; diff --git a/js/src/computers/types.ts b/js/src/computers/types.ts new file mode 100644 index 0000000..2afde58 --- /dev/null +++ b/js/src/computers/types.ts @@ -0,0 +1,57 @@ +export type ComputerStatus = + | "creating" + | "running" + | "stopping" + | "stopped" + | "starting" + | "deleting" + | "deleted" + | "error"; + +export interface ComputerConnectionInfo { + ssh?: string; + accessUrl?: string; +} + +export interface ComputerInfo { + id: string; + name: string; + status: ComputerStatus; + vcpus: number; + ramMb: number; + image: string; + connection?: ComputerConnectionInfo; + lastError?: string | null; + createdAt: string; + stoppedAt?: string | null; +} + +export interface ComputerListResponse { + computers: ComputerInfo[]; + count: number; +} + +export interface ComputerExecResponse { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface CreateComputerParams { + /** Number of virtual CPUs (1-16). Defaults to 1. */ + cpus?: number; + /** Memory in MB (512-32768). Defaults to 1024. */ + memory?: number; + /** OS image name. Defaults to "ubuntu-desktop-24.04". */ + image?: string; +} + +export interface ExecParams { + /** Timeout in seconds (1-300). Defaults to 30. */ + timeout?: number; +} + +export interface OpenTerminalOptions { + /** Abort the connect() handshake. */ + signal?: AbortSignal; +} diff --git a/js/src/index.ts b/js/src/index.ts index d4e216b..2f0b2fe 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -1,3 +1,4 @@ +import { ComputersClient } from "./computers"; import { GatekeeperClient } from "./gatekeeper"; import type { ClientConfig } from "./core/config"; @@ -17,13 +18,26 @@ export type { GatekeeperRevokeParams, GatekeeperRevokeResponse, } from "./gatekeeper"; +export { ComputersClient, Terminal } from "./computers"; +export type { + ComputerConnectionInfo, + ComputerExecResponse, + ComputerInfo, + ComputerListResponse, + ComputerStatus, + CreateComputerParams, + ExecParams, + OpenTerminalOptions, +} from "./computers"; export type { ClientConfig, RequestOverrides } from "./core/config"; export { CelestoApiError } from "./core/errors"; export class CelestoClient { readonly gatekeeper: GatekeeperClient; + readonly computers: ComputersClient; constructor(config: ClientConfig) { this.gatekeeper = new GatekeeperClient(config); + this.computers = new ComputersClient(config); } } diff --git a/js/tests/computers.test.ts b/js/tests/computers.test.ts new file mode 100644 index 0000000..58d14da --- /dev/null +++ b/js/tests/computers.test.ts @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { ComputersClient } from "../src/computers/client"; +import type { ClientConfig } from "../src/core/config"; +import { CelestoApiError } from "../src/core/errors"; + +interface RecordedCall { + url: string; + method: string; + headers: Record; + body: unknown; +} + +const makeFetchMock = ( + responder: (call: RecordedCall) => { status: number; body: unknown }, +): { fetch: typeof fetch; calls: RecordedCall[] } => { + const calls: RecordedCall[] = []; + const mock: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const rawHeaders = (init?.headers ?? {}) as Record; + const headers: Record = {}; + for (const [k, v] of Object.entries(rawHeaders)) { + headers[k.toLowerCase()] = v; + } + const bodyStr = typeof init?.body === "string" ? init.body : undefined; + const parsed = bodyStr ? JSON.parse(bodyStr) : undefined; + const call: RecordedCall = { + url, + method: init?.method ?? "GET", + headers, + body: parsed, + }; + calls.push(call); + const { status, body } = responder(call); + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }; + return { fetch: mock, calls }; +}; + +const makeConfig = (fetchMock: typeof fetch): ClientConfig => ({ + baseUrl: "https://api.example.test", + token: "test-token", + fetch: fetchMock, +}); + +describe("ComputersClient", () => { + it("create() sends vcpus/ram_mb wire fields and unwraps snake_case response", async () => { + const { fetch, calls } = makeFetchMock(() => ({ + status: 201, + body: { + id: "cmp_abc", + name: "test", + status: "creating", + vcpus: 2, + ram_mb: 2048, + image: "ubuntu-desktop-24.04", + created_at: "2026-04-16T00:00:00Z", + last_error: null, + stopped_at: null, + }, + })); + const client = new ComputersClient(makeConfig(fetch)); + + const result = await client.create({ cpus: 2, memory: 2048 }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]!.method, "POST"); + assert.equal(calls[0]!.url, "https://api.example.test/v1/computers"); + assert.deepEqual(calls[0]!.body, { + vcpus: 2, + ram_mb: 2048, + image: "ubuntu-desktop-24.04", + }); + assert.equal(calls[0]!.headers["authorization"], "Bearer test-token"); + assert.equal(result.id, "cmp_abc"); + assert.equal(result.ramMb, 2048); + assert.equal(result.lastError, null); + }); + + it("create() applies defaults when no params are provided", async () => { + const { fetch, calls } = makeFetchMock(() => ({ + status: 201, + body: { + id: "cmp_d", + name: "d", + status: "creating", + vcpus: 1, + ram_mb: 1024, + image: "ubuntu-desktop-24.04", + created_at: "2026-04-16T00:00:00Z", + }, + })); + const client = new ComputersClient(makeConfig(fetch)); + + await client.create(); + + assert.deepEqual(calls[0]!.body, { + vcpus: 1, + ram_mb: 1024, + image: "ubuntu-desktop-24.04", + }); + }); + + it("list() maps each computer through the wire transform", async () => { + const { fetch } = makeFetchMock(() => ({ + status: 200, + body: { + computers: [ + { + id: "cmp_1", + name: "one", + status: "running", + vcpus: 1, + ram_mb: 1024, + image: "ubuntu-desktop-24.04", + created_at: "2026-04-16T00:00:00Z", + connection: { ssh: "user@host", access_url: "https://a" }, + }, + { + id: "cmp_2", + name: "two", + status: "stopped", + vcpus: 4, + ram_mb: 8192, + image: "ubuntu-desktop-24.04", + created_at: "2026-04-16T00:00:00Z", + stopped_at: "2026-04-16T01:00:00Z", + }, + ], + count: 2, + }, + })); + const client = new ComputersClient(makeConfig(fetch)); + + const result = await client.list(); + + assert.equal(result.count, 2); + assert.equal(result.computers.length, 2); + assert.equal(result.computers[0]!.connection?.ssh, "user@host"); + assert.equal(result.computers[0]!.connection?.accessUrl, "https://a"); + assert.equal(result.computers[1]!.stoppedAt, "2026-04-16T01:00:00Z"); + }); + + it("get() hits /v1/computers/{id} and is used by openTerminal for ID resolution", async () => { + const { fetch, calls } = makeFetchMock(() => ({ + status: 200, + body: { + id: "cmp_resolved", + name: "my-name", + status: "running", + vcpus: 1, + ram_mb: 1024, + image: "ubuntu-desktop-24.04", + created_at: "2026-04-16T00:00:00Z", + }, + })); + const client = new ComputersClient(makeConfig(fetch)); + + const info = await client.get("my-name"); + + assert.equal(calls[0]!.url, "https://api.example.test/v1/computers/my-name"); + assert.equal(info.id, "cmp_resolved"); + }); + + it("exec() sends command + timeout and unwraps exit_code", async () => { + const { fetch, calls } = makeFetchMock(() => ({ + status: 200, + body: { exit_code: 0, stdout: "ok\n", stderr: "" }, + })); + const client = new ComputersClient(makeConfig(fetch)); + + const result = await client.exec("cmp_1", "uname -a", { timeout: 60 }); + + assert.equal(calls[0]!.url, "https://api.example.test/v1/computers/cmp_1/exec"); + assert.deepEqual(calls[0]!.body, { command: "uname -a", timeout: 60 }); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, "ok\n"); + }); + + it("stop/start/delete hit the right endpoints with the right methods", async () => { + const hits: string[] = []; + const { fetch } = makeFetchMock((call) => { + hits.push(`${call.method} ${call.url}`); + return { + status: 200, + body: { + id: "cmp_1", + name: "n", + status: "stopping", + vcpus: 1, + ram_mb: 1024, + image: "ubuntu-desktop-24.04", + created_at: "2026-04-16T00:00:00Z", + }, + }; + }); + const client = new ComputersClient(makeConfig(fetch)); + + await client.stop("cmp_1"); + await client.start("cmp_1"); + await client.delete("cmp_1"); + + assert.deepEqual(hits, [ + "POST https://api.example.test/v1/computers/cmp_1/stop", + "POST https://api.example.test/v1/computers/cmp_1/start", + "DELETE https://api.example.test/v1/computers/cmp_1", + ]); + }); + + it("throws CelestoApiError on non-2xx responses", async () => { + const { fetch } = makeFetchMock(() => ({ + status: 404, + body: { detail: "Computer not found" }, + })); + const client = new ComputersClient(makeConfig(fetch)); + + await assert.rejects( + () => client.get("cmp_missing"), + (err: unknown) => { + assert.ok(err instanceof CelestoApiError); + assert.equal(err.status, 404); + assert.equal(err.message, "Computer not found"); + return true; + }, + ); + }); +}); diff --git a/js/tests/terminal.test.ts b/js/tests/terminal.test.ts new file mode 100644 index 0000000..d2f8b1f --- /dev/null +++ b/js/tests/terminal.test.ts @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { after, before, describe, it } from "node:test"; + +import { WebSocketServer, type WebSocket as ServerSocket } from "ws"; + +import { openTerminalConnection } from "../src/computers/terminal"; +import type { ClientConfig } from "../src/core/config"; + +interface ServerContext { + baseUrl: string; + authHeader: string | undefined; + lastPath: string | undefined; + serverSockets: ServerSocket[]; + firstMessages: string[]; + shutdown: () => Promise; +} + +const startServer = async (): Promise => { + const http = createServer(); + const wss = new WebSocketServer({ noServer: true }); + + const ctx: ServerContext = { + baseUrl: "", + authHeader: undefined, + lastPath: undefined, + serverSockets: [], + firstMessages: [], + shutdown: async () => {}, + }; + + http.on("upgrade", (request, socket, head) => { + ctx.authHeader = request.headers["authorization"] as string | undefined; + ctx.lastPath = request.url; + wss.handleUpgrade(request, socket, head, (ws) => { + ctx.serverSockets.push(ws); + ws.once("message", (raw) => { + ctx.firstMessages.push(raw.toString("utf-8")); + }); + }); + }); + + await new Promise((resolve) => http.listen(0, "127.0.0.1", resolve)); + const addr = http.address() as AddressInfo; + ctx.baseUrl = `http://127.0.0.1:${addr.port}`; + + ctx.shutdown = async () => { + for (const sock of ctx.serverSockets) { + sock.close(); + } + wss.close(); + await new Promise((resolve) => http.close(() => resolve())); + }; + + return ctx; +}; + +describe("openTerminalConnection", () => { + let server: ServerContext; + + before(async () => { + server = await startServer(); + }); + + after(async () => { + await server.shutdown(); + }); + + const makeConfig = (): ClientConfig => ({ + baseUrl: server.baseUrl, + token: "term-token", + }); + + it("connects to /v1/computers/{id}/terminal with Authorization header", async () => { + const terminal = await openTerminalConnection(makeConfig(), "cmp_abc"); + + assert.equal(server.lastPath, "/v1/computers/cmp_abc/terminal"); + assert.equal(server.authHeader, "Bearer term-token"); + + await terminal.close(); + }); + + it("sends the legacy first-message token after handshake", async () => { + const before = server.firstMessages.length; + const terminal = await openTerminalConnection(makeConfig(), "cmp_first"); + + // Wait a tick for the first-message to flush server-side + await new Promise((resolve) => setTimeout(resolve, 20)); + + const newMessages = server.firstMessages.slice(before); + assert.equal(newMessages.length, 1); + assert.deepEqual(JSON.parse(newMessages[0]!), { token: "term-token" }); + + await terminal.close(); + }); + + it("emits 'data' for server-sent text and binary messages", async () => { + const terminal = await openTerminalConnection(makeConfig(), "cmp_data"); + const serverSocket = server.serverSockets[server.serverSockets.length - 1]!; + + const received: Array = []; + terminal.on("data", (chunk) => received.push(chunk)); + + // Wait a tick so the server-side socket has fully upgraded + await new Promise((resolve) => setTimeout(resolve, 10)); + + serverSocket.send("hello"); + serverSocket.send(Buffer.from([0x01, 0x02, 0x03])); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.equal(received.length, 2); + assert.equal(received[0], "hello"); + assert.ok(Buffer.isBuffer(received[1])); + assert.deepEqual(received[1], Buffer.from([0x01, 0x02, 0x03])); + + await terminal.close(); + }); + + it("write() sends keystrokes and resize() sends JSON resize frame", async () => { + const terminal = await openTerminalConnection(makeConfig(), "cmp_write"); + const serverSocket = server.serverSockets[server.serverSockets.length - 1]!; + + const serverRecv: string[] = []; + serverSocket.on("message", (raw) => serverRecv.push(raw.toString("utf-8"))); + + // Wait past the first-message token that was sent during handshake + await new Promise((resolve) => setTimeout(resolve, 20)); + const firstMessageCount = serverRecv.length; + + terminal.write("ls\n"); + terminal.resize(120, 40); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const newMessages = serverRecv.slice(firstMessageCount); + assert.equal(newMessages.length, 2); + assert.equal(newMessages[0], "ls\n"); + assert.deepEqual(JSON.parse(newMessages[1]!), { type: "resize", cols: 120, rows: 40 }); + + await terminal.close(); + }); + + it("emits 'close' with the server-sent code and reason", async () => { + const terminal = await openTerminalConnection(makeConfig(), "cmp_close"); + const serverSocket = server.serverSockets[server.serverSockets.length - 1]!; + + const closePromise = new Promise<{ code: number; reason: string }>((resolve) => { + terminal.on("close", (code: number, reason: string) => resolve({ code, reason })); + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + serverSocket.close(4042, "server-bye"); + + const result = await closePromise; + assert.equal(result.code, 4042); + assert.equal(result.reason, "server-bye"); + assert.equal(terminal.closed, true); + }); + + it("write() throws after close", async () => { + const terminal = await openTerminalConnection(makeConfig(), "cmp_post_close"); + await terminal.close(); + assert.throws(() => terminal.write("nope"), /Terminal is closed/); + }); + + it("rejects if no token is configured", async () => { + await assert.rejects( + () => openTerminalConnection({ baseUrl: server.baseUrl }, "cmp_notoken"), + /token is required/i, + ); + }); +}); diff --git a/js/tsconfig.json b/js/tsconfig.json index e7bb926..8e7eb67 100644 --- a/js/tsconfig.json +++ b/js/tsconfig.json @@ -12,5 +12,5 @@ "resolveJsonModule": true, "noUncheckedIndexedAccess": true }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/js/tsup.config.ts b/js/tsup.config.ts index 1a3a435..c454266 100644 --- a/js/tsup.config.ts +++ b/js/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: "src/index.ts", "gatekeeper/index": "src/gatekeeper/index.ts", + "computers/index": "src/computers/index.ts", }, format: ["esm", "cjs"], dts: true, From 81dd580bc2b8755f3e91f12ef8b21c1c8f293e77 Mon Sep 17 00:00:00 2001 From: Aniket Maurya Date: Thu, 16 Apr 2026 01:13:00 +0100 Subject: [PATCH 2/2] ci: remove path filters from test workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Python and JS test workflows now run on every push/PR regardless of which files changed. Avoids deadlocks if either check is ever promoted to a required status — no more "check pending because the paths filter didn't match" scenarios. The extra ~30s of CI on irrelevant PRs is worth the simplicity. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/js-test.yml | 6 ------ .github/workflows/test.yml | 14 -------------- 2 files changed, 20 deletions(-) diff --git a/.github/workflows/js-test.yml b/.github/workflows/js-test.yml index f8bd115..485e395 100644 --- a/.github/workflows/js-test.yml +++ b/.github/workflows/js-test.yml @@ -3,13 +3,7 @@ name: JS Tests on: push: branches: ["main"] - paths: - - "js/**" - - ".github/workflows/js-test.yml" pull_request: - paths: - - "js/**" - - ".github/workflows/js-test.yml" jobs: test: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d2f6d1..bd3a00a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,21 +3,7 @@ name: Tests on: push: branches: ["main"] - paths: - - "src/**" - - "tests/**" - - "benchmarks/**" - - "pyproject.toml" - - "uv.lock" - - ".github/workflows/test.yml" pull_request: - paths: - - "src/**" - - "tests/**" - - "benchmarks/**" - - "pyproject.toml" - - "uv.lock" - - ".github/workflows/test.yml" jobs: test: