feat: add /convert-to-apple-container skill, remove /convert-to-docker (#324)
Docker is now the default runtime. The /convert-to-apple-container skill uses the new skills engine format (manifest.yaml, modify/, intent files, tests/) to switch to Apple Container on macOS. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
175
.claude/skills/convert-to-apple-container/SKILL.md
Normal file
175
.claude/skills/convert-to-apple-container/SKILL.md
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: convert-to-apple-container
|
||||
description: Switch from Docker to Apple Container for macOS-native container isolation. Use when the user wants Apple Container instead of Docker, or is setting up on macOS and prefers the native runtime. Triggers on "apple container", "convert to apple container", "switch to apple container", or "use apple container".
|
||||
---
|
||||
|
||||
# Convert to Apple Container
|
||||
|
||||
This skill switches NanoClaw's container runtime from Docker to Apple Container (macOS-only). It uses the skills engine for deterministic code changes, then walks through verification.
|
||||
|
||||
**What this changes:**
|
||||
- Container runtime binary: `docker` → `container`
|
||||
- Mount syntax: `-v path:path:ro` → `--mount type=bind,source=...,target=...,readonly`
|
||||
- Startup check: `docker info` → `container system status` (with auto-start)
|
||||
- Orphan detection: `docker ps --filter` → `container ls --format json`
|
||||
- Build script default: `docker` → `container`
|
||||
|
||||
**What stays the same:**
|
||||
- Dockerfile (shared by both runtimes)
|
||||
- Container runner code (`src/container-runner.ts`)
|
||||
- Mount security/allowlist validation
|
||||
- All other functionality
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Verify Apple Container is installed:
|
||||
|
||||
```bash
|
||||
container --version && echo "Apple Container ready" || echo "Install Apple Container first"
|
||||
```
|
||||
|
||||
If not installed:
|
||||
- Download from https://github.com/apple/container/releases
|
||||
- Install the `.pkg` file
|
||||
- Verify: `container --version`
|
||||
|
||||
Apple Container requires macOS. It does not work on Linux.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Read `.nanoclaw/state.yaml`. If `convert-to-apple-container` is in `applied_skills`, skip to Phase 3 (Verify). The code changes are already in place.
|
||||
|
||||
### Check current runtime
|
||||
|
||||
```bash
|
||||
grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts
|
||||
```
|
||||
|
||||
If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 3.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
|
||||
|
||||
### Initialize skills system (if needed)
|
||||
|
||||
If `.nanoclaw/` directory doesn't exist yet:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts --init
|
||||
```
|
||||
|
||||
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
|
||||
|
||||
### Apply the skill
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts .claude/skills/convert-to-apple-container
|
||||
```
|
||||
|
||||
This deterministically:
|
||||
- Replaces `src/container-runtime.ts` with the Apple Container implementation
|
||||
- Replaces `src/container-runtime.test.ts` with Apple Container-specific tests
|
||||
- Updates `container/build.sh` to default to `container` runtime
|
||||
- Records the application in `.nanoclaw/state.yaml`
|
||||
|
||||
If the apply reports merge conflicts, read the intent files:
|
||||
- `modify/src/container-runtime.ts.intent.md` — what changed and invariants
|
||||
- `modify/container/build.sh.intent.md` — what changed for build script
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
### Ensure Apple Container runtime is running
|
||||
|
||||
```bash
|
||||
container system status || container system start
|
||||
```
|
||||
|
||||
### Build the container image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
### Test basic execution
|
||||
|
||||
```bash
|
||||
echo '{}' | container run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
|
||||
```
|
||||
|
||||
### Test readonly mounts
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/test-ro && echo "test" > /tmp/test-ro/file.txt
|
||||
container run --rm --entrypoint /bin/bash \
|
||||
--mount type=bind,source=/tmp/test-ro,target=/test,readonly \
|
||||
nanoclaw-agent:latest \
|
||||
-c "cat /test/file.txt && touch /test/new.txt 2>&1 || echo 'Write blocked (expected)'"
|
||||
rm -rf /tmp/test-ro
|
||||
```
|
||||
|
||||
Expected: Read succeeds, write fails with "Read-only file system".
|
||||
|
||||
### Test read-write mounts
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/test-rw
|
||||
container run --rm --entrypoint /bin/bash \
|
||||
-v /tmp/test-rw:/test \
|
||||
nanoclaw-agent:latest \
|
||||
-c "echo 'test write' > /test/new.txt && cat /test/new.txt"
|
||||
cat /tmp/test-rw/new.txt && rm -rf /tmp/test-rw
|
||||
```
|
||||
|
||||
Expected: Both operations succeed.
|
||||
|
||||
### Full integration test
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
Send a message via WhatsApp and verify the agent responds.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Apple Container not found:**
|
||||
- Download from https://github.com/apple/container/releases
|
||||
- Install the `.pkg` file
|
||||
- Verify: `container --version`
|
||||
|
||||
**Runtime won't start:**
|
||||
```bash
|
||||
container system start
|
||||
container system status
|
||||
```
|
||||
|
||||
**Image build fails:**
|
||||
```bash
|
||||
# Clean rebuild — Apple Container caches aggressively
|
||||
container builder stop && container builder rm && container builder start
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
**Container can't write to mounted directories:**
|
||||
Check directory permissions on the host. The container runs as uid 1000.
|
||||
|
||||
## Summary of Changed Files
|
||||
|
||||
| File | Type of Change |
|
||||
|------|----------------|
|
||||
| `src/container-runtime.ts` | Full replacement — Docker → Apple Container API |
|
||||
| `src/container-runtime.test.ts` | Full replacement — tests for Apple Container behavior |
|
||||
| `container/build.sh` | Default runtime: `docker` → `container` |
|
||||
13
.claude/skills/convert-to-apple-container/manifest.yaml
Normal file
13
.claude/skills/convert-to-apple-container/manifest.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
skill: convert-to-apple-container
|
||||
version: 1.0.0
|
||||
description: "Switch container runtime from Docker to Apple Container (macOS)"
|
||||
core_version: 0.1.0
|
||||
adds: []
|
||||
modifies:
|
||||
- src/container-runtime.ts
|
||||
- src/container-runtime.test.ts
|
||||
- container/build.sh
|
||||
structured: {}
|
||||
conflicts: []
|
||||
depends: []
|
||||
test: "npx vitest run src/container-runtime.test.ts"
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Build the NanoClaw agent container image
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
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}"
|
||||
|
||||
${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_RUNTIME} run -i ${IMAGE_NAME}:${TAG}"
|
||||
@@ -0,0 +1,17 @@
|
||||
# Intent: container/build.sh modifications
|
||||
|
||||
## What changed
|
||||
Changed the default container runtime from `docker` to `container` (Apple Container CLI).
|
||||
|
||||
## Key sections
|
||||
- `CONTAINER_RUNTIME` default: `docker` → `container`
|
||||
- All build/run commands use `${CONTAINER_RUNTIME}` variable (unchanged)
|
||||
|
||||
## Invariants
|
||||
- The `CONTAINER_RUNTIME` environment variable override still works
|
||||
- IMAGE_NAME and TAG logic unchanged
|
||||
- Build and test echo commands unchanged
|
||||
|
||||
## Must-keep
|
||||
- The `CONTAINER_RUNTIME` env var override pattern
|
||||
- The test command echo at the end
|
||||
@@ -0,0 +1,177 @@
|
||||
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 type=bind and 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', () => {
|
||||
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('auto-starts when system status fails', () => {
|
||||
// First call (system status) fails
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
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(
|
||||
'Container runtime is required but failed to start',
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- cleanupOrphans ---
|
||||
|
||||
describe('cleanupOrphans', () => {
|
||||
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();
|
||||
|
||||
// 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-group3-333`,
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group3-333'] },
|
||||
'Stopped orphaned containers',
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when no orphans exist', () => {
|
||||
mockExecSync.mockReturnValueOnce('[]');
|
||||
|
||||
cleanupOrphans();
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and continues when ls fails', () => {
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
throw new Error('container 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 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');
|
||||
});
|
||||
// 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Container runtime abstraction for NanoClaw.
|
||||
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
||||
*/
|
||||
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 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} 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Intent: src/container-runtime.ts modifications
|
||||
|
||||
## What changed
|
||||
Replaced Docker runtime with Apple Container runtime. This is a full file replacement — the exported API is identical, only the implementation differs.
|
||||
|
||||
## Key sections
|
||||
|
||||
### CONTAINER_RUNTIME_BIN
|
||||
- Changed: `'docker'` → `'container'` (the Apple Container CLI binary)
|
||||
|
||||
### readonlyMountArgs
|
||||
- Changed: Docker `-v host:container:ro` → Apple Container `--mount type=bind,source=...,target=...,readonly`
|
||||
|
||||
### ensureContainerRuntimeRunning
|
||||
- Changed: `docker info` → `container system status` for checking
|
||||
- Added: auto-start via `container system start` when not running (Apple Container supports this; Docker requires manual start)
|
||||
- Changed: error message references Apple Container instead of Docker
|
||||
|
||||
### cleanupOrphans
|
||||
- Changed: `docker ps --filter name=nanoclaw- --format '{{.Names}}'` → `container ls --format json` with JSON parsing
|
||||
- Apple Container returns JSON with `{ status, configuration: { id } }` structure
|
||||
|
||||
## Invariants
|
||||
- All five exports remain identical: `CONTAINER_RUNTIME_BIN`, `readonlyMountArgs`, `stopContainer`, `ensureContainerRuntimeRunning`, `cleanupOrphans`
|
||||
- `stopContainer` implementation is unchanged (`<bin> stop <name>`)
|
||||
- Logger usage pattern is unchanged
|
||||
- Error handling pattern is unchanged
|
||||
|
||||
## Must-keep
|
||||
- The exported function signatures (consumed by container-runner.ts and index.ts)
|
||||
- The error box-drawing output format
|
||||
- The orphan cleanup logic (find + stop pattern)
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
describe('convert-to-apple-container skill package', () => {
|
||||
const skillDir = path.resolve(__dirname, '..');
|
||||
|
||||
it('has a valid manifest', () => {
|
||||
const manifestPath = path.join(skillDir, 'manifest.yaml');
|
||||
expect(fs.existsSync(manifestPath)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(manifestPath, 'utf-8');
|
||||
expect(content).toContain('skill: convert-to-apple-container');
|
||||
expect(content).toContain('version: 1.0.0');
|
||||
expect(content).toContain('container-runtime.ts');
|
||||
expect(content).toContain('container/build.sh');
|
||||
});
|
||||
|
||||
it('has all modified files', () => {
|
||||
const runtimeFile = path.join(skillDir, 'modify', 'src', 'container-runtime.ts');
|
||||
expect(fs.existsSync(runtimeFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(runtimeFile, 'utf-8');
|
||||
expect(content).toContain("CONTAINER_RUNTIME_BIN = 'container'");
|
||||
expect(content).toContain('system status');
|
||||
expect(content).toContain('system start');
|
||||
expect(content).toContain('ls --format json');
|
||||
|
||||
const testFile = path.join(skillDir, 'modify', 'src', 'container-runtime.test.ts');
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
|
||||
const testContent = fs.readFileSync(testFile, 'utf-8');
|
||||
expect(testContent).toContain('system status');
|
||||
expect(testContent).toContain('--mount');
|
||||
});
|
||||
|
||||
it('has intent files for modified sources', () => {
|
||||
const runtimeIntent = path.join(skillDir, 'modify', 'src', 'container-runtime.ts.intent.md');
|
||||
expect(fs.existsSync(runtimeIntent)).toBe(true);
|
||||
|
||||
const buildIntent = path.join(skillDir, 'modify', 'container', 'build.sh.intent.md');
|
||||
expect(fs.existsSync(buildIntent)).toBe(true);
|
||||
});
|
||||
|
||||
it('has build.sh with Apple Container default', () => {
|
||||
const buildFile = path.join(skillDir, 'modify', 'container', 'build.sh');
|
||||
expect(fs.existsSync(buildFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(buildFile, 'utf-8');
|
||||
expect(content).toContain('CONTAINER_RUNTIME:-container');
|
||||
expect(content).not.toContain('CONTAINER_RUNTIME:-docker');
|
||||
});
|
||||
|
||||
it('uses Apple Container API patterns (not Docker)', () => {
|
||||
const runtimeFile = path.join(skillDir, 'modify', 'src', 'container-runtime.ts');
|
||||
const content = fs.readFileSync(runtimeFile, 'utf-8');
|
||||
|
||||
// Apple Container patterns
|
||||
expect(content).toContain('system status');
|
||||
expect(content).toContain('system start');
|
||||
expect(content).toContain('ls --format json');
|
||||
expect(content).toContain('type=bind,source=');
|
||||
|
||||
// Should NOT contain Docker patterns
|
||||
expect(content).not.toContain('docker info');
|
||||
expect(content).not.toContain("'-v'");
|
||||
expect(content).not.toContain('--filter name=');
|
||||
});
|
||||
});
|
||||
@@ -1,363 +0,0 @@
|
||||
---
|
||||
name: convert-to-docker
|
||||
description: Convert NanoClaw from Apple Container to Docker for cross-platform support. Use when user wants to run on Linux, switch to Docker, enable cross-platform deployment, or migrate away from Apple Container. Triggers on "docker", "linux support", "convert to docker", "cross-platform", or "replace apple container".
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# Convert to Docker
|
||||
|
||||
This skill migrates NanoClaw from Apple Container (macOS-only) to Docker for cross-platform support (macOS and Linux).
|
||||
|
||||
**What this changes:**
|
||||
- Container runtime: Apple Container → Docker
|
||||
- Mount syntax: `--mount type=bind,...,readonly` → `-v path:path:ro`
|
||||
- Startup check: `container system status` → `docker info`
|
||||
- Build commands: `container build/run` → `docker build/run`
|
||||
|
||||
**What stays the same:**
|
||||
- Dockerfile (already Docker-compatible)
|
||||
- Agent runner code
|
||||
- Mount security/allowlist validation
|
||||
- All other functionality
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Verify Docker is installed before starting:
|
||||
|
||||
```bash
|
||||
docker --version && docker info >/dev/null 2>&1 && echo "Docker ready" || echo "Install Docker first"
|
||||
```
|
||||
|
||||
If Docker is not installed:
|
||||
- **macOS**: Download from https://docker.com/products/docker-desktop
|
||||
- **Linux**: `curl -fsSL https://get.docker.com | sh && sudo systemctl start docker`
|
||||
|
||||
## 1. Update Container Runner
|
||||
|
||||
Edit `src/container-runner.ts`:
|
||||
|
||||
### 1a. Update module comment (around line 3)
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
* Spawns agent execution in Apple Container and handles IPC
|
||||
|
||||
// After:
|
||||
* Spawns agent execution in Docker container and handles IPC
|
||||
```
|
||||
|
||||
### 1b. Update directory mount comment (around line 88)
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
// Apple Container only supports directory mounts, not file mounts
|
||||
|
||||
// After:
|
||||
// Docker bind mounts work with both files and directories
|
||||
```
|
||||
|
||||
### 1c. Update env workaround comment (around line 120)
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
// Environment file directory (workaround for Apple Container -i env var bug)
|
||||
|
||||
// After:
|
||||
// Environment file directory (keeps credentials out of process listings)
|
||||
```
|
||||
|
||||
### 1d. Update buildContainerArgs function
|
||||
|
||||
Replace the entire function with Docker mount syntax:
|
||||
|
||||
```typescript
|
||||
function buildContainerArgs(mounts: VolumeMount[]): string[] {
|
||||
const args: string[] = ['run', '-i', '--rm'];
|
||||
|
||||
// Docker: -v with :ro suffix for readonly
|
||||
for (const mount of mounts) {
|
||||
if (mount.readonly) {
|
||||
args.push('-v', `${mount.hostPath}:${mount.containerPath}:ro`);
|
||||
} else {
|
||||
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
args.push(CONTAINER_IMAGE);
|
||||
|
||||
return args;
|
||||
}
|
||||
```
|
||||
|
||||
### 1e. Update spawn command (around line 204)
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
const container = spawn('container', containerArgs, {
|
||||
|
||||
// After:
|
||||
const container = spawn('docker', containerArgs, {
|
||||
```
|
||||
|
||||
## 2. Update Startup Check
|
||||
|
||||
Edit `src/index.ts`:
|
||||
|
||||
### 2a. Replace the container system check function
|
||||
|
||||
Find `ensureContainerSystemRunning()` and replace entirely with:
|
||||
|
||||
```typescript
|
||||
function ensureDockerRunning(): void {
|
||||
try {
|
||||
execSync('docker info', { stdio: 'pipe', timeout: 10000 });
|
||||
logger.debug('Docker daemon is running');
|
||||
} catch {
|
||||
logger.error('Docker daemon is not running');
|
||||
console.error('\n╔════════════════════════════════════════════════════════════════╗');
|
||||
console.error('║ FATAL: Docker is not running ║');
|
||||
console.error('║ ║');
|
||||
console.error('║ Agents cannot run without Docker. To fix: ║');
|
||||
console.error('║ macOS: Start Docker Desktop ║');
|
||||
console.error('║ Linux: sudo systemctl start docker ║');
|
||||
console.error('║ ║');
|
||||
console.error('║ Install from: https://docker.com/products/docker-desktop ║');
|
||||
console.error('╚════════════════════════════════════════════════════════════════╝\n');
|
||||
throw new Error('Docker is required but not running');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2b. Update the function call in main()
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
ensureContainerSystemRunning();
|
||||
|
||||
// After:
|
||||
ensureDockerRunning();
|
||||
```
|
||||
|
||||
## 3. Update Build Script
|
||||
|
||||
Edit `container/build.sh`:
|
||||
|
||||
### 3a. Update build command (around line 15-16)
|
||||
|
||||
```bash
|
||||
# Before:
|
||||
# Build with Apple Container
|
||||
container build -t "${IMAGE_NAME}:${TAG}" .
|
||||
|
||||
# After:
|
||||
# Build with Docker
|
||||
docker build -t "${IMAGE_NAME}:${TAG}" .
|
||||
```
|
||||
|
||||
### 3b. Update test command (around line 23)
|
||||
|
||||
```bash
|
||||
# Before:
|
||||
echo " echo '{...}' | container run -i ${IMAGE_NAME}:${TAG}"
|
||||
|
||||
# After:
|
||||
echo " echo '{...}' | docker run -i ${IMAGE_NAME}:${TAG}"
|
||||
```
|
||||
|
||||
## 4. Update Documentation
|
||||
|
||||
Update references in documentation files:
|
||||
|
||||
| File | Find | Replace |
|
||||
|------|------|---------|
|
||||
| `CLAUDE.md` | "Apple Container (Linux VMs)" | "Docker containers" |
|
||||
| `README.md` | "Apple containers" | "Docker containers" |
|
||||
| `README.md` | "Apple Container" | "Docker" |
|
||||
| `README.md` | Requirements section | Update to show Docker instead |
|
||||
| `docs/REQUIREMENTS.md` | "Apple Container" | "Docker" |
|
||||
| `docs/SPEC.md` | "APPLE CONTAINER" | "DOCKER CONTAINER" |
|
||||
| `docs/SPEC.md` | All Apple Container references | Docker equivalents |
|
||||
|
||||
### Key README.md updates:
|
||||
|
||||
**Requirements section:**
|
||||
```markdown
|
||||
## Requirements
|
||||
|
||||
- macOS or Linux
|
||||
- Node.js 20+
|
||||
- [Claude Code](https://claude.ai/download)
|
||||
- [Docker](https://docker.com/products/docker-desktop)
|
||||
```
|
||||
|
||||
**FAQ - "Why Docker?":**
|
||||
```markdown
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS and Linux), a large ecosystem, and mature tooling. Docker Desktop on macOS uses a lightweight Linux VM similar to other container solutions.
|
||||
```
|
||||
|
||||
**FAQ - "Can I run this on Linux?":**
|
||||
```markdown
|
||||
**Can I run this on Linux?**
|
||||
|
||||
Yes. NanoClaw uses Docker, which works on both macOS and Linux. Just install Docker and run `/setup`.
|
||||
```
|
||||
|
||||
## 5. Update Skills
|
||||
|
||||
### 5a. Update `.claude/skills/setup/SKILL.md`
|
||||
|
||||
Replace Section 2 "Install Apple Container" with Docker installation:
|
||||
|
||||
```markdown
|
||||
## 2. Install Docker
|
||||
|
||||
Check if Docker is installed and running:
|
||||
|
||||
\`\`\`bash
|
||||
docker --version && docker info >/dev/null 2>&1 && echo "Docker is running" || echo "Docker not running or not installed"
|
||||
\`\`\`
|
||||
|
||||
If not installed or not running, tell the user:
|
||||
> Docker is required for running agents in isolated environments.
|
||||
>
|
||||
> **macOS:**
|
||||
> 1. Download Docker Desktop from https://docker.com/products/docker-desktop
|
||||
> 2. Install and start Docker Desktop
|
||||
> 3. Wait for the whale icon in the menu bar to stop animating
|
||||
>
|
||||
> **Linux:**
|
||||
> \`\`\`bash
|
||||
> curl -fsSL https://get.docker.com | sh
|
||||
> sudo systemctl start docker
|
||||
> sudo usermod -aG docker $USER # Then log out and back in
|
||||
> \`\`\`
|
||||
>
|
||||
> Let me know when you've completed these steps.
|
||||
|
||||
Wait for user confirmation, then verify:
|
||||
|
||||
\`\`\`bash
|
||||
docker run --rm hello-world
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
Update build verification:
|
||||
```markdown
|
||||
Verify the build succeeded:
|
||||
|
||||
\`\`\`bash
|
||||
docker images | grep nanoclaw-agent
|
||||
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK" || echo "Container build failed"
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
Update troubleshooting section to reference Docker commands.
|
||||
|
||||
### 5b. Update `.claude/skills/debug/SKILL.md`
|
||||
|
||||
Replace all `container` commands with `docker` equivalents:
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `container run` | `docker run` |
|
||||
| `container system status` | `docker info` |
|
||||
| `container builder prune` | `docker builder prune` |
|
||||
| `container images` | `docker images` |
|
||||
| `--mount type=bind,source=...,readonly` | `-v ...:ro` |
|
||||
|
||||
Update the architecture diagram header:
|
||||
```
|
||||
Host (macOS/Linux) Container (Docker)
|
||||
```
|
||||
|
||||
## 6. Build and Verify
|
||||
|
||||
After making all changes:
|
||||
|
||||
```bash
|
||||
# Compile TypeScript
|
||||
npm run build
|
||||
|
||||
# Build Docker image
|
||||
./container/build.sh
|
||||
|
||||
# Verify image exists
|
||||
docker images | grep nanoclaw-agent
|
||||
```
|
||||
|
||||
## 7. Test the Migration
|
||||
|
||||
### 7a. Test basic container execution
|
||||
|
||||
```bash
|
||||
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
|
||||
```
|
||||
|
||||
### 7b. Test readonly mounts
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/test-ro && echo "test" > /tmp/test-ro/file.txt
|
||||
docker run --rm --entrypoint /bin/bash -v /tmp/test-ro:/test:ro nanoclaw-agent:latest \
|
||||
-c "cat /test/file.txt && touch /test/new.txt 2>&1 || echo 'Write blocked (expected)'"
|
||||
rm -rf /tmp/test-ro
|
||||
```
|
||||
|
||||
Expected: Read succeeds, write fails with "Read-only file system".
|
||||
|
||||
### 7c. Test read-write mounts
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/test-rw
|
||||
docker run --rm --entrypoint /bin/bash -v /tmp/test-rw:/test nanoclaw-agent:latest \
|
||||
-c "echo 'test write' > /test/new.txt && cat /test/new.txt"
|
||||
cat /tmp/test-rw/new.txt && rm -rf /tmp/test-rw
|
||||
```
|
||||
|
||||
Expected: Both operations succeed.
|
||||
|
||||
### 7d. Full integration test
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# Send @AssistantName hello via WhatsApp
|
||||
# Verify response received
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Docker not running:**
|
||||
- macOS: Start Docker Desktop from Applications
|
||||
- Linux: `sudo systemctl start docker`
|
||||
- Verify: `docker info`
|
||||
|
||||
**Permission denied on Docker socket (Linux):**
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in
|
||||
```
|
||||
|
||||
**Image build fails:**
|
||||
```bash
|
||||
# Clean rebuild
|
||||
docker builder prune -af
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
**Container can't write to mounted directories:**
|
||||
Check directory permissions on the host. The container runs as uid 1000.
|
||||
|
||||
## Summary of Changed Files
|
||||
|
||||
| File | Type of Change |
|
||||
|------|----------------|
|
||||
| `src/container-runner.ts` | Mount syntax, spawn command, comments |
|
||||
| `src/index.ts` | Startup check function |
|
||||
| `container/build.sh` | Build and run commands |
|
||||
| `CLAUDE.md` | Quick context |
|
||||
| `README.md` | Requirements, FAQ |
|
||||
| `docs/REQUIREMENTS.md` | Architecture references |
|
||||
| `docs/SPEC.md` | Architecture diagram, tech stack |
|
||||
| `.claude/skills/setup/SKILL.md` | Installation instructions |
|
||||
| `.claude/skills/debug/SKILL.md` | Debug commands |
|
||||
@@ -57,7 +57,21 @@ Check the preflight results for `APPLE_CONTAINER` and `DOCKER`.
|
||||
- macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop
|
||||
- Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership.
|
||||
|
||||
### 3b. Build and test
|
||||
### 3b. Apple Container conversion gate (if needed)
|
||||
|
||||
**If the chosen runtime is Apple Container**, you MUST check whether the source code has already been converted from Docker to Apple Container. Do NOT skip this step. Run:
|
||||
|
||||
```bash
|
||||
grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "ALREADY_CONVERTED" || echo "NEEDS_CONVERSION"
|
||||
```
|
||||
|
||||
**If NEEDS_CONVERSION**, the source code still uses Docker as the runtime. You MUST run the `/convert-to-apple-container` skill NOW, before proceeding to the build step.
|
||||
|
||||
**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 3c.
|
||||
|
||||
**If the chosen runtime is Docker**, no conversion is needed — Docker is the default. Continue to 3c.
|
||||
|
||||
### 3c. Build and test
|
||||
|
||||
Run `./.claude/skills/setup/scripts/03-setup-container.sh --runtime <chosen>` and parse the status block.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user