revert: roll back DB merge, keep useful improvements
Fully revert DB merge patch (WAL, composite PK, SERVICE_ID in IPC/ router_state/sessions). Return to proven split-DB architecture. Keep: - Docker/Apple Container cleanup - Usage dashboard CLAUDE_CODE_OAUTH_TOKEN fallback - Codex runner heartbeat - Agent-runner stderr timeout reset
This commit is contained in:
@@ -1,175 +0,0 @@
|
||||
---
|
||||
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`
|
||||
- Dockerfile entrypoint: `.env` shadowing via `mount --bind` inside the container (Apple Container only supports directory mounts, not file mounts like Docker's `/dev/null` overlay)
|
||||
- Container runner: main-group containers start as root for `mount --bind`, then drop privileges via `setpriv`
|
||||
|
||||
**What stays the same:**
|
||||
- Mount security/allowlist validation
|
||||
- All exported interfaces and IPC protocol
|
||||
- Non-main container behavior (still uses `--user` flag)
|
||||
- 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
|
||||
|
||||
```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
|
||||
|
||||
### Ensure upstream remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/apple-container
|
||||
git merge upstream/skill/apple-container
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/container-runtime.ts` — Apple Container implementation (replaces Docker)
|
||||
- `src/container-runtime.test.ts` — Apple Container-specific tests
|
||||
- `src/container-runner.ts` — .env shadow mount fix and privilege dropping
|
||||
- `runners/Dockerfile` — entrypoint that shadows .env via `mount --bind`
|
||||
- `runners/build.sh` — default runtime set to `container`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### 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
|
||||
./runners/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
|
||||
./runners/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 |
|
||||
| `src/container-runner.ts` | .env shadow mount removed, main containers start as root with privilege drop |
|
||||
| `runners/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop |
|
||||
| `runners/build.sh` | Default runtime: `docker` → `container` |
|
||||
22
CLAUDE.md
22
CLAUDE.md
@@ -57,7 +57,7 @@ Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/`
|
||||
- `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code`
|
||||
- `nanoclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex`
|
||||
- Both share the same codebase (`dist/index.js`), differentiated by env vars
|
||||
- Currently separate dirs (`store/` vs `store-codex/`), but DB supports shared access:
|
||||
- Unified dirs (`store/`, `groups/`, `data/` shared by both services):
|
||||
- `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`)
|
||||
- `sessions`: composite PK `(group_folder, agent_type)`
|
||||
- `registered_groups`: filtered by `agent_type` on load
|
||||
@@ -65,14 +65,18 @@ Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/`
|
||||
|
||||
## Debugging Paths (Server: clone-ej@100.64.185.108)
|
||||
|
||||
| 항목 | Claude (`nanoclaw`) | Codex (`nanoclaw-codex`) |
|
||||
|------|---------------------|--------------------------|
|
||||
| 서비스 로그 | `journalctl --user -u nanoclaw -f` | `journalctl --user -u nanoclaw-codex -f` |
|
||||
| 앱 로그 | `logs/nanoclaw.log` | `logs/nanoclaw-codex.log` |
|
||||
| 그룹별 로그 | `groups/{name}/logs/` | `groups-codex/{name}/logs/` |
|
||||
| DB | `store/messages.db` | `store-codex/messages.db` |
|
||||
| 세션 | `data/sessions/{name}/.claude/` | `data-codex/sessions/{name}/.codex/` |
|
||||
| 글로벌 설정 | `groups/global/CLAUDE.md` | `~/.codex/AGENTS.md` |
|
||||
Unified DB + directories (both services share `store/`, `groups/`, `data/`):
|
||||
|
||||
| 항목 | 경로 |
|
||||
|------|------|
|
||||
| **DB** | `store/messages.db` (공유, WAL 모드) |
|
||||
| 서비스 로그 (Claude) | `journalctl --user -u nanoclaw -f` 또는 `logs/nanoclaw.log` |
|
||||
| 서비스 로그 (Codex) | `journalctl --user -u nanoclaw-codex -f` 또는 `logs/nanoclaw-codex.log` |
|
||||
| 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) |
|
||||
| Claude 세션 | `data/sessions/{name}/.claude/` |
|
||||
| Codex 세션 | `data/sessions/{name}/.codex/` |
|
||||
| Claude 글로벌 설정 | `groups/global/CLAUDE.md` |
|
||||
| Codex 글로벌 설정 | `~/.codex/AGENTS.md` |
|
||||
|
||||
## Codex SDK
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# Apple Container Networking Setup (macOS 26)
|
||||
|
||||
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
|
||||
|
||||
## Quick Setup
|
||||
|
||||
Run these two commands (requires `sudo`):
|
||||
|
||||
```bash
|
||||
# 1. Enable IP forwarding so the host routes container traffic
|
||||
sudo sysctl -w net.inet.ip.forwarding=1
|
||||
|
||||
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
|
||||
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
|
||||
|
||||
## Making It Persistent
|
||||
|
||||
These settings reset on reboot. To make them permanent:
|
||||
|
||||
**IP Forwarding** — add to `/etc/sysctl.conf`:
|
||||
```
|
||||
net.inet.ip.forwarding=1
|
||||
```
|
||||
|
||||
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
|
||||
```
|
||||
nat on en0 from 192.168.64.0/24 to any -> (en0)
|
||||
```
|
||||
|
||||
Then reload: `sudo pfctl -f /etc/pf.conf`
|
||||
|
||||
## IPv6 DNS Issue
|
||||
|
||||
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
|
||||
|
||||
The container image and runner are configured to prefer IPv4 via:
|
||||
```
|
||||
NODE_OPTIONS=--dns-result-order=ipv4first
|
||||
```
|
||||
|
||||
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Check IP forwarding is enabled
|
||||
sysctl net.inet.ip.forwarding
|
||||
# Expected: net.inet.ip.forwarding: 1
|
||||
|
||||
# Test container internet access
|
||||
container run --rm --entrypoint curl nanoclaw-agent:latest \
|
||||
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
|
||||
# Expected: 404
|
||||
|
||||
# Check bridge interface (only exists when a container is running)
|
||||
ifconfig bridge100
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
|
||||
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
|
||||
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
|
||||
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Container VM (192.168.64.x)
|
||||
│
|
||||
├── eth0 → gateway 192.168.64.1
|
||||
│
|
||||
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
|
||||
│
|
||||
├── IP forwarding (sysctl) routes packets from bridge100 → en0
|
||||
│
|
||||
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
|
||||
│
|
||||
en0 (your WiFi/Ethernet) → Internet
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
|
||||
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building
|
||||
@@ -174,13 +174,19 @@ async function executeTurn(
|
||||
): Promise<{ result: string; error?: string }> {
|
||||
const ac = new AbortController();
|
||||
|
||||
// Poll close sentinel during turn
|
||||
// Poll close sentinel + heartbeat during turn
|
||||
let turnSeconds = 0;
|
||||
const sentinel = setInterval(() => {
|
||||
if (shouldClose()) {
|
||||
log('Close sentinel detected during turn, aborting');
|
||||
ac.abort();
|
||||
return;
|
||||
}
|
||||
}, IPC_POLL_MS);
|
||||
turnSeconds += 5;
|
||||
if (turnSeconds % 60 === 0) {
|
||||
log(`Turn in progress... (${Math.round(turnSeconds / 60)}min)`);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
const turn = await thread.run(input, { signal: ac.signal });
|
||||
|
||||
@@ -13,6 +13,8 @@ vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||
IDLE_TIMEOUT: 1800000, // 30min
|
||||
SERVICE_ID: 'claude',
|
||||
SERVICE_AGENT_TYPE: 'claude-code',
|
||||
TIMEZONE: 'America/Los_Angeles',
|
||||
}));
|
||||
|
||||
|
||||
@@ -424,22 +424,6 @@ export async function runAgentProcess(
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
const lines = chunk.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line) logger.debug({ agent: group.folder }, line);
|
||||
}
|
||||
if (stderrTruncated) return;
|
||||
const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length;
|
||||
if (chunk.length > remaining) {
|
||||
stderr += chunk.slice(0, remaining);
|
||||
stderrTruncated = true;
|
||||
} else {
|
||||
stderr += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
let hadStreamingOutput = false;
|
||||
const configTimeout = group.agentConfig?.timeout || AGENT_TIMEOUT;
|
||||
@@ -465,6 +449,29 @@ export async function runAgentProcess(
|
||||
timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||
};
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
const lines = chunk.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
if (line.includes('Turn in progress')) {
|
||||
logger.info({ group: group.name }, line.replace(/^\[.*?\]\s*/, ''));
|
||||
} else {
|
||||
logger.debug({ agent: group.folder }, line);
|
||||
}
|
||||
}
|
||||
// Stderr activity means agent is alive — reset timeout
|
||||
resetTimeout();
|
||||
if (stderrTruncated) return;
|
||||
const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length;
|
||||
if (chunk.length > remaining) {
|
||||
stderr += chunk.slice(0, remaining);
|
||||
stderrTruncated = true;
|
||||
} else {
|
||||
stderr += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
21
src/index.ts
21
src/index.ts
@@ -42,6 +42,7 @@ import {
|
||||
storeChatMetadata,
|
||||
storeMessage,
|
||||
} from './db.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { startIpcWatcher } from './ipc.js';
|
||||
@@ -552,13 +553,19 @@ interface CodexRateLimit {
|
||||
|
||||
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
||||
try {
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const credsPath = path.join(configDir, '.credentials.json');
|
||||
if (!fs.existsSync(credsPath)) return null;
|
||||
|
||||
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
|
||||
const token = creds?.claudeAiOauth?.accessToken;
|
||||
const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']);
|
||||
let token =
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||
envToken.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||
'';
|
||||
if (!token) {
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const credsPath = path.join(configDir, '.credentials.json');
|
||||
if (!fs.existsSync(credsPath)) return null;
|
||||
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
|
||||
token = creds?.claudeAiOauth?.accessToken || '';
|
||||
}
|
||||
if (!token) return null;
|
||||
|
||||
const res = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
||||
|
||||
Reference in New Issue
Block a user