SessionSpec and hands it to the selected SessionDriver; Docker-specific creation, supervision, and teardown live in src/drivers/docker-driver.ts. NANOCLAW_RUNTIME_DRIVER selects a registered driver, and an unknown value aborts startup. This seam does not itself install an alternative runtime.
The image
Every install runs from one base image, taggednanoclaw-agent-v2-<slug>:latest, where the slug defaults to the first 8 hex characters of sha1(projectRoot). NANOCLAW_INSTALL_ID overrides it; use distinct IDs for separate installs. The setup wizard’s default is to pull the pinned hardened build of that image; picking Build it here builds the same image locally from container/Dockerfile (see Installation). What’s inside:
node:22-slimbase with Bun as the agent-runner’s runtime — TypeScript runs directly, no compile steptinias the image’s default entrypoint — it only applies to a baredocker run, since production spawns override the entrypoint and pass--initinstead, putting Docker’s own init at PID 1 (see the spawn section below)- Chromium plus its library stack for browser automation (
agent-browserdrives it; CJK fonts are an opt-inINSTALL_CJK_FONTS=truebuild arg) - Pinned global CLIs via pnpm —
@anthropic-ai/claude-codeandagent-browser, each pinned to an exact version. The versions live incontainer/cli-tools.json;install-cli-tools.shreads that manifest and runspnpm install -gfor each entry, so a skill adds a CLI by appending to the manifest instead of editing the Dockerfile — that’s how/add-verceladds the Vercel CLI, which is no longer baked into every image
container/pull.sh fetches the pinned reference by digest and rejects a wrong architecture. A missing lockfile label also fails unless explicitly allowed; a label that differs from this checkout’s lockfile currently warns and continues retagging. See the hardened-runtime guide for that temporary mismatch allowance. Either way, the image carries two provenance labels: dev.nanoclaw.image-source reads hardened for a pulled image, local for one built here, or derived for a per-group image built on top of either (see below); dev.nanoclaw.agent-runner-lock-sha256 is the lockfile hash pull.sh checks against.
The agent-runner source is never baked in — the host bind-mounts it read-only at /app/src on every spawn, so source changes never require a rebuild. Rebuilds are only for the Dockerfile itself, CLI version bumps, or agent-runner dependency changes — package.json and bun.lock are copied in and bun installed at build time.
When a group installs apt or npm packages, the host generates a Dockerfile FROM the base image, builds it as nanoclaw-agent-v2-<slug>:<agent-group-id>, and stores the tag in the group’s config — that group spawns from its custom image from then on. Mechanics in Container configuration.
The lifecycle
Wake — deduplicated, never throws
wakeContainer(session) fires when the router writes an inbound message, when the host sweep finds due work (scheduled tasks, retries) with no container running, or on an explicit ncl groups restart. Three layers prevent duplicates against the same session directory:
- An
activeContainersmap keyed by session ID — if a container is already running, wake is a no-op. - An in-flight promise map — a second wake arriving while the first spawn is still mid-setup (vault wiring, mount assembly) joins the existing promise instead of spawning a racy double.
- A durable session claim — a claim held by another live host process blocks spawning or adoption, so two hosts cannot own the same run.
false, the inbound row stays pending, and the next sweep tick retries.
Spawn — the world rebuilt every time
Before the driver creates the container, the runner reassembles everything the agent sees: it refreshes the session’s destination map and reply routing, materializescontainer.json from the database, syncs skill symlinks to match the config, and composes the group’s project document with instruction sources inlined (details in Architecture). A stale heartbeat from the previous container is removed so the new run gets fresh grace.
The exact mounts, from buildMounts in src/container-runner.ts:
The project document and provider-state rows above describe the default Claude surfaces; a provider such as Codex can supply its own. Providers can also contribute extra mounts and env vars, such as OpenCode’s XDG directories. Shared base instructions are read on the host and inlined into the composed document, so
/app/CLAUDE.md and .claude-fragments are no longer separate read-only mounts. The group dir stays writable, with nested read-only mounts protecting container.json, the composed document, and stamped plugin code. Standing instructions, memory, working files, and plugin data remain writable.
The Docker driver uses docker create --rm, then docker start --attach. The container has a deterministic ncl-… name derived from its install and session identity; the old nanoclaw-v2-<folder>-<timestamp> name survives as the nanoclaw-container-name label. The spec carries install/session labels, TZ, mailbox configuration, and provider/gateway environment contributions; the runner also reads the materialized container.json. Two optional resource caps ride alongside: if CONTAINER_CPU_LIMIT or CONTAINER_MEMORY_LIMIT is set, the runner adds --cpus / --memory (opt-in — unset leaves CPU and memory unbounded, today’s default; see Hardening). A fixed hardening set is not optional and has no per-group or per-install override: --shm-size=1g (Docker’s 64 MB /dev/shm default silently short-writes under a headless browser), --cap-drop=ALL, --security-opt no-new-privileges, and --init. A separate, configurable PID cap defaults to 2048 as a fork-bomb backstop; zero, blank, negative, or invalid CONTAINER_PIDS_LIMIT values omit it (see Always-on container hardening). The OneCLI Agent Vault then injects HTTPS_PROXY and certificates so the agent’s API calls get credentials in transit; if the vault can’t be wired, the spawn aborts rather than running credential-less. When the host user isn’t uid 0 or 1000, the container runs --user <hostUid>:<hostGid> so mounted files keep sane ownership. The spec sets HOME=/home/node for every non-root host uid, including 1000. The image is the group’s image_tag if set, otherwise the base — and the image’s tini entrypoint is overridden: the runner passes --entrypoint bash with -c 'exec bun run /app/src/index.ts', so bash execs into Bun. --init is what keeps that safe: Docker’s own init runs as PID 1 and Bun is its child, so the process that receives signals has a handler for them.
When egress lockdown is enabled (NANOCLAW_EGRESS_LOCKDOWN=true), the container joins a Docker --internal network with the vault gateway as the only reachable hop — no internet route exists, and the agent runs non-root without NET_ADMIN, so it can’t undo it. If lockdown is on but can’t be established, the spawn fails rather than running with open egress. Setup in Hardening.
Running
docker-init runs as PID 1 and Bun runs the agent-runner as its child (the spawn’s entrypoint override bypasses the image’s tini, and --init puts Docker’s init in its place); the runner polls inbound.db and touches /workspace/.heartbeat on every provider event — a provider-progress signal; runtime supervision also tracks container status and exit events. Container stderr is streamed into the host log at debug level; stdout is unused, since all IO is database rows. There is deliberately no wall-clock idle timeout on the host side.
Death
The host sweep kills a running container under exactly two conditions — both heartbeat-driven and documented with the rest of the sweep in Architecture:- Absolute ceiling — no heartbeat for longer than max(30 minutes, the container’s declared Bash timeout). If the runner has never written a heartbeat, the ceiling uses the tracked spawn or adoption time instead.
- Claim-stuck — a message was claimed and the container showed no heartbeat for over max(60 seconds, declared Bash timeout) since the claim.
docker stop -t 1 — SIGTERM delivered to docker-init as PID 1, which forwards it to Bun, with one second to finalize DB writes before Docker escalates to SIGKILL. If the stop command fails, the driver kills its attach process to unblock supervision and attempts docker rm --force cleanup. The --init flag is what makes the graceful path work: Linux discards default-action signals sent to PID 1, so with Bun itself at PID 1 and no signal handler, SIGTERM would be ignored and every stop would end in SIGKILL after the full grace period. On any exit — clean or killed — the close handler removes the session from the active map, marks the container stopped, and logs Container exited with the exit code. Orphaned processing rows are reset to pending with exponential backoff; after 5 tries a message is marked failed.
At host startup, adoptRunningSessions reconciles containers belonging to this install. A still-running container with an active session is adopted after acquiring its durable claim; true orphans are stopped. A failed claim write leaves a running container untouched for retry, and a claim owned by another live host prevents adoption. Runtime identity includes the install slug; separate installs need distinct IDs.
What the boundary holds
The container sees only the mount table above — no host home directory, no.ssh, and no raw credentials: API keys live in the OneCLI Agent Vault on the host and are injected into HTTPS requests in transit, so a fully compromised agent has nothing to exfiltrate but its own workspace. Additional mounts must pass the allowlist at ~/.config/nanoclaw/mount-allowlist.json (no allowlist means none are permitted), and egress lockdown closes the remaining hole — by default the agent has open internet access through the proxy. See Hardening for locking both down and Credentials for how vault injection works.
Related pages
- Architecture — the host sweep, CLAUDE.md composition, and the two-database transport
- Container configuration — every config field consumed at spawn
- Isolation levels — what session and group boundaries do and don’t separate
- Security model — the threat model behind these boundaries