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
|
||||
store/
|
||||
store-*/
|
||||
data/
|
||||
data-*/
|
||||
logs/
|
||||
|
||||
# Groups - only track base structure and specific CLAUDE.md files
|
||||
groups-*/
|
||||
groups/*
|
||||
!groups/main/
|
||||
!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];
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -140,7 +140,8 @@ function prepareGroupEnvironment(
|
||||
const extraPaths = [nodeBin, npmGlobalBin].filter(
|
||||
(p) => !currentPath.includes(p) && fs.existsSync(p),
|
||||
);
|
||||
const enrichedPath = extraPaths.length > 0
|
||||
const enrichedPath =
|
||||
extraPaths.length > 0
|
||||
? `${extraPaths.join(':')}:${currentPath}`
|
||||
: currentPath;
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
43
src/index.ts
43
src/index.ts
@@ -239,43 +239,13 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
}, 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;
|
||||
|
||||
// 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);
|
||||
await channel.setTyping?.(chatJid, true);
|
||||
|
||||
const output = await runAgent(
|
||||
effectiveGroup,
|
||||
group,
|
||||
prompt,
|
||||
chatJid,
|
||||
async (result) => {
|
||||
@@ -288,14 +258,14 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
.replace(/<internal>[\s\S]*?<\/internal>/g, '')
|
||||
.trim();
|
||||
logger.info(
|
||||
{ group: group.name, agentType },
|
||||
{ group: group.name },
|
||||
`Agent output: ${raw.slice(0, 200)}`,
|
||||
);
|
||||
if (text) {
|
||||
await sendChannel.sendMessage(chatJid, text);
|
||||
await channel.sendMessage(chatJid, text);
|
||||
outputSentToUser = true;
|
||||
}
|
||||
await sendChannel.setTyping?.(chatJid, false);
|
||||
await channel.setTyping?.(chatJid, false);
|
||||
resetIdleTimer();
|
||||
}
|
||||
|
||||
@@ -309,12 +279,11 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
},
|
||||
);
|
||||
|
||||
await sendChannel.setTyping?.(chatJid, false);
|
||||
await channel.setTyping?.(chatJid, false);
|
||||
|
||||
if (output === 'error') {
|
||||
hadError = true;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
Reference in New Issue
Block a user