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
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,10 +6,13 @@ dist/
|
|||||||
|
|
||||||
# Local data & auth
|
# Local data & auth
|
||||||
store/
|
store/
|
||||||
|
store-*/
|
||||||
data/
|
data/
|
||||||
|
data-*/
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
# Groups - only track base structure and specific CLAUDE.md files
|
# Groups - only track base structure and specific CLAUDE.md files
|
||||||
|
groups-*/
|
||||||
groups/*
|
groups/*
|
||||||
!groups/main/
|
!groups/main/
|
||||||
!groups/global/
|
!groups/global/
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -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"]
|
|
||||||
38
launchd/com.nanoclaw-codex.plist
Normal file
38
launchd/com.nanoclaw-codex.plist
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.nanoclaw-codex</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>{{NODE_PATH}}</string>
|
||||||
|
<string>{{PROJECT_ROOT}}/dist/index.js</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>{{PROJECT_ROOT}}</string>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>{{HOME}}/.npm-global/bin:{{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||||
|
<key>HOME</key>
|
||||||
|
<string>{{HOME}}</string>
|
||||||
|
<key>ASSISTANT_NAME</key>
|
||||||
|
<string>Andy-Codex</string>
|
||||||
|
<key>NANOCLAW_STORE_DIR</key>
|
||||||
|
<string>{{PROJECT_ROOT}}/store-codex</string>
|
||||||
|
<key>NANOCLAW_GROUPS_DIR</key>
|
||||||
|
<string>{{PROJECT_ROOT}}/groups-codex</string>
|
||||||
|
<key>NANOCLAW_DATA_DIR</key>
|
||||||
|
<string>{{PROJECT_ROOT}}/data-codex</string>
|
||||||
|
</dict>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>{{PROJECT_ROOT}}/logs/nanoclaw-codex.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>{{PROJECT_ROOT}}/logs/nanoclaw-codex.error.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -250,9 +250,6 @@ export class DiscordChannel implements Channel {
|
|||||||
const group = this.opts.registeredGroups()[jid];
|
const group = this.opts.registeredGroups()[jid];
|
||||||
if (!group) return false;
|
if (!group) return false;
|
||||||
const groupType = group.agentType || 'claude-code';
|
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;
|
return groupType === this.agentTypeFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import path from 'path';
|
|||||||
|
|
||||||
import { readEnvFile } from './env.js';
|
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']);
|
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
|
||||||
|
|
||||||
export const ASSISTANT_NAME =
|
export const ASSISTANT_NAME =
|
||||||
@@ -16,31 +13,19 @@ export const ASSISTANT_HAS_OWN_NUMBER =
|
|||||||
export const POLL_INTERVAL = 2000;
|
export const POLL_INTERVAL = 2000;
|
||||||
export const SCHEDULER_POLL_INTERVAL = 60000;
|
export const SCHEDULER_POLL_INTERVAL = 60000;
|
||||||
|
|
||||||
// Absolute paths needed for container mounts
|
|
||||||
const PROJECT_ROOT = process.cwd();
|
const PROJECT_ROOT = process.cwd();
|
||||||
const HOME_DIR = process.env.HOME || os.homedir();
|
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(
|
export const SENDER_ALLOWLIST_PATH = path.join(
|
||||||
HOME_DIR,
|
HOME_DIR,
|
||||||
'.config',
|
'.config',
|
||||||
'nanoclaw',
|
'nanoclaw',
|
||||||
'sender-allowlist.json',
|
'sender-allowlist.json',
|
||||||
);
|
);
|
||||||
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
export const STORE_DIR = path.resolve(process.env.NANOCLAW_STORE_DIR || path.join(PROJECT_ROOT, 'store'));
|
||||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
export const GROUPS_DIR = path.resolve(process.env.NANOCLAW_GROUPS_DIR || path.join(PROJECT_ROOT, 'groups'));
|
||||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
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(
|
export const CONTAINER_TIMEOUT = parseInt(
|
||||||
process.env.CONTAINER_TIMEOUT || '1800000',
|
process.env.CONTAINER_TIMEOUT || '1800000',
|
||||||
10,
|
10,
|
||||||
@@ -49,10 +34,6 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
|||||||
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
||||||
10,
|
10,
|
||||||
); // 10MB default
|
); // 10MB default
|
||||||
export const CREDENTIAL_PROXY_PORT = parseInt(
|
|
||||||
process.env.CREDENTIAL_PROXY_PORT || '3001',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
export const IPC_POLL_INTERVAL = 1000;
|
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 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(
|
export const MAX_CONCURRENT_CONTAINERS = Math.max(
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
|||||||
|
|
||||||
// Mock config
|
// Mock config
|
||||||
vi.mock('./config.js', () => ({
|
vi.mock('./config.js', () => ({
|
||||||
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
|
||||||
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
||||||
CONTAINER_TIMEOUT: 1800000, // 30min
|
CONTAINER_TIMEOUT: 1800000, // 30min
|
||||||
CREDENTIAL_PROXY_PORT: 3001,
|
|
||||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||||
IDLE_TIMEOUT: 1800000, // 30min
|
IDLE_TIMEOUT: 1800000, // 30min
|
||||||
|
|||||||
@@ -140,9 +140,10 @@ function prepareGroupEnvironment(
|
|||||||
const extraPaths = [nodeBin, npmGlobalBin].filter(
|
const extraPaths = [nodeBin, npmGlobalBin].filter(
|
||||||
(p) => !currentPath.includes(p) && fs.existsSync(p),
|
(p) => !currentPath.includes(p) && fs.existsSync(p),
|
||||||
);
|
);
|
||||||
const enrichedPath = extraPaths.length > 0
|
const enrichedPath =
|
||||||
? `${extraPaths.join(':')}:${currentPath}`
|
extraPaths.length > 0
|
||||||
: currentPath;
|
? `${extraPaths.join(':')}:${currentPath}`
|
||||||
|
: currentPath;
|
||||||
|
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
...cleanEnv,
|
...cleanEnv,
|
||||||
|
|||||||
@@ -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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, string> = {};
|
|
||||||
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<void>((resolve) =>
|
|
||||||
upstreamServer.listen(0, '127.0.0.1', resolve),
|
|
||||||
);
|
|
||||||
upstreamPort = (upstreamServer.address() as AddressInfo).port;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await new Promise<void>((r) => proxyServer?.close(() => r()));
|
|
||||||
await new Promise<void>((r) => upstreamServer?.close(() => r()));
|
|
||||||
for (const key of Object.keys(mockEnv)) delete mockEnv[key];
|
|
||||||
});
|
|
||||||
|
|
||||||
async function startProxy(env: Record<string, string>): Promise<number> {
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<Server> {
|
|
||||||
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<string, string | number | string[] | undefined> =
|
|
||||||
{
|
|
||||||
...(req.headers as Record<string, string>),
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
101
src/index.ts
101
src/index.ts
@@ -239,81 +239,50 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
}, IDLE_TIMEOUT);
|
}, 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 hadError = false;
|
||||||
let outputSentToUser = false;
|
let outputSentToUser = false;
|
||||||
|
|
||||||
for (const agentType of agentTypes) {
|
await channel.setTyping?.(chatJid, true);
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Find the right channel to send responses through
|
const output = await runAgent(
|
||||||
const sendChannel =
|
group,
|
||||||
group.agentType === 'both'
|
prompt,
|
||||||
? channels.find(
|
chatJid,
|
||||||
(c) =>
|
async (result) => {
|
||||||
c.name.includes('discord') &&
|
if (result.result) {
|
||||||
c.name.includes(
|
const raw =
|
||||||
agentType === 'codex' ? 'codex' : 'claude-code',
|
typeof result.result === 'string'
|
||||||
),
|
? result.result
|
||||||
) || channel
|
: JSON.stringify(result.result);
|
||||||
: channel;
|
const text = raw
|
||||||
|
.replace(/<internal>[\s\S]*?<\/internal>/g, '')
|
||||||
await sendChannel.setTyping?.(chatJid, true);
|
.trim();
|
||||||
|
logger.info(
|
||||||
const output = await runAgent(
|
{ group: group.name },
|
||||||
effectiveGroup,
|
`Agent output: ${raw.slice(0, 200)}`,
|
||||||
prompt,
|
);
|
||||||
chatJid,
|
if (text) {
|
||||||
async (result) => {
|
await channel.sendMessage(chatJid, text);
|
||||||
if (result.result) {
|
outputSentToUser = true;
|
||||||
const raw =
|
|
||||||
typeof result.result === 'string'
|
|
||||||
? result.result
|
|
||||||
: JSON.stringify(result.result);
|
|
||||||
const text = raw
|
|
||||||
.replace(/<internal>[\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();
|
|
||||||
}
|
}
|
||||||
|
await channel.setTyping?.(chatJid, false);
|
||||||
|
resetIdleTimer();
|
||||||
|
}
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
queue.notifyIdle(chatJid);
|
queue.notifyIdle(chatJid);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'error') {
|
if (result.status === 'error') {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await sendChannel.setTyping?.(chatJid, false);
|
await channel.setTyping?.(chatJid, false);
|
||||||
|
|
||||||
if (output === 'error') {
|
if (output === 'error') {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
25
src/types.ts
25
src/types.ts
@@ -4,35 +4,12 @@ export interface AdditionalMount {
|
|||||||
readonly?: boolean; // Default: true for safety
|
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 {
|
export interface ContainerConfig {
|
||||||
additionalMounts?: AdditionalMount[];
|
additionalMounts?: AdditionalMount[];
|
||||||
timeout?: number; // Default: 300000 (5 minutes)
|
timeout?: number; // Default: 300000 (5 minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentType = 'claude-code' | 'codex' | 'both';
|
export type AgentType = 'claude-code' | 'codex';
|
||||||
|
|
||||||
export interface RegisteredGroup {
|
export interface RegisteredGroup {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user