diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index ebeef91..05827e4 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -14,7 +14,7 @@ Host (macOS) Container (Linux VM) ───────────────────────────────────────────────────────────── 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 │ │ ├── data/env/env ──────────────> /workspace/env-dir/env @@ -80,7 +80,7 @@ cat .env # Should show one of: ### 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. @@ -94,9 +94,9 @@ echo '{}' | container run -i \ ### 3. Mount Issues -**Apple Container quirks:** +**Container mount notes:** - 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 # Readonly: use --mount --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?" [ -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" echo -e "\n4. Container image exists?" diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 9bac2fa..e77329f 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -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). -**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`. diff --git a/CLAUDE.md b/CLAUDE.md index ca33505..e55c066 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Personal Claude assistant. See [README.md](README.md) for philosophy and setup. ## 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 @@ -47,11 +47,4 @@ launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist ## 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: - -```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` +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`. diff --git a/container/build.sh b/container/build.sh index 8de24d5..fbdef31 100755 --- a/container/build.sh +++ b/container/build.sh @@ -8,16 +8,16 @@ cd "$SCRIPT_DIR" IMAGE_NAME="nanoclaw-agent" TAG="${1:-latest}" +CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}" echo "Building NanoClaw agent container image..." echo "Image: ${IMAGE_NAME}:${TAG}" -# Build with Apple Container -container build -t "${IMAGE_NAME}:${TAG}" . +${CONTAINER_RUNTIME} build -t "${IMAGE_NAME}:${TAG}" . echo "" echo "Build complete!" echo "Image: ${IMAGE_NAME}:${TAG}" echo "" 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}" diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 31162dc..606db8f 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -20,7 +20,7 @@ The entire codebase should be something you can read and understand. One Node.js ### 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 @@ -71,7 +71,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. **Core components:** - **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 - **Persistent memory** per conversation and globally - **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 ### 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 - Containers provide filesystem isolation - agents can only see mounted paths - Bash access is safe because commands run inside the container, not on the host diff --git a/docs/SECURITY.md b/docs/SECURITY.md index eabbd76..578b041 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -13,7 +13,7 @@ ### 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 - **Filesystem isolation** - Only explicitly mounted directories are visible - **Non-root execution** - Runs as unprivileged `node` user (uid 1000) diff --git a/docs/SPEC.md b/docs/SPEC.md index 09364c0..082ac19 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -45,7 +45,7 @@ A personal Claude assistant accessible via WhatsApp, with persistent memory per │ │ spawns container │ │ ▼ │ ├─────────────────────────────────────────────────────────────────────┤ -│ APPLE CONTAINER (Linux VM) │ +│ CONTAINER (Linux VM) │ ├─────────────────────────────────────────────────────────────────────┤ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ 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 | | 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 | | Browser Automation | agent-browser + Chromium | Web interaction and screenshots | | Runtime | Node.js 20+ | Host process for routing and scheduling | @@ -111,7 +111,7 @@ nanoclaw/ │ ├── mount-security.ts # Mount allowlist validation for containers │ ├── whatsapp-auth.ts # Standalone WhatsApp authentication │ ├── task-scheduler.ts # Runs scheduled tasks when due -│ └── container-runner.ts # Spawns agents in Apple Containers +│ └── container-runner.ts # Spawns agents in containers │ ├── container/ │ ├── 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'); ``` -**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 @@ -223,7 +223,7 @@ registerGroup("1234567890@g.us", { 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 @@ -240,7 +240,7 @@ The token can be extracted from `~/.claude/.credentials.json` if you're logged i 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 @@ -484,7 +484,7 @@ NanoClaw runs as a single macOS launchd service. ### Startup Sequence 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) 3. Loads state from SQLite (registered groups, sessions, router state) 4. Connects to WhatsApp (on `connection.open`): @@ -557,7 +557,7 @@ tail -f logs/nanoclaw.log ### 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 - **Safe Bash access**: Commands run inside the container, not on your Mac - **Network isolation**: Can be configured per-container if needed @@ -605,7 +605,7 @@ chmod 700 groups/ | Issue | Cause | Solution | |-------|-------|----------| | 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/` | | 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/` | diff --git a/src/container-runner.ts b/src/container-runner.ts index 7f4cd5c..0d78caa 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -1,6 +1,6 @@ /** * 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 fs from 'fs'; @@ -17,6 +17,7 @@ import { } from './config.js'; import { readEnvFile } from './env.js'; import { logger } from './logger.js'; +import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { validateAdditionalMounts } from './mount-security.js'; import { RegisteredGroup } from './types.js'; @@ -88,7 +89,7 @@ function buildVolumeMounts( }); // 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'); if (fs.existsSync(globalDir)) { mounts.push({ @@ -160,7 +161,7 @@ function buildVolumeMounts( }); // 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'); mounts.push({ hostPath: agentRunnerSrc, @@ -202,13 +203,9 @@ function buildContainerArgs(mounts: VolumeMount[], containerName: string): strin args.push('-e', 'HOME=/home/node'); } - // Apple Container: --mount for readonly, -v for read-write for (const mount of mounts) { if (mount.readonly) { - args.push( - '--mount', - `type=bind,source=${mount.hostPath},target=${mount.containerPath},readonly`, - ); + args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); } else { args.push('-v', `${mount.hostPath}:${mount.containerPath}`); } @@ -262,7 +259,7 @@ export async function runContainerAgent( fs.mkdirSync(logsDir, { recursive: true }); return new Promise((resolve) => { - const container = spawn('container', containerArgs, { + const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { stdio: ['pipe', 'pipe', 'pipe'], }); @@ -369,7 +366,7 @@ export async function runContainerAgent( const killOnTimeout = () => { timedOut = true; 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) { logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing'); container.kill('SIGKILL'); diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts new file mode 100644 index 0000000..ab2dec5 --- /dev/null +++ b/src/container-runtime.test.ts @@ -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', + ); + }); +}); diff --git a/src/container-runtime.ts b/src/container-runtime.ts new file mode 100644 index 0000000..507769f --- /dev/null +++ b/src/container-runtime.ts @@ -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'); + } +} diff --git a/src/index.ts b/src/index.ts index 7e4000f..34e4e34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -17,6 +16,7 @@ import { writeGroupsSnapshot, writeTasksSnapshot, } from './container-runner.js'; +import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js'; import { getAllChats, getAllRegisteredGroups, @@ -399,65 +399,8 @@ function recoverPendingMessages(): void { } function ensureContainerSystemRunning(): void { - try { - execSync('container system status', { stdio: 'pipe' }); - 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'); - } + ensureContainerRuntimeRunning(); + cleanupOrphans(); } async function main(): Promise {