From ebe7572f46550d63da3613f161e9fee97e8b39a3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 11:23:02 -0700 Subject: [PATCH 01/24] docs(spec): execution sandbox design Provider-agnostic SandboxProvider contract + Docker reference; per-thread whole-workspace (fs+exec+network) isolation; create-or-reattach lifecycle (release keeps the volume, destroy removes it); allow+denylist egress; opt-in `sandbox` config key + new typed `config()` helper. Grounded against the real integration points (workspace capability backends, thread_id plumbing into prepareRouteExecution, DELETE/shutdown hooks, dawn check). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-25-execution-sandbox-design.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-execution-sandbox-design.md 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..a23b5bcf --- /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 contract + the Docker reference + (under a `/testing` subpath) `fakeSandbox` and a conformance kit. The author-facing **types** live where they avoid a dependency cycle: `SandboxConfig` (referenced by `DawnConfig`) and the contract types go in a types-only location reachable by `@dawn-ai/core`'s `DawnConfig` without `core` depending on `@dawn-ai/sandbox` (mirror the `ToolScope`-in-sdk / `DawnConfig`-in-core precedent; the plan pins exact placement and verifies no cycle). The impl (`dockerSandbox`, `fakeSandbox`) lives in `@dawn-ai/sandbox`. + +```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`). Exported from `@dawn-ai/sdk` and re-exported from `@dawn-ai/cli` (the import authors already use). 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. From dd9c4825fc2ba1944f003e8c1e924fd4be0f2b89 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:01:30 -0700 Subject: [PATCH 02/24] docs(plan): execution sandbox implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16 tasks across 6 phases (contract+config helper → fakeSandbox+conformance+ SandboxManager → runtime wiring → dawn check → dockerSandbox+gated lane → docs+changeset). Corrects spec type placement (contract types in @dawn-ai/ workspace, config() in @dawn-ai/core) after verifying the dep graph avoids a core↔sandbox / sdk↔core cycle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-25-execution-sandbox.md | 1654 +++++++++++++++++ .../2026-06-25-execution-sandbox-design.md | 4 +- 2 files changed, 1656 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-25-execution-sandbox.md 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 index a23b5bcf..cc8dc467 100644 --- a/docs/superpowers/specs/2026-06-25-execution-sandbox-design.md +++ b/docs/superpowers/specs/2026-06-25-execution-sandbox-design.md @@ -28,7 +28,7 @@ The goal is a **hard boundary suitable for untrusted code / multi-tenant**: the ### 1. The contract (`@dawn-ai/sandbox`) -New package `@dawn-ai/sandbox` ships the contract + the Docker reference + (under a `/testing` subpath) `fakeSandbox` and a conformance kit. The author-facing **types** live where they avoid a dependency cycle: `SandboxConfig` (referenced by `DawnConfig`) and the contract types go in a types-only location reachable by `@dawn-ai/core`'s `DawnConfig` without `core` depending on `@dawn-ai/sandbox` (mirror the `ToolScope`-in-sdk / `DawnConfig`-in-core precedent; the plan pins exact placement and verifies no cycle). The impl (`dockerSandbox`, `fakeSandbox`) lives in `@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" @@ -122,7 +122,7 @@ export default config({ }) ``` -- **`config(c: DawnConfig): DawnConfig`** — pure identity for IDE autocomplete, modeled on `agent()` (`sdk/agent.ts:57`). Exported from `@dawn-ai/sdk` and re-exported from `@dawn-ai/cli` (the import authors already use). The loader (`core/config.ts:22-40`) is unchanged — it reads `mod.default`, so a wrapped or bare object both work. +- **`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. From c034c8b8dc756eb25a48fa583bccd6af2f962f81 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:07:30 -0700 Subject: [PATCH 03/24] feat(workspace): sandbox provider contract types Add SandboxPolicy, SandboxHandle, SandboxProvider, and SandboxConfig interfaces to @dawn-ai/workspace, re-using FilesystemBackend/ExecBackend so the workspace capability can be redirected into a sandbox with no interface changes. Export all four types from the package index. --- packages/workspace/src/index.ts | 6 ++ packages/workspace/src/sandbox-types.ts | 59 +++++++++++++++++++ packages/workspace/test/sandbox-types.test.ts | 42 +++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 packages/workspace/src/sandbox-types.ts create mode 100644 packages/workspace/test/sandbox-types.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") + }) +}) From 5ce373ba3ac6a2346e05f642f8ea9c1abd7de4c4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:28:00 -0700 Subject: [PATCH 04/24] feat(core): DawnConfig.sandbox key + typed config() helper Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/index.ts | 2 ++ packages/core/src/config-helper.ts | 10 +++++++++ packages/core/src/index.ts | 11 ++++++++-- packages/core/src/types.ts | 3 ++- packages/core/test/config-helper.test.ts | 26 ++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/config-helper.ts create mode 100644 packages/core/test/config-helper.test.ts 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/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") + }) +}) From 239cac8ab249b90f38b0007b6af00a5d290460c1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:32:38 -0700 Subject: [PATCH 05/24] chore(sandbox): scaffold @dawn-ai/sandbox package Co-Authored-By: Claude Sonnet 4.6 --- packages/sandbox/package.json | 49 +++++++++++++++++++++++++++++++ packages/sandbox/src/index.ts | 7 +++++ packages/sandbox/tsconfig.json | 15 ++++++++++ packages/sandbox/vitest.config.ts | 8 +++++ pnpm-lock.yaml | 13 ++++++++ vitest.workspace.ts | 1 + 6 files changed, 93 insertions(+) create mode 100644 packages/sandbox/package.json create mode 100644 packages/sandbox/src/index.ts create mode 100644 packages/sandbox/tsconfig.json create mode 100644 packages/sandbox/vitest.config.ts diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json new file mode 100644 index 00000000..a7178310 --- /dev/null +++ b/packages/sandbox/package.json @@ -0,0 +1,49 @@ +{ + "name": "@dawn-ai/sandbox", + "version": "0.8.4", + "private": false, + "type": "module", + "license": "MIT", + "homepage": "https://github.com/cacheplane/dawnai/tree/main/packages/sandbox#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/cacheplane/dawnai.git", + "directory": "packages/sandbox" + }, + "bugs": { + "url": "https://github.com/cacheplane/dawnai/issues" + }, + "engines": { + "node": ">=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/index.ts b/packages/sandbox/src/index.ts new file mode 100644 index 00000000..b6441d23 --- /dev/null +++ b/packages/sandbox/src/index.ts @@ -0,0 +1,7 @@ +export type { + SandboxConfig, + SandboxHandle, + SandboxPolicy, + SandboxProvider, +} from "@dawn-ai/workspace" +// dockerSandbox is added in a later task. 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/pnpm-lock.yaml b/pnpm-lock.yaml index 02b0583f..fc061a07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -412,6 +412,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': 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", From 0152fa44e86117c640b335d835c71b0eca5c041e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:35:53 -0700 Subject: [PATCH 06/24] feat(sandbox): in-memory fakeSandbox provider for tests Co-Authored-By: Claude Sonnet 4.6 --- packages/sandbox/src/testing/fake-sandbox.ts | 81 ++++++++++++++++++++ packages/sandbox/src/testing/index.ts | 2 + packages/sandbox/test/fake-sandbox.test.ts | 39 ++++++++++ 3 files changed, 122 insertions(+) create mode 100644 packages/sandbox/src/testing/fake-sandbox.ts create mode 100644 packages/sandbox/src/testing/index.ts create mode 100644 packages/sandbox/test/fake-sandbox.test.ts 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..951b3b6e --- /dev/null +++ b/packages/sandbox/src/testing/index.ts @@ -0,0 +1,2 @@ +export { fakeSandbox } from "./fake-sandbox.js" +// runProviderConformance is added in a later task. 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 }) + }) +}) From 1a59bb31bfeee2b3dadb12918a44d9eb79d48c9b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:40:06 -0700 Subject: [PATCH 07/24] feat(sandbox): provider conformance kit Add runProviderConformance() shared suite that enforces the four SandboxProvider invariants (acquire idempotency, thread isolation, release-keeps/destroy-clears, numeric exit code) against fakeSandbox now and dockerSandbox in a later gated lane. Co-Authored-By: Claude Sonnet 4.6 --- packages/sandbox/src/testing/conformance.ts | 62 +++++++++++++++++++ packages/sandbox/src/testing/index.ts | 2 +- .../sandbox/test/conformance-fake.test.ts | 8 +++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/sandbox/src/testing/conformance.ts create mode 100644 packages/sandbox/test/conformance-fake.test.ts 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/index.ts b/packages/sandbox/src/testing/index.ts index 951b3b6e..8384cd04 100644 --- a/packages/sandbox/src/testing/index.ts +++ b/packages/sandbox/src/testing/index.ts @@ -1,2 +1,2 @@ +export { runProviderConformance } from "./conformance.js" export { fakeSandbox } from "./fake-sandbox.js" -// runProviderConformance is added in a later task. 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, +}) From 97b81f1bac22c4c6d5392d8870a1ce9172825b67 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:47:46 -0700 Subject: [PATCH 08/24] feat(cli): SandboxManager per-thread lifecycle Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/package.json | 1 + .../cli/src/lib/runtime/sandbox-manager.ts | 84 +++++++++++++++++ packages/cli/test/sandbox-manager.test.ts | 89 +++++++++++++++++++ packages/cli/vitest.config.ts | 1 + pnpm-lock.yaml | 3 + 5 files changed, 178 insertions(+) create mode 100644 packages/cli/src/lib/runtime/sandbox-manager.ts create mode 100644 packages/cli/test/sandbox-manager.test.ts 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/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index fc061a07..ba192109 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 From b88cbd3b16ce452d246de24362d4201ba8365607 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:57:18 -0700 Subject: [PATCH 09/24] feat(cli): resolve sandbox manager + route the workspace into the thread sandbox Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/lib/runtime/execute-route.ts | 42 +++++++++++++++++-- .../cli/src/lib/runtime/resolve-sandbox.ts | 28 +++++++++++++ packages/cli/test/resolve-sandbox.test.ts | 26 ++++++++++++ .../src/capabilities/built-in/workspace.ts | 7 ++-- packages/core/src/capabilities/types.ts | 6 +++ 5 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/lib/runtime/resolve-sandbox.ts create mode 100644 packages/cli/test/resolve-sandbox.test.ts diff --git a/packages/cli/src/lib/runtime/execute-route.ts b/packages/cli/src/lib/runtime/execute-route.ts index 99c18f23..74b3e6f8 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,9 @@ export async function executeResolvedRoute(options: { readonly routeFile: string readonly routeId: string readonly routePath: string + readonly sandboxManager?: SandboxManager readonly signal?: AbortSignal + readonly threadId?: string }): Promise { return await executeRouteAtResolvedPath({ ...options, @@ -215,6 +218,7 @@ export async function invokeResolvedRoute(options: { readonly routeFile: string readonly routeId: string readonly routePath: string + readonly sandboxManager?: SandboxManager readonly signal?: AbortSignal readonly threadId?: string }): Promise { @@ -238,6 +242,7 @@ export async function* streamResolvedRoute(options: { readonly routeFile: string readonly routeId: string readonly routePath: string + readonly sandboxManager?: SandboxManager readonly signal?: AbortSignal /** * Stable per-conversation identifier forwarded to the agent-adapter as @@ -375,6 +380,8 @@ async function prepareRouteExecution(options: { readonly routeId: string readonly routePath: string readonly signal?: AbortSignal + readonly threadId?: string + readonly sandboxManager?: SandboxManager }): Promise { const { isSubagent = false } = options const routeDir = resolve(options.routeFile, "..") @@ -449,9 +456,24 @@ 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 + 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 + } + const offload = buildOffload( loadedDawnConfig, - configBackends?.filesystem, + sandboxBackends?.filesystem ?? configBackends?.filesystem, options.signal ?? new AbortController().signal, options.appRoot, ) @@ -489,8 +511,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 +567,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 +676,8 @@ async function prepareRouteExecution(options: { routeManifest, descriptor, descriptorRouteMap, + ...(options.sandboxManager ? { sandboxManager: options.sandboxManager } : {}), + ...(options.threadId ? { threadId: options.threadId } : {}), }) } } @@ -693,6 +719,7 @@ async function executeRouteAtResolvedPath(options: { readonly routeFile: string readonly routeId: string readonly routePath: string + readonly sandboxManager?: SandboxManager readonly signal?: AbortSignal readonly startedAt: number readonly threadId?: string @@ -1007,8 +1034,11 @@ function buildSubagentResolver(args: { readonly routeManifest: RouteManifest readonly descriptor: DawnAgent | undefined readonly descriptorRouteMap: ReadonlyMap + readonly sandboxManager?: SandboxManager + readonly threadId?: string }): SubagentResolver { const { appRoot, routeDir, routeManifest, descriptor, descriptorRouteMap } = args + const { sandboxManager, threadId } = args const findConventionRoute = (leaf: string): RouteDefinition | undefined => { const conventionDir = `${routeDir}/subagents/${leaf}` @@ -1048,6 +1078,8 @@ function buildSubagentResolver(args: { routeFile: route.entryFile, routeId: route.id, routePath: route.pathname, + ...(sandboxManager ? { sandboxManager } : {}), + ...(threadId ? { threadId } : {}), }) if (result.status === "failed") { // Surface the failure to the dispatcher in a shape that @@ -1068,6 +1100,8 @@ function buildSubagentResolver(args: { routeFile: route.entryFile, routeId: route.id, routePath: route.pathname, + ...(sandboxManager ? { sandboxManager } : {}), + ...(threadId ? { threadId } : {}), })) { 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/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/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 } From f32436c8a2d2fe8e09a2ece39e7375810766d4f5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 16:02:16 -0700 Subject: [PATCH 10/24] feat(cli): sandbox manager singleton, DELETE/shutdown hooks, idle reaper Wire a per-server SandboxManager into createRuntimeRequestListener: resolve it from config, start a 60 s idle-reaper interval (unref'd), pass it through buildRouteTable into every run handler so streamResolvedRoute/invokeResolvedRoute receive it alongside thread_id. DELETE /threads/:id calls destroyThread before 204; close() clears the reaper and calls releaseAll. All hooks are no-ops when no sandbox config is present. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/lib/dev/runtime-server.ts | 31 +++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/dev/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts index d8145201..33aacf0b 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,9 @@ export async function createRuntimeRequestListener( state.closed = true shutdownController.abort(new Error("Runtime server shutting down")) + if (sandboxReaper) clearInterval(sandboxReaper) + if (sandboxManager) await sandboxManager.releaseAll() + // Drain in-flight requests await new Promise((resolve) => { const check = () => { @@ -195,10 +210,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 +293,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 +312,7 @@ function buildRouteTable(ctx: { registry, request: req, response: res, + ...(sandboxManager ? { sandboxManager } : {}), signal, threadId: params.thread_id ?? "", threadRouteMap, @@ -316,6 +334,7 @@ function buildRouteTable(ctx: { registry, request: req, response: res, + ...(sandboxManager ? { sandboxManager } : {}), signal, threadId: params.thread_id ?? "", threadRouteMap, @@ -365,6 +384,7 @@ function buildRouteTable(ctx: { registry, request: req, response: res, + ...(sandboxManager ? { sandboxManager } : {}), signal, threadId: params.thread_id ?? "", threadRouteMap, @@ -422,6 +442,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 +454,7 @@ async function handleApStreamRequest(options: { registry, request, response, + sandboxManager, signal, threadId, threadRouteMap, @@ -506,6 +528,7 @@ async function handleApStreamRequest(options: { routeFile: route.routeFile, routeId: route.routeId, routePath: route.routePath, + ...(sandboxManager ? { sandboxManager } : {}), signal, threadId, })) { @@ -534,6 +557,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 +569,7 @@ async function handleApWaitRequest(options: { registry, request, response, + sandboxManager, signal, threadId, threadRouteMap, @@ -607,6 +632,7 @@ async function handleApWaitRequest(options: { routeFile: route.routeFile, routeId: route.routeId, routePath: route.routePath, + ...(sandboxManager ? { sandboxManager } : {}), signal, threadId, }) @@ -662,6 +688,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 +701,7 @@ async function handleResumeRequest(options: { registry, request, response, + sandboxManager, signal, threadId, threadRouteMap, @@ -810,6 +838,7 @@ async function handleResumeRequest(options: { routeFile: route.routeFile, routeId: route.routeId, routePath: route.routePath, + ...(sandboxManager ? { sandboxManager } : {}), signal, threadId, })) { From 8d9d1915dc769fe2a151ccd3046749e79e9520d1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 16:23:09 -0700 Subject: [PATCH 11/24] test(runtime): sandbox wiring e2e via fakeSandbox (no Docker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keystone behavioral proof for the execution-sandbox feature. Drives streamResolvedRoute directly with the SandboxManager resolved from the fixture's dawn.config.ts (+ threadId) — exactly how the runtime HTTP server injects it — so no Docker / real model key is needed (aimock). Proves (green): an agent's workspace writeFile/readFile route into the thread's sandbox volume, persist across turns on the same thread, and leave the host fs untouched. The workspace capability activates here ONLY because the injected sandbox workspaceRoot is present (no host workspace/ dir), so writeFile being offered is itself proof. Verified real via a false-green check: forcing the local backend (disabling the sandboxBackends assignment) makes writeFile disappear and the test fail. Also surfaces a real DEFECT (tracked as `it.fails`): per-thread sandbox isolation is currently broken — @dawn-ai/langchain materializeAgent() caches the compiled agent per descriptor, freezing the first thread's sandbox-bound workspace tools, so a second thread reuses them and sees the first thread's files. Affects the real server for any agent served across more than one thread in a process. Exposes resolveSandboxManager + SandboxManager from @dawn-ai/cli/runtime so out-of-band drivers (and this test) can build the same manager. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/runtime-exports.ts | 6 + .../fixtures/sandbox-app/dawn.config.ts | 27 ++ .../runtime/fixtures/sandbox-app/package.json | 5 + .../sandbox-app/src/app/agent/index.ts | 11 + test/runtime/run-sandbox-wiring.test.ts | 234 ++++++++++++++++++ test/runtime/vitest.config.ts | 1 + 6 files changed, 284 insertions(+) create mode 100644 test/runtime/fixtures/sandbox-app/dawn.config.ts create mode 100644 test/runtime/fixtures/sandbox-app/package.json create mode 100644 test/runtime/fixtures/sandbox-app/src/app/agent/index.ts create mode 100644 test/runtime/run-sandbox-wiring.test.ts 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/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..d1fa2a87 --- /dev/null +++ b/test/runtime/run-sandbox-wiring.test.ts @@ -0,0 +1,234 @@ +/** + * 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) +}) + +// KNOWN DEFECT — per-thread sandbox isolation is currently BROKEN. +// +// Expected: thread B has its own empty sandbox volume, so reading report.md +// (written only on thread A) must ENOENT. Observed: thread B reads back +// "SANDBOXED" — it sees thread A's file. +// +// Root cause: @dawn-ai/langchain materializeAgent() caches the compiled agent +// per descriptor in a module-level WeakMap. The workspace capability's +// readFile/writeFile tools close over the sandbox filesystem backend captured at +// capability load time, and that closure is baked into the cached agent on the +// FIRST thread's run. Every later thread reuses the cached agent (no per-invoke +// tool rebinding on the DawnAgent path — config.tools is honored only on the +// legacy Runnable path), so its workspace tools still point at the first +// thread's volume. This affects the real runtime server too: any agent served +// for more than one thread in a single process loses sandbox isolation. +// +// `it.fails` asserts this case currently FAILS (defect present) → green in CI, +// and will START FAILING the moment the bug is fixed, prompting whoever fixes it +// to flip this to a normal `it` with the real assertion below. +it.fails("isolates per-thread sandbox volumes (DEFECT: materialized-agent cache freezes thread A's fs)", 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() + // The CORRECT behavior: thread B's read must error (ENOENT) — it never wrote + // report.md. This assertion currently does NOT hold (isError === false, + // content === "SANDBOXED"), which is exactly why this is `it.fails`. + expect(isoTool?.isError).toBe(true) + expect(String(isoTool?.content)).toContain("report.md") +}) 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, From a92a2c91fc422e4c9867c442a1d0dfc90f3fa1fc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 19:23:09 -0700 Subject: [PATCH 12/24] =?UTF-8?q?fix(langchain):=20bypass=20materialized-a?= =?UTF-8?q?gent=20cache=20when=20sandboxed=20=E2=80=94=20per-thread=20tool?= =?UTF-8?q?=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/lib/runtime/execute-route.ts | 14 ++++++++ packages/langchain/src/agent-adapter.ts | 21 ++++++++++-- test/runtime/run-sandbox-wiring.test.ts | 33 ++++++------------- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/lib/runtime/execute-route.ts b/packages/cli/src/lib/runtime/execute-route.ts index 74b3e6f8..777c7081 100644 --- a/packages/cli/src/lib/runtime/execute-route.ts +++ b/packages/cli/src/lib/runtime/execute-route.ts @@ -273,6 +273,7 @@ export async function* streamResolvedRoute(options: { offload, summarization, workspaceFs, + sandboxed, } = prepared if (normalized.kind !== "agent") { @@ -311,6 +312,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": @@ -366,6 +368,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 { @@ -708,6 +717,7 @@ async function prepareRouteExecution(options: { ...(subagentResolver ? { subagentResolver } : {}), tools, workspaceFs, + ...(sandboxBackends !== undefined ? { sandboxed: true } : {}), } } @@ -756,6 +766,7 @@ async function executeRouteAtResolvedPath(options: { offload, summarization, workspaceFs, + sandboxed, } = prepared mode = normalized.kind @@ -779,6 +790,7 @@ async function executeRouteAtResolvedPath(options: { ...(streamTransformers && streamTransformers.length > 0 ? { streamTransformers } : {}), ...(subagentResolver ? { subagentResolver } : {}), ...(options.threadId ? { threadId: options.threadId } : {}), + ...(sandboxed ? { sandboxed: true } : {}), }) return createRuntimeSuccessResult({ @@ -838,6 +850,7 @@ async function invokeEntry( > readonly subagentResolver?: SubagentResolver readonly threadId?: string + readonly sandboxed?: boolean }, ): Promise { if (kind === "agent") { @@ -870,6 +883,7 @@ async function invokeEntry( ? { subagentResolver: agentContext.subagentResolver } : {}), ...(agentContext?.threadId ? { threadId: agentContext.threadId } : {}), + ...(agentContext?.sandboxed ? { sandboxed: true } : {}), }) } 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 { +// 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", @@ -226,9 +213,9 @@ it.fails("isolates per-thread sandbox volumes (DEFECT: materialized-agent cache const isoTool = isoResult.toolResults.find((r) => r.name === "readFile") expect(isoTool).toBeDefined() - // The CORRECT behavior: thread B's read must error (ENOENT) — it never wrote - // report.md. This assertion currently does NOT hold (isError === false, - // content === "SANDBOXED"), which is exactly why this is `it.fails`. + // 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") }) From 9c3a48d3705279af9ad719021ccfd083eb14e281 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 19:26:36 -0700 Subject: [PATCH 13/24] feat(cli): dawn check validates sandbox config + runs provider preflight Co-Authored-By: Claude Fable 5 --- packages/cli/src/commands/check.ts | 16 +++++- .../src/lib/runtime/collect-sandbox-errors.ts | 38 ++++++++++++++ .../cli/test/collect-sandbox-errors.test.ts | 50 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/lib/runtime/collect-sandbox-errors.ts create mode 100644 packages/cli/test/collect-sandbox-errors.test.ts 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/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/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([]) + }) +}) From af0199c503c38399a66cc1d61919e78c444407ca Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 19:28:30 -0700 Subject: [PATCH 14/24] feat(sandbox): injectable docker CLI wrapper --- packages/sandbox/src/docker/docker-cli.ts | 50 +++++++++++++++++++++++ packages/sandbox/test/docker-cli.test.ts | 31 ++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 packages/sandbox/src/docker/docker-cli.ts create mode 100644 packages/sandbox/test/docker-cli.test.ts 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/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") + }) +}) From ea4dc2745a967c046ae56df6b37161bc8e98712f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 19:31:05 -0700 Subject: [PATCH 15/24] feat(sandbox): docker filesystem + exec backends --- packages/sandbox/src/docker/docker-exec.ts | 28 ++++++ .../sandbox/src/docker/docker-filesystem.ts | 58 ++++++++++++ packages/sandbox/test/docker-backends.test.ts | 88 +++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 packages/sandbox/src/docker/docker-exec.ts create mode 100644 packages/sandbox/src/docker/docker-filesystem.ts create mode 100644 packages/sandbox/test/docker-backends.test.ts diff --git a/packages/sandbox/src/docker/docker-exec.ts b/packages/sandbox/src/docker/docker-exec.ts new file mode 100644 index 00000000..f6c09455 --- /dev/null +++ b/packages/sandbox/src/docker/docker-exec.ts @@ -0,0 +1,28 @@ +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]) => `${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..06b7f678 --- /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(`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/test/docker-backends.test.ts b/packages/sandbox/test/docker-backends.test.ts new file mode 100644 index 00000000..86a14368 --- /dev/null +++ b/packages/sandbox/test/docker-backends.test.ts @@ -0,0 +1,88 @@ +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("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 }) + }) +}) From 1d44aa6dd9651150eb5f0fab149b271c39ec8107 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 19:33:47 -0700 Subject: [PATCH 16/24] feat(sandbox): dockerSandbox provider (acquire/release/destroy/preflight) --- packages/sandbox/src/docker/docker-sandbox.ts | 107 +++++++++++++++++ packages/sandbox/src/index.ts | 2 +- .../sandbox/test/docker-sandbox.unit.test.ts | 112 ++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 packages/sandbox/src/docker/docker-sandbox.ts create mode 100644 packages/sandbox/test/docker-sandbox.unit.test.ts 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 index b6441d23..0db86c7c 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -4,4 +4,4 @@ export type { SandboxPolicy, SandboxProvider, } from "@dawn-ai/workspace" -// dockerSandbox is added in a later task. +export { type DockerSandboxOptions, dockerSandbox } from "./docker/docker-sandbox.js" 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") + }) +}) From 75e2d28461a80ae1bf5b545258f046a3666ea4db Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 19:46:46 -0700 Subject: [PATCH 17/24] test(sandbox): gated real-Docker conformance + e2e CI lane --- .github/workflows/ci.yml | 34 +++++++++ .../test/docker-sandbox.integration.test.ts | 71 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 packages/sandbox/test/docker-sandbox.integration.test.ts 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/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) + } + }) +}) From 99b6bb7e46a09a48ff6b48808dba91ce24a5a2ba Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 19:49:39 -0700 Subject: [PATCH 18/24] docs: execution sandbox guide --- apps/web/app/components/docs/nav.ts | 1 + apps/web/app/docs/sandbox/page.tsx | 9 ++ apps/web/content/docs/sandbox.mdx | 134 ++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 apps/web/app/docs/sandbox/page.tsx create mode 100644 apps/web/content/docs/sandbox.mdx 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 + + From 1e488ac4553bb5a5dcaab55bb6b35dd98b1a28ea Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 20:01:03 -0700 Subject: [PATCH 19/24] chore: changeset for execution sandbox (patch) --- .changeset/execution-sandbox.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/execution-sandbox.md 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). From 3e74d32d3c68da66d10335faa18c03901d45b861 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 20:06:27 -0700 Subject: [PATCH 20/24] fix(cli): decouple sandbox scoping key from checkpoint thread_id in subagent dispatch Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/lib/runtime/execute-route.ts | 46 ++++++++++++++++--- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lib/runtime/execute-route.ts b/packages/cli/src/lib/runtime/execute-route.ts index 777c7081..9ab788b8 100644 --- a/packages/cli/src/lib/runtime/execute-route.ts +++ b/packages/cli/src/lib/runtime/execute-route.ts @@ -154,6 +154,12 @@ export async function executeResolvedRoute(options: { 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 { @@ -219,6 +225,8 @@ export async function invokeResolvedRoute(options: { 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 { @@ -243,6 +251,8 @@ export async function* streamResolvedRoute(options: { 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 @@ -391,6 +401,12 @@ async function prepareRouteExecution(options: { 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, "..") @@ -471,9 +487,10 @@ async function prepareRouteExecution(options: { // into the isolated env with no capability-logic change. let sandboxBackends: { filesystem: FilesystemBackend; exec: ExecBackend } | undefined let sandboxWorkspaceRoot: string | undefined - if (options.sandboxManager && options.threadId) { + const sandboxKey = options.sandboxThreadId ?? options.threadId + if (options.sandboxManager && sandboxKey) { const handle = await options.sandboxManager.getForThread( - options.threadId, + sandboxKey, options.signal ?? new AbortController().signal, ) sandboxBackends = { filesystem: handle.filesystem, exec: handle.exec } @@ -686,7 +703,7 @@ async function prepareRouteExecution(options: { descriptor, descriptorRouteMap, ...(options.sandboxManager ? { sandboxManager: options.sandboxManager } : {}), - ...(options.threadId ? { threadId: options.threadId } : {}), + ...(sandboxKey ? { sandboxThreadId: sandboxKey } : {}), }) } } @@ -730,6 +747,8 @@ async function executeRouteAtResolvedPath(options: { 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 @@ -1049,10 +1068,17 @@ function buildSubagentResolver(args: { readonly descriptor: DawnAgent | undefined readonly descriptorRouteMap: ReadonlyMap readonly sandboxManager?: SandboxManager - readonly threadId?: string + /** + * 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, threadId } = args + const { sandboxManager, sandboxThreadId } = args const findConventionRoute = (leaf: string): RouteDefinition | undefined => { const conventionDir = `${routeDir}/subagents/${leaf}` @@ -1093,7 +1119,12 @@ function buildSubagentResolver(args: { routeId: route.id, routePath: route.pathname, ...(sandboxManager ? { sandboxManager } : {}), - ...(threadId ? { threadId } : {}), + // 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 @@ -1115,7 +1146,8 @@ function buildSubagentResolver(args: { routeId: route.id, routePath: route.pathname, ...(sandboxManager ? { sandboxManager } : {}), - ...(threadId ? { threadId } : {}), + // Same as invoke() above: sandbox key only, never the checkpoint id. + ...(sandboxThreadId ? { sandboxThreadId } : {}), })) { yield chunk } From ac74458acbac1ab35a453ae120cc67df27bc796c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 20:07:35 -0700 Subject: [PATCH 21/24] chore(sandbox): align new package version with fixed group (0.8.5) --- packages/sandbox/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index a7178310..f217a484 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@dawn-ai/sandbox", - "version": "0.8.4", + "version": "0.8.5", "private": false, "type": "module", "license": "MIT", From 22830f94ea29703bf10c443107aad994c2c449e3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 20:07:44 -0700 Subject: [PATCH 22/24] chore: lockfile after sandbox version align --- pnpm-lock.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba192109..9d1300b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1712,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==} @@ -3978,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==} @@ -5264,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 @@ -8084,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: From c578762c34e161fbb9b4a3261baacf39ec5d182d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 20:47:21 -0700 Subject: [PATCH 23/24] =?UTF-8?q?fix(sandbox):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20writeFile=20parent=20dirs,=20drain-before-release?= =?UTF-8?q?=20shutdown,=20env=20key=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/lib/dev/runtime-server.ts | 5 +++- packages/sandbox/src/docker/docker-exec.ts | 9 ++++++- .../sandbox/src/docker/docker-filesystem.ts | 2 +- packages/sandbox/test/docker-backends.test.ts | 25 +++++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/dev/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts index 33aacf0b..ae9b6e4e 100644 --- a/packages/cli/src/lib/dev/runtime-server.ts +++ b/packages/cli/src/lib/dev/runtime-server.ts @@ -126,7 +126,6 @@ export async function createRuntimeRequestListener( shutdownController.abort(new Error("Runtime server shutting down")) if (sandboxReaper) clearInterval(sandboxReaper) - if (sandboxManager) await sandboxManager.releaseAll() // Drain in-flight requests await new Promise((resolve) => { @@ -145,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 } diff --git a/packages/sandbox/src/docker/docker-exec.ts b/packages/sandbox/src/docker/docker-exec.ts index f6c09455..047896f3 100644 --- a/packages/sandbox/src/docker/docker-exec.ts +++ b/packages/sandbox/src/docker/docker-exec.ts @@ -11,7 +11,14 @@ export function dockerExec(docker: Docker, container: string): ExecBackend { async runCommand(args, ctx: BackendContext) { const envPrefix = args.env ? Object.entries(args.env) - .map(([k, v]) => `${k}=${shellQuote(v)} `) + .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)} && ` : "" diff --git a/packages/sandbox/src/docker/docker-filesystem.ts b/packages/sandbox/src/docker/docker-filesystem.ts index 06b7f678..f89767de 100644 --- a/packages/sandbox/src/docker/docker-filesystem.ts +++ b/packages/sandbox/src/docker/docker-filesystem.ts @@ -23,7 +23,7 @@ export function dockerFilesystem(docker: Docker, container: string): FilesystemB return r.stdout }, async writeFile(path, content, ctx) { - const r = await run(`cat > ${q(path)}`, ctx, content) + 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) } }, diff --git a/packages/sandbox/test/docker-backends.test.ts b/packages/sandbox/test/docker-backends.test.ts index 86a14368..b2f8e484 100644 --- a/packages/sandbox/test/docker-backends.test.ts +++ b/packages/sandbox/test/docker-backends.test.ts @@ -48,6 +48,24 @@ describe("dockerFilesystem", () => { 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 }) }), @@ -85,4 +103,11 @@ describe("dockerExec", () => { 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"/) + }) }) From ebafe305d539dafa0081b58e2bc563e4b8d3e2b2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Jul 2026 21:21:27 -0700 Subject: [PATCH 24/24] =?UTF-8?q?fix(cli):=20route=20startRuntimeServer=20?= =?UTF-8?q?shutdown=20through=20the=20listener=20close=20=E2=80=94=20sandb?= =?UTF-8?q?ox=20cleanup=20on=20production=20shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/lib/dev/runtime-server.ts | 31 +++++++--------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/lib/dev/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts index ae9b6e4e..6d74240d 100644 --- a/packages/cli/src/lib/dev/runtime-server.ts +++ b/packages/cli/src/lib/dev/runtime-server.ts @@ -160,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) @@ -177,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}`, }