Architecture: Sandbox
Engine bridge, binary bridges internals, and the integration pipeline
This page explains the internal architecture of the sandbox system. Read this if you want to understand how the pieces fit together, extend the system with a custom engine or strategy, or debug issues.
Engine Bridge + Strategies
The container-backed sandboxes combine two patterns on orthogonal axes, so Docker and Apple Container share one strategy family — only the dialect differs:
- Bridge (dialect axis). A
ContainerEngine<TOpts>captures everything that differs between container runtimes — the CLI binary, therun/exec/build/inspectargv, volume-mount syntax, status parsing, error detection, workdir bootstrap, name-collision recovery, and the installer context. There is one engine per backend (dockerEngine,appleEngine). - Template Method (mode axis). A single
ContainerSandboxStrategy<TOpts>owns the shared lifecycle (resolve an image, prepare volumes, start a long-running container, configure it, then return the common sandbox methods) and delegates every CLI dialect to its injected engine. It is typed toCommonSandboxOptions, so the skeleton can never read an engine-only knob (Docker's security/network; Apple'sarch). Subclasses pick the mode.
ContainerEngine<TOpts> ← dialect seam (one impl per backend)
├── dockerEngine — docker CLI
└── appleEngine — container CLI (per-container VMs, virtiofs)
ContainerSandboxStrategy<TOpts> ← shared skeleton, engine-injected
├── RuntimeStrategy — install packages/binaries at runtime
└── ContainerfileStrategy — build image from a Dockerfile (cached)
ComposeStrategy — Docker-only; not part of the bridgeTemplate Method Flow
The base strategy defines the creation algorithm and delegates each CLI dialect
to engine.*. Subclasses override only getImage() and configure():
create()
│
├─ 1. getImage() ← mode-specific
│ ├─ Runtime: return image name (or engine.defaultImage)
│ ├─ Containerfile: engine.buildImage(...) if needed, return cached tag
│ └─ Compose: return '' (compose manages images)
│
├─ 2. prepareVolumes() ← common: validate bind paths, inspect/create volumes
│
├─ 3. startContainer() ← common: spawn engine.runArgs(...) with --rm
│ └─ Compose override: docker compose up -d
│
├─ 4. engine.ensureWorkdir() ← engine: no-op on Docker; `mkdir -p` on Apple
│
├─ 5. configure() ← mode-specific
│ ├─ Runtime: run installers via engine.createInstallerContext()
│ ├─ Containerfile: no-op (image already configured)
│ └─ Compose: no-op (compose file defines everything)
│
└─ 6. createSandboxMethods() ← common: return { executeCommand, spawn, readFile, writeFiles, dispose }If configure() throws, the base class auto-stops the container before re-throwing.
The methods object returned by createSandboxMethods() also implements
[Symbol.asyncDispose], which delegates to dispose(). Because
DisposableSandbox extends AsyncDisposable, callers can bind any backend with
await using and the container is torn down at scope exit.
Container Startup
All single-container strategies start a long-running container and then execute
commands inside it. Docker uses docker run; Apple Container uses
container run with equivalent name, resource, volume, environment, arch, and
command controls where the Apple CLI supports them.
docker run -d --rm \
--name sandbox-<uuid> \
--memory=1g --cpus=2 \
-w /workspace \
--mount type=bind,src=/host/path,dst=/container/path,readonly \
<image> [command ...]Command resolution for Runtime and Dockerfile strategies:
commandomitted (default): appendtail -f /dev/nullas a keep-alive.command: nullorcommand: []: append nothing; image/DockerfileCMD/ENTRYPOINTrun as declared.- Non-empty
command: append verbatim, overriding image/DockerfileCMD.
Commands then execute via docker exec <id> sh -c "<command>".
Apple Container follows the same shape with container run --detach --rm ...
and executes commands via container exec <id> sh -c "<command>". It does not
support Compose, Docker volume drivers, or Docker security/network flags;
instead each container runs inside its own lightweight VM and exposes a smaller
set of resource, arch, bind-mount, named-volume, and environment controls.
Dockerfile Image Caching
The shared ContainerfileStrategy generates a deterministic image tag from the
Dockerfile content plus the build identity (Docker's platform / Apple's
arch), then asks the engine to build only on a cache miss:
Dockerfile content + identity → SHA-256 → first 12 chars → "sandbox-a1b2c3d4e5f6"Same Dockerfile + identity produces the same tag. The strategy checks
engine.imageExists(tag) and calls engine.buildImage(...) only when it is
missing; Docker's layer cache handles docker build, and Apple Container skips
container build when the tag already exists locally.
Compose Overrides
ComposeStrategy overrides three base class methods:
| Method | Standard | Compose Override |
|---|---|---|
startContainer() | docker run | docker compose up -d |
exec() | docker exec <id> | docker compose exec -T <service> |
stopContainer() | docker stop | docker compose down |
Binary Bridges
Binary bridges connect just-bash virtual environments to real host binaries. They solve three path resolution problems:
Virtual CWD to Real CWD
just-bash uses virtual paths like /home/user. Binary bridges resolve them to real host paths:
ReadWriteFs: root + cwd → path.join(fs.root, ctx.cwd)
OverlayFs: fs.toRealPath(ctx.cwd)
InMemoryFs: fallback to process.cwd()Virtual PATH to Real PATH
just-bash sets PATH=/bin:/usr/bin which doesn't include host binary locations (nvm, homebrew, etc.). Binary bridges always use process.env.PATH for binary resolution.
File Argument Detection
Arguments that look like file paths are resolved relative to the real CWD:
Detected as path: Has file extension (.md, .py), contains /, starts with .
Passed through: Starts with - (flags), no path indicatorsSecurity via allowedArgs
createBinaryBridges({
name: 'git',
allowedArgs: /^(status|log|diff|show)/,
});
// Allowed: git status, git log, git diff
// Blocked: git log --oneline (flags are also tested), git reset --hardEach argument is tested individually against the regex. Any non-matching argument returns exit code 1 with a security policy error.
Integration Pipeline
To give an AI agent a bash tool that runs inside a sandbox, compose the two independent systems explicitly at the call site:
1. createDockerSandbox(sandboxOptions)
└─ Returns: DisposableSandbox { executeCommand, spawn, readFile, writeFiles, dispose }
2. createBashTool({ sandbox, ...bashOptions })
└─ Returns: { bash, tools, sandbox }Skill provisioning and discovery are application concerns. After making files
available through the backend, pass explicit AvailableSkill[] metadata to the
skills() context fragment; createBashTool does not inspect those files.
Any DisposableSandbox matches what createBashTool expects from its
sandbox parameter (executeCommand(command, options?) → { stdout, stderr, exitCode }). Backends that can expose unbuffered process streams, including
Docker, Apple Container, Microsandbox, Agent OS, and Daytona, also implement
optional spawn(command, options?) for streaming stdout/stderr. This is why
real process backends can be plugged directly into the shared, owned
createBashTool from @deepagents/context.
Decorator Chain Behavior
createBashTool layers decorators over the backend sandbox. The decorators
preserve spawn when the backend exposes it:
- The bash tool forwards AI SDK's native
executionOptions.abortSignaltoexecuteCommandasoptions.signal. observeSandboxFileEventssnapshots before and afterspawnto record write/modify/delete events underdestination, and delaysexitresolution until the post-spawn snapshot is captured.
File Operations: Base64 Encoding
readFile and writeFiles use base64 encoding internally because buffered
executeCommand uses CLI process helpers that must preserve binary-safe content
across host/container boundaries:
readFile: <backend> exec <id> sh -c 'base64 "/path/to/file"' → decode on host
writeFiles: echo "<base64>" | base64 -d > "/path/to/file" → decode in containerspawn bypasses nano-spawn and uses raw child-process streams so callers get
byte-accurate live output and structured exit metadata.
Extending
There are two extension points, one per axis:
Add a backend → implement ContainerEngine. Provide the CLI dialect (run/
exec/build args, status parsing, error detection, installer context) plus a
factory that wires the engine into the shared strategies. No strategy subclassing
needed — this is how appleEngine works.
import {
ContainerfileStrategy,
RuntimeStrategy,
type ContainerEngine,
type CommonSandboxOptions,
} from '@deepagents/context';
const podmanEngine: ContainerEngine = {
cli: 'podman',
runArgs: (image, id, opts, workdir) => [/* podman run argv */],
// execArgs, inspectArgs, mountArg, parseStatus, buildImage,
// createInstallerContext, errors, ... (the full dialect seam)
};
export function createPodmanSandbox(options: CommonSandboxOptions = {}) {
return new RuntimeStrategy(options, podmanEngine, {
image: options.image ?? podmanEngine.defaultImage,
installers: options.installers ?? [],
}).create();
}Add a creation mode → subclass ContainerSandboxStrategy. Override
getImage() / configure() (and any lifecycle method, as ComposeStrategy
does) while still delegating the CLI dialect to the injected engine.
Next Steps
- Docker Sandbox - Usage guide for all three strategies
- Apple Container Sandbox - VM-backed local containers on Apple silicon
- Agent Wrapper - Agent integration
- Sandbox Overview - Choosing the right approach