From 5853055d24bd4fa66f924f8ed71d6926d5cd5d78 Mon Sep 17 00:00:00 2001 From: shwang Date: Wed, 2 Sep 2026 13:28:20 +0800 Subject: [PATCH 1/2] feat: complete sandbox runtime API v0.1 --- .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug.yml | 56 ++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/provider-rfc.yml | 43 + .github/dependabot.yml | 4 + AGENTS.md | 4 + CHANGELOG.md | 13 + CONTRIBUTING.md | 2 + DOCS-INDEX.md | 44 + NON_GOALS.md | 9 + ORIGIN_AND_PROVENANCE.md | 9 + README.md | 30 +- README.zh-CN.md | 26 +- ROADMAP.md | 35 + SECURITY.md | 13 + biome.json | 2 +- docs/00-v0.1-summary.md | 57 ++ docs/api-and-sdk.md | 80 ++ docs/clean-room-policy.md | 9 + docs/providers/local.md | 48 + docs/quickstart.md | 56 ++ docs/testing.md | 48 + package.json | 30 +- pnpm-lock.yaml | 260 ++++- scripts/check-docs.mjs | 59 ++ scripts/scan-public.sh | 11 +- spec/README.md | 11 + spec/capabilities.md | 12 + .../0001-provider-neutral-sandbox-runtime.md | 9 + spec/openapi.yaml | 551 +++++++++++ spec/protocol.md | 83 ++ spec/runtime-model.md | 12 +- src/cli.ts | 43 + src/conformance.ts | 271 +++++- src/index.ts | 4 + src/protocol.ts | 80 +- src/provider.ts | 35 +- src/providers/local.ts | 406 ++++++++ src/providers/mock.ts | 98 +- src/runtime.ts | 887 ++++++++++++++++-- src/sdk.ts | 215 +++++ src/server.ts | 297 ++++++ src/validation.ts | 214 +++++ tests/http-sdk.test.ts | 313 ++++++ tests/local-provider.test.ts | 213 +++++ tests/runtime.test.ts | 594 +++++++++++- tests/spec.test.ts | 55 ++ tests/validation.test.ts | 103 ++ tsconfig.build.json | 3 +- vitest.config.ts | 18 + 50 files changed, 5240 insertions(+), 241 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/provider-rfc.yml create mode 100644 CHANGELOG.md create mode 100644 DOCS-INDEX.md create mode 100644 ROADMAP.md create mode 100644 docs/00-v0.1-summary.md create mode 100644 docs/api-and-sdk.md create mode 100644 docs/providers/local.md create mode 100644 docs/quickstart.md create mode 100644 docs/testing.md create mode 100644 scripts/check-docs.mjs create mode 100644 spec/openapi.yaml create mode 100644 spec/protocol.md create mode 100644 src/cli.ts create mode 100644 src/providers/local.ts create mode 100644 src/sdk.ts create mode 100644 src/server.ts create mode 100644 src/validation.ts create mode 100644 tests/http-sdk.test.ts create mode 100644 tests/local-provider.test.ts create mode 100644 tests/spec.test.ts create mode 100644 tests/validation.test.ts create mode 100644 vitest.config.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..e84831e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @kevinten10 diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..5e32076 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,56 @@ +name: Bug report +description: Report reproducible portable-runtime behavior that differs from the specification. +title: "bug: " +labels: [bug] +body: + - type: markdown + attributes: + value: Do not include credentials, private infrastructure, user data, or proprietary code. + - type: input + id: version + attributes: + label: Version or commit + placeholder: v0.1.0 or full commit SHA + validations: + required: true + - type: dropdown + id: surface + attributes: + label: Surface + options: + - Protocol or specification + - Reference runtime + - HTTP or SSE server + - TypeScript SDK + - Local Provider + - Mock Provider + - Conformance runner + - Documentation + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal public reproduction + description: Use synthetic identifiers and public inputs only. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: checkboxes + id: safety + attributes: + label: Public-safety confirmation + options: + - label: This report contains no private code, endpoints, credentials, or user data. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..275608f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Private security report + url: https://github.com/capa-cloud/sandbox-runtime-api/security/advisories/new + about: Report vulnerabilities privately; do not publish exploit details. diff --git a/.github/ISSUE_TEMPLATE/provider-rfc.yml b/.github/ISSUE_TEMPLATE/provider-rfc.yml new file mode 100644 index 0000000..0e60de3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/provider-rfc.yml @@ -0,0 +1,43 @@ +name: Provider or protocol RFC +description: Propose a public-source Provider adapter or a change to the portable contract. +title: "rfc: " +labels: [rfc] +body: + - type: markdown + attributes: + value: Read ORIGIN_AND_PROVENANCE.md and docs/clean-room-policy.md before proposing a change. + - type: textarea + id: problem + attributes: + label: Portable problem + description: Explain why this is shared semantics rather than one deployment detail. + validations: + required: true + - type: textarea + id: sources + attributes: + label: Public sources + description: Link public specifications, SDKs, or documentation. Do not cite private material. + validations: + required: true + - type: textarea + id: capabilities + attributes: + label: Capability and conformance impact + description: Name capability changes, negative behavior, and tests required. + validations: + required: true + - type: textarea + id: security + attributes: + label: Security boundary + description: State what is and is not guaranteed. + validations: + required: true + - type: checkboxes + id: cleanroom + attributes: + label: Clean-room confirmation + options: + - label: This proposal is independently explainable from public sources or first principles. + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 54b34d4..236c3bd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,7 @@ updates: - '@types/*' - typescript - vitest + ignore: + - dependency-name: '@types/node' + update-types: + - version-update:semver-major diff --git a/AGENTS.md b/AGENTS.md index dbd32df..44ef167 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ provider SPI, mock adapter, SDK-facing types, and conformance checks. ## Read First +- `DOCS-INDEX.md` - `README.md` - `docs/clean-room-policy.md` - `ORIGIN_AND_PROVENANCE.md` @@ -19,6 +20,9 @@ provider SPI, mock adapter, SDK-facing types, and conformance checks. - Full check: `pnpm check` - Build: `pnpm build` - Tests: `pnpm test` +- Coverage: `pnpm test:coverage` +- Documentation: `pnpm docs:check` +- Package contents: `pnpm pack:check` - Public-content scan: `pnpm sanitize` ## Contract Rules diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a456771 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 0.1.0 - 2026-09-02 + +Initial public MVP: + +- lifecycle, generation fencing, reconciliation, and recreation; +- capability manifests and provider SPI; +- command, file, event replay, and SSE contracts; +- TypeScript SDK and CLI reference server; +- Local and Mock Providers; +- conformance, security-negative, HTTP/SDK, and concurrency tests; +- clean-room provenance, public-content scanning, OpenAPI, and bilingual documentation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45d1512..f0adbb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,8 @@ pnpm install pnpm check pnpm sanitize +pnpm test:coverage +pnpm pack:check ``` Use Node.js 22 or later. Keep each change scoped to one protocol or provider concern and add tests diff --git a/DOCS-INDEX.md b/DOCS-INDEX.md new file mode 100644 index 0000000..e53466b --- /dev/null +++ b/DOCS-INDEX.md @@ -0,0 +1,44 @@ +--- +id: docs-index +authority: canonical +status: canonical +title: Sandbox Runtime API documentation index +genre: spec +last_verified: 2026-09-02 +--- + +# Documentation Index + +## Active context + +The `v0.1` MVP is the current public baseline. It owns portable lifecycle, capability negotiation, +command and file operations, event replay, the TypeScript SDK, the loopback-only reference server, +the unsafe Local Provider, and provider conformance. + +Real cloud providers, production multi-tenancy, strong isolation, durable storage, authentication, +network policy enforcement, snapshots, PTY, and port forwarding remain outside the completed MVP. + +## Canonical contract + +- [Runtime model](spec/runtime-model.md) +- [Protocol](spec/protocol.md) +- [Capabilities](spec/capabilities.md) +- [OpenAPI](spec/openapi.yaml) +- [Provider-neutral decision](spec/decisions/0001-provider-neutral-sandbox-runtime.md) +- [Non-goals](NON_GOALS.md) +- [Origin and provenance](ORIGIN_AND_PROVENANCE.md) +- [Security policy](SECURITY.md) + +## Reference guides + +- [Quickstart](docs/quickstart.md) +- [API and SDK guide](docs/api-and-sdk.md) +- [Local Provider](docs/providers/local.md) +- [Testing and conformance](docs/testing.md) +- [Clean-room policy](docs/clean-room-policy.md) + +## Process and release evidence + +- [v0.1 completion summary](docs/00-v0.1-summary.md) +- [Changelog](CHANGELOG.md) +- [Roadmap](ROADMAP.md) diff --git a/NON_GOALS.md b/NON_GOALS.md index d0059f4..43e5e48 100644 --- a/NON_GOALS.md +++ b/NON_GOALS.md @@ -1,3 +1,12 @@ +--- +id: non-goals +authority: canonical +status: canonical +title: Project non-goals +genre: spec +last_verified: 2026-09-02 +--- + # Non-goals The project intentionally does not provide: diff --git a/ORIGIN_AND_PROVENANCE.md b/ORIGIN_AND_PROVENANCE.md index 7a7bff1..b7ead57 100644 --- a/ORIGIN_AND_PROVENANCE.md +++ b/ORIGIN_AND_PROVENANCE.md @@ -1,3 +1,12 @@ +--- +id: origin-and-provenance +authority: canonical +status: canonical +title: Origin and provenance +genre: spec +last_verified: 2026-09-02 +--- + # Origin and Provenance Sandbox Runtime API is an independently designed public project. diff --git a/README.md b/README.md index b39cfeb..ea25676 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # Sandbox Runtime API +[![CI](https://github.com/capa-cloud/sandbox-runtime-api/actions/workflows/ci.yml/badge.svg)](https://github.com/capa-cloud/sandbox-runtime-api/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) +

English | 简体中文

-Sandbox Runtime API is an independently designed, provider-neutral contract for creating, +Sandbox Runtime API `v0.1` is an independently designed, provider-neutral contract for creating, observing, controlling, and deleting isolated execution environments for AI agents and developer tools. @@ -31,7 +34,7 @@ Application or Harness Runtime | +--------+---------+----------+ | | | - Local Docker Kubernetes / Cloud + Local Mock Future custom Provider Provider Provider | sandbox agent @@ -53,19 +56,22 @@ Application Neither API requires the other. An integration may use both when it needs portable harness and portable sandbox semantics. -## Current Status +## v0.1 Features -Version `0.1.0-dev` is a clean-room, pre-release development baseline. It currently contains: +Version `0.1.0` contains: -- a transport-neutral TypeScript protocol model; +- a normative runtime model, protocol, capability vocabulary, and OpenAPI document; - capability preflight; -- generation-fenced mutation semantics; -- an in-memory runtime; -- a mock provider; +- idempotent lifecycle, reconciliation, generation fencing, and recreation; +- bounded command execution and sandbox-relative file operations; +- append-only event listing and resumable SSE projection; +- an in-memory runtime, TypeScript SDK, CLI, and loopback reference server; +- Mock and unsafe Local Providers; - a provider conformance runner; - public-source provenance and clean-room contribution rules. -The protocol is expected to change before `1.0.0`. +The API remains pre-1.0 and may change incompatibly. The Local Provider is not a security sandbox and +must never execute untrusted code. ## Quick Start @@ -74,8 +80,12 @@ Requirements: Node.js 22 or later and pnpm 10. ```bash pnpm install pnpm check +pnpm build ``` +Continue with the [Quickstart](docs/quickstart.md) or open the +[documentation index](DOCS-INDEX.md). + ## Repository Map | Path | Purpose | @@ -83,8 +93,10 @@ pnpm check | `src/protocol.ts` | Portable states, resources, capabilities, and errors | | `src/provider.ts` | Provider SPI | | `src/runtime.ts` | In-memory reference runtime | +| `src/providers/local.ts` | Unsafe local-process development Provider | | `src/providers/mock.ts` | Deterministic development provider | | `src/conformance.ts` | Reusable provider checks | +| `src/server.ts` / `src/sdk.ts` | HTTP/SSE reference server and TypeScript client | | `spec/` | Normative model and design decisions | | `docs/clean-room-policy.md` | Public-source and contribution boundary | diff --git a/README.zh-CN.md b/README.zh-CN.md index bb5b6d8..c922d7a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,10 +1,13 @@ # Sandbox Runtime API +[![CI](https://github.com/capa-cloud/sandbox-runtime-api/actions/workflows/ci.yml/badge.svg)](https://github.com/capa-cloud/sandbox-runtime-api/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) +

English | 简体中文

-Sandbox Runtime API 是一个独立设计、Provider 中立的公共契约,用于创建、观察、控制和删除 +Sandbox Runtime API `v0.1` 是一个独立设计、Provider 中立的公共契约,用于创建、观察、控制和删除 面向 AI Agent 与开发工具的隔离执行环境。 项目只标准化可移植的生命周期和能力语义。它不是托管 Sandbox 平台,不是 Agent 框架,也不是 @@ -29,7 +32,7 @@ Agent 应用通常需要相似的执行能力,但不同 Provider 对生命周 | +-------+--------+----------+ | | | -Local Docker Kubernetes / Cloud +Local Mock Future custom Provider Provider Provider | sandbox agent @@ -42,19 +45,21 @@ Harness 执行;Sandbox Runtime API 规范 Harness 或工具运行所需的隔 二者没有强制依赖。需要同时获得 Harness 与 Sandbox 可移植性时,可以组合使用。 -## 当前状态 +## v0.1 能力 -`0.1.0-dev` 是 clean-room 的开发基线,目前包含: +`0.1.0` 包含: -- 与传输无关的 TypeScript 协议模型; +- 规范化 Runtime 模型、协议、能力词表与 OpenAPI; - capability preflight; -- generation fencing; -- 内存参考 Runtime; -- Mock Provider; +- 幂等生命周期、reconcile、generation fencing 与重建; +- 有界命令执行和 Sandbox 相对路径文件操作; +- 追加事件列表与 SSE 投影; +- 内存 Runtime、TypeScript SDK、CLI 和 loopback 参考服务; +- Mock Provider 与不提供安全隔离的 Local Provider; - Provider conformance runner; - 公开来源与 clean-room 贡献规则。 -协议在 `1.0.0` 前可能发生不兼容变化。 +协议在 `1.0.0` 前可能发生不兼容变化。Local Provider 不是安全 Sandbox,禁止执行不可信代码。 ## 快速开始 @@ -63,8 +68,11 @@ Harness 执行;Sandbox Runtime API 规范 Harness 或工具运行所需的隔 ```bash pnpm install pnpm check +pnpm build ``` +继续阅读 [快速开始](docs/quickstart.md) 或 [文档索引](DOCS-INDEX.md)。 + ## Clean-room 边界 本仓库只基于公开规范、公开仓库和通用分布式系统原则独立设计。禁止提交私有代码、私有 API、 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..63b0a26 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,35 @@ +--- +id: roadmap +authority: process +status: active +title: Public roadmap +genre: draft +last_verified: 2026-09-02 +--- + +# Roadmap + +## Completed in v0.1 + +- portable lifecycle and generation fencing; +- capability negotiation; +- command and file operations; +- append-only event replay and SSE; +- TypeScript SDK and loopback reference server; +- unsafe Local Provider and Mock Provider; +- provider conformance; +- clean-room, public-content, documentation, security, and CI gates. + +## Candidate follow-ups + +- package publication after API review; +- durable event and resource repository SPI; +- explicit TTL and idle-expiration policy; +- PTY and port-forwarding extensions; +- snapshot and volume extension contracts; +- public Kubernetes Agent Sandbox adapter; +- public OCI container adapter; +- additional client SDKs. + +No provider enters the portable core solely because one deployment needs it. Cloud adapters require a +separate public-source RFC, capability mapping, security boundary, and conformance evidence. diff --git a/SECURITY.md b/SECURITY.md index d6c6f98..564e4dd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,12 @@ +--- +id: security-policy +authority: canonical +status: canonical +title: Security policy and deployment boundary +genre: spec +last_verified: 2026-09-02 +--- + # Security Policy ## Supported versions @@ -21,3 +30,7 @@ The reference runtime and mock provider are development implementations. They do A production adapter must document its trust boundary, capability limitations, credential model, and isolation evidence. Passing portable conformance does not certify security. + +The Local Provider runs processes with the current OS user's permissions. Those processes may access +the host filesystem, network, credentials, and services available to that user. Its file API path +checks do not make command execution safe for untrusted input. diff --git a/biome.json b/biome.json index dfde492..3fc8a14 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.9/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.11/schema.json", "files": { "includes": ["**", "!**/dist", "!**/coverage", "!**/node_modules"] }, diff --git a/docs/00-v0.1-summary.md b/docs/00-v0.1-summary.md new file mode 100644 index 0000000..51da63d --- /dev/null +++ b/docs/00-v0.1-summary.md @@ -0,0 +1,57 @@ +--- +id: v0-1-summary +authority: process +status: active +title: v0.1 completion summary +genre: summary +last_verified: 2026-09-02 +--- + +# v0.1 Completion Summary + +Sandbox Runtime API v0.1 delivers a clean-room, provider-neutral development contract from lifecycle +through command, file, event, HTTP, SDK, Local Provider, and conformance behavior. + +## Delivered + +- normative runtime, protocol, capability, and OpenAPI sources; +- generation-fenced lifecycle with idempotent concurrent creation; +- bounded command and file data-plane operations; +- monotonic in-memory events with list and SSE projections; +- TypeScript SDK, CLI, reference server, Mock Provider, and unsafe Local Provider; +- local and CI verification plus public-content and clean-room controls. + +## Verification snapshot + +As of 2026-09-02 on the candidate branch: + +- 5 test files and 83 tests pass; +- coverage passes at 88.93% statements, 84.80% branches, 93.04% functions, and 91.15% lines; +- formatting, lint, typecheck, build, OpenAPI/Markdown validation, public-content scan, Git history + scan, package dry-run, and production dependency audit pass; +- a built CLI smoke completed health, create, file write, command execution, event read, fenced + termination, process shutdown, and temporary-resource cleanup; +- a deliberate generation-fencing mutation made the targeted regression test fail, then passed after + restoration. +- a fresh read-only contributor session selected `AGENTS.md` → `DOCS-INDEX.md` → canonical specs, + found no competing entry or private-source instruction, and routed future cloud work to a public RFC. + +GitHub CI and the final merge SHA are release-time evidence and are not asserted by this pre-merge +snapshot. + +## Architecture decision + +The project standardizes semantics above isolation implementations. Providers declare capability and +must pass conformance, while deployments retain responsibility for security, authentication, durable +state, networking, and operations. + +## Known boundary + +No production isolation provider is included. The Local Provider is explicitly unsafe for untrusted +code. Cloud, Kubernetes, container, snapshot, PTY, and port-forwarding work requires separate public +RFCs and evidence. + +## Navigation + +Start at [DOCS-INDEX](../DOCS-INDEX.md), then read the canonical contract before a package or provider +guide. diff --git a/docs/api-and-sdk.md b/docs/api-and-sdk.md new file mode 100644 index 0000000..a036cba --- /dev/null +++ b/docs/api-and-sdk.md @@ -0,0 +1,80 @@ +--- +id: api-and-sdk +authority: reference +status: active +title: API and TypeScript SDK guide +genre: primer +last_verified: 2026-09-02 +--- + +# API and TypeScript SDK + +## HTTP resources + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/healthz` | Process health | +| `GET` | `/v1/runtime` | Protocol version and Provider manifest | +| `POST` | `/v1/sandboxes` | Idempotent create | +| `GET` | `/v1/sandboxes` | List resources | +| `GET` | `/v1/sandboxes/{id}` | Read one resource | +| `POST` | `/v1/sandboxes/{id}/actions/reconcile` | Refresh observed state | +| `POST` | `/v1/sandboxes/{id}/actions/{pause,resume,terminate,recreate}` | Fenced lifecycle mutation | +| `POST` | `/v1/sandboxes/{id}/commands` | Execute an argv command | +| `GET/PUT` | `/v1/sandboxes/{id}/files/content` | Read or write file bytes | +| `GET` | `/v1/sandboxes/{id}/files/entries` | List one directory | +| `GET` | `/v1/events` | Replay events after a cursor | +| `GET` | `/v1/events/stream` | SSE event stream | + +The machine-readable contract is [OpenAPI](../spec/openapi.yaml). + +The reference server accepts JSON request bodies up to 16 MiB by default so the Local Provider's +10 MiB raw-file limit remains reachable after base64 expansion. Embedders may lower the bound with +`maxBodyBytes` and should align it with their Provider's documented file limit. + +## TypeScript client + +```ts +// Inside a source checkout after `pnpm build`. +import { SandboxRuntimeClient } from '../dist/index.js' + +const client = new SandboxRuntimeClient('http://127.0.0.1:4311') +const sandbox = await client.create({ + clientRequestId: crypto.randomUUID(), + requiredCapabilities: ['commandExecution'], +}) + +const result = await client.execute(sandbox.id, { + expectedGeneration: sandbox.generation, + argv: ['printf', 'hello'], + timeoutSeconds: 10, +}) + +await client.terminate(sandbox.id, sandbox.generation) +``` + +The npm package is intentionally not published in v0.1. Package publication remains a separately +reviewed roadmap item. + +## Event replay + +```ts +for await (const event of client.streamEvents({ after: 0, sandboxId: sandbox.id })) { + console.log(event.cursor, event.type) +} +``` + +SSE events contain lifecycle and operation metadata only. Command output and file content remain in +their direct responses. + +## Error handling + +The SDK throws `RuntimeError`. Switch on `error.code`, not message text. HTTP status mapping is: + +- `400`: invalid request; +- `404`: resource or route not found; +- `409`: idempotency, generation, or lifecycle conflict; +- `410`: requested event cursor is older than retained history; +- `422`: unsupported capability; +- `502`: provider contract violation; +- `503`: provider unavailable. diff --git a/docs/clean-room-policy.md b/docs/clean-room-policy.md index 55f5715..b1de435 100644 --- a/docs/clean-room-policy.md +++ b/docs/clean-room-policy.md @@ -1,3 +1,12 @@ +--- +id: clean-room-policy +authority: reference +status: canonical +title: Clean-room development policy +genre: how-to +last_verified: 2026-09-02 +--- + # Clean-room Development Policy ## Purpose diff --git a/docs/providers/local.md b/docs/providers/local.md new file mode 100644 index 0000000..4dad476 --- /dev/null +++ b/docs/providers/local.md @@ -0,0 +1,48 @@ +--- +id: local-provider +authority: reference +status: active +title: Local Provider boundary +genre: primer +last_verified: 2026-09-02 +--- + +# Local Provider + +## Purpose + +The Local Provider is a deterministic development adapter for protocol, SDK, and conformance tests. +It creates one temporary working directory per sandbox generation and executes argument vectors with +Node.js `spawn` and `shell: false`. + +## Security warning + +The Local Provider is **not a security sandbox**. A command is an ordinary process owned by the current +OS user and can access resources allowed to that user. Filesystem API paths are confined to the +working directory, but command arguments are not a kernel security boundary. + +Never run untrusted or AI-generated code with this Provider. + +## Implemented capability + +- bounded non-interactive command execution; +- UTF-8 stdout/stderr capture; +- timeout and abort termination; +- sandbox-relative regular-file read/write; +- one-directory listing; +- resource cleanup and clean generation recreation. + +The Provider rejects image and template fields because it does not build or isolate an image. + +## Filesystem protections + +File operations reject: + +- absolute or empty paths; +- lexical `..` escape; +- existing paths whose real path escapes the sandbox directory; +- symbolic-link entries and symbolic-link write targets; +- files above the configured byte limit; +- malformed base64 input. + +These controls protect the file API contract. They do not constrain a spawned command. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..c32d54e --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,56 @@ +--- +id: quickstart +authority: reference +status: active +title: Local quickstart +genre: how-to +last_verified: 2026-09-02 +--- + +# Local Quickstart + +> The Local Provider runs ordinary host processes. It is for development and contract testing only; +> it does not isolate untrusted code. + +## Install and verify + +```bash +pnpm install +pnpm check +pnpm build +``` + +## Start the reference server + +```bash +node dist/cli.js serve --host 127.0.0.1 --port 4311 +``` + +The server prints its base URL and binds to loopback by default. It has no authentication and refuses +a non-loopback address unless the embedding application explicitly enables the unsafe override. + +## Create and use a sandbox + +```bash +curl -s -X POST http://127.0.0.1:4311/v1/sandboxes \ + -H 'content-type: application/json' \ + -d '{"clientRequestId":"quickstart","requiredCapabilities":["commandExecution","fileAccess"]}' +``` + +Use the returned `id` for execution: + +```bash +curl -s -X POST http://127.0.0.1:4311/v1/sandboxes//commands \ + -H 'content-type: application/json' \ + -d '{"expectedGeneration":1,"argv":["printf","hello"]}' +``` + +Use the returned `generation` when terminating: + +```bash +curl -s -X POST http://127.0.0.1:4311/v1/sandboxes//actions/terminate \ + -H 'content-type: application/json' \ + -d '{"expectedGeneration":1}' +``` + +See [API and SDK guide](api-and-sdk.md) for the complete MVP surface. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..3f744a3 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,48 @@ +--- +id: testing +authority: reference +status: active +title: Testing and provider conformance +genre: how-to +last_verified: 2026-09-02 +--- + +# Testing and Provider Conformance + +## Required local gates + +```bash +pnpm check +pnpm build +pnpm sanitize +pnpm docs:check +pnpm test:coverage +pnpm pack:check +``` + +`pnpm check` runs formatting, lint, type checking, and the complete test suite. CI runs the same gates +on Node.js 22 and 24, followed by the production dependency audit. + +## Test dimensions + +| Dimension | Coverage | +| --- | --- | +| Unit and boundary | validation, lifecycle, errors, event cursors | +| Invariant | concurrent idempotent create and serialized termination | +| Provider contract | manifest-to-method consistency, lifecycle, command and file round-trip | +| Local integration | process execution, timeout, output bound, cleanup | +| HTTP and SDK | lifecycle, files, commands, SSE, error mapping | +| Security-negative | lexical and symbolic-link traversal, malformed input, non-loopback bind | +| Public safety | credentials, private networks, absolute paths, tracked-content scan | + +## Conformance runner + +`runProviderConformance(provider, options)` checks behavior declared by the Provider manifest. A +Provider that declares `commandExecution` must supply `options.commandRequest` with a safe command +known to exist in that runtime; the portable core does not assume POSIX utilities or a particular +language runtime. Each invocation uses a unique synthetic resource identity and cleans it up. A failed +check means the Provider must not claim compatibility. Passing conformance does not certify isolation, +production availability, or performance. + +Provider-specific real-account tests must be opt-in, use environment-provided credentials, and stay +outside default CI. diff --git a/package.json b/package.json index 6110c3a..4397d43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sandbox-runtime-api", - "version": "0.1.0-dev.0", + "version": "0.1.0", "private": true, "description": "A provider-neutral runtime contract for isolated AI agent execution environments.", "license": "Apache-2.0", @@ -20,24 +20,46 @@ "conformance" ], "type": "module", + "bin": { + "sandbox-runtime": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "README.zh-CN.md", + "LICENSE", + "CHANGELOG.md", + "spec/openapi.yaml" + ], "packageManager": "pnpm@10.33.0", "engines": { "node": ">=22" }, "scripts": { "build": "tsc -p tsconfig.build.json", - "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test", + "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm docs:check", + "docs:check": "node scripts/check-docs.mjs", "format": "biome format --write .", "format:check": "biome format .", "lint": "biome lint .", + "pack:check": "pnpm build && pnpm pack --dry-run", "sanitize": "bash scripts/scan-public.sh .", "test": "vitest run", + "test:coverage": "vitest run --coverage", "typecheck": "tsc -p tsconfig.tests.json --noEmit" }, "devDependencies": { - "@biomejs/biome": "2.5.9", + "@biomejs/biome": "2.5.11", "@types/node": "22.20.1", + "@vitest/coverage-v8": "4.1.11", "typescript": "7.0.2", - "vitest": "4.1.11" + "vitest": "4.1.11", + "yaml": "2.9.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b740d8..27340a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,80 +9,114 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: 2.5.9 - version: 2.5.9 + specifier: 2.5.11 + version: 2.5.11 '@types/node': specifier: 22.20.1 version: 22.20.1 + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) typescript: specifier: 7.0.2 version: 7.0.2 vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@22.20.1)(vite@8.2.2(@types/node@22.20.1)) + version: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@22.20.1)(yaml@2.9.0)) + yaml: + specifier: 2.9.0 + version: 2.9.0 packages: - '@biomejs/biome@2.5.9': - resolution: {integrity: sha512-KkgCvdHB4IhtpHpF564plA9jo6fDOwWGQ/3jvreLzgOtRLEDoPqr7QO9qejNA8jKwDsSkAKr77hqBHnyUbIw4g==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/biome@2.5.11': + resolution: {integrity: sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.9': - resolution: {integrity: sha512-am22pX2aBqznqq1eMyIj/bZ++riF3Lk6ct7cbv+gQK0csFhr+d8O0RkOi2FF2qSgFgANbqNkIZ0/PxlnW2pLFg==} + '@biomejs/cli-darwin-arm64@2.5.11': + resolution: {integrity: sha512-6SGZxoKbXvUjMn1t6A98HqWISPnGNbYs0R/Rt2JarmXBSev+lva4QxUMWEBX9lX1Wo1XTJ78uk5xVDtG58SRZg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.9': - resolution: {integrity: sha512-l44KWDHLDvEnD0N/XcrVs7VXb3A18xL7QS3WB0eL93wbmk529ffIG55vleGCqaunpRUjLrdnjK05Qki1dsjylg==} + '@biomejs/cli-darwin-x64@2.5.11': + resolution: {integrity: sha512-nYkXY7tLBEgnGbYapDKAyKzgt44ZEyG+AKalvTXtCWKYgepI9dw327q+cVgedxm+Udi1ZzHKUyZrIusHi/KQbw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.9': - resolution: {integrity: sha512-7ImVPwBLCtkmpR5esd8RHhTqW94f0JLJQum6AneYcy94jRm18TaPPm7slaigGzFhfgt3QiD1Vj52LKmBAnKizA==} + '@biomejs/cli-linux-arm64-musl@2.5.11': + resolution: {integrity: sha512-qhyZUMyCbWYFV2bAwRNVvfMVZ+hv7WYl6mossGrxC+uiQQXhvsuWWU8zz6jYX0mChZd9MgQZbm4vozTmG/5iGw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.9': - resolution: {integrity: sha512-ICaK+IYaVZvKbBxX2rwrPT0DdUDMnE9Vm3nQGe+mltQPmUg19pONzkPWGdY4FCsoreDETWDynvdt4ysCbF5gNQ==} + '@biomejs/cli-linux-arm64@2.5.11': + resolution: {integrity: sha512-3PVLSTD9RR73rvVPt5G3T1gc+ycggWEGfTD7RvzzbtcDPD27NxgxBbAFfpm7DXJKW6VLHWE1lLMGvFt2Qxjcow==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.9': - resolution: {integrity: sha512-RXGaD0o1/pTTguYw1aeDJh9ad6Lfrui0fI7mBderTyGr7WuUJkBIttgLkR3XJyoxOkkgfBDspaUT8wXArTqLZw==} + '@biomejs/cli-linux-x64-musl@2.5.11': + resolution: {integrity: sha512-oRRlrchG5EfrEL/EmtT1qUjSNHk3/5LGeZhQqADBBAJF1b1ET6964xEKe7aGlGARzDfza8H/seEsFJl7S6Ql9w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.9': - resolution: {integrity: sha512-z22Q/zFYSvbIJfW1CbfZPu4X8PddS6Qd2ORbc6h+aT6EcwAxUF3m6fA4HjNvA3TU4X0dTJRwNPB165ES3PJXzg==} + '@biomejs/cli-linux-x64@2.5.11': + resolution: {integrity: sha512-JOytptlsgM33B2MMFUg8iBrb4IKpbD5JnJrSeYiaFEeAj4vuXx0iQSQZ4qK7sqyMtfjZxxPdNdMZZVL4y/mFyA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.9': - resolution: {integrity: sha512-nHK+/HHC+D0ogAHUxomgoSTdjImb6fmNNVTKmf0tyu4eDL1DqPKIHc+i+UL8+b0RnAu8224qo8F2tCVnaT0A3w==} + '@biomejs/cli-win32-arm64@2.5.11': + resolution: {integrity: sha512-e49E6K9hzH/ohJNx8Y26mY8HaV4I4ZViIeoqhKsmoXLKHhQnMeBAVqCgsGf2Wa3lXlS7RkporDXMHHWkzvZzFw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.9': - resolution: {integrity: sha512-Yiq0H56LjXSSw/hd9YkXgSLQfzyDJzbzU2TezozxyNw+uKWAqOtqGVvBfzKRRDiaFF5avGAhHdWKx7LtDOShUw==} + '@biomejs/cli-win32-x64@2.5.11': + resolution: {integrity: sha512-QSQr/KjOgXA7OzXJUWS+oguKyAZ3Q0l/lnlDGbu397eKo83atuWUjBPJrsqbKNF6CARGw8XXJLGzpHC8Ryhd4Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@oxc-project/types@0.147.0': resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} @@ -320,6 +354,15 @@ packages: cpu: [x64] os: [win32] + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -353,6 +396,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -388,6 +434,28 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -465,6 +533,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -493,6 +568,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -506,6 +586,10 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -618,45 +702,72 @@ packages: engines: {node: '>=8'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + snapshots: - '@biomejs/biome@2.5.9': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.9 - '@biomejs/cli-darwin-x64': 2.5.9 - '@biomejs/cli-linux-arm64': 2.5.9 - '@biomejs/cli-linux-arm64-musl': 2.5.9 - '@biomejs/cli-linux-x64': 2.5.9 - '@biomejs/cli-linux-x64-musl': 2.5.9 - '@biomejs/cli-win32-arm64': 2.5.9 - '@biomejs/cli-win32-x64': 2.5.9 + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} - '@biomejs/cli-darwin-arm64@2.5.9': + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/biome@2.5.11': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.11 + '@biomejs/cli-darwin-x64': 2.5.11 + '@biomejs/cli-linux-arm64': 2.5.11 + '@biomejs/cli-linux-arm64-musl': 2.5.11 + '@biomejs/cli-linux-x64': 2.5.11 + '@biomejs/cli-linux-x64-musl': 2.5.11 + '@biomejs/cli-win32-arm64': 2.5.11 + '@biomejs/cli-win32-x64': 2.5.11 + + '@biomejs/cli-darwin-arm64@2.5.11': optional: true - '@biomejs/cli-darwin-x64@2.5.9': + '@biomejs/cli-darwin-x64@2.5.11': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.9': + '@biomejs/cli-linux-arm64-musl@2.5.11': optional: true - '@biomejs/cli-linux-arm64@2.5.9': + '@biomejs/cli-linux-arm64@2.5.11': optional: true - '@biomejs/cli-linux-x64-musl@2.5.9': + '@biomejs/cli-linux-x64-musl@2.5.11': optional: true - '@biomejs/cli-linux-x64@2.5.9': + '@biomejs/cli-linux-x64@2.5.11': optional: true - '@biomejs/cli-win32-arm64@2.5.9': + '@biomejs/cli-win32-arm64@2.5.11': optional: true - '@biomejs/cli-win32-x64@2.5.9': + '@biomejs/cli-win32-x64@2.5.11': optional: true + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@oxc-project/types@0.147.0': {} '@rolldown/binding-android-arm-eabi@1.2.6': @@ -781,6 +892,20 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@22.20.1)(yaml@2.9.0)) + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -790,13 +915,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@22.20.1))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@22.20.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@22.20.1) + vite: 8.2.2(@types/node@22.20.1)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -824,6 +949,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + chai@6.2.2: {} convert-source-map@2.0.0: {} @@ -845,6 +976,25 @@ snapshots: fsevents@2.3.3: optional: true + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -898,6 +1048,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + nanoid@3.3.18: {} obug@2.1.4: {} @@ -935,6 +1095,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.6 '@rolldown/binding-win32-x64-msvc': 1.2.6 + semver@7.8.5: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -943,6 +1105,10 @@ snapshots: std-env@4.2.0: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tinybench@2.9.0: {} tinyexec@1.3.0: {} @@ -979,7 +1145,7 @@ snapshots: undici-types@6.21.0: {} - vite@8.2.2(@types/node@22.20.1): + vite@8.2.2(@types/node@22.20.1)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -989,11 +1155,12 @@ snapshots: optionalDependencies: '@types/node': 22.20.1 fsevents: 2.3.3 + yaml: 2.9.0 - vitest@4.1.11(@types/node@22.20.1)(vite@8.2.2(@types/node@22.20.1)): + vitest@4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@22.20.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1010,10 +1177,11 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@22.20.1) + vite: 8.2.2(@types/node@22.20.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw @@ -1021,3 +1189,5 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + yaml@2.9.0: {} diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs new file mode 100644 index 0000000..5bce305 --- /dev/null +++ b/scripts/check-docs.mjs @@ -0,0 +1,59 @@ +import { readFile, readdir } from 'node:fs/promises' +import { dirname, join, normalize, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse } from 'yaml' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const ignored = new Set(['node_modules', '.git', 'dist', 'coverage']) + +const walk = async (directory) => { + const result = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (ignored.has(entry.name)) continue + const path = join(directory, entry.name) + if (entry.isDirectory()) result.push(...(await walk(path))) + else result.push(path) + } + return result +} + +const files = await walk(root) +const markdown = files.filter((path) => path.endsWith('.md')) +const broken = [] +const ids = new Map() + +for (const path of markdown) { + const content = await readFile(path, 'utf8') + const frontmatter = content.match(/^---\n([\s\S]*?)\n---\n/) + if (frontmatter) { + const metadata = parse(frontmatter[1]) + if (!metadata?.id || !metadata?.authority || !metadata?.status) { + broken.push(`${relative(root, path)}: incomplete frontmatter`) + } else if (ids.has(metadata.id)) { + broken.push(`${relative(root, path)}: duplicate id ${metadata.id}`) + } else ids.set(metadata.id, path) + } + + for (const match of content.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)) { + const target = match[1] + if (!target || target.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(target)) continue + const withoutAnchor = decodeURIComponent(target.split('#')[0]) + const resolved = normalize(resolve(dirname(path), withoutAnchor)) + if (!files.includes(resolved)) broken.push(`${relative(root, path)} -> ${target}`) + } +} + +const openapiPath = join(root, 'spec/openapi.yaml') +const openapi = parse(await readFile(openapiPath, 'utf8')) +if (openapi?.openapi !== '3.1.0' || !openapi?.paths || !openapi?.components?.schemas) { + broken.push('spec/openapi.yaml: incomplete OpenAPI 3.1 document') +} + +if (broken.length > 0) { + process.stderr.write(`documentation validation failed:\n${broken.join('\n')}\n`) + process.exit(1) +} + +process.stdout.write( + `documentation validation passed: ${markdown.length} markdown files, ${ids.size} managed ids\n`, +) diff --git a/scripts/scan-public.sh b/scripts/scan-public.sh index 9ff57f0..c41e148 100644 --- a/scripts/scan-public.sh +++ b/scripts/scan-public.sh @@ -23,7 +23,7 @@ scan() { local pattern="$2" local output output=$(rg -n -i --hidden \ - --glob '!.git/**' --glob '!node_modules/**' --glob '!dist/**' --glob '!pnpm-lock.yaml' \ + --glob '!.git/**' --glob '!node_modules/**' --glob '!coverage/**' --glob '!pnpm-lock.yaml' \ --glob '!scripts/scan-public.sh' \ "$pattern" "$root" || true) if [[ -n "$output" ]]; then @@ -37,4 +37,13 @@ scan 'credential-shaped content' \ scan 'local absolute path or private-network literal' \ '(/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|https?://[^/[:space:]]+\.(internal|local)(/|[[:space:]]|$)|(^|[^0-9])(10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3})([^0-9]|$))' +if git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + history=$(git -C "$root" log --all -p -- . ':!pnpm-lock.yaml' ':!scripts/scan-public.sh' | \ + rg -n -i '(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|authorization:[[:space:]]*bearer[[:space:]]+[A-Za-z0-9._-]{16,}|/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|https?://[^/[:space:]]+\.(internal|local)(/|[[:space:]]|$))' || true) + if [[ -n "$history" ]]; then + printf 'sensitive-shaped git history content:\n%s\n' "$history" + status=1 + fi +fi + exit "$status" diff --git a/spec/README.md b/spec/README.md index 416323a..d90747f 100644 --- a/spec/README.md +++ b/spec/README.md @@ -1,9 +1,20 @@ +--- +id: specification-index +authority: canonical +status: canonical +title: Specification index +genre: spec +last_verified: 2026-09-02 +--- + # Specification This directory contains the normative portable contract. - [Runtime model](runtime-model.md) +- [Protocol](protocol.md) - [Capabilities](capabilities.md) +- [OpenAPI](openapi.yaml) - [Decision 0001: provider-neutral sandbox runtime](decisions/0001-provider-neutral-sandbox-runtime.md) The TypeScript types in `src/protocol.ts` are the executable development projection. If the prose diff --git a/spec/capabilities.md b/spec/capabilities.md index 06ab323..8b631a0 100644 --- a/spec/capabilities.md +++ b/spec/capabilities.md @@ -1,3 +1,12 @@ +--- +id: capabilities +authority: canonical +status: canonical +title: Capability vocabulary +genre: spec +last_verified: 2026-09-02 +--- + # Capabilities Capabilities let a client reject an unsuitable provider before allocating resources. @@ -15,6 +24,9 @@ Initial vocabulary: | `memorySnapshot` | Capture and restore process memory state | | `persistentVolume` | Attach storage whose lifetime is independent from one allocation | | `networkPolicy` | Enforce declared ingress or egress policy | +| `imageReference` | Provision from a portable image reference | +| `templateReference` | Provision from a Provider-defined reusable template reference | +| `resourceLimits` | Enforce requested CPU, memory, and disk bounds | A `true` value means the provider implements the portable semantics and is expected to pass the matching conformance checks. It does not certify the isolation implementation or production SLO. diff --git a/spec/decisions/0001-provider-neutral-sandbox-runtime.md b/spec/decisions/0001-provider-neutral-sandbox-runtime.md index a66a21c..1c7c455 100644 --- a/spec/decisions/0001-provider-neutral-sandbox-runtime.md +++ b/spec/decisions/0001-provider-neutral-sandbox-runtime.md @@ -1,3 +1,12 @@ +--- +id: provider-neutral-sandbox-runtime +authority: canonical +status: canonical +title: Provider-neutral Sandbox Runtime +genre: adr +last_verified: 2026-09-02 +--- + # 0001: Provider-neutral Sandbox Runtime - Status: accepted for `0.1.0-dev` diff --git a/spec/openapi.yaml b/spec/openapi.yaml new file mode 100644 index 0000000..0bee1c1 --- /dev/null +++ b/spec/openapi.yaml @@ -0,0 +1,551 @@ +openapi: 3.1.0 +info: + title: Sandbox Runtime API + version: 0.1.0 + description: Provider-neutral lifecycle and data-plane contract for isolated execution environments. +servers: + - url: http://127.0.0.1:4311 + description: Loopback reference server +paths: + /healthz: + get: + operationId: getHealth + responses: + "200": + description: Process is accepting requests + content: + application/json: + schema: + type: object + required: [status] + properties: + status: { const: ok } + /v1/runtime: + get: + operationId: getRuntimeInfo + responses: + "200": + description: Protocol and Provider manifest + content: + application/json: + schema: { $ref: "#/components/schemas/RuntimeInfo" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + /v1/sandboxes: + get: + operationId: listSandboxes + responses: + "200": + description: Current in-memory resources + content: + application/json: + schema: + type: object + required: [sandboxes] + properties: + sandboxes: + type: array + items: { $ref: "#/components/schemas/SandboxResource" } + post: + operationId: createSandbox + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SandboxSpec" } + responses: + "201": + description: Created or idempotently returned Sandbox + content: + application/json: + schema: { $ref: "#/components/schemas/SandboxResource" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unsupported" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + /v1/sandboxes/{sandboxId}: + parameters: + - $ref: "#/components/parameters/SandboxId" + get: + operationId: getSandbox + responses: + "200": + description: Sandbox resource + content: + application/json: + schema: { $ref: "#/components/schemas/SandboxResource" } + "404": { $ref: "#/components/responses/NotFound" } + /v1/sandboxes/{sandboxId}/actions/reconcile: + parameters: + - $ref: "#/components/parameters/SandboxId" + post: + operationId: reconcileSandbox + responses: + "200": + description: Reconciled Sandbox + content: + application/json: + schema: { $ref: "#/components/schemas/SandboxResource" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + /v1/sandboxes/{sandboxId}/actions/pause: + parameters: + - $ref: "#/components/parameters/SandboxId" + post: + operationId: pauseSandbox + requestBody: { $ref: "#/components/requestBodies/ExpectedGeneration" } + responses: + "200": + description: Paused Sandbox + content: + application/json: + schema: { $ref: "#/components/schemas/SandboxResource" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unsupported" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + /v1/sandboxes/{sandboxId}/actions/resume: + parameters: + - $ref: "#/components/parameters/SandboxId" + post: + operationId: resumeSandbox + requestBody: { $ref: "#/components/requestBodies/ExpectedGeneration" } + responses: + "200": + description: Ready Sandbox + content: + application/json: + schema: { $ref: "#/components/schemas/SandboxResource" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unsupported" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + /v1/sandboxes/{sandboxId}/actions/terminate: + parameters: + - $ref: "#/components/parameters/SandboxId" + post: + operationId: terminateSandbox + requestBody: { $ref: "#/components/requestBodies/ExpectedGeneration" } + responses: + "200": + description: Terminated Sandbox + content: + application/json: + schema: { $ref: "#/components/schemas/SandboxResource" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + /v1/sandboxes/{sandboxId}/actions/recreate: + parameters: + - $ref: "#/components/parameters/SandboxId" + post: + operationId: recreateSandbox + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [expectedGeneration, spec] + properties: + expectedGeneration: { type: integer, minimum: 1 } + spec: { $ref: "#/components/schemas/SandboxSpec" } + responses: + "200": + description: Recreated Sandbox with incremented generation + content: + application/json: + schema: { $ref: "#/components/schemas/SandboxResource" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unsupported" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + /v1/sandboxes/{sandboxId}/commands: + parameters: + - $ref: "#/components/parameters/SandboxId" + post: + operationId: executeCommand + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CommandRequest" } + responses: + "200": + description: Terminal command result + content: + application/json: + schema: { $ref: "#/components/schemas/CommandResult" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unsupported" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + /v1/sandboxes/{sandboxId}/files/content: + parameters: + - $ref: "#/components/parameters/SandboxId" + get: + operationId: readFile + parameters: + - in: query + name: path + required: true + schema: { type: string } + - in: query + name: expectedGeneration + required: true + schema: { type: integer, minimum: 1 } + responses: + "200": + description: Base64 file content + content: + application/json: + schema: { $ref: "#/components/schemas/FileReadResult" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Unsupported" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + put: + operationId: writeFile + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/FileWriteRequest" } + responses: + "200": + description: Written file descriptor + content: + application/json: + schema: { $ref: "#/components/schemas/FileReadResult" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unsupported" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + /v1/sandboxes/{sandboxId}/files/entries: + parameters: + - $ref: "#/components/parameters/SandboxId" + get: + operationId: listFiles + parameters: + - in: query + name: path + schema: { type: string, default: "." } + - in: query + name: expectedGeneration + required: true + schema: { type: integer, minimum: 1 } + responses: + "200": + description: One-directory listing + content: + application/json: + schema: + type: object + required: [entries] + properties: + entries: + type: array + items: { $ref: "#/components/schemas/FileEntry" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unsupported" } + "502": { $ref: "#/components/responses/ProviderProtocolError" } + "503": { $ref: "#/components/responses/ProviderUnavailable" } + /v1/events: + get: + operationId: listEvents + parameters: + - $ref: "#/components/parameters/AfterCursor" + - $ref: "#/components/parameters/OptionalSandboxId" + responses: + "200": + description: Events after the cursor + content: + application/json: + schema: + type: object + required: [events] + properties: + events: + type: array + items: { $ref: "#/components/schemas/SandboxEvent" } + "410": { $ref: "#/components/responses/EventHistoryUnavailable" } + /v1/events/stream: + get: + operationId: streamEvents + parameters: + - $ref: "#/components/parameters/AfterCursor" + - $ref: "#/components/parameters/OptionalSandboxId" + responses: + "200": + description: Server-Sent Events stream + content: + text/event-stream: + schema: { type: string } + "410": { $ref: "#/components/responses/EventHistoryUnavailable" } +components: + parameters: + SandboxId: + in: path + name: sandboxId + required: true + schema: { type: string, minLength: 1 } + AfterCursor: + in: query + name: after + schema: { type: integer, minimum: 0, default: 0 } + OptionalSandboxId: + in: query + name: sandboxId + schema: { type: string } + requestBodies: + ExpectedGeneration: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [expectedGeneration] + properties: + expectedGeneration: { type: integer, minimum: 1 } + responses: + InvalidRequest: + description: Invalid request + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + Conflict: + description: Idempotency, generation, or state conflict + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + Unsupported: + description: Provider does not support a required capability + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + NotFound: + description: Resource not found + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + ProviderUnavailable: + description: Provider operation failed + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + ProviderProtocolError: + description: Provider returned a malformed manifest, observation, or result + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + EventHistoryUnavailable: + description: Requested event cursor is older than retained history + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + schemas: + CapabilityName: + type: string + enum: + - commandExecution + - fileAccess + - interactiveTerminal + - portForwarding + - pauseResume + - filesystemSnapshot + - memorySnapshot + - persistentVolume + - networkPolicy + - imageReference + - templateReference + - resourceLimits + RuntimeCapabilities: + type: object + additionalProperties: false + required: + - commandExecution + - fileAccess + - interactiveTerminal + - portForwarding + - pauseResume + - filesystemSnapshot + - memorySnapshot + - persistentVolume + - networkPolicy + - imageReference + - templateReference + - resourceLimits + properties: + commandExecution: { type: boolean } + fileAccess: { type: boolean } + interactiveTerminal: { type: boolean } + portForwarding: { type: boolean } + pauseResume: { type: boolean } + filesystemSnapshot: { type: boolean } + memorySnapshot: { type: boolean } + persistentVolume: { type: boolean } + networkPolicy: { type: boolean } + imageReference: { type: boolean } + templateReference: { type: boolean } + resourceLimits: { type: boolean } + ProviderManifest: + type: object + required: [name, version, runtimeClass, capabilities] + properties: + name: { type: string } + version: { type: string } + runtimeClass: { type: string } + capabilities: { $ref: "#/components/schemas/RuntimeCapabilities" } + RuntimeInfo: + type: object + required: [protocolVersion, provider] + properties: + protocolVersion: { const: "0.1" } + provider: { $ref: "#/components/schemas/ProviderManifest" } + SandboxState: + type: string + enum: [requested, starting, ready, pausing, paused, resuming, terminating, terminated, failed] + SandboxSpec: + type: object + additionalProperties: false + not: + required: [image, template] + required: [clientRequestId] + properties: + clientRequestId: { type: string, minLength: 1, pattern: "\\S" } + image: { type: string, minLength: 1, pattern: "\\S" } + template: { type: string, minLength: 1, pattern: "\\S" } + resources: + type: object + additionalProperties: false + properties: + cpuMillis: { type: integer, minimum: 1 } + memoryMiB: { type: integer, minimum: 1 } + diskMiB: { type: integer, minimum: 1 } + requiredCapabilities: + type: array + uniqueItems: true + items: { $ref: "#/components/schemas/CapabilityName" } + extensions: + type: object + propertyNames: + pattern: "^[a-z][a-z0-9-]*\\.[A-Za-z][A-Za-z0-9_.-]*$" + SandboxResource: + type: object + required: [id, generation, state, spec, provider, createdAt, updatedAt] + properties: + id: { type: string } + generation: { type: integer, minimum: 1 } + state: { $ref: "#/components/schemas/SandboxState" } + spec: { $ref: "#/components/schemas/SandboxSpec" } + provider: { type: string } + endpoint: + type: object + required: [baseUrl] + properties: + baseUrl: { type: string, format: uri } + providerExtensions: { type: object } + failure: + type: object + required: [code, message] + properties: + code: { type: string } + message: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + CommandRequest: + type: object + additionalProperties: false + required: [expectedGeneration, argv] + properties: + expectedGeneration: { type: integer, minimum: 1 } + argv: + type: array + minItems: 1 + items: { type: string } + cwd: { type: string } + env: + type: object + additionalProperties: { type: string } + timeoutSeconds: { type: number, exclusiveMinimum: 0, maximum: 3600 } + maxOutputBytes: + type: integer + minimum: 1 + maximum: 10485760 + description: Maximum retained bytes independently for stdout and stderr. + CommandResult: + type: object + required: [exitCode, stdout, stderr, timedOut, cancelled, truncated, startedAt, finishedAt] + properties: + exitCode: { type: [integer, "null"] } + stdout: { type: string } + stderr: { type: string } + timedOut: { type: boolean } + cancelled: { type: boolean } + truncated: { type: boolean } + startedAt: { type: string, format: date-time } + finishedAt: { type: string, format: date-time } + FileWriteRequest: + type: object + additionalProperties: false + required: [expectedGeneration, path, contentBase64] + properties: + expectedGeneration: { type: integer, minimum: 1 } + path: { type: string } + contentBase64: { type: string, contentEncoding: base64 } + FileReadResult: + type: object + required: [path, contentBase64, size] + properties: + path: { type: string } + contentBase64: { type: string, contentEncoding: base64 } + size: { type: integer, minimum: 0 } + FileEntry: + type: object + required: [path, kind, size] + properties: + path: { type: string } + kind: { enum: [file, directory] } + size: { type: integer, minimum: 0 } + SandboxEvent: + type: object + required: [cursor, type, sandboxId, generation, timestamp, data] + properties: + cursor: { type: integer, minimum: 1 } + type: + enum: [sandbox.created, sandbox.state_changed, sandbox.command_completed, sandbox.file_written] + sandboxId: { type: string } + generation: { type: integer, minimum: 1 } + timestamp: { type: string, format: date-time } + data: { type: object } + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: { type: string } + message: { type: string } diff --git a/spec/protocol.md b/spec/protocol.md new file mode 100644 index 0000000..b75a8e9 --- /dev/null +++ b/spec/protocol.md @@ -0,0 +1,83 @@ +--- +id: protocol +authority: canonical +status: canonical +title: Portable protocol semantics +genre: spec +last_verified: 2026-09-02 +--- + +# Portable Protocol Semantics + +## Transport + +The normative HTTP projection uses JSON under `/v1`. Errors use: + +```json +{ + "error": { + "code": "generation_conflict", + "message": "expected generation 1, observed 2" + } +} +``` + +The stable machine field is `error.code`. Human-readable messages may improve without a protocol +version change. + +## Lifecycle + +Create is idempotent by `clientRequestId`. Concurrent identical requests converge to one logical +resource; the same key with different intent returns `idempotency_conflict`. +An idempotency key is scoped to the generation it created. Replaying an older generation's key after +recreation returns `idempotency_conflict` instead of returning a resource with a different spec. + +State-changing and destructive calls carry `expectedGeneration`. A stale generation returns +`generation_conflict`. A terminated logical resource may be recreated with a new request ID and +generation `n + 1`. + +Command execution and every file operation also carry `expectedGeneration`. This prevents a client +holding generation `n` from reading or mutating generation `n + 1` after recreation. + +Provider observations must follow the transition table in [Runtime model](runtime-model.md). An +invalid observation is a provider protocol failure, not a new portable state. + +Extension keys are namespaced strings such as `example.feature`. Extension values must be JSON and do +not become portable guarantees. + +## Command execution + +Commands are argument vectors, never shell strings. Portable results contain: + +- nullable exit code; +- UTF-8 stdout and stderr; +- timeout and truncation flags; +- an explicit cancellation flag; +- start and finish timestamps. + +`timeoutSeconds` is a wall-clock bound. `maxOutputBytes` applies independently to stdout and stderr, +so the combined retained output is at most twice that value. A provider may apply a stricter +documented limit. Command execution is available only while the sandbox is `ready` and only when the +manifest declares `commandExecution`. + +## Files + +Portable file content uses base64. Paths are sandbox-relative. A conforming provider must prevent +lexical or symbolic-link traversal outside its declared filesystem boundary. The portable MVP +supports read, write, and one-directory listing; recursive transfer and deletion are not included. + +## Events + +Events are append-only within one runtime process and use monotonically increasing integer cursors. +Consumers may list events after a cursor or consume Server-Sent Events. Event replay is a transport +convenience in the reference runtime, not durable event storage. The reference runtime retains a +bounded history; a cursor older than the retained window returns `event_history_unavailable`. + +Event payloads never contain command stdout/stderr or file bytes. Providers and deployments must not +place credentials or user data in extension metadata. + +## Capability contract + +A declared capability means that the provider implements the portable method set and should pass the +matching conformance cases. It does not certify security, availability, performance, or equivalence to +another provider. diff --git a/spec/runtime-model.md b/spec/runtime-model.md index e218497..dabd079 100644 --- a/spec/runtime-model.md +++ b/spec/runtime-model.md @@ -1,3 +1,12 @@ +--- +id: runtime-model +authority: canonical +status: canonical +title: Runtime model +genre: spec +last_verified: 2026-09-02 +--- + # Runtime Model ## Resource identity @@ -36,7 +45,8 @@ portable guarantees. ## Idempotency `clientRequestId` identifies one create intent. Repeating the same create request returns the same -logical sandbox. Reusing it with a different specification fails with `idempotency_conflict`. +logical sandbox generation. Reusing it with a different specification, or replaying an older +generation's key after recreation, fails with `idempotency_conflict`. ## Capability preflight diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..7e840a6 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { InMemorySandboxRuntime } from './runtime.js' +import { LocalSandboxProvider } from './providers/local.js' +import { startSandboxRuntimeServer } from './server.js' + +const argument = (name: string): string | undefined => { + const index = process.argv.indexOf(name) + return index >= 0 ? process.argv[index + 1] : undefined +} + +const main = async (): Promise => { + const command = process.argv[2] + if (command !== 'serve') { + process.stderr.write( + 'Usage: sandbox-runtime serve [--host 127.0.0.1] [--port 4311] [--root DIRECTORY]\n', + ) + process.exitCode = 64 + return + } + const host = argument('--host') ?? '127.0.0.1' + const portValue = argument('--port') ?? '4311' + const port = Number(portValue) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`invalid port ${portValue}`) + } + const rootDirectory = argument('--root') + const provider = new LocalSandboxProvider(rootDirectory ? { rootDirectory } : {}) + const runtime = new InMemorySandboxRuntime(provider) + const handle = await startSandboxRuntimeServer(runtime, { host, port }) + process.stdout.write(`${handle.baseUrl}\n`) + + const shutdown = async () => { + await handle.close() + await provider.dispose() + } + process.once('SIGINT', () => void shutdown().then(() => process.exit(0))) + process.once('SIGTERM', () => void shutdown().then(() => process.exit(0))) +} + +void main().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : 'runtime failed'}\n`) + process.exitCode = 1 +}) diff --git a/src/conformance.ts b/src/conformance.ts index a394d01..0ecc846 100644 --- a/src/conformance.ts +++ b/src/conformance.ts @@ -1,4 +1,13 @@ -import type { SandboxProvider } from './provider.js' +import { randomUUID } from 'node:crypto' +import type { ProviderObservation, SandboxProvider } from './provider.js' +import { + capabilityNames, + type CommandRequest, + type CommandResult, + type FileEntry, + type FileReadResult, + type ProviderManifest, +} from './protocol.js' export type ConformanceResult = Readonly<{ name: string @@ -6,39 +15,154 @@ export type ConformanceResult = Readonly<{ detail?: string }> +export type ProviderConformanceOptions = Readonly<{ + readinessTimeoutMs?: number + pollIntervalMs?: number + commandRequest?: Omit +}> + +const failure = (name: string, error: unknown): ConformanceResult => ({ + name, + passed: false, + detail: error instanceof Error ? error.message : 'provider failed without an error', +}) + +const validManifest = (manifest: ProviderManifest): boolean => + Boolean( + manifest && + typeof manifest.name === 'string' && + manifest.name.trim() && + typeof manifest.version === 'string' && + manifest.version.trim() && + typeof manifest.runtimeClass === 'string' && + manifest.runtimeClass.trim() && + manifest.capabilities && + capabilityNames.every((name) => typeof manifest.capabilities[name] === 'boolean'), + ) + +const validTimestamp = (value: unknown): value is string => + typeof value === 'string' && Number.isFinite(Date.parse(value)) + +const validCommandResult = (result: CommandResult): boolean => + Boolean( + result && + (result.exitCode === null || Number.isInteger(result.exitCode)) && + typeof result.stdout === 'string' && + typeof result.stderr === 'string' && + typeof result.timedOut === 'boolean' && + typeof result.cancelled === 'boolean' && + typeof result.truncated === 'boolean' && + validTimestamp(result.startedAt) && + validTimestamp(result.finishedAt), + ) + +const validFileResult = (result: FileReadResult, path: string, size: number): boolean => + Boolean( + result && + result.path === path && + typeof result.contentBase64 === 'string' && + result.size === size, + ) + +const validFileEntry = (entry: FileEntry): boolean => + Boolean( + entry && + typeof entry.path === 'string' && + ['file', 'directory'].includes(entry.kind) && + Number.isInteger(entry.size) && + entry.size >= 0, + ) + +const observeBefore = async ( + provider: SandboxProvider, + key: Readonly<{ sandboxId: string; generation: number }>, + requestId: string, + deadline: number, +): Promise => { + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) throw new Error('readiness observation timed out') + const controller = new AbortController() + let timer: ReturnType | undefined + try { + return await Promise.race([ + provider.observe(key, { requestId, signal: controller.signal }), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + controller.abort() + reject(new Error('readiness observation timed out')) + }, remainingMs) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + export const runProviderConformance = async ( provider: SandboxProvider, + options: ProviderConformanceOptions = {}, ): Promise => { - const context = { requestId: 'conformance' } - const manifest = await provider.describe(context) - const key = { sandboxId: 'conformance-sandbox', generation: 1 } + const runId = randomUUID() + const context = { requestId: `conformance-${runId}` } + const key = { sandboxId: `conformance-${runId}`, generation: 1 } const results: ConformanceResult[] = [] + const readinessTimeoutMs = Math.max(1, options.readinessTimeoutMs ?? 5_000) + const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? 25) - const provisioned = await provider.provision( - { - ...key, - spec: { clientRequestId: 'conformance-create', image: 'public.example/sandbox:latest' }, - }, - context, - ) - results.push({ - name: 'provision returns an observable non-terminal state', - passed: !['failed', 'terminated'].includes(provisioned.state), - ...(provisioned.state === 'failed' || provisioned.state === 'terminated' - ? { detail: `observed ${provisioned.state}` } - : {}), - }) - - const observed = await provider.observe(key, context) - results.push({ - name: 'observe preserves the provider resource identity', - passed: observed.state === provisioned.state, - ...(observed.state !== provisioned.state - ? { detail: `provision=${provisioned.state} observe=${observed.state}` } - : {}), - }) - - if (manifest.capabilities.pauseResume) { + let manifest: ProviderManifest + try { + manifest = await provider.describe(context) + } catch (error) { + return [failure('provider manifest is readable', error)] + } + results.push({ name: 'provider manifest is complete', passed: validManifest(manifest) }) + if (!validManifest(manifest)) return results + + let provisioned: ProviderObservation + try { + provisioned = await provider.provision( + { ...key, spec: { clientRequestId: 'conformance-create' } }, + context, + ) + results.push({ + name: 'provision returns an observable non-terminal state', + passed: !['failed', 'terminated'].includes(provisioned.state), + ...(['failed', 'terminated'].includes(provisioned.state) + ? { detail: `observed ${provisioned.state}` } + : {}), + }) + } catch (error) { + results.push(failure('provision returns an observation', error)) + try { + await provider.terminate(key, context) + results.push({ name: 'ambiguous provision failure is cleaned up', passed: true }) + } catch (cleanupError) { + results.push(failure('ambiguous provision failure is cleaned up', cleanupError)) + } + return results + } + + let ready = false + try { + const deadline = Date.now() + readinessTimeoutMs + let observed = await observeBefore(provider, key, context.requestId, deadline) + while (!['ready', 'failed', 'terminated'].includes(observed.state) && Date.now() < deadline) { + await new Promise((resolve) => + setTimeout(resolve, Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()))), + ) + observed = await observeBefore(provider, key, context.requestId, deadline) + } + ready = observed.state === 'ready' + results.push({ + name: 'provision reaches ready within the bounded deadline', + passed: ready, + ...(!ready ? { detail: `last observed state ${observed.state}` } : {}), + }) + } catch (error) { + results.push(failure('provision reaches ready within the bounded deadline', error)) + } + + if (ready && manifest.capabilities.pauseResume) { if (!provider.pause || !provider.resume) { results.push({ name: 'pauseResume capability has SPI methods', @@ -46,15 +170,92 @@ export const runProviderConformance = async ( detail: 'manifest declares pauseResume without pause and resume methods', }) } else { - const paused = await provider.pause(key, context) - const resumed = await provider.resume(key, context) - results.push({ name: 'pause reaches paused', passed: paused.state === 'paused' }) - results.push({ name: 'resume leaves paused', passed: resumed.state !== 'paused' }) + try { + const paused = await provider.pause(key, context) + results.push({ name: 'pause reaches paused', passed: paused.state === 'paused' }) + const resumed = await provider.resume(key, context) + results.push({ name: 'resume leaves paused', passed: resumed.state !== 'paused' }) + } catch (error) { + results.push(failure('pause and resume complete', error)) + } + } + } + + if (ready && manifest.capabilities.commandExecution) { + if (!provider.execute) { + results.push({ name: 'commandExecution capability has an execute method', passed: false }) + } else if (!options.commandRequest) { + results.push({ + name: 'command execution probe is configured', + passed: false, + detail: 'commandRequest is required because no command is portable across providers', + }) + } else { + try { + const command = await provider.execute( + key, + { ...options.commandRequest, expectedGeneration: key.generation }, + context, + ) + results.push({ + name: 'command execution returns a complete successful result', + passed: + validCommandResult(command) && + command.exitCode === 0 && + !command.timedOut && + !command.cancelled, + }) + } catch (error) { + results.push(failure('command execution completes', error)) + } + } + } + + if (ready && manifest.capabilities.fileAccess) { + if (!provider.writeFile || !provider.readFile || !provider.listFiles) { + results.push({ + name: 'fileAccess capability has read, write, and list methods', + passed: false, + }) + } else { + try { + const contentBase64 = Buffer.from('conformance').toString('base64') + const expectedSize = Buffer.byteLength('conformance') + const written = await provider.writeFile( + key, + { expectedGeneration: key.generation, path: 'probe.txt', contentBase64 }, + context, + ) + const read = await provider.readFile(key, 'probe.txt', context) + const listed = await provider.listFiles(key, '.', context) + results.push({ + name: 'file round-trip returns complete metadata, bytes, and listing', + passed: + validFileResult(written, 'probe.txt', expectedSize) && + written.contentBase64 === contentBase64 && + validFileResult(read, 'probe.txt', expectedSize) && + read.contentBase64 === contentBase64 && + listed.every(validFileEntry) && + listed.some( + (entry) => + entry.path === 'probe.txt' && entry.kind === 'file' && entry.size === expectedSize, + ), + }) + } catch (error) { + results.push(failure('file round-trip completes', error)) + } } } - const terminated = await provider.terminate(key, context) - results.push({ name: 'terminate reaches terminated', passed: terminated.state === 'terminated' }) + try { + const terminated = await provider.terminate(key, context) + results.push({ + name: 'terminate reaches terminated', + passed: terminated.state === 'terminated', + }) + } catch (error) { + results.push(failure('terminate completes', error)) + } return results } diff --git a/src/index.ts b/src/index.ts index df82e22..05e25ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,9 @@ export * from './conformance.js' export * from './protocol.js' export * from './provider.js' +export * from './providers/local.js' export * from './providers/mock.js' export * from './runtime.js' +export * from './sdk.js' +export * from './server.js' +export * from './validation.js' diff --git a/src/protocol.ts b/src/protocol.ts index 6274724..6ef7498 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -12,6 +12,8 @@ export const sandboxStates = [ export type SandboxState = (typeof sandboxStates)[number] +export const protocolVersion = '0.1' + export const capabilityNames = [ 'commandExecution', 'fileAccess', @@ -22,6 +24,9 @@ export const capabilityNames = [ 'memorySnapshot', 'persistentVolume', 'networkPolicy', + 'imageReference', + 'templateReference', + 'resourceLimits', ] as const export type CapabilityName = (typeof capabilityNames)[number] @@ -35,31 +40,72 @@ export type ProviderManifest = Readonly<{ capabilities: RuntimeCapabilities }> +export type RuntimeInfo = Readonly<{ + protocolVersion: string + provider: ProviderManifest +}> + export type ResourceRequest = Readonly<{ cpuMillis?: number memoryMiB?: number diskMiB?: number }> -export type LifetimePolicy = Readonly<{ - idleTimeoutSeconds?: number - maxLifetimeSeconds?: number -}> +export type JsonValue = string | number | boolean | null | readonly JsonValue[] | JsonObject + +export type JsonObject = Readonly<{ [key: string]: JsonValue }> export type SandboxSpec = Readonly<{ clientRequestId: string image?: string template?: string resources?: ResourceRequest - lifetime?: LifetimePolicy requiredCapabilities?: readonly CapabilityName[] - extensions?: Readonly> + extensions?: JsonObject }> export type SandboxEndpoint = Readonly<{ baseUrl: string }> +export type CommandRequest = Readonly<{ + expectedGeneration: number + argv: readonly string[] + cwd?: string + env?: Readonly> + timeoutSeconds?: number + maxOutputBytes?: number +}> + +export type CommandResult = Readonly<{ + exitCode: number | null + stdout: string + stderr: string + timedOut: boolean + cancelled: boolean + truncated: boolean + startedAt: string + finishedAt: string +}> + +export type FileWriteRequest = Readonly<{ + expectedGeneration: number + path: string + contentBase64: string +}> + +export type FileReadResult = Readonly<{ + path: string + contentBase64: string + size: number +}> + +export type FileEntry = Readonly<{ + path: string + kind: 'file' | 'directory' + size: number +}> + export type SandboxResource = Readonly<{ id: string generation: number @@ -67,7 +113,7 @@ export type SandboxResource = Readonly<{ spec: SandboxSpec provider: string endpoint?: SandboxEndpoint - providerExtensions?: Readonly> + providerExtensions?: JsonObject failure?: Readonly<{ code: string message: string @@ -76,6 +122,24 @@ export type SandboxResource = Readonly<{ updatedAt: string }> +export const sandboxEventTypes = [ + 'sandbox.created', + 'sandbox.state_changed', + 'sandbox.command_completed', + 'sandbox.file_written', +] as const + +export type SandboxEventType = (typeof sandboxEventTypes)[number] + +export type SandboxEvent = Readonly<{ + cursor: number + type: SandboxEventType + sandboxId: string + generation: number + timestamp: string + data: JsonObject +}> + export const runtimeErrorCodes = [ 'invalid_request', 'unsupported_capability', @@ -84,6 +148,8 @@ export const runtimeErrorCodes = [ 'not_found', 'invalid_state', 'provider_unavailable', + 'provider_protocol_error', + 'event_history_unavailable', ] as const export type RuntimeErrorCode = (typeof runtimeErrorCodes)[number] diff --git a/src/provider.ts b/src/provider.ts index 54c5f49..a3668ab 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,4 +1,15 @@ -import type { ProviderManifest, SandboxEndpoint, SandboxSpec, SandboxState } from './protocol.js' +import type { + CommandRequest, + CommandResult, + FileEntry, + FileReadResult, + FileWriteRequest, + JsonObject, + ProviderManifest, + SandboxEndpoint, + SandboxSpec, + SandboxState, +} from './protocol.js' export type ProviderContext = Readonly<{ requestId: string @@ -13,7 +24,7 @@ export type ProviderSandboxKey = Readonly<{ export type ProviderObservation = Readonly<{ state: SandboxState endpoint?: SandboxEndpoint - extensions?: Readonly> + extensions?: JsonObject failure?: Readonly<{ code: string message: string @@ -35,4 +46,24 @@ export interface SandboxProvider { terminate(key: ProviderSandboxKey, context: ProviderContext): Promise pause?(key: ProviderSandboxKey, context: ProviderContext): Promise resume?(key: ProviderSandboxKey, context: ProviderContext): Promise + execute?( + key: ProviderSandboxKey, + request: CommandRequest, + context: ProviderContext, + ): Promise + readFile?( + key: ProviderSandboxKey, + path: string, + context: ProviderContext, + ): Promise + writeFile?( + key: ProviderSandboxKey, + request: FileWriteRequest, + context: ProviderContext, + ): Promise + listFiles?( + key: ProviderSandboxKey, + path: string, + context: ProviderContext, + ): Promise } diff --git a/src/providers/local.ts b/src/providers/local.ts new file mode 100644 index 0000000..986e4a1 --- /dev/null +++ b/src/providers/local.ts @@ -0,0 +1,406 @@ +import { createHash } from 'node:crypto' +import { spawn, type ChildProcess } from 'node:child_process' +import { constants } from 'node:fs' +import { + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + realpath, + rm, + stat, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import type { + ProviderContext, + ProviderObservation, + ProviderProvisionRequest, + ProviderSandboxKey, + SandboxProvider, +} from '../provider.js' +import { + type CommandRequest, + type CommandResult, + type FileEntry, + type FileReadResult, + type FileWriteRequest, + type ProviderManifest, + RuntimeError, +} from '../protocol.js' + +type LocalResource = { + directory: string + processes: Map void> +} + +export type LocalSandboxProviderOptions = Readonly<{ + rootDirectory?: string + defaultTimeoutSeconds?: number + defaultMaxOutputBytes?: number + maxFileBytes?: number +}> + +const isWithin = (root: string, candidate: string): boolean => { + const path = relative(root, candidate) + return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path)) +} + +const decodeBase64 = (value: string): Buffer => { + const normalized = value.replace(/\s/g, '') + if ( + normalized.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(normalized) + ) { + throw new RuntimeError('invalid_request', 'contentBase64 is not valid base64') + } + return Buffer.from(normalized, 'base64') +} + +export class LocalSandboxProvider implements SandboxProvider { + readonly #resources = new Map() + readonly #root: Promise + readonly #ownsRoot: boolean + readonly #defaultTimeoutSeconds: number + readonly #defaultMaxOutputBytes: number + readonly #maxFileBytes: number + + constructor(options: LocalSandboxProviderOptions = {}) { + this.#ownsRoot = !options.rootDirectory + const configuredRoot = options.rootDirectory + this.#root = configuredRoot + ? mkdir(resolve(configuredRoot), { recursive: true }).then(() => realpath(configuredRoot)) + : mkdtemp(join(tmpdir(), 'sandbox-runtime-api-')) + this.#defaultTimeoutSeconds = options.defaultTimeoutSeconds ?? 30 + this.#defaultMaxOutputBytes = options.defaultMaxOutputBytes ?? 1024 * 1024 + this.#maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024 + } + + async describe(_context: ProviderContext): Promise { + return { + name: 'local', + version: '0.1.0', + runtimeClass: 'local-process-unsafe', + capabilities: { + commandExecution: true, + fileAccess: true, + interactiveTerminal: false, + portForwarding: false, + pauseResume: false, + filesystemSnapshot: false, + memorySnapshot: false, + persistentVolume: false, + networkPolicy: false, + imageReference: false, + templateReference: false, + resourceLimits: false, + }, + } + } + + async provision( + request: ProviderProvisionRequest, + _context: ProviderContext, + ): Promise { + if (request.spec.image || request.spec.template) { + throw new RuntimeError( + 'invalid_request', + 'the local provider does not implement image or template isolation', + ) + } + const root = await this.#root + const resourceKey = this.#key(request) + const directory = join(root, createHash('sha256').update(resourceKey).digest('hex')) + await mkdir(join(directory, 'tmp'), { recursive: true }) + this.#resources.set(resourceKey, { directory: await realpath(directory), processes: new Map() }) + return { state: 'ready', extensions: { 'local.resourceKey': resourceKey } } + } + + async observe(key: ProviderSandboxKey, _context: ProviderContext): Promise { + return { state: this.#resources.has(this.#key(key)) ? 'ready' : 'terminated' } + } + + async terminate( + key: ProviderSandboxKey, + _context: ProviderContext, + ): Promise { + const resource = this.#resources.get(this.#key(key)) + if (!resource) return { state: 'terminated' } + for (const cancel of resource.processes.values()) cancel() + await rm(resource.directory, { recursive: true, force: true }) + this.#resources.delete(this.#key(key)) + return { state: 'terminated' } + } + + async execute( + key: ProviderSandboxKey, + request: CommandRequest, + context: ProviderContext, + ): Promise { + const startedAt = new Date().toISOString() + if (context.signal?.aborted) return this.#cancelledResult(startedAt) + if ( + request.argv.length === 0 || + request.argv.some((argument) => typeof argument !== 'string') + ) { + throw new RuntimeError('invalid_request', 'argv must contain at least one string') + } + if (request.argv.some((argument) => argument.includes('\0'))) { + throw new RuntimeError('invalid_request', 'argv must not contain null bytes') + } + const resource = this.#resource(key) + const cwd = await this.#existingPath(resource, request.cwd ?? '.') + if (!(await stat(cwd)).isDirectory()) { + throw new RuntimeError('invalid_request', 'cwd must be a directory') + } + if (context.signal?.aborted) return this.#cancelledResult(startedAt) + const timeoutSeconds = request.timeoutSeconds ?? this.#defaultTimeoutSeconds + const maxOutputBytes = request.maxOutputBytes ?? this.#defaultMaxOutputBytes + if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || timeoutSeconds > 3600) { + throw new RuntimeError('invalid_request', 'timeoutSeconds must be in (0, 3600]') + } + if ( + !Number.isInteger(maxOutputBytes) || + maxOutputBytes < 1 || + maxOutputBytes > 10 * 1024 * 1024 + ) { + throw new RuntimeError('invalid_request', 'maxOutputBytes must be in [1, 10485760]') + } + for (const [name, value] of Object.entries(request.env ?? {})) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || value.includes('\0')) { + throw new RuntimeError('invalid_request', `invalid environment entry ${name}`) + } + } + + const [command, ...arguments_] = request.argv + if (!command) throw new RuntimeError('invalid_request', 'argv is required') + const child = spawn(command, arguments_, { + cwd, + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: resource.directory, + TMPDIR: join(resource.directory, 'tmp'), + LANG: 'C.UTF-8', + ...request.env, + }, + shell: false, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + let stdoutBytes = 0 + let stderrBytes = 0 + let truncated = false + let timedOut = false + let cancelled = false + const capture = (target: Buffer[], chunk: Buffer, currentBytes: number): number => { + const available = Math.max(0, maxOutputBytes - currentBytes) + if (chunk.byteLength > available) truncated = true + if (available > 0) target.push(chunk.subarray(0, available)) + return currentBytes + Math.min(available, chunk.byteLength) + } + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBytes = capture(stdout, chunk, stdoutBytes) + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderrBytes = capture(stderr, chunk, stderrBytes) + }) + + const timeout = () => { + timedOut = true + this.#killProcess(child) + } + const abort = () => { + cancelled = true + this.#killProcess(child) + } + resource.processes.set(child, abort) + const timer = setTimeout(timeout, timeoutSeconds * 1000) + context.signal?.addEventListener('abort', abort, { once: true }) + if (context.signal?.aborted) abort() + + try { + const exitCode = await new Promise((resolveExit, reject) => { + child.once('error', reject) + child.once('close', resolveExit) + }) + return { + exitCode, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + timedOut, + cancelled, + truncated, + startedAt, + finishedAt: new Date().toISOString(), + } + } finally { + clearTimeout(timer) + context.signal?.removeEventListener('abort', abort) + resource.processes.delete(child) + } + } + + async readFile( + key: ProviderSandboxKey, + path: string, + _context: ProviderContext, + ): Promise { + const resource = this.#resource(key) + const target = await this.#existingPath(resource, path) + const metadata = await lstat(target) + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new RuntimeError('invalid_request', 'path must reference a regular file') + } + if (metadata.size > this.#maxFileBytes) { + throw new RuntimeError('invalid_request', 'file exceeds the configured read limit') + } + const content = await readFile(target) + return { path, contentBase64: content.toString('base64'), size: content.byteLength } + } + + async writeFile( + key: ProviderSandboxKey, + request: FileWriteRequest, + _context: ProviderContext, + ): Promise { + const resource = this.#resource(key) + const target = await this.#writablePath(resource, request.path) + const content = decodeBase64(request.contentBase64) + if (content.byteLength > this.#maxFileBytes) { + throw new RuntimeError('invalid_request', 'file exceeds the configured write limit') + } + const handle = await open( + target, + constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, + 0o600, + ).catch(() => { + throw new RuntimeError('invalid_request', 'write target must be a regular non-symbolic file') + }) + try { + await handle.writeFile(content) + } finally { + await handle.close() + } + return { + path: request.path, + contentBase64: content.toString('base64'), + size: content.byteLength, + } + } + + async listFiles( + key: ProviderSandboxKey, + path: string, + _context: ProviderContext, + ): Promise { + const resource = this.#resource(key) + const directory = await this.#existingPath(resource, path) + if (!(await stat(directory)).isDirectory()) { + throw new RuntimeError('invalid_request', 'path must reference a directory') + } + const entries = await readdir(directory, { withFileTypes: true }) + const result: FileEntry[] = [] + for (const entry of entries) { + if (entry.isSymbolicLink()) { + throw new RuntimeError('invalid_request', 'symbolic links are not portable file entries') + } + const entryPath = join(directory, entry.name) + const metadata = await stat(entryPath) + result.push({ + path: path === '.' || path === '' ? entry.name : `${path.replace(/\/$/, '')}/${entry.name}`, + kind: entry.isDirectory() ? 'directory' : 'file', + size: metadata.size, + }) + } + return result.sort((left, right) => left.path.localeCompare(right.path)) + } + + async dispose(): Promise { + for (const [key, resource] of this.#resources) { + for (const cancel of resource.processes.values()) cancel() + await rm(resource.directory, { recursive: true, force: true }) + this.#resources.delete(key) + } + if (this.#ownsRoot) await rm(await this.#root, { recursive: true, force: true }) + } + + #key(key: ProviderSandboxKey): string { + return `${key.sandboxId}:${key.generation}` + } + + #cancelledResult(startedAt: string): CommandResult { + return { + exitCode: null, + stdout: '', + stderr: '', + timedOut: false, + cancelled: true, + truncated: false, + startedAt, + finishedAt: new Date().toISOString(), + } + } + + #killProcess(child: ChildProcess): void { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return + if (process.platform !== 'win32') { + try { + process.kill(-child.pid, 'SIGKILL') + return + } catch { + // Fall back to the direct child when the process group has already exited. + } + } + child.kill('SIGKILL') + } + + #resource(key: ProviderSandboxKey): LocalResource { + const resource = this.#resources.get(this.#key(key)) + if (!resource) throw new RuntimeError('not_found', 'local sandbox resource does not exist') + return resource + } + + #validateRelativePath(path: string): void { + if (!path || isAbsolute(path) || path.includes('\0')) { + throw new RuntimeError('invalid_request', 'path must be a non-empty relative path') + } + } + + async #existingPath(resource: LocalResource, path: string): Promise { + this.#validateRelativePath(path) + const candidate = resolve(resource.directory, path) + if (!isWithin(resource.directory, candidate)) { + throw new RuntimeError('invalid_request', 'path escapes the sandbox directory') + } + const actual = await realpath(candidate).catch(() => { + throw new RuntimeError('not_found', `path ${path} does not exist`) + }) + if (!isWithin(resource.directory, actual)) { + throw new RuntimeError('invalid_request', 'path resolves outside the sandbox directory') + } + return actual + } + + async #writablePath(resource: LocalResource, path: string): Promise { + this.#validateRelativePath(path) + const candidate = resolve(resource.directory, path) + if (!isWithin(resource.directory, candidate)) { + throw new RuntimeError('invalid_request', 'path escapes the sandbox directory') + } + const parent = await realpath(dirname(candidate)).catch(() => { + throw new RuntimeError('not_found', 'parent directory does not exist') + }) + if (!isWithin(resource.directory, parent)) { + throw new RuntimeError('invalid_request', 'parent resolves outside the sandbox directory') + } + const existing = await lstat(candidate).catch(() => undefined) + if (existing?.isSymbolicLink()) { + throw new RuntimeError('invalid_request', 'symbolic-link writes are not allowed') + } + return candidate + } +} diff --git a/src/providers/mock.ts b/src/providers/mock.ts index 5ed795b..4094cd0 100644 --- a/src/providers/mock.ts +++ b/src/providers/mock.ts @@ -5,7 +5,15 @@ import type { ProviderSandboxKey, SandboxProvider, } from '../provider.js' -import type { ProviderManifest, RuntimeCapabilities } from '../protocol.js' +import type { + CommandRequest, + CommandResult, + FileEntry, + FileReadResult, + FileWriteRequest, + ProviderManifest, + RuntimeCapabilities, +} from '../protocol.js' const allCapabilities = (overrides: Partial = {}): RuntimeCapabilities => ({ commandExecution: true, @@ -17,11 +25,15 @@ const allCapabilities = (overrides: Partial = {}): RuntimeC memorySnapshot: false, persistentVolume: false, networkPolicy: false, + imageReference: false, + templateReference: false, + resourceLimits: false, ...overrides, }) export class MockSandboxProvider implements SandboxProvider { readonly #resources = new Map() + readonly #files = new Map>() readonly #manifest: ProviderManifest constructor(capabilities: Partial = {}) { @@ -46,12 +58,13 @@ export class MockSandboxProvider implements SandboxProvider { endpoint: { baseUrl: `http://127.0.0.1/mock/${request.sandboxId}` }, extensions: { 'mock.resourceId': `${request.sandboxId}:${request.generation}` }, } - this.#resources.set(request.sandboxId, observation) + this.#resources.set(this.#key(request), observation) + this.#files.set(this.#key(request), new Map()) return observation } async observe(key: ProviderSandboxKey, _context: ProviderContext): Promise { - const observation = this.#resources.get(key.sandboxId) + const observation = this.#resources.get(this.#key(key)) if (!observation) { return { state: 'failed', failure: { code: 'not_found', message: 'mock resource missing' } } } @@ -63,13 +76,14 @@ export class MockSandboxProvider implements SandboxProvider { _context: ProviderContext, ): Promise { const observation: ProviderObservation = { state: 'terminated' } - this.#resources.set(key.sandboxId, observation) + this.#resources.set(this.#key(key), observation) + this.#files.delete(this.#key(key)) return observation } async pause(key: ProviderSandboxKey, _context: ProviderContext): Promise { const observation: ProviderObservation = { state: 'paused' } - this.#resources.set(key.sandboxId, observation) + this.#resources.set(this.#key(key), observation) return observation } @@ -78,7 +92,79 @@ export class MockSandboxProvider implements SandboxProvider { state: 'ready', endpoint: { baseUrl: `http://127.0.0.1/mock/${key.sandboxId}` }, } - this.#resources.set(key.sandboxId, observation) + this.#resources.set(this.#key(key), observation) return observation } + + async execute( + _key: ProviderSandboxKey, + request: CommandRequest, + _context: ProviderContext, + ): Promise { + if (request.argv.length === 0) throw new Error('argv is required') + const now = new Date().toISOString() + return { + exitCode: 0, + stdout: request.argv.join(' '), + stderr: '', + timedOut: false, + cancelled: false, + truncated: false, + startedAt: now, + finishedAt: now, + } + } + + async readFile( + key: ProviderSandboxKey, + path: string, + _context: ProviderContext, + ): Promise { + const content = this.#fileMap(key).get(path) + if (!content) throw new Error(`file ${path} does not exist`) + return { + path, + contentBase64: Buffer.from(content).toString('base64'), + size: content.byteLength, + } + } + + async writeFile( + key: ProviderSandboxKey, + request: FileWriteRequest, + _context: ProviderContext, + ): Promise { + const content = Buffer.from(request.contentBase64, 'base64') + this.#fileMap(key).set(request.path, content) + return { + path: request.path, + contentBase64: content.toString('base64'), + size: content.byteLength, + } + } + + async listFiles( + key: ProviderSandboxKey, + path: string, + _context: ProviderContext, + ): Promise { + const prefix = path === '.' || path === '' ? '' : `${path.replace(/\/$/, '')}/` + return [...this.#fileMap(key).entries()] + .filter(([name]) => { + if (!name.startsWith(prefix)) return false + return !name.slice(prefix.length).includes('/') + }) + .map(([name, content]) => ({ path: name, kind: 'file' as const, size: content.byteLength })) + .sort((left, right) => left.path.localeCompare(right.path)) + } + + #key(key: ProviderSandboxKey): string { + return `${key.sandboxId}:${key.generation}` + } + + #fileMap(key: ProviderSandboxKey): Map { + const files = this.#files.get(this.#key(key)) + if (!files) throw new Error('mock resource missing') + return files + } } diff --git a/src/runtime.ts b/src/runtime.ts index bbeb58c..f760f9c 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,6 +1,20 @@ import type { ProviderContext, ProviderObservation, SandboxProvider } from './provider.js' import { + type CapabilityName, + type CommandRequest, + type CommandResult, + type FileEntry, + type FileReadResult, + type FileWriteRequest, + type JsonObject, + type JsonValue, + protocolVersion, + capabilityNames, + type ProviderManifest, RuntimeError, + type RuntimeInfo, + type SandboxEvent, + type SandboxEventType, type SandboxResource, type SandboxSpec, type SandboxState, @@ -8,11 +22,23 @@ import { type Clock = () => Date type IdFactory = () => string +type EventListener = (event: SandboxEvent) => void +type ListenerErrorHandler = (error: unknown, event: SandboxEvent) => void + +const transitions: Readonly> = { + requested: ['requested', 'starting', 'terminating', 'failed'], + starting: ['starting', 'ready', 'terminating', 'failed'], + ready: ['ready', 'pausing', 'terminating', 'failed'], + pausing: ['pausing', 'paused', 'ready', 'terminating', 'failed'], + paused: ['paused', 'resuming', 'terminating', 'failed'], + resuming: ['resuming', 'ready', 'paused', 'terminating', 'failed'], + terminating: ['terminating', 'terminated', 'failed'], + terminated: ['terminated', 'requested'], + failed: ['failed', 'terminating', 'terminated'], +} const canonicalize = (value: unknown): unknown => { - if (Array.isArray(value)) { - return value.map(canonicalize) - } + if (Array.isArray(value)) return value.map(canonicalize) if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value) @@ -24,47 +50,459 @@ const canonicalize = (value: unknown): unknown => { } const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value)) +const extensionKeyPattern = /^[a-z][a-z0-9-]*\.[A-Za-z][A-Za-z0-9_.-]*$/ + +const isJsonValue = (value: unknown, seen = new WeakSet()): value is JsonValue => { + if (value === null || ['string', 'boolean'].includes(typeof value)) return true + if (typeof value === 'number') return Number.isFinite(value) + if (typeof value !== 'object') return false + if (seen.has(value)) return false + seen.add(value) + const valid = Array.isArray(value) + ? value.every((entry) => isJsonValue(entry, seen)) + : [Object.prototype, null].includes(Object.getPrototypeOf(value)) && + Object.values(value).every((entry) => isJsonValue(entry, seen)) + seen.delete(value) + return valid +} + +const strictBase64 = (value: unknown): Buffer | undefined => { + if (typeof value !== 'string') return undefined + const normalized = value.replace(/\s/g, '') + if ( + normalized.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(normalized) + ) { + return undefined + } + return Buffer.from(normalized, 'base64') +} export class InMemorySandboxRuntime { readonly #provider: SandboxProvider readonly #clock: Clock readonly #idFactory: IdFactory + readonly #maxEvents: number + readonly #listenerErrorHandler: ListenerErrorHandler readonly #resources = new Map() - readonly #requests = new Map() + readonly #requests = new Map< + string, + { sandboxId: string; generation: number; fingerprint: string } + >() + readonly #inflightCreates = new Map< + string, + { fingerprint: string; promise: Promise } + >() + readonly #locks = new Map>() + readonly #events: SandboxEvent[] = [] + readonly #listeners = new Set() + #nextCursor = 1 constructor( provider: SandboxProvider, - options: Readonly<{ clock?: Clock; idFactory?: IdFactory }> = {}, + options: Readonly<{ + clock?: Clock + idFactory?: IdFactory + maxEvents?: number + listenerErrorHandler?: ListenerErrorHandler + }> = {}, ) { this.#provider = provider this.#clock = options.clock ?? (() => new Date()) this.#idFactory = options.idFactory ?? (() => crypto.randomUUID()) + this.#maxEvents = options.maxEvents ?? 10_000 + this.#listenerErrorHandler = options.listenerErrorHandler ?? (() => {}) + if (!Number.isInteger(this.#maxEvents) || this.#maxEvents < 1) { + throw new RuntimeError('invalid_request', 'maxEvents must be a positive integer') + } } - async create(spec: SandboxSpec, context: ProviderContext): Promise { - this.#validateSpec(spec) + async info(context: ProviderContext): Promise { + return { protocolVersion, provider: await this.#describeProvider(context) } + } + + create(spec: SandboxSpec, context: ProviderContext): Promise { + try { + this.#validateSpec(spec) + } catch (error) { + return Promise.reject(error) + } const fingerprint = stableJson(spec) const existingRequest = this.#requests.get(spec.clientRequestId) if (existingRequest) { if (existingRequest.fingerprint !== fingerprint) { - throw new RuntimeError( - 'idempotency_conflict', - `clientRequestId ${spec.clientRequestId} was reused with a different specification`, + return Promise.reject( + new RuntimeError( + 'idempotency_conflict', + `clientRequestId ${spec.clientRequestId} was reused with a different specification`, + ), ) } - return this.get(existingRequest.sandboxId) + const resource = this.get(existingRequest.sandboxId) + if (resource.generation !== existingRequest.generation) { + return Promise.reject( + new RuntimeError( + 'idempotency_conflict', + `clientRequestId ${spec.clientRequestId} belongs to an older generation`, + ), + ) + } + return Promise.resolve(resource) } - const manifest = await this.#provider.describe(context) - for (const capability of spec.requiredCapabilities ?? []) { - if (!manifest.capabilities[capability]) { - throw new RuntimeError( - 'unsupported_capability', - `provider ${manifest.name} does not support ${capability}`, + const inflight = this.#inflightCreates.get(spec.clientRequestId) + if (inflight) { + if (inflight.fingerprint !== fingerprint) { + return Promise.reject( + new RuntimeError( + 'idempotency_conflict', + `clientRequestId ${spec.clientRequestId} has a different create in flight`, + ), ) } + return inflight.promise } + const promise = this.#createNew(spec, fingerprint, context).finally(() => { + const current = this.#inflightCreates.get(spec.clientRequestId) + if (current?.promise === promise) this.#inflightCreates.delete(spec.clientRequestId) + }) + this.#inflightCreates.set(spec.clientRequestId, { fingerprint, promise }) + return promise + } + + get(id: string): SandboxResource { + const resource = this.#resources.get(id) + if (!resource) throw new RuntimeError('not_found', `sandbox ${id} does not exist`) + return resource + } + + list(): readonly SandboxResource[] { + return [...this.#resources.values()] + } + + events(afterCursor = 0, sandboxId?: string): readonly SandboxEvent[] { + if (!Number.isInteger(afterCursor) || afterCursor < 0) { + throw new RuntimeError('invalid_request', 'afterCursor must be a non-negative integer') + } + const oldestCursor = this.#events[0]?.cursor + if (afterCursor > 0 && oldestCursor !== undefined && afterCursor < oldestCursor - 1) { + throw new RuntimeError( + 'event_history_unavailable', + `event history starts at cursor ${oldestCursor}`, + ) + } + return this.#events.filter( + (event) => event.cursor > afterCursor && (!sandboxId || event.sandboxId === sandboxId), + ) + } + + subscribe(listener: EventListener): () => void { + this.#listeners.add(listener) + return () => this.#listeners.delete(listener) + } + + reconcile(id: string, context: ProviderContext): Promise { + return this.#withLock(id, async () => { + const current = this.get(id) + if (current.state === 'terminated') return current + try { + const observation = await this.#provider.observe( + { sandboxId: id, generation: current.generation }, + context, + ) + return this.#writeObservation(id, observation) + } catch (error) { + throw this.#providerError('observe', error) + } + }) + } + + terminate( + id: string, + expectedGeneration: number, + context: ProviderContext, + ): Promise { + return this.#withLock(id, async () => { + const current = this.get(id) + this.#assertGeneration(current, expectedGeneration) + if (current.state === 'terminated') return current + if (!['requested', 'starting', 'ready', 'paused', 'failed'].includes(current.state)) { + throw new RuntimeError( + 'invalid_state', + `sandbox ${id} cannot terminate from ${current.state}`, + ) + } + this.#writeObservation(id, { state: 'terminating' }) + try { + return this.#writeObservation( + id, + await this.#provider.terminate( + { sandboxId: id, generation: current.generation }, + context, + ), + ) + } catch (error) { + this.#writeObservation(id, { + state: 'failed', + failure: { code: 'provider_unavailable', message: this.#errorMessage(error) }, + }) + throw this.#providerError('terminate', error) + } + }) + } + + pause( + id: string, + expectedGeneration: number, + context: ProviderContext, + ): Promise { + return this.#withLock(id, async () => { + const current = this.get(id) + this.#assertGeneration(current, expectedGeneration) + if (current.state !== 'ready') { + throw new RuntimeError('invalid_state', `sandbox ${id} is not ready`) + } + await this.#requireCapability('pauseResume', context) + if (!this.#provider.pause) { + throw new RuntimeError('provider_protocol_error', 'provider does not implement pause') + } + this.#writeObservation(id, { state: 'pausing' }) + try { + return this.#writeObservation( + id, + await this.#provider.pause({ sandboxId: id, generation: current.generation }, context), + ) + } catch (error) { + throw this.#providerError('pause', error) + } + }) + } + + resume( + id: string, + expectedGeneration: number, + context: ProviderContext, + ): Promise { + return this.#withLock(id, async () => { + const current = this.get(id) + this.#assertGeneration(current, expectedGeneration) + if (current.state !== 'paused') { + throw new RuntimeError('invalid_state', `sandbox ${id} is not paused`) + } + await this.#requireCapability('pauseResume', context) + if (!this.#provider.resume) { + throw new RuntimeError('provider_protocol_error', 'provider does not implement resume') + } + this.#writeObservation(id, { state: 'resuming' }) + try { + return this.#writeObservation( + id, + await this.#provider.resume({ sandboxId: id, generation: current.generation }, context), + ) + } catch (error) { + throw this.#providerError('resume', error) + } + }) + } + + recreate( + id: string, + expectedGeneration: number, + spec: SandboxSpec, + context: ProviderContext, + ): Promise { + this.#validateSpec(spec) + this.#validateExpectedGeneration(expectedGeneration) + return this.#withLock(id, async () => { + const current = this.get(id) + const fingerprint = stableJson(spec) + const existingRequest = this.#requests.get(spec.clientRequestId) + if (existingRequest) { + if ( + existingRequest.fingerprint !== fingerprint || + existingRequest.sandboxId !== id || + current.spec.clientRequestId !== spec.clientRequestId || + current.generation !== expectedGeneration + 1 || + existingRequest.generation !== current.generation + ) { + throw new RuntimeError('idempotency_conflict', 'recreate clientRequestId is already used') + } + return current + } + this.#assertGeneration(current, expectedGeneration) + if (current.state !== 'terminated') { + throw new RuntimeError('invalid_state', `sandbox ${id} is not terminated`) + } + const manifest = await this.#describeProvider(context) + this.#assertCapabilities(spec, manifest.capabilities) + const next: SandboxResource = { + id, + generation: current.generation + 1, + state: 'requested', + spec, + provider: manifest.name, + createdAt: current.createdAt, + updatedAt: this.#clock().toISOString(), + } + this.#resources.set(id, next) + this.#requests.set(spec.clientRequestId, { + sandboxId: id, + generation: next.generation, + fingerprint, + }) + this.#emit('sandbox.created', next, { recreated: true }) + this.#writeObservation(id, { state: 'starting' }) + let observation: ProviderObservation + try { + observation = await this.#provider.provision( + { sandboxId: id, generation: next.generation, spec }, + context, + ) + } catch (error) { + return this.#writeObservation(id, { + state: 'failed', + failure: { code: 'provider_unavailable', message: this.#errorMessage(error) }, + }) + } + return this.#writeObservation(id, observation) + }) + } + + execute(id: string, request: CommandRequest, context: ProviderContext): Promise { + return (async () => { + this.#validateCommandRequest(request) + const resource = await this.#readyWithCapability( + id, + request.expectedGeneration, + 'commandExecution', + context, + ) + if (!this.#provider.execute) { + throw new RuntimeError('provider_protocol_error', 'provider does not implement execute') + } + try { + const result = await this.#provider.execute( + { sandboxId: id, generation: resource.generation }, + request, + context, + ) + this.#assertCommandResult(result) + this.#emit('sandbox.command_completed', resource, { + exitCode: result.exitCode, + timedOut: result.timedOut, + cancelled: result.cancelled, + truncated: result.truncated, + }) + return result + } catch (error) { + if (error instanceof RuntimeError) throw error + throw this.#providerError('execute', error) + } + })() + } + + readFile( + id: string, + expectedGeneration: number, + path: string, + context: ProviderContext, + ): Promise { + return (async () => { + this.#validatePath(path) + const resource = await this.#readyWithCapability( + id, + expectedGeneration, + 'fileAccess', + context, + ) + if (!this.#provider.readFile) { + throw new RuntimeError('provider_protocol_error', 'provider does not implement readFile') + } + try { + const result = await this.#provider.readFile( + { sandboxId: id, generation: resource.generation }, + path, + context, + ) + this.#assertFileResult(result, path) + return result + } catch (error) { + throw this.#providerError('readFile', error) + } + })() + } + + writeFile( + id: string, + request: FileWriteRequest, + context: ProviderContext, + ): Promise { + return (async () => { + this.#validateFileWriteRequest(request) + const resource = await this.#readyWithCapability( + id, + request.expectedGeneration, + 'fileAccess', + context, + ) + if (!this.#provider.writeFile) { + throw new RuntimeError('provider_protocol_error', 'provider does not implement writeFile') + } + let result: FileReadResult + try { + result = await this.#provider.writeFile( + { sandboxId: id, generation: resource.generation }, + request, + context, + ) + this.#assertFileResult(result, request.path, request.contentBase64) + } catch (error) { + throw this.#providerError('writeFile', error) + } + this.#emit('sandbox.file_written', resource, { path: result.path, size: result.size }) + return result + })() + } + + listFiles( + id: string, + expectedGeneration: number, + path: string, + context: ProviderContext, + ): Promise { + return (async () => { + this.#validatePath(path) + const resource = await this.#readyWithCapability( + id, + expectedGeneration, + 'fileAccess', + context, + ) + if (!this.#provider.listFiles) { + throw new RuntimeError('provider_protocol_error', 'provider does not implement listFiles') + } + try { + const entries = await this.#provider.listFiles( + { sandboxId: id, generation: resource.generation }, + path, + context, + ) + this.#assertFileEntries(entries) + return entries + } catch (error) { + throw this.#providerError('listFiles', error) + } + })() + } + + async #createNew( + spec: SandboxSpec, + fingerprint: string, + context: ProviderContext, + ): Promise { + const manifest = await this.#describeProvider(context) + this.#assertCapabilities(spec, manifest.capabilities) const id = this.#idFactory() const now = this.#clock().toISOString() const requested: SandboxResource = { @@ -77,121 +515,171 @@ export class InMemorySandboxRuntime { updatedAt: now, } this.#resources.set(id, requested) - this.#requests.set(spec.clientRequestId, { sandboxId: id, fingerprint }) + this.#requests.set(spec.clientRequestId, { + sandboxId: id, + generation: requested.generation, + fingerprint, + }) + this.#emit('sandbox.created', requested, { recreated: false }) this.#writeObservation(id, { state: 'starting' }) - + let observation: ProviderObservation try { - const observation = await this.#provider.provision( - { sandboxId: id, generation: 1, spec }, + observation = await this.#provider.provision( + { sandboxId: id, generation: requested.generation, spec }, context, ) - return this.#writeObservation(id, observation) } catch (error) { - const message = error instanceof Error ? error.message : 'provider failed without an error' return this.#writeObservation(id, { state: 'failed', - failure: { code: 'provider_unavailable', message }, + failure: { code: 'provider_unavailable', message: this.#errorMessage(error) }, }) } + return this.#writeObservation(id, observation) } - get(id: string): SandboxResource { - const resource = this.#resources.get(id) - if (!resource) { - throw new RuntimeError('not_found', `sandbox ${id} does not exist`) - } - return resource - } - - list(): readonly SandboxResource[] { - return [...this.#resources.values()] - } - - async reconcile(id: string, context: ProviderContext): Promise { + #writeObservation(id: string, observation: ProviderObservation): SandboxResource { const current = this.get(id) - if (current.state === 'terminated') { - return current + if (observation.extensions && !isJsonValue(observation.extensions)) { + throw new RuntimeError( + 'provider_protocol_error', + 'provider extensions must contain only JSON values', + ) } - const observation = await this.#provider.observe( - { sandboxId: id, generation: current.generation }, - context, - ) - return this.#writeObservation(id, observation) + if ( + observation.extensions && + Object.keys(observation.extensions).some((key) => !extensionKeyPattern.test(key)) + ) { + throw new RuntimeError( + 'provider_protocol_error', + 'provider extension keys must be namespaced', + ) + } + if (observation.failure && observation.state !== 'failed') { + throw new RuntimeError('provider_protocol_error', 'provider failure requires failed state') + } + if (observation.state === 'failed' && !observation.failure) { + throw new RuntimeError('provider_protocol_error', 'failed provider state requires failure') + } + if (observation.endpoint) { + try { + const endpoint = new URL(observation.endpoint.baseUrl) + if (!['http:', 'https:'].includes(endpoint.protocol)) + throw new Error('unsupported protocol') + } catch { + throw new RuntimeError('provider_protocol_error', 'provider endpoint must be an HTTP URL') + } + } + if (!transitions[current.state].includes(observation.state)) { + throw new RuntimeError( + 'provider_protocol_error', + `provider attempted invalid transition ${current.state} -> ${observation.state}`, + ) + } + const endpoint = observation.endpoint ?? current.endpoint + const providerExtensions = observation.extensions ?? current.providerExtensions + const updated: SandboxResource = { + id: current.id, + generation: current.generation, + state: observation.state, + spec: current.spec, + provider: current.provider, + createdAt: current.createdAt, + updatedAt: this.#clock().toISOString(), + ...(observation.state !== 'terminated' && endpoint ? { endpoint } : {}), + ...(providerExtensions ? { providerExtensions } : {}), + ...(observation.failure ? { failure: observation.failure } : {}), + } + this.#resources.set(id, updated) + if (current.state !== updated.state) { + this.#emit('sandbox.state_changed', updated, { + previousState: current.state, + state: updated.state, + }) + } + return updated } - async terminate( - id: string, - expectedGeneration: number, - context: ProviderContext, - ): Promise { - const current = this.get(id) - this.#assertGeneration(current, expectedGeneration) - if (current.state === 'terminated') { - return current + #emit(type: SandboxEventType, resource: SandboxResource, data: JsonObject): void { + const event: SandboxEvent = { + cursor: this.#nextCursor++, + type, + sandboxId: resource.id, + generation: resource.generation, + timestamp: this.#clock().toISOString(), + data, + } + this.#events.push(event) + if (this.#events.length > this.#maxEvents) { + this.#events.splice(0, this.#events.length - this.#maxEvents) + } + for (const listener of this.#listeners) { + try { + listener(event) + } catch (error) { + try { + this.#listenerErrorHandler(error, event) + } catch { + // Observer failures must not change portable runtime behavior. + } + } } - this.#writeObservation(id, { state: 'terminating' }) - const observation = await this.#provider.terminate( - { sandboxId: id, generation: current.generation }, - context, - ) - return this.#writeObservation(id, observation) } - async pause( + async #readyWithCapability( id: string, expectedGeneration: number, + capability: CapabilityName, context: ProviderContext, ): Promise { + const resource = this.get(id) + this.#assertGeneration(resource, expectedGeneration) + if (resource.state !== 'ready') { + throw new RuntimeError('invalid_state', `sandbox ${id} is not ready`) + } + await this.#requireCapability(capability, context) const current = this.get(id) this.#assertGeneration(current, expectedGeneration) if (current.state !== 'ready') { throw new RuntimeError('invalid_state', `sandbox ${id} is not ready`) } - if (!this.#provider.pause) { - throw new RuntimeError('unsupported_capability', 'provider does not implement pause') - } - this.#writeObservation(id, { state: 'pausing' }) - return this.#writeObservation( - id, - await this.#provider.pause({ sandboxId: id, generation: current.generation }, context), - ) + return current } - async resume( - id: string, - expectedGeneration: number, - context: ProviderContext, - ): Promise { - const current = this.get(id) - this.#assertGeneration(current, expectedGeneration) - if (current.state !== 'paused') { - throw new RuntimeError('invalid_state', `sandbox ${id} is not paused`) - } - if (!this.#provider.resume) { - throw new RuntimeError('unsupported_capability', 'provider does not implement resume') + async #requireCapability(capability: CapabilityName, context: ProviderContext): Promise { + const manifest = await this.#describeProvider(context) + if (!manifest.capabilities[capability]) { + throw new RuntimeError( + 'unsupported_capability', + `provider ${manifest.name} does not support ${capability}`, + ) } - this.#writeObservation(id, { state: 'resuming' }) - return this.#writeObservation( - id, - await this.#provider.resume({ sandboxId: id, generation: current.generation }, context), - ) } - #writeObservation(id: string, observation: ProviderObservation): SandboxResource { - const current = this.get(id) - const updated: SandboxResource = { - ...current, - state: observation.state, - updatedAt: this.#clock().toISOString(), - ...(observation.endpoint ? { endpoint: observation.endpoint } : {}), - ...(observation.extensions ? { providerExtensions: observation.extensions } : {}), - ...(observation.failure ? { failure: observation.failure } : {}), + #assertCapabilities( + spec: SandboxSpec, + capabilities: Readonly>, + ): void { + if (spec.image && !capabilities.imageReference) { + throw new RuntimeError('unsupported_capability', 'provider does not support imageReference') + } + if (spec.template && !capabilities.templateReference) { + throw new RuntimeError( + 'unsupported_capability', + 'provider does not support templateReference', + ) + } + if (spec.resources && !capabilities.resourceLimits) { + throw new RuntimeError('unsupported_capability', 'provider does not support resourceLimits') + } + for (const capability of spec.requiredCapabilities ?? []) { + if (!capabilities[capability]) { + throw new RuntimeError('unsupported_capability', `provider does not support ${capability}`) + } } - this.#resources.set(id, updated) - return updated } #assertGeneration(resource: SandboxResource, expectedGeneration: number): void { + this.#validateExpectedGeneration(expectedGeneration) if (resource.generation !== expectedGeneration) { throw new RuntimeError( 'generation_conflict', @@ -200,6 +688,12 @@ export class InMemorySandboxRuntime { } } + #validateExpectedGeneration(expectedGeneration: number): void { + if (!Number.isInteger(expectedGeneration) || expectedGeneration < 1) { + throw new RuntimeError('invalid_request', 'expectedGeneration must be a positive integer') + } + } + #validateSpec(spec: SandboxSpec): void { if (!spec.clientRequestId.trim()) { throw new RuntimeError('invalid_request', 'clientRequestId is required') @@ -207,6 +701,207 @@ export class InMemorySandboxRuntime { if (spec.image && spec.template) { throw new RuntimeError('invalid_request', 'image and template are mutually exclusive') } + if (spec.image !== undefined && !spec.image.trim()) { + throw new RuntimeError('invalid_request', 'image must not be empty') + } + if (spec.template !== undefined && !spec.template.trim()) { + throw new RuntimeError('invalid_request', 'template must not be empty') + } + for (const [name, value] of Object.entries(spec.resources ?? {})) { + if (!Number.isInteger(value) || value <= 0) { + throw new RuntimeError('invalid_request', `${name} must be a positive integer`) + } + } + if ( + new Set(spec.requiredCapabilities ?? []).size !== (spec.requiredCapabilities ?? []).length + ) { + throw new RuntimeError('invalid_request', 'requiredCapabilities must not contain duplicates') + } + if (spec.extensions && !isJsonValue(spec.extensions)) { + throw new RuntimeError('invalid_request', 'extensions must contain only JSON values') + } + if ( + spec.extensions && + Object.keys(spec.extensions).some((key) => !extensionKeyPattern.test(key)) + ) { + throw new RuntimeError('invalid_request', 'extension keys must be namespaced') + } + } + + #validateCommandRequest(request: CommandRequest): void { + this.#validateExpectedGeneration(request.expectedGeneration) + if ( + !Array.isArray(request.argv) || + request.argv.length === 0 || + request.argv.some((argument) => typeof argument !== 'string' || argument.includes('\0')) + ) { + throw new RuntimeError('invalid_request', 'argv must contain at least one valid string') + } + if (request.cwd !== undefined) this.#validatePath(request.cwd) + if ( + request.timeoutSeconds !== undefined && + (!Number.isFinite(request.timeoutSeconds) || + request.timeoutSeconds <= 0 || + request.timeoutSeconds > 3600) + ) { + throw new RuntimeError('invalid_request', 'timeoutSeconds must be in (0, 3600]') + } + if ( + request.maxOutputBytes !== undefined && + (!Number.isInteger(request.maxOutputBytes) || + request.maxOutputBytes < 1 || + request.maxOutputBytes > 10 * 1024 * 1024) + ) { + throw new RuntimeError('invalid_request', 'maxOutputBytes must be in [1, 10485760]') + } + for (const [name, value] of Object.entries(request.env ?? {})) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || value.includes('\0')) { + throw new RuntimeError('invalid_request', `invalid environment entry ${name}`) + } + } + } + + #validateFileWriteRequest(request: FileWriteRequest): void { + this.#validateExpectedGeneration(request.expectedGeneration) + this.#validatePath(request.path) + const normalized = request.contentBase64.replace(/\s/g, '') + if ( + normalized.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(normalized) + ) { + throw new RuntimeError('invalid_request', 'contentBase64 is not valid base64') + } + } + + #validatePath(path: string): void { + if (typeof path !== 'string' || !path || path.includes('\0')) { + throw new RuntimeError('invalid_request', 'path must be a non-empty string') + } + } + + #assertCommandResult(result: unknown): asserts result is CommandResult { + if ( + !result || + typeof result !== 'object' || + !('exitCode' in result) || + !(result.exitCode === null || Number.isInteger(result.exitCode)) || + !('stdout' in result) || + typeof result.stdout !== 'string' || + !('stderr' in result) || + typeof result.stderr !== 'string' || + !('timedOut' in result) || + typeof result.timedOut !== 'boolean' || + !('cancelled' in result) || + typeof result.cancelled !== 'boolean' || + !('truncated' in result) || + typeof result.truncated !== 'boolean' || + !('startedAt' in result) || + typeof result.startedAt !== 'string' || + !Number.isFinite(Date.parse(result.startedAt)) || + !('finishedAt' in result) || + typeof result.finishedAt !== 'string' || + !Number.isFinite(Date.parse(result.finishedAt)) + ) { + throw new RuntimeError( + 'provider_protocol_error', + 'provider returned an invalid command result', + ) + } + } + + #assertFileResult(result: unknown, path: string, expectedContentBase64?: string): void { + if (!result || typeof result !== 'object') { + throw new RuntimeError('provider_protocol_error', 'provider returned an invalid file result') + } + const content = 'contentBase64' in result ? strictBase64(result.contentBase64) : undefined + if ( + !('path' in result) || + result.path !== path || + !content || + !('size' in result) || + !Number.isInteger(result.size) || + result.size !== content.byteLength + ) { + throw new RuntimeError('provider_protocol_error', 'provider returned an invalid file result') + } + const expected = expectedContentBase64 ? strictBase64(expectedContentBase64) : undefined + if (expected && !content.equals(expected)) { + throw new RuntimeError('provider_protocol_error', 'provider changed written file content') + } + } + + #assertFileEntries(entries: unknown): asserts entries is readonly FileEntry[] { + if ( + !Array.isArray(entries) || + entries.some( + (entry) => + !entry || + typeof entry !== 'object' || + !('path' in entry) || + typeof entry.path !== 'string' || + !entry.path || + !('kind' in entry) || + !['file', 'directory'].includes(entry.kind as string) || + !('size' in entry) || + !Number.isInteger(entry.size) || + (entry.size as number) < 0, + ) + ) { + throw new RuntimeError('provider_protocol_error', 'provider returned invalid file entries') + } + } + + async #withLock(id: string, operation: () => Promise): Promise { + const previous = this.#locks.get(id) ?? Promise.resolve() + let release = () => {} + const current = new Promise((resolve) => { + release = resolve + }) + const chained = previous.then(() => current) + this.#locks.set(id, chained) + await previous + try { + return await operation() + } finally { + release() + if (this.#locks.get(id) === chained) this.#locks.delete(id) + } + } + + #errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'provider failed without an error' + } + + #providerError(operation: string, error: unknown): RuntimeError { + if (error instanceof RuntimeError) return error + return new RuntimeError( + 'provider_unavailable', + `provider ${operation} failed: ${this.#errorMessage(error)}`, + ) + } + + async #describeProvider(context: ProviderContext): Promise { + let manifest: ProviderManifest + try { + manifest = await this.#provider.describe(context) + } catch (error) { + throw this.#providerError('describe', error) + } + if ( + !manifest || + typeof manifest.name !== 'string' || + !manifest.name.trim() || + typeof manifest.version !== 'string' || + !manifest.version.trim() || + typeof manifest.runtimeClass !== 'string' || + !manifest.runtimeClass.trim() || + !manifest.capabilities || + typeof manifest.capabilities !== 'object' || + capabilityNames.some((name) => typeof manifest.capabilities[name] !== 'boolean') + ) { + throw new RuntimeError('provider_protocol_error', 'provider manifest is incomplete') + } + return manifest } } diff --git a/src/sdk.ts b/src/sdk.ts new file mode 100644 index 0000000..33dbbc7 --- /dev/null +++ b/src/sdk.ts @@ -0,0 +1,215 @@ +import type { + CommandRequest, + CommandResult, + FileEntry, + FileReadResult, + RuntimeInfo, + SandboxEvent, + SandboxResource, + SandboxSpec, +} from './protocol.js' +import { RuntimeError, runtimeErrorCodes } from './protocol.js' + +type RequestOptions = Readonly<{ + method?: string | undefined + body?: unknown + signal?: AbortSignal | undefined +}> + +export class SandboxRuntimeClient { + readonly #baseUrl: string + readonly #fetch: typeof fetch + + constructor(baseUrl: string, fetchImplementation: typeof fetch = fetch) { + this.#baseUrl = baseUrl.replace(/\/$/, '') + this.#fetch = fetchImplementation + } + + getRuntimeInfo(signal?: AbortSignal): Promise { + return this.#request('/v1/runtime', { signal }) + } + + create(spec: SandboxSpec, signal?: AbortSignal): Promise { + return this.#request('/v1/sandboxes', { method: 'POST', body: spec, signal }) + } + + async list(signal?: AbortSignal): Promise { + return (await this.#request<{ sandboxes: SandboxResource[] }>('/v1/sandboxes', { signal })) + .sandboxes + } + + get(id: string, signal?: AbortSignal): Promise { + return this.#request(`/v1/sandboxes/${encodeURIComponent(id)}`, { signal }) + } + + reconcile(id: string, signal?: AbortSignal): Promise { + return this.#action(id, 'reconcile', {}, signal) + } + + terminate( + id: string, + expectedGeneration: number, + signal?: AbortSignal, + ): Promise { + return this.#action(id, 'terminate', { expectedGeneration }, signal) + } + + pause(id: string, expectedGeneration: number, signal?: AbortSignal): Promise { + return this.#action(id, 'pause', { expectedGeneration }, signal) + } + + resume(id: string, expectedGeneration: number, signal?: AbortSignal): Promise { + return this.#action(id, 'resume', { expectedGeneration }, signal) + } + + recreate( + id: string, + expectedGeneration: number, + spec: SandboxSpec, + signal?: AbortSignal, + ): Promise { + return this.#action(id, 'recreate', { expectedGeneration, spec }, signal) + } + + execute(id: string, request: CommandRequest, signal?: AbortSignal): Promise { + return this.#request(`/v1/sandboxes/${encodeURIComponent(id)}/commands`, { + method: 'POST', + body: request, + signal, + }) + } + + readFile( + id: string, + expectedGeneration: number, + path: string, + signal?: AbortSignal, + ): Promise { + const query = new URLSearchParams({ path, expectedGeneration: String(expectedGeneration) }) + return this.#request(`/v1/sandboxes/${encodeURIComponent(id)}/files/content?${query}`, { + signal, + }) + } + + writeFile( + id: string, + expectedGeneration: number, + path: string, + contentBase64: string, + signal?: AbortSignal, + ): Promise { + return this.#request(`/v1/sandboxes/${encodeURIComponent(id)}/files/content`, { + method: 'PUT', + body: { expectedGeneration, path, contentBase64 }, + signal, + }) + } + + async listFiles( + id: string, + expectedGeneration: number, + path = '.', + signal?: AbortSignal, + ): Promise { + const query = new URLSearchParams({ path, expectedGeneration: String(expectedGeneration) }) + return ( + await this.#request<{ entries: FileEntry[] }>( + `/v1/sandboxes/${encodeURIComponent(id)}/files/entries?${query}`, + { signal }, + ) + ).entries + } + + async listEvents( + after = 0, + sandboxId?: string, + signal?: AbortSignal, + ): Promise { + const query = new URLSearchParams({ after: String(after) }) + if (sandboxId) query.set('sandboxId', sandboxId) + return (await this.#request<{ events: SandboxEvent[] }>(`/v1/events?${query}`, { signal })) + .events + } + + async *streamEvents( + options: Readonly<{ after?: number; sandboxId?: string; signal?: AbortSignal }> = {}, + ): AsyncGenerator { + const query = new URLSearchParams({ after: String(options.after ?? 0) }) + if (options.sandboxId) query.set('sandboxId', options.sandboxId) + const response = await this.#fetch(`${this.#baseUrl}/v1/events/stream?${query}`, { + headers: { accept: 'text/event-stream' }, + ...(options.signal ? { signal: options.signal } : {}), + }) + if (!response.ok || !response.body) await this.#throwResponse(response) + const body = response.body + if (!body) throw new RuntimeError('provider_unavailable', 'event stream has no response body') + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = '' + try { + while (true) { + const { done, value } = await reader.read() + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }) + const preserveTrailingCarriageReturn = !done && buffer.endsWith('\r') + const complete = preserveTrailingCarriageReturn ? buffer.slice(0, -1) : buffer + buffer = complete.replace(/\r\n?/g, '\n') + (preserveTrailingCarriageReturn ? '\r' : '') + let boundary = buffer.indexOf('\n\n') + while (boundary >= 0) { + const frame = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + const data = frame + .split('\n') + .filter((line) => line.startsWith('data: ')) + .map((line) => line.slice(6)) + .join('\n') + if (data) yield JSON.parse(data) as SandboxEvent + boundary = buffer.indexOf('\n\n') + } + if (done) return + } + } finally { + await reader.cancel().catch(() => undefined) + reader.releaseLock() + } + } + + #action( + id: string, + action: string, + body: unknown, + signal?: AbortSignal, + ): Promise { + return this.#request( + `/v1/sandboxes/${encodeURIComponent(id)}/actions/${encodeURIComponent(action)}`, + { method: 'POST', body, signal }, + ) + } + + async #request(path: string, options: RequestOptions = {}): Promise { + const response = await this.#fetch(`${this.#baseUrl}${path}`, { + method: options.method ?? 'GET', + ...(options.body === undefined + ? {} + : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(options.body) }), + ...(options.signal ? { signal: options.signal } : {}), + }) + if (!response.ok) await this.#throwResponse(response) + return (await response.json()) as T + } + + async #throwResponse(response: Response): Promise { + const body = (await response.json().catch(() => undefined)) as + | { error?: { code?: string; message?: string } } + | undefined + const receivedCode = body?.error?.code + const code = runtimeErrorCodes.includes( + receivedCode as ConstructorParameters[0], + ) + ? (receivedCode as ConstructorParameters[0]) + : 'provider_unavailable' + throw new RuntimeError( + code, + body?.error?.message ?? `runtime request failed with HTTP ${response.status}`, + ) + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..c8cc4d1 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,297 @@ +import { randomUUID } from 'node:crypto' +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import { RuntimeError, type RuntimeErrorCode, type SandboxEvent } from './protocol.js' +import type { InMemorySandboxRuntime } from './runtime.js' +import { + isRecord, + parseCommandRequest, + parseExpectedGeneration, + parseFileWriteRequest, + parseSandboxSpec, +} from './validation.js' + +const defaultBodyLimitBytes = 16 * 1024 * 1024 + +const statusByCode: Readonly> = { + invalid_request: 400, + unsupported_capability: 422, + idempotency_conflict: 409, + generation_conflict: 409, + not_found: 404, + invalid_state: 409, + provider_unavailable: 503, + provider_protocol_error: 502, + event_history_unavailable: 410, +} + +const isLoopback = (host: string): boolean => + host === '127.0.0.1' || host === '::1' || host === 'localhost' + +const json = (response: ServerResponse, status: number, value: unknown): void => { + const body = JSON.stringify(value) + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(body), + 'cache-control': 'no-store', + }) + response.end(body) +} + +const readJson = async (request: IncomingMessage, bodyLimitBytes: number): Promise => { + const chunks: Buffer[] = [] + let size = 0 + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + size += buffer.byteLength + if (size > bodyLimitBytes) + throw new RuntimeError('invalid_request', 'request body is too large') + chunks.push(buffer) + } + if (chunks.length === 0) return {} + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')) + } catch { + throw new RuntimeError('invalid_request', 'request body is not valid JSON') + } +} + +const eventFrame = (event: SandboxEvent): string => + `id: ${event.cursor}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n` + +export type SandboxRuntimeServerOptions = Readonly<{ + host?: string + port?: number + allowUnsafeNetwork?: boolean + maxBodyBytes?: number +}> + +export type SandboxRuntimeServerHandle = Readonly<{ + baseUrl: string + close(): Promise +}> + +export const startSandboxRuntimeServer = async ( + runtime: InMemorySandboxRuntime, + options: SandboxRuntimeServerOptions = {}, +): Promise => { + const host = options.host ?? '127.0.0.1' + const port = options.port ?? 4311 + const maxBodyBytes = options.maxBodyBytes ?? defaultBodyLimitBytes + if (!Number.isInteger(maxBodyBytes) || maxBodyBytes < 1) { + throw new RuntimeError('invalid_request', 'maxBodyBytes must be a positive integer') + } + if (!isLoopback(host) && !options.allowUnsafeNetwork) { + throw new RuntimeError( + 'invalid_request', + 'the unauthenticated reference server may bind only to loopback', + ) + } + + const server = createServer((request, response) => { + void handleRequest(runtime, request, response, maxBodyBytes).catch((error: unknown) => { + if (response.headersSent) { + response.end() + return + } + if (error instanceof RuntimeError) { + json(response, statusByCode[error.code], { + error: { code: error.code, message: error.message }, + }) + return + } + json(response, 500, { error: { code: 'internal_error', message: 'internal server error' } }) + }) + }) + + await new Promise((resolveListen, reject) => { + server.once('error', reject) + server.listen(port, host, () => { + server.off('error', reject) + resolveListen() + }) + }) + const address = server.address() as AddressInfo + return { + baseUrl: `http://${address.family === 'IPv6' ? `[${address.address}]` : address.address}:${address.port}`, + close: () => + new Promise((resolveClose, reject) => { + server.close((error) => (error ? reject(error) : resolveClose())) + server.closeAllConnections() + }), + } +} + +const handleRequest = async ( + runtime: InMemorySandboxRuntime, + request: IncomingMessage, + response: ServerResponse, + maxBodyBytes: number, +): Promise => { + const url = new URL(request.url ?? '/', 'http://runtime.invalid') + const method = request.method ?? 'GET' + const segments = url.pathname.split('/').filter(Boolean).map(decodeURIComponent) + const requestIdHeader = request.headers['x-request-id'] + const abortController = new AbortController() + response.once('close', () => { + if (!response.writableEnded) abortController.abort() + }) + const context = { + requestId: typeof requestIdHeader === 'string' ? requestIdHeader : randomUUID(), + signal: abortController.signal, + } + + if (method === 'GET' && url.pathname === '/healthz') { + json(response, 200, { status: 'ok' }) + return + } + if (method === 'GET' && url.pathname === '/v1/runtime') { + json(response, 200, await runtime.info(context)) + return + } + if (method === 'GET' && url.pathname === '/v1/sandboxes') { + json(response, 200, { sandboxes: runtime.list() }) + return + } + if (method === 'POST' && url.pathname === '/v1/sandboxes') { + json( + response, + 201, + await runtime.create(parseSandboxSpec(await readJson(request, maxBodyBytes)), context), + ) + return + } + if (method === 'GET' && url.pathname === '/v1/events') { + const after = Number(url.searchParams.get('after') ?? '0') + const sandboxId = url.searchParams.get('sandboxId') ?? undefined + json(response, 200, { events: runtime.events(after, sandboxId) }) + return + } + if (method === 'GET' && url.pathname === '/v1/events/stream') { + const after = Number(url.searchParams.get('after') ?? '0') + const sandboxId = url.searchParams.get('sandboxId') ?? undefined + const replay = runtime.events(after, sandboxId) + response.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + }) + response.flushHeaders() + for (const event of replay) response.write(eventFrame(event)) + const unsubscribe = runtime.subscribe((event) => { + if (!sandboxId || sandboxId === event.sandboxId) response.write(eventFrame(event)) + }) + const heartbeat = setInterval(() => response.write(': keepalive\n\n'), 15_000) + request.once('close', () => { + clearInterval(heartbeat) + unsubscribe() + response.end() + }) + return + } + + if (segments[0] !== 'v1' || segments[1] !== 'sandboxes' || !segments[2]) { + throw new RuntimeError('not_found', 'route does not exist') + } + const sandboxId = segments[2] + if (segments.length === 3 && method === 'GET') { + json(response, 200, runtime.get(sandboxId)) + return + } + if (segments[3] === 'actions' && segments[4] && method === 'POST') { + const body = await readJson(request, maxBodyBytes) + if (segments[4] === 'reconcile') { + json(response, 200, await runtime.reconcile(sandboxId, context)) + return + } + if (segments[4] === 'terminate') { + const expectedGeneration = parseExpectedGeneration(body) + json(response, 200, await runtime.terminate(sandboxId, expectedGeneration, context)) + return + } + if (segments[4] === 'pause') { + const expectedGeneration = parseExpectedGeneration(body) + json(response, 200, await runtime.pause(sandboxId, expectedGeneration, context)) + return + } + if (segments[4] === 'resume') { + const expectedGeneration = parseExpectedGeneration(body) + json(response, 200, await runtime.resume(sandboxId, expectedGeneration, context)) + return + } + if (segments[4] === 'recreate') { + if (!isRecord(body) || body.spec === undefined) { + throw new RuntimeError('invalid_request', 'recreate requires spec') + } + if (Object.keys(body).some((key) => !['expectedGeneration', 'spec'].includes(key))) { + throw new RuntimeError('invalid_request', 'recreate contains unknown request fields') + } + const expectedGeneration = parseExpectedGeneration({ + expectedGeneration: body.expectedGeneration, + }) + json( + response, + 200, + await runtime.recreate(sandboxId, expectedGeneration, parseSandboxSpec(body.spec), context), + ) + return + } + } + if (segments[3] === 'commands' && segments.length === 4 && method === 'POST') { + json( + response, + 200, + await runtime.execute( + sandboxId, + parseCommandRequest(await readJson(request, maxBodyBytes)), + context, + ), + ) + return + } + if (segments[3] === 'files' && segments[4] === 'content') { + if (method === 'GET') { + const expectedGeneration = parseExpectedGeneration({ + expectedGeneration: Number(url.searchParams.get('expectedGeneration') ?? ''), + }) + json( + response, + 200, + await runtime.readFile( + sandboxId, + expectedGeneration, + url.searchParams.get('path') ?? '', + context, + ), + ) + return + } + if (method === 'PUT') { + json( + response, + 200, + await runtime.writeFile( + sandboxId, + parseFileWriteRequest(await readJson(request, maxBodyBytes)), + context, + ), + ) + return + } + } + if (segments[3] === 'files' && segments[4] === 'entries' && method === 'GET') { + const expectedGeneration = parseExpectedGeneration({ + expectedGeneration: Number(url.searchParams.get('expectedGeneration') ?? ''), + }) + json(response, 200, { + entries: await runtime.listFiles( + sandboxId, + expectedGeneration, + url.searchParams.get('path') ?? '.', + context, + ), + }) + return + } + throw new RuntimeError('not_found', 'route does not exist') +} diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..45f0f7e --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,214 @@ +import { + capabilityNames, + type CapabilityName, + type CommandRequest, + type FileWriteRequest, + type JsonObject, + type JsonValue, + RuntimeError, + type SandboxSpec, +} from './protocol.js' + +export const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const isJsonValue = (value: unknown, seen = new WeakSet()): value is JsonValue => { + if (value === null || ['string', 'boolean'].includes(typeof value)) return true + if (typeof value === 'number') return Number.isFinite(value) + if (typeof value !== 'object') return false + if (seen.has(value)) return false + seen.add(value) + const valid = Array.isArray(value) + ? value.every((entry) => isJsonValue(entry, seen)) + : isRecord(value) && + [Object.prototype, null].includes(Object.getPrototypeOf(value)) && + Object.values(value).every((entry) => isJsonValue(entry, seen)) + seen.delete(value) + return valid +} + +const assertKeys = (record: Record, allowed: readonly string[]): void => { + const unknown = Object.keys(record).filter((key) => !allowed.includes(key)) + if (unknown.length > 0) { + throw new RuntimeError( + 'invalid_request', + `unknown request fields: ${unknown.sort().join(', ')}`, + ) + } +} + +const optionalString = (record: Record, name: string): string | undefined => { + const value = record[name] + if (value === undefined) return undefined + if (typeof value !== 'string') + throw new RuntimeError('invalid_request', `${name} must be a string`) + return value +} + +const optionalNumber = (record: Record, name: string): number | undefined => { + const value = record[name] + if (value === undefined) return undefined + if (typeof value !== 'number') + throw new RuntimeError('invalid_request', `${name} must be a number`) + return value +} + +export const parseSandboxSpec = (value: unknown): SandboxSpec => { + if (!isRecord(value)) throw new RuntimeError('invalid_request', 'request body must be an object') + assertKeys(value, [ + 'clientRequestId', + 'image', + 'template', + 'resources', + 'requiredCapabilities', + 'extensions', + ]) + const clientRequestId = value.clientRequestId + if (typeof clientRequestId !== 'string') { + throw new RuntimeError('invalid_request', 'clientRequestId must be a string') + } + const requiredCapabilitiesValue = value.requiredCapabilities + let requiredCapabilities: CapabilityName[] | undefined + if (requiredCapabilitiesValue !== undefined) { + if ( + !Array.isArray(requiredCapabilitiesValue) || + requiredCapabilitiesValue.some( + (entry) => typeof entry !== 'string' || !capabilityNames.includes(entry as CapabilityName), + ) + ) { + throw new RuntimeError( + 'invalid_request', + 'requiredCapabilities contains an unknown capability', + ) + } + requiredCapabilities = [...requiredCapabilitiesValue] as CapabilityName[] + } + const resourcesValue = value.resources + if (resourcesValue !== undefined && !isRecord(resourcesValue)) { + throw new RuntimeError('invalid_request', 'resources must be an object') + } + if (resourcesValue) assertKeys(resourcesValue, ['cpuMillis', 'memoryMiB', 'diskMiB']) + if ( + value.extensions !== undefined && + (!isRecord(value.extensions) || !isJsonValue(value.extensions)) + ) { + throw new RuntimeError('invalid_request', 'extensions must be a JSON object') + } + + const image = optionalString(value, 'image') + const template = optionalString(value, 'template') + const cpuMillis = resourcesValue ? optionalNumber(resourcesValue, 'cpuMillis') : undefined + const memoryMiB = resourcesValue ? optionalNumber(resourcesValue, 'memoryMiB') : undefined + const diskMiB = resourcesValue ? optionalNumber(resourcesValue, 'diskMiB') : undefined + + return { + clientRequestId, + ...(image !== undefined ? { image } : {}), + ...(template !== undefined ? { template } : {}), + ...(resourcesValue + ? { + resources: { + ...(cpuMillis !== undefined ? { cpuMillis } : {}), + ...(memoryMiB !== undefined ? { memoryMiB } : {}), + ...(diskMiB !== undefined ? { diskMiB } : {}), + }, + } + : {}), + ...(requiredCapabilities ? { requiredCapabilities } : {}), + ...(value.extensions ? { extensions: value.extensions as JsonObject } : {}), + } +} + +export const parseCommandRequest = (value: unknown): CommandRequest => { + if (!isRecord(value)) throw new RuntimeError('invalid_request', 'request body must be an object') + assertKeys(value, [ + 'expectedGeneration', + 'argv', + 'cwd', + 'env', + 'timeoutSeconds', + 'maxOutputBytes', + ]) + const expectedGeneration = parseExpectedGeneration({ + expectedGeneration: value.expectedGeneration, + }) + if ( + !Array.isArray(value.argv) || + value.argv.length === 0 || + value.argv.some((entry) => typeof entry !== 'string' || entry.includes('\0')) + ) { + throw new RuntimeError('invalid_request', 'argv must contain at least one valid string') + } + if (value.env !== undefined && !isRecord(value.env)) { + throw new RuntimeError('invalid_request', 'env must be an object') + } + const env = value.env + ? Object.fromEntries( + Object.entries(value.env).map(([name, entry]) => { + if (typeof entry !== 'string') { + throw new RuntimeError('invalid_request', `environment value ${name} must be a string`) + } + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || entry.includes('\0')) { + throw new RuntimeError('invalid_request', `invalid environment entry ${name}`) + } + return [name, entry] + }), + ) + : undefined + const cwd = optionalString(value, 'cwd') + const timeoutSeconds = optionalNumber(value, 'timeoutSeconds') + const maxOutputBytes = optionalNumber(value, 'maxOutputBytes') + if ( + timeoutSeconds !== undefined && + (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || timeoutSeconds > 3600) + ) { + throw new RuntimeError('invalid_request', 'timeoutSeconds must be in (0, 3600]') + } + if ( + maxOutputBytes !== undefined && + (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 10 * 1024 * 1024) + ) { + throw new RuntimeError('invalid_request', 'maxOutputBytes must be in [1, 10485760]') + } + return { + expectedGeneration, + argv: value.argv as string[], + ...(cwd !== undefined ? { cwd } : {}), + ...(env ? { env } : {}), + ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}), + ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), + } +} + +export const parseExpectedGeneration = (value: unknown): number => { + if (!isRecord(value) || typeof value.expectedGeneration !== 'number') { + throw new RuntimeError('invalid_request', 'expectedGeneration must be a number') + } + assertKeys(value, ['expectedGeneration']) + if (!Number.isInteger(value.expectedGeneration) || value.expectedGeneration < 1) { + throw new RuntimeError('invalid_request', 'expectedGeneration must be a positive integer') + } + return value.expectedGeneration +} + +export const parseFileWriteRequest = (value: unknown): FileWriteRequest => { + if ( + !isRecord(value) || + typeof value.path !== 'string' || + typeof value.contentBase64 !== 'string' || + typeof value.expectedGeneration !== 'number' + ) { + throw new RuntimeError( + 'invalid_request', + 'expectedGeneration must be a number and path/contentBase64 must be strings', + ) + } + assertKeys(value, ['expectedGeneration', 'path', 'contentBase64']) + return { + expectedGeneration: parseExpectedGeneration({ + expectedGeneration: value.expectedGeneration, + }), + path: value.path, + contentBase64: value.contentBase64, + } +} diff --git a/tests/http-sdk.test.ts b/tests/http-sdk.test.ts new file mode 100644 index 0000000..0d4fb30 --- /dev/null +++ b/tests/http-sdk.test.ts @@ -0,0 +1,313 @@ +import { access, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + InMemorySandboxRuntime, + LocalSandboxProvider, + MockSandboxProvider, + RuntimeError, + SandboxRuntimeClient, + startSandboxRuntimeServer, + type SandboxRuntimeServerHandle, +} from '../src/index.js' + +const handles: SandboxRuntimeServerHandle[] = [] +const providers: LocalSandboxProvider[] = [] +const cleanupPaths: string[] = [] + +afterEach(async () => { + await Promise.all(handles.splice(0).map((handle) => handle.close())) + await Promise.all(providers.splice(0).map((provider) => provider.dispose())) + await Promise.all( + cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ) +}) + +const start = async () => { + const provider = new LocalSandboxProvider() + providers.push(provider) + const runtime = new InMemorySandboxRuntime(provider, { idFactory: () => 'sandbox-1' }) + const handle = await startSandboxRuntimeServer(runtime, { port: 0 }) + handles.push(handle) + return { client: new SandboxRuntimeClient(handle.baseUrl), handle } +} + +const startMock = async () => { + const runtime = new InMemorySandboxRuntime(new MockSandboxProvider(), { + idFactory: () => 'sandbox-1', + }) + const handle = await startSandboxRuntimeServer(runtime, { port: 0 }) + handles.push(handle) + return { client: new SandboxRuntimeClient(handle.baseUrl), handle } +} + +describe('HTTP server and TypeScript SDK', () => { + it('runs lifecycle, command, file, event, terminate, and recreate', async () => { + const { client } = await start() + expect(await client.getRuntimeInfo()).toMatchObject({ + protocolVersion: '0.1', + provider: { name: 'local', runtimeClass: 'local-process-unsafe' }, + }) + const sandbox = await client.create({ + clientRequestId: 'http-create', + requiredCapabilities: ['commandExecution', 'fileAccess'], + }) + expect(sandbox).toMatchObject({ id: 'sandbox-1', state: 'ready', generation: 1 }) + expect(await client.list()).toHaveLength(1) + expect(await client.get('sandbox-1')).toEqual(sandbox) + + const contentBase64 = Buffer.from('hello').toString('base64') + await client.writeFile('sandbox-1', 1, 'hello.txt', contentBase64) + expect(await client.readFile('sandbox-1', 1, 'hello.txt')).toMatchObject({ + contentBase64, + size: 5, + }) + expect(await client.listFiles('sandbox-1', 1)).toContainEqual({ + path: 'hello.txt', + kind: 'file', + size: 5, + }) + expect( + await client.execute('sandbox-1', { + expectedGeneration: 1, + argv: [process.execPath, '-e', 'process.stdout.write("hello")'], + }), + ).toMatchObject({ exitCode: 0, stdout: 'hello', timedOut: false }) + expect((await client.listEvents()).at(-1)?.type).toBe('sandbox.command_completed') + expect((await client.terminate('sandbox-1', 1)).state).toBe('terminated') + const recreated = await client.recreate('sandbox-1', 1, { + clientRequestId: 'http-recreate', + }) + expect(recreated).toMatchObject({ state: 'ready', generation: 2 }) + await expect( + client.writeFile('sandbox-1', 1, 'stale.txt', contentBase64), + ).rejects.toMatchObject({ + code: 'generation_conflict', + }) + await expect( + client.execute('sandbox-1', { expectedGeneration: 1, argv: ['printf', 'stale'] }), + ).rejects.toMatchObject({ code: 'generation_conflict' }) + await expect(client.readFile('sandbox-1', 1, 'stale.txt')).rejects.toMatchObject({ + code: 'generation_conflict', + }) + await expect(client.listFiles('sandbox-1', 1)).rejects.toMatchObject({ + code: 'generation_conflict', + }) + }) + + it('carries file content larger than the former one MiB HTTP limit', async () => { + const { client } = await start() + const sandbox = await client.create({ clientRequestId: 'large-file' }) + const content = Buffer.alloc(1024 * 1024, 0x61) + const written = await client.writeFile( + sandbox.id, + sandbox.generation, + 'large.bin', + content.toString('base64'), + ) + expect(written.size).toBe(content.byteLength) + expect((await client.readFile(sandbox.id, sandbox.generation, 'large.bin')).size).toBe( + content.byteLength, + ) + }) + + it('replays events over SSE and allows the consumer to close the stream', async () => { + const { client } = await start() + await client.create({ clientRequestId: 'sse-create' }) + const stream = client.streamEvents({ after: 0 }) + const first = await stream.next() + expect(first.value).toMatchObject({ cursor: 1, type: 'sandbox.created' }) + await stream.return(undefined) + }) + + it('closes the server even while an SSE client is connected', async () => { + const { handle } = await start() + const response = await fetch(`${handle.baseUrl}/v1/events/stream?after=0`) + expect(response.status).toBe(200) + handles.splice(handles.indexOf(handle), 1) + await expect(handle.close()).resolves.toBeUndefined() + }) + + it('returns 410 before opening an SSE stream for an expired cursor', async () => { + const provider = new MockSandboxProvider() + const runtime = new InMemorySandboxRuntime(provider, { maxEvents: 2 }) + const handle = await startSandboxRuntimeServer(runtime, { port: 0 }) + handles.push(handle) + await runtime.create({ clientRequestId: 'bounded' }, { requestId: 'bounded' }) + const [resource] = runtime.list() + if (!resource) throw new Error('sandbox was not created') + await runtime.execute( + resource.id, + { expectedGeneration: 1, argv: ['event'] }, + { requestId: 'event' }, + ) + const response = await fetch(`${handle.baseUrl}/v1/events/stream?after=1`) + expect(response.status).toBe(410) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'event_history_unavailable' }, + }) + }) + + it('projects pause, resume, and reconcile through the SDK', async () => { + const { client } = await startMock() + const sandbox = await client.create({ + clientRequestId: 'mock-create', + requiredCapabilities: ['pauseResume'], + }) + expect((await client.reconcile(sandbox.id)).state).toBe('ready') + expect((await client.pause(sandbox.id, sandbox.generation)).state).toBe('paused') + expect((await client.resume(sandbox.id, sandbox.generation)).state).toBe('ready') + await expect(client.terminate(sandbox.id, 2)).rejects.toMatchObject({ + code: 'generation_conflict', + }) + }) + + it('maps runtime errors and rejects malformed JSON', async () => { + const { client, handle } = await start() + await expect(client.get('missing')).rejects.toMatchObject({ code: 'not_found' }) + const response = await fetch(`${handle.baseUrl}/v1/sandboxes`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{', + }) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: { code: 'invalid_request' } }) + }) + + it('propagates an HTTP disconnect to the running local process group', async () => { + const { client } = await start() + const sandbox = await client.create({ clientRequestId: 'abort-create' }) + const marker = join(process.cwd(), '.test-http-abort-marker') + cleanupPaths.push(marker) + await rm(marker, { force: true }) + const script = [ + 'const {spawn}=require("node:child_process")', + `spawn(process.execPath,["-e",${JSON.stringify(`setTimeout(()=>require("node:fs").writeFileSync(${JSON.stringify(marker)},"bad"),300)`)}],{stdio:"ignore"})`, + 'setTimeout(()=>{},10000)', + ].join(';') + const controller = new AbortController() + const execution = client.execute( + sandbox.id, + { expectedGeneration: 1, argv: [process.execPath, '-e', script], timeoutSeconds: 5 }, + controller.signal, + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + controller.abort() + await expect(execution).rejects.toMatchObject({ name: 'AbortError' }) + await new Promise((resolve) => setTimeout(resolve, 400)) + expect( + await access(marker) + .then(() => true) + .catch(() => false), + ).toBe(false) + await expect( + client.execute(sandbox.id, { + expectedGeneration: 1, + argv: [process.execPath, '-e', 'process.stdout.write("alive")'], + }), + ).resolves.toMatchObject({ stdout: 'alive', exitCode: 0 }) + }) + + it('does not expose unknown error details', async () => { + const { handle } = await start() + const response = await fetch(`${handle.baseUrl}/v1/sandboxes/%E0%A4%A`) + expect(response.status).toBe(500) + expect(await response.text()).not.toContain('URIError') + }) + + it('returns not found for an unknown route and rejects oversized bodies', async () => { + const provider = new LocalSandboxProvider() + providers.push(provider) + const runtime = new InMemorySandboxRuntime(provider) + const handle = await startSandboxRuntimeServer(runtime, { port: 0, maxBodyBytes: 1024 }) + handles.push(handle) + expect((await fetch(`${handle.baseUrl}/unknown`)).status).toBe(404) + const oversized = await fetch(`${handle.baseUrl}/v1/sandboxes`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ clientRequestId: 'large', padding: 'x'.repeat(1024) }), + }) + expect(oversized.status).toBe(400) + }) + + it('refuses a non-loopback bind without an explicit unsafe override', async () => { + const provider = new LocalSandboxProvider() + providers.push(provider) + const runtime = new InMemorySandboxRuntime(provider) + await expect( + startSandboxRuntimeServer(runtime, { host: '0.0.0.0', port: 0 }), + ).rejects.toBeInstanceOf(RuntimeError) + }) + + it('normalizes unknown remote error codes', async () => { + const fakeFetch = (() => + Promise.resolve( + new Response( + JSON.stringify({ error: { code: 'remote_private_code', message: 'failed' } }), + { + status: 500, + headers: { 'content-type': 'application/json' }, + }, + ), + )) as typeof fetch + const client = new SandboxRuntimeClient('http://runtime.invalid', fakeFetch) + await expect(client.getRuntimeInfo()).rejects.toMatchObject({ + code: 'provider_unavailable', + message: 'failed', + }) + }) + + it('parses CRLF SSE frames split across network chunks', async () => { + const event = { + cursor: 1, + type: 'sandbox.created', + sandboxId: 'sandbox-1', + generation: 1, + timestamp: '2026-09-02T00:00:00.000Z', + data: {}, + } + const payload = `data: ${JSON.stringify(event)}` + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`${payload}\r`)) + controller.enqueue(encoder.encode('\n\r')) + controller.enqueue(encoder.encode('\n')) + controller.close() + }, + }) + const fakeFetch = (() => + Promise.resolve( + new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }), + )) as typeof fetch + const client = new SandboxRuntimeClient('http://runtime.invalid', fakeFetch) + const events = client.streamEvents() + await expect(events.next()).resolves.toMatchObject({ value: event, done: false }) + await events.return(undefined) + }) + + it('parses SSE frames delimited by CR-only line endings', async () => { + const event = { + cursor: 1, + type: 'sandbox.created', + sandboxId: 'sandbox-1', + generation: 1, + timestamp: '2026-09-02T00:00:00.000Z', + data: {}, + } + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\r\r`)) + controller.close() + }, + }) + const fakeFetch = (() => + Promise.resolve( + new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }), + )) as typeof fetch + const client = new SandboxRuntimeClient('http://runtime.invalid', fakeFetch) + const events = client.streamEvents() + await expect(events.next()).resolves.toMatchObject({ value: event, done: false }) + await events.return(undefined) + }) +}) diff --git a/tests/local-provider.test.ts b/tests/local-provider.test.ts new file mode 100644 index 0000000..3cd82af --- /dev/null +++ b/tests/local-provider.test.ts @@ -0,0 +1,213 @@ +import { access, mkdir, readdir, rm, symlink } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + InMemorySandboxRuntime, + LocalSandboxProvider, + runProviderConformance, +} from '../src/index.js' + +const providers: LocalSandboxProvider[] = [] +const roots: string[] = [] +const context = { requestId: 'local-test' } + +const makeProvider = (): LocalSandboxProvider => { + const result = new LocalSandboxProvider({ defaultTimeoutSeconds: 1 }) + providers.push(result) + return result +} + +afterEach(async () => { + await Promise.all(providers.splice(0).map((entry) => entry.dispose())) + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('LocalSandboxProvider', () => { + it('executes argv without a shell and uses sandbox-local cwd and env', async () => { + const runtime = new InMemorySandboxRuntime(makeProvider(), { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + const result = await runtime.execute( + 'sandbox-1', + { + expectedGeneration: 1, + argv: [process.execPath, '-e', 'process.stdout.write(process.cwd()+"|"+process.env.PROBE)'], + env: { PROBE: 'ok' }, + }, + context, + ) + expect(result.exitCode).toBe(0) + expect(result.stdout.endsWith('|ok')).toBe(true) + expect(result.timedOut).toBe(false) + }) + + it('captures stderr, bounds output, and terminates timed-out commands', async () => { + const runtime = new InMemorySandboxRuntime(makeProvider(), { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + const bounded = await runtime.execute( + 'sandbox-1', + { + expectedGeneration: 1, + argv: [ + process.execPath, + '-e', + 'process.stdout.write("abcdef");process.stderr.write("bad")', + ], + maxOutputBytes: 3, + }, + context, + ) + expect(bounded).toMatchObject({ stdout: 'abc', stderr: 'bad', truncated: true }) + + const timedOut = await runtime.execute( + 'sandbox-1', + { + expectedGeneration: 1, + argv: [process.execPath, '-e', 'setTimeout(()=>{}, 10000)'], + timeoutSeconds: 0.02, + }, + context, + ) + expect(timedOut.timedOut).toBe(true) + }) + + it('does not spawn a command when the context is already cancelled', async () => { + const runtime = new InMemorySandboxRuntime(makeProvider(), { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + const controller = new AbortController() + controller.abort() + await expect( + runtime.execute( + 'sandbox-1', + { + expectedGeneration: 1, + argv: [process.execPath, '-e', 'process.stdout.write("should-not-run")'], + }, + { requestId: 'cancelled', signal: controller.signal }, + ), + ).resolves.toMatchObject({ exitCode: null, cancelled: true, timedOut: false, stdout: '' }) + }) + + it('allows lifecycle termination to cancel a running command and its descendants', async () => { + const runtime = new InMemorySandboxRuntime(makeProvider(), { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + const marker = join(process.cwd(), '.test-descendant-marker') + await rm(marker, { force: true }) + roots.push(marker) + const script = [ + 'const {spawn}=require("node:child_process")', + `spawn(process.execPath,["-e",${JSON.stringify(`setTimeout(()=>require("node:fs").writeFileSync(${JSON.stringify(marker)},"bad"),300)`)}],{stdio:"ignore"})`, + 'setTimeout(()=>{},10000)', + ].join(';') + const execution = runtime.execute( + 'sandbox-1', + { expectedGeneration: 1, argv: [process.execPath, '-e', script], timeoutSeconds: 5 }, + context, + ) + await new Promise((resolve) => setTimeout(resolve, 30)) + const terminated = await runtime.terminate('sandbox-1', 1, context) + const result = await execution + expect(terminated.state).toBe('terminated') + expect(result).toMatchObject({ timedOut: false, cancelled: true, exitCode: null }) + await new Promise((resolve) => setTimeout(resolve, 400)) + expect( + await access(marker) + .then(() => true) + .catch(() => false), + ).toBe(false) + }) + + it('round-trips files and rejects lexical path traversal', async () => { + const runtime = new InMemorySandboxRuntime(makeProvider(), { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + const contentBase64 = Buffer.from('safe').toString('base64') + await runtime.writeFile( + 'sandbox-1', + { expectedGeneration: 1, path: 'safe.txt', contentBase64 }, + context, + ) + expect(await runtime.readFile('sandbox-1', 1, 'safe.txt', context)).toMatchObject({ + contentBase64, + size: 4, + }) + expect(await runtime.listFiles('sandbox-1', 1, '.', context)).toContainEqual({ + path: 'safe.txt', + kind: 'file', + size: 4, + }) + await expect(runtime.readFile('sandbox-1', 1, '../outside.txt', context)).rejects.toMatchObject( + { + code: 'invalid_request', + }, + ) + await expect( + runtime.writeFile( + 'sandbox-1', + { expectedGeneration: 1, path: '../outside.txt', contentBase64 }, + context, + ), + ).rejects.toMatchObject({ code: 'invalid_request' }) + }) + + it('rejects symlink escape and invalid base64', async () => { + const root = join(process.cwd(), '.test-local-provider') + roots.push(root) + await rm(root, { recursive: true, force: true }) + const local = new LocalSandboxProvider({ rootDirectory: root }) + providers.push(local) + const runtime = new InMemorySandboxRuntime(local, { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + const [resourceDirectory] = await readdir(root) + if (!resourceDirectory) throw new Error('local provider resource directory was not created') + const outside = join(root, 'outside') + await mkdir(outside) + await symlink(outside, join(root, resourceDirectory, 'escape')) + + await expect(runtime.listFiles('sandbox-1', 1, '.', context)).rejects.toMatchObject({ + code: 'invalid_request', + }) + await expect( + runtime.writeFile( + 'sandbox-1', + { expectedGeneration: 1, path: 'bad.txt', contentBase64: 'not-base64' }, + context, + ), + ).rejects.toMatchObject({ code: 'invalid_request' }) + }) + + it('removes the resource and recreates a clean generation', async () => { + const runtime = new InMemorySandboxRuntime(makeProvider(), { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + await runtime.writeFile( + 'sandbox-1', + { + expectedGeneration: 1, + path: 'old.txt', + contentBase64: Buffer.from('old').toString('base64'), + }, + context, + ) + await runtime.terminate('sandbox-1', 1, context) + await runtime.recreate('sandbox-1', 1, { clientRequestId: 'create-2' }, context) + await expect(runtime.readFile('sandbox-1', 2, 'old.txt', context)).rejects.toMatchObject({ + code: 'not_found', + }) + }) + + it('rejects unsupported image and resource requests before creating a local resource', async () => { + const runtime = new InMemorySandboxRuntime(makeProvider()) + await expect( + runtime.create({ clientRequestId: 'image', image: 'public.example/image:latest' }, context), + ).rejects.toMatchObject({ code: 'unsupported_capability' }) + await expect( + runtime.create({ clientRequestId: 'resource', resources: { memoryMiB: 128 } }, context), + ).rejects.toMatchObject({ code: 'unsupported_capability' }) + expect(runtime.list()).toHaveLength(0) + }) + + it('passes portable provider conformance', async () => { + const results = await runProviderConformance(makeProvider(), { + commandRequest: { argv: [process.execPath, '-e', 'process.exit(0)'] }, + }) + expect(results.filter((result) => !result.passed)).toEqual([]) + }) +}) diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index cc118a4..d04f4c6 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -1,44 +1,81 @@ import { describe, expect, it } from 'vitest' +import type { SandboxProvider } from '../src/provider.js' import { InMemorySandboxRuntime, MockSandboxProvider, - RuntimeError, runProviderConformance, } from '../src/index.js' const context = { requestId: 'test' } -describe('InMemorySandboxRuntime', () => { +describe('InMemorySandboxRuntime lifecycle', () => { it('creates one ready sandbox idempotently', async () => { const runtime = new InMemorySandboxRuntime(new MockSandboxProvider(), { idFactory: () => 'sandbox-1', clock: () => new Date('2026-09-01T00:00:00.000Z'), }) - const spec = { clientRequestId: 'create-1', image: 'public.example/sandbox:latest' } - + const spec = { clientRequestId: 'create-1' } const first = await runtime.create(spec, context) const second = await runtime.create(spec, context) - expect(first).toEqual(second) expect(first).toMatchObject({ id: 'sandbox-1', generation: 1, state: 'ready' }) expect(runtime.list()).toHaveLength(1) }) - it('rejects one idempotency key with different intent', async () => { - const runtime = new InMemorySandboxRuntime(new MockSandboxProvider()) - await runtime.create({ clientRequestId: 'same', image: 'public.example/a:latest' }, context) + it('coalesces concurrent creates with the same intent', async () => { + let provisions = 0 + const base = new MockSandboxProvider() + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: async (request, ctx) => { + provisions += 1 + await new Promise((resolve) => setTimeout(resolve, 5)) + return base.provision(request, ctx) + }, + observe: (key, ctx) => base.observe(key, ctx), + terminate: (key, ctx) => base.terminate(key, ctx), + } + const runtime = new InMemorySandboxRuntime(provider, { idFactory: () => 'sandbox-1' }) + const resources = await Promise.all( + Array.from({ length: 100 }, () => runtime.create({ clientRequestId: 'same' }, context)), + ) + expect(new Set(resources.map((resource) => resource.id))).toEqual(new Set(['sandbox-1'])) + expect(provisions).toBe(1) + }) + it('rejects a conflicting intent while the original create is in flight', async () => { + const base = new MockSandboxProvider() + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: async (request, ctx) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return base.provision(request, ctx) + }, + observe: (key, ctx) => base.observe(key, ctx), + terminate: (key, ctx) => base.terminate(key, ctx), + } + const runtime = new InMemorySandboxRuntime(provider) + const original = runtime.create( + { clientRequestId: 'same', extensions: { 'test.variant': 'a' } }, + context, + ) await expect( - runtime.create({ clientRequestId: 'same', image: 'public.example/b:latest' }, context), + runtime.create({ clientRequestId: 'same', extensions: { 'test.variant': 'b' } }, context), ).rejects.toMatchObject({ code: 'idempotency_conflict' }) + await expect(original).resolves.toMatchObject({ state: 'ready' }) }) - it('detects idempotency conflicts inside nested specifications', async () => { + it('rejects reused idempotency keys with different nested intent', async () => { const runtime = new InMemorySandboxRuntime(new MockSandboxProvider()) - await runtime.create({ clientRequestId: 'nested', resources: { memoryMiB: 512 } }, context) - + await runtime.create( + { clientRequestId: 'same', extensions: { 'test.profile': { memoryMiB: 512 } } }, + context, + ) await expect( - runtime.create({ clientRequestId: 'nested', resources: { memoryMiB: 1024 } }, context), + runtime.create( + { clientRequestId: 'same', extensions: { 'test.profile': { memoryMiB: 1024 } } }, + context, + ), ).rejects.toMatchObject({ code: 'idempotency_conflict' }) }) @@ -46,7 +83,6 @@ describe('InMemorySandboxRuntime', () => { const runtime = new InMemorySandboxRuntime( new MockSandboxProvider({ interactiveTerminal: false }), ) - await expect( runtime.create( { clientRequestId: 'terminal', requiredCapabilities: ['interactiveTerminal'] }, @@ -56,32 +92,542 @@ describe('InMemorySandboxRuntime', () => { expect(runtime.list()).toHaveLength(0) }) - it('fences termination with the observed generation', async () => { + it.each([ + { clientRequestId: '' }, + { clientRequestId: 'both', image: 'a', template: 'b' }, + { clientRequestId: 'cpu', resources: { cpuMillis: 0 } }, + { clientRequestId: 'duplicates', requiredCapabilities: ['fileAccess', 'fileAccess'] as const }, + ])('validates malformed create intent %#', async (spec) => { + const runtime = new InMemorySandboxRuntime(new MockSandboxProvider()) + await expect(runtime.create(spec, context)).rejects.toMatchObject({ code: 'invalid_request' }) + }) + + it('fails provider-specific source and resource semantics before allocation', async () => { + const runtime = new InMemorySandboxRuntime(new MockSandboxProvider()) + await expect( + runtime.create({ clientRequestId: 'image', image: 'public/image' }, context), + ).rejects.toMatchObject({ code: 'unsupported_capability' }) + await expect( + runtime.create({ clientRequestId: 'template', template: 'public-template' }, context), + ).rejects.toMatchObject({ code: 'unsupported_capability' }) + await expect( + runtime.create({ clientRequestId: 'resource', resources: { memoryMiB: 128 } }, context), + ).rejects.toMatchObject({ code: 'unsupported_capability' }) + expect(runtime.list()).toHaveLength(0) + }) + + it('rejects non-JSON and cyclic extension values', async () => { + const runtime = new InMemorySandboxRuntime(new MockSandboxProvider()) + await expect( + runtime.create( + { clientRequestId: 'date', extensions: { 'test.value': new Date() } as never }, + context, + ), + ).rejects.toMatchObject({ code: 'invalid_request' }) + const cyclic: Record = {} + cyclic['test.self'] = cyclic + await expect( + runtime.create({ clientRequestId: 'cycle', extensions: cyclic as never }, context), + ).rejects.toMatchObject({ code: 'invalid_request' }) + await expect( + runtime.create( + { clientRequestId: 'unnamespaced', extensions: { value: true } as never }, + context, + ), + ).rejects.toMatchObject({ code: 'invalid_request' }) + }) + + it('pauses, resumes, terminates, recreates, and fences stale generations', async () => { const runtime = new InMemorySandboxRuntime(new MockSandboxProvider(), { idFactory: () => 'sandbox-1', }) await runtime.create({ clientRequestId: 'create-1' }, context) + expect((await runtime.pause('sandbox-1', 1, context)).state).toBe('paused') + expect((await runtime.resume('sandbox-1', 1, context)).state).toBe('ready') + expect((await runtime.terminate('sandbox-1', 1, context)).state).toBe('terminated') + const recreated = await runtime.recreate( + 'sandbox-1', + 1, + { clientRequestId: 'create-2' }, + context, + ) + expect(recreated).toMatchObject({ id: 'sandbox-1', generation: 2, state: 'ready' }) + expect( + await runtime.recreate('sandbox-1', 1, { clientRequestId: 'create-2' }, context), + ).toEqual(recreated) + await expect(runtime.create({ clientRequestId: 'create-1' }, context)).rejects.toMatchObject({ + code: 'idempotency_conflict', + }) + await expect(runtime.terminate('sandbox-1', 1, context)).rejects.toMatchObject({ + code: 'generation_conflict', + }) + expect((await runtime.terminate('sandbox-1', 2, context)).state).toBe('terminated') + }) + + it('serializes concurrent termination and calls the provider once', async () => { + let terminations = 0 + const base = new MockSandboxProvider() + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: (request, ctx) => base.provision(request, ctx), + observe: (key, ctx) => base.observe(key, ctx), + terminate: async (key, ctx) => { + terminations += 1 + await new Promise((resolve) => setTimeout(resolve, 5)) + return base.terminate(key, ctx) + }, + } + const runtime = new InMemorySandboxRuntime(provider, { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + const results = await Promise.all([ + runtime.terminate('sandbox-1', 1, context), + runtime.terminate('sandbox-1', 1, context), + ]) + expect(results.every((resource) => resource.state === 'terminated')).toBe(true) + expect(terminations).toBe(1) + }) - await expect(runtime.terminate('sandbox-1', 2, context)).rejects.toBeInstanceOf(RuntimeError) - const terminated = await runtime.terminate('sandbox-1', 1, context) - expect(terminated.state).toBe('terminated') + it('reconciles ambiguous pause and resume failures from transitional state', async () => { + const base = new MockSandboxProvider() + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: (request, ctx) => base.provision(request, ctx), + observe: (key, ctx) => base.observe(key, ctx), + terminate: (key, ctx) => base.terminate(key, ctx), + pause: async (key, ctx) => { + await base.pause(key, ctx) + throw new Error('pause response lost') + }, + resume: async (key, ctx) => { + await base.resume(key, ctx) + throw new Error('resume response lost') + }, + } + const runtime = new InMemorySandboxRuntime(provider, { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + await expect(runtime.pause('sandbox-1', 1, context)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + expect(runtime.get('sandbox-1').state).toBe('pausing') + expect((await runtime.reconcile('sandbox-1', context)).state).toBe('paused') + await expect(runtime.resume('sandbox-1', 1, context)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + expect(runtime.get('sandbox-1').state).toBe('resuming') + expect((await runtime.reconcile('sandbox-1', context)).state).toBe('ready') }) - it('pauses and resumes only through declared provider behavior', async () => { + it('preserves protocol errors from malformed provision observations', async () => { + const base = new MockSandboxProvider() + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: () => Promise.resolve({ state: 'paused' }), + observe: (key, ctx) => base.observe(key, ctx), + terminate: (key, ctx) => base.terminate(key, ctx), + } + const runtime = new InMemorySandboxRuntime(provider) + await expect( + runtime.create({ clientRequestId: 'bad-provider' }, context), + ).rejects.toMatchObject({ + code: 'provider_protocol_error', + }) + expect(runtime.list()).toContainEqual(expect.objectContaining({ state: 'starting' })) + }) + + it('normalizes Provider describe failures and rejects incomplete manifests', async () => { + const unavailable: SandboxProvider = { + describe: () => Promise.reject(new Error('offline')), + provision: () => Promise.resolve({ state: 'ready' }), + observe: () => Promise.resolve({ state: 'ready' }), + terminate: () => Promise.resolve({ state: 'terminated' }), + } + await expect(new InMemorySandboxRuntime(unavailable).info(context)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + + const malformed: SandboxProvider = { + describe: () => + Promise.resolve({ name: '', version: '', runtimeClass: '', capabilities: {} } as never), + provision: () => Promise.resolve({ state: 'ready' }), + observe: () => Promise.resolve({ state: 'ready' }), + terminate: () => Promise.resolve({ state: 'terminated' }), + } + await expect(new InMemorySandboxRuntime(malformed).info(context)).rejects.toMatchObject({ + code: 'provider_protocol_error', + }) + }) +}) + +describe('execution, files, and events', () => { + it('rejects malformed provider data-plane results', async () => { + const base = new MockSandboxProvider() + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: (request, ctx) => base.provision(request, ctx), + observe: (key, ctx) => base.observe(key, ctx), + terminate: (key, ctx) => base.terminate(key, ctx), + execute: () => Promise.resolve({ exitCode: 0 } as never), + readFile: () => Promise.resolve({ path: 'probe.txt', contentBase64: 'YQ==', size: 2 }), + writeFile: () => Promise.resolve({ path: 'other.txt', contentBase64: 'YQ==', size: 1 }), + listFiles: () => Promise.resolve([{ path: '', kind: 'file', size: -1 }]), + } + const runtime = new InMemorySandboxRuntime(provider, { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + await expect( + runtime.execute('sandbox-1', { expectedGeneration: 1, argv: ['probe'] }, context), + ).rejects.toMatchObject({ code: 'provider_protocol_error' }) + await expect(runtime.readFile('sandbox-1', 1, 'probe.txt', context)).rejects.toMatchObject({ + code: 'provider_protocol_error', + }) + await expect( + runtime.writeFile( + 'sandbox-1', + { expectedGeneration: 1, path: 'probe.txt', contentBase64: 'YQ==' }, + context, + ), + ).rejects.toMatchObject({ code: 'provider_protocol_error' }) + await expect(runtime.listFiles('sandbox-1', 1, '.', context)).rejects.toMatchObject({ + code: 'provider_protocol_error', + }) + }) + + it('rechecks data-plane readiness after asynchronous capability lookup', async () => { + const base = new MockSandboxProvider() + let describeCalls = 0 + let releaseCapabilityLookup = () => {} + let markCapabilityLookupStarted = () => {} + const capabilityLookupGate = new Promise((resolve) => { + releaseCapabilityLookup = resolve + }) + const capabilityLookupStarted = new Promise((resolve) => { + markCapabilityLookupStarted = resolve + }) + const provider: SandboxProvider = { + describe: async (ctx) => { + describeCalls += 1 + if (describeCalls === 2) { + markCapabilityLookupStarted() + await capabilityLookupGate + } + return base.describe(ctx) + }, + provision: (request, ctx) => base.provision(request, ctx), + observe: (key, ctx) => base.observe(key, ctx), + terminate: (key, ctx) => base.terminate(key, ctx), + execute: (key, request, ctx) => base.execute(key, request, ctx), + } + const runtime = new InMemorySandboxRuntime(provider, { idFactory: () => 'sandbox-1' }) + await runtime.create({ clientRequestId: 'create-1' }, context) + + const execution = runtime.execute( + 'sandbox-1', + { expectedGeneration: 1, argv: ['serialized'] }, + context, + ) + await capabilityLookupStarted + const termination = runtime.terminate('sandbox-1', 1, context) + await expect(termination).resolves.toMatchObject({ state: 'terminated' }) + + releaseCapabilityLookup() + await expect(execution).rejects.toMatchObject({ code: 'invalid_state' }) + expect(runtime.events().map((event) => event.type)).toEqual([ + 'sandbox.created', + 'sandbox.state_changed', + 'sandbox.state_changed', + 'sandbox.state_changed', + 'sandbox.state_changed', + ]) + }) + + it.each([ + { expectedGeneration: 1, argv: [] }, + { expectedGeneration: 1, argv: ['ok'], timeoutSeconds: 0 }, + { expectedGeneration: 1, argv: ['ok'], maxOutputBytes: 0 }, + { expectedGeneration: 1, argv: ['ok'], env: { 'bad-name': 'value' } }, + ])('rejects invalid portable command request %#', async (request) => { const runtime = new InMemorySandboxRuntime(new MockSandboxProvider(), { idFactory: () => 'sandbox-1', }) await runtime.create({ clientRequestId: 'create-1' }, context) + await expect(runtime.execute('sandbox-1', request, context)).rejects.toMatchObject({ + code: 'invalid_request', + }) + }) - expect((await runtime.pause('sandbox-1', 1, context)).state).toBe('paused') - expect((await runtime.resume('sandbox-1', 1, context)).state).toBe('ready') + it('records monotonic replayable lifecycle and data-plane events', async () => { + const runtime = new InMemorySandboxRuntime(new MockSandboxProvider(), { + idFactory: () => 'sandbox-1', + }) + await runtime.create({ clientRequestId: 'create-1' }, context) + await runtime.execute( + 'sandbox-1', + { expectedGeneration: 1, argv: ['printf', 'hello'] }, + context, + ) + await runtime.writeFile( + 'sandbox-1', + { + expectedGeneration: 1, + path: 'hello.txt', + contentBase64: Buffer.from('hello').toString('base64'), + }, + context, + ) + const events = runtime.events() + expect(events.map((event) => event.cursor)).toEqual(events.map((_event, index) => index + 1)) + expect(events.map((event) => event.type)).toEqual([ + 'sandbox.created', + 'sandbox.state_changed', + 'sandbox.state_changed', + 'sandbox.command_completed', + 'sandbox.file_written', + ]) + expect(runtime.events(3).map((event) => event.cursor)).toEqual([4, 5]) + expect(runtime.events(0, 'other')).toEqual([]) + }) + + it('round-trips mock files and lists them', async () => { + const runtime = new InMemorySandboxRuntime(new MockSandboxProvider(), { + idFactory: () => 'sandbox-1', + }) + await runtime.create({ clientRequestId: 'create-1' }, context) + const contentBase64 = Buffer.from('portable').toString('base64') + await runtime.writeFile( + 'sandbox-1', + { expectedGeneration: 1, path: 'probe.txt', contentBase64 }, + context, + ) + expect(await runtime.readFile('sandbox-1', 1, 'probe.txt', context)).toMatchObject({ + contentBase64, + size: 8, + }) + expect(await runtime.listFiles('sandbox-1', 1, '.', context)).toEqual([ + { path: 'probe.txt', kind: 'file', size: 8 }, + ]) + await runtime.writeFile( + 'sandbox-1', + { expectedGeneration: 1, path: 'nested/probe.txt', contentBase64 }, + context, + ) + expect(await runtime.listFiles('sandbox-1', 1, '.', context)).toEqual([ + { path: 'probe.txt', kind: 'file', size: 8 }, + ]) + expect(await runtime.listFiles('sandbox-1', 1, 'nested', context)).toEqual([ + { path: 'nested/probe.txt', kind: 'file', size: 8 }, + ]) + }) + + it('notifies and unsubscribes event listeners', async () => { + const runtime = new InMemorySandboxRuntime(new MockSandboxProvider()) + const cursors: number[] = [] + const unsubscribe = runtime.subscribe((event) => cursors.push(event.cursor)) + await runtime.create({ clientRequestId: 'first' }, context) + unsubscribe() + await runtime.create({ clientRequestId: 'second' }, context) + expect(cursors).toEqual([1, 2, 3]) + }) + + it('isolates lifecycle operations from subscriber failures', async () => { + const listenerErrors: string[] = [] + const runtime = new InMemorySandboxRuntime(new MockSandboxProvider(), { + listenerErrorHandler: (error) => + listenerErrors.push(error instanceof Error ? error.message : 'unknown'), + }) + runtime.subscribe(() => { + throw new Error('subscriber failed') + }) + const observed: number[] = [] + runtime.subscribe((event) => observed.push(event.cursor)) + + const resource = await runtime.create({ clientRequestId: 'listener' }, context) + expect(resource.state).toBe('ready') + expect(observed).toEqual([1, 2, 3]) + expect(listenerErrors).toEqual(['subscriber failed', 'subscriber failed', 'subscriber failed']) + }) + + it('bounds event history and rejects stale replay cursors', async () => { + const runtime = new InMemorySandboxRuntime(new MockSandboxProvider(), { maxEvents: 2 }) + await runtime.create({ clientRequestId: 'bounded' }, context) + expect(runtime.events().map((event) => event.cursor)).toEqual([2, 3]) + expect(() => runtime.events(1)).not.toThrow() + const [resource] = runtime.list() + if (!resource) throw new Error('sandbox was not created') + await runtime.execute(resource.id, { expectedGeneration: 1, argv: ['ok'] }, context) + expect(() => runtime.events(1)).toThrowError(/starts at cursor 3/) }) }) describe('provider conformance', () => { it('accepts the deterministic mock provider', async () => { - const results = await runProviderConformance(new MockSandboxProvider()) - expect(results).not.toHaveLength(0) - expect(results.every((result) => result.passed)).toBe(true) + const results = await runProviderConformance(new MockSandboxProvider(), { + commandRequest: { argv: ['conformance'] }, + }) + expect(results.filter((result) => !result.passed)).toEqual([]) + }) + + it('isolates concurrent conformance runs with unique resource identities', async () => { + const base = new MockSandboxProvider() + const sandboxIds: string[] = [] + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: async (request, ctx) => { + sandboxIds.push(request.sandboxId) + await Promise.resolve() + return base.provision(request, ctx) + }, + observe: (key, ctx) => base.observe(key, ctx), + terminate: (key, ctx) => base.terminate(key, ctx), + pause: (key, ctx) => base.pause(key, ctx), + resume: (key, ctx) => base.resume(key, ctx), + execute: (key, request, ctx) => base.execute(key, request, ctx), + readFile: (key, path, ctx) => base.readFile(key, path, ctx), + writeFile: (key, request, ctx) => base.writeFile(key, request, ctx), + listFiles: (key, path, ctx) => base.listFiles(key, path, ctx), + } + const runs = await Promise.all([ + runProviderConformance(provider, { commandRequest: { argv: ['first'] } }), + runProviderConformance(provider, { commandRequest: { argv: ['second'] } }), + ]) + expect(new Set(sandboxIds).size).toBe(2) + expect(runs.flat().filter((result) => !result.passed)).toEqual([]) + }) + + it('detects a capability without its SPI method', async () => { + const base = new MockSandboxProvider() + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: (request, ctx) => base.provision(request, ctx), + observe: (key, ctx) => base.observe(key, ctx), + terminate: (key, ctx) => base.terminate(key, ctx), + } + const results = await runProviderConformance(provider, { + commandRequest: { argv: ['conformance'] }, + }) + expect(results.some((result) => !result.passed)).toBe(true) + }) + + it('accepts a provider that becomes ready asynchronously', async () => { + const base = new MockSandboxProvider({ pauseResume: false, fileAccess: false }) + let observations = 0 + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: () => Promise.resolve({ state: 'starting' }), + observe: () => { + observations += 1 + return Promise.resolve({ state: 'ready' }) + }, + terminate: () => Promise.resolve({ state: 'terminated' }), + execute: (_key, request) => { + if (observations === 0) throw new Error('execute ran before readiness') + const now = new Date().toISOString() + return Promise.resolve({ + exitCode: 0, + stdout: request.argv.join(' '), + stderr: '', + timedOut: false, + cancelled: false, + truncated: false, + startedAt: now, + finishedAt: now, + }) + }, + } + await expect( + runProviderConformance(provider, { commandRequest: { argv: ['conformance'] } }), + ).resolves.not.toContainEqual(expect.objectContaining({ passed: false })) + }) + + it('attempts cleanup when the provision response is lost', async () => { + const base = new MockSandboxProvider({ pauseResume: false, fileAccess: false }) + let terminations = 0 + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: () => Promise.reject(new Error('response lost')), + observe: () => Promise.resolve({ state: 'ready' }), + terminate: () => { + terminations += 1 + return Promise.resolve({ state: 'terminated' }) + }, + execute: (key, request, ctx) => base.execute(key, request, ctx), + } + const results = await runProviderConformance(provider) + expect(terminations).toBe(1) + expect(results).toContainEqual({ + name: 'ambiguous provision failure is cleaned up', + passed: true, + }) + }) + + it('bounds a readiness observation that never settles and still cleans up', async () => { + const base = new MockSandboxProvider({ + commandExecution: false, + fileAccess: false, + pauseResume: false, + }) + let terminations = 0 + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: () => Promise.resolve({ state: 'starting' }), + observe: () => new Promise(() => {}), + terminate: () => { + terminations += 1 + return Promise.resolve({ state: 'terminated' }) + }, + } + const startedAt = Date.now() + const results = await runProviderConformance(provider, { + readinessTimeoutMs: 20, + pollIntervalMs: 5, + }) + expect(Date.now() - startedAt).toBeLessThan(500) + expect(terminations).toBe(1) + expect(results).toContainEqual({ + name: 'provision reaches ready within the bounded deadline', + passed: false, + detail: 'readiness observation timed out', + }) + }) + + it('rejects incomplete command result shapes', async () => { + const base = new MockSandboxProvider({ pauseResume: false, fileAccess: false }) + const provider: SandboxProvider = { + describe: (ctx) => base.describe(ctx), + provision: (request, ctx) => base.provision(request, ctx), + observe: (key, ctx) => base.observe(key, ctx), + terminate: (key, ctx) => base.terminate(key, ctx), + execute: () => Promise.resolve({ exitCode: 0, timedOut: false } as never), + } + const results = await runProviderConformance(provider, { + commandRequest: { argv: ['incomplete'] }, + }) + expect(results).toContainEqual({ + name: 'command execution returns a complete successful result', + passed: false, + }) + }) + + it('reports unreadable and incomplete manifests without throwing', async () => { + const unreadable: SandboxProvider = { + describe: () => Promise.reject(new Error('offline')), + provision: () => Promise.resolve({ state: 'ready' }), + observe: () => Promise.resolve({ state: 'ready' }), + terminate: () => Promise.resolve({ state: 'terminated' }), + } + await expect(runProviderConformance(unreadable)).resolves.toContainEqual({ + name: 'provider manifest is readable', + passed: false, + detail: 'offline', + }) + + const incomplete: SandboxProvider = { + describe: () => + Promise.resolve({ name: '', version: '', runtimeClass: '', capabilities: {} } as never), + provision: () => Promise.resolve({ state: 'ready' }), + observe: () => Promise.resolve({ state: 'ready' }), + terminate: () => Promise.resolve({ state: 'terminated' }), + } + await expect(runProviderConformance(incomplete)).resolves.toEqual([ + { name: 'provider manifest is complete', passed: false }, + ]) }) }) diff --git a/tests/spec.test.ts b/tests/spec.test.ts new file mode 100644 index 0000000..4f0d9e2 --- /dev/null +++ b/tests/spec.test.ts @@ -0,0 +1,55 @@ +import { readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { capabilityNames, protocolVersion, sandboxEventTypes, sandboxStates } from '../src/index.js' + +describe('OpenAPI projection', () => { + it('matches the executable protocol vocabularies', async () => { + const document = parse(await readFile('spec/openapi.yaml', 'utf8')) + expect(document.openapi).toBe('3.1.0') + expect(document.info.version).toBe('0.1.0') + expect(document.components.schemas.RuntimeInfo.properties.protocolVersion.const).toBe( + protocolVersion, + ) + expect(document.components.schemas.CapabilityName.enum).toEqual([...capabilityNames]) + expect(document.components.schemas.RuntimeCapabilities.required).toEqual([...capabilityNames]) + expect(document.components.schemas.SandboxState.enum).toEqual([...sandboxStates]) + expect(document.components.schemas.SandboxEvent.properties.type.enum).toEqual([ + ...sandboxEventTypes, + ]) + expect(document.components.schemas.CommandRequest.required).toContain('expectedGeneration') + expect(document.components.schemas.FileWriteRequest.required).toContain('expectedGeneration') + expect(document.components.schemas.FileReadResult.required).not.toContain('expectedGeneration') + expect( + document.components.requestBodies.ExpectedGeneration.content['application/json'].schema + .additionalProperties, + ).toBe(false) + expect(document.components.responses.ProviderProtocolError).toBeDefined() + expect(document.paths['/v1/runtime'].get.responses).toMatchObject({ + '502': { $ref: '#/components/responses/ProviderProtocolError' }, + '503': { $ref: '#/components/responses/ProviderUnavailable' }, + }) + }) + + it('documents every public reference-server route', async () => { + const document = parse(await readFile('spec/openapi.yaml', 'utf8')) + expect(Object.keys(document.paths).sort()).toEqual( + [ + '/healthz', + '/v1/events', + '/v1/events/stream', + '/v1/runtime', + '/v1/sandboxes', + '/v1/sandboxes/{sandboxId}', + '/v1/sandboxes/{sandboxId}/actions/pause', + '/v1/sandboxes/{sandboxId}/actions/reconcile', + '/v1/sandboxes/{sandboxId}/actions/recreate', + '/v1/sandboxes/{sandboxId}/actions/resume', + '/v1/sandboxes/{sandboxId}/actions/terminate', + '/v1/sandboxes/{sandboxId}/commands', + '/v1/sandboxes/{sandboxId}/files/content', + '/v1/sandboxes/{sandboxId}/files/entries', + ].sort(), + ) + }) +}) diff --git a/tests/validation.test.ts b/tests/validation.test.ts new file mode 100644 index 0000000..9493ad5 --- /dev/null +++ b/tests/validation.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { + parseCommandRequest, + parseExpectedGeneration, + parseFileWriteRequest, + parseSandboxSpec, +} from '../src/index.js' + +describe('transport validation', () => { + it('parses a complete sandbox specification', () => { + expect( + parseSandboxSpec({ + clientRequestId: 'complete', + image: 'public.example/image:latest', + resources: { cpuMillis: 100, memoryMiB: 128, diskMiB: 256 }, + requiredCapabilities: ['commandExecution'], + extensions: { 'example.public': true }, + }), + ).toEqual({ + clientRequestId: 'complete', + image: 'public.example/image:latest', + resources: { cpuMillis: 100, memoryMiB: 128, diskMiB: 256 }, + requiredCapabilities: ['commandExecution'], + extensions: { 'example.public': true }, + }) + }) + + it.each([ + null, + [], + {}, + { clientRequestId: 1 }, + { clientRequestId: 'x', resources: [] }, + { clientRequestId: 'x', extensions: [] }, + { clientRequestId: 'x', extra: true }, + { clientRequestId: 'x', resources: { memoryMiB: 1, extra: true } }, + { clientRequestId: 'x', requiredCapabilities: ['unknown'] }, + ])('rejects malformed sandbox transport input %#', (value) => { + expect(() => parseSandboxSpec(value)).toThrowError() + }) + + it('parses a complete command request', () => { + expect( + parseCommandRequest({ + expectedGeneration: 2, + argv: ['printf', 'hello'], + cwd: '.', + env: { PROBE: 'ok' }, + timeoutSeconds: 2, + maxOutputBytes: 10, + }), + ).toEqual({ + expectedGeneration: 2, + argv: ['printf', 'hello'], + cwd: '.', + env: { PROBE: 'ok' }, + timeoutSeconds: 2, + maxOutputBytes: 10, + }) + }) + + it.each([ + undefined, + {}, + { expectedGeneration: 1, argv: 'printf' }, + { expectedGeneration: 1, argv: [1] }, + { expectedGeneration: 1, argv: [] }, + { expectedGeneration: 1, argv: ['ok'], env: [] }, + { expectedGeneration: 1, argv: ['ok'], env: { PROBE: 1 } }, + { expectedGeneration: 1, argv: ['ok'], timeoutSeconds: -1 }, + { expectedGeneration: 1, argv: ['ok'], maxOutputBytes: 0 }, + { expectedGeneration: 1, argv: ['ok'], extra: true }, + ])('rejects malformed command transport input %#', (value) => { + expect(() => parseCommandRequest(value)).toThrowError() + }) + + it('parses generation and file write inputs', () => { + expect(parseExpectedGeneration({ expectedGeneration: 2 })).toBe(2) + expect( + parseFileWriteRequest({ expectedGeneration: 2, path: 'a.txt', contentBase64: 'YQ==' }), + ).toEqual({ + expectedGeneration: 2, + path: 'a.txt', + contentBase64: 'YQ==', + }) + }) + + it('rejects malformed generation and file inputs', () => { + expect(() => parseExpectedGeneration({ expectedGeneration: '2' })).toThrowError() + expect(() => parseExpectedGeneration({ expectedGeneration: 2, extra: true })).toThrowError() + expect(() => + parseFileWriteRequest({ expectedGeneration: 1, path: 1, contentBase64: true }), + ).toThrowError() + expect(() => + parseFileWriteRequest({ + expectedGeneration: 1, + path: 'a', + contentBase64: 'YQ==', + extra: true, + }), + ).toThrowError() + }) +}) diff --git a/tsconfig.build.json b/tsconfig.build.json index 797d39a..e4c2145 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,7 +5,8 @@ "declarationMap": true, "outDir": "dist", "rootDir": "src", - "sourceMap": true + "sourceMap": true, + "types": ["node"] }, "include": ["src/**/*.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..feb94a9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + include: ['src/**/*.ts'], + exclude: ['src/cli.ts', 'src/index.ts'], + thresholds: { + lines: 85, + functions: 85, + statements: 85, + branches: 75, + }, + }, + }, +}) From 6f9a8889250a4517325dadf8408615f4ee781086 Mon Sep 17 00:00:00 2001 From: shwang Date: Wed, 2 Sep 2026 13:30:01 +0800 Subject: [PATCH 2/2] ci: run complete release gate in check --- AGENTS.md | 2 +- docs/testing.md | 5 +++-- package.json | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 44ef167..3b034bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ provider SPI, mock adapter, SDK-facing types, and conformance checks. ## Commands - Install: `pnpm install` -- Full check: `pnpm check` +- Full check (including build, coverage, docs, and package dry-run): `pnpm check` - Build: `pnpm build` - Tests: `pnpm test` - Coverage: `pnpm test:coverage` diff --git a/docs/testing.md b/docs/testing.md index 3f744a3..efc0518 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -20,8 +20,9 @@ pnpm test:coverage pnpm pack:check ``` -`pnpm check` runs formatting, lint, type checking, and the complete test suite. CI runs the same gates -on Node.js 22 and 24, followed by the production dependency audit. +`pnpm check` runs formatting, lint, type checking, the complete test suite, documentation validation, +build, coverage thresholds, and package dry-run. CI runs the same gates on Node.js 22 and 24, followed +by the public-content scan and production dependency audit. ## Test dimensions diff --git a/package.json b/package.json index 4397d43..43c0b4c 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ }, "scripts": { "build": "tsc -p tsconfig.build.json", - "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm docs:check", + "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm docs:check && pnpm build && pnpm test:coverage && pnpm pack:check", "docs:check": "node scripts/check-docs.mjs", "format": "biome format --write .", "format:check": "biome format .",