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..3b034bf 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`
@@ -16,9 +17,12 @@ 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`
+- 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
+[](https://github.com/capa-cloud/sandbox-runtime-api/actions/workflows/ci.yml)
+[](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
+[](https://github.com/capa-cloud/sandbox-runtime-api/actions/workflows/ci.yml)
+[](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..efc0518
--- /dev/null
+++ b/docs/testing.md
@@ -0,0 +1,49 @@
+---
+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, 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
+
+| 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..43c0b4c 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 && pnpm build && pnpm test:coverage && pnpm pack: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,
+ },
+ },
+ },
+})