Skip to content

Commit 831556a

Browse files
committed
fix: make default Daytona + Grok/Codex sandbox runs work (#1081 #1083 #1084 #1085)
Default defineSandbox + gitSkill + grokBuildText/codexText + withSandbox works on Daytona without empty commands.deny, create wrappers, hardcoded workdir, or hand skill symlinks. Resume starts stopped sandboxes. Secrets stay off create-time envVars and command strings. Spawn uses session log streams and stdin. Native fs/git remap /workspace.
1 parent 79d8812 commit 831556a

44 files changed

Lines changed: 1796 additions & 278 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@tanstack/ai-sandbox': patch
3+
'@tanstack/ai-sandbox-daytona': patch
4+
'@tanstack/ai-grok-build': patch
5+
'@tanstack/ai-codex': patch
6+
'@tanstack/ai-acp': patch
7+
'@tanstack/ai-claude-code': patch
8+
---
9+
10+
fix: make default Daytona + Grok/Codex sandbox runs work without extra wrappers
11+
12+
Headless Grok and Codex stay permissive when policy is deny-only plus default allow. Daytona remaps `/workspace`, starts stopped sandboxes on resume, keeps secrets out of create-time envVars and command strings, and uses native fs/git plus session stdin. Nested skills project by name. Durable Grok journals when durability is wired.

docs/adapters/codex.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const stream = chat({
5353
| Option | Description |
5454
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
5555
| `cwd` | Working directory for the harness session. Defaults to `process.cwd()`. |
56-
| `sandboxMode` | Codex sandbox: `'read-only'` (harness default), `'workspace-write'`, or `'danger-full-access'`. This is the safety boundary on a server. |
56+
| `sandboxMode` | Codex sandbox: `'read-only'`, `'workspace-write'`, or `'danger-full-access'`. Default is `'workspace-write'` on local-process and Docker. Default is `'danger-full-access'` on Daytona and Cloudflare, because those providers cannot create a nested bubblewrap namespace. Isolation is then the outer VM plus `defineSandboxPolicy`. |
5757
| `approvalPolicy` | Codex approval policy. Defaults to `'never'` — headless runs have no approval UI, so anything else can stall a turn. |
5858
| `modelReasoningEffort` | `'minimal'` \| `'low'` \| `'medium'` \| `'high'` \| `'xhigh'`. |
5959
| `skipGitRepoCheck` | Skip the harness's git-repo safety check. Defaults to `true` (server adapters routinely point at scratch directories). |

docs/adapters/grok-build.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ Adapter config (second argument to `grokBuildText`):
105105
| `grokExecutable` | Path/name of the `grok` executable inside the sandbox. Defaults to `grok`. |
106106
| `env` | Extra environment variables for the `grok` process inside the sandbox. |
107107
| `emitDiff` | Emit a `file.changed` CUSTOM event with the working-tree `git diff` after the run. Defaults to `true`. |
108+
| `protocol` | Harness wire protocol: `'acp'` or `'streaming-json'`. Defaults to `'acp'`. A durable sandbox run with no protocol set uses `'streaming-json'` so the run can journal. |
108109
| `extraArgs` | Extra raw CLI flags appended verbatim (advanced). |
109110

110111
Per-call overrides go through `modelOptions`:
@@ -114,6 +115,7 @@ Per-call overrides go through `modelOptions`:
114115
| `sessionId` | Resume an existing Grok Build session (see below). |
115116
| `cwd` | Per-call override of the harness working directory. |
116117
| `maxTurns` | Per-call cap on the number of harness turns. |
118+
| `protocol` | Per-call override of the harness wire protocol. |
117119

118120
## Stateful Sessions
119121

@@ -166,6 +168,23 @@ tools inside a live process and can't pause across an HTTP round-trip. A tool
166168
without a server `execute()` (or marked `needsApproval`) fails fast; run those
167169
with a regular provider adapter.
168170

171+
## Durable runs
172+
173+
A durable sandbox run (you pass `runs` and `durability` to `withSandbox`)
174+
journals the agent output so a later request can take the run over. ACP does
175+
not journal. When durability is wired and you do not set `protocol`, the
176+
adapter uses `'streaming-json'`.
177+
178+
Set `protocol: 'acp'` only when you want ACP on that call. A fresh ACP run
179+
with durability wired logs a warning. An attach against ACP throws
180+
`DurableAttachNotSupportedError`.
181+
182+
## Headless NDJSON flags
183+
184+
On the `'streaming-json'` path with auto-approve, the adapter adds
185+
`--always-approve --no-plan --no-auto-update`. Those flags keep Plan Mode and
186+
the CLI update check from blocking a headless run.
187+
169188
## Limitations
170189

171190
- **Requires a sandbox.** Always run it under `withSandbox(...)`; see the

docs/config.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,7 @@
521521
"label": "Providers",
522522
"to": "sandbox/providers",
523523
"addedAt": "2026-06-29",
524-
"updatedAt": "2026-08-04"
524+
"updatedAt": "2026-08-12"
525525
},
526526
{
527527
"label": "Harnesses",
@@ -533,7 +533,7 @@
533533
"label": "Workspace",
534534
"to": "sandbox/workspace",
535535
"addedAt": "2026-06-29",
536-
"updatedAt": "2026-08-04"
536+
"updatedAt": "2026-08-12"
537537
},
538538
{
539539
"label": "Tools",
@@ -545,13 +545,13 @@
545545
"label": "Policy",
546546
"to": "sandbox/policy",
547547
"addedAt": "2026-06-29",
548-
"updatedAt": "2026-08-04"
548+
"updatedAt": "2026-08-12"
549549
},
550550
{
551551
"label": "Lifecycle & Snapshots",
552552
"to": "sandbox/lifecycle",
553553
"addedAt": "2026-06-29",
554-
"updatedAt": "2026-08-04"
554+
"updatedAt": "2026-08-12"
555555
},
556556
{
557557
"label": "Instance Durability",
@@ -596,7 +596,7 @@
596596
"label": "Provisioning",
597597
"to": "sandbox/provisioning",
598598
"addedAt": "2026-06-29",
599-
"updatedAt": "2026-08-04"
599+
"updatedAt": "2026-08-12"
600600
},
601601
{
602602
"label": "Observability",
@@ -859,7 +859,7 @@
859859
"label": "Codex",
860860
"to": "adapters/codex",
861861
"addedAt": "2026-06-12",
862-
"updatedAt": "2026-06-30"
862+
"updatedAt": "2026-08-12"
863863
},
864864
{
865865
"label": "OpenCode",
@@ -870,7 +870,8 @@
870870
{
871871
"label": "Grok Build",
872872
"to": "adapters/grok-build",
873-
"addedAt": "2026-06-29"
873+
"addedAt": "2026-06-29",
874+
"updatedAt": "2026-08-12"
874875
},
875876
{
876877
"label": "ACP-Compatible",

docs/sandbox/lifecycle.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const sandbox = defineSandbox({
3636
| ------------------- | ------------------------------------------------------------------------------------------- |
3737
| `reuse` | `'thread'` keeps one sandbox per `threadId`; `'none'` provisions a fresh sandbox per run. |
3838
| `snapshot` | `'after-setup'` snapshots the workspace once bootstrap finishes, on snapshot-capable providers. |
39-
| `keepAlive` | Duration hint (e.g. `'30m'`) for how long the sandbox should stay warm between runs. Nothing in `@tanstack/ai-sandbox` reads it today; it is carried for providers and host apps that implement their own idle GC. |
39+
| `keepAlive` | Duration hint (e.g. `'30m'`) for how long the sandbox should stay warm between runs. Nothing in `@tanstack/ai-sandbox` reads it today. Daytona idle stop is `autoStopInterval` on `daytonaSandbox()`. Pass minutes. `0` turns auto-stop off. |
4040
| `destroyOnComplete` | When `false`, the sandbox survives the run so the next one can resume it. |
4141
| `snapshotMaxAge` | Duration (e.g. `'24h'`) after which a stored snapshot is treated as stale and re-created. |
4242

@@ -58,8 +58,9 @@ destroys, exactly as an abort does. See
5858

5959
## Snapshot after setup
6060

61-
When the provider supports snapshots (e.g. [Docker](./providers)), bootstrap
62-
automatically takes a snapshot after `setup` completes. The snapshot caches the
61+
When the provider supports snapshots (for example [Docker](./providers) or
62+
[Daytona](./providers)), bootstrap automatically takes a snapshot after
63+
`setup` completes. The snapshot caches the
6364
fully bootstrapped [workspace](./workspace) (the cloned repo with dependencies
6465
installed) so subsequent runs resume from it instead of re-running the setup
6566
steps, which dramatically reduces cold-start time.

docs/sandbox/policy.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ This is the broad backstop: even if a specific network command isn't in your
9191
`commands` lists, `network: 'ask'` still forces an approval for anything that
9292
reaches out.
9393

94+
On Daytona, `network: 'deny'` also sets `networkBlockAll` when the sandbox is
95+
created. The sandbox then has no outbound network.
96+
9497
## Precedence: deny > ask > allow
9598

9699
When more than one rule could match an action, the strictest wins. The order is
@@ -164,6 +167,20 @@ throwing. Because the mapping is the adapter's job, you write the policy once
164167
and it behaves consistently no matter which provider or harness runs the
165168
sandbox.
166169

170+
A deny-only list with `default: 'allow'` stays permissive on Grok Build and
171+
Codex. Those harnesses keep auto-approve. They do not enforce
172+
`commands.deny`. Isolation is the outer sandbox. Use Claude Code when you
173+
need command-level deny.
174+
175+
## Daytona setup needs sudo
176+
177+
The Daytona user is not root. Package installs in `setup` must run as
178+
`sudo -n apt-get install …`. Do not put `sudo *` in `deny` for a Daytona
179+
sandbox. That pattern blocks the setup commands.
180+
181+
A Docker sandbox with `default: 'ask'` can still deny `sudo *`. The container
182+
often runs as root, so setup does not need sudo.
183+
167184
## Wiring it on
168185

169186
A policy does nothing on its own, it takes effect when you attach it to a

docs/sandbox/providers.md

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ same.
2222
| --- | --- | --- | --- |
2323
| Local process | `@tanstack/ai-sandbox-local-process` | none (host) | The fast, no-Docker dev loop. Trusted/dev use only. |
2424
| Docker | `@tanstack/ai-sandbox-docker` | container | Real isolation; commit-based snapshots, fork, resume-by-id. |
25-
| Daytona | `@tanstack/ai-sandbox-daytona` | cloud sandbox | Managed [Daytona](https://www.daytona.io/) sandboxes; port preview links, resume-by-id. Needs `DAYTONA_API_KEY`. |
25+
| Daytona | `@tanstack/ai-sandbox-daytona` | cloud sandbox | Managed [Daytona](https://www.daytona.io/) sandboxes; snapshots after setup, port preview links, resume-by-id. Needs `DAYTONA_API_KEY`. |
2626
| Vercel | `@tanstack/ai-sandbox-vercel` | microVM | Managed [Vercel Sandbox](https://vercel.com/docs/sandbox) microVMs; exposed-port domains, resume-by-id (persistent). Needs `VERCEL_TOKEN` + team/project. |
2727
| Sprites | `@tanstack/ai-sandbox-sprites` | stateful sandbox | Managed [Sprites](https://sprites.dev) (Fly.io) sandboxes; durable filesystem, in-place checkpoints, single proxied public-URL port, resume-by-id. Needs `SPRITES_API_KEY`. |
2828

@@ -139,19 +139,80 @@ const isolated = dockerSandbox({ image: 'node:22' })
139139
```ts
140140
import { daytonaSandbox } from '@tanstack/ai-sandbox-daytona'
141141

142-
const daytona = daytonaSandbox({ apiKey: process.env.DAYTONA_API_KEY })
142+
const daytona = daytonaSandbox({
143+
apiKey: process.env.DAYTONA_API_KEY,
144+
snapshot: 'daytona-medium',
145+
autoStopInterval: 0,
146+
})
143147
```
144148

145149
- **Isolation:** a managed cloud sandbox on a remote VM you do not run yourself.
146-
- **Auth / env:** needs `DAYTONA_API_KEY`. Harness credentials are injected as
147-
workspace secrets; there is no host login to fall back on.
148-
- **Snapshot / resume:** no snapshots; resume-by-id reconnects to a still-running
149-
sandbox (not a restored point-in-time snapshot), plus port preview links for
150-
live previews.
150+
- **Auth / env:** needs `DAYTONA_API_KEY`. Harness credentials are workspace
151+
secrets. They go onto the live handle and into each `exec` call through the
152+
SDK env argument. Spawn sources a workdir env file. Secret values never
153+
enter create-time `envVars` or the stored command string. Git clone auth
154+
uses the native username and password arguments.
155+
- **Snapshot / resume:** point-in-time snapshots and restore.
156+
`daytonaSandbox({ snapshot })` selects the Daytona image to create from
157+
(for example `'daytona-medium'`). After `setup`, the sandbox layer takes its
158+
own snapshot when `lifecycle.snapshot` is `'after-setup'` (the default on
159+
this provider). That snapshot stops the sandbox, captures the filesystem,
160+
then starts it again. Resume-by-id starts a `stopped` or `archived`
161+
sandbox, then returns the handle. Daytona stops an idle sandbox after 15
162+
minutes unless you set `autoStopInterval` (minutes; `0` turns auto-stop
163+
off). Set `ephemeral: true` to delete the sandbox when it stops.
164+
- **Network:** `policy.capabilities.network === 'deny'` sets
165+
`networkBlockAll` on create. The provider reports `networkPolicy: true`.
166+
- **Working directory:** the virtual root `/workspace` maps to
167+
`/home/daytona/workspace` by default. Set `workdir` on `daytonaSandbox()` if
168+
you need a different path. Native `sandbox.fs` and `sandbox.git` use that
169+
mapped path.
170+
- **Stdin:** spawned processes accept stdin through
171+
`sendSessionCommandInput`. `writableStdin` is `true`.
172+
- **Privileges:** the Daytona user is not root. Setup commands that install
173+
packages must use `sudo -n`. Do not deny `sudo *` on a Daytona
174+
[policy](./policy).
151175
- **Bridge:** the sandbox is remote, so a [bridged tool](./tools) call can't reach
152176
your laptop's `localhost`. In local dev, tunnel the bridge (see [tools](./tools));
153177
a deployed orchestrator is reachable out of the box.
154178

179+
```ts
180+
import { chat } from '@tanstack/ai'
181+
import { grokBuildText } from '@tanstack/ai-grok-build'
182+
import {
183+
defineSandbox,
184+
defineSandboxPolicy,
185+
defineWorkspace,
186+
gitSkill,
187+
withSandbox,
188+
} from '@tanstack/ai-sandbox'
189+
import { daytonaSandbox } from '@tanstack/ai-sandbox-daytona'
190+
191+
const sandbox = defineSandbox({
192+
id: 'daytona-agent',
193+
provider: daytonaSandbox({
194+
apiKey: process.env.DAYTONA_API_KEY,
195+
snapshot: 'daytona-medium',
196+
}),
197+
workspace: defineWorkspace({
198+
skills: [gitSkill({ repo: 'owner/skills-pack' })],
199+
}),
200+
policy: defineSandboxPolicy({
201+
default: 'allow',
202+
commands: { deny: ['rm -rf /'] },
203+
}),
204+
})
205+
206+
const stream = chat({
207+
adapter: grokBuildText('grok-build'),
208+
messages: [{ role: 'user', content: 'List the project files.' }],
209+
middleware: [withSandbox(sandbox)],
210+
})
211+
```
212+
213+
Grok Build and Codex do not enforce `commands.deny`. Isolation is the Daytona
214+
sandbox. Use Claude Code when you need command-level deny.
215+
155216
## Vercel
156217

157218
```ts
@@ -211,7 +272,7 @@ Providers declare what they support via `capabilities()`. The flags are:
211272
| `env` | Inject environment variables. |
212273
| `ports` | Expose/forward ports (preview URLs). |
213274
| `backgroundProcesses` | Keep long-running processes alive between calls. |
214-
| `writableStdin` | A spawned process exposes a writable host→process stdin. `true` for local-process and Docker; `false` on remote/edge providers (Daytona, Vercel, Cloudflare), where stdin-fed harnesses deliver the prompt via a file + shell redirection instead. |
275+
| `writableStdin` | A spawned process exposes a writable host→process stdin. `true` for local-process, Docker, and Daytona. `false` on Vercel and Cloudflare, where stdin-fed harnesses deliver the prompt via a file + shell redirection. |
215276
| `killableProcesses` | A spawned process can be forcibly stopped via `SpawnHandle.kill()` **and** aborted mid-flight via the `signal` passed to `spawn`. |
216277
| `snapshots` | Capture and restore point-in-time snapshots. |
217278
| `networkPolicy` | Enforce network allow/deny rules. |

docs/sandbox/provisioning.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ defineWorkspace({
6464
})
6565
```
6666

67-
`secrets` is [declared on the workspace](./workspace), and the values are
68-
injected into the sandbox env at create/resume time.
67+
`secrets` is [declared on the workspace](./workspace). `ensure()` puts the
68+
values onto the live handle at create, resume, and snapshot restore. They
69+
never stay on the Daytona sandbox record as create-time `envVars`.
6970

7071
### Why the values never leak
7172

@@ -175,7 +176,12 @@ defineWorkspace({
175176

176177
`gitSkill` takes an optional `into` field, an **absolute path inside the
177178
sandbox**, controlling where the repo is cloned. It defaults to
178-
`.tanstack-skills/<repo-basename>`:
179+
`.tanstack-skills/<repo-basename>`. Bootstrap creates the parent directory
180+
before the clone.
181+
182+
Paths that start with `/workspace` map to the provider workdir. On Daytona
183+
that workdir is `/home/daytona/workspace` by default. You can keep the
184+
portable `/workspace/...` path in `into`.
179185

180186
```ts
181187
import { createSecrets, defineWorkspace, gitSkill, githubRepo } from '@tanstack/ai-sandbox'
@@ -206,6 +212,11 @@ format:
206212
| Codex | `.codex/config.toml` |
207213
| OpenCode | `opencode.json` |
208214

215+
Each projector walks the cloned repo for folders that contain `SKILL.md`. A
216+
nested pack (`skills/foo/SKILL.md`) is linked under the skill name `foo`. A
217+
flat clone with `SKILL.md` at the root uses the clone name. You do not write
218+
`ln -s` by hand.
219+
209220
A concept a given CLI lacks (for example, `plugins` on Codex) **emits a
210221
warning and is silently skipped** rather than throwing. The same applies to
211222
`agentSkill` on Claude Code: there is no reliable primitive to install a public

docs/sandbox/workspace.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ subshell (like the Grok CLI installer, `(curl … || curl …) | bash`) is not a
170170
valid argument to another command, and `sh` then fails at parse time with
171171
`syntax error: unexpected "("` (exit `2`) without running anything.
172172

173+
On [Daytona](./providers) the user is not root. Write a package install as
174+
`sudo -n apt-get install …`. Do not deny `sudo *` on that provider. See
175+
[Policy](./policy).
176+
173177
## Scripts
174178

175179
`scripts` is a map of named commands the agent and user can invoke by name,

packages/ai-acp/src/adapters/projection.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
* `fileSkill` and `instructions` are already written by the provider-agnostic
1818
* bootstrap (into the workspace root + `AGENTS.md`), so they need no projection.
1919
*/
20-
import { isSecretRef, resolveGitSkillDir } from '@tanstack/ai-sandbox'
20+
import {
21+
discoverSkillDirs,
22+
isSecretRef,
23+
resolveGitSkillDir,
24+
} from '@tanstack/ai-sandbox'
2125
import type {
2226
BearerRef,
2327
SandboxHandle,
@@ -36,11 +40,6 @@ function shellQuote(value: string): string {
3640
return `'${value.replace(/'/g, `'\\''`)}'`
3741
}
3842

39-
function basenameOf(path: string): string {
40-
const segments = path.split('/').filter((segment) => segment !== '')
41-
return segments[segments.length - 1] ?? path
42-
}
43-
4443
/**
4544
* Make a sandbox path relative to the workspace root, so shell commands work on
4645
* every provider. Only `fs.*` remaps the virtual `/workspace`; a raw `/workspace`
@@ -129,16 +128,19 @@ export async function projectAcpWorkspace(
129128
await handle.fs.mkdir(`${projection.root}/${skillsDir}`)
130129
for (const skill of gitSkills) {
131130
const source = skill.into ?? resolveGitSkillDir(projection.root, skill)
132-
const relSource = relativeToRoot(projection.root, source)
133-
const relTarget = `${skillsDir}/${basenameOf(source)}`
134-
const cp = await handle.process.exec(
135-
`cp -r ${shellQuote(relSource)} ${shellQuote(relTarget)}`,
136-
{ cwd: projection.root },
137-
)
138-
if (cp.exitCode !== 0) {
139-
console.warn(
140-
`[${harnessName}] failed to copy gitSkill "${skill.repo}" into ${relTarget}: ${cp.stderr.trim()}`,
131+
const discovered = await discoverSkillDirs(handle, source)
132+
for (const { name, dir } of discovered) {
133+
const relSource = relativeToRoot(projection.root, dir)
134+
const relTarget = `${skillsDir}/${name}`
135+
const cp = await handle.process.exec(
136+
`cp -r ${shellQuote(relSource)} ${shellQuote(relTarget)}`,
137+
{ cwd: projection.root },
141138
)
139+
if (cp.exitCode !== 0) {
140+
console.warn(
141+
`[${harnessName}] failed to copy gitSkill "${skill.repo}" into ${relTarget}: ${cp.stderr.trim()}`,
142+
)
143+
}
142144
}
143145
}
144146
}

0 commit comments

Comments
 (0)