From 57cafc19f813554ff24baf6a0dff0559504bb424 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 11 Mar 2026 00:50:03 +0900 Subject: [PATCH] refactor: dual-instance setup, remove dead container code - Make STORE_DIR, DATA_DIR, GROUPS_DIR configurable via env vars (NANOCLAW_STORE_DIR, NANOCLAW_DATA_DIR, NANOCLAW_GROUPS_DIR) for running two instances from one codebase - Remove broken 'both' agent type sequential loop from processGroupMessages - Delete dead code: container-runtime, mount-security, credential-proxy, Dockerfiles, and related config constants (~1,085 lines removed) - Add codex instance launchd plist template --- .gitignore | 3 + container/Dockerfile | 68 ----- container/Dockerfile.codex | 43 ---- launchd/com.nanoclaw-codex.plist | 38 +++ src/channels/discord.ts | 3 - src/config.ts | 25 +- src/container-runner.test.ts | 2 - src/container-runner.ts | 7 +- src/container-runtime.test.ts | 179 ------------- src/container-runtime.ts | 123 --------- src/credential-proxy.test.ts | 192 -------------- src/credential-proxy.ts | 125 --------- src/index.ts | 101 +++----- src/mount-security.ts | 419 ------------------------------- src/types.ts | 25 +- 15 files changed, 84 insertions(+), 1269 deletions(-) delete mode 100644 container/Dockerfile delete mode 100644 container/Dockerfile.codex create mode 100644 launchd/com.nanoclaw-codex.plist delete mode 100644 src/container-runtime.test.ts delete mode 100644 src/container-runtime.ts delete mode 100644 src/credential-proxy.test.ts delete mode 100644 src/credential-proxy.ts delete mode 100644 src/mount-security.ts 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;