ai-sandbox
GitHub用于在隔离沙箱中运行 Claude Code 等适配器。支持声明式工作区配置、类型安全密钥管理、技能插件注入及生命周期快照,确保执行环境的安全与可复现性。
触发场景
安装
npx skills add TanStack/ai --skill ai-sandbox -g -y
SKILL.md
Frontmatter
{
"name": "ai-sandbox",
"type": "sub-skill",
"library": "tanstack-ai",
"sources": [
"TanStack\/ai:docs\/sandbox\/overview.md"
],
"description": "Run harness adapters (Claude Code, Codex, OpenCode) INSIDE isolated sandboxes via defineSandbox + withSandbox + a provider (localProcessSandbox \/ dockerSandbox). Covers declarative provisioning: createSecrets + secret\/bearer, skills (agentSkill\/gitSkill\/mcpSkill\/ fileSkill), plugins, instructions → canonical AGENTS.md + symlinks projected per harness; shallow-clone default with depth opt-out; serial\/parallel setup callback over a persistent shell; snapshot-after-setup default with snapshotMaxAge TTL; defineWorkspace (git\/setup\/scripts\/skills\/secrets\/ instructions\/plugins), defineSandboxPolicy (allow\/ask\/deny), lifecycle\/resume, the SandboxHandle (fs\/git\/process\/ports), capability tokens, defineSandbox hooks (onFile\/onFileCreate\/onFileChange\/onFileDelete\/onReady\/onError\/ onDestroy) + fileEvents flag, chat middleware sandbox group (defineChatMiddleware sandbox hooks), the sandbox debug category, watchWorkspace as a low-level building block, and the file.changed \/ sandbox.file \/ claude-code.session-id events. Use whenever a harness adapter needs a sandbox or when building sandbox providers.\n",
"library_version": "0.1.0"
}
Sandboxes
Harness adapters declare requires: [SandboxCapability]. chat() errors unless
some middleware provides it — withSandbox(...) does. The adapter then runs the
agent CLI inside the sandbox and streams its events back.
Setup — Claude Code in a Docker sandbox
import { chat } from '@tanstack/ai'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import {
defineSandbox,
defineWorkspace,
withSandbox,
} from '@tanstack/ai-sandbox'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
const sandbox = defineSandbox({
id: 'repo-agent',
provider: dockerSandbox({ image: 'node:22' }),
workspace: defineWorkspace({
source: { type: 'git', url: 'https://github.com/owner/repo', ref: 'main' },
packageManager: 'pnpm',
setup: ['corepack enable', 'pnpm install'],
scripts: { test: 'pnpm test' },
secrets: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? '' },
}),
lifecycle: { reuse: 'thread', snapshot: 'after-setup', keepAlive: '30m' },
})
const stream = chat({
threadId,
adapter: claudeCodeText('sonnet'),
messages,
middleware: [withSandbox(sandbox)],
})
Type-safe secrets
import { createSecrets, bearer } from '@tanstack/ai-sandbox'
const secrets = createSecrets({
GH: process.env.GH_TOKEN ?? '',
SENTRY: process.env.SENTRY_TOKEN ?? '',
})
// secrets.GH is a SecretRef — the underlying string is stored in a
// non-enumerable symbol-keyed registry and never logged, snapshotted,
// or written to the sandbox store.
Pass secrets to defineWorkspace({ secrets }) so skill and MCP projectors
can resolve them. Use secret: secrets.GH in gitSkill for private-repo auth
and secrets.GH / bearer(secrets.GH) in MCP header values:
secrets.GH— resolves to the raw token value.bearer(secrets.GH)— resolves to"Bearer <value>".
Declarative provisioning (skills, plugins, MCP, instructions)
import {
agentSkill,
gitSkill,
mcpSkill,
fileSkill,
bearer,
createSecrets,
defineWorkspace,
} from '@tanstack/ai-sandbox'
const secrets = createSecrets({ GH: process.env.GH_TOKEN ?? '' })
defineWorkspace({
source: { type: 'git', url: 'https://github.com/owner/repo' },
secrets,
skills: [
agentSkill('tanstack'), // named skill (no-op with warning on CLIs that lack the concept)
gitSkill({
repo: 'owner/private-skills',
secret: secrets.GH, // resolved at bootstrap time, never stored
// into: '/abs/path/inside/sandbox' // optional; defaults to .tanstack-skills/<repo>
}),
mcpSkill('my-mcp', {
url: 'https://mcp.example.com',
headers: { Authorization: bearer(secrets.GH) },
}),
fileSkill({ path: '.hints.md', content: 'Prefer pnpm.' }),
],
plugins: ['@anthropic/plugin-foo'], // no-op with warning on CLIs without a plugin concept
instructions: 'Always run `pnpm test` before proposing a change.',
})
Each skill type is projected per harness (Claude Code → .mcp.json; Codex →
.codex/config.toml; OpenCode → opencode.json).
instructions is written as AGENTS.md at the workspace root; CLAUDE.md and
GEMINI.md are created as symlinks (falling back to copies on symlink failure).
Skills/plugins that a CLI lacks emit a console.warn and are skipped.
gitSkill into field: an absolute path inside the sandbox where the
repo is cloned. Defaults to <root>/.tanstack-skills/<repo-basename>.
Fast init
Shallow clone (depth)
githubRepo / gitSource default to --depth 1 --single-branch. Override:
import { githubRepo, defineWorkspace } from '@tanstack/ai-sandbox'
defineWorkspace({ source: githubRepo({ repo: 'owner/app' }) }) // depth 1 (default)
defineWorkspace({ source: githubRepo({ repo: 'owner/app', depth: 10 }) }) // 10 commits
defineWorkspace({ source: githubRepo({ repo: 'owner/app', depth: 'full' }) }) // full history
Serial / parallel setup callback
setup accepts a plain Array<string> (all serial) or a callback that records
serial and parallel groups over a persistent shell whose cwd/env carry over
between serial steps:
defineWorkspace({
source: githubRepo({ repo: 'owner/app' }),
setup: ({ serial, parallel }) => {
serial('corepack enable')
serial('pnpm install')
parallel(['pnpm build', 'pnpm typecheck']) // concurrent; inherit cwd+env from shell
serial('echo done')
},
})
Snapshot-after-setup and snapshotMaxAge
When the provider supports snapshots, bootstrap takes one automatically after
setup completes. Subsequent runs resume from the snapshot (skipping setup).
Override or add a TTL:
lifecycle: {
snapshot: 'after-setup', // default when provider.capabilities().snapshots
snapshotMaxAge: '24h', // re-create when the snapshot is older than this
}
Providers without snapshot support skip the step silently.
Providers
localProcessSandbox()— runs on the host (no isolation; dev loop only).dockerSandbox({ image })— isolated container; snapshots, fork, resume-by-id.
Both implement the same SandboxHandle: fs (read/write/list/mkdir/remove/
rename/exists), git (clone/status/add/commit/push/pull/branch), process
(exec + duplex spawn), ports.connect(port), env.set, optional
snapshot()/fork(), destroy(). Providers advertise support via
capabilities(); calling an unsupported optional method throws
UnsupportedCapabilityError.
Policy
import { defineSandboxPolicy } from '@tanstack/ai-sandbox'
const policy = defineSandboxPolicy({
commands: {
allow: ['pnpm test'],
ask: ['curl *'],
deny: ['sudo *', 'rm -rf *'],
},
capabilities: { fileWrite: 'allow', network: 'ask' },
default: 'ask', // deny > ask > allow
})
// pass to defineSandbox({ policy }); harness adapters map it to native permissions
Lifecycle & resume
reuse: 'thread' resumes one sandbox per threadId; the compound key folds in
provider + workspace hash + tenant so changing the repo/setup/image starts
fresh. Ensure order: resume running → restore snapshot → create + bootstrap.
File-event hooks
Watch the workspace for create/change/delete events. Provider-agnostic: native
fs.watch on local-process, a portable find poll on Docker/exec-only
providers (no extra deps or image changes).
Declare hooks on defineSandbox({ hooks }) (sandbox-scoped) or on any chat
middleware via the sandbox group (run-scoped):
import {
defineSandbox,
defineChatMiddleware,
withSandbox,
} from '@tanstack/ai-sandbox'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
// Sandbox-scoped hooks (all optional):
const sandbox = defineSandbox({
id: 'repo-agent',
provider: dockerSandbox({ image: 'node:22' }),
hooks: {
onFile: (e) => console.log(e.type, e.path), // catch-all
onFileCreate: (e) => console.log('created', e.path),
onFileChange: (e) => console.log('changed', e.path),
onFileDelete: (e) => console.log('deleted', e.path),
onReady: (handle) => console.log('ready', handle.id),
onError: (err) => console.error(err),
onDestroy: () => console.log('destroyed'),
},
fileEvents: true, // default; set false to disable watching entirely
})
// Run-scoped hooks via chat middleware (ctx is ChatMiddlewareContext):
const auditMiddleware = defineChatMiddleware({
name: 'audit',
sandbox: {
onFile: (ctx, e) => console.log(ctx.runId, e.type, e.path),
onFileCreate: (ctx, e) => db.log({ run: ctx.runId, event: e }),
onFileChange: (ctx, e) => metrics.increment('file.change'),
onFileDelete: (ctx, e) => console.warn('deleted', e.path),
},
})
// No extra middleware needed — sandbox.file CUSTOM events are emitted
// automatically. Read them from the stream:
for await (const chunk of stream) {
if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file') {
const value = chunk.value
if (
value !== null &&
typeof value === 'object' &&
'type' in value &&
'path' in value
) {
console.log('file event', value) // { type, path, timestamp }
}
}
}
watchWorkspace() is available as a low-level building block for watching
outside a chat() run:
import { watchWorkspace } from '@tanstack/ai-sandbox'
const watcher = await watchWorkspace(handle, {
onEvent: (e) => console.log(e.type, e.path),
ignore: ['.git', 'node_modules'], // default
})
await watcher.stop()
Enable the sandbox debug category to log watcher start/stop, event dispatch,
and lifecycle transitions:
chat({ threadId, adapter, messages, debug: { sandbox: true } })
// or debug: true to enable all categories
Edge / serverless execution
A request-scoped Worker can't hold a multi-minute agent run open. The serverless/edge model splits this: a trigger starts the run and returns immediately, a durable orchestrator drives it, and clients tail from a resumable cursor.
Core primitives (@tanstack/ai-sandbox, transport- and runtime-agnostic):
RunEventLog/InMemoryRunEventLog— append-only,seq-indexed log of a run'sStreamChunks with replay-then-tail reads. A dropped connection / new tab / hibernated orchestrator reconnect by passing their last-seenseq(read({ fromSeq })).TerminalRunStatus=done | error | aborted.pipeToRunLog/RunController— the run driver.pipeToRunLogpumps achat()stream into a log and never rejects: a thrown stream error becomes a terminalRUN_ERRORevent, so detached clients always observe failures.RunController.startis fire-and-track;attach(runId, { fromSeq })tails;drain()awaits in-flight runs (e.g. in awaitUntil).- Transport-agnostic tool-bridge —
createToolBridgeCore+handleBridgeJsonRpcare the portable core;startHostToolBridgeis thenode:httphost transport. TheToolBridgeProvisionercapability injects the transport, so an edge orchestrator serves the same core from its ownfetchhandler (no raw TCP listener). Default = host transport. - Co-located host-tool seam —
toolDescriptors/remoteToolStubs/httpRemoteToolExecutor(container side) +executeHostTool(orchestrator side): only chat()-tool EXECUTION crosses the container→orchestrator boundary, not the whole MCP protocol. SandboxCapabilities.writableStdin—falsefor providers (e.g. Cloudflare) with no writable host→process stdin; stdin-fed harnesses then deliver the prompt via a file + in-shell redirection (claude -p … < file).
Cloudflare runtime (@tanstack/ai-sandbox-cloudflare):
createCloudflareSandboxAgent(config)→{ Coordinator, Sandbox, worker }— an app'sworker.tsis one configured call plus the wrangler-required DO re-exports. Two models viamode:do-drives(the DO runschat()) andcolocated(harness + bridge run in-container; the DO is a thin coordinator, pair withrunInContainerHarnessfrom/runner).DurableObjectRunEventLogmirrorsInMemoryRunEventLogover DO storage;timingSafeBearerEqualWebis the Web-Crypto constant-time bearer check.
Events
claude-code.session-id(CUSTOM) — resumable session id → pass back viamodelOptions.sessionId.file.changed(CUSTOM) —{ path, diff }working-tree diff after the run.sandbox.file(CUSTOM) —{ type, path, timestamp }per file create/change/ delete, emitted automatically when a sandbox is active.
Critical rules
- Harness adapters require a sandbox. Always include
withSandbox(...)inmiddleware— without itchat()throws a missing-capability error. - Secrets (
workspace.secrets) are injected into the sandbox env and never persisted (no snapshots, no sandbox store, no event log). Always create them withcreateSecrets(...)so the values stay hidden behindSecretReftokens. The agent binary (claude) must exist in the sandbox image (install it insetupor bake it into the image). - Secret-bearing projected files (e.g. MCP config with resolved header values) are re-written on every projection call so rotated secrets re-apply; they are never included in a snapshot.
- chat()-provided
toolsare bridged into the in-sandbox agent over a host-side MCP tool-proxy: the agent calls them asmcp__tanstack__<tool>and each call is proxied back to the host where the tool'sexecute()runs (with its closures / DB / secrets). The agent also has its own native tools (Bash/Edit/Read/…). The host bridge binds on the host; the sandbox reaches it (localhost, orhost.docker.internalfor Docker), gated by a per-run bearer token. - Use
localProcessSandbox()only in trusted/dev contexts (no isolation). - Skills/plugins that a CLI lacks (e.g.
agentSkillon Codex,pluginson Codex) warn and skip — they do not throw.
版本历史
- 5deda27 当前 2026-07-05 10:52


