Why containers?
Containers provide:- Process isolation - Agent processes can’t affect the host system
- Filesystem isolation - Agents only see explicitly mounted directories
- Resource limits - CPU/memory can be constrained (future)
- Ephemeral execution - Fresh environment per invocation, no persistence
- Non-root execution - Agents run as unprivileged user
NanoClaw uses Docker by default for cross-platform compatibility. On macOS, you can use Apple Container instead (via
/convert-to-apple-container skill).Container architecture
Base image
The container is built fromcontainer/Dockerfile:
- Base:
node:22-slim(Debian-based, minimal) - Browser: Chromium with all dependencies
- Tools:
agent-browserfor browser automation - Runtime:
@anthropic-ai/claude-code(Claude Agent SDK) - User:
node(uid 1000, non-root) - Working directory:
/workspace/group
Entrypoint flow
The entrypoint script (/app/entrypoint.sh):
- Recompile agent-runner:
npx tsc --outDir /tmp/dist- Allows per-group customization of agent runner code
- Compiled output is read-only to prevent runtime tampering
- Read input from stdin:
cat > /tmp/input.json- Secrets passed via stdin (never written to disk)
- Input file deleted immediately after read
- Execute agent:
node /tmp/dist/index.js < /tmp/input.json- Runs agent-runner with input
- Outputs JSON results to stdout
- Container exits: Cleaned up automatically via
--rmflag
The agent-runner is recompiled on every container start. This allows agents to modify their own tools and behavior by editing files in
/workspace/group/ or their agent-runner source.Volume mounts
Containers only see what’s explicitly mounted:Main group mounts
Non-main group mounts
Mount security
All mounts are validated against the allowlist at~/.config/nanoclaw/mount-allowlist.json:
- Resolve symlinks to real path
- Check against blocked patterns (
.ssh,.env, etc.) - Verify path is in allowlist (for additional mounts)
- Enforce
nonMainReadOnlyfor non-main groups - Reject container paths with
..or absolute paths
Container lifecycle
Spawn
- If host uid is 0 (root): Run as container’s
nodeuser (uid 1000) - If host uid is 1000: Run as
nodeuser (no mapping needed) - Otherwise: Run as host uid (via
--userflag)
Execute
- Secrets passed: Via stdin JSON, never written to disk
- Stdout streaming: Parsed for output markers in real-time
- Stderr logging: Logged at debug level (SDK writes lots of debug info)
- Timeout tracking: Hard timeout with activity-based reset
- Graceful shutdown:
docker stop(SIGTERM) beforeSIGKILL
Cleanup
Containers are ephemeral:--rmflag: Docker removes container on exit- Logs persisted: Written to
groups/{folder}/logs/before removal - Sessions persisted:
.claude/mounted, survives container death - Group files persisted:
/workspace/groupmounted, survives restart
Even if the container crashes, all data in mounted directories persists. Only the container itself is ephemeral.
Output streaming
The container uses sentinel markers for robust output parsing:- SDK writes debug logs to stdout
- Markers ensure JSON isn’t corrupted by debug output
- Supports multiple outputs per container (streaming)
- Robust against stderr bleeding into stdout
onOutput callback is provided:
- Parse buffer accumulates stdout chunks
- Each complete marker pair triggers
onOutput(parsed) - Session ID tracked across multiple outputs
- Container stays alive between outputs (idle timeout)
_closesentinel in IPC signals graceful shutdown
Timeouts
Two timeout mechanisms:Hard timeout (container timeout)
- Default: 30 minutes (
CONTAINER_TIMEOUT) - Configurable: Per-group via
containerConfig.timeout - Grace period: At least
IDLE_TIMEOUT + 30s - Reset on activity: Resets whenever output markers are parsed
- Enforcement:
docker stop(SIGTERM), thenSIGKILLafter 15s
Idle timeout (stdin close)
- Default: 30 minutes (
IDLE_TIMEOUT) - Purpose: Close container when no follow-up messages arrive
- Mechanism: Write
_closesentinel to IPC input directory - Agent behavior: Exit gracefully when
_closedetected - Reset on: New messages piped to container
- Tasks use shorter close delay: 10 seconds
- Tasks are single-turn (no follow-ups expected)
- Closes automatically after producing result
Idle timeout is agent-cooperative (relies on agent checking IPC). Hard timeout is enforced by the container runtime (kills process if exceeded).
Resource limits
Currently no CPU/memory limits enforced. Future enhancement:Logging
All container runs are logged: Log location:groups/{folder}/logs/container-{timestamp}.log
Log contents (verbose mode or errors):
- Success + non-verbose: Summary only (input length, mount count)
- Success + verbose (
LOG_LEVEL=debug): Full input/output - Error: Always full input/output/stderr
Set
LOG_LEVEL=debug in .env to enable verbose logging for all container runs.Container runtime
NanoClaw detects and uses the available container runtime:- Docker (default): Cross-platform, well-tested
- Apple Container (macOS): Lightweight, native virtualization
Skills and MCP servers
Skills synced to each container:- Copy from shared:
container/skills/{skill-name}/→data/sessions/{group}/.claude/skills/{skill-name}/ - Available to agent: Claude Agent SDK loads from
.claude/skills/ - Per-group customization: Each group gets a copy, can modify independently
- Configured in agent-runner source (
container/agent-runner/src/index.ts) - Built-in:
nanoclawMCP server (task scheduling, message sending) - Custom: Add by modifying agent-runner code
Adding a custom MCP server
Adding a custom MCP server
- Edit
data/sessions/{group}/agent-runner-src/index.ts - Import and register MCP server:
- Next container spawn will recompile with new server
- Tools available to agent immediately
Browser automation
Chromium runs inside the container:- Executable:
/usr/bin/chromium - CLI:
agent-browser(installed globally) - Headless: Always (no display in container)
- User data: Stored in group folder (persists across runs)
- Network: Full access (same as host, no restrictions)
Security implications
What containers protect against
- Filesystem access: Agents can’t read
~/.sshor other sensitive paths - Process interference: Agents can’t kill host processes or inject code
- Persistence: Containers are ephemeral, no state survives unless mounted
- Privilege escalation: Non-root execution limits kernel attack surface
What containers DON’T protect against
- Network access: Agents have full network access (can exfiltrate data)
- Mounted directory tampering: Agents can modify anything in mounted directories
- Credential exposure: Mounted credentials (Claude tokens) are readable
- Resource exhaustion: No CPU/memory limits (can DoS host)
Troubleshooting
Container won’t start
- Check Docker is running:
docker ps - Check image exists:
docker images | grep nanoclaw-agent - Rebuild image:
./container/build.sh - Check logs:
groups/{folder}/logs/
Container timeout
- Check timeout setting:
CONTAINER_TIMEOUTin.env - Check if task is legitimately slow (increase timeout)
- Check idle timeout:
IDLE_TIMEOUT(controls stdin close) - Review logs for last activity timestamp
Permission errors
- Check mount paths are readable by host user
- Check uid/gid mapping (logged in verbose mode)
- Verify allowlist includes path (for additional mounts)
- Check symlink resolution didn’t change path
Output not parsed
- Check for output markers in logs
- Verify agent-runner is writing markers correctly
- Check stdout isn’t truncated (
CONTAINER_MAX_OUTPUT_SIZE) - Review stderr for SDK errors