diff --git a/.gitignore b/.gitignore
index e259fbf..f6ab2f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,10 +6,13 @@ dist/
# Local data & auth
store/
+store-*/
data/
+data-*/
logs/
# Groups - only track base structure and specific CLAUDE.md files
+groups-*/
groups/*
!groups/main/
!groups/global/
diff --git a/container/Dockerfile b/container/Dockerfile
deleted file mode 100644
index 65763df..0000000
--- a/container/Dockerfile
+++ /dev/null
@@ -1,68 +0,0 @@
-# NanoClaw Agent Container
-# Runs Claude Agent SDK in isolated Linux VM with browser automation
-
-FROM node:22-slim
-
-# Install system dependencies for Chromium
-RUN apt-get update && apt-get install -y \
- chromium \
- fonts-liberation \
- fonts-noto-color-emoji \
- libgbm1 \
- libnss3 \
- libatk-bridge2.0-0 \
- libgtk-3-0 \
- libx11-xcb1 \
- libxcomposite1 \
- libxdamage1 \
- libxrandr2 \
- libasound2 \
- libpangocairo-1.0-0 \
- libcups2 \
- libdrm2 \
- libxshmfence1 \
- curl \
- git \
- && rm -rf /var/lib/apt/lists/*
-
-# Set Chromium path for agent-browser
-ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
-ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
-
-# Install agent-browser and claude-code globally
-RUN npm install -g agent-browser @anthropic-ai/claude-code
-
-# Create app directory
-WORKDIR /app
-
-# Copy package files first for better caching
-COPY agent-runner/package*.json ./
-
-# Install dependencies
-RUN npm install
-
-# Copy source code
-COPY agent-runner/ ./
-
-# Build TypeScript
-RUN npm run build
-
-# Create workspace directories
-RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
-
-# Create entrypoint script
-# 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/
-# 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
-
-# Set working directory to group workspace
-WORKDIR /workspace/group
-
-# Entry point reads JSON from stdin, outputs JSON to stdout
-ENTRYPOINT ["/app/entrypoint.sh"]
diff --git a/container/Dockerfile.codex b/container/Dockerfile.codex
deleted file mode 100644
index b921f29..0000000
--- a/container/Dockerfile.codex
+++ /dev/null
@@ -1,43 +0,0 @@
-# NanoClaw Codex Agent Container
-# Runs Codex CLI in isolated Linux VM
-
-FROM node:22-slim
-
-# Install system dependencies
-RUN apt-get update && apt-get install -y \
- curl \
- git \
- && rm -rf /var/lib/apt/lists/*
-
-# Install Codex CLI globally
-RUN npm install -g @openai/codex
-
-# Create app directory
-WORKDIR /app
-
-# Copy package files first for better caching
-COPY codex-runner/package*.json ./
-
-# Install dependencies
-RUN npm install
-
-# Copy source code
-COPY codex-runner/ ./
-
-# Build TypeScript
-RUN npm run build
-
-# Create workspace directories
-RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
-
-# Create entrypoint script — use pre-built dist/ from image,
-# but if /app/src is mounted with newer source, recompile on the fly.
-RUN printf '#!/bin/bash\nset -e\n\n# Use pre-built dist if available, recompile only if source was mounted\nif [ -f /app/dist/index.js ]; then\n RUNNER_DIR=/app/dist\nelse\n cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\n ln -s /app/node_modules /tmp/dist/node_modules\n RUNNER_DIR=/tmp/dist\nfi\n\n# Capture stdin to temp file\ncat > /tmp/input.json\n\n# Drop privileges if running as root\nif [ "$(id -u)" = "0" ] && [ -n "$RUN_UID" ]; then\n chown "$RUN_UID:$RUN_GID" /tmp/input.json\n exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --clear-groups -- node "$RUNNER_DIR/index.js" < /tmp/input.json\nfi\n\nexec node "$RUNNER_DIR/index.js" < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
-
-# Set ownership to node user
-RUN chown -R node:node /workspace && chmod 777 /home/node
-
-# Set working directory to group workspace
-WORKDIR /workspace/group
-
-ENTRYPOINT ["/app/entrypoint.sh"]
diff --git a/launchd/com.nanoclaw-codex.plist b/launchd/com.nanoclaw-codex.plist
new file mode 100644
index 0000000..8abc9a7
--- /dev/null
+++ b/launchd/com.nanoclaw-codex.plist
@@ -0,0 +1,38 @@
+
+
+
+
+ Label
+ com.nanoclaw-codex
+ ProgramArguments
+
+ {{NODE_PATH}}
+ {{PROJECT_ROOT}}/dist/index.js
+
+ WorkingDirectory
+ {{PROJECT_ROOT}}
+ RunAtLoad
+
+ KeepAlive
+
+ EnvironmentVariables
+
+ PATH
+ {{HOME}}/.npm-global/bin:{{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin
+ HOME
+ {{HOME}}
+ ASSISTANT_NAME
+ Andy-Codex
+ NANOCLAW_STORE_DIR
+ {{PROJECT_ROOT}}/store-codex
+ NANOCLAW_GROUPS_DIR
+ {{PROJECT_ROOT}}/groups-codex
+ NANOCLAW_DATA_DIR
+ {{PROJECT_ROOT}}/data-codex
+
+ StandardOutPath
+ {{PROJECT_ROOT}}/logs/nanoclaw-codex.log
+ StandardErrorPath
+ {{PROJECT_ROOT}}/logs/nanoclaw-codex.error.log
+
+
diff --git a/src/channels/discord.ts b/src/channels/discord.ts
index 7e27d85..52c9286 100644
--- a/src/channels/discord.ts
+++ b/src/channels/discord.ts
@@ -250,9 +250,6 @@ export class DiscordChannel implements Channel {
const group = this.opts.registeredGroups()[jid];
if (!group) return false;
const groupType = group.agentType || 'claude-code';
- // 'both' channels are owned by the claude-code bot (primary handler for
- // message storage and typing). The codex bot skips them to avoid duplicates.
- if (groupType === 'both') return this.agentTypeFilter === 'claude-code';
return groupType === this.agentTypeFilter;
}
diff --git a/src/config.ts b/src/config.ts
index 527b705..6c93b8b 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -3,9 +3,6 @@ import path from 'path';
import { readEnvFile } from './env.js';
-// Read config values from .env (falls back to process.env).
-// Secrets (API keys, tokens) are NOT read here — they are loaded only
-// by the credential proxy (credential-proxy.ts), never exposed to containers.
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
export const ASSISTANT_NAME =
@@ -16,31 +13,19 @@ export const ASSISTANT_HAS_OWN_NUMBER =
export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000;
-// Absolute paths needed for container mounts
const PROJECT_ROOT = process.cwd();
const HOME_DIR = process.env.HOME || os.homedir();
-// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
-export const MOUNT_ALLOWLIST_PATH = path.join(
- HOME_DIR,
- '.config',
- 'nanoclaw',
- 'mount-allowlist.json',
-);
export const SENDER_ALLOWLIST_PATH = path.join(
HOME_DIR,
'.config',
'nanoclaw',
'sender-allowlist.json',
);
-export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
-export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
-export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
+export const STORE_DIR = path.resolve(process.env.NANOCLAW_STORE_DIR || path.join(PROJECT_ROOT, 'store'));
+export const GROUPS_DIR = path.resolve(process.env.NANOCLAW_GROUPS_DIR || path.join(PROJECT_ROOT, 'groups'));
+export const DATA_DIR = path.resolve(process.env.NANOCLAW_DATA_DIR || path.join(PROJECT_ROOT, 'data'));
-export const CONTAINER_IMAGE =
- process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
-export const CODEX_CONTAINER_IMAGE =
- process.env.CODEX_CONTAINER_IMAGE || 'nanoclaw-codex-agent:latest';
export const CONTAINER_TIMEOUT = parseInt(
process.env.CONTAINER_TIMEOUT || '1800000',
10,
@@ -49,10 +34,6 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
10,
); // 10MB default
-export const CREDENTIAL_PROXY_PORT = parseInt(
- process.env.CREDENTIAL_PROXY_PORT || '3001',
- 10,
-);
export const IPC_POLL_INTERVAL = 1000;
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
export const MAX_CONCURRENT_CONTAINERS = Math.max(
diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts
index 8f8d7a3..0b993cd 100644
--- a/src/container-runner.test.ts
+++ b/src/container-runner.test.ts
@@ -8,10 +8,8 @@ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
// Mock config
vi.mock('./config.js', () => ({
- CONTAINER_IMAGE: 'nanoclaw-agent:latest',
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
CONTAINER_TIMEOUT: 1800000, // 30min
- CREDENTIAL_PROXY_PORT: 3001,
DATA_DIR: '/tmp/nanoclaw-test-data',
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
IDLE_TIMEOUT: 1800000, // 30min
diff --git a/src/container-runner.ts b/src/container-runner.ts
index 32b486b..8f93ed6 100644
--- a/src/container-runner.ts
+++ b/src/container-runner.ts
@@ -140,9 +140,10 @@ function prepareGroupEnvironment(
const extraPaths = [nodeBin, npmGlobalBin].filter(
(p) => !currentPath.includes(p) && fs.existsSync(p),
);
- const enrichedPath = extraPaths.length > 0
- ? `${extraPaths.join(':')}:${currentPath}`
- : currentPath;
+ const enrichedPath =
+ extraPaths.length > 0
+ ? `${extraPaths.join(':')}:${currentPath}`
+ : currentPath;
const env: Record = {
...cleanEnv,
diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts
deleted file mode 100644
index 9312ed5..0000000
--- a/src/container-runtime.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-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',
- );
- });
-});
diff --git a/src/container-runtime.ts b/src/container-runtime.ts
deleted file mode 100644
index 6c62041..0000000
--- a/src/container-runtime.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * 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';
-
-/**
- * Hostname containers use to reach the host machine.
- * Apple Container VMs access the host via the default gateway (192.168.64.1).
- */
-export const CONTAINER_HOST_GATEWAY = '192.168.64.1';
-
-/**
- * Address the credential proxy binds to on the host.
- * Apple Container VMs reach the host via 192.168.64.1, so we must bind to all interfaces.
- */
-export const PROXY_BIND_HOST = '0.0.0.0';
-
-/**
- * CLI args needed for the container to resolve the host gateway.
- * Apple Container provides host networking natively on macOS — no extra args needed.
- */
-export function hostGatewayArgs(): string[] {
- return [];
-}
-
-/** 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');
- }
-}
diff --git a/src/credential-proxy.test.ts b/src/credential-proxy.test.ts
deleted file mode 100644
index de76c89..0000000
--- a/src/credential-proxy.test.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import http from 'http';
-import type { AddressInfo } from 'net';
-
-const mockEnv: Record = {};
-vi.mock('./env.js', () => ({
- readEnvFile: vi.fn(() => ({ ...mockEnv })),
-}));
-
-vi.mock('./logger.js', () => ({
- logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
-}));
-
-import { startCredentialProxy } from './credential-proxy.js';
-
-function makeRequest(
- port: number,
- options: http.RequestOptions,
- body = '',
-): Promise<{
- statusCode: number;
- body: string;
- headers: http.IncomingHttpHeaders;
-}> {
- return new Promise((resolve, reject) => {
- const req = http.request(
- { ...options, hostname: '127.0.0.1', port },
- (res) => {
- const chunks: Buffer[] = [];
- res.on('data', (c) => chunks.push(c));
- res.on('end', () => {
- resolve({
- statusCode: res.statusCode!,
- body: Buffer.concat(chunks).toString(),
- headers: res.headers,
- });
- });
- },
- );
- req.on('error', reject);
- req.write(body);
- req.end();
- });
-}
-
-describe('credential-proxy', () => {
- let proxyServer: http.Server;
- let upstreamServer: http.Server;
- let proxyPort: number;
- let upstreamPort: number;
- let lastUpstreamHeaders: http.IncomingHttpHeaders;
-
- beforeEach(async () => {
- lastUpstreamHeaders = {};
-
- upstreamServer = http.createServer((req, res) => {
- lastUpstreamHeaders = { ...req.headers };
- res.writeHead(200, { 'content-type': 'application/json' });
- res.end(JSON.stringify({ ok: true }));
- });
- await new Promise((resolve) =>
- upstreamServer.listen(0, '127.0.0.1', resolve),
- );
- upstreamPort = (upstreamServer.address() as AddressInfo).port;
- });
-
- afterEach(async () => {
- await new Promise((r) => proxyServer?.close(() => r()));
- await new Promise((r) => upstreamServer?.close(() => r()));
- for (const key of Object.keys(mockEnv)) delete mockEnv[key];
- });
-
- async function startProxy(env: Record): Promise {
- Object.assign(mockEnv, env, {
- ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
- });
- proxyServer = await startCredentialProxy(0);
- return (proxyServer.address() as AddressInfo).port;
- }
-
- it('API-key mode injects x-api-key and strips placeholder', async () => {
- proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
-
- await makeRequest(
- proxyPort,
- {
- method: 'POST',
- path: '/v1/messages',
- headers: {
- 'content-type': 'application/json',
- 'x-api-key': 'placeholder',
- },
- },
- '{}',
- );
-
- expect(lastUpstreamHeaders['x-api-key']).toBe('sk-ant-real-key');
- });
-
- it('OAuth mode replaces Authorization when container sends one', async () => {
- proxyPort = await startProxy({
- CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
- });
-
- await makeRequest(
- proxyPort,
- {
- method: 'POST',
- path: '/api/oauth/claude_cli/create_api_key',
- headers: {
- 'content-type': 'application/json',
- authorization: 'Bearer placeholder',
- },
- },
- '{}',
- );
-
- expect(lastUpstreamHeaders['authorization']).toBe(
- 'Bearer real-oauth-token',
- );
- });
-
- it('OAuth mode does not inject Authorization when container omits it', async () => {
- proxyPort = await startProxy({
- CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
- });
-
- // Post-exchange: container uses x-api-key only, no Authorization header
- await makeRequest(
- proxyPort,
- {
- method: 'POST',
- path: '/v1/messages',
- headers: {
- 'content-type': 'application/json',
- 'x-api-key': 'temp-key-from-exchange',
- },
- },
- '{}',
- );
-
- expect(lastUpstreamHeaders['x-api-key']).toBe('temp-key-from-exchange');
- expect(lastUpstreamHeaders['authorization']).toBeUndefined();
- });
-
- it('strips hop-by-hop headers', async () => {
- proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
-
- await makeRequest(
- proxyPort,
- {
- method: 'POST',
- path: '/v1/messages',
- headers: {
- 'content-type': 'application/json',
- connection: 'keep-alive',
- 'keep-alive': 'timeout=5',
- 'transfer-encoding': 'chunked',
- },
- },
- '{}',
- );
-
- // Proxy strips client hop-by-hop headers. Node's HTTP client may re-add
- // its own Connection header (standard HTTP/1.1 behavior), but the client's
- // custom keep-alive and transfer-encoding must not be forwarded.
- expect(lastUpstreamHeaders['keep-alive']).toBeUndefined();
- expect(lastUpstreamHeaders['transfer-encoding']).toBeUndefined();
- });
-
- it('returns 502 when upstream is unreachable', async () => {
- Object.assign(mockEnv, {
- ANTHROPIC_API_KEY: 'sk-ant-real-key',
- ANTHROPIC_BASE_URL: 'http://127.0.0.1:59999',
- });
- proxyServer = await startCredentialProxy(0);
- proxyPort = (proxyServer.address() as AddressInfo).port;
-
- const res = await makeRequest(
- proxyPort,
- {
- method: 'POST',
- path: '/v1/messages',
- headers: { 'content-type': 'application/json' },
- },
- '{}',
- );
-
- expect(res.statusCode).toBe(502);
- expect(res.body).toBe('Bad Gateway');
- });
-});
diff --git a/src/credential-proxy.ts b/src/credential-proxy.ts
deleted file mode 100644
index 8a893dd..0000000
--- a/src/credential-proxy.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * Credential proxy for container isolation.
- * Containers connect here instead of directly to the Anthropic API.
- * The proxy injects real credentials so containers never see them.
- *
- * Two auth modes:
- * API key: Proxy injects x-api-key on every request.
- * OAuth: Container CLI exchanges its placeholder token for a temp
- * API key via /api/oauth/claude_cli/create_api_key.
- * Proxy injects real OAuth token on that exchange request;
- * subsequent requests carry the temp key which is valid as-is.
- */
-import { createServer, Server } from 'http';
-import { request as httpsRequest } from 'https';
-import { request as httpRequest, RequestOptions } from 'http';
-
-import { readEnvFile } from './env.js';
-import { logger } from './logger.js';
-
-export type AuthMode = 'api-key' | 'oauth';
-
-export interface ProxyConfig {
- authMode: AuthMode;
-}
-
-export function startCredentialProxy(
- port: number,
- host = '127.0.0.1',
-): Promise {
- const secrets = readEnvFile([
- 'ANTHROPIC_API_KEY',
- 'CLAUDE_CODE_OAUTH_TOKEN',
- 'ANTHROPIC_AUTH_TOKEN',
- 'ANTHROPIC_BASE_URL',
- ]);
-
- const authMode: AuthMode = secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
- const oauthToken =
- secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.ANTHROPIC_AUTH_TOKEN;
-
- const upstreamUrl = new URL(
- secrets.ANTHROPIC_BASE_URL || 'https://api.anthropic.com',
- );
- const isHttps = upstreamUrl.protocol === 'https:';
- const makeRequest = isHttps ? httpsRequest : httpRequest;
-
- return new Promise((resolve, reject) => {
- const server = createServer((req, res) => {
- const chunks: Buffer[] = [];
- req.on('data', (c) => chunks.push(c));
- req.on('end', () => {
- const body = Buffer.concat(chunks);
- const headers: Record =
- {
- ...(req.headers as Record),
- host: upstreamUrl.host,
- 'content-length': body.length,
- };
-
- // Strip hop-by-hop headers that must not be forwarded by proxies
- delete headers['connection'];
- delete headers['keep-alive'];
- delete headers['transfer-encoding'];
-
- if (authMode === 'api-key') {
- // API key mode: inject x-api-key on every request
- delete headers['x-api-key'];
- headers['x-api-key'] = secrets.ANTHROPIC_API_KEY;
- } else {
- // OAuth mode: replace placeholder Bearer token with the real one
- // only when the container actually sends an Authorization header
- // (exchange request + auth probes). Post-exchange requests use
- // x-api-key only, so they pass through without token injection.
- if (headers['authorization']) {
- delete headers['authorization'];
- if (oauthToken) {
- headers['authorization'] = `Bearer ${oauthToken}`;
- }
- }
- }
-
- const upstream = makeRequest(
- {
- hostname: upstreamUrl.hostname,
- port: upstreamUrl.port || (isHttps ? 443 : 80),
- path: req.url,
- method: req.method,
- headers,
- } as RequestOptions,
- (upRes) => {
- res.writeHead(upRes.statusCode!, upRes.headers);
- upRes.pipe(res);
- },
- );
-
- upstream.on('error', (err) => {
- logger.error(
- { err, url: req.url },
- 'Credential proxy upstream error',
- );
- if (!res.headersSent) {
- res.writeHead(502);
- res.end('Bad Gateway');
- }
- });
-
- upstream.write(body);
- upstream.end();
- });
- });
-
- server.listen(port, host, () => {
- logger.info({ port, host, authMode }, 'Credential proxy started');
- resolve(server);
- });
-
- server.on('error', reject);
- });
-}
-
-/** Detect which auth mode the host is configured for. */
-export function detectAuthMode(): AuthMode {
- const secrets = readEnvFile(['ANTHROPIC_API_KEY']);
- return secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
-}
diff --git a/src/index.ts b/src/index.ts
index 7c0512b..5db251c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -239,81 +239,50 @@ async function processGroupMessages(chatJid: string): Promise {
}, IDLE_TIMEOUT);
};
- // Determine which agent types to run
- const agentTypes: Array<'claude-code' | 'codex'> =
- group.agentType === 'both'
- ? ['claude-code', 'codex']
- : [group.agentType || 'claude-code'];
-
let hadError = false;
let outputSentToUser = false;
- for (const agentType of agentTypes) {
- // For 'both' groups, create a variant group with the specific agent type
- // and a separate folder so each agent has its own session/workspace
- const effectiveGroup: RegisteredGroup =
- group.agentType === 'both'
- ? {
- ...group,
- agentType,
- folder: `${group.folder}_${agentType === 'claude-code' ? 'cc' : 'codex'}`,
- }
- : group;
+ await channel.setTyping?.(chatJid, true);
- // Find the right channel to send responses through
- const sendChannel =
- group.agentType === 'both'
- ? channels.find(
- (c) =>
- c.name.includes('discord') &&
- c.name.includes(
- agentType === 'codex' ? 'codex' : 'claude-code',
- ),
- ) || channel
- : channel;
-
- await sendChannel.setTyping?.(chatJid, true);
-
- const output = await runAgent(
- effectiveGroup,
- prompt,
- chatJid,
- async (result) => {
- if (result.result) {
- const raw =
- typeof result.result === 'string'
- ? result.result
- : JSON.stringify(result.result);
- const text = raw
- .replace(/[\s\S]*?<\/internal>/g, '')
- .trim();
- logger.info(
- { group: group.name, agentType },
- `Agent output: ${raw.slice(0, 200)}`,
- );
- if (text) {
- await sendChannel.sendMessage(chatJid, text);
- outputSentToUser = true;
- }
- await sendChannel.setTyping?.(chatJid, false);
- resetIdleTimer();
+ const output = await runAgent(
+ group,
+ prompt,
+ chatJid,
+ async (result) => {
+ if (result.result) {
+ const raw =
+ typeof result.result === 'string'
+ ? result.result
+ : JSON.stringify(result.result);
+ const text = raw
+ .replace(/[\s\S]*?<\/internal>/g, '')
+ .trim();
+ logger.info(
+ { group: group.name },
+ `Agent output: ${raw.slice(0, 200)}`,
+ );
+ if (text) {
+ await channel.sendMessage(chatJid, text);
+ outputSentToUser = true;
}
+ await channel.setTyping?.(chatJid, false);
+ resetIdleTimer();
+ }
- if (result.status === 'success') {
- queue.notifyIdle(chatJid);
- }
+ if (result.status === 'success') {
+ queue.notifyIdle(chatJid);
+ }
- if (result.status === 'error') {
- hadError = true;
- }
- },
- );
+ if (result.status === 'error') {
+ hadError = true;
+ }
+ },
+ );
- await sendChannel.setTyping?.(chatJid, false);
+ await channel.setTyping?.(chatJid, false);
- if (output === 'error') {
- hadError = true;
- }
+ if (output === 'error') {
+ hadError = true;
}
if (idleTimer) clearTimeout(idleTimer);
diff --git a/src/mount-security.ts b/src/mount-security.ts
deleted file mode 100644
index 3dceea5..0000000
--- a/src/mount-security.ts
+++ /dev/null
@@ -1,419 +0,0 @@
-/**
- * Mount Security Module for NanoClaw
- *
- * Validates additional mounts against an allowlist stored OUTSIDE the project root.
- * This prevents container agents from modifying security configuration.
- *
- * Allowlist location: ~/.config/nanoclaw/mount-allowlist.json
- */
-import fs from 'fs';
-import os from 'os';
-import path from 'path';
-import pino from 'pino';
-
-import { MOUNT_ALLOWLIST_PATH } from './config.js';
-import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js';
-
-const logger = pino({
- level: process.env.LOG_LEVEL || 'info',
- transport: { target: 'pino-pretty', options: { colorize: true } },
-});
-
-// Cache the allowlist in memory - only reloads on process restart
-let cachedAllowlist: MountAllowlist | null = null;
-let allowlistLoadError: string | null = null;
-
-/**
- * Default blocked patterns - paths that should never be mounted
- */
-const DEFAULT_BLOCKED_PATTERNS = [
- '.ssh',
- '.gnupg',
- '.gpg',
- '.aws',
- '.azure',
- '.gcloud',
- '.kube',
- '.docker',
- 'credentials',
- '.env',
- '.netrc',
- '.npmrc',
- '.pypirc',
- 'id_rsa',
- 'id_ed25519',
- 'private_key',
- '.secret',
-];
-
-/**
- * Load the mount allowlist from the external config location.
- * Returns null if the file doesn't exist or is invalid.
- * Result is cached in memory for the lifetime of the process.
- */
-export function loadMountAllowlist(): MountAllowlist | null {
- if (cachedAllowlist !== null) {
- return cachedAllowlist;
- }
-
- if (allowlistLoadError !== null) {
- // Already tried and failed, don't spam logs
- return null;
- }
-
- try {
- if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
- allowlistLoadError = `Mount allowlist not found at ${MOUNT_ALLOWLIST_PATH}`;
- logger.warn(
- { path: MOUNT_ALLOWLIST_PATH },
- 'Mount allowlist not found - additional mounts will be BLOCKED. ' +
- 'Create the file to enable additional mounts.',
- );
- return null;
- }
-
- const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
- const allowlist = JSON.parse(content) as MountAllowlist;
-
- // Validate structure
- if (!Array.isArray(allowlist.allowedRoots)) {
- throw new Error('allowedRoots must be an array');
- }
-
- if (!Array.isArray(allowlist.blockedPatterns)) {
- throw new Error('blockedPatterns must be an array');
- }
-
- if (typeof allowlist.nonMainReadOnly !== 'boolean') {
- throw new Error('nonMainReadOnly must be a boolean');
- }
-
- // Merge with default blocked patterns
- const mergedBlockedPatterns = [
- ...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]),
- ];
- allowlist.blockedPatterns = mergedBlockedPatterns;
-
- cachedAllowlist = allowlist;
- logger.info(
- {
- path: MOUNT_ALLOWLIST_PATH,
- allowedRoots: allowlist.allowedRoots.length,
- blockedPatterns: allowlist.blockedPatterns.length,
- },
- 'Mount allowlist loaded successfully',
- );
-
- return cachedAllowlist;
- } catch (err) {
- allowlistLoadError = err instanceof Error ? err.message : String(err);
- logger.error(
- {
- path: MOUNT_ALLOWLIST_PATH,
- error: allowlistLoadError,
- },
- 'Failed to load mount allowlist - additional mounts will be BLOCKED',
- );
- return null;
- }
-}
-
-/**
- * Expand ~ to home directory and resolve to absolute path
- */
-function expandPath(p: string): string {
- const homeDir = process.env.HOME || os.homedir();
- if (p.startsWith('~/')) {
- return path.join(homeDir, p.slice(2));
- }
- if (p === '~') {
- return homeDir;
- }
- return path.resolve(p);
-}
-
-/**
- * Get the real path, resolving symlinks.
- * Returns null if the path doesn't exist.
- */
-function getRealPath(p: string): string | null {
- try {
- return fs.realpathSync(p);
- } catch {
- return null;
- }
-}
-
-/**
- * Check if a path matches any blocked pattern
- */
-function matchesBlockedPattern(
- realPath: string,
- blockedPatterns: string[],
-): string | null {
- const pathParts = realPath.split(path.sep);
-
- for (const pattern of blockedPatterns) {
- // Check if any path component matches the pattern
- for (const part of pathParts) {
- if (part === pattern || part.includes(pattern)) {
- return pattern;
- }
- }
-
- // Also check if the full path contains the pattern
- if (realPath.includes(pattern)) {
- return pattern;
- }
- }
-
- return null;
-}
-
-/**
- * Check if a real path is under an allowed root
- */
-function findAllowedRoot(
- realPath: string,
- allowedRoots: AllowedRoot[],
-): AllowedRoot | null {
- for (const root of allowedRoots) {
- const expandedRoot = expandPath(root.path);
- const realRoot = getRealPath(expandedRoot);
-
- if (realRoot === null) {
- // Allowed root doesn't exist, skip it
- continue;
- }
-
- // Check if realPath is under realRoot
- const relative = path.relative(realRoot, realPath);
- if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
- return root;
- }
- }
-
- return null;
-}
-
-/**
- * Validate the container path to prevent escaping /workspace/extra/
- */
-function isValidContainerPath(containerPath: string): boolean {
- // Must not contain .. to prevent path traversal
- if (containerPath.includes('..')) {
- return false;
- }
-
- // Must not be absolute (it will be prefixed with /workspace/extra/)
- if (containerPath.startsWith('/')) {
- return false;
- }
-
- // Must not be empty
- if (!containerPath || containerPath.trim() === '') {
- return false;
- }
-
- return true;
-}
-
-export interface MountValidationResult {
- allowed: boolean;
- reason: string;
- realHostPath?: string;
- resolvedContainerPath?: string;
- effectiveReadonly?: boolean;
-}
-
-/**
- * Validate a single additional mount against the allowlist.
- * Returns validation result with reason.
- */
-export function validateMount(
- mount: AdditionalMount,
- isMain: boolean,
-): MountValidationResult {
- const allowlist = loadMountAllowlist();
-
- // If no allowlist, block all additional mounts
- if (allowlist === null) {
- return {
- allowed: false,
- reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`,
- };
- }
-
- // Derive containerPath from hostPath basename if not specified
- const containerPath = mount.containerPath || path.basename(mount.hostPath);
-
- // Validate container path (cheap check)
- if (!isValidContainerPath(containerPath)) {
- return {
- allowed: false,
- reason: `Invalid container path: "${containerPath}" - must be relative, non-empty, and not contain ".."`,
- };
- }
-
- // Expand and resolve the host path
- const expandedPath = expandPath(mount.hostPath);
- const realPath = getRealPath(expandedPath);
-
- if (realPath === null) {
- return {
- allowed: false,
- reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`,
- };
- }
-
- // Check against blocked patterns
- const blockedMatch = matchesBlockedPattern(
- realPath,
- allowlist.blockedPatterns,
- );
- if (blockedMatch !== null) {
- return {
- allowed: false,
- reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`,
- };
- }
-
- // Check if under an allowed root
- const allowedRoot = findAllowedRoot(realPath, allowlist.allowedRoots);
- if (allowedRoot === null) {
- return {
- allowed: false,
- reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots
- .map((r) => expandPath(r.path))
- .join(', ')}`,
- };
- }
-
- // Determine effective readonly status
- const requestedReadWrite = mount.readonly === false;
- let effectiveReadonly = true; // Default to readonly
-
- if (requestedReadWrite) {
- if (!isMain && allowlist.nonMainReadOnly) {
- // Non-main groups forced to read-only
- effectiveReadonly = true;
- logger.info(
- {
- mount: mount.hostPath,
- },
- 'Mount forced to read-only for non-main group',
- );
- } else if (!allowedRoot.allowReadWrite) {
- // Root doesn't allow read-write
- effectiveReadonly = true;
- logger.info(
- {
- mount: mount.hostPath,
- root: allowedRoot.path,
- },
- 'Mount forced to read-only - root does not allow read-write',
- );
- } else {
- // Read-write allowed
- effectiveReadonly = false;
- }
- }
-
- return {
- allowed: true,
- reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`,
- realHostPath: realPath,
- resolvedContainerPath: containerPath,
- effectiveReadonly,
- };
-}
-
-/**
- * Validate all additional mounts for a group.
- * Returns array of validated mounts (only those that passed validation).
- * Logs warnings for rejected mounts.
- */
-export function validateAdditionalMounts(
- mounts: AdditionalMount[],
- groupName: string,
- isMain: boolean,
-): Array<{
- hostPath: string;
- containerPath: string;
- readonly: boolean;
-}> {
- const validatedMounts: Array<{
- hostPath: string;
- containerPath: string;
- readonly: boolean;
- }> = [];
-
- for (const mount of mounts) {
- const result = validateMount(mount, isMain);
-
- if (result.allowed) {
- validatedMounts.push({
- hostPath: result.realHostPath!,
- containerPath: `/workspace/extra/${result.resolvedContainerPath}`,
- readonly: result.effectiveReadonly!,
- });
-
- logger.debug(
- {
- group: groupName,
- hostPath: result.realHostPath,
- containerPath: result.resolvedContainerPath,
- readonly: result.effectiveReadonly,
- reason: result.reason,
- },
- 'Mount validated successfully',
- );
- } else {
- logger.warn(
- {
- group: groupName,
- requestedPath: mount.hostPath,
- containerPath: mount.containerPath,
- reason: result.reason,
- },
- 'Additional mount REJECTED',
- );
- }
- }
-
- return validatedMounts;
-}
-
-/**
- * Generate a template allowlist file for users to customize
- */
-export function generateAllowlistTemplate(): string {
- const template: MountAllowlist = {
- allowedRoots: [
- {
- path: '~/projects',
- allowReadWrite: true,
- description: 'Development projects',
- },
- {
- path: '~/repos',
- allowReadWrite: true,
- description: 'Git repositories',
- },
- {
- path: '~/Documents/work',
- allowReadWrite: false,
- description: 'Work documents (read-only)',
- },
- ],
- blockedPatterns: [
- // Additional patterns beyond defaults
- 'password',
- 'secret',
- 'token',
- ],
- nonMainReadOnly: true,
- };
-
- return JSON.stringify(template, null, 2);
-}
diff --git a/src/types.ts b/src/types.ts
index 2a36e75..d18f328 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -4,35 +4,12 @@ export interface AdditionalMount {
readonly?: boolean; // Default: true for safety
}
-/**
- * Mount Allowlist - Security configuration for additional mounts
- * This file should be stored at ~/.config/nanoclaw/mount-allowlist.json
- * and is NOT mounted into any container, making it tamper-proof from agents.
- */
-export interface MountAllowlist {
- // Directories that can be mounted into containers
- allowedRoots: AllowedRoot[];
- // Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg")
- blockedPatterns: string[];
- // If true, non-main groups can only mount read-only regardless of config
- nonMainReadOnly: boolean;
-}
-
-export interface AllowedRoot {
- // Absolute path or ~ for home (e.g., "~/projects", "/var/repos")
- path: string;
- // Whether read-write mounts are allowed under this root
- allowReadWrite: boolean;
- // Optional description for documentation
- description?: string;
-}
-
export interface ContainerConfig {
additionalMounts?: AdditionalMount[];
timeout?: number; // Default: 300000 (5 minutes)
}
-export type AgentType = 'claude-code' | 'codex' | 'both';
+export type AgentType = 'claude-code' | 'codex';
export interface RegisteredGroup {
name: string;