@schemavaults/sandbox-management-sdk 0.0.0-stage → 0.1.1

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -2
  3. package/dist/commands.d.ts +16 -0
  4. package/dist/commands.js +99 -0
  5. package/dist/dbh/index.d.ts +4 -0
  6. package/dist/dbh/index.js +2 -0
  7. package/dist/dbh/migration.d.ts +18 -0
  8. package/dist/dbh/migration.js +41 -0
  9. package/dist/dbh/store.d.ts +42 -0
  10. package/dist/dbh/store.js +105 -0
  11. package/dist/docker/egress-proxy/config.d.ts +20 -0
  12. package/dist/docker/egress-proxy/config.js +29 -0
  13. package/dist/docker/egress-proxy/main.d.ts +1 -0
  14. package/dist/docker/egress-proxy/main.js +19 -0
  15. package/dist/docker/egress-proxy/policy.d.ts +49 -0
  16. package/dist/docker/egress-proxy/policy.js +89 -0
  17. package/dist/docker/egress-proxy/server.d.ts +25 -0
  18. package/dist/docker/egress-proxy/server.js +225 -0
  19. package/dist/docker/egress.d.ts +41 -0
  20. package/dist/docker/egress.js +67 -0
  21. package/dist/docker/index.d.ts +3 -0
  22. package/dist/docker/index.js +2 -0
  23. package/dist/docker/provider.d.ts +101 -0
  24. package/dist/docker/provider.js +373 -0
  25. package/dist/docker/tar.d.ts +20 -0
  26. package/dist/docker/tar.js +124 -0
  27. package/dist/errors.d.ts +40 -0
  28. package/dist/errors.js +67 -0
  29. package/dist/in-memory-store.d.ts +11 -0
  30. package/dist/in-memory-store.js +40 -0
  31. package/dist/index.d.ts +12 -0
  32. package/dist/index.js +8 -0
  33. package/dist/internal/base64.d.ts +7 -0
  34. package/dist/internal/base64.js +20 -0
  35. package/dist/internal/output.d.ts +6 -0
  36. package/dist/internal/output.js +25 -0
  37. package/dist/internal/posix-path.d.ts +8 -0
  38. package/dist/internal/posix-path.js +38 -0
  39. package/dist/manager.d.ts +126 -0
  40. package/dist/manager.js +477 -0
  41. package/dist/network.d.ts +27 -0
  42. package/dist/network.js +128 -0
  43. package/dist/provider.d.ts +77 -0
  44. package/dist/provider.js +1 -0
  45. package/dist/shell-main-process.d.ts +27 -0
  46. package/dist/shell-main-process.js +94 -0
  47. package/dist/store.d.ts +36 -0
  48. package/dist/store.js +19 -0
  49. package/dist/testing/index.d.ts +3 -0
  50. package/dist/testing/index.js +2 -0
  51. package/dist/testing/mock-provider.d.ts +99 -0
  52. package/dist/testing/mock-provider.js +181 -0
  53. package/dist/types.d.ts +158 -0
  54. package/dist/types.js +21 -0
  55. package/dist/vercel/index.d.ts +2 -0
  56. package/dist/vercel/index.js +1 -0
  57. package/dist/vercel/provider.d.ts +125 -0
  58. package/dist/vercel/provider.js +190 -0
  59. package/dist/web-storage/index.d.ts +2 -0
  60. package/dist/web-storage/index.js +1 -0
  61. package/dist/web-storage/store.d.ts +53 -0
  62. package/dist/web-storage/store.js +118 -0
  63. package/package.json +117 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SchemaVaults
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 CHANGED
@@ -1,3 +1,296 @@
1
- # Temporary Holding Version
1
+ # @schemavaults/sandbox-management-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A TypeScript SDK for running sandboxes on any backend through one API. You can create, set up, start, stop, resume, list and destroy sandboxes on Docker or [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox), and the sandbox state lives in your app's own database.
4
+
5
+ - **One lifecycle everywhere.** `create` clones an optional git repo, runs `setup` once and launches `start` as the long-running main process. `stop` keeps the filesystem. Touching a stopped sandbox wakes it and runs `resume`.
6
+ - **State in your database.** Records go through a `SandboxStore`. The SDK ships an in-memory store, a Kysely / [`@schemavaults/dbh`](https://github.com/schemavaults/dbh) Postgres store with a migration, and a Web Storage store (`localStorage` / `sessionStorage`) for browsers. Every status change is a compare-and-set, so many server processes can share sandboxes safely.
7
+ - **Runtimes.** Runs on Node.js ≥ 24 and Bun, and the core, Vercel adapter and dbh store also run in serverless functions. The core and the Web Storage store also run in browsers.
8
+
9
+ ```
10
+ bun add @schemavaults/sandbox-management-sdk
11
+ # plus whichever backends you use (all optional peer dependencies):
12
+ bun add dockerode # Docker provider
13
+ bun add @vercel/sandbox # Vercel provider
14
+ bun add @schemavaults/dbh # Postgres store (or any Kysely instance)
15
+ ```
16
+
17
+ | Entry point | Contents |
18
+ | --- | --- |
19
+ | `@schemavaults/sandbox-management-sdk` | `SandboxManager`, `InMemorySandboxStore`, types, errors, helpers for writing providers |
20
+ | `…/docker` | `DockerSandboxProvider` |
21
+ | `…/vercel` | `VercelSandboxProvider` |
22
+ | `…/dbh` | `DbhSandboxStore`, `createSandboxesMigration`, `up` / `down` |
23
+ | `…/dbh/migration` | `up` / `down` only, to re-export from a migrations directory |
24
+ | `…/web-storage` | `WebStorageSandboxStore` (browser `localStorage` / `sessionStorage`) |
25
+ | `…/testing` | `MockSandboxProvider`, `InMemorySandboxStore` |
26
+
27
+ ## Quick start
28
+
29
+ ```ts
30
+ import { SandboxManager } from "@schemavaults/sandbox-management-sdk";
31
+ import { DockerSandboxProvider } from "@schemavaults/sandbox-management-sdk/docker";
32
+ import { VercelSandboxProvider } from "@schemavaults/sandbox-management-sdk/vercel";
33
+ import { DbhSandboxStore } from "@schemavaults/sandbox-management-sdk/dbh";
34
+ import createDbh from "@schemavaults/dbh";
35
+
36
+ const dbh = await createDbh("postgres", { environment: "production" });
37
+
38
+ const sandboxes = new SandboxManager({
39
+ providers: [
40
+ new DockerSandboxProvider({ defaultImage: "node:24-bookworm" }),
41
+ new VercelSandboxProvider(), // uses VERCEL_OIDC_TOKEN, or pass { credentials }
42
+ ],
43
+ defaultProvider: "vercel",
44
+ store: new DbhSandboxStore({ db: dbh }),
45
+ });
46
+
47
+ const sandbox = await sandboxes.create({
48
+ ownerId: user.id,
49
+ labels: { project: "acme" },
50
+ spec: {
51
+ git: { url: "https://github.com/acme/app.git", revision: "main", depth: 1 },
52
+ setup: ["npm ci", "npm run build"],
53
+ start: "npm run dev -- --port 3000",
54
+ resume: "npm run dev -- --port 3000", // defaults to `start`
55
+ ports: [3000],
56
+ env: { NODE_ENV: "development" },
57
+ timeoutMs: 30 * 60_000,
58
+ resources: { vcpus: 2 },
59
+ },
60
+ onStdout: (chunk) => process.stdout.write(chunk), // setup output
61
+ });
62
+
63
+ await sandboxes.getUrl(sandbox.id, 3000); // https://….vercel.run
64
+ await sandboxes.exec(sandbox.id, "npm test"); // { exitCode, stdout, stderr }
65
+ await sandboxes.stop(sandbox.id); // filesystem is kept
66
+ await sandboxes.exec(sandbox.id, "ls"); // auto-resumes first
67
+ await sandboxes.list({ ownerId: user.id, labels: { project: "acme" } });
68
+ await sandboxes.destroy(sandbox.id);
69
+ ```
70
+
71
+ ## Concepts
72
+
73
+ ### The spec
74
+
75
+ The `SandboxSpec` is stored with the sandbox, so any process can later resume it by ID alone. Any of these fields can be left out:
76
+
77
+ | Field | Meaning |
78
+ | --- | --- |
79
+ | `workdir` | Absolute directory commands run in. Default: `/workspace` (Docker), `/vercel/sandbox` (Vercel). |
80
+ | `env` | Environment variables for every command. |
81
+ | `git` | `{ url, revision?, depth?, auth? }`, cloned into `workdir` before setup. `revision` may be a branch, tag or commit SHA. `auth` is sent as a one-off HTTP header and never written to `.git/config`. The image needs `git`. |
82
+ | `setup` | One command or a list of them, run once after creation. Stops at the first non-zero exit. |
83
+ | `start` | The main process, launched detached after setup. |
84
+ | `resume` | Launched detached as the new main process whenever a stopped sandbox is woken. Defaults to `start`. |
85
+ | `ports` | Ports reachable through `getUrl`. |
86
+ | `timeoutMs` | Maximum run time. Vercel enforces it itself. For Docker, call `stopExpired()` periodically. |
87
+ | `resources` | `{ vcpus?, memoryMb? }`. Vercel only supports `vcpus` (2 GB of memory per vCPU). |
88
+ | `network` | Outbound access: `"allow-all"` (default), `"deny-all"`, or an allowlist `{ allowDomains?, allowCidrs?, denyCidrs? }`. See [Network access](#network-access). |
89
+ | `providerOptions` | Provider-specific settings: `{ docker: { image, user, network, pullPolicy, labels } }` or `{ vercel: { image \| runtime, region, networkPolicy, tags } }`. |
90
+
91
+ A command is either a string, run with `sh -c`, or `{ cmd, args?, cwd?, env? }`, run as an exact argv with no shell. A relative `cwd` resolves against `workdir`.
92
+
93
+ > `spec.env` and `git.auth` are stored in your store as-is. Treat the table as sensitive, or pass short-lived credentials.
94
+
95
+ ### Network access
96
+
97
+ `spec.network` limits where a sandbox can connect, for example so that an agent can only reach the platforms it works with:
98
+
99
+ ```ts
100
+ await sandboxes.create({
101
+ spec: {
102
+ git: { url: "https://github.com/acme/agent.git" },
103
+ setup: "npm ci",
104
+ start: "node agent.js",
105
+ network: {
106
+ allowDomains: ["github.com", "*.github.com", "registry.npmjs.org", "api.acme.dev"],
107
+ allowCidrs: ["203.0.113.10", "198.51.100.0/24"], // IP addresses or CIDR ranges
108
+ denyCidrs: ["169.254.169.254"], // always blocked, even for allowed domains
109
+ },
110
+ },
111
+ });
112
+ ```
113
+
114
+ - Everything not on the allowlist is blocked. `"deny-all"` blocks everything, and `"allow-all"` (the default) blocks nothing.
115
+ - A host name is allowed if it is on `allowDomains` or resolves to an address in `allowCidrs`. A leading `*.` matches subdomains only, so list `example.com` as well as `*.example.com` if you need both. An IP address is allowed if it is in `allowCidrs`. `denyCidrs` wins over both.
116
+ - The policy applies from creation, so `git` and `setup` run under it too. Allow your git host and package registries, or those steps fail.
117
+ - A provider only gets sandboxes whose policy it can enforce. Otherwise `create` throws before anything is created or stored, so a policy is never silently ignored.
118
+
119
+ | Provider | `"deny-all"` | Allowlist |
120
+ | --- | --- | --- |
121
+ | Vercel | Yes | Yes, as the native network policy |
122
+ | Docker | Yes: `--network none`, or behind an egress proxy when the sandbox has `ports` | Yes, behind an egress proxy (HTTP and HTTPS only, see [Docker](#docker)) |
123
+
124
+ ### Lifecycle
125
+
126
+ ```
127
+ create ─► provisioning ─► setting_up ─► starting ─► running ◄──► stopping ─► stopped
128
+ ▲ │
129
+ └──────── resuming ◄────┘
130
+ any ─► destroying ─► destroyed failures ─► failed
131
+ ```
132
+
133
+ - **Auto-resume.** `exec`, `getUrl`, `readFile`, `writeFiles`, `logs` and the other operations on a stopped sandbox first wake it and launch `resume`. This also covers a sandbox the provider stopped by itself (for example a Vercel timeout). Turn it off with `autoResume: false`, and call `resume(id)` yourself.
134
+ - **Concurrency.** Status changes are compare-and-set on a version column. If several requests hit a stopped sandbox at once, exactly one resumes it and the others wait for it (up to `transitionTimeoutMs`, default 10 minutes).
135
+ - **Failures.** A failing setup step (`SandboxCommandError`) or provider error marks the sandbox `failed` and keeps the provider sandbox so you can inspect it. Call `destroy` to remove it. `resume` also retries a failed sandbox (without re-running setup).
136
+ - **Destroyed records are kept**, but `list` hides them unless you pass `includeDestroyed: true`.
137
+ - **`reconcile(ids?)`** syncs records with what the providers report: sandboxes stopped behind the SDK's back become `stopped`, vanished ones become `failed`, and transitions abandoned by a crashed process are settled.
138
+
139
+ ### Main process and logs
140
+
141
+ `start` and `resume` run detached. Their stdout and stderr are appended to `/tmp/sandbox-sdk/main.log` inside the sandbox, which survives restarts:
142
+
143
+ ```ts
144
+ await sandboxes.getMainProcess(id); // { pid, running, exitCode }
145
+ await sandboxes.readLogs(id); // everything so far
146
+ for await (const chunk of sandboxes.logs(id, { follow: true, signal })) {
147
+ process.stdout.write(chunk);
148
+ }
149
+ ```
150
+
151
+ Only the first log read auto-resumes a stopped sandbox. A `follow` stream ends when the sandbox stops or `signal` aborts.
152
+
153
+ ### Files
154
+
155
+ ```ts
156
+ await sandboxes.writeFiles(id, [{ path: "prompt.md", content: "…" }, { path: "/usr/local/bin/x", content: bytes, mode: 0o755 }]);
157
+ await sandboxes.readTextFile(id, "out/result.json"); // null if missing
158
+ ```
159
+
160
+ ## Providers
161
+
162
+ ### Docker
163
+
164
+ ```ts
165
+ new DockerSandboxProvider({
166
+ docker: new Docker({ socketPath: "/var/run/docker.sock" }), // default: new Docker() (honors DOCKER_HOST)
167
+ defaultImage: "node:24-bookworm",
168
+ pullPolicy: "if-not-present", // or "always" / "never"
169
+ portBindingHost: "127.0.0.1", // where published ports bind
170
+ urlHost: "localhost", // host used in getUrl() URLs
171
+ egressProxyImage: DOCKER_EGRESS_PROXY_IMAGE, // the default: enforces spec.network allowlists
172
+ });
173
+ ```
174
+
175
+ - Each sandbox is a long-lived container named `sandbox-<id>` and labeled `dev.schemavaults.sandbox.id=<id>`. It runs an idle PID 1 (the entrypoint is overridden; the image needs `/bin/sh`) with `--init`.
176
+ - `stop` and `wake` map to `docker stop` and `docker start`, so the container filesystem persists. Published ports get random host ports again on every start, and `getUrl` looks them up.
177
+ - Docker has no native timeout. Run `sandboxes.stopExpired()` from a cron job.
178
+ - If a command passed to `exec` with `timeoutMs` or `signal` is abandoned, the process inside the container keeps running.
179
+ - `spec.network: "deny-all"` without `ports` runs the container with `--network none`.
180
+
181
+ #### Network allowlists
182
+
183
+ A sandbox with a `spec.network` allowlist (or `"deny-all"` and `ports`) runs behind an egress proxy:
184
+
185
+ ```
186
+ sandbox-<id> ──(internal network, no route out)── sandbox-<id>-egress ──► allowed hosts
187
+ ▲ published ports
188
+ ```
189
+
190
+ - The sandbox container is attached only to an internal Docker network of its own (`sandbox-<id>-net`), so it has no route out and cannot resolve outside names.
191
+ - Its only way out is the proxy container `sandbox-<id>-egress`, which joins that network and `providerOptions.docker.network` (default `"bridge"`). The proxy enforces the allowlist and forwards the sandbox's `ports`, which it publishes on the host in the sandbox's place. Both carry the label `dev.schemavaults.sandbox.egress-proxy=<id>`.
192
+ - The sandbox gets `HTTP_PROXY`, `HTTPS_PROXY` (and lowercase), `NO_PROXY` and `NODE_USE_ENV_PROXY=1`, which curl, wget, git over HTTPS, npm, pip, Bun and Node.js honor. HTTPS is tunneled with `CONNECT`, so TLS stays end to end and needs no extra CA. Set any of these in `spec.env` to override them.
193
+ - **Only HTTP and HTTPS get out.** Tools that ignore the proxy variables, git over SSH, other TCP protocols, UDP and DNS lookups all fail, because there is no route out.
194
+ - The proxy only resolves names that could be allowed, so it cannot be used to leak data through DNS lookups. It refuses clients outside the sandbox's network and never connects to its own loopback.
195
+ - HTTPS is checked by the host name in the `CONNECT` request, not inside the encrypted tunnel. A client could still reach another site served from the same address as an allowed host (domain fronting), where that host's CDN permits it.
196
+ - `stop` and `wake` stop and start the proxy with the sandbox, and `destroy` removes the proxy and the network. `status` and `reconcile` report the sandbox as stopped if its proxy is not running, and the next access restarts it. `docker logs sandbox-<id>-egress` shows every allowed and refused connection.
197
+ - The proxy image is `DOCKER_EGRESS_PROXY_IMAGE` (`ghcr.io/schemavaults/sandbox-egress-proxy:<sdk version>`), built from [`images/egress-proxy`](images/egress-proxy/Dockerfile) and published with each release. To use your own build, run `docker build -f images/egress-proxy/Dockerfile -t my-proxy .` and pass `egressProxyImage: "my-proxy"`. The provider waits for the image's `HEALTHCHECK` before starting the sandbox.
198
+ - Each allowlisted sandbox uses one Docker network, stopped or running. Docker's default address pools hold about 30 networks; raise `default-address-pools` in `daemon.json` (for example `{ "base": "10.200.0.0/16", "size": 24 }`) to run more.
199
+
200
+ ### Vercel Sandbox
201
+
202
+ ```ts
203
+ new VercelSandboxProvider({
204
+ credentials: { token, teamId, projectId }, // omit to use VERCEL_OIDC_TOKEN
205
+ defaultImage: "vercel/sandbox/universal",
206
+ });
207
+ ```
208
+
209
+ - Sandboxes are created `persistent` and named after the SDK ID. `stop` snapshots the filesystem and `wake` restores it.
210
+ - `timeoutMs` becomes the native sandbox timeout, and `extendTimeout(id, ms)` extends it. The SDK detects when Vercel has stopped a sandbox and resumes it through its own `resume` flow, instead of letting `@vercel/sandbox` resume it silently without relaunching your main process.
211
+ - `spec.network` becomes the sandbox's native network policy. For rules `spec.network` cannot express, such as injecting an API key header into requests to one domain, pass a `@vercel/sandbox` `NetworkPolicy` as `providerOptions.vercel.networkPolicy` instead (`toVercelNetworkPolicy(network)` gives you a starting point). The two cannot be combined.
212
+
213
+ ### Writing a provider
214
+
215
+ Implement `SandboxProvider` (see `src/provider.ts`). For backends that can only "run a command", the exported `shellStartMainProcess`, `shellGetMainProcess` and `shellReadMainLog` implement the main-process and log bookkeeping on top of `exec`.
216
+
217
+ If your backend can restrict network access, list what it enforces in `networkPolicies` (`"deny-all"`, `"allowlist"`) and apply `spec.network` in `create`. A provider without `networkPolicies` is only given sandboxes with unrestricted access. `networkPolicyKind` and `normalizeCidr` help with the mapping.
218
+
219
+ ## Stores
220
+
221
+ ### Postgres via `@schemavaults/dbh` / Kysely
222
+
223
+ Add the migration to your dbh migrations directory:
224
+
225
+ ```ts
226
+ // src/db/migrations/00012-create-sandboxes.ts
227
+ export { up, down } from "@schemavaults/sandbox-management-sdk/dbh/migration";
228
+ ```
229
+
230
+ For a different table name, use `createSandboxesMigration("my_sandboxes")` and `new DbhSandboxStore({ db, tableName: "my_sandboxes" })`. The store takes a dbh handler or any Kysely instance using the Postgres dialect. It never opens or closes connections itself, so the `postgres-neon-proxy` adapter works unchanged.
231
+
232
+ ### Browser storage (`localStorage` / `sessionStorage`)
233
+
234
+ For an app with no database, such as a demo or a local-first tool, keep records in the browser. The core has no Node.js dependencies, so `SandboxManager` runs in the page too:
235
+
236
+ ```ts
237
+ import { WebStorageSandboxStore } from "@schemavaults/sandbox-management-sdk/web-storage";
238
+
239
+ const store = new WebStorageSandboxStore(); // localStorage
240
+ const perTab = new WebStorageSandboxStore({ storage: sessionStorage, prefix: "my-app:sandboxes:" });
241
+ ```
242
+
243
+ Each record is one JSON entry under `prefix + id` (default prefix `sandbox-sdk:sandboxes:`), with dates as ISO strings, so you can inspect it in the browser's dev tools. `update` reads and writes synchronously, which makes it an atomic compare-and-set within one page. Browsers do not guarantee that across tabs sharing `localStorage`, so give tabs that write the same records their own `prefix` or `ownerId`, or use `sessionStorage`. The store also accepts any object with the Web Storage API (`length`, `key`, `getItem`, `setItem`, `removeItem`). Storage quotas are around 5 MB per origin.
244
+
245
+ ### Your own store
246
+
247
+ Implement `SandboxStore` (`insert`, `get`, `list`, `update`, `delete`). `update` must be an atomic compare-and-set on `version`. Run the shared contract suite in `tests/contracts/store-contract.ts` against your store to check it.
248
+
249
+ ## Testing your app
250
+
251
+ ```ts
252
+ import { MockSandboxProvider, InMemorySandboxStore } from "@schemavaults/sandbox-management-sdk/testing";
253
+
254
+ const provider = new MockSandboxProvider({
255
+ execHandler: ({ command }) => (command.args.includes("npm test") ? { exitCode: 1, stderr: "1 failing" } : undefined),
256
+ });
257
+ const sandboxes = new SandboxManager({ providers: [provider], store: new InMemorySandboxStore() });
258
+
259
+ // …exercise your code, then assert on what happened:
260
+ provider.callsFor(id, "exec");
261
+ provider.simulateExternalStop(id); // e.g. a provider-side timeout
262
+ provider.appendMainLog(id, "server listening\n");
263
+ ```
264
+
265
+ The mock executes nothing. It records every call, keeps files in memory, and simulates stop, wake and external stops. It accepts every `spec.network` policy; pass `networkPolicies` to simulate a provider that enforces fewer.
266
+
267
+ ## Development
268
+
269
+ ```bash
270
+ bun install
271
+ bun run lint && bun run typecheck && bun run build
272
+ bun run test:unit # no external services needed
273
+
274
+ bun run test:e2e:fixture-image && bun run test:e2e:egress-proxy-image && bun run test:e2e:docker # needs a Docker daemon
275
+ bun run test:e2e:dbh # needs Postgres (POSTGRES_* env vars)
276
+ node ./tests/smoke/node-smoke.mjs # built package under Node (after build)
277
+ ```
278
+
279
+ CI (`.github/workflows/ci.yml`) runs lint, typecheck, build, the unit tests and a Node 24 smoke test. It also builds the egress proxy image and runs the Docker e2e suite against the runner's Docker daemon, including allowlists enforced end to end, and the store suite against a Postgres service container. The Vercel adapter is tested against a fake `@vercel/sandbox` client, with no live API calls.
280
+
281
+ ### Website
282
+
283
+ The landing page in `website/` is a static Next.js export built with [`@schemavaults/ui`](https://github.com/schemavaults/ui), with its own `package.json` and lockfile. Its hero runs an agent demo on the SDK's own build, so build the SDK first:
284
+
285
+ ```bash
286
+ bun install && bun run build # the SDK, into dist/
287
+ cd website && bun install
288
+ bun run dev # http://localhost:3000
289
+ bun run build # static export in website/out
290
+ ```
291
+
292
+ `.github/workflows/pages.yml` builds it on pull requests that touch it and deploys it to GitHub Pages on every push to `main` (set **Settings → Pages → Source** to **GitHub Actions** once). Its code samples live in `website/src/content/snippets.ts`; keep them in step with this README. See [`website/README.md`](website/README.md) for details.
293
+
294
+ ### Releasing
295
+
296
+ Bump `version` in `package.json` and the tag in `DOCKER_EGRESS_PROXY_IMAGE` (`src/docker/egress.ts`; a unit test checks they match), then merge to `main`. On every push to `main`, `.github/workflows/publish.yml` checks whether that version is on npm, and stops there if it is. If it isn't, it pushes the egress proxy image to `ghcr.io/<owner>/sandbox-egress-proxy:<version>` for amd64 and arm64, publishes to npm with [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC, no token), then tags the commit `v<version>` and creates a GitHub release with generated notes. If a run fails partway, re-run its failed jobs; it skips an image that is already pushed and a GitHub release that already exists. The first time, make the GHCR package public (**Package settings → Change visibility**) so Docker hosts can pull it.
@@ -0,0 +1,16 @@
1
+ import type { CommandInput, GitSource, NormalizedCommand, SandboxSpec } from "./types.js";
2
+ /** Resolve `path` against `workdir` unless it is already absolute. */
3
+ export declare function resolveSandboxPath(workdir: string, path: string): string;
4
+ export declare function normalizeCommand(input: CommandInput, workdir: string): NormalizedCommand;
5
+ /** Quote a string for POSIX `sh`. */
6
+ export declare function shellQuote(value: string): string;
7
+ /** Render a normalized command as a single `sh` command line (cd + env + argv). */
8
+ export declare function toShellCommandLine(command: NormalizedCommand): string;
9
+ /** Throws a `SandboxError` describing the first problem found in `spec`. */
10
+ export declare function validateSpec(spec: SandboxSpec): void;
11
+ /**
12
+ * The command that clones `git` into `workdir`. Credentials travel as a
13
+ * one-off `http.extraHeader` via `GIT_CONFIG_*` env vars, so they are never
14
+ * written to `.git/config`.
15
+ */
16
+ export declare function gitCloneCommand(git: GitSource, workdir: string): NormalizedCommand;
@@ -0,0 +1,99 @@
1
+ import { SandboxError } from "./errors.js";
2
+ import { encodeBase64 } from "./internal/base64.js";
3
+ import * as posix from "./internal/posix-path.js";
4
+ import { validateNetworkPolicy } from "./network.js";
5
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
6
+ /** Resolve `path` against `workdir` unless it is already absolute. */
7
+ export function resolveSandboxPath(workdir, path) {
8
+ return posix.isAbsolute(path) ? posix.normalize(path) : posix.join(workdir, path);
9
+ }
10
+ export function normalizeCommand(input, workdir) {
11
+ if (typeof input === "string") {
12
+ return { cmd: "sh", args: ["-c", input], cwd: workdir, env: {} };
13
+ }
14
+ return {
15
+ cmd: input.cmd,
16
+ args: input.args ?? [],
17
+ cwd: input.cwd ? resolveSandboxPath(workdir, input.cwd) : workdir,
18
+ env: input.env ?? {},
19
+ };
20
+ }
21
+ /** Quote a string for POSIX `sh`. */
22
+ export function shellQuote(value) {
23
+ return `'${value.replaceAll("'", `'\\''`)}'`;
24
+ }
25
+ /** Render a normalized command as a single `sh` command line (cd + env + argv). */
26
+ export function toShellCommandLine(command) {
27
+ const env = Object.entries(command.env).map(([key, value]) => {
28
+ assertEnvName(key);
29
+ return `${key}=${shellQuote(value)}`;
30
+ });
31
+ const argv = [command.cmd, ...command.args].map(shellQuote);
32
+ const run = env.length > 0 ? ["env", ...env, ...argv] : argv;
33
+ return `cd ${shellQuote(command.cwd)} && ${run.join(" ")}`;
34
+ }
35
+ function assertEnvName(name) {
36
+ if (!ENV_NAME.test(name)) {
37
+ throw new SandboxError(`Invalid environment variable name "${name}"`);
38
+ }
39
+ }
40
+ /** Throws a `SandboxError` describing the first problem found in `spec`. */
41
+ export function validateSpec(spec) {
42
+ for (const key of Object.keys(spec.env ?? {}))
43
+ assertEnvName(key);
44
+ for (const port of spec.ports ?? []) {
45
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
46
+ throw new SandboxError(`Invalid port ${port}`);
47
+ }
48
+ }
49
+ if (spec.timeoutMs !== undefined && !(spec.timeoutMs > 0)) {
50
+ throw new SandboxError(`timeoutMs must be a positive number`);
51
+ }
52
+ if (spec.workdir !== undefined && !posix.isAbsolute(spec.workdir)) {
53
+ throw new SandboxError(`workdir must be an absolute path`);
54
+ }
55
+ if (spec.git) {
56
+ if (!spec.git.url)
57
+ throw new SandboxError(`git.url is required`);
58
+ if (spec.git.depth !== undefined && !(Number.isInteger(spec.git.depth) && spec.git.depth > 0)) {
59
+ throw new SandboxError(`git.depth must be a positive integer`);
60
+ }
61
+ }
62
+ validateNetworkPolicy(spec.network);
63
+ }
64
+ const GIT_CLONE_SCRIPT = `set -e
65
+ mkdir -p "$SANDBOX_GIT_DIR"
66
+ cd "$SANDBOX_GIT_DIR"
67
+ if [ -z "$SANDBOX_GIT_REVISION" ]; then
68
+ git clone $SANDBOX_GIT_DEPTH -- "$SANDBOX_GIT_URL" .
69
+ elif git clone $SANDBOX_GIT_DEPTH --branch "$SANDBOX_GIT_REVISION" -- "$SANDBOX_GIT_URL" . 2>/dev/null; then
70
+ :
71
+ else
72
+ # Not a branch or tag: fetch the revision (e.g. a commit SHA) directly.
73
+ rm -rf .git
74
+ git init -q
75
+ git remote add origin "$SANDBOX_GIT_URL"
76
+ git fetch -q $SANDBOX_GIT_DEPTH origin "$SANDBOX_GIT_REVISION"
77
+ git checkout -q FETCH_HEAD
78
+ fi`;
79
+ /**
80
+ * The command that clones `git` into `workdir`. Credentials travel as a
81
+ * one-off `http.extraHeader` via `GIT_CONFIG_*` env vars, so they are never
82
+ * written to `.git/config`.
83
+ */
84
+ export function gitCloneCommand(git, workdir) {
85
+ const env = {
86
+ GIT_TERMINAL_PROMPT: "0",
87
+ SANDBOX_GIT_DIR: workdir,
88
+ SANDBOX_GIT_URL: git.url,
89
+ SANDBOX_GIT_REVISION: git.revision ?? "",
90
+ SANDBOX_GIT_DEPTH: git.depth ? `--depth=${git.depth}` : "",
91
+ };
92
+ if (git.auth) {
93
+ const basic = encodeBase64(new TextEncoder().encode(`${git.auth.username}:${git.auth.password}`));
94
+ env.GIT_CONFIG_COUNT = "1";
95
+ env.GIT_CONFIG_KEY_0 = "http.extraHeader";
96
+ env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${basic}`;
97
+ }
98
+ return { cmd: "sh", args: ["-c", GIT_CLONE_SCRIPT], cwd: "/", env };
99
+ }
@@ -0,0 +1,4 @@
1
+ export { DbhSandboxStore } from "./store.js";
2
+ export type { DbhSandboxStoreOptions, SandboxRow } from "./store.js";
3
+ export { DEFAULT_SANDBOXES_TABLE, createSandboxesMigration, down, up } from "./migration.js";
4
+ export type { SandboxesMigration } from "./migration.js";
@@ -0,0 +1,2 @@
1
+ export { DbhSandboxStore } from "./store.js";
2
+ export { DEFAULT_SANDBOXES_TABLE, createSandboxesMigration, down, up } from "./migration.js";
@@ -0,0 +1,18 @@
1
+ import type { Kysely } from "kysely";
2
+ export declare const DEFAULT_SANDBOXES_TABLE = "sandboxes";
3
+ export interface SandboxesMigration {
4
+ up(db: Kysely<any>): Promise<void>;
5
+ down(db: Kysely<any>): Promise<void>;
6
+ }
7
+ /** Kysely migration creating the sandboxes table (Postgres) under `tableName`. */
8
+ export declare function createSandboxesMigration(tableName?: string): SandboxesMigration;
9
+ /**
10
+ * Re-export from a file in your dbh migrations directory:
11
+ *
12
+ * ```ts
13
+ * // src/db/migrations/00012-create-sandboxes.ts
14
+ * export { up, down } from "@schemavaults/sandbox-management-sdk/dbh/migration";
15
+ * ```
16
+ */
17
+ export declare const up: (db: Kysely<any>) => Promise<void>;
18
+ export declare const down: (db: Kysely<any>) => Promise<void>;
@@ -0,0 +1,41 @@
1
+ export const DEFAULT_SANDBOXES_TABLE = "sandboxes";
2
+ /** Kysely migration creating the sandboxes table (Postgres) under `tableName`. */
3
+ export function createSandboxesMigration(tableName = DEFAULT_SANDBOXES_TABLE) {
4
+ return {
5
+ async up(db) {
6
+ await db.schema
7
+ .createTable(tableName)
8
+ .addColumn("id", "text", (col) => col.primaryKey())
9
+ .addColumn("provider", "text", (col) => col.notNull())
10
+ .addColumn("provider_ref", "text")
11
+ .addColumn("status", "text", (col) => col.notNull())
12
+ .addColumn("spec", "jsonb", (col) => col.notNull())
13
+ .addColumn("owner_id", "text")
14
+ .addColumn("labels", "jsonb", (col) => col.notNull().defaultTo("{}"))
15
+ .addColumn("metadata", "jsonb", (col) => col.notNull().defaultTo("{}"))
16
+ .addColumn("error", "text")
17
+ .addColumn("version", "integer", (col) => col.notNull().defaultTo(0))
18
+ .addColumn("created_at", "timestamptz", (col) => col.notNull())
19
+ .addColumn("updated_at", "timestamptz", (col) => col.notNull())
20
+ .addColumn("expires_at", "timestamptz")
21
+ .execute();
22
+ await db.schema.createIndex(`${tableName}_owner_id_idx`).on(tableName).column("owner_id").execute();
23
+ await db.schema.createIndex(`${tableName}_status_idx`).on(tableName).column("status").execute();
24
+ await db.schema.createIndex(`${tableName}_labels_idx`).on(tableName).using("gin").column("labels").execute();
25
+ },
26
+ async down(db) {
27
+ await db.schema.dropTable(tableName).ifExists().execute();
28
+ },
29
+ };
30
+ }
31
+ const migration = createSandboxesMigration();
32
+ /**
33
+ * Re-export from a file in your dbh migrations directory:
34
+ *
35
+ * ```ts
36
+ * // src/db/migrations/00012-create-sandboxes.ts
37
+ * export { up, down } from "@schemavaults/sandbox-management-sdk/dbh/migration";
38
+ * ```
39
+ */
40
+ export const up = migration.up;
41
+ export const down = migration.down;
@@ -0,0 +1,42 @@
1
+ import type { Kysely } from "kysely";
2
+ import type { SandboxListFilter, SandboxRecordPatch, SandboxStore } from "../store.js";
3
+ import type { SandboxRecord } from "../types.js";
4
+ /** Row shape of the sandboxes table. */
5
+ export interface SandboxRow {
6
+ id: string;
7
+ provider: string;
8
+ provider_ref: string | null;
9
+ status: string;
10
+ spec: unknown;
11
+ owner_id: string | null;
12
+ labels: unknown;
13
+ metadata: unknown;
14
+ error: string | null;
15
+ version: number;
16
+ created_at: Date | string;
17
+ updated_at: Date | string;
18
+ expires_at: Date | string | null;
19
+ }
20
+ export interface DbhSandboxStoreOptions {
21
+ /**
22
+ * Your app's Kysely instance (Postgres dialect), or a `@schemavaults/dbh`
23
+ * handler (anything with a `db` Kysely property). The store never opens or
24
+ * closes connections itself.
25
+ */
26
+ db: Kysely<any> | {
27
+ db: Kysely<any>;
28
+ };
29
+ /** Default `"sandboxes"`; must match the table name given to the migration. */
30
+ tableName?: string;
31
+ }
32
+ /** `SandboxStore` backed by a Postgres table, via Kysely / `@schemavaults/dbh`. */
33
+ export declare class DbhSandboxStore implements SandboxStore {
34
+ private readonly db;
35
+ private readonly table;
36
+ constructor(options: DbhSandboxStoreOptions);
37
+ insert(record: SandboxRecord): Promise<void>;
38
+ get(id: string): Promise<SandboxRecord | null>;
39
+ list(filter?: SandboxListFilter): Promise<SandboxRecord[]>;
40
+ update(id: string, expectedVersion: number, patch: SandboxRecordPatch): Promise<SandboxRecord | null>;
41
+ delete(id: string): Promise<boolean>;
42
+ }
@@ -0,0 +1,105 @@
1
+ import { DEFAULT_SANDBOXES_TABLE } from "./migration.js";
2
+ /** `SandboxStore` backed by a Postgres table, via Kysely / `@schemavaults/dbh`. */
3
+ export class DbhSandboxStore {
4
+ db;
5
+ table;
6
+ constructor(options) {
7
+ const db = "selectFrom" in options.db ? options.db : options.db.db;
8
+ this.db = db;
9
+ this.table = options.tableName ?? DEFAULT_SANDBOXES_TABLE;
10
+ }
11
+ async insert(record) {
12
+ await this.db
13
+ .insertInto(this.table)
14
+ .values({
15
+ id: record.id,
16
+ provider: record.provider,
17
+ provider_ref: record.providerRef,
18
+ status: record.status,
19
+ spec: JSON.stringify(record.spec),
20
+ owner_id: record.ownerId,
21
+ labels: JSON.stringify(record.labels),
22
+ metadata: JSON.stringify(record.metadata),
23
+ error: record.error,
24
+ version: record.version,
25
+ created_at: record.createdAt,
26
+ updated_at: record.updatedAt,
27
+ expires_at: record.expiresAt,
28
+ })
29
+ .execute();
30
+ }
31
+ async get(id) {
32
+ const row = await this.db.selectFrom(this.table).selectAll().where("id", "=", id).executeTakeFirst();
33
+ return row ? fromRow(row) : null;
34
+ }
35
+ async list(filter = {}) {
36
+ let query = this.db.selectFrom(this.table).selectAll();
37
+ if (filter.ownerId !== undefined)
38
+ query = query.where("owner_id", "=", filter.ownerId);
39
+ if (filter.provider !== undefined)
40
+ query = query.where("provider", "=", filter.provider);
41
+ if (filter.status !== undefined) {
42
+ const statuses = [].concat(filter.status);
43
+ if (statuses.length === 0)
44
+ return [];
45
+ query = query.where("status", "in", statuses);
46
+ }
47
+ else if (!filter.includeDestroyed) {
48
+ query = query.where("status", "!=", "destroyed");
49
+ }
50
+ if (filter.labels && Object.keys(filter.labels).length > 0) {
51
+ query = query.where("labels", "@>", JSON.stringify(filter.labels));
52
+ }
53
+ query = query.orderBy("created_at", "desc").orderBy("id", "desc");
54
+ if (filter.limit !== undefined)
55
+ query = query.limit(filter.limit);
56
+ if (filter.offset !== undefined)
57
+ query = query.offset(filter.offset);
58
+ const rows = await query.execute();
59
+ return rows.map(fromRow);
60
+ }
61
+ async update(id, expectedVersion, patch) {
62
+ const row = await this.db
63
+ .updateTable(this.table)
64
+ .set({
65
+ ...(patch.status !== undefined && { status: patch.status }),
66
+ ...(patch.providerRef !== undefined && { provider_ref: patch.providerRef }),
67
+ ...(patch.error !== undefined && { error: patch.error }),
68
+ ...(patch.expiresAt !== undefined && { expires_at: patch.expiresAt }),
69
+ ...(patch.labels !== undefined && { labels: JSON.stringify(patch.labels) }),
70
+ ...(patch.metadata !== undefined && { metadata: JSON.stringify(patch.metadata) }),
71
+ version: expectedVersion + 1,
72
+ updated_at: new Date(),
73
+ })
74
+ .where("id", "=", id)
75
+ .where("version", "=", expectedVersion)
76
+ .returningAll()
77
+ .executeTakeFirst();
78
+ return row ? fromRow(row) : null;
79
+ }
80
+ async delete(id) {
81
+ const result = await this.db.deleteFrom(this.table).where("id", "=", id).executeTakeFirst();
82
+ return Number(result.numDeletedRows) > 0;
83
+ }
84
+ }
85
+ function fromRow(row) {
86
+ return {
87
+ id: row.id,
88
+ provider: row.provider,
89
+ providerRef: row.provider_ref,
90
+ status: row.status,
91
+ spec: parseJson(row.spec),
92
+ ownerId: row.owner_id,
93
+ labels: parseJson(row.labels),
94
+ metadata: parseJson(row.metadata),
95
+ error: row.error,
96
+ version: Number(row.version),
97
+ createdAt: new Date(row.created_at),
98
+ updatedAt: new Date(row.updated_at),
99
+ expiresAt: row.expires_at === null ? null : new Date(row.expires_at),
100
+ };
101
+ }
102
+ /** jsonb comes back parsed from `pg`, but as text from some drivers. */
103
+ function parseJson(value) {
104
+ return typeof value === "string" ? JSON.parse(value) : value;
105
+ }