diff --git a/.changeset/execution-sandbox.md b/.changeset/execution-sandbox.md
new file mode 100644
index 00000000..700dd1c6
--- /dev/null
+++ b/.changeset/execution-sandbox.md
@@ -0,0 +1,17 @@
+---
+"@dawn-ai/sandbox": patch
+"@dawn-ai/workspace": patch
+"@dawn-ai/core": patch
+"@dawn-ai/cli": patch
+"@dawn-ai/langchain": patch
+---
+
+Add an opt-in execution sandbox: a provider-agnostic `SandboxProvider` contract
+with a Docker reference (`dockerSandbox`), giving each conversation thread a
+hard-isolated workspace (filesystem + shell + network). Enable via
+`dawn.config.ts` `sandbox: { provider: dockerSandbox({ image }) }`; without it,
+behavior is unchanged. Adds a typed `config()` helper. When sandboxed, the
+materialized agent cache is bypassed so tools bind per-thread. Honest scope:
+Docker's boundary (not a microVM); `allow`-mode network denylist is best-effort
+in the Docker reference. New package `@dawn-ai/sandbox` (+ `@dawn-ai/sandbox/testing`
+`fakeSandbox` and a provider conformance kit).
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 71e75e6b..ced8085e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -93,3 +93,37 @@ jobs:
name: harness-artifacts
path: artifacts/testing/
retention-days: 7
+
+ sandbox-docker:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+ with:
+ version: 10.33.0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ with:
+ # 22.14.0 — node:sqlite (used by @dawn-ai/sqlite-storage) is only
+ # available without the --experimental-sqlite flag from 22.13+.
+ # Matches release.yml and the engines floor.
+ node-version: 22.14.0
+ cache: pnpm
+
+ - name: Install
+ run: pnpm install --frozen-lockfile
+
+ - name: Build sandbox package
+ run: pnpm --filter @dawn-ai/workspace build && pnpm --filter @dawn-ai/sandbox build
+
+ - name: Pull sandbox image
+ run: docker pull node:22-slim
+
+ - name: Real-Docker sandbox conformance + e2e
+ run: DAWN_TEST_DOCKER=1 pnpm --filter @dawn-ai/sandbox test docker-sandbox.integration
diff --git a/apps/web/app/components/docs/nav.ts b/apps/web/app/components/docs/nav.ts
index 6d067962..105af77d 100644
--- a/apps/web/app/components/docs/nav.ts
+++ b/apps/web/app/components/docs/nav.ts
@@ -33,6 +33,7 @@ export const DOCS_NAV: readonly DocsNavSection[] = [
{ label: "Workspace Filesystem", href: "/docs/workspace" },
{ label: "Context Management", href: "/docs/context-management" },
{ label: "Permissions", href: "/docs/permissions" },
+ { label: "Sandbox", href: "/docs/sandbox" },
{ label: "Retry", href: "/docs/retry" },
],
},
diff --git a/apps/web/app/docs/sandbox/page.tsx b/apps/web/app/docs/sandbox/page.tsx
new file mode 100644
index 00000000..49bd8eef
--- /dev/null
+++ b/apps/web/app/docs/sandbox/page.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from "next"
+import Content from "../../../content/docs/sandbox.mdx"
+import { DocsPage } from "../../components/docs/DocsPage"
+
+export const metadata: Metadata = { title: "Execution Sandbox" }
+
+export default function Page() {
+ return
+}
diff --git a/apps/web/content/docs/sandbox.mdx b/apps/web/content/docs/sandbox.mdx
new file mode 100644
index 00000000..116dc249
--- /dev/null
+++ b/apps/web/content/docs/sandbox.mdx
@@ -0,0 +1,134 @@
+# Execution Sandbox
+
+The execution sandbox gives each Agent-Protocol conversation thread a hard-isolated workspace — filesystem, shell, and network — instead of the local `/workspace/` directory the agent otherwise reads and writes on the host. It's opt-in: add a `sandbox` key to `dawn.config.ts` and every `readFile`, `writeFile`, `listDir`, and `runBash` call for that thread routes into the isolated environment through a provider-agnostic `SandboxProvider` contract. Dawn ships a Docker reference implementation.
+
+This is a distinct layer from the other two access controls in Dawn: [tool scoping](/docs/tools) decides *which* tools the model may call; [permissions](/docs/permissions) decide *whether a given call should run* (human-in-the-loop approval); the sandbox decides *what an allowed, approved call can actually touch*. The three compose — none of them substitutes for the others.
+
+## Quickstart
+
+Docker must be installed and the daemon running. Configure a provider in `dawn.config.ts` using the typed `config()` helper (a bare object still works — `config()` is pure identity for IntelliSense):
+
+```ts title="dawn.config.ts"
+import { config } from "@dawn-ai/cli"
+import { dockerSandbox } from "@dawn-ai/sandbox"
+
+export default config({
+ sandbox: {
+ provider: dockerSandbox({ image: "node:22-slim" }),
+ network: { mode: "allow", denylist: ["169.254.169.254"] },
+ env: { NODE_ENV: "production" },
+ resources: { memoryMb: 512, cpus: 1, timeoutMs: 120_000 },
+ idleTimeoutMs: 600_000,
+ },
+})
+```
+
+No `sandbox` key means no behavior change — the app keeps using the local `workspace/` directory exactly as before.
+
+`dawn check` validates the `sandbox` config shape and runs the provider's `preflight()` — for `dockerSandbox`, that means confirming the Docker daemon is reachable — so a misconfiguration or a stopped daemon fails at check time instead of mid-run.
+
+## What's isolated
+
+- **Filesystem** — `readFile`, `writeFile`, and `listDir` operate inside the sandbox's workspace volume. The host filesystem is never touched.
+- **Shell** — `runBash` executes inside the sandbox, still gated by the [permissions](/docs/permissions) allow/deny lists.
+- **Network** — governed by the configured [network policy](#network-policy); `deny` mode is zero egress.
+- **Environment** — the host's environment variables are never inherited. Only the key/value pairs in `sandbox.env` are injected into the sandbox.
+- **Resources** — `resources.memoryMb` and `resources.cpus` cap the sandbox's memory and CPU; `resources.timeoutMs` caps how long a single exec call may run.
+
+## Lifecycle
+
+One sandbox is created per conversation thread, on that thread's first turn. It stays warm and is reused across every subsequent turn on the same thread — the agent isn't paying container start-up cost on every message.
+
+- **Persistence** — the workspace (a named volume) survives across turns, across a container being idle-reaped, and across a full server restart. On the next turn, the provider reattaches the existing volume by its deterministic name rather than starting from an empty workspace.
+- **Idle reap** — a thread with no activity for `idleTimeoutMs` (default 10 minutes) has its warm container released. The volume is kept, so the next turn on that thread reattaches it with all files intact.
+- **Thread delete** — an Agent-Protocol `DELETE` on the thread destroys the sandbox *and* its volume. This is the only operation that discards the workspace permanently.
+
+Turn trace: turn 1 on a new thread → no live sandbox → `acquire()` creates the container and volume. Turns 2..N on the same thread → the same live sandbox is reused. Thread idle past `idleTimeoutMs` → container released, volume kept. Next turn after that → `acquire()` reattaches the existing volume into a fresh container. Thread deleted → `destroy()` removes the container and the volume.
+
+## Network policy
+
+`sandbox.network` takes one of two shapes:
+
+- **`{ mode: "deny" }`** — zero egress. This is an exact guarantee in the Docker reference (`--network none`); the sandbox cannot reach the network at all. An optional `allowlist` may be added for providers that support scoped egress.
+- **`{ mode: "allow", denylist?: [...] }`** — egress is on by default, with an optional denylist of hosts to block.
+
+The default, when `network` is omitted, is `{ mode: "allow", denylist: ["169.254.169.254"] }` — the cloud-metadata endpoint is blocked out of the box since it's a common SSRF target.
+
+
+ In the Docker reference provider, the `allow`-mode denylist is **best-effort**, not enforced with the same rigor as `deny` mode. Blocking arbitrary outbound hosts from inside a container needs an in-container firewall rule or an egress proxy; the reference does not guarantee every denylisted host is unreachable. `deny` mode's `--network none` remains exact. A provider backed by a microVM or a cloud sandbox can enforce the denylist more strongly.
+
+
+## Subagents
+
+A subagent dispatch runs under the same conversation thread as its parent, so it resolves to and shares the parent's sandbox — the coordinator and its subagents operate in one isolated environment, not one each.
+
+## Custom providers
+
+`SandboxProvider` is the contract any isolation backend implements — Docker, a microVM, or a cloud sandbox service:
+
+```ts
+import type { SandboxHandle, SandboxPolicy, SandboxProvider } from "@dawn-ai/workspace"
+
+export interface SandboxProvider {
+ readonly name: string
+ acquire(input: {
+ readonly threadId: string
+ readonly policy: SandboxPolicy
+ readonly signal: AbortSignal
+ }): Promise
+ release(threadId: string): Promise
+ destroy(threadId: string): Promise
+ preflight?(): Promise<{ readonly ok: boolean; readonly detail?: string }>
+}
+```
+
+`acquire` is create-or-reattach and idempotent per `threadId`: called at the start of every turn, it returns the same live sandbox until `release` or `destroy` is called. `release` drops warm compute but keeps the workspace volume (idle reap, server shutdown); `destroy` removes the volume too (thread delete). The returned `SandboxHandle`'s `filesystem` and `exec` are the same `FilesystemBackend`/`ExecBackend` interfaces the [workspace](/docs/workspace) capability already consumes, so wiring a new provider in requires no change to the capability itself.
+
+Validate a custom provider against the same conformance suite `dockerSandbox` and `fakeSandbox` are held to:
+
+```ts
+import { runProviderConformance } from "@dawn-ai/sandbox/testing"
+import { describe } from "vitest"
+import { myCloudSandbox } from "./my-cloud-sandbox.js"
+
+runProviderConformance({
+ name: "my-cloud-sandbox",
+ makeProvider: () => myCloudSandbox({ apiKey: process.env.MY_SANDBOX_KEY! }),
+ describe,
+})
+```
+
+The conformance kit checks `acquire` idempotency and reattachment, per-thread isolation, `release`-keeps/`destroy`-clears volume semantics, and that `exec` returns a numeric exit code.
+
+## Testing your agent
+
+`fakeSandbox()` from `@dawn-ai/sandbox/testing` is an in-memory `SandboxProvider` — deterministic, CI-safe, and requires no Docker daemon:
+
+```ts title="dawn.config.ts (test)"
+import { config } from "@dawn-ai/cli"
+import { fakeSandbox } from "@dawn-ai/sandbox/testing"
+
+export default config({
+ sandbox: { provider: fakeSandbox() },
+})
+```
+
+Use it in harness-driven tests the same way you'd test any other agent route — it satisfies the same `SandboxProvider` contract as `dockerSandbox`, so wiring behavior (per-thread isolation, warm reuse, subagents sharing a thread's sandbox) is exercised without spinning up containers.
+
+## What it is — and isn't
+
+**Is:** per-thread kernel-level filesystem and process isolation; the host filesystem is never touched; the host environment is never leaked into the sandbox; CPU and memory caps via `resources`; multi-tenant separation by thread; `network: { mode: "deny" }` is zero egress; the workspace survives turns, server restarts, and container crashes.
+
+**Is not:** a guarantee against container-escape zero-days. This is Docker's isolation boundary, not a microVM — which is why the contract is provider-agnostic: a gVisor-, Kata-, or cloud-microVM-backed provider is a stronger drop-in replacement with no change to your app code. The `allow`-mode denylist is best-effort in the Docker reference; it does not stop an agent exfiltrating its own sandbox's data under `allow` mode. And the sandbox does not govern tool *surface* — that's [tool scoping](/docs/tools)'s job (`agent({ tools })`); the sandbox and tool scoping are complementary layers, not substitutes for each other.
+
+Dawn ships the isolation seam plus a Docker reference. For hostile-grade multi-tenant isolation, plug in a microVM-backed provider.
+
+## Related
+
+
diff --git a/docs/superpowers/plans/2026-06-25-execution-sandbox.md b/docs/superpowers/plans/2026-06-25-execution-sandbox.md
new file mode 100644
index 00000000..881224d9
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-25-execution-sandbox.md
@@ -0,0 +1,1654 @@
+# Execution Sandbox Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a hard, per-thread execution sandbox (fs + exec + network isolation) for the agent workspace, via a provider-agnostic contract with a Docker reference implementation, fully opt-in through `dawn.config.ts`.
+
+**Architecture:** A `SandboxProvider` yields per-thread `{ filesystem, exec }` backends that implement the existing `@dawn-ai/workspace` interfaces, so the workspace capability consumes them unchanged. A `SandboxManager` (per-server singleton) owns the per-thread lifecycle (acquire/reuse/idle-reap/release/destroy). The CLI runtime threads `thread_id` into route prep and swaps the local backends for the thread's sandbox handle. Docker reference shells out to the `docker` CLI; an in-memory `fakeSandbox` keeps unit + wiring tests Docker-free.
+
+**Tech Stack:** TypeScript (ESM, `node:` builtins), Vitest, Biome, pnpm fixed-group workspace, `@dawn-ai/workspace` backend interfaces, the `docker` CLI.
+
+**Spec:** `docs/superpowers/specs/2026-06-25-execution-sandbox-design.md` — read it first.
+
+**Package placement (verified against the dep graph; do not change without re-checking):**
+- Contract **types** → `@dawn-ai/workspace` (leaf; already owns `FilesystemBackend`/`ExecBackend`; `core` already depends on it → no cycle).
+- `config()` helper → `@dawn-ai/core` (co-located with `DawnConfig`; `core`→`sdk` means `config()` canNOT live in `sdk`), re-exported from `@dawn-ai/cli`.
+- Provider **impl** (`dockerSandbox`, `fakeSandbox`, conformance kit) → new `@dawn-ai/sandbox` (+ `/testing` subpath).
+- `SandboxManager` + runtime wiring + `dawn check` pass → `@dawn-ai/cli`.
+
+**Conventions to mirror:** `localExec`/`localFilesystem` factory style (`packages/workspace/src/local-exec.ts`); `resolveCheckpointer`/`resolveThreadsStore` config resolution (`packages/cli/src/lib/runtime/execute-route.ts:172-203`); `collectToolScopeErrors` for the `dawn check` pass (`packages/cli/src/lib/runtime/collect-tool-scope-errors.ts`); the `isSubagent` threading precedent for `threadId` plumbing (search `isSubagent` in `execute-route.ts`). Per `feedback_gpt5_only`, any example model id uses the gpt-5 family.
+
+---
+
+## Phase A — Contract types + `config()` helper (no Docker)
+
+### Task 1: Sandbox contract types in `@dawn-ai/workspace`
+
+**Files:**
+- Create: `packages/workspace/src/sandbox-types.ts`
+- Modify: `packages/workspace/src/index.ts`
+- Test: `packages/workspace/test/sandbox-types.test.ts`
+
+- [ ] **Step 1: Write the failing test** (type-level + a structural smoke so the module exists)
+
+```ts
+// packages/workspace/test/sandbox-types.test.ts
+import { describe, expect, test } from "vitest"
+import type {
+ SandboxConfig,
+ SandboxHandle,
+ SandboxPolicy,
+ SandboxProvider,
+} from "../src/sandbox-types.ts"
+import type { ExecBackend, FilesystemBackend } from "../src/types.ts"
+
+describe("sandbox contract types", () => {
+ test("a handle exposes workspace backends + an in-sandbox root", () => {
+ const fs = {} as FilesystemBackend
+ const exec = {} as ExecBackend
+ const handle: SandboxHandle = { threadId: "t1", filesystem: fs, exec, workspaceRoot: "/workspace" }
+ expect(handle.workspaceRoot).toBe("/workspace")
+ })
+
+ test("policy network is a discriminated union (allow|deny)", () => {
+ const allow: SandboxPolicy["network"] = { mode: "allow", denylist: ["1.2.3.4"] }
+ const deny: SandboxPolicy["network"] = { mode: "deny", allowlist: ["registry.npmjs.org"] }
+ expect(allow.mode).toBe("allow")
+ expect(deny.mode).toBe("deny")
+ })
+
+ test("a provider implements acquire/release/destroy", async () => {
+ const provider: SandboxProvider = {
+ name: "noop",
+ acquire: async ({ threadId }) => ({
+ threadId,
+ filesystem: {} as FilesystemBackend,
+ exec: {} as ExecBackend,
+ workspaceRoot: "/workspace",
+ }),
+ release: async () => {},
+ destroy: async () => {},
+ }
+ const h = await provider.acquire({ threadId: "t1", policy: { network: { mode: "allow" } }, signal: new AbortController().signal })
+ expect(h.threadId).toBe("t1")
+ const cfg: SandboxConfig = { provider }
+ expect(cfg.provider.name).toBe("noop")
+ })
+})
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `pnpm --filter @dawn-ai/workspace test sandbox-types`
+Expected: FAIL — cannot find module `../src/sandbox-types.ts`.
+
+- [ ] **Step 3: Implement the types**
+
+```ts
+// packages/workspace/src/sandbox-types.ts
+/**
+ * Execution-sandbox contract. A SandboxProvider yields, per conversation
+ * thread, a SandboxHandle whose filesystem/exec backends implement the same
+ * interfaces the workspace capability already consumes — so swapping them in
+ * redirects all of readFile/writeFile/listDir/runBash into the isolated env
+ * with no change to the capability. See the execution-sandbox spec.
+ */
+import type { ExecBackend, FilesystemBackend } from "./types.js"
+
+export interface SandboxPolicy {
+ readonly network:
+ | { readonly mode: "allow"; readonly denylist?: readonly string[] }
+ | { readonly mode: "deny"; readonly allowlist?: readonly string[] }
+ /** Explicit env injected into the sandbox. The host env is NEVER inherited. */
+ readonly env?: Readonly>
+ readonly resources?: {
+ readonly memoryMb?: number
+ readonly cpus?: number
+ readonly timeoutMs?: number
+ }
+}
+
+export interface SandboxHandle {
+ readonly threadId: string
+ readonly filesystem: FilesystemBackend
+ readonly exec: ExecBackend
+ /** Absolute path of the workspace root INSIDE the sandbox, e.g. "/workspace". */
+ readonly workspaceRoot: string
+}
+
+export interface SandboxProvider {
+ readonly name: string
+ /**
+ * Create-or-reattach the thread's sandbox. Idempotent per threadId: called at
+ * the start of every turn; returns the same live sandbox across turns until
+ * release()/destroy(). Reattaches an existing workspace volume by deterministic
+ * name after a restart or container reap rather than starting empty.
+ */
+ acquire(input: {
+ readonly threadId: string
+ readonly policy: SandboxPolicy
+ readonly signal: AbortSignal
+ }): Promise
+ /** Drop warm compute but KEEP the workspace volume (idle-reap + shutdown). */
+ release(threadId: string): Promise
+ /** Destroy the sandbox AND its workspace volume (thread delete). */
+ destroy(threadId: string): Promise
+ /** Optional availability probe surfaced by `dawn check`. */
+ preflight?(): Promise<{ readonly ok: boolean; readonly detail?: string }>
+}
+
+export interface SandboxConfig {
+ readonly provider: SandboxProvider
+ readonly network?: SandboxPolicy["network"]
+ readonly env?: SandboxPolicy["env"]
+ readonly resources?: SandboxPolicy["resources"]
+ /** Manager-level idle reap window. Default 600_000 (10 min). */
+ readonly idleTimeoutMs?: number
+}
+```
+
+- [ ] **Step 4: Export from the package index**
+
+Add to `packages/workspace/src/index.ts`:
+
+```ts
+export type {
+ SandboxConfig,
+ SandboxHandle,
+ SandboxPolicy,
+ SandboxProvider,
+} from "./sandbox-types.js"
+```
+
+- [ ] **Step 5: Run test + typecheck**
+
+Run: `pnpm --filter @dawn-ai/workspace test sandbox-types && pnpm --filter @dawn-ai/workspace typecheck`
+Expected: PASS, no type errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/workspace/src/sandbox-types.ts packages/workspace/src/index.ts packages/workspace/test/sandbox-types.test.ts
+git commit -m "feat(workspace): sandbox provider contract types"
+```
+
+### Task 2: `DawnConfig.sandbox` + `config()` helper
+
+**Files:**
+- Modify: `packages/core/src/types.ts` (the `DawnConfig` interface, ~line 9-80)
+- Create: `packages/core/src/config-helper.ts`
+- Modify: `packages/core/src/index.ts`
+- Modify: `packages/cli/src/index.ts` (re-export `config`)
+- Test: `packages/core/test/config-helper.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// packages/core/test/config-helper.test.ts
+import { describe, expect, test } from "vitest"
+import { config } from "../src/config-helper.ts"
+import type { DawnConfig } from "../src/types.ts"
+
+describe("config()", () => {
+ test("returns the same object (identity) for IntelliSense", () => {
+ const c: DawnConfig = { appDir: "src/app" }
+ expect(config(c)).toBe(c)
+ })
+
+ test("accepts a sandbox key", () => {
+ const provider = {
+ name: "noop",
+ acquire: async () => ({ threadId: "t", filesystem: {} as never, exec: {} as never, workspaceRoot: "/workspace" }),
+ release: async () => {},
+ destroy: async () => {},
+ }
+ const c = config({ sandbox: { provider, network: { mode: "deny" } } })
+ expect(c.sandbox?.provider.name).toBe("noop")
+ })
+})
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `pnpm --filter @dawn-ai/core test config-helper`
+Expected: FAIL — cannot find `../src/config-helper.ts`, and `sandbox` not on `DawnConfig`.
+
+- [ ] **Step 3: Add the `sandbox` key to `DawnConfig`**
+
+In `packages/core/src/types.ts`, add the import (near the existing `@dawn-ai/workspace` import) and the key (alongside the other optional keys):
+
+```ts
+import type { ExecBackend, FilesystemBackend, SandboxConfig } from "@dawn-ai/workspace"
+// ... inside interface DawnConfig:
+ readonly sandbox?: SandboxConfig
+```
+
+- [ ] **Step 4: Implement `config()`**
+
+```ts
+// packages/core/src/config-helper.ts
+import type { DawnConfig } from "./types.js"
+
+/**
+ * Typed identity helper for `dawn.config.ts`. Purely for IntelliSense — the
+ * loader reads `export default`, so `export default config({...})` and a bare
+ * `export default {...}` are equivalent at runtime.
+ */
+export function config(c: DawnConfig): DawnConfig {
+ return c
+}
+```
+
+- [ ] **Step 5: Export from core, re-export from cli**
+
+`packages/core/src/index.ts`:
+
+```ts
+export { config } from "./config-helper.js"
+```
+
+`packages/cli/src/index.ts` (mirror however core symbols are already re-exported there; if `agent` is re-exported, add `config` beside it):
+
+```ts
+export { config } from "@dawn-ai/core"
+```
+
+- [ ] **Step 6: Run tests + typecheck both packages**
+
+Run: `pnpm --filter @dawn-ai/core test config-helper && pnpm --filter @dawn-ai/core typecheck && pnpm --filter @dawn-ai/cli typecheck`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add packages/core/src/types.ts packages/core/src/config-helper.ts packages/core/src/index.ts packages/cli/src/index.ts packages/core/test/config-helper.test.ts
+git commit -m "feat(core): DawnConfig.sandbox key + typed config() helper"
+```
+
+---
+
+## Phase B — `@dawn-ai/sandbox` package: `fakeSandbox`, conformance kit, `SandboxManager`
+
+### Task 3: Scaffold the `@dawn-ai/sandbox` package
+
+**Files:**
+- Create: `packages/sandbox/package.json`, `packages/sandbox/tsconfig.json`, `packages/sandbox/tsconfig.build.json`, `packages/sandbox/vitest.config.ts`, `packages/sandbox/src/index.ts`
+- Modify: `vitest.workspace.ts` (add the project), root release config if it enumerates packages (it does not — fixed group is in `.changeset/config.json`; confirm the new pkg name is covered by the `@dawn-ai/*` patterns)
+
+- [ ] **Step 1: Create `package.json`** (mirror `packages/workspace/package.json` — a leaf-ish lib; sandbox depends only on `@dawn-ai/workspace` for the contract types)
+
+```jsonc
+{
+ "name": "@dawn-ai/sandbox",
+ "version": "0.8.4", // match the current fixed-group version at implementation time
+ "type": "module",
+ "exports": {
+ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
+ "./testing": { "types": "./dist/testing/index.d.ts", "default": "./dist/testing/index.js" }
+ },
+ "files": ["dist"],
+ "publishConfig": { "access": "public" },
+ "scripts": {
+ "build": "tsc -b tsconfig.build.json",
+ "lint": "biome check --config-path ../config-biome/biome.json package.json src test tsconfig.json vitest.config.ts",
+ "test": "vitest run",
+ "typecheck": "tsc --noEmit -p tsconfig.json"
+ },
+ "dependencies": { "@dawn-ai/workspace": "workspace:*" },
+ "devDependencies": {
+ "@dawn-ai/config-biome": "workspace:*",
+ "@dawn-ai/config-typescript": "workspace:*",
+ "vitest": "catalog:" // match how other packages reference vitest (check packages/workspace/package.json)
+ }
+}
+```
+
+- [ ] **Step 2: Create `tsconfig.json`, `tsconfig.build.json`, `vitest.config.ts`** by copying `packages/workspace/`'s equivalents verbatim, then adjusting any `references` to point at `../workspace`. (Open `packages/workspace/tsconfig.build.json` and replicate, adding `{ "path": "../workspace" }` to `references`.)
+
+- [ ] **Step 3: Create a placeholder index that re-exports the contract types** (so the public type surface is importable from `@dawn-ai/sandbox` too)
+
+```ts
+// packages/sandbox/src/index.ts
+export type {
+ SandboxConfig,
+ SandboxHandle,
+ SandboxPolicy,
+ SandboxProvider,
+} from "@dawn-ai/workspace"
+// dockerSandbox is added in Phase E.
+```
+
+- [ ] **Step 4: Register the vitest project**
+
+Add `"./packages/sandbox/vitest.config.ts"` to the `projects` array in `vitest.workspace.ts`.
+
+- [ ] **Step 5: Install + build to wire the workspace**
+
+Run: `pnpm install && pnpm --filter @dawn-ai/sandbox build`
+Expected: installs the new workspace package; build emits `dist/index.js`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/sandbox vitest.workspace.ts pnpm-lock.yaml
+git commit -m "chore(sandbox): scaffold @dawn-ai/sandbox package"
+```
+
+### Task 4: `fakeSandbox` — in-memory provider
+
+**Files:**
+- Create: `packages/sandbox/src/testing/fake-sandbox.ts`, `packages/sandbox/src/testing/index.ts`
+- Test: `packages/sandbox/test/fake-sandbox.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// packages/sandbox/test/fake-sandbox.test.ts
+import { describe, expect, test } from "vitest"
+import { fakeSandbox } from "../src/testing/index.ts"
+
+const ctx = (workspaceRoot: string) => ({ signal: new AbortController().signal, workspaceRoot })
+
+describe("fakeSandbox", () => {
+ test("isolates filesystem per thread, persists across acquire (reattach)", async () => {
+ const provider = fakeSandbox()
+ const a1 = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ await a1.filesystem.writeFile("/workspace/note.txt", "hello", ctx(a1.workspaceRoot))
+
+ // same thread: reattach sees the file
+ const a2 = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ expect(await a2.filesystem.readFile("/workspace/note.txt", ctx(a2.workspaceRoot))).toBe("hello")
+
+ // different thread: empty + cannot see thread a's file
+ const b = await provider.acquire({ threadId: "b", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ expect(await b.filesystem.listDir("/workspace", ctx(b.workspaceRoot))).toEqual([])
+ })
+
+ test("release keeps the volume, destroy clears it", async () => {
+ const provider = fakeSandbox()
+ const h = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ await h.filesystem.writeFile("/workspace/f", "1", ctx(h.workspaceRoot))
+
+ await provider.release("a") // keep volume
+ const after = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ expect(await after.filesystem.readFile("/workspace/f", ctx(after.workspaceRoot))).toBe("1")
+
+ await provider.destroy("a") // clear volume
+ const fresh = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ expect(await fresh.filesystem.listDir("/workspace", ctx(fresh.workspaceRoot))).toEqual([])
+ })
+
+ test("exec is scripted + records commands; runBash sees fs writes", async () => {
+ const provider = fakeSandbox({ exec: async ({ command }) => ({ stdout: `ran:${command}`, stderr: "", exitCode: 0 }) })
+ const h = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ const r = await h.exec.runCommand({ command: "echo hi" }, ctx(h.workspaceRoot))
+ expect(r).toEqual({ stdout: "ran:echo hi", stderr: "", exitCode: 0 })
+ })
+})
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `pnpm --filter @dawn-ai/sandbox test fake-sandbox`
+Expected: FAIL — cannot find `../src/testing/index.ts`.
+
+- [ ] **Step 3: Implement `fakeSandbox`**
+
+```ts
+// packages/sandbox/src/testing/fake-sandbox.ts
+import type {
+ BackendContext,
+ ExecBackend,
+ FilesystemBackend,
+ SandboxHandle,
+ SandboxProvider,
+} from "@dawn-ai/workspace"
+
+type ExecFn = (
+ args: { readonly command: string; readonly cwd?: string; readonly env?: Readonly> },
+ ctx: BackendContext,
+) => Promise<{ readonly stdout: string; readonly stderr: string; readonly exitCode: number }>
+
+const ROOT = "/workspace"
+
+/** In-memory SandboxProvider for unit + wiring tests. No Docker. */
+export function fakeSandbox(opts: { readonly exec?: ExecFn } = {}): SandboxProvider {
+ // volume per thread: path -> content. Survives release(), cleared by destroy().
+ const volumes = new Map>()
+ const liveThreads = new Set()
+
+ const volumeFor = (threadId: string): Map => {
+ let v = volumes.get(threadId)
+ if (!v) {
+ v = new Map()
+ volumes.set(threadId, v)
+ }
+ return v
+ }
+
+ const makeFilesystem = (vol: Map): FilesystemBackend => ({
+ async readFile(path) {
+ const v = vol.get(path)
+ if (v === undefined) throw new Error(`ENOENT: ${path}`)
+ return v
+ },
+ async writeFile(path, content) {
+ vol.set(path, content)
+ return { bytesWritten: Buffer.byteLength(content) }
+ },
+ async listDir(path) {
+ const prefix = path.endsWith("/") ? path : `${path}/`
+ const names = new Set()
+ for (const key of vol.keys()) {
+ if (key.startsWith(prefix)) names.add(key.slice(prefix.length).split("/")[0]!)
+ }
+ return [...names].sort()
+ },
+ async realPath(path) {
+ return path
+ },
+ })
+
+ const defaultExec: ExecFn = async () => ({ stdout: "", stderr: "", exitCode: 0 })
+
+ return {
+ name: "fake",
+ async acquire({ threadId }): Promise {
+ liveThreads.add(threadId)
+ const vol = volumeFor(threadId)
+ const exec: ExecBackend = { runCommand: (args, ctx) => (opts.exec ?? defaultExec)(args, ctx) }
+ return { threadId, filesystem: makeFilesystem(vol), exec, workspaceRoot: ROOT }
+ },
+ async release(threadId) {
+ liveThreads.delete(threadId) // keep the volume
+ },
+ async destroy(threadId) {
+ liveThreads.delete(threadId)
+ volumes.delete(threadId)
+ },
+ async preflight() {
+ return { ok: true }
+ },
+ }
+}
+```
+
+```ts
+// packages/sandbox/src/testing/index.ts
+export { fakeSandbox } from "./fake-sandbox.js"
+// runProviderConformance is added in Task 5.
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pnpm --filter @dawn-ai/sandbox test fake-sandbox`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/sandbox/src/testing packages/sandbox/test/fake-sandbox.test.ts
+git commit -m "feat(sandbox): in-memory fakeSandbox provider for tests"
+```
+
+### Task 5: Provider conformance kit
+
+**Files:**
+- Create: `packages/sandbox/src/testing/conformance.ts`
+- Modify: `packages/sandbox/src/testing/index.ts`
+- Test: `packages/sandbox/test/conformance-fake.test.ts`
+
+- [ ] **Step 1: Write the failing test** (run the kit against `fakeSandbox`)
+
+```ts
+// packages/sandbox/test/conformance-fake.test.ts
+import { describe } from "vitest"
+import { fakeSandbox, runProviderConformance } from "../src/testing/index.ts"
+
+runProviderConformance({
+ name: "fakeSandbox",
+ makeProvider: () => fakeSandbox(),
+ describe,
+})
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `pnpm --filter @dawn-ai/sandbox test conformance-fake`
+Expected: FAIL — `runProviderConformance` is not exported.
+
+- [ ] **Step 3: Implement the conformance kit**
+
+```ts
+// packages/sandbox/src/testing/conformance.ts
+import { expect, test } from "vitest"
+import type { SandboxProvider } from "@dawn-ai/workspace"
+
+const ctx = (workspaceRoot: string) => ({ signal: new AbortController().signal, workspaceRoot })
+const policy = { network: { mode: "allow" } } as const
+
+/**
+ * The contract every SandboxProvider must satisfy. Reused by fakeSandbox (CI)
+ * and dockerSandbox (gated Docker lane) so the fake cannot drift from reality.
+ * Pass vitest's `describe` so the kit can group under any runner.
+ */
+export function runProviderConformance(opts: {
+ readonly name: string
+ readonly makeProvider: () => SandboxProvider
+ readonly describe: (name: string, fn: () => void) => void
+}): void {
+ opts.describe(`SandboxProvider conformance: ${opts.name}`, () => {
+ test("acquire is idempotent per thread and reattaches the workspace", async () => {
+ const p = opts.makeProvider()
+ const a = await p.acquire({ threadId: "t1", policy, signal: ctx("/").signal })
+ await a.filesystem.writeFile(`${a.workspaceRoot}/x`, "1", ctx(a.workspaceRoot))
+ const b = await p.acquire({ threadId: "t1", policy, signal: ctx("/").signal })
+ expect(await b.filesystem.readFile(`${b.workspaceRoot}/x`, ctx(b.workspaceRoot))).toBe("1")
+ await p.destroy("t1")
+ })
+
+ test("threads are isolated", async () => {
+ const p = opts.makeProvider()
+ const a = await p.acquire({ threadId: "a", policy, signal: ctx("/").signal })
+ await a.filesystem.writeFile(`${a.workspaceRoot}/secret`, "s", ctx(a.workspaceRoot))
+ const b = await p.acquire({ threadId: "b", policy, signal: ctx("/").signal })
+ expect(await b.filesystem.listDir(b.workspaceRoot, ctx(b.workspaceRoot))).not.toContain("secret")
+ await p.destroy("a")
+ await p.destroy("b")
+ })
+
+ test("release keeps the volume, destroy clears it", async () => {
+ const p = opts.makeProvider()
+ const a = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal })
+ await a.filesystem.writeFile(`${a.workspaceRoot}/keep`, "1", ctx(a.workspaceRoot))
+ await p.release("t")
+ const r = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal })
+ expect(await r.filesystem.readFile(`${r.workspaceRoot}/keep`, ctx(r.workspaceRoot))).toBe("1")
+ await p.destroy("t")
+ const d = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal })
+ expect(await d.filesystem.listDir(d.workspaceRoot, ctx(d.workspaceRoot))).not.toContain("keep")
+ await p.destroy("t")
+ })
+
+ test("exec returns a numeric exit code", async () => {
+ const p = opts.makeProvider()
+ const a = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal })
+ const r = await a.exec.runCommand({ command: "true" }, ctx(a.workspaceRoot))
+ expect(typeof r.exitCode).toBe("number")
+ await p.destroy("t")
+ })
+ })
+}
+```
+
+Add to `packages/sandbox/src/testing/index.ts`:
+
+```ts
+export { runProviderConformance } from "./conformance.js"
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pnpm --filter @dawn-ai/sandbox test conformance-fake`
+Expected: PASS (4 conformance tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/sandbox/src/testing/conformance.ts packages/sandbox/src/testing/index.ts packages/sandbox/test/conformance-fake.test.ts
+git commit -m "feat(sandbox): provider conformance kit"
+```
+
+### Task 6: `SandboxManager` (per-thread lifecycle)
+
+**Files:**
+- Create: `packages/cli/src/lib/runtime/sandbox-manager.ts`
+- Test: `packages/cli/src/lib/runtime/sandbox-manager.test.ts`
+- Note: `@dawn-ai/cli` must add `@dawn-ai/sandbox` as a **devDependency** (the manager only needs the contract types from `@dawn-ai/workspace`, which cli gets transitively via core; `@dawn-ai/sandbox` is a devDep so tests can import `fakeSandbox`).
+
+- [ ] **Step 1: Add `@dawn-ai/sandbox` devDep to cli**
+
+In `packages/cli/package.json` `devDependencies`: `"@dawn-ai/sandbox": "workspace:*"`, then `pnpm install`.
+
+- [ ] **Step 2: Write the failing test**
+
+```ts
+// packages/cli/src/lib/runtime/sandbox-manager.test.ts
+import { describe, expect, test, vi } from "vitest"
+import { fakeSandbox } from "@dawn-ai/sandbox/testing"
+import type { SandboxProvider } from "@dawn-ai/workspace"
+import { SandboxManager } from "./sandbox-manager.ts"
+
+const policy = { network: { mode: "allow" } } as const
+const signal = () => new AbortController().signal
+const now = { t: 1_000 }
+const clock = () => now.t
+
+describe("SandboxManager", () => {
+ test("reuses one handle across turns for a thread", async () => {
+ const provider = fakeSandbox()
+ const acquire = vi.spyOn(provider, "acquire")
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 10_000, clock })
+ const h1 = await mgr.getForThread("t1", signal())
+ const h2 = await mgr.getForThread("t1", signal())
+ expect(h1).toBe(h2)
+ expect(acquire).toHaveBeenCalledTimes(1)
+ })
+
+ test("dedups concurrent acquires for the same thread", async () => {
+ const provider = fakeSandbox()
+ const acquire = vi.spyOn(provider, "acquire")
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 10_000, clock })
+ const [a, b] = await Promise.all([mgr.getForThread("t1", signal()), mgr.getForThread("t1", signal())])
+ expect(a).toBe(b)
+ expect(acquire).toHaveBeenCalledTimes(1)
+ })
+
+ test("reapIdle releases (not destroys) idle threads, keeping the volume", async () => {
+ const provider = fakeSandbox()
+ const release = vi.spyOn(provider, "release")
+ const destroy = vi.spyOn(provider, "destroy")
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 10_000, clock })
+ await mgr.getForThread("t1", signal())
+ now.t = 25_000 // advance past idle window
+ await mgr.reapIdle()
+ expect(release).toHaveBeenCalledWith("t1")
+ expect(destroy).not.toHaveBeenCalled()
+ // next turn re-acquires
+ await mgr.getForThread("t1", signal())
+ })
+
+ test("does not reap an in-flight (in-use) thread", async () => {
+ const provider = fakeSandbox()
+ const release = vi.spyOn(provider, "release")
+ let resolveAcquire!: () => void
+ vi.spyOn(provider, "acquire").mockImplementation(
+ () => new Promise((r) => { resolveAcquire = () => r({ threadId: "t1", filesystem: {} as never, exec: {} as never, workspaceRoot: "/workspace" }) }),
+ )
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 1, clock })
+ const inflight = mgr.getForThread("t1", signal())
+ now.t = 1_000_000
+ await mgr.reapIdle()
+ expect(release).not.toHaveBeenCalled()
+ resolveAcquire()
+ await inflight
+ })
+
+ test("releaseThread destroys + drops the entry", async () => {
+ const provider = fakeSandbox()
+ const destroy = vi.spyOn(provider, "destroy")
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 10_000, clock })
+ await mgr.getForThread("t1", signal())
+ await mgr.destroyThread("t1")
+ expect(destroy).toHaveBeenCalledWith("t1")
+ })
+
+ test("releaseAll releases every live thread", async () => {
+ const provider = fakeSandbox()
+ const release = vi.spyOn(provider, "release")
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 10_000, clock })
+ await mgr.getForThread("a", signal())
+ await mgr.getForThread("b", signal())
+ await mgr.releaseAll()
+ expect(release.mock.calls.map((c) => c[0]).sort()).toEqual(["a", "b"])
+ })
+})
+```
+
+- [ ] **Step 3: Run it to verify it fails**
+
+Run: `pnpm --filter @dawn-ai/cli test sandbox-manager`
+Expected: FAIL — cannot find `./sandbox-manager.ts`.
+
+- [ ] **Step 4: Implement `SandboxManager`**
+
+```ts
+// packages/cli/src/lib/runtime/sandbox-manager.ts
+import type { SandboxHandle, SandboxPolicy, SandboxProvider } from "@dawn-ai/workspace"
+
+interface Entry {
+ handle?: SandboxHandle
+ acquiring?: Promise
+ lastUsedAt: number
+ inUse: number
+}
+
+/**
+ * Owns the per-thread sandbox lifecycle. One instance per server process.
+ * - getForThread: create-or-reuse the thread's handle (concurrent acquires deduped).
+ * - reapIdle: release() warm compute for threads idle past idleTimeoutMs (volume kept).
+ * - destroyThread: full teardown (volume removed) — thread delete.
+ * - releaseAll: shutdown — release() everything (volume kept).
+ */
+export class SandboxManager {
+ readonly #provider: SandboxProvider
+ readonly #policy: SandboxPolicy
+ readonly #idleTimeoutMs: number
+ readonly #clock: () => number
+ readonly #entries = new Map()
+
+ constructor(opts: {
+ provider: SandboxProvider
+ policy: SandboxPolicy
+ idleTimeoutMs: number
+ clock?: () => number
+ }) {
+ this.#provider = opts.provider
+ this.#policy = opts.policy
+ this.#idleTimeoutMs = opts.idleTimeoutMs
+ this.#clock = opts.clock ?? Date.now
+ }
+
+ async getForThread(threadId: string, signal: AbortSignal): Promise {
+ const existing = this.#entries.get(threadId)
+ if (existing?.handle) {
+ existing.lastUsedAt = this.#clock()
+ existing.inUse += 1
+ try {
+ return existing.handle
+ } finally {
+ existing.inUse -= 1
+ }
+ }
+ if (existing?.acquiring) return existing.acquiring
+
+ const entry: Entry = { lastUsedAt: this.#clock(), inUse: 1 }
+ this.#entries.set(threadId, entry)
+ entry.acquiring = this.#provider
+ .acquire({ threadId, policy: this.#policy, signal })
+ .then((handle) => {
+ entry.handle = handle
+ entry.acquiring = undefined
+ entry.lastUsedAt = this.#clock()
+ return handle
+ })
+ .catch((err) => {
+ this.#entries.delete(threadId) // never cache a failed acquire
+ throw err
+ })
+ .finally(() => {
+ entry.inUse -= 1
+ })
+ return entry.acquiring
+ }
+
+ async reapIdle(): Promise {
+ const cutoff = this.#clock() - this.#idleTimeoutMs
+ for (const [threadId, entry] of [...this.#entries]) {
+ if (entry.inUse > 0 || entry.acquiring) continue
+ if (entry.lastUsedAt > cutoff) continue
+ this.#entries.delete(threadId)
+ await this.#provider.release(threadId) // keep the volume
+ }
+ }
+
+ async destroyThread(threadId: string): Promise {
+ this.#entries.delete(threadId)
+ await this.#provider.destroy(threadId)
+ }
+
+ async releaseAll(): Promise {
+ const ids = [...this.#entries.keys()]
+ this.#entries.clear()
+ await Promise.all(ids.map((id) => this.#provider.release(id)))
+ }
+}
+```
+
+- [ ] **Step 5: Run tests + typecheck**
+
+Run: `pnpm --filter @dawn-ai/cli test sandbox-manager && pnpm --filter @dawn-ai/cli typecheck`
+Expected: PASS (6 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/cli/package.json packages/cli/src/lib/runtime/sandbox-manager.ts packages/cli/src/lib/runtime/sandbox-manager.test.ts pnpm-lock.yaml
+git commit -m "feat(cli): SandboxManager per-thread lifecycle"
+```
+
+---
+
+## Phase C — Runtime wiring
+
+### Task 7: Resolve the manager from config + thread the handle into route prep
+
+**Files:**
+- Create: `packages/cli/src/lib/runtime/resolve-sandbox.ts`
+- Modify: `packages/cli/src/lib/runtime/execute-route.ts` (the `streamResolvedRoute`/`executeResolvedRoute`/`prepareRouteExecution` chain + backend construction sites at ~441-450, ~491-497, ~548-556)
+- Test: `packages/cli/src/lib/runtime/resolve-sandbox.test.ts`
+
+- [ ] **Step 1: Write the failing test for `resolveSandboxManager`**
+
+```ts
+// packages/cli/src/lib/runtime/resolve-sandbox.test.ts
+import { mkdtemp, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { afterEach, describe, expect, test } from "vitest"
+import { resolveSandboxManager } from "./resolve-sandbox.ts"
+
+const dirs: string[] = []
+afterEach(async () => { /* tmp dirs auto-clean by OS; nothing to do */ })
+
+describe("resolveSandboxManager", () => {
+ test("returns undefined when no dawn.config.ts / no sandbox key", async () => {
+ const appRoot = await mkdtemp(join(tmpdir(), "dawn-sbx-cfg-"))
+ dirs.push(appRoot)
+ expect(await resolveSandboxManager(appRoot)).toBeUndefined()
+ })
+
+ test("builds a manager from config.sandbox.provider", async () => {
+ const appRoot = await mkdtemp(join(tmpdir(), "dawn-sbx-cfg-"))
+ dirs.push(appRoot)
+ await writeFile(
+ join(appRoot, "dawn.config.ts"),
+ [
+ `import { fakeSandbox } from "@dawn-ai/sandbox/testing"`,
+ `export default { sandbox: { provider: fakeSandbox(), network: { mode: "deny" } } }`,
+ ].join("\n"),
+ "utf8",
+ )
+ const mgr = await resolveSandboxManager(appRoot)
+ expect(mgr).toBeDefined()
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `pnpm --filter @dawn-ai/cli test resolve-sandbox`
+Expected: FAIL — cannot find `./resolve-sandbox.ts`.
+
+- [ ] **Step 3: Implement `resolveSandboxManager`** (mirrors `resolveCheckpointer`, `execute-route.ts:189-203`)
+
+```ts
+// packages/cli/src/lib/runtime/resolve-sandbox.ts
+import { loadDawnConfig } from "@dawn-ai/core"
+import type { SandboxPolicy } from "@dawn-ai/workspace"
+import { SandboxManager } from "./sandbox-manager.js"
+
+const DEFAULT_IDLE_MS = 600_000
+const DEFAULT_NETWORK: SandboxPolicy["network"] = { mode: "allow", denylist: ["169.254.169.254"] }
+
+/** Build the per-server SandboxManager from dawn.config.ts, or undefined if unconfigured. */
+export async function resolveSandboxManager(appRoot: string): Promise {
+ let sandbox: import("@dawn-ai/workspace").SandboxConfig | undefined
+ try {
+ const loaded = await loadDawnConfig({ appRoot })
+ sandbox = loaded.config.sandbox
+ } catch {
+ return undefined // no dawn.config.ts
+ }
+ if (!sandbox) return undefined
+ const policy: SandboxPolicy = {
+ network: sandbox.network ?? DEFAULT_NETWORK,
+ ...(sandbox.env ? { env: sandbox.env } : {}),
+ ...(sandbox.resources ? { resources: sandbox.resources } : {}),
+ }
+ return new SandboxManager({
+ provider: sandbox.provider,
+ policy,
+ idleTimeoutMs: sandbox.idleTimeoutMs ?? DEFAULT_IDLE_MS,
+ })
+}
+```
+
+- [ ] **Step 4: Thread `threadId` + `sandboxManager` into route prep**
+
+In `execute-route.ts`:
+1. Add `readonly sandboxManager?: SandboxManager` and ensure `readonly threadId?: string` to the options of `streamResolvedRoute`, `executeResolvedRoute`, and `prepareRouteExecution` (search the existing `threadId` option on `streamResolvedRoute` ~line 248 and the `isSubagent` option to copy the threading pattern). Pass both from `streamResolvedRoute`/`executeResolvedRoute` down into `prepareRouteExecution`.
+2. Inside `prepareRouteExecution`, BEFORE building backends (before `loadDawnConfig`/`configBackends` at ~441 and `createWorkspaceFs` at ~491), resolve the handle when both are present:
+
+```ts
+// inside prepareRouteExecution, after options are in scope
+let sandboxBackends: { filesystem: FilesystemBackend; exec: ExecBackend } | undefined
+let sandboxWorkspaceRoot: string | undefined
+if (options.sandboxManager && options.threadId) {
+ const handle = await options.sandboxManager.getForThread(
+ options.threadId,
+ options.signal ?? new AbortController().signal,
+ )
+ sandboxBackends = { filesystem: handle.filesystem, exec: handle.exec }
+ sandboxWorkspaceRoot = handle.workspaceRoot
+}
+```
+
+3. At each backend-construction site, prefer the sandbox handle:
+ - `createWorkspaceFs` (~491): `workspaceRoot: sandboxWorkspaceRoot ?? join(options.appRoot, "workspace")`, `backend: sandboxBackends?.filesystem ?? configBackends?.filesystem ?? localFilesystem()`.
+ - `applyCapabilities` backends (~552): pass `backends: sandboxBackends ?? configBackends` (the workspace capability also reads `workspaceRoot` from `context.appRoot` today; if the capability computes the host `workspace/` dir from `appRoot`, additionally thread `sandboxWorkspaceRoot` through `CapabilityMarkerContext` — see Step 5).
+ - offload store (~1091): `backend: sandboxBackends?.filesystem ?? filesystem ?? localFilesystem()`.
+
+- [ ] **Step 5: Thread the in-sandbox `workspaceRoot` into the workspace capability**
+
+The workspace marker computes `workspaceRoot(context.appRoot)` (host `/workspace`) in `packages/core/src/capabilities/built-in/workspace.ts:121-123`. Add an optional `workspaceRoot?: string` to `CapabilityMarkerContext` (`packages/core/src/capabilities/types.ts`) and, in the workspace marker, prefer it: `const root = context.workspaceRoot ?? workspaceRoot(context.appRoot)`. When sandboxed, `detect` must return `true` without a host `existsSync` check — gate the `existsSync` on `context.workspaceRoot` being absent:
+
+```ts
+detect: async (_routeDir, context) =>
+ context.workspaceRoot !== undefined || existsSync(workspaceRoot(context.appRoot)),
+load: async (_routeDir, context) => {
+ const root = context.workspaceRoot ?? workspaceRoot(context.appRoot)
+ if (context.workspaceRoot === undefined && !existsSync(root)) return {}
+ const fs = context.backends?.filesystem ?? localFilesystem()
+ const exec = context.backends?.exec ?? localExec()
+ // ...unchanged
+}
+```
+
+Pass `...(sandboxWorkspaceRoot ? { workspaceRoot: sandboxWorkspaceRoot } : {})` in the `applyCapabilities` context object.
+
+- [ ] **Step 6: Run typecheck + the resolve test**
+
+Run: `pnpm --filter @dawn-ai/cli typecheck && pnpm --filter @dawn-ai/core typecheck && pnpm --filter @dawn-ai/cli test resolve-sandbox`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add packages/cli/src/lib/runtime/resolve-sandbox.ts packages/cli/src/lib/runtime/resolve-sandbox.test.ts packages/cli/src/lib/runtime/execute-route.ts packages/core/src/capabilities/built-in/workspace.ts packages/core/src/capabilities/types.ts
+git commit -m "feat(cli): resolve sandbox manager + route the workspace into the thread sandbox"
+```
+
+### Task 8: Server singleton + DELETE/shutdown hooks + idle reaper
+
+**Files:**
+- Modify: `packages/cli/src/lib/dev/runtime-server.ts` (`createRuntimeRequestListener` ~54-76; DELETE handler ~268-285; `close()` ~107-133)
+- Modify: `packages/cli/src/lib/runtime/build-route-table.ts` (wherever `buildRouteTable` threads singletons + calls `streamResolvedRoute`/`executeResolvedRoute` — pass `sandboxManager` + `threadId`)
+- Test: covered by Task 10's wiring e2e (a unit test of the server lifecycle would require booting the server; the DELETE/shutdown hooks are asserted via the e2e + a focused manager test already exists).
+
+- [ ] **Step 1: Build the manager singleton + reaper in `createRuntimeRequestListener`**
+
+After `threadsStore`/`checkpointer` are resolved (~60), add:
+
+```ts
+const sandboxManager = await resolveSandboxManager(options.appRoot)
+let reaper: ReturnType | undefined
+if (sandboxManager) {
+ reaper = setInterval(() => { void sandboxManager.reapIdle() }, 60_000)
+ reaper.unref?.()
+}
+```
+
+Pass `sandboxManager` into `buildRouteTable({ ..., sandboxManager })`.
+
+- [ ] **Step 2: Thread `sandboxManager` + `threadId` through `buildRouteTable` into the run handlers**
+
+In `build-route-table.ts`, accept `sandboxManager` and pass `{ sandboxManager, threadId }` into every `streamResolvedRoute`/`executeResolvedRoute` call (the `runs/stream` and `runs/wait` handlers already extract `thread_id`).
+
+- [ ] **Step 3: Hook DELETE → `destroyThread`**
+
+In the `DELETE /threads/{id}` handler (`runtime-server.ts:268-285`), before `res.writeHead(204)`:
+
+```ts
+if (sandboxManager) await sandboxManager.destroyThread(threadId)
+```
+
+- [ ] **Step 4: Hook shutdown → `releaseAll`**
+
+In `close()` (~107-133), after `shutdownController.abort(...)` and BEFORE the drain loop returns, add:
+
+```ts
+if (reaper) clearInterval(reaper)
+if (sandboxManager) await sandboxManager.releaseAll()
+```
+
+- [ ] **Step 5: Typecheck + boot smoke**
+
+Run: `pnpm --filter @dawn-ai/cli typecheck`
+Expected: PASS. (Behavioral coverage is Task 10.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/cli/src/lib/dev/runtime-server.ts packages/cli/src/lib/runtime/build-route-table.ts
+git commit -m "feat(cli): sandbox manager singleton, DELETE/shutdown hooks, idle reaper"
+```
+
+### Task 9: Wiring e2e via `@dawn-ai/testing` + `fakeSandbox`
+
+**Files:**
+- Create: `test/runtime/run-sandbox-wiring.test.ts`
+- Create: `test/runtime/fixtures/sandbox-app/` (a minimal app with a `workspace/` route that writes + reads a file and runs a command)
+- Modify: `test/runtime/vitest.config.ts` (add the test to `include`)
+
+- [ ] **Step 1: Write the failing aimock e2e**
+
+Build a fixture app whose route, on a user turn, calls `writeFile` then `readFile` then `runBash`. Configure `dawn.config.ts` with `sandbox: { provider: fakeSandbox() }`. Drive it through the in-process harness (`createAgentHarness` / the aimock runner used by `test/runtime/run-tool-scope.test.ts` — copy that test's setup). Assert:
+
+```ts
+// test/runtime/run-sandbox-wiring.test.ts (skeleton; mirror run-tool-scope.test.ts for harness setup)
+import { describe, expect, test } from "vitest"
+// ...harness imports identical to run-tool-scope.test.ts...
+
+describe("sandbox wiring", () => {
+ test("workspace tools route through the sandbox handle (not localExec)", async () => {
+ // 1. scaffold/point harness at test/runtime/fixtures/sandbox-app with a fakeSandbox spy provider
+ // 2. run a turn that writes "report.md" then runs "echo hi"
+ // 3. assert the file is readable back through the SAME provider's volume
+ // and NOT present on the host /workspace dir
+ // 4. assert a second thread sees an empty workspace (isolation)
+ })
+})
+```
+
+Use a `fakeSandbox` whose `exec` records commands and whose volume you can inspect after the run (export a test variant that returns the volume map, or assert via a second `acquire` on the same threadId).
+
+- [ ] **Step 2: Run to verify it fails** (before wiring is correct it will not isolate)
+
+Run: `pnpm exec vitest --run --config test/runtime/vitest.config.ts run-sandbox-wiring`
+Expected: FAIL.
+
+- [ ] **Step 3: Make it pass** — fix any wiring gaps surfaced (threadId propagation, capability `workspaceRoot`).
+
+- [ ] **Step 4: Run + false-green check** — temporarily break the wiring (force `localExec`) and confirm the test FAILS, then restore.
+
+Run: `pnpm exec vitest --run --config test/runtime/vitest.config.ts run-sandbox-wiring`
+Expected: PASS (and proven non-trivial via the false-green check).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add test/runtime/run-sandbox-wiring.test.ts test/runtime/fixtures/sandbox-app test/runtime/vitest.config.ts
+git commit -m "test(runtime): sandbox wiring e2e via fakeSandbox (no Docker)"
+```
+
+---
+
+## Phase D — `dawn check`
+
+### Task 10: Sandbox config validation + preflight pass
+
+**Files:**
+- Create: `packages/cli/src/lib/runtime/collect-sandbox-errors.ts`
+- Modify: `packages/cli/src/commands/check.ts` (~13-53; add a pass after the tool-scope pass)
+- Test: `packages/cli/src/lib/runtime/collect-sandbox-errors.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// packages/cli/src/lib/runtime/collect-sandbox-errors.test.ts
+import { describe, expect, test } from "vitest"
+import { collectSandboxErrors } from "./collect-sandbox-errors.ts"
+
+describe("collectSandboxErrors", () => {
+ test("no sandbox config → no errors", async () => {
+ expect(await collectSandboxErrors({})).toEqual([])
+ })
+
+ test("provider missing acquire → error", async () => {
+ const errors = await collectSandboxErrors({ sandbox: { provider: { name: "bad" } as never } })
+ expect(errors.join("\n")).toMatch(/acquire/)
+ })
+
+ test("preflight failure → error with detail", async () => {
+ const provider = {
+ name: "p", acquire: async () => ({}) as never, release: async () => {}, destroy: async () => {},
+ preflight: async () => ({ ok: false, detail: "Docker daemon not reachable" }),
+ }
+ const errors = await collectSandboxErrors({ sandbox: { provider } })
+ expect(errors.join("\n")).toMatch(/Docker daemon not reachable/)
+ })
+
+ test("healthy provider → no errors", async () => {
+ const provider = {
+ name: "p", acquire: async () => ({}) as never, release: async () => {}, destroy: async () => {},
+ preflight: async () => ({ ok: true }),
+ }
+ expect(await collectSandboxErrors({ sandbox: { provider } })).toEqual([])
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `pnpm --filter @dawn-ai/cli test collect-sandbox-errors`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Implement `collectSandboxErrors`**
+
+```ts
+// packages/cli/src/lib/runtime/collect-sandbox-errors.ts
+import type { DawnConfig } from "@dawn-ai/core"
+
+/** Validate the dawn.config.ts sandbox block + run the provider preflight. */
+export async function collectSandboxErrors(config: Pick): Promise {
+ const sandbox = config.sandbox
+ if (!sandbox) return []
+ const errors: string[] = []
+ const p = sandbox.provider as Partial | undefined
+ if (!p || typeof p.acquire !== "function" || typeof p.release !== "function" || typeof p.destroy !== "function") {
+ errors.push(`dawn.config sandbox.provider must implement acquire/release/destroy (got: ${p?.name ?? "undefined"}).`)
+ return errors
+ }
+ if (typeof p.preflight === "function") {
+ try {
+ const result = await p.preflight()
+ if (!result.ok) errors.push(`Sandbox provider "${p.name}" preflight failed: ${result.detail ?? "unavailable"}.`)
+ } catch (error) {
+ errors.push(`Sandbox provider "${p.name}" preflight threw: ${error instanceof Error ? error.message : String(error)}.`)
+ }
+ }
+ return errors
+}
+```
+
+- [ ] **Step 4: Wire into `dawn check`**
+
+In `packages/cli/src/commands/check.ts`, after the tool-scope pass (~45-48), load the config once and run the pass (mirror how the config is already loaded for other passes; if check doesn't load config yet, add a `loadDawnConfig` guarded by try/catch returning `{}`):
+
+```ts
+const sandboxErrors = await collectSandboxErrors(loadedConfig ?? {})
+if (sandboxErrors.length > 0) throw new CliError(sandboxErrors.join("\n"))
+```
+
+- [ ] **Step 5: Run tests + a manual check smoke**
+
+Run: `pnpm --filter @dawn-ai/cli test collect-sandbox-errors && pnpm --filter @dawn-ai/cli typecheck`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/cli/src/lib/runtime/collect-sandbox-errors.ts packages/cli/src/lib/runtime/collect-sandbox-errors.test.ts packages/cli/src/commands/check.ts
+git commit -m "feat(cli): dawn check validates sandbox config + runs provider preflight"
+```
+
+---
+
+## Phase E — Docker reference provider
+
+### Task 11: `docker` CLI wrapper
+
+**Files:**
+- Create: `packages/sandbox/src/docker/docker-cli.ts`
+- Test: `packages/sandbox/test/docker-cli.test.ts`
+
+- [ ] **Step 1: Write the failing test** (inject a fake spawner so the unit test needs no Docker)
+
+```ts
+// packages/sandbox/test/docker-cli.test.ts
+import { describe, expect, test } from "vitest"
+import { createDocker } from "../src/docker/docker-cli.ts"
+
+describe("createDocker", () => {
+ test("runs docker with args, returns stdout/exit", async () => {
+ const calls: string[][] = []
+ const docker = createDocker({
+ spawn: async (args, _opts) => { calls.push(args); return { stdout: "ok", stderr: "", exitCode: 0 } },
+ })
+ const r = await docker.run(["ps", "-q"])
+ expect(r.stdout).toBe("ok")
+ expect(calls[0]).toEqual(["ps", "-q"])
+ })
+
+ test("execInto pipes stdin and targets a container", async () => {
+ const seen: { args: string[]; stdin?: string }[] = []
+ const docker = createDocker({
+ spawn: async (args, opts) => { seen.push({ args, stdin: opts?.stdin }); return { stdout: "", stderr: "", exitCode: 0 } },
+ })
+ await docker.exec("c1", ["sh", "-c", "cat > /workspace/f"], { stdin: "data" })
+ expect(seen[0]!.args.slice(0, 2)).toEqual(["exec", "-i"])
+ expect(seen[0]!.args).toContain("c1")
+ expect(seen[0]!.stdin).toBe("data")
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `pnpm --filter @dawn-ai/sandbox test docker-cli`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Implement `createDocker`** (default spawner uses `node:child_process`; injectable for tests)
+
+```ts
+// packages/sandbox/src/docker/docker-cli.ts
+import { spawn } from "node:child_process"
+
+export interface SpawnResult { readonly stdout: string; readonly stderr: string; readonly exitCode: number }
+export type Spawner = (
+ args: readonly string[],
+ opts?: { readonly stdin?: string; readonly signal?: AbortSignal },
+) => Promise
+
+const defaultSpawn: Spawner = (args, opts) =>
+ new Promise((resolve, reject) => {
+ const child = spawn("docker", [...args], { stdio: ["pipe", "pipe", "pipe"], ...(opts?.signal ? { signal: opts.signal } : {}) })
+ let stdout = ""
+ let stderr = ""
+ child.stdout.on("data", (c) => { stdout += String(c) })
+ child.stderr.on("data", (c) => { stderr += String(c) })
+ child.on("error", reject)
+ child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }))
+ if (opts?.stdin !== undefined) child.stdin.end(opts.stdin)
+ else child.stdin.end()
+ })
+
+export interface Docker {
+ run(args: readonly string[], opts?: { signal?: AbortSignal }): Promise
+ exec(container: string, command: readonly string[], opts?: { stdin?: string; signal?: AbortSignal }): Promise
+}
+
+export function createDocker(deps: { spawn?: Spawner } = {}): Docker {
+ const sp = deps.spawn ?? defaultSpawn
+ return {
+ run: (args, opts) => sp(args, opts),
+ exec: (container, command, opts) =>
+ sp(["exec", "-i", container, ...command], opts),
+ }
+}
+```
+
+- [ ] **Step 4: Run tests** — `pnpm --filter @dawn-ai/sandbox test docker-cli` → PASS.
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/sandbox/src/docker/docker-cli.ts packages/sandbox/test/docker-cli.test.ts
+git commit -m "feat(sandbox): injectable docker CLI wrapper"
+```
+
+### Task 12: Docker filesystem + exec backends
+
+**Files:**
+- Create: `packages/sandbox/src/docker/docker-filesystem.ts`, `packages/sandbox/src/docker/docker-exec.ts`
+- Test: `packages/sandbox/test/docker-backends.test.ts` (uses an injected fake `Docker`, no real daemon)
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// packages/sandbox/test/docker-backends.test.ts
+import { describe, expect, test } from "vitest"
+import type { Docker } from "../src/docker/docker-cli.ts"
+import { dockerExec } from "../src/docker/docker-exec.ts"
+import { dockerFilesystem } from "../src/docker/docker-filesystem.ts"
+
+const ctx = { signal: new AbortController().signal, workspaceRoot: "/workspace" }
+const fakeDocker = (handlers: Partial): Docker => ({
+ run: handlers.run ?? (async () => ({ stdout: "", stderr: "", exitCode: 0 })),
+ exec: handlers.exec ?? (async () => ({ stdout: "", stderr: "", exitCode: 0 })),
+})
+
+describe("dockerFilesystem", () => {
+ test("readFile cats inside the container", async () => {
+ const fs = dockerFilesystem(fakeDocker({ exec: async (_c, cmd) => ({ stdout: cmd.join(" ").includes("cat") ? "file-body" : "", stderr: "", exitCode: 0 }) }), "c1")
+ expect(await fs.readFile("/workspace/a.txt", ctx)).toBe("file-body")
+ })
+ test("writeFile pipes content via stdin", async () => {
+ let stdin: string | undefined
+ const fs = dockerFilesystem(fakeDocker({ exec: async (_c, _cmd, opts) => { stdin = opts?.stdin; return { stdout: "", stderr: "", exitCode: 0 } } }), "c1")
+ const r = await fs.writeFile("/workspace/a.txt", "hello", ctx)
+ expect(stdin).toBe("hello")
+ expect(r.bytesWritten).toBe(5)
+ })
+ test("listDir parses ls -1 output", async () => {
+ const fs = dockerFilesystem(fakeDocker({ exec: async () => ({ stdout: "a\nb\n", stderr: "", exitCode: 0 }) }), "c1")
+ expect(await fs.listDir("/workspace", ctx)).toEqual(["a", "b"])
+ })
+})
+
+describe("dockerExec", () => {
+ test("runCommand runs sh -c inside the container", async () => {
+ const exec = dockerExec(fakeDocker({ exec: async (_c, cmd) => ({ stdout: cmd.join(" "), stderr: "", exitCode: 0 }) }), "c1")
+ const r = await exec.runCommand({ command: "echo hi" }, ctx)
+ expect(r.stdout).toContain("echo hi")
+ expect(r.exitCode).toBe(0)
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify it fails** — `pnpm --filter @dawn-ai/sandbox test docker-backends` → FAIL.
+
+- [ ] **Step 3: Implement the backends**
+
+```ts
+// packages/sandbox/src/docker/docker-exec.ts
+import type { BackendContext, ExecBackend } from "@dawn-ai/workspace"
+import type { Docker } from "./docker-cli.js"
+
+export function dockerExec(docker: Docker, container: string): ExecBackend {
+ return {
+ async runCommand(args, ctx: BackendContext) {
+ const envPrefix = args.env
+ ? Object.entries(args.env).map(([k, v]) => `${k}=${shellQuote(v)} `).join("")
+ : ""
+ const cdPrefix = args.cwd ? `cd ${shellQuote(args.cwd)} && ` : ""
+ const r = await docker.exec(container, ["sh", "-c", `${envPrefix}${cdPrefix}${args.command}`], { signal: ctx.signal })
+ return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode }
+ },
+ }
+}
+
+function shellQuote(s: string): string {
+ return `'${s.replaceAll("'", `'\\''`)}'`
+}
+```
+
+```ts
+// packages/sandbox/src/docker/docker-filesystem.ts
+import type { BackendContext, FilesystemBackend } from "@dawn-ai/workspace"
+import type { Docker } from "./docker-cli.js"
+
+function q(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'` }
+
+export function dockerFilesystem(docker: Docker, container: string): FilesystemBackend {
+ const run = (cmd: string, ctx: BackendContext, stdin?: string) =>
+ docker.exec(container, ["sh", "-c", cmd], { ...(stdin !== undefined ? { stdin } : {}), signal: ctx.signal })
+ return {
+ async readFile(path, ctx) {
+ const r = await run(`cat ${q(path)}`, ctx)
+ if (r.exitCode !== 0) throw new Error(`readFile failed: ${r.stderr.trim()}`)
+ return r.stdout
+ },
+ async writeFile(path, content, ctx) {
+ const r = await run(`cat > ${q(path)}`, ctx, content)
+ if (r.exitCode !== 0) throw new Error(`writeFile failed: ${r.stderr.trim()}`)
+ return { bytesWritten: Buffer.byteLength(content) }
+ },
+ async listDir(path, ctx) {
+ const r = await run(`ls -1 ${q(path)}`, ctx)
+ if (r.exitCode !== 0) throw new Error(`listDir failed: ${r.stderr.trim()}`)
+ return r.stdout.split("\n").map((l) => l.trim()).filter(Boolean)
+ },
+ async realPath(path, ctx) {
+ const r = await run(`realpath -m ${q(path)}`, ctx)
+ return r.exitCode === 0 ? r.stdout.trim() : path
+ },
+ async statFile(path, ctx) {
+ const r = await run(`stat -c '%s %Y' ${q(path)}`, ctx)
+ if (r.exitCode !== 0) throw new Error(`statFile failed: ${r.stderr.trim()}`)
+ const [size, mtime] = r.stdout.trim().split(" ")
+ return { size: Number(size), mtimeMs: Number(mtime) * 1000 }
+ },
+ async removeFile(path, ctx) { await run(`rm -f ${q(path)}`, ctx) },
+ async touchFile(path, ctx) { await run(`touch ${q(path)}`, ctx) },
+ async mkdir(path, ctx) { await run(`mkdir -p ${q(path)}`, ctx) },
+ }
+}
+```
+
+- [ ] **Step 4: Run tests** → PASS.
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/sandbox/src/docker/docker-filesystem.ts packages/sandbox/src/docker/docker-exec.ts packages/sandbox/test/docker-backends.test.ts
+git commit -m "feat(sandbox): docker filesystem + exec backends"
+```
+
+### Task 13: `dockerSandbox` provider
+
+**Files:**
+- Create: `packages/sandbox/src/docker/docker-sandbox.ts`
+- Modify: `packages/sandbox/src/index.ts` (export `dockerSandbox`)
+- Test: `packages/sandbox/test/docker-sandbox.unit.test.ts` (injected fake `Docker`; asserts lifecycle commands — no daemon)
+
+- [ ] **Step 1: Write the failing unit test** (assert the docker commands acquire/release/destroy emit)
+
+```ts
+// packages/sandbox/test/docker-sandbox.unit.test.ts
+import { describe, expect, test } from "vitest"
+import type { Docker } from "../src/docker/docker-cli.ts"
+import { dockerSandbox } from "../src/docker/docker-sandbox.ts"
+
+function recordingDocker(): { docker: Docker; runs: string[][] } {
+ const runs: string[][] = []
+ const docker: Docker = {
+ run: async (args) => {
+ runs.push([...args])
+ if (args[0] === "ps") return { stdout: "", stderr: "", exitCode: 0 } // not running
+ return { stdout: "ok", stderr: "", exitCode: 0 }
+ },
+ exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
+ }
+ return { docker, runs }
+}
+
+describe("dockerSandbox (unit, no daemon)", () => {
+ const policy = { network: { mode: "deny" } } as const
+ test("acquire runs a container named for the thread + names a volume", async () => {
+ const { docker, runs } = recordingDocker()
+ const p = dockerSandbox({ image: "node:22-slim", docker })
+ const h = await p.acquire({ threadId: "abc", policy, signal: new AbortController().signal })
+ expect(h.workspaceRoot).toBe("/workspace")
+ const runCmd = runs.find((r) => r[0] === "run")!
+ expect(runCmd.join(" ")).toContain("dawn-sbx-abc")
+ expect(runCmd.join(" ")).toContain("dawn-sbx-vol-abc")
+ expect(runCmd.join(" ")).toContain("--network")
+ expect(runCmd.join(" ")).toContain("none") // deny → --network none
+ })
+ test("release removes container but not volume; destroy removes both", async () => {
+ const { docker, runs } = recordingDocker()
+ const p = dockerSandbox({ image: "node:22-slim", docker })
+ await p.acquire({ threadId: "abc", policy, signal: new AbortController().signal })
+ await p.release("abc")
+ expect(runs.some((r) => r[0] === "rm" && r.includes("dawn-sbx-abc"))).toBe(true)
+ expect(runs.some((r) => r[0] === "volume" && r[1] === "rm")).toBe(false)
+ await p.destroy("abc")
+ expect(runs.some((r) => r[0] === "volume" && r[1] === "rm" && r.includes("dawn-sbx-vol-abc"))).toBe(true)
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify it fails** → FAIL.
+
+- [ ] **Step 3: Implement `dockerSandbox`**
+
+```ts
+// packages/sandbox/src/docker/docker-sandbox.ts
+import type { SandboxHandle, SandboxPolicy, SandboxProvider } from "@dawn-ai/workspace"
+import { createDocker, type Docker } from "./docker-cli.js"
+import { dockerExec } from "./docker-exec.js"
+import { dockerFilesystem } from "./docker-filesystem.js"
+
+const ROOT = "/workspace"
+const containerName = (threadId: string) => `dawn-sbx-${sanitize(threadId)}`
+const volumeName = (threadId: string) => `dawn-sbx-vol-${sanitize(threadId)}`
+const sanitize = (s: string) => s.replaceAll(/[^a-zA-Z0-9_.-]/g, "_")
+
+export interface DockerSandboxOptions {
+ readonly image: string
+ /** Injected for tests; defaults to the real docker CLI. */
+ readonly docker?: Docker
+}
+
+export function dockerSandbox(opts: DockerSandboxOptions): SandboxProvider {
+ const docker = opts.docker ?? createDocker()
+
+ const ensureContainer = async (threadId: string, policy: SandboxPolicy, signal: AbortSignal): Promise => {
+ const name = containerName(threadId)
+ const running = await docker.run(["ps", "-q", "--filter", `name=^${name}$`], { signal })
+ if (running.stdout.trim()) return name
+ const existing = await docker.run(["ps", "-aq", "--filter", `name=^${name}$`], { signal })
+ if (existing.stdout.trim()) {
+ await docker.run(["start", name], { signal })
+ return name
+ }
+ const net = policy.network.mode === "deny" ? ["--network", "none"] : ["--network", "bridge"]
+ const envArgs = Object.entries(policy.env ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`])
+ const res = policy.resources
+ const limits = [
+ ...(res?.memoryMb ? ["--memory", `${res.memoryMb}m`] : []),
+ ...(res?.cpus ? ["--cpus", String(res.cpus)] : []),
+ ]
+ await docker.run(
+ [
+ "run", "-d", "--name", name,
+ "--label", `dawn.sandbox=${threadId}`,
+ "-v", `${volumeName(threadId)}:${ROOT}`,
+ "-w", ROOT,
+ ...net, ...envArgs, ...limits,
+ opts.image, "sleep", "infinity",
+ ],
+ { signal },
+ )
+ // best-effort denylist note (allow mode): full egress filtering deferred — see spec.
+ return name
+ }
+
+ return {
+ name: "docker",
+ async acquire({ threadId, policy, signal }): Promise {
+ const container = await ensureContainer(threadId, policy, signal)
+ return {
+ threadId,
+ filesystem: dockerFilesystem(docker, container),
+ exec: dockerExec(docker, container),
+ workspaceRoot: ROOT,
+ }
+ },
+ async release(threadId) {
+ await docker.run(["rm", "-f", containerName(threadId)]).catch(() => {})
+ },
+ async destroy(threadId) {
+ await docker.run(["rm", "-f", containerName(threadId)]).catch(() => {})
+ await docker.run(["volume", "rm", volumeName(threadId)]).catch(() => {})
+ },
+ async preflight() {
+ const v = await docker.run(["version", "--format", "{{.Server.Version}}"]).catch(() => undefined)
+ if (!v || v.exitCode !== 0) return { ok: false, detail: "Docker daemon not reachable (`docker version` failed)." }
+ return { ok: true, detail: `Docker ${v.stdout.trim()}` }
+ },
+ }
+}
+```
+
+Add to `packages/sandbox/src/index.ts`: `export { dockerSandbox, type DockerSandboxOptions } from "./docker/docker-sandbox.js"`.
+
+- [ ] **Step 4: Run unit tests + typecheck** → PASS.
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/sandbox/src/docker/docker-sandbox.ts packages/sandbox/src/index.ts packages/sandbox/test/docker-sandbox.unit.test.ts
+git commit -m "feat(sandbox): dockerSandbox provider (acquire/release/destroy/preflight)"
+```
+
+### Task 14: Gated real-Docker conformance + e2e + CI lane
+
+**Files:**
+- Create: `packages/sandbox/test/docker-sandbox.integration.test.ts` (gated `describe.skipIf(!process.env.DAWN_TEST_DOCKER)`)
+- Modify: `.github/workflows/ci.yml` (new `sandbox-docker` job, NOT in the default `validate` job)
+
+- [ ] **Step 1: Write the gated integration test**
+
+```ts
+// packages/sandbox/test/docker-sandbox.integration.test.ts
+import { describe } from "vitest"
+import { dockerSandbox } from "../src/index.ts"
+import { runProviderConformance } from "../src/testing/index.ts"
+
+const enabled = process.env.DAWN_TEST_DOCKER === "1"
+
+describe.skipIf(!enabled)("dockerSandbox (real Docker)", () => {
+ runProviderConformance({
+ name: "dockerSandbox",
+ makeProvider: () => dockerSandbox({ image: "node:22-slim" }),
+ describe,
+ })
+ // Plus: a deny-network egress test (a `curl` inside the container fails) and a
+ // host-fs-untouched assertion (the host has no file the agent wrote). Author
+ // these as additional `test()`s here; each acquires a unique threadId and
+ // destroys it in a finally block.
+})
+```
+
+- [ ] **Step 2: Add the CI job** to `.github/workflows/ci.yml` (separate job; ubuntu runners ship Docker):
+
+```yaml
+ sandbox-docker:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@ # match the pin used elsewhere in this file
+ - uses: pnpm/action-setup@
+ - uses: actions/setup-node@
+ with: { node-version: 22.14.0 }
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm --filter @dawn-ai/sandbox build
+ - run: docker pull node:22-slim
+ - run: DAWN_TEST_DOCKER=1 pnpm --filter @dawn-ai/sandbox test docker-sandbox.integration
+```
+
+- [ ] **Step 3: Verify locally if Docker is available**
+
+Run: `DAWN_TEST_DOCKER=1 pnpm --filter @dawn-ai/sandbox test docker-sandbox.integration` (skips cleanly without Docker / the env var).
+Expected: PASS with Docker; SKIPPED otherwise.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/sandbox/test/docker-sandbox.integration.test.ts .github/workflows/ci.yml
+git commit -m "test(sandbox): gated real-Docker conformance + e2e CI lane"
+```
+
+---
+
+## Phase F — Docs, changeset, release
+
+### Task 15: `sandbox.mdx` docs page
+
+**Files:**
+- Create: `apps/web/content/docs/sandbox.mdx`, `apps/web/app/docs/sandbox/page.tsx`
+- Modify: `apps/web/app/components/docs/nav.ts` (add the nav entry)
+
+- [ ] **Step 1: Write the docs page** — base it on the spec's "Honest scope", "Config surface", and the walkthrough. Cover: enabling it (`config()` + `dockerSandbox`), what's isolated, network policy, the `config()` helper, writing a custom provider, testing with `fakeSandbox`, and the IS/IS-NOT section verbatim. Register the page in all three places (`DOCS_NAV` entry + `content/docs/sandbox.mdx` + `app/docs/sandbox/page.tsx`) — `check-docs.mjs` validates nav→file.
+
+- [ ] **Step 2: Run the docs check**
+
+Run: `node scripts/check-docs.mjs`
+Expected: PASS (nav entry resolves; no banned marketing phrases — avoid "byte-identical" etc.).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add apps/web/content/docs/sandbox.mdx apps/web/app/docs/sandbox/page.tsx apps/web/app/components/docs/nav.ts
+git commit -m "docs: execution sandbox guide"
+```
+
+### Task 16: Changeset + full verification + PR
+
+**Files:**
+- Create: `.changeset/execution-sandbox.md`
+
+- [ ] **Step 1: Write the changeset as `patch`** (GOTCHA 6: a `minor` forces the fixed 0.x group to 1.0.0 — keep it patch to stay pre-1.0)
+
+```md
+---
+"@dawn-ai/sandbox": patch
+"@dawn-ai/workspace": patch
+"@dawn-ai/core": patch
+"@dawn-ai/cli": patch
+---
+
+Add an opt-in execution sandbox: a provider-agnostic `SandboxProvider` contract
+with a Docker reference (`dockerSandbox`), giving each conversation thread a
+hard-isolated workspace (filesystem + shell + network). Enable via
+`dawn.config.ts` `sandbox: { provider: dockerSandbox({ image }) }`; without it,
+behavior is unchanged. Adds a typed `config()` helper. Honest scope: Docker's
+boundary (not a microVM); `allow`-mode network denylist is best-effort in the
+Docker reference. New package `@dawn-ai/sandbox` (+ `/testing` `fakeSandbox`).
+```
+
+- [ ] **Step 2: Full local verification**
+
+Run: `pnpm lint && pnpm build && pnpm typecheck && pnpm test && node scripts/check-docs.mjs && pnpm verify:harness:framework`
+Expected: all PASS. (Sandbox unit/wiring tests are Docker-free; the real-Docker lane runs only in CI / with `DAWN_TEST_DOCKER=1`.)
+
+- [ ] **Step 3: Confirm the Version PR will compute a PATCH, not 1.0.0**
+
+After opening the PR + merge, when the Version Packages PR appears, verify it resolves to the next patch (e.g. `0.8.5`), NOT `1.0.0`, before admin-merging (per GOTCHA 6 / #268). **New package `@dawn-ai/sandbox` is a first publish → it needs the one-time manual OIDC bootstrap** (`npm publish --access public` once + trusted-publishing config) exactly like `@dawn-ai/memory` at 0.8.3; budget for it at release.
+
+- [ ] **Step 4: Commit + open PR**
+
+```bash
+git add .changeset/execution-sandbox.md
+git commit -m "chore: changeset for execution sandbox (patch)"
+git push -u origin feat/execution-sandbox
+gh pr create --title "feat: execution sandbox (per-thread Docker isolation, opt-in)" --body "Implements docs/superpowers/specs/2026-06-25-execution-sandbox-design.md"
+```
+
+---
+
+## Self-review notes (for the implementer)
+
+- **Spec coverage:** contract (T1), config+helper (T2), package (T3), fakeSandbox (T4), conformance (T5), manager (T6), wiring + capability workspaceRoot (T7–T8), wiring e2e (T9), dawn check (T10), docker provider (T11–T13), gated docker lane (T14), docs (T15), changeset/release flags (T16). Network deny is exact (T13 `--network none`); allow-denylist is best-effort + documented (T13 comment, T15 docs).
+- **Type consistency:** `SandboxProvider.acquire/release/destroy/preflight`, `SandboxHandle.{threadId,filesystem,exec,workspaceRoot}`, `SandboxPolicy.network` discriminated union, `SandboxManager.{getForThread,reapIdle,destroyThread,releaseAll}` are used identically across tasks.
+- **Watch-outs:** (1) `vitest` / `catalog:` references in the new package.json must match how sibling packages reference deps — copy from `packages/workspace/package.json` exactly. (2) `threadId` must reach BOTH top-route and recursive subagent dispatch (T7) — assert subagent sharing in T9. (3) the workspace capability's `detect()` must not host-`existsSync` when sandboxed (T7 Step 5). (4) `Date.now` is used only in `SandboxManager` default clock (injectable) — keep lib code deterministic per the memory's no-`Date.now` rule for capabilities (the manager is runtime infra, not a capability, so a default `Date.now` is acceptable, but tests inject `clock`).
diff --git a/docs/superpowers/specs/2026-06-25-execution-sandbox-design.md b/docs/superpowers/specs/2026-06-25-execution-sandbox-design.md
new file mode 100644
index 00000000..cc8dc467
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-25-execution-sandbox-design.md
@@ -0,0 +1,211 @@
+# Execution Sandbox (Design)
+
+**Status:** Approved for planning
+**Date:** 2026-06-25
+**Roadmap:** Phase 4 (Richer Authoring Systems) — the **execution sandbox**: a hard isolation boundary for the agent's workspace (fs + exec + network). Complements tool scoping (PR #261): tool scoping limits *which* tools the model may name; the sandbox bounds *what an allowed tool can actually do*. This is the least-privilege layer eve and flue both have and Dawn is behind on (see `project_competitors_eve_flue`).
+
+## Problem
+
+Dawn's workspace capability gives an agent real `readFile`/`writeFile`/`listDir`/`runBash` against `/workspace/` on the **host**, through pluggable `FilesystemBackend`/`ExecBackend` (`@dawn-ai/workspace`). The path-jail (`workspace-fs.ts`) and the HITL `runBash` permission gate are soft, host-side controls: a determined or prompt-injected agent that runs `runBash("python -c '...'")` executes arbitrary code on the host, with host network and host environment. Tool scoping reduced the *surface*; it explicitly deferred *execution isolation* as "a separate, larger effort." This spec is that effort.
+
+The goal is a **hard boundary suitable for untrusted code / multi-tenant**: the agent's whole workspace — filesystem, shell, network egress — runs inside a real isolated environment, **one per conversation thread**, while Dawn's orchestration (LLM calls, checkpointer, AP server) stays on the trusted host. Dawn is a meta-framework, so it does **not** build an isolation runtime; it defines a provider-agnostic contract and ships one reference integration on Docker.
+
+## Decisions (from brainstorming)
+
+1. **Threat model:** hard boundary for untrusted code / multi-tenant — not just blast-radius reduction. Soft in-process policy is insufficient.
+2. **Substrate:** a provider-agnostic `SandboxProvider` contract (Dawn's seam) + **one reference integration on local Docker/Podman** (zero cloud dep, self-hostable, kernel-level isolation, dogfoodable). Cloud/microVM providers (E2B, Daytona, gVisor/Kata) plug in later with no core change.
+3. **Boundary scope:** the **whole workspace** — both `ExecBackend` and `FilesystemBackend` target the sandbox; the workspace dir lives inside it, so `readFile`/`writeFile`/`listDir`/`runBash` all operate in the isolated env and the host fs is never touched. Dawn's LLM orchestration/checkpointer stay host-side.
+4. **Lifecycle:** **per-thread** (per Agent-Protocol thread). One sandbox per `thread_id`, reused across turns (warm), idle-reaped, destroyed on thread delete. The workspace persists across turns and across server restarts (create-or-reattach).
+5. **Network egress:** **allow-by-default + denylist** (configurable to deny+allowlist). Honest caveat (documented): allow mode protects the host and other tenants but does **not** stop an agent exfiltrating *its own* sandbox's data; the sandbox is not a data-loss-prevention boundary.
+6. **Architecture:** Approach A — the provider yields per-thread fs+exec backends; the existing workspace capability consumes them unchanged. (Rejected: backend middleware — no thread/lifecycle context; and host-mounted volume + exec-only — touches the host fs, contradicting decision 3.)
+7. **Honest scope:** it is Docker's boundary (not a microVM); the `allow`-mode denylist is best-effort in the Docker reference; tool *surface* is governed by tool scoping, not the sandbox. We do not over-claim.
+
+## Naming (DX audit, 2026-06-25)
+
+`dawn.config.ts` is a plain `export default {}` object (tsx-evaluated; `core/config.ts:22-40`). There is **no** `defineConfig`. We add, as part of this work, a typed identity helper named **`config()`** (not `defineConfig`) following the `agent()` (`sdk/agent.ts:57`) precedent — for IntelliSense on the config object; the plain object stays valid. Provider factories follow the `localExec`/`localFilesystem` convention: **`dockerSandbox()`** and the test double **`fakeSandbox()`** (a matched `docker`/`fake` pair). The config key is a plain **`sandbox`** (like `permissions`, `summarization`, `memory`). Type names: `SandboxProvider`, `SandboxHandle` (matches the existing `DevServerHandle`/`AimockHandle` family), `SandboxPolicy`, `SandboxConfig`. The broader `define*` → bare-noun rename (`defineMemory`/`defineEval`/`defineMiddleware`) is **out of scope** — a separate API-naming pass (`eval()` collides with the JS builtin; renaming shipped exports is breaking).
+
+## Design
+
+### 1. The contract (`@dawn-ai/sandbox`)
+
+New package `@dawn-ai/sandbox` ships the **impl** (`dockerSandbox`) + (under a `/testing` subpath) `fakeSandbox` and a conformance kit. The **contract types** (`SandboxProvider`/`SandboxHandle`/`SandboxPolicy`/`SandboxConfig`) live in **`@dawn-ai/workspace`** — a leaf package that already owns `FilesystemBackend`/`ExecBackend` (which the contract references), and which `@dawn-ai/core` already depends on. So `DawnConfig.sandbox?: SandboxConfig` imports from `workspace` with no new edge and **no cycle** (`core` does NOT depend on `@dawn-ai/sandbox`). Verified against the dep graph during planning.
+
+```ts
+import type { ExecBackend, FilesystemBackend } from "@dawn-ai/workspace"
+
+export interface SandboxProvider {
+ readonly name: string // diagnostics + `dawn check`
+
+ /** Create-or-reattach the thread's sandbox. Idempotent per threadId: called at
+ * the start of EVERY turn; returns the same live sandbox across turns until
+ * release()/destroy(). Provisions the workspace volume + applies the network
+ * policy. After a server restart or container reap, reattaches the existing
+ * volume by deterministic name rather than starting empty. */
+ acquire(input: {
+ readonly threadId: string
+ readonly policy: SandboxPolicy
+ readonly signal: AbortSignal
+ }): Promise
+
+ /** Drop the warm compute (e.g. stop/remove the container) but KEEP the
+ * workspace volume so a later acquire() reattaches it. Idle-reap + shutdown. */
+ release(threadId: string): Promise
+
+ /** Destroy the sandbox AND its workspace volume — full teardown. Thread delete. */
+ destroy(threadId: string): Promise
+
+ /** Optional availability probe surfaced by `dawn check`. */
+ preflight?(): Promise<{ readonly ok: boolean; readonly detail?: string }>
+}
+
+export interface SandboxHandle {
+ readonly threadId: string
+ readonly filesystem: FilesystemBackend // the existing @dawn-ai/workspace interfaces —
+ readonly exec: ExecBackend // the workspace capability consumes them unchanged
+ readonly workspaceRoot: string // path INSIDE the sandbox, e.g. "/workspace"
+}
+
+export interface SandboxPolicy {
+ readonly network:
+ | { readonly mode: "allow"; readonly denylist?: readonly string[] }
+ | { readonly mode: "deny"; readonly allowlist?: readonly string[] }
+ readonly env?: Readonly> // explicit; host env is NEVER inherited
+ readonly resources?: { readonly memoryMb?: number; readonly cpus?: number; readonly timeoutMs?: number }
+}
+
+export interface SandboxConfig {
+ readonly provider: SandboxProvider
+ readonly network?: SandboxPolicy["network"] // default { mode: "allow", denylist: [metadata ip] }
+ readonly env?: SandboxPolicy["env"]
+ readonly resources?: SandboxPolicy["resources"]
+ readonly idleTimeoutMs?: number // manager-level; default 600_000
+}
+```
+
+**Key reuse:** `SandboxHandle.filesystem`/`exec` implement the existing `FilesystemBackend`/`ExecBackend`. `workspace-fs.ts`'s `createWorkspaceFs` dispatches every op to `opts.backend.()`, and the workspace capability (`built-in/workspace.ts:125-126`) already takes `context.backends?.filesystem ?? localFilesystem()`. So swapping the backend inputs redirects all of `readFile`/`writeFile`/`listDir`/`runBash` with **no change to the capability**. The handle's `workspaceRoot` is the in-sandbox path; the capability's existing path-jail resolves agent paths against it (defense-in-depth on top of the container).
+
+### 2. `SandboxManager` (per-thread lifecycle)
+
+A new runtime singleton (the only stateful new piece), constructed once per server in `createRuntimeRequestListener` (`runtime-server.ts:54-76`) alongside `threadsStore`/`checkpointer`, from `dawn.config.sandbox`. If no `sandbox` config, the manager is absent and the runtime falls through to today's local backends.
+
+- **State:** `Map, lastUsedAt, inUse }>`.
+- **`getForThread(threadId, policy, signal)`:** live handle → bump `lastUsedAt`/`inUse`, return; else `provider.acquire(...)`. Concurrent turns on the same thread share one in-flight `acquiring` promise (dedup → one sandbox); different threads acquire independently.
+- **Idle reaper:** a `setInterval` (net-new; precedent = the shutdown-drain loop at `runtime-server.ts:117-132`) calls `provider.release(threadId)` for handles idle past `idleTimeoutMs` and not `inUse` — drops the warm container, **keeps the volume**. Cleared on server `close()`.
+- **Release triggers:** (1) idle-reap → `release`; (2) server shutdown `close()` (`runtime-server.ts:107-133`) → `release` all (best-effort); (3) AP `DELETE /threads/:id` (`runtime-server.ts:268-285`) → **`destroy`** (container + volume).
+- **Failure semantics:** an `acquire` failure (Docker down, image missing, OOM) is **not cached** — it rejects the current turn with an actionable error; the next turn retries cleanly. The manager never hands the capability a half-dead handle.
+
+### 3. Runtime wiring
+
+The integration is at the backend-construction site, not inside the capability.
+
+- **Thread-id plumbing (the one structural change).** `prepareRouteExecution` (`execute-route.ts:~371`) currently does **not** receive `threadId`, though its callers `streamResolvedRoute`/`executeResolvedRoute` do (`:248`). Thread `threadId` (and a handle to the `SandboxManager`) **into** `prepareRouteExecution` — the same plumbing applied for `isSubagent` in tool scoping (TS3). The manager is passed from the route table (built with the singletons in `createRuntimeRequestListener`) through the run handlers into `streamResolvedRoute`/`executeResolvedRoute`.
+- **Per-turn resolution.** When a `SandboxManager` is present, before constructing the workspace backends: `handle = await manager.getForThread(threadId, policy, signal)`, then use `handle.filesystem`/`handle.exec` and `workspaceRoot = handle.workspaceRoot` everywhere the local defaults are used today — `createWorkspaceFs` (`execute-route.ts:491-497`), the `applyCapabilities` `backends` context (`:548-556`), the `ctx.fs` tool injection (`:661-669`), and the offload store (`:~1091`). The capability logic is unchanged; only its inputs swap.
+- **No `thread_id` (e.g. a direct non-AP invocation):** fall through to local backends + host workspace. Sandboxing requires the AP thread lifecycle.
+- **Subagents inherit the thread's sandbox.** A subagent dispatch is a recursive `executeResolvedRoute` under the same `thread_id` + same manager → it resolves the *same* handle. The coordinator and its subagents share one isolated env per conversation (correct — they're one logical agent).
+- **Composes with existing guards.** The path-jail still resolves agent paths against `workspaceRoot` (now in-sandbox); the HITL `runBash` gate still fires; tool scoping still filters the surface. Three orthogonal layers stack.
+
+### 4. Config surface + `config()` helper
+
+```ts
+// dawn.config.ts — plain object stays valid; config() adds IntelliSense
+import { config } from "@dawn-ai/cli" // typed identity helper (new)
+import { dockerSandbox } from "@dawn-ai/sandbox"
+
+export default config({
+ sandbox: {
+ provider: dockerSandbox({ image: "node:22-slim" }),
+ network: { mode: "allow", denylist: ["169.254.169.254", "metadata.google.internal"] },
+ env: { NODE_ENV: "production" }, // injected explicitly; host env is NOT inherited
+ resources: { memoryMb: 512, cpus: 1, timeoutMs: 120_000 },
+ idleTimeoutMs: 600_000,
+ },
+})
+```
+
+- **`config(c: DawnConfig): DawnConfig`** — pure identity for IDE autocomplete, modeled on `agent()` (`sdk/agent.ts:57`). Lives in **`@dawn-ai/core`** (co-located with `DawnConfig`) and is re-exported from `@dawn-ai/cli` (the import authors already use). NOT in `@dawn-ai/sdk`: `core` depends on `sdk`, so `sdk` importing `DawnConfig` from `core` would cycle (verified via the dep graph during planning). The loader (`core/config.ts:22-40`) is unchanged — it reads `mod.default`, so a wrapped or bare object both work.
+- **`DawnConfig.sandbox?: SandboxConfig`** added to `core/types.ts:9-80` (the 10th key), consistent with the existing optional nested keys.
+- **Defaults** (manager-applied): `network: { mode: "allow", denylist: ["169.254.169.254"] }` (the cloud-metadata endpoint, the classic SSRF egress target, denied even in allow mode), `idleTimeoutMs: 600_000`, conservative `resources` caps.
+- **`dawn check`** gains a sandbox pass (mirror `collectToolScopeErrors`, `check.ts:45-48`): validate the `sandbox` config shape and run `provider.preflight?.()` (e.g. "is the Docker daemon reachable / image pullable?") so misconfig fails at check-time, not mid-run.
+
+### 5. Docker reference provider (`dockerSandbox`)
+
+Shells out to the `docker` CLI via the existing `spawnProcess` helper — **no new runtime dependency** (Docker must be installed regardless).
+
+- **`acquire`** — container `dawn-sbx-`, named volume `dawn-sbx-vol-` mounted at `/workspace` (workdir), labeled `dawn.sandbox=`. Lookup by name: running → reattach; stopped → `start`; absent → `docker run -d` with the image, the volume, `--memory`/`--cpus`, a **clean env** (only `policy.env`, never the host's), the network policy, and a `sleep infinity` entrypoint so the container persists for `exec` across turns.
+- **`SandboxHandle.filesystem`** — a `FilesystemBackend` whose ops are `docker exec` calls: `readFile`→`cat` (size-capped via `maxBytes`), `writeFile`→piped `cat >`, `listDir`→`ls`, `realPath`→`realpath`, plus optional `stat/remove/touch/mkdir` (all required for offload GC, which routes through the same backend). `workspaceRoot = "/workspace"`.
+- **`SandboxHandle.exec`** — `runCommand` → `docker exec sh -c ''` with cwd/env, capturing stdout/stderr/exit, killing on `signal` abort.
+- **`release`** — `docker rm -f ` (volume kept). **`destroy`** — `docker rm -f` + `docker volume rm`.
+- **`preflight`** — daemon reachable + image present/pullable.
+- **Orphan sweep** — on manager init, `docker ps -aq --filter label=dawn.sandbox` reaps containers whose threads are unknown to the live `threadsStore`, so crashes don't leak containers.
+
+**Network enforcement (honest limitation):** `mode:"deny"` → `--network none` (exact, zero egress). `mode:"allow"` → default bridge; the **denylist is best-effort** — true per-host egress filtering needs an in-container iptables rule (NET_ADMIN) or an egress-proxy sidecar; the reference injects an iptables rule when the container has the capability, else **logs a clear warning that the denylist is unenforced**. The *contract* supports full allow/deny lists; a cloud/microVM provider with native egress rules implements them fully.
+
+**Perf:** each fs op is one `docker exec` round-trip (tens of ms); acceptable, and offloading already curbs large reads. Batching is a later optimization.
+
+## Authoring examples
+
+```ts
+// Strict tenant: no egress, tight caps
+export default config({
+ sandbox: { provider: dockerSandbox({ image: "python:3.12-slim" }), network: { mode: "deny" },
+ resources: { memoryMb: 256, cpus: 0.5, timeoutMs: 60_000 } },
+})
+```
+
+```ts
+// Later, swap to a cloud microVM provider — nothing else changes
+export default config({ sandbox: { provider: e2bSandbox({ apiKey: process.env.E2B_KEY! }) } })
+```
+
+## Error handling / edge cases
+
+- **Docker down / image missing at runtime** → `acquire` rejects with an actionable error ("Sandbox unavailable: Docker daemon not reachable — run `dawn check`"); not cached; retried next turn.
+- **Container crash / OOM mid-thread** → the named volume outlives the container, so the next turn's `acquire` recreates the container and reattaches the volume — **workspace state survives**; only in-flight work is lost. A non-zero `exec` is a normal failed command the agent sees.
+- **Server restart** → in-memory manager map is gone but the conversation checkpoint survives (SQLite); `acquire` reattaches `dawn-sbx-vol-` by name — the agent's files survive the restart, matching its conversation.
+- **Idle reaper safety** → never reaps an `inUse` (mid-turn) handle; only truly idle; `release` keeps the volume so a later turn restores the workspace.
+- **`release`/`destroy` failure** → logged, best-effort; the startup orphan sweep is the backstop.
+- **Abort/cancel** → `signal` kills the in-flight `docker exec`; the container stays warm.
+
+## Testing
+
+Mirrors Dawn's "real-thing-but-gated" discipline (aimock, Verdaccio). Default `validate` stays **Docker-free**.
+
+1. **`fakeSandbox()` (in-memory)** — a `SandboxProvider` over an in-memory `FilesystemBackend` (`Map`) + scripted `ExecBackend`. Lets the **`SandboxManager` lifecycle** (per-thread keying, concurrent-acquire dedup, idle-reap = release-not-destroy, destroy-on-delete, create-or-reattach) be unit-tested deterministically. The bulk of the new logic; 100% CI-safe.
+2. **Provider conformance kit** — a reusable suite any provider must pass (`acquire` idempotent-per-thread, reattach returns the same workspace, `release` keeps the volume / `destroy` removes it, fs round-trips, `exec` exit codes, abort). Run against `fakeSandbox` in CI and real Docker in the gated lane → the fake can't silently drift from the contract.
+3. **Gated real-Docker lane** — a dedicated `sandbox-docker` CI job (GitHub ubuntu runners ship Docker), **not** part of `validate`: the conformance kit + an e2e (file written inside survives a turn, `runBash` runs isolated, `network:"deny"` blocks egress via a failing `curl`, host fs untouched, restart reattaches). Locally gated by `DAWN_TEST_DOCKER`/daemon-detection.
+4. **Wiring e2e via `@dawn-ai/testing` + `fakeSandbox`** — an aimock agent run with `sandbox.provider = fakeSandbox()` asserting `writeFile`/`readFile`/`runBash` route through the handle (not `localExec`), per-thread isolation, and a subagent sharing the thread's sandbox. Proves §3 wiring **without Docker**.
+
+So lifecycle + wiring are fully covered Docker-free in `validate`; the real boundary is verified in the separate Docker lane; the conformance kit keeps the fake honest.
+
+## Packaging & rollout
+
+- **New package `@dawn-ai/sandbox`** (fixed group → versions with the rest). Exports contract types, `dockerSandbox`; `@dawn-ai/sandbox/testing` exports `fakeSandbox` + the conformance kit. **GOTCHA 1 (new-package OIDC bootstrap):** the first publish needs the one-time manual `npm publish` + trusted-publishing config — same as `@dawn-ai/memory` at 0.8.3 (`project_release_harness_workspace_dep`). Call it out at release.
+- **`@dawn-ai/sdk`** — add `config()`; place the `SandboxConfig`/contract types to avoid a `core`↔`sandbox` cycle (plan pins exact location).
+- **`@dawn-ai/core`** — `DawnConfig.sandbox?`.
+- **`@dawn-ai/cli`** — `SandboxManager`, the `threadId`→`prepareRouteExecution` plumbing, the `DELETE`→`destroy` + shutdown→`release` hooks, the idle reaper, re-export `config()`, and the `dawn check` pass.
+- **No default-scaffold change** — sandbox is opt-in, not in the `create-dawn-ai-app` template, so `SCAFFOLD_PACKAGES` is untouched (the Verdaccio harness publishes the whole workspace, so the new package is covered; wiring tests use `fakeSandbox`, never npmjs).
+- **Changeset — GOTCHA 6:** a feature + new package is semantically `minor`, but a `minor` in the fixed 0.x group forces the group to **1.0.0**. To stay pre-1.0 ship it as **`patch`** (the tool-scoping precedent, #268). Surface this explicitly at the changeset step.
+- **Docs** — new `apps/web/content/docs/sandbox.mdx` (+ nav): config, the `config()` helper, the threat-model framing, and the honest-scope section verbatim.
+- **Rollout** — opt-in, default off, **zero behavior change** without `sandbox` config.
+
+## Honest scope (ships in docs verbatim)
+
+- **IS:** per-thread kernel-level fs/process isolation; host fs never touched; host env never leaked; CPU/mem/wall-time caps; multi-tenant separation by thread; `network:"deny"` = zero egress; workspace survives turns, restarts, and container crashes.
+- **IS NOT:** a guarantee against container-escape 0-days (Docker's boundary, not a microVM — that's why the seam is provider-agnostic; a gVisor/Kata/cloud-microVM provider is the stronger drop-in); the `allow`-mode denylist is best-effort in the Docker reference; it does **not** stop an agent exfiltrating *its own* sandbox's data under `allow` mode. Tool *surface* is tool scoping's job, not the sandbox's.
+- **Framing:** "Dawn ships the isolation *seam* + a Docker reference; for hostile-grade multi-tenant, plug a microVM-backed provider."
+
+## Out of scope (later)
+
+- Cloud/microVM provider integrations (E2B, Daytona, gVisor/Kata) — the contract supports them; reference is Docker only.
+- Full host-level egress enforcement in the Docker reference (proxy sidecar / guaranteed iptables) — best-effort + warning for v1.
+- Per-tool sandbox policies, per-thread resource autoscaling, snapshot/restore of volumes, image build from the app.
+- The broader `define*` → bare-noun API rename (separate naming pass).
+- Channels ingress, blueprint system (separate Phase-4 sub-projects).
+
+## Risks
+
+- **Thread-id plumbing** must reach `prepareRouteExecution` correctly for top routes *and* recursive subagent dispatch (so subagents share the thread's sandbox). Covered by the wiring e2e + the `isSubagent` precedent.
+- **Backends are captured per route-prep, not per-op** — fine because the handle is resolved per turn before prep and a thread reuses one sandbox; verified by the per-thread isolation test.
+- **Idle-reap vs in-flight** — the `inUse` guard must be correct or a mid-turn container could be reaped; covered by a lifecycle unit test.
+- **Denylist over-claim** — must be framed as best-effort in the Docker reference; deny mode is the exact guarantee.
+- **Offload store** routes through the same backend — the Docker filesystem backend must implement `stat/remove/touch/mkdir` or offload GC breaks; covered by the conformance kit.
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 65955606..47d98ac8 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -60,6 +60,7 @@
},
"devDependencies": {
"@dawn-ai/config-typescript": "workspace:*",
+ "@dawn-ai/sandbox": "workspace:*",
"@dawn-ai/sdk": "workspace:*",
"@dawn-ai/workspace": "workspace:*",
"@langchain/core": "1.2.1",
diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts
index 9680d920..1ef61fdd 100644
--- a/packages/cli/src/commands/check.ts
+++ b/packages/cli/src/commands/check.ts
@@ -1,7 +1,8 @@
-import { discoverRoutes } from "@dawn-ai/core"
+import { type DawnConfig, discoverRoutes, loadDawnConfig } from "@dawn-ai/core"
import type { Command } from "commander"
import { CliError, type CommandIo, formatErrorMessage, writeLine } from "../lib/output.js"
+import { collectSandboxErrors } from "../lib/runtime/collect-sandbox-errors.js"
import { collectToolScopeErrors } from "../lib/runtime/collect-tool-scope-errors.js"
import { discoverToolDefinitions } from "../lib/runtime/tool-discovery.js"
import { collectUnknownModelIdWarnings } from "../lib/runtime/warn-unknown-model-ids.js"
@@ -46,6 +47,19 @@ export async function runCheckCommand(options: CheckOptions, io: CommandIo): Pro
if (scopeErrors.length > 0) {
throw new CliError(`Invalid tool scope:\n${scopeErrors.join("\n")}`)
}
+
+ let loadedConfig: Pick = {}
+ try {
+ const loaded = await loadDawnConfig({ appRoot: manifest.appRoot })
+ loadedConfig = loaded.config
+ } catch {
+ loadedConfig = {}
+ }
+
+ const sandboxErrors = await collectSandboxErrors(loadedConfig)
+ if (sandboxErrors.length > 0) {
+ throw new CliError(`Invalid sandbox config:\n${sandboxErrors.join("\n")}`)
+ }
} catch (error) {
if (error instanceof CliError) throw error
throw new CliError(`Validation failed: ${formatErrorMessage(error)}`)
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index d5ed9385..115aea2f 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -1,5 +1,7 @@
#!/usr/bin/env node
+export { config } from "@dawn-ai/core"
+
import { realpathSync } from "node:fs"
import { resolve } from "node:path"
import { fileURLToPath } from "node:url"
diff --git a/packages/cli/src/lib/dev/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts
index d8145201..6d74240d 100644
--- a/packages/cli/src/lib/dev/runtime-server.ts
+++ b/packages/cli/src/lib/dev/runtime-server.ts
@@ -9,6 +9,8 @@ import {
resolveThreadsStore,
streamResolvedRoute,
} from "../runtime/execute-route.js"
+import { resolveSandboxManager } from "../runtime/resolve-sandbox.js"
+import type { SandboxManager } from "../runtime/sandbox-manager.js"
import { type StreamChunk, toSseEvent } from "../runtime/stream-types.js"
import { loadMiddleware, runMiddleware } from "./middleware.js"
import { createRuntimeRegistry, type RuntimeRegistry } from "./runtime-registry.js"
@@ -58,6 +60,15 @@ export async function createRuntimeRequestListener(
const middleware = await loadMiddleware(options.appRoot)
const threadsStore = await resolveThreadsStore(options.appRoot)
const checkpointer = await resolveCheckpointer(options.appRoot)
+ const sandboxManager = await resolveSandboxManager(options.appRoot)
+
+ let sandboxReaper: ReturnType | undefined
+ if (sandboxManager) {
+ sandboxReaper = setInterval(() => {
+ void sandboxManager.reapIdle()
+ }, 60_000)
+ sandboxReaper.unref?.()
+ }
const state = {
acceptingRequests: true,
@@ -71,6 +82,7 @@ export async function createRuntimeRequestListener(
checkpointer,
middleware,
registry,
+ ...(sandboxManager ? { sandboxManager } : {}),
signal: shutdownController.signal,
threadsStore,
})
@@ -113,6 +125,8 @@ export async function createRuntimeRequestListener(
state.closed = true
shutdownController.abort(new Error("Runtime server shutting down"))
+ if (sandboxReaper) clearInterval(sandboxReaper)
+
// Drain in-flight requests
await new Promise((resolve) => {
const check = () => {
@@ -130,6 +144,10 @@ export async function createRuntimeRequestListener(
}
check()
})
+
+ // Release sandboxes only after in-flight requests have drained, so tools
+ // executing against a sandbox are never yanked mid-request.
+ if (sandboxManager) await sandboxManager.releaseAll()
}
return { close, listener, shutdownController, state }
@@ -142,7 +160,7 @@ export async function createRuntimeRequestListener(
export async function startRuntimeServer(
options: StartRuntimeServerOptions,
): Promise {
- const { listener, state, shutdownController } = await createRuntimeRequestListener(options)
+ const { close: listenerClose, listener, state } = await createRuntimeRequestListener(options)
const server = createServer(listener)
@@ -159,28 +177,15 @@ export async function startRuntimeServer(
if (state.closed) {
return
}
- state.acceptingRequests = false
- state.closed = true
- shutdownController.abort(new Error("Runtime server shutting down"))
- await new Promise((resolve, reject) => {
- server.close((error) => {
- if (error) {
- reject(error)
- return
- }
- if (state.activeRequests === 0) {
- resolve()
- return
- }
- const interval = setInterval(() => {
- if (state.activeRequests > 0) {
- return
- }
- clearInterval(interval)
- resolve()
- }, 10)
- })
+ // Stop accepting new TCP connections; existing sockets finish below.
+ const serverClosed = new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()))
})
+ // Abort + drain in-flight requests + clear the sandbox reaper + release
+ // sandboxes — the single shutdown path shared with the in-process
+ // listener. This is the only place that flips state.closed.
+ await listenerClose()
+ await serverClosed
},
url: `http://127.0.0.1:${(address as AddressInfo).port}`,
}
@@ -195,10 +200,11 @@ function buildRouteTable(ctx: {
readonly checkpointer: BaseCheckpointSaver
readonly middleware: DawnMiddleware | undefined
readonly registry: RuntimeRegistry
+ readonly sandboxManager?: SandboxManager
readonly signal: AbortSignal
readonly threadsStore: ThreadsStore
}): RouteMatcher[] {
- const { appRoot, checkpointer, middleware, registry, signal, threadsStore } = ctx
+ const { appRoot, checkpointer, middleware, registry, sandboxManager, signal, threadsStore } = ctx
// Server-scoped map: thread_id → last routeKey used for that thread.
// Populated by runs/stream and runs/wait; read by the resume endpoint so it
@@ -277,6 +283,7 @@ function buildRouteTable(ctx: {
checkpointer as unknown as { deleteThread(id: string): Promise }
).deleteThread(threadId)
}
+ if (sandboxManager) await sandboxManager.destroyThread(threadId)
res.writeHead(204)
res.end()
},
@@ -295,6 +302,7 @@ function buildRouteTable(ctx: {
registry,
request: req,
response: res,
+ ...(sandboxManager ? { sandboxManager } : {}),
signal,
threadId: params.thread_id ?? "",
threadRouteMap,
@@ -316,6 +324,7 @@ function buildRouteTable(ctx: {
registry,
request: req,
response: res,
+ ...(sandboxManager ? { sandboxManager } : {}),
signal,
threadId: params.thread_id ?? "",
threadRouteMap,
@@ -365,6 +374,7 @@ function buildRouteTable(ctx: {
registry,
request: req,
response: res,
+ ...(sandboxManager ? { sandboxManager } : {}),
signal,
threadId: params.thread_id ?? "",
threadRouteMap,
@@ -422,6 +432,7 @@ async function handleApStreamRequest(options: {
readonly registry: RuntimeRegistry
readonly request: IncomingMessage
readonly response: ServerResponse
+ readonly sandboxManager?: SandboxManager
readonly signal: AbortSignal
readonly threadId: string
readonly threadRouteMap: Map
@@ -433,6 +444,7 @@ async function handleApStreamRequest(options: {
registry,
request,
response,
+ sandboxManager,
signal,
threadId,
threadRouteMap,
@@ -506,6 +518,7 @@ async function handleApStreamRequest(options: {
routeFile: route.routeFile,
routeId: route.routeId,
routePath: route.routePath,
+ ...(sandboxManager ? { sandboxManager } : {}),
signal,
threadId,
})) {
@@ -534,6 +547,7 @@ async function handleApWaitRequest(options: {
readonly registry: RuntimeRegistry
readonly request: IncomingMessage
readonly response: ServerResponse
+ readonly sandboxManager?: SandboxManager
readonly signal: AbortSignal
readonly threadId: string
readonly threadRouteMap: Map
@@ -545,6 +559,7 @@ async function handleApWaitRequest(options: {
registry,
request,
response,
+ sandboxManager,
signal,
threadId,
threadRouteMap,
@@ -607,6 +622,7 @@ async function handleApWaitRequest(options: {
routeFile: route.routeFile,
routeId: route.routeId,
routePath: route.routePath,
+ ...(sandboxManager ? { sandboxManager } : {}),
signal,
threadId,
})
@@ -662,6 +678,7 @@ async function handleResumeRequest(options: {
readonly registry: RuntimeRegistry
readonly request: IncomingMessage
readonly response: ServerResponse
+ readonly sandboxManager?: SandboxManager
readonly signal: AbortSignal
readonly threadId: string
readonly threadRouteMap: Map
@@ -674,6 +691,7 @@ async function handleResumeRequest(options: {
registry,
request,
response,
+ sandboxManager,
signal,
threadId,
threadRouteMap,
@@ -810,6 +828,7 @@ async function handleResumeRequest(options: {
routeFile: route.routeFile,
routeId: route.routeId,
routePath: route.routePath,
+ ...(sandboxManager ? { sandboxManager } : {}),
signal,
threadId,
})) {
diff --git a/packages/cli/src/lib/runtime/collect-sandbox-errors.ts b/packages/cli/src/lib/runtime/collect-sandbox-errors.ts
new file mode 100644
index 00000000..3870d48d
--- /dev/null
+++ b/packages/cli/src/lib/runtime/collect-sandbox-errors.ts
@@ -0,0 +1,38 @@
+import type { DawnConfig } from "@dawn-ai/core"
+import type { SandboxProvider } from "@dawn-ai/workspace"
+
+/** Validate the dawn.config.ts sandbox block + run the provider preflight. */
+export async function collectSandboxErrors(
+ config: Pick,
+): Promise {
+ const sandbox = config.sandbox
+ if (!sandbox) return []
+ const errors: string[] = []
+ const p = sandbox.provider as Partial | undefined
+ if (
+ !p ||
+ typeof p.acquire !== "function" ||
+ typeof p.release !== "function" ||
+ typeof p.destroy !== "function"
+ ) {
+ errors.push(
+ `dawn.config sandbox.provider must implement acquire/release/destroy (got: ${p?.name ?? "undefined"}).`,
+ )
+ return errors
+ }
+ if (typeof p.preflight === "function") {
+ try {
+ const result = await p.preflight()
+ if (!result.ok) {
+ errors.push(
+ `Sandbox provider "${p.name}" preflight failed: ${result.detail ?? "unavailable"}.`,
+ )
+ }
+ } catch (error) {
+ errors.push(
+ `Sandbox provider "${p.name}" preflight threw: ${error instanceof Error ? error.message : String(error)}.`,
+ )
+ }
+ }
+ return errors
+}
diff --git a/packages/cli/src/lib/runtime/execute-route.ts b/packages/cli/src/lib/runtime/execute-route.ts
index 99c18f23..9ab788b8 100644
--- a/packages/cli/src/lib/runtime/execute-route.ts
+++ b/packages/cli/src/lib/runtime/execute-route.ts
@@ -65,6 +65,7 @@ import {
type RuntimeExecutionResult,
} from "./result.js"
import { deriveRouteIdentity } from "./route-identity.js"
+import type { SandboxManager } from "./sandbox-manager.js"
import { discoverStateDefinition } from "./state-discovery.js"
import type { StreamChunk } from "./stream-types.js"
import {
@@ -152,7 +153,15 @@ export async function executeResolvedRoute(options: {
readonly routeFile: string
readonly routeId: string
readonly routePath: string
+ readonly sandboxManager?: SandboxManager
+ /**
+ * Sandbox scoping key, decoupled from the checkpoint `threadId`. Subagent
+ * dispatch sets this to the PARENT thread id so the child resolves the same
+ * SandboxHandle without inheriting the parent's LangGraph checkpoint thread.
+ */
+ readonly sandboxThreadId?: string
readonly signal?: AbortSignal
+ readonly threadId?: string
}): Promise {
return await executeRouteAtResolvedPath({
...options,
@@ -215,6 +224,9 @@ export async function invokeResolvedRoute(options: {
readonly routeFile: string
readonly routeId: string
readonly routePath: string
+ readonly sandboxManager?: SandboxManager
+ /** Sandbox scoping key override — see `executeResolvedRoute`. */
+ readonly sandboxThreadId?: string
readonly signal?: AbortSignal
readonly threadId?: string
}): Promise {
@@ -238,6 +250,9 @@ export async function* streamResolvedRoute(options: {
readonly routeFile: string
readonly routeId: string
readonly routePath: string
+ readonly sandboxManager?: SandboxManager
+ /** Sandbox scoping key override — see `executeResolvedRoute`. */
+ readonly sandboxThreadId?: string
readonly signal?: AbortSignal
/**
* Stable per-conversation identifier forwarded to the agent-adapter as
@@ -268,6 +283,7 @@ export async function* streamResolvedRoute(options: {
offload,
summarization,
workspaceFs,
+ sandboxed,
} = prepared
if (normalized.kind !== "agent") {
@@ -306,6 +322,7 @@ export async function* streamResolvedRoute(options: {
...(streamTransformers && streamTransformers.length > 0 ? { streamTransformers } : {}),
...(subagentResolver ? { subagentResolver } : {}),
...(options.threadId ? { threadId: options.threadId } : {}),
+ ...(sandboxed ? { sandboxed: true } : {}),
})) {
switch (chunk.type) {
case "token":
@@ -361,6 +378,13 @@ interface PreparedRoute {
>
readonly subagentResolver?: SubagentResolver
readonly workspaceFs: WorkspaceFs
+ /**
+ * True when a per-thread sandbox is active for this turn (sandboxManager +
+ * threadId resolved a handle). The agent-adapter uses this to bypass its
+ * materialized-agent cache so tools bound to this thread's sandbox backends
+ * are never reused for another thread.
+ */
+ readonly sandboxed?: boolean
}
interface PreparedRouteError {
@@ -375,6 +399,14 @@ async function prepareRouteExecution(options: {
readonly routeId: string
readonly routePath: string
readonly signal?: AbortSignal
+ readonly threadId?: string
+ readonly sandboxManager?: SandboxManager
+ /**
+ * Sandbox scoping key, decoupled from `threadId` (the checkpoint identity).
+ * When absent, the sandbox handle falls back to `threadId` — the top-route
+ * case, where the two identities coincide.
+ */
+ readonly sandboxThreadId?: string
}): Promise {
const { isSubagent = false } = options
const routeDir = resolve(options.routeFile, "..")
@@ -449,9 +481,25 @@ async function prepareRouteExecution(options: {
// No dawn.config.ts (or unreadable). Fall back to defaults for all fields.
}
+ // When a SandboxManager is configured and we have a stable thread id, resolve
+ // the thread's sandbox handle and route the workspace filesystem/exec (and the
+ // workspace root) into it. All of readFile/writeFile/listDir/runBash redirect
+ // into the isolated env with no capability-logic change.
+ let sandboxBackends: { filesystem: FilesystemBackend; exec: ExecBackend } | undefined
+ let sandboxWorkspaceRoot: string | undefined
+ const sandboxKey = options.sandboxThreadId ?? options.threadId
+ if (options.sandboxManager && sandboxKey) {
+ const handle = await options.sandboxManager.getForThread(
+ sandboxKey,
+ options.signal ?? new AbortController().signal,
+ )
+ sandboxBackends = { filesystem: handle.filesystem, exec: handle.exec }
+ sandboxWorkspaceRoot = handle.workspaceRoot
+ }
+
const offload = buildOffload(
loadedDawnConfig,
- configBackends?.filesystem,
+ sandboxBackends?.filesystem ?? configBackends?.filesystem,
options.signal ?? new AbortController().signal,
options.appRoot,
)
@@ -489,8 +537,8 @@ async function prepareRouteExecution(options: {
await permissionsStore.load()
const workspaceFs = createWorkspaceFs({
- workspaceRoot: join(options.appRoot, "workspace"),
- backend: configBackends?.filesystem ?? localFilesystem(),
+ workspaceRoot: sandboxWorkspaceRoot ?? join(options.appRoot, "workspace"),
+ backend: sandboxBackends?.filesystem ?? configBackends?.filesystem ?? localFilesystem(),
permissions: permissionsStore,
signal: options.signal ?? new AbortController().signal,
interruptCapable: normalized.kind === "agent",
@@ -545,13 +593,15 @@ async function prepareRouteExecution(options: {
})
}
+ const capabilityBackends = sandboxBackends ?? configBackends
const applied = await applyCapabilities(registry, routeDir, {
routeManifest,
descriptor,
descriptorRouteMap,
- ...(configBackends ? { backends: configBackends } : {}),
+ ...(capabilityBackends ? { backends: capabilityBackends } : {}),
permissions: permissionsStore,
appRoot: options.appRoot,
+ ...(sandboxWorkspaceRoot ? { workspaceRoot: sandboxWorkspaceRoot } : {}),
...(memoryContext ? { memory: memoryContext } : {}),
})
@@ -652,6 +702,8 @@ async function prepareRouteExecution(options: {
routeManifest,
descriptor,
descriptorRouteMap,
+ ...(options.sandboxManager ? { sandboxManager: options.sandboxManager } : {}),
+ ...(sandboxKey ? { sandboxThreadId: sandboxKey } : {}),
})
}
}
@@ -682,6 +734,7 @@ async function prepareRouteExecution(options: {
...(subagentResolver ? { subagentResolver } : {}),
tools,
workspaceFs,
+ ...(sandboxBackends !== undefined ? { sandboxed: true } : {}),
}
}
@@ -693,6 +746,9 @@ async function executeRouteAtResolvedPath(options: {
readonly routeFile: string
readonly routeId: string
readonly routePath: string
+ readonly sandboxManager?: SandboxManager
+ /** Sandbox scoping key override — see `executeResolvedRoute`. */
+ readonly sandboxThreadId?: string
readonly signal?: AbortSignal
readonly startedAt: number
readonly threadId?: string
@@ -729,6 +785,7 @@ async function executeRouteAtResolvedPath(options: {
offload,
summarization,
workspaceFs,
+ sandboxed,
} = prepared
mode = normalized.kind
@@ -752,6 +809,7 @@ async function executeRouteAtResolvedPath(options: {
...(streamTransformers && streamTransformers.length > 0 ? { streamTransformers } : {}),
...(subagentResolver ? { subagentResolver } : {}),
...(options.threadId ? { threadId: options.threadId } : {}),
+ ...(sandboxed ? { sandboxed: true } : {}),
})
return createRuntimeSuccessResult({
@@ -811,6 +869,7 @@ async function invokeEntry(
>
readonly subagentResolver?: SubagentResolver
readonly threadId?: string
+ readonly sandboxed?: boolean
},
): Promise {
if (kind === "agent") {
@@ -843,6 +902,7 @@ async function invokeEntry(
? { subagentResolver: agentContext.subagentResolver }
: {}),
...(agentContext?.threadId ? { threadId: agentContext.threadId } : {}),
+ ...(agentContext?.sandboxed ? { sandboxed: true } : {}),
})
}
@@ -1007,8 +1067,18 @@ function buildSubagentResolver(args: {
readonly routeManifest: RouteManifest
readonly descriptor: DawnAgent | undefined
readonly descriptorRouteMap: ReadonlyMap
+ readonly sandboxManager?: SandboxManager
+ /**
+ * The dispatching thread's sandbox key (top routes: its checkpoint
+ * threadId; nested subagents: the inherited key). Forwarded to children as
+ * `sandboxThreadId` ONLY — children never receive a checkpoint `threadId`,
+ * so each child turn runs as an independent uncheckpointed invocation while
+ * still resolving the same per-thread SandboxHandle as its parent.
+ */
+ readonly sandboxThreadId?: string
}): SubagentResolver {
const { appRoot, routeDir, routeManifest, descriptor, descriptorRouteMap } = args
+ const { sandboxManager, sandboxThreadId } = args
const findConventionRoute = (leaf: string): RouteDefinition | undefined => {
const conventionDir = `${routeDir}/subagents/${leaf}`
@@ -1048,6 +1118,13 @@ function buildSubagentResolver(args: {
routeFile: route.entryFile,
routeId: route.id,
routePath: route.pathname,
+ ...(sandboxManager ? { sandboxManager } : {}),
+ // Deliberately NOT `threadId`: the child must run as an independent
+ // uncheckpointed invocation (forwarding the parent's threadId would
+ // share its in-flight LangGraph checkpoint and short-circuit the
+ // child turn). `sandboxThreadId` scopes only the sandbox handle, so
+ // the child still shares the parent thread's sandbox.
+ ...(sandboxThreadId ? { sandboxThreadId } : {}),
})
if (result.status === "failed") {
// Surface the failure to the dispatcher in a shape that
@@ -1068,6 +1145,9 @@ function buildSubagentResolver(args: {
routeFile: route.entryFile,
routeId: route.id,
routePath: route.pathname,
+ ...(sandboxManager ? { sandboxManager } : {}),
+ // Same as invoke() above: sandbox key only, never the checkpoint id.
+ ...(sandboxThreadId ? { sandboxThreadId } : {}),
})) {
yield chunk
}
diff --git a/packages/cli/src/lib/runtime/resolve-sandbox.ts b/packages/cli/src/lib/runtime/resolve-sandbox.ts
new file mode 100644
index 00000000..2f9e1ca4
--- /dev/null
+++ b/packages/cli/src/lib/runtime/resolve-sandbox.ts
@@ -0,0 +1,28 @@
+import { loadDawnConfig } from "@dawn-ai/core"
+import type { SandboxConfig, SandboxPolicy } from "@dawn-ai/workspace"
+import { SandboxManager } from "./sandbox-manager.js"
+
+const DEFAULT_IDLE_MS = 600_000
+const DEFAULT_NETWORK: SandboxPolicy["network"] = { mode: "allow", denylist: ["169.254.169.254"] }
+
+/** Build the per-server SandboxManager from dawn.config.ts, or undefined if unconfigured. */
+export async function resolveSandboxManager(appRoot: string): Promise {
+ let sandbox: SandboxConfig | undefined
+ try {
+ const loaded = await loadDawnConfig({ appRoot })
+ sandbox = loaded.config.sandbox
+ } catch {
+ return undefined
+ }
+ if (!sandbox) return undefined
+ const policy: SandboxPolicy = {
+ network: sandbox.network ?? DEFAULT_NETWORK,
+ ...(sandbox.env ? { env: sandbox.env } : {}),
+ ...(sandbox.resources ? { resources: sandbox.resources } : {}),
+ }
+ return new SandboxManager({
+ provider: sandbox.provider,
+ policy,
+ idleTimeoutMs: sandbox.idleTimeoutMs ?? DEFAULT_IDLE_MS,
+ })
+}
diff --git a/packages/cli/src/lib/runtime/sandbox-manager.ts b/packages/cli/src/lib/runtime/sandbox-manager.ts
new file mode 100644
index 00000000..92e01047
--- /dev/null
+++ b/packages/cli/src/lib/runtime/sandbox-manager.ts
@@ -0,0 +1,84 @@
+import type { SandboxHandle, SandboxPolicy, SandboxProvider } from "@dawn-ai/workspace"
+
+interface Entry {
+ handle?: SandboxHandle
+ acquiring?: Promise
+ lastUsedAt: number
+ inUse: number
+}
+
+/**
+ * Owns the per-thread sandbox lifecycle. One instance per server process.
+ * - getForThread: create-or-reuse the thread's handle (concurrent acquires deduped).
+ * - reapIdle: release() warm compute for threads idle past idleTimeoutMs (volume kept).
+ * - destroyThread: full teardown (volume removed) — thread delete.
+ * - releaseAll: shutdown — release() everything (volume kept).
+ */
+export class SandboxManager {
+ readonly #provider: SandboxProvider
+ readonly #policy: SandboxPolicy
+ readonly #idleTimeoutMs: number
+ readonly #clock: () => number
+ readonly #entries = new Map()
+
+ constructor(opts: {
+ provider: SandboxProvider
+ policy: SandboxPolicy
+ idleTimeoutMs: number
+ clock?: () => number
+ }) {
+ this.#provider = opts.provider
+ this.#policy = opts.policy
+ this.#idleTimeoutMs = opts.idleTimeoutMs
+ this.#clock = opts.clock ?? Date.now
+ }
+
+ async getForThread(threadId: string, signal: AbortSignal): Promise {
+ const existing = this.#entries.get(threadId)
+ if (existing?.handle) {
+ existing.lastUsedAt = this.#clock()
+ return existing.handle
+ }
+ if (existing?.acquiring) return existing.acquiring
+
+ const entry: Entry = { lastUsedAt: this.#clock(), inUse: 1 }
+ this.#entries.set(threadId, entry)
+ entry.acquiring = this.#provider
+ .acquire({ threadId, policy: this.#policy, signal })
+ .then((handle) => {
+ entry.handle = handle
+ delete entry.acquiring
+ entry.lastUsedAt = this.#clock()
+ return handle
+ })
+ .catch((err) => {
+ this.#entries.delete(threadId)
+ throw err
+ })
+ .finally(() => {
+ entry.inUse -= 1
+ })
+ return entry.acquiring
+ }
+
+ async reapIdle(): Promise {
+ const cutoff = this.#clock() - this.#idleTimeoutMs
+ for (const [threadId, entry] of [...this.#entries]) {
+ if (entry.inUse > 0 || entry.acquiring) continue
+ if (entry.lastUsedAt > cutoff) continue
+ this.#entries.delete(threadId)
+ await this.#provider.release(threadId)
+ }
+ }
+
+ async destroyThread(threadId: string): Promise {
+ this.#entries.delete(threadId)
+ await this.#provider.destroy(threadId)
+ }
+
+ async releaseAll(): Promise {
+ const ids = [...this.#entries.keys()]
+ this.#entries.clear()
+ await Promise.all(ids.map((id) => this.#provider.release(id)))
+ }
+}
diff --git a/packages/cli/src/runtime-exports.ts b/packages/cli/src/runtime-exports.ts
index a39ba285..67d70518 100644
--- a/packages/cli/src/runtime-exports.ts
+++ b/packages/cli/src/runtime-exports.ts
@@ -22,5 +22,11 @@ export {
resolveThreadsStore,
streamResolvedRoute,
} from "./lib/runtime/execute-route.js"
+// Exposed so wiring tests (and any out-of-band driver) can build the same
+// per-server SandboxManager the runtime HTTP server builds, then thread it
+// (+ threadId) into streamResolvedRoute — exactly what createRuntimeRequestListener
+// does internally.
+export { resolveSandboxManager } from "./lib/runtime/resolve-sandbox.js"
+export type { SandboxManager } from "./lib/runtime/sandbox-manager.js"
export type { StreamChunk } from "./lib/runtime/stream-types.js"
export { runTypegen } from "./lib/typegen/run-typegen.js"
diff --git a/packages/cli/test/collect-sandbox-errors.test.ts b/packages/cli/test/collect-sandbox-errors.test.ts
new file mode 100644
index 00000000..fa559a21
--- /dev/null
+++ b/packages/cli/test/collect-sandbox-errors.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from "vitest"
+import { collectSandboxErrors } from "../src/lib/runtime/collect-sandbox-errors.js"
+
+describe("collectSandboxErrors", () => {
+ test("no sandbox config → no errors", async () => {
+ expect(await collectSandboxErrors({})).toEqual([])
+ })
+
+ test("provider missing acquire → error", async () => {
+ const errors = await collectSandboxErrors({ sandbox: { provider: { name: "bad" } as never } })
+ expect(errors.join("\n")).toMatch(/acquire/)
+ })
+
+ test("preflight failure → error with detail", async () => {
+ const provider = {
+ name: "p",
+ acquire: async () => ({}) as never,
+ release: async () => {},
+ destroy: async () => {},
+ preflight: async () => ({ ok: false, detail: "Docker daemon not reachable" }),
+ }
+ const errors = await collectSandboxErrors({ sandbox: { provider } })
+ expect(errors.join("\n")).toMatch(/Docker daemon not reachable/)
+ })
+
+ test("preflight throw → error with message", async () => {
+ const provider = {
+ name: "p",
+ acquire: async () => ({}) as never,
+ release: async () => {},
+ destroy: async () => {},
+ preflight: async () => {
+ throw new Error("boom")
+ },
+ }
+ const errors = await collectSandboxErrors({ sandbox: { provider } })
+ expect(errors.join("\n")).toMatch(/boom/)
+ })
+
+ test("healthy provider → no errors", async () => {
+ const provider = {
+ name: "p",
+ acquire: async () => ({}) as never,
+ release: async () => {},
+ destroy: async () => {},
+ preflight: async () => ({ ok: true }),
+ }
+ expect(await collectSandboxErrors({ sandbox: { provider } })).toEqual([])
+ })
+})
diff --git a/packages/cli/test/resolve-sandbox.test.ts b/packages/cli/test/resolve-sandbox.test.ts
new file mode 100644
index 00000000..bda8d190
--- /dev/null
+++ b/packages/cli/test/resolve-sandbox.test.ts
@@ -0,0 +1,26 @@
+import { mkdtemp, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { describe, expect, test } from "vitest"
+import { resolveSandboxManager } from "../src/lib/runtime/resolve-sandbox.js"
+
+describe("resolveSandboxManager", () => {
+ test("returns undefined when no dawn.config.ts", async () => {
+ const appRoot = await mkdtemp(join(tmpdir(), "dawn-sbx-cfg-"))
+ expect(await resolveSandboxManager(appRoot)).toBeUndefined()
+ })
+
+ test("builds a manager from config.sandbox.provider", async () => {
+ const appRoot = await mkdtemp(join(tmpdir(), "dawn-sbx-cfg-"))
+ await writeFile(
+ join(appRoot, "dawn.config.ts"),
+ [
+ `import { fakeSandbox } from "@dawn-ai/sandbox/testing"`,
+ `export default { sandbox: { provider: fakeSandbox(), network: { mode: "deny" } } }`,
+ ].join("\n"),
+ "utf8",
+ )
+ const mgr = await resolveSandboxManager(appRoot)
+ expect(mgr).toBeDefined()
+ })
+})
diff --git a/packages/cli/test/sandbox-manager.test.ts b/packages/cli/test/sandbox-manager.test.ts
new file mode 100644
index 00000000..2e468845
--- /dev/null
+++ b/packages/cli/test/sandbox-manager.test.ts
@@ -0,0 +1,89 @@
+import { fakeSandbox } from "@dawn-ai/sandbox/testing"
+import { describe, expect, test, vi } from "vitest"
+import { SandboxManager } from "../src/lib/runtime/sandbox-manager.js"
+
+const policy = { network: { mode: "allow" } } as const
+const signal = () => new AbortController().signal
+const now = { t: 1_000 }
+const clock = () => now.t
+
+describe("SandboxManager", () => {
+ test("reuses one handle across turns for a thread", async () => {
+ const provider = fakeSandbox()
+ const acquire = vi.spyOn(provider, "acquire")
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 10_000, clock })
+ const h1 = await mgr.getForThread("t1", signal())
+ const h2 = await mgr.getForThread("t1", signal())
+ expect(h1).toBe(h2)
+ expect(acquire).toHaveBeenCalledTimes(1)
+ })
+
+ test("dedups concurrent acquires for the same thread", async () => {
+ const provider = fakeSandbox()
+ const acquire = vi.spyOn(provider, "acquire")
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 10_000, clock })
+ const [a, b] = await Promise.all([
+ mgr.getForThread("t1", signal()),
+ mgr.getForThread("t1", signal()),
+ ])
+ expect(a).toBe(b)
+ expect(acquire).toHaveBeenCalledTimes(1)
+ })
+
+ test("reapIdle releases (not destroys) idle threads, keeping the volume", async () => {
+ const provider = fakeSandbox()
+ const release = vi.spyOn(provider, "release")
+ const destroy = vi.spyOn(provider, "destroy")
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 10_000, clock })
+ await mgr.getForThread("t1", signal())
+ now.t = 25_000
+ await mgr.reapIdle()
+ expect(release).toHaveBeenCalledWith("t1")
+ expect(destroy).not.toHaveBeenCalled()
+ await mgr.getForThread("t1", signal())
+ })
+
+ test("does not reap an in-flight (in-use) thread", async () => {
+ const provider = fakeSandbox()
+ const release = vi.spyOn(provider, "release")
+ let resolveAcquire!: () => void
+ vi.spyOn(provider, "acquire").mockImplementation(
+ () =>
+ new Promise((r) => {
+ resolveAcquire = () =>
+ r({
+ threadId: "t1",
+ filesystem: {} as never,
+ exec: {} as never,
+ workspaceRoot: "/workspace",
+ })
+ }),
+ )
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 1, clock })
+ const inflight = mgr.getForThread("t1", signal())
+ now.t = 1_000_000
+ await mgr.reapIdle()
+ expect(release).not.toHaveBeenCalled()
+ resolveAcquire()
+ await inflight
+ })
+
+ test("destroyThread destroys + drops the entry", async () => {
+ const provider = fakeSandbox()
+ const destroy = vi.spyOn(provider, "destroy")
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 10_000, clock })
+ await mgr.getForThread("t1", signal())
+ await mgr.destroyThread("t1")
+ expect(destroy).toHaveBeenCalledWith("t1")
+ })
+
+ test("releaseAll releases every live thread", async () => {
+ const provider = fakeSandbox()
+ const release = vi.spyOn(provider, "release")
+ const mgr = new SandboxManager({ provider, policy, idleTimeoutMs: 10_000, clock })
+ await mgr.getForThread("a", signal())
+ await mgr.getForThread("b", signal())
+ await mgr.releaseAll()
+ expect(release.mock.calls.map((c) => c[0]).sort()).toEqual(["a", "b"])
+ })
+})
diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts
index 247fc22f..b418b7c3 100644
--- a/packages/cli/vitest.config.ts
+++ b/packages/cli/vitest.config.ts
@@ -12,6 +12,7 @@ export default defineConfig({
"@dawn-ai/langchain": resolve(rootDir, "../langchain/src/index.ts"),
"@dawn-ai/langgraph": resolve(rootDir, "../langgraph/src/index.ts"),
"@dawn-ai/memory": resolve(rootDir, "../memory/src/index.ts"),
+ "@dawn-ai/sandbox/testing": resolve(rootDir, "../sandbox/src/testing/index.ts"),
"@dawn-ai/sdk/testing": resolve(rootDir, "../sdk/src/testing/index.ts"),
"@dawn-ai/sdk": resolve(rootDir, "../sdk/src/index.ts"),
},
diff --git a/packages/core/src/capabilities/built-in/workspace.ts b/packages/core/src/capabilities/built-in/workspace.ts
index dcbc0d90..851ac700 100644
--- a/packages/core/src/capabilities/built-in/workspace.ts
+++ b/packages/core/src/capabilities/built-in/workspace.ts
@@ -118,10 +118,11 @@ function buildWorkspaceTools(
export function createWorkspaceMarker(): CapabilityMarker {
return {
name: "workspace",
- detect: async (_routeDir, context) => existsSync(workspaceRoot(context.appRoot)),
+ detect: async (_routeDir, context) =>
+ context.workspaceRoot !== undefined || existsSync(workspaceRoot(context.appRoot)),
load: async (_routeDir, context) => {
- const root = workspaceRoot(context.appRoot)
- if (!existsSync(root)) return {}
+ const root = context.workspaceRoot ?? workspaceRoot(context.appRoot)
+ if (context.workspaceRoot === undefined && !existsSync(root)) return {}
const fs = context.backends?.filesystem ?? localFilesystem()
const exec = context.backends?.exec ?? localExec()
const permissions = context.permissions
diff --git a/packages/core/src/capabilities/types.ts b/packages/core/src/capabilities/types.ts
index ade42333..e1e15383 100644
--- a/packages/core/src/capabilities/types.ts
+++ b/packages/core/src/capabilities/types.ts
@@ -64,6 +64,12 @@ export interface CapabilityMarkerContext {
readonly permissions?: PermissionsStore
/** Absolute path to the Dawn app root. Capabilities should resolve app-relative paths (e.g. workspace/) against this, NOT process.cwd(). */
readonly appRoot: string
+ /**
+ * When set, the workspace root path INSIDE a sandbox (e.g. "/workspace").
+ * Capabilities use this in place of `/workspace` and skip the host
+ * `existsSync` gate, since the directory lives in the sandbox, not on the host.
+ */
+ readonly workspaceRoot?: string
readonly memory?: MemoryContext
}
diff --git a/packages/core/src/config-helper.ts b/packages/core/src/config-helper.ts
new file mode 100644
index 00000000..19bb6e96
--- /dev/null
+++ b/packages/core/src/config-helper.ts
@@ -0,0 +1,10 @@
+import type { DawnConfig } from "./types.js"
+
+/**
+ * Typed identity helper for `dawn.config.ts`. Purely for IntelliSense — the
+ * loader reads `export default`, so `export default config({...})` and a bare
+ * `export default {...}` are equivalent at runtime.
+ */
+export function config(c: DawnConfig): DawnConfig {
+ return c
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 3de12b30..6c94e423 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -14,7 +14,10 @@ export type {
CapabilityError,
CapabilityRegistry,
} from "./capabilities/registry.js"
-export { applyCapabilities, createCapabilityRegistry } from "./capabilities/registry.js"
+export {
+ applyCapabilities,
+ createCapabilityRegistry,
+} from "./capabilities/registry.js"
export type {
CapabilityContribution,
CapabilityMarker,
@@ -31,6 +34,7 @@ export type {
export type { CreateWorkspaceFsOptions } from "./capabilities/workspace-fs.js"
export { createWorkspaceFs } from "./capabilities/workspace-fs.js"
export { loadDawnConfig } from "./config.js"
+export { config } from "./config-helper.js"
export { discoverRoutes } from "./discovery/discover-routes.js"
export { assertDawnRoutesDir, findDawnApp } from "./discovery/find-dawn-app.js"
export {
@@ -46,7 +50,10 @@ export type { ExtractToolSchemasOptions } from "./typegen/extract-tool-schema.js
export { extractToolSchemasForRoute } from "./typegen/extract-tool-schema.js"
export type { ExtractToolTypesOptions } from "./typegen/extract-tool-types.js"
export { extractToolTypesForRoute } from "./typegen/extract-tool-types.js"
-export { renderDawnTypes, renderRouteTypes } from "./typegen/render-route-types.js"
+export {
+ renderDawnTypes,
+ renderRouteTypes,
+} from "./typegen/render-route-types.js"
export type { RouteStateFields } from "./typegen/render-state-types.js"
export { renderStateTypes } from "./typegen/render-state-types.js"
export { renderToolTypes } from "./typegen/render-tool-types.js"
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index d937cc5b..c29dc2fb 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1,7 +1,7 @@
import type { PermissionMode } from "@dawn-ai/permissions"
import type { RouteKind } from "@dawn-ai/sdk"
import type { ThreadsStore } from "@dawn-ai/sqlite-storage"
-import type { ExecBackend, FilesystemBackend } from "@dawn-ai/workspace"
+import type { ExecBackend, FilesystemBackend, SandboxConfig } from "@dawn-ai/workspace"
import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"
export type { RouteKind }
@@ -63,6 +63,7 @@ export interface DawnConfig {
readonly signal: AbortSignal
}) => Promise
}
+ readonly sandbox?: SandboxConfig
readonly memory?: {
readonly enabled?: boolean
/** Custom memory store. Defaults to an SQLite-backed store at /.dawn/memory.sqlite. */
diff --git a/packages/core/test/config-helper.test.ts b/packages/core/test/config-helper.test.ts
new file mode 100644
index 00000000..42262ab8
--- /dev/null
+++ b/packages/core/test/config-helper.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "vitest"
+import { config } from "../src/config-helper.ts"
+import type { DawnConfig } from "../src/types.ts"
+
+describe("config()", () => {
+ test("returns the same object (identity) for IntelliSense", () => {
+ const c: DawnConfig = { appDir: "src/app" }
+ expect(config(c)).toBe(c)
+ })
+
+ test("accepts a sandbox key", () => {
+ const provider = {
+ name: "noop",
+ acquire: async () => ({
+ threadId: "t",
+ filesystem: {} as never,
+ exec: {} as never,
+ workspaceRoot: "/workspace",
+ }),
+ release: async () => {},
+ destroy: async () => {},
+ }
+ const c = config({ sandbox: { provider, network: { mode: "deny" } } })
+ expect(c.sandbox?.provider.name).toBe("noop")
+ })
+})
diff --git a/packages/langchain/src/agent-adapter.ts b/packages/langchain/src/agent-adapter.ts
index 33b90e6f..6b3b1e6f 100644
--- a/packages/langchain/src/agent-adapter.ts
+++ b/packages/langchain/src/agent-adapter.ts
@@ -176,11 +176,18 @@ export async function materializeAgentGraph(options: {
readonly stateFields?: readonly ResolvedStateField[]
readonly promptFragments?: readonly PromptFragment[]
readonly summarization?: ResolvedSummarizationConfig
+ /**
+ * Set when the caller's tools are bound to a per-thread sandbox (workspace
+ * fs/exec backends). Bypasses the per-descriptor cache so one thread's
+ * sandbox closures never leak into another thread's agent.
+ */
+ readonly sandboxed?: boolean
}): Promise {
return materializeAgent(options.descriptor, options.tools ?? [], options.checkpointer, {
...(options.stateFields ? { stateFields: options.stateFields } : {}),
...(options.promptFragments ? { promptFragments: options.promptFragments } : {}),
...(options.summarization ? { summarization: options.summarization } : {}),
+ ...(options.sandboxed === true ? { bypassCache: true } : {}),
})
}
@@ -345,6 +352,14 @@ export interface AgentOptions {
*/
readonly threadId?: string
readonly summarization?: ResolvedSummarizationConfig
+ /**
+ * Set by the CLI runtime when a per-thread sandbox is active for this turn
+ * (the workspace tools close over the thread's sandbox filesystem/exec
+ * backend). Forces `bypassCache` in materializeAgent so a cached agent
+ * compiled with one thread's sandbox tools is never reused for another
+ * thread — the one leak a sandbox must never allow.
+ */
+ readonly sandboxed?: boolean
}
export async function executeAgent(options: AgentOptions): Promise {
@@ -413,7 +428,9 @@ export async function* streamAgent(options: AgentOptions): AsyncGenerator=22.12.0"
+ },
+ "files": [
+ "dist"
+ ],
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./testing": {
+ "types": "./dist/testing/index.d.ts",
+ "default": "./dist/testing/index.js"
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsc -b tsconfig.json",
+ "lint": "biome check --config-path ../config-biome/biome.json package.json src tsconfig.json vitest.config.ts",
+ "test": "vitest --run --config vitest.config.ts --passWithNoTests",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@dawn-ai/workspace": "workspace:*"
+ },
+ "devDependencies": {
+ "@dawn-ai/config-typescript": "workspace:*",
+ "@types/node": "25.6.0"
+ }
+}
diff --git a/packages/sandbox/src/docker/docker-cli.ts b/packages/sandbox/src/docker/docker-cli.ts
new file mode 100644
index 00000000..9546a7d8
--- /dev/null
+++ b/packages/sandbox/src/docker/docker-cli.ts
@@ -0,0 +1,50 @@
+import { spawn } from "node:child_process"
+
+export interface SpawnResult {
+ readonly stdout: string
+ readonly stderr: string
+ readonly exitCode: number
+}
+
+export type Spawner = (
+ args: readonly string[],
+ opts?: { readonly stdin?: string; readonly signal?: AbortSignal },
+) => Promise
+
+const defaultSpawn: Spawner = (args, opts) =>
+ new Promise((resolve, reject) => {
+ const child = spawn("docker", [...args], {
+ stdio: ["pipe", "pipe", "pipe"],
+ ...(opts?.signal ? { signal: opts.signal } : {}),
+ })
+ let stdout = ""
+ let stderr = ""
+ child.stdout.on("data", (c) => {
+ stdout += String(c)
+ })
+ child.stderr.on("data", (c) => {
+ stderr += String(c)
+ })
+ child.on("error", reject)
+ child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }))
+ if (opts?.stdin !== undefined) child.stdin.end(opts.stdin)
+ else child.stdin.end()
+ })
+
+export interface Docker {
+ run(args: readonly string[], opts?: { readonly signal?: AbortSignal }): Promise
+ exec(
+ container: string,
+ command: readonly string[],
+ opts?: { readonly stdin?: string; readonly signal?: AbortSignal },
+ ): Promise
+}
+
+/** Thin docker-CLI wrapper. `spawn` is injectable so unit tests need no daemon. */
+export function createDocker(deps: { readonly spawn?: Spawner } = {}): Docker {
+ const sp = deps.spawn ?? defaultSpawn
+ return {
+ run: (args, opts) => sp(args, opts),
+ exec: (container, command, opts) => sp(["exec", "-i", container, ...command], opts),
+ }
+}
diff --git a/packages/sandbox/src/docker/docker-exec.ts b/packages/sandbox/src/docker/docker-exec.ts
new file mode 100644
index 00000000..047896f3
--- /dev/null
+++ b/packages/sandbox/src/docker/docker-exec.ts
@@ -0,0 +1,35 @@
+import type { BackendContext, ExecBackend } from "@dawn-ai/workspace"
+import type { Docker } from "./docker-cli.ts"
+
+function shellQuote(s: string): string {
+ return `'${s.replaceAll("'", `'\\''`)}'`
+}
+
+/** ExecBackend that runs commands inside a docker container via `docker exec sh -c`. */
+export function dockerExec(docker: Docker, container: string): ExecBackend {
+ return {
+ async runCommand(args, ctx: BackendContext) {
+ const envPrefix = args.env
+ ? Object.entries(args.env)
+ .map(([k, v]) => {
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
+ throw new Error(
+ `Invalid environment variable name ${JSON.stringify(k)}: keys must match /^[A-Za-z_][A-Za-z0-9_]*$/`,
+ )
+ }
+ return `${k}=${shellQuote(v)} `
+ })
+ .join("")
+ : ""
+ const cdPrefix = args.cwd ? `cd ${shellQuote(args.cwd)} && ` : ""
+ const r = await docker.exec(
+ container,
+ ["sh", "-c", `${envPrefix}${cdPrefix}${args.command}`],
+ {
+ signal: ctx.signal,
+ },
+ )
+ return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode }
+ },
+ }
+}
diff --git a/packages/sandbox/src/docker/docker-filesystem.ts b/packages/sandbox/src/docker/docker-filesystem.ts
new file mode 100644
index 00000000..f89767de
--- /dev/null
+++ b/packages/sandbox/src/docker/docker-filesystem.ts
@@ -0,0 +1,58 @@
+import type { BackendContext, FilesystemBackend } from "@dawn-ai/workspace"
+import type { Docker } from "./docker-cli.ts"
+
+function q(s: string): string {
+ return `'${s.replaceAll("'", `'\\''`)}'`
+}
+
+/** FilesystemBackend whose ops run inside a docker container via `docker exec`. */
+export function dockerFilesystem(docker: Docker, container: string): FilesystemBackend {
+ const run = (cmd: string, ctx: BackendContext, stdin?: string) =>
+ docker.exec(container, ["sh", "-c", cmd], {
+ ...(stdin !== undefined ? { stdin } : {}),
+ signal: ctx.signal,
+ })
+ return {
+ async readFile(path, ctx, opts) {
+ const r = await run(`cat ${q(path)}`, ctx)
+ if (r.exitCode !== 0) throw new Error(`readFile failed: ${r.stderr.trim()}`)
+ const max = opts?.maxBytes
+ if (max !== undefined && Number.isFinite(max) && Buffer.byteLength(r.stdout) > max) {
+ throw new Error(`readFile ${path}: content exceeds maxBytes (${max}).`)
+ }
+ return r.stdout
+ },
+ async writeFile(path, content, ctx) {
+ const r = await run(`mkdir -p "$(dirname ${q(path)})" && cat > ${q(path)}`, ctx, content)
+ if (r.exitCode !== 0) throw new Error(`writeFile failed: ${r.stderr.trim()}`)
+ return { bytesWritten: Buffer.byteLength(content) }
+ },
+ async listDir(path, ctx) {
+ const r = await run(`ls -1 ${q(path)}`, ctx)
+ if (r.exitCode !== 0) throw new Error(`listDir failed: ${r.stderr.trim()}`)
+ return r.stdout
+ .split("\n")
+ .map((l) => l.trim())
+ .filter(Boolean)
+ },
+ async realPath(path, ctx) {
+ const r = await run(`realpath -m ${q(path)}`, ctx)
+ return r.exitCode === 0 ? r.stdout.trim() : path
+ },
+ async statFile(path, ctx) {
+ const r = await run(`stat -c '%s %Y' ${q(path)}`, ctx)
+ if (r.exitCode !== 0) throw new Error(`statFile failed: ${r.stderr.trim()}`)
+ const [size, mtime] = r.stdout.trim().split(" ")
+ return { size: Number(size), mtimeMs: Number(mtime) * 1000 }
+ },
+ async removeFile(path, ctx) {
+ await run(`rm -f ${q(path)}`, ctx)
+ },
+ async touchFile(path, ctx) {
+ await run(`touch ${q(path)}`, ctx)
+ },
+ async mkdir(path, ctx) {
+ await run(`mkdir -p ${q(path)}`, ctx)
+ },
+ }
+}
diff --git a/packages/sandbox/src/docker/docker-sandbox.ts b/packages/sandbox/src/docker/docker-sandbox.ts
new file mode 100644
index 00000000..5e640fa1
--- /dev/null
+++ b/packages/sandbox/src/docker/docker-sandbox.ts
@@ -0,0 +1,107 @@
+import type { SandboxHandle, SandboxPolicy, SandboxProvider } from "@dawn-ai/workspace"
+import { createDocker, type Docker } from "./docker-cli.js"
+import { dockerExec } from "./docker-exec.js"
+import { dockerFilesystem } from "./docker-filesystem.js"
+
+const ROOT = "/workspace"
+const sanitize = (s: string) => s.replaceAll(/[^a-zA-Z0-9_.-]/g, "_")
+const containerName = (threadId: string) => `dawn-sbx-${sanitize(threadId)}`
+const volumeName = (threadId: string) => `dawn-sbx-vol-${sanitize(threadId)}`
+
+export interface DockerSandboxOptions {
+ /** Container image for the sandbox (must include a POSIX shell). */
+ readonly image: string
+ /** Injected for tests; defaults to the real docker CLI. */
+ readonly docker?: Docker
+}
+
+/**
+ * Docker reference SandboxProvider. Per thread: a persistent container
+ * `dawn-sbx-` (sleep infinity) with a named volume mounted at
+ * /workspace. acquire() is create-or-reattach (running → reuse; stopped →
+ * start; absent → run). release() removes the container but KEEPS the volume;
+ * destroy() removes both. Network: deny → --network none (exact); allow →
+ * bridge (denylist is best-effort and NOT enforced here — see the spec's
+ * honest-scope note). Host env is never inherited; only policy.env is passed.
+ */
+export function dockerSandbox(opts: DockerSandboxOptions): SandboxProvider {
+ const docker = opts.docker ?? createDocker()
+
+ const ensureContainer = async (
+ threadId: string,
+ policy: SandboxPolicy,
+ signal: AbortSignal,
+ ): Promise => {
+ const name = containerName(threadId)
+ const running = await docker.run(["ps", "-q", "--filter", `name=^${name}$`], { signal })
+ if (running.stdout.trim()) return name
+ const existing = await docker.run(["ps", "-aq", "--filter", `name=^${name}$`], { signal })
+ if (existing.stdout.trim()) {
+ await docker.run(["start", name], { signal })
+ return name
+ }
+ const net = policy.network.mode === "deny" ? ["--network", "none"] : ["--network", "bridge"]
+ const envArgs = Object.entries(policy.env ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`])
+ const res = policy.resources
+ const limits = [
+ ...(res?.memoryMb ? ["--memory", `${res.memoryMb}m`] : []),
+ ...(res?.cpus ? ["--cpus", String(res.cpus)] : []),
+ ]
+ const created = await docker.run(
+ [
+ "run",
+ "-d",
+ "--name",
+ name,
+ "--label",
+ `dawn.sandbox=${sanitize(threadId)}`,
+ "-v",
+ `${volumeName(threadId)}:${ROOT}`,
+ "-w",
+ ROOT,
+ ...net,
+ ...envArgs,
+ ...limits,
+ opts.image,
+ "sleep",
+ "infinity",
+ ],
+ { signal },
+ )
+ if (created.exitCode !== 0) {
+ throw new Error(
+ `Sandbox unavailable: docker run failed for thread "${threadId}": ${created.stderr.trim() || "unknown error"}. Run \`dawn check\`.`,
+ )
+ }
+ return name
+ }
+
+ return {
+ name: "docker",
+ async acquire({ threadId, policy, signal }): Promise {
+ const container = await ensureContainer(threadId, policy, signal)
+ return {
+ threadId,
+ filesystem: dockerFilesystem(docker, container),
+ exec: dockerExec(docker, container),
+ workspaceRoot: ROOT,
+ }
+ },
+ async release(threadId) {
+ await docker.run(["rm", "-f", containerName(threadId)]).catch(() => {})
+ },
+ async destroy(threadId) {
+ await docker.run(["rm", "-f", containerName(threadId)]).catch(() => {})
+ await docker.run(["volume", "rm", volumeName(threadId)]).catch(() => {})
+ },
+ async preflight() {
+ const v = await docker
+ .run(["version", "--format", "{{.Server.Version}}"])
+ .catch(() => undefined)
+ if (!v || v.exitCode !== 0) {
+ return { ok: false, detail: "Docker daemon not reachable (`docker version` failed)." }
+ }
+ return { ok: true, detail: `Docker ${v.stdout.trim()}` }
+ },
+ }
+}
diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts
new file mode 100644
index 00000000..0db86c7c
--- /dev/null
+++ b/packages/sandbox/src/index.ts
@@ -0,0 +1,7 @@
+export type {
+ SandboxConfig,
+ SandboxHandle,
+ SandboxPolicy,
+ SandboxProvider,
+} from "@dawn-ai/workspace"
+export { type DockerSandboxOptions, dockerSandbox } from "./docker/docker-sandbox.js"
diff --git a/packages/sandbox/src/testing/conformance.ts b/packages/sandbox/src/testing/conformance.ts
new file mode 100644
index 00000000..b0bef3b5
--- /dev/null
+++ b/packages/sandbox/src/testing/conformance.ts
@@ -0,0 +1,62 @@
+import type { SandboxProvider } from "@dawn-ai/workspace"
+import { expect, test } from "vitest"
+
+const ctx = (workspaceRoot: string) => ({ signal: new AbortController().signal, workspaceRoot })
+const policy = { network: { mode: "allow" } } as const
+
+/**
+ * The contract every SandboxProvider must satisfy. Reused by fakeSandbox (CI)
+ * and dockerSandbox (gated Docker lane) so the fake cannot drift from reality.
+ * Pass vitest's `describe` so the kit can group under any runner.
+ */
+export function runProviderConformance(opts: {
+ readonly name: string
+ readonly makeProvider: () => SandboxProvider
+ readonly describe: (name: string, fn: () => void) => void
+}): void {
+ opts.describe(`SandboxProvider conformance: ${opts.name}`, () => {
+ test("acquire is idempotent per thread and reattaches the workspace", async () => {
+ const p = opts.makeProvider()
+ const a = await p.acquire({ threadId: "t1", policy, signal: ctx("/").signal })
+ await a.filesystem.writeFile(`${a.workspaceRoot}/x`, "1", ctx(a.workspaceRoot))
+ const b = await p.acquire({ threadId: "t1", policy, signal: ctx("/").signal })
+ expect(await b.filesystem.readFile(`${b.workspaceRoot}/x`, ctx(b.workspaceRoot))).toBe("1")
+ await p.destroy("t1")
+ })
+
+ test("threads are isolated", async () => {
+ const p = opts.makeProvider()
+ const a = await p.acquire({ threadId: "a", policy, signal: ctx("/").signal })
+ await a.filesystem.writeFile(`${a.workspaceRoot}/secret`, "s", ctx(a.workspaceRoot))
+ const b = await p.acquire({ threadId: "b", policy, signal: ctx("/").signal })
+ expect(await b.filesystem.listDir(b.workspaceRoot, ctx(b.workspaceRoot))).not.toContain(
+ "secret",
+ )
+ await p.destroy("a")
+ await p.destroy("b")
+ })
+
+ test("release keeps the volume, destroy clears it", async () => {
+ const p = opts.makeProvider()
+ const a = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal })
+ await a.filesystem.writeFile(`${a.workspaceRoot}/keep`, "1", ctx(a.workspaceRoot))
+ await p.release("t")
+ const r = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal })
+ expect(await r.filesystem.readFile(`${r.workspaceRoot}/keep`, ctx(r.workspaceRoot))).toBe("1")
+ await p.destroy("t")
+ const d = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal })
+ expect(await d.filesystem.listDir(d.workspaceRoot, ctx(d.workspaceRoot))).not.toContain(
+ "keep",
+ )
+ await p.destroy("t")
+ })
+
+ test("exec returns a numeric exit code", async () => {
+ const p = opts.makeProvider()
+ const a = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal })
+ const r = await a.exec.runCommand({ command: "true" }, ctx(a.workspaceRoot))
+ expect(typeof r.exitCode).toBe("number")
+ await p.destroy("t")
+ })
+ })
+}
diff --git a/packages/sandbox/src/testing/fake-sandbox.ts b/packages/sandbox/src/testing/fake-sandbox.ts
new file mode 100644
index 00000000..c62de4cc
--- /dev/null
+++ b/packages/sandbox/src/testing/fake-sandbox.ts
@@ -0,0 +1,81 @@
+import type {
+ BackendContext,
+ ExecBackend,
+ FilesystemBackend,
+ SandboxHandle,
+ SandboxProvider,
+} from "@dawn-ai/workspace"
+
+type ExecFn = (
+ args: {
+ readonly command: string
+ readonly cwd?: string
+ readonly env?: Readonly>
+ },
+ ctx: BackendContext,
+) => Promise<{ readonly stdout: string; readonly stderr: string; readonly exitCode: number }>
+
+const ROOT = "/workspace"
+
+/** In-memory SandboxProvider for unit + wiring tests. No Docker. */
+export function fakeSandbox(opts: { readonly exec?: ExecFn } = {}): SandboxProvider {
+ const volumes = new Map>()
+ const liveThreads = new Set()
+
+ const volumeFor = (threadId: string): Map => {
+ let v = volumes.get(threadId)
+ if (!v) {
+ v = new Map()
+ volumes.set(threadId, v)
+ }
+ return v
+ }
+
+ const makeFilesystem = (vol: Map): FilesystemBackend => ({
+ async readFile(path) {
+ const v = vol.get(path)
+ if (v === undefined) throw new Error(`ENOENT: ${path}`)
+ return v
+ },
+ async writeFile(path, content) {
+ vol.set(path, content)
+ return { bytesWritten: Buffer.byteLength(content) }
+ },
+ async listDir(path) {
+ const prefix = path.endsWith("/") ? path : `${path}/`
+ const names = new Set()
+ for (const key of vol.keys()) {
+ if (key.startsWith(prefix)) {
+ const part = key.slice(prefix.length).split("/")[0]
+ if (part !== undefined) names.add(part)
+ }
+ }
+ return [...names].sort()
+ },
+ async realPath(path) {
+ return path
+ },
+ })
+
+ const defaultExec: ExecFn = async () => ({ stdout: "", stderr: "", exitCode: 0 })
+
+ return {
+ name: "fake",
+ async acquire({ threadId }): Promise {
+ liveThreads.add(threadId)
+ const vol = volumeFor(threadId)
+ const exec: ExecBackend = { runCommand: (args, ctx) => (opts.exec ?? defaultExec)(args, ctx) }
+ return { threadId, filesystem: makeFilesystem(vol), exec, workspaceRoot: ROOT }
+ },
+ async release(threadId) {
+ liveThreads.delete(threadId)
+ },
+ async destroy(threadId) {
+ liveThreads.delete(threadId)
+ volumes.delete(threadId)
+ },
+ async preflight() {
+ return { ok: true }
+ },
+ }
+}
diff --git a/packages/sandbox/src/testing/index.ts b/packages/sandbox/src/testing/index.ts
new file mode 100644
index 00000000..8384cd04
--- /dev/null
+++ b/packages/sandbox/src/testing/index.ts
@@ -0,0 +1,2 @@
+export { runProviderConformance } from "./conformance.js"
+export { fakeSandbox } from "./fake-sandbox.js"
diff --git a/packages/sandbox/test/conformance-fake.test.ts b/packages/sandbox/test/conformance-fake.test.ts
new file mode 100644
index 00000000..df064eb1
--- /dev/null
+++ b/packages/sandbox/test/conformance-fake.test.ts
@@ -0,0 +1,8 @@
+import { describe } from "vitest"
+import { fakeSandbox, runProviderConformance } from "../src/testing/index.ts"
+
+runProviderConformance({
+ name: "fakeSandbox",
+ makeProvider: () => fakeSandbox(),
+ describe,
+})
diff --git a/packages/sandbox/test/docker-backends.test.ts b/packages/sandbox/test/docker-backends.test.ts
new file mode 100644
index 00000000..b2f8e484
--- /dev/null
+++ b/packages/sandbox/test/docker-backends.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, test } from "vitest"
+import type { Docker } from "../src/docker/docker-cli.ts"
+import { dockerExec } from "../src/docker/docker-exec.ts"
+import { dockerFilesystem } from "../src/docker/docker-filesystem.ts"
+
+const ctx = { signal: new AbortController().signal, workspaceRoot: "/workspace" }
+const fakeDocker = (handlers: Partial): Docker => ({
+ run: handlers.run ?? (async () => ({ stdout: "", stderr: "", exitCode: 0 })),
+ exec: handlers.exec ?? (async () => ({ stdout: "", stderr: "", exitCode: 0 })),
+})
+
+describe("dockerFilesystem", () => {
+ test("readFile cats inside the container", async () => {
+ const fs = dockerFilesystem(
+ fakeDocker({
+ exec: async (_c, cmd) => ({
+ stdout: cmd.join(" ").includes("cat") ? "file-body" : "",
+ stderr: "",
+ exitCode: 0,
+ }),
+ }),
+ "c1",
+ )
+ expect(await fs.readFile("/workspace/a.txt", ctx)).toBe("file-body")
+ })
+
+ test("readFile enforces maxBytes", async () => {
+ const fs = dockerFilesystem(
+ fakeDocker({ exec: async () => ({ stdout: "0123456789", stderr: "", exitCode: 0 }) }),
+ "c1",
+ )
+ await expect(fs.readFile("/workspace/a.txt", ctx, { maxBytes: 4 })).rejects.toThrow(/maxBytes|too large|exceeds/i)
+ })
+
+ test("writeFile pipes content via stdin", async () => {
+ let stdin: string | undefined
+ const fs = dockerFilesystem(
+ fakeDocker({
+ exec: async (_c, _cmd, opts) => {
+ stdin = opts?.stdin
+ return { stdout: "", stderr: "", exitCode: 0 }
+ },
+ }),
+ "c1",
+ )
+ const r = await fs.writeFile("/workspace/a.txt", "hello", ctx)
+ expect(stdin).toBe("hello")
+ expect(r.bytesWritten).toBe(5)
+ })
+
+ test("writeFile creates parent directories before writing", async () => {
+ let seen: readonly string[] = []
+ const fs = dockerFilesystem(
+ fakeDocker({
+ exec: async (_c, cmd) => {
+ seen = cmd
+ return { stdout: "", stderr: "", exitCode: 0 }
+ },
+ }),
+ "c1",
+ )
+ await fs.writeFile("/workspace/new dir/deep/a.txt", "hello", ctx)
+ const shCmd = seen[2] ?? ""
+ expect(shCmd).toContain("mkdir -p")
+ expect(shCmd).toContain("cat >")
+ expect(shCmd).toContain(`"$(dirname '/workspace/new dir/deep/a.txt')"`)
+ })
+
+ test("listDir parses ls -1 output", async () => {
+ const fs = dockerFilesystem(
+ fakeDocker({ exec: async () => ({ stdout: "a\nb\n", stderr: "", exitCode: 0 }) }),
+ "c1",
+ )
+ expect(await fs.listDir("/workspace", ctx)).toEqual(["a", "b"])
+ })
+
+ test("failed op throws with stderr", async () => {
+ const fs = dockerFilesystem(
+ fakeDocker({ exec: async () => ({ stdout: "", stderr: "No such file", exitCode: 1 }) }),
+ "c1",
+ )
+ await expect(fs.readFile("/workspace/nope", ctx)).rejects.toThrow(/No such file/)
+ })
+})
+
+describe("dockerExec", () => {
+ test("runCommand runs sh -c inside the container with cwd + env", async () => {
+ let seen: readonly string[] = []
+ const exec = dockerExec(
+ fakeDocker({
+ exec: async (_c, cmd) => {
+ seen = cmd
+ return { stdout: "out", stderr: "", exitCode: 0 }
+ },
+ }),
+ "c1",
+ )
+ const r = await exec.runCommand({ command: "echo hi", cwd: "/workspace/sub", env: { A: "1" } }, ctx)
+ expect(seen[0]).toBe("sh")
+ expect(seen[1]).toBe("-c")
+ expect(seen[2]).toContain("echo hi")
+ expect(seen[2]).toContain("cd '/workspace/sub'")
+ expect(seen[2]).toContain("A='1'")
+ expect(r).toEqual({ stdout: "out", stderr: "", exitCode: 0 })
+ })
+
+ test("runCommand rejects invalid env keys with a clear error", async () => {
+ const exec = dockerExec(fakeDocker({}), "c1")
+ await expect(
+ exec.runCommand({ command: "echo hi", env: { "BAD KEY;x": "1" } }, ctx),
+ ).rejects.toThrow(/Invalid environment variable name "BAD KEY;x"/)
+ })
+})
diff --git a/packages/sandbox/test/docker-cli.test.ts b/packages/sandbox/test/docker-cli.test.ts
new file mode 100644
index 00000000..c094d047
--- /dev/null
+++ b/packages/sandbox/test/docker-cli.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from "vitest"
+import { createDocker } from "../src/docker/docker-cli.ts"
+
+describe("createDocker", () => {
+ test("runs docker with args, returns stdout/exit", async () => {
+ const calls: string[][] = []
+ const docker = createDocker({
+ spawn: async (args, _opts) => {
+ calls.push([...args])
+ return { stdout: "ok", stderr: "", exitCode: 0 }
+ },
+ })
+ const r = await docker.run(["ps", "-q"])
+ expect(r.stdout).toBe("ok")
+ expect(calls[0]).toEqual(["ps", "-q"])
+ })
+
+ test("execInto pipes stdin and targets a container", async () => {
+ const seen: { args: string[]; stdin?: string }[] = []
+ const docker = createDocker({
+ spawn: async (args, opts) => {
+ seen.push({ args: [...args], ...(opts?.stdin !== undefined ? { stdin: opts.stdin } : {}) })
+ return { stdout: "", stderr: "", exitCode: 0 }
+ },
+ })
+ await docker.exec("c1", ["sh", "-c", "cat > /workspace/f"], { stdin: "data" })
+ expect(seen[0]?.args.slice(0, 2)).toEqual(["exec", "-i"])
+ expect(seen[0]?.args).toContain("c1")
+ expect(seen[0]?.stdin).toBe("data")
+ })
+})
diff --git a/packages/sandbox/test/docker-sandbox.integration.test.ts b/packages/sandbox/test/docker-sandbox.integration.test.ts
new file mode 100644
index 00000000..ff5d1c17
--- /dev/null
+++ b/packages/sandbox/test/docker-sandbox.integration.test.ts
@@ -0,0 +1,71 @@
+import { randomUUID } from "node:crypto"
+import { existsSync } from "node:fs"
+import { describe, expect, test } from "vitest"
+import { dockerSandbox } from "../src/index.ts"
+import { runProviderConformance } from "../src/testing/index.ts"
+
+// Real-Docker lane. Runs ONLY when DAWN_TEST_DOCKER=1 (the dedicated CI job
+// sets it; the default validate lane never does). Locally: DAWN_TEST_DOCKER=1
+// with a running Docker daemon.
+const enabled = process.env.DAWN_TEST_DOCKER === "1"
+const IMAGE = "node:22-slim"
+const ctx = (workspaceRoot: string) => ({ signal: new AbortController().signal, workspaceRoot })
+const policyDeny = { network: { mode: "deny" } } as const
+
+describe.skipIf(!enabled)("dockerSandbox (real Docker)", { timeout: 120_000 }, () => {
+ runProviderConformance({
+ name: "dockerSandbox",
+ makeProvider: () => dockerSandbox({ image: IMAGE }),
+ describe,
+ })
+
+ test("network deny blocks egress (curl/wget fails inside)", { timeout: 120_000 }, async () => {
+ const p = dockerSandbox({ image: IMAGE })
+ const threadId = `net-${randomUUID()}`
+ try {
+ const h = await p.acquire({ threadId, policy: policyDeny, signal: ctx("/").signal })
+ // node:22-slim has node; use node's fetch with a short timeout — no curl dependency.
+ const r = await h.exec.runCommand(
+ {
+ command:
+ `node -e "fetch('https://registry.npmjs.org/', {signal: AbortSignal.timeout(5000)}).then(()=>{console.log('REACHED');process.exit(0)}).catch(()=>{console.log('BLOCKED');process.exit(7)})"`,
+ },
+ ctx(h.workspaceRoot),
+ )
+ expect(r.exitCode).toBe(7)
+ expect(r.stdout).toContain("BLOCKED")
+ } finally {
+ await p.destroy(threadId)
+ }
+ })
+
+ test("host filesystem is untouched by sandbox writes", { timeout: 120_000 }, async () => {
+ const p = dockerSandbox({ image: IMAGE })
+ const threadId = `host-${randomUUID()}`
+ try {
+ const h = await p.acquire({ threadId, policy: policyDeny, signal: ctx("/").signal })
+ await h.filesystem.writeFile(`${h.workspaceRoot}/host-check.txt`, "sandboxed", ctx(h.workspaceRoot))
+ expect(await h.filesystem.readFile(`${h.workspaceRoot}/host-check.txt`, ctx(h.workspaceRoot))).toBe(
+ "sandboxed",
+ )
+ expect(existsSync("/workspace/host-check.txt")).toBe(false)
+ expect(existsSync(`${process.cwd()}/workspace/host-check.txt`)).toBe(false)
+ } finally {
+ await p.destroy(threadId)
+ }
+ })
+
+ test("restart durability: release then reacquire reattaches the volume", { timeout: 180_000 }, async () => {
+ const p = dockerSandbox({ image: IMAGE })
+ const threadId = `dur-${randomUUID()}`
+ try {
+ const h1 = await p.acquire({ threadId, policy: policyDeny, signal: ctx("/").signal })
+ await h1.filesystem.writeFile(`${h1.workspaceRoot}/persist.txt`, "v1", ctx(h1.workspaceRoot))
+ await p.release(threadId) // container gone, volume kept
+ const h2 = await p.acquire({ threadId, policy: policyDeny, signal: ctx("/").signal })
+ expect(await h2.filesystem.readFile(`${h2.workspaceRoot}/persist.txt`, ctx(h2.workspaceRoot))).toBe("v1")
+ } finally {
+ await p.destroy(threadId)
+ }
+ })
+})
diff --git a/packages/sandbox/test/docker-sandbox.unit.test.ts b/packages/sandbox/test/docker-sandbox.unit.test.ts
new file mode 100644
index 00000000..56f1bcf5
--- /dev/null
+++ b/packages/sandbox/test/docker-sandbox.unit.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, test } from "vitest"
+import type { Docker } from "../src/docker/docker-cli.ts"
+import { dockerSandbox } from "../src/docker/docker-sandbox.ts"
+
+function recordingDocker(): { docker: Docker; runs: string[][] } {
+ const runs: string[][] = []
+ const docker: Docker = {
+ run: async (args) => {
+ runs.push([...args])
+ if (args[0] === "ps") return { stdout: "", stderr: "", exitCode: 0 } // not running / absent
+ return { stdout: "ok", stderr: "", exitCode: 0 }
+ },
+ exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
+ }
+ return { docker, runs }
+}
+
+const signal = () => new AbortController().signal
+
+describe("dockerSandbox (unit, no daemon)", () => {
+ test("acquire runs a container named for the thread + names a volume; deny → --network none", async () => {
+ const { docker, runs } = recordingDocker()
+ const p = dockerSandbox({ image: "node:22-slim", docker })
+ const h = await p.acquire({ threadId: "abc", policy: { network: { mode: "deny" } }, signal: signal() })
+ expect(h.workspaceRoot).toBe("/workspace")
+ expect(h.threadId).toBe("abc")
+ const runCmd = runs.find((r) => r[0] === "run")
+ expect(runCmd).toBeDefined()
+ const joined = (runCmd ?? []).join(" ")
+ expect(joined).toContain("dawn-sbx-abc")
+ expect(joined).toContain("dawn-sbx-vol-abc:/workspace")
+ expect(joined).toContain("--network none")
+ expect(joined).toContain("--label dawn.sandbox=abc")
+ expect(joined).toContain("sleep infinity")
+ })
+
+ test("allow mode uses bridge network; resources + env are applied; host env NOT inherited", async () => {
+ const { docker, runs } = recordingDocker()
+ const p = dockerSandbox({ image: "node:22-slim", docker })
+ await p.acquire({
+ threadId: "abc",
+ policy: {
+ network: { mode: "allow", denylist: ["169.254.169.254"] },
+ env: { FOO: "bar" },
+ resources: { memoryMb: 512, cpus: 1 },
+ },
+ signal: signal(),
+ })
+ const joined = (runs.find((r) => r[0] === "run") ?? []).join(" ")
+ expect(joined).toContain("--network bridge")
+ expect(joined).toContain("--memory 512m")
+ expect(joined).toContain("--cpus 1")
+ expect(joined).toContain("FOO=bar")
+ expect(joined).not.toContain("PATH=") // no host env leakage
+ })
+
+ test("acquire reattaches: running container → no docker run; stopped → docker start", async () => {
+ const runs: string[][] = []
+ let psQCount = 0
+ const docker: Docker = {
+ run: async (args) => {
+ runs.push([...args])
+ if (args[0] === "ps" && args.includes("-q") && !args.includes("-a")) {
+ psQCount += 1
+ return { stdout: psQCount === 1 ? "runningid" : "", stderr: "", exitCode: 0 }
+ }
+ if (args[0] === "ps") return { stdout: "stoppedid", stderr: "", exitCode: 0 } // ps -aq: exists
+ return { stdout: "", stderr: "", exitCode: 0 }
+ },
+ exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
+ }
+ const p = dockerSandbox({ image: "node:22-slim", docker })
+ // 1st acquire: container "running" → neither run nor start
+ await p.acquire({ threadId: "t", policy: { network: { mode: "deny" } }, signal: signal() })
+ expect(runs.some((r) => r[0] === "run")).toBe(false)
+ expect(runs.some((r) => r[0] === "start")).toBe(false)
+ // 2nd acquire: not running but exists → docker start
+ await p.acquire({ threadId: "t", policy: { network: { mode: "deny" } }, signal: signal() })
+ expect(runs.some((r) => r[0] === "start")).toBe(true)
+ })
+
+ test("release removes container but not volume; destroy removes both", async () => {
+ const { docker, runs } = recordingDocker()
+ const p = dockerSandbox({ image: "node:22-slim", docker })
+ await p.acquire({ threadId: "abc", policy: { network: { mode: "deny" } }, signal: signal() })
+ await p.release("abc")
+ expect(runs.some((r) => r[0] === "rm" && r.includes("dawn-sbx-abc"))).toBe(true)
+ expect(runs.some((r) => r[0] === "volume" && r[1] === "rm")).toBe(false)
+ await p.destroy("abc")
+ expect(runs.some((r) => r[0] === "volume" && r[1] === "rm" && r.includes("dawn-sbx-vol-abc"))).toBe(true)
+ })
+
+ test("preflight reports daemon unreachable", async () => {
+ const docker: Docker = {
+ run: async () => ({ stdout: "", stderr: "cannot connect", exitCode: 1 }),
+ exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
+ }
+ const p = dockerSandbox({ image: "node:22-slim", docker })
+ const r = await p.preflight?.()
+ expect(r?.ok).toBe(false)
+ expect(r?.detail).toMatch(/daemon|reachable/i)
+ })
+
+ test("thread ids are sanitized for container/volume names", async () => {
+ const { docker, runs } = recordingDocker()
+ const p = dockerSandbox({ image: "node:22-slim", docker })
+ await p.acquire({ threadId: "t/1:x", policy: { network: { mode: "deny" } }, signal: signal() })
+ const joined = (runs.find((r) => r[0] === "run") ?? []).join(" ")
+ expect(joined).toContain("dawn-sbx-t_1_x")
+ expect(joined).not.toContain("t/1:x")
+ })
+})
diff --git a/packages/sandbox/test/fake-sandbox.test.ts b/packages/sandbox/test/fake-sandbox.test.ts
new file mode 100644
index 00000000..b98db9c8
--- /dev/null
+++ b/packages/sandbox/test/fake-sandbox.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from "vitest"
+import { fakeSandbox } from "../src/testing/index.ts"
+
+const ctx = (workspaceRoot: string) => ({ signal: new AbortController().signal, workspaceRoot })
+
+describe("fakeSandbox", () => {
+ test("isolates filesystem per thread, persists across acquire (reattach)", async () => {
+ const provider = fakeSandbox()
+ const a1 = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ await a1.filesystem.writeFile("/workspace/note.txt", "hello", ctx(a1.workspaceRoot))
+
+ const a2 = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ expect(await a2.filesystem.readFile("/workspace/note.txt", ctx(a2.workspaceRoot))).toBe("hello")
+
+ const b = await provider.acquire({ threadId: "b", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ expect(await b.filesystem.listDir("/workspace", ctx(b.workspaceRoot))).toEqual([])
+ })
+
+ test("release keeps the volume, destroy clears it", async () => {
+ const provider = fakeSandbox()
+ const h = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ await h.filesystem.writeFile("/workspace/f", "1", ctx(h.workspaceRoot))
+
+ await provider.release("a")
+ const after = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ expect(await after.filesystem.readFile("/workspace/f", ctx(after.workspaceRoot))).toBe("1")
+
+ await provider.destroy("a")
+ const fresh = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ expect(await fresh.filesystem.listDir("/workspace", ctx(fresh.workspaceRoot))).toEqual([])
+ })
+
+ test("exec is scripted + records commands; runBash sees fs writes", async () => {
+ const provider = fakeSandbox({ exec: async ({ command }) => ({ stdout: `ran:${command}`, stderr: "", exitCode: 0 }) })
+ const h = await provider.acquire({ threadId: "a", policy: { network: { mode: "allow" } }, signal: ctx("/x").signal })
+ const r = await h.exec.runCommand({ command: "echo hi" }, ctx(h.workspaceRoot))
+ expect(r).toEqual({ stdout: "ran:echo hi", stderr: "", exitCode: 0 })
+ })
+})
diff --git a/packages/sandbox/tsconfig.json b/packages/sandbox/tsconfig.json
new file mode 100644
index 00000000..34173d08
--- /dev/null
+++ b/packages/sandbox/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "../config-typescript/node.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
+ },
+ "include": ["src/**/*.ts"],
+ "references": [
+ {
+ "path": "../workspace"
+ }
+ ]
+}
diff --git a/packages/sandbox/vitest.config.ts b/packages/sandbox/vitest.config.ts
new file mode 100644
index 00000000..44373404
--- /dev/null
+++ b/packages/sandbox/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config"
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["test/**/*.test.ts"],
+ },
+})
diff --git a/packages/workspace/src/index.ts b/packages/workspace/src/index.ts
index a9f62329..a6a5ea67 100644
--- a/packages/workspace/src/index.ts
+++ b/packages/workspace/src/index.ts
@@ -1,6 +1,12 @@
export { compose } from "./compose.js"
export { type LocalExecOptions, localExec } from "./local-exec.js"
export { type LocalFilesystemOptions, localFilesystem } from "./local-filesystem.js"
+export type {
+ SandboxConfig,
+ SandboxHandle,
+ SandboxPolicy,
+ SandboxProvider,
+} from "./sandbox-types.js"
export type {
BackendContext,
ExecBackend,
diff --git a/packages/workspace/src/sandbox-types.ts b/packages/workspace/src/sandbox-types.ts
new file mode 100644
index 00000000..c3ce8c6a
--- /dev/null
+++ b/packages/workspace/src/sandbox-types.ts
@@ -0,0 +1,59 @@
+/**
+ * Execution-sandbox contract. A SandboxProvider yields, per conversation
+ * thread, a SandboxHandle whose filesystem/exec backends implement the same
+ * interfaces the workspace capability already consumes — so swapping them in
+ * redirects all of readFile/writeFile/listDir/runBash into the isolated env
+ * with no change to the capability. See the execution-sandbox spec.
+ */
+import type { ExecBackend, FilesystemBackend } from "./types.js"
+
+export interface SandboxPolicy {
+ readonly network:
+ | { readonly mode: "allow"; readonly denylist?: readonly string[] }
+ | { readonly mode: "deny"; readonly allowlist?: readonly string[] }
+ /** Explicit env injected into the sandbox. The host env is NEVER inherited. */
+ readonly env?: Readonly>
+ readonly resources?: {
+ readonly memoryMb?: number
+ readonly cpus?: number
+ readonly timeoutMs?: number
+ }
+}
+
+export interface SandboxHandle {
+ readonly threadId: string
+ readonly filesystem: FilesystemBackend
+ readonly exec: ExecBackend
+ /** Absolute path of the workspace root INSIDE the sandbox, e.g. "/workspace". */
+ readonly workspaceRoot: string
+}
+
+export interface SandboxProvider {
+ readonly name: string
+ /**
+ * Create-or-reattach the thread's sandbox. Idempotent per threadId: called at
+ * the start of every turn; returns the same live sandbox across turns until
+ * release()/destroy(). Reattaches an existing workspace volume by deterministic
+ * name after a restart or container reap rather than starting empty.
+ */
+ acquire(input: {
+ readonly threadId: string
+ readonly policy: SandboxPolicy
+ readonly signal: AbortSignal
+ }): Promise
+ /** Drop warm compute but KEEP the workspace volume (idle-reap + shutdown). */
+ release(threadId: string): Promise
+ /** Destroy the sandbox AND its workspace volume (thread delete). */
+ destroy(threadId: string): Promise
+ /** Optional availability probe surfaced by `dawn check`. */
+ preflight?(): Promise<{ readonly ok: boolean; readonly detail?: string }>
+}
+
+export interface SandboxConfig {
+ readonly provider: SandboxProvider
+ readonly network?: SandboxPolicy["network"]
+ readonly env?: SandboxPolicy["env"]
+ readonly resources?: SandboxPolicy["resources"]
+ /** Manager-level idle reap window. Default 600_000 (10 min). */
+ readonly idleTimeoutMs?: number
+}
diff --git a/packages/workspace/test/sandbox-types.test.ts b/packages/workspace/test/sandbox-types.test.ts
new file mode 100644
index 00000000..02747097
--- /dev/null
+++ b/packages/workspace/test/sandbox-types.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from "vitest"
+import type {
+ SandboxConfig,
+ SandboxHandle,
+ SandboxPolicy,
+ SandboxProvider,
+} from "../src/sandbox-types.ts"
+import type { ExecBackend, FilesystemBackend } from "../src/types.ts"
+
+describe("sandbox contract types", () => {
+ test("a handle exposes workspace backends + an in-sandbox root", () => {
+ const fs = {} as FilesystemBackend
+ const exec = {} as ExecBackend
+ const handle: SandboxHandle = { threadId: "t1", filesystem: fs, exec, workspaceRoot: "/workspace" }
+ expect(handle.workspaceRoot).toBe("/workspace")
+ })
+
+ test("policy network is a discriminated union (allow|deny)", () => {
+ const allow: SandboxPolicy["network"] = { mode: "allow", denylist: ["1.2.3.4"] }
+ const deny: SandboxPolicy["network"] = { mode: "deny", allowlist: ["registry.npmjs.org"] }
+ expect(allow.mode).toBe("allow")
+ expect(deny.mode).toBe("deny")
+ })
+
+ test("a provider implements acquire/release/destroy", async () => {
+ const provider: SandboxProvider = {
+ name: "noop",
+ acquire: async ({ threadId }) => ({
+ threadId,
+ filesystem: {} as FilesystemBackend,
+ exec: {} as ExecBackend,
+ workspaceRoot: "/workspace",
+ }),
+ release: async () => {},
+ destroy: async () => {},
+ }
+ const h = await provider.acquire({ threadId: "t1", policy: { network: { mode: "allow" } }, signal: new AbortController().signal })
+ expect(h.threadId).toBe("t1")
+ const cfg: SandboxConfig = { provider }
+ expect(cfg.provider.name).toBe("noop")
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 02b0583f..9d1300b7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -218,6 +218,9 @@ importers:
'@dawn-ai/config-typescript':
specifier: workspace:*
version: link:../config-typescript
+ '@dawn-ai/sandbox':
+ specifier: workspace:*
+ version: link:../sandbox
'@dawn-ai/sdk':
specifier: workspace:*
version: link:../sdk
@@ -412,6 +415,19 @@ importers:
specifier: 26.1.0
version: 26.1.0
+ packages/sandbox:
+ dependencies:
+ '@dawn-ai/workspace':
+ specifier: workspace:*
+ version: link:../workspace
+ devDependencies:
+ '@dawn-ai/config-typescript':
+ specifier: workspace:*
+ version: link:../config-typescript
+ '@types/node':
+ specifier: 25.6.0
+ version: 25.6.0
+
packages/sdk:
devDependencies:
'@dawn-ai/config-typescript':
@@ -1696,6 +1712,9 @@ packages:
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
+ '@types/node@25.6.0':
+ resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
+
'@types/node@26.1.0':
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
@@ -3962,6 +3981,9 @@ packages:
engines: {node: '>=0.8.0'}
hasBin: true
+ undici-types@7.19.2:
+ resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
+
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
@@ -5248,6 +5270,10 @@ snapshots:
'@types/node@12.20.55': {}
+ '@types/node@25.6.0':
+ dependencies:
+ undici-types: 7.19.2
+
'@types/node@26.1.0':
dependencies:
undici-types: 8.3.0
@@ -8068,6 +8094,8 @@ snapshots:
uglify-js@3.19.3:
optional: true
+ undici-types@7.19.2: {}
+
undici-types@8.3.0: {}
unified@11.0.5:
diff --git a/test/runtime/fixtures/sandbox-app/dawn.config.ts b/test/runtime/fixtures/sandbox-app/dawn.config.ts
new file mode 100644
index 00000000..3e07d810
--- /dev/null
+++ b/test/runtime/fixtures/sandbox-app/dawn.config.ts
@@ -0,0 +1,27 @@
+// Imported from source (not the "@dawn-ai/sandbox/testing" package specifier)
+// because this fixture's dawn.config.ts is loaded at runtime by the tsx loader,
+// and @dawn-ai/sandbox is not symlinked into this worktree's node_modules. The
+// relative source path resolves under tsx with no install/link step. fakeSandbox
+// only type-imports from @dawn-ai/workspace (erased at runtime), so this pulls in
+// no runtime package dependency.
+import { fakeSandbox } from "../../../../packages/sandbox/src/testing/fake-sandbox.ts"
+
+// A single fakeSandbox instance backs the whole app: the SandboxManager keeps
+// one provider and asks it for a per-thread handle, so each thread gets its own
+// in-memory volume (path → content) that persists across turns and is isolated
+// from other threads. The `exec` is observable — it echoes the command into
+// stdout so a runBash routing assertion is possible — but the primary proof in
+// run-sandbox-wiring.test.ts is the filesystem (writeFile/readFile), which works
+// with the default exec too.
+export default {
+ appDir: "src/app",
+ sandbox: {
+ provider: fakeSandbox({
+ exec: async ({ command }) => ({
+ stdout: `SANDBOX_EXEC: ${command}`,
+ stderr: "",
+ exitCode: 0,
+ }),
+ }),
+ },
+}
diff --git a/test/runtime/fixtures/sandbox-app/package.json b/test/runtime/fixtures/sandbox-app/package.json
new file mode 100644
index 00000000..56dd4e6a
--- /dev/null
+++ b/test/runtime/fixtures/sandbox-app/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "sandbox-app",
+ "private": true,
+ "type": "module"
+}
diff --git a/test/runtime/fixtures/sandbox-app/src/app/agent/index.ts b/test/runtime/fixtures/sandbox-app/src/app/agent/index.ts
new file mode 100644
index 00000000..4cffcfc2
--- /dev/null
+++ b/test/runtime/fixtures/sandbox-app/src/app/agent/index.ts
@@ -0,0 +1,11 @@
+import { agent } from "@dawn-ai/sdk"
+
+// No host `workspace/` directory exists in this fixture. The workspace
+// capability still activates because prepareRouteExecution injects the sandbox
+// handle's `workspaceRoot` (the fakeSandbox `/workspace`) — the capability's
+// `detect` honors an injected workspaceRoot. So readFile/writeFile/runBash are
+// offered and route into the thread's sandbox volume.
+export default agent({
+ model: "gpt-5-mini",
+ systemPrompt: "SANDBOX_WIRING_AGENT workspace agent.",
+})
diff --git a/test/runtime/run-sandbox-wiring.test.ts b/test/runtime/run-sandbox-wiring.test.ts
new file mode 100644
index 00000000..d84a4589
--- /dev/null
+++ b/test/runtime/run-sandbox-wiring.test.ts
@@ -0,0 +1,221 @@
+/**
+ * Sandbox wiring e2e — the behavioral proof that an agent's workspace tools
+ * actually route into the per-thread sandbox (no Docker; fakeSandbox in-memory).
+ *
+ * This is the keystone test for the execution-sandbox feature: it proves that
+ * configuring `sandbox: { provider }` in dawn.config.ts + threading the resolved
+ * SandboxManager (+ threadId) into streamResolvedRoute causes
+ * readFile/writeFile/runBash to redirect into the thread's isolated sandbox
+ * volume instead of the host filesystem.
+ *
+ * INJECTION PATH (in-process, mirrors the runtime HTTP server):
+ * The runtime server (createRuntimeRequestListener) builds ONE SandboxManager
+ * via resolveSandboxManager(appRoot) and passes the SAME manager + the route's
+ * thread_id into every streamResolvedRoute call. We do exactly that here: build
+ * the manager once from the fixture's dawn.config.ts (which holds the
+ * fakeSandbox instance), then drive streamResolvedRoute directly with
+ * { sandboxManager, threadId }. The manager keeps one provider, so each thread
+ * gets its own in-memory volume that persists across turns and is isolated
+ * from other threads.
+ *
+ * ASSERTIONS (purely behavioral — no fakeSandbox internals):
+ * 1. Routing + persistence: thread A writes report.md ("SANDBOXED"); a second
+ * turn on thread A reads it back and the agent sees "SANDBOXED". Proves the
+ * write landed in the sandbox volume and persisted across turns.
+ * 2. Host untouched: no file exists at /workspace/report.md on the
+ * host — the write went to the sandbox, not the host fs.
+ * 3. Isolation: thread B reading report.md gets ENOENT — per-thread isolation.
+ */
+import { existsSync } from "node:fs"
+import { join } from "node:path"
+import { fileURLToPath } from "node:url"
+
+import {
+ __resetMaterializedAgentsForTests,
+ createRuntimeRegistry,
+ resolveSandboxManager,
+ runTypegen,
+ type SandboxManager,
+ streamResolvedRoute,
+} from "@dawn-ai/cli/runtime"
+import { discoverRoutes } from "@dawn-ai/core"
+import { type Aimock, collectRunResult, createAimock } from "@dawn-ai/testing"
+import { afterAll, beforeAll, expect, it } from "vitest"
+
+const appRoot = fileURLToPath(new URL("./fixtures/sandbox-app", import.meta.url))
+
+let aimock: Aimock
+let manager: SandboxManager | undefined
+let resolved: { routeFile: string; routeId: string; routePath: string }
+let prevBaseUrl: string | undefined
+let prevKey: string | undefined
+
+/**
+ * Build the aimock fixtures for one turn:
+ * - a tool-call response keyed on the turn's (last) user message, and
+ * - a follow-up text reply keyed on the tool result id.
+ * The reply fixture is listed FIRST so it wins once the tool result is present
+ * (the matcher returns the first match). This avoids relying on turnIndex /
+ * hasToolResult, both of which are unreliable on a checkpoint-resumed thread
+ * whose history already contains prior assistant/tool messages.
+ */
+function toolThenReply(opts: {
+ readonly userMessage: string
+ readonly toolName: string
+ readonly toolArgs: Record
+ readonly callId: string
+ readonly reply: string
+}): unknown[] {
+ return [
+ {
+ match: { toolCallId: opts.callId },
+ response: { content: opts.reply },
+ },
+ {
+ match: { userMessage: opts.userMessage },
+ response: { toolCalls: [{ id: opts.callId, name: opts.toolName, arguments: opts.toolArgs }] },
+ },
+ ]
+}
+
+beforeAll(async () => {
+ prevBaseUrl = process.env.OPENAI_BASE_URL
+ prevKey = process.env.OPENAI_API_KEY
+
+ aimock = await createAimock({ fixtures: [] })
+ process.env.OPENAI_BASE_URL = aimock.baseUrl
+ process.env.OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-not-used"
+
+ // Generate tool schemas (dev-boot fidelity), then resolve the agent route.
+ const manifest = await discoverRoutes({ appRoot })
+ await runTypegen({ appRoot, manifest })
+ const registry = await createRuntimeRegistry(appRoot)
+ const lookup = registry.lookup("/agent#agent")
+ if (!lookup) throw new Error("sandbox-app: route /agent#agent not found")
+ resolved = lookup
+
+ // Build the SandboxManager ONCE from dawn.config.ts — exactly what the runtime
+ // server does. The fixture's config holds a single fakeSandbox provider, so the
+ // manager hands each thread its own persistent in-memory volume.
+ manager = await resolveSandboxManager(appRoot)
+ if (!manager) throw new Error("sandbox-app: resolveSandboxManager returned undefined")
+}, 120_000)
+
+afterAll(async () => {
+ await manager?.releaseAll()
+ await aimock.close()
+ if (prevBaseUrl === undefined) delete process.env.OPENAI_BASE_URL
+ else process.env.OPENAI_BASE_URL = prevBaseUrl
+ if (prevKey === undefined) delete process.env.OPENAI_API_KEY
+ else process.env.OPENAI_API_KEY = prevKey
+ __resetMaterializedAgentsForTests()
+})
+
+// Shared across the two cases below: a single write on thread A whose presence
+// the other cases probe. Each `it` is self-contained (own threadId), but they
+// run in declaration order against the SAME process-wide sandbox manager.
+const threadA = `sbx-A-${Date.now()}`
+const threadB = `sbx-B-${Date.now()}`
+
+async function runTurn(opts: {
+ readonly threadId: string
+ readonly userMessage: string
+ readonly toolName: string
+ readonly toolArgs: Record
+ readonly callId: string
+ readonly reply: string
+}) {
+ aimock.addFixtures(
+ toolThenReply({
+ userMessage: opts.userMessage,
+ toolName: opts.toolName,
+ toolArgs: opts.toolArgs,
+ callId: opts.callId,
+ reply: opts.reply,
+ }) as never,
+ )
+ const stream = streamResolvedRoute({
+ appRoot,
+ input: { messages: [{ role: "user", content: opts.userMessage }] },
+ routeFile: resolved.routeFile,
+ routeId: resolved.routeId,
+ routePath: resolved.routePath,
+ sandboxManager: manager,
+ threadId: opts.threadId,
+ })
+ return collectRunResult(stream, opts.threadId)
+}
+
+it("routes workspace tools into the per-thread sandbox, persists across turns, and leaves the host untouched", async () => {
+ // --- Turn 1 (thread A): write report.md = "SANDBOXED" ----------------------
+ const writeResult = await runTurn({
+ threadId: threadA,
+ userMessage: "alpha-write",
+ toolName: "writeFile",
+ toolArgs: { path: "report.md", content: "SANDBOXED" },
+ callId: "call_write_a",
+ reply: "wrote the report",
+ })
+
+ // The write tool actually ran and succeeded. Critically, the workspace
+ // capability only activates here because prepareRouteExecution injects the
+ // sandbox handle's workspaceRoot — there is NO host `workspace/` dir in this
+ // fixture. So `writeFile` being offered + succeeding already proves the
+ // workspace routed into the sandbox. (The false-green check confirms: with
+ // sandbox wiring disabled, `writeFile` is not even offered.)
+ expect(writeResult.toolCalls.map((c) => c.name)).toContain("writeFile")
+ const writeTool = writeResult.toolResults.find((r) => r.name === "writeFile")
+ expect(writeTool).toBeDefined()
+ expect(writeTool?.isError).toBe(false)
+ expect(String(writeTool?.content)).toContain("report.md")
+
+ // --- Turn 2 (thread A): read report.md back --------------------------------
+ // Same threadId → checkpointer resumes; the sandbox volume persists.
+ const readResult = await runTurn({
+ threadId: threadA,
+ userMessage: "alpha-read",
+ toolName: "readFile",
+ toolArgs: { path: "report.md" },
+ callId: "call_read_a",
+ reply: "the report says done",
+ })
+
+ // The agent read back exactly the bytes written on turn 1 — proving the write
+ // persisted in the thread's sandbox volume across turns.
+ const readTool = readResult.toolResults.find((r) => r.name === "readFile")
+ expect(readTool).toBeDefined()
+ expect(readTool?.isError).toBe(false)
+ // ToolMessage content may be JSON-stringified ('"SANDBOXED"'); the exact file
+ // body we wrote on turn 1 is present, which is the load-bearing fact.
+ expect(String(readTool?.content)).toContain("SANDBOXED")
+
+ // --- Host untouched --------------------------------------------------------
+ // The write went into the sandbox (workspaceRoot "/workspace" in fakeSandbox),
+ // never to the host. No file should exist under the host workspace dir.
+ expect(existsSync(join(appRoot, "workspace", "report.md"))).toBe(false)
+})
+
+// Per-thread isolation: thread B has its own empty sandbox volume, so reading
+// report.md (written only on thread A) must ENOENT. Guaranteed by the
+// agent-adapter bypassing its materialized-agent cache when sandboxed
+// (agent-adapter.ts, same precedent as the subagent `task` tool): workspace
+// tools close over the thread's sandbox backends, so the compiled agent is
+// never reused across threads.
+it("isolates per-thread sandbox volumes", async () => {
+ const isoResult = await runTurn({
+ threadId: threadB,
+ userMessage: "bravo-read",
+ toolName: "readFile",
+ toolArgs: { path: "report.md" },
+ callId: "call_read_b",
+ reply: "could not find the report",
+ })
+
+ const isoTool = isoResult.toolResults.find((r) => r.name === "readFile")
+ expect(isoTool).toBeDefined()
+ // Thread B's read must error (ENOENT from fakeSandbox) — it never wrote
+ // report.md, and thread A's file must not be visible here.
+ expect(isoTool?.isError).toBe(true)
+ expect(String(isoTool?.content)).toContain("report.md")
+ expect(String(isoTool?.content)).not.toContain("SANDBOXED")
+})
diff --git a/test/runtime/vitest.config.ts b/test/runtime/vitest.config.ts
index 46ca7353..5202274a 100644
--- a/test/runtime/vitest.config.ts
+++ b/test/runtime/vitest.config.ts
@@ -31,6 +31,7 @@ export default defineConfig({
"test/runtime/run-runtime-contract.test.ts",
"test/runtime/run-agent-protocol.test.ts",
"test/runtime/run-tool-scope.test.ts",
+ "test/runtime/run-sandbox-wiring.test.ts",
"test/runtime/dawn-testing/agent-behavior.test.ts",
],
testTimeout: 240_000,
diff --git a/vitest.workspace.ts b/vitest.workspace.ts
index a9935f4d..698bd1d7 100644
--- a/vitest.workspace.ts
+++ b/vitest.workspace.ts
@@ -11,6 +11,7 @@ export default defineConfig({
"./packages/evals/vitest.config.ts",
"./packages/langchain/vitest.config.ts",
"./packages/langgraph/vitest.config.ts",
+ "./packages/sandbox/vitest.config.ts",
"./packages/sdk/vitest.config.ts",
"./packages/testing/vitest.config.ts",
"./packages/vite-plugin/vitest.config.ts",