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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
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('--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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,13 +22,15 @@ 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 {
|
||||||
|
logger.info('Starting container runtime...');
|
||||||
|
try {
|
||||||
|
execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 });
|
||||||
|
logger.info('Container runtime started');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, 'Failed to reach container runtime');
|
logger.error({ err }, 'Failed to start container runtime');
|
||||||
console.error(
|
console.error(
|
||||||
'\n╔════════════════════════════════════════════════════════════════╗',
|
'\n╔════════════════════════════════════════════════════════════════╗',
|
||||||
);
|
);
|
||||||
@@ -45,10 +44,10 @@ export function ensureContainerRuntimeRunning(): void {
|
|||||||
'║ Agents cannot run without a container runtime. To fix: ║',
|
'║ Agents cannot run without a container runtime. To fix: ║',
|
||||||
);
|
);
|
||||||
console.error(
|
console.error(
|
||||||
'║ 1. Ensure Docker is installed and running ║',
|
'║ 1. Ensure Apple Container is installed ║',
|
||||||
);
|
);
|
||||||
console.error(
|
console.error(
|
||||||
'║ 2. Run: docker info ║',
|
'║ 2. Run: container system start ║',
|
||||||
);
|
);
|
||||||
console.error(
|
console.error(
|
||||||
'║ 3. Restart NanoClaw ║',
|
'║ 3. Restart NanoClaw ║',
|
||||||
@@ -59,27 +58,26 @@ export function ensureContainerRuntimeRunning(): void {
|
|||||||
throw new Error('Container runtime is required but failed to start');
|
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user