Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c1ba408
feat(ai-sandbox-render): add Render sandbox provider
R4ph-t Oct 28, 2026
247b1be
test(ai-sandbox-render): add unit tests
R4ph-t Oct 28, 2026
adca89b
test(ai-sandbox-render): add live tests and journal conformance
R4ph-t Oct 28, 2026
efe1728
docs(sandbox): document the Render provider
R4ph-t Oct 28, 2026
ba9d20d
docs(ai-sandbox-render): add package README and changeset
R4ph-t Oct 28, 2026
40dd3d3
test(e2e): check that an aborted Render create terminates the sandbox
R4ph-t Oct 28, 2026
f715762
fix(ai-sandbox-render): keep env values out of the exec command string
R4ph-t Oct 28, 2026
4881fba
fix(ai-sandbox-render): resume returns null when the sandbox ends mid…
R4ph-t Oct 28, 2026
0d10986
fix(ai-sandbox-render): keep snapshot names within Render's 64-charac…
R4ph-t Oct 28, 2026
cc78232
fix(ai-sandbox-render): never start a command killed during its env u…
R4ph-t Oct 28, 2026
e7174a8
fix(ai-sandbox-render): keep the env file out of snapshots
R4ph-t Oct 28, 2026
cd1fca3
fix(ai-sandbox-render): reject token or ownerId alongside a pre-built…
R4ph-t Oct 28, 2026
a6dbdb1
fix(ai-sandbox-render): aborted fs calls reject
R4ph-t Oct 28, 2026
719fa95
fix(ai-sandbox-render): reject read-only env names, cap output cleanl…
R4ph-t Oct 28, 2026
5963196
fix(ai-sandbox-render): serialize the env overlay with snapshots and …
R4ph-t Oct 28, 2026
3daffab
fix(ai-sandbox-render): secure process cleanup and snapshot secrets
R4ph-t Oct 28, 2026
d817eed
test(ai-sandbox-render): clean up live-test snapshots and sandboxes
R4ph-t Oct 29, 2026
2c328bb
test(ai-sandbox-render): delete the live-test snapshot even if destro…
R4ph-t Oct 29, 2026
421c380
fix(ai-sandbox-render): keep env files in memory-backed /dev
R4ph-t Oct 29, 2026
becdf00
test(ai-sandbox-render): cover the /dev overlay and both snapshot kinds
R4ph-t Oct 29, 2026
6d946ad
test(ai-sandbox-render): check live that filesystem snapshots leave o…
R4ph-t Oct 29, 2026
82dc6be
docs(ai-sandbox-render): document the /dev env overlay and runtime sn…
R4ph-t Oct 29, 2026
f5ab2ab
test(ai-sandbox-render): load the live suite without credentials
R4ph-t Oct 29, 2026
fd97c1b
fix(ai-sandbox-render): forward the kill signal to the process group
R4ph-t Oct 29, 2026
3257981
fix(ai-sandbox-render): cap captured exec output in UTF-8 bytes
R4ph-t Oct 29, 2026
806b34b
test(ai-sandbox-render): look up the sandbox group before snapshotting
R4ph-t Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agent/domain-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"packages/ai-sandbox-e2b",
"packages/ai-sandbox-local-process",
"packages/ai-sandbox-railway",
"packages/ai-sandbox-render",
"packages/ai-sandbox-sprites",
"packages/ai-sandbox-upstash-box",
"packages/ai-sandbox-vercel"
Expand Down
17 changes: 17 additions & 0 deletions .changeset/add-ai-sandbox-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@tanstack/ai-sandbox-render': minor
---

Add `@tanstack/ai-sandbox-render`, a sandbox provider backed by
[Render](https://render.com/docs/sandboxes) sandboxes through `@renderinc/sdk`.
It implements the `SandboxProvider` / `SandboxHandle` contract: native file
transfer for reads and writes, shell-backed `list`, `mkdir`, `remove`,
`rename`, `exists` and a non-following `lstat`, commands with separate stdout
and stderr, background processes, a shared env overlay, filesystem or
runtime snapshots, resume by id, and an enforced `deny-all` network policy.

`killableProcesses` is `true` and measured against a real sandbox: every
command records its process group, and `kill()` or an aborted `signal` sends
the requested signal (`TERM` by default) to the group from a second command,
then `KILL` after 2 s. `ports`,
`writableStdin` and `fork` are `false`.
43 changes: 42 additions & 1 deletion docs/sandbox/providers-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,10 +380,50 @@ export function provider(env: SandboxAgentEnv) {

The agent coordinator, durable runs, live previews through a quick tunnel, callback hosts, and the Durable Object run log are on [Cloudflare](./cloudflare) and [Cloudflare Edge Details](./cloudflare-advanced).

## Render

```ts
import { renderSandbox } from '@tanstack/ai-sandbox-render'

const render = renderSandbox({
plan: '2c-4g',
idleTimeoutSeconds: 900,
// Optional: when the policy allows network, allow only these hosts.
networkPolicy: {
type: 'allow-list',
rules: [{ domain: 'registry.npmjs.org' }, { domain: 'api.anthropic.com' }],
},
})
```

- **Isolation:** a managed [Render](https://render.com/docs/sandboxes) sandbox. It boots from the base image (Debian 12, `root` user), a custom image (`buildName`), or a named snapshot (`snapshotName`).
- **Auth:** the SDK reads `RENDER_API_KEY` and `RENDER_WORKSPACE_ID`. Pass `token` and `ownerId` to set them in code. Harness credentials are [workspace secrets](./provisioning). The provider uploads them to a shared file in `/dev/tsai` that only `root` can read. `/dev` is held in memory, so the secrets never reach the sandbox disk. Reconnecting with fresh secrets replaces that file and removes older overlays. No value goes into a command string.
- **Lifetime:** Render suspends an idle sandbox after `idleTimeoutSeconds` (default 900) and keeps its disk, so `durableFilesystem` is `true`. The next command or file call resumes it. After `timeoutSeconds` (default 86400), Render terminates it.
- **Network:** `policy.capabilities.network: 'deny'` sends `deny-all`, and Render enforces it. Otherwise the provider sends `networkPolicy`, or allows all egress.
- **Not available:** inbound ports, stdin for a spawned process, and fork. `ports.connect` throws. Harnesses that need a port or stdin, such as OpenCode and ACP harnesses, do not run on Render.

Snapshots and resume on Render:

Take snapshots while commands are stopped. A runtime snapshot also captures memory, so the provider removes the env file for the capture and writes it back after. When multiple orchestrator processes share a sandbox, use your lifecycle lock to serialize runtime snapshots and secret updates.

- `snapshot(label)` captures a `filesystem` snapshot named `tsai-<label>`, and waits until it is available. Set `snapshotKind: 'runtime'` to keep memory and processes too. A runtime snapshot restores only onto the same `plan`.
- Render restores a snapshot only inside its sandbox group, so keep the same `sandboxGroupId` between capture and restore. `restoreSnapshot()` applies env and the network policy again. Filesystem snapshots leave out `/dev`, so they never contain the env file. Runtime snapshots retain the environment of existing processes.
- The contract has no delete hook. Delete `tsai-*` snapshots that you do not use with `sandboxes.snapshots.delete()` from `@renderinc/sdk`.
- Resume by id reads the sandbox status. A missing, `errored`, or `terminated` sandbox resumes as `null`. Auth and server errors are thrown again.
- Render makes its own `sbx-` ids, so `create` ignores `input.id`. Keep the returned id in your instance store.

Processes on Render:

- Each command runs through `bash -c` in `/workspace`. Set `workdir` to change it. stdout and stderr stay separate, and `spawn()` reports `pid: -1`.
- Each command has its own process group, recorded in `/tmp/tsai`. `kill(signal)` sends the requested signal and abort sends `TERM`, then `KILL` after 2 s if the group is still alive. Signals other than `TERM`, `INT`, `HUP`, `QUIT` and `KILL` become `KILL`. A cancellation marker prevents delayed commands from starting.
- A supervising shell stops remaining children before it reports exit. A kill finishes on the confirmed remote exit. An aborted `exec` resolves with the exit code of the signal.
- If the host disconnects, the command keeps running in the sandbox. The [run journal](./journal) owns run identity, so a new host reads the output from the journal.
- `read`, `readBytes`, and `write` use native file transfer. Each other file call is one command, about 0.6 s. Render allows 1,000 commands per minute for each user.

## `writableStdin` per provider

- `true`: local process, Docker container, Daytona, Upstash Box, E2B, boxd, and Railway.
- `false`: Docker Sandboxes (`sbx`), Vercel, Sprites, Blaxel, and Cloudflare.
- `false`: Docker Sandboxes (`sbx`), Vercel, Sprites, Blaxel, Cloudflare, and Render.

When the value is `false`, a harness that reads its prompt from stdin gets the prompt from a file. The shell redirects that file into the process.

Expand Down Expand Up @@ -412,6 +452,7 @@ If a provider cannot be measured yet, it stays `false`. A `false` value only mak
| Cloudflare | `false` | `kill()` does nothing. The `AbortSignal` of the caller reaches neither `exec` nor `spawn`, because Workers RPC cannot serialize one. |
| boxd | `true` | **Measured.** The spawn wrapper runs under `setsid`, so the recorded pid leads its own process group. `kill()` runs a shell inside the machine that signals that group, then sends `KILL`, then checks with `kill -0`. Closing the stream alone is not a kill: the process survived it. Verified against production: a spawned `sleep 5 && touch <marker>` was killed, and the marker never appeared. Needs `BOXD_API_KEY`. |
| Railway | `true` | **Measured.** `kill()` and an aborted `exec` send `TERM` to the process group, then `KILL` after 2 s. An aborted `spawn` sends `KILL` at once. Each one finishes only when the remote exit is confirmed. Verified against production: the shared journal conformance kill case passes. Spawned `sleep 5 && touch <marker>` commands (one with a background child) were killed before the marker appeared, for `kill()`, an aborted `exec`, and an aborted `spawn`. Needs `RAILWAY_API_TOKEN` or `RAILWAY_TOKEN` and `RAILWAY_ENVIRONMENT_ID`. |
| Render | `true` | **Measured.** Each command records its process group. `kill()` sends the requested signal (`TERM` by default) to the group from a second command, then `KILL` after 2 s. A closed exec stream does not stop the process (measured), so every abort sends the kill. The shared journal conformance kill case passes against a real sandbox. Needs `RENDER_API_KEY` and `RENDER_WORKSPACE_ID`. |

Each remote provider registers the shared journal conformance suite, so you can test each claim:

Expand Down
8 changes: 6 additions & 2 deletions docs/sandbox/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Providers
id: providers
order: 3
description: "Pick where a TanStack AI sandbox runs (local process, Docker container, Docker Sandboxes microVM, Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, Railway, or Cloudflare), install it, and configure it."
description: "Pick where a TanStack AI sandbox runs (local process, Docker container, Docker Sandboxes microVM, Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, Railway, Cloudflare, or Render), install it, and configure it."
---

A provider is where the sandbox runs: a local process for a fast loop, or an isolated VM in production.
Expand All @@ -27,6 +27,7 @@ A provider is that place. Every provider has the same contract, so your [workspa
| [boxd](https://boxd.sh) | `@tanstack/ai-sandbox-boxd` | microVM | `BOXD_API_KEY` and `BOXD_ORG`. |
| [Railway](https://railway.com) | `@tanstack/ai-sandbox-railway` | microVM | `RAILWAY_API_TOKEN` or `RAILWAY_TOKEN`, and an environment. |
| [Cloudflare](./cloudflare) | `@tanstack/ai-sandbox-cloudflare` | Container | A `Sandbox` Durable Object binding in a Worker. |
| [Render](https://render.com/docs/sandboxes) | `@tanstack/ai-sandbox-render` | Cloud sandbox | `RENDER_API_KEY` and `RENDER_WORKSPACE_ID` |

2. Install its package. This example installs the Docker provider. Use the package from your row.

Expand Down Expand Up @@ -82,6 +83,7 @@ import { blaxelSandbox } from '@tanstack/ai-sandbox-blaxel'
import { e2bSandbox } from '@tanstack/ai-sandbox-e2b'
import { boxdSandbox } from '@tanstack/ai-sandbox-boxd'
import { railwaySandbox } from '@tanstack/ai-sandbox-railway'
import { renderSandbox } from '@tanstack/ai-sandbox-render'

const dev = localProcessSandbox() // runs on your host
const isolated = dockerSandbox({ image: 'node:22' }) // container
Expand All @@ -94,6 +96,7 @@ const blaxel = blaxelSandbox() // uses the API key or the CLI login
const e2b = e2bSandbox() // reads E2B_API_KEY
const boxd = boxdSandbox({ org: 'acme' }) // reads BOXD_API_KEY
const railway = railwaySandbox() // reads RAILWAY_TOKEN and RAILWAY_ENVIRONMENT_ID
const render = renderSandbox() // reads RENDER_API_KEY and RENDER_WORKSPACE_ID
```

`dockerSandbox()` and `sbxSandbox()` come from the same package. Every other provider has its own package.
Expand All @@ -108,7 +111,7 @@ const railway = railwaySandbox() // reads RAILWAY_TOKEN and RAILWAY_ENVIRONMENT_
- Provider-native snapshots and resume keep or recreate the provider state. They make bootstrap faster. [Portable Snapshots](./portable-snapshots) store finished files in your own persistence.

> [!NOTE]
> Cloud providers (Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, and Railway) run remotely. When you drive them from your laptop, [bridged tools](./tools) cannot reach your `localhost`. Use the ngrok bridge tunnel from the [tools guide](./tools). Cloudflare is different: the Worker that owns the sandbox also runs the bridge, so there is no laptop in the path. See [Cloudflare](./cloudflare).
> Cloud providers (Daytona, Vercel, Sprites, Upstash Box, Blaxel, E2B, boxd, Railway, and Render) run remotely. When you drive them from your laptop, [bridged tools](./tools) cannot reach your `localhost`. Use the ngrok bridge tunnel from the [tools guide](./tools). Cloudflare is different: the Worker that owns the sandbox also runs the bridge, so there is no laptop in the path. See [Cloudflare](./cloudflare).

## Capabilities

Expand Down Expand Up @@ -162,6 +165,7 @@ The [run journal](./journal) reads this flag to decide how to read a run's outpu
| Cloudflare | `false` |
| boxd | `true` |
| Railway | `true` |
| Render | `true` |

A bring-your-own provider must set this flag too. For the reason behind each value, see [Providers (Advanced)](./providers-advanced#killableprocesses-per-provider).

Expand Down
121 changes: 121 additions & 0 deletions packages/ai-sandbox-render/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<div align="center">
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://tanstack.com/api/readme/ai.png?theme=dark"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://tanstack.com/api/readme/ai.png"
/>
<img
src="https://tanstack.com/api/readme/ai.png"
alt="TanStack AI"
width="900"
/>
</picture>
</div>

<br />

# @tanstack/ai-sandbox-render

Render sandbox provider for [TanStack AI](https://tanstack.com/ai). Runs
harness adapters inside [Render](https://render.com/docs/sandboxes) sandboxes
through the uniform `SandboxHandle`: real filesystem, shell, background
processes, snapshots, and an enforced egress policy.

## Install

```bash
npm install @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-render
```

Set `RENDER_API_KEY` and `RENDER_WORKSPACE_ID` (your `tea-` workspace id).

## Usage

```ts
import {
defineSandbox,
defineWorkspace,
withSandbox,
} from '@tanstack/ai-sandbox'
import { renderSandbox } from '@tanstack/ai-sandbox-render'

const sandbox = defineSandbox({
id: 'agent',
provider: renderSandbox({ plan: '2c-4g', idleTimeoutSeconds: 900 }),
workspace: defineWorkspace({/* source, setup, secrets */}),
})

// Then pass `withSandbox(sandbox)` as chat() middleware.
```

Use the provider directly through the `SandboxHandle`:

```ts
import { renderSandbox } from '@tanstack/ai-sandbox-render'

const provider = renderSandbox()
const sbx = await provider.create({})
try {
await sbx.fs.write('/workspace/hello.txt', 'hello from render')
console.log(await sbx.fs.read('/workspace/hello.txt'))

const run = await sbx.process.exec('node --version')
console.log('node', run.stdout.trim(), 'exit', run.exitCode)

const ref = await sbx.snapshot!('after-setup')
const copy = await provider.restoreSnapshot!({ snapshotId: ref.id })
await copy.destroy()
} finally {
await sbx.destroy()
}
```

## Configuration

| Option | Default | Notes |
| -------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------- |
| `token` | `RENDER_API_KEY` | Render API key. |
| `ownerId` | `RENDER_WORKSPACE_ID` | Workspace id (`tea-...`). |
| `client` | none | A pre-built `SandboxesClient` from `@renderinc/sdk`. Set `token` and `ownerId` on it; passing them too throws. |
| `sandboxGroupId` | workspace default | Snapshots restore only inside the group they were captured in. |
| `plan` | `1c-2g` | `1c-2g`, `2c-4g`, `4c-8g`, `8c-16g`, or a newer plan. |
| `timeoutSeconds` | `86400` | Maximum continuous runtime. Render terminates the sandbox after it. |
| `idleTimeoutSeconds` | `900` | Render suspends an idle sandbox and keeps its disk. `0` turns off idle suspension. |
| `networkPolicy` | `allow-all` | Egress when the policy allows network, for example an `allow-list`. A `deny` policy sends `deny-all`. |
| `buildName` | none | Boot every new sandbox from this Render sandbox build (a custom image). |
| `snapshotName` | none | Boot every new sandbox from this named snapshot. |
| `snapshotKind` | `filesystem` | What `snapshot()` captures. `runtime` also keeps memory and processes, and needs the same `plan`. |
| `workdir` | `/workspace` | Where the `/workspace` virtual root maps. Created at boot. |

Render makes its own `sbx-` ids, so `create` ignores the id that `ensure()`
passes. Keep the returned id in your instance store.

## Capabilities

| Capability | Supported | Why |
| --------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fs` | yes | `read`, `readBytes`, and `write` use native file transfer. Other calls run one shell command each. `lstat` is a GNU `stat` probe that does not follow symlinks. |
| `exec` | yes | Separate `stdout` and `stderr`. An aborted `exec` resolves with the exit code of the signal. |
| `env` | yes | Create-time env plus a shared root-only overlay in memory-backed `/dev`, replaced when secrets are reapplied. No value goes into a command string. |
| `backgroundProcesses` | yes | `spawn()` streams output. `pid` is `-1`. A command keeps running if the host disconnects. |
| `killableProcesses` | yes | A supervisor owns each command group and stops remaining children. `kill(signal)` sends the requested signal (`TERM` by default), then `KILL` after 2 s, and prevents delayed commands from starting. |
| `snapshots` | yes | `filesystem` by default, `runtime` with `snapshotKind`. Named `tsai-<label>`. |
| `networkPolicy` | yes | `deny-all`, `allow-all`, and `allow-list` are enforced by Render. |
| `durableFilesystem` | yes | An idle sandbox suspends with its disk and resumes on the next command or file call. |
| `ports` | no | Render sandboxes have no inbound ports. |
| `writableStdin` | no | Render exec has no stdin. Harnesses get their prompt from a file. |
| `fork` | no | Render has no native fork. |

## Known limits

- **No ports and no stdin.** Harnesses that need either one, such as OpenCode
and ACP harnesses, do not run on Render. Claude Code and Codex run.
- **Snapshots are not deleted for you.** Delete `tsai-*` snapshots that you do
not use with `sandboxes.snapshots.delete()` from `@renderinc/sdk`.
- **Live tests need credentials.** `tests/render.test.ts` and the journal
conformance suite run only with `RENDER_API_KEY` and `RENDER_WORKSPACE_ID`.
Without them they report a named skip.
Loading