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