diff --git a/container/Dockerfile b/container/Dockerfile index e8537c3..3539cc0 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -7,7 +7,6 @@ FROM node:22-slim RUN apt-get update && apt-get install -y \ chromium \ fonts-liberation \ - fonts-noto-cjk \ fonts-noto-color-emoji \ libgbm1 \ libnss3 \ @@ -55,14 +54,14 @@ RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/ # Container input (prompt, group info) is passed via stdin JSON. # Credentials are injected by the host's credential proxy — never passed here. # Follow-up messages arrive via IPC files in /workspace/ipc/input/ -RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh +# Apple Container only supports directory mounts (VirtioFS), so .env cannot be +# shadowed with a host-side /dev/null file mount. Instead the entrypoint starts +# as root, uses mount --bind to shadow .env, then drops to the host user via setpriv. +RUN printf '#!/bin/bash\nset -e\n\n# Shadow .env so the agent cannot read host secrets (requires root)\nif [ "$(id -u)" = "0" ] && [ -f /workspace/project/.env ]; then\n mount --bind /dev/null /workspace/project/.env\nfi\n\n# Compile agent-runner\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\n\n# Capture stdin (secrets JSON) to temp file\ncat > /tmp/input.json\n\n# Drop privileges if running as root (main-group containers)\nif [ "$(id -u)" = "0" ] && [ -n "$RUN_UID" ]; then\n chown "$RUN_UID:$RUN_GID" /tmp/input.json /tmp/dist\n exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --clear-groups -- node /tmp/dist/index.js < /tmp/input.json\nfi\n\nexec node /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh # Set ownership to node user (non-root) for writable directories RUN chown -R node:node /workspace && chmod 777 /home/node -# Switch to non-root user (required for --dangerously-skip-permissions) -USER node - # Set working directory to group workspace WORKDIR /workspace/group diff --git a/container/build.sh b/container/build.sh index 8321fdf..fbdef31 100755 --- a/container/build.sh +++ b/container/build.sh @@ -8,7 +8,7 @@ cd "$SCRIPT_DIR" IMAGE_NAME="nanoclaw-agent" TAG="${1:-latest}" -CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" +CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}" echo "Building NanoClaw agent container image..." echo "Image: ${IMAGE_NAME}:${TAG}" diff --git a/src/container-runner.ts b/src/container-runner.ts index be6f356..2cfb1a8 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -215,6 +215,7 @@ function buildVolumeMounts( function buildContainerArgs( mounts: VolumeMount[], containerName: string, + isMain: boolean, ): string[] { const args: string[] = ['run', '-i', '--rm', '--name', containerName]; @@ -247,7 +248,14 @@ function buildContainerArgs( const hostUid = process.getuid?.(); const hostGid = process.getgid?.(); if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { - args.push('--user', `${hostUid}:${hostGid}`); + if (isMain) { + // Main containers start as root so the entrypoint can mount --bind + // to shadow .env. Privileges are dropped via setpriv in entrypoint.sh. + args.push('-e', `RUN_UID=${hostUid}`); + args.push('-e', `RUN_GID=${hostGid}`); + } else { + args.push('--user', `${hostUid}:${hostGid}`); + } args.push('-e', 'HOME=/home/node'); } @@ -278,7 +286,7 @@ export async function runContainerAgent( const mounts = buildVolumeMounts(group, input.isMain); const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); const containerName = `nanoclaw-${safeName}-${Date.now()}`; - const containerArgs = buildContainerArgs(mounts, containerName); + const containerArgs = buildContainerArgs(mounts, containerName, input.isMain); logger.debug( { diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 08ffd59..79b77a3 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -32,9 +32,12 @@ beforeEach(() => { // --- Pure functions --- describe('readonlyMountArgs', () => { - it('returns -v flag with :ro suffix', () => { + it('returns --mount flag with type=bind and readonly', () => { const args = readonlyMountArgs('/host/path', '/container/path'); - expect(args).toEqual(['-v', '/host/path:/container/path:ro']); + expect(args).toEqual([ + '--mount', + 'type=bind,source=/host/path,target=/container/path,readonly', + ]); }); }); @@ -55,18 +58,35 @@ describe('ensureContainerRuntimeRunning', () => { ensureContainerRuntimeRunning(); expect(mockExecSync).toHaveBeenCalledTimes(1); - expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, { - stdio: 'pipe', - timeout: 10000, - }); - expect(logger.debug).toHaveBeenCalledWith( - 'Container runtime already running', + expect(mockExecSync).toHaveBeenCalledWith( + `${CONTAINER_RUNTIME_BIN} system status`, + { stdio: 'pipe' }, ); + expect(logger.debug).toHaveBeenCalledWith('Container runtime already running'); }); - it('throws when docker info fails', () => { + it('auto-starts when system status fails', () => { + // First call (system status) fails mockExecSync.mockImplementationOnce(() => { - throw new Error('Cannot connect to the Docker daemon'); + throw new Error('not running'); + }); + // Second call (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( @@ -79,17 +99,21 @@ describe('ensureContainerRuntimeRunning', () => { // --- cleanupOrphans --- describe('cleanupOrphans', () => { - it('stops orphaned nanoclaw containers', () => { - // docker ps returns container names, one per line - mockExecSync.mockReturnValueOnce( - 'nanoclaw-group1-111\nnanoclaw-group2-222\n', - ); + it('stops orphaned nanoclaw containers from JSON output', () => { + // Apple Container ls returns JSON + const lsOutput = JSON.stringify([ + { status: 'running', configuration: { id: 'nanoclaw-group1-111' } }, + { status: 'stopped', configuration: { id: 'nanoclaw-group2-222' } }, + { status: 'running', configuration: { id: 'nanoclaw-group3-333' } }, + { status: 'running', configuration: { id: 'other-container' } }, + ]); + mockExecSync.mockReturnValueOnce(lsOutput); // stop calls succeed mockExecSync.mockReturnValue(''); cleanupOrphans(); - // ps + 2 stop calls + // ls + 2 stop calls (only running nanoclaw- containers) expect(mockExecSync).toHaveBeenCalledTimes(3); expect(mockExecSync).toHaveBeenNthCalledWith( 2, @@ -98,17 +122,17 @@ describe('cleanupOrphans', () => { ); expect(mockExecSync).toHaveBeenNthCalledWith( 3, - `${CONTAINER_RUNTIME_BIN} stop nanoclaw-group2-222`, + `${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`, { stdio: 'pipe' }, ); expect(logger.info).toHaveBeenCalledWith( - { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] }, + { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group3-333'] }, 'Stopped orphaned containers', ); }); it('does nothing when no orphans exist', () => { - mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce('[]'); cleanupOrphans(); @@ -116,9 +140,9 @@ describe('cleanupOrphans', () => { expect(logger.info).not.toHaveBeenCalled(); }); - it('warns and continues when ps fails', () => { + it('warns and continues when ls fails', () => { mockExecSync.mockImplementationOnce(() => { - throw new Error('docker not available'); + throw new Error('container not available'); }); cleanupOrphans(); // should not throw @@ -130,7 +154,11 @@ describe('cleanupOrphans', () => { }); it('continues stopping remaining containers when one stop fails', () => { - mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n'); + const lsOutput = JSON.stringify([ + { status: 'running', configuration: { id: 'nanoclaw-a-1' } }, + { status: 'running', configuration: { id: 'nanoclaw-b-2' } }, + ]); + mockExecSync.mockReturnValueOnce(lsOutput); // First stop fails mockExecSync.mockImplementationOnce(() => { throw new Error('already stopped'); diff --git a/src/container-runtime.ts b/src/container-runtime.ts index c4acdba..3e31361 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -9,7 +9,7 @@ import os from 'os'; import { logger } from './logger.js'; /** The container runtime binary name. */ -export const CONTAINER_RUNTIME_BIN = 'docker'; +export const CONTAINER_RUNTIME_BIN = 'container'; /** Hostname containers use to reach the host machine. */ export const CONTAINER_HOST_GATEWAY = 'host.docker.internal'; @@ -50,11 +50,8 @@ export function hostGatewayArgs(): string[] { } /** Returns CLI args for a readonly bind mount. */ -export function readonlyMountArgs( - hostPath: string, - containerPath: string, -): string[] { - return ['-v', `${hostPath}:${containerPath}:ro`]; +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. */ @@ -65,61 +62,62 @@ export function stopContainer(name: string): string { /** Ensure the container runtime is running, starting it if needed. */ export function ensureContainerRuntimeRunning(): void { try { - execSync(`${CONTAINER_RUNTIME_BIN} info`, { - stdio: 'pipe', - timeout: 10000, - }); + execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' }); logger.debug('Container runtime already running'); - } catch (err) { - logger.error({ err }, 'Failed to reach 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 Docker is installed and running ║', - ); - console.error( - '║ 2. Run: docker info ║', - ); - console.error( - '║ 3. Restart NanoClaw ║', - ); - console.error( - '╚════════════════════════════════════════════════════════════════╝\n', - ); - throw new Error('Container runtime is required but failed to start'); + } 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 Apple Container is installed ║', + ); + console.error( + '║ 2. Run: container system start ║', + ); + console.error( + '║ 3. 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} ps --filter name=nanoclaw- --format '{{.Names}}'`, - { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }, - ); - const orphans = output.trim().split('\n').filter(Boolean); + 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 */ - } + } catch { /* already stopped */ } } if (orphans.length > 0) { - logger.info( - { count: orphans.length, names: orphans }, - 'Stopped orphaned containers', - ); + logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); } } catch (err) { logger.warn({ err }, 'Failed to clean up orphaned containers');