Skip to content

Commit d1ed540

Browse files
committed
fix(ai-sandbox-docker): close sbx review bugs A46-A54
Allow localhost on real sbx allowlists so the host proxy rewrite does not block the tool bridge. Keep denyNetwork-only empty. Treat more already-exists texts as a live name collision. Delete the owned clone on that path. Do not run sbx rm. Walk every brace in parseJsonAfterBanner. Do not treat a sandbox already-exists as policy init success. Fix the connectPort error after a live publish. Add the abort listener before reading aborted. Drop the stale Task 8 comments.
1 parent c67e347 commit d1ed540

15 files changed

Lines changed: 339 additions & 66 deletions

File tree

docs/config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@
498498
"label": "Overview",
499499
"to": "sandbox/overview",
500500
"addedAt": "2026-06-16",
501-
"updatedAt": "2026-08-11"
501+
"updatedAt": "2026-08-12"
502502
},
503503
{
504504
"label": "Quick Start",
@@ -528,7 +528,7 @@
528528
"label": "Tools",
529529
"to": "sandbox/tools",
530530
"addedAt": "2026-06-29",
531-
"updatedAt": "2026-08-04"
531+
"updatedAt": "2026-08-12"
532532
},
533533
{
534534
"label": "Policy",

docs/sandbox/overview.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ shell.
108108
[Quick Start](./quick-start) gets an agent fixing a bug in a sandbox on your laptop.
109109
After that, pick the piece you need:
110110

111-
- [Providers](./providers): local process, Docker, Daytona, Vercel, Sprites, and what
112-
each one can do.
111+
- [Providers](./providers): local process, Docker container, Docker Sandboxes
112+
(`sbxSandbox`), Daytona, Vercel, Sprites, and what each one can do.
113113
- [Harnesses](./harnesses): which agent runs. Grok Build, Claude Code, Codex,
114114
OpenCode, or any ACP agent.
115115
- [Workspace](./workspace): the source repo, clone depth, and setup commands.

docs/sandbox/policy.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,11 @@ Most providers leave `capabilities.network` to the harness. `sbxSandbox()` also
9999

100100
| TanStack `capabilities.network` | What `sbxSandbox()` writes |
101101
| --- | --- |
102-
| No policy and no `allowNetwork` / `denyNetwork` | If the policy list is empty, `sbxSandbox()` runs `sbx policy init deny-all`. A known adapter (`grok-build`, `claude-code`, `codex`) then writes its model API host as a per-sandbox allow on top of that deny-all. Unknown adapters stay on the machine preset. |
103-
| No policy and `allowNetwork` | Per-sandbox allow of the model API host (when the adapter is known) plus `allowNetwork`, then apply `denyNetwork`. |
102+
| No policy and no `allowNetwork` / `denyNetwork` | If the policy list is empty, `sbxSandbox()` runs `sbx policy init deny-all`. A known adapter (`grok-build`, `claude-code`, `codex`) then writes its model API host and `localhost` as a per-sandbox allow on top of that deny-all. Unknown adapters stay on the machine preset. |
103+
| No policy and `allowNetwork` | Per-sandbox allow of the model API host (when the adapter is known), `localhost`, and `allowNetwork`, then apply `denyNetwork`. |
104+
| No policy and `denyNetwork` only | Per-sandbox deny of those hosts. Allow stays empty. No auto hosts and no `localhost`. This is additive deny on the machine preset. |
104105
| `allow` | Allow `**`, then apply `denyNetwork`. |
105-
| `deny` | Allow only the model API host plus `allowNetwork`, then apply `denyNetwork`. |
106+
| `deny` | Allow the model API host, `localhost`, and `allowNetwork`, then apply `denyNetwork`. |
106107
| `ask` (or the policy `default` when `network` is unset) | Same allowlist as `deny`. The harness still asks for tools and commands. |
107108

108109
Auto-allowed model hosts:
@@ -114,6 +115,8 @@ Auto-allowed model hosts:
114115

115116
If the allowlist would be empty under `deny` or `ask`, create throws. Pass `allowNetwork`, or use `grokBuildText` / `claudeCodeText` / `codexText`.
116117

118+
The guest still dials `host.docker.internal` for the tool bridge. The `sbx` proxy rewrites that host to `localhost` before the policy match. `sbxSandbox()` adds `localhost` when it writes a real allowlist. It does not add `localhost` for `denyNetwork` only.
119+
117120
```ts
118121
import { sbxSandbox } from '@tanstack/ai-sandbox-docker'
119122
import { defineSandbox, defineSandboxPolicy } from '@tanstack/ai-sandbox'

docs/sandbox/quick-start.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,12 @@ export const repoSandbox = defineSandbox({
194194
## Run the working example
195195

196196
A complete, runnable app ships at
197-
[`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web)
198-
a "build me an app" agent (Claude Code on a Docker sandbox) with durable,
199-
refresh-surviving runs; it scaffolds an app in the sandbox, runs the dev
200-
server, and streams back a live preview URL. For a coding agent running at the
201-
edge, with the harness (Claude Code, Codex, Grok Build) picked per run from
202-
the UI, see
197+
[`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web).
198+
That app is a "build me an app" agent (Claude Code on a Docker sandbox) with
199+
durable, refresh-surviving runs. It scaffolds an app in the sandbox, runs the
200+
dev server, and streams back a live preview URL. For a coding agent running at
201+
the edge, with the harness (Claude Code, Codex, Grok Build) picked per run
202+
from the UI, see
203203
[`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare).
204204

205205
From here:

docs/sandbox/tools.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,24 @@ That holds in two cases and breaks in a third.
6868

6969
| Topology | Host the sandbox dials | Setup |
7070
| --- | --- | --- |
71-
| Local process / Docker | `localhost` / `host.docker.internal` | None. Works out of the box. |
72-
| Deployed orchestrator (production) | Your public host, derived from the request | None. Works out of the box. |
71+
| Local process | `localhost` | None. Works by default. |
72+
| Docker container (`dockerSandbox`) | `host.docker.internal` | None. Works by default. |
73+
| Docker Sandboxes (`sbxSandbox`) | `host.docker.internal` | The guest URL stays `host.docker.internal`. The `sbx` proxy rewrites that host to `localhost` before the policy match. A deny or ask allowlist must include `localhost`. `sbxSandbox()` adds `localhost` when it writes a real allowlist. |
74+
| Deployed orchestrator (production) | Your public host, derived from the request | None. Works by default. |
7375
| Remote cloud sandbox, driven from your laptop | Your laptop, which has no public URL | Tunnel the bridge with `withNgrokBridge`. |
7476

75-
### Local process / Docker
77+
### Local process / Docker container
7678

7779
The orchestrator is the same machine as the sandbox, reached on `localhost`
78-
(local-process) or `host.docker.internal` (Docker). Bridged tools work with no
79-
extra configuration.
80+
(local-process) or `host.docker.internal` (Docker container). Bridged tools
81+
work with no extra configuration.
82+
83+
### Docker Sandboxes (`sbxSandbox`)
84+
85+
The guest still dials `host.docker.internal`. The host HTTP proxy rewrites
86+
that host to `localhost` before it checks `sbx policy`. If you write a deny
87+
or ask allowlist, allow `localhost` (or let `sbxSandbox()` add it).
88+
`denyNetwork` alone does not write that allowlist.
8089

8190
### A deployed orchestrator (production)
8291

packages/ai-sandbox-docker/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ Sandbox providers that run a TanStack AI harness on Docker.
99
import { dockerSandbox, sbxSandbox } from '@tanstack/ai-sandbox-docker'
1010

1111
const container = dockerSandbox({ image: 'node:22' })
12-
const microvm = sbxSandbox()
12+
const microvm = sbxSandbox({
13+
allowNetwork: ['*.npmjs.org', 'registry.npmjs.org'],
14+
})
1315
```
1416

15-
`sbxSandbox()` needs `sbx` on `PATH`, `sbx login`, a hypervisor, and a Git repo to pass to `sbx create --clone`. See the [providers guide](https://tanstack.com/ai/latest/docs/sandbox/providers).
17+
`sbxSandbox()` needs `sbx` on `PATH`, `sbx login`, a hypervisor, and a Git
18+
repo to pass to `sbx create --clone`. Pass `allowNetwork` when you use a
19+
deny or ask policy, or when you need extra hosts besides the model API host
20+
and `localhost`. See the [providers guide](https://tanstack.com/ai/latest/docs/sandbox/providers).

packages/ai-sandbox-docker/src/sbx/cli.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export function defaultSpawn(
7070
})
7171
})
7272
if (opts.signal) {
73+
opts.signal.addEventListener('abort', onAbort, { once: true })
7374
if (opts.signal.aborted) child.kill()
74-
else opts.signal.addEventListener('abort', onAbort, { once: true })
7575
}
7676
return {
7777
kill: () => {
@@ -255,22 +255,23 @@ export function sbxExecArgs(
255255
* Walk every `{` / `[` until one slice parses.
256256
*/
257257
export function parseJsonAfterBanner(stdout: string): unknown {
258-
const brace = stdout.indexOf('{')
259-
const bracket = stdout.indexOf('[')
260-
const starts = [brace, bracket].filter((i) => i >= 0).sort((a, b) => a - b)
261-
if (starts.length === 0) {
262-
throw new SyntaxError(
263-
`sbx JSON: no object or array in: ${stdout.slice(0, 200)}`,
264-
)
265-
}
266258
let lastError: unknown
267-
for (const start of starts) {
259+
let sawStart = false
260+
for (let i = 0; i < stdout.length; i++) {
261+
const ch = stdout[i]
262+
if (ch !== '{' && ch !== '[') continue
263+
sawStart = true
268264
try {
269-
return JSON.parse(stdout.slice(start)) as unknown
265+
return JSON.parse(stdout.slice(i)) as unknown
270266
} catch (error) {
271267
lastError = error
272268
}
273269
}
270+
if (!sawStart) {
271+
throw new SyntaxError(
272+
`sbx JSON: no object or array in: ${stdout.slice(0, 200)}`,
273+
)
274+
}
274275
throw lastError instanceof Error
275276
? lastError
276277
: new SyntaxError(`sbx JSON: ${stdout.slice(0, 200)}`)

packages/ai-sandbox-docker/src/sbx/handle.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
* fs is the same base64-over-exec design as the container handle. Kill uses
55
* the same in-VM pid file. Do not only kill the host `sbx exec` process.
66
*
7-
* `writableStdin` and `killableProcesses` stay false until the live test in
8-
* Task 8 measures them.
7+
* `writableStdin` and `killableProcesses` stay false until a live
8+
* measurement after `sbx login`.
99
*/
1010
import { randomUUID } from 'node:crypto'
1111
import { createExecBackedGit } from '@tanstack/ai-sandbox'
@@ -32,9 +32,9 @@ export const SBX_CAPS: SandboxCapabilities = {
3232
env: true,
3333
ports: true,
3434
backgroundProcesses: true,
35-
// Stay false until the live test in Task 8 proves stdin works.
35+
// Stay false until a live measurement after `sbx login` proves stdin works.
3636
writableStdin: false,
37-
// Stay false until the live test in Task 8 proves in-VM kill works.
37+
// Stay false until a live measurement after `sbx login` proves in-VM kill.
3838
killableProcesses: false,
3939
snapshots: false,
4040
networkPolicy: true,
@@ -171,9 +171,7 @@ export function isAlreadyGone(error: unknown): boolean {
171171

172172
export function isNameAlreadyExists(error: unknown): boolean {
173173
const message = error instanceof Error ? error.message : String(error)
174-
return /(?:sandbox|vm|container)\s+(?:'[^']+'|"[^"]+"|\S+)\s+already exists/i.test(
175-
message,
176-
)
174+
return /(?:sandbox|vm|container).{0,80}already exists/i.test(message)
177175
}
178176

179177
/** Host login or transport text that `test -e` never prints for a missing path. */
@@ -549,7 +547,7 @@ export class SbxHandle implements SandboxHandle {
549547
const hostPort = hostPortFromPortsJson(listed.stdout, port)
550548
if (hostPort === null) {
551549
throw new Error(
552-
`sbx: sandbox port ${port} is not published. Pass publishPorts: [${port}] to sbxSandbox() to reach it from the host.`,
550+
`sbx: sandbox port ${port} is not in sbx ports --json after publish.`,
553551
)
554552
}
555553
return { url: `http://localhost:${hostPort}` }

packages/ai-sandbox-docker/src/sbx/policy.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,20 @@ const AUTO_HOSTS: Record<string, Array<string>> = {
1313
const EMPTY_ALLOWLIST =
1414
'sbxSandbox: network deny/ask has an empty allowlist. Pass allowNetwork, or use grokBuildText / claudeCodeText / codexText so the model API host is added.'
1515

16+
/** Guest URLs use host.docker.internal; the sbx proxy rewrites that to localhost. */
17+
const BRIDGE_HOST = 'localhost'
18+
1619
export function autoApiHosts(adapterName: string | undefined): Array<string> {
1720
if (!adapterName) return []
1821
if (!Object.hasOwn(AUTO_HOSTS, adapterName)) return []
1922
return AUTO_HOSTS[adapterName] ?? []
2023
}
2124

25+
function withBridgeHost(hosts: Array<string>): Array<string> {
26+
if (hosts.includes(BRIDGE_HOST) || hosts.includes('**')) return hosts
27+
return [...hosts, BRIDGE_HOST]
28+
}
29+
2230
function networkDecision(
2331
policy: SandboxPolicy | undefined,
2432
): PolicyDecision | undefined {
@@ -38,7 +46,7 @@ export function planSbxPolicy(input: {
3846
const auto = autoApiHosts(input.adapterName)
3947
if (!input.policy && !hasHostLists) {
4048
if (auto.length > 0) {
41-
return { kind: 'per-sandbox', allow: auto, deny: [] }
49+
return { kind: 'per-sandbox', allow: withBridgeHost(auto), deny: [] }
4250
}
4351
return { kind: 'machine-preset' }
4452
}
@@ -59,7 +67,14 @@ export function planSbxPolicy(input: {
5967
if (allow.length === 0 && (decision === 'deny' || decision === 'ask')) {
6068
throw new Error(EMPTY_ALLOWLIST)
6169
}
62-
return { kind: 'per-sandbox', allow, deny }
70+
// Real allowlists must include localhost. The proxy rewrites
71+
// host.docker.internal to localhost before the policy match.
72+
// denyNetwork-only keeps allow=[] (A19). Open ** stays ** only.
73+
return {
74+
kind: 'per-sandbox',
75+
allow: mergeAuto && allow.length > 0 ? withBridgeHost(allow) : allow,
76+
deny,
77+
}
6378
}
6479

6580
export function policyArgs(

packages/ai-sandbox-docker/src/sbx/provider.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,7 @@ function isAlreadyInitialized(error: unknown): boolean {
111111
error instanceof Error ? error.message : String(error)
112112
).toLowerCase()
113113
return (
114-
message.includes('already initialized') ||
115-
message.includes('already set') ||
116-
message.includes('already exists')
114+
message.includes('already initialized') || message.includes('already set')
117115
)
118116
}
119117

@@ -247,16 +245,16 @@ class SbxProvider implements SandboxProvider {
247245
if (input.env) await handle.env.set(input.env)
248246
return handle
249247
} catch (error) {
248+
if (host.owned) {
249+
await removeOwnedClone(id)
250+
}
250251
if (isNameAlreadyExists(error)) throw error
251252
let rmError: unknown
252253
try {
253254
await this.run(['rm', '--force', id])
254255
} catch (caught) {
255256
rmError = caught
256257
}
257-
if (host.owned) {
258-
await removeOwnedClone(id)
259-
}
260258
if (rmError && !isAlreadyGone(rmError)) throw rmError
261259
throw error
262260
}

0 commit comments

Comments
 (0)