ai-sdk-sandbox-sbx 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/dist/create-args.d.ts +11 -0
- package/dist/create-args.js +78 -0
- package/dist/credential-broker.d.ts +35 -0
- package/dist/credential-broker.js +143 -0
- package/dist/free-loopback-port.d.ts +2 -0
- package/dist/free-loopback-port.js +12 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/open-sandbox.d.ts +22 -0
- package/dist/open-sandbox.js +54 -0
- package/dist/port-publisher.d.ts +30 -0
- package/dist/port-publisher.js +75 -0
- package/dist/sandbox-scripts.d.ts +29 -0
- package/dist/sandbox-scripts.js +41 -0
- package/dist/sandbox-template.d.ts +26 -0
- package/dist/sandbox-template.js +74 -0
- package/dist/sbx-cli.d.ts +42 -0
- package/dist/sbx-cli.js +77 -0
- package/dist/sbx-error.d.ts +11 -0
- package/dist/sbx-error.js +14 -0
- package/dist/sbx-network-sandbox-session.d.ts +74 -0
- package/dist/sbx-network-sandbox-session.js +133 -0
- package/dist/sbx-network-sandbox.d.ts +26 -0
- package/dist/sbx-network-sandbox.js +68 -0
- package/dist/sbx-sandbox-not-found-error.d.ts +5 -0
- package/dist/sbx-sandbox-not-found-error.js +10 -0
- package/dist/sbx-sandbox-session.d.ts +60 -0
- package/dist/sbx-sandbox-session.js +159 -0
- package/dist/sbx-settings.d.ts +118 -0
- package/dist/sbx-settings.js +1 -0
- package/package.json +87 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# ai-sdk-sandbox-sbx
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- 1f6ebc7: First stable release: run AI SDK harness agents (Claude Code, Codex…) in a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) microVM through the `sbx` CLI. `createSbxNetworkSandboxSession()` creates a sandbox, optionally from a template image baked once by `agent.getSandboxTemplate()`, and `resumeSbxNetworkSandboxSession()` reattaches to it. Ports are published on demand, on the host loopback only, and credentials stay outside the sandbox: the Docker Sandboxes proxy puts them in the requests on the way out.
|
|
8
|
+
|
|
9
|
+
Docker Sandboxes Cloud is supported with `cloud: true`, as an experimental feature that may change in a minor release while it settles.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fabien Pasquet
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# ai-sdk-sandbox-sbx
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://www.npmjs.com/package/ai-sdk-sandbox-sbx"><img alt="npm" src="https://img.shields.io/npm/v/ai-sdk-sandbox-sbx" /></a>
|
|
5
|
+
<a href="https://github.com/fpasquet/ai-sdk-harness/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/fpasquet/ai-sdk-harness/actions/workflows/ci.yml/badge.svg" /></a>
|
|
6
|
+
<img alt="Node >= 24" src="https://img.shields.io/badge/node-%3E%3D24-3c873a" />
|
|
7
|
+
<img alt="TypeScript strict" src="https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white" />
|
|
8
|
+
<a href="https://github.com/fpasquet/ai-sdk-harness/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
Run [AI SDK harness agents](https://ai-sdk.dev/docs/ai-sdk-harnesses/harness-agent) (Claude Code, Codex, OpenCode…) in a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/): a microVM on your machine, or in Docker Sandboxes Cloud, driven through the `sbx` CLI.
|
|
12
|
+
|
|
13
|
+
The package gives `HarnessAgent.createSession({ sandboxSession })` what it expects, a `HarnessV1NetworkSandboxSession`, the same way [`@ai-sdk/sandbox-vercel`](https://www.npmjs.com/package/@ai-sdk/sandbox-vercel) does for Vercel Sandbox:
|
|
14
|
+
|
|
15
|
+
- **Isolation**: every command and file operation is an `sbx exec` into the microVM. Nothing runs on the host, and nothing of the host is mounted unless you ask for it.
|
|
16
|
+
- **Credentials stay on the host**: the harness hands the sandbox a placeholder; the Docker Sandboxes proxy swaps the real value in on the way out.
|
|
17
|
+
- **The harness is installed once**: pass `agent.getSandboxTemplate()` and the first sandbox is saved as a template image; every later one starts from it in seconds.
|
|
18
|
+
- **Local or cloud**: the same API runs the sandbox on your machine, its ports on the loopback only, or in [Docker Sandboxes Cloud](#docker-sandboxes-cloud) with `cloud: true`.
|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
> This package follows [Semantic Versioning](https://semver.org). The AI SDK harnesses it plugs into are themselves experimental, and its [cloud mode](#docker-sandboxes-cloud) is too. It is a community package, not affiliated with Vercel or Docker.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Node.js 24 or later.
|
|
27
|
+
- [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/get-started/) installed, with `sbx` on the `PATH` (or pointed at by the `binary` option), and signed in (`sbx login`).
|
|
28
|
+
- `@ai-sdk/harness` and a harness adapter, such as `@ai-sdk/harness-claude-code`.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm add ai-sdk-sandbox-sbx @ai-sdk/harness @ai-sdk/harness-claude-code
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```ts title="agent.ts"
|
|
39
|
+
import { HarnessAgent } from '@ai-sdk/harness/agent';
|
|
40
|
+
import { createClaudeCode } from '@ai-sdk/harness-claude-code';
|
|
41
|
+
import { createSbxNetworkSandboxSession } from 'ai-sdk-sandbox-sbx';
|
|
42
|
+
|
|
43
|
+
// Authenticated from CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY.
|
|
44
|
+
const agent = new HarnessAgent({ id: 'coder', harness: createClaudeCode() });
|
|
45
|
+
|
|
46
|
+
const sandboxSession = await createSbxNetworkSandboxSession({
|
|
47
|
+
// The Claude Code bridge listens on the first port; the harness reaches it on the loopback.
|
|
48
|
+
ports: [4000],
|
|
49
|
+
// The bridge installs its dependencies with pnpm, which the `shell` kit does not ship.
|
|
50
|
+
setup: ['npm install --global --silent pnpm@10'],
|
|
51
|
+
// Bakes Claude Code into a local image the first time, reused afterwards.
|
|
52
|
+
template: await agent.getSandboxTemplate(),
|
|
53
|
+
});
|
|
54
|
+
const session = await agent.createSession({ sandboxSession });
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const result = await agent.generate({
|
|
58
|
+
session,
|
|
59
|
+
prompt: 'Write a script that prints the first ten primes, then run it.',
|
|
60
|
+
});
|
|
61
|
+
console.log(result.text);
|
|
62
|
+
} finally {
|
|
63
|
+
await session.destroy();
|
|
64
|
+
await sandboxSession.destroy();
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The caller owns the sandbox: ending the harness session leaves it running. `stop()` stops its microVM and keeps its files; `destroy()` removes it.
|
|
69
|
+
|
|
70
|
+
`agent.stream()` works the same way, and its result's `toUIMessageStreamResponse()` feeds `useChat` straight away. The [Next.js example](https://github.com/fpasquet/ai-sdk-harness/tree/main/examples/next-chat) is a complete chat on top of it.
|
|
71
|
+
|
|
72
|
+
## Templates
|
|
73
|
+
|
|
74
|
+
A harness bootstraps itself in the sandbox before its first turn: Claude Code installs `@anthropic-ai/claude-agent-sdk` and the `claude` CLI, which takes a minute or two. Pass the agent's template and it happens once:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const sandboxSession = await createSbxNetworkSandboxSession({
|
|
78
|
+
ports: [4000],
|
|
79
|
+
setup: ['npm install --global --silent pnpm@10'],
|
|
80
|
+
template: await agent.getSandboxTemplate(),
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The first call creates a throwaway sandbox, runs `setup` in it, lets the harness prepare it, saves it with `sbx template save` and removes it. The image is named `ai-sdk-harness-template:<digest>`, the digest covering the harness recipe, the agent kit, the `image` and the `setup` commands, so a new harness version gets an image of its own. Every later sandbox starts from it with `sbx create --template … --pull never`.
|
|
85
|
+
|
|
86
|
+
The images stay in the sandbox runtime's store: list them with `sbx template ls`, remove an outdated one with `sbx template rm`.
|
|
87
|
+
|
|
88
|
+
## Resuming a sandbox
|
|
89
|
+
|
|
90
|
+
Creation never resumes: a sandbox already named `sandboxId` is a conflict. Name the sandbox, keep its id, and reattach to it, from the same process or another one:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import {
|
|
94
|
+
createSbxNetworkSandboxSession,
|
|
95
|
+
resumeSbxNetworkSandboxSession,
|
|
96
|
+
SbxSandboxNotFoundError,
|
|
97
|
+
} from 'ai-sdk-sandbox-sbx';
|
|
98
|
+
|
|
99
|
+
async function openSandbox() {
|
|
100
|
+
try {
|
|
101
|
+
const sandbox = await resumeSbxNetworkSandboxSession({ sandboxId: 'my-agent', ports: [4000] });
|
|
102
|
+
// A previous run may have left a bridge behind, holding the port.
|
|
103
|
+
await sandbox.killAllProcesses();
|
|
104
|
+
return sandbox;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (!(error instanceof SbxSandboxNotFoundError)) throw error;
|
|
107
|
+
return createSbxNetworkSandboxSession({ sandboxId: 'my-agent', ports: [4000] });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A stopped sandbox starts again on the first command. `sbx` does not remember which ports a harness uses until they are published, so pass the same `ports` again.
|
|
113
|
+
|
|
114
|
+
## Working on your files
|
|
115
|
+
|
|
116
|
+
By default the sandbox mounts nothing of the host: the agent works on the microVM's own filesystem. Mount a directory to let it work on yours:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// Read-write: the agent edits the directory itself.
|
|
120
|
+
await createSbxNetworkSandboxSession({ workspace: '/path/to/project', ports: [4000] });
|
|
121
|
+
|
|
122
|
+
// A private clone of a Git repository instead: the host repository is mounted read-only, and
|
|
123
|
+
// the agent's commits come back through the `sandbox-<name>` git remote on the host.
|
|
124
|
+
await createSbxNetworkSandboxSession({ workspace: '/path/to/repo', clone: true, ports: [4000] });
|
|
125
|
+
|
|
126
|
+
// More directories, read-only.
|
|
127
|
+
await createSbxNetworkSandboxSession({ readOnlyWorkspaces: ['/path/to/docs'], ports: [4000] });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The sandbox's working directory (`defaultWorkingDirectory`, under which the harness creates each session's own directory) is read from the live sandbox: the workspace when one is mounted, `/home/agent/workspace` otherwise.
|
|
131
|
+
|
|
132
|
+
## Docker Sandboxes Cloud
|
|
133
|
+
|
|
134
|
+
With `cloud: true`, the sandbox runs in Docker Sandboxes Cloud rather than on your machine: every `sbx` command goes out as `sbx --cloud …`. It needs a [Docker Agentic Platform](https://agentic-platform.docker.com) subscription and `sbx login` (personal access tokens carry no cloud access).
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const sandboxSession = await createSbxNetworkSandboxSession({
|
|
138
|
+
cloud: true,
|
|
139
|
+
ports: [4000],
|
|
140
|
+
setup: ['npm install --global --silent pnpm@10'],
|
|
141
|
+
template: await agent.getSandboxTemplate(),
|
|
142
|
+
ttl: '2h',
|
|
143
|
+
onTimeout: 'stop',
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
What changes from a local sandbox:
|
|
148
|
+
|
|
149
|
+
- **Nothing of your machine is mounted**: `workspace`, `clone` and `readOnlyWorkspaces` are refused. Copy what the agent needs in with `writeFile()`, or clone it from inside the sandbox.
|
|
150
|
+
- **Ports get a public URL** assigned by the control plane, instead of a loopback port. The harness bridges authenticate their connections with a token of their own.
|
|
151
|
+
- **Credentials are set by the proxy**: the cloud proxy puts the whole header on the requests to the host, under a secret named after the sandbox, the host and the header. Cloud secrets belong to the account, so `destroy()` removes them too.
|
|
152
|
+
- **Templates live in the cloud registry**, named `ai-sdk-harness-template-<digest>`. Saving one snapshots the running sandbox and takes several minutes; list them with `sbx --cloud template ls`.
|
|
153
|
+
- **A cloud sandbox has a time-to-live**, 24 hours at most: set it with `ttl` and `onTimeout`, extend it with `extendTtl('1h')`. `stop()` suspends it with its memory.
|
|
154
|
+
- **Sizing** comes in billable shapes: `cpus` and `memory` together, 2 / `4g` by default. `allowNetwork` and `platform` are cloud-only settings.
|
|
155
|
+
|
|
156
|
+
**Cloud support is experimental**: it follows the `sbx --cloud` command reference, and may change in a minor release while it settles. Its e2e suite runs with `SBX_E2E_CLOUD=1 pnpm test:e2e`.
|
|
157
|
+
|
|
158
|
+
## Credentials
|
|
159
|
+
|
|
160
|
+
A harness that supports credential brokering never puts the real credential in the sandbox. It hands the sandbox a random placeholder and asks the sandbox session to transform the requests on their way out. This package turns each transformation into an `sbx secret set-custom` scoped to the sandbox, so the Docker Sandboxes proxy replaces the placeholder with the real value (a cloud proxy sets the whole header instead), which only exists on the host and in the proxy. The placeholders are withdrawn by `release()` and `stop()`, and removed with the sandbox by `destroy()`.
|
|
161
|
+
|
|
162
|
+
Turn it off with `brokerCredentials: false` for an `sbx` without custom secrets: the harness then forwards the real credential into the sandbox environment.
|
|
163
|
+
|
|
164
|
+
Docker Sandboxes also pre-sets its own credential variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GH_TOKEN`…) to a `proxy-managed` placeholder, for credentials stored with `sbx secret set`. Left in place, one can take precedence over the credential the harness passes (the `claude` CLI prefers `ANTHROPIC_API_KEY` to `CLAUDE_CODE_OAUTH_TOKEN`), so they are dropped from every command that does not set them itself. Keep them with `keepProxyManagedEnv: true`; drop more with `clearEnv`.
|
|
165
|
+
|
|
166
|
+
Variables a command does set are forwarded as bare `sbx exec -e NAME`: `sbx` reads the value from its own environment, so it never appears on a command line.
|
|
167
|
+
|
|
168
|
+
## Network
|
|
169
|
+
|
|
170
|
+
Outbound traffic goes through the Docker Sandboxes proxy and its policy (`sbx policy`). `denyNetwork` adds deny rules for the new sandbox only. A local deny can only narrow egress, so it holds whatever the global policy allows:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
await createSbxNetworkSandboxSession({
|
|
174
|
+
denyNetwork: ['github.com', '*.github.com'],
|
|
175
|
+
ports: [4000],
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`setNetworkPolicy` is not implemented: the harness treats it as optional, and `sbx policy` remains the place to manage what the sandbox may reach.
|
|
180
|
+
|
|
181
|
+
## Ports
|
|
182
|
+
|
|
183
|
+
`ports` lists the ports inside the sandbox the harness may reach. Each one is published the first time `getPortEndpoint()` asks for it, on this host's loopback (`127.0.0.1:<free port>`) or, for a cloud sandbox, at the public `https://` URL the control plane assigns, and unpublished by `release()`, `stop()` or `setPorts()`. A port outside the list is refused with `HarnessCapabilityUnsupportedError`.
|
|
184
|
+
|
|
185
|
+
## Lifecycle
|
|
186
|
+
|
|
187
|
+
| Method | What it does |
|
|
188
|
+
| --------------------- | ------------------------------------------------------------------------------------------------- |
|
|
189
|
+
| `release()` | Stops the processes this session started, unpublishes its ports, withdraws its placeholders |
|
|
190
|
+
| `killAllProcesses()` | Stops every process any session started in the sandbox, including what a previous run left behind |
|
|
191
|
+
| `stop()` | `release()`, then stops the microVM. Its files stay; the next command starts it again |
|
|
192
|
+
| `extendTtl(duration)` | Extends the time-to-live of a cloud sandbox (`'1h'`), within its 24 hours |
|
|
193
|
+
| `destroy()` | Removes the sandbox, its files and its secrets |
|
|
194
|
+
| `restricted()` | The files-and-processes view of the same sandbox, to hand to tools (see below) |
|
|
195
|
+
|
|
196
|
+
`restricted()` returns an `Experimental_SandboxSession`: it can run commands and read and write files, but cannot stop the sandbox, publish ports or touch credentials. Pass it to AI SDK tools that accept `experimental_sandbox`.
|
|
197
|
+
|
|
198
|
+
## Options
|
|
199
|
+
|
|
200
|
+
`createSbxNetworkSandboxSession(options)` takes every option below; `resumeSbxNetworkSandboxSession(options)` takes `sandboxId`, `abortSignal`, `cloud` and the connection settings.
|
|
201
|
+
|
|
202
|
+
| Option | Default | Description |
|
|
203
|
+
| --------------------- | -------------- | ----------------------------------------------------------------------------------------------- |
|
|
204
|
+
| `sandboxId` | `ai-sdk-<hex>` | The sandbox's name in `sbx ls`. Creation fails if it is taken |
|
|
205
|
+
| `template` | none | `await agent.getSandboxTemplate()`: prepare once, start every later sandbox from the image |
|
|
206
|
+
| `abortSignal` | none | Aborts the creation or the resume |
|
|
207
|
+
| `agent` | `'shell'` | The Docker Sandboxes agent kit: its image and network rules |
|
|
208
|
+
| `image` | the kit's own | Container image to use instead (`sbx create --template`) |
|
|
209
|
+
| `cloud` | `false` | Run in Docker Sandboxes Cloud (`sbx --cloud`) rather than on this host |
|
|
210
|
+
| `workspace` | none | Host directory to mount read-write as the working directory (local only) |
|
|
211
|
+
| `clone` | `false` | With `workspace`: a private clone of the Git repository instead (local only) |
|
|
212
|
+
| `readOnlyWorkspaces` | `[]` | More host directories, mounted read-only (local only) |
|
|
213
|
+
| `denyNetwork` | `[]` | Hosts the sandbox may never reach (`sbx create --deny-network`) |
|
|
214
|
+
| `allowNetwork` | `[]` | Hosts a cloud sandbox may reach, on top of the account's policy (cloud only) |
|
|
215
|
+
| `cpus` | decided by sbx | CPUs of the microVM |
|
|
216
|
+
| `memory` | decided by sbx | Memory limit of the microVM (`4g`, `512m`) |
|
|
217
|
+
| `platform` | decided by sbx | `linux/amd64` or `linux/arm64` (cloud only) |
|
|
218
|
+
| `ttl` | decided by sbx | Time-to-live of a cloud sandbox, 24 hours at most (cloud only) |
|
|
219
|
+
| `onTimeout` | decided by sbx | `stop`, `restart` or `delete` the cloud sandbox when its `ttl` lapses (cloud only) |
|
|
220
|
+
| `setup` | `[]` | Commands run once after creation, as the sandbox user (who has `sudo`), baked into the template |
|
|
221
|
+
| `ports` | `[]` | _Connection._ Ports the harness may reach, published on demand |
|
|
222
|
+
| `brokerCredentials` | `true` | _Connection._ Keep credentials out of the sandbox, swapped in by the proxy |
|
|
223
|
+
| `clearEnv` | `[]` | _Connection._ More variables to drop from commands that do not set them |
|
|
224
|
+
| `keepProxyManagedEnv` | `false` | _Connection._ Keep the `proxy-managed` credential variables Docker Sandboxes pre-sets |
|
|
225
|
+
| `binary` | `'sbx'` | _Connection._ The `sbx` binary |
|
|
226
|
+
|
|
227
|
+
## Errors
|
|
228
|
+
|
|
229
|
+
- `SbxSandboxNotFoundError`: `resumeSbxNetworkSandboxSession()` found no sandbox of that name. Carries `sandboxId`.
|
|
230
|
+
- `SbxError`: an `sbx` command exited with a non-zero status. Carries `args`, `exitCode` and `stderr`.
|
|
231
|
+
- `HarnessCapabilityUnsupportedError` (from `@ai-sdk/harness`): a port that is not exposed, or a credential the proxy cannot broker.
|
|
232
|
+
|
|
233
|
+
## Limitations
|
|
234
|
+
|
|
235
|
+
- **One bridge per port.** A bridge-backed harness listens on the first port: run one harness session at a time per sandbox, or give each its own port.
|
|
236
|
+
|
|
237
|
+
## Development
|
|
238
|
+
|
|
239
|
+
This package lives in the [`ai-sdk-harness`](https://github.com/fpasquet/ai-sdk-harness) monorepo. `pnpm test` runs the unit tests against a fake `sbx`; `pnpm test:e2e` runs them against the real one, creating and removing sandboxes on your machine.
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
[MIT](https://github.com/fpasquet/ai-sdk-harness/blob/main/LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SbxCreationSettings } from './sbx-settings.js';
|
|
2
|
+
export declare function assertSandboxName(name: string, cloud: boolean): void;
|
|
3
|
+
/** Fails on a setting the sandbox's side, local or cloud, would ignore or refuse. */
|
|
4
|
+
export declare function assertSettingsFit(settings: SbxCreationSettings, cloud: boolean): void;
|
|
5
|
+
/** The `sbx create` command line of a new sandbox, made from `image` when there is one. */
|
|
6
|
+
export declare function createArgs({ name, image, fromTemplate, settings, }: {
|
|
7
|
+
name: string;
|
|
8
|
+
image: string | undefined;
|
|
9
|
+
fromTemplate: boolean;
|
|
10
|
+
settings: SbxCreationSettings;
|
|
11
|
+
}): string[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** What `sbx create --name` accepts: two characters or more, starting with a letter or a digit. */
|
|
2
|
+
const LOCAL_NAME = /^[A-Za-z0-9][A-Za-z0-9.-]+$/;
|
|
3
|
+
/** The same, without periods, which cloud sandbox names refuse. */
|
|
4
|
+
const CLOUD_NAME = /^[A-Za-z0-9][A-Za-z0-9-]+$/;
|
|
5
|
+
/** Settings that only mean something on one side: a cloud sandbox mounts nothing of this host. */
|
|
6
|
+
const LOCAL_ONLY = ['workspace', 'clone', 'readOnlyWorkspaces'];
|
|
7
|
+
const CLOUD_ONLY = ['allowNetwork', 'ttl', 'onTimeout', 'platform'];
|
|
8
|
+
export function assertSandboxName(name, cloud) {
|
|
9
|
+
if (!(cloud ? CLOUD_NAME : LOCAL_NAME).test(name) || name === 'default') {
|
|
10
|
+
const characters = cloud
|
|
11
|
+
? 'letters, digits and hyphens'
|
|
12
|
+
: 'letters, digits, hyphens and periods';
|
|
13
|
+
throw new Error(`"${name}" is not a valid sandbox id: use two characters or more (${characters}), ` +
|
|
14
|
+
'starting with a letter or a digit ("default" is reserved).');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Fails on a setting the sandbox's side, local or cloud, would ignore or refuse. */
|
|
18
|
+
export function assertSettingsFit(settings, cloud) {
|
|
19
|
+
const misplaced = (cloud ? LOCAL_ONLY : CLOUD_ONLY).filter((key) => settings[key] !== undefined && settings[key] !== false);
|
|
20
|
+
if (misplaced.length > 0) {
|
|
21
|
+
const names = misplaced.map((key) => `\`${key}\``).join(', ');
|
|
22
|
+
throw new Error(cloud
|
|
23
|
+
? `${names}: a cloud sandbox mounts nothing of this host.`
|
|
24
|
+
: `${names}: only apply to a cloud sandbox (\`cloud: true\`).`);
|
|
25
|
+
}
|
|
26
|
+
if (settings.clone && settings.workspace === undefined) {
|
|
27
|
+
throw new Error('`clone` needs a `workspace`: the Git repository of this host to clone.');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** `--template`, for a sandbox made from an image rather than from its kit's own. */
|
|
31
|
+
function imageFlags(image, localTemplate) {
|
|
32
|
+
if (image === undefined)
|
|
33
|
+
return [];
|
|
34
|
+
// A local template image only exists in the local store: never look for it in a registry.
|
|
35
|
+
return localTemplate ? ['--template', image, '--pull', 'never'] : ['--template', image];
|
|
36
|
+
}
|
|
37
|
+
function resourceFlags({ cpus, memory, platform }) {
|
|
38
|
+
return [
|
|
39
|
+
...(cpus === undefined ? [] : ['--cpus', String(cpus)]),
|
|
40
|
+
...(memory === undefined ? [] : ['--memory', memory]),
|
|
41
|
+
...(platform === undefined ? [] : ['--platform', platform]),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
function networkFlags({ denyNetwork = [], allowNetwork = [] }) {
|
|
45
|
+
return [
|
|
46
|
+
...denyNetwork.flatMap((host) => ['--deny-network', host]),
|
|
47
|
+
...allowNetwork.flatMap((host) => ['--allow-network', host]),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
function lifetimeFlags({ ttl, onTimeout }) {
|
|
51
|
+
return [
|
|
52
|
+
...(ttl === undefined ? [] : ['--ttl', ttl]),
|
|
53
|
+
...(onTimeout === undefined ? [] : ['--on-timeout', onTimeout]),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
/** The agent kit, then the host directories the sandbox mounts. */
|
|
57
|
+
function workspaceArgs({ agent = 'shell', workspace, clone = false, readOnlyWorkspaces = [], }) {
|
|
58
|
+
return [
|
|
59
|
+
...(clone ? ['--clone'] : []),
|
|
60
|
+
agent,
|
|
61
|
+
...(workspace === undefined ? [] : [workspace]),
|
|
62
|
+
...readOnlyWorkspaces.map((path) => `${path}:ro`),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
/** The `sbx create` command line of a new sandbox, made from `image` when there is one. */
|
|
66
|
+
export function createArgs({ name, image, fromTemplate, settings, }) {
|
|
67
|
+
return [
|
|
68
|
+
'create',
|
|
69
|
+
'--name',
|
|
70
|
+
name,
|
|
71
|
+
'--quiet',
|
|
72
|
+
...imageFlags(image, fromTemplate && settings.cloud !== true),
|
|
73
|
+
...resourceFlags(settings),
|
|
74
|
+
...networkFlags(settings),
|
|
75
|
+
...lifetimeFlags(settings),
|
|
76
|
+
...workspaceArgs(settings),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { HarnessV1RequestTransformation } from '@ai-sdk/harness';
|
|
2
|
+
import type { SbxCli } from './sbx-cli.js';
|
|
3
|
+
type Transformations = ReadonlyArray<HarnessV1RequestTransformation>;
|
|
4
|
+
/** Recorded on the errors this package raises through the harness's own error types. */
|
|
5
|
+
export declare const SBX_SANDBOX_PROVIDER_ID = "sbx";
|
|
6
|
+
/**
|
|
7
|
+
* Keeps credentials out of the sandbox: each request transformation becomes a custom secret of the
|
|
8
|
+
* Docker Sandboxes proxy, scoped to the sandbox, so the real value only exists on this host and in
|
|
9
|
+
* the proxy.
|
|
10
|
+
*/
|
|
11
|
+
export interface CredentialBroker {
|
|
12
|
+
add(transformations: Transformations): Promise<void>;
|
|
13
|
+
/** Withdraws the secrets this broker registered. */
|
|
14
|
+
release(): Promise<void>;
|
|
15
|
+
/** Withdraws every secret scoped to the sandbox, a previous process's included. */
|
|
16
|
+
clear(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Local sandboxes: the proxy swaps one string for another in the requests to the host. The
|
|
20
|
+
* sandbox sends the harness's placeholder, `Bearer <placeholder>`, and the proxy puts the secret
|
|
21
|
+
* in its place: only the part that differs is registered.
|
|
22
|
+
*
|
|
23
|
+
* A header the request does not carry, the proxy cannot add. When the same transformation does
|
|
24
|
+
* protect a credential, such a header is left out with a warning: Codex, logged in with ChatGPT,
|
|
25
|
+
* asks for a `ChatGPT-Account-ID` its backend does without. A transformation that would protect
|
|
26
|
+
* no credential at all is refused.
|
|
27
|
+
*/
|
|
28
|
+
export declare function placeholderBroker(cli: SbxCli, sandbox: string): CredentialBroker;
|
|
29
|
+
/**
|
|
30
|
+
* Cloud sandboxes: the proxy sets the whole header on the requests to the host, whatever the
|
|
31
|
+
* sandbox sent in it. Each secret is named after the sandbox, the host and the header, so
|
|
32
|
+
* registering it again replaces it rather than piling up.
|
|
33
|
+
*/
|
|
34
|
+
export declare function headerBroker(cli: SbxCli, sandbox: string): CredentialBroker;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { HarnessCapabilityUnsupportedError } from '@ai-sdk/harness';
|
|
2
|
+
/** Recorded on the errors this package raises through the harness's own error types. */
|
|
3
|
+
export const SBX_SANDBOX_PROVIDER_ID = 'sbx';
|
|
4
|
+
/**
|
|
5
|
+
* Local sandboxes: the proxy swaps one string for another in the requests to the host. The
|
|
6
|
+
* sandbox sends the harness's placeholder, `Bearer <placeholder>`, and the proxy puts the secret
|
|
7
|
+
* in its place: only the part that differs is registered.
|
|
8
|
+
*
|
|
9
|
+
* A header the request does not carry, the proxy cannot add. When the same transformation does
|
|
10
|
+
* protect a credential, such a header is left out with a warning: Codex, logged in with ChatGPT,
|
|
11
|
+
* asks for a `ChatGPT-Account-ID` its backend does without. A transformation that would protect
|
|
12
|
+
* no credential at all is refused.
|
|
13
|
+
*/
|
|
14
|
+
export function placeholderBroker(cli, sandbox) {
|
|
15
|
+
const placeholders = new Set();
|
|
16
|
+
const remove = (placeholder) => cli.run(['secret', 'rm', '--sandbox', sandbox, '--placeholder', placeholder, '--force']);
|
|
17
|
+
return {
|
|
18
|
+
add: async (transformations) => {
|
|
19
|
+
for (const { match, header, secret } of swappableHeaders(transformations)) {
|
|
20
|
+
const [placeholder, value] = placeholderOf(match, header, secret);
|
|
21
|
+
await cli.check([
|
|
22
|
+
'secret',
|
|
23
|
+
'set-custom',
|
|
24
|
+
'--sandbox',
|
|
25
|
+
sandbox,
|
|
26
|
+
'--host',
|
|
27
|
+
match.host,
|
|
28
|
+
'--placeholder',
|
|
29
|
+
placeholder,
|
|
30
|
+
// On this host's command line for as long as `sbx` runs, never inside the sandbox.
|
|
31
|
+
'--value',
|
|
32
|
+
value,
|
|
33
|
+
]);
|
|
34
|
+
placeholders.add(placeholder);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
release: async () => {
|
|
38
|
+
for (const placeholder of placeholders)
|
|
39
|
+
await remove(placeholder);
|
|
40
|
+
placeholders.clear();
|
|
41
|
+
},
|
|
42
|
+
clear: async () => {
|
|
43
|
+
const listed = await cli.check(['secret', 'ls', '--sandbox', sandbox, '--json']);
|
|
44
|
+
const { custom_secrets: secrets = [] } = JSON.parse(listed);
|
|
45
|
+
for (const { scope, placeholder } of secrets) {
|
|
46
|
+
if (scope === sandbox)
|
|
47
|
+
await remove(placeholder);
|
|
48
|
+
}
|
|
49
|
+
placeholders.clear();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Cloud sandboxes: the proxy sets the whole header on the requests to the host, whatever the
|
|
55
|
+
* sandbox sent in it. Each secret is named after the sandbox, the host and the header, so
|
|
56
|
+
* registering it again replaces it rather than piling up.
|
|
57
|
+
*/
|
|
58
|
+
export function headerBroker(cli, sandbox) {
|
|
59
|
+
const names = new Set();
|
|
60
|
+
const remove = (name) => cli.run(['secret', 'rm', name, '--force']);
|
|
61
|
+
const release = async () => {
|
|
62
|
+
for (const name of names)
|
|
63
|
+
await remove(name);
|
|
64
|
+
names.clear();
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
add: async (transformations) => {
|
|
68
|
+
for (const { match, header, secret } of headersOf(transformations)) {
|
|
69
|
+
const name = `${sandbox}-${match.host}-${header}`.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
70
|
+
await cli.check([
|
|
71
|
+
'secret',
|
|
72
|
+
'set-custom',
|
|
73
|
+
'--sandbox',
|
|
74
|
+
sandbox,
|
|
75
|
+
'--name',
|
|
76
|
+
name,
|
|
77
|
+
'--host',
|
|
78
|
+
match.host,
|
|
79
|
+
'--header',
|
|
80
|
+
header,
|
|
81
|
+
'--format',
|
|
82
|
+
'%s',
|
|
83
|
+
'--value',
|
|
84
|
+
secret,
|
|
85
|
+
]);
|
|
86
|
+
names.add(name);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
release,
|
|
90
|
+
// Names are derived from the sandbox: what a previous process registered is overwritten, not
|
|
91
|
+
// left behind, so withdrawing this broker's own is all there is to do.
|
|
92
|
+
clear: release,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function headersOf(transformations) {
|
|
96
|
+
return transformations.flatMap(({ match, transform }) => Object.entries(transform.headers).map(([header, secret]) => ({ match, header, secret })));
|
|
97
|
+
}
|
|
98
|
+
/** The value the sandbox sends for `header`, when it is an exact placeholder. */
|
|
99
|
+
function sentValue(match, header) {
|
|
100
|
+
const sent = match.headers?.find(({ key }) => key && 'exact' in key ? key.exact.toLowerCase() === header.toLowerCase() : false)?.value;
|
|
101
|
+
return sent && 'exact' in sent ? sent.exact : undefined;
|
|
102
|
+
}
|
|
103
|
+
/** Headers already warned about, so a long-running process says it once. */
|
|
104
|
+
const warned = new Set();
|
|
105
|
+
/**
|
|
106
|
+
* The headers a placeholder-swapping proxy can set: those the request carries a placeholder for.
|
|
107
|
+
* The others are left out, with a warning, when their transformation protects a credential.
|
|
108
|
+
*/
|
|
109
|
+
function swappableHeaders(transformations) {
|
|
110
|
+
return transformations.flatMap((transformation) => {
|
|
111
|
+
const headers = headersOf([transformation]);
|
|
112
|
+
const swappable = headers.filter(({ match, header }) => sentValue(match, header) !== undefined);
|
|
113
|
+
// Nothing to swap: the request carries no credential to protect. Let placeholderOf say so.
|
|
114
|
+
if (swappable.length === 0)
|
|
115
|
+
return headers;
|
|
116
|
+
for (const { match, header } of headers) {
|
|
117
|
+
const key = `${match.host} ${header}`;
|
|
118
|
+
if (swappable.some((kept) => kept.header === header) || warned.has(key))
|
|
119
|
+
continue;
|
|
120
|
+
warned.add(key);
|
|
121
|
+
process.emitWarning(`The ${header} header of ${match.host} is left out: a local Docker Sandboxes proxy swaps ` +
|
|
122
|
+
'placeholders, and cannot add a header the request does not carry.', { code: 'AI_SDK_SANDBOX_SBX_HEADER_LEFT_OUT' });
|
|
123
|
+
}
|
|
124
|
+
return swappable;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The placeholder the sandbox sends for `header`, and the value the proxy must put in its place,
|
|
129
|
+
* without the prefix they share.
|
|
130
|
+
*/
|
|
131
|
+
function placeholderOf(match, header, secret) {
|
|
132
|
+
const sent = sentValue(match, header);
|
|
133
|
+
if (sent === undefined) {
|
|
134
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
135
|
+
harnessId: SBX_SANDBOX_PROVIDER_ID,
|
|
136
|
+
message: `Cannot broker the ${header} header of ${match.host}: Docker Sandboxes swaps a placeholder, and the request carries none.`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
let shared = 0;
|
|
140
|
+
while (shared < sent.length && sent[shared] === secret[shared])
|
|
141
|
+
shared += 1;
|
|
142
|
+
return [sent.slice(shared), secret.slice(shared)];
|
|
143
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
/** A TCP port free on this host's loopback right now. */
|
|
3
|
+
export function freeLoopbackPort() {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const server = createServer();
|
|
6
|
+
server.once('error', reject);
|
|
7
|
+
server.listen(0, '127.0.0.1', () => {
|
|
8
|
+
const { port } = server.address();
|
|
9
|
+
server.close(() => resolve(port));
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { SbxError } from './sbx-error.js';
|
|
2
|
+
export { SBX_SANDBOX_PROVIDER_ID, SbxNetworkSandboxSession, } from './sbx-network-sandbox-session.js';
|
|
3
|
+
export { createSbxNetworkSandboxSession, resumeSbxNetworkSandboxSession, } from './sbx-network-sandbox.js';
|
|
4
|
+
export { SbxSandboxNotFoundError } from './sbx-sandbox-not-found-error.js';
|
|
5
|
+
export { SbxSandboxSession } from './sbx-sandbox-session.js';
|
|
6
|
+
export type { SbxConnectionSettings, SbxCreationSettings, SbxNetworkSandboxSessionCreateOptions, SbxNetworkSandboxSessionResumeOptions, } from './sbx-settings.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SbxError } from './sbx-error.js';
|
|
2
|
+
export { SBX_SANDBOX_PROVIDER_ID, SbxNetworkSandboxSession, } from './sbx-network-sandbox-session.js';
|
|
3
|
+
export { createSbxNetworkSandboxSession, resumeSbxNetworkSandboxSession, } from './sbx-network-sandbox.js';
|
|
4
|
+
export { SbxSandboxNotFoundError } from './sbx-sandbox-not-found-error.js';
|
|
5
|
+
export { SbxSandboxSession } from './sbx-sandbox-session.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SbxCli } from './sbx-cli.js';
|
|
2
|
+
import type { SbxConnectionSettings } from './sbx-settings.js';
|
|
3
|
+
import { SbxNetworkSandboxSession } from './sbx-network-sandbox-session.js';
|
|
4
|
+
/** The sandboxes `sbx` knows of, by name and, for cloud ones, by `sbx_*` id too. */
|
|
5
|
+
export declare function listSandboxes(cli: SbxCli, abortSignal?: AbortSignal): Promise<Set<string>>;
|
|
6
|
+
/** Throws {@link SbxSandboxNotFoundError} unless a sandbox named `name` exists. */
|
|
7
|
+
export declare function assertSandboxExists(cli: SbxCli, name: string, abortSignal?: AbortSignal): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Runs each of `setup` in the sandbox `name`, in order, stopping at the first failure. They run as
|
|
10
|
+
* the sandbox user: `sbx --cloud exec` refuses `--user`, and the kits give that user `sudo`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runSetup(cli: SbxCli, name: string, { setup, abortSignal }: {
|
|
13
|
+
setup: readonly string[];
|
|
14
|
+
abortSignal?: AbortSignal;
|
|
15
|
+
}): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* A session on the existing sandbox `name`. Its working directory and its environment are read from
|
|
18
|
+
* the live sandbox: they belong to its image, not to this package.
|
|
19
|
+
*/
|
|
20
|
+
export declare function openSandbox(cli: SbxCli, name: string, settings: SbxConnectionSettings & {
|
|
21
|
+
abortSignal?: AbortSignal;
|
|
22
|
+
}): Promise<SbxNetworkSandboxSession>;
|