skill/apple-container: switch runtime from Docker to Apple Container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-03-08 23:18:06 +02:00
parent cfabdd816b
commit 0161ba508a
5 changed files with 113 additions and 96 deletions

View File

@@ -7,7 +7,6 @@ FROM node:22-slim
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
chromium \ chromium \
fonts-liberation \ fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \ fonts-noto-color-emoji \
libgbm1 \ libgbm1 \
libnss3 \ libnss3 \
@@ -54,14 +53,14 @@ RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/
# Create entrypoint script # Create entrypoint script
# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it # Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
# Follow-up messages arrive via IPC files in /workspace/ipc/input/ # 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 # Set ownership to node user (non-root) for writable directories
RUN chown -R node:node /workspace && chmod 777 /home/node 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 # Set working directory to group workspace
WORKDIR /workspace/group WORKDIR /workspace/group

View File

@@ -8,7 +8,7 @@ cd "$SCRIPT_DIR"
IMAGE_NAME="nanoclaw-agent" IMAGE_NAME="nanoclaw-agent"
TAG="${1:-latest}" TAG="${1:-latest}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" 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}"

View File

@@ -74,17 +74,6 @@ function buildVolumeMounts(
readonly: true, readonly: true,
}); });
// Shadow .env so the agent cannot read secrets from the mounted project root.
// Secrets are passed via stdin instead (see readSecrets()).
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({
hostPath: '/dev/null',
containerPath: '/workspace/project/.env',
readonly: true,
});
}
// Main also gets its group folder as the working directory // Main also gets its group folder as the working directory
mounts.push({ mounts.push({
hostPath: groupDir, hostPath: groupDir,
@@ -215,17 +204,13 @@ function buildVolumeMounts(
* Secrets are never written to disk or mounted as files. * Secrets are never written to disk or mounted as files.
*/ */
function readSecrets(): Record<string, string> { function readSecrets(): Record<string, string> {
return readEnvFile([ return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
'CLAUDE_CODE_OAUTH_TOKEN',
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
]);
} }
function buildContainerArgs( function buildContainerArgs(
mounts: VolumeMount[], mounts: VolumeMount[],
containerName: string, containerName: string,
isMain: boolean,
): string[] { ): string[] {
const args: string[] = ['run', '-i', '--rm', '--name', containerName]; const args: string[] = ['run', '-i', '--rm', '--name', containerName];
@@ -238,7 +223,14 @@ function buildContainerArgs(
const hostUid = process.getuid?.(); const hostUid = process.getuid?.();
const hostGid = process.getgid?.(); const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { 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'); args.push('-e', 'HOME=/home/node');
} }
@@ -269,7 +261,7 @@ export async function runContainerAgent(
const mounts = buildVolumeMounts(group, input.isMain); const mounts = buildVolumeMounts(group, input.isMain);
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const containerName = `nanoclaw-${safeName}-${Date.now()}`; const containerName = `nanoclaw-${safeName}-${Date.now()}`;
const containerArgs = buildContainerArgs(mounts, containerName); const containerArgs = buildContainerArgs(mounts, containerName, input.isMain);
logger.debug( logger.debug(
{ {

View File

@@ -32,9 +32,12 @@ beforeEach(() => {
// --- Pure functions --- // --- Pure functions ---
describe('readonlyMountArgs', () => { 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'); 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(); ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(1); expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, { expect(mockExecSync).toHaveBeenCalledWith(
stdio: 'pipe', `${CONTAINER_RUNTIME_BIN} system status`,
timeout: 10000, { stdio: 'pipe' },
});
expect(logger.debug).toHaveBeenCalledWith(
'Container runtime already running',
); );
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(() => { 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( expect(() => ensureContainerRuntimeRunning()).toThrow(
@@ -79,17 +99,21 @@ describe('ensureContainerRuntimeRunning', () => {
// --- cleanupOrphans --- // --- cleanupOrphans ---
describe('cleanupOrphans', () => { describe('cleanupOrphans', () => {
it('stops orphaned nanoclaw containers', () => { it('stops orphaned nanoclaw containers from JSON output', () => {
// docker ps returns container names, one per line // Apple Container ls returns JSON
mockExecSync.mockReturnValueOnce( const lsOutput = JSON.stringify([
'nanoclaw-group1-111\nnanoclaw-group2-222\n', { 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 // stop calls succeed
mockExecSync.mockReturnValue(''); mockExecSync.mockReturnValue('');
cleanupOrphans(); cleanupOrphans();
// ps + 2 stop calls // ls + 2 stop calls (only running nanoclaw- containers)
expect(mockExecSync).toHaveBeenCalledTimes(3); expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(mockExecSync).toHaveBeenNthCalledWith( expect(mockExecSync).toHaveBeenNthCalledWith(
2, 2,
@@ -98,17 +122,17 @@ describe('cleanupOrphans', () => {
); );
expect(mockExecSync).toHaveBeenNthCalledWith( expect(mockExecSync).toHaveBeenNthCalledWith(
3, 3,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group2-222`, `${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`,
{ stdio: 'pipe' }, { stdio: 'pipe' },
); );
expect(logger.info).toHaveBeenCalledWith( 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', 'Stopped orphaned containers',
); );
}); });
it('does nothing when no orphans exist', () => { it('does nothing when no orphans exist', () => {
mockExecSync.mockReturnValueOnce(''); mockExecSync.mockReturnValueOnce('[]');
cleanupOrphans(); cleanupOrphans();
@@ -116,9 +140,9 @@ describe('cleanupOrphans', () => {
expect(logger.info).not.toHaveBeenCalled(); expect(logger.info).not.toHaveBeenCalled();
}); });
it('warns and continues when ps fails', () => { it('warns and continues when ls fails', () => {
mockExecSync.mockImplementationOnce(() => { mockExecSync.mockImplementationOnce(() => {
throw new Error('docker not available'); throw new Error('container not available');
}); });
cleanupOrphans(); // should not throw cleanupOrphans(); // should not throw
@@ -130,7 +154,11 @@ describe('cleanupOrphans', () => {
}); });
it('continues stopping remaining containers when one stop fails', () => { 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 // First stop fails
mockExecSync.mockImplementationOnce(() => { mockExecSync.mockImplementationOnce(() => {
throw new Error('already stopped'); throw new Error('already stopped');

View File

@@ -7,14 +7,11 @@ import { execSync } from 'child_process';
import { logger } from './logger.js'; import { logger } from './logger.js';
/** The container runtime binary name. */ /** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'docker'; export const CONTAINER_RUNTIME_BIN = 'container';
/** Returns CLI args for a readonly bind mount. */ /** Returns CLI args for a readonly bind mount. */
export function readonlyMountArgs( export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
hostPath: string, return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
containerPath: string,
): string[] {
return ['-v', `${hostPath}:${containerPath}:ro`];
} }
/** Returns the shell command to stop a container by name. */ /** Returns the shell command to stop a container by name. */
@@ -25,61 +22,62 @@ export function stopContainer(name: string): string {
/** Ensure the container runtime is running, starting it if needed. */ /** Ensure the container runtime is running, starting it if needed. */
export function ensureContainerRuntimeRunning(): void { export function ensureContainerRuntimeRunning(): void {
try { try {
execSync(`${CONTAINER_RUNTIME_BIN} info`, { execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' });
stdio: 'pipe',
timeout: 10000,
});
logger.debug('Container runtime already running'); logger.debug('Container runtime already running');
} catch (err) { } catch {
logger.error({ err }, 'Failed to reach container runtime'); logger.info('Starting container runtime...');
console.error( try {
'\n╔════════════════════════════════════════════════════════════════╗', execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 });
); logger.info('Container runtime started');
console.error( } catch (err) {
'║ FATAL: Container runtime failed to start ║', logger.error({ err }, 'Failed to start container runtime');
); console.error(
console.error( '\n╔════════════════════════════════════════════════════════════════╗',
'║ ║', );
); console.error(
console.error( '║ FATAL: Container runtime failed to start ║',
'║ Agents cannot run without a container runtime. To fix: ║', );
); console.error(
console.error( '║ ║',
'║ 1. Ensure Docker is installed and running ║', );
); console.error(
console.error( '║ Agents cannot run without a container runtime. To fix: ║',
'║ 2. Run: docker info ║', );
); console.error(
console.error( '║ 1. Ensure Apple Container is installed ║',
'║ 3. Restart NanoClaw ║', );
); console.error(
console.error( '║ 2. Run: container system start ║',
'╚════════════════════════════════════════════════════════════════╝\n', );
); console.error(
throw new Error('Container runtime is required but failed to start'); '║ 3. Restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Container runtime is required but failed to start');
}
} }
} }
/** Kill orphaned NanoClaw containers from previous runs. */ /** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void { export function cleanupOrphans(): void {
try { try {
const output = execSync( const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, {
`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, stdio: ['pipe', 'pipe', 'pipe'],
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }, encoding: 'utf-8',
); });
const orphans = output.trim().split('\n').filter(Boolean); 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) { for (const name of orphans) {
try { try {
execSync(stopContainer(name), { stdio: 'pipe' }); execSync(stopContainer(name), { stdio: 'pipe' });
} catch { } catch { /* already stopped */ }
/* already stopped */
}
} }
if (orphans.length > 0) { if (orphans.length > 0) {
logger.info( logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
{ count: orphans.length, names: orphans },
'Stopped orphaned containers',
);
} }
} catch (err) { } catch (err) {
logger.warn({ err }, 'Failed to clean up orphaned containers'); logger.warn({ err }, 'Failed to clean up orphaned containers');