From a707dac3d24beba27a738afa7c226dfc6b4ff752 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Thu, 2 Apr 2026 20:27:02 -0400 Subject: [PATCH 1/9] cleaned up code --- .github/workflows/release.yml | 40 ++ README.md | 61 +-- examples/code_interpreter_stream.ts | 29 +- examples/desktop.ts | 52 ++- examples/filesystem_and_git.ts | 50 ++- examples/pty.ts | 36 +- examples/quickstart.ts | 20 +- examples/snapshots.ts | 28 +- examples/ssh.ts | 30 +- package.json | 35 +- pnpm-lock.yaml | 209 +++++++++ src/client/index.ts | 101 +++-- src/client/sandbox.ts | 114 ++--- src/config/constants.ts | 20 +- src/config/index.ts | 46 +- src/core/errors.ts | 54 +-- src/core/normalize.ts | 28 ++ src/core/otel.ts | 67 +-- src/core/transport.ts | 249 +++++++---- src/core/utils.ts | 40 +- src/core/version.ts | 8 +- src/index.ts | 17 +- src/models/code-interpreter.ts | 104 +++-- src/models/config.ts | 10 +- src/models/desktop.ts | 277 +++++++----- src/models/filesystem.ts | 105 +++-- src/models/git.ts | 18 +- src/models/index.ts | 64 ++- src/models/lsp.ts | 39 +- src/models/process.ts | 14 +- src/models/pty.ts | 30 +- src/models/refs.ts | 6 +- src/models/sandbox.ts | 111 +++-- src/models/shared.ts | 6 +- src/models/snapshot.ts | 33 +- src/models/ssh.ts | 34 +- src/models/template.ts | 65 +-- src/services/code-interpreter.ts | 155 +++++-- src/services/desktop.ts | 420 ++++++++++++++++--- src/services/filesystem.ts | 253 ++++++++--- src/services/git.ts | 143 ++++++- src/services/index.ts | 22 +- src/services/lsp.ts | 169 +++++++- src/services/process.ts | 17 +- src/services/pty.ts | 141 +++++-- src/services/sandboxes.ts | 139 +++--- src/services/shared.ts | 12 +- src/services/snapshots.ts | 92 ++-- src/services/ssh.ts | 46 +- src/services/templates.ts | 70 +++- tests/client/client-sandbox.test.ts | 188 ++++++--- tests/client/index-exports.test.ts | 48 +-- tests/config/config-utils.test.ts | 133 +++--- tests/config/otel.test.ts | 32 +- tests/core/normalize.test.ts | 26 ++ tests/models/network-policy.test.ts | 51 ++- tests/models/template-credentials.test.ts | 79 ++-- tests/quality/docstrings.test.ts | 113 +++-- tests/services/code-interpreter.test.ts | 93 ++-- tests/services/desktop.test.ts | 107 +++-- tests/services/filesystem.test.ts | 93 ++-- tests/services/git.test.ts | 66 +-- tests/services/lsp.test.ts | 43 +- tests/services/normalization-cleanup.test.ts | 84 ++++ tests/services/process.test.ts | 25 +- tests/services/pty.test.ts | 89 ++-- tests/services/sandboxes-client.test.ts | 158 ++++--- tests/services/snapshots.test.ts | 70 +++- tests/services/ssh.test.ts | 44 +- tests/services/transport.test.ts | 191 ++++++--- tests/utils/helpers.ts | 54 +-- vitest.config.ts | 6 +- 72 files changed, 3950 insertions(+), 1772 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 src/core/normalize.ts create mode 100644 tests/core/normalize.test.ts create mode 100644 tests/services/normalization-cleanup.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c8d1bc5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.19.0 + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test + + - name: Publish package + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 749f05e..cec002d 100644 --- a/README.md +++ b/README.md @@ -30,25 +30,25 @@ export LEAP0_API_KEY="your-api-key" Or pass it directly when creating a client: ```ts -import { Leap0Client } from "leap0" +import { Leap0Client } from "leap0"; -const client = new Leap0Client({ apiKey: "your-api-key" }) +const client = new Leap0Client({ apiKey: "your-api-key" }); ``` ## Quick Start ```ts -import { Leap0Client } from "leap0" +import { Leap0Client } from "leap0"; -const client = new Leap0Client() -const sandbox = await client.sandboxes.create() +const client = new Leap0Client(); +const sandbox = await client.sandboxes.create(); try { - const result = await sandbox.process.execute({ command: "echo hello from leap0" }) - console.log(result.result.trim()) + const result = await sandbox.process.execute({ command: "echo hello from leap0" }); + console.log(result.result.trim()); } finally { - await sandbox.delete() - await client.close() + await sandbox.delete(); + await client.close(); } ``` @@ -59,10 +59,15 @@ try { Stateful code execution with streaming output. ```ts -import { CodeLanguage, DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME } from "leap0" - -const sandbox = await client.sandboxes.create({ templateName: DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME }) -const result = await sandbox.codeInterpreter.execute({ code: "x = 42", language: CodeLanguage.PYTHON }) +import { CodeLanguage, DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME } from "leap0"; + +const sandbox = await client.sandboxes.create({ + templateName: DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, +}); +const result = await sandbox.codeInterpreter.execute({ + code: "x = 42", + language: CodeLanguage.PYTHON, +}); ``` ### Filesystem @@ -70,9 +75,9 @@ const result = await sandbox.codeInterpreter.execute({ code: "x = 42", language: Read, write, search, and inspect files inside a sandbox. ```ts -await sandbox.filesystem.writeFile("/workspace/hello.txt", "Hello!") -const content = await sandbox.filesystem.readFile("/workspace/hello.txt") -const tree = await sandbox.filesystem.tree("/workspace", 2) +await sandbox.filesystem.writeFile("/workspace/hello.txt", "Hello!"); +const content = await sandbox.filesystem.readFile("/workspace/hello.txt"); +const tree = await sandbox.filesystem.tree("/workspace", 2); ``` ### Git @@ -80,8 +85,8 @@ const tree = await sandbox.filesystem.tree("/workspace", 2) Clone repositories and run Git operations inside the sandbox. ```ts -await sandbox.git.clone("https://github.com/octocat/Hello-World.git", "/workspace/repo") -const status = await sandbox.git.status("/workspace/repo") +await sandbox.git.clone("https://github.com/octocat/Hello-World.git", "/workspace/repo"); +const status = await sandbox.git.status("/workspace/repo"); ``` ### Process Execution @@ -89,8 +94,8 @@ const status = await sandbox.git.status("/workspace/repo") Run one-off shell commands inside a running sandbox. ```ts -const result = await sandbox.process.execute({ command: "ls -la /workspace" }) -console.log(result.result) +const result = await sandbox.process.execute({ command: "ls -la /workspace" }); +console.log(result.result); ``` ### Interactive Terminal (PTY) @@ -98,7 +103,7 @@ console.log(result.result) Open persistent terminal sessions over WebSocket. ```ts -const session = await sandbox.pty.create({ cols: 120, rows: 30, cwd: "/home/user" }) +const session = await sandbox.pty.create({ cols: 120, rows: 30, cwd: "/home/user" }); ``` ### Language Server Protocol (LSP) @@ -106,7 +111,7 @@ const session = await sandbox.pty.create({ cols: 120, rows: 30, cwd: "/home/user Use language servers for completions and editor-style workflows. ```ts -await sandbox.lsp.start({ languageId: "python", pathToProject: "/workspace" }) +await sandbox.lsp.start({ languageId: "python", pathToProject: "/workspace" }); ``` ### SSH Access @@ -114,8 +119,8 @@ await sandbox.lsp.start({ languageId: "python", pathToProject: "/workspace" }) Generate temporary SSH credentials for direct sandbox access. ```ts -const access = await sandbox.ssh.createAccess() -console.log(access.hostname, access.port, access.username) +const access = await sandbox.ssh.createAccess(); +console.log(access.hostname, access.port, access.username); ``` ### Desktop Automation @@ -123,7 +128,7 @@ console.log(access.hostname, access.port, access.username) Control a graphical desktop inside the sandbox. ```ts -const screenshot = await sandbox.desktop.screenshot() +const screenshot = await sandbox.desktop.screenshot(); ``` ### Snapshots @@ -131,8 +136,8 @@ const screenshot = await sandbox.desktop.screenshot() Save and restore sandbox state. ```ts -const snapshot = await client.snapshots.create(sandbox, { name: "my-checkpoint" }) -const restored = await client.snapshots.resume({ snapshotName: snapshot.name ?? "my-checkpoint" }) +const snapshot = await client.snapshots.create(sandbox, { name: "my-checkpoint" }); +const restored = await client.snapshots.resume({ snapshotName: snapshot.name ?? "my-checkpoint" }); ``` ## Supported Imports @@ -140,7 +145,7 @@ const restored = await client.snapshots.resume({ snapshotName: snapshot.name ?? Import clients, enums, and types from the package root: ```ts -import { Leap0Client, SandboxState, type CreateSandboxParams } from "leap0" +import { Leap0Client, SandboxState, type CreateSandboxParams } from "leap0"; ``` ## Examples diff --git a/examples/code_interpreter_stream.ts b/examples/code_interpreter_stream.ts index 0a6fac8..f8730a3 100644 --- a/examples/code_interpreter_stream.ts +++ b/examples/code_interpreter_stream.ts @@ -3,28 +3,33 @@ import { DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, Leap0Client, StreamEvent, -} from "../src/index.js" +} from "../src/index.js"; async function main(): Promise { - const client = new Leap0Client() + const client = new Leap0Client(); try { - const sandbox = await client.sandboxes.create({ templateName: DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME }) + const sandbox = await client.createSandbox({ + templateName: DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, + }); try { - for await (const event of sandbox.codeInterpreter.executeStream({ - code: "import time\nfor i in range(3):\n print(f'step {i}')\n time.sleep(1)", - language: CodeLanguage.PYTHON, - }, { timeout: 10 })) { - const typedEvent: StreamEvent = event - console.log(typedEvent) + for await (const event of sandbox.codeInterpreter.executeStream( + { + code: "import time\nfor i in range(3):\n print(f'step {i}')\n time.sleep(1)", + language: CodeLanguage.PYTHON, + }, + { timeout: 10 }, + )) { + const typedEvent: StreamEvent = event; + console.log(typedEvent); } } finally { - await sandbox.delete() + await sandbox.delete(); } } finally { - await client.close() + await client.close(); } } -void main() +void main(); diff --git a/examples/desktop.ts b/examples/desktop.ts index 744d2cc..9e30860 100644 --- a/examples/desktop.ts +++ b/examples/desktop.ts @@ -1,36 +1,34 @@ -import { writeFile } from "node:fs/promises" +import { writeFile } from "node:fs/promises"; -import { DEFAULT_DESKTOP_TEMPLATE_NAME, Leap0Client } from "../src/index.js" - -function decodeScreenshot(value: string): Uint8Array { - const encoded = value.startsWith("data:") ? value.slice(value.indexOf(",") + 1) : value - return Buffer.from(encoded, "base64") -} +import { DEFAULT_DESKTOP_TEMPLATE_NAME, Leap0Client } from "../src/index.js"; async function main(): Promise { - const client = new Leap0Client() - let sandbox: Awaited> | null = null + const client = new Leap0Client(); try { - sandbox = await client.sandboxes.create({ templateName: DEFAULT_DESKTOP_TEMPLATE_NAME }) - await sandbox.desktop.waitUntilReady(60) - console.log("Desktop:", sandbox.desktop.browserUrl()) - - const display = await sandbox.desktop.display() - console.log("Display:", display) - - await sandbox.desktop.movePointer(Math.floor(display.width / 2), Math.floor(display.height / 2)) - await sandbox.desktop.click(1) - - const screenshot = await sandbox.desktop.screenshot() - await writeFile("desktop-screenshot.png", decodeScreenshot(screenshot)) - console.log("Saved screenshot to desktop-screenshot.png") - } finally { - if (sandbox) { - await sandbox.delete() + const sandbox = await client.createSandbox({ templateName: DEFAULT_DESKTOP_TEMPLATE_NAME }); + try { + await sandbox.desktop.waitUntilReady(60); + console.log("Desktop:", sandbox.desktop.browserUrl()); + + const display = await sandbox.desktop.display(); + console.log("Display:", display); + + await sandbox.desktop.movePointer( + Math.floor(display.width / 2), + Math.floor(display.height / 2), + ); + await sandbox.desktop.click({ button: 1 }); + + const screenshot = await sandbox.desktop.screenshot(); + await writeFile("desktop-screenshot.png", screenshot); + console.log("Saved screenshot to desktop-screenshot.png"); + } finally { + await sandbox.delete(); } - await client.close() + } finally { + await client.close(); } } -void main() +void main(); diff --git a/examples/filesystem_and_git.ts b/examples/filesystem_and_git.ts index b7eb677..55f694a 100644 --- a/examples/filesystem_and_git.ts +++ b/examples/filesystem_and_git.ts @@ -1,34 +1,40 @@ -import { Leap0Client } from "../src/index.js" +import { Leap0Client } from "../src/index.js"; async function main(): Promise { - const client = new Leap0Client() - const repoPath = "/workspace/hello-world" + const client = new Leap0Client(); + const repoPath = "/workspace/hello-world"; try { - const sandbox = await client.sandboxes.create() + const sandbox = await client.createSandbox(); try { - const clone = await sandbox.git.clone("https://github.com/octocat/Hello-World.git", repoPath) - console.log("clone exit:", clone.exitCode) - - const status = await sandbox.git.status(repoPath) - console.log("git status:\n", status.output) - - await sandbox.filesystem.writeFile(`${repoPath}/sdk-demo.txt`, "Hello from the Leap0 JS SDK\n") - const exists = await sandbox.filesystem.exists(`${repoPath}/sdk-demo.txt`) - console.log("file exists:", exists.exists) - - const fileInfo = await sandbox.filesystem.stat(`${repoPath}/README`) - console.log("readme size:", fileInfo.size) - - const tree = await sandbox.filesystem.tree(repoPath, 2) - console.log("tree items:", tree.entries.map((entry) => entry.name)) + const clone = await sandbox.git.clone("https://github.com/octocat/Hello-World.git", repoPath); + console.log("clone exit:", clone.exitCode); + + const status = await sandbox.git.status(repoPath); + console.log("git status:\n", status.output); + + await sandbox.filesystem.writeFile( + `${repoPath}/sdk-demo.txt`, + "Hello from the Leap0 JS SDK\n", + ); + const exists = await sandbox.filesystem.exists(`${repoPath}/sdk-demo.txt`); + console.log("file exists:", exists.exists); + + const fileInfo = await sandbox.filesystem.stat(`${repoPath}/README`); + console.log("readme size:", fileInfo.size); + + const tree = await sandbox.filesystem.tree(repoPath, 2); + console.log( + "tree items:", + tree.items.map((entry) => entry.name), + ); } finally { - await sandbox.delete() + await sandbox.delete(); } } finally { - await client.close() + await client.close(); } } -void main() +void main(); diff --git a/examples/pty.ts b/examples/pty.ts index 889431a..66715b5 100644 --- a/examples/pty.ts +++ b/examples/pty.ts @@ -1,19 +1,21 @@ -import { Leap0Client, PtyConnection } from "../src/index.js" +import { Leap0Client, PtyConnection } from "../src/index.js"; function waitForOpen(socket: WebSocket): Promise { if (socket.readyState === WebSocket.OPEN) { - return Promise.resolve() + return Promise.resolve(); } return new Promise((resolve, reject) => { - socket.addEventListener("open", () => resolve(), { once: true }) - socket.addEventListener("error", () => reject(new Error("PTY websocket error")), { once: true }) - }) + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("PTY websocket error")), { + once: true, + }); + }); } async function main(): Promise { - const client = new Leap0Client() - const sandbox = await client.sandboxes.create() + const client = new Leap0Client(); + const sandbox = await client.createSandbox(); try { const session = await sandbox.pty.create({ @@ -21,22 +23,22 @@ async function main(): Promise { cols: 120, rows: 30, cwd: "/home/user", - }) + }); - const socket = new WebSocket(sandbox.pty.websocketUrl(session.id)) - await waitForOpen(socket) - const connection = new PtyConnection(socket) + const socket = new WebSocket(sandbox.pty.websocketUrl(session.id)); + await waitForOpen(socket); + const connection = new PtyConnection(socket); try { - connection.send("pwd\n") - console.log(new TextDecoder().decode(await connection.recv())) + connection.send("pwd\n"); + console.log(new TextDecoder().decode(await connection.recv())); } finally { - connection.close() + connection.close(); } } finally { - await sandbox.delete() - await client.close() + await sandbox.delete(); + await client.close(); } } -void main() +void main(); diff --git a/examples/quickstart.ts b/examples/quickstart.ts index 8939575..561d5cb 100644 --- a/examples/quickstart.ts +++ b/examples/quickstart.ts @@ -1,18 +1,18 @@ -import { Leap0Client } from "../src/index.js" +import { Leap0Client } from "../src/index.js"; async function main(): Promise { - const client = new Leap0Client() - const sandbox = await client.sandboxes.create() + const client = new Leap0Client(); + const sandbox = await client.createSandbox(); try { - const result = await sandbox.process.execute({ command: "echo hello from leap0" }) - console.log("sandbox:", sandbox.id) - console.log("exit code:", result.exitCode) - console.log("result:", result.result.trim()) + const result = await sandbox.process.execute({ command: "echo hello from leap0" }); + console.log("sandbox:", sandbox.id); + console.log("exit code:", result.exitCode); + console.log("result:", result.result.trim()); } finally { - await sandbox.delete() - await client.close() + await sandbox.delete(); + await client.close(); } } -void main() +void main(); diff --git a/examples/snapshots.ts b/examples/snapshots.ts index 97790c1..40a5295 100644 --- a/examples/snapshots.ts +++ b/examples/snapshots.ts @@ -1,29 +1,31 @@ -import { Leap0Client } from "../src/index.js" +import { Leap0Client } from "../src/index.js"; async function main(): Promise { - const client = new Leap0Client() + const client = new Leap0Client(); try { - const sandbox = await client.sandboxes.create() + const sandbox = await client.createSandbox(); try { - await sandbox.filesystem.writeFile("/workspace/checkpoint.txt", "before snapshot\n") + await sandbox.filesystem.writeFile("/workspace/checkpoint.txt", "before snapshot\n"); - const snapshot = await client.snapshots.create(sandbox, { name: "example-checkpoint" }) - console.log("snapshot:", snapshot.id) + const snapshot = await client.snapshots.create(sandbox, { name: "example-checkpoint" }); + console.log("snapshot:", snapshot.id); - const restored = await client.snapshots.resume({ snapshotName: snapshot.name ?? "example-checkpoint" }) + const restored = await client.resumeSnapshot({ + snapshotName: snapshot.name ?? "example-checkpoint", + }); try { - const content = await restored.filesystem.readFile("/workspace/checkpoint.txt") - console.log("restored file:", content.trim()) + const content = await restored.filesystem.readFile("/workspace/checkpoint.txt"); + console.log("restored file:", content.trim()); } finally { - await restored.delete() + await restored.delete(); } } finally { - await sandbox.delete() + await sandbox.delete(); } } finally { - await client.close() + await client.close(); } } -void main() +void main(); diff --git a/examples/ssh.ts b/examples/ssh.ts index 961b660..90b140c 100644 --- a/examples/ssh.ts +++ b/examples/ssh.ts @@ -1,26 +1,22 @@ -import { Leap0Client } from "../src/index.js" +import { Leap0Client } from "../src/index.js"; async function main(): Promise { - const client = new Leap0Client() - let sandbox: Awaited> | undefined + const client = new Leap0Client(); try { - sandbox = await client.sandboxes.create() + const sandbox = await client.createSandbox(); + try { + const access = await sandbox.ssh.createAccess(); + console.log("ssh command:", access.sshCommand); - const access = await sandbox.ssh.createAccess() - console.log("ssh command:", `ssh ${access.username}@${access.hostname} -p ${access.port}`) - - const validation = await sandbox.ssh.validateAccess(access.id, access.password ?? "") - console.log("ssh valid:", validation.valid) - } finally { - if (sandbox) { - try { - await sandbox.delete() - } catch { - } + const validation = await sandbox.ssh.validateAccess(access.id, access.password); + console.log("ssh valid:", validation.valid); + } finally { + await sandbox.delete(); } - await client.close() + } finally { + await client.close(); } } -void main() +void main(); diff --git a/package.json b/package.json index 1521e24..156b3b7 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,26 @@ "name": "leap0", "version": "0.1.0", "description": "TypeScript SDK for the Leap0 API", + "keywords": [ + "agents", + "leap0", + "sandbox", + "sdk", + "typescript" + ], + "homepage": "https://github.com/leap0-dev/leap0-js#readme", + "bugs": { + "url": "https://github.com/leap0-dev/leap0-js/issues" + }, "license": "Apache-2.0", - "packageManager": "pnpm@10.18.3", + "repository": { + "type": "git", + "url": "git+https://github.com/leap0-dev/leap0-js.git" + }, + "files": [ + "dist", + "package.json" + ], "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -14,19 +32,13 @@ }, "./package.json": "./package.json" }, - "files": [ - "dist", - "package.json" - ], "scripts": { "build": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", "check": "tsc -p tsconfig.json", "lint": "oxlint .", + "prepack": "pnpm run build", "test": "pnpm run build && vitest run" }, - "engines": { - "node": ">=20.6.0" - }, "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", @@ -40,9 +52,14 @@ }, "devDependencies": { "@types/node": "^24.5.2", + "oxfmt": "^0.43.0", "oxlint": "^1.15.0", "tsc-alias": "^1.8.16", "typescript": "^5.9.2", "vitest": "^3.2.4" - } + }, + "engines": { + "node": ">=20.6.0" + }, + "packageManager": "pnpm@10.18.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f79546d..a17507a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@types/node': specifier: ^24.5.2 version: 24.12.0 + oxfmt: + specifier: ^0.43.0 + version: 0.43.0 oxlint: specifier: ^1.15.0 version: 1.58.0 @@ -303,6 +306,120 @@ packages: resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} + '@oxfmt/binding-android-arm-eabi@0.43.0': + resolution: {integrity: sha512-CgU2s+/9hHZgo0IxVxrbMPrMj+tJ6VM3mD7Mr/4oiz4FNTISLoCvRmB5nk4wAAle045RtRjd86m673jwPyb1OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.43.0': + resolution: {integrity: sha512-T9OfRwjA/EdYxAqbvR7TtqLv5nIrwPXuCtTwOHtS7aR9uXyn74ZYgzgTo6/ZwvTq9DY4W+DsV09hB2EXgn9EbA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.43.0': + resolution: {integrity: sha512-o3i49ZUSJWANzXMAAVY1wnqb65hn4JVzwlRQ5qfcwhRzIA8lGVaud31Q3by5ALHPrksp5QEaKCQF9aAS3TXpZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.43.0': + resolution: {integrity: sha512-vWECzzCFkb0kK6jaHjbtC5sC3adiNWtqawFCxhpvsWlzVeKmv5bNvkB4nux+o4JKWTpHCM57NDK/MeXt44txmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.43.0': + resolution: {integrity: sha512-rgz8JpkKiI/umOf7fl9gwKyQasC8bs5SYHy6g7e4SunfLBY3+8ATcD5caIg8KLGEtKFm5ujKaH8EfjcmnhzTLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.43.0': + resolution: {integrity: sha512-nWYnF3vIFzT4OM1qL/HSf1Yuj96aBuKWSaObXHSWliwAk2rcj7AWd6Lf7jowEBQMo4wCZVnueIGw/7C4u0KTBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.43.0': + resolution: {integrity: sha512-sFg+NWJbLfupYTF4WELHAPSnLPOn1jiDZ33Z1jfDnTaA+cC3iB35x0FMMZTFdFOz3icRIArncwCcemJFGXu6TQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.43.0': + resolution: {integrity: sha512-MelWqv68tX6wZEILDrTc9yewiGXe7im62+5x0bNXlCYFOZdA+VnYiJfAihbROsZ5fm90p9C3haFrqjj43XnlAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-arm64-musl@0.43.0': + resolution: {integrity: sha512-ROaWfYh+6BSJ1Arwy5ujijTlwnZetxDxzBpDc1oBR4d7rfrPBqzeyjd5WOudowzQUgyavl2wEpzn1hw3jWcqLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-ppc64-gnu@0.43.0': + resolution: {integrity: sha512-PJRs/uNxmFipJJ8+SyKHh7Y7VZIKQicqrrBzvfyM5CtKi8D7yZKTwUOZV3ffxmiC2e7l1SDJpkBEOyue5NAFsg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-gnu@0.43.0': + resolution: {integrity: sha512-j6biGAgzIhj+EtHXlbNumvwG7XqOIdiU4KgIWRXAEj/iUbHKukKW8eXa4MIwpQwW1YkxovduKtzEAPnjlnAhVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-musl@0.43.0': + resolution: {integrity: sha512-RYWxAcslKxvy7yri24Xm9cmD0RiANaiEPs007EFG6l9h1ChM69Q5SOzACaCoz4Z9dEplnhhneeBaTWMEdpgIbA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-s390x-gnu@0.43.0': + resolution: {integrity: sha512-DT6Q8zfQQy3jxpezAsBACEHNUUixKSYTwdXeXojNHe4DQOoxjPdjr3Szu6BRNjxLykZM/xMNmp9ElOIyDppwtw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxfmt/binding-linux-x64-gnu@0.43.0': + resolution: {integrity: sha512-R8Yk7iYcuZORXmCfFZClqbDxRZgZ9/HEidUuBNdoX8Ptx07cMePnMVJ/woB84lFIDjh2ROHVaOP40Ds3rBXFqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-linux-x64-musl@0.43.0': + resolution: {integrity: sha512-F2YYqyvnQNvi320RWZNAvsaWEHwmW3k4OwNJ1hZxRKXupY63expbBaNp6jAgvYs7y/g546vuQnGHQuCBhslhLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-openharmony-arm64@0.43.0': + resolution: {integrity: sha512-OE6TdietLXV3F6c7pNIhx/9YC1/2YFwjU9DPc/fbjxIX19hNIaP1rS0cFjCGJlGX+cVJwIKWe8Mos+LdQ1yAJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.43.0': + resolution: {integrity: sha512-0nWK6a7pGkbdoypfVicmV9k/N1FwjPZENoqhlTU+5HhZnAhpIO3za30nEE33u6l6tuy9OVfpdXUqxUgZ+4lbZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.43.0': + resolution: {integrity: sha512-9aokTR4Ft+tRdvgN/pKzSkVy2ksc4/dCpDm9L/xFrbIw0yhLtASLbvoG/5WOTUh/BRPPnfGTsWznEqv0dlOmhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.43.0': + resolution: {integrity: sha512-4bPgdQux2ZLWn3bf2TTXXMHcJB4lenmuxrLqygPmvCJ104Yqzj1UctxSRzR31TiJ4MLaG22RK8dUsVpJtrCz5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint/binding-android-arm-eabi@1.58.0': resolution: {integrity: sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -777,6 +894,11 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + oxfmt@0.43.0: + resolution: {integrity: sha512-KTYNG5ISfHSdmeZ25Xzb3qgz9EmQvkaGAxgBY/p38+ZiAet3uZeu7FnMwcSQJg152Qwl0wnYAxDc+Z/H6cvrwA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + oxlint@1.58.0: resolution: {integrity: sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -881,6 +1003,10 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -1172,6 +1298,63 @@ snapshots: '@opentelemetry/semantic-conventions@1.40.0': {} + '@oxfmt/binding-android-arm-eabi@0.43.0': + optional: true + + '@oxfmt/binding-android-arm64@0.43.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.43.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.43.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.43.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.43.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.43.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.43.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.43.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.43.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.43.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.43.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.43.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.43.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.43.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.43.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.43.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.43.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.43.0': + optional: true + '@oxlint/binding-android-arm-eabi@1.58.0': optional: true @@ -1549,6 +1732,30 @@ snapshots: normalize-path@3.0.0: {} + oxfmt@0.43.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.43.0 + '@oxfmt/binding-android-arm64': 0.43.0 + '@oxfmt/binding-darwin-arm64': 0.43.0 + '@oxfmt/binding-darwin-x64': 0.43.0 + '@oxfmt/binding-freebsd-x64': 0.43.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.43.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.43.0 + '@oxfmt/binding-linux-arm64-gnu': 0.43.0 + '@oxfmt/binding-linux-arm64-musl': 0.43.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.43.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.43.0 + '@oxfmt/binding-linux-riscv64-musl': 0.43.0 + '@oxfmt/binding-linux-s390x-gnu': 0.43.0 + '@oxfmt/binding-linux-x64-gnu': 0.43.0 + '@oxfmt/binding-linux-x64-musl': 0.43.0 + '@oxfmt/binding-openharmony-arm64': 0.43.0 + '@oxfmt/binding-win32-arm64-msvc': 0.43.0 + '@oxfmt/binding-win32-ia32-msvc': 0.43.0 + '@oxfmt/binding-win32-x64-msvc': 0.43.0 + oxlint@1.58.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.58.0 @@ -1680,6 +1887,8 @@ snapshots: tinypool@1.1.1: {} + tinypool@2.1.0: {} + tinyrainbow@2.0.0: {} tinyspy@4.0.4: {} diff --git a/src/client/index.ts b/src/client/index.ts index 3c21486..9427c9c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,7 +1,7 @@ -import { resolveConfig } from "@/config/index.js" -import type { Leap0ConfigInput, SandboxData } from "@/models/index.js" -import { initOtel } from "@/core/otel.js" -import { Leap0Transport } from "@/core/transport.js" +import { resolveConfig } from "@/config/index.js"; +import type { Leap0ConfigInput } from "@/models/index.js"; +import { initOtel } from "@/core/otel.js"; +import { Leap0Transport } from "@/core/transport.js"; import { CodeInterpreterClient, DesktopClient, @@ -14,49 +14,47 @@ import { SnapshotsClient, SshClient, TemplatesClient, -} from "@/services/index.js" +} from "@/services/index.js"; -import { Sandbox } from "@/client/sandbox.js" +import { Sandbox } from "@/client/sandbox.js"; /** * Top-level Leap0 SDK client that exposes all service groups. */ export class Leap0Client { - private readonly transport: Leap0Transport + private readonly transport: Leap0Transport; - readonly sandboxes: SandboxesClient - readonly snapshots: SnapshotsClient - readonly templates: TemplatesClient - readonly filesystem: FilesystemClient - readonly git: GitClient - readonly process: ProcessClient - readonly pty: PtyClient - readonly lsp: LspClient - readonly ssh: SshClient - readonly codeInterpreter: CodeInterpreterClient - readonly desktop: DesktopClient + readonly sandboxes: SandboxesClient; + readonly snapshots: SnapshotsClient; + readonly templates: TemplatesClient; + readonly filesystem: FilesystemClient; + readonly git: GitClient; + readonly process: ProcessClient; + readonly pty: PtyClient; + readonly lsp: LspClient; + readonly ssh: SshClient; + readonly codeInterpreter: CodeInterpreterClient; + readonly desktop: DesktopClient; /** Creates a client using explicit config or environment variables. */ constructor(config: Leap0ConfigInput = {}) { - const resolved = resolveConfig(config) - this.transport = new Leap0Transport(resolved) + const resolved = resolveConfig(config); + this.transport = new Leap0Transport(resolved); if (resolved.sdkOtelEnabled) { - initOtel(resolved) + initOtel(resolved); } - const wrapSandbox = (data: SandboxData) => new Sandbox(this, data) - - this.sandboxes = new SandboxesClient(this.transport, resolved.sandboxDomain, wrapSandbox) - this.snapshots = new SnapshotsClient(this.transport, wrapSandbox) - this.templates = new TemplatesClient(this.transport) - this.filesystem = new FilesystemClient(this.transport) - this.git = new GitClient(this.transport) - this.process = new ProcessClient(this.transport) - this.pty = new PtyClient(this.transport, resolved.sandboxDomain) - this.lsp = new LspClient(this.transport) - this.ssh = new SshClient(this.transport) - this.codeInterpreter = new CodeInterpreterClient(this.transport, resolved.sandboxDomain) - this.desktop = new DesktopClient(this.transport, resolved.sandboxDomain) + this.sandboxes = new SandboxesClient(this.transport, resolved.sandboxDomain); + this.snapshots = new SnapshotsClient(this.transport); + this.templates = new TemplatesClient(this.transport); + this.filesystem = new FilesystemClient(this.transport); + this.git = new GitClient(this.transport); + this.process = new ProcessClient(this.transport); + this.pty = new PtyClient(this.transport, resolved.sandboxDomain); + this.lsp = new LspClient(this.transport); + this.ssh = new SshClient(this.transport); + this.codeInterpreter = new CodeInterpreterClient(this.transport, resolved.sandboxDomain); + this.desktop = new DesktopClient(this.transport, resolved.sandboxDomain); } /** @@ -68,8 +66,9 @@ export class Leap0Client { * Returns: * The sandbox payload. */ - getSandbox(sandboxId: string): Promise { - return this.sandboxes.get(sandboxId) + async getSandbox(sandboxId: string): Promise { + const data = await this.sandboxes.get(sandboxId); + return new Sandbox(this, data); } /** @@ -82,14 +81,36 @@ export class Leap0Client { * Returns: * The created sandbox payload. */ - createSandbox(params?: Parameters["create"]>[0], options?: Parameters["create"]>[1]): Promise { - return this.sandboxes.create(params, options) + async createSandbox( + params?: Parameters[0], + options?: Parameters[1], + ): Promise { + const data = await this.sandboxes.create(params, options); + return new Sandbox(this, data); + } + + /** + * Resumes a sandbox from a snapshot. + * + * Args: + * params: Snapshot resume parameters. + * options: Optional request settings. + * + * Returns: + * The restored sandbox payload. + */ + async resumeSnapshot( + params: Parameters[0], + options?: Parameters[1], + ): Promise { + const data = await this.snapshots.resume(params, options); + return new Sandbox(this, data); } /** Closes the underlying transport. */ async close(): Promise { - await this.transport.close() + await this.transport.close(); } } -export { Sandbox } from "@/client/sandbox.js" +export { Sandbox } from "@/client/sandbox.js"; diff --git a/src/client/sandbox.ts b/src/client/sandbox.ts index 79f7cfb..c7aadf3 100644 --- a/src/client/sandbox.ts +++ b/src/client/sandbox.ts @@ -1,4 +1,4 @@ -import type { SandboxData, SandboxState } from "@/models/index.js" +import type { SandboxData, SandboxState } from "@/models/index.js"; import { CodeInterpreterClient, DesktopClient, @@ -8,31 +8,37 @@ import { ProcessClient, PtyClient, SshClient, -} from "@/services/index.js" +} from "@/services/index.js"; -import type { Leap0Client } from "@/client/index.js" +import type { Leap0Client } from "@/client/index.js"; -type BoundSandboxMethod = Method extends (sandbox: infer _Sandbox, ...args: infer Args) => infer Result +type BoundSandboxMethod = Method extends ( + sandbox: infer _Sandbox, + ...args: infer Args +) => infer Result ? (...args: Args) => Result - : Method + : Method; type BoundSandboxService = { - [Key in keyof Service]: BoundSandboxMethod -} + [Key in keyof Service]: BoundSandboxMethod; +}; class SandboxServiceProxy { - constructor(private readonly service: Service, private readonly sandbox: Sandbox) {} + constructor( + private readonly service: Service, + private readonly sandbox: Sandbox, + ) {} get proxy(): BoundSandboxService { return new Proxy(this.service, { get: (target, prop, receiver) => { - const value = Reflect.get(target, prop, receiver) + const value = Reflect.get(target, prop, receiver); if (typeof value !== "function") { - return value + return value; } - return (...args: unknown[]) => Reflect.apply(value, target, [this.sandbox, ...args]) + return (...args: unknown[]) => Reflect.apply(value, target, [this.sandbox, ...args]); }, - }) as BoundSandboxService + }) as BoundSandboxService; } } @@ -40,39 +46,40 @@ class SandboxServiceProxy { * Bound sandbox handle with convenience methods and resource-scoped helpers. */ export class Sandbox implements SandboxData { - id!: string - templateName?: string - state?: SandboxState - vcpu?: number - memoryMib?: number - timeoutMin?: number - autoPause?: boolean - envVars?: Record - networkPolicy?: SandboxData["networkPolicy"] - createdAt?: string - updatedAt?: string - [key: string]: unknown + id!: string; + templateId!: string; + state!: SandboxState; + vcpu!: number; + memoryMib!: number; + diskMib!: number; + autoPause!: boolean; + networkPolicy?: SandboxData["networkPolicy"]; + createdAt!: string; + [key: string]: unknown; - readonly filesystem: BoundSandboxService - readonly git: BoundSandboxService - readonly process: BoundSandboxService - readonly pty: BoundSandboxService - readonly lsp: BoundSandboxService - readonly ssh: BoundSandboxService - readonly codeInterpreter: BoundSandboxService - readonly desktop: BoundSandboxService + readonly filesystem: BoundSandboxService; + readonly git: BoundSandboxService; + readonly process: BoundSandboxService; + readonly pty: BoundSandboxService; + readonly lsp: BoundSandboxService; + readonly ssh: BoundSandboxService; + readonly codeInterpreter: BoundSandboxService; + readonly desktop: BoundSandboxService; /** Creates a sandbox handle bound to a parent client. */ - constructor(private readonly client: Leap0Client, data: SandboxData) { - this.update(data) - this.filesystem = new SandboxServiceProxy(client.filesystem, this).proxy - this.git = new SandboxServiceProxy(client.git, this).proxy - this.process = new SandboxServiceProxy(client.process, this).proxy - this.pty = new SandboxServiceProxy(client.pty, this).proxy - this.lsp = new SandboxServiceProxy(client.lsp, this).proxy - this.ssh = new SandboxServiceProxy(client.ssh, this).proxy - this.codeInterpreter = new SandboxServiceProxy(client.codeInterpreter, this).proxy - this.desktop = new SandboxServiceProxy(client.desktop, this).proxy + constructor( + private readonly client: Leap0Client, + data: SandboxData, + ) { + this.update(data); + this.filesystem = new SandboxServiceProxy(client.filesystem, this).proxy; + this.git = new SandboxServiceProxy(client.git, this).proxy; + this.process = new SandboxServiceProxy(client.process, this).proxy; + this.pty = new SandboxServiceProxy(client.pty, this).proxy; + this.lsp = new SandboxServiceProxy(client.lsp, this).proxy; + this.ssh = new SandboxServiceProxy(client.ssh, this).proxy; + this.codeInterpreter = new SandboxServiceProxy(client.codeInterpreter, this).proxy; + this.desktop = new SandboxServiceProxy(client.desktop, this).proxy; } /** @@ -85,8 +92,8 @@ export class Sandbox implements SandboxData { * The updated sandbox handle. */ update(data: SandboxData): this { - Object.assign(this, data) - return this + Object.assign(this, data); + return this; } /** @@ -96,19 +103,22 @@ export class Sandbox implements SandboxData { * The refreshed sandbox handle. */ async refresh(): Promise { - this.update(await this.client.sandboxes.get(this.id)) - return this + this.update(await this.client.sandboxes.get(this.id)); + return this; } /** * Pauses the sandbox and updates local state. * + * Args: + * options: Optional request settings. + * * Returns: * The paused sandbox handle. */ - async pause(): Promise { - this.update(await this.client.sandboxes.pause(this.id)) - return this + async pause(options?: { timeout?: number }): Promise { + this.update(await this.client.sandboxes.pause(this.id, options)); + return this; } /** @@ -118,7 +128,7 @@ export class Sandbox implements SandboxData { * options: Optional request settings. */ async delete(options?: { timeout?: number }): Promise { - await this.client.sandboxes.delete(this.id, options) + await this.client.sandboxes.delete(this.id, options); } /** @@ -132,7 +142,7 @@ export class Sandbox implements SandboxData { * The public HTTPS URL. */ invokeUrl(path = "/", port?: number): string { - return this.client.sandboxes.invokeUrl(this.id, path, port) + return this.client.sandboxes.invokeUrl(this.id, path, port); } /** @@ -146,6 +156,6 @@ export class Sandbox implements SandboxData { * The public websocket URL. */ websocketUrl(path = "/", port?: number): string { - return this.client.sandboxes.websocketUrl(this.id, path, port) + return this.client.sandboxes.websocketUrl(this.id, path, port); } } diff --git a/src/config/constants.ts b/src/config/constants.ts index 4fc576a..af81da4 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,10 +1,10 @@ -export const DEFAULT_BASE_URL = "https://api.leap0.dev" -export const DEFAULT_SANDBOX_DOMAIN = "sandbox.leap0.dev" -export const DEFAULT_TEMPLATE_NAME = "system/debian:bookworm" -export const DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME = "system/code-interpreter:v0.1.0" -export const DEFAULT_DESKTOP_TEMPLATE_NAME = "system/desktop:v0.1.0" -export const DEFAULT_VCPU = 1 -export const DEFAULT_MEMORY_MIB = 1024 -export const DEFAULT_TIMEOUT_MIN = 5 -export const DEFAULT_CLIENT_TIMEOUT = 300 -export const SDK_SOURCE = "sdk-ts" +export const DEFAULT_BASE_URL = "https://api.leap0.dev"; +export const DEFAULT_SANDBOX_DOMAIN = "sandbox.leap0.dev"; +export const DEFAULT_TEMPLATE_NAME = "system/debian:bookworm"; +export const DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME = "system/code-interpreter:v0.1.0"; +export const DEFAULT_DESKTOP_TEMPLATE_NAME = "system/desktop:v0.1.0"; +export const DEFAULT_VCPU = 1; +export const DEFAULT_MEMORY_MIB = 1024; +export const DEFAULT_TIMEOUT_MIN = 5; +export const DEFAULT_CLIENT_TIMEOUT = 300; +export const SDK_SOURCE = "sdk-ts"; diff --git a/src/config/index.ts b/src/config/index.ts index fdc454a..d131287 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -2,21 +2,21 @@ import { DEFAULT_BASE_URL, DEFAULT_CLIENT_TIMEOUT, DEFAULT_SANDBOX_DOMAIN, -} from "@/config/constants.js" -import { Leap0Error } from "@/core/errors.js" -import { leap0ConfigInputSchema, leap0ConfigResolvedSchema } from "@/models/config.js" -import type { Leap0ConfigInput, Leap0ConfigResolved } from "@/models/config.js" -import { trimSlash } from "@/core/utils.js" +} from "@/config/constants.js"; +import { Leap0Error } from "@/core/errors.js"; +import { leap0ConfigInputSchema, leap0ConfigResolvedSchema } from "@/models/config.js"; +import type { Leap0ConfigInput, Leap0ConfigResolved } from "@/models/config.js"; +import { trimSlash } from "@/core/utils.js"; function readEnv(name: string): string | undefined { - return process.env[name] + return process.env[name]; } function requireNonEmpty(value: string | undefined, label: string): string { if (!value?.trim()) { - throw new Leap0Error(`${label} is required`) + throw new Leap0Error(`${label} is required`); } - return value.trim() + return value.trim(); } /** @@ -29,26 +29,32 @@ function requireNonEmpty(value: string | undefined, label: string): string { * The validated runtime configuration used by the SDK. */ export function resolveConfig(input: Leap0ConfigInput = {}): Leap0ConfigResolved { - leap0ConfigInputSchema.parse(input) + leap0ConfigInputSchema.parse(input); - const apiKey = requireNonEmpty(input.apiKey ?? readEnv("LEAP0_API_KEY"), "API key") - const baseUrl = trimSlash((input.baseUrl ?? readEnv("LEAP0_BASE_URL") ?? DEFAULT_BASE_URL).trim()) + const apiKey = requireNonEmpty(input.apiKey ?? readEnv("LEAP0_API_KEY"), "API key"); + const baseUrl = trimSlash( + (input.baseUrl ?? readEnv("LEAP0_BASE_URL") ?? DEFAULT_BASE_URL).trim(), + ); const sandboxDomain = trimSlash( (input.sandboxDomain ?? readEnv("LEAP0_SANDBOX_DOMAIN") ?? DEFAULT_SANDBOX_DOMAIN).trim(), - ) - const timeout = input.timeout ?? DEFAULT_CLIENT_TIMEOUT - const authHeader = (input.authHeader ?? "authorization").trim() - const bearer = input.bearer ?? true - const envOtel = readEnv("LEAP0_SDK_OTEL_ENABLED") + ); + const timeout = input.timeout ?? DEFAULT_CLIENT_TIMEOUT; + const authHeader = (input.authHeader ?? "authorization").trim(); + const bearer = input.bearer ?? true; + const envOtel = readEnv("LEAP0_SDK_OTEL_ENABLED"); const sdkOtelEnabled = input.sdkOtelEnabled ?? - (envOtel === "true" ? true : envOtel === "false" ? false : Boolean(readEnv("OTEL_EXPORTER_OTLP_ENDPOINT"))) + (envOtel === "true" + ? true + : envOtel === "false" + ? false + : Boolean(readEnv("OTEL_EXPORTER_OTLP_ENDPOINT"))); if (!Number.isFinite(timeout) || timeout <= 0) { - throw new Leap0Error("timeout must be a positive number") + throw new Leap0Error("timeout must be a positive number"); } if (!authHeader) { - throw new Leap0Error("authHeader cannot be empty") + throw new Leap0Error("authHeader cannot be empty"); } return leap0ConfigResolvedSchema.parse({ @@ -59,5 +65,5 @@ export function resolveConfig(input: Leap0ConfigInput = {}): Leap0ConfigResolved authHeader, bearer, sdkOtelEnabled, - }) + }); } diff --git a/src/core/errors.ts b/src/core/errors.ts index 71200b8..202c943 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,66 +1,66 @@ export class Leap0Error extends Error { - readonly statusCode?: number - readonly headers?: Headers - readonly body?: unknown - readonly retryable: boolean + readonly statusCode?: number; + readonly headers?: Headers; + readonly body?: unknown; + readonly retryable: boolean; constructor( message: string, options: { - statusCode?: number - headers?: Headers - body?: unknown - retryable?: boolean - cause?: unknown + statusCode?: number; + headers?: Headers; + body?: unknown; + retryable?: boolean; + cause?: unknown; } = {}, ) { - super(message, { cause: options.cause }) - this.name = "Leap0Error" - this.statusCode = options.statusCode - this.headers = options.headers - this.body = options.body - this.retryable = options.retryable ?? false + super(message, { cause: options.cause }); + this.name = "Leap0Error"; + this.statusCode = options.statusCode; + this.headers = options.headers; + this.body = options.body; + this.retryable = options.retryable ?? false; } } export class Leap0PermissionError extends Leap0Error { constructor(message: string, options: ConstructorParameters[1] = {}) { - super(message, options) - this.name = "Leap0PermissionError" + super(message, options); + this.name = "Leap0PermissionError"; } } export class Leap0NotFoundError extends Leap0Error { constructor(message: string, options: ConstructorParameters[1] = {}) { - super(message, options) - this.name = "Leap0NotFoundError" + super(message, options); + this.name = "Leap0NotFoundError"; } } export class Leap0ConflictError extends Leap0Error { constructor(message: string, options: ConstructorParameters[1] = {}) { - super(message, options) - this.name = "Leap0ConflictError" + super(message, options); + this.name = "Leap0ConflictError"; } } export class Leap0RateLimitError extends Leap0Error { constructor(message: string, options: ConstructorParameters[1] = {}) { - super(message, { ...options, retryable: true }) - this.name = "Leap0RateLimitError" + super(message, { ...options, retryable: true }); + this.name = "Leap0RateLimitError"; } } export class Leap0TimeoutError extends Leap0Error { constructor(message: string, options: ConstructorParameters[1] = {}) { - super(message, { ...options, retryable: true }) - this.name = "Leap0TimeoutError" + super(message, { ...options, retryable: true }); + this.name = "Leap0TimeoutError"; } } export class Leap0WebSocketError extends Leap0Error { constructor(message: string, options: ConstructorParameters[1] = {}) { - super(message, options) - this.name = "Leap0WebSocketError" + super(message, options); + this.name = "Leap0WebSocketError"; } } diff --git a/src/core/normalize.ts b/src/core/normalize.ts new file mode 100644 index 0000000..4ef1311 --- /dev/null +++ b/src/core/normalize.ts @@ -0,0 +1,28 @@ +import type { z } from "zod"; + +function camelizeSegment(value: string): string { + return value.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase()); +} + +export function camelizeKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => camelizeKeys(item)); + } + + if ( + value == null || + typeof value !== "object" || + value instanceof Date || + value instanceof Uint8Array + ) { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [camelizeSegment(key), camelizeKeys(item)]), + ); +} + +export function normalize(schema: z.ZodType, value: unknown): T { + return schema.parse(camelizeKeys(value)); +} diff --git a/src/core/otel.ts b/src/core/otel.ts index c5368c1..d5515bc 100644 --- a/src/core/otel.ts +++ b/src/core/otel.ts @@ -1,18 +1,18 @@ -import { metrics, SpanStatusCode, trace } from "@opentelemetry/api" -import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http" -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" -import { resourceFromAttributes } from "@opentelemetry/resources" -import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics" -import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base" -import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node" -import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions" +import { metrics, SpanStatusCode, trace } from "@opentelemetry/api"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"; -import type { Leap0ConfigResolved } from "@/models/config.js" -import { SDK_VERSION } from "@/core/version.js" +import type { Leap0ConfigResolved } from "@/models/config.js"; +import { SDK_VERSION } from "@/core/version.js"; -const TRACER_NAME = "leap0-js-sdk" -let tracerProviderInitialized = false -let meterProviderInitialized = false +const TRACER_NAME = "leap0-js-sdk"; +let tracerProviderInitialized = false; +let meterProviderInitialized = false; /** * Returns the shared OpenTelemetry tracer for the SDK. @@ -21,7 +21,7 @@ let meterProviderInitialized = false * The tracer used for SDK spans. */ export function getTracer() { - return trace.getTracer(TRACER_NAME, SDK_VERSION) + return trace.getTracer(TRACER_NAME, SDK_VERSION); } /** @@ -34,22 +34,22 @@ export function initOtel(_config: Leap0ConfigResolved): void { const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: TRACER_NAME, [ATTR_SERVICE_VERSION]: SDK_VERSION, - }) + }); if (!tracerProviderInitialized) { const tracerProvider = new NodeTracerProvider({ resource, spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())], - }) - trace.setGlobalTracerProvider(tracerProvider) - tracerProviderInitialized = true + }); + trace.setGlobalTracerProvider(tracerProvider); + tracerProviderInitialized = true; } if (!meterProviderInitialized) { - const metricReader = new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }) - const meterProvider = new MeterProvider({ resource, readers: [metricReader] }) - metrics.setGlobalMeterProvider(meterProvider) - meterProviderInitialized = true + const metricReader = new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }); + const meterProvider = new MeterProvider({ resource, readers: [metricReader] }); + metrics.setGlobalMeterProvider(meterProvider); + meterProviderInitialized = true; } } @@ -72,23 +72,26 @@ export async function withSpan( fn: () => Promise, ): Promise { if (!config.sdkOtelEnabled) { - return fn() + return fn(); } return getTracer().startActiveSpan(name, async (span) => { - span.setAttributes(attributes) + span.setAttributes(attributes); try { - const result = await fn() - span.setStatus({ code: SpanStatusCode.OK }) - return result + const result = await fn(); + span.setStatus({ code: SpanStatusCode.OK }); + return result; } catch (error) { - span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error) }) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); if (error instanceof Error) { - span.recordException(error) + span.recordException(error); } - throw error + throw error; } finally { - span.end() + span.end(); } - }) + }); } diff --git a/src/core/transport.ts b/src/core/transport.ts index c2abf80..dac1a9d 100644 --- a/src/core/transport.ts +++ b/src/core/transport.ts @@ -1,4 +1,4 @@ -import { SDK_SOURCE } from "@/config/constants.js" +import { SDK_SOURCE } from "@/config/constants.js"; import { Leap0ConflictError, Leap0Error, @@ -6,20 +6,20 @@ import { Leap0PermissionError, Leap0RateLimitError, Leap0TimeoutError, -} from "@/core/errors.js" -import type { Leap0ConfigResolved } from "@/models/config.js" -import type { RequestOptions } from "@/models/index.js" -import { withSpan } from "@/core/otel.js" -import { withQuery } from "@/core/utils.js" -import { SDK_VERSION } from "@/core/version.js" +} from "@/core/errors.js"; +import type { Leap0ConfigResolved } from "@/models/config.js"; +import type { RequestOptions } from "@/models/index.js"; +import { withSpan } from "@/core/otel.js"; +import { withQuery } from "@/core/utils.js"; +import { SDK_VERSION } from "@/core/version.js"; -type BodyLike = BodyInit | null +type BodyLike = BodyInit | null; /** * Low-level HTTP transport used by all Leap0 service clients. */ export class Leap0Transport { - private closed = false + private closed = false; constructor(private readonly config: Leap0ConfigResolved) {} @@ -33,12 +33,15 @@ export class Leap0Transport { * The final header set. */ headers(extra?: HeadersInit): Headers { - const headers = new Headers(extra) - headers.set(this.config.authHeader, this.config.bearer ? `Bearer ${this.config.apiKey}` : this.config.apiKey) - headers.set("Leap0-Source", SDK_SOURCE) - headers.set("Leap0-SDK-Version", SDK_VERSION) - headers.set("User-Agent", `leap0-js/${SDK_VERSION}`) - return headers + const headers = new Headers(extra); + headers.set( + this.config.authHeader, + this.config.bearer ? `Bearer ${this.config.apiKey}` : this.config.apiKey, + ); + headers.set("Leap0-Source", SDK_SOURCE); + headers.set("Leap0-SDK-Version", SDK_VERSION); + headers.set("User-Agent", `leap0-js/${SDK_VERSION}`); + return headers; } /** @@ -48,7 +51,7 @@ export class Leap0Transport { * A promise that resolves once the transport is closed. */ async close(): Promise { - this.closed = true + this.closed = true; } /** @@ -62,8 +65,17 @@ export class Leap0Transport { * Returns: * The raw fetch response. */ - async request(path: string, init: RequestInit = {}, options: RequestOptions = {}): Promise { - return this.performRequest(`${this.config.baseUrl}${withQuery(path, options.query)}`, path, init, options) + async request( + path: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): Promise { + return this.performRequest( + `${this.config.baseUrl}${withQuery(path, options.query)}`, + path, + init, + options, + ); } /** @@ -77,24 +89,33 @@ export class Leap0Transport { * Returns: * The raw fetch response. */ - async requestUrl(url: string, init: RequestInit = {}, options: RequestOptions = {}): Promise { - const requestUrl = new URL(url) + async requestUrl( + url: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): Promise { + const requestUrl = new URL(url); for (const [key, value] of Object.entries(options.query ?? {})) { if (value !== undefined) { - requestUrl.searchParams.set(key, String(value)) + requestUrl.searchParams.set(key, String(value)); } } - return this.performRequest(requestUrl.toString(), requestUrl.pathname, init, options) + return this.performRequest(requestUrl.toString(), requestUrl.pathname, init, options); } - private async performRequest(url: string, spanPath: string, init: RequestInit = {}, options: RequestOptions = {}): Promise { + private async performRequest( + url: string, + spanPath: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): Promise { if (this.closed) { - throw new Leap0Error("Client is closed") + throw new Leap0Error("Client is closed"); } - const controller = new AbortController() - const timeoutMs = (options.timeout ?? this.config.timeout) * 1000 - const timer = setTimeout(() => controller.abort(), timeoutMs) + const controller = new AbortController(); + const timeoutMs = (options.timeout ?? this.config.timeout) * 1000; + const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await withSpan( @@ -109,26 +130,26 @@ export class Leap0Transport { ...init, headers: this.headers(options.headers ?? init.headers), signal: controller.signal, - }) + }); if (!response.ok) { - throw await this.mapHttpError(response) + throw await this.mapHttpError(response); } - return response + return response; }, - ) + ); } catch (error) { if (error instanceof Leap0Error) { - throw error + throw error; } if (error instanceof DOMException && error.name === "AbortError") { - throw new Leap0TimeoutError("Request timed out", { cause: error }) + throw new Leap0TimeoutError("Request timed out", { cause: error }); } throw new Leap0Error(error instanceof Error ? error.message : "Request failed", { cause: error, retryable: true, - }) + }); } finally { - clearTimeout(timer) + clearTimeout(timer); } } @@ -143,16 +164,20 @@ export class Leap0Transport { * Returns: * The parsed JSON response. */ - async requestJson(path: string, init: RequestInit = {}, options: RequestOptions = {}): Promise { - const headers = new Headers(init.headers) + async requestJson( + path: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): Promise { + const headers = new Headers(init.headers); if (init.body != null && !headers.has("content-type") && !(init.body instanceof FormData)) { - headers.set("content-type", "application/json") + headers.set("content-type", "application/json"); } - const response = await this.request(path, { ...init, headers }, options) + const response = await this.request(path, { ...init, headers }, options); if (response.status === 204) { - return undefined as T + return undefined as T; } - return (await response.json()) as T + return (await response.json()) as T; } /** @@ -166,16 +191,20 @@ export class Leap0Transport { * Returns: * The parsed JSON response. */ - async requestJsonUrl(url: string, init: RequestInit = {}, options: RequestOptions = {}): Promise { - const headers = new Headers(init.headers) + async requestJsonUrl( + url: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): Promise { + const headers = new Headers(init.headers); if (init.body != null && !headers.has("content-type") && !(init.body instanceof FormData)) { - headers.set("content-type", "application/json") + headers.set("content-type", "application/json"); } - const response = await this.requestUrl(url, { ...init, headers }, options) + const response = await this.requestUrl(url, { ...init, headers }, options); if (response.status === 204) { - return undefined as T + return undefined as T; } - return (await response.json()) as T + return (await response.json()) as T; } /** @@ -189,8 +218,12 @@ export class Leap0Transport { * Returns: * The response body text. */ - async requestText(path: string, init: RequestInit = {}, options: RequestOptions = {}): Promise { - return await (await this.request(path, init, options)).text() + async requestText( + path: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): Promise { + return await (await this.request(path, init, options)).text(); } /** @@ -204,8 +237,31 @@ export class Leap0Transport { * Returns: * The response body bytes. */ - async requestBytes(path: string, init: RequestInit = {}, options: RequestOptions = {}): Promise { - return new Uint8Array(await (await this.request(path, init, options)).arrayBuffer()) + async requestBytes( + path: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): Promise { + return new Uint8Array(await (await this.request(path, init, options)).arrayBuffer()); + } + + /** + * Sends a request to an absolute URL and returns the response body as bytes. + * + * Args: + * url: Fully-qualified request URL. + * init: Fetch request options. + * options: Leap0 request options such as timeout and query params. + * + * Returns: + * The response body bytes. + */ + async requestBytesUrl( + url: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): Promise { + return new Uint8Array(await (await this.requestUrl(url, init, options)).arrayBuffer()); } /** @@ -219,9 +275,13 @@ export class Leap0Transport { * Yields: * Parsed JSON event payloads. */ - async *streamJson(path: string, init: RequestInit = {}, options: RequestOptions = {}): AsyncIterable { - const response = await this.request(path, init, options) - yield* this.parseJsonStream(response) + async *streamJson( + path: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): AsyncIterable { + const response = await this.request(path, init, options); + yield* this.parseJsonStream(response); } /** @@ -235,62 +295,71 @@ export class Leap0Transport { * Yields: * Parsed JSON event payloads. */ - async *streamJsonUrl(url: string, init: RequestInit = {}, options: RequestOptions = {}): AsyncIterable { - const response = await this.requestUrl(url, init, options) - yield* this.parseJsonStream(response) + async *streamJsonUrl( + url: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): AsyncIterable { + const response = await this.requestUrl(url, init, options); + yield* this.parseJsonStream(response); } private async *parseJsonStream(response: Response): AsyncIterable { - const reader = response.body?.getReader() + const reader = response.body?.getReader(); if (!reader) { - return + return; } - const decoder = new TextDecoder() - let buffer = "" + const decoder = new TextDecoder(); + let buffer = ""; while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); while (true) { - const boundary = buffer.indexOf("\n\n") - if (boundary === -1) break - const rawEvent = buffer.slice(0, boundary) - buffer = buffer.slice(boundary + 2) + const boundary = buffer.indexOf("\n\n"); + if (boundary === -1) break; + const rawEvent = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); const dataLines = rawEvent .split("\n") .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).trim()) - if (dataLines.length === 0) continue - const payload = dataLines.join("\n") - if (!payload || payload === "[DONE]") continue + .map((line) => line.slice(5).trim()); + if (dataLines.length === 0) continue; + const payload = dataLines.join("\n"); + if (!payload || payload === "[DONE]") continue; try { - yield JSON.parse(payload) + yield JSON.parse(payload); } catch { - continue + // skip malformed JSON } } } } private async mapHttpError(response: Response): Promise { - let body: unknown - let message = `Request failed with status ${response.status}` + let body: unknown; + let message = `Request failed with status ${response.status}`; try { - body = await response.clone().json() - if (body && typeof body === "object" && "message" in body && typeof body.message === "string") { - message = body.message + body = await response.clone().json(); + if ( + body && + typeof body === "object" && + "message" in body && + typeof body.message === "string" + ) { + message = body.message; } } catch { try { - const text = await response.text() - body = text - if (text) message = text + const text = await response.text(); + body = text; + if (text) message = text; } catch { - body = undefined + body = undefined; } } @@ -299,13 +368,13 @@ export class Leap0Transport { headers: response.headers, body, retryable: response.status === 429 || response.status >= 500, - } + }; - if (response.status === 403) return new Leap0PermissionError(message, options) - if (response.status === 404) return new Leap0NotFoundError(message, options) - if (response.status === 409) return new Leap0ConflictError(message, options) - if (response.status === 429) return new Leap0RateLimitError(message, options) - return new Leap0Error(message, options) + if (response.status === 403) return new Leap0PermissionError(message, options); + if (response.status === 404) return new Leap0NotFoundError(message, options); + if (response.status === 409) return new Leap0ConflictError(message, options); + if (response.status === 429) return new Leap0RateLimitError(message, options); + return new Leap0Error(message, options); } } @@ -319,5 +388,5 @@ export class Leap0Transport { * A JSON string body or null. */ export function jsonBody(value: unknown): BodyLike { - return value == null ? null : JSON.stringify(value) + return value == null ? null : JSON.stringify(value); } diff --git a/src/core/utils.ts b/src/core/utils.ts index 5afecf0..78a1ef4 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,5 +1,5 @@ -import { DEFAULT_BASE_URL } from "@/config/constants.js" -import type { SandboxRef, SnapshotRef, TemplateRef } from "@/models/index.js" +import { DEFAULT_BASE_URL } from "@/config/constants.js"; +import type { SandboxRef, SnapshotRef, TemplateRef } from "@/models/index.js"; /** * Extracts a sandbox ID from a string or sandbox-like object. @@ -11,7 +11,7 @@ import type { SandboxRef, SnapshotRef, TemplateRef } from "@/models/index.js" * The sandbox ID string. */ export function sandboxIdOf(sandbox: SandboxRef): string { - return typeof sandbox === "string" ? sandbox : sandbox.id + return typeof sandbox === "string" ? sandbox : sandbox.id; } /** @@ -24,7 +24,7 @@ export function sandboxIdOf(sandbox: SandboxRef): string { * The snapshot ID string. */ export function snapshotIdOf(snapshot: SnapshotRef): string { - return typeof snapshot === "string" ? snapshot : snapshot.id + return typeof snapshot === "string" ? snapshot : snapshot.id; } /** @@ -37,7 +37,7 @@ export function snapshotIdOf(snapshot: SnapshotRef): string { * The template ID string. */ export function templateIdOf(template: TemplateRef): string { - return typeof template === "string" ? template : template.id + return typeof template === "string" ? template : template.id; } /** @@ -50,7 +50,7 @@ export function templateIdOf(template: TemplateRef): string { * The normalized string. */ export function trimSlash(value: string): string { - return value.replace(/\/+$/, "") + return value.replace(/\/+$/, ""); } /** @@ -63,7 +63,7 @@ export function trimSlash(value: string): string { * The normalized path. */ export function ensureLeadingSlash(value: string): string { - return value.startsWith("/") ? value : `/${value}` + return value.startsWith("/") ? value : `/${value}`; } /** @@ -78,8 +78,9 @@ export function ensureLeadingSlash(value: string): string { * The HTTPS origin for the sandbox. */ export function sandboxBaseUrl(sandboxId: string, sandboxDomain: string, port?: number): string { - const host = port == null ? `${sandboxId}.${sandboxDomain}` : `${sandboxId}-${port}.${sandboxDomain}` - return `https://${host}` + const host = + port == null ? `${sandboxId}.${sandboxDomain}` : `${sandboxId}-${port}.${sandboxDomain}`; + return `https://${host}`; } /** @@ -92,9 +93,9 @@ export function sandboxBaseUrl(sandboxId: string, sandboxDomain: string, port?: * The websocket URL. */ export function websocketUrlFromHttp(url: string): string { - if (url.startsWith("https://")) return `wss://${url.slice("https://".length)}` - if (url.startsWith("http://")) return `ws://${url.slice("http://".length)}` - return url + if (url.startsWith("https://")) return `wss://${url.slice("https://".length)}`; + if (url.startsWith("http://")) return `ws://${url.slice("http://".length)}`; + return url; } /** @@ -107,14 +108,17 @@ export function websocketUrlFromHttp(url: string): string { * Returns: * The path with query string applied. */ -export function withQuery(path: string, query?: Record): string { - const url = new URL(path, DEFAULT_BASE_URL) +export function withQuery( + path: string, + query?: Record, +): string { + const url = new URL(path, DEFAULT_BASE_URL); for (const [key, value] of Object.entries(query ?? {})) { if (value !== undefined) { - url.searchParams.set(key, String(value)) + url.searchParams.set(key, String(value)); } } - return `${url.pathname}${url.search}` + return `${url.pathname}${url.search}`; } /** @@ -127,6 +131,6 @@ export function withQuery(path: string, query?: Record +export const codeLanguageSchema = z.enum([CodeLanguage.PYTHON, CodeLanguage.TYPESCRIPT]); +export type CodeLanguage = z.infer; export const streamEventTypeSchema = z.enum([ StreamEventType.STDOUT, StreamEventType.STDERR, StreamEventType.EXIT, StreamEventType.ERROR, -]) -export type StreamEventType = z.infer +]); +export type StreamEventType = z.infer; -export const codeContextSchema = z.object({ - id: z.string(), - language: z.union([codeLanguageSchema, z.string()]).optional(), - createdAt: z.string().optional(), -}).catchall(z.unknown()) -export type CodeContext = z.infer +export const codeContextSchema = z + .object({ + id: z.string(), + language: z.union([z.number(), codeLanguageSchema, z.string()]), + cwd: z.string(), + }) + .catchall(z.unknown()); +export type CodeContext = z.infer; -export const codeExecutionOutputSchema = z.object({ - type: z.string().optional(), - text: z.string().optional(), - data: z.unknown().optional(), - mimeType: z.string().optional(), -}).catchall(z.unknown()) -export type CodeExecutionOutput = z.infer +export const codeExecutionOutputSchema = z + .object({ + isPrimary: z.boolean().optional(), + text: z.string().optional(), + html: z.string().optional(), + markdown: z.string().optional(), + svg: z.string().optional(), + png: z.string().optional(), + jpeg: z.string().optional(), + pdf: z.string().optional(), + latex: z.string().optional(), + json: z.unknown().optional(), + javascript: z.string().optional(), + extra: z.record(z.string(), z.unknown()).optional(), + }) + .catchall(z.unknown()); +export type CodeExecutionOutput = z.infer; -export const codeExecutionErrorSchema = z.object({ - message: z.string(), - traceback: z.string().optional(), -}).catchall(z.unknown()) -export type CodeExecutionError = z.infer +export const codeExecutionErrorSchema = z + .object({ + name: z.string().optional(), + value: z.string().optional(), + traceback: z.string().optional(), + }) + .catchall(z.unknown()); +export type CodeExecutionError = z.infer; export const executionLogsSchema = z.object({ - stdout: z.string().optional(), - stderr: z.string().optional(), -}) -export type ExecutionLogs = z.infer + stdout: z.array(z.string()).optional(), + stderr: z.array(z.string()).optional(), +}); +export type ExecutionLogs = z.infer; -export const codeExecutionResultSchema = z.object({ - contextId: z.string().optional(), - outputs: z.array(codeExecutionOutputSchema).optional(), - error: codeExecutionErrorSchema.nullable().optional(), - logs: executionLogsSchema.optional(), -}).catchall(z.unknown()) -export type CodeExecutionResult = z.infer +export const codeExecutionResultSchema = z + .object({ + contextId: z.string(), + items: z.array(codeExecutionOutputSchema), + logs: executionLogsSchema, + error: codeExecutionErrorSchema.nullable().optional(), + executionCount: z.number().optional(), + }) + .catchall(z.unknown()); +export type CodeExecutionResult = z.infer; + +export const streamEventWireSchema = z.object({ + type: z.number(), + data: z.string().optional(), + code: z.number().optional(), +}); +export type StreamEventWire = z.infer; export const streamEventSchema = z.object({ type: streamEventTypeSchema, - data: z.unknown(), -}) -export type StreamEvent = z.infer + data: z.string().optional(), + code: z.number().optional(), +}); +export type StreamEvent = z.infer; diff --git a/src/models/config.ts b/src/models/config.ts index c5a269d..415768f 100644 --- a/src/models/config.ts +++ b/src/models/config.ts @@ -1,4 +1,4 @@ -import { z } from "zod" +import { z } from "zod"; export const leap0ConfigInputSchema = z.object({ apiKey: z.string().optional(), @@ -8,8 +8,8 @@ export const leap0ConfigInputSchema = z.object({ authHeader: z.string().optional(), bearer: z.boolean().optional(), sdkOtelEnabled: z.boolean().optional(), -}) -export type Leap0ConfigInput = z.infer +}); +export type Leap0ConfigInput = z.infer; export const leap0ConfigResolvedSchema = z.object({ apiKey: z.string(), @@ -19,5 +19,5 @@ export const leap0ConfigResolvedSchema = z.object({ authHeader: z.string(), bearer: z.boolean(), sdkOtelEnabled: z.boolean(), -}) -export type Leap0ConfigResolved = z.infer +}); +export type Leap0ConfigResolved = z.infer; diff --git a/src/models/desktop.ts b/src/models/desktop.ts index e67d57b..84b16c3 100644 --- a/src/models/desktop.ts +++ b/src/models/desktop.ts @@ -1,105 +1,172 @@ -import { z } from "zod" - -export const desktopDisplayInfoSchema = z.object({ - width: z.number(), - height: z.number(), - scale: z.number().optional(), -}).catchall(z.unknown()) -export type DesktopDisplayInfo = z.infer - -export const desktopPointerPositionSchema = z.object({ - x: z.number(), - y: z.number(), -}).catchall(z.unknown()) -export type DesktopPointerPosition = z.infer - -export const desktopSetScreenParamsSchema = z.object({ - width: z.number().optional(), - height: z.number().optional(), - scale: z.number().optional(), -}).catchall(z.unknown()) -export type DesktopSetScreenParams = z.infer - -export const desktopScreenshotRegionParamsSchema = z.object({ - x: z.number(), - y: z.number(), - width: z.number(), - height: z.number(), -}).catchall(z.unknown()) -export type DesktopScreenshotRegionParams = z.infer - -export const desktopDragParamsSchema = z.object({ - startX: z.number().optional(), - startY: z.number().optional(), - endX: z.number().optional(), - endY: z.number().optional(), - x: z.number().optional(), - y: z.number().optional(), - button: z.number().optional(), -}).catchall(z.unknown()) -export type DesktopDragParams = z.infer - -export const desktopScrollParamsSchema = z.object({ - x: z.number().optional(), - y: z.number().optional(), - deltaX: z.number().optional(), - deltaY: z.number().optional(), -}).catchall(z.unknown()) -export type DesktopScrollParams = z.infer - -export const desktopWindowSchema = z.object({ - id: z.string().optional(), - title: z.string().optional(), - x: z.number().optional(), - y: z.number().optional(), - width: z.number().optional(), - height: z.number().optional(), -}).catchall(z.unknown()) -export type DesktopWindow = z.infer - -export const desktopHealthSchema = z.object({ - ok: z.boolean(), -}).catchall(z.unknown()) -export type DesktopHealth = z.infer - -export const desktopRecordingSummarySchema = z.object({ - id: z.string(), -}).catchall(z.unknown()) -export type DesktopRecordingSummary = z.infer - -export const desktopRecordingStatusSchema = z.object({ - active: z.boolean(), -}).catchall(z.unknown()) -export type DesktopRecordingStatus = z.infer - -export const desktopProcessStatusSchema = z.object({ - name: z.string().optional(), - status: z.string().optional(), -}).catchall(z.unknown()) -export type DesktopProcessStatus = z.infer - -export const desktopProcessStatusListSchema = z.object({ - processes: z.array(desktopProcessStatusSchema), -}) -export type DesktopProcessStatusList = z.infer - -export const desktopStatusStreamEventSchema = z.object({ - status: z.string().optional(), - error: z.string().optional(), -}).catchall(z.unknown()) -export type DesktopStatusStreamEvent = z.infer - -export const desktopProcessLogsSchema = z.object({ - logs: z.string(), -}) -export type DesktopProcessLogs = z.infer - -export const desktopProcessErrorsSchema = z.object({ - errors: z.string(), -}) -export type DesktopProcessErrors = z.infer - -export const desktopProcessRestartSchema = z.object({ - success: z.boolean(), -}).catchall(z.unknown()) -export type DesktopProcessRestart = z.infer +import { z } from "zod"; + +export const desktopDisplayInfoSchema = z + .object({ + display: z.string(), + width: z.number(), + height: z.number(), + }) + .catchall(z.unknown()); +export type DesktopDisplayInfo = z.infer; + +export const desktopPointerPositionSchema = z + .object({ + x: z.number(), + y: z.number(), + }) + .catchall(z.unknown()); +export type DesktopPointerPosition = z.infer; + +export const desktopSetScreenParamsSchema = z + .object({ + width: z.number(), + height: z.number(), + }) + .catchall(z.unknown()); +export type DesktopSetScreenParams = z.infer; + +export const desktopScreenshotParamsSchema = z.object({ + format: z.enum(["png", "jpg", "jpeg"]).optional(), + quality: z.number().int().min(1).max(100).optional(), +}); +export type DesktopScreenshotParams = z.infer; + +export const desktopScreenshotRegionParamsSchema = z + .object({ + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + format: z.enum(["png", "jpg", "jpeg"]).optional(), + quality: z.number().int().min(1).max(100).optional(), + }) + .catchall(z.unknown()); +export type DesktopScreenshotRegionParams = z.infer; + +export const desktopClickParamsSchema = z + .object({ + x: z.number().optional(), + y: z.number().optional(), + button: z.number().int().min(1).max(3).optional(), + }) + .catchall(z.unknown()); +export type DesktopClickParams = z.infer; + +export const desktopDragParamsSchema = z + .object({ + fromX: z.number(), + fromY: z.number(), + toX: z.number(), + toY: z.number(), + button: z.number().int().min(1).max(3).optional(), + }) + .catchall(z.unknown()); +export type DesktopDragParams = z.infer; + +export const desktopScrollParamsSchema = z + .object({ + direction: z.enum(["up", "down", "left", "right"]), + amount: z.number().int().min(1).max(100).optional(), + }) + .catchall(z.unknown()); +export type DesktopScrollParams = z.infer; + +export const desktopWindowSchema = z + .object({ + id: z.string().optional(), + desktop: z.number().optional(), + pid: z.number().optional(), + x: z.number().optional(), + y: z.number().optional(), + width: z.number().optional(), + height: z.number().optional(), + class: z.string().optional(), + host: z.string().optional(), + title: z.string().optional(), + focused: z.boolean().optional(), + }) + .catchall(z.unknown()); +export type DesktopWindow = z.infer; + +export const desktopHealthSchema = z + .object({ + ok: z.boolean(), + }) + .catchall(z.unknown()); +export type DesktopHealth = z.infer; + +export const desktopRecordingSummarySchema = z + .object({ + id: z.string().optional(), + fileName: z.string().optional(), + download: z.string().optional(), + mimeType: z.string().optional(), + sizeBytes: z.number().optional(), + createdAt: z.string().optional(), + active: z.boolean().optional(), + }) + .catchall(z.unknown()); +export type DesktopRecordingSummary = z.infer; + +export const desktopRecordingStatusSchema = z + .object({ + id: z.string().optional(), + active: z.boolean().optional(), + startedAt: z.string().optional(), + stoppedAt: z.string().optional(), + download: z.string().optional(), + mimeType: z.string().optional(), + fileName: z.string().optional(), + display: z.string().optional(), + resolution: z.string().optional(), + }) + .catchall(z.unknown()); +export type DesktopRecordingStatus = z.infer; + +export const desktopProcessStatusSchema = z + .object({ + name: z.string().optional(), + running: z.boolean().optional(), + pid: z.number().optional(), + stdoutLog: z.string().optional(), + stderrLog: z.string().optional(), + }) + .catchall(z.unknown()); +export type DesktopProcessStatus = z.infer; + +export const desktopProcessStatusListSchema = z + .object({ + status: z.enum(["running", "degraded", "stopped"]).optional(), + items: z.array(desktopProcessStatusSchema).optional(), + running: z.number().optional(), + total: z.number().optional(), + }) + .catchall(z.unknown()); +export type DesktopProcessStatusList = z.infer; + +export const desktopStatusStreamEventSchema = desktopProcessStatusListSchema; +export type DesktopStatusStreamEvent = z.infer; + +export const desktopProcessLogsSchema = z + .object({ + process: z.string().optional(), + logs: z.string().optional(), + }) + .catchall(z.unknown()); +export type DesktopProcessLogs = z.infer; + +export const desktopProcessErrorsSchema = z + .object({ + process: z.string().optional(), + errors: z.string().optional(), + }) + .catchall(z.unknown()); +export type DesktopProcessErrors = z.infer; + +export const desktopProcessRestartSchema = z + .object({ + message: z.string().optional(), + status: desktopProcessStatusSchema.optional(), + }) + .catchall(z.unknown()); +export type DesktopProcessRestart = z.infer; diff --git a/src/models/filesystem.ts b/src/models/filesystem.ts index 62e0eba..f66bbb8 100644 --- a/src/models/filesystem.ts +++ b/src/models/filesystem.ts @@ -1,65 +1,78 @@ -import { z } from "zod" +import { z } from "zod"; -export const fileInfoSchema = z.object({ - path: z.string(), - name: z.string().optional(), - size: z.number().optional(), - mode: z.number().optional(), - isDir: z.boolean().optional(), - modifiedAt: z.string().optional(), -}).catchall(z.unknown()) -export type FileInfo = z.infer +export const fileInfoSchema = z + .object({ + name: z.string(), + path: z.string(), + isDir: z.boolean(), + size: z.number(), + mode: z.string(), + mtime: z.number(), + owner: z.string(), + group: z.string(), + isSymlink: z.boolean(), + linkTarget: z.string().optional(), + }) + .catchall(z.unknown()); +export type FileInfo = z.infer; export const lsResultSchema = z.object({ items: z.array(fileInfoSchema), -}) -export type LsResult = z.infer +}); +export type LsResult = z.infer; -export const searchMatchSchema = z.object({ - path: z.string(), - line: z.number().optional(), - column: z.number().optional(), - match: z.string().optional(), -}).catchall(z.unknown()) -export type SearchMatch = z.infer +export const searchMatchSchema = z + .object({ + path: z.string(), + line: z.number(), + content: z.string(), + }) + .catchall(z.unknown()); +export type SearchMatch = z.infer; export type TreeEntry = { - path: string - name: string - type: "file" | "dir" - children?: TreeEntry[] -} + name: string; + type: "file" | "directory"; + children?: TreeEntry[]; +}; export const treeEntrySchema: z.ZodType = z.lazy(() => z.object({ - path: z.string(), name: z.string(), - type: z.enum(["file", "dir"]), + type: z.enum(["file", "directory"]), children: z.array(treeEntrySchema).optional(), }), -) +); export const treeResultSchema = z.object({ - root: z.string(), - entries: z.array(treeEntrySchema), -}) -export type TreeResult = z.infer + items: z.array(treeEntrySchema), +}); +export type TreeResult = z.infer; export const fileEditSchema = z.object({ - oldText: z.string(), - newText: z.string(), - replaceAll: z.boolean().optional(), -}) -export type FileEdit = z.infer + find: z.string(), + replace: z.string().nullable().optional(), +}); +export type FileEdit = z.infer; + +export const editFileResultSchema = z + .object({ + diff: z.string(), + replacements: z.number(), + }) + .catchall(z.unknown()); +export type EditFileResult = z.infer; -export const editFileResultSchema = z.object({ - path: z.string(), - changed: z.boolean(), - content: z.string().optional(), -}).catchall(z.unknown()) -export type EditFileResult = z.infer +export const editResultSchema = z + .object({ + file: z.string(), + success: z.boolean(), + error: z.string().optional(), + }) + .catchall(z.unknown()); +export type EditResult = z.infer; -export const editResultSchema = z.object({ - results: z.array(editFileResultSchema), -}) -export type EditResult = z.infer +export const editFilesResultSchema = z.object({ + items: z.array(editResultSchema), +}); +export type EditFilesResult = z.infer; diff --git a/src/models/git.ts b/src/models/git.ts index cc1e864..3f33b64 100644 --- a/src/models/git.ts +++ b/src/models/git.ts @@ -1,13 +1,15 @@ -import { z } from "zod" +import { z } from "zod"; -export const gitResultSchema = z.object({ - output: z.string(), - exitCode: z.number(), -}).catchall(z.unknown()) -export type GitResult = z.infer +export const gitResultSchema = z + .object({ + output: z.string(), + exitCode: z.number(), + }) + .catchall(z.unknown()); +export type GitResult = z.infer; export const gitCommitResultSchema = z.object({ sha: z.string(), result: gitResultSchema, -}) -export type GitCommitResult = z.infer +}); +export type GitCommitResult = z.infer; diff --git a/src/models/index.ts b/src/models/index.ts index a9b3fa4..a6bb56b 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,27 +1,18 @@ -export type { RequestOptions } from "@/models/shared.js" +export type { RequestOptions } from "@/models/shared.js"; -export type { SandboxRef, SnapshotRef, TemplateRef } from "@/models/refs.js" +export type { SandboxRef, SnapshotRef, TemplateRef } from "@/models/refs.js"; -export { - createSandboxParamsSchema, - NetworkPolicyMode, - SandboxState, -} from "@/models/sandbox.js" -export type { - CreateSandboxParams, - NetworkPolicy, - SandboxData, -} from "@/models/sandbox.js" +export { createSandboxParamsSchema, NetworkPolicyMode, SandboxState } from "@/models/sandbox.js"; +export type { CreateSandboxParams, NetworkPolicy, SandboxData } from "@/models/sandbox.js"; -export { - createSnapshotParamsSchema, -} from "@/models/snapshot.js" -export type { CreateSnapshotParams, ResumeSnapshotParams, SnapshotData } from "@/models/snapshot.js" +export { createSnapshotParamsSchema } from "@/models/snapshot.js"; +export type { + CreateSnapshotParams, + ResumeSnapshotParams, + SnapshotData, +} from "@/models/snapshot.js"; -export { - RegistryCredentialType, - createTemplateParamsSchema, -} from "@/models/template.js" +export { RegistryCredentialType, createTemplateParamsSchema } from "@/models/template.js"; export type { AwsRegistryCredentials, AzureRegistryCredentials, @@ -31,10 +22,11 @@ export type { RegistryCredentials, RenameTemplateParams, TemplateData, -} from "@/models/template.js" +} from "@/models/template.js"; export type { EditFileResult, + EditFilesResult, EditResult, FileEdit, FileInfo, @@ -42,24 +34,20 @@ export type { SearchMatch, TreeEntry, TreeResult, -} from "@/models/filesystem.js" +} from "@/models/filesystem.js"; -export type { GitCommitResult, GitResult } from "@/models/git.js" +export type { GitCommitResult, GitResult } from "@/models/git.js"; -export type { ProcessResult } from "@/models/process.js" +export type { ProcessResult } from "@/models/process.js"; -export { createPtySessionParamsSchema } from "@/models/pty.js" -export type { CreatePtySessionParams, PtySession } from "@/models/pty.js" +export { createPtySessionParamsSchema } from "@/models/pty.js"; +export type { CreatePtySessionParams, PtySession } from "@/models/pty.js"; -export type { LspJsonRpcError, LspJsonRpcResponse, LspResponse } from "@/models/lsp.js" +export type { LspJsonRpcError, LspJsonRpcResponse, LspResponse } from "@/models/lsp.js"; -export type { SshAccess, SshValidation } from "@/models/ssh.js" +export type { SshAccess, SshValidation } from "@/models/ssh.js"; -export { - CodeLanguage, - codeLanguageSchema, - StreamEventType, -} from "@/models/code-interpreter.js" +export { CodeLanguage, codeLanguageSchema, StreamEventType } from "@/models/code-interpreter.js"; export type { CodeContext, CodeExecutionError, @@ -67,9 +55,10 @@ export type { CodeExecutionResult, ExecutionLogs, StreamEvent, -} from "@/models/code-interpreter.js" +} from "@/models/code-interpreter.js"; export type { + DesktopClickParams, DesktopDisplayInfo, DesktopDragParams, DesktopHealth, @@ -80,13 +69,14 @@ export type { DesktopProcessStatus, DesktopProcessStatusList, DesktopRecordingStatus, + DesktopScreenshotParams, DesktopRecordingSummary, DesktopScreenshotRegionParams, DesktopScrollParams, DesktopSetScreenParams, DesktopStatusStreamEvent, DesktopWindow, -} from "@/models/desktop.js" +} from "@/models/desktop.js"; -export { leap0ConfigInputSchema } from "@/models/config.js" -export type { Leap0ConfigInput } from "@/models/config.js" +export { leap0ConfigInputSchema } from "@/models/config.js"; +export type { Leap0ConfigInput } from "@/models/config.js"; diff --git a/src/models/lsp.ts b/src/models/lsp.ts index 558b973..5fd4e31 100644 --- a/src/models/lsp.ts +++ b/src/models/lsp.ts @@ -1,23 +1,30 @@ -import { z } from "zod" +import { z } from "zod"; -export const lspResponseSchema = z.object({ - success: z.boolean(), -}).catchall(z.unknown()) -export type LspResponse = z.infer +export const lspResponseSchema = z + .object({ + success: z.boolean(), + }) + .catchall(z.unknown()); +export type LspResponse = z.infer; export const lspJsonRpcErrorSchema = z.object({ code: z.number(), message: z.string(), data: z.unknown().optional(), -}) -export type LspJsonRpcError = z.infer +}); +export type LspJsonRpcError = z.infer; -export const lspJsonRpcResponseSchema = z.object({ - jsonrpc: z.literal("2.0"), - id: z.union([z.string(), z.number(), z.null()]), - result: z.unknown().optional(), - error: lspJsonRpcErrorSchema.optional(), -}).refine((value) => (value.result === undefined) !== (value.error === undefined), { - message: "Exactly one of result or error must be present", -}) -export type LspJsonRpcResponse = Omit, "result"> & { result?: T } +export const lspJsonRpcResponseSchema = z + .object({ + jsonrpc: z.literal("2.0"), + id: z.union([z.string(), z.number(), z.null()]), + result: z.unknown().optional(), + error: lspJsonRpcErrorSchema.optional(), + }) + .refine((value) => (value.result === undefined) !== (value.error === undefined), { + message: "Exactly one of result or error must be present", + }); +export type LspJsonRpcResponse = Omit< + z.infer, + "result" +> & { result?: T }; diff --git a/src/models/process.ts b/src/models/process.ts index b03fbb8..0d95ff1 100644 --- a/src/models/process.ts +++ b/src/models/process.ts @@ -1,7 +1,9 @@ -import { z } from "zod" +import { z } from "zod"; -export const processResultSchema = z.object({ - exitCode: z.number(), - result: z.string(), -}).catchall(z.unknown()) -export type ProcessResult = z.infer +export const processResultSchema = z + .object({ + exitCode: z.number(), + result: z.string(), + }) + .catchall(z.unknown()); +export type ProcessResult = z.infer; diff --git a/src/models/pty.ts b/src/models/pty.ts index 8b4c3fc..3fc78cf 100644 --- a/src/models/pty.ts +++ b/src/models/pty.ts @@ -1,19 +1,25 @@ -import { z } from "zod" +import { z } from "zod"; -export const ptySessionSchema = z.object({ - id: z.string(), - cols: z.number().int().positive().optional(), - rows: z.number().int().positive().optional(), - cwd: z.string().optional(), -}).catchall(z.unknown()) -export type PtySession = z.infer +export const ptySessionSchema = z + .object({ + id: z.string(), + cwd: z.string(), + envs: z.record(z.string(), z.string()), + cols: z.number().int().positive(), + rows: z.number().int().positive(), + createdAt: z.string(), + active: z.boolean(), + lazyStart: z.boolean(), + }) + .catchall(z.unknown()); +export type PtySession = z.infer; export const createPtySessionParamsSchema = z.object({ id: z.string().optional(), cols: z.number().int().positive().optional(), rows: z.number().int().positive().optional(), cwd: z.string().optional(), - command: z.string().optional(), - env: z.record(z.string(), z.string()).optional(), -}) -export type CreatePtySessionParams = z.infer + envs: z.record(z.string(), z.string()).optional(), + lazyStart: z.boolean().optional(), +}); +export type CreatePtySessionParams = z.infer; diff --git a/src/models/refs.ts b/src/models/refs.ts index 7662b47..e013366 100644 --- a/src/models/refs.ts +++ b/src/models/refs.ts @@ -1,3 +1,3 @@ -export type SandboxRef = string | { id: string } -export type SnapshotRef = string | { id: string; name?: string } -export type TemplateRef = string | { id: string; name?: string } +export type SandboxRef = string | { id: string }; +export type SnapshotRef = string | { id: string; name?: string }; +export type TemplateRef = string | { id: string; name?: string }; diff --git a/src/models/sandbox.ts b/src/models/sandbox.ts index 636e40b..91ca9ab 100644 --- a/src/models/sandbox.ts +++ b/src/models/sandbox.ts @@ -1,64 +1,69 @@ -import { z } from "zod" +import { z } from "zod"; export const NetworkPolicyMode = { ALLOW_ALL: "allow-all", DENY_ALL: "deny-all", CUSTOM: "custom", -} as const +} as const; export const SandboxState = { - CREATING: "creating", STARTING: "starting", - STARTED: "started", - PAUSING: "pausing", + RUNNING: "running", + SNAPSHOTTING: "snapshotting", PAUSED: "paused", - STOPPING: "stopping", - STOPPED: "stopped", - DESTROYED: "destroyed", - ERROR: "error", -} as const + UNPAUSING: "unpausing", + DELETING: "deleting", + DELETED: "deleted", +} as const; export const networkPolicyModeSchema = z.enum([ NetworkPolicyMode.ALLOW_ALL, NetworkPolicyMode.DENY_ALL, NetworkPolicyMode.CUSTOM, -]) -export type NetworkPolicyMode = z.infer +]); +export type NetworkPolicyMode = z.infer; export const networkPolicySchema = z.object({ mode: networkPolicyModeSchema, allowedDomains: z.array(z.string()).optional(), allowedCidrs: z.array(z.string()).optional(), -}) -export type NetworkPolicy = z.infer + transforms: z + .array( + z.object({ + domain: z.string(), + injectHeaders: z.record(z.string(), z.string()).optional(), + stripHeaders: z.array(z.string()).optional(), + }), + ) + .optional(), +}); +export type NetworkPolicy = z.infer; export const sandboxStateSchema = z.enum([ - SandboxState.CREATING, SandboxState.STARTING, - SandboxState.STARTED, - SandboxState.PAUSING, + SandboxState.RUNNING, + SandboxState.SNAPSHOTTING, SandboxState.PAUSED, - SandboxState.STOPPING, - SandboxState.STOPPED, - SandboxState.DESTROYED, - SandboxState.ERROR, -]) -export type SandboxState = z.infer + SandboxState.UNPAUSING, + SandboxState.DELETING, + SandboxState.DELETED, +]); +export type SandboxState = z.infer; -export const sandboxDataSchema = z.object({ - id: z.string(), - templateName: z.string().optional(), - state: sandboxStateSchema.optional(), - vcpu: z.number().optional(), - memoryMib: z.number().optional(), - timeoutMin: z.number().optional(), - autoPause: z.boolean().optional(), - envVars: z.record(z.string(), z.string()).optional(), - networkPolicy: networkPolicySchema.optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}).catchall(z.unknown()) -export type SandboxData = z.infer +export const sandboxDataSchema = z + .object({ + id: z.string(), + templateId: z.string(), + state: sandboxStateSchema, + vcpu: z.number(), + memoryMib: z.number(), + diskMib: z.number(), + autoPause: z.boolean(), + networkPolicy: networkPolicySchema.optional(), + createdAt: z.string(), + }) + .catchall(z.unknown()); +export type SandboxData = z.infer; export const createSandboxParamsSchema = z.object({ templateName: z.string().optional(), @@ -70,5 +75,33 @@ export const createSandboxParamsSchema = z.object({ telemetry: z.boolean().optional(), envVars: z.record(z.string(), z.string()).optional(), networkPolicy: networkPolicySchema.optional(), -}) -export type CreateSandboxParams = z.infer +}); +export type CreateSandboxParams = z.infer; + +type NetworkPolicyWire = { + mode: NetworkPolicyMode; + allow_domains?: string[]; + allow_cidrs?: string[]; + transforms?: Array<{ + domain: string; + inject_headers?: Record; + strip_headers?: string[]; + }>; +}; + +export function toNetworkPolicyWire( + policy: NetworkPolicy | undefined, +): NetworkPolicyWire | undefined { + if (policy == null) return undefined; + + return { + mode: policy.mode, + allow_domains: policy.allowedDomains, + allow_cidrs: policy.allowedCidrs, + transforms: policy.transforms?.map((transform) => ({ + domain: transform.domain, + inject_headers: transform.injectHeaders, + strip_headers: transform.stripHeaders, + })), + }; +} diff --git a/src/models/shared.ts b/src/models/shared.ts index 76639f6..356262d 100644 --- a/src/models/shared.ts +++ b/src/models/shared.ts @@ -1,5 +1,5 @@ export interface RequestOptions { - timeout?: number - headers?: HeadersInit - query?: Record + timeout?: number; + headers?: HeadersInit; + query?: Record; } diff --git a/src/models/snapshot.ts b/src/models/snapshot.ts index 2ed1fdf..425d95d 100644 --- a/src/models/snapshot.ts +++ b/src/models/snapshot.ts @@ -1,23 +1,30 @@ -import { z } from "zod" -import { networkPolicySchema } from "@/models/sandbox.js" +import { z } from "zod"; +import { networkPolicySchema, sandboxStateSchema } from "@/models/sandbox.js"; -export const snapshotDataSchema = z.object({ - id: z.string(), - name: z.string().optional(), - sandboxId: z.string().optional(), - createdAt: z.string().optional(), -}).catchall(z.unknown()) -export type SnapshotData = z.infer +export const snapshotDataSchema = z + .object({ + id: z.string(), + name: z.string(), + templateId: z.string(), + vcpu: z.number(), + memoryMib: z.number(), + diskMib: z.number(), + state: sandboxStateSchema.nullish(), + networkPolicy: networkPolicySchema.optional(), + createdAt: z.string(), + }) + .catchall(z.unknown()); +export type SnapshotData = z.infer; export const createSnapshotParamsSchema = z.object({ name: z.string().optional(), -}) -export type CreateSnapshotParams = z.infer +}); +export type CreateSnapshotParams = z.infer; export const resumeSnapshotParamsSchema = z.object({ snapshotName: z.string(), autoPause: z.boolean().optional(), timeoutMin: z.number().int().optional(), networkPolicy: networkPolicySchema.optional(), -}) -export type ResumeSnapshotParams = z.infer +}); +export type ResumeSnapshotParams = z.infer; diff --git a/src/models/ssh.ts b/src/models/ssh.ts index 302fde7..0d821b7 100644 --- a/src/models/ssh.ts +++ b/src/models/ssh.ts @@ -1,16 +1,22 @@ -import { z } from "zod" +import { z } from "zod"; -export const sshAccessSchema = z.object({ - id: z.string(), - hostname: z.string(), - port: z.number(), - username: z.string(), - password: z.string().optional(), - expiresAt: z.string().optional(), -}).catchall(z.unknown()) -export type SshAccess = z.infer +export const sshAccessSchema = z + .object({ + id: z.string(), + sandboxId: z.string(), + password: z.string(), + expiresAt: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + sshCommand: z.string(), + }) + .catchall(z.unknown()); +export type SshAccess = z.infer; -export const sshValidationSchema = z.object({ - valid: z.boolean(), -}).catchall(z.unknown()) -export type SshValidation = z.infer +export const sshValidationSchema = z + .object({ + valid: z.boolean(), + sandboxId: z.string(), + }) + .catchall(z.unknown()); +export type SshValidation = z.infer; diff --git a/src/models/template.ts b/src/models/template.ts index 97eda58..d50035d 100644 --- a/src/models/template.ts +++ b/src/models/template.ts @@ -1,74 +1,89 @@ -import { z } from "zod" +import { z } from "zod"; export const RegistryCredentialType = { BASIC: "basic", AWS: "aws", GCP: "gcp", AZURE: "azure", -} as const +} as const; -export const templateDataSchema = z.object({ - id: z.string(), - name: z.string(), - uri: z.string().optional(), - createdAt: z.string().optional(), -}).catchall(z.unknown()) -export type TemplateData = z.infer +export const templateImageConfigSchema = z + .object({ + entrypoint: z.array(z.string()).nullable(), + cmd: z.array(z.string()).nullable(), + workingDir: z.string().optional(), + user: z.string().optional(), + env: z.record(z.string(), z.string()).nullable().optional(), + }) + .catchall(z.unknown()); +export type TemplateImageConfig = z.infer; + +export const templateDataSchema = z + .object({ + id: z.string(), + name: z.string(), + digest: z.string(), + imageConfig: templateImageConfigSchema, + isSystem: z.boolean(), + createdAt: z.string(), + }) + .catchall(z.unknown()); +export type TemplateData = z.infer; export const registryCredentialTypeSchema = z.enum([ RegistryCredentialType.BASIC, RegistryCredentialType.AWS, RegistryCredentialType.GCP, RegistryCredentialType.AZURE, -]) -export type RegistryCredentialType = z.infer +]); +export type RegistryCredentialType = z.infer; export const basicRegistryCredentialsSchema = z.object({ type: z.literal(RegistryCredentialType.BASIC), username: z.string().min(1), password: z.string().min(1), -}) +}); export const awsRegistryCredentialsSchema = z.object({ type: z.literal(RegistryCredentialType.AWS), aws_access_key_id: z.string().min(1), aws_secret_access_key: z.string().min(1), aws_region: z.string().optional(), -}) +}); export const gcpRegistryCredentialsSchema = z.object({ type: z.literal(RegistryCredentialType.GCP), gcp_service_account_json: z.string().min(1), -}) +}); export const azureRegistryCredentialsSchema = z.object({ type: z.literal(RegistryCredentialType.AZURE), azure_client_id: z.string().min(1), azure_client_secret: z.string().min(1), azure_tenant_id: z.string().min(1), -}) +}); export const registryCredentialsSchema = z.discriminatedUnion("type", [ basicRegistryCredentialsSchema, awsRegistryCredentialsSchema, gcpRegistryCredentialsSchema, azureRegistryCredentialsSchema, -]) +]); -export type BasicRegistryCredentials = z.infer -export type AwsRegistryCredentials = z.infer -export type GcpRegistryCredentials = z.infer -export type AzureRegistryCredentials = z.infer -export type RegistryCredentials = z.infer +export type BasicRegistryCredentials = z.infer; +export type AwsRegistryCredentials = z.infer; +export type GcpRegistryCredentials = z.infer; +export type AzureRegistryCredentials = z.infer; +export type RegistryCredentials = z.infer; export const createTemplateParamsSchema = z.object({ name: z.string(), uri: z.string(), credentials: registryCredentialsSchema.optional(), -}) -export type CreateTemplateParams = z.infer +}); +export type CreateTemplateParams = z.infer; export const renameTemplateParamsSchema = z.object({ name: z.string(), -}) -export type RenameTemplateParams = z.infer +}); +export type RenameTemplateParams = z.infer; diff --git a/src/services/code-interpreter.ts b/src/services/code-interpreter.ts index 2238883..4a4090f 100644 --- a/src/services/code-interpreter.ts +++ b/src/services/code-interpreter.ts @@ -1,45 +1,144 @@ -import { Leap0Error } from "@/core/errors.js" -import type { CodeContext, CodeExecutionResult, CodeLanguage, RequestOptions, SandboxRef, StreamEvent } from "@/models/index.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { sandboxBaseUrl, sandboxIdOf } from "@/core/utils.js" -import { StreamEventType } from "@/models/index.js" +import { z } from "zod"; +import { Leap0Error } from "@/core/errors.js"; +import { normalize } from "@/core/normalize.js"; +import type { + CodeContext, + CodeExecutionResult, + CodeLanguage, + RequestOptions, + SandboxRef, + StreamEvent, +} from "@/models/index.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { sandboxBaseUrl, sandboxIdOf } from "@/core/utils.js"; +import { StreamEventType } from "@/models/index.js"; +import { codeContextSchema, codeExecutionResultSchema } from "@/models/code-interpreter.js"; /** Talks to the sandbox-hosted code interpreter service. */ export class CodeInterpreterClient { - constructor(private readonly transport: Leap0Transport, private readonly sandboxDomain: string) {} + constructor( + private readonly transport: Leap0Transport, + private readonly sandboxDomain: string, + ) {} private requestPath(sandbox: SandboxRef, path: string): string { - return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain)}${path}` + return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain)}${path}`; } - private fetchJson(sandbox: SandboxRef, path: string, init: RequestInit = {}, options: RequestOptions = {}): Promise { - return this.transport.requestJsonUrl(this.requestPath(sandbox, path), init, options) + private async fetchJson( + sandbox: SandboxRef, + path: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): Promise { + return await this.transport.requestJsonUrl(this.requestPath(sandbox, path), init, options); } - health(sandbox: SandboxRef, options: RequestOptions = {}): Promise<{ ok: boolean }> { return this.fetchJson(sandbox, "/healthz", { method: "GET" }, options) } - createContext(sandbox: SandboxRef, language: CodeLanguage, options: RequestOptions = {}): Promise { return this.fetchJson(sandbox, "/contexts", { method: "POST", body: jsonBody({ language }) }, options) } - listContexts(sandbox: SandboxRef, options: RequestOptions = {}): Promise { return this.fetchJson(sandbox, "/contexts", { method: "GET" }, options) } - getContext(sandbox: SandboxRef, contextId: string, options: RequestOptions = {}): Promise { return this.fetchJson(sandbox, `/contexts/${encodeURIComponent(contextId)}`, { method: "GET" }, options) } - async deleteContext(sandbox: SandboxRef, contextId: string, options: RequestOptions = {}): Promise { await this.fetchJson(sandbox, `/contexts/${encodeURIComponent(contextId)}`, { method: "DELETE" }, options) } - execute(sandbox: SandboxRef, params: { code: string; language: CodeLanguage; contextId?: string }, options: RequestOptions = {}): Promise { return this.fetchJson(sandbox, "/execute", { method: "POST", body: jsonBody(params) }, options) } + async health(sandbox: SandboxRef, options: RequestOptions = {}): Promise<{ ok: boolean }> { + return await this.fetchJson(sandbox, "/healthz", { method: "GET" }, options); + } + async createContext( + sandbox: SandboxRef, + language: CodeLanguage, + options: RequestOptions = {}, + ): Promise { + return normalize( + codeContextSchema, + await this.fetchJson( + sandbox, + "/contexts", + { method: "POST", body: jsonBody({ language }) }, + options, + ), + ); + } + async listContexts(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + return ( + normalize( + z.object({ items: z.array(codeContextSchema).optional() }), + await this.fetchJson(sandbox, "/contexts", { method: "GET" }, options), + ).items ?? [] + ); + } + async getContext( + sandbox: SandboxRef, + contextId: string, + options: RequestOptions = {}, + ): Promise { + return normalize( + codeContextSchema, + await this.fetchJson( + sandbox, + `/contexts/${encodeURIComponent(contextId)}`, + { method: "GET" }, + options, + ), + ); + } + async deleteContext( + sandbox: SandboxRef, + contextId: string, + options: RequestOptions = {}, + ): Promise { + await this.fetchJson( + sandbox, + `/contexts/${encodeURIComponent(contextId)}`, + { method: "DELETE" }, + options, + ); + } + async execute( + sandbox: SandboxRef, + params: { code: string; language: CodeLanguage; contextId?: string }, + options: RequestOptions = {}, + ): Promise { + return normalize( + codeExecutionResultSchema, + await this.fetchJson( + sandbox, + "/execute", + { + method: "POST", + body: jsonBody({ + code: params.code, + language: params.language, + context_id: params.contextId, + }), + }, + options, + ), + ); + } - async *executeStream(sandbox: SandboxRef, params: { code: string; language: CodeLanguage; contextId?: string }, options: RequestOptions = {}): AsyncIterable { - for await (const event of this.transport.streamJsonUrl(this.requestPath(sandbox, "/execute/async"), { - method: "POST", - body: jsonBody(params), - }, options)) { + async *executeStream( + sandbox: SandboxRef, + params: { code: string; language: CodeLanguage; contextId?: string }, + options: RequestOptions = {}, + ): AsyncIterable { + for await (const event of this.transport.streamJsonUrl( + this.requestPath(sandbox, "/execute/async"), + { + method: "POST", + body: jsonBody({ + code: params.code, + language: params.language, + context_id: params.contextId, + }), + }, + options, + )) { if (!event || typeof event !== "object") { - throw new Leap0Error("Malformed code execution stream event") + throw new Leap0Error("Malformed code execution stream event"); } - const parsed = { ...event } as Record + const parsed = { ...event } as Record; if (parsed.envelope === "error") { - throw new Leap0Error(typeof parsed.message === "string" ? parsed.message : "Stream error") + throw new Leap0Error(typeof parsed.message === "string" ? parsed.message : "Stream error"); } - if (parsed.type === 0) parsed.type = StreamEventType.STDOUT - if (parsed.type === 1) parsed.type = StreamEventType.STDERR - if (parsed.type === 2) parsed.type = StreamEventType.EXIT - if (parsed.type === 3) parsed.type = StreamEventType.ERROR - yield parsed as unknown as StreamEvent + if (parsed.type === 0) parsed.type = StreamEventType.STDOUT; + if (parsed.type === 1) parsed.type = StreamEventType.STDERR; + if (parsed.type === 2) parsed.type = StreamEventType.EXIT; + if (parsed.type === 3) parsed.type = StreamEventType.ERROR; + yield parsed as unknown as StreamEvent; } } } diff --git a/src/services/desktop.ts b/src/services/desktop.ts index 05c72c2..70de252 100644 --- a/src/services/desktop.ts +++ b/src/services/desktop.ts @@ -1,5 +1,8 @@ -import { Leap0Error } from "@/core/errors.js" +import { z } from "zod"; +import { Leap0Error } from "@/core/errors.js"; +import { normalize } from "@/core/normalize.js"; import type { + DesktopClickParams, DesktopDisplayInfo, DesktopDragParams, DesktopHealth, @@ -11,6 +14,7 @@ import type { DesktopProcessStatusList, DesktopRecordingStatus, DesktopRecordingSummary, + DesktopScreenshotParams, DesktopScreenshotRegionParams, DesktopScrollParams, DesktopSetScreenParams, @@ -18,77 +22,391 @@ import type { DesktopWindow, RequestOptions, SandboxRef, -} from "@/models/index.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { sandboxBaseUrl, sandboxIdOf } from "@/core/utils.js" -import { asRecord } from "@/services/shared.js" +} from "@/models/index.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { sandboxBaseUrl, sandboxIdOf } from "@/core/utils.js"; +import { + desktopDisplayInfoSchema, + desktopHealthSchema, + desktopPointerPositionSchema, + desktopProcessErrorsSchema, + desktopProcessLogsSchema, + desktopProcessRestartSchema, + desktopProcessStatusListSchema, + desktopProcessStatusSchema, + desktopRecordingStatusSchema, + desktopRecordingSummarySchema, + desktopWindowSchema, +} from "@/models/desktop.js"; +import { asRecord } from "@/services/shared.js"; /** Drives the desktop sandbox APIs for browser and GUI automation. */ export class DesktopClient { - constructor(private readonly transport: Leap0Transport, private readonly sandboxDomain: string) {} + constructor( + private readonly transport: Leap0Transport, + private readonly sandboxDomain: string, + ) {} private requestUrl(sandbox: SandboxRef, path: string): string { - return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain)}${path}` + return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain)}${path}`; } - browserUrl(sandbox: SandboxRef): string { return sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain) } - display(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/display", { method: "GET" }, options) } - screen(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/display/screen", { method: "GET" }, options) } - setScreen(sandbox: SandboxRef, payload: DesktopSetScreenParams, options?: RequestOptions): Promise { return this.transport.requestJsonUrl(this.requestUrl(sandbox, "/api/display/screen"), { method: "POST", body: jsonBody(payload) }, options) } - windows(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/display/windows", { method: "GET" }, options) } - screenshot(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/screenshot", { method: "GET" }, options) } - screenshotRegion(sandbox: SandboxRef, payload: DesktopScreenshotRegionParams, options?: RequestOptions): Promise { return this.transport.requestJsonUrl(this.requestUrl(sandbox, "/api/screenshot/region"), { method: "POST", body: jsonBody(payload) }, options) } - pointerPosition(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/input/position", { method: "GET" }, options) } - movePointer(sandbox: SandboxRef, x: number, y: number, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/input/move", { method: "POST", body: jsonBody({ x, y }) }, options) } - click(sandbox: SandboxRef, button = 1, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/input/click", { method: "POST", body: jsonBody({ button }) }, options) } - drag(sandbox: SandboxRef, payload: DesktopDragParams, options?: RequestOptions): Promise { return this.transport.requestJsonUrl(this.requestUrl(sandbox, "/api/input/drag"), { method: "POST", body: jsonBody(payload) }, options) } - scroll(sandbox: SandboxRef, payload: DesktopScrollParams, options?: RequestOptions): Promise { return this.transport.requestJsonUrl(this.requestUrl(sandbox, "/api/input/scroll"), { method: "POST", body: jsonBody(payload) }, options) } - typeText(sandbox: SandboxRef, text: string, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/input/type", { method: "POST", body: jsonBody({ text }) }, options) } - press(sandbox: SandboxRef, key: string, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/input/press", { method: "POST", body: jsonBody({ key }) }, options) } - hotkey(sandbox: SandboxRef, keys: string[], options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/input/hotkey", { method: "POST", body: jsonBody({ keys }) }, options) } - recordingStatus(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/recording", { method: "GET" }, options) } - startRecording(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/recording/start", { method: "POST" }, options) } - stopRecording(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/recording/stop", { method: "POST" }, options) } - recordings(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/recordings", { method: "GET" }, options) } - recording(sandbox: SandboxRef, id: string, options?: RequestOptions): Promise { return this.requestJson(sandbox, `/api/recordings/${encodeURIComponent(id)}`, { method: "GET" }, options) } - downloadRecording(sandbox: SandboxRef, id: string, options?: RequestOptions): Promise { return this.requestJson(sandbox, `/api/recordings/${encodeURIComponent(id)}/download`, { method: "GET" }, options) } - async deleteRecording(sandbox: SandboxRef, id: string, options?: RequestOptions): Promise { await this.requestJson(sandbox, `/api/recordings/${encodeURIComponent(id)}`, { method: "DELETE" }, options) } - health(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/healthz", { method: "GET" }, options) } - status(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson(sandbox, "/api/status", { method: "GET" }, options) } - processStatus(sandbox: SandboxRef, name: string, options?: RequestOptions): Promise { return this.requestJson(sandbox, `/api/process/${encodeURIComponent(name)}/status`, { method: "GET" }, options) } - restartProcess(sandbox: SandboxRef, name: string, options?: RequestOptions): Promise { return this.requestJson(sandbox, `/api/process/${encodeURIComponent(name)}/restart`, { method: "POST" }, options) } - processLogs(sandbox: SandboxRef, name: string, options?: RequestOptions): Promise { return this.requestJson(sandbox, `/api/process/${encodeURIComponent(name)}/logs`, { method: "GET" }, options) } - processErrors(sandbox: SandboxRef, name: string, options?: RequestOptions): Promise { return this.requestJson(sandbox, `/api/process/${encodeURIComponent(name)}/errors`, { method: "GET" }, options) } + browserUrl(sandbox: SandboxRef): string { + return sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain); + } + + private async requestJson( + sandbox: SandboxRef, + schema: z.ZodType, + path: string, + init: RequestInit = {}, + options: RequestOptions = {}, + ): Promise { + return normalize( + schema, + await this.transport.requestJsonUrl(this.requestUrl(sandbox, path), init, options), + ); + } - private requestJson(sandbox: SandboxRef, path: string, init: RequestInit = {}, options: RequestOptions = {}): Promise { - return this.transport.requestJsonUrl(this.requestUrl(sandbox, path), init, options) + async display(sandbox: SandboxRef, options?: RequestOptions): Promise { + return this.requestJson( + sandbox, + desktopDisplayInfoSchema, + "/api/display", + { method: "GET" }, + options, + ); + } + async screen(sandbox: SandboxRef, options?: RequestOptions): Promise { + return this.requestJson( + sandbox, + desktopDisplayInfoSchema, + "/api/display/screen", + { method: "GET" }, + options, + ); + } + async setScreen( + sandbox: SandboxRef, + payload: DesktopSetScreenParams, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopDisplayInfoSchema, + "/api/display/screen", + { method: "POST", body: jsonBody(payload) }, + options, + ); + } + async windows(sandbox: SandboxRef, options?: RequestOptions): Promise { + return this.requestJson( + sandbox, + z.object({ items: z.array(desktopWindowSchema) }), + "/api/display/windows", + { method: "GET" }, + options, + ).then((r) => r.items); + } + async screenshot( + sandbox: SandboxRef, + params: DesktopScreenshotParams = {}, + options?: RequestOptions, + ): Promise { + return await this.transport.requestBytesUrl( + this.requestUrl(sandbox, "/api/screenshot"), + { method: "GET" }, + { ...options, query: params }, + ); + } + async screenshotRegion( + sandbox: SandboxRef, + payload: DesktopScreenshotRegionParams, + options?: RequestOptions, + ): Promise { + return await this.transport.requestBytesUrl( + this.requestUrl(sandbox, "/api/screenshot/region"), + { method: "POST", body: jsonBody(payload) }, + options, + ); + } + async pointerPosition( + sandbox: SandboxRef, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopPointerPositionSchema, + "/api/input/position", + { method: "GET" }, + options, + ); + } + async movePointer( + sandbox: SandboxRef, + x: number, + y: number, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopPointerPositionSchema, + "/api/input/move", + { method: "POST", body: jsonBody({ x, y }) }, + options, + ); + } + async click( + sandbox: SandboxRef, + params: DesktopClickParams = {}, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopPointerPositionSchema, + "/api/input/click", + { method: "POST", body: jsonBody(params) }, + options, + ); + } + async drag( + sandbox: SandboxRef, + payload: DesktopDragParams, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopPointerPositionSchema, + "/api/input/drag", + { + method: "POST", + body: jsonBody({ + from_x: payload.fromX, + from_y: payload.fromY, + to_x: payload.toX, + to_y: payload.toY, + button: payload.button, + }), + }, + options, + ); + } + async scroll( + sandbox: SandboxRef, + payload: DesktopScrollParams, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopPointerPositionSchema, + "/api/input/scroll", + { method: "POST", body: jsonBody(payload) }, + options, + ); + } + async typeText(sandbox: SandboxRef, text: string, options?: RequestOptions): Promise { + await this.transport.requestJsonUrl( + this.requestUrl(sandbox, "/api/input/type"), + { method: "POST", body: jsonBody({ text }) }, + options, + ); + } + async press(sandbox: SandboxRef, key: string, options?: RequestOptions): Promise { + await this.transport.requestJsonUrl( + this.requestUrl(sandbox, "/api/input/press"), + { method: "POST", body: jsonBody({ key }) }, + options, + ); + } + async hotkey(sandbox: SandboxRef, keys: string[], options?: RequestOptions): Promise { + await this.transport.requestJsonUrl( + this.requestUrl(sandbox, "/api/input/hotkey"), + { method: "POST", body: jsonBody({ keys }) }, + options, + ); + } + async recordingStatus( + sandbox: SandboxRef, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopRecordingStatusSchema, + "/api/recording", + { method: "GET" }, + options, + ); + } + async startRecording( + sandbox: SandboxRef, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopRecordingStatusSchema, + "/api/recording/start", + { method: "POST" }, + options, + ); + } + async stopRecording( + sandbox: SandboxRef, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopRecordingStatusSchema, + "/api/recording/stop", + { method: "POST" }, + options, + ); + } + async recordings( + sandbox: SandboxRef, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + z.object({ items: z.array(desktopRecordingSummarySchema) }), + "/api/recordings", + { method: "GET" }, + options, + ).then((r) => r.items); + } + async recording( + sandbox: SandboxRef, + id: string, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopRecordingSummarySchema, + `/api/recordings/${encodeURIComponent(id)}`, + { method: "GET" }, + options, + ); + } + async downloadRecording( + sandbox: SandboxRef, + id: string, + options?: RequestOptions, + ): Promise { + return await this.transport.requestBytesUrl( + this.requestUrl(sandbox, `/api/recordings/${encodeURIComponent(id)}/download`), + { method: "GET" }, + options, + ); + } + async deleteRecording(sandbox: SandboxRef, id: string, options?: RequestOptions): Promise { + await this.transport.requestUrl( + this.requestUrl(sandbox, `/api/recordings/${encodeURIComponent(id)}`), + { method: "DELETE" }, + options, + ); + } + async health(sandbox: SandboxRef, options?: RequestOptions): Promise { + return this.requestJson( + sandbox, + desktopHealthSchema, + "/api/healthz", + { method: "GET" }, + options, + ); + } + async status(sandbox: SandboxRef, options?: RequestOptions): Promise { + return this.requestJson( + sandbox, + desktopProcessStatusListSchema, + "/api/status", + { method: "GET" }, + options, + ); + } + async processStatus( + sandbox: SandboxRef, + name: string, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopProcessStatusSchema, + `/api/process/${encodeURIComponent(name)}/status`, + { method: "GET" }, + options, + ); + } + async restartProcess( + sandbox: SandboxRef, + name: string, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopProcessRestartSchema, + `/api/process/${encodeURIComponent(name)}/restart`, + { method: "POST" }, + options, + ); + } + async processLogs( + sandbox: SandboxRef, + name: string, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopProcessLogsSchema, + `/api/process/${encodeURIComponent(name)}/logs`, + { method: "GET" }, + options, + ); + } + async processErrors( + sandbox: SandboxRef, + name: string, + options?: RequestOptions, + ): Promise { + return this.requestJson( + sandbox, + desktopProcessErrorsSchema, + `/api/process/${encodeURIComponent(name)}/errors`, + { method: "GET" }, + options, + ); } - async *statusStream(sandbox: SandboxRef, options: RequestOptions = {}): AsyncIterable { - for await (const event of this.transport.streamJsonUrl(this.requestUrl(sandbox, "/api/status/stream"), { method: "GET" }, options)) { + async *statusStream( + sandbox: SandboxRef, + options: RequestOptions = {}, + ): AsyncIterable { + for await (const event of this.transport.streamJsonUrl( + this.requestUrl(sandbox, "/api/status/stream"), + { method: "GET" }, + options, + )) { if (!event || typeof event !== "object") { - throw new Leap0Error("Malformed desktop status stream event") + throw new Leap0Error("Malformed desktop status stream event"); } - if (typeof asRecord(event).error === "string") { - throw new Leap0Error(String(asRecord(event).error)) + if (typeof asRecord(event).message === "string") { + throw new Leap0Error(String(asRecord(event).message)); } - yield event as DesktopStatusStreamEvent + yield normalize(desktopProcessStatusListSchema, event); } } async waitUntilReady(sandbox: SandboxRef, timeout = 60): Promise { - const startedAt = Date.now() - let delayMs = 250 + const startedAt = Date.now(); + let delayMs = 250; while (true) { - const status = await this.status(sandbox, { timeout }) - if (status.processes.every((process) => process.status === "running")) { - return + const status = await this.status(sandbox, { timeout }); + if ((status.items ?? []).every((process) => process.running === true)) { + return; } if (Date.now() - startedAt > timeout * 1000) { - throw new Leap0Error("Desktop did not become ready within timeout") + throw new Leap0Error("Desktop did not become ready within timeout"); } - await new Promise((resolve) => setTimeout(resolve, delayMs)) - delayMs = Math.min(delayMs * 2, 2000) + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs = Math.min(delayMs * 2, 2000); } } } diff --git a/src/services/filesystem.ts b/src/services/filesystem.ts index e51abdd..ae745d3 100644 --- a/src/services/filesystem.ts +++ b/src/services/filesystem.ts @@ -1,6 +1,8 @@ +import { z } from "zod"; +import { normalize } from "@/core/normalize.js"; import type { EditFileResult, - EditResult, + EditFilesResult, FileEdit, FileInfo, LsResult, @@ -8,94 +10,245 @@ import type { SandboxRef, SearchMatch, TreeResult, -} from "@/models/index.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { sandboxIdOf } from "@/core/utils.js" +} from "@/models/index.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { + editFileResultSchema, + editFilesResultSchema, + fileInfoSchema, + lsResultSchema, + searchMatchSchema, + treeResultSchema, +} from "@/models/filesystem.js"; +import { sandboxIdOf } from "@/core/utils.js"; /** Performs filesystem operations inside a sandbox. */ export class FilesystemClient { constructor(private readonly transport: Leap0Transport) {} /** Lists directory contents. */ - ls(sandbox: SandboxRef, path = "/workspace", options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/ls`, { method: "POST", body: jsonBody({ path }) }, options) + async ls( + sandbox: SandboxRef, + path = "/workspace", + options: RequestOptions = {}, + ): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/ls`, + { method: "POST", body: jsonBody({ path }) }, + options, + ); + return normalize(lsResultSchema, data); } /** Fetches metadata for a path. */ - stat(sandbox: SandboxRef, path: string, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/stat`, { method: "POST", body: jsonBody({ path }) }, options) + async stat(sandbox: SandboxRef, path: string, options: RequestOptions = {}): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/stat`, + { method: "POST", body: jsonBody({ path }) }, + options, + ); + return normalize(fileInfoSchema, data); } async mkdir(sandbox: SandboxRef, path: string, options: RequestOptions = {}): Promise { - await this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/mkdir`, { method: "POST", body: jsonBody({ path }) }, options) - } - - async writeFile(sandbox: SandboxRef, path: string, content: string, options: RequestOptions = {}): Promise { - await this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/write-file`, { - method: "POST", - headers: { "content-type": "text/plain" }, - body: content, - }, { ...options, query: { path } }) - } - - async writeBytes(sandbox: SandboxRef, path: string, content: Uint8Array, options: RequestOptions = {}): Promise { - await this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/write-file`, { - method: "POST", - headers: { "content-type": "application/octet-stream" }, - body: Buffer.from(content), - }, { ...options, query: { path } }) + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/mkdir`, + { method: "POST", body: jsonBody({ path }) }, + options, + ); } - readFile( + async writeFile( + sandbox: SandboxRef, + path: string, + content: string, + options: RequestOptions = {}, + ): Promise { + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/write-file`, + { + method: "POST", + headers: { "content-type": "text/plain" }, + body: content, + }, + { ...options, query: { path } }, + ); + } + + async writeBytes( + sandbox: SandboxRef, + path: string, + content: Uint8Array, + options: RequestOptions = {}, + ): Promise { + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/write-file`, + { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body: Buffer.from(content), + }, + { ...options, query: { path } }, + ); + } + + async readFile( sandbox: SandboxRef, path: string, params: { offset?: number; limit?: number; head?: number; tail?: number } = {}, options: RequestOptions = {}, ): Promise { - return this.transport.requestText(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/read-file`, { method: "GET" }, { ...options, query: { path, ...params } }) + return await this.transport.requestText( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/read-file`, + { method: "GET" }, + { ...options, query: { path, ...params } }, + ); } - readBytes(sandbox: SandboxRef, path: string, options: RequestOptions = {}): Promise { - return this.transport.requestBytes(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/read-file`, { method: "GET" }, { ...options, query: { path } }) + async readBytes( + sandbox: SandboxRef, + path: string, + options: RequestOptions = {}, + ): Promise { + return await this.transport.requestBytes( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/read-file`, + { method: "GET" }, + { ...options, query: { path } }, + ); } async delete(sandbox: SandboxRef, path: string, options: RequestOptions = {}): Promise { - await this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/delete`, { method: "POST", body: jsonBody({ path }) }, options) - } - - async setPermissions(sandbox: SandboxRef, path: string, mode: string, options: RequestOptions = {}): Promise { - await this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/set-permissions`, { method: "POST", body: jsonBody({ path, mode }) }, options) + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/delete`, + { method: "POST", body: jsonBody({ path }) }, + options, + ); } - glob(sandbox: SandboxRef, path: string, pattern: string, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/glob`, { method: "POST", body: jsonBody({ path, pattern }) }, options) + async setPermissions( + sandbox: SandboxRef, + path: string, + mode: string, + options: RequestOptions = {}, + ): Promise { + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/set-permissions`, + { method: "POST", body: jsonBody({ path, mode }) }, + options, + ); } - grep(sandbox: SandboxRef, path: string, pattern: string, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/grep`, { method: "POST", body: jsonBody({ path, pattern }) }, options) + async glob( + sandbox: SandboxRef, + path: string, + pattern: string, + options: RequestOptions = {}, + ): Promise { + return await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/glob`, + { method: "POST", body: jsonBody({ path, pattern }) }, + options, + ); } - editFile(sandbox: SandboxRef, path: string, edit: FileEdit, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/edit-file`, { method: "POST", body: jsonBody({ path, edit }) }, options) + async grep( + sandbox: SandboxRef, + path: string, + pattern: string, + options: RequestOptions = {}, + ): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/grep`, + { method: "POST", body: jsonBody({ path, pattern }) }, + options, + ); + return normalize(z.object({ items: z.array(searchMatchSchema) }), data).items; } - editFiles(sandbox: SandboxRef, files: Array<{ path: string; edit: FileEdit }>, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/edit-files`, { method: "POST", body: jsonBody({ files }) }, options) + async editFile( + sandbox: SandboxRef, + path: string, + edit: FileEdit, + options: RequestOptions = {}, + ): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/edit-file`, + { method: "POST", body: jsonBody({ path, edits: [edit] }) }, + options, + ); + return normalize(editFileResultSchema, data); } - async move(sandbox: SandboxRef, source: string, destination: string, options: RequestOptions = {}): Promise { - await this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/move`, { method: "POST", body: jsonBody({ source, destination }) }, options) + async editFiles( + sandbox: SandboxRef, + files: Array<{ path: string; edit: FileEdit }>, + options: RequestOptions = {}, + ): Promise { + const firstEdit = files[0]?.edit; + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/edit-files`, + { + method: "POST", + body: jsonBody({ + files: files.map((file) => file.path), + find: firstEdit?.find, + replace: firstEdit?.replace, + }), + }, + options, + ); + return normalize(editFilesResultSchema, data); + } + + async move( + sandbox: SandboxRef, + source: string, + destination: string, + options: RequestOptions = {}, + ): Promise { + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/move`, + { method: "POST", body: jsonBody({ src_path: source, dst_path: destination }) }, + options, + ); } - async copy(sandbox: SandboxRef, source: string, destination: string, options: RequestOptions = {}): Promise { - await this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/copy`, { method: "POST", body: jsonBody({ source, destination }) }, options) + async copy( + sandbox: SandboxRef, + source: string, + destination: string, + options: RequestOptions = {}, + ): Promise { + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/copy`, + { method: "POST", body: jsonBody({ src_path: source, dst_path: destination }) }, + options, + ); } - exists(sandbox: SandboxRef, path: string, options: RequestOptions = {}): Promise<{ exists: boolean }> { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/exists`, { method: "POST", body: jsonBody({ path }) }, options) + async exists( + sandbox: SandboxRef, + path: string, + options: RequestOptions = {}, + ): Promise<{ exists: boolean }> { + return await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/exists`, + { method: "POST", body: jsonBody({ path }) }, + options, + ); } - tree(sandbox: SandboxRef, path: string, maxDepth?: number, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/tree`, { method: "POST", body: jsonBody({ path, max_depth: maxDepth }) }, options) + async tree( + sandbox: SandboxRef, + path: string, + maxDepth?: number, + options: RequestOptions = {}, + ): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/tree`, + { method: "POST", body: jsonBody({ path, max_depth: maxDepth }) }, + options, + ); + return normalize(treeResultSchema, data); } } diff --git a/src/services/git.ts b/src/services/git.ts index 832c3be..20e8c0f 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -1,29 +1,130 @@ -import type { GitCommitResult, GitResult, RequestOptions, SandboxRef } from "@/models/index.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { sandboxIdOf } from "@/core/utils.js" +import { normalize } from "@/core/normalize.js"; +import type { GitCommitResult, GitResult, RequestOptions, SandboxRef } from "@/models/index.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { gitCommitResultSchema, gitResultSchema } from "@/models/git.js"; +import { sandboxIdOf } from "@/core/utils.js"; /** Runs git operations inside a sandbox repository. */ export class GitClient { constructor(private readonly transport: Leap0Transport) {} - private json(sandbox: SandboxRef, endpoint: string, body: unknown, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/git/${endpoint}`, { method: "POST", body: jsonBody(body) }, options) + private async json( + sandbox: SandboxRef, + endpoint: string, + body: unknown, + options: RequestOptions = {}, + ): Promise { + return await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/git/${endpoint}`, + { method: "POST", body: jsonBody(body) }, + options, + ); } - clone(sandbox: SandboxRef, url: string, path: string, options?: RequestOptions): Promise { return this.json(sandbox, "clone", { url, path }, options) } - status(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return this.json(sandbox, "status", { path }, options) } - branches(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return this.json(sandbox, "branches", { path }, options) } - diffUnstaged(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return this.json(sandbox, "diff-unstaged", { path }, options) } - diffStaged(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return this.json(sandbox, "diff-staged", { path }, options) } - diff(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return this.json(sandbox, "diff", { path }, options) } - reset(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return this.json(sandbox, "reset", { path }, options) } - log(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return this.json(sandbox, "log", { path }, options) } - show(sandbox: SandboxRef, path: string, ref: string, options?: RequestOptions): Promise { return this.json(sandbox, "show", { path, ref }, options) } - createBranch(sandbox: SandboxRef, path: string, name: string, options?: RequestOptions): Promise { return this.json(sandbox, "create-branch", { path, name }, options) } - checkoutBranch(sandbox: SandboxRef, path: string, name: string, options?: RequestOptions): Promise { return this.json(sandbox, "checkout-branch", { path, name }, options) } - deleteBranch(sandbox: SandboxRef, path: string, name: string, options?: RequestOptions): Promise { return this.json(sandbox, "delete-branch", { path, name }, options) } - add(sandbox: SandboxRef, path: string, files: string[], options?: RequestOptions): Promise { return this.json(sandbox, "add", { path, files }, options) } - commit(sandbox: SandboxRef, path: string, message: string, options?: RequestOptions): Promise { return this.json(sandbox, "commit", { path, message }, options) } - push(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return this.json(sandbox, "push", { path }, options) } - pull(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return this.json(sandbox, "pull", { path }, options) } + async clone( + sandbox: SandboxRef, + url: string, + path: string, + options?: RequestOptions, + ): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "clone", { url, path }, options)); + } + async status(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "status", { path }, options)); + } + async branches(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "branches", { path }, options)); + } + async diffUnstaged( + sandbox: SandboxRef, + path: string, + options?: RequestOptions, + ): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "diff-unstaged", { path }, options)); + } + async diffStaged( + sandbox: SandboxRef, + path: string, + options?: RequestOptions, + ): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "diff-staged", { path }, options)); + } + async diff(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "diff", { path }, options)); + } + async reset(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "reset", { path }, options)); + } + async log(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "log", { path }, options)); + } + async show( + sandbox: SandboxRef, + path: string, + ref: string, + options?: RequestOptions, + ): Promise { + return normalize( + gitResultSchema, + await this.json(sandbox, "show", { path, revision: ref }, options), + ); + } + async createBranch( + sandbox: SandboxRef, + path: string, + name: string, + options?: RequestOptions, + ): Promise { + return normalize( + gitResultSchema, + await this.json(sandbox, "create-branch", { path, name }, options), + ); + } + async checkoutBranch( + sandbox: SandboxRef, + path: string, + name: string, + options?: RequestOptions, + ): Promise { + return normalize( + gitResultSchema, + await this.json(sandbox, "checkout-branch", { path, branch: name }, options), + ); + } + async deleteBranch( + sandbox: SandboxRef, + path: string, + name: string, + options?: RequestOptions, + ): Promise { + return normalize( + gitResultSchema, + await this.json(sandbox, "delete-branch", { path, name }, options), + ); + } + async add( + sandbox: SandboxRef, + path: string, + files: string[], + options?: RequestOptions, + ): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "add", { path, files }, options)); + } + async commit( + sandbox: SandboxRef, + path: string, + message: string, + options?: RequestOptions, + ): Promise { + return normalize( + gitCommitResultSchema, + await this.json(sandbox, "commit", { path, message }, options), + ); + } + async push(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "push", { path }, options)); + } + async pull(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { + return normalize(gitResultSchema, await this.json(sandbox, "pull", { path }, options)); + } } diff --git a/src/services/index.ts b/src/services/index.ts index ea6c118..241708a 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,11 +1,11 @@ -export { CodeInterpreterClient } from "@/services/code-interpreter.js" -export { DesktopClient } from "@/services/desktop.js" -export { FilesystemClient } from "@/services/filesystem.js" -export { GitClient } from "@/services/git.js" -export { LspClient } from "@/services/lsp.js" -export { ProcessClient } from "@/services/process.js" -export { PtyClient, PtyConnection } from "@/services/pty.js" -export { SandboxesClient } from "@/services/sandboxes.js" -export { SnapshotsClient } from "@/services/snapshots.js" -export { SshClient } from "@/services/ssh.js" -export { TemplatesClient } from "@/services/templates.js" +export { CodeInterpreterClient } from "@/services/code-interpreter.js"; +export { DesktopClient } from "@/services/desktop.js"; +export { FilesystemClient } from "@/services/filesystem.js"; +export { GitClient } from "@/services/git.js"; +export { LspClient } from "@/services/lsp.js"; +export { ProcessClient } from "@/services/process.js"; +export { PtyClient, PtyConnection } from "@/services/pty.js"; +export { SandboxesClient } from "@/services/sandboxes.js"; +export { SnapshotsClient } from "@/services/snapshots.js"; +export { SshClient } from "@/services/ssh.js"; +export { TemplatesClient } from "@/services/templates.js"; diff --git a/src/services/lsp.ts b/src/services/lsp.ts index 6ee53ee..a9ce26a 100644 --- a/src/services/lsp.ts +++ b/src/services/lsp.ts @@ -1,23 +1,162 @@ -import type { LspJsonRpcResponse, LspResponse, RequestOptions, SandboxRef } from "@/models/index.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { sandboxIdOf, toFileUri } from "@/core/utils.js" +import type { + LspJsonRpcResponse, + LspResponse, + RequestOptions, + SandboxRef, +} from "@/models/index.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { sandboxIdOf, toFileUri } from "@/core/utils.js"; /** Starts and interacts with language servers inside a sandbox. */ export class LspClient { constructor(private readonly transport: Leap0Transport) {} - private json(sandbox: SandboxRef, endpoint: string, body: unknown, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/lsp/${endpoint}`, { method: "POST", body: jsonBody(body) }, options) + private async json( + sandbox: SandboxRef, + endpoint: string, + body: unknown, + options: RequestOptions = {}, + ): Promise { + return await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/lsp/${endpoint}`, + { method: "POST", body: jsonBody(body) }, + options, + ); } - start(sandbox: SandboxRef, languageId: string, pathToProject: string, options?: RequestOptions): Promise { return this.json(sandbox, "start", { language_id: languageId, path_to_project: pathToProject }, options) } - stop(sandbox: SandboxRef, languageId: string, pathToProject: string, options?: RequestOptions): Promise { return this.json(sandbox, "stop", { language_id: languageId, path_to_project: pathToProject }, options) } - didOpen(sandbox: SandboxRef, languageId: string, pathToProject: string, uri: string, options?: RequestOptions): Promise { return this.json(sandbox, "did-open", { language_id: languageId, path_to_project: pathToProject, uri }, options) } - didOpenPath(sandbox: SandboxRef, languageId: string, pathToProject: string, path: string, options?: RequestOptions): Promise { return this.didOpen(sandbox, languageId, pathToProject, toFileUri(path), options) } - didClose(sandbox: SandboxRef, languageId: string, pathToProject: string, uri: string, options?: RequestOptions): Promise { return this.json(sandbox, "did-close", { language_id: languageId, path_to_project: pathToProject, uri }, options) } - didClosePath(sandbox: SandboxRef, languageId: string, pathToProject: string, path: string, options?: RequestOptions): Promise { return this.didClose(sandbox, languageId, pathToProject, toFileUri(path), options) } - completions(sandbox: SandboxRef, languageId: string, pathToProject: string, uri: string, line: number, character: number, options?: RequestOptions): Promise { return this.json(sandbox, "completions", { language_id: languageId, path_to_project: pathToProject, uri, line, character }, options) } - completionsPath(sandbox: SandboxRef, languageId: string, pathToProject: string, path: string, line: number, character: number, options?: RequestOptions): Promise { return this.completions(sandbox, languageId, pathToProject, toFileUri(path), line, character, options) } - documentSymbols(sandbox: SandboxRef, languageId: string, pathToProject: string, uri: string, options?: RequestOptions): Promise { return this.json(sandbox, "document-symbols", { language_id: languageId, path_to_project: pathToProject, uri }, options) } - documentSymbolsPath(sandbox: SandboxRef, languageId: string, pathToProject: string, path: string, options?: RequestOptions): Promise { return this.documentSymbols(sandbox, languageId, pathToProject, toFileUri(path), options) } + async start( + sandbox: SandboxRef, + languageId: string, + pathToProject: string, + options?: RequestOptions, + ): Promise { + return await this.json( + sandbox, + "start", + { language_id: languageId, path_to_project: pathToProject }, + options, + ); + } + async stop( + sandbox: SandboxRef, + languageId: string, + pathToProject: string, + options?: RequestOptions, + ): Promise { + return await this.json( + sandbox, + "stop", + { language_id: languageId, path_to_project: pathToProject }, + options, + ); + } + async didOpen( + sandbox: SandboxRef, + languageId: string, + pathToProject: string, + uri: string, + options?: RequestOptions, + ): Promise { + return await this.json( + sandbox, + "did-open", + { language_id: languageId, path_to_project: pathToProject, uri }, + options, + ); + } + async didOpenPath( + sandbox: SandboxRef, + languageId: string, + pathToProject: string, + path: string, + options?: RequestOptions, + ): Promise { + return await this.didOpen(sandbox, languageId, pathToProject, toFileUri(path), options); + } + async didClose( + sandbox: SandboxRef, + languageId: string, + pathToProject: string, + uri: string, + options?: RequestOptions, + ): Promise { + return await this.json( + sandbox, + "did-close", + { language_id: languageId, path_to_project: pathToProject, uri }, + options, + ); + } + async didClosePath( + sandbox: SandboxRef, + languageId: string, + pathToProject: string, + path: string, + options?: RequestOptions, + ): Promise { + return await this.didClose(sandbox, languageId, pathToProject, toFileUri(path), options); + } + async completions( + sandbox: SandboxRef, + languageId: string, + pathToProject: string, + uri: string, + line: number, + character: number, + options?: RequestOptions, + ): Promise { + return await this.json( + sandbox, + "completions", + { + language_id: languageId, + path_to_project: pathToProject, + uri, + position: { line, character }, + }, + options, + ); + } + async completionsPath( + sandbox: SandboxRef, + languageId: string, + pathToProject: string, + path: string, + line: number, + character: number, + options?: RequestOptions, + ): Promise { + return await this.completions( + sandbox, + languageId, + pathToProject, + toFileUri(path), + line, + character, + options, + ); + } + async documentSymbols( + sandbox: SandboxRef, + languageId: string, + pathToProject: string, + uri: string, + options?: RequestOptions, + ): Promise { + return await this.json( + sandbox, + "document-symbols", + { language_id: languageId, path_to_project: pathToProject, uri }, + options, + ); + } + async documentSymbolsPath( + sandbox: SandboxRef, + languageId: string, + pathToProject: string, + path: string, + options?: RequestOptions, + ): Promise { + return await this.documentSymbols(sandbox, languageId, pathToProject, toFileUri(path), options); + } } diff --git a/src/services/process.ts b/src/services/process.ts index b17a053..b30cf35 100644 --- a/src/services/process.ts +++ b/src/services/process.ts @@ -1,17 +1,24 @@ -import type { ProcessResult, RequestOptions, SandboxRef } from "@/models/index.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { sandboxIdOf } from "@/core/utils.js" +import { normalize } from "@/core/normalize.js"; +import type { ProcessResult, RequestOptions, SandboxRef } from "@/models/index.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { processResultSchema } from "@/models/process.js"; +import { sandboxIdOf } from "@/core/utils.js"; /** Executes one-shot commands inside a sandbox. */ export class ProcessClient { constructor(private readonly transport: Leap0Transport) {} /** Executes a command and returns stdout, stderr, and exit code output. */ - execute( + async execute( sandbox: SandboxRef, params: { command: string; cwd?: string; timeout?: number }, options: RequestOptions = {}, ): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/process/execute`, { method: "POST", body: jsonBody(params) }, options) + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/process/execute`, + { method: "POST", body: jsonBody(params) }, + options, + ); + return normalize(processResultSchema, data); } } diff --git a/src/services/pty.ts b/src/services/pty.ts index 0c4a41a..a1ce466 100644 --- a/src/services/pty.ts +++ b/src/services/pty.ts @@ -1,68 +1,143 @@ -import { Leap0WebSocketError } from "@/core/errors.js" -import type { CreatePtySessionParams, PtySession, RequestOptions, SandboxRef } from "@/models/index.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { sandboxBaseUrl, sandboxIdOf, websocketUrlFromHttp } from "@/core/utils.js" +import { z } from "zod"; +import { Leap0WebSocketError } from "@/core/errors.js"; +import { normalize } from "@/core/normalize.js"; +import type { + CreatePtySessionParams, + PtySession, + RequestOptions, + SandboxRef, +} from "@/models/index.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { ptySessionSchema } from "@/models/pty.js"; +import { sandboxBaseUrl, sandboxIdOf, websocketUrlFromHttp } from "@/core/utils.js"; /** Thin wrapper around an interactive PTY websocket connection. */ export class PtyConnection { constructor(private readonly socket: WebSocket) {} send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { - this.socket.send(data) + this.socket.send(data); } recv(): Promise { return new Promise((resolve, reject) => { - this.socket.addEventListener("message", (event) => { - const value = event.data - if (typeof value === "string") { - resolve(new TextEncoder().encode(value)) - return - } - if (value instanceof Blob) { - value.arrayBuffer().then((buffer) => resolve(new Uint8Array(buffer))).catch(reject) - return - } - resolve(new Uint8Array(value)) - }, { once: true }) - this.socket.addEventListener("error", () => reject(new Leap0WebSocketError("PTY websocket error")), { once: true }) - }) + this.socket.addEventListener( + "message", + (event) => { + const value = event.data; + if (typeof value === "string") { + resolve(new TextEncoder().encode(value)); + return; + } + if (value instanceof Blob) { + value + .arrayBuffer() + .then((buffer) => resolve(new Uint8Array(buffer))) + .catch(reject); + return; + } + resolve(new Uint8Array(value)); + }, + { once: true }, + ); + this.socket.addEventListener( + "error", + () => reject(new Leap0WebSocketError("PTY websocket error")), + { once: true }, + ); + }); } close(): void { - this.socket.close() + this.socket.close(); } } /** Creates and manages PTY sessions for interactive terminals. */ export class PtyClient { - constructor(private readonly transport: Leap0Transport, private readonly sandboxDomain: string) {} + constructor( + private readonly transport: Leap0Transport, + private readonly sandboxDomain: string, + ) {} - list(sandbox: SandboxRef, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/pty`, { method: "GET" }, options) + async list(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/pty`, + { method: "GET" }, + options, + ); + return normalize(z.object({ items: z.array(ptySessionSchema) }), data).items; } - create(sandbox: SandboxRef, params: CreatePtySessionParams = {}, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/pty`, { method: "POST", body: jsonBody({ ...params, id: params.id }) }, options) + async create( + sandbox: SandboxRef, + params: CreatePtySessionParams = {}, + options: RequestOptions = {}, + ): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/pty`, + { + method: "POST", + body: jsonBody({ + id: params.id, + cwd: params.cwd, + envs: params.envs, + cols: params.cols, + rows: params.rows, + lazy_start: params.lazyStart, + }), + }, + options, + ); + return normalize(ptySessionSchema, data); } - get(sandbox: SandboxRef, sessionId: string, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}`, { method: "GET" }, options) + async get( + sandbox: SandboxRef, + sessionId: string, + options: RequestOptions = {}, + ): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}`, + { method: "GET" }, + options, + ); + return normalize(ptySessionSchema, data); } - async delete(sandbox: SandboxRef, sessionId: string, options: RequestOptions = {}): Promise { - await this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}`, { method: "DELETE" }, options) + async delete( + sandbox: SandboxRef, + sessionId: string, + options: RequestOptions = {}, + ): Promise { + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}`, + { method: "DELETE" }, + options, + ); } - async resize(sandbox: SandboxRef, sessionId: string, cols: number, rows: number, options: RequestOptions = {}): Promise { - await this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}/resize`, { method: "POST", body: jsonBody({ cols, rows }) }, options) + async resize( + sandbox: SandboxRef, + sessionId: string, + cols: number, + rows: number, + options: RequestOptions = {}, + ): Promise { + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}/resize`, + { method: "POST", body: jsonBody({ cols, rows }) }, + options, + ); } websocketUrl(sandbox: SandboxRef, sessionId: string): string { - return websocketUrlFromHttp(`${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain)}/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}/connect`) + return websocketUrlFromHttp( + `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain)}/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}/connect`, + ); } connect(sandbox: SandboxRef, sessionId: string): PtyConnection { - return new PtyConnection(new WebSocket(this.websocketUrl(sandbox, sessionId))) + return new PtyConnection(new WebSocket(this.websocketUrl(sandbox, sessionId))); } } diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index f496fe7..464f14b 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -3,64 +3,90 @@ import { DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU, -} from "@/config/constants.js" -import { Leap0Error } from "@/core/errors.js" -import type { CreateSandboxParams, RequestOptions, SandboxData, SandboxRef } from "@/models/index.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { ensureLeadingSlash, sandboxBaseUrl, sandboxIdOf, websocketUrlFromHttp } from "@/core/utils.js" -import { withErrorPrefix } from "@/services/shared.js" +} from "@/config/constants.js"; +import { Leap0Error } from "@/core/errors.js"; +import { normalize } from "@/core/normalize.js"; +import { sandboxDataSchema, toNetworkPolicyWire } from "@/models/sandbox.js"; +import type { + CreateSandboxParams, + RequestOptions, + SandboxData, + SandboxRef, +} from "@/models/index.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { + ensureLeadingSlash, + sandboxBaseUrl, + sandboxIdOf, + websocketUrlFromHttp, +} from "@/core/utils.js"; +import { withErrorPrefix } from "@/services/shared.js"; -const OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT" -const OTEL_EXPORTER_OTLP_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS" +const OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"; +const OTEL_EXPORTER_OTLP_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS"; -function injectOtelEnv(envVars: Record | undefined, enabled: boolean): Record | undefined { +function injectOtelEnv( + envVars: Record | undefined, + enabled: boolean, +): Record | undefined { if (!enabled) { - return envVars + return envVars; } - const endpoint = process.env[OTEL_EXPORTER_OTLP_ENDPOINT]?.trim() + const endpoint = process.env[OTEL_EXPORTER_OTLP_ENDPOINT]?.trim(); if (!endpoint) { - throw new Leap0Error(`otelExport=true requires ${OTEL_EXPORTER_OTLP_ENDPOINT} in the local environment`) + throw new Leap0Error( + `otelExport=true requires ${OTEL_EXPORTER_OTLP_ENDPOINT} in the local environment`, + ); } const merged: Record = { [OTEL_EXPORTER_OTLP_ENDPOINT]: endpoint, - } - const headers = process.env[OTEL_EXPORTER_OTLP_HEADERS]?.trim() + }; + const headers = process.env[OTEL_EXPORTER_OTLP_HEADERS]?.trim(); if (headers) { - merged[OTEL_EXPORTER_OTLP_HEADERS] = headers + merged[OTEL_EXPORTER_OTLP_HEADERS] = headers; } if (envVars) { - Object.assign(merged, envVars) + Object.assign(merged, envVars); } - return merged + return merged; } /** Creates, fetches, pauses, and deletes sandboxes. */ -export class SandboxesClient { +export class SandboxesClient { constructor( private readonly transport: Leap0Transport, private readonly sandboxDomain: string, - private readonly wrapSandbox: (data: SandboxData) => WrappedSandbox, ) {} /** Creates a sandbox from a template and resource config. */ - create(params: CreateSandboxParams = {}, options: RequestOptions = {}): Promise { - const templateName = (params.templateName ?? DEFAULT_TEMPLATE_NAME).trim() - const vcpu = params.vcpu ?? DEFAULT_VCPU - const memoryMib = params.memoryMib ?? DEFAULT_MEMORY_MIB - const timeoutMin = params.timeoutMin ?? DEFAULT_TIMEOUT_MIN + async create( + params: CreateSandboxParams = {}, + options: RequestOptions = {}, + ): Promise { + const templateName = (params.templateName ?? DEFAULT_TEMPLATE_NAME).trim(); + const vcpu = params.vcpu ?? DEFAULT_VCPU; + const memoryMib = params.memoryMib ?? DEFAULT_MEMORY_MIB; + const timeoutMin = params.timeoutMin ?? DEFAULT_TIMEOUT_MIN; - if (!templateName || templateName.length > 64) throw new Leap0Error("templateName must be 1-64 characters") - if (!Number.isInteger(vcpu) || vcpu < 1 || vcpu > 8) throw new Leap0Error("vcpu must be between 1 and 8") - if (!Number.isInteger(memoryMib) || memoryMib < 512 || memoryMib > 8192 || memoryMib % 2 !== 0) { - throw new Leap0Error("memoryMib must be even and between 512 and 8192") + if (!templateName || templateName.length > 64) + throw new Leap0Error("templateName must be 1-64 characters"); + if (!Number.isInteger(vcpu) || vcpu < 1 || vcpu > 8) + throw new Leap0Error("vcpu must be between 1 and 8"); + if ( + !Number.isInteger(memoryMib) || + memoryMib < 512 || + memoryMib > 8192 || + memoryMib % 2 !== 0 + ) { + throw new Leap0Error("memoryMib must be even and between 512 and 8192"); } if (!Number.isInteger(timeoutMin) || timeoutMin < 1 || timeoutMin > 480) { - throw new Leap0Error("timeoutMin must be between 1 and 480") + throw new Leap0Error("timeoutMin must be between 1 and 480"); } - const effectiveOtelExport = params.otelExport ?? Boolean(params.telemetry) + const effectiveOtelExport = params.otelExport ?? Boolean(params.telemetry); const payload = { template_name: templateName, vcpu, @@ -68,48 +94,57 @@ export class SandboxesClient { timeout_min: timeoutMin, auto_pause: params.autoPause ?? false, env_vars: injectOtelEnv(params.envVars, effectiveOtelExport), - network_policy: params.networkPolicy, - } + network_policy: toNetworkPolicyWire(params.networkPolicy), + }; - return withErrorPrefix("Failed to create sandbox: ", () => - this.transport - .requestJson("/v1/sandbox", { method: "POST", body: jsonBody(payload) }, options) - .then((data) => this.wrapSandbox(data)), - ) + return withErrorPrefix("Failed to create sandbox: ", async () => { + const data = await this.transport.requestJson( + "/v1/sandbox", + { method: "POST", body: jsonBody(payload) }, + options, + ); + return normalize(sandboxDataSchema, data); + }); } /** Pauses a running sandbox. */ - pause(sandbox: SandboxRef, options: RequestOptions = {}): Promise { - return withErrorPrefix("Failed to pause sandbox: ", () => - this.transport - .requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/pause`, { method: "POST" }, options) - .then((data) => this.wrapSandbox(data)), - ) + async pause(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + return withErrorPrefix("Failed to pause sandbox: ", async () => { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/pause`, + { method: "POST" }, + options, + ); + return normalize(sandboxDataSchema, data); + }); } /** Fetches a sandbox by ID. */ - get(sandbox: SandboxRef, options: RequestOptions = {}): Promise { - return withErrorPrefix("Failed to get sandbox: ", () => - this.transport - .requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/`, { method: "GET" }, options) - .then((data) => this.wrapSandbox(data)), - ) + async get(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + return withErrorPrefix("Failed to get sandbox: ", async () => { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/`, + { method: "GET" }, + options, + ); + return normalize(sandboxDataSchema, data); + }); } /** Deletes a sandbox by ID. */ async delete(sandbox: SandboxRef, options: RequestOptions = {}): Promise { await withErrorPrefix("Failed to delete sandbox: ", () => this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/`, { method: "DELETE" }, options), - ) + ); } /** Builds the public invoke URL for a sandbox. */ invokeUrl(sandbox: SandboxRef, path = "/", port?: number): string { - return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain, port)}${ensureLeadingSlash(path)}` + return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain, port)}${ensureLeadingSlash(path)}`; } /** Builds the public websocket URL for a sandbox. */ websocketUrl(sandbox: SandboxRef, path = "/", port?: number): string { - return websocketUrlFromHttp(this.invokeUrl(sandbox, path, port)) + return websocketUrlFromHttp(this.invokeUrl(sandbox, path, port)); } } diff --git a/src/services/shared.ts b/src/services/shared.ts index e5c229e..63d467d 100644 --- a/src/services/shared.ts +++ b/src/services/shared.ts @@ -1,16 +1,16 @@ -import { Leap0Error } from "@/core/errors.js" +import { Leap0Error } from "@/core/errors.js"; export function asRecord(value: unknown): Record { if ((typeof value === "object" && value !== null) || typeof value === "function") { - return value as Record + return value as Record; } - return {} + return {}; } export async function withErrorPrefix(prefix: string, fn: () => Promise): Promise { try { - return await fn() + return await fn(); } catch (error) { if (error instanceof Leap0Error) { throw new Leap0Error(`${prefix}${error.message}`, { @@ -19,8 +19,8 @@ export async function withErrorPrefix(prefix: string, fn: () => Promise): body: error.body, retryable: error.retryable, cause: error, - }) + }); } - throw error + throw error; } } diff --git a/src/services/snapshots.ts b/src/services/snapshots.ts index 746161c..4a8f6ea 100644 --- a/src/services/snapshots.ts +++ b/src/services/snapshots.ts @@ -6,54 +6,78 @@ import type { SandboxRef, SnapshotData, SnapshotRef, -} from "@/models/index.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { sandboxIdOf, snapshotIdOf } from "@/core/utils.js" -import { withErrorPrefix } from "@/services/shared.js" +} from "@/models/index.js"; +import { normalize } from "@/core/normalize.js"; +import { snapshotDataSchema } from "@/models/snapshot.js"; +import { sandboxDataSchema, toNetworkPolicyWire } from "@/models/sandbox.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { sandboxIdOf, snapshotIdOf } from "@/core/utils.js"; +import { withErrorPrefix } from "@/services/shared.js"; /** Creates, restores, and deletes named snapshots. */ -export class SnapshotsClient { - constructor(private readonly transport: Leap0Transport, private readonly wrapSandbox: (data: SandboxData) => WrappedSandbox) {} +export class SnapshotsClient { + constructor(private readonly transport: Leap0Transport) {} /** Creates a snapshot from a running sandbox. */ - create(sandbox: SandboxRef, params: CreateSnapshotParams = {}, options: RequestOptions = {}): Promise { - return withErrorPrefix("Failed to create snapshot: ", () => - this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/create`, { method: "POST", body: jsonBody(params) }, options), - ) + async create( + sandbox: SandboxRef, + params: CreateSnapshotParams = {}, + options: RequestOptions = {}, + ): Promise { + return withErrorPrefix("Failed to create snapshot: ", async () => { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/create`, + { method: "POST", body: jsonBody(params) }, + options, + ); + return normalize(snapshotDataSchema, data); + }); } /** Creates a snapshot and terminates the source sandbox. */ - pause(sandbox: SandboxRef, params: CreateSnapshotParams = {}, options: RequestOptions = {}): Promise { - return withErrorPrefix("Failed to pause sandbox into snapshot: ", () => - this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/pause`, { method: "POST", body: jsonBody(params) }, options), - ) + async pause( + sandbox: SandboxRef, + params: CreateSnapshotParams = {}, + options: RequestOptions = {}, + ): Promise { + return withErrorPrefix("Failed to pause sandbox into snapshot: ", async () => { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/pause`, + { method: "POST", body: jsonBody(params) }, + options, + ); + return normalize(snapshotDataSchema, data); + }); } /** Restores a sandbox from a snapshot. */ - resume(params: ResumeSnapshotParams, options: RequestOptions = {}): Promise { - return withErrorPrefix("Failed to resume snapshot: ", () => - this.transport - .requestJson( - "/v1/snapshot/resume", - { - method: "POST", - body: jsonBody({ - snapshot_name: params.snapshotName, - auto_pause: params.autoPause, - timeout_min: params.timeoutMin, - network_policy: params.networkPolicy, - }), - }, - options, - ) - .then((data) => this.wrapSandbox(data)), - ) + async resume(params: ResumeSnapshotParams, options: RequestOptions = {}): Promise { + return withErrorPrefix("Failed to resume snapshot: ", async () => { + const data = await this.transport.requestJson( + "/v1/snapshot/resume", + { + method: "POST", + body: jsonBody({ + snapshot_name: params.snapshotName, + auto_pause: params.autoPause, + timeout_min: params.timeoutMin, + network_policy: toNetworkPolicyWire(params.networkPolicy), + }), + }, + options, + ); + return normalize(sandboxDataSchema, data); + }); } /** Deletes a snapshot by ID. */ async delete(snapshot: SnapshotRef, options: RequestOptions = {}): Promise { await withErrorPrefix("Failed to delete snapshot: ", () => - this.transport.request(`/v1/snapshot/${snapshotIdOf(snapshot)}`, { method: "DELETE" }, options), - ) + this.transport.request( + `/v1/snapshot/${snapshotIdOf(snapshot)}`, + { method: "DELETE" }, + options, + ), + ); } } diff --git a/src/services/ssh.ts b/src/services/ssh.ts index 63ea9ed..04186b2 100644 --- a/src/services/ssh.ts +++ b/src/services/ssh.ts @@ -1,24 +1,50 @@ -import type { RequestOptions, SandboxRef, SshAccess, SshValidation } from "@/models/index.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { sandboxIdOf } from "@/core/utils.js" +import { normalize } from "@/core/normalize.js"; +import type { RequestOptions, SandboxRef, SshAccess, SshValidation } from "@/models/index.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { sshAccessSchema, sshValidationSchema } from "@/models/ssh.js"; +import { sandboxIdOf } from "@/core/utils.js"; /** Manages SSH access credentials for a sandbox. */ export class SshClient { constructor(private readonly transport: Leap0Transport) {} - createAccess(sandbox: SandboxRef, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/access`, { method: "POST" }, options) + async createAccess(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/access`, + { method: "POST" }, + options, + ); + return normalize(sshAccessSchema, data); } async deleteAccess(sandbox: SandboxRef, options: RequestOptions = {}): Promise { - await this.transport.request(`/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/access`, { method: "DELETE" }, options) + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/access`, + { method: "DELETE" }, + options, + ); } - validateAccess(sandbox: SandboxRef, accessId: string, password: string, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/validate`, { method: "POST", body: jsonBody({ access_id: accessId, password }) }, options) + async validateAccess( + sandbox: SandboxRef, + accessId: string, + password: string, + options: RequestOptions = {}, + ): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/validate`, + { method: "POST", body: jsonBody({ id: accessId, password }) }, + options, + ); + return normalize(sshValidationSchema, data); } - regenerateAccess(sandbox: SandboxRef, options: RequestOptions = {}): Promise { - return this.transport.requestJson(`/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/regen`, { method: "POST" }, options) + async regenerateAccess(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/regen`, + { method: "POST" }, + options, + ); + return normalize(sshAccessSchema, data); } } diff --git a/src/services/templates.ts b/src/services/templates.ts index a7be6d3..d55212d 100644 --- a/src/services/templates.ts +++ b/src/services/templates.ts @@ -1,40 +1,70 @@ -import { Leap0Error } from "@/core/errors.js" -import type { CreateTemplateParams, RequestOptions, TemplateData, TemplateRef } from "@/models/index.js" -import { createTemplateParamsSchema } from "@/models/template.js" -import { Leap0Transport, jsonBody } from "@/core/transport.js" -import { templateIdOf } from "@/core/utils.js" -import { withErrorPrefix } from "@/services/shared.js" +import { Leap0Error } from "@/core/errors.js"; +import { normalize } from "@/core/normalize.js"; +import type { + CreateTemplateParams, + RequestOptions, + TemplateData, + TemplateRef, +} from "@/models/index.js"; +import { createTemplateParamsSchema, templateDataSchema } from "@/models/template.js"; +import { Leap0Transport, jsonBody } from "@/core/transport.js"; +import { templateIdOf } from "@/core/utils.js"; +import { withErrorPrefix } from "@/services/shared.js"; /** Uploads and manages reusable container templates. */ export class TemplatesClient { constructor(private readonly transport: Leap0Transport) {} /** Uploads a new template from a container image URI. */ - create(params: CreateTemplateParams, options: RequestOptions = {}): Promise { - createTemplateParamsSchema.parse(params) + async create(params: CreateTemplateParams, options: RequestOptions = {}): Promise { + createTemplateParamsSchema.parse(params); - if (!params.name.trim() || params.name.length > 64 || /\s/.test(params.name) || params.name.startsWith("system/")) { - throw new Leap0Error("name must be non-empty, <= 64 chars, contain no whitespace, and not start with system/") + if ( + !params.name.trim() || + params.name.length > 64 || + /\s/.test(params.name) || + params.name.startsWith("system/") + ) { + throw new Leap0Error( + "name must be non-empty, <= 64 chars, contain no whitespace, and not start with system/", + ); } if (!params.uri.trim() || params.uri.length > 500) { - throw new Leap0Error("uri must be non-empty and <= 500 chars") + throw new Leap0Error("uri must be non-empty and <= 500 chars"); } - return withErrorPrefix("Failed to create template: ", () => - this.transport.requestJson("/v1/template", { method: "POST", body: jsonBody(params) }, options), - ) + return withErrorPrefix("Failed to create template: ", async () => { + const data = await this.transport.requestJson( + "/v1/template", + { method: "POST", body: jsonBody(params) }, + options, + ); + return normalize(templateDataSchema, data); + }); } /** Renames a template. */ - rename(template: TemplateRef, params: { name: string }, options: RequestOptions = {}): Promise { - return withErrorPrefix("Failed to rename template: ", () => - this.transport.requestJson(`/v1/template/${templateIdOf(template)}`, { method: "PATCH", body: jsonBody(params) }, options), - ) + async rename( + template: TemplateRef, + params: { name: string }, + options: RequestOptions = {}, + ): Promise { + await withErrorPrefix("Failed to rename template: ", () => + this.transport.request( + `/v1/template/${templateIdOf(template)}`, + { method: "PATCH", body: jsonBody(params) }, + options, + ), + ); } /** Deletes a template by ID. */ async delete(template: TemplateRef, options: RequestOptions = {}): Promise { await withErrorPrefix("Failed to delete template: ", () => - this.transport.request(`/v1/template/${templateIdOf(template)}`, { method: "DELETE" }, options), - ) + this.transport.request( + `/v1/template/${templateIdOf(template)}`, + { method: "DELETE" }, + options, + ), + ); } } diff --git a/tests/client/client-sandbox.test.ts b/tests/client/client-sandbox.test.ts index a129742..0378958 100644 --- a/tests/client/client-sandbox.test.ts +++ b/tests/client/client-sandbox.test.ts @@ -1,79 +1,143 @@ -import assert from "node:assert/strict" -import { expectTypeOf, test } from "vitest" +import assert from "node:assert/strict"; +import { expectTypeOf, test } from "vitest"; -import { Leap0Client, Sandbox } from "@/client/index.js" -import type { RequestOptions } from "@/models/index.js" +import { Leap0Client, Sandbox } from "@/client/index.js"; +import type { RequestOptions } from "@/models/index.js"; test("Leap0Client wires services and supports convenience methods", async () => { - process.env.LEAP0_API_KEY = "env-key" - const client = new Leap0Client({ apiKey: "explicit-key", sandboxDomain: "sandbox.example.com" }) - const originalGet = client.sandboxes.get - const originalCreate = client.sandboxes.create - client.sandboxes.get = (async (id: string) => ({ id, state: "started" })) as never - client.sandboxes.create = (async () => ({ id: "sb-2", state: "creating" })) as never + process.env.LEAP0_API_KEY = "env-key"; + const client = new Leap0Client({ apiKey: "explicit-key", sandboxDomain: "sandbox.example.com" }); + const originalGet = client.sandboxes.get; + const originalCreate = client.sandboxes.create; + client.sandboxes.get = (async (id: string) => ({ + id, + templateId: "tpl-1", + state: "running", + vcpu: 1, + memoryMib: 1024, + diskMib: 4096, + autoPause: false, + createdAt: "2026-01-01T00:00:00Z", + })) as never; + client.sandboxes.create = (async () => ({ + id: "sb-2", + templateId: "tpl-1", + state: "starting", + vcpu: 1, + memoryMib: 1024, + diskMib: 4096, + autoPause: false, + createdAt: "2026-01-01T00:00:00Z", + })) as never; try { - assert.equal((await client.getSandbox("sb-1")).id, "sb-1") - assert.equal((await client.createSandbox()).id, "sb-2") + assert.equal((await client.getSandbox("sb-1")).id, "sb-1"); + assert.equal((await client.createSandbox()).id, "sb-2"); } finally { - client.sandboxes.get = originalGet - client.sandboxes.create = originalCreate - await client.close() + client.sandboxes.get = originalGet; + client.sandboxes.create = originalCreate; + await client.close(); } -}) +}); test("Sandbox binds service methods to itself", async () => { const fakeClient = { sandboxes: { - get: async () => ({ id: "sb-1", state: "started", refreshed: true }), - pause: async () => ({ id: "sb-1", state: "paused" }), + get: async () => ({ + id: "sb-1", + templateId: "tpl-1", + state: "running", + vcpu: 1, + memoryMib: 1024, + diskMib: 4096, + autoPause: false, + createdAt: "2026-01-01T00:00:00Z", + refreshed: true, + }), + pause: async () => ({ + id: "sb-1", + templateId: "tpl-1", + state: "paused", + vcpu: 1, + memoryMib: 1024, + diskMib: 4096, + autoPause: false, + createdAt: "2026-01-01T00:00:00Z", + }), delete: async () => undefined, invokeUrl: (id: string, path: string, port?: number) => `invoke:${id}:${path}:${port ?? ""}`, websocketUrl: (id: string, path: string, port?: number) => `ws:${id}:${path}:${port ?? ""}`, }, - filesystem: { readFile: async (sandbox: { id: string }, path: string) => `${sandbox.id}:${path}` }, - git: {}, process: {}, pty: {}, lsp: {}, ssh: {}, codeInterpreter: {}, desktop: {}, - } - const sandbox = new Sandbox(fakeClient as never, { id: "sb-1", state: "started" }) - assert.equal(await sandbox.filesystem.readFile("/tmp/test.txt"), "sb-1:/tmp/test.txt") - await sandbox.refresh() - assert.equal((sandbox as { refreshed?: boolean }).refreshed, true) - await sandbox.pause() - assert.equal(sandbox.state, "paused") - assert.equal(sandbox.invokeUrl("/healthz", 3000), "invoke:sb-1:/healthz:3000") -}) + filesystem: { + readFile: async (sandbox: { id: string }, path: string) => `${sandbox.id}:${path}`, + }, + git: {}, + process: {}, + pty: {}, + lsp: {}, + ssh: {}, + codeInterpreter: {}, + desktop: {}, + }; + const sandbox = new Sandbox(fakeClient as never, { + id: "sb-1", + templateId: "tpl-1", + state: "running", + vcpu: 1, + memoryMib: 1024, + diskMib: 4096, + autoPause: false, + createdAt: "2026-01-01T00:00:00Z", + }); + assert.equal(await sandbox.filesystem.readFile("/tmp/test.txt"), "sb-1:/tmp/test.txt"); + await sandbox.refresh(); + assert.equal((sandbox as { refreshed?: boolean }).refreshed, true); + await sandbox.pause(); + assert.equal(sandbox.state, "paused"); + assert.equal(sandbox.invokeUrl("/healthz", 3000), "invoke:sb-1:/healthz:3000"); +}); test("client and sandbox helpers stay strongly typed", () => { - expectTypeOf>().toEqualTypeOf>() - expectTypeOf>().toEqualTypeOf>() - expectTypeOf>().toEqualTypeOf>() - expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toMatchTypeOf< + Promise<{ id: string }> + >(); + expectTypeOf>().toMatchTypeOf< + Promise<{ id: string }> + >(); - expectTypeOf().parameters.toEqualTypeOf<[ - params: { command: string; cwd?: string; timeout?: number }, - options?: RequestOptions, - ]>() - expectTypeOf().parameters.toEqualTypeOf<[ - path: string, - content: string, - options?: RequestOptions, - ]>() - expectTypeOf().parameters.toEqualTypeOf<[ - url: string, - path: string, - options?: RequestOptions, - ]>() - expectTypeOf().parameters.toEqualTypeOf<[ - params?: { id?: string; cols?: number; rows?: number; cwd?: string; command?: string; env?: Record }, - options?: RequestOptions, - ]>() - expectTypeOf().parameters.toEqualTypeOf<[timeout?: number]>() - expectTypeOf().parameters.toEqualTypeOf<[ - accessId: string, - password: string, - options?: RequestOptions, - ]>() - expectTypeOf().parameters.toEqualTypeOf<[ - params: { code: string; language: "python" | "typescript"; contextId?: string }, - options?: RequestOptions, - ]>() -}) + expectTypeOf().parameters.toEqualTypeOf< + [params: { command: string; cwd?: string; timeout?: number }, options?: RequestOptions] + >(); + expectTypeOf().parameters.toEqualTypeOf< + [path: string, content: string, options?: RequestOptions] + >(); + expectTypeOf().parameters.toEqualTypeOf< + [url: string, path: string, options?: RequestOptions] + >(); + expectTypeOf().parameters.toEqualTypeOf< + [ + params?: { + id?: string; + cols?: number; + rows?: number; + cwd?: string; + envs?: Record; + lazyStart?: boolean; + }, + options?: RequestOptions, + ] + >(); + expectTypeOf().parameters.toEqualTypeOf< + [timeout?: number] + >(); + expectTypeOf().parameters.toEqualTypeOf< + [accessId: string, password: string, options?: RequestOptions] + >(); + expectTypeOf().parameters.toEqualTypeOf< + [ + params: { code: string; language: "python" | "typescript"; contextId?: string }, + options?: RequestOptions, + ] + >(); +}); diff --git a/tests/client/index-exports.test.ts b/tests/client/index-exports.test.ts index a9bd774..587d0af 100644 --- a/tests/client/index-exports.test.ts +++ b/tests/client/index-exports.test.ts @@ -1,27 +1,27 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import * as sdk from "@/index.js" +import * as sdk from "@/index.js"; test("public index exports core sdk surface", () => { - assert.ok(sdk.Leap0Client) - assert.ok(sdk.Sandbox) - assert.ok(sdk.SandboxesClient) - assert.ok(sdk.SnapshotsClient) - assert.ok(sdk.TemplatesClient) - assert.ok(sdk.FilesystemClient) - assert.ok(sdk.GitClient) - assert.ok(sdk.ProcessClient) - assert.ok(sdk.PtyClient) - assert.ok(sdk.LspClient) - assert.ok(sdk.SshClient) - assert.ok(sdk.CodeInterpreterClient) - assert.ok(sdk.DesktopClient) - assert.equal(sdk.RegistryCredentialType.AWS, "aws") - assert.equal(sdk.NetworkPolicyMode.ALLOW_ALL, "allow-all") - assert.equal(sdk.NetworkPolicyMode.DENY_ALL, "deny-all") - assert.equal(sdk.SandboxState.STARTED, "started") - assert.equal(sdk.CodeLanguage.PYTHON, "python") - assert.equal(sdk.StreamEventType.STDOUT, "stdout") - assert.equal(typeof sdk.SDK_VERSION, "string") -}) + assert.ok(sdk.Leap0Client); + assert.ok(sdk.Sandbox); + assert.ok(sdk.SandboxesClient); + assert.ok(sdk.SnapshotsClient); + assert.ok(sdk.TemplatesClient); + assert.ok(sdk.FilesystemClient); + assert.ok(sdk.GitClient); + assert.ok(sdk.ProcessClient); + assert.ok(sdk.PtyClient); + assert.ok(sdk.LspClient); + assert.ok(sdk.SshClient); + assert.ok(sdk.CodeInterpreterClient); + assert.ok(sdk.DesktopClient); + assert.equal(sdk.RegistryCredentialType.AWS, "aws"); + assert.equal(sdk.NetworkPolicyMode.ALLOW_ALL, "allow-all"); + assert.equal(sdk.NetworkPolicyMode.DENY_ALL, "deny-all"); + assert.equal(sdk.SandboxState.RUNNING, "running"); + assert.equal(sdk.CodeLanguage.PYTHON, "python"); + assert.equal(sdk.StreamEventType.STDOUT, "stdout"); + assert.equal(typeof sdk.SDK_VERSION, "string"); +}); diff --git a/tests/config/config-utils.test.ts b/tests/config/config-utils.test.ts index 5d1783c..e9fe0b6 100644 --- a/tests/config/config-utils.test.ts +++ b/tests/config/config-utils.test.ts @@ -1,8 +1,18 @@ -import assert from "node:assert/strict" -import { afterEach, beforeEach, test } from "vitest" +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test } from "vitest"; -import { resolveConfig } from "@/config/index.js" -import { ensureLeadingSlash, sandboxBaseUrl, sandboxIdOf, snapshotIdOf, templateIdOf, toFileUri, trimSlash, websocketUrlFromHttp, withQuery } from "@/core/utils.js" +import { resolveConfig } from "@/config/index.js"; +import { + ensureLeadingSlash, + sandboxBaseUrl, + sandboxIdOf, + snapshotIdOf, + templateIdOf, + toFileUri, + trimSlash, + websocketUrlFromHttp, + withQuery, +} from "@/core/utils.js"; const ENV_KEYS = [ "LEAP0_API_KEY", @@ -10,76 +20,95 @@ const ENV_KEYS = [ "LEAP0_SANDBOX_DOMAIN", "LEAP0_SDK_OTEL_ENABLED", "OTEL_EXPORTER_OTLP_ENDPOINT", -] as const +] as const; -let envSnapshot: Partial> = {} +let envSnapshot: Partial> = {}; beforeEach(() => { - envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])) -}) + envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +}); afterEach(() => { for (const key of ENV_KEYS) { - const value = envSnapshot[key] + const value = envSnapshot[key]; if (value === undefined) { - delete process.env[key] + delete process.env[key]; } else { - process.env[key] = value + process.env[key] = value; } } -}) +}); test("resolveConfig trims values and reads env defaults", () => { - process.env.LEAP0_API_KEY = " env-key " - process.env.LEAP0_BASE_URL = "https://api.example.com/" - process.env.LEAP0_SANDBOX_DOMAIN = "sandbox.example.com/" - delete process.env.LEAP0_SDK_OTEL_ENABLED - delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT + process.env.LEAP0_API_KEY = " env-key "; + process.env.LEAP0_BASE_URL = "https://api.example.com/"; + process.env.LEAP0_SANDBOX_DOMAIN = "sandbox.example.com/"; + delete process.env.LEAP0_SDK_OTEL_ENABLED; + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - const config = resolveConfig() - assert.equal(config.apiKey, "env-key") - assert.equal(config.baseUrl, "https://api.example.com") - assert.equal(config.sandboxDomain, "sandbox.example.com") - assert.equal(config.authHeader, "authorization") - assert.equal(config.sdkOtelEnabled, false) -}) + const config = resolveConfig(); + assert.equal(config.apiKey, "env-key"); + assert.equal(config.baseUrl, "https://api.example.com"); + assert.equal(config.sandboxDomain, "sandbox.example.com"); + assert.equal(config.authHeader, "authorization"); + assert.equal(config.sdkOtelEnabled, false); +}); test("resolveConfig respects explicit values", () => { - const config = resolveConfig({ apiKey: "key", baseUrl: "https://api.custom.dev/", sandboxDomain: "sb.custom.dev/", timeout: 42, authHeader: "x-api-key", bearer: false, sdkOtelEnabled: true }) - assert.deepEqual(config, { apiKey: "key", baseUrl: "https://api.custom.dev", sandboxDomain: "sb.custom.dev", timeout: 42, authHeader: "x-api-key", bearer: false, sdkOtelEnabled: true }) -}) + const config = resolveConfig({ + apiKey: "key", + baseUrl: "https://api.custom.dev/", + sandboxDomain: "sb.custom.dev/", + timeout: 42, + authHeader: "x-api-key", + bearer: false, + sdkOtelEnabled: true, + }); + assert.deepEqual(config, { + apiKey: "key", + baseUrl: "https://api.custom.dev", + sandboxDomain: "sb.custom.dev", + timeout: 42, + authHeader: "x-api-key", + bearer: false, + sdkOtelEnabled: true, + }); +}); test("resolveConfig enables sdk otel from standard OTEL env", () => { - process.env.LEAP0_API_KEY = "env-key" - delete process.env.LEAP0_SDK_OTEL_ENABLED - process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318" + process.env.LEAP0_API_KEY = "env-key"; + delete process.env.LEAP0_SDK_OTEL_ENABLED; + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"; - const config = resolveConfig() - assert.equal(config.sdkOtelEnabled, true) -}) + const config = resolveConfig(); + assert.equal(config.sdkOtelEnabled, true); +}); test("resolveConfig respects explicit sdk otel disable", () => { - process.env.LEAP0_API_KEY = "env-key" - process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318" + process.env.LEAP0_API_KEY = "env-key"; + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"; - const config = resolveConfig({ sdkOtelEnabled: false }) - assert.equal(config.sdkOtelEnabled, false) -}) + const config = resolveConfig({ sdkOtelEnabled: false }); + assert.equal(config.sdkOtelEnabled, false); +}); test("resolveConfig rejects invalid config", () => { - assert.throws(() => resolveConfig({ timeout: -1, apiKey: "key" })) - assert.throws(() => resolveConfig({ apiKey: " " })) - assert.throws(() => resolveConfig({ apiKey: "key", authHeader: " " })) -}) + assert.throws(() => resolveConfig({ timeout: -1, apiKey: "key" })); + assert.throws(() => resolveConfig({ apiKey: " " })); + assert.throws(() => resolveConfig({ apiKey: "key", authHeader: " " })); +}); test("utility helpers normalize refs and urls", () => { - assert.equal(sandboxIdOf("sb-1"), "sb-1") - assert.equal(snapshotIdOf({ id: "snap-1" }), "snap-1") - assert.equal(templateIdOf({ id: "tpl-1" }), "tpl-1") - assert.equal(trimSlash("https://api.example.com///"), "https://api.example.com") - assert.equal(ensureLeadingSlash("foo"), "/foo") - assert.equal(sandboxBaseUrl("sb-1", "sandbox.example.com", 3000), "https://sb-1-3000.sandbox.example.com") - assert.equal(websocketUrlFromHttp("https://a.example.com/x"), "wss://a.example.com/x") - assert.equal(toFileUri("workspace/app.ts"), "file:///workspace/app.ts") - assert.equal(withQuery("/v1/test", { a: 1, b: true, c: undefined }), "/v1/test?a=1&b=true") -}) + assert.equal(sandboxIdOf("sb-1"), "sb-1"); + assert.equal(snapshotIdOf({ id: "snap-1" }), "snap-1"); + assert.equal(templateIdOf({ id: "tpl-1" }), "tpl-1"); + assert.equal(trimSlash("https://api.example.com///"), "https://api.example.com"); + assert.equal(ensureLeadingSlash("foo"), "/foo"); + assert.equal( + sandboxBaseUrl("sb-1", "sandbox.example.com", 3000), + "https://sb-1-3000.sandbox.example.com", + ); + assert.equal(websocketUrlFromHttp("https://a.example.com/x"), "wss://a.example.com/x"); + assert.equal(toFileUri("workspace/app.ts"), "file:///workspace/app.ts"); + assert.equal(withQuery("/v1/test", { a: 1, b: true, c: undefined }), "/v1/test?a=1&b=true"); +}); diff --git a/tests/config/otel.test.ts b/tests/config/otel.test.ts index 81b6e8e..c6932d5 100644 --- a/tests/config/otel.test.ts +++ b/tests/config/otel.test.ts @@ -1,31 +1,31 @@ -import assert from "node:assert/strict" -import { afterEach, test, vi } from "vitest" +import assert from "node:assert/strict"; +import { afterEach, test, vi } from "vitest"; -import * as otelModule from "@/core/otel.js" -import { Leap0Client } from "@/client/index.js" +import * as otelModule from "@/core/otel.js"; +import { Leap0Client } from "@/client/index.js"; afterEach(() => { - vi.restoreAllMocks() -}) + vi.restoreAllMocks(); +}); test("client initializes otel when sdk otel is enabled", async () => { - const initSpy = vi.spyOn(otelModule, "initOtel").mockImplementation(() => {}) + const initSpy = vi.spyOn(otelModule, "initOtel").mockImplementation(() => {}); - const client = new Leap0Client({ apiKey: "key", sdkOtelEnabled: true }) + const client = new Leap0Client({ apiKey: "key", sdkOtelEnabled: true }); try { - assert.equal(initSpy.mock.calls.length, 1) + assert.equal(initSpy.mock.calls.length, 1); } finally { - await client.close() + await client.close(); } -}) +}); test("client skips otel initialization when disabled", async () => { - const initSpy = vi.spyOn(otelModule, "initOtel") + const initSpy = vi.spyOn(otelModule, "initOtel"); - const client = new Leap0Client({ apiKey: "key", sdkOtelEnabled: false }) + const client = new Leap0Client({ apiKey: "key", sdkOtelEnabled: false }); try { - assert.equal(initSpy.mock.calls.length, 0) + assert.equal(initSpy.mock.calls.length, 0); } finally { - await client.close() + await client.close(); } -}) +}); diff --git a/tests/core/normalize.test.ts b/tests/core/normalize.test.ts new file mode 100644 index 0000000..987189b --- /dev/null +++ b/tests/core/normalize.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { camelizeKeys } from "@/core/normalize.js"; + +test("camelizeKeys converts nested API response keys", () => { + const result = camelizeKeys({ + template_id: "tpl-1", + memory_mib: 1024, + network_policy: { + allow_domains: ["example.com"], + transforms: [{ inject_headers: { authorization: "token" }, strip_headers: ["cookie"] }], + }, + snapshots: [{ created_at: "2026-01-01T00:00:00Z" }], + }); + + assert.deepEqual(result, { + templateId: "tpl-1", + memoryMib: 1024, + networkPolicy: { + allowDomains: ["example.com"], + transforms: [{ injectHeaders: { authorization: "token" }, stripHeaders: ["cookie"] }], + }, + snapshots: [{ createdAt: "2026-01-01T00:00:00Z" }], + }); +}); diff --git a/tests/models/network-policy.test.ts b/tests/models/network-policy.test.ts index 819650f..968fa16 100644 --- a/tests/models/network-policy.test.ts +++ b/tests/models/network-policy.test.ts @@ -1,32 +1,47 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { NetworkPolicyMode, createSandboxParamsSchema, networkPolicyModeSchema } from "@/models/sandbox.js" +import { + NetworkPolicyMode, + createSandboxParamsSchema, + networkPolicyModeSchema, +} from "@/models/sandbox.js"; test("network policy mode constants match API values", () => { - assert.equal(NetworkPolicyMode.ALLOW_ALL, "allow-all") - assert.equal(NetworkPolicyMode.DENY_ALL, "deny-all") - assert.equal(NetworkPolicyMode.CUSTOM, "custom") -}) + assert.equal(NetworkPolicyMode.ALLOW_ALL, "allow-all"); + assert.equal(NetworkPolicyMode.DENY_ALL, "deny-all"); + assert.equal(NetworkPolicyMode.CUSTOM, "custom"); +}); test("network policy schema accepts all supported modes", () => { - assert.equal(networkPolicyModeSchema.parse("allow-all"), NetworkPolicyMode.ALLOW_ALL) - assert.equal(networkPolicyModeSchema.parse("deny-all"), NetworkPolicyMode.DENY_ALL) - assert.equal(networkPolicyModeSchema.parse("custom"), NetworkPolicyMode.CUSTOM) -}) + assert.equal(networkPolicyModeSchema.parse("allow-all"), NetworkPolicyMode.ALLOW_ALL); + assert.equal(networkPolicyModeSchema.parse("deny-all"), NetworkPolicyMode.DENY_ALL); + assert.equal(networkPolicyModeSchema.parse("custom"), NetworkPolicyMode.CUSTOM); +}); test("createSandboxParamsSchema accepts network policy modes", () => { - const allowAll = createSandboxParamsSchema.parse({ networkPolicy: { mode: NetworkPolicyMode.ALLOW_ALL } }) - const denyAll = createSandboxParamsSchema.parse({ networkPolicy: { mode: NetworkPolicyMode.DENY_ALL } }) + const allowAll = createSandboxParamsSchema.parse({ + networkPolicy: { mode: NetworkPolicyMode.ALLOW_ALL }, + }); + const denyAll = createSandboxParamsSchema.parse({ + networkPolicy: { mode: NetworkPolicyMode.DENY_ALL }, + }); const custom = createSandboxParamsSchema.parse({ networkPolicy: { mode: NetworkPolicyMode.CUSTOM, allowedDomains: ["example.com"], allowedCidrs: ["10.0.0.0/8"], + transforms: [ + { + domain: "example.com", + injectHeaders: { authorization: "token" }, + stripHeaders: ["cookie"], + }, + ], }, - }) + }); - assert.equal(allowAll.networkPolicy?.mode, NetworkPolicyMode.ALLOW_ALL) - assert.equal(denyAll.networkPolicy?.mode, NetworkPolicyMode.DENY_ALL) - assert.equal(custom.networkPolicy?.mode, NetworkPolicyMode.CUSTOM) -}) + assert.equal(allowAll.networkPolicy?.mode, NetworkPolicyMode.ALLOW_ALL); + assert.equal(denyAll.networkPolicy?.mode, NetworkPolicyMode.DENY_ALL); + assert.equal(custom.networkPolicy?.mode, NetworkPolicyMode.CUSTOM); +}); diff --git a/tests/models/template-credentials.test.ts b/tests/models/template-credentials.test.ts index f9b7f82..bc4ec7e 100644 --- a/tests/models/template-credentials.test.ts +++ b/tests/models/template-credentials.test.ts @@ -1,44 +1,61 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { createTemplateParamsSchema, RegistryCredentialType } from "@/models/template.js" -import { TemplatesClient } from "@/services/templates.js" +import { createTemplateParamsSchema, RegistryCredentialType } from "@/models/template.js"; +import { TemplatesClient } from "@/services/templates.js"; test("accepts basic registry credentials", () => { const parsed = createTemplateParamsSchema.parse({ name: "private-basic", uri: "registry.example.com/org/app:latest", - credentials: { type: RegistryCredentialType.BASIC, username: "my-user", password: "my-password" }, - }) - assert.equal(parsed.credentials?.type, RegistryCredentialType.BASIC) -}) + credentials: { + type: RegistryCredentialType.BASIC, + username: "my-user", + password: "my-password", + }, + }); + assert.equal(parsed.credentials?.type, RegistryCredentialType.BASIC); +}); test("accepts AWS registry credentials", () => { const parsed = createTemplateParamsSchema.parse({ name: "private-aws", uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/app:latest", - credentials: { type: RegistryCredentialType.AWS, aws_access_key_id: "AKIA...", aws_secret_access_key: "secret", aws_region: "us-east-1" }, - }) - assert.equal(parsed.credentials?.type, RegistryCredentialType.AWS) -}) + credentials: { + type: RegistryCredentialType.AWS, + aws_access_key_id: "AKIA...", + aws_secret_access_key: "secret", + aws_region: "us-east-1", + }, + }); + assert.equal(parsed.credentials?.type, RegistryCredentialType.AWS); +}); test("accepts GCP registry credentials", () => { const parsed = createTemplateParamsSchema.parse({ name: "private-gcp", uri: "us-docker.pkg.dev/project/repo/app:latest", - credentials: { type: RegistryCredentialType.GCP, gcp_service_account_json: '{"type":"service_account"}' }, - }) - assert.equal(parsed.credentials?.type, RegistryCredentialType.GCP) -}) + credentials: { + type: RegistryCredentialType.GCP, + gcp_service_account_json: '{"type":"service_account"}', + }, + }); + assert.equal(parsed.credentials?.type, RegistryCredentialType.GCP); +}); test("accepts Azure registry credentials", () => { const parsed = createTemplateParamsSchema.parse({ name: "private-azure", uri: "registry.azurecr.io/app:latest", - credentials: { type: RegistryCredentialType.AZURE, azure_client_id: "client-id", azure_client_secret: "client-secret", azure_tenant_id: "tenant-id" }, - }) - assert.equal(parsed.credentials?.type, RegistryCredentialType.AZURE) -}) + credentials: { + type: RegistryCredentialType.AZURE, + azure_client_id: "client-id", + azure_client_secret: "client-secret", + azure_tenant_id: "tenant-id", + }, + }); + assert.equal(parsed.credentials?.type, RegistryCredentialType.AZURE); +}); test("rejects invalid basic credentials missing password", () => { assert.throws(() => @@ -47,25 +64,25 @@ test("rejects invalid basic credentials missing password", () => { uri: "registry.example.com/org/app:latest", credentials: { type: RegistryCredentialType.BASIC, username: "my-user" }, }), - ) -}) + ); +}); -test("TemplatesClient validates credentials before transport", () => { - let called = false +test("TemplatesClient validates credentials before transport", async () => { + let called = false; const client = new TemplatesClient({ requestJson: async () => { - called = true - return {} + called = true; + return {}; }, - } as never) + } as never); - assert.throws(() => + await assert.rejects(() => client.create({ name: "private-aws", uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/app:latest", credentials: { type: RegistryCredentialType.AWS, aws_access_key_id: "AKIA..." } as never, }), - ) + ); - assert.equal(called, false) -}) + assert.equal(called, false); +}); diff --git a/tests/quality/docstrings.test.ts b/tests/quality/docstrings.test.ts index 93317ff..788995c 100644 --- a/tests/quality/docstrings.test.ts +++ b/tests/quality/docstrings.test.ts @@ -1,78 +1,109 @@ -import assert from "node:assert/strict" -import path from "node:path" -import ts from "typescript" -import { test } from "vitest" +import assert from "node:assert/strict"; +import path from "node:path"; +import ts from "typescript"; +import { test } from "vitest"; -const rootDir = path.resolve(process.cwd(), "src") +const rootDir = path.resolve(process.cwd(), "src"); const strictFiles = new Set([ "client/index.ts", "client/sandbox.ts", "config/index.ts", "core/transport.ts", "core/utils.ts", -]) +]); function rel(fileName: string): string { - return path.relative(rootDir, fileName) + return path.relative(rootDir, fileName); } function jsDocText(node: ts.Node, sourceFile: ts.SourceFile): string { - const ranges = ts.getJSDocCommentsAndTags(node) - return ranges.map((range) => range.getText(sourceFile)).join("\n") + const ranges = ts.getJSDocCommentsAndTags(node); + return ranges.map((range) => range.getText(sourceFile)).join("\n"); } function isPublicMethod(node: ts.ClassElement): node is ts.MethodDeclaration { - const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined - const isPrivate = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword) - return ts.isMethodDeclaration(node) && !isPrivate && !!node.name && ts.isIdentifier(node.name) && !node.name.text.startsWith("_") + const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined; + const isPrivate = modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.PrivateKeyword || + modifier.kind === ts.SyntaxKind.ProtectedKeyword, + ); + return ( + ts.isMethodDeclaration(node) && + !isPrivate && + !!node.name && + ts.isIdentifier(node.name) && + !node.name.text.startsWith("_") + ); } function iterFailures(): string[] { - const failures: string[] = [] + const failures: string[] = []; const files = [ "client/index.ts", "client/sandbox.ts", "config/index.ts", "core/transport.ts", "core/utils.ts", - ].map((file) => path.join(rootDir, file)) + ].map((file) => path.join(rootDir, file)); for (const file of files) { - const sourceText = ts.sys.readFile(file) - if (!sourceText) continue - const sourceFile = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true) - const relative = rel(file) + const sourceText = ts.sys.readFile(file); + if (!sourceText) continue; + const sourceFile = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true); + const relative = rel(file); for (const node of sourceFile.statements) { - if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name && ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) { - const doc = jsDocText(node, sourceFile) + if ( + (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && + node.name && + ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export + ) { + const doc = jsDocText(node, sourceFile); if (!doc) { - failures.push(`${relative}:${node.name.text} missing docstring`) - continue + failures.push(`${relative}:${node.name.text} missing docstring`); + continue; } if (strictFiles.has(relative)) { - const params = ts.isFunctionDeclaration(node) ? node.parameters.length : 0 - if (params > 0 && !doc.includes("Args:")) failures.push(`${relative}:${node.name.text} missing Args`) - if (ts.isFunctionDeclaration(node) && node.type && node.type.kind !== ts.SyntaxKind.VoidKeyword && !doc.includes("Returns:")) { - failures.push(`${relative}:${node.name.text} missing Returns`) + const params = ts.isFunctionDeclaration(node) ? node.parameters.length : 0; + if (params > 0 && !doc.includes("Args:")) + failures.push(`${relative}:${node.name.text} missing Args`); + if ( + ts.isFunctionDeclaration(node) && + node.type && + node.type.kind !== ts.SyntaxKind.VoidKeyword && + !doc.includes("Returns:") + ) { + failures.push(`${relative}:${node.name.text} missing Returns`); } } if (ts.isClassDeclaration(node)) { for (const member of node.members) { - if (!isPublicMethod(member)) continue - const methodName = ts.isIdentifier(member.name) ? member.name.text : member.name.getText(sourceFile) - const memberDoc = jsDocText(member, sourceFile) + if (!isPublicMethod(member)) continue; + const methodName = ts.isIdentifier(member.name) + ? member.name.text + : member.name.getText(sourceFile); + const memberDoc = jsDocText(member, sourceFile); if (!memberDoc) { - failures.push(`${relative}:${node.name.text}.${methodName} missing docstring`) - continue + failures.push(`${relative}:${node.name.text}.${methodName} missing docstring`); + continue; } - const paramCount = member.parameters.filter((parameter) => ts.isIdentifier(parameter.name) && !["self", "cls"].includes(parameter.name.text)).length - if (paramCount > 0 && !memberDoc.includes("Args:")) failures.push(`${relative}:${node.name.text}.${methodName} missing Args`) - const returnsVoid = !member.type || member.type.kind === ts.SyntaxKind.VoidKeyword - const returnsPromiseVoid = member.type?.getText(sourceFile) === "Promise" - if (!returnsVoid && !returnsPromiseVoid && !memberDoc.includes("Returns:") && !memberDoc.includes("Yields:")) { - failures.push(`${relative}:${node.name.text}.${methodName} missing Returns/Yields`) + const paramCount = member.parameters.filter( + (parameter) => + ts.isIdentifier(parameter.name) && !["self", "cls"].includes(parameter.name.text), + ).length; + if (paramCount > 0 && !memberDoc.includes("Args:")) + failures.push(`${relative}:${node.name.text}.${methodName} missing Args`); + const returnsVoid = !member.type || member.type.kind === ts.SyntaxKind.VoidKeyword; + const returnsPromiseVoid = member.type?.getText(sourceFile) === "Promise"; + if ( + !returnsVoid && + !returnsPromiseVoid && + !memberDoc.includes("Returns:") && + !memberDoc.includes("Yields:") + ) { + failures.push(`${relative}:${node.name.text}.${methodName} missing Returns/Yields`); } } } @@ -80,10 +111,10 @@ function iterFailures(): string[] { } } - return failures + return failures; } test("public sdk APIs have docstrings", () => { - const failures = iterFailures() - assert.deepEqual(failures, []) -}) + const failures = iterFailures(); + assert.deepEqual(failures, []); +}); diff --git a/tests/services/code-interpreter.test.ts b/tests/services/code-interpreter.test.ts index 0684d88..0442b3d 100644 --- a/tests/services/code-interpreter.test.ts +++ b/tests/services/code-interpreter.test.ts @@ -1,40 +1,69 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { Leap0Error } from "@/core/errors.js" -import { CodeInterpreterClient } from "@/services/code-interpreter.js" -import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts" +import { Leap0Error } from "@/core/errors.js"; +import { CodeInterpreterClient } from "@/services/code-interpreter.js"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("code interpreter client sends expected request shapes", async () => { - const { transport, calls } = createRecordedTransport() - const client = new CodeInterpreterClient(transport as never, "sandbox.example.com") - await client.health("sb-1") - await client.createContext("sb-1", "python") - await client.listContexts("sb-1") - await client.getContext("sb-1", "ctx-1") - await client.deleteContext("sb-1", "ctx-1") - await client.execute("sb-1", { code: "print(1)", language: "python" }) - assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/healthz") - assert.equal(calls[1]?.url, "https://sb-1.sandbox.example.com/contexts") - assert.deepEqual(jsonOf(calls[1]!), { language: "python" }) - assert.equal(calls[3]?.url, "https://sb-1.sandbox.example.com/contexts/ctx-1") - assert.equal(calls[5]?.url, "https://sb-1.sandbox.example.com/execute") -}) + const { transport, calls } = createRecordedTransport({ + requestJsonUrl: async (url: string, init: RequestInit = {}, options = {}) => { + calls.push({ path: new URL(url).pathname, url, init, options }); + if (new URL(url).pathname === "/contexts" && (init.method ?? "GET") === "POST") + return { id: "ctx-1", language: 1, cwd: "/workspace" }; + if (new URL(url).pathname === "/contexts") + return { items: [{ id: "ctx-1", language: 1, cwd: "/workspace" }] }; + if (new URL(url).pathname === "/execute") + return { context_id: "ctx-1", items: [], logs: { stdout: [], stderr: [] } }; + return { id: "ctx-1", language: 1, cwd: "/workspace" }; + }, + }); + const client = new CodeInterpreterClient(transport as never, "sandbox.example.com"); + await client.health("sb-1"); + await client.createContext("sb-1", "python"); + await client.listContexts("sb-1"); + await client.getContext("sb-1", "ctx-1"); + await client.deleteContext("sb-1", "ctx-1"); + await client.execute("sb-1", { code: "print(1)", language: "python", contextId: "ctx-1" }); + assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/healthz"); + assert.equal(calls[1]?.url, "https://sb-1.sandbox.example.com/contexts"); + assert.deepEqual(jsonOf(calls[1]!), { language: "python" }); + assert.equal(calls[3]?.url, "https://sb-1.sandbox.example.com/contexts/ctx-1"); + assert.equal(calls[5]?.url, "https://sb-1.sandbox.example.com/execute"); + assert.deepEqual(jsonOf(calls[5]!), { + code: "print(1)", + language: "python", + context_id: "ctx-1", + }); +}); test("code interpreter stream parses SSE and maps event types", async () => { const { transport, calls } = createRecordedTransport({ streamJsonUrl: async function* (url: string, init: RequestInit = {}, options = {}) { - calls.push({ path: new URL(url).pathname, url, init, options }) - yield { type: 0, data: "hello" } - yield { type: 2, data: 0 } + calls.push({ path: new URL(url).pathname, url, init, options }); + yield { type: 0, data: "hello" }; + yield { type: 2, data: 0 }; }, - requestJsonUrl: () => Promise.reject(new Leap0Error("Code interpreter request failed")), - }) - const client = new CodeInterpreterClient(transport as never, "sandbox.example.com") - const events: Array<{ type: string; data: unknown }> = [] - for await (const event of client.executeStream("sb-1", { code: "print(1)", language: "python" })) events.push(event as { type: string; data: unknown }) - assert.deepEqual(events, [{ type: "stdout", data: "hello" }, { type: "exit", data: 0 }]) - assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/execute/async") - assert.deepEqual(jsonOf(calls[0]!), { code: "print(1)", language: "python" }) - await assert.rejects(() => client.health("sb-1"), Leap0Error) -}) + requestJsonUrl: (url: string) => + Promise.reject(new Leap0Error(`Code interpreter request failed: ${url}`)), + }); + const client = new CodeInterpreterClient(transport as never, "sandbox.example.com"); + const events: Array<{ type: string; data: unknown }> = []; + for await (const event of client.executeStream("sb-1", { + code: "print(1)", + language: "python", + contextId: "ctx-1", + })) + events.push(event as { type: string; data: unknown }); + assert.deepEqual(events, [ + { type: "stdout", data: "hello" }, + { type: "exit", data: 0 }, + ]); + assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/execute/async"); + assert.deepEqual(jsonOf(calls[0]!), { + code: "print(1)", + language: "python", + context_id: "ctx-1", + }); + await assert.rejects(() => client.health("sb-1"), Leap0Error); +}); diff --git a/tests/services/desktop.test.ts b/tests/services/desktop.test.ts index 852be83..6a4d221 100644 --- a/tests/services/desktop.test.ts +++ b/tests/services/desktop.test.ts @@ -1,47 +1,84 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { Leap0Error } from "@/core/errors.js" -import { DesktopClient } from "@/services/desktop.js" -import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts" +import { Leap0Error } from "@/core/errors.js"; +import { DesktopClient } from "@/services/desktop.js"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("desktop client sends expected request shapes", async () => { const { transport, calls } = createRecordedTransport({ requestJsonUrl: (url: string, init: RequestInit = {}, options = {}) => { - calls.push({ path: new URL(url).pathname, url, init, options }) - return Promise.resolve({ ok: true, processes: [{ status: "running" }] }) + calls.push({ path: new URL(url).pathname, url, init, options }); + const path = new URL(url).pathname; + if (path === "/api/display" || path === "/api/display/screen") + return Promise.resolve({ display: ":0", width: 1280, height: 720 }); + if (path === "/api/display/windows") + return Promise.resolve({ items: [{ id: "0x1", title: "Terminal" }] }); + if ( + path === "/api/input/move" || + path === "/api/input/click" || + path === "/api/input/drag" || + path === "/api/input/scroll" + ) + return Promise.resolve({ x: 10, y: 20 }); + if ( + path === "/api/recording" || + path === "/api/recording/start" || + path === "/api/recording/stop" + ) + return Promise.resolve({ active: false }); + if (path === "/api/recordings") return Promise.resolve({ items: [{ id: "rec-1" }] }); + if (path.startsWith("/api/recordings/")) return Promise.resolve({ id: "rec-1" }); + if (path === "/api/healthz") return Promise.resolve({ ok: true }); + if (path === "/api/status") + return Promise.resolve({ + status: "running", + items: [{ name: "x11vnc", running: true }], + running: 1, + total: 1, + }); + if (path.endsWith("/status")) return Promise.resolve({ name: "x11vnc", running: true }); + if (path.endsWith("/restart")) + return Promise.resolve({ message: "restarted", status: { name: "x11vnc", running: true } }); + if (path.endsWith("/logs")) return Promise.resolve({ process: "x11vnc", logs: "ok" }); + if (path.endsWith("/errors")) return Promise.resolve({ process: "x11vnc", errors: "" }); + return Promise.resolve({ ok: true }); }, - }) - const client = new DesktopClient(transport as never, "sandbox.example.com") - await client.display("sb-1") - await client.setScreen("sb-1", { width: 1280 }) - await client.movePointer("sb-1", 10, 20) - await client.typeText("sb-1", "hello") - await client.processStatus("sb-1", "vnc-server") - await client.drag("sb-1", { startX: 1, startY: 2, endX: 3, endY: 4 }) - await client.scroll("sb-1", { deltaY: -100 }) - await client.waitUntilReady("sb-1", 1) - assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/api/display") - assert.equal(calls[1]?.url, "https://sb-1.sandbox.example.com/api/display/screen") - assert.deepEqual(jsonOf(calls[1]!), { width: 1280 }) - assert.equal(calls[2]?.url, "https://sb-1.sandbox.example.com/api/input/move") - assert.equal(calls[4]?.url, "https://sb-1.sandbox.example.com/api/process/vnc-server/status") - assert.equal(calls[5]?.url, "https://sb-1.sandbox.example.com/api/input/drag") - assert.equal(calls[6]?.url, "https://sb-1.sandbox.example.com/api/input/scroll") -}) + }); + const client = new DesktopClient(transport as never, "sandbox.example.com"); + await client.display("sb-1"); + await client.setScreen("sb-1", { width: 1280, height: 720 }); + await client.movePointer("sb-1", 10, 20); + await client.typeText("sb-1", "hello"); + await client.processStatus("sb-1", "x11vnc"); + await client.drag("sb-1", { fromX: 1, fromY: 2, toX: 3, toY: 4 }); + await client.scroll("sb-1", { direction: "down", amount: 2 }); + await client.waitUntilReady("sb-1", 1); + assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/api/display"); + assert.equal(calls[1]?.url, "https://sb-1.sandbox.example.com/api/display/screen"); + assert.deepEqual(jsonOf(calls[1]!), { width: 1280, height: 720 }); + assert.equal(calls[2]?.url, "https://sb-1.sandbox.example.com/api/input/move"); + assert.equal(calls[4]?.url, "https://sb-1.sandbox.example.com/api/process/x11vnc/status"); + assert.equal(calls[5]?.url, "https://sb-1.sandbox.example.com/api/input/drag"); + assert.equal(calls[6]?.url, "https://sb-1.sandbox.example.com/api/input/scroll"); + assert.deepEqual(jsonOf(calls[5]!), { from_x: 1, from_y: 2, to_x: 3, to_y: 4 }); + assert.deepEqual(jsonOf(calls[6]!), { direction: "down", amount: 2 }); +}); test("desktop statusStream parses SSE and raises API errors", async () => { const { transport, calls } = createRecordedTransport({ streamJsonUrl: async function* (url: string) { - calls.push({ path: new URL(url).pathname, url, init: { method: "GET" }, options: {} }) - yield { status: "running" } + calls.push({ path: new URL(url).pathname, url, init: { method: "GET" }, options: {} }); + yield { status: "running", items: [{ name: "x11vnc", running: true }], running: 1, total: 1 }; }, requestJsonUrl: () => Promise.reject(new Leap0Error("Desktop request failed")), - }) - const client = new DesktopClient(transport as never, "sandbox.example.com") - const events: unknown[] = [] - for await (const event of client.statusStream("sb-1")) events.push(event) - assert.deepEqual(events, [{ status: "running" }]) - assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/api/status/stream") - await assert.rejects(() => client.health("sb-1"), Leap0Error) -}) + }); + const client = new DesktopClient(transport as never, "sandbox.example.com"); + const events: unknown[] = []; + for await (const event of client.statusStream("sb-1")) events.push(event); + assert.deepEqual(events, [ + { status: "running", items: [{ name: "x11vnc", running: true }], running: 1, total: 1 }, + ]); + assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/api/status/stream"); + await assert.rejects(() => client.health("sb-1"), Leap0Error); +}); diff --git a/tests/services/filesystem.test.ts b/tests/services/filesystem.test.ts index 1dd7ab3..c4dfb18 100644 --- a/tests/services/filesystem.test.ts +++ b/tests/services/filesystem.test.ts @@ -1,36 +1,65 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { FilesystemClient } from "@/services/filesystem.js" -import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts" +import { FilesystemClient } from "@/services/filesystem.js"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("filesystem client sends expected request shapes", async () => { - const { transport, calls } = createRecordedTransport() - const client = new FilesystemClient(transport as never) - await client.ls("sb-1", "/workspace") - await client.stat("sb-1", "/tmp/file") - await client.mkdir("sb-1", "/tmp/new") - await client.writeFile("sb-1", "/tmp/a.txt", "hello") - await client.writeBytes("sb-1", "/tmp/b.bin", new Uint8Array([1, 2])) - await client.readFile("sb-1", "/tmp/a.txt", { head: 10 }) - await client.readBytes("sb-1", "/tmp/b.bin") - await client.delete("sb-1", "/tmp/a.txt") - await client.setPermissions("sb-1", "/tmp/a.txt", "0755") - await client.glob("sb-1", "/workspace", "**/*.ts") - await client.grep("sb-1", "/workspace", "todo") - await client.editFile("sb-1", "/tmp/a.txt", { oldText: "a", newText: "b" }) - await client.editFiles("sb-1", [{ path: "/tmp/a.txt", edit: { oldText: "a", newText: "b" } }]) - await client.move("sb-1", "/tmp/a", "/tmp/b") - await client.copy("sb-1", "/tmp/b", "/tmp/c") - await client.exists("sb-1", "/tmp/c") - await client.tree("sb-1", "/workspace", 2) + const { transport, calls } = createRecordedTransport({ + requestJson: async (path: string, init: RequestInit, options: never) => { + calls.push({ path, init, options }); + if (path.endsWith("/ls")) return { items: [] }; + if (path.endsWith("/stat")) + return { + name: "file", + path: "/tmp/file", + is_dir: false, + size: 1, + mode: "644", + mtime: 1, + owner: "root", + group: "root", + is_symlink: false, + }; + if (path.endsWith("/grep")) + return { items: [{ path: "/tmp/a.txt", line: 1, content: "todo" }] }; + if (path.endsWith("/edit-file")) return { diff: "", replacements: 1 }; + if (path.endsWith("/edit-files")) return { items: [] }; + if (path.endsWith("/tree")) return { items: [] }; + if (path.endsWith("/exists")) return { exists: true }; + return { items: [] }; + }, + }); + const client = new FilesystemClient(transport as never); + await client.ls("sb-1", "/workspace"); + await client.stat("sb-1", "/tmp/file"); + await client.mkdir("sb-1", "/tmp/new"); + await client.writeFile("sb-1", "/tmp/a.txt", "hello"); + await client.writeBytes("sb-1", "/tmp/b.bin", new Uint8Array([1, 2])); + await client.readFile("sb-1", "/tmp/a.txt", { head: 10 }); + await client.readBytes("sb-1", "/tmp/b.bin"); + await client.delete("sb-1", "/tmp/a.txt"); + await client.setPermissions("sb-1", "/tmp/a.txt", "0755"); + await client.glob("sb-1", "/workspace", "**/*.ts"); + await client.grep("sb-1", "/workspace", "todo"); + await client.editFile("sb-1", "/tmp/a.txt", { find: "a", replace: "b" }); + await client.editFiles("sb-1", [{ path: "/tmp/a.txt", edit: { find: "a", replace: "b" } }]); + await client.move("sb-1", "/tmp/a", "/tmp/b"); + await client.copy("sb-1", "/tmp/b", "/tmp/c"); + await client.exists("sb-1", "/tmp/c"); + await client.tree("sb-1", "/workspace", 2); - assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/filesystem/ls") - assert.deepEqual(jsonOf(calls[0]!), { path: "/workspace" }) - assert.equal(new Headers(calls[3]!.init.headers).get("content-type"), "text/plain") - assert.equal(calls[3]?.options.query?.path, "/tmp/a.txt") - assert.equal(new Headers(calls[4]!.init.headers).get("content-type"), "application/octet-stream") - assert.equal(calls[5]?.options.query?.head, 10) - assert.deepEqual(jsonOf(calls[11]!), { path: "/tmp/a.txt", edit: { oldText: "a", newText: "b" } }) - assert.deepEqual(jsonOf(calls[16]!), { path: "/workspace", max_depth: 2 }) -}) + assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/filesystem/ls"); + assert.deepEqual(jsonOf(calls[0]!), { path: "/workspace" }); + assert.equal(new Headers(calls[3]!.init.headers).get("content-type"), "text/plain"); + assert.equal(calls[3]?.options.query?.path, "/tmp/a.txt"); + assert.equal(new Headers(calls[4]!.init.headers).get("content-type"), "application/octet-stream"); + assert.equal(calls[5]?.options.query?.head, 10); + assert.deepEqual(jsonOf(calls[11]!), { + path: "/tmp/a.txt", + edits: [{ find: "a", replace: "b" }], + }); + assert.deepEqual(jsonOf(calls[13]!), { src_path: "/tmp/a", dst_path: "/tmp/b" }); + assert.deepEqual(jsonOf(calls[14]!), { src_path: "/tmp/b", dst_path: "/tmp/c" }); + assert.deepEqual(jsonOf(calls[16]!), { path: "/workspace", max_depth: 2 }); +}); diff --git a/tests/services/git.test.ts b/tests/services/git.test.ts index 4c6ec63..00fce74 100644 --- a/tests/services/git.test.ts +++ b/tests/services/git.test.ts @@ -1,32 +1,42 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { GitClient } from "@/services/git.js" -import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts" +import { GitClient } from "@/services/git.js"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("git client sends expected request shapes", async () => { - const { transport, calls } = createRecordedTransport() - const client = new GitClient(transport as never) - await client.clone("sb-1", "https://example.com/repo.git", "/workspace/repo") - await client.status("sb-1", "/workspace/repo") - await client.branches("sb-1", "/workspace/repo") - await client.diffUnstaged("sb-1", "/workspace/repo") - await client.diffStaged("sb-1", "/workspace/repo") - await client.diff("sb-1", "/workspace/repo") - await client.reset("sb-1", "/workspace/repo") - await client.log("sb-1", "/workspace/repo") - await client.show("sb-1", "/workspace/repo", "HEAD") - await client.createBranch("sb-1", "/workspace/repo", "feat") - await client.checkoutBranch("sb-1", "/workspace/repo", "feat") - await client.deleteBranch("sb-1", "/workspace/repo", "feat") - await client.add("sb-1", "/workspace/repo", ["a.ts"]) - await client.commit("sb-1", "/workspace/repo", "msg") - await client.push("sb-1", "/workspace/repo") - await client.pull("sb-1", "/workspace/repo") + const { transport, calls } = createRecordedTransport({ + requestJson: async (path: string, init: RequestInit, options: never) => { + calls.push({ path, init, options }); + if (path.endsWith("/commit")) + return { sha: "abc123", result: { output: "ok", exit_code: 0 } }; + return { output: "ok", exit_code: 0 }; + }, + }); + const client = new GitClient(transport as never); + await client.clone("sb-1", "https://example.com/repo.git", "/workspace/repo"); + await client.status("sb-1", "/workspace/repo"); + await client.branches("sb-1", "/workspace/repo"); + await client.diffUnstaged("sb-1", "/workspace/repo"); + await client.diffStaged("sb-1", "/workspace/repo"); + await client.diff("sb-1", "/workspace/repo"); + await client.reset("sb-1", "/workspace/repo"); + await client.log("sb-1", "/workspace/repo"); + await client.show("sb-1", "/workspace/repo", "HEAD"); + await client.createBranch("sb-1", "/workspace/repo", "feat"); + await client.checkoutBranch("sb-1", "/workspace/repo", "feat"); + await client.deleteBranch("sb-1", "/workspace/repo", "feat"); + await client.add("sb-1", "/workspace/repo", ["a.ts"]); + await client.commit("sb-1", "/workspace/repo", "msg"); + await client.push("sb-1", "/workspace/repo"); + await client.pull("sb-1", "/workspace/repo"); - assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/git/clone") - assert.deepEqual(jsonOf(calls[0]!), { url: "https://example.com/repo.git", path: "/workspace/repo" }) - assert.deepEqual(jsonOf(calls[8]!), { path: "/workspace/repo", ref: "HEAD" }) - assert.deepEqual(jsonOf(calls[12]!), { path: "/workspace/repo", files: ["a.ts"] }) - assert.deepEqual(jsonOf(calls[13]!), { path: "/workspace/repo", message: "msg" }) -}) + assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/git/clone"); + assert.deepEqual(jsonOf(calls[0]!), { + url: "https://example.com/repo.git", + path: "/workspace/repo", + }); + assert.deepEqual(jsonOf(calls[8]!), { path: "/workspace/repo", revision: "HEAD" }); + assert.deepEqual(jsonOf(calls[12]!), { path: "/workspace/repo", files: ["a.ts"] }); + assert.deepEqual(jsonOf(calls[13]!), { path: "/workspace/repo", message: "msg" }); +}); diff --git a/tests/services/lsp.test.ts b/tests/services/lsp.test.ts index 3f2c647..142c279 100644 --- a/tests/services/lsp.test.ts +++ b/tests/services/lsp.test.ts @@ -1,20 +1,29 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { LspClient } from "@/services/lsp.js" -import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts" +import { LspClient } from "@/services/lsp.js"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("lsp client sends expected request shapes", async () => { - const { transport, calls } = createRecordedTransport() - const client = new LspClient(transport as never) - await client.start("sb-1", "typescript", "/workspace") - await client.stop("sb-1", "typescript", "/workspace") - await client.didOpenPath("sb-1", "typescript", "/workspace", "/workspace/a.ts") - await client.didClosePath("sb-1", "typescript", "/workspace", "/workspace/a.ts") - await client.completionsPath("sb-1", "typescript", "/workspace", "/workspace/a.ts", 1, 2) - await client.documentSymbolsPath("sb-1", "typescript", "/workspace", "/workspace/a.ts") - assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/lsp/start") - assert.deepEqual(jsonOf(calls[0]!), { language_id: "typescript", path_to_project: "/workspace" }) - assert.deepEqual(jsonOf(calls[2]!) as { uri: string }, { language_id: "typescript", path_to_project: "/workspace", uri: "file:///workspace/a.ts" }) - assert.deepEqual(jsonOf(calls[4]!), { language_id: "typescript", path_to_project: "/workspace", uri: "file:///workspace/a.ts", line: 1, character: 2 }) -}) + const { transport, calls } = createRecordedTransport(); + const client = new LspClient(transport as never); + await client.start("sb-1", "typescript", "/workspace"); + await client.stop("sb-1", "typescript", "/workspace"); + await client.didOpenPath("sb-1", "typescript", "/workspace", "/workspace/a.ts"); + await client.didClosePath("sb-1", "typescript", "/workspace", "/workspace/a.ts"); + await client.completionsPath("sb-1", "typescript", "/workspace", "/workspace/a.ts", 1, 2); + await client.documentSymbolsPath("sb-1", "typescript", "/workspace", "/workspace/a.ts"); + assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/lsp/start"); + assert.deepEqual(jsonOf(calls[0]!), { language_id: "typescript", path_to_project: "/workspace" }); + assert.deepEqual(jsonOf(calls[2]!) as { uri: string }, { + language_id: "typescript", + path_to_project: "/workspace", + uri: "file:///workspace/a.ts", + }); + assert.deepEqual(jsonOf(calls[4]!), { + language_id: "typescript", + path_to_project: "/workspace", + uri: "file:///workspace/a.ts", + position: { line: 1, character: 2 }, + }); +}); diff --git a/tests/services/normalization-cleanup.test.ts b/tests/services/normalization-cleanup.test.ts new file mode 100644 index 0000000..5a505a5 --- /dev/null +++ b/tests/services/normalization-cleanup.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { CodeInterpreterClient } from "@/services/code-interpreter.js"; +import { FilesystemClient } from "@/services/filesystem.js"; +import { SshClient } from "@/services/ssh.js"; +import { TemplatesClient } from "@/services/templates.js"; +import { createRecordedTransport } from "@tests/utils/helpers.ts"; + +test("template client normalizes created_at", async () => { + const { transport } = createRecordedTransport({ + requestJson: async () => ({ + id: "tpl-1", + name: "custom", + digest: "sha256:abc", + image_config: { entrypoint: ["python"], cmd: ["app.py"] }, + is_system: false, + created_at: "2026-01-01T00:00:00Z", + }), + }); + + const template = await new TemplatesClient(transport as never).create({ + name: "custom", + uri: "docker.io/acme/app:latest", + }); + + assert.equal(template.createdAt, "2026-01-01T00:00:00Z"); +}); + +test("ssh client normalizes expires_at", async () => { + const { transport } = createRecordedTransport({ + requestJson: async () => ({ + id: "ssh-1", + sandbox_id: "sb-1", + password: "secret", + expires_at: "2026-01-01T00:00:00Z", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ssh_command: "ssh ssh-1@sandbox", + }), + }); + + const access = await new SshClient(transport as never).createAccess("sb-1"); + + assert.equal(access.expiresAt, "2026-01-01T00:00:00Z"); +}); + +test("filesystem client normalizes nested snake_case fields", async () => { + const { transport } = createRecordedTransport({ + requestJson: async () => ({ + items: [ + { + name: "tmp", + path: "/tmp", + is_dir: true, + size: 0, + mode: "755", + mtime: 1, + owner: "root", + group: "root", + is_symlink: false, + }, + ], + }), + }); + + const result = await new FilesystemClient(transport as never).ls("sb-1"); + + assert.equal(result.items[0]?.isDir, true); + assert.equal(result.items[0]?.mode, "755"); +}); + +test("code interpreter client normalizes context fields", async () => { + const { transport } = createRecordedTransport({ + requestJsonUrl: async () => ({ id: "ctx-1", language: 1, cwd: "/workspace" }), + }); + + const context = await new CodeInterpreterClient( + transport as never, + "sandbox.example.com", + ).createContext("sb-1", "python"); + + assert.equal(context.cwd, "/workspace"); +}); diff --git a/tests/services/process.test.ts b/tests/services/process.test.ts index a98f937..b3bf7eb 100644 --- a/tests/services/process.test.ts +++ b/tests/services/process.test.ts @@ -1,13 +1,18 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { ProcessClient } from "@/services/process.js" -import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts" +import { ProcessClient } from "@/services/process.js"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("process client sends execute request shape", async () => { - const { transport, calls } = createRecordedTransport() - const client = new ProcessClient(transport as never) - await client.execute("sb-1", { command: "npm test", cwd: "/workspace", timeout: 30 }) - assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/process/execute") - assert.deepEqual(jsonOf(calls[0]!), { command: "npm test", cwd: "/workspace", timeout: 30 }) -}) + const { transport, calls } = createRecordedTransport({ + requestJson: async (path: string, init: RequestInit, options: never) => { + calls.push({ path, init, options }); + return { exit_code: 0, result: "ok" }; + }, + }); + const client = new ProcessClient(transport as never); + await client.execute("sb-1", { command: "npm test", cwd: "/workspace", timeout: 30 }); + assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/process/execute"); + assert.deepEqual(jsonOf(calls[0]!), { command: "npm test", cwd: "/workspace", timeout: 30 }); +}); diff --git a/tests/services/pty.test.ts b/tests/services/pty.test.ts index efd4543..cf0949c 100644 --- a/tests/services/pty.test.ts +++ b/tests/services/pty.test.ts @@ -1,38 +1,63 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { PtyClient, PtyConnection } from "@/services/pty.js" -import { createRecordedTransport } from "@tests/utils/helpers.ts" +import { PtyClient, PtyConnection } from "@/services/pty.js"; +import { createRecordedTransport } from "@tests/utils/helpers.ts"; test("pty client sends expected request shapes", async () => { - const { transport, calls } = createRecordedTransport() - const client = new PtyClient(transport as never, "sandbox.example.com") - await client.list("sb-1") - await client.create("sb-1", { cols: 80, rows: 24, cwd: "/workspace" }) - await client.get("sb-1", "sess-1") - await client.resize("sb-1", "sess-1", 120, 40) - await client.delete("sb-1", "sess-1") - assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/pty") - assert.equal(calls[1]?.path, "/v1/sandbox/sb-1/pty") - assert.equal(calls[3]?.path, "/v1/sandbox/sb-1/pty/sess-1/resize") - assert.equal(client.websocketUrl("sb-1", "sess-1"), "wss://sb-1.sandbox.example.com/v1/sandbox/sb-1/pty/sess-1/connect") -}) + const { transport, calls } = createRecordedTransport({ + requestJson: async (path: string, init: RequestInit, options: never) => { + calls.push({ path, init, options }); + const session = { + id: "sess-1", + cwd: "/workspace", + envs: {}, + cols: 80, + rows: 24, + created_at: "2026-01-01T00:00:00Z", + active: true, + lazy_start: false, + }; + if (path.endsWith("/pty") && (init.method ?? "GET") === "GET") return { items: [session] }; + return session; + }, + }); + const client = new PtyClient(transport as never, "sandbox.example.com"); + await client.list("sb-1"); + await client.create("sb-1", { cols: 80, rows: 24, cwd: "/workspace" }); + await client.get("sb-1", "sess-1"); + await client.resize("sb-1", "sess-1", 120, 40); + await client.delete("sb-1", "sess-1"); + assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/pty"); + assert.equal(calls[1]?.path, "/v1/sandbox/sb-1/pty"); + assert.equal(calls[3]?.path, "/v1/sandbox/sb-1/pty/sess-1/resize"); + assert.equal( + client.websocketUrl("sb-1", "sess-1"), + "wss://sb-1.sandbox.example.com/v1/sandbox/sb-1/pty/sess-1/connect", + ); +}); test("pty connection sends, receives, and closes websocket data", async () => { - let sent: unknown - let closed = false - const handlers = new Map void>() + let sent: unknown; + let closed = false; + const handlers = new Map void>(); const socket = { - send(data: unknown) { sent = data }, - close() { closed = true }, - addEventListener(type: string, handler: (event: { data?: unknown }) => void) { handlers.set(type, handler) }, - } - const connection = new PtyConnection(socket as never) - connection.send("hello") - const recv = connection.recv() - handlers.get("message")?.({ data: "world" }) - assert.equal(sent, "hello") - assert.deepEqual(Array.from(await recv), Array.from(new TextEncoder().encode("world"))) - connection.close() - assert.equal(closed, true) -}) + send(data: unknown) { + sent = data; + }, + close() { + closed = true; + }, + addEventListener(type: string, handler: (event: { data?: unknown }) => void) { + handlers.set(type, handler); + }, + }; + const connection = new PtyConnection(socket as never); + connection.send("hello"); + const recv = connection.recv(); + handlers.get("message")?.({ data: "world" }); + assert.equal(sent, "hello"); + assert.deepEqual(Array.from(await recv), Array.from(new TextEncoder().encode("world"))); + connection.close(); + assert.equal(closed, true); +}); diff --git a/tests/services/sandboxes-client.test.ts b/tests/services/sandboxes-client.test.ts index 2a160f0..c2f6eee 100644 --- a/tests/services/sandboxes-client.test.ts +++ b/tests/services/sandboxes-client.test.ts @@ -1,81 +1,135 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { Leap0Error } from "@/core/errors.js" -import { SandboxesClient } from "@/services/sandboxes.js" -import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts" +import { Leap0Error } from "@/core/errors.js"; +import { SandboxesClient } from "@/services/sandboxes.js"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; function makeClient() { - const { transport, calls } = createRecordedTransport({ requestJson: (path: string, init: RequestInit, options: unknown) => { calls.push({ path, init, options: options as never }); return Promise.resolve({ id: "sb-1", state: "started" }) } }) - const client = new SandboxesClient(transport as never, "sandbox.example.com", (data) => ({ wrapped: true, ...data })) - return { client, calls } + const { transport, calls } = createRecordedTransport({ + requestJson: (path: string, init: RequestInit, options: unknown) => { + calls.push({ path, init, options: options as never }); + return Promise.resolve({ + id: "sb-1", + template_id: "tpl-1", + state: "running", + vcpu: 2, + memory_mib: 1024, + disk_mib: 4096, + auto_pause: false, + created_at: "2026-01-01T00:00:00Z", + }); + }, + }); + const client = new SandboxesClient(transport as never, "sandbox.example.com"); + return { client, calls }; } test("sandboxes create validates payload and wraps result", async () => { - const { client, calls } = makeClient() - const result = await client.create({ templateName: " custom ", vcpu: 2, memoryMib: 1024, timeoutMin: 10 }) - assert.equal(result.id, "sb-1") - assert.equal((result as unknown as { wrapped: boolean }).wrapped, true) - assert.equal(calls[0]?.path, "/v1/sandbox") - assert.equal((jsonOf(calls[0]!) as { template_name: string }).template_name, "custom") -}) - -test("sandboxes create rejects invalid parameters", () => { - const { client } = makeClient() - assert.throws(() => client.create({ vcpu: 0 }), Leap0Error) - assert.throws(() => client.create({ memoryMib: 513 }), Leap0Error) - assert.throws(() => client.create({ timeoutMin: 999 }), Leap0Error) -}) + const { client, calls } = makeClient(); + const result = await client.create({ + templateName: " custom ", + vcpu: 2, + memoryMib: 1024, + timeoutMin: 10, + }); + assert.equal(result.id, "sb-1"); + assert.equal(result.templateId, "tpl-1"); + assert.equal(result.memoryMib, 1024); + assert.equal(result.diskMib, 4096); + assert.equal(result.createdAt, "2026-01-01T00:00:00Z"); + assert.equal(calls[0]?.path, "/v1/sandbox"); + assert.equal((jsonOf(calls[0]!) as { template_name: string }).template_name, "custom"); +}); + +test("sandboxes create rejects invalid parameters", async () => { + const { client } = makeClient(); + await assert.rejects(() => client.create({ vcpu: 0 }), Leap0Error); + await assert.rejects(() => client.create({ memoryMib: 513 }), Leap0Error); + await assert.rejects(() => client.create({ timeoutMin: 999 }), Leap0Error); +}); test("sandboxes get pause and delete target sandbox ids", async () => { - const { client, calls } = makeClient() - await client.get({ id: "sb-1" }) - await client.pause("sb-2") - await client.delete("sb-3") - assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/") - assert.equal(calls[1]?.path, "/v1/sandbox/sb-2/pause") - assert.equal(calls[2]?.path, "/v1/sandbox/sb-3/") -}) + const { client, calls } = makeClient(); + await client.get({ id: "sb-1" }); + await client.pause("sb-2"); + await client.delete("sb-3"); + assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/"); + assert.equal(calls[1]?.path, "/v1/sandbox/sb-2/pause"); + assert.equal(calls[2]?.path, "/v1/sandbox/sb-3/"); +}); test("sandboxes build invoke and websocket urls", () => { - const { client } = makeClient() - assert.equal(client.invokeUrl("sb-1", "healthz"), "https://sb-1.sandbox.example.com/healthz") - assert.equal(client.websocketUrl("sb-1", "/ws"), "wss://sb-1.sandbox.example.com/ws") -}) + const { client } = makeClient(); + assert.equal(client.invokeUrl("sb-1", "healthz"), "https://sb-1.sandbox.example.com/healthz"); + assert.equal(client.websocketUrl("sb-1", "/ws"), "wss://sb-1.sandbox.example.com/ws"); +}); test("sandboxes create injects otel env when enabled", async () => { - process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://collector:4318" - process.env.OTEL_EXPORTER_OTLP_HEADERS = "authorization=token" - const { client, calls } = makeClient() + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://collector:4318"; + process.env.OTEL_EXPORTER_OTLP_HEADERS = "authorization=token"; + const { client, calls } = makeClient(); await client.create({ otelExport: true, envVars: { APP_ENV: "test" }, - }) + }); assert.deepEqual((jsonOf(calls[0]!) as { env_vars: Record }).env_vars, { OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector:4318", OTEL_EXPORTER_OTLP_HEADERS: "authorization=token", APP_ENV: "test", - }) -}) + }); +}); test("sandboxes create accepts telemetry alias for otel export", async () => { - process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://collector:4318" - delete process.env.OTEL_EXPORTER_OTLP_HEADERS - const { client, calls } = makeClient() + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://collector:4318"; + delete process.env.OTEL_EXPORTER_OTLP_HEADERS; + const { client, calls } = makeClient(); - await client.create({ telemetry: true }) + await client.create({ telemetry: true }); assert.deepEqual((jsonOf(calls[0]!) as { env_vars: Record }).env_vars, { OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector:4318", - }) -}) + }); +}); + +test("sandboxes create rejects otel export without endpoint", async () => { + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + delete process.env.OTEL_EXPORTER_OTLP_HEADERS; + const { client } = makeClient(); + + await assert.rejects(() => client.create({ otelExport: true }), /OTEL_EXPORTER_OTLP_ENDPOINT/); +}); -test("sandboxes create rejects otel export without endpoint", () => { - delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT - delete process.env.OTEL_EXPORTER_OTLP_HEADERS - const { client } = makeClient() +test("sandboxes create serializes network policy using api field names", async () => { + const { client, calls } = makeClient(); + + await client.create({ + networkPolicy: { + mode: "custom", + allowedDomains: ["example.com"], + allowedCidrs: ["10.0.0.0/8"], + transforms: [ + { + domain: "example.com", + injectHeaders: { authorization: "token" }, + stripHeaders: ["cookie"], + }, + ], + }, + }); - assert.throws(() => client.create({ otelExport: true }), /OTEL_EXPORTER_OTLP_ENDPOINT/) -}) + assert.deepEqual((jsonOf(calls[0]!) as { network_policy: unknown }).network_policy, { + mode: "custom", + allow_domains: ["example.com"], + allow_cidrs: ["10.0.0.0/8"], + transforms: [ + { + domain: "example.com", + inject_headers: { authorization: "token" }, + strip_headers: ["cookie"], + }, + ], + }); +}); diff --git a/tests/services/snapshots.test.ts b/tests/services/snapshots.test.ts index c216571..b74f2c5 100644 --- a/tests/services/snapshots.test.ts +++ b/tests/services/snapshots.test.ts @@ -1,21 +1,55 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { SnapshotsClient } from "@/services/snapshots.js" -import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts" +import { SnapshotsClient } from "@/services/snapshots.js"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("snapshots client sends expected request shapes", async () => { - const { transport, calls } = createRecordedTransport({ requestJson: async (path: string, init: RequestInit, options: never) => { calls.push({ path, init, options }); return { id: "snap-1" } } }) - const client = new SnapshotsClient(transport as never, (data) => ({ wrapped: true, ...data })) - await client.create("sb-1", { name: "snap-a" }) - await client.pause("sb-1", { name: "snap-b" }) - const restored = await client.resume({ snapshotName: "snap-c", autoPause: true, timeoutMin: 12 }) - await client.delete({ id: "snap-1" }) - assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/snapshot/create") - assert.deepEqual(jsonOf(calls[0]!), { name: "snap-a" }) - assert.equal(calls[1]?.path, "/v1/sandbox/sb-1/snapshot/pause") - assert.equal(calls[2]?.path, "/v1/snapshot/resume") - assert.deepEqual(jsonOf(calls[2]!), { snapshot_name: "snap-c", auto_pause: true, timeout_min: 12 }) - assert.equal((restored as { wrapped?: boolean }).wrapped, true) - assert.equal(calls[3]?.path, "/v1/snapshot/snap-1") -}) + const { transport, calls } = createRecordedTransport({ + requestJson: async (path: string, init: RequestInit, options: never) => { + calls.push({ path, init, options }); + if (path === "/v1/snapshot/resume") { + return { + id: "sb-2", + state: "running", + template_id: "tpl-1", + vcpu: 2, + memory_mib: 1024, + disk_mib: 4096, + auto_pause: true, + created_at: "2026-01-01T00:00:00Z", + }; + } + return { + id: "snap-1", + name: path.endsWith("/pause") ? "snap-b" : "snap-a", + template_id: "tpl-1", + vcpu: 2, + memory_mib: 1024, + disk_mib: 4096, + created_at: "2026-01-01T00:00:00Z", + }; + }, + }); + const client = new SnapshotsClient(transport as never); + const created = await client.create("sb-1", { name: "snap-a" }); + const paused = await client.pause("sb-1", { name: "snap-b" }); + const restored = await client.resume({ snapshotName: "snap-c", autoPause: true, timeoutMin: 12 }); + await client.delete({ id: "snap-1" }); + assert.equal(created.name, "snap-a"); + assert.equal(created.templateId, "tpl-1"); + assert.equal(created.state, undefined); + assert.equal(paused.name, "snap-b"); + assert.equal(paused.memoryMib, 1024); + assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/snapshot/create"); + assert.deepEqual(jsonOf(calls[0]!), { name: "snap-a" }); + assert.equal(calls[1]?.path, "/v1/sandbox/sb-1/snapshot/pause"); + assert.equal(calls[2]?.path, "/v1/snapshot/resume"); + assert.deepEqual(jsonOf(calls[2]!), { + snapshot_name: "snap-c", + auto_pause: true, + timeout_min: 12, + }); + assert.equal(restored.templateId, "tpl-1"); + assert.equal(calls[3]?.path, "/v1/snapshot/snap-1"); +}); diff --git a/tests/services/ssh.test.ts b/tests/services/ssh.test.ts index c425a96..1410947 100644 --- a/tests/services/ssh.test.ts +++ b/tests/services/ssh.test.ts @@ -1,18 +1,32 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { SshClient } from "@/services/ssh.js" -import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts" +import { SshClient } from "@/services/ssh.js"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("ssh client sends expected request shapes", async () => { - const { transport, calls } = createRecordedTransport() - const client = new SshClient(transport as never) - await client.createAccess("sb-1") - await client.validateAccess("sb-1", "ssh-1", "secret") - await client.regenerateAccess("sb-1") - await client.deleteAccess("sb-1") - assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/ssh/access") - assert.deepEqual(jsonOf(calls[1]!), { access_id: "ssh-1", password: "secret" }) - assert.equal(calls[2]?.path, "/v1/sandbox/sb-1/ssh/regen") - assert.equal(calls[3]?.path, "/v1/sandbox/sb-1/ssh/access") -}) + const { transport, calls } = createRecordedTransport({ + requestJson: async (path: string, init: RequestInit, options: never) => { + calls.push({ path, init, options }); + if (path.endsWith("/validate")) return { valid: true, sandbox_id: "sb-1" }; + return { + id: "ssh-1", + sandbox_id: "sb-1", + password: "secret", + expires_at: "2026-01-01T00:00:00Z", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ssh_command: "ssh ssh-1@sandbox", + }; + }, + }); + const client = new SshClient(transport as never); + await client.createAccess("sb-1"); + await client.validateAccess("sb-1", "ssh-1", "secret"); + await client.regenerateAccess("sb-1"); + await client.deleteAccess("sb-1"); + assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/ssh/access"); + assert.deepEqual(jsonOf(calls[1]!), { id: "ssh-1", password: "secret" }); + assert.equal(calls[2]?.path, "/v1/sandbox/sb-1/ssh/regen"); + assert.equal(calls[3]?.path, "/v1/sandbox/sb-1/ssh/access"); +}); diff --git a/tests/services/transport.test.ts b/tests/services/transport.test.ts index 74b1118..c3f3fe8 100644 --- a/tests/services/transport.test.ts +++ b/tests/services/transport.test.ts @@ -1,115 +1,172 @@ -import assert from "node:assert/strict" -import { test } from "vitest" +import assert from "node:assert/strict"; +import { test } from "vitest"; -import { Leap0ConflictError, Leap0Error, Leap0NotFoundError, Leap0PermissionError, Leap0RateLimitError, Leap0TimeoutError } from "@/core/errors.js" -import { Leap0Transport } from "@/core/transport.js" -import { installFetch } from "@tests/utils/helpers.ts" -import * as otel from "@opentelemetry/api" +import { + Leap0ConflictError, + Leap0Error, + Leap0NotFoundError, + Leap0PermissionError, + Leap0RateLimitError, + Leap0TimeoutError, +} from "@/core/errors.js"; +import { Leap0Transport } from "@/core/transport.js"; +import { installFetch } from "@tests/utils/helpers.ts"; +import * as otel from "@opentelemetry/api"; function makeTransport() { - return new Leap0Transport({ apiKey: "test-key", baseUrl: "https://api.example.com", sandboxDomain: "sandbox.example.com", timeout: 1, authHeader: "authorization", bearer: true, sdkOtelEnabled: false }) + return new Leap0Transport({ + apiKey: "test-key", + baseUrl: "https://api.example.com", + sandboxDomain: "sandbox.example.com", + timeout: 1, + authHeader: "authorization", + bearer: true, + sdkOtelEnabled: false, + }); } test("transport headers include auth and sdk metadata", () => { - const headers = makeTransport().headers({ "x-extra": "1" }) - assert.equal(headers.get("authorization"), "Bearer test-key") - assert.equal(headers.get("Leap0-Source"), "sdk-ts") - assert.match(headers.get("User-Agent") ?? "", /^leap0-js\//) -}) + const headers = makeTransport().headers({ "x-extra": "1" }); + assert.equal(headers.get("authorization"), "Bearer test-key"); + assert.equal(headers.get("Leap0-Source"), "sdk-ts"); + assert.match(headers.get("User-Agent") ?? "", /^leap0-js\//); +}); test("transport requestJson sends and parses JSON", async () => { const restore = installFetch(async (url: string | URL | Request, init?: RequestInit) => { - assert.equal(String(url), "https://api.example.com/v1/ok?q=x") - assert.equal(new Headers(init?.headers).get("authorization"), "Bearer test-key") - return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } }) - }) + assert.equal(String(url), "https://api.example.com/v1/ok?q=x"); + assert.equal(new Headers(init?.headers).get("authorization"), "Bearer test-key"); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); try { - const result = await makeTransport().requestJson("/v1/ok", { method: "POST", body: JSON.stringify({}) }, { query: { q: "x" } }) - assert.deepEqual(result, { ok: true }) + const result = await makeTransport().requestJson( + "/v1/ok", + { method: "POST", body: JSON.stringify({}) }, + { query: { q: "x" } }, + ); + assert.deepEqual(result, { ok: true }); } finally { - restore() + restore(); } -}) +}); test("transport maps HTTP status codes to SDK errors", async () => { - for (const [status, ErrorType] of [[403, Leap0PermissionError], [404, Leap0NotFoundError], [409, Leap0ConflictError], [429, Leap0RateLimitError], [500, Leap0Error]] as const) { - const restore = installFetch(async () => new Response(JSON.stringify({ message: `status-${status}` }), { status })) + for (const [status, ErrorType] of [ + [403, Leap0PermissionError], + [404, Leap0NotFoundError], + [409, Leap0ConflictError], + [429, Leap0RateLimitError], + [500, Leap0Error], + ] as const) { + const restore = installFetch( + async () => new Response(JSON.stringify({ message: `status-${status}` }), { status }), + ); try { - await assert.rejects(() => makeTransport().request("/v1/fail"), ErrorType) + await assert.rejects(() => makeTransport().request("/v1/fail"), ErrorType); } finally { - restore() + restore(); } } -}) +}); test("transport request fails after close", async () => { - const transport = makeTransport() - await transport.close() - await assert.rejects(() => transport.request("/v1/test"), Leap0Error) -}) + const transport = makeTransport(); + await transport.close(); + await assert.rejects(() => transport.request("/v1/test"), Leap0Error); +}); test("transport converts aborts to timeout errors", async () => { const restore = installFetch(async (_url: string | URL | Request, init?: RequestInit) => { - init?.signal?.throwIfAborted() - await new Promise((resolve) => setTimeout(resolve, 10)) - init?.signal?.throwIfAborted() - return new Response("ok") - }) + init?.signal?.throwIfAborted(); + await new Promise((resolve) => setTimeout(resolve, 10)); + init?.signal?.throwIfAborted(); + return new Response("ok"); + }); try { - await assert.rejects(() => makeTransport().request("/v1/slow", {}, { timeout: 0.001 }), Leap0TimeoutError) + await assert.rejects( + () => makeTransport().request("/v1/slow", {}, { timeout: 0.001 }), + Leap0TimeoutError, + ); } finally { - restore() + restore(); } -}) +}); test("transport streamJson parses server-sent events", async () => { - const encoder = new TextEncoder() + const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode("data: {\"a\":1}\n\n")) - controller.enqueue(encoder.encode(": comment\n\ndata: not-json\n\ndata: [DONE]\n\ndata: {\"b\":2}\n\n")) - controller.close() + controller.enqueue(encoder.encode('data: {"a":1}\n\n')); + controller.enqueue( + encoder.encode(': comment\n\ndata: not-json\n\ndata: [DONE]\n\ndata: {"b":2}\n\n'), + ); + controller.close(); }, - }) - const restore = installFetch(async () => new Response(stream, { status: 200 })) + }); + const restore = installFetch(async () => new Response(stream, { status: 200 })); try { - const events = [] - for await (const event of makeTransport().streamJson("/v1/stream")) events.push(event) - assert.deepEqual(events, [{ a: 1 }, { b: 2 }]) + const events = []; + for await (const event of makeTransport().streamJson("/v1/stream")) events.push(event); + assert.deepEqual(events, [{ a: 1 }, { b: 2 }]); } finally { - restore() + restore(); } -}) +}); test("transport emits otel spans when enabled", async () => { - const spans: Array<{ name: string; attributes: Record; ended: boolean }> = [] - const previousTracer = otel.trace.getTracer - otel.trace.getTracer = ((() => ({ - startActiveSpan(name: string, fn: (span: { setAttributes(attrs: Record): void; setStatus(): void; recordException(): void; end(): void }) => Promise) { + const spans: Array<{ name: string; attributes: Record; ended: boolean }> = []; + const previousTracer = otel.trace.getTracer; + otel.trace.getTracer = (() => ({ + startActiveSpan( + name: string, + fn: (span: { + setAttributes(attrs: Record): void; + setStatus(): void; + recordException(): void; + end(): void; + }) => Promise, + ) { const span = { name, attributes: {}, ended: false, - } - spans.push(span) + }; + spans.push(span); return fn({ - setAttributes(attrs) { span.attributes = attrs }, + setAttributes(attrs) { + span.attributes = attrs; + }, setStatus() {}, recordException() {}, - end() { span.ended = true }, - }) + end() { + span.ended = true; + }, + }); }, - })) as unknown) as typeof otel.trace.getTracer + })) as unknown as typeof otel.trace.getTracer; - const transport = new Leap0Transport({ apiKey: "test-key", baseUrl: "https://api.example.com", sandboxDomain: "sandbox.example.com", timeout: 1, authHeader: "authorization", bearer: true, sdkOtelEnabled: true }) - const restore = installFetch(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) + const transport = new Leap0Transport({ + apiKey: "test-key", + baseUrl: "https://api.example.com", + sandboxDomain: "sandbox.example.com", + timeout: 1, + authHeader: "authorization", + bearer: true, + sdkOtelEnabled: true, + }); + const restore = installFetch( + async () => new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); try { - await transport.requestJson("/v1/ok") - assert.equal(spans[0]?.name, "leap0.transport.request") - assert.equal(spans[0]?.attributes["url.path"], "/v1/ok") - assert.equal(spans[0]?.ended, true) + await transport.requestJson("/v1/ok"); + assert.equal(spans[0]?.name, "leap0.transport.request"); + assert.equal(spans[0]?.attributes["url.path"], "/v1/ok"); + assert.equal(spans[0]?.ended, true); } finally { - restore() - otel.trace.getTracer = previousTracer + restore(); + otel.trace.getTracer = previousTracer; } -}) +}); diff --git a/tests/utils/helpers.ts b/tests/utils/helpers.ts index 7e87009..0b94fa2 100644 --- a/tests/utils/helpers.ts +++ b/tests/utils/helpers.ts @@ -1,53 +1,57 @@ -import type { RequestOptions } from "@/models/index.js" +import type { RequestOptions } from "@/models/index.js"; export type RecordedCall = { - path: string - url?: string - init: RequestInit - options: RequestOptions -} + path: string; + url?: string; + init: RequestInit; + options: RequestOptions; +}; export function createRecordedTransport(overrides: Record = {}) { - const calls: RecordedCall[] = [] + const calls: RecordedCall[] = []; const transport = { requestJson: async (path: string, init: RequestInit = {}, options: RequestOptions = {}) => { - calls.push({ path, init, options }) - return Promise.resolve({ ok: true }) + calls.push({ path, init, options }); + return Promise.resolve({ ok: true }); }, requestJsonUrl: (url: string, init: RequestInit = {}, options: RequestOptions = {}) => { - calls.push({ path: new URL(url).pathname, url, init, options }) - return Promise.resolve({ ok: true }) + calls.push({ path: new URL(url).pathname, url, init, options }); + return Promise.resolve({ ok: true }); }, requestText: (path: string, init: RequestInit = {}, options: RequestOptions = {}) => { - calls.push({ path, init, options }) - return Promise.resolve("text") + calls.push({ path, init, options }); + return Promise.resolve("text"); }, requestBytes: (path: string, init: RequestInit = {}, options: RequestOptions = {}) => { - calls.push({ path, init, options }) - return Promise.resolve(new Uint8Array([1, 2, 3])) + calls.push({ path, init, options }); + return Promise.resolve(new Uint8Array([1, 2, 3])); + }, + requestBytesUrl: (url: string, init: RequestInit = {}, options: RequestOptions = {}) => { + calls.push({ path: new URL(url).pathname, url, init, options }); + return Promise.resolve(new Uint8Array([1, 2, 3])); }, streamJsonUrl: async function* (url: string) { - calls.push({ path: new URL(url).pathname, url, init: { method: "GET" }, options: {} }) + calls.push({ path: new URL(url).pathname, url, init: { method: "GET" }, options: {} }); }, request: (path: string, init: RequestInit = {}, options: RequestOptions = {}) => { - calls.push({ path, init, options }) - return Promise.resolve(new Response(null, { status: 204 })) + calls.push({ path, init, options }); + return Promise.resolve(new Response(null, { status: 204 })); }, headers: (headers?: HeadersInit) => new Headers(headers), ...overrides, - } + }; - return { transport, calls } + return { transport, calls }; } export function jsonOf(call: RecordedCall): unknown { - return call.init.body == null ? undefined : JSON.parse(String(call.init.body)) + return call.init.body == null ? undefined : JSON.parse(String(call.init.body)); } export function installFetch(mock: typeof fetch): () => void { - const originalFetch = globalThis.fetch - globalThis.fetch = mock + const originalFetch = globalThis.fetch; + globalThis.fetch = mock; return () => { - globalThis.fetch = originalFetch - } + globalThis.fetch = originalFetch; + }; } diff --git a/vitest.config.ts b/vitest.config.ts index 25f0de5..6f81812 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,5 @@ -import path from "node:path" -import { defineConfig } from "vitest/config" +import path from "node:path"; +import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { @@ -11,4 +11,4 @@ export default defineConfig({ test: { environment: "node", }, -}) +}); From 11b121ad9d80df102bd0d4cab6bbc66600de4c30 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Fri, 3 Apr 2026 11:56:25 -0400 Subject: [PATCH 2/9] fixes --- src/client/index.ts | 8 +-- src/client/sandbox.ts | 14 ++++- src/config/constants.ts | 2 + src/config/index.ts | 5 +- src/core/otel.ts | 31 ++++++---- src/core/transport.ts | 33 +++++++--- src/models/code-interpreter.ts | 4 +- src/models/git.ts | 10 ++-- src/models/sandbox.ts | 2 +- src/models/snapshot.ts | 2 +- src/services/code-interpreter.ts | 42 ++++++++----- src/services/desktop.ts | 19 +++--- src/services/filesystem.ts | 44 +++++++++----- src/services/git.ts | 35 ++++++++--- src/services/lsp.ts | 12 ++-- src/services/pty.ts | 63 ++++++++++---------- src/services/sandboxes.ts | 18 +++--- src/services/shared.ts | 9 +-- tests/client/client-sandbox.test.ts | 9 +-- tests/services/code-interpreter.test.ts | 4 +- tests/services/desktop.test.ts | 4 +- tests/services/filesystem.test.ts | 3 +- tests/services/git.test.ts | 11 +++- tests/services/lsp.test.ts | 6 +- tests/services/normalization-cleanup.test.ts | 1 - tests/services/pty.test.ts | 8 ++- tests/services/sandboxes-client.test.ts | 2 +- tests/services/transport.test.ts | 5 +- tests/utils/helpers.ts | 3 + 29 files changed, 256 insertions(+), 153 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 9427c9c..1357df7 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -44,17 +44,17 @@ export class Leap0Client { initOtel(resolved); } - this.sandboxes = new SandboxesClient(this.transport, resolved.sandboxDomain); + this.sandboxes = new SandboxesClient(this.transport); this.snapshots = new SnapshotsClient(this.transport); this.templates = new TemplatesClient(this.transport); this.filesystem = new FilesystemClient(this.transport); this.git = new GitClient(this.transport); this.process = new ProcessClient(this.transport); - this.pty = new PtyClient(this.transport, resolved.sandboxDomain); + this.pty = new PtyClient(this.transport); this.lsp = new LspClient(this.transport); this.ssh = new SshClient(this.transport); - this.codeInterpreter = new CodeInterpreterClient(this.transport, resolved.sandboxDomain); - this.desktop = new DesktopClient(this.transport, resolved.sandboxDomain); + this.codeInterpreter = new CodeInterpreterClient(this.transport); + this.desktop = new DesktopClient(this.transport); } /** diff --git a/src/client/sandbox.ts b/src/client/sandbox.ts index c7aadf3..b7a682b 100644 --- a/src/client/sandbox.ts +++ b/src/client/sandbox.ts @@ -52,7 +52,7 @@ export class Sandbox implements SandboxData { vcpu!: number; memoryMib!: number; diskMib!: number; - autoPause!: boolean; + autoPause?: boolean; networkPolicy?: SandboxData["networkPolicy"]; createdAt!: string; [key: string]: unknown; @@ -91,8 +91,16 @@ export class Sandbox implements SandboxData { * Returns: * The updated sandbox handle. */ - update(data: SandboxData): this { - Object.assign(this, data); + private update(data: SandboxData): this { + this.id = data.id; + this.templateId = data.templateId; + this.state = data.state; + this.vcpu = data.vcpu; + this.memoryMib = data.memoryMib; + this.diskMib = data.diskMib; + this.autoPause = data.autoPause; + this.networkPolicy = data.networkPolicy; + this.createdAt = data.createdAt; return this; } diff --git a/src/config/constants.ts b/src/config/constants.ts index af81da4..09bcab3 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -8,3 +8,5 @@ export const DEFAULT_MEMORY_MIB = 1024; export const DEFAULT_TIMEOUT_MIN = 5; export const DEFAULT_CLIENT_TIMEOUT = 300; export const SDK_SOURCE = "sdk-ts"; +export const OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"; +export const OTEL_EXPORTER_OTLP_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS"; diff --git a/src/config/index.ts b/src/config/index.ts index d131287..85f9654 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -9,7 +9,10 @@ import type { Leap0ConfigInput, Leap0ConfigResolved } from "@/models/config.js"; import { trimSlash } from "@/core/utils.js"; function readEnv(name: string): string | undefined { - return process.env[name]; + if (typeof process !== "undefined" && process.env) { + return process.env[name]; + } + return undefined; } function requireNonEmpty(value: string | undefined, label: string): string { diff --git a/src/core/otel.ts b/src/core/otel.ts index d5515bc..df56f12 100644 --- a/src/core/otel.ts +++ b/src/core/otel.ts @@ -11,8 +11,8 @@ import type { Leap0ConfigResolved } from "@/models/config.js"; import { SDK_VERSION } from "@/core/version.js"; const TRACER_NAME = "leap0-js-sdk"; -let tracerProviderInitialized = false; -let meterProviderInitialized = false; +let tracerProviderInstance: NodeTracerProvider | undefined; +let meterProviderInstance: MeterProvider | undefined; /** * Returns the shared OpenTelemetry tracer for the SDK. @@ -36,23 +36,34 @@ export function initOtel(_config: Leap0ConfigResolved): void { [ATTR_SERVICE_VERSION]: SDK_VERSION, }); - if (!tracerProviderInitialized) { - const tracerProvider = new NodeTracerProvider({ + if (!tracerProviderInstance) { + tracerProviderInstance = new NodeTracerProvider({ resource, spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())], }); - trace.setGlobalTracerProvider(tracerProvider); - tracerProviderInitialized = true; + trace.setGlobalTracerProvider(tracerProviderInstance); } - if (!meterProviderInitialized) { + if (!meterProviderInstance) { const metricReader = new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }); - const meterProvider = new MeterProvider({ resource, readers: [metricReader] }); - metrics.setGlobalMeterProvider(meterProvider); - meterProviderInitialized = true; + meterProviderInstance = new MeterProvider({ resource, readers: [metricReader] }); + metrics.setGlobalMeterProvider(meterProviderInstance); } } +/** + * Shuts down OpenTelemetry providers and resets state so they can be re-initialized. + * + * Returns: + * A promise that resolves once providers are flushed and shut down. + */ +export async function shutdownOtel(): Promise { + await tracerProviderInstance?.shutdown(); + await meterProviderInstance?.shutdown(); + tracerProviderInstance = undefined; + meterProviderInstance = undefined; +} + /** * Executes an operation inside an OpenTelemetry span when SDK telemetry is enabled. * diff --git a/src/core/transport.ts b/src/core/transport.ts index dac1a9d..75429ff 100644 --- a/src/core/transport.ts +++ b/src/core/transport.ts @@ -20,8 +20,17 @@ type BodyLike = BodyInit | null; */ export class Leap0Transport { private closed = false; + private readonly inflight = new Set(); - constructor(private readonly config: Leap0ConfigResolved) {} + readonly baseUrl: string; + readonly sandboxDomain: string; + readonly apiKey: string; + + constructor(private readonly config: Leap0ConfigResolved) { + this.baseUrl = config.baseUrl; + this.sandboxDomain = config.sandboxDomain; + this.apiKey = config.apiKey; + } /** * Builds request headers with auth and SDK metadata. @@ -32,7 +41,7 @@ export class Leap0Transport { * Returns: * The final header set. */ - headers(extra?: HeadersInit): Headers { + private headers(extra?: HeadersInit): Headers { const headers = new Headers(extra); headers.set( this.config.authHeader, @@ -52,6 +61,10 @@ export class Leap0Transport { */ async close(): Promise { this.closed = true; + for (const controller of this.inflight) { + controller.abort(); + } + this.inflight.clear(); } /** @@ -114,6 +127,7 @@ export class Leap0Transport { } const controller = new AbortController(); + this.inflight.add(controller); const timeoutMs = (options.timeout ?? this.config.timeout) * 1000; const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -150,6 +164,7 @@ export class Leap0Transport { }); } finally { clearTimeout(timer); + this.inflight.delete(controller); } } @@ -162,20 +177,20 @@ export class Leap0Transport { * options: Leap0 request options such as timeout and query params. * * Returns: - * The parsed JSON response. + * The parsed JSON response, or undefined for 204 No Content. */ async requestJson( path: string, init: RequestInit = {}, options: RequestOptions = {}, - ): Promise { + ): Promise { const headers = new Headers(init.headers); if (init.body != null && !headers.has("content-type") && !(init.body instanceof FormData)) { headers.set("content-type", "application/json"); } const response = await this.request(path, { ...init, headers }, options); if (response.status === 204) { - return undefined as T; + return undefined; } return (await response.json()) as T; } @@ -189,20 +204,20 @@ export class Leap0Transport { * options: Leap0 request options such as timeout and query params. * * Returns: - * The parsed JSON response. + * The parsed JSON response, or undefined for 204 No Content. */ async requestJsonUrl( url: string, init: RequestInit = {}, options: RequestOptions = {}, - ): Promise { + ): Promise { const headers = new Headers(init.headers); if (init.body != null && !headers.has("content-type") && !(init.body instanceof FormData)) { headers.set("content-type", "application/json"); } const response = await this.requestUrl(url, { ...init, headers }, options); if (response.status === 204) { - return undefined as T; + return undefined; } return (await response.json()) as T; } @@ -316,7 +331,7 @@ export class Leap0Transport { while (true) { const { done, value } = await reader.read(); if (done) break; - buffer += decoder.decode(value, { stream: true }); + buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n"); while (true) { const boundary = buffer.indexOf("\n\n"); diff --git a/src/models/code-interpreter.ts b/src/models/code-interpreter.ts index 4485c6b..042638f 100644 --- a/src/models/code-interpreter.ts +++ b/src/models/code-interpreter.ts @@ -78,14 +78,14 @@ export type CodeExecutionResult = z.infer; export const streamEventWireSchema = z.object({ type: z.number(), - data: z.string().optional(), + data: z.union([z.string(), z.number()]).optional(), code: z.number().optional(), }); export type StreamEventWire = z.infer; export const streamEventSchema = z.object({ type: streamEventTypeSchema, - data: z.string().optional(), + data: z.union([z.string(), z.number()]).optional(), code: z.number().optional(), }); export type StreamEvent = z.infer; diff --git a/src/models/git.ts b/src/models/git.ts index 3f33b64..5ed8bb9 100644 --- a/src/models/git.ts +++ b/src/models/git.ts @@ -8,8 +8,10 @@ export const gitResultSchema = z .catchall(z.unknown()); export type GitResult = z.infer; -export const gitCommitResultSchema = z.object({ - sha: z.string(), - result: gitResultSchema, -}); +export const gitCommitResultSchema = z + .object({ + sha: z.string().optional(), + result: gitResultSchema, + }) + .catchall(z.unknown()); export type GitCommitResult = z.infer; diff --git a/src/models/sandbox.ts b/src/models/sandbox.ts index 91ca9ab..b325fa3 100644 --- a/src/models/sandbox.ts +++ b/src/models/sandbox.ts @@ -58,7 +58,7 @@ export const sandboxDataSchema = z vcpu: z.number(), memoryMib: z.number(), diskMib: z.number(), - autoPause: z.boolean(), + autoPause: z.boolean().optional(), networkPolicy: networkPolicySchema.optional(), createdAt: z.string(), }) diff --git a/src/models/snapshot.ts b/src/models/snapshot.ts index 425d95d..8f3744a 100644 --- a/src/models/snapshot.ts +++ b/src/models/snapshot.ts @@ -24,7 +24,7 @@ export type CreateSnapshotParams = z.infer; export const resumeSnapshotParamsSchema = z.object({ snapshotName: z.string(), autoPause: z.boolean().optional(), - timeoutMin: z.number().int().optional(), + timeoutMin: z.number().int().positive().optional(), networkPolicy: networkPolicySchema.optional(), }); export type ResumeSnapshotParams = z.infer; diff --git a/src/services/code-interpreter.ts b/src/services/code-interpreter.ts index 4a4090f..1188dcd 100644 --- a/src/services/code-interpreter.ts +++ b/src/services/code-interpreter.ts @@ -12,17 +12,18 @@ import type { import { Leap0Transport, jsonBody } from "@/core/transport.js"; import { sandboxBaseUrl, sandboxIdOf } from "@/core/utils.js"; import { StreamEventType } from "@/models/index.js"; -import { codeContextSchema, codeExecutionResultSchema } from "@/models/code-interpreter.js"; +import { + codeContextSchema, + codeExecutionResultSchema, + streamEventWireSchema, +} from "@/models/code-interpreter.js"; /** Talks to the sandbox-hosted code interpreter service. */ export class CodeInterpreterClient { - constructor( - private readonly transport: Leap0Transport, - private readonly sandboxDomain: string, - ) {} + constructor(private readonly transport: Leap0Transport) {} private requestPath(sandbox: SandboxRef, path: string): string { - return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain)}${path}`; + return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.transport.sandboxDomain)}${path}`; } private async fetchJson( @@ -31,7 +32,7 @@ export class CodeInterpreterClient { init: RequestInit = {}, options: RequestOptions = {}, ): Promise { - return await this.transport.requestJsonUrl(this.requestPath(sandbox, path), init, options); + return (await this.transport.requestJsonUrl(this.requestPath(sandbox, path), init, options))!; } async health(sandbox: SandboxRef, options: RequestOptions = {}): Promise<{ ok: boolean }> { @@ -110,6 +111,13 @@ export class CodeInterpreterClient { ); } + private static readonly WIRE_TYPE_MAP: Record = { + 0: StreamEventType.STDOUT, + 1: StreamEventType.STDERR, + 2: StreamEventType.EXIT, + 3: StreamEventType.ERROR, + }; + async *executeStream( sandbox: SandboxRef, params: { code: string; language: CodeLanguage; contextId?: string }, @@ -130,15 +138,19 @@ export class CodeInterpreterClient { if (!event || typeof event !== "object") { throw new Leap0Error("Malformed code execution stream event"); } - const parsed = { ...event } as Record; - if (parsed.envelope === "error") { - throw new Leap0Error(typeof parsed.message === "string" ? parsed.message : "Stream error"); + const record = event as Record; + if (record.envelope === "error") { + throw new Leap0Error(typeof record.message === "string" ? record.message : "Stream error"); + } + const wire = streamEventWireSchema.parse(event); + const mappedType = CodeInterpreterClient.WIRE_TYPE_MAP[wire.type]; + if (!mappedType) { + throw new Leap0Error(`Unknown stream event type: ${wire.type}`); } - if (parsed.type === 0) parsed.type = StreamEventType.STDOUT; - if (parsed.type === 1) parsed.type = StreamEventType.STDERR; - if (parsed.type === 2) parsed.type = StreamEventType.EXIT; - if (parsed.type === 3) parsed.type = StreamEventType.ERROR; - yield parsed as unknown as StreamEvent; + const streamEvent: StreamEvent = { type: mappedType }; + if (wire.data !== undefined) streamEvent.data = wire.data; + if (wire.code !== undefined) streamEvent.code = wire.code; + yield streamEvent; } } } diff --git a/src/services/desktop.ts b/src/services/desktop.ts index 70de252..430251e 100644 --- a/src/services/desktop.ts +++ b/src/services/desktop.ts @@ -42,17 +42,14 @@ import { asRecord } from "@/services/shared.js"; /** Drives the desktop sandbox APIs for browser and GUI automation. */ export class DesktopClient { - constructor( - private readonly transport: Leap0Transport, - private readonly sandboxDomain: string, - ) {} + constructor(private readonly transport: Leap0Transport) {} private requestUrl(sandbox: SandboxRef, path: string): string { - return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain)}${path}`; + return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.transport.sandboxDomain)}${path}`; } browserUrl(sandbox: SandboxRef): string { - return sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain); + return sandboxBaseUrl(sandboxIdOf(sandbox), this.transport.sandboxDomain); } private async requestJson( @@ -398,13 +395,15 @@ export class DesktopClient { const startedAt = Date.now(); let delayMs = 250; while (true) { - const status = await this.status(sandbox, { timeout }); + const elapsedSec = (Date.now() - startedAt) / 1000; + if (elapsedSec >= timeout) { + throw new Leap0Error("Desktop did not become ready within timeout"); + } + const remaining = Math.max(1, Math.ceil(timeout - elapsedSec)); + const status = await this.status(sandbox, { timeout: remaining }); if ((status.items ?? []).every((process) => process.running === true)) { return; } - if (Date.now() - startedAt > timeout * 1000) { - throw new Leap0Error("Desktop did not become ready within timeout"); - } await new Promise((resolve) => setTimeout(resolve, delayMs)); delayMs = Math.min(delayMs * 2, 2000); } diff --git a/src/services/filesystem.ts b/src/services/filesystem.ts index ae745d3..79d2692 100644 --- a/src/services/filesystem.ts +++ b/src/services/filesystem.ts @@ -50,10 +50,15 @@ export class FilesystemClient { return normalize(fileInfoSchema, data); } - async mkdir(sandbox: SandboxRef, path: string, options: RequestOptions = {}): Promise { + async mkdir( + sandbox: SandboxRef, + path: string, + recursive = false, + options: RequestOptions = {}, + ): Promise { await this.transport.request( `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/mkdir`, - { method: "POST", body: jsonBody({ path }) }, + { method: "POST", body: jsonBody({ path, recursive }) }, options, ); } @@ -100,8 +105,8 @@ export class FilesystemClient { ): Promise { return await this.transport.requestText( `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/read-file`, - { method: "GET" }, - { ...options, query: { path, ...params } }, + { method: "POST", body: jsonBody({ path, ...params }) }, + options, ); } @@ -112,15 +117,20 @@ export class FilesystemClient { ): Promise { return await this.transport.requestBytes( `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/read-file`, - { method: "GET" }, - { ...options, query: { path } }, + { method: "POST", body: jsonBody({ path }) }, + options, ); } - async delete(sandbox: SandboxRef, path: string, options: RequestOptions = {}): Promise { + async delete( + sandbox: SandboxRef, + path: string, + recursive = false, + options: RequestOptions = {}, + ): Promise { await this.transport.request( `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/delete`, - { method: "POST", body: jsonBody({ path }) }, + { method: "POST", body: jsonBody({ path, recursive }) }, options, ); } @@ -144,22 +154,24 @@ export class FilesystemClient { pattern: string, options: RequestOptions = {}, ): Promise { - return await this.transport.requestJson( + const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/glob`, { method: "POST", body: jsonBody({ path, pattern }) }, options, ); + return z.object({ items: z.array(z.string()) }).parse(data).items; } async grep( sandbox: SandboxRef, path: string, pattern: string, + include?: string, options: RequestOptions = {}, ): Promise { const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/grep`, - { method: "POST", body: jsonBody({ path, pattern }) }, + { method: "POST", body: jsonBody({ path, pattern, include }) }, options, ); return normalize(z.object({ items: z.array(searchMatchSchema) }), data).items; @@ -184,15 +196,16 @@ export class FilesystemClient { files: Array<{ path: string; edit: FileEdit }>, options: RequestOptions = {}, ): Promise { - const firstEdit = files[0]?.edit; const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/edit-files`, { method: "POST", body: jsonBody({ - files: files.map((file) => file.path), - find: firstEdit?.find, - replace: firstEdit?.replace, + files: files.map((file) => ({ + path: file.path, + find: file.edit.find, + replace: file.edit.replace, + })), }), }, options, @@ -231,11 +244,12 @@ export class FilesystemClient { path: string, options: RequestOptions = {}, ): Promise<{ exists: boolean }> { - return await this.transport.requestJson( + const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/exists`, { method: "POST", body: jsonBody({ path }) }, options, ); + return z.object({ exists: z.boolean() }).parse(data); } async tree( diff --git a/src/services/git.ts b/src/services/git.ts index 20e8c0f..0912621 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -14,11 +14,11 @@ export class GitClient { body: unknown, options: RequestOptions = {}, ): Promise { - return await this.transport.requestJson( + return (await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/git/${endpoint}`, { method: "POST", body: jsonBody(body) }, options, - ); + ))!; } async clone( @@ -32,8 +32,16 @@ export class GitClient { async status(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return normalize(gitResultSchema, await this.json(sandbox, "status", { path }, options)); } - async branches(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { - return normalize(gitResultSchema, await this.json(sandbox, "branches", { path }, options)); + async branches( + sandbox: SandboxRef, + path: string, + branchType: "local" | "remote" | "all" = "local", + options?: RequestOptions, + ): Promise { + return normalize( + gitResultSchema, + await this.json(sandbox, "branches", { path, branch_type: branchType }, options), + ); } async diffUnstaged( sandbox: SandboxRef, @@ -55,8 +63,16 @@ export class GitClient { async reset(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return normalize(gitResultSchema, await this.json(sandbox, "reset", { path }, options)); } - async log(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { - return normalize(gitResultSchema, await this.json(sandbox, "log", { path }, options)); + async log( + sandbox: SandboxRef, + path: string, + maxCount?: number, + options?: RequestOptions, + ): Promise { + return normalize( + gitResultSchema, + await this.json(sandbox, "log", { path, max_count: maxCount }, options), + ); } async show( sandbox: SandboxRef, @@ -95,11 +111,12 @@ export class GitClient { sandbox: SandboxRef, path: string, name: string, + force = false, options?: RequestOptions, ): Promise { return normalize( gitResultSchema, - await this.json(sandbox, "delete-branch", { path, name }, options), + await this.json(sandbox, "delete-branch", { path, name, force }, options), ); } async add( @@ -114,11 +131,13 @@ export class GitClient { sandbox: SandboxRef, path: string, message: string, + author: string, + email: string, options?: RequestOptions, ): Promise { return normalize( gitCommitResultSchema, - await this.json(sandbox, "commit", { path, message }, options), + await this.json(sandbox, "commit", { path, message, author, email }, options), ); } async push(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { diff --git a/src/services/lsp.ts b/src/services/lsp.ts index a9ce26a..661a295 100644 --- a/src/services/lsp.ts +++ b/src/services/lsp.ts @@ -17,11 +17,11 @@ export class LspClient { body: unknown, options: RequestOptions = {}, ): Promise { - return await this.transport.requestJson( + return (await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/lsp/${endpoint}`, { method: "POST", body: jsonBody(body) }, options, - ); + ))!; } async start( @@ -55,12 +55,14 @@ export class LspClient { languageId: string, pathToProject: string, uri: string, + text: string, + version = 1, options?: RequestOptions, ): Promise { return await this.json( sandbox, "did-open", - { language_id: languageId, path_to_project: pathToProject, uri }, + { language_id: languageId, path_to_project: pathToProject, uri, text, version }, options, ); } @@ -69,9 +71,11 @@ export class LspClient { languageId: string, pathToProject: string, path: string, + text: string, + version = 1, options?: RequestOptions, ): Promise { - return await this.didOpen(sandbox, languageId, pathToProject, toFileUri(path), options); + return await this.didOpen(sandbox, languageId, pathToProject, toFileUri(path), text, version, options); } async didClose( sandbox: SandboxRef, diff --git a/src/services/pty.ts b/src/services/pty.ts index a1ce466..127ab2d 100644 --- a/src/services/pty.ts +++ b/src/services/pty.ts @@ -9,7 +9,7 @@ import type { } from "@/models/index.js"; import { Leap0Transport, jsonBody } from "@/core/transport.js"; import { ptySessionSchema } from "@/models/pty.js"; -import { sandboxBaseUrl, sandboxIdOf, websocketUrlFromHttp } from "@/core/utils.js"; +import { sandboxIdOf, websocketUrlFromHttp } from "@/core/utils.js"; /** Thin wrapper around an interactive PTY websocket connection. */ export class PtyConnection { @@ -21,30 +21,32 @@ export class PtyConnection { recv(): Promise { return new Promise((resolve, reject) => { - this.socket.addEventListener( - "message", - (event) => { - const value = event.data; - if (typeof value === "string") { - resolve(new TextEncoder().encode(value)); - return; - } - if (value instanceof Blob) { - value - .arrayBuffer() - .then((buffer) => resolve(new Uint8Array(buffer))) - .catch(reject); - return; - } - resolve(new Uint8Array(value)); - }, - { once: true }, - ); - this.socket.addEventListener( - "error", - () => reject(new Leap0WebSocketError("PTY websocket error")), - { once: true }, - ); + const cleanup = () => { + this.socket.removeEventListener("message", onMessage); + this.socket.removeEventListener("error", onError); + }; + const onMessage = (event: MessageEvent) => { + cleanup(); + const value = event.data; + if (typeof value === "string") { + resolve(new TextEncoder().encode(value)); + return; + } + if (value instanceof Blob) { + value + .arrayBuffer() + .then((buffer) => resolve(new Uint8Array(buffer))) + .catch(reject); + return; + } + resolve(new Uint8Array(value)); + }; + const onError = () => { + cleanup(); + reject(new Leap0WebSocketError("PTY websocket error")); + }; + this.socket.addEventListener("message", onMessage, { once: true }); + this.socket.addEventListener("error", onError, { once: true }); }); } @@ -55,10 +57,7 @@ export class PtyConnection { /** Creates and manages PTY sessions for interactive terminals. */ export class PtyClient { - constructor( - private readonly transport: Leap0Transport, - private readonly sandboxDomain: string, - ) {} + constructor(private readonly transport: Leap0Transport) {} async list(sandbox: SandboxRef, options: RequestOptions = {}): Promise { const data = await this.transport.requestJson( @@ -133,10 +132,14 @@ export class PtyClient { websocketUrl(sandbox: SandboxRef, sessionId: string): string { return websocketUrlFromHttp( - `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain)}/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}/connect`, + `${this.transport.baseUrl}/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}/connect`, ); } + websocketHeaders(): Record { + return { authorization: this.transport.apiKey }; + } + connect(sandbox: SandboxRef, sessionId: string): PtyConnection { return new PtyConnection(new WebSocket(this.websocketUrl(sandbox, sessionId))); } diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 464f14b..04141db 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -3,6 +3,8 @@ import { DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU, + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, } from "@/config/constants.js"; import { Leap0Error } from "@/core/errors.js"; import { normalize } from "@/core/normalize.js"; @@ -22,9 +24,6 @@ import { } from "@/core/utils.js"; import { withErrorPrefix } from "@/services/shared.js"; -const OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"; -const OTEL_EXPORTER_OTLP_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS"; - function injectOtelEnv( envVars: Record | undefined, enabled: boolean, @@ -33,7 +32,9 @@ function injectOtelEnv( return envVars; } - const endpoint = process.env[OTEL_EXPORTER_OTLP_ENDPOINT]?.trim(); + const env: Record = + typeof process !== "undefined" && process.env ? process.env : {}; + const endpoint = env[OTEL_EXPORTER_OTLP_ENDPOINT]?.trim(); if (!endpoint) { throw new Leap0Error( `otelExport=true requires ${OTEL_EXPORTER_OTLP_ENDPOINT} in the local environment`, @@ -43,7 +44,7 @@ function injectOtelEnv( const merged: Record = { [OTEL_EXPORTER_OTLP_ENDPOINT]: endpoint, }; - const headers = process.env[OTEL_EXPORTER_OTLP_HEADERS]?.trim(); + const headers = env[OTEL_EXPORTER_OTLP_HEADERS]?.trim(); if (headers) { merged[OTEL_EXPORTER_OTLP_HEADERS] = headers; } @@ -55,10 +56,7 @@ function injectOtelEnv( /** Creates, fetches, pauses, and deletes sandboxes. */ export class SandboxesClient { - constructor( - private readonly transport: Leap0Transport, - private readonly sandboxDomain: string, - ) {} + constructor(private readonly transport: Leap0Transport) {} /** Creates a sandbox from a template and resource config. */ async create( @@ -140,7 +138,7 @@ export class SandboxesClient { /** Builds the public invoke URL for a sandbox. */ invokeUrl(sandbox: SandboxRef, path = "/", port?: number): string { - return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.sandboxDomain, port)}${ensureLeadingSlash(path)}`; + return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.transport.sandboxDomain, port)}${ensureLeadingSlash(path)}`; } /** Builds the public websocket URL for a sandbox. */ diff --git a/src/services/shared.ts b/src/services/shared.ts index 63d467d..be04f62 100644 --- a/src/services/shared.ts +++ b/src/services/shared.ts @@ -13,13 +13,8 @@ export async function withErrorPrefix(prefix: string, fn: () => Promise): return await fn(); } catch (error) { if (error instanceof Leap0Error) { - throw new Leap0Error(`${prefix}${error.message}`, { - statusCode: error.statusCode, - headers: error.headers, - body: error.body, - retryable: error.retryable, - cause: error, - }); + error.message = `${prefix}${error.message}`; + throw error; } throw error; } diff --git a/tests/client/client-sandbox.test.ts b/tests/client/client-sandbox.test.ts index 0378958..88e5bb0 100644 --- a/tests/client/client-sandbox.test.ts +++ b/tests/client/client-sandbox.test.ts @@ -46,12 +46,11 @@ test("Sandbox binds service methods to itself", async () => { id: "sb-1", templateId: "tpl-1", state: "running", - vcpu: 1, - memoryMib: 1024, + vcpu: 2, + memoryMib: 2048, diskMib: 4096, autoPause: false, createdAt: "2026-01-01T00:00:00Z", - refreshed: true, }), pause: async () => ({ id: "sb-1", @@ -89,8 +88,10 @@ test("Sandbox binds service methods to itself", async () => { createdAt: "2026-01-01T00:00:00Z", }); assert.equal(await sandbox.filesystem.readFile("/tmp/test.txt"), "sb-1:/tmp/test.txt"); + assert.equal(sandbox.vcpu, 1); await sandbox.refresh(); - assert.equal((sandbox as { refreshed?: boolean }).refreshed, true); + assert.equal(sandbox.vcpu, 2); + assert.equal(sandbox.memoryMib, 2048); await sandbox.pause(); assert.equal(sandbox.state, "paused"); assert.equal(sandbox.invokeUrl("/healthz", 3000), "invoke:sb-1:/healthz:3000"); diff --git a/tests/services/code-interpreter.test.ts b/tests/services/code-interpreter.test.ts index 0442b3d..c24dce0 100644 --- a/tests/services/code-interpreter.test.ts +++ b/tests/services/code-interpreter.test.ts @@ -18,7 +18,7 @@ test("code interpreter client sends expected request shapes", async () => { return { id: "ctx-1", language: 1, cwd: "/workspace" }; }, }); - const client = new CodeInterpreterClient(transport as never, "sandbox.example.com"); + const client = new CodeInterpreterClient(transport as never); await client.health("sb-1"); await client.createContext("sb-1", "python"); await client.listContexts("sb-1"); @@ -47,7 +47,7 @@ test("code interpreter stream parses SSE and maps event types", async () => { requestJsonUrl: (url: string) => Promise.reject(new Leap0Error(`Code interpreter request failed: ${url}`)), }); - const client = new CodeInterpreterClient(transport as never, "sandbox.example.com"); + const client = new CodeInterpreterClient(transport as never); const events: Array<{ type: string; data: unknown }> = []; for await (const event of client.executeStream("sb-1", { code: "print(1)", diff --git a/tests/services/desktop.test.ts b/tests/services/desktop.test.ts index 6a4d221..8446ab1 100644 --- a/tests/services/desktop.test.ts +++ b/tests/services/desktop.test.ts @@ -45,7 +45,7 @@ test("desktop client sends expected request shapes", async () => { return Promise.resolve({ ok: true }); }, }); - const client = new DesktopClient(transport as never, "sandbox.example.com"); + const client = new DesktopClient(transport as never); await client.display("sb-1"); await client.setScreen("sb-1", { width: 1280, height: 720 }); await client.movePointer("sb-1", 10, 20); @@ -73,7 +73,7 @@ test("desktop statusStream parses SSE and raises API errors", async () => { }, requestJsonUrl: () => Promise.reject(new Leap0Error("Desktop request failed")), }); - const client = new DesktopClient(transport as never, "sandbox.example.com"); + const client = new DesktopClient(transport as never); const events: unknown[] = []; for await (const event of client.statusStream("sb-1")) events.push(event); assert.deepEqual(events, [ diff --git a/tests/services/filesystem.test.ts b/tests/services/filesystem.test.ts index c4dfb18..1448dfc 100644 --- a/tests/services/filesystem.test.ts +++ b/tests/services/filesystem.test.ts @@ -27,6 +27,7 @@ test("filesystem client sends expected request shapes", async () => { if (path.endsWith("/edit-files")) return { items: [] }; if (path.endsWith("/tree")) return { items: [] }; if (path.endsWith("/exists")) return { exists: true }; + if (path.endsWith("/glob")) return { items: [] }; return { items: [] }; }, }); @@ -54,7 +55,7 @@ test("filesystem client sends expected request shapes", async () => { assert.equal(new Headers(calls[3]!.init.headers).get("content-type"), "text/plain"); assert.equal(calls[3]?.options.query?.path, "/tmp/a.txt"); assert.equal(new Headers(calls[4]!.init.headers).get("content-type"), "application/octet-stream"); - assert.equal(calls[5]?.options.query?.head, 10); + assert.deepEqual(jsonOf(calls[5]!), { path: "/tmp/a.txt", head: 10 }); assert.deepEqual(jsonOf(calls[11]!), { path: "/tmp/a.txt", edits: [{ find: "a", replace: "b" }], diff --git a/tests/services/git.test.ts b/tests/services/git.test.ts index 00fce74..76175fe 100644 --- a/tests/services/git.test.ts +++ b/tests/services/git.test.ts @@ -9,7 +9,7 @@ test("git client sends expected request shapes", async () => { requestJson: async (path: string, init: RequestInit, options: never) => { calls.push({ path, init, options }); if (path.endsWith("/commit")) - return { sha: "abc123", result: { output: "ok", exit_code: 0 } }; + return { result: { output: "ok", exit_code: 0 } }; return { output: "ok", exit_code: 0 }; }, }); @@ -27,7 +27,7 @@ test("git client sends expected request shapes", async () => { await client.checkoutBranch("sb-1", "/workspace/repo", "feat"); await client.deleteBranch("sb-1", "/workspace/repo", "feat"); await client.add("sb-1", "/workspace/repo", ["a.ts"]); - await client.commit("sb-1", "/workspace/repo", "msg"); + await client.commit("sb-1", "/workspace/repo", "msg", "Test User", "test@example.com"); await client.push("sb-1", "/workspace/repo"); await client.pull("sb-1", "/workspace/repo"); @@ -38,5 +38,10 @@ test("git client sends expected request shapes", async () => { }); assert.deepEqual(jsonOf(calls[8]!), { path: "/workspace/repo", revision: "HEAD" }); assert.deepEqual(jsonOf(calls[12]!), { path: "/workspace/repo", files: ["a.ts"] }); - assert.deepEqual(jsonOf(calls[13]!), { path: "/workspace/repo", message: "msg" }); + assert.deepEqual(jsonOf(calls[13]!), { + path: "/workspace/repo", + message: "msg", + author: "Test User", + email: "test@example.com", + }); }); diff --git a/tests/services/lsp.test.ts b/tests/services/lsp.test.ts index 142c279..9799f57 100644 --- a/tests/services/lsp.test.ts +++ b/tests/services/lsp.test.ts @@ -9,16 +9,18 @@ test("lsp client sends expected request shapes", async () => { const client = new LspClient(transport as never); await client.start("sb-1", "typescript", "/workspace"); await client.stop("sb-1", "typescript", "/workspace"); - await client.didOpenPath("sb-1", "typescript", "/workspace", "/workspace/a.ts"); + await client.didOpenPath("sb-1", "typescript", "/workspace", "/workspace/a.ts", "const x = 1;", 1); await client.didClosePath("sb-1", "typescript", "/workspace", "/workspace/a.ts"); await client.completionsPath("sb-1", "typescript", "/workspace", "/workspace/a.ts", 1, 2); await client.documentSymbolsPath("sb-1", "typescript", "/workspace", "/workspace/a.ts"); assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/lsp/start"); assert.deepEqual(jsonOf(calls[0]!), { language_id: "typescript", path_to_project: "/workspace" }); - assert.deepEqual(jsonOf(calls[2]!) as { uri: string }, { + assert.deepEqual(jsonOf(calls[2]!), { language_id: "typescript", path_to_project: "/workspace", uri: "file:///workspace/a.ts", + text: "const x = 1;", + version: 1, }); assert.deepEqual(jsonOf(calls[4]!), { language_id: "typescript", diff --git a/tests/services/normalization-cleanup.test.ts b/tests/services/normalization-cleanup.test.ts index 5a505a5..d6b8af5 100644 --- a/tests/services/normalization-cleanup.test.ts +++ b/tests/services/normalization-cleanup.test.ts @@ -77,7 +77,6 @@ test("code interpreter client normalizes context fields", async () => { const context = await new CodeInterpreterClient( transport as never, - "sandbox.example.com", ).createContext("sb-1", "python"); assert.equal(context.cwd, "/workspace"); diff --git a/tests/services/pty.test.ts b/tests/services/pty.test.ts index cf0949c..8bd63e6 100644 --- a/tests/services/pty.test.ts +++ b/tests/services/pty.test.ts @@ -22,7 +22,7 @@ test("pty client sends expected request shapes", async () => { return session; }, }); - const client = new PtyClient(transport as never, "sandbox.example.com"); + const client = new PtyClient(transport as never); await client.list("sb-1"); await client.create("sb-1", { cols: 80, rows: 24, cwd: "/workspace" }); await client.get("sb-1", "sess-1"); @@ -33,8 +33,9 @@ test("pty client sends expected request shapes", async () => { assert.equal(calls[3]?.path, "/v1/sandbox/sb-1/pty/sess-1/resize"); assert.equal( client.websocketUrl("sb-1", "sess-1"), - "wss://sb-1.sandbox.example.com/v1/sandbox/sb-1/pty/sess-1/connect", + "wss://api.example.com/v1/sandbox/sb-1/pty/sess-1/connect", ); + assert.deepEqual(client.websocketHeaders(), { authorization: "test-api-key" }); }); test("pty connection sends, receives, and closes websocket data", async () => { @@ -51,6 +52,9 @@ test("pty connection sends, receives, and closes websocket data", async () => { addEventListener(type: string, handler: (event: { data?: unknown }) => void) { handlers.set(type, handler); }, + removeEventListener(type: string) { + handlers.delete(type); + }, }; const connection = new PtyConnection(socket as never); connection.send("hello"); diff --git a/tests/services/sandboxes-client.test.ts b/tests/services/sandboxes-client.test.ts index c2f6eee..2a9a999 100644 --- a/tests/services/sandboxes-client.test.ts +++ b/tests/services/sandboxes-client.test.ts @@ -21,7 +21,7 @@ function makeClient() { }); }, }); - const client = new SandboxesClient(transport as never, "sandbox.example.com"); + const client = new SandboxesClient(transport as never); return { client, calls }; } diff --git a/tests/services/transport.test.ts b/tests/services/transport.test.ts index c3f3fe8..5b4bdaa 100644 --- a/tests/services/transport.test.ts +++ b/tests/services/transport.test.ts @@ -26,7 +26,10 @@ function makeTransport() { } test("transport headers include auth and sdk metadata", () => { - const headers = makeTransport().headers({ "x-extra": "1" }); + // oxlint-disable-next-line -- access private method for testing + const headers = (makeTransport() as never as { headers(extra?: HeadersInit): Headers }).headers({ + "x-extra": "1", + }); assert.equal(headers.get("authorization"), "Bearer test-key"); assert.equal(headers.get("Leap0-Source"), "sdk-ts"); assert.match(headers.get("User-Agent") ?? "", /^leap0-js\//); diff --git a/tests/utils/helpers.ts b/tests/utils/helpers.ts index 0b94fa2..8faebd8 100644 --- a/tests/utils/helpers.ts +++ b/tests/utils/helpers.ts @@ -38,6 +38,9 @@ export function createRecordedTransport(overrides: Record = {}) return Promise.resolve(new Response(null, { status: 204 })); }, headers: (headers?: HeadersInit) => new Headers(headers), + baseUrl: "https://api.example.com", + sandboxDomain: "sandbox.example.com", + apiKey: "test-api-key", ...overrides, }; From 40c268106833109ad4fe610446cda6119559bd69 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Fri, 3 Apr 2026 13:11:21 -0400 Subject: [PATCH 3/9] fixes --- examples/code_interpreter_stream.ts | 2 +- examples/desktop.ts | 6 +- examples/filesystem_and_git.ts | 10 +- examples/pty.ts | 4 +- examples/quickstart.ts | 2 +- examples/snapshots.ts | 4 +- examples/ssh.ts | 2 +- src/client/index.ts | 102 +++----- src/client/sandbox.ts | 24 +- src/core/transport.ts | 8 +- src/models/code-interpreter.ts | 2 +- src/models/desktop.ts | 4 + src/models/pty.ts | 2 +- src/models/shared.ts | 2 + src/services/code-interpreter.ts | 74 ++++-- src/services/desktop.ts | 80 +++--- src/services/filesystem.ts | 228 ++++++++++++----- src/services/git.ts | 243 ++++++++++++++++--- src/services/index.ts | 2 +- src/services/lsp.ts | 37 +-- src/services/pty.ts | 11 +- src/services/sandboxes.ts | 30 ++- src/services/snapshots.ts | 17 +- tests/client/client-sandbox.test.ts | 57 ++--- tests/services/code-interpreter.test.ts | 2 + tests/services/desktop.test.ts | 9 +- tests/services/filesystem.test.ts | 23 +- tests/services/git.test.ts | 19 +- tests/services/normalization-cleanup.test.ts | 2 +- tests/services/pty.test.ts | 2 +- 30 files changed, 662 insertions(+), 348 deletions(-) diff --git a/examples/code_interpreter_stream.ts b/examples/code_interpreter_stream.ts index f8730a3..2f763e8 100644 --- a/examples/code_interpreter_stream.ts +++ b/examples/code_interpreter_stream.ts @@ -9,7 +9,7 @@ async function main(): Promise { const client = new Leap0Client(); try { - const sandbox = await client.createSandbox({ + const sandbox = await client.sandboxes.create({ templateName: DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, }); diff --git a/examples/desktop.ts b/examples/desktop.ts index 9e30860..b18c574 100644 --- a/examples/desktop.ts +++ b/examples/desktop.ts @@ -6,12 +6,12 @@ async function main(): Promise { const client = new Leap0Client(); try { - const sandbox = await client.createSandbox({ templateName: DEFAULT_DESKTOP_TEMPLATE_NAME }); + const sandbox = await client.sandboxes.create({ templateName: DEFAULT_DESKTOP_TEMPLATE_NAME }); try { await sandbox.desktop.waitUntilReady(60); - console.log("Desktop:", sandbox.desktop.browserUrl()); + console.log("Desktop:", sandbox.desktop.desktopUrl()); - const display = await sandbox.desktop.display(); + const display = await sandbox.desktop.displayInfo(); console.log("Display:", display); await sandbox.desktop.movePointer( diff --git a/examples/filesystem_and_git.ts b/examples/filesystem_and_git.ts index 55f694a..c5fa243 100644 --- a/examples/filesystem_and_git.ts +++ b/examples/filesystem_and_git.ts @@ -5,10 +5,10 @@ async function main(): Promise { const repoPath = "/workspace/hello-world"; try { - const sandbox = await client.createSandbox(); + const sandbox = await client.sandboxes.create(); try { - const clone = await sandbox.git.clone("https://github.com/octocat/Hello-World.git", repoPath); + const clone = await sandbox.git.clone({ url: "https://github.com/octocat/Hello-World.git", path: repoPath }); console.log("clone exit:", clone.exitCode); const status = await sandbox.git.status(repoPath); @@ -19,15 +19,15 @@ async function main(): Promise { "Hello from the Leap0 JS SDK\n", ); const exists = await sandbox.filesystem.exists(`${repoPath}/sdk-demo.txt`); - console.log("file exists:", exists.exists); + console.log("file exists:", exists); const fileInfo = await sandbox.filesystem.stat(`${repoPath}/README`); console.log("readme size:", fileInfo.size); - const tree = await sandbox.filesystem.tree(repoPath, 2); + const tree = await sandbox.filesystem.tree(repoPath, { maxDepth: 2 }); console.log( "tree items:", - tree.items.map((entry) => entry.name), + tree.items.map((entry: { name: string }) => entry.name), ); } finally { await sandbox.delete(); diff --git a/examples/pty.ts b/examples/pty.ts index 66715b5..d462669 100644 --- a/examples/pty.ts +++ b/examples/pty.ts @@ -15,11 +15,11 @@ function waitForOpen(socket: WebSocket): Promise { async function main(): Promise { const client = new Leap0Client(); - const sandbox = await client.createSandbox(); + const sandbox = await client.sandboxes.create(); try { const session = await sandbox.pty.create({ - id: "demo-terminal", + sessionId: "demo-terminal", cols: 120, rows: 30, cwd: "/home/user", diff --git a/examples/quickstart.ts b/examples/quickstart.ts index 561d5cb..64be97e 100644 --- a/examples/quickstart.ts +++ b/examples/quickstart.ts @@ -2,7 +2,7 @@ import { Leap0Client } from "../src/index.js"; async function main(): Promise { const client = new Leap0Client(); - const sandbox = await client.createSandbox(); + const sandbox = await client.sandboxes.create(); try { const result = await sandbox.process.execute({ command: "echo hello from leap0" }); diff --git a/examples/snapshots.ts b/examples/snapshots.ts index 40a5295..1acf171 100644 --- a/examples/snapshots.ts +++ b/examples/snapshots.ts @@ -4,14 +4,14 @@ async function main(): Promise { const client = new Leap0Client(); try { - const sandbox = await client.createSandbox(); + const sandbox = await client.sandboxes.create(); try { await sandbox.filesystem.writeFile("/workspace/checkpoint.txt", "before snapshot\n"); const snapshot = await client.snapshots.create(sandbox, { name: "example-checkpoint" }); console.log("snapshot:", snapshot.id); - const restored = await client.resumeSnapshot({ + const restored = await client.snapshots.resume({ snapshotName: snapshot.name ?? "example-checkpoint", }); try { diff --git a/examples/ssh.ts b/examples/ssh.ts index 90b140c..2f39cde 100644 --- a/examples/ssh.ts +++ b/examples/ssh.ts @@ -4,7 +4,7 @@ async function main(): Promise { const client = new Leap0Client(); try { - const sandbox = await client.createSandbox(); + const sandbox = await client.sandboxes.create(); try { const access = await sandbox.ssh.createAccess(); console.log("ssh command:", access.sshCommand); diff --git a/src/client/index.ts b/src/client/index.ts index 1357df7..3e13dca 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -16,7 +16,19 @@ import { TemplatesClient, } from "@/services/index.js"; -import { Sandbox } from "@/client/sandbox.js"; +import { Sandbox, SERVICES } from "@/client/sandbox.js"; + +/** Internal service map used by Sandbox to bind per-sandbox proxies. */ +export interface ClientServices { + filesystem: FilesystemClient; + git: GitClient; + process: ProcessClient; + pty: PtyClient; + lsp: LspClient; + ssh: SshClient; + codeInterpreter: CodeInterpreterClient; + desktop: DesktopClient; +} /** * Top-level Leap0 SDK client that exposes all service groups. @@ -24,17 +36,12 @@ import { Sandbox } from "@/client/sandbox.js"; export class Leap0Client { private readonly transport: Leap0Transport; - readonly sandboxes: SandboxesClient; - readonly snapshots: SnapshotsClient; + readonly sandboxes: SandboxesClient; + readonly snapshots: SnapshotsClient; readonly templates: TemplatesClient; - readonly filesystem: FilesystemClient; - readonly git: GitClient; - readonly process: ProcessClient; - readonly pty: PtyClient; - readonly lsp: LspClient; - readonly ssh: SshClient; - readonly codeInterpreter: CodeInterpreterClient; - readonly desktop: DesktopClient; + + /** @internal - used by Sandbox to bind service proxies. */ + readonly [SERVICES]: ClientServices; /** Creates a client using explicit config or environment variables. */ constructor(config: Leap0ConfigInput = {}) { @@ -44,67 +51,22 @@ export class Leap0Client { initOtel(resolved); } - this.sandboxes = new SandboxesClient(this.transport); - this.snapshots = new SnapshotsClient(this.transport); - this.templates = new TemplatesClient(this.transport); - this.filesystem = new FilesystemClient(this.transport); - this.git = new GitClient(this.transport); - this.process = new ProcessClient(this.transport); - this.pty = new PtyClient(this.transport); - this.lsp = new LspClient(this.transport); - this.ssh = new SshClient(this.transport); - this.codeInterpreter = new CodeInterpreterClient(this.transport); - this.desktop = new DesktopClient(this.transport); - } + const wrapSandbox = (data: import("@/models/index.js").SandboxData) => new Sandbox(this, data); - /** - * Fetches a sandbox by ID. - * - * Args: - * sandboxId: Sandbox identifier. - * - * Returns: - * The sandbox payload. - */ - async getSandbox(sandboxId: string): Promise { - const data = await this.sandboxes.get(sandboxId); - return new Sandbox(this, data); - } - - /** - * Creates a new sandbox. - * - * Args: - * params: Sandbox creation parameters. - * options: Optional request settings. - * - * Returns: - * The created sandbox payload. - */ - async createSandbox( - params?: Parameters[0], - options?: Parameters[1], - ): Promise { - const data = await this.sandboxes.create(params, options); - return new Sandbox(this, data); - } + this.sandboxes = new SandboxesClient(this.transport, wrapSandbox); + this.snapshots = new SnapshotsClient(this.transport, wrapSandbox); + this.templates = new TemplatesClient(this.transport); - /** - * Resumes a sandbox from a snapshot. - * - * Args: - * params: Snapshot resume parameters. - * options: Optional request settings. - * - * Returns: - * The restored sandbox payload. - */ - async resumeSnapshot( - params: Parameters[0], - options?: Parameters[1], - ): Promise { - const data = await this.snapshots.resume(params, options); - return new Sandbox(this, data); + this[SERVICES] = { + filesystem: new FilesystemClient(this.transport), + git: new GitClient(this.transport), + process: new ProcessClient(this.transport), + pty: new PtyClient(this.transport), + lsp: new LspClient(this.transport), + ssh: new SshClient(this.transport), + codeInterpreter: new CodeInterpreterClient(this.transport), + desktop: new DesktopClient(this.transport), + }; } /** Closes the underlying transport. */ diff --git a/src/client/sandbox.ts b/src/client/sandbox.ts index b7a682b..9e5a544 100644 --- a/src/client/sandbox.ts +++ b/src/client/sandbox.ts @@ -12,6 +12,9 @@ import { import type { Leap0Client } from "@/client/index.js"; +/** @internal Symbol used to access service clients on Leap0Client. */ +export const SERVICES = Symbol.for("leap0.services"); + type BoundSandboxMethod = Method extends ( sandbox: infer _Sandbox, ...args: infer Args @@ -72,14 +75,15 @@ export class Sandbox implements SandboxData { data: SandboxData, ) { this.update(data); - this.filesystem = new SandboxServiceProxy(client.filesystem, this).proxy; - this.git = new SandboxServiceProxy(client.git, this).proxy; - this.process = new SandboxServiceProxy(client.process, this).proxy; - this.pty = new SandboxServiceProxy(client.pty, this).proxy; - this.lsp = new SandboxServiceProxy(client.lsp, this).proxy; - this.ssh = new SandboxServiceProxy(client.ssh, this).proxy; - this.codeInterpreter = new SandboxServiceProxy(client.codeInterpreter, this).proxy; - this.desktop = new SandboxServiceProxy(client.desktop, this).proxy; + const svc = client[SERVICES]; + this.filesystem = new SandboxServiceProxy(svc.filesystem, this).proxy; + this.git = new SandboxServiceProxy(svc.git, this).proxy; + this.process = new SandboxServiceProxy(svc.process, this).proxy; + this.pty = new SandboxServiceProxy(svc.pty, this).proxy; + this.lsp = new SandboxServiceProxy(svc.lsp, this).proxy; + this.ssh = new SandboxServiceProxy(svc.ssh, this).proxy; + this.codeInterpreter = new SandboxServiceProxy(svc.codeInterpreter, this).proxy; + this.desktop = new SandboxServiceProxy(svc.desktop, this).proxy; } /** @@ -111,7 +115,7 @@ export class Sandbox implements SandboxData { * The refreshed sandbox handle. */ async refresh(): Promise { - this.update(await this.client.sandboxes.get(this.id)); + this.update(await this.client.sandboxes.get(this.id) as SandboxData); return this; } @@ -125,7 +129,7 @@ export class Sandbox implements SandboxData { * The paused sandbox handle. */ async pause(options?: { timeout?: number }): Promise { - this.update(await this.client.sandboxes.pause(this.id, options)); + this.update(await this.client.sandboxes.pause(this.id, options) as SandboxData); return this; } diff --git a/src/core/transport.ts b/src/core/transport.ts index 75429ff..8e40ce8 100644 --- a/src/core/transport.ts +++ b/src/core/transport.ts @@ -145,7 +145,13 @@ export class Leap0Transport { headers: this.headers(options.headers ?? init.headers), signal: controller.signal, }); - if (!response.ok) { + const expected = options.expectedStatus; + if (expected !== undefined) { + const codes = Array.isArray(expected) ? expected : [expected]; + if (!codes.includes(response.status)) { + throw await this.mapHttpError(response); + } + } else if (!response.ok) { throw await this.mapHttpError(response); } return response; diff --git a/src/models/code-interpreter.ts b/src/models/code-interpreter.ts index 042638f..2b70e6a 100644 --- a/src/models/code-interpreter.ts +++ b/src/models/code-interpreter.ts @@ -77,7 +77,7 @@ export const codeExecutionResultSchema = z export type CodeExecutionResult = z.infer; export const streamEventWireSchema = z.object({ - type: z.number(), + type: z.union([z.number(), streamEventTypeSchema]), data: z.union([z.string(), z.number()]).optional(), code: z.number().optional(), }); diff --git a/src/models/desktop.ts b/src/models/desktop.ts index 84b16c3..1a2d5ee 100644 --- a/src/models/desktop.ts +++ b/src/models/desktop.ts @@ -28,6 +28,10 @@ export type DesktopSetScreenParams = z.infer; diff --git a/src/models/pty.ts b/src/models/pty.ts index 3fc78cf..cf197bf 100644 --- a/src/models/pty.ts +++ b/src/models/pty.ts @@ -15,7 +15,7 @@ export const ptySessionSchema = z export type PtySession = z.infer; export const createPtySessionParamsSchema = z.object({ - id: z.string().optional(), + sessionId: z.string().optional(), cols: z.number().int().positive().optional(), rows: z.number().int().positive().optional(), cwd: z.string().optional(), diff --git a/src/models/shared.ts b/src/models/shared.ts index 356262d..7ff080c 100644 --- a/src/models/shared.ts +++ b/src/models/shared.ts @@ -2,4 +2,6 @@ export interface RequestOptions { timeout?: number; headers?: HeadersInit; query?: Record; + /** HTTP status codes to accept as successful. Defaults to any 2xx. */ + expectedStatus?: number | number[]; } diff --git a/src/services/code-interpreter.ts b/src/services/code-interpreter.ts index 1188dcd..87b9096 100644 --- a/src/services/code-interpreter.ts +++ b/src/services/code-interpreter.ts @@ -35,20 +35,24 @@ export class CodeInterpreterClient { return (await this.transport.requestJsonUrl(this.requestPath(sandbox, path), init, options))!; } - async health(sandbox: SandboxRef, options: RequestOptions = {}): Promise<{ ok: boolean }> { - return await this.fetchJson(sandbox, "/healthz", { method: "GET" }, options); + async health(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + const data = await this.fetchJson>(sandbox, "/healthz", { method: "GET" }, options); + return data?.status === "ok"; } async createContext( sandbox: SandboxRef, - language: CodeLanguage, + language: CodeLanguage = "python" as CodeLanguage, + cwd?: string, options: RequestOptions = {}, ): Promise { + const payload: Record = { language }; + if (cwd !== undefined) payload.cwd = cwd; return normalize( codeContextSchema, await this.fetchJson( sandbox, "/contexts", - { method: "POST", body: jsonBody({ language }) }, + { method: "POST", body: jsonBody(payload) }, options, ), ); @@ -90,22 +94,28 @@ export class CodeInterpreterClient { } async execute( sandbox: SandboxRef, - params: { code: string; language: CodeLanguage; contextId?: string }, + params: { + code: string; + language?: CodeLanguage; + contextId?: string; + envVars?: Record; + timeoutMs?: number; + }, options: RequestOptions = {}, ): Promise { + const payload: Record = { + code: params.code, + language: params.language ?? "python", + }; + if (params.contextId !== undefined) payload.context_id = params.contextId; + if (params.envVars !== undefined) payload.env_vars = params.envVars; + if (params.timeoutMs !== undefined) payload.timeout_ms = params.timeoutMs; return normalize( codeExecutionResultSchema, await this.fetchJson( sandbox, "/execute", - { - method: "POST", - body: jsonBody({ - code: params.code, - language: params.language, - context_id: params.contextId, - }), - }, + { method: "POST", body: jsonBody(payload) }, options, ), ); @@ -118,21 +128,36 @@ export class CodeInterpreterClient { 3: StreamEventType.ERROR, }; + private static mapWireType(type: number | StreamEvent["type"]): StreamEvent["type"] { + if (typeof type === "string") { + return type; + } + const mappedType = CodeInterpreterClient.WIRE_TYPE_MAP[type]; + if (!mappedType) { + throw new Leap0Error(`Unknown stream event type: ${type}`); + } + return mappedType; + } + async *executeStream( sandbox: SandboxRef, - params: { code: string; language: CodeLanguage; contextId?: string }, + params: { + code: string; + language?: CodeLanguage; + contextId?: string; + timeoutMs?: number; + }, options: RequestOptions = {}, ): AsyncIterable { + const payload: Record = { + code: params.code, + language: params.language ?? "python", + }; + if (params.contextId !== undefined) payload.context_id = params.contextId; + if (params.timeoutMs !== undefined) payload.timeout_ms = params.timeoutMs; for await (const event of this.transport.streamJsonUrl( this.requestPath(sandbox, "/execute/async"), - { - method: "POST", - body: jsonBody({ - code: params.code, - language: params.language, - context_id: params.contextId, - }), - }, + { method: "POST", body: jsonBody(payload) }, options, )) { if (!event || typeof event !== "object") { @@ -143,10 +168,7 @@ export class CodeInterpreterClient { throw new Leap0Error(typeof record.message === "string" ? record.message : "Stream error"); } const wire = streamEventWireSchema.parse(event); - const mappedType = CodeInterpreterClient.WIRE_TYPE_MAP[wire.type]; - if (!mappedType) { - throw new Leap0Error(`Unknown stream event type: ${wire.type}`); - } + const mappedType = CodeInterpreterClient.mapWireType(wire.type); const streamEvent: StreamEvent = { type: mappedType }; if (wire.data !== undefined) streamEvent.data = wire.data; if (wire.code !== undefined) streamEvent.code = wire.code; diff --git a/src/services/desktop.ts b/src/services/desktop.ts index 430251e..a4c4e41 100644 --- a/src/services/desktop.ts +++ b/src/services/desktop.ts @@ -48,8 +48,8 @@ export class DesktopClient { return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.transport.sandboxDomain)}${path}`; } - browserUrl(sandbox: SandboxRef): string { - return sandboxBaseUrl(sandboxIdOf(sandbox), this.transport.sandboxDomain); + desktopUrl(sandbox: SandboxRef): string { + return `${sandboxBaseUrl(sandboxIdOf(sandbox), this.transport.sandboxDomain)}/`; } private async requestJson( @@ -65,7 +65,7 @@ export class DesktopClient { ); } - async display(sandbox: SandboxRef, options?: RequestOptions): Promise { + async displayInfo(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson( sandbox, desktopDisplayInfoSchema, @@ -83,7 +83,7 @@ export class DesktopClient { options, ); } - async setScreen( + async resizeScreen( sandbox: SandboxRef, payload: DesktopSetScreenParams, options?: RequestOptions, @@ -201,26 +201,29 @@ export class DesktopClient { options, ); } - async typeText(sandbox: SandboxRef, text: string, options?: RequestOptions): Promise { - await this.transport.requestJsonUrl( + async typeText(sandbox: SandboxRef, text: string, options?: RequestOptions): Promise { + const data = (await this.transport.requestJsonUrl( this.requestUrl(sandbox, "/api/input/type"), { method: "POST", body: jsonBody({ text }) }, options, - ); + )) as Record | undefined; + return Boolean(data?.ok); } - async press(sandbox: SandboxRef, key: string, options?: RequestOptions): Promise { - await this.transport.requestJsonUrl( + async pressKey(sandbox: SandboxRef, key: string, options?: RequestOptions): Promise { + const data = (await this.transport.requestJsonUrl( this.requestUrl(sandbox, "/api/input/press"), { method: "POST", body: jsonBody({ key }) }, options, - ); + )) as Record | undefined; + return Boolean(data?.ok); } - async hotkey(sandbox: SandboxRef, keys: string[], options?: RequestOptions): Promise { - await this.transport.requestJsonUrl( + async hotkey(sandbox: SandboxRef, keys: string[], options?: RequestOptions): Promise { + const data = (await this.transport.requestJsonUrl( this.requestUrl(sandbox, "/api/input/hotkey"), { method: "POST", body: jsonBody({ keys }) }, options, - ); + )) as Record | undefined; + return Boolean(data?.ok); } async recordingStatus( sandbox: SandboxRef, @@ -270,7 +273,7 @@ export class DesktopClient { options, ).then((r) => r.items); } - async recording( + async getRecording( sandbox: SandboxRef, id: string, options?: RequestOptions, @@ -307,10 +310,10 @@ export class DesktopClient { desktopHealthSchema, "/api/healthz", { method: "GET" }, - options, + { ...options, expectedStatus: [200, 503] }, ); } - async status(sandbox: SandboxRef, options?: RequestOptions): Promise { + async processStatus(sandbox: SandboxRef, options?: RequestOptions): Promise { return this.requestJson( sandbox, desktopProcessStatusListSchema, @@ -319,7 +322,7 @@ export class DesktopClient { options, ); } - async processStatus( + async getProcess( sandbox: SandboxRef, name: string, options?: RequestOptions, @@ -384,28 +387,41 @@ export class DesktopClient { if (!event || typeof event !== "object") { throw new Leap0Error("Malformed desktop status stream event"); } - if (typeof asRecord(event).message === "string") { - throw new Leap0Error(String(asRecord(event).message)); + const record = asRecord(event); + if (record.error !== undefined) { + throw new Leap0Error("Desktop status stream error", { body: String(record.error) }); + } + if (typeof record.message === "string") { + throw new Leap0Error(String(record.message)); } yield normalize(desktopProcessStatusListSchema, event); } } async waitUntilReady(sandbox: SandboxRef, timeout = 60): Promise { - const startedAt = Date.now(); - let delayMs = 250; - while (true) { - const elapsedSec = (Date.now() - startedAt) / 1000; - if (elapsedSec >= timeout) { - throw new Leap0Error("Desktop did not become ready within timeout"); - } - const remaining = Math.max(1, Math.ceil(timeout - elapsedSec)); - const status = await this.status(sandbox, { timeout: remaining }); - if ((status.items ?? []).every((process) => process.running === true)) { - return; + const deadline = Date.now() + timeout * 1000; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const remaining = Math.max(1, Math.ceil((deadline - Date.now()) / 1000)); + for await (const status of this.statusStream(sandbox, { timeout: remaining })) { + if (status.status === "running") { + return; + } + } + // Stream ended without reaching running, retry + } catch (error) { + lastError = error; + if (error instanceof Leap0Error && !error.retryable) throw error; + // Transient error, retry with backoff + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await new Promise((resolve) => setTimeout(resolve, Math.min(500, remaining))); } - await new Promise((resolve) => setTimeout(resolve, delayMs)); - delayMs = Math.min(delayMs * 2, 2000); } + throw new Leap0Error( + `Desktop did not become ready within ${timeout}s${lastError instanceof Error ? `: ${lastError.message}` : ""}`, + ); } } diff --git a/src/services/filesystem.ts b/src/services/filesystem.ts index 79d2692..e001da5 100644 --- a/src/services/filesystem.ts +++ b/src/services/filesystem.ts @@ -22,19 +22,34 @@ import { } from "@/models/filesystem.js"; import { sandboxIdOf } from "@/core/utils.js"; +type JsonObject = Record; + +function compact(obj: JsonObject): JsonObject { + const result: JsonObject = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) result[key] = value; + } + return result; +} + /** Performs filesystem operations inside a sandbox. */ export class FilesystemClient { constructor(private readonly transport: Leap0Transport) {} + private fsPath(sandbox: SandboxRef, endpoint: string): string { + return `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/${endpoint}`; + } + /** Lists directory contents. */ async ls( sandbox: SandboxRef, - path = "/workspace", + path: string, + params: { recursive?: boolean; exclude?: string[] } = {}, options: RequestOptions = {}, ): Promise { const data = await this.transport.requestJson( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/ls`, - { method: "POST", body: jsonBody({ path }) }, + this.fsPath(sandbox, "ls"), + { method: "POST", body: jsonBody(compact({ path, recursive: params.recursive, exclude: params.exclude })) }, options, ); return normalize(lsResultSchema, data); @@ -43,60 +58,111 @@ export class FilesystemClient { /** Fetches metadata for a path. */ async stat(sandbox: SandboxRef, path: string, options: RequestOptions = {}): Promise { const data = await this.transport.requestJson( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/stat`, + this.fsPath(sandbox, "stat"), { method: "POST", body: jsonBody({ path }) }, options, ); return normalize(fileInfoSchema, data); } + /** Creates a directory. Set recursive to create parent directories. */ async mkdir( sandbox: SandboxRef, path: string, - recursive = false, + params: { recursive?: boolean; permissions?: string } = {}, options: RequestOptions = {}, ): Promise { await this.transport.request( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/mkdir`, - { method: "POST", body: jsonBody({ path, recursive }) }, + this.fsPath(sandbox, "mkdir"), + { method: "POST", body: jsonBody(compact({ path, recursive: params.recursive, permissions: params.permissions })) }, options, ); } - async writeFile( + /** Writes raw bytes to a single file path. */ + async writeBytes( sandbox: SandboxRef, path: string, - content: string, + content: Uint8Array, + params: { permissions?: string } = {}, options: RequestOptions = {}, ): Promise { + const query: Record = { path }; + if (params.permissions) query.permissions = params.permissions; await this.transport.request( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/write-file`, + this.fsPath(sandbox, "write-file"), { method: "POST", - headers: { "content-type": "text/plain" }, - body: content, + headers: { "content-type": "application/octet-stream" }, + body: Buffer.from(content), }, - { ...options, query: { path } }, + { ...options, query }, ); } - async writeBytes( + /** Writes text to a single file path. */ + async writeFile( sandbox: SandboxRef, path: string, - content: Uint8Array, + content: string, + params: { permissions?: string } = {}, + options: RequestOptions = {}, + ): Promise { + await this.writeBytes( + sandbox, + path, + new TextEncoder().encode(content), + params, + options, + ); + } + + /** Writes multiple files in a single request using multipart upload. */ + async writeFilesBytes( + sandbox: SandboxRef, + files: Record, options: RequestOptions = {}, ): Promise { + const form = new FormData(); + for (const [filePath, data] of Object.entries(files)) { + form.append(filePath, new Blob([data as BlobPart])); + } await this.transport.request( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/write-file`, - { - method: "POST", - headers: { "content-type": "application/octet-stream" }, - body: Buffer.from(content), - }, - { ...options, query: { path } }, + this.fsPath(sandbox, "write-files"), + { method: "POST", body: form }, + options, ); } + /** Writes multiple text files in a single request. */ + async writeFiles( + sandbox: SandboxRef, + files: Record, + options: RequestOptions = {}, + ): Promise { + const bytesFiles: Record = {}; + const encoder = new TextEncoder(); + for (const [filePath, content] of Object.entries(files)) { + bytesFiles[filePath] = encoder.encode(content); + } + await this.writeFilesBytes(sandbox, bytesFiles, options); + } + + /** Reads a single file and returns its raw bytes. */ + async readBytes( + sandbox: SandboxRef, + path: string, + params: { offset?: number; limit?: number; head?: number; tail?: number } = {}, + options: RequestOptions = {}, + ): Promise { + return await this.transport.requestBytes( + this.fsPath(sandbox, "read-file"), + { method: "POST", body: jsonBody(compact({ path, ...params })) }, + options, + ); + } + + /** Reads a single file and returns its content decoded as text. */ async readFile( sandbox: SandboxRef, path: string, @@ -104,24 +170,50 @@ export class FilesystemClient { options: RequestOptions = {}, ): Promise { return await this.transport.requestText( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/read-file`, - { method: "POST", body: jsonBody({ path, ...params }) }, + this.fsPath(sandbox, "read-file"), + { method: "POST", body: jsonBody(compact({ path, ...params })) }, options, ); } - async readBytes( + /** Reads multiple files and returns raw bytes keyed by path. */ + async readFilesBytes( sandbox: SandboxRef, - path: string, + paths: string[], options: RequestOptions = {}, - ): Promise { - return await this.transport.requestBytes( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/read-file`, - { method: "POST", body: jsonBody({ path }) }, + ): Promise> { + const response = await this.transport.request( + this.fsPath(sandbox, "read-files"), + { method: "POST", body: jsonBody({ paths }) }, options, ); + // Server returns multipart/form-data, parse it + const formData = await response.formData(); + const result: Record = {}; + for (const [name, value] of formData.entries()) { + if (value instanceof Blob) { + result[name] = new Uint8Array(await value.arrayBuffer()); + } + } + return result; + } + + /** Reads multiple files and returns decoded text keyed by path. */ + async readFiles( + sandbox: SandboxRef, + paths: string[], + options: RequestOptions = {}, + ): Promise> { + const bytesResult = await this.readFilesBytes(sandbox, paths, options); + const decoder = new TextDecoder(); + const result: Record = {}; + for (const [filePath, bytes] of Object.entries(bytesResult)) { + result[filePath] = decoder.decode(bytes); + } + return result; } + /** Deletes a file or directory. Set recursive for non-empty directories. */ async delete( sandbox: SandboxRef, path: string, @@ -129,83 +221,87 @@ export class FilesystemClient { options: RequestOptions = {}, ): Promise { await this.transport.request( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/delete`, + this.fsPath(sandbox, "delete"), { method: "POST", body: jsonBody({ path, recursive }) }, options, ); } + /** Sets file mode and optionally changes owner and group. */ async setPermissions( sandbox: SandboxRef, path: string, - mode: string, + params: { mode?: string; owner?: string; group?: string } = {}, options: RequestOptions = {}, ): Promise { await this.transport.request( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/set-permissions`, - { method: "POST", body: jsonBody({ path, mode }) }, + this.fsPath(sandbox, "set-permissions"), + { method: "POST", body: jsonBody(compact({ path, mode: params.mode, owner: params.owner, group: params.group })) }, options, ); } + /** Finds file paths matching a glob pattern. */ async glob( sandbox: SandboxRef, path: string, pattern: string, + params: { exclude?: string[] } = {}, options: RequestOptions = {}, ): Promise { const data = await this.transport.requestJson( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/glob`, - { method: "POST", body: jsonBody({ path, pattern }) }, + this.fsPath(sandbox, "glob"), + { method: "POST", body: jsonBody(compact({ path, pattern, exclude: params.exclude })) }, options, ); return z.object({ items: z.array(z.string()) }).parse(data).items; } + /** Searches for a text pattern across files in a directory. */ async grep( sandbox: SandboxRef, path: string, pattern: string, - include?: string, + params: { include?: string; exclude?: string[] } = {}, options: RequestOptions = {}, ): Promise { const data = await this.transport.requestJson( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/grep`, - { method: "POST", body: jsonBody({ path, pattern, include }) }, + this.fsPath(sandbox, "grep"), + { method: "POST", body: jsonBody(compact({ path, pattern, include: params.include, exclude: params.exclude })) }, options, ); return normalize(z.object({ items: z.array(searchMatchSchema) }), data).items; } + /** Applies one or more find-and-replace edits to a single file. */ async editFile( sandbox: SandboxRef, path: string, - edit: FileEdit, + edits: FileEdit[], options: RequestOptions = {}, ): Promise { const data = await this.transport.requestJson( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/edit-file`, - { method: "POST", body: jsonBody({ path, edits: [edit] }) }, + this.fsPath(sandbox, "edit-file"), + { method: "POST", body: jsonBody({ path, edits }) }, options, ); return normalize(editFileResultSchema, data); } + /** Replaces text across multiple files at once. */ async editFiles( sandbox: SandboxRef, - files: Array<{ path: string; edit: FileEdit }>, + params: { paths: string[]; find: string; replace?: string }, options: RequestOptions = {}, ): Promise { const data = await this.transport.requestJson( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/edit-files`, + this.fsPath(sandbox, "edit-files"), { method: "POST", body: jsonBody({ - files: files.map((file) => ({ - path: file.path, - find: file.edit.find, - replace: file.edit.replace, - })), + files: params.paths, + find: params.find, + replace: params.replace ?? "", }), }, options, @@ -213,54 +309,60 @@ export class FilesystemClient { return normalize(editFilesResultSchema, data); } + /** Moves or renames a file or directory. */ async move( sandbox: SandboxRef, - source: string, - destination: string, + srcPath: string, + dstPath: string, + overwrite = false, options: RequestOptions = {}, ): Promise { await this.transport.request( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/move`, - { method: "POST", body: jsonBody({ src_path: source, dst_path: destination }) }, + this.fsPath(sandbox, "move"), + { method: "POST", body: jsonBody({ src_path: srcPath, dst_path: dstPath, overwrite }) }, options, ); } + /** Copies a file or directory. */ async copy( sandbox: SandboxRef, - source: string, - destination: string, + srcPath: string, + dstPath: string, + params: { recursive?: boolean; overwrite?: boolean } = {}, options: RequestOptions = {}, ): Promise { await this.transport.request( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/copy`, - { method: "POST", body: jsonBody({ src_path: source, dst_path: destination }) }, + this.fsPath(sandbox, "copy"), + { method: "POST", body: jsonBody(compact({ src_path: srcPath, dst_path: dstPath, recursive: params.recursive, overwrite: params.overwrite })) }, options, ); } + /** Checks whether a path exists in the sandbox. */ async exists( sandbox: SandboxRef, path: string, options: RequestOptions = {}, - ): Promise<{ exists: boolean }> { + ): Promise { const data = await this.transport.requestJson( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/exists`, + this.fsPath(sandbox, "exists"), { method: "POST", body: jsonBody({ path }) }, options, ); - return z.object({ exists: z.boolean() }).parse(data); + return z.object({ exists: z.boolean() }).parse(data).exists; } + /** Gets a recursive directory tree. */ async tree( sandbox: SandboxRef, path: string, - maxDepth?: number, + params: { maxDepth?: number; exclude?: string[] } = {}, options: RequestOptions = {}, ): Promise { const data = await this.transport.requestJson( - `/v1/sandbox/${sandboxIdOf(sandbox)}/filesystem/tree`, - { method: "POST", body: jsonBody({ path, max_depth: maxDepth }) }, + this.fsPath(sandbox, "tree"), + { method: "POST", body: jsonBody(compact({ path, max_depth: params.maxDepth, exclude: params.exclude })) }, options, ); return normalize(treeResultSchema, data); diff --git a/src/services/git.ts b/src/services/git.ts index 0912621..28e402b 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -4,6 +4,16 @@ import { Leap0Transport, jsonBody } from "@/core/transport.js"; import { gitCommitResultSchema, gitResultSchema } from "@/models/git.js"; import { sandboxIdOf } from "@/core/utils.js"; +type JsonObject = Record; + +function compact(obj: JsonObject): JsonObject { + const result: JsonObject = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) result[key] = value; + } + return result; +} + /** Runs git operations inside a sandbox repository. */ export class GitClient { constructor(private readonly transport: Leap0Transport) {} @@ -23,90 +33,191 @@ export class GitClient { async clone( sandbox: SandboxRef, - url: string, - path: string, + params: { + url: string; + path: string; + branch?: string; + commitId?: string; + depth?: number; + username?: string; + password?: string; + }, options?: RequestOptions, ): Promise { - return normalize(gitResultSchema, await this.json(sandbox, "clone", { url, path }, options)); + return normalize( + gitResultSchema, + await this.json( + sandbox, + "clone", + compact({ + url: params.url, + path: params.path, + branch: params.branch, + commit_id: params.commitId, + depth: params.depth, + username: params.username, + password: params.password, + }), + options, + ), + ); } + async status(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return normalize(gitResultSchema, await this.json(sandbox, "status", { path }, options)); } + async branches( sandbox: SandboxRef, - path: string, - branchType: "local" | "remote" | "all" = "local", + params: { + path: string; + branchType?: "local" | "remote" | "all"; + contains?: string; + notContains?: string; + }, options?: RequestOptions, ): Promise { return normalize( gitResultSchema, - await this.json(sandbox, "branches", { path, branch_type: branchType }, options), + await this.json( + sandbox, + "branches", + compact({ + path: params.path, + branch_type: params.branchType ?? "local", + contains: params.contains, + not_contains: params.notContains, + }), + options, + ), ); } + async diffUnstaged( sandbox: SandboxRef, path: string, + contextLines?: number, options?: RequestOptions, ): Promise { - return normalize(gitResultSchema, await this.json(sandbox, "diff-unstaged", { path }, options)); + return normalize( + gitResultSchema, + await this.json(sandbox, "diff-unstaged", compact({ path, context_lines: contextLines }), options), + ); } + async diffStaged( sandbox: SandboxRef, path: string, + contextLines?: number, options?: RequestOptions, ): Promise { - return normalize(gitResultSchema, await this.json(sandbox, "diff-staged", { path }, options)); + return normalize( + gitResultSchema, + await this.json(sandbox, "diff-staged", compact({ path, context_lines: contextLines }), options), + ); } - async diff(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { - return normalize(gitResultSchema, await this.json(sandbox, "diff", { path }, options)); + + async diff( + sandbox: SandboxRef, + path: string, + target: string, + contextLines?: number, + options?: RequestOptions, + ): Promise { + return normalize( + gitResultSchema, + await this.json(sandbox, "diff", compact({ path, target, context_lines: contextLines }), options), + ); } + async reset(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { return normalize(gitResultSchema, await this.json(sandbox, "reset", { path }, options)); } + async log( sandbox: SandboxRef, - path: string, - maxCount?: number, + params: { + path: string; + maxCount?: number; + startTimestamp?: string; + endTimestamp?: string; + }, options?: RequestOptions, ): Promise { return normalize( gitResultSchema, - await this.json(sandbox, "log", { path, max_count: maxCount }, options), + await this.json( + sandbox, + "log", + compact({ + path: params.path, + max_count: params.maxCount, + start_timestamp: params.startTimestamp, + end_timestamp: params.endTimestamp, + }), + options, + ), ); } + async show( sandbox: SandboxRef, path: string, - ref: string, + revision = "HEAD", options?: RequestOptions, ): Promise { return normalize( gitResultSchema, - await this.json(sandbox, "show", { path, revision: ref }, options), + await this.json(sandbox, "show", { path, revision }, options), ); } + async createBranch( sandbox: SandboxRef, - path: string, - name: string, + params: { + path: string; + name: string; + checkout?: boolean; + baseBranch?: string; + }, options?: RequestOptions, ): Promise { return normalize( gitResultSchema, - await this.json(sandbox, "create-branch", { path, name }, options), + await this.json( + sandbox, + "create-branch", + compact({ + path: params.path, + name: params.name, + checkout: params.checkout, + base_branch: params.baseBranch, + }), + options, + ), ); } + async checkoutBranch( sandbox: SandboxRef, - path: string, - name: string, + params: { + path: string; + branch: string; + create?: boolean; + }, options?: RequestOptions, ): Promise { return normalize( gitResultSchema, - await this.json(sandbox, "checkout-branch", { path, branch: name }, options), + await this.json( + sandbox, + "checkout-branch", + compact({ path: params.path, branch: params.branch, create: params.create }), + options, + ), ); } + async deleteBranch( sandbox: SandboxRef, path: string, @@ -119,6 +230,7 @@ export class GitClient { await this.json(sandbox, "delete-branch", { path, name, force }, options), ); } + async add( sandbox: SandboxRef, path: string, @@ -127,23 +239,94 @@ export class GitClient { ): Promise { return normalize(gitResultSchema, await this.json(sandbox, "add", { path, files }, options)); } + async commit( sandbox: SandboxRef, - path: string, - message: string, - author: string, - email: string, + params: { + path: string; + message: string; + author?: string; + email?: string; + allowEmpty?: boolean; + }, options?: RequestOptions, ): Promise { return normalize( gitCommitResultSchema, - await this.json(sandbox, "commit", { path, message, author, email }, options), + await this.json( + sandbox, + "commit", + compact({ + path: params.path, + message: params.message, + author: params.author, + email: params.email, + allow_empty: params.allowEmpty, + }), + options, + ), ); } - async push(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { - return normalize(gitResultSchema, await this.json(sandbox, "push", { path }, options)); + + async push( + sandbox: SandboxRef, + params: { + path: string; + remote?: string; + branch?: string; + setUpstream?: boolean; + username?: string; + password?: string; + }, + options?: RequestOptions, + ): Promise { + return normalize( + gitResultSchema, + await this.json( + sandbox, + "push", + compact({ + path: params.path, + remote: params.remote, + branch: params.branch, + set_upstream: params.setUpstream, + username: params.username, + password: params.password, + }), + options, + ), + ); } - async pull(sandbox: SandboxRef, path: string, options?: RequestOptions): Promise { - return normalize(gitResultSchema, await this.json(sandbox, "pull", { path }, options)); + + async pull( + sandbox: SandboxRef, + params: { + path: string; + remote?: string; + branch?: string; + rebase?: boolean; + setUpstream?: boolean; + username?: string; + password?: string; + }, + options?: RequestOptions, + ): Promise { + return normalize( + gitResultSchema, + await this.json( + sandbox, + "pull", + compact({ + path: params.path, + remote: params.remote, + branch: params.branch, + rebase: params.rebase, + set_upstream: params.setUpstream, + username: params.username, + password: params.password, + }), + options, + ), + ); } } diff --git a/src/services/index.ts b/src/services/index.ts index 241708a..dfa8b35 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -5,7 +5,7 @@ export { GitClient } from "@/services/git.js"; export { LspClient } from "@/services/lsp.js"; export { ProcessClient } from "@/services/process.js"; export { PtyClient, PtyConnection } from "@/services/pty.js"; -export { SandboxesClient } from "@/services/sandboxes.js"; +export { SandboxesClient, type SandboxFactory } from "@/services/sandboxes.js"; export { SnapshotsClient } from "@/services/snapshots.js"; export { SshClient } from "@/services/ssh.js"; export { TemplatesClient } from "@/services/templates.js"; diff --git a/src/services/lsp.ts b/src/services/lsp.ts index 661a295..7a5d317 100644 --- a/src/services/lsp.ts +++ b/src/services/lsp.ts @@ -55,14 +55,20 @@ export class LspClient { languageId: string, pathToProject: string, uri: string, - text: string, + text?: string, version = 1, options?: RequestOptions, - ): Promise { - return await this.json( - sandbox, - "did-open", - { language_id: languageId, path_to_project: pathToProject, uri, text, version }, + ): Promise { + const payload: Record = { + language_id: languageId, + path_to_project: pathToProject, + uri, + version, + }; + if (text !== undefined) payload.text = text; + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/lsp/did-open`, + { method: "POST", body: jsonBody(payload) }, options, ); } @@ -71,11 +77,11 @@ export class LspClient { languageId: string, pathToProject: string, path: string, - text: string, + text?: string, version = 1, options?: RequestOptions, - ): Promise { - return await this.didOpen(sandbox, languageId, pathToProject, toFileUri(path), text, version, options); + ): Promise { + await this.didOpen(sandbox, languageId, pathToProject, toFileUri(path), text, version, options); } async didClose( sandbox: SandboxRef, @@ -83,11 +89,10 @@ export class LspClient { pathToProject: string, uri: string, options?: RequestOptions, - ): Promise { - return await this.json( - sandbox, - "did-close", - { language_id: languageId, path_to_project: pathToProject, uri }, + ): Promise { + await this.transport.request( + `/v1/sandbox/${sandboxIdOf(sandbox)}/lsp/did-close`, + { method: "POST", body: jsonBody({ language_id: languageId, path_to_project: pathToProject, uri }) }, options, ); } @@ -97,8 +102,8 @@ export class LspClient { pathToProject: string, path: string, options?: RequestOptions, - ): Promise { - return await this.didClose(sandbox, languageId, pathToProject, toFileUri(path), options); + ): Promise { + await this.didClose(sandbox, languageId, pathToProject, toFileUri(path), options); } async completions( sandbox: SandboxRef, diff --git a/src/services/pty.ts b/src/services/pty.ts index 127ab2d..62c4f95 100644 --- a/src/services/pty.ts +++ b/src/services/pty.ts @@ -78,7 +78,7 @@ export class PtyClient { { method: "POST", body: jsonBody({ - id: params.id, + id: params.sessionId, cwd: params.cwd, envs: params.envs, cols: params.cols, @@ -122,12 +122,13 @@ export class PtyClient { cols: number, rows: number, options: RequestOptions = {}, - ): Promise { - await this.transport.request( + ): Promise { + const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/pty/${encodeURIComponent(sessionId)}/resize`, { method: "POST", body: jsonBody({ cols, rows }) }, options, ); + return normalize(ptySessionSchema, data); } websocketUrl(sandbox: SandboxRef, sessionId: string): string { @@ -141,6 +142,8 @@ export class PtyClient { } connect(sandbox: SandboxRef, sessionId: string): PtyConnection { - return new PtyConnection(new WebSocket(this.websocketUrl(sandbox, sessionId))); + return new PtyConnection( + new WebSocket(this.websocketUrl(sandbox, sessionId), { headers: this.websocketHeaders() } as never), + ); } } diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 04141db..975c10f 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -54,15 +54,29 @@ function injectOtelEnv( return merged; } +export type SandboxFactory = (data: SandboxData) => T; + /** Creates, fetches, pauses, and deletes sandboxes. */ -export class SandboxesClient { - constructor(private readonly transport: Leap0Transport) {} +export class SandboxesClient { + private readonly sandboxFactory?: SandboxFactory; + + constructor(transport: Leap0Transport, sandboxFactory?: SandboxFactory); + constructor( + private readonly transport: Leap0Transport, + sandboxFactory?: SandboxFactory, + ) { + this.sandboxFactory = sandboxFactory; + } + + private wrap(data: SandboxData): T { + return (this.sandboxFactory ? this.sandboxFactory(data) : data) as T; + } /** Creates a sandbox from a template and resource config. */ async create( params: CreateSandboxParams = {}, options: RequestOptions = {}, - ): Promise { + ): Promise { const templateName = (params.templateName ?? DEFAULT_TEMPLATE_NAME).trim(); const vcpu = params.vcpu ?? DEFAULT_VCPU; const memoryMib = params.memoryMib ?? DEFAULT_MEMORY_MIB; @@ -101,31 +115,31 @@ export class SandboxesClient { { method: "POST", body: jsonBody(payload) }, options, ); - return normalize(sandboxDataSchema, data); + return this.wrap(normalize(sandboxDataSchema, data)); }); } /** Pauses a running sandbox. */ - async pause(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + async pause(sandbox: SandboxRef, options: RequestOptions = {}): Promise { return withErrorPrefix("Failed to pause sandbox: ", async () => { const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/pause`, { method: "POST" }, options, ); - return normalize(sandboxDataSchema, data); + return this.wrap(normalize(sandboxDataSchema, data)); }); } /** Fetches a sandbox by ID. */ - async get(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + async get(sandbox: SandboxRef, options: RequestOptions = {}): Promise { return withErrorPrefix("Failed to get sandbox: ", async () => { const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/`, { method: "GET" }, options, ); - return normalize(sandboxDataSchema, data); + return this.wrap(normalize(sandboxDataSchema, data)); }); } diff --git a/src/services/snapshots.ts b/src/services/snapshots.ts index 4a8f6ea..6360c57 100644 --- a/src/services/snapshots.ts +++ b/src/services/snapshots.ts @@ -14,9 +14,18 @@ import { Leap0Transport, jsonBody } from "@/core/transport.js"; import { sandboxIdOf, snapshotIdOf } from "@/core/utils.js"; import { withErrorPrefix } from "@/services/shared.js"; +import type { SandboxFactory } from "@/services/sandboxes.js"; + /** Creates, restores, and deletes named snapshots. */ -export class SnapshotsClient { - constructor(private readonly transport: Leap0Transport) {} +export class SnapshotsClient { + constructor( + private readonly transport: Leap0Transport, + private readonly sandboxFactory?: SandboxFactory, + ) {} + + private wrap(data: SandboxData): T { + return (this.sandboxFactory ? this.sandboxFactory(data) : data) as T; + } /** Creates a snapshot from a running sandbox. */ async create( @@ -51,7 +60,7 @@ export class SnapshotsClient { } /** Restores a sandbox from a snapshot. */ - async resume(params: ResumeSnapshotParams, options: RequestOptions = {}): Promise { + async resume(params: ResumeSnapshotParams, options: RequestOptions = {}): Promise { return withErrorPrefix("Failed to resume snapshot: ", async () => { const data = await this.transport.requestJson( "/v1/snapshot/resume", @@ -66,7 +75,7 @@ export class SnapshotsClient { }, options, ); - return normalize(sandboxDataSchema, data); + return this.wrap(normalize(sandboxDataSchema, data)); }); } diff --git a/tests/client/client-sandbox.test.ts b/tests/client/client-sandbox.test.ts index 88e5bb0..9fe0d4b 100644 --- a/tests/client/client-sandbox.test.ts +++ b/tests/client/client-sandbox.test.ts @@ -2,9 +2,10 @@ import assert from "node:assert/strict"; import { expectTypeOf, test } from "vitest"; import { Leap0Client, Sandbox } from "@/client/index.js"; +import { SERVICES } from "@/client/sandbox.js"; import type { RequestOptions } from "@/models/index.js"; -test("Leap0Client wires services and supports convenience methods", async () => { +test("Leap0Client wires services and supports direct access", async () => { process.env.LEAP0_API_KEY = "env-key"; const client = new Leap0Client({ apiKey: "explicit-key", sandboxDomain: "sandbox.example.com" }); const originalGet = client.sandboxes.get; @@ -30,8 +31,8 @@ test("Leap0Client wires services and supports convenience methods", async () => createdAt: "2026-01-01T00:00:00Z", })) as never; try { - assert.equal((await client.getSandbox("sb-1")).id, "sb-1"); - assert.equal((await client.createSandbox()).id, "sb-2"); + assert.equal((await client.sandboxes.get("sb-1")).id, "sb-1"); + assert.equal((await client.sandboxes.create()).id, "sb-2"); } finally { client.sandboxes.get = originalGet; client.sandboxes.create = originalCreate; @@ -66,16 +67,18 @@ test("Sandbox binds service methods to itself", async () => { invokeUrl: (id: string, path: string, port?: number) => `invoke:${id}:${path}:${port ?? ""}`, websocketUrl: (id: string, path: string, port?: number) => `ws:${id}:${path}:${port ?? ""}`, }, - filesystem: { - readFile: async (sandbox: { id: string }, path: string) => `${sandbox.id}:${path}`, + [SERVICES]: { + filesystem: { + readFile: async (sandbox: { id: string }, path: string) => `${sandbox.id}:${path}`, + }, + git: {}, + process: {}, + pty: {}, + lsp: {}, + ssh: {}, + codeInterpreter: {}, + desktop: {}, }, - git: {}, - process: {}, - pty: {}, - lsp: {}, - ssh: {}, - codeInterpreter: {}, - desktop: {}, }; const sandbox = new Sandbox(fakeClient as never, { id: "sb-1", @@ -98,8 +101,6 @@ test("Sandbox binds service methods to itself", async () => { }); test("client and sandbox helpers stay strongly typed", () => { - expectTypeOf>().toEqualTypeOf>(); - expectTypeOf>().toEqualTypeOf>(); expectTypeOf>().toMatchTypeOf< Promise<{ id: string }> >(); @@ -110,35 +111,7 @@ test("client and sandbox helpers stay strongly typed", () => { expectTypeOf().parameters.toEqualTypeOf< [params: { command: string; cwd?: string; timeout?: number }, options?: RequestOptions] >(); - expectTypeOf().parameters.toEqualTypeOf< - [path: string, content: string, options?: RequestOptions] - >(); - expectTypeOf().parameters.toEqualTypeOf< - [url: string, path: string, options?: RequestOptions] - >(); - expectTypeOf().parameters.toEqualTypeOf< - [ - params?: { - id?: string; - cols?: number; - rows?: number; - cwd?: string; - envs?: Record; - lazyStart?: boolean; - }, - options?: RequestOptions, - ] - >(); - expectTypeOf().parameters.toEqualTypeOf< - [timeout?: number] - >(); expectTypeOf().parameters.toEqualTypeOf< [accessId: string, password: string, options?: RequestOptions] >(); - expectTypeOf().parameters.toEqualTypeOf< - [ - params: { code: string; language: "python" | "typescript"; contextId?: string }, - options?: RequestOptions, - ] - >(); }); diff --git a/tests/services/code-interpreter.test.ts b/tests/services/code-interpreter.test.ts index c24dce0..d22630c 100644 --- a/tests/services/code-interpreter.test.ts +++ b/tests/services/code-interpreter.test.ts @@ -25,6 +25,8 @@ test("code interpreter client sends expected request shapes", async () => { await client.getContext("sb-1", "ctx-1"); await client.deleteContext("sb-1", "ctx-1"); await client.execute("sb-1", { code: "print(1)", language: "python", contextId: "ctx-1" }); + const healthResult = await client.health("sb-1"); + assert.equal(typeof healthResult, "boolean"); assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/healthz"); assert.equal(calls[1]?.url, "https://sb-1.sandbox.example.com/contexts"); assert.deepEqual(jsonOf(calls[1]!), { language: "python" }); diff --git a/tests/services/desktop.test.ts b/tests/services/desktop.test.ts index 8446ab1..ae8898b 100644 --- a/tests/services/desktop.test.ts +++ b/tests/services/desktop.test.ts @@ -46,14 +46,13 @@ test("desktop client sends expected request shapes", async () => { }, }); const client = new DesktopClient(transport as never); - await client.display("sb-1"); - await client.setScreen("sb-1", { width: 1280, height: 720 }); + await client.displayInfo("sb-1"); + await client.resizeScreen("sb-1", { width: 1280, height: 720 }); await client.movePointer("sb-1", 10, 20); await client.typeText("sb-1", "hello"); - await client.processStatus("sb-1", "x11vnc"); + await client.getProcess("sb-1", "x11vnc"); await client.drag("sb-1", { fromX: 1, fromY: 2, toX: 3, toY: 4 }); await client.scroll("sb-1", { direction: "down", amount: 2 }); - await client.waitUntilReady("sb-1", 1); assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/api/display"); assert.equal(calls[1]?.url, "https://sb-1.sandbox.example.com/api/display/screen"); assert.deepEqual(jsonOf(calls[1]!), { width: 1280, height: 720 }); @@ -80,5 +79,5 @@ test("desktop statusStream parses SSE and raises API errors", async () => { { status: "running", items: [{ name: "x11vnc", running: true }], running: 1, total: 1 }, ]); assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/api/status/stream"); - await assert.rejects(() => client.health("sb-1"), Leap0Error); + // health endpoint tested separately, it accepts 503 gracefully }); diff --git a/tests/services/filesystem.test.ts b/tests/services/filesystem.test.ts index 1448dfc..9c8d483 100644 --- a/tests/services/filesystem.test.ts +++ b/tests/services/filesystem.test.ts @@ -40,19 +40,20 @@ test("filesystem client sends expected request shapes", async () => { await client.readFile("sb-1", "/tmp/a.txt", { head: 10 }); await client.readBytes("sb-1", "/tmp/b.bin"); await client.delete("sb-1", "/tmp/a.txt"); - await client.setPermissions("sb-1", "/tmp/a.txt", "0755"); + await client.setPermissions("sb-1", "/tmp/a.txt", { mode: "0755" }); await client.glob("sb-1", "/workspace", "**/*.ts"); await client.grep("sb-1", "/workspace", "todo"); - await client.editFile("sb-1", "/tmp/a.txt", { find: "a", replace: "b" }); - await client.editFiles("sb-1", [{ path: "/tmp/a.txt", edit: { find: "a", replace: "b" } }]); + await client.editFile("sb-1", "/tmp/a.txt", [{ find: "a", replace: "b" }]); + await client.editFiles("sb-1", { paths: ["/tmp/a.txt"], find: "a", replace: "b" }); await client.move("sb-1", "/tmp/a", "/tmp/b"); await client.copy("sb-1", "/tmp/b", "/tmp/c"); - await client.exists("sb-1", "/tmp/c"); - await client.tree("sb-1", "/workspace", 2); + const fileExists = await client.exists("sb-1", "/tmp/c"); + await client.tree("sb-1", "/workspace", { maxDepth: 2 }); assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/filesystem/ls"); assert.deepEqual(jsonOf(calls[0]!), { path: "/workspace" }); - assert.equal(new Headers(calls[3]!.init.headers).get("content-type"), "text/plain"); + // writeFile goes through writeBytes, both use octet-stream + assert.equal(new Headers(calls[3]!.init.headers).get("content-type"), "application/octet-stream"); assert.equal(calls[3]?.options.query?.path, "/tmp/a.txt"); assert.equal(new Headers(calls[4]!.init.headers).get("content-type"), "application/octet-stream"); assert.deepEqual(jsonOf(calls[5]!), { path: "/tmp/a.txt", head: 10 }); @@ -60,7 +61,13 @@ test("filesystem client sends expected request shapes", async () => { path: "/tmp/a.txt", edits: [{ find: "a", replace: "b" }], }); - assert.deepEqual(jsonOf(calls[13]!), { src_path: "/tmp/a", dst_path: "/tmp/b" }); - assert.deepEqual(jsonOf(calls[14]!), { src_path: "/tmp/b", dst_path: "/tmp/c" }); + assert.deepEqual(jsonOf(calls[12]!), { + files: ["/tmp/a.txt"], + find: "a", + replace: "b", + }); + assert.deepEqual(jsonOf(calls[13]!), { src_path: "/tmp/a", dst_path: "/tmp/b", overwrite: false }); + assert.equal(typeof fileExists, "boolean"); + assert.equal(fileExists, true); assert.deepEqual(jsonOf(calls[16]!), { path: "/workspace", max_depth: 2 }); }); diff --git a/tests/services/git.test.ts b/tests/services/git.test.ts index 76175fe..8269ecb 100644 --- a/tests/services/git.test.ts +++ b/tests/services/git.test.ts @@ -14,28 +14,29 @@ test("git client sends expected request shapes", async () => { }, }); const client = new GitClient(transport as never); - await client.clone("sb-1", "https://example.com/repo.git", "/workspace/repo"); + await client.clone("sb-1", { url: "https://example.com/repo.git", path: "/workspace/repo" }); await client.status("sb-1", "/workspace/repo"); - await client.branches("sb-1", "/workspace/repo"); + await client.branches("sb-1", { path: "/workspace/repo" }); await client.diffUnstaged("sb-1", "/workspace/repo"); await client.diffStaged("sb-1", "/workspace/repo"); - await client.diff("sb-1", "/workspace/repo"); + await client.diff("sb-1", "/workspace/repo", "main"); await client.reset("sb-1", "/workspace/repo"); - await client.log("sb-1", "/workspace/repo"); + await client.log("sb-1", { path: "/workspace/repo" }); await client.show("sb-1", "/workspace/repo", "HEAD"); - await client.createBranch("sb-1", "/workspace/repo", "feat"); - await client.checkoutBranch("sb-1", "/workspace/repo", "feat"); + await client.createBranch("sb-1", { path: "/workspace/repo", name: "feat" }); + await client.checkoutBranch("sb-1", { path: "/workspace/repo", branch: "feat" }); await client.deleteBranch("sb-1", "/workspace/repo", "feat"); await client.add("sb-1", "/workspace/repo", ["a.ts"]); - await client.commit("sb-1", "/workspace/repo", "msg", "Test User", "test@example.com"); - await client.push("sb-1", "/workspace/repo"); - await client.pull("sb-1", "/workspace/repo"); + await client.commit("sb-1", { path: "/workspace/repo", message: "msg", author: "Test User", email: "test@example.com" }); + await client.push("sb-1", { path: "/workspace/repo" }); + await client.pull("sb-1", { path: "/workspace/repo" }); assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/git/clone"); assert.deepEqual(jsonOf(calls[0]!), { url: "https://example.com/repo.git", path: "/workspace/repo", }); + assert.deepEqual(jsonOf(calls[5]!), { path: "/workspace/repo", target: "main" }); assert.deepEqual(jsonOf(calls[8]!), { path: "/workspace/repo", revision: "HEAD" }); assert.deepEqual(jsonOf(calls[12]!), { path: "/workspace/repo", files: ["a.ts"] }); assert.deepEqual(jsonOf(calls[13]!), { diff --git a/tests/services/normalization-cleanup.test.ts b/tests/services/normalization-cleanup.test.ts index d6b8af5..dc8fe90 100644 --- a/tests/services/normalization-cleanup.test.ts +++ b/tests/services/normalization-cleanup.test.ts @@ -64,7 +64,7 @@ test("filesystem client normalizes nested snake_case fields", async () => { }), }); - const result = await new FilesystemClient(transport as never).ls("sb-1"); + const result = await new FilesystemClient(transport as never).ls("sb-1", "/workspace"); assert.equal(result.items[0]?.isDir, true); assert.equal(result.items[0]?.mode, "755"); diff --git a/tests/services/pty.test.ts b/tests/services/pty.test.ts index 8bd63e6..034d85b 100644 --- a/tests/services/pty.test.ts +++ b/tests/services/pty.test.ts @@ -24,7 +24,7 @@ test("pty client sends expected request shapes", async () => { }); const client = new PtyClient(transport as never); await client.list("sb-1"); - await client.create("sb-1", { cols: 80, rows: 24, cwd: "/workspace" }); + await client.create("sb-1", { sessionId: "sess-1", cols: 80, rows: 24, cwd: "/workspace" }); await client.get("sb-1", "sess-1"); await client.resize("sb-1", "sess-1", 120, 40); await client.delete("sb-1", "sess-1"); From 0db78996b99c9b54c8ac05a90f166c75a159b048 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Fri, 3 Apr 2026 13:41:27 -0400 Subject: [PATCH 4/9] fixes --- examples/filesystem_and_git.ts | 5 +- examples/quickstart.ts | 7 +- src/client/sandbox.ts | 40 +++++++++++- src/config/index.ts | 18 ++++-- src/core/otel.ts | 13 +++- src/services/code-interpreter.ts | 13 +++- src/services/desktop.ts | 16 ++++- src/services/filesystem.ts | 68 +++++++++++++------- src/services/git.ts | 21 +++++- src/services/lsp.ts | 16 +++-- src/services/pty.ts | 4 +- src/services/sandboxes.ts | 5 +- src/services/templates.ts | 14 ++-- tests/client/client-sandbox.test.ts | 42 +++++++++--- tests/core/normalize.test.ts | 10 +++ tests/quality/docstrings.test.ts | 2 +- tests/services/desktop.test.ts | 11 ++++ tests/services/filesystem.test.ts | 17 +++-- tests/services/git.test.ts | 27 ++++++-- tests/services/lsp.test.ts | 21 +++++- tests/services/normalization-cleanup.test.ts | 37 +++++++---- tests/services/process.test.ts | 1 + tests/services/sandboxes-client.test.ts | 20 +++++- tests/services/ssh.test.ts | 1 + 24 files changed, 331 insertions(+), 98 deletions(-) diff --git a/examples/filesystem_and_git.ts b/examples/filesystem_and_git.ts index c5fa243..4674d43 100644 --- a/examples/filesystem_and_git.ts +++ b/examples/filesystem_and_git.ts @@ -8,7 +8,10 @@ async function main(): Promise { const sandbox = await client.sandboxes.create(); try { - const clone = await sandbox.git.clone({ url: "https://github.com/octocat/Hello-World.git", path: repoPath }); + const clone = await sandbox.git.clone({ + url: "https://github.com/octocat/Hello-World.git", + path: repoPath, + }); console.log("clone exit:", clone.exitCode); const status = await sandbox.git.status(repoPath); diff --git a/examples/quickstart.ts b/examples/quickstart.ts index 64be97e..2cc065c 100644 --- a/examples/quickstart.ts +++ b/examples/quickstart.ts @@ -2,15 +2,18 @@ import { Leap0Client } from "../src/index.js"; async function main(): Promise { const client = new Leap0Client(); - const sandbox = await client.sandboxes.create(); + let sandbox: Awaited> | undefined; try { + sandbox = await client.sandboxes.create(); const result = await sandbox.process.execute({ command: "echo hello from leap0" }); console.log("sandbox:", sandbox.id); console.log("exit code:", result.exitCode); console.log("result:", result.result.trim()); } finally { - await sandbox.delete(); + if (sandbox) { + await sandbox.delete(); + } await client.close(); } } diff --git a/src/client/sandbox.ts b/src/client/sandbox.ts index 9e5a544..a1f970f 100644 --- a/src/client/sandbox.ts +++ b/src/client/sandbox.ts @@ -15,6 +15,41 @@ import type { Leap0Client } from "@/client/index.js"; /** @internal Symbol used to access service clients on Leap0Client. */ export const SERVICES = Symbol.for("leap0.services"); +function isSandboxData(value: unknown): value is SandboxData { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.templateId === "string" && + typeof record.state === "string" && + typeof record.vcpu === "number" && + typeof record.memoryMib === "number" && + typeof record.diskMib === "number" && + typeof record.createdAt === "string" + ); +} + +function unwrapSandboxData(value: unknown): SandboxData { + if (value instanceof Sandbox || isSandboxData(value)) { + return value; + } + if (value && typeof value === "object") { + const record = value as { data?: unknown; toJSON?: () => unknown }; + if (isSandboxData(record.data)) { + return record.data; + } + if (typeof record.toJSON === "function") { + const json = record.toJSON(); + if (isSandboxData(json)) { + return json; + } + } + } + throw new TypeError("Expected this.client.sandboxes.get() to return SandboxData or Sandbox"); +} + type BoundSandboxMethod = Method extends ( sandbox: infer _Sandbox, ...args: infer Args @@ -115,7 +150,8 @@ export class Sandbox implements SandboxData { * The refreshed sandbox handle. */ async refresh(): Promise { - this.update(await this.client.sandboxes.get(this.id) as SandboxData); + const latest = await this.client.sandboxes.get(this.id); + this.update(unwrapSandboxData(latest)); return this; } @@ -129,7 +165,7 @@ export class Sandbox implements SandboxData { * The paused sandbox handle. */ async pause(options?: { timeout?: number }): Promise { - this.update(await this.client.sandboxes.pause(this.id, options) as SandboxData); + this.update((await this.client.sandboxes.pause(this.id, options)) as SandboxData); return this; } diff --git a/src/config/index.ts b/src/config/index.ts index 85f9654..383d380 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -22,6 +22,16 @@ function requireNonEmpty(value: string | undefined, label: string): string { return value.trim(); } +function resolveSdkOtelFromEnv(envOtel: string | undefined): boolean { + if (envOtel === "true") { + return true; + } + if (envOtel === "false") { + return false; + } + return Boolean(readEnv("OTEL_EXPORTER_OTLP_ENDPOINT")); +} + /** * Resolves SDK configuration from explicit input and environment variables. * @@ -45,13 +55,7 @@ export function resolveConfig(input: Leap0ConfigInput = {}): Leap0ConfigResolved const authHeader = (input.authHeader ?? "authorization").trim(); const bearer = input.bearer ?? true; const envOtel = readEnv("LEAP0_SDK_OTEL_ENABLED"); - const sdkOtelEnabled = - input.sdkOtelEnabled ?? - (envOtel === "true" - ? true - : envOtel === "false" - ? false - : Boolean(readEnv("OTEL_EXPORTER_OTLP_ENDPOINT"))); + const sdkOtelEnabled = input.sdkOtelEnabled ?? resolveSdkOtelFromEnv(envOtel); if (!Number.isFinite(timeout) || timeout <= 0) { throw new Leap0Error("timeout must be a positive number"); diff --git a/src/core/otel.ts b/src/core/otel.ts index df56f12..633a45b 100644 --- a/src/core/otel.ts +++ b/src/core/otel.ts @@ -58,10 +58,19 @@ export function initOtel(_config: Leap0ConfigResolved): void { * A promise that resolves once providers are flushed and shut down. */ export async function shutdownOtel(): Promise { - await tracerProviderInstance?.shutdown(); - await meterProviderInstance?.shutdown(); + const tracerProvider = tracerProviderInstance; + const meterProvider = meterProviderInstance; tracerProviderInstance = undefined; meterProviderInstance = undefined; + + await Promise.all([ + tracerProvider?.shutdown().catch((error) => { + console.warn("Failed to shutdown OpenTelemetry tracer provider", error); + }), + meterProvider?.shutdown().catch((error) => { + console.warn("Failed to shutdown OpenTelemetry meter provider", error); + }), + ]); } /** diff --git a/src/services/code-interpreter.ts b/src/services/code-interpreter.ts index 87b9096..83ffc4f 100644 --- a/src/services/code-interpreter.ts +++ b/src/services/code-interpreter.ts @@ -32,11 +32,20 @@ export class CodeInterpreterClient { init: RequestInit = {}, options: RequestOptions = {}, ): Promise { - return (await this.transport.requestJsonUrl(this.requestPath(sandbox, path), init, options))!; + return (await this.transport.requestJsonUrl( + this.requestPath(sandbox, path), + init, + options, + ))!; } async health(sandbox: SandboxRef, options: RequestOptions = {}): Promise { - const data = await this.fetchJson>(sandbox, "/healthz", { method: "GET" }, options); + const data = await this.fetchJson>( + sandbox, + "/healthz", + { method: "GET" }, + options, + ); return data?.status === "ok"; } async createContext( diff --git a/src/services/desktop.ts b/src/services/desktop.ts index a4c4e41..3d4b942 100644 --- a/src/services/desktop.ts +++ b/src/services/desktop.ts @@ -313,7 +313,10 @@ export class DesktopClient { { ...options, expectedStatus: [200, 503] }, ); } - async processStatus(sandbox: SandboxRef, options?: RequestOptions): Promise { + async processStatus( + sandbox: SandboxRef, + options?: RequestOptions, + ): Promise { return this.requestJson( sandbox, desktopProcessStatusListSchema, @@ -409,8 +412,17 @@ export class DesktopClient { if (status.status === "running") { return; } + if ( + typeof status.running === "number" && + typeof status.total === "number" && + status.running >= status.total + ) { + return; + } } - // Stream ended without reaching running, retry + const retryDelay = deadline - Date.now(); + if (retryDelay <= 0) break; + await new Promise((resolve) => setTimeout(resolve, Math.min(500, retryDelay))); } catch (error) { lastError = error; if (error instanceof Leap0Error && !error.retryable) throw error; diff --git a/src/services/filesystem.ts b/src/services/filesystem.ts index e001da5..cd10449 100644 --- a/src/services/filesystem.ts +++ b/src/services/filesystem.ts @@ -49,7 +49,10 @@ export class FilesystemClient { ): Promise { const data = await this.transport.requestJson( this.fsPath(sandbox, "ls"), - { method: "POST", body: jsonBody(compact({ path, recursive: params.recursive, exclude: params.exclude })) }, + { + method: "POST", + body: jsonBody(compact({ path, recursive: params.recursive, exclude: params.exclude })), + }, options, ); return normalize(lsResultSchema, data); @@ -74,7 +77,12 @@ export class FilesystemClient { ): Promise { await this.transport.request( this.fsPath(sandbox, "mkdir"), - { method: "POST", body: jsonBody(compact({ path, recursive: params.recursive, permissions: params.permissions })) }, + { + method: "POST", + body: jsonBody( + compact({ path, recursive: params.recursive, permissions: params.permissions }), + ), + }, options, ); } @@ -108,13 +116,7 @@ export class FilesystemClient { params: { permissions?: string } = {}, options: RequestOptions = {}, ): Promise { - await this.writeBytes( - sandbox, - path, - new TextEncoder().encode(content), - params, - options, - ); + await this.writeBytes(sandbox, path, new TextEncoder().encode(content), params, options); } /** Writes multiple files in a single request using multipart upload. */ @@ -236,7 +238,12 @@ export class FilesystemClient { ): Promise { await this.transport.request( this.fsPath(sandbox, "set-permissions"), - { method: "POST", body: jsonBody(compact({ path, mode: params.mode, owner: params.owner, group: params.group })) }, + { + method: "POST", + body: jsonBody( + compact({ path, mode: params.mode, owner: params.owner, group: params.group }), + ), + }, options, ); } @@ -267,7 +274,12 @@ export class FilesystemClient { ): Promise { const data = await this.transport.requestJson( this.fsPath(sandbox, "grep"), - { method: "POST", body: jsonBody(compact({ path, pattern, include: params.include, exclude: params.exclude })) }, + { + method: "POST", + body: jsonBody( + compact({ path, pattern, include: params.include, exclude: params.exclude }), + ), + }, options, ); return normalize(z.object({ items: z.array(searchMatchSchema) }), data).items; @@ -294,15 +306,16 @@ export class FilesystemClient { params: { paths: string[]; find: string; replace?: string }, options: RequestOptions = {}, ): Promise { + const payload = { + files: params.paths, + find: params.find, + ...(params.replace == null ? {} : { replace: params.replace }), + }; const data = await this.transport.requestJson( this.fsPath(sandbox, "edit-files"), { method: "POST", - body: jsonBody({ - files: params.paths, - find: params.find, - replace: params.replace ?? "", - }), + body: jsonBody(payload), }, options, ); @@ -334,17 +347,23 @@ export class FilesystemClient { ): Promise { await this.transport.request( this.fsPath(sandbox, "copy"), - { method: "POST", body: jsonBody(compact({ src_path: srcPath, dst_path: dstPath, recursive: params.recursive, overwrite: params.overwrite })) }, + { + method: "POST", + body: jsonBody( + compact({ + src_path: srcPath, + dst_path: dstPath, + recursive: params.recursive, + overwrite: params.overwrite, + }), + ), + }, options, ); } /** Checks whether a path exists in the sandbox. */ - async exists( - sandbox: SandboxRef, - path: string, - options: RequestOptions = {}, - ): Promise { + async exists(sandbox: SandboxRef, path: string, options: RequestOptions = {}): Promise { const data = await this.transport.requestJson( this.fsPath(sandbox, "exists"), { method: "POST", body: jsonBody({ path }) }, @@ -362,7 +381,10 @@ export class FilesystemClient { ): Promise { const data = await this.transport.requestJson( this.fsPath(sandbox, "tree"), - { method: "POST", body: jsonBody(compact({ path, max_depth: params.maxDepth, exclude: params.exclude })) }, + { + method: "POST", + body: jsonBody(compact({ path, max_depth: params.maxDepth, exclude: params.exclude })), + }, options, ); return normalize(treeResultSchema, data); diff --git a/src/services/git.ts b/src/services/git.ts index 28e402b..23c52a0 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -101,7 +101,12 @@ export class GitClient { ): Promise { return normalize( gitResultSchema, - await this.json(sandbox, "diff-unstaged", compact({ path, context_lines: contextLines }), options), + await this.json( + sandbox, + "diff-unstaged", + compact({ path, context_lines: contextLines }), + options, + ), ); } @@ -113,7 +118,12 @@ export class GitClient { ): Promise { return normalize( gitResultSchema, - await this.json(sandbox, "diff-staged", compact({ path, context_lines: contextLines }), options), + await this.json( + sandbox, + "diff-staged", + compact({ path, context_lines: contextLines }), + options, + ), ); } @@ -126,7 +136,12 @@ export class GitClient { ): Promise { return normalize( gitResultSchema, - await this.json(sandbox, "diff", compact({ path, target, context_lines: contextLines }), options), + await this.json( + sandbox, + "diff", + compact({ path, target, context_lines: contextLines }), + options, + ), ); } diff --git a/src/services/lsp.ts b/src/services/lsp.ts index 7a5d317..2b25a94 100644 --- a/src/services/lsp.ts +++ b/src/services/lsp.ts @@ -17,11 +17,16 @@ export class LspClient { body: unknown, options: RequestOptions = {}, ): Promise { - return (await this.transport.requestJson( - `/v1/sandbox/${sandboxIdOf(sandbox)}/lsp/${endpoint}`, + const path = `/v1/sandbox/${sandboxIdOf(sandbox)}/lsp/${endpoint}`; + const result = await this.transport.requestJson( + path, { method: "POST", body: jsonBody(body) }, options, - ))!; + ); + if (result == null) { + throw new Error(`Empty response from ${path}`); + } + return result; } async start( @@ -92,7 +97,10 @@ export class LspClient { ): Promise { await this.transport.request( `/v1/sandbox/${sandboxIdOf(sandbox)}/lsp/did-close`, - { method: "POST", body: jsonBody({ language_id: languageId, path_to_project: pathToProject, uri }) }, + { + method: "POST", + body: jsonBody({ language_id: languageId, path_to_project: pathToProject, uri }), + }, options, ); } diff --git a/src/services/pty.ts b/src/services/pty.ts index 62c4f95..0091402 100644 --- a/src/services/pty.ts +++ b/src/services/pty.ts @@ -143,7 +143,9 @@ export class PtyClient { connect(sandbox: SandboxRef, sessionId: string): PtyConnection { return new PtyConnection( - new WebSocket(this.websocketUrl(sandbox, sessionId), { headers: this.websocketHeaders() } as never), + new WebSocket(this.websocketUrl(sandbox, sessionId), { + headers: this.websocketHeaders(), + } as never), ); } } diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 975c10f..925d7bb 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -73,10 +73,7 @@ export class SandboxesClient { } /** Creates a sandbox from a template and resource config. */ - async create( - params: CreateSandboxParams = {}, - options: RequestOptions = {}, - ): Promise { + async create(params: CreateSandboxParams = {}, options: RequestOptions = {}): Promise { const templateName = (params.templateName ?? DEFAULT_TEMPLATE_NAME).trim(); const vcpu = params.vcpu ?? DEFAULT_VCPU; const memoryMib = params.memoryMib ?? DEFAULT_MEMORY_MIB; diff --git a/src/services/templates.ts b/src/services/templates.ts index d55212d..27082c1 100644 --- a/src/services/templates.ts +++ b/src/services/templates.ts @@ -17,25 +17,25 @@ export class TemplatesClient { /** Uploads a new template from a container image URI. */ async create(params: CreateTemplateParams, options: RequestOptions = {}): Promise { - createTemplateParamsSchema.parse(params); + const parsed = createTemplateParamsSchema.parse(params); if ( - !params.name.trim() || - params.name.length > 64 || - /\s/.test(params.name) || - params.name.startsWith("system/") + !parsed.name.trim() || + parsed.name.length > 64 || + /\s/.test(parsed.name) || + parsed.name.startsWith("system/") ) { throw new Leap0Error( "name must be non-empty, <= 64 chars, contain no whitespace, and not start with system/", ); } - if (!params.uri.trim() || params.uri.length > 500) { + if (!parsed.uri.trim() || parsed.uri.length > 500) { throw new Leap0Error("uri must be non-empty and <= 500 chars"); } return withErrorPrefix("Failed to create template: ", async () => { const data = await this.transport.requestJson( "/v1/template", - { method: "POST", body: jsonBody(params) }, + { method: "POST", body: jsonBody(parsed) }, options, ); return normalize(templateDataSchema, data); diff --git a/tests/client/client-sandbox.test.ts b/tests/client/client-sandbox.test.ts index 9fe0d4b..6432a21 100644 --- a/tests/client/client-sandbox.test.ts +++ b/tests/client/client-sandbox.test.ts @@ -6,6 +6,7 @@ import { SERVICES } from "@/client/sandbox.js"; import type { RequestOptions } from "@/models/index.js"; test("Leap0Client wires services and supports direct access", async () => { + const originalApiKey = process.env.LEAP0_API_KEY; process.env.LEAP0_API_KEY = "env-key"; const client = new Leap0Client({ apiKey: "explicit-key", sandboxDomain: "sandbox.example.com" }); const originalGet = client.sandboxes.get; @@ -37,22 +38,43 @@ test("Leap0Client wires services and supports direct access", async () => { client.sandboxes.get = originalGet; client.sandboxes.create = originalCreate; await client.close(); + if (originalApiKey === undefined) { + delete process.env.LEAP0_API_KEY; + } else { + process.env.LEAP0_API_KEY = originalApiKey; + } } }); test("Sandbox binds service methods to itself", async () => { + let wrapped = false; const fakeClient = { sandboxes: { - get: async () => ({ - id: "sb-1", - templateId: "tpl-1", - state: "running", - vcpu: 2, - memoryMib: 2048, - diskMib: 4096, - autoPause: false, - createdAt: "2026-01-01T00:00:00Z", - }), + get: async () => { + if (!wrapped) { + wrapped = true; + return new Sandbox(fakeClient as never, { + id: "sb-1", + templateId: "tpl-1", + state: "running", + vcpu: 2, + memoryMib: 2048, + diskMib: 4096, + autoPause: false, + createdAt: "2026-01-01T00:00:00Z", + }); + } + return { + id: "sb-1", + templateId: "tpl-1", + state: "running", + vcpu: 2, + memoryMib: 2048, + diskMib: 4096, + autoPause: false, + createdAt: "2026-01-01T00:00:00Z", + }; + }, pause: async () => ({ id: "sb-1", templateId: "tpl-1", diff --git a/tests/core/normalize.test.ts b/tests/core/normalize.test.ts index 987189b..91ad1fa 100644 --- a/tests/core/normalize.test.ts +++ b/tests/core/normalize.test.ts @@ -4,6 +4,8 @@ import { test } from "vitest"; import { camelizeKeys } from "@/core/normalize.js"; test("camelizeKeys converts nested API response keys", () => { + const createdAt = new Date("2026-01-01T00:00:00Z"); + const bytes = new Uint8Array([1, 2, 3]); const result = camelizeKeys({ template_id: "tpl-1", memory_mib: 1024, @@ -12,6 +14,8 @@ test("camelizeKeys converts nested API response keys", () => { transforms: [{ inject_headers: { authorization: "token" }, strip_headers: ["cookie"] }], }, snapshots: [{ created_at: "2026-01-01T00:00:00Z" }], + created_at_date: createdAt, + file_bytes: bytes, }); assert.deepEqual(result, { @@ -22,5 +26,11 @@ test("camelizeKeys converts nested API response keys", () => { transforms: [{ injectHeaders: { authorization: "token" }, stripHeaders: ["cookie"] }], }, snapshots: [{ createdAt: "2026-01-01T00:00:00Z" }], + createdAtDate: createdAt, + fileBytes: bytes, }); + assert.ok((result as { createdAtDate: unknown }).createdAtDate instanceof Date); + assert.equal((result as { createdAtDate: Date }).createdAtDate.getTime(), createdAt.getTime()); + assert.ok((result as { fileBytes: unknown }).fileBytes instanceof Uint8Array); + assert.deepEqual(Array.from((result as { fileBytes: Uint8Array }).fileBytes), [1, 2, 3]); }); diff --git a/tests/quality/docstrings.test.ts b/tests/quality/docstrings.test.ts index 788995c..85fab1a 100644 --- a/tests/quality/docstrings.test.ts +++ b/tests/quality/docstrings.test.ts @@ -13,7 +13,7 @@ const strictFiles = new Set([ ]); function rel(fileName: string): string { - return path.relative(rootDir, fileName); + return path.relative(rootDir, fileName).split(path.sep).join(path.posix.sep); } function jsDocText(node: ts.Node, sourceFile: ts.SourceFile): string { diff --git a/tests/services/desktop.test.ts b/tests/services/desktop.test.ts index ae8898b..7f3e0c4 100644 --- a/tests/services/desktop.test.ts +++ b/tests/services/desktop.test.ts @@ -81,3 +81,14 @@ test("desktop statusStream parses SSE and raises API errors", async () => { assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/api/status/stream"); // health endpoint tested separately, it accepts 503 gracefully }); + +test("desktop waitUntilReady treats count-only updates as ready", async () => { + const { transport } = createRecordedTransport({ + streamJsonUrl: async function* () { + yield { items: [{ name: "x11vnc", running: true }], running: 1, total: 1 }; + }, + }); + const client = new DesktopClient(transport as never); + + await client.waitUntilReady("sb-1", 1); +}); diff --git a/tests/services/filesystem.test.ts b/tests/services/filesystem.test.ts index 9c8d483..e6f61ef 100644 --- a/tests/services/filesystem.test.ts +++ b/tests/services/filesystem.test.ts @@ -41,10 +41,12 @@ test("filesystem client sends expected request shapes", async () => { await client.readBytes("sb-1", "/tmp/b.bin"); await client.delete("sb-1", "/tmp/a.txt"); await client.setPermissions("sb-1", "/tmp/a.txt", { mode: "0755" }); + await client.setPermissions("sb-1", "/tmp/b.txt", { owner: "alice", group: "staff" }); await client.glob("sb-1", "/workspace", "**/*.ts"); await client.grep("sb-1", "/workspace", "todo"); await client.editFile("sb-1", "/tmp/a.txt", [{ find: "a", replace: "b" }]); await client.editFiles("sb-1", { paths: ["/tmp/a.txt"], find: "a", replace: "b" }); + await client.editFiles("sb-1", { paths: ["/tmp/b.txt"], find: "x" }); await client.move("sb-1", "/tmp/a", "/tmp/b"); await client.copy("sb-1", "/tmp/b", "/tmp/c"); const fileExists = await client.exists("sb-1", "/tmp/c"); @@ -57,17 +59,24 @@ test("filesystem client sends expected request shapes", async () => { assert.equal(calls[3]?.options.query?.path, "/tmp/a.txt"); assert.equal(new Headers(calls[4]!.init.headers).get("content-type"), "application/octet-stream"); assert.deepEqual(jsonOf(calls[5]!), { path: "/tmp/a.txt", head: 10 }); - assert.deepEqual(jsonOf(calls[11]!), { + assert.deepEqual(jsonOf(calls[8]!), { path: "/tmp/a.txt", mode: "0755" }); + assert.deepEqual(jsonOf(calls[9]!), { path: "/tmp/b.txt", owner: "alice", group: "staff" }); + assert.deepEqual(jsonOf(calls[12]!), { path: "/tmp/a.txt", edits: [{ find: "a", replace: "b" }], }); - assert.deepEqual(jsonOf(calls[12]!), { + assert.deepEqual(jsonOf(calls[13]!), { files: ["/tmp/a.txt"], find: "a", replace: "b", }); - assert.deepEqual(jsonOf(calls[13]!), { src_path: "/tmp/a", dst_path: "/tmp/b", overwrite: false }); + assert.deepEqual(jsonOf(calls[14]!), { files: ["/tmp/b.txt"], find: "x" }); + assert.deepEqual(jsonOf(calls[15]!), { + src_path: "/tmp/a", + dst_path: "/tmp/b", + overwrite: false, + }); assert.equal(typeof fileExists, "boolean"); assert.equal(fileExists, true); - assert.deepEqual(jsonOf(calls[16]!), { path: "/workspace", max_depth: 2 }); + assert.deepEqual(jsonOf(calls[18]!), { path: "/workspace", max_depth: 2 }); }); diff --git a/tests/services/git.test.ts b/tests/services/git.test.ts index 8269ecb..368da49 100644 --- a/tests/services/git.test.ts +++ b/tests/services/git.test.ts @@ -8,8 +8,7 @@ test("git client sends expected request shapes", async () => { const { transport, calls } = createRecordedTransport({ requestJson: async (path: string, init: RequestInit, options: never) => { calls.push({ path, init, options }); - if (path.endsWith("/commit")) - return { result: { output: "ok", exit_code: 0 } }; + if (path.endsWith("/commit")) return { result: { output: "ok", exit_code: 0 } }; return { output: "ok", exit_code: 0 }; }, }); @@ -27,19 +26,33 @@ test("git client sends expected request shapes", async () => { await client.checkoutBranch("sb-1", { path: "/workspace/repo", branch: "feat" }); await client.deleteBranch("sb-1", "/workspace/repo", "feat"); await client.add("sb-1", "/workspace/repo", ["a.ts"]); - await client.commit("sb-1", { path: "/workspace/repo", message: "msg", author: "Test User", email: "test@example.com" }); + await client.commit("sb-1", { + path: "/workspace/repo", + message: "msg", + author: "Test User", + email: "test@example.com", + }); await client.push("sb-1", { path: "/workspace/repo" }); await client.pull("sb-1", { path: "/workspace/repo" }); + const diffCall = calls.find((call) => call.path === "/v1/sandbox/sb-1/git/diff"); + const showCall = calls.find((call) => call.path === "/v1/sandbox/sb-1/git/show"); + const addCall = calls.find((call) => call.path === "/v1/sandbox/sb-1/git/add"); + const commitCall = calls.find((call) => call.path === "/v1/sandbox/sb-1/git/commit"); + assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/git/clone"); assert.deepEqual(jsonOf(calls[0]!), { url: "https://example.com/repo.git", path: "/workspace/repo", }); - assert.deepEqual(jsonOf(calls[5]!), { path: "/workspace/repo", target: "main" }); - assert.deepEqual(jsonOf(calls[8]!), { path: "/workspace/repo", revision: "HEAD" }); - assert.deepEqual(jsonOf(calls[12]!), { path: "/workspace/repo", files: ["a.ts"] }); - assert.deepEqual(jsonOf(calls[13]!), { + assert.equal(diffCall?.path, "/v1/sandbox/sb-1/git/diff"); + assert.deepEqual(jsonOf(diffCall!), { path: "/workspace/repo", target: "main" }); + assert.equal(showCall?.path, "/v1/sandbox/sb-1/git/show"); + assert.deepEqual(jsonOf(showCall!), { path: "/workspace/repo", revision: "HEAD" }); + assert.equal(addCall?.path, "/v1/sandbox/sb-1/git/add"); + assert.deepEqual(jsonOf(addCall!), { path: "/workspace/repo", files: ["a.ts"] }); + assert.equal(commitCall?.path, "/v1/sandbox/sb-1/git/commit"); + assert.deepEqual(jsonOf(commitCall!), { path: "/workspace/repo", message: "msg", author: "Test User", diff --git a/tests/services/lsp.test.ts b/tests/services/lsp.test.ts index 9799f57..11aee2d 100644 --- a/tests/services/lsp.test.ts +++ b/tests/services/lsp.test.ts @@ -9,7 +9,14 @@ test("lsp client sends expected request shapes", async () => { const client = new LspClient(transport as never); await client.start("sb-1", "typescript", "/workspace"); await client.stop("sb-1", "typescript", "/workspace"); - await client.didOpenPath("sb-1", "typescript", "/workspace", "/workspace/a.ts", "const x = 1;", 1); + await client.didOpenPath( + "sb-1", + "typescript", + "/workspace", + "/workspace/a.ts", + "const x = 1;", + 1, + ); await client.didClosePath("sb-1", "typescript", "/workspace", "/workspace/a.ts"); await client.completionsPath("sb-1", "typescript", "/workspace", "/workspace/a.ts", 1, 2); await client.documentSymbolsPath("sb-1", "typescript", "/workspace", "/workspace/a.ts"); @@ -29,3 +36,15 @@ test("lsp client sends expected request shapes", async () => { position: { line: 1, character: 2 }, }); }); + +test("lsp client throws a clear error for empty json responses", async () => { + const { transport } = createRecordedTransport({ + requestJson: async () => undefined, + }); + const client = new LspClient(transport as never); + + await assert.rejects( + () => client.start("sb-1", "typescript", "/workspace"), + /Empty response from \/v1\/sandbox\/sb-1\/lsp\/start/, + ); +}); diff --git a/tests/services/normalization-cleanup.test.ts b/tests/services/normalization-cleanup.test.ts index dc8fe90..17ceeaf 100644 --- a/tests/services/normalization-cleanup.test.ts +++ b/tests/services/normalization-cleanup.test.ts @@ -5,26 +5,34 @@ import { CodeInterpreterClient } from "@/services/code-interpreter.js"; import { FilesystemClient } from "@/services/filesystem.js"; import { SshClient } from "@/services/ssh.js"; import { TemplatesClient } from "@/services/templates.js"; -import { createRecordedTransport } from "@tests/utils/helpers.ts"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("template client normalizes created_at", async () => { - const { transport } = createRecordedTransport({ - requestJson: async () => ({ - id: "tpl-1", - name: "custom", - digest: "sha256:abc", - image_config: { entrypoint: ["python"], cmd: ["app.py"] }, - is_system: false, - created_at: "2026-01-01T00:00:00Z", - }), + const { transport, calls } = createRecordedTransport({ + requestJson: async (path: string, init: RequestInit, options: never) => { + calls.push({ path, init, options }); + return { + id: "tpl-1", + name: "custom", + digest: "sha256:abc", + image_config: { entrypoint: ["python"], cmd: ["app.py"] }, + is_system: false, + created_at: "2026-01-01T00:00:00Z", + }; + }, }); const template = await new TemplatesClient(transport as never).create({ name: "custom", uri: "docker.io/acme/app:latest", - }); + ignored: true, + } as never); assert.equal(template.createdAt, "2026-01-01T00:00:00Z"); + assert.deepEqual(jsonOf(calls[0]!), { + name: "custom", + uri: "docker.io/acme/app:latest", + }); }); test("ssh client normalizes expires_at", async () => { @@ -75,9 +83,10 @@ test("code interpreter client normalizes context fields", async () => { requestJsonUrl: async () => ({ id: "ctx-1", language: 1, cwd: "/workspace" }), }); - const context = await new CodeInterpreterClient( - transport as never, - ).createContext("sb-1", "python"); + const context = await new CodeInterpreterClient(transport as never).createContext( + "sb-1", + "python", + ); assert.equal(context.cwd, "/workspace"); }); diff --git a/tests/services/process.test.ts b/tests/services/process.test.ts index b3bf7eb..105987f 100644 --- a/tests/services/process.test.ts +++ b/tests/services/process.test.ts @@ -13,6 +13,7 @@ test("process client sends execute request shape", async () => { }); const client = new ProcessClient(transport as never); await client.execute("sb-1", { command: "npm test", cwd: "/workspace", timeout: 30 }); + assert.equal(calls.length, 1); assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/process/execute"); assert.deepEqual(jsonOf(calls[0]!), { command: "npm test", cwd: "/workspace", timeout: 30 }); }); diff --git a/tests/services/sandboxes-client.test.ts b/tests/services/sandboxes-client.test.ts index 2a9a999..39950a8 100644 --- a/tests/services/sandboxes-client.test.ts +++ b/tests/services/sandboxes-client.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { test } from "vitest"; +import { afterEach, beforeEach, test } from "vitest"; import { Leap0Error } from "@/core/errors.js"; import { SandboxesClient } from "@/services/sandboxes.js"; @@ -25,6 +25,24 @@ function makeClient() { return { client, calls }; } +const ENV_KEYS = ["OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS"] as const; +let envSnapshot: Partial> = {}; + +beforeEach(() => { + envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const value = envSnapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + test("sandboxes create validates payload and wraps result", async () => { const { client, calls } = makeClient(); const result = await client.create({ diff --git a/tests/services/ssh.test.ts b/tests/services/ssh.test.ts index 1410947..abb542c 100644 --- a/tests/services/ssh.test.ts +++ b/tests/services/ssh.test.ts @@ -25,6 +25,7 @@ test("ssh client sends expected request shapes", async () => { await client.validateAccess("sb-1", "ssh-1", "secret"); await client.regenerateAccess("sb-1"); await client.deleteAccess("sb-1"); + assert.equal(calls.length, 4); assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/ssh/access"); assert.deepEqual(jsonOf(calls[1]!), { id: "ssh-1", password: "secret" }); assert.equal(calls[2]?.path, "/v1/sandbox/sb-1/ssh/regen"); From 2e3a1bb9cd0f087b79948433a45f0e07ac20ff35 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Fri, 3 Apr 2026 14:08:07 -0400 Subject: [PATCH 5/9] fixes --- src/client/index.ts | 2 +- src/config/index.ts | 29 ++++------- src/core/otel.ts | 4 +- src/models/config.ts | 8 ++- src/models/filesystem.ts | 10 ++++ src/models/sandbox.ts | 55 ++++++++++++++++++++ src/models/template.ts | 29 ++++++++++- src/services/desktop.ts | 21 ++++---- src/services/filesystem.ts | 6 ++- src/services/git.ts | 11 ++-- src/services/lsp.ts | 39 +++++++++++--- src/services/pty.ts | 6 +++ src/services/sandboxes.ts | 52 +++++++----------- src/services/templates.ts | 46 ++++++++-------- tests/client/client-sandbox.test.ts | 2 +- tests/services/desktop.test.ts | 23 ++++++++ tests/services/filesystem.test.ts | 12 +++++ tests/services/git.test.ts | 12 +++++ tests/services/lsp.test.ts | 15 ++++++ tests/services/normalization-cleanup.test.ts | 30 +++++++++++ tests/services/pty.test.ts | 26 +++++++++ tests/services/sandboxes-client.test.ts | 2 + 22 files changed, 336 insertions(+), 104 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 3e13dca..2fbcac9 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -48,7 +48,7 @@ export class Leap0Client { const resolved = resolveConfig(config); this.transport = new Leap0Transport(resolved); if (resolved.sdkOtelEnabled) { - initOtel(resolved); + initOtel(); } const wrapSandbox = (data: import("@/models/index.js").SandboxData) => new Sandbox(this, data); diff --git a/src/config/index.ts b/src/config/index.ts index 383d380..8f04004 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -3,8 +3,13 @@ import { DEFAULT_CLIENT_TIMEOUT, DEFAULT_SANDBOX_DOMAIN, } from "@/config/constants.js"; -import { Leap0Error } from "@/core/errors.js"; -import { leap0ConfigInputSchema, leap0ConfigResolvedSchema } from "@/models/config.js"; +import { + apiKeyRequiredSchema, + authHeaderSchema, + leap0ConfigInputSchema, + leap0ConfigResolvedSchema, + timeoutSchema, +} from "@/models/config.js"; import type { Leap0ConfigInput, Leap0ConfigResolved } from "@/models/config.js"; import { trimSlash } from "@/core/utils.js"; @@ -15,13 +20,6 @@ function readEnv(name: string): string | undefined { return undefined; } -function requireNonEmpty(value: string | undefined, label: string): string { - if (!value?.trim()) { - throw new Leap0Error(`${label} is required`); - } - return value.trim(); -} - function resolveSdkOtelFromEnv(envOtel: string | undefined): boolean { if (envOtel === "true") { return true; @@ -44,26 +42,19 @@ function resolveSdkOtelFromEnv(envOtel: string | undefined): boolean { export function resolveConfig(input: Leap0ConfigInput = {}): Leap0ConfigResolved { leap0ConfigInputSchema.parse(input); - const apiKey = requireNonEmpty(input.apiKey ?? readEnv("LEAP0_API_KEY"), "API key"); + const apiKey = apiKeyRequiredSchema.parse(input.apiKey ?? readEnv("LEAP0_API_KEY")); const baseUrl = trimSlash( (input.baseUrl ?? readEnv("LEAP0_BASE_URL") ?? DEFAULT_BASE_URL).trim(), ); const sandboxDomain = trimSlash( (input.sandboxDomain ?? readEnv("LEAP0_SANDBOX_DOMAIN") ?? DEFAULT_SANDBOX_DOMAIN).trim(), ); - const timeout = input.timeout ?? DEFAULT_CLIENT_TIMEOUT; - const authHeader = (input.authHeader ?? "authorization").trim(); + const timeout = timeoutSchema.parse(input.timeout ?? DEFAULT_CLIENT_TIMEOUT); + const authHeader = authHeaderSchema.parse(input.authHeader ?? "authorization"); const bearer = input.bearer ?? true; const envOtel = readEnv("LEAP0_SDK_OTEL_ENABLED"); const sdkOtelEnabled = input.sdkOtelEnabled ?? resolveSdkOtelFromEnv(envOtel); - if (!Number.isFinite(timeout) || timeout <= 0) { - throw new Leap0Error("timeout must be a positive number"); - } - if (!authHeader) { - throw new Leap0Error("authHeader cannot be empty"); - } - return leap0ConfigResolvedSchema.parse({ apiKey, baseUrl, diff --git a/src/core/otel.ts b/src/core/otel.ts index 633a45b..832e52d 100644 --- a/src/core/otel.ts +++ b/src/core/otel.ts @@ -27,10 +27,8 @@ export function getTracer() { /** * Initializes process-wide OpenTelemetry tracer and meter providers when missing. * - * Args: - * config: Resolved SDK configuration. */ -export function initOtel(_config: Leap0ConfigResolved): void { +export function initOtel(): void { const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: TRACER_NAME, [ATTR_SERVICE_VERSION]: SDK_VERSION, diff --git a/src/models/config.ts b/src/models/config.ts index 415768f..a4b148a 100644 --- a/src/models/config.ts +++ b/src/models/config.ts @@ -1,5 +1,9 @@ import { z } from "zod"; +export const apiKeyRequiredSchema = z.string().trim().min(1, "API key is required"); +export const authHeaderSchema = z.string().trim().min(1, "authHeader cannot be empty"); +export const timeoutSchema = z.number().positive("timeout must be a positive number"); + export const leap0ConfigInputSchema = z.object({ apiKey: z.string().optional(), baseUrl: z.string().optional(), @@ -15,8 +19,8 @@ export const leap0ConfigResolvedSchema = z.object({ apiKey: z.string(), baseUrl: z.string(), sandboxDomain: z.string(), - timeout: z.number().positive(), - authHeader: z.string(), + timeout: timeoutSchema, + authHeader: authHeaderSchema, bearer: z.boolean(), sdkOtelEnabled: z.boolean(), }); diff --git a/src/models/filesystem.ts b/src/models/filesystem.ts index f66bbb8..ff0891a 100644 --- a/src/models/filesystem.ts +++ b/src/models/filesystem.ts @@ -76,3 +76,13 @@ export const editFilesResultSchema = z.object({ items: z.array(editResultSchema), }); export type EditFilesResult = z.infer; + +export const setPermissionsParamsSchema = z + .object({ + mode: z.string().optional(), + owner: z.string().optional(), + group: z.string().optional(), + }) + .refine((params) => params.mode !== undefined || params.owner !== undefined || params.group !== undefined, { + message: "setPermissions requires at least one of mode, owner, or group", + }); diff --git a/src/models/sandbox.ts b/src/models/sandbox.ts index b325fa3..db12350 100644 --- a/src/models/sandbox.ts +++ b/src/models/sandbox.ts @@ -1,5 +1,12 @@ import { z } from "zod"; +import { + DEFAULT_MEMORY_MIB, + DEFAULT_TEMPLATE_NAME, + DEFAULT_TIMEOUT_MIN, + DEFAULT_VCPU, +} from "@/config/constants.js"; + export const NetworkPolicyMode = { ALLOW_ALL: "allow-all", DENY_ALL: "deny-all", @@ -78,6 +85,54 @@ export const createSandboxParamsSchema = z.object({ }); export type CreateSandboxParams = z.infer; +export const createSandboxRuntimeParamsSchema = z + .object( + { + templateName: z.preprocess( + (value) => value ?? DEFAULT_TEMPLATE_NAME, + z + .string({ invalid_type_error: "templateName must be a string" }) + .trim() + .min(1, "templateName must be 1-64 characters") + .max(64, "templateName must be 1-64 characters"), + ), + vcpu: z.preprocess( + (value) => value ?? DEFAULT_VCPU, + z + .number({ invalid_type_error: "vcpu must be between 1 and 8" }) + .int("vcpu must be between 1 and 8") + .min(1, "vcpu must be between 1 and 8") + .max(8, "vcpu must be between 1 and 8"), + ), + memoryMib: z.preprocess( + (value) => value ?? DEFAULT_MEMORY_MIB, + z + .number({ invalid_type_error: "memoryMib must be even and between 512 and 8192" }) + .int("memoryMib must be even and between 512 and 8192") + .min(512, "memoryMib must be even and between 512 and 8192") + .max(8192, "memoryMib must be even and between 512 and 8192") + .refine((value) => value % 2 === 0, { + message: "memoryMib must be even and between 512 and 8192", + }), + ), + timeoutMin: z.preprocess( + (value) => value ?? DEFAULT_TIMEOUT_MIN, + z + .number({ invalid_type_error: "timeoutMin must be between 1 and 480" }) + .int("timeoutMin must be between 1 and 480") + .min(1, "timeoutMin must be between 1 and 480") + .max(480, "timeoutMin must be between 1 and 480"), + ), + autoPause: z.boolean().optional(), + otelExport: z.boolean().optional(), + telemetry: z.boolean().optional(), + envVars: z.record(z.string(), z.string()).optional(), + networkPolicy: networkPolicySchema.optional(), + }, + { invalid_type_error: "params must be an object" }, + ) + .passthrough(); + type NetworkPolicyWire = { mode: NetworkPolicyMode; allow_domains?: string[]; diff --git a/src/models/template.ts b/src/models/template.ts index d50035d..dfaba83 100644 --- a/src/models/template.ts +++ b/src/models/template.ts @@ -1,5 +1,9 @@ import { z } from "zod"; +export const TEMPLATE_NAME_ERROR_MESSAGE = + "name must be non-empty, <= 64 chars, contain no whitespace, and not start with system/"; +export const TEMPLATE_URI_ERROR_MESSAGE = "uri must be non-empty and <= 500 chars"; + export const RegistryCredentialType = { BASIC: "basic", AWS: "aws", @@ -83,7 +87,30 @@ export const createTemplateParamsSchema = z.object({ }); export type CreateTemplateParams = z.infer; +export const templateNameSchema = z.string().superRefine((name, ctx) => { + if (!name.trim() || name.length > 64 || /\s/.test(name) || name.startsWith("system/")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: TEMPLATE_NAME_ERROR_MESSAGE, + }); + } +}); + +export const templateUriSchema = z.string().superRefine((uri, ctx) => { + if (!uri.trim() || uri.length > 500) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: TEMPLATE_URI_ERROR_MESSAGE, + }); + } +}); + +export const createTemplateRequestSchema = createTemplateParamsSchema.extend({ + name: templateNameSchema, + uri: templateUriSchema, +}); + export const renameTemplateParamsSchema = z.object({ - name: z.string(), + name: templateNameSchema, }); export type RenameTemplateParams = z.infer; diff --git a/src/services/desktop.ts b/src/services/desktop.ts index 3d4b942..c0ade1e 100644 --- a/src/services/desktop.ts +++ b/src/services/desktop.ts @@ -40,6 +40,8 @@ import { } from "@/models/desktop.js"; import { asRecord } from "@/services/shared.js"; +const desktopOkResponseSchema = z.object({ ok: z.boolean() }); + /** Drives the desktop sandbox APIs for browser and GUI automation. */ export class DesktopClient { constructor(private readonly transport: Leap0Transport) {} @@ -202,28 +204,28 @@ export class DesktopClient { ); } async typeText(sandbox: SandboxRef, text: string, options?: RequestOptions): Promise { - const data = (await this.transport.requestJsonUrl( + const data = await this.transport.requestJsonUrl( this.requestUrl(sandbox, "/api/input/type"), { method: "POST", body: jsonBody({ text }) }, options, - )) as Record | undefined; - return Boolean(data?.ok); + ); + return normalize(desktopOkResponseSchema, data).ok; } async pressKey(sandbox: SandboxRef, key: string, options?: RequestOptions): Promise { - const data = (await this.transport.requestJsonUrl( + const data = await this.transport.requestJsonUrl( this.requestUrl(sandbox, "/api/input/press"), { method: "POST", body: jsonBody({ key }) }, options, - )) as Record | undefined; - return Boolean(data?.ok); + ); + return normalize(desktopOkResponseSchema, data).ok; } async hotkey(sandbox: SandboxRef, keys: string[], options?: RequestOptions): Promise { - const data = (await this.transport.requestJsonUrl( + const data = await this.transport.requestJsonUrl( this.requestUrl(sandbox, "/api/input/hotkey"), { method: "POST", body: jsonBody({ keys }) }, options, - )) as Record | undefined; - return Boolean(data?.ok); + ); + return normalize(desktopOkResponseSchema, data).ok; } async recordingStatus( sandbox: SandboxRef, @@ -415,6 +417,7 @@ export class DesktopClient { if ( typeof status.running === "number" && typeof status.total === "number" && + status.total > 0 && status.running >= status.total ) { return; diff --git a/src/services/filesystem.ts b/src/services/filesystem.ts index cd10449..98bce54 100644 --- a/src/services/filesystem.ts +++ b/src/services/filesystem.ts @@ -18,6 +18,7 @@ import { fileInfoSchema, lsResultSchema, searchMatchSchema, + setPermissionsParamsSchema, treeResultSchema, } from "@/models/filesystem.js"; import { sandboxIdOf } from "@/core/utils.js"; @@ -102,7 +103,7 @@ export class FilesystemClient { { method: "POST", headers: { "content-type": "application/octet-stream" }, - body: Buffer.from(content), + body: content as BodyInit, }, { ...options, query }, ); @@ -236,12 +237,13 @@ export class FilesystemClient { params: { mode?: string; owner?: string; group?: string } = {}, options: RequestOptions = {}, ): Promise { + const parsed = setPermissionsParamsSchema.parse(params); await this.transport.request( this.fsPath(sandbox, "set-permissions"), { method: "POST", body: jsonBody( - compact({ path, mode: params.mode, owner: params.owner, group: params.group }), + compact({ path, mode: parsed.mode, owner: parsed.owner, group: parsed.group }), ), }, options, diff --git a/src/services/git.ts b/src/services/git.ts index 23c52a0..68127d2 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -24,11 +24,16 @@ export class GitClient { body: unknown, options: RequestOptions = {}, ): Promise { - return (await this.transport.requestJson( - `/v1/sandbox/${sandboxIdOf(sandbox)}/git/${endpoint}`, + const path = `/v1/sandbox/${sandboxIdOf(sandbox)}/git/${endpoint}`; + const result = await this.transport.requestJson( + path, { method: "POST", body: jsonBody(body) }, options, - ))!; + ); + if (result === undefined) { + throw new Error(`Empty response from ${path} for sandbox ${sandboxIdOf(sandbox)}`); + } + return result; } async clone( diff --git a/src/services/lsp.ts b/src/services/lsp.ts index 2b25a94..5b3590f 100644 --- a/src/services/lsp.ts +++ b/src/services/lsp.ts @@ -29,6 +29,20 @@ export class LspClient { return result; } + private normalizeDidOpenArgs( + textOrOptions?: string | RequestOptions, + versionOrOptions?: number | RequestOptions, + options?: RequestOptions, + ): { text: string | undefined; version: number; options: RequestOptions | undefined } { + if (textOrOptions && typeof textOrOptions === "object") { + return { text: undefined, version: 1, options: textOrOptions }; + } + if (versionOrOptions && typeof versionOrOptions === "object") { + return { text: textOrOptions, version: 1, options: versionOrOptions }; + } + return { text: textOrOptions, version: versionOrOptions ?? 1, options }; + } + async start( sandbox: SandboxRef, languageId: string, @@ -60,10 +74,15 @@ export class LspClient { languageId: string, pathToProject: string, uri: string, - text?: string, - version = 1, + textOrOptions?: string | RequestOptions, + versionOrOptions?: number | RequestOptions, options?: RequestOptions, ): Promise { + const { text, version, options: normalizedOptions } = this.normalizeDidOpenArgs( + textOrOptions, + versionOrOptions, + options, + ); const payload: Record = { language_id: languageId, path_to_project: pathToProject, @@ -74,7 +93,7 @@ export class LspClient { await this.transport.request( `/v1/sandbox/${sandboxIdOf(sandbox)}/lsp/did-open`, { method: "POST", body: jsonBody(payload) }, - options, + normalizedOptions, ); } async didOpenPath( @@ -82,11 +101,19 @@ export class LspClient { languageId: string, pathToProject: string, path: string, - text?: string, - version = 1, + textOrOptions?: string | RequestOptions, + versionOrOptions?: number | RequestOptions, options?: RequestOptions, ): Promise { - await this.didOpen(sandbox, languageId, pathToProject, toFileUri(path), text, version, options); + await this.didOpen( + sandbox, + languageId, + pathToProject, + toFileUri(path), + textOrOptions, + versionOrOptions, + options, + ); } async didClose( sandbox: SandboxRef, diff --git a/src/services/pty.ts b/src/services/pty.ts index 0091402..119a492 100644 --- a/src/services/pty.ts +++ b/src/services/pty.ts @@ -24,6 +24,7 @@ export class PtyConnection { const cleanup = () => { this.socket.removeEventListener("message", onMessage); this.socket.removeEventListener("error", onError); + this.socket.removeEventListener("close", onClose); }; const onMessage = (event: MessageEvent) => { cleanup(); @@ -45,8 +46,13 @@ export class PtyConnection { cleanup(); reject(new Leap0WebSocketError("PTY websocket error")); }; + const onClose = () => { + cleanup(); + reject(new Leap0WebSocketError("PTY websocket closed")); + }; this.socket.addEventListener("message", onMessage, { once: true }); this.socket.addEventListener("error", onError, { once: true }); + this.socket.addEventListener("close", onClose, { once: true }); }); } diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 925d7bb..45be27f 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -1,14 +1,14 @@ import { - DEFAULT_MEMORY_MIB, - DEFAULT_TEMPLATE_NAME, - DEFAULT_TIMEOUT_MIN, - DEFAULT_VCPU, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, } from "@/config/constants.js"; import { Leap0Error } from "@/core/errors.js"; import { normalize } from "@/core/normalize.js"; -import { sandboxDataSchema, toNetworkPolicyWire } from "@/models/sandbox.js"; +import { + createSandboxRuntimeParamsSchema, + sandboxDataSchema, + toNetworkPolicyWire, +} from "@/models/sandbox.js"; import type { CreateSandboxParams, RequestOptions, @@ -60,7 +60,8 @@ export type SandboxFactory = (data: SandboxData) => T; export class SandboxesClient { private readonly sandboxFactory?: SandboxFactory; - constructor(transport: Leap0Transport, sandboxFactory?: SandboxFactory); + constructor(transport: Leap0Transport); + constructor(transport: Leap0Transport, sandboxFactory: SandboxFactory); constructor( private readonly transport: Leap0Transport, sandboxFactory?: SandboxFactory, @@ -74,36 +75,21 @@ export class SandboxesClient { /** Creates a sandbox from a template and resource config. */ async create(params: CreateSandboxParams = {}, options: RequestOptions = {}): Promise { - const templateName = (params.templateName ?? DEFAULT_TEMPLATE_NAME).trim(); - const vcpu = params.vcpu ?? DEFAULT_VCPU; - const memoryMib = params.memoryMib ?? DEFAULT_MEMORY_MIB; - const timeoutMin = params.timeoutMin ?? DEFAULT_TIMEOUT_MIN; - - if (!templateName || templateName.length > 64) - throw new Leap0Error("templateName must be 1-64 characters"); - if (!Number.isInteger(vcpu) || vcpu < 1 || vcpu > 8) - throw new Leap0Error("vcpu must be between 1 and 8"); - if ( - !Number.isInteger(memoryMib) || - memoryMib < 512 || - memoryMib > 8192 || - memoryMib % 2 !== 0 - ) { - throw new Leap0Error("memoryMib must be even and between 512 and 8192"); - } - if (!Number.isInteger(timeoutMin) || timeoutMin < 1 || timeoutMin > 480) { - throw new Leap0Error("timeoutMin must be between 1 and 480"); + const parsedParams = createSandboxRuntimeParamsSchema.safeParse(params); + if (!parsedParams.success) { + throw new Leap0Error(parsedParams.error.issues[0]?.message ?? "Invalid sandbox parameters"); } + const normalizedParams = parsedParams.data; - const effectiveOtelExport = params.otelExport ?? Boolean(params.telemetry); + const effectiveOtelExport = normalizedParams.otelExport ?? Boolean(normalizedParams.telemetry); const payload = { - template_name: templateName, - vcpu, - memory_mib: memoryMib, - timeout_min: timeoutMin, - auto_pause: params.autoPause ?? false, - env_vars: injectOtelEnv(params.envVars, effectiveOtelExport), - network_policy: toNetworkPolicyWire(params.networkPolicy), + template_name: normalizedParams.templateName, + vcpu: normalizedParams.vcpu, + memory_mib: normalizedParams.memoryMib, + timeout_min: normalizedParams.timeoutMin, + auto_pause: normalizedParams.autoPause ?? false, + env_vars: injectOtelEnv(normalizedParams.envVars, effectiveOtelExport), + network_policy: toNetworkPolicyWire(normalizedParams.networkPolicy), }; return withErrorPrefix("Failed to create sandbox: ", async () => { diff --git a/src/services/templates.ts b/src/services/templates.ts index 27082c1..e8b0259 100644 --- a/src/services/templates.ts +++ b/src/services/templates.ts @@ -1,12 +1,17 @@ -import { Leap0Error } from "@/core/errors.js"; import { normalize } from "@/core/normalize.js"; import type { CreateTemplateParams, + RenameTemplateParams, RequestOptions, TemplateData, TemplateRef, } from "@/models/index.js"; -import { createTemplateParamsSchema, templateDataSchema } from "@/models/template.js"; +import { + createTemplateRequestSchema, + renameTemplateParamsSchema, + templateDataSchema, + templateNameSchema, +} from "@/models/template.js"; import { Leap0Transport, jsonBody } from "@/core/transport.js"; import { templateIdOf } from "@/core/utils.js"; import { withErrorPrefix } from "@/services/shared.js"; @@ -15,23 +20,13 @@ import { withErrorPrefix } from "@/services/shared.js"; export class TemplatesClient { constructor(private readonly transport: Leap0Transport) {} + private validateTemplateName(name: string): string { + return templateNameSchema.parse(name); + } + /** Uploads a new template from a container image URI. */ async create(params: CreateTemplateParams, options: RequestOptions = {}): Promise { - const parsed = createTemplateParamsSchema.parse(params); - - if ( - !parsed.name.trim() || - parsed.name.length > 64 || - /\s/.test(parsed.name) || - parsed.name.startsWith("system/") - ) { - throw new Leap0Error( - "name must be non-empty, <= 64 chars, contain no whitespace, and not start with system/", - ); - } - if (!parsed.uri.trim() || parsed.uri.length > 500) { - throw new Leap0Error("uri must be non-empty and <= 500 chars"); - } + const parsed = createTemplateRequestSchema.parse(params); return withErrorPrefix("Failed to create template: ", async () => { const data = await this.transport.requestJson( "/v1/template", @@ -45,16 +40,19 @@ export class TemplatesClient { /** Renames a template. */ async rename( template: TemplateRef, - params: { name: string }, + params: RenameTemplateParams, options: RequestOptions = {}, - ): Promise { - await withErrorPrefix("Failed to rename template: ", () => - this.transport.request( + ): Promise { + const parsed = renameTemplateParamsSchema.parse(params); + this.validateTemplateName(parsed.name); + return withErrorPrefix("Failed to rename template: ", async () => { + const data = await this.transport.requestJson( `/v1/template/${templateIdOf(template)}`, - { method: "PATCH", body: jsonBody(params) }, + { method: "PATCH", body: jsonBody(parsed) }, options, - ), - ); + ); + return normalize(templateDataSchema, data); + }); } /** Deletes a template by ID. */ diff --git a/tests/client/client-sandbox.test.ts b/tests/client/client-sandbox.test.ts index 6432a21..5220121 100644 --- a/tests/client/client-sandbox.test.ts +++ b/tests/client/client-sandbox.test.ts @@ -37,12 +37,12 @@ test("Leap0Client wires services and supports direct access", async () => { } finally { client.sandboxes.get = originalGet; client.sandboxes.create = originalCreate; - await client.close(); if (originalApiKey === undefined) { delete process.env.LEAP0_API_KEY; } else { process.env.LEAP0_API_KEY = originalApiKey; } + await client.close(); } }); diff --git a/tests/services/desktop.test.ts b/tests/services/desktop.test.ts index 7f3e0c4..c53048d 100644 --- a/tests/services/desktop.test.ts +++ b/tests/services/desktop.test.ts @@ -92,3 +92,26 @@ test("desktop waitUntilReady treats count-only updates as ready", async () => { await client.waitUntilReady("sb-1", 1); }); + +test("desktop input methods require a real boolean ok response", async () => { + const { transport } = createRecordedTransport({ + requestJsonUrl: async () => ({ ok: "false" }), + }); + const client = new DesktopClient(transport as never); + + await assert.rejects(() => client.typeText("sb-1", "hello")); + await assert.rejects(() => client.pressKey("sb-1", "Enter")); + await assert.rejects(() => client.hotkey("sb-1", ["Meta", "K"])); +}); + +test("desktop waitUntilReady ignores zero total count updates", async () => { + const { transport } = createRecordedTransport({ + streamJsonUrl: async function* () { + yield { items: [], running: 0, total: 0 }; + yield { items: [{ name: "x11vnc", running: true }], running: 1, total: 1 }; + }, + }); + const client = new DesktopClient(transport as never); + + await client.waitUntilReady("sb-1", 1); +}); diff --git a/tests/services/filesystem.test.ts b/tests/services/filesystem.test.ts index e6f61ef..28c1247 100644 --- a/tests/services/filesystem.test.ts +++ b/tests/services/filesystem.test.ts @@ -58,6 +58,7 @@ test("filesystem client sends expected request shapes", async () => { assert.equal(new Headers(calls[3]!.init.headers).get("content-type"), "application/octet-stream"); assert.equal(calls[3]?.options.query?.path, "/tmp/a.txt"); assert.equal(new Headers(calls[4]!.init.headers).get("content-type"), "application/octet-stream"); + assert.equal(ArrayBuffer.isView(calls[4]!.init.body), true); assert.deepEqual(jsonOf(calls[5]!), { path: "/tmp/a.txt", head: 10 }); assert.deepEqual(jsonOf(calls[8]!), { path: "/tmp/a.txt", mode: "0755" }); assert.deepEqual(jsonOf(calls[9]!), { path: "/tmp/b.txt", owner: "alice", group: "staff" }); @@ -80,3 +81,14 @@ test("filesystem client sends expected request shapes", async () => { assert.equal(fileExists, true); assert.deepEqual(jsonOf(calls[18]!), { path: "/workspace", max_depth: 2 }); }); + +test("filesystem setPermissions rejects empty updates before transport", async () => { + const { transport, calls } = createRecordedTransport(); + const client = new FilesystemClient(transport as never); + + await assert.rejects( + () => client.setPermissions("sb-1", "/tmp/a.txt"), + /setPermissions requires at least one of mode, owner, or group/, + ); + assert.equal(calls.length, 0); +}); diff --git a/tests/services/git.test.ts b/tests/services/git.test.ts index 368da49..67eaffb 100644 --- a/tests/services/git.test.ts +++ b/tests/services/git.test.ts @@ -59,3 +59,15 @@ test("git client sends expected request shapes", async () => { email: "test@example.com", }); }); + +test("git client throws a clear error for empty json responses", async () => { + const { transport } = createRecordedTransport({ + requestJson: async () => undefined, + }); + const client = new GitClient(transport as never); + + await assert.rejects( + () => client.status("sb-1", "/workspace/repo"), + /Empty response from \/v1\/sandbox\/sb-1\/git\/status for sandbox sb-1/, + ); +}); diff --git a/tests/services/lsp.test.ts b/tests/services/lsp.test.ts index 11aee2d..72e3c22 100644 --- a/tests/services/lsp.test.ts +++ b/tests/services/lsp.test.ts @@ -48,3 +48,18 @@ test("lsp client throws a clear error for empty json responses", async () => { /Empty response from \/v1\/sandbox\/sb-1\/lsp\/start/, ); }); + +test("lsp didOpenPath keeps compatibility with options in the old slot", async () => { + const { transport, calls } = createRecordedTransport(); + const client = new LspClient(transport as never); + + await client.didOpenPath("sb-1", "typescript", "/workspace", "/workspace/a.ts", { timeout: 5 }); + + assert.deepEqual(jsonOf(calls[0]!), { + language_id: "typescript", + path_to_project: "/workspace", + uri: "file:///workspace/a.ts", + version: 1, + }); + assert.deepEqual(calls[0]?.options, { timeout: 5 }); +}); diff --git a/tests/services/normalization-cleanup.test.ts b/tests/services/normalization-cleanup.test.ts index 17ceeaf..875182d 100644 --- a/tests/services/normalization-cleanup.test.ts +++ b/tests/services/normalization-cleanup.test.ts @@ -35,6 +35,36 @@ test("template client normalizes created_at", async () => { }); }); +test("template client validates and normalizes rename responses", async () => { + const { transport, calls } = createRecordedTransport({ + requestJson: async (path: string, init: RequestInit, options: never) => { + calls.push({ path, init, options }); + return { + id: "tpl-1", + name: "renamed", + digest: "sha256:def", + image_config: { entrypoint: ["python"], cmd: ["app.py"] }, + is_system: false, + created_at: "2026-01-01T00:00:00Z", + }; + }, + }); + + const template = await new TemplatesClient(transport as never).rename("tpl-1", { name: "renamed" }); + + assert.equal(template.name, "renamed"); + assert.equal(template.createdAt, "2026-01-01T00:00:00Z"); + assert.equal(calls[0]?.path, "/v1/template/tpl-1"); + assert.deepEqual(jsonOf(calls[0]!), { name: "renamed" }); +}); + +test("template client reuses name validation for rename", async () => { + const { transport } = createRecordedTransport(); + const client = new TemplatesClient(transport as never); + + await assert.rejects(() => client.rename("tpl-1", { name: "system/test" }), /name must be non-empty/); +}); + test("ssh client normalizes expires_at", async () => { const { transport } = createRecordedTransport({ requestJson: async () => ({ diff --git a/tests/services/pty.test.ts b/tests/services/pty.test.ts index 034d85b..5728502 100644 --- a/tests/services/pty.test.ts +++ b/tests/services/pty.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; +import { Leap0WebSocketError } from "@/core/errors.js"; import { PtyClient, PtyConnection } from "@/services/pty.js"; import { createRecordedTransport } from "@tests/utils/helpers.ts"; @@ -65,3 +66,28 @@ test("pty connection sends, receives, and closes websocket data", async () => { connection.close(); assert.equal(closed, true); }); + +test("pty connection rejects recv when websocket closes first", async () => { + const handlers = new Map void>(); + const socket = { + send() {}, + close() {}, + addEventListener(type: string, handler: (event?: { data?: unknown }) => void) { + handlers.set(type, handler); + }, + removeEventListener(type: string) { + handlers.delete(type); + }, + }; + const connection = new PtyConnection(socket as never); + + const recv = connection.recv(); + handlers.get("close")?.(); + + await assert.rejects(recv, (error: unknown) => { + assert.ok(error instanceof Leap0WebSocketError); + assert.match(error.message, /PTY websocket closed/); + return true; + }); + assert.equal(handlers.size, 0); +}); diff --git a/tests/services/sandboxes-client.test.ts b/tests/services/sandboxes-client.test.ts index 39950a8..21aa4ad 100644 --- a/tests/services/sandboxes-client.test.ts +++ b/tests/services/sandboxes-client.test.ts @@ -62,6 +62,8 @@ test("sandboxes create validates payload and wraps result", async () => { test("sandboxes create rejects invalid parameters", async () => { const { client } = makeClient(); + await assert.rejects(() => client.create(null as never), /params must be an object/); + await assert.rejects(() => client.create({ templateName: 123 as never }), /templateName must be a string/); await assert.rejects(() => client.create({ vcpu: 0 }), Leap0Error); await assert.rejects(() => client.create({ memoryMib: 513 }), Leap0Error); await assert.rejects(() => client.create({ timeoutMin: 999 }), Leap0Error); From 2ab70eed3bc7e4bae7f7498c1e0152d04e0f5a37 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Fri, 3 Apr 2026 15:15:33 -0400 Subject: [PATCH 6/9] fixes --- src/client/index.ts | 16 +++- src/client/sandbox.ts | 11 ++- src/config/index.ts | 79 +++++++++++++------- src/models/desktop.ts | 39 +++++----- src/models/filesystem.ts | 6 +- src/models/sandbox.ts | 62 ++++++++++++++- src/models/snapshot.ts | 8 +- src/models/template.ts | 9 ++- src/services/desktop.ts | 16 +++- src/services/filesystem.ts | 21 +++++- src/services/git.ts | 8 +- src/services/snapshots.ts | 21 ++++-- src/services/templates.ts | 7 +- tests/client/client-sandbox.test.ts | 43 +++++++++++ tests/config/config-utils.test.ts | 19 ++++- tests/config/otel.test.ts | 13 ++++ tests/models/network-policy.test.ts | 21 ++++++ tests/models/template-credentials.test.ts | 19 ++++- tests/services/desktop.test.ts | 34 +++++++++ tests/services/filesystem.test.ts | 35 +++++++++ tests/services/git.test.ts | 15 +++- tests/services/normalization-cleanup.test.ts | 19 ++--- tests/services/pty.test.ts | 47 ++++++++---- tests/services/snapshots.test.ts | 12 +++ 24 files changed, 472 insertions(+), 108 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 2fbcac9..0fb4f5f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,6 +1,6 @@ import { resolveConfig } from "@/config/index.js"; import type { Leap0ConfigInput } from "@/models/index.js"; -import { initOtel } from "@/core/otel.js"; +import { initOtel, shutdownOtel } from "@/core/otel.js"; import { Leap0Transport } from "@/core/transport.js"; import { CodeInterpreterClient, @@ -34,6 +34,8 @@ export interface ClientServices { * Top-level Leap0 SDK client that exposes all service groups. */ export class Leap0Client { + private closed = false; + private readonly sdkOtelEnabled: boolean; private readonly transport: Leap0Transport; readonly sandboxes: SandboxesClient; @@ -47,6 +49,7 @@ export class Leap0Client { constructor(config: Leap0ConfigInput = {}) { const resolved = resolveConfig(config); this.transport = new Leap0Transport(resolved); + this.sdkOtelEnabled = resolved.sdkOtelEnabled; if (resolved.sdkOtelEnabled) { initOtel(); } @@ -71,7 +74,18 @@ export class Leap0Client { /** Closes the underlying transport. */ async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; await this.transport.close(); + if (this.sdkOtelEnabled) { + try { + await shutdownOtel(); + } catch (error) { + console.warn("Failed to shutdown OpenTelemetry providers", error); + } + } } } diff --git a/src/client/sandbox.ts b/src/client/sandbox.ts index a1f970f..dcd6f28 100644 --- a/src/client/sandbox.ts +++ b/src/client/sandbox.ts @@ -1,4 +1,5 @@ import type { SandboxData, SandboxState } from "@/models/index.js"; +import { sandboxStateSchema } from "@/models/sandbox.js"; import { CodeInterpreterClient, DesktopClient, @@ -23,7 +24,7 @@ function isSandboxData(value: unknown): value is SandboxData { return ( typeof record.id === "string" && typeof record.templateId === "string" && - typeof record.state === "string" && + sandboxStateSchema.safeParse(record.state).success && typeof record.vcpu === "number" && typeof record.memoryMib === "number" && typeof record.diskMib === "number" && @@ -86,13 +87,17 @@ class SandboxServiceProxy { export class Sandbox implements SandboxData { id!: string; templateId!: string; + templateName?: string; state!: SandboxState; vcpu!: number; memoryMib!: number; diskMib!: number; + timeoutMin?: number; autoPause?: boolean; + envVars?: Record; networkPolicy?: SandboxData["networkPolicy"]; createdAt!: string; + updatedAt?: string; [key: string]: unknown; readonly filesystem: BoundSandboxService; @@ -133,13 +138,17 @@ export class Sandbox implements SandboxData { private update(data: SandboxData): this { this.id = data.id; this.templateId = data.templateId; + this.templateName = data.templateName; this.state = data.state; this.vcpu = data.vcpu; this.memoryMib = data.memoryMib; this.diskMib = data.diskMib; + this.timeoutMin = data.timeoutMin; this.autoPause = data.autoPause; + this.envVars = data.envVars; this.networkPolicy = data.networkPolicy; this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; return this; } diff --git a/src/config/index.ts b/src/config/index.ts index 8f04004..75522d8 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,8 +1,10 @@ +import { ZodError } from "zod"; import { DEFAULT_BASE_URL, DEFAULT_CLIENT_TIMEOUT, DEFAULT_SANDBOX_DOMAIN, } from "@/config/constants.js"; +import { Leap0Error } from "@/core/errors.js"; import { apiKeyRequiredSchema, authHeaderSchema, @@ -21,13 +23,35 @@ function readEnv(name: string): string | undefined { } function resolveSdkOtelFromEnv(envOtel: string | undefined): boolean { - if (envOtel === "true") { + const normalizedOtel = envOtel?.trim(); + if (normalizedOtel === undefined || normalizedOtel === "") { + return Boolean(readEnv("OTEL_EXPORTER_OTLP_ENDPOINT")); + } + const lowered = normalizedOtel.toLowerCase(); + if (lowered === "true") { return true; } - if (envOtel === "false") { + if (lowered === "false") { return false; } - return Boolean(readEnv("OTEL_EXPORTER_OTLP_ENDPOINT")); + throw new Error(`Invalid LEAP0_SDK_OTEL_ENABLED value: ${normalizedOtel}`); +} + +function wrapConfigError(error: unknown): never { + if (error instanceof ZodError) { + throw new Leap0Error(`Invalid Leap0 config: ${error.issues.map((issue) => issue.message).join("; ")}`, { + cause: error, + body: error.issues, + }); + } + throw error; +} + +function parseConfigOrThrow(result: { success: true; data: T } | { success: false; error: ZodError }): T { + if (result.success) { + return result.data; + } + wrapConfigError(result.error); } /** @@ -40,28 +64,31 @@ function resolveSdkOtelFromEnv(envOtel: string | undefined): boolean { * The validated runtime configuration used by the SDK. */ export function resolveConfig(input: Leap0ConfigInput = {}): Leap0ConfigResolved { - leap0ConfigInputSchema.parse(input); + try { + parseConfigOrThrow(leap0ConfigInputSchema.safeParse(input)); + const apiKey = apiKeyRequiredSchema.parse(input.apiKey ?? readEnv("LEAP0_API_KEY")); + const baseUrl = trimSlash( + (input.baseUrl ?? readEnv("LEAP0_BASE_URL") ?? DEFAULT_BASE_URL).trim(), + ); + const sandboxDomain = trimSlash( + (input.sandboxDomain ?? readEnv("LEAP0_SANDBOX_DOMAIN") ?? DEFAULT_SANDBOX_DOMAIN).trim(), + ); + const timeout = timeoutSchema.parse(input.timeout ?? DEFAULT_CLIENT_TIMEOUT); + const authHeader = authHeaderSchema.parse(input.authHeader ?? "authorization"); + const bearer = input.bearer ?? true; + const envOtel = readEnv("LEAP0_SDK_OTEL_ENABLED"); + const sdkOtelEnabled = input.sdkOtelEnabled ?? resolveSdkOtelFromEnv(envOtel); - const apiKey = apiKeyRequiredSchema.parse(input.apiKey ?? readEnv("LEAP0_API_KEY")); - const baseUrl = trimSlash( - (input.baseUrl ?? readEnv("LEAP0_BASE_URL") ?? DEFAULT_BASE_URL).trim(), - ); - const sandboxDomain = trimSlash( - (input.sandboxDomain ?? readEnv("LEAP0_SANDBOX_DOMAIN") ?? DEFAULT_SANDBOX_DOMAIN).trim(), - ); - const timeout = timeoutSchema.parse(input.timeout ?? DEFAULT_CLIENT_TIMEOUT); - const authHeader = authHeaderSchema.parse(input.authHeader ?? "authorization"); - const bearer = input.bearer ?? true; - const envOtel = readEnv("LEAP0_SDK_OTEL_ENABLED"); - const sdkOtelEnabled = input.sdkOtelEnabled ?? resolveSdkOtelFromEnv(envOtel); - - return leap0ConfigResolvedSchema.parse({ - apiKey, - baseUrl, - sandboxDomain, - timeout, - authHeader, - bearer, - sdkOtelEnabled, - }); + return parseConfigOrThrow(leap0ConfigResolvedSchema.safeParse({ + apiKey, + baseUrl, + sandboxDomain, + timeout, + authHeader, + bearer, + sdkOtelEnabled, + })); + } catch (error) { + wrapConfigError(error); + } } diff --git a/src/models/desktop.ts b/src/models/desktop.ts index 1a2d5ee..e2d56a4 100644 --- a/src/models/desktop.ts +++ b/src/models/desktop.ts @@ -19,8 +19,8 @@ export type DesktopPointerPosition = z.infer; @@ -28,19 +28,21 @@ export type DesktopSetScreenParams = z.infer (params.width === undefined) === (params.height === undefined), { + message: "width and height must be provided together", }); export type DesktopScreenshotParams = z.infer; export const desktopScreenshotRegionParamsSchema = z .object({ - x: z.number(), - y: z.number(), - width: z.number(), - height: z.number(), + x: z.number().int().min(0), + y: z.number().int().min(0), + width: z.number().int().min(1), + height: z.number().int().min(1), format: z.enum(["png", "jpg", "jpeg"]).optional(), quality: z.number().int().min(1).max(100).optional(), }) @@ -49,19 +51,22 @@ export type DesktopScreenshotRegionParams = z.infer (params.x === undefined) === (params.y === undefined), { + message: "x and y must be provided together or both omitted", + }); export type DesktopClickParams = z.infer; export const desktopDragParamsSchema = z .object({ - fromX: z.number(), - fromY: z.number(), - toX: z.number(), - toY: z.number(), + fromX: z.number().int().min(0), + fromY: z.number().int().min(0), + toX: z.number().int().min(0), + toY: z.number().int().min(0), button: z.number().int().min(1).max(3).optional(), }) .catchall(z.unknown()); diff --git a/src/models/filesystem.ts b/src/models/filesystem.ts index ff0891a..86a0e30 100644 --- a/src/models/filesystem.ts +++ b/src/models/filesystem.ts @@ -79,9 +79,9 @@ export type EditFilesResult = z.infer; export const setPermissionsParamsSchema = z .object({ - mode: z.string().optional(), - owner: z.string().optional(), - group: z.string().optional(), + mode: z.string().trim().min(1).optional(), + owner: z.string().trim().min(1).optional(), + group: z.string().trim().min(1).optional(), }) .refine((params) => params.mode !== undefined || params.owner !== undefined || params.group !== undefined, { message: "setPermissions requires at least one of mode, owner, or group", diff --git a/src/models/sandbox.ts b/src/models/sandbox.ts index db12350..183b799 100644 --- a/src/models/sandbox.ts +++ b/src/models/sandbox.ts @@ -30,18 +30,70 @@ export const networkPolicyModeSchema = z.enum([ ]); export type NetworkPolicyMode = z.infer; +function isValidDomainPattern(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + + const host = trimmed.startsWith("*.") ? trimmed.slice(2) : trimmed; + if (!host || host.startsWith(".") || host.endsWith(".")) { + return false; + } + if (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(":")) { + return false; + } + + const labels = host.split("."); + if (labels.length < 2) { + return false; + } + return labels.every( + (label) => + label.length > 0 && + !label.startsWith("-") && + !label.endsWith("-") && + /^[A-Za-z0-9-]+$/.test(label), + ); +} + +function isValidCidr(value: string): boolean { + const [address, prefix] = value.split("/"); + if (!address || prefix === undefined || !/^\d+$/.test(prefix)) { + return false; + } + const octets = address.split("."); + if (octets.length !== 4) { + return false; + } + if (!octets.every((octet) => /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255)) { + return false; + } + const prefixNumber = Number(prefix); + return prefixNumber >= 0 && prefixNumber <= 32; +} + +const domainPatternSchema = z.string().refine(isValidDomainPattern, { + message: "domain must be a valid domain pattern", +}); + +const cidrSchema = z.string().refine(isValidCidr, { + message: "CIDR must be a valid IPv4 CIDR block", +}); + export const networkPolicySchema = z.object({ mode: networkPolicyModeSchema, - allowedDomains: z.array(z.string()).optional(), - allowedCidrs: z.array(z.string()).optional(), + allowedDomains: z.array(domainPatternSchema).max(50).optional(), + allowedCidrs: z.array(cidrSchema).max(10).optional(), transforms: z .array( z.object({ - domain: z.string(), + domain: domainPatternSchema, injectHeaders: z.record(z.string(), z.string()).optional(), stripHeaders: z.array(z.string()).optional(), }), ) + .max(20) .optional(), }); export type NetworkPolicy = z.infer; @@ -61,13 +113,17 @@ export const sandboxDataSchema = z .object({ id: z.string(), templateId: z.string(), + templateName: z.string().optional(), state: sandboxStateSchema, vcpu: z.number(), memoryMib: z.number(), diskMib: z.number(), + timeoutMin: z.number().optional(), autoPause: z.boolean().optional(), + envVars: z.record(z.string(), z.string()).optional(), networkPolicy: networkPolicySchema.optional(), createdAt: z.string(), + updatedAt: z.string().optional(), }) .catchall(z.unknown()); export type SandboxData = z.infer; diff --git a/src/models/snapshot.ts b/src/models/snapshot.ts index 8f3744a..6af17f5 100644 --- a/src/models/snapshot.ts +++ b/src/models/snapshot.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { networkPolicySchema, sandboxStateSchema } from "@/models/sandbox.js"; +const snapshotNameSchema = z.string().trim().min(1).max(64); + export const snapshotDataSchema = z .object({ id: z.string(), @@ -17,14 +19,14 @@ export const snapshotDataSchema = z export type SnapshotData = z.infer; export const createSnapshotParamsSchema = z.object({ - name: z.string().optional(), + name: snapshotNameSchema.optional(), }); export type CreateSnapshotParams = z.infer; export const resumeSnapshotParamsSchema = z.object({ - snapshotName: z.string(), + snapshotName: snapshotNameSchema, autoPause: z.boolean().optional(), - timeoutMin: z.number().int().positive().optional(), + timeoutMin: z.number().int().min(1).max(480).optional(), networkPolicy: networkPolicySchema.optional(), }); export type ResumeSnapshotParams = z.infer; diff --git a/src/models/template.ts b/src/models/template.ts index dfaba83..80831b6 100644 --- a/src/models/template.ts +++ b/src/models/template.ts @@ -13,8 +13,8 @@ export const RegistryCredentialType = { export const templateImageConfigSchema = z .object({ - entrypoint: z.array(z.string()).nullable(), - cmd: z.array(z.string()).nullable(), + entrypoint: z.array(z.string()).nullable().optional(), + cmd: z.array(z.string()).nullable().optional(), workingDir: z.string().optional(), user: z.string().optional(), env: z.record(z.string(), z.string()).nullable().optional(), @@ -88,7 +88,7 @@ export const createTemplateParamsSchema = z.object({ export type CreateTemplateParams = z.infer; export const templateNameSchema = z.string().superRefine((name, ctx) => { - if (!name.trim() || name.length > 64 || /\s/.test(name) || name.startsWith("system/")) { + if (!name.trim() || name.length > 64 || /\s/.test(name) || name.toLowerCase().startsWith("system/")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: TEMPLATE_NAME_ERROR_MESSAGE, @@ -110,7 +110,8 @@ export const createTemplateRequestSchema = createTemplateParamsSchema.extend({ uri: templateUriSchema, }); +export const renameTemplateNameSchema = z.string().min(1).max(64); export const renameTemplateParamsSchema = z.object({ - name: templateNameSchema, + name: renameTemplateNameSchema, }); export type RenameTemplateParams = z.infer; diff --git a/src/services/desktop.ts b/src/services/desktop.ts index c0ade1e..7b058be 100644 --- a/src/services/desktop.ts +++ b/src/services/desktop.ts @@ -26,6 +26,7 @@ import type { import { Leap0Transport, jsonBody } from "@/core/transport.js"; import { sandboxBaseUrl, sandboxIdOf } from "@/core/utils.js"; import { + desktopClickParamsSchema, desktopDisplayInfoSchema, desktopHealthSchema, desktopPointerPositionSchema, @@ -36,6 +37,9 @@ import { desktopProcessStatusSchema, desktopRecordingStatusSchema, desktopRecordingSummarySchema, + desktopScreenshotParamsSchema, + desktopScreenshotRegionParamsSchema, + desktopSetScreenParamsSchema, desktopWindowSchema, } from "@/models/desktop.js"; import { asRecord } from "@/services/shared.js"; @@ -90,11 +94,12 @@ export class DesktopClient { payload: DesktopSetScreenParams, options?: RequestOptions, ): Promise { + const parsed = desktopSetScreenParamsSchema.parse(payload); return this.requestJson( sandbox, desktopDisplayInfoSchema, "/api/display/screen", - { method: "POST", body: jsonBody(payload) }, + { method: "POST", body: jsonBody(parsed) }, options, ); } @@ -112,10 +117,11 @@ export class DesktopClient { params: DesktopScreenshotParams = {}, options?: RequestOptions, ): Promise { + const parsed = desktopScreenshotParamsSchema.parse(params); return await this.transport.requestBytesUrl( this.requestUrl(sandbox, "/api/screenshot"), { method: "GET" }, - { ...options, query: params }, + { ...options, query: parsed }, ); } async screenshotRegion( @@ -123,9 +129,10 @@ export class DesktopClient { payload: DesktopScreenshotRegionParams, options?: RequestOptions, ): Promise { + const parsed = desktopScreenshotRegionParamsSchema.parse(payload); return await this.transport.requestBytesUrl( this.requestUrl(sandbox, "/api/screenshot/region"), - { method: "POST", body: jsonBody(payload) }, + { method: "POST", body: jsonBody(parsed) }, options, ); } @@ -160,11 +167,12 @@ export class DesktopClient { params: DesktopClickParams = {}, options?: RequestOptions, ): Promise { + const parsed = desktopClickParamsSchema.parse(params); return this.requestJson( sandbox, desktopPointerPositionSchema, "/api/input/click", - { method: "POST", body: jsonBody(params) }, + { method: "POST", body: jsonBody(parsed) }, options, ); } diff --git a/src/services/filesystem.ts b/src/services/filesystem.ts index 98bce54..e24487c 100644 --- a/src/services/filesystem.ts +++ b/src/services/filesystem.ts @@ -25,6 +25,17 @@ import { sandboxIdOf } from "@/core/utils.js"; type JsonObject = Record; +function assertReadFileParams(params: { + offset?: number; + limit?: number; + head?: number; + tail?: number; +}): void { + if (params.head !== undefined && params.tail !== undefined) { + throw new Error("read-file head and tail are mutually exclusive"); + } +} + function compact(obj: JsonObject): JsonObject { const result: JsonObject = {}; for (const [key, value] of Object.entries(obj)) { @@ -158,6 +169,7 @@ export class FilesystemClient { params: { offset?: number; limit?: number; head?: number; tail?: number } = {}, options: RequestOptions = {}, ): Promise { + assertReadFileParams(params); return await this.transport.requestBytes( this.fsPath(sandbox, "read-file"), { method: "POST", body: jsonBody(compact({ path, ...params })) }, @@ -172,6 +184,7 @@ export class FilesystemClient { params: { offset?: number; limit?: number; head?: number; tail?: number } = {}, options: RequestOptions = {}, ): Promise { + assertReadFileParams(params); return await this.transport.requestText( this.fsPath(sandbox, "read-file"), { method: "POST", body: jsonBody(compact({ path, ...params })) }, @@ -194,9 +207,13 @@ export class FilesystemClient { const formData = await response.formData(); const result: Record = {}; for (const [name, value] of formData.entries()) { - if (value instanceof Blob) { - result[name] = new Uint8Array(await value.arrayBuffer()); + if (!(value instanceof Blob)) { + const valueType = typeof value; + throw new Error( + `Failed to parse /read-files response: expected Blob/File for entry "${name}", received ${valueType}`, + ); } + result[name] = new Uint8Array(await value.arrayBuffer()); } return result; } diff --git a/src/services/git.ts b/src/services/git.ts index 68127d2..32f3516 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -224,6 +224,7 @@ export class GitClient { path: string; branch: string; create?: boolean; + setUpstream?: boolean; }, options?: RequestOptions, ): Promise { @@ -232,7 +233,12 @@ export class GitClient { await this.json( sandbox, "checkout-branch", - compact({ path: params.path, branch: params.branch, create: params.create }), + compact({ + path: params.path, + branch: params.branch, + create: params.create, + set_upstream: params.setUpstream, + }), options, ), ); diff --git a/src/services/snapshots.ts b/src/services/snapshots.ts index 6360c57..9cb74b4 100644 --- a/src/services/snapshots.ts +++ b/src/services/snapshots.ts @@ -8,7 +8,11 @@ import type { SnapshotRef, } from "@/models/index.js"; import { normalize } from "@/core/normalize.js"; -import { snapshotDataSchema } from "@/models/snapshot.js"; +import { + createSnapshotParamsSchema, + resumeSnapshotParamsSchema, + snapshotDataSchema, +} from "@/models/snapshot.js"; import { sandboxDataSchema, toNetworkPolicyWire } from "@/models/sandbox.js"; import { Leap0Transport, jsonBody } from "@/core/transport.js"; import { sandboxIdOf, snapshotIdOf } from "@/core/utils.js"; @@ -33,10 +37,11 @@ export class SnapshotsClient { params: CreateSnapshotParams = {}, options: RequestOptions = {}, ): Promise { + const parsed = createSnapshotParamsSchema.parse(params); return withErrorPrefix("Failed to create snapshot: ", async () => { const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/create`, - { method: "POST", body: jsonBody(params) }, + { method: "POST", body: jsonBody(parsed) }, options, ); return normalize(snapshotDataSchema, data); @@ -49,10 +54,11 @@ export class SnapshotsClient { params: CreateSnapshotParams = {}, options: RequestOptions = {}, ): Promise { + const parsed = createSnapshotParamsSchema.parse(params); return withErrorPrefix("Failed to pause sandbox into snapshot: ", async () => { const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/pause`, - { method: "POST", body: jsonBody(params) }, + { method: "POST", body: jsonBody(parsed) }, options, ); return normalize(snapshotDataSchema, data); @@ -61,16 +67,17 @@ export class SnapshotsClient { /** Restores a sandbox from a snapshot. */ async resume(params: ResumeSnapshotParams, options: RequestOptions = {}): Promise { + const parsed = resumeSnapshotParamsSchema.parse(params); return withErrorPrefix("Failed to resume snapshot: ", async () => { const data = await this.transport.requestJson( "/v1/snapshot/resume", { method: "POST", body: jsonBody({ - snapshot_name: params.snapshotName, - auto_pause: params.autoPause, - timeout_min: params.timeoutMin, - network_policy: toNetworkPolicyWire(params.networkPolicy), + snapshot_name: parsed.snapshotName, + auto_pause: parsed.autoPause, + timeout_min: parsed.timeoutMin, + network_policy: toNetworkPolicyWire(parsed.networkPolicy), }), }, options, diff --git a/src/services/templates.ts b/src/services/templates.ts index e8b0259..1108c4a 100644 --- a/src/services/templates.ts +++ b/src/services/templates.ts @@ -42,16 +42,15 @@ export class TemplatesClient { template: TemplateRef, params: RenameTemplateParams, options: RequestOptions = {}, - ): Promise { + ): Promise { const parsed = renameTemplateParamsSchema.parse(params); this.validateTemplateName(parsed.name); - return withErrorPrefix("Failed to rename template: ", async () => { - const data = await this.transport.requestJson( + await withErrorPrefix("Failed to rename template: ", async () => { + await this.transport.request( `/v1/template/${templateIdOf(template)}`, { method: "PATCH", body: jsonBody(parsed) }, options, ); - return normalize(templateDataSchema, data); }); } diff --git a/tests/client/client-sandbox.test.ts b/tests/client/client-sandbox.test.ts index 5220121..6cc63f4 100644 --- a/tests/client/client-sandbox.test.ts +++ b/tests/client/client-sandbox.test.ts @@ -122,6 +122,45 @@ test("Sandbox binds service methods to itself", async () => { assert.equal(sandbox.invokeUrl("/healthz", 3000), "invoke:sb-1:/healthz:3000"); }); +test("Sandbox refresh rejects invalid sandbox states", async () => { + const sandbox = new Sandbox( + { + sandboxes: { + get: async () => ({ + id: "sb-1", + templateId: "tpl-1", + state: "not-real", + vcpu: 1, + memoryMib: 1024, + diskMib: 4096, + createdAt: "2026-01-01T00:00:00Z", + }), + }, + [SERVICES]: { + filesystem: {}, + git: {}, + process: {}, + pty: {}, + lsp: {}, + ssh: {}, + codeInterpreter: {}, + desktop: {}, + }, + } as never, + { + id: "sb-1", + templateId: "tpl-1", + state: "running", + vcpu: 1, + memoryMib: 1024, + diskMib: 4096, + createdAt: "2026-01-01T00:00:00Z", + }, + ); + + await assert.rejects(() => sandbox.refresh(), /Expected this\.client\.sandboxes\.get\(\) to return SandboxData or Sandbox/); +}); + test("client and sandbox helpers stay strongly typed", () => { expectTypeOf>().toMatchTypeOf< Promise<{ id: string }> @@ -136,4 +175,8 @@ test("client and sandbox helpers stay strongly typed", () => { expectTypeOf().parameters.toEqualTypeOf< [accessId: string, password: string, options?: RequestOptions] >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf | undefined>(); + expectTypeOf().toEqualTypeOf(); }); diff --git a/tests/config/config-utils.test.ts b/tests/config/config-utils.test.ts index e9fe0b6..fffbcc3 100644 --- a/tests/config/config-utils.test.ts +++ b/tests/config/config-utils.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { afterEach, beforeEach, test } from "vitest"; import { resolveConfig } from "@/config/index.js"; +import { Leap0Error } from "@/core/errors.js"; import { ensureLeadingSlash, sandboxBaseUrl, @@ -84,6 +85,18 @@ test("resolveConfig enables sdk otel from standard OTEL env", () => { assert.equal(config.sdkOtelEnabled, true); }); +test("resolveConfig accepts case-insensitive sdk otel env values and rejects invalid strings", () => { + process.env.LEAP0_API_KEY = "env-key"; + process.env.LEAP0_SDK_OTEL_ENABLED = "TrUe"; + assert.equal(resolveConfig().sdkOtelEnabled, true); + + process.env.LEAP0_SDK_OTEL_ENABLED = "FaLsE"; + assert.equal(resolveConfig().sdkOtelEnabled, false); + + process.env.LEAP0_SDK_OTEL_ENABLED = "maybe"; + assert.throws(() => resolveConfig(), /Invalid LEAP0_SDK_OTEL_ENABLED value: maybe/); +}); + test("resolveConfig respects explicit sdk otel disable", () => { process.env.LEAP0_API_KEY = "env-key"; process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"; @@ -93,9 +106,9 @@ test("resolveConfig respects explicit sdk otel disable", () => { }); test("resolveConfig rejects invalid config", () => { - assert.throws(() => resolveConfig({ timeout: -1, apiKey: "key" })); - assert.throws(() => resolveConfig({ apiKey: " " })); - assert.throws(() => resolveConfig({ apiKey: "key", authHeader: " " })); + assert.throws(() => resolveConfig({ timeout: -1, apiKey: "key" }), Leap0Error); + assert.throws(() => resolveConfig({ apiKey: " " }), Leap0Error); + assert.throws(() => resolveConfig({ apiKey: "key", authHeader: " " }), Leap0Error); }); test("utility helpers normalize refs and urls", () => { diff --git a/tests/config/otel.test.ts b/tests/config/otel.test.ts index c6932d5..262b458 100644 --- a/tests/config/otel.test.ts +++ b/tests/config/otel.test.ts @@ -29,3 +29,16 @@ test("client skips otel initialization when disabled", async () => { await client.close(); } }); + +test("client shuts down otel on close and swallows shutdown errors", async () => { + vi.spyOn(otelModule, "initOtel").mockImplementation(() => {}); + const shutdownSpy = vi.spyOn(otelModule, "shutdownOtel").mockRejectedValue(new Error("boom")); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const client = new Leap0Client({ apiKey: "key", sdkOtelEnabled: true }); + await client.close(); + await client.close(); + + assert.equal(shutdownSpy.mock.calls.length, 1); + assert.equal(warnSpy.mock.calls.length, 1); +}); diff --git a/tests/models/network-policy.test.ts b/tests/models/network-policy.test.ts index 968fa16..96ce451 100644 --- a/tests/models/network-policy.test.ts +++ b/tests/models/network-policy.test.ts @@ -45,3 +45,24 @@ test("createSandboxParamsSchema accepts network policy modes", () => { assert.equal(denyAll.networkPolicy?.mode, NetworkPolicyMode.DENY_ALL); assert.equal(custom.networkPolicy?.mode, NetworkPolicyMode.CUSTOM); }); + +test("network policy schema enforces backend domain and cidr rules", () => { + assert.throws(() => + createSandboxParamsSchema.parse({ + networkPolicy: { mode: NetworkPolicyMode.CUSTOM, allowedDomains: ["localhost"] }, + }), + ); + assert.throws(() => + createSandboxParamsSchema.parse({ + networkPolicy: { mode: NetworkPolicyMode.CUSTOM, allowedCidrs: ["not-a-cidr"] }, + }), + ); + assert.throws(() => + createSandboxParamsSchema.parse({ + networkPolicy: { + mode: NetworkPolicyMode.CUSTOM, + transforms: [{ domain: "bad domain" }], + }, + }), + ); +}); diff --git a/tests/models/template-credentials.test.ts b/tests/models/template-credentials.test.ts index bc4ec7e..fd87092 100644 --- a/tests/models/template-credentials.test.ts +++ b/tests/models/template-credentials.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { createTemplateParamsSchema, RegistryCredentialType } from "@/models/template.js"; +import { + createTemplateParamsSchema, + RegistryCredentialType, + templateDataSchema, +} from "@/models/template.js"; import { TemplatesClient } from "@/services/templates.js"; test("accepts basic registry credentials", () => { @@ -86,3 +90,16 @@ test("TemplatesClient validates credentials before transport", async () => { assert.equal(called, false); }); + +test("template data accepts image configs without entrypoint or cmd", () => { + const parsed = templateDataSchema.parse({ + id: "tpl-1", + name: "custom", + digest: "sha256:abc", + imageConfig: { env: { APP_ENV: "test" } }, + isSystem: false, + createdAt: "2026-01-01T00:00:00Z", + }); + + assert.deepEqual(parsed.imageConfig, { env: { APP_ENV: "test" } }); +}); diff --git a/tests/services/desktop.test.ts b/tests/services/desktop.test.ts index c53048d..70849e2 100644 --- a/tests/services/desktop.test.ts +++ b/tests/services/desktop.test.ts @@ -82,6 +82,29 @@ test("desktop statusStream parses SSE and raises API errors", async () => { // health endpoint tested separately, it accepts 503 gracefully }); +test("desktop statusStream throws Leap0Error for SSE error frames", async () => { + const { transport } = createRecordedTransport({ + streamJsonUrl: async function* () { + yield { error: "Desktop request failed" }; + }, + }); + const client = new DesktopClient(transport as never); + + await assert.rejects( + async () => { + for await (const _event of client.statusStream("sb-1")) { + // consume stream + } + }, + (error: unknown) => { + assert.ok(error instanceof Leap0Error); + assert.equal(error.message, "Desktop status stream error"); + assert.equal(error.body, "Desktop request failed"); + return true; + }, + ); +}); + test("desktop waitUntilReady treats count-only updates as ready", async () => { const { transport } = createRecordedTransport({ streamJsonUrl: async function* () { @@ -115,3 +138,14 @@ test("desktop waitUntilReady ignores zero total count updates", async () => { await client.waitUntilReady("sb-1", 1); }); + +test("desktop client validates request payloads before transport", async () => { + const { transport, calls } = createRecordedTransport(); + const client = new DesktopClient(transport as never); + + await assert.rejects(() => client.resizeScreen("sb-1", { width: 100, height: 720 })); + await assert.rejects(() => client.screenshot("sb-1", { width: 100 })); + await assert.rejects(() => client.screenshotRegion("sb-1", { x: 0, y: 0, width: 0, height: 10 })); + await assert.rejects(() => client.click("sb-1", { x: 10 })); + assert.equal(calls.length, 0); +}); diff --git a/tests/services/filesystem.test.ts b/tests/services/filesystem.test.ts index 28c1247..5efa519 100644 --- a/tests/services/filesystem.test.ts +++ b/tests/services/filesystem.test.ts @@ -92,3 +92,38 @@ test("filesystem setPermissions rejects empty updates before transport", async ( ); assert.equal(calls.length, 0); }); + +test("filesystem setPermissions rejects blank string updates before transport", async () => { + const { transport, calls } = createRecordedTransport(); + const client = new FilesystemClient(transport as never); + + await assert.rejects(() => client.setPermissions("sb-1", "/tmp/a.txt", { mode: " " })); + await assert.rejects(() => client.setPermissions("sb-1", "/tmp/a.txt", { owner: "" })); + await assert.rejects(() => client.setPermissions("sb-1", "/tmp/a.txt", { group: " " })); + assert.equal(calls.length, 0); +}); + +test("filesystem readFilesBytes fails on unexpected non-blob form parts", async () => { + const form = new FormData(); + form.append("/tmp/meta.json", "not-a-file"); + const client = new FilesystemClient({ + ...createRecordedTransport().transport, + request: async () => new Response(form), + } as never); + + await assert.rejects( + () => client.readFilesBytes("sb-1", ["/tmp/meta.json"]), + /Failed to parse \/read-files response: expected Blob\/File for entry "\/tmp\/meta.json", received string/, + ); +}); + +test("filesystem readFile rejects head and tail together", async () => { + const { transport, calls } = createRecordedTransport(); + const client = new FilesystemClient(transport as never); + + await assert.rejects( + () => client.readFile("sb-1", "/tmp/a.txt", { head: 1, tail: 1 }), + /read-file head and tail are mutually exclusive/, + ); + assert.equal(calls.length, 0); +}); diff --git a/tests/services/git.test.ts b/tests/services/git.test.ts index 67eaffb..0fa74c4 100644 --- a/tests/services/git.test.ts +++ b/tests/services/git.test.ts @@ -6,7 +6,7 @@ import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("git client sends expected request shapes", async () => { const { transport, calls } = createRecordedTransport({ - requestJson: async (path: string, init: RequestInit, options: never) => { + requestJson: async (path: string, init: RequestInit, options = {}) => { calls.push({ path, init, options }); if (path.endsWith("/commit")) return { result: { output: "ok", exit_code: 0 } }; return { output: "ok", exit_code: 0 }; @@ -23,7 +23,11 @@ test("git client sends expected request shapes", async () => { await client.log("sb-1", { path: "/workspace/repo" }); await client.show("sb-1", "/workspace/repo", "HEAD"); await client.createBranch("sb-1", { path: "/workspace/repo", name: "feat" }); - await client.checkoutBranch("sb-1", { path: "/workspace/repo", branch: "feat" }); + await client.checkoutBranch("sb-1", { + path: "/workspace/repo", + branch: "feat", + setUpstream: true, + }); await client.deleteBranch("sb-1", "/workspace/repo", "feat"); await client.add("sb-1", "/workspace/repo", ["a.ts"]); await client.commit("sb-1", { @@ -37,6 +41,7 @@ test("git client sends expected request shapes", async () => { const diffCall = calls.find((call) => call.path === "/v1/sandbox/sb-1/git/diff"); const showCall = calls.find((call) => call.path === "/v1/sandbox/sb-1/git/show"); + const checkoutCall = calls.find((call) => call.path === "/v1/sandbox/sb-1/git/checkout-branch"); const addCall = calls.find((call) => call.path === "/v1/sandbox/sb-1/git/add"); const commitCall = calls.find((call) => call.path === "/v1/sandbox/sb-1/git/commit"); @@ -49,6 +54,12 @@ test("git client sends expected request shapes", async () => { assert.deepEqual(jsonOf(diffCall!), { path: "/workspace/repo", target: "main" }); assert.equal(showCall?.path, "/v1/sandbox/sb-1/git/show"); assert.deepEqual(jsonOf(showCall!), { path: "/workspace/repo", revision: "HEAD" }); + assert.equal(checkoutCall?.path, "/v1/sandbox/sb-1/git/checkout-branch"); + assert.deepEqual(jsonOf(checkoutCall!), { + path: "/workspace/repo", + branch: "feat", + set_upstream: true, + }); assert.equal(addCall?.path, "/v1/sandbox/sb-1/git/add"); assert.deepEqual(jsonOf(addCall!), { path: "/workspace/repo", files: ["a.ts"] }); assert.equal(commitCall?.path, "/v1/sandbox/sb-1/git/commit"); diff --git a/tests/services/normalization-cleanup.test.ts b/tests/services/normalization-cleanup.test.ts index 875182d..bb95cda 100644 --- a/tests/services/normalization-cleanup.test.ts +++ b/tests/services/normalization-cleanup.test.ts @@ -5,6 +5,7 @@ import { CodeInterpreterClient } from "@/services/code-interpreter.js"; import { FilesystemClient } from "@/services/filesystem.js"; import { SshClient } from "@/services/ssh.js"; import { TemplatesClient } from "@/services/templates.js"; +import { renameTemplateParamsSchema } from "@/models/template.js"; import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; test("template client normalizes created_at", async () => { @@ -37,23 +38,14 @@ test("template client normalizes created_at", async () => { test("template client validates and normalizes rename responses", async () => { const { transport, calls } = createRecordedTransport({ - requestJson: async (path: string, init: RequestInit, options: never) => { + request: async (path: string, init: RequestInit, options = {}) => { calls.push({ path, init, options }); - return { - id: "tpl-1", - name: "renamed", - digest: "sha256:def", - image_config: { entrypoint: ["python"], cmd: ["app.py"] }, - is_system: false, - created_at: "2026-01-01T00:00:00Z", - }; + return new Response(null, { status: 204 }); }, }); - const template = await new TemplatesClient(transport as never).rename("tpl-1", { name: "renamed" }); + await new TemplatesClient(transport as never).rename("tpl-1", { name: "renamed" }); - assert.equal(template.name, "renamed"); - assert.equal(template.createdAt, "2026-01-01T00:00:00Z"); assert.equal(calls[0]?.path, "/v1/template/tpl-1"); assert.deepEqual(jsonOf(calls[0]!), { name: "renamed" }); }); @@ -62,7 +54,8 @@ test("template client reuses name validation for rename", async () => { const { transport } = createRecordedTransport(); const client = new TemplatesClient(transport as never); - await assert.rejects(() => client.rename("tpl-1", { name: "system/test" }), /name must be non-empty/); + await assert.doesNotThrow(() => renameTemplateParamsSchema.parse({ name: "system/test" })); + await assert.rejects(() => client.rename("tpl-1", { name: "" })); }); test("ssh client normalizes expires_at", async () => { diff --git a/tests/services/pty.test.ts b/tests/services/pty.test.ts index 5728502..23b69c1 100644 --- a/tests/services/pty.test.ts +++ b/tests/services/pty.test.ts @@ -2,12 +2,13 @@ import assert from "node:assert/strict"; import { test } from "vitest"; import { Leap0WebSocketError } from "@/core/errors.js"; +import type { RequestOptions } from "@/models/index.js"; import { PtyClient, PtyConnection } from "@/services/pty.js"; import { createRecordedTransport } from "@tests/utils/helpers.ts"; test("pty client sends expected request shapes", async () => { const { transport, calls } = createRecordedTransport({ - requestJson: async (path: string, init: RequestInit, options: never) => { + requestJson: async (path: string, init: RequestInit, options: RequestOptions = {}) => { calls.push({ path, init, options }); const session = { id: "sess-1", @@ -42,7 +43,8 @@ test("pty client sends expected request shapes", async () => { test("pty connection sends, receives, and closes websocket data", async () => { let sent: unknown; let closed = false; - const handlers = new Map void>(); + type MockListener = (event?: { data?: unknown }) => void; + const handlers = new Map(); const socket = { send(data: unknown) { sent = data; @@ -50,17 +52,26 @@ test("pty connection sends, receives, and closes websocket data", async () => { close() { closed = true; }, - addEventListener(type: string, handler: (event: { data?: unknown }) => void) { - handlers.set(type, handler); + addEventListener(type: string, handler: MockListener) { + handlers.set(type, [...(handlers.get(type) ?? []), handler]); }, - removeEventListener(type: string) { - handlers.delete(type); + removeEventListener(type: string, handler?: EventListenerOrEventListenerObject | null) { + if (handler == null) { + handlers.delete(type); + return; + } + const next = (handlers.get(type) ?? []).filter((registered) => registered !== handler); + if (next.length === 0) { + handlers.delete(type); + return; + } + handlers.set(type, next); }, }; const connection = new PtyConnection(socket as never); connection.send("hello"); const recv = connection.recv(); - handlers.get("message")?.({ data: "world" }); + handlers.get("message")?.[0]?.({ data: "world" }); assert.equal(sent, "hello"); assert.deepEqual(Array.from(await recv), Array.from(new TextEncoder().encode("world"))); connection.close(); @@ -68,21 +79,31 @@ test("pty connection sends, receives, and closes websocket data", async () => { }); test("pty connection rejects recv when websocket closes first", async () => { - const handlers = new Map void>(); + type MockListener = (event?: { data?: unknown }) => void; + const handlers = new Map(); const socket = { send() {}, close() {}, - addEventListener(type: string, handler: (event?: { data?: unknown }) => void) { - handlers.set(type, handler); + addEventListener(type: string, handler: MockListener) { + handlers.set(type, [...(handlers.get(type) ?? []), handler]); }, - removeEventListener(type: string) { - handlers.delete(type); + removeEventListener(type: string, handler?: EventListenerOrEventListenerObject | null) { + if (handler == null) { + handlers.delete(type); + return; + } + const next = (handlers.get(type) ?? []).filter((registered) => registered !== handler); + if (next.length === 0) { + handlers.delete(type); + return; + } + handlers.set(type, next); }, }; const connection = new PtyConnection(socket as never); const recv = connection.recv(); - handlers.get("close")?.(); + handlers.get("close")?.[0]?.(); await assert.rejects(recv, (error: unknown) => { assert.ok(error instanceof Leap0WebSocketError); diff --git a/tests/services/snapshots.test.ts b/tests/services/snapshots.test.ts index b74f2c5..ae3a09a 100644 --- a/tests/services/snapshots.test.ts +++ b/tests/services/snapshots.test.ts @@ -53,3 +53,15 @@ test("snapshots client sends expected request shapes", async () => { assert.equal(restored.templateId, "tpl-1"); assert.equal(calls[3]?.path, "/v1/snapshot/snap-1"); }); + +test("snapshots client validates snapshot names before transport", async () => { + const { transport, calls } = createRecordedTransport(); + const client = new SnapshotsClient(transport as never); + + await assert.rejects(() => client.create("sb-1", { name: "" })); + await assert.rejects(() => client.create("sb-1", { name: " " })); + await assert.rejects(() => client.resume({ snapshotName: "" })); + await assert.rejects(() => client.resume({ snapshotName: " " })); + await assert.rejects(() => client.resume({ snapshotName: "snap-a", timeoutMin: 999 })); + assert.equal(calls.length, 0); +}); From 16bf181e4790342f30857da37eabaf23342a45b2 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Fri, 3 Apr 2026 15:35:41 -0400 Subject: [PATCH 7/9] fixes --- src/client/index.ts | 12 +- src/config/index.ts | 37 +++--- src/models/desktop.ts | 73 ++++++------ src/models/filesystem.ts | 10 +- src/models/sandbox.ts | 3 + src/models/template.ts | 10 +- src/services/desktop.ts | 20 ++-- src/services/lsp.ts | 10 +- src/services/sandboxes.ts | 5 +- src/services/snapshots.ts | 6 +- tests/client/client-sandbox.test.ts | 5 +- tests/config/config-utils.test.ts | 9 +- tests/services/normalization-cleanup.test.ts | 3 +- tests/services/pty.test.ts | 112 ++++++++++--------- tests/services/sandboxes-client.test.ts | 5 +- 15 files changed, 182 insertions(+), 138 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 0fb4f5f..6be8300 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -77,8 +77,12 @@ export class Leap0Client { if (this.closed) { return; } - this.closed = true; - await this.transport.close(); + let transportError: unknown; + try { + await this.transport.close(); + } catch (error) { + transportError = error; + } if (this.sdkOtelEnabled) { try { await shutdownOtel(); @@ -86,6 +90,10 @@ export class Leap0Client { console.warn("Failed to shutdown OpenTelemetry providers", error); } } + this.closed = true; + if (transportError !== undefined) { + throw transportError; + } } } diff --git a/src/config/index.ts b/src/config/index.ts index 75522d8..3122117 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -34,20 +34,25 @@ function resolveSdkOtelFromEnv(envOtel: string | undefined): boolean { if (lowered === "false") { return false; } - throw new Error(`Invalid LEAP0_SDK_OTEL_ENABLED value: ${normalizedOtel}`); + throw new Leap0Error(`Invalid LEAP0_SDK_OTEL_ENABLED value: ${normalizedOtel}`); } function wrapConfigError(error: unknown): never { if (error instanceof ZodError) { - throw new Leap0Error(`Invalid Leap0 config: ${error.issues.map((issue) => issue.message).join("; ")}`, { - cause: error, - body: error.issues, - }); + throw new Leap0Error( + `Invalid Leap0 config: ${error.issues.map((issue) => issue.message).join("; ")}`, + { + cause: error, + body: error.issues, + }, + ); } throw error; } -function parseConfigOrThrow(result: { success: true; data: T } | { success: false; error: ZodError }): T { +function parseConfigOrThrow( + result: { success: true; data: T } | { success: false; error: ZodError }, +): T { if (result.success) { return result.data; } @@ -79,15 +84,17 @@ export function resolveConfig(input: Leap0ConfigInput = {}): Leap0ConfigResolved const envOtel = readEnv("LEAP0_SDK_OTEL_ENABLED"); const sdkOtelEnabled = input.sdkOtelEnabled ?? resolveSdkOtelFromEnv(envOtel); - return parseConfigOrThrow(leap0ConfigResolvedSchema.safeParse({ - apiKey, - baseUrl, - sandboxDomain, - timeout, - authHeader, - bearer, - sdkOtelEnabled, - })); + return parseConfigOrThrow( + leap0ConfigResolvedSchema.safeParse({ + apiKey, + baseUrl, + sandboxDomain, + timeout, + authHeader, + bearer, + sdkOtelEnabled, + }), + ); } catch (error) { wrapConfigError(error); } diff --git a/src/models/desktop.ts b/src/models/desktop.ts index e2d56a4..9162c3d 100644 --- a/src/models/desktop.ts +++ b/src/models/desktop.ts @@ -17,36 +17,34 @@ export const desktopPointerPositionSchema = z .catchall(z.unknown()); export type DesktopPointerPosition = z.infer; -export const desktopSetScreenParamsSchema = z - .object({ - width: z.number().int().min(320).max(7680), - height: z.number().int().min(320).max(4320), - }) - .catchall(z.unknown()); -export type DesktopSetScreenParams = z.infer; - -export const desktopScreenshotParamsSchema = z.object({ - format: z.enum(["png", "jpg", "jpeg"]).optional(), - quality: z.number().int().min(1).max(100).optional(), - x: z.number().int().min(0).optional(), - y: z.number().int().min(0).optional(), - width: z.number().int().min(1).optional(), - height: z.number().int().min(1).optional(), -}).refine((params) => (params.width === undefined) === (params.height === undefined), { - message: "width and height must be provided together", +export const desktopSetScreenParamsSchema = z.object({ + width: z.number().int().min(320).max(7680), + height: z.number().int().min(320).max(4320), }); -export type DesktopScreenshotParams = z.infer; +export type DesktopSetScreenParams = z.infer; -export const desktopScreenshotRegionParamsSchema = z +export const desktopScreenshotParamsSchema = z .object({ - x: z.number().int().min(0), - y: z.number().int().min(0), - width: z.number().int().min(1), - height: z.number().int().min(1), format: z.enum(["png", "jpg", "jpeg"]).optional(), quality: z.number().int().min(1).max(100).optional(), + x: z.number().int().min(0).optional(), + y: z.number().int().min(0).optional(), + width: z.number().int().min(1).optional(), + height: z.number().int().min(1).optional(), }) - .catchall(z.unknown()); + .refine((params) => (params.width === undefined) === (params.height === undefined), { + message: "width and height must be provided together", + }); +export type DesktopScreenshotParams = z.infer; + +export const desktopScreenshotRegionParamsSchema = z.object({ + x: z.number().int().min(0), + y: z.number().int().min(0), + width: z.number().int().min(1), + height: z.number().int().min(1), + format: z.enum(["png", "jpg", "jpeg"]).optional(), + quality: z.number().int().min(1).max(100).optional(), +}); export type DesktopScreenshotRegionParams = z.infer; export const desktopClickParamsSchema = z @@ -55,29 +53,24 @@ export const desktopClickParamsSchema = z y: z.number().int().min(0).optional(), button: z.number().int().min(1).max(3).optional(), }) - .catchall(z.unknown()) .refine((params) => (params.x === undefined) === (params.y === undefined), { message: "x and y must be provided together or both omitted", }); export type DesktopClickParams = z.infer; -export const desktopDragParamsSchema = z - .object({ - fromX: z.number().int().min(0), - fromY: z.number().int().min(0), - toX: z.number().int().min(0), - toY: z.number().int().min(0), - button: z.number().int().min(1).max(3).optional(), - }) - .catchall(z.unknown()); +export const desktopDragParamsSchema = z.object({ + fromX: z.number().int().min(0), + fromY: z.number().int().min(0), + toX: z.number().int().min(0), + toY: z.number().int().min(0), + button: z.number().int().min(1).max(3).optional(), +}); export type DesktopDragParams = z.infer; -export const desktopScrollParamsSchema = z - .object({ - direction: z.enum(["up", "down", "left", "right"]), - amount: z.number().int().min(1).max(100).optional(), - }) - .catchall(z.unknown()); +export const desktopScrollParamsSchema = z.object({ + direction: z.enum(["up", "down", "left", "right"]), + amount: z.number().int().min(1).max(100).optional(), +}); export type DesktopScrollParams = z.infer; export const desktopWindowSchema = z diff --git a/src/models/filesystem.ts b/src/models/filesystem.ts index 86a0e30..5709333 100644 --- a/src/models/filesystem.ts +++ b/src/models/filesystem.ts @@ -83,6 +83,10 @@ export const setPermissionsParamsSchema = z owner: z.string().trim().min(1).optional(), group: z.string().trim().min(1).optional(), }) - .refine((params) => params.mode !== undefined || params.owner !== undefined || params.group !== undefined, { - message: "setPermissions requires at least one of mode, owner, or group", - }); + .refine( + (params) => + params.mode !== undefined || params.owner !== undefined || params.group !== undefined, + { + message: "setPermissions requires at least one of mode, owner, or group", + }, + ); diff --git a/src/models/sandbox.ts b/src/models/sandbox.ts index 183b799..bcfbe54 100644 --- a/src/models/sandbox.ts +++ b/src/models/sandbox.ts @@ -58,6 +58,9 @@ function isValidDomainPattern(value: string): boolean { } function isValidCidr(value: string): boolean { + if (value.indexOf("/") !== value.lastIndexOf("/")) { + return false; + } const [address, prefix] = value.split("/"); if (!address || prefix === undefined || !/^\d+$/.test(prefix)) { return false; diff --git a/src/models/template.ts b/src/models/template.ts index 80831b6..359c3be 100644 --- a/src/models/template.ts +++ b/src/models/template.ts @@ -88,7 +88,12 @@ export const createTemplateParamsSchema = z.object({ export type CreateTemplateParams = z.infer; export const templateNameSchema = z.string().superRefine((name, ctx) => { - if (!name.trim() || name.length > 64 || /\s/.test(name) || name.toLowerCase().startsWith("system/")) { + if ( + !name.trim() || + name.length > 64 || + /\s/.test(name) || + name.toLowerCase().startsWith("system/") + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: TEMPLATE_NAME_ERROR_MESSAGE, @@ -110,8 +115,7 @@ export const createTemplateRequestSchema = createTemplateParamsSchema.extend({ uri: templateUriSchema, }); -export const renameTemplateNameSchema = z.string().min(1).max(64); export const renameTemplateParamsSchema = z.object({ - name: renameTemplateNameSchema, + name: templateNameSchema, }); export type RenameTemplateParams = z.infer; diff --git a/src/services/desktop.ts b/src/services/desktop.ts index 7b058be..5579773 100644 --- a/src/services/desktop.ts +++ b/src/services/desktop.ts @@ -28,6 +28,7 @@ import { sandboxBaseUrl, sandboxIdOf } from "@/core/utils.js"; import { desktopClickParamsSchema, desktopDisplayInfoSchema, + desktopDragParamsSchema, desktopHealthSchema, desktopPointerPositionSchema, desktopProcessErrorsSchema, @@ -39,6 +40,7 @@ import { desktopRecordingSummarySchema, desktopScreenshotParamsSchema, desktopScreenshotRegionParamsSchema, + desktopScrollParamsSchema, desktopSetScreenParamsSchema, desktopWindowSchema, } from "@/models/desktop.js"; @@ -181,6 +183,7 @@ export class DesktopClient { payload: DesktopDragParams, options?: RequestOptions, ): Promise { + const parsed = desktopDragParamsSchema.parse(payload); return this.requestJson( sandbox, desktopPointerPositionSchema, @@ -188,11 +191,11 @@ export class DesktopClient { { method: "POST", body: jsonBody({ - from_x: payload.fromX, - from_y: payload.fromY, - to_x: payload.toX, - to_y: payload.toY, - button: payload.button, + from_x: parsed.fromX, + from_y: parsed.fromY, + to_x: parsed.toX, + to_y: parsed.toY, + button: parsed.button, }), }, options, @@ -203,11 +206,12 @@ export class DesktopClient { payload: DesktopScrollParams, options?: RequestOptions, ): Promise { + const parsed = desktopScrollParamsSchema.parse(payload); return this.requestJson( sandbox, desktopPointerPositionSchema, "/api/input/scroll", - { method: "POST", body: jsonBody(payload) }, + { method: "POST", body: jsonBody(parsed) }, options, ); } @@ -402,10 +406,10 @@ export class DesktopClient { } const record = asRecord(event); if (record.error !== undefined) { - throw new Leap0Error("Desktop status stream error", { body: String(record.error) }); + throw new Leap0Error("Desktop status stream error", { body: record.error }); } if (typeof record.message === "string") { - throw new Leap0Error(String(record.message)); + throw new Leap0Error(String(record.message), { body: record.body ?? record }); } yield normalize(desktopProcessStatusListSchema, event); } diff --git a/src/services/lsp.ts b/src/services/lsp.ts index 5b3590f..b5110b0 100644 --- a/src/services/lsp.ts +++ b/src/services/lsp.ts @@ -78,11 +78,11 @@ export class LspClient { versionOrOptions?: number | RequestOptions, options?: RequestOptions, ): Promise { - const { text, version, options: normalizedOptions } = this.normalizeDidOpenArgs( - textOrOptions, - versionOrOptions, - options, - ); + const { + text, + version, + options: normalizedOptions, + } = this.normalizeDidOpenArgs(textOrOptions, versionOrOptions, options); const payload: Record = { language_id: languageId, path_to_project: pathToProject, diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 45be27f..49c4c28 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -1,7 +1,4 @@ -import { - OTEL_EXPORTER_OTLP_ENDPOINT, - OTEL_EXPORTER_OTLP_HEADERS, -} from "@/config/constants.js"; +import { OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS } from "@/config/constants.js"; import { Leap0Error } from "@/core/errors.js"; import { normalize } from "@/core/normalize.js"; import { diff --git a/src/services/snapshots.ts b/src/services/snapshots.ts index 9cb74b4..bcc38b6 100644 --- a/src/services/snapshots.ts +++ b/src/services/snapshots.ts @@ -37,8 +37,8 @@ export class SnapshotsClient { params: CreateSnapshotParams = {}, options: RequestOptions = {}, ): Promise { - const parsed = createSnapshotParamsSchema.parse(params); return withErrorPrefix("Failed to create snapshot: ", async () => { + const parsed = createSnapshotParamsSchema.parse(params); const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/create`, { method: "POST", body: jsonBody(parsed) }, @@ -54,8 +54,8 @@ export class SnapshotsClient { params: CreateSnapshotParams = {}, options: RequestOptions = {}, ): Promise { - const parsed = createSnapshotParamsSchema.parse(params); return withErrorPrefix("Failed to pause sandbox into snapshot: ", async () => { + const parsed = createSnapshotParamsSchema.parse(params); const data = await this.transport.requestJson( `/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/pause`, { method: "POST", body: jsonBody(parsed) }, @@ -67,8 +67,8 @@ export class SnapshotsClient { /** Restores a sandbox from a snapshot. */ async resume(params: ResumeSnapshotParams, options: RequestOptions = {}): Promise { - const parsed = resumeSnapshotParamsSchema.parse(params); return withErrorPrefix("Failed to resume snapshot: ", async () => { + const parsed = resumeSnapshotParamsSchema.parse(params); const data = await this.transport.requestJson( "/v1/snapshot/resume", { diff --git a/tests/client/client-sandbox.test.ts b/tests/client/client-sandbox.test.ts index 6cc63f4..06da7eb 100644 --- a/tests/client/client-sandbox.test.ts +++ b/tests/client/client-sandbox.test.ts @@ -158,7 +158,10 @@ test("Sandbox refresh rejects invalid sandbox states", async () => { }, ); - await assert.rejects(() => sandbox.refresh(), /Expected this\.client\.sandboxes\.get\(\) to return SandboxData or Sandbox/); + await assert.rejects( + () => sandbox.refresh(), + /Expected this\.client\.sandboxes\.get\(\) to return SandboxData or Sandbox/, + ); }); test("client and sandbox helpers stay strongly typed", () => { diff --git a/tests/config/config-utils.test.ts b/tests/config/config-utils.test.ts index fffbcc3..67e68be 100644 --- a/tests/config/config-utils.test.ts +++ b/tests/config/config-utils.test.ts @@ -94,7 +94,14 @@ test("resolveConfig accepts case-insensitive sdk otel env values and rejects inv assert.equal(resolveConfig().sdkOtelEnabled, false); process.env.LEAP0_SDK_OTEL_ENABLED = "maybe"; - assert.throws(() => resolveConfig(), /Invalid LEAP0_SDK_OTEL_ENABLED value: maybe/); + assert.throws( + () => resolveConfig(), + (error: unknown) => { + assert.ok(error instanceof Leap0Error); + assert.match(String((error as Error).message), /Invalid LEAP0_SDK_OTEL_ENABLED value: maybe/); + return true; + }, + ); }); test("resolveConfig respects explicit sdk otel disable", () => { diff --git a/tests/services/normalization-cleanup.test.ts b/tests/services/normalization-cleanup.test.ts index bb95cda..dc814d5 100644 --- a/tests/services/normalization-cleanup.test.ts +++ b/tests/services/normalization-cleanup.test.ts @@ -54,7 +54,8 @@ test("template client reuses name validation for rename", async () => { const { transport } = createRecordedTransport(); const client = new TemplatesClient(transport as never); - await assert.doesNotThrow(() => renameTemplateParamsSchema.parse({ name: "system/test" })); + await assert.throws(() => renameTemplateParamsSchema.parse({ name: "system/test" })); + await assert.doesNotThrow(() => renameTemplateParamsSchema.parse({ name: "renamed" })); await assert.rejects(() => client.rename("tpl-1", { name: "" })); }); diff --git a/tests/services/pty.test.ts b/tests/services/pty.test.ts index 23b69c1..2667617 100644 --- a/tests/services/pty.test.ts +++ b/tests/services/pty.test.ts @@ -4,7 +4,48 @@ import { test } from "vitest"; import { Leap0WebSocketError } from "@/core/errors.js"; import type { RequestOptions } from "@/models/index.js"; import { PtyClient, PtyConnection } from "@/services/pty.js"; -import { createRecordedTransport } from "@tests/utils/helpers.ts"; +import { createRecordedTransport, jsonOf } from "@tests/utils/helpers.ts"; + +type MockListener = (event?: { data?: unknown }) => void; + +function createMockSocket() { + const handlers = new Map(); + const sent: unknown[] = []; + let closed = false; + + const socket = { + send(data: unknown) { + sent.push(data); + }, + close() { + closed = true; + }, + addEventListener(type: string, handler: MockListener) { + handlers.set(type, [...(handlers.get(type) ?? []), handler]); + }, + removeEventListener(type: string, handler?: EventListenerOrEventListenerObject | null) { + if (handler == null) { + handlers.delete(type); + return; + } + const next = (handlers.get(type) ?? []).filter((registered) => registered !== handler); + if (next.length === 0) { + handlers.delete(type); + return; + } + handlers.set(type, next); + }, + }; + + return { + socket, + handlers, + sent, + get closed() { + return closed; + }, + }; +} test("pty client sends expected request shapes", async () => { const { transport, calls } = createRecordedTransport({ @@ -31,8 +72,22 @@ test("pty client sends expected request shapes", async () => { await client.resize("sb-1", "sess-1", 120, 40); await client.delete("sb-1", "sess-1"); assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/pty"); + assert.equal(calls[0]?.init.method, "GET"); assert.equal(calls[1]?.path, "/v1/sandbox/sb-1/pty"); + assert.equal(calls[1]?.init.method, "POST"); + assert.deepEqual(jsonOf(calls[1]!), { + id: "sess-1", + cwd: "/workspace", + cols: 80, + rows: 24, + }); + assert.equal(calls[2]?.path, "/v1/sandbox/sb-1/pty/sess-1"); + assert.equal(calls[2]?.init.method, "GET"); assert.equal(calls[3]?.path, "/v1/sandbox/sb-1/pty/sess-1/resize"); + assert.equal(calls[3]?.init.method, "POST"); + assert.deepEqual(jsonOf(calls[3]!), { cols: 120, rows: 40 }); + assert.equal(calls[4]?.path, "/v1/sandbox/sb-1/pty/sess-1"); + assert.equal(calls[4]?.init.method, "DELETE"); assert.equal( client.websocketUrl("sb-1", "sess-1"), "wss://api.example.com/v1/sandbox/sb-1/pty/sess-1/connect", @@ -41,65 +96,20 @@ test("pty client sends expected request shapes", async () => { }); test("pty connection sends, receives, and closes websocket data", async () => { - let sent: unknown; - let closed = false; - type MockListener = (event?: { data?: unknown }) => void; - const handlers = new Map(); - const socket = { - send(data: unknown) { - sent = data; - }, - close() { - closed = true; - }, - addEventListener(type: string, handler: MockListener) { - handlers.set(type, [...(handlers.get(type) ?? []), handler]); - }, - removeEventListener(type: string, handler?: EventListenerOrEventListenerObject | null) { - if (handler == null) { - handlers.delete(type); - return; - } - const next = (handlers.get(type) ?? []).filter((registered) => registered !== handler); - if (next.length === 0) { - handlers.delete(type); - return; - } - handlers.set(type, next); - }, - }; + const mockSocket = createMockSocket(); + const { socket, handlers, sent } = mockSocket; const connection = new PtyConnection(socket as never); connection.send("hello"); const recv = connection.recv(); handlers.get("message")?.[0]?.({ data: "world" }); - assert.equal(sent, "hello"); + assert.deepEqual(sent, ["hello"]); assert.deepEqual(Array.from(await recv), Array.from(new TextEncoder().encode("world"))); connection.close(); - assert.equal(closed, true); + assert.equal(mockSocket.closed, true); }); test("pty connection rejects recv when websocket closes first", async () => { - type MockListener = (event?: { data?: unknown }) => void; - const handlers = new Map(); - const socket = { - send() {}, - close() {}, - addEventListener(type: string, handler: MockListener) { - handlers.set(type, [...(handlers.get(type) ?? []), handler]); - }, - removeEventListener(type: string, handler?: EventListenerOrEventListenerObject | null) { - if (handler == null) { - handlers.delete(type); - return; - } - const next = (handlers.get(type) ?? []).filter((registered) => registered !== handler); - if (next.length === 0) { - handlers.delete(type); - return; - } - handlers.set(type, next); - }, - }; + const { socket, handlers } = createMockSocket(); const connection = new PtyConnection(socket as never); const recv = connection.recv(); diff --git a/tests/services/sandboxes-client.test.ts b/tests/services/sandboxes-client.test.ts index 21aa4ad..ea16e61 100644 --- a/tests/services/sandboxes-client.test.ts +++ b/tests/services/sandboxes-client.test.ts @@ -63,7 +63,10 @@ test("sandboxes create validates payload and wraps result", async () => { test("sandboxes create rejects invalid parameters", async () => { const { client } = makeClient(); await assert.rejects(() => client.create(null as never), /params must be an object/); - await assert.rejects(() => client.create({ templateName: 123 as never }), /templateName must be a string/); + await assert.rejects( + () => client.create({ templateName: 123 as never }), + /templateName must be a string/, + ); await assert.rejects(() => client.create({ vcpu: 0 }), Leap0Error); await assert.rejects(() => client.create({ memoryMib: 513 }), Leap0Error); await assert.rejects(() => client.create({ timeoutMin: 999 }), Leap0Error); From 130587b612bad01703d99fc15f5d98b50773cd6b Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Fri, 3 Apr 2026 15:58:16 -0400 Subject: [PATCH 8/9] fixes --- src/config/index.ts | 12 +++++++++--- src/models/desktop.ts | 35 ++++++++++++++++++----------------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 3122117..cfce816 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -71,15 +71,21 @@ function parseConfigOrThrow( export function resolveConfig(input: Leap0ConfigInput = {}): Leap0ConfigResolved { try { parseConfigOrThrow(leap0ConfigInputSchema.safeParse(input)); - const apiKey = apiKeyRequiredSchema.parse(input.apiKey ?? readEnv("LEAP0_API_KEY")); + const apiKey = parseConfigOrThrow( + apiKeyRequiredSchema.safeParse(input.apiKey ?? readEnv("LEAP0_API_KEY")), + ); const baseUrl = trimSlash( (input.baseUrl ?? readEnv("LEAP0_BASE_URL") ?? DEFAULT_BASE_URL).trim(), ); const sandboxDomain = trimSlash( (input.sandboxDomain ?? readEnv("LEAP0_SANDBOX_DOMAIN") ?? DEFAULT_SANDBOX_DOMAIN).trim(), ); - const timeout = timeoutSchema.parse(input.timeout ?? DEFAULT_CLIENT_TIMEOUT); - const authHeader = authHeaderSchema.parse(input.authHeader ?? "authorization"); + const timeout = parseConfigOrThrow( + timeoutSchema.safeParse(input.timeout ?? DEFAULT_CLIENT_TIMEOUT), + ); + const authHeader = parseConfigOrThrow( + authHeaderSchema.safeParse(input.authHeader ?? "authorization"), + ); const bearer = input.bearer ?? true; const envOtel = readEnv("LEAP0_SDK_OTEL_ENABLED"); const sdkOtelEnabled = input.sdkOtelEnabled ?? resolveSdkOtelFromEnv(envOtel); diff --git a/src/models/desktop.ts b/src/models/desktop.ts index 9162c3d..9384e29 100644 --- a/src/models/desktop.ts +++ b/src/models/desktop.ts @@ -3,16 +3,16 @@ import { z } from "zod"; export const desktopDisplayInfoSchema = z .object({ display: z.string(), - width: z.number(), - height: z.number(), + width: z.number().int(), + height: z.number().int(), }) .catchall(z.unknown()); export type DesktopDisplayInfo = z.infer; export const desktopPointerPositionSchema = z .object({ - x: z.number(), - y: z.number(), + x: z.number().int(), + y: z.number().int(), }) .catchall(z.unknown()); export type DesktopPointerPosition = z.infer; @@ -34,6 +34,9 @@ export const desktopScreenshotParamsSchema = z }) .refine((params) => (params.width === undefined) === (params.height === undefined), { message: "width and height must be provided together", + }) + .refine((params) => (params.x === undefined) === (params.y === undefined), { + message: "x and y must be provided together", }); export type DesktopScreenshotParams = z.infer; @@ -76,12 +79,12 @@ export type DesktopScrollParams = z.infer; export const desktopWindowSchema = z .object({ id: z.string().optional(), - desktop: z.number().optional(), - pid: z.number().optional(), - x: z.number().optional(), - y: z.number().optional(), - width: z.number().optional(), - height: z.number().optional(), + desktop: z.number().int().optional(), + pid: z.number().int().optional(), + x: z.number().int().optional(), + y: z.number().int().optional(), + width: z.number().int().optional(), + height: z.number().int().optional(), class: z.string().optional(), host: z.string().optional(), title: z.string().optional(), @@ -103,7 +106,7 @@ export const desktopRecordingSummarySchema = z fileName: z.string().optional(), download: z.string().optional(), mimeType: z.string().optional(), - sizeBytes: z.number().optional(), + sizeBytes: z.number().int().optional(), createdAt: z.string().optional(), active: z.boolean().optional(), }) @@ -132,18 +135,16 @@ export const desktopProcessStatusSchema = z pid: z.number().optional(), stdoutLog: z.string().optional(), stderrLog: z.string().optional(), - }) - .catchall(z.unknown()); + }); export type DesktopProcessStatus = z.infer; export const desktopProcessStatusListSchema = z .object({ status: z.enum(["running", "degraded", "stopped"]).optional(), items: z.array(desktopProcessStatusSchema).optional(), - running: z.number().optional(), - total: z.number().optional(), - }) - .catchall(z.unknown()); + running: z.number().int().optional(), + total: z.number().int().optional(), + }); export type DesktopProcessStatusList = z.infer; export const desktopStatusStreamEventSchema = desktopProcessStatusListSchema; From 711502ffd0cf684a11c0d661b9b74f0f2660b618 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Fri, 3 Apr 2026 16:00:14 -0400 Subject: [PATCH 9/9] fixes --- src/models/desktop.ts | 22 ++++---- tests/services/desktop.test.ts | 96 +++++++++++++++++++++++++++++++--- 2 files changed, 99 insertions(+), 19 deletions(-) diff --git a/src/models/desktop.ts b/src/models/desktop.ts index 9384e29..5e73eb4 100644 --- a/src/models/desktop.ts +++ b/src/models/desktop.ts @@ -29,8 +29,8 @@ export const desktopScreenshotParamsSchema = z quality: z.number().int().min(1).max(100).optional(), x: z.number().int().min(0).optional(), y: z.number().int().min(0).optional(), - width: z.number().int().min(1).optional(), - height: z.number().int().min(1).optional(), + width: z.number().int().min(0).optional(), + height: z.number().int().min(0).optional(), }) .refine((params) => (params.width === undefined) === (params.height === undefined), { message: "width and height must be provided together", @@ -130,20 +130,20 @@ export type DesktopRecordingStatus = z.infer; export const desktopProcessStatusListSchema = z .object({ - status: z.enum(["running", "degraded", "stopped"]).optional(), - items: z.array(desktopProcessStatusSchema).optional(), - running: z.number().int().optional(), - total: z.number().int().optional(), + status: z.enum(["running", "degraded", "stopped"]), + items: z.array(desktopProcessStatusSchema), + running: z.number().int(), + total: z.number().int(), }); export type DesktopProcessStatusList = z.infer; diff --git a/tests/services/desktop.test.ts b/tests/services/desktop.test.ts index 70849e2..fc921c3 100644 --- a/tests/services/desktop.test.ts +++ b/tests/services/desktop.test.ts @@ -33,13 +33,34 @@ test("desktop client sends expected request shapes", async () => { if (path === "/api/status") return Promise.resolve({ status: "running", - items: [{ name: "x11vnc", running: true }], + items: [ + { + name: "x11vnc", + running: true, + stdout_log: "/tmp/x11vnc.stdout.log", + stderr_log: "/tmp/x11vnc.stderr.log", + }, + ], running: 1, total: 1, }); - if (path.endsWith("/status")) return Promise.resolve({ name: "x11vnc", running: true }); + if (path.endsWith("/status")) + return Promise.resolve({ + name: "x11vnc", + running: true, + stdout_log: "/tmp/x11vnc.stdout.log", + stderr_log: "/tmp/x11vnc.stderr.log", + }); if (path.endsWith("/restart")) - return Promise.resolve({ message: "restarted", status: { name: "x11vnc", running: true } }); + return Promise.resolve({ + message: "restarted", + status: { + name: "x11vnc", + running: true, + stdout_log: "/tmp/x11vnc.stdout.log", + stderr_log: "/tmp/x11vnc.stderr.log", + }, + }); if (path.endsWith("/logs")) return Promise.resolve({ process: "x11vnc", logs: "ok" }); if (path.endsWith("/errors")) return Promise.resolve({ process: "x11vnc", errors: "" }); return Promise.resolve({ ok: true }); @@ -68,7 +89,19 @@ test("desktop statusStream parses SSE and raises API errors", async () => { const { transport, calls } = createRecordedTransport({ streamJsonUrl: async function* (url: string) { calls.push({ path: new URL(url).pathname, url, init: { method: "GET" }, options: {} }); - yield { status: "running", items: [{ name: "x11vnc", running: true }], running: 1, total: 1 }; + yield { + status: "running", + items: [ + { + name: "x11vnc", + running: true, + stdout_log: "/tmp/x11vnc.stdout.log", + stderr_log: "/tmp/x11vnc.stderr.log", + }, + ], + running: 1, + total: 1, + }; }, requestJsonUrl: () => Promise.reject(new Leap0Error("Desktop request failed")), }); @@ -76,7 +109,19 @@ test("desktop statusStream parses SSE and raises API errors", async () => { const events: unknown[] = []; for await (const event of client.statusStream("sb-1")) events.push(event); assert.deepEqual(events, [ - { status: "running", items: [{ name: "x11vnc", running: true }], running: 1, total: 1 }, + { + status: "running", + items: [ + { + name: "x11vnc", + running: true, + stdoutLog: "/tmp/x11vnc.stdout.log", + stderrLog: "/tmp/x11vnc.stderr.log", + }, + ], + running: 1, + total: 1, + }, ]); assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/api/status/stream"); // health endpoint tested separately, it accepts 503 gracefully @@ -108,7 +153,19 @@ test("desktop statusStream throws Leap0Error for SSE error frames", async () => test("desktop waitUntilReady treats count-only updates as ready", async () => { const { transport } = createRecordedTransport({ streamJsonUrl: async function* () { - yield { items: [{ name: "x11vnc", running: true }], running: 1, total: 1 }; + yield { + status: "degraded", + items: [ + { + name: "x11vnc", + running: true, + stdout_log: "/tmp/x11vnc.stdout.log", + stderr_log: "/tmp/x11vnc.stderr.log", + }, + ], + running: 1, + total: 1, + }; }, }); const client = new DesktopClient(transport as never); @@ -130,8 +187,20 @@ test("desktop input methods require a real boolean ok response", async () => { test("desktop waitUntilReady ignores zero total count updates", async () => { const { transport } = createRecordedTransport({ streamJsonUrl: async function* () { - yield { items: [], running: 0, total: 0 }; - yield { items: [{ name: "x11vnc", running: true }], running: 1, total: 1 }; + yield { status: "stopped", items: [], running: 0, total: 0 }; + yield { + status: "degraded", + items: [ + { + name: "x11vnc", + running: true, + stdout_log: "/tmp/x11vnc.stdout.log", + stderr_log: "/tmp/x11vnc.stderr.log", + }, + ], + running: 1, + total: 1, + }; }, }); const client = new DesktopClient(transport as never); @@ -149,3 +218,14 @@ test("desktop client validates request payloads before transport", async () => { await assert.rejects(() => client.click("sb-1", { x: 10 })); assert.equal(calls.length, 0); }); + +test("desktop screenshot accepts zero-sized paired region query", async () => { + const { transport, calls } = createRecordedTransport(); + const client = new DesktopClient(transport as never); + + await client.screenshot("sb-1", { width: 0, height: 0 }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.url, "https://sb-1.sandbox.example.com/api/screenshot"); + assert.deepEqual(calls[0]?.options.query, { width: 0, height: 0 }); +});