refactor: extract runtime-specific code into src/container-runtime.ts (#321)
Move all container-runtime-specific logic (binary name, mount args, stop command, startup check, orphan cleanup) into a single file so swapping runtimes only requires replacing this one file. Neutralize "Apple Container" references in comments and docs that would become incorrect after a runtime swap. References that list both runtimes as options are left unchanged. No behavior change — Apple Container remains the default runtime. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ Host (macOS) Container (Linux VM)
|
|||||||
─────────────────────────────────────────────────────────────
|
─────────────────────────────────────────────────────────────
|
||||||
src/container-runner.ts container/agent-runner/
|
src/container-runner.ts container/agent-runner/
|
||||||
│ │
|
│ │
|
||||||
│ spawns Apple Container │ runs Claude Agent SDK
|
│ spawns container │ runs Claude Agent SDK
|
||||||
│ with volume mounts │ with MCP servers
|
│ with volume mounts │ with MCP servers
|
||||||
│ │
|
│ │
|
||||||
├── data/env/env ──────────────> /workspace/env-dir/env
|
├── data/env/env ──────────────> /workspace/env-dir/env
|
||||||
@@ -80,7 +80,7 @@ cat .env # Should show one of:
|
|||||||
|
|
||||||
### 2. Environment Variables Not Passing
|
### 2. Environment Variables Not Passing
|
||||||
|
|
||||||
**Apple Container Bug:** Environment variables passed via `-e` are lost when using `-i` (interactive/piped stdin).
|
**Runtime note:** Environment variables passed via `-e` may be lost when using `-i` (interactive/piped stdin).
|
||||||
|
|
||||||
**Workaround:** The system extracts only authentication variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`) from `.env` and mounts them for sourcing inside the container. Other env vars are not exposed.
|
**Workaround:** The system extracts only authentication variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`) from `.env` and mounts them for sourcing inside the container. Other env vars are not exposed.
|
||||||
|
|
||||||
@@ -94,9 +94,9 @@ echo '{}' | container run -i \
|
|||||||
|
|
||||||
### 3. Mount Issues
|
### 3. Mount Issues
|
||||||
|
|
||||||
**Apple Container quirks:**
|
**Container mount notes:**
|
||||||
- Only mounts directories, not individual files
|
- Only mounts directories, not individual files
|
||||||
- `-v` syntax does NOT support `:ro` suffix - use `--mount` for readonly:
|
- `-v` syntax may NOT support `:ro` suffix - use `--mount` for readonly:
|
||||||
```bash
|
```bash
|
||||||
# Readonly: use --mount
|
# Readonly: use --mount
|
||||||
--mount "type=bind,source=/path,target=/container/path,readonly"
|
--mount "type=bind,source=/path,target=/container/path,readonly"
|
||||||
@@ -326,7 +326,7 @@ echo -e "\n1. Authentication configured?"
|
|||||||
echo -e "\n2. Env file copied for container?"
|
echo -e "\n2. Env file copied for container?"
|
||||||
[ -f data/env/env ] && echo "OK" || echo "MISSING - will be created on first run"
|
[ -f data/env/env ] && echo "OK" || echo "MISSING - will be created on first run"
|
||||||
|
|
||||||
echo -e "\n3. Apple Container system running?"
|
echo -e "\n3. Container runtime running?"
|
||||||
container system status &>/dev/null && echo "OK" || echo "NOT RUNNING - NanoClaw should auto-start it; check logs"
|
container system status &>/dev/null && echo "OK" || echo "NOT RUNNING - NanoClaw should auto-start it; check logs"
|
||||||
|
|
||||||
echo -e "\n4. Container image exists?"
|
echo -e "\n4. Container image exists?"
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ Show the log tail command: `tail -f logs/nanoclaw.log`
|
|||||||
|
|
||||||
**Service not starting:** Check `logs/nanoclaw.error.log`. Common causes: wrong Node path in plist (re-run step 10), missing `.env` (re-run step 4), missing WhatsApp auth (re-run step 5).
|
**Service not starting:** Check `logs/nanoclaw.error.log`. Common causes: wrong Node path in plist (re-run step 10), missing `.env` (re-run step 4), missing WhatsApp auth (re-run step 5).
|
||||||
|
|
||||||
**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — start it: `container system start` (Apple Container) or `open -a Docker` (macOS Docker). Check container logs in `groups/main/logs/container-*.log`.
|
**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — start it with the appropriate command for your runtime. Check container logs in `groups/main/logs/container-*.log`.
|
||||||
|
|
||||||
**No response to messages:** Verify the trigger pattern matches. Main channel and personal/solo chats don't need a prefix. Check the registered JID in the database: `sqlite3 store/messages.db "SELECT * FROM registered_groups"`. Check `logs/nanoclaw.log`.
|
**No response to messages:** Verify the trigger pattern matches. Main channel and personal/solo chats don't need a prefix. Check the registered JID in the database: `sqlite3 store/messages.db "SELECT * FROM registered_groups"`. Check `logs/nanoclaw.log`.
|
||||||
|
|
||||||
|
|||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -4,7 +4,7 @@ Personal Claude assistant. See [README.md](README.md) for philosophy and setup.
|
|||||||
|
|
||||||
## Quick Context
|
## Quick Context
|
||||||
|
|
||||||
Single Node.js process that connects to WhatsApp, routes messages to Claude Agent SDK running in Apple Container (Linux VMs). Each group has isolated filesystem and memory.
|
Single Node.js process that connects to WhatsApp, routes messages to Claude Agent SDK running in containers (Linux VMs). Each group has isolated filesystem and memory.
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
@@ -47,11 +47,4 @@ launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
|||||||
|
|
||||||
## Container Build Cache
|
## Container Build Cache
|
||||||
|
|
||||||
Apple Container's buildkit caches the build context aggressively. `--no-cache` alone does NOT invalidate COPY steps — the builder's volume retains stale files. To force a truly clean rebuild:
|
The container buildkit caches the build context aggressively. `--no-cache` alone does NOT invalidate COPY steps — the builder's volume retains stale files. To force a truly clean rebuild, prune the builder then re-run `./container/build.sh`.
|
||||||
|
|
||||||
```bash
|
|
||||||
container builder stop && container builder rm && container builder start
|
|
||||||
./container/build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Always verify after rebuild: `container run -i --rm --entrypoint wc nanoclaw-agent:latest -l /app/src/index.ts`
|
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ cd "$SCRIPT_DIR"
|
|||||||
|
|
||||||
IMAGE_NAME="nanoclaw-agent"
|
IMAGE_NAME="nanoclaw-agent"
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
|
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}"
|
||||||
|
|
||||||
echo "Building NanoClaw agent container image..."
|
echo "Building NanoClaw agent container image..."
|
||||||
echo "Image: ${IMAGE_NAME}:${TAG}"
|
echo "Image: ${IMAGE_NAME}:${TAG}"
|
||||||
|
|
||||||
# Build with Apple Container
|
${CONTAINER_RUNTIME} build -t "${IMAGE_NAME}:${TAG}" .
|
||||||
container build -t "${IMAGE_NAME}:${TAG}" .
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Build complete!"
|
echo "Build complete!"
|
||||||
echo "Image: ${IMAGE_NAME}:${TAG}"
|
echo "Image: ${IMAGE_NAME}:${TAG}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Test with:"
|
echo "Test with:"
|
||||||
echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | container run -i ${IMAGE_NAME}:${TAG}"
|
echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | ${CONTAINER_RUNTIME} run -i ${IMAGE_NAME}:${TAG}"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ The entire codebase should be something you can read and understand. One Node.js
|
|||||||
|
|
||||||
### Security Through True Isolation
|
### Security Through True Isolation
|
||||||
|
|
||||||
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers (Apple Container). The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac.
|
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac.
|
||||||
|
|
||||||
### Built for One User
|
### Built for One User
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code.
|
|||||||
|
|
||||||
**Core components:**
|
**Core components:**
|
||||||
- **Claude Agent SDK** as the core agent
|
- **Claude Agent SDK** as the core agent
|
||||||
- **Apple Container** for isolated agent execution (Linux VMs)
|
- **Containers** for isolated agent execution (Linux VMs)
|
||||||
- **WhatsApp** as the primary I/O channel
|
- **WhatsApp** as the primary I/O channel
|
||||||
- **Persistent memory** per conversation and globally
|
- **Persistent memory** per conversation and globally
|
||||||
- **Scheduled tasks** that run Claude and can message back
|
- **Scheduled tasks** that run Claude and can message back
|
||||||
@@ -104,7 +104,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code.
|
|||||||
- Sessions auto-compact when context gets too long, preserving critical information
|
- Sessions auto-compact when context gets too long, preserving critical information
|
||||||
|
|
||||||
### Container Isolation
|
### Container Isolation
|
||||||
- All agents run inside Apple Container (lightweight Linux VMs)
|
- All agents run inside containers (lightweight Linux VMs)
|
||||||
- Each agent invocation spawns a container with mounted directories
|
- Each agent invocation spawns a container with mounted directories
|
||||||
- Containers provide filesystem isolation - agents can only see mounted paths
|
- Containers provide filesystem isolation - agents can only see mounted paths
|
||||||
- Bash access is safe because commands run inside the container, not on the host
|
- Bash access is safe because commands run inside the container, not on the host
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
### 1. Container Isolation (Primary Boundary)
|
### 1. Container Isolation (Primary Boundary)
|
||||||
|
|
||||||
Agents execute in Apple Container (lightweight Linux VMs), providing:
|
Agents execute in containers (lightweight Linux VMs), providing:
|
||||||
- **Process isolation** - Container processes cannot affect the host
|
- **Process isolation** - Container processes cannot affect the host
|
||||||
- **Filesystem isolation** - Only explicitly mounted directories are visible
|
- **Filesystem isolation** - Only explicitly mounted directories are visible
|
||||||
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
|
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
|
||||||
|
|||||||
18
docs/SPEC.md
18
docs/SPEC.md
@@ -45,7 +45,7 @@ A personal Claude assistant accessible via WhatsApp, with persistent memory per
|
|||||||
│ │ spawns container │
|
│ │ spawns container │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
├─────────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
│ APPLE CONTAINER (Linux VM) │
|
│ CONTAINER (Linux VM) │
|
||||||
├─────────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ AGENT RUNNER │ │
|
│ │ AGENT RUNNER │ │
|
||||||
@@ -75,7 +75,7 @@ A personal Claude assistant accessible via WhatsApp, with persistent memory per
|
|||||||
|-----------|------------|---------|
|
|-----------|------------|---------|
|
||||||
| WhatsApp Connection | Node.js (@whiskeysockets/baileys) | Connect to WhatsApp, send/receive messages |
|
| WhatsApp Connection | Node.js (@whiskeysockets/baileys) | Connect to WhatsApp, send/receive messages |
|
||||||
| Message Storage | SQLite (better-sqlite3) | Store messages for polling |
|
| Message Storage | SQLite (better-sqlite3) | Store messages for polling |
|
||||||
| Container Runtime | Apple Container | Isolated Linux VMs for agent execution |
|
| Container Runtime | Containers (Linux VMs) | Isolated environments for agent execution |
|
||||||
| Agent | @anthropic-ai/claude-agent-sdk (0.2.29) | Run Claude with tools and MCP servers |
|
| Agent | @anthropic-ai/claude-agent-sdk (0.2.29) | Run Claude with tools and MCP servers |
|
||||||
| Browser Automation | agent-browser + Chromium | Web interaction and screenshots |
|
| Browser Automation | agent-browser + Chromium | Web interaction and screenshots |
|
||||||
| Runtime | Node.js 20+ | Host process for routing and scheduling |
|
| Runtime | Node.js 20+ | Host process for routing and scheduling |
|
||||||
@@ -111,7 +111,7 @@ nanoclaw/
|
|||||||
│ ├── mount-security.ts # Mount allowlist validation for containers
|
│ ├── mount-security.ts # Mount allowlist validation for containers
|
||||||
│ ├── whatsapp-auth.ts # Standalone WhatsApp authentication
|
│ ├── whatsapp-auth.ts # Standalone WhatsApp authentication
|
||||||
│ ├── task-scheduler.ts # Runs scheduled tasks when due
|
│ ├── task-scheduler.ts # Runs scheduled tasks when due
|
||||||
│ └── container-runner.ts # Spawns agents in Apple Containers
|
│ └── container-runner.ts # Spawns agents in containers
|
||||||
│
|
│
|
||||||
├── container/
|
├── container/
|
||||||
│ ├── Dockerfile # Container image (runs as 'node' user, includes Claude Code CLI)
|
│ ├── Dockerfile # Container image (runs as 'node' user, includes Claude Code CLI)
|
||||||
@@ -196,7 +196,7 @@ export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CO
|
|||||||
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
|
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Paths must be absolute for Apple Container volume mounts to work correctly.
|
**Note:** Paths must be absolute for container volume mounts to work correctly.
|
||||||
|
|
||||||
### Container Configuration
|
### Container Configuration
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ registerGroup("1234567890@g.us", {
|
|||||||
|
|
||||||
Additional mounts appear at `/workspace/extra/{containerPath}` inside the container.
|
Additional mounts appear at `/workspace/extra/{containerPath}` inside the container.
|
||||||
|
|
||||||
**Apple Container mount syntax note:** Read-write mounts use `-v host:container`, but readonly mounts require `--mount "type=bind,source=...,target=...,readonly"` (the `:ro` suffix doesn't work).
|
**Mount syntax note:** Read-write mounts use `-v host:container`, but readonly mounts require `--mount "type=bind,source=...,target=...,readonly"` (the `:ro` suffix may not work on all runtimes).
|
||||||
|
|
||||||
### Claude Authentication
|
### Claude Authentication
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ The token can be extracted from `~/.claude/.credentials.json` if you're logged i
|
|||||||
ANTHROPIC_API_KEY=sk-ant-api03-...
|
ANTHROPIC_API_KEY=sk-ant-api03-...
|
||||||
```
|
```
|
||||||
|
|
||||||
Only the authentication variables (`CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY`) are extracted from `.env` and written to `data/env/env`, then mounted into the container at `/workspace/env-dir/env` and sourced by the entrypoint script. This ensures other environment variables in `.env` are not exposed to the agent. This workaround is needed because Apple Container loses `-e` environment variables when using `-i` (interactive mode with piped stdin).
|
Only the authentication variables (`CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY`) are extracted from `.env` and written to `data/env/env`, then mounted into the container at `/workspace/env-dir/env` and sourced by the entrypoint script. This ensures other environment variables in `.env` are not exposed to the agent. This workaround is needed because some container runtimes lose `-e` environment variables when using `-i` (interactive mode with piped stdin).
|
||||||
|
|
||||||
### Changing the Assistant Name
|
### Changing the Assistant Name
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ NanoClaw runs as a single macOS launchd service.
|
|||||||
### Startup Sequence
|
### Startup Sequence
|
||||||
|
|
||||||
When NanoClaw starts, it:
|
When NanoClaw starts, it:
|
||||||
1. **Ensures Apple Container system is running** - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs
|
1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs
|
||||||
2. Initializes the SQLite database (migrates from JSON files if they exist)
|
2. Initializes the SQLite database (migrates from JSON files if they exist)
|
||||||
3. Loads state from SQLite (registered groups, sessions, router state)
|
3. Loads state from SQLite (registered groups, sessions, router state)
|
||||||
4. Connects to WhatsApp (on `connection.open`):
|
4. Connects to WhatsApp (on `connection.open`):
|
||||||
@@ -557,7 +557,7 @@ tail -f logs/nanoclaw.log
|
|||||||
|
|
||||||
### Container Isolation
|
### Container Isolation
|
||||||
|
|
||||||
All agents run inside Apple Container (lightweight Linux VMs), providing:
|
All agents run inside containers (lightweight Linux VMs), providing:
|
||||||
- **Filesystem isolation**: Agents can only access mounted directories
|
- **Filesystem isolation**: Agents can only access mounted directories
|
||||||
- **Safe Bash access**: Commands run inside the container, not on your Mac
|
- **Safe Bash access**: Commands run inside the container, not on your Mac
|
||||||
- **Network isolation**: Can be configured per-container if needed
|
- **Network isolation**: Can be configured per-container if needed
|
||||||
@@ -605,7 +605,7 @@ chmod 700 groups/
|
|||||||
| Issue | Cause | Solution |
|
| Issue | Cause | Solution |
|
||||||
|-------|-------|----------|
|
|-------|-------|----------|
|
||||||
| No response to messages | Service not running | Check `launchctl list | grep nanoclaw` |
|
| No response to messages | Service not running | Check `launchctl list | grep nanoclaw` |
|
||||||
| "Claude Code process exited with code 1" | Apple Container failed to start | Check logs; NanoClaw auto-starts container system but may fail |
|
| "Claude Code process exited with code 1" | Container runtime failed to start | Check logs; NanoClaw auto-starts container runtime but may fail |
|
||||||
| "Claude Code process exited with code 1" | Session mount path wrong | Ensure mount is to `/home/node/.claude/` not `/root/.claude/` |
|
| "Claude Code process exited with code 1" | Session mount path wrong | Ensure mount is to `/home/node/.claude/` not `/root/.claude/` |
|
||||||
| Session not continuing | Session ID not saved | Check SQLite: `sqlite3 store/messages.db "SELECT * FROM sessions"` |
|
| Session not continuing | Session ID not saved | Check SQLite: `sqlite3 store/messages.db "SELECT * FROM sessions"` |
|
||||||
| Session not continuing | Mount path mismatch | Container user is `node` with HOME=/home/node; sessions must be at `/home/node/.claude/` |
|
| Session not continuing | Mount path mismatch | Container user is `node` with HOME=/home/node; sessions must be at `/home/node/.claude/` |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Container Runner for NanoClaw
|
* Container Runner for NanoClaw
|
||||||
* Spawns agent execution in Apple Container and handles IPC
|
* Spawns agent execution in containers and handles IPC
|
||||||
*/
|
*/
|
||||||
import { ChildProcess, exec, spawn } from 'child_process';
|
import { ChildProcess, exec, spawn } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { readEnvFile } from './env.js';
|
import { readEnvFile } from './env.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||||
import { validateAdditionalMounts } from './mount-security.js';
|
import { validateAdditionalMounts } from './mount-security.js';
|
||||||
import { RegisteredGroup } from './types.js';
|
import { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ function buildVolumeMounts(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Global memory directory (read-only for non-main)
|
// Global memory directory (read-only for non-main)
|
||||||
// Apple Container only supports directory mounts, not file mounts
|
// Only directory mounts are supported, not file mounts
|
||||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||||
if (fs.existsSync(globalDir)) {
|
if (fs.existsSync(globalDir)) {
|
||||||
mounts.push({
|
mounts.push({
|
||||||
@@ -160,7 +161,7 @@ function buildVolumeMounts(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mount agent-runner source from host — recompiled on container startup.
|
// Mount agent-runner source from host — recompiled on container startup.
|
||||||
// Bypasses Apple Container's sticky build cache for code changes.
|
// Bypasses sticky build cache for code changes.
|
||||||
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
|
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
|
||||||
mounts.push({
|
mounts.push({
|
||||||
hostPath: agentRunnerSrc,
|
hostPath: agentRunnerSrc,
|
||||||
@@ -202,13 +203,9 @@ function buildContainerArgs(mounts: VolumeMount[], containerName: string): strin
|
|||||||
args.push('-e', 'HOME=/home/node');
|
args.push('-e', 'HOME=/home/node');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple Container: --mount for readonly, -v for read-write
|
|
||||||
for (const mount of mounts) {
|
for (const mount of mounts) {
|
||||||
if (mount.readonly) {
|
if (mount.readonly) {
|
||||||
args.push(
|
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
|
||||||
'--mount',
|
|
||||||
`type=bind,source=${mount.hostPath},target=${mount.containerPath},readonly`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
||||||
}
|
}
|
||||||
@@ -262,7 +259,7 @@ export async function runContainerAgent(
|
|||||||
fs.mkdirSync(logsDir, { recursive: true });
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const container = spawn('container', containerArgs, {
|
const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,7 +366,7 @@ export async function runContainerAgent(
|
|||||||
const killOnTimeout = () => {
|
const killOnTimeout = () => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully');
|
logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully');
|
||||||
exec(`container stop ${containerName}`, { timeout: 15000 }, (err) => {
|
exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing');
|
logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing');
|
||||||
container.kill('SIGKILL');
|
container.kill('SIGKILL');
|
||||||
|
|||||||
189
src/container-runtime.test.ts
Normal file
189
src/container-runtime.test.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock logger
|
||||||
|
vi.mock('./logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock child_process — store the mock fn so tests can configure it
|
||||||
|
const mockExecSync = vi.fn();
|
||||||
|
vi.mock('child_process', () => ({
|
||||||
|
execSync: (...args: unknown[]) => mockExecSync(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
CONTAINER_RUNTIME_BIN,
|
||||||
|
readonlyMountArgs,
|
||||||
|
stopContainer,
|
||||||
|
ensureContainerRuntimeRunning,
|
||||||
|
cleanupOrphans,
|
||||||
|
} from './container-runtime.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Pure functions ---
|
||||||
|
|
||||||
|
describe('readonlyMountArgs', () => {
|
||||||
|
it('returns --mount flag with bind,source,target,readonly', () => {
|
||||||
|
const args = readonlyMountArgs('/host/path', '/container/path');
|
||||||
|
expect(args).toEqual([
|
||||||
|
'--mount',
|
||||||
|
'type=bind,source=/host/path,target=/container/path,readonly',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stopContainer', () => {
|
||||||
|
it('returns stop command using CONTAINER_RUNTIME_BIN', () => {
|
||||||
|
expect(stopContainer('nanoclaw-test-123')).toBe(
|
||||||
|
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-test-123`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- ensureContainerRuntimeRunning ---
|
||||||
|
|
||||||
|
describe('ensureContainerRuntimeRunning', () => {
|
||||||
|
it('does nothing when runtime is already running', () => {
|
||||||
|
// system status succeeds
|
||||||
|
mockExecSync.mockReturnValueOnce('');
|
||||||
|
|
||||||
|
ensureContainerRuntimeRunning();
|
||||||
|
|
||||||
|
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockExecSync).toHaveBeenCalledWith(
|
||||||
|
`${CONTAINER_RUNTIME_BIN} system status`,
|
||||||
|
{ stdio: 'pipe' },
|
||||||
|
);
|
||||||
|
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts runtime when status check fails', () => {
|
||||||
|
// system status fails
|
||||||
|
mockExecSync.mockImplementationOnce(() => {
|
||||||
|
throw new Error('not running');
|
||||||
|
});
|
||||||
|
// system start succeeds
|
||||||
|
mockExecSync.mockReturnValueOnce('');
|
||||||
|
|
||||||
|
ensureContainerRuntimeRunning();
|
||||||
|
|
||||||
|
expect(mockExecSync).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
`${CONTAINER_RUNTIME_BIN} system start`,
|
||||||
|
{ stdio: 'pipe', timeout: 30000 },
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith('Container runtime started');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when both status and start fail', () => {
|
||||||
|
mockExecSync.mockImplementation(() => {
|
||||||
|
throw new Error('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => ensureContainerRuntimeRunning()).toThrow(
|
||||||
|
'Container runtime is required but failed to start',
|
||||||
|
);
|
||||||
|
expect(logger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- cleanupOrphans ---
|
||||||
|
|
||||||
|
describe('cleanupOrphans', () => {
|
||||||
|
it('stops orphaned nanoclaw containers', () => {
|
||||||
|
const containers = [
|
||||||
|
{ status: 'running', configuration: { id: 'nanoclaw-group1-111' } },
|
||||||
|
{ status: 'running', configuration: { id: 'nanoclaw-group2-222' } },
|
||||||
|
{ status: 'running', configuration: { id: 'other-container' } },
|
||||||
|
{ status: 'stopped', configuration: { id: 'nanoclaw-old-333' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ls returns container list
|
||||||
|
mockExecSync.mockReturnValueOnce(JSON.stringify(containers));
|
||||||
|
// stop calls succeed
|
||||||
|
mockExecSync.mockReturnValue('');
|
||||||
|
|
||||||
|
cleanupOrphans();
|
||||||
|
|
||||||
|
// ls + 2 stop calls (only running nanoclaw- containers)
|
||||||
|
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group1-111`,
|
||||||
|
{ stdio: 'pipe' },
|
||||||
|
);
|
||||||
|
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group2-222`,
|
||||||
|
{ stdio: 'pipe' },
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
|
||||||
|
'Stopped orphaned containers',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no orphans exist', () => {
|
||||||
|
mockExecSync.mockReturnValueOnce(JSON.stringify([]));
|
||||||
|
|
||||||
|
cleanupOrphans();
|
||||||
|
|
||||||
|
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.info).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty output from ls', () => {
|
||||||
|
mockExecSync.mockReturnValueOnce('');
|
||||||
|
|
||||||
|
cleanupOrphans();
|
||||||
|
|
||||||
|
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns and continues when ls fails', () => {
|
||||||
|
mockExecSync.mockImplementationOnce(() => {
|
||||||
|
throw new Error('runtime not available');
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanupOrphans(); // should not throw
|
||||||
|
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ err: expect.any(Error) }),
|
||||||
|
'Failed to clean up orphaned containers',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues stopping remaining containers when one stop fails', () => {
|
||||||
|
const containers = [
|
||||||
|
{ status: 'running', configuration: { id: 'nanoclaw-a-1' } },
|
||||||
|
{ status: 'running', configuration: { id: 'nanoclaw-b-2' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
mockExecSync.mockReturnValueOnce(JSON.stringify(containers));
|
||||||
|
// First stop fails
|
||||||
|
mockExecSync.mockImplementationOnce(() => {
|
||||||
|
throw new Error('already stopped');
|
||||||
|
});
|
||||||
|
// Second stop succeeds
|
||||||
|
mockExecSync.mockReturnValueOnce('');
|
||||||
|
|
||||||
|
cleanupOrphans(); // should not throw
|
||||||
|
|
||||||
|
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
|
||||||
|
'Stopped orphaned containers',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
src/container-runtime.ts
Normal file
82
src/container-runtime.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Container runtime abstraction for NanoClaw.
|
||||||
|
* Swap this file to switch between container runtimes (e.g. Docker).
|
||||||
|
*/
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/** The container runtime binary name. */
|
||||||
|
export const CONTAINER_RUNTIME_BIN = 'container';
|
||||||
|
|
||||||
|
/** Returns CLI args for a readonly bind mount. */
|
||||||
|
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
||||||
|
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the shell command to stop a container by name. */
|
||||||
|
export function stopContainer(name: string): string {
|
||||||
|
return `${CONTAINER_RUNTIME_BIN} stop ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure the container runtime is running, starting it if needed. */
|
||||||
|
export function ensureContainerRuntimeRunning(): void {
|
||||||
|
try {
|
||||||
|
execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' });
|
||||||
|
logger.debug('Container runtime already running');
|
||||||
|
} catch {
|
||||||
|
logger.info('Starting container runtime...');
|
||||||
|
try {
|
||||||
|
execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 });
|
||||||
|
logger.info('Container runtime started');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Failed to start container runtime');
|
||||||
|
console.error(
|
||||||
|
'\n╔════════════════════════════════════════════════════════════════╗',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'║ FATAL: Container runtime failed to start ║',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'║ ║',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'║ Agents cannot run without a container runtime. To fix: ║',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'║ 1. Ensure your container runtime is installed ║',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'║ 2. Start the runtime, then restart NanoClaw ║',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'╚════════════════════════════════════════════════════════════════╝\n',
|
||||||
|
);
|
||||||
|
throw new Error('Container runtime is required but failed to start');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill orphaned NanoClaw containers from previous runs. */
|
||||||
|
export function cleanupOrphans(): void {
|
||||||
|
try {
|
||||||
|
const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]');
|
||||||
|
const orphans = containers
|
||||||
|
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
|
||||||
|
.map((c) => c.configuration.id);
|
||||||
|
for (const name of orphans) {
|
||||||
|
try {
|
||||||
|
execSync(stopContainer(name), { stdio: 'pipe' });
|
||||||
|
} catch { /* already stopped */ }
|
||||||
|
}
|
||||||
|
if (orphans.length > 0) {
|
||||||
|
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/index.ts
63
src/index.ts
@@ -1,4 +1,3 @@
|
|||||||
import { execSync } from 'child_process';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
writeGroupsSnapshot,
|
writeGroupsSnapshot,
|
||||||
writeTasksSnapshot,
|
writeTasksSnapshot,
|
||||||
} from './container-runner.js';
|
} from './container-runner.js';
|
||||||
|
import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
|
||||||
import {
|
import {
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
@@ -399,65 +399,8 @@ function recoverPendingMessages(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureContainerSystemRunning(): void {
|
function ensureContainerSystemRunning(): void {
|
||||||
try {
|
ensureContainerRuntimeRunning();
|
||||||
execSync('container system status', { stdio: 'pipe' });
|
cleanupOrphans();
|
||||||
logger.debug('Apple Container system already running');
|
|
||||||
} catch {
|
|
||||||
logger.info('Starting Apple Container system...');
|
|
||||||
try {
|
|
||||||
execSync('container system start', { stdio: 'pipe', timeout: 30000 });
|
|
||||||
logger.info('Apple Container system started');
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, 'Failed to start Apple Container system');
|
|
||||||
console.error(
|
|
||||||
'\n╔════════════════════════════════════════════════════════════════╗',
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
'║ FATAL: Apple Container system failed to start ║',
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
'║ ║',
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
'║ Agents cannot run without Apple Container. To fix: ║',
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
'║ 1. Install from: https://github.com/apple/container/releases ║',
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
'║ 2. Run: container system start ║',
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
'║ 3. Restart NanoClaw ║',
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
'╚════════════════════════════════════════════════════════════════╝\n',
|
|
||||||
);
|
|
||||||
throw new Error('Apple Container system is required but failed to start');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kill and clean up orphaned NanoClaw containers from previous runs
|
|
||||||
try {
|
|
||||||
const output = execSync('container ls --format json', {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
encoding: 'utf-8',
|
|
||||||
});
|
|
||||||
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]');
|
|
||||||
const orphans = containers
|
|
||||||
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
|
|
||||||
.map((c) => c.configuration.id);
|
|
||||||
for (const name of orphans) {
|
|
||||||
try {
|
|
||||||
execSync(`container stop ${name}`, { stdio: 'pipe' });
|
|
||||||
} catch { /* already stopped */ }
|
|
||||||
}
|
|
||||||
if (orphans.length > 0) {
|
|
||||||
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user