From 08dd468692bd45b37cf6cf7a3229baa65905cb83 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 10 Mar 2026 21:05:21 +0900 Subject: [PATCH] feat: remove container layer, run agents as direct host processes - Rewrite container-runner.ts to spawn node processes directly instead of Docker/Apple Container - Runner paths configurable via env vars (NANOCLAW_GROUP_DIR, etc.) with container-path defaults for backwards compat - Pass real credentials directly (no credential proxy needed) - Remove ensureContainerSystemRunning() and startCredentialProxy() from startup - Add build:runners script for building agent-runner and codex-runner - Fix TS strict mode errors in agent-runner ipc-mcp-stdio.ts --- container/agent-runner/package-lock.json | 3 + container/agent-runner/src/index.ts | 18 +- container/agent-runner/src/ipc-mcp-stdio.ts | 10 +- container/codex-runner/package-lock.json | 47 ++ container/codex-runner/src/index.ts | 9 +- package.json | 1 + src/channels/discord.test.ts | 11 +- src/channels/discord.ts | 67 ++- src/container-runner.test.ts | 13 +- src/container-runner.ts | 578 ++++++-------------- src/container-runtime.test.ts | 4 +- src/container-runtime.ts | 32 +- src/index.ts | 57 +- src/session-commands.test.ts | 65 ++- src/session-commands.ts | 32 +- 15 files changed, 449 insertions(+), 498 deletions(-) create mode 100644 container/codex-runner/package-lock.json diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json index 89cee2c..2c6696e 100644 --- a/container/agent-runner/package-lock.json +++ b/container/agent-runner/package-lock.json @@ -724,6 +724,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -928,6 +929,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz", "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -1507,6 +1509,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 0921d5c..e144b85 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -55,7 +55,13 @@ interface SDKUserMessage { session_id: string; } -const IPC_INPUT_DIR = '/workspace/ipc/input'; +// Paths configurable via env vars (defaults to container paths for backwards compat) +const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; +const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; +const GLOBAL_DIR = process.env.NANOCLAW_GLOBAL_DIR || '/workspace/global'; +const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra'; + +const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); const MAX_TURNS = 100; const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); const IPC_POLL_MS = 500; @@ -167,7 +173,7 @@ function createPreCompactHook(assistantName?: string): HookCallback { const summary = getSessionSummary(sessionId, transcriptPath); const name = summary ? sanitizeFilename(summary) : generateFallbackName(); - const conversationsDir = '/workspace/group/conversations'; + const conversationsDir = path.join(GROUP_DIR, 'conversations'); fs.mkdirSync(conversationsDir, { recursive: true }); const date = new Date().toISOString().split('T')[0]; @@ -393,7 +399,7 @@ async function runQuery( let resultCount = 0; // Load global CLAUDE.md as additional system context (shared across all groups) - const globalClaudeMdPath = '/workspace/global/CLAUDE.md'; + const globalClaudeMdPath = path.join(GLOBAL_DIR, 'CLAUDE.md'); let globalClaudeMd: string | undefined; if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); @@ -402,7 +408,7 @@ async function runQuery( // Discover additional directories mounted at /workspace/extra/* // These are passed to the SDK so their CLAUDE.md files are loaded automatically const extraDirs: string[] = []; - const extraBase = '/workspace/extra'; + const extraBase = EXTRA_BASE; if (fs.existsSync(extraBase)) { for (const entry of fs.readdirSync(extraBase)) { const fullPath = path.join(extraBase, entry); @@ -434,7 +440,7 @@ async function runQuery( for await (const message of query({ prompt: stream, options: { - cwd: '/workspace/group', + cwd: GROUP_DIR, model, thinking, effort, @@ -573,7 +579,7 @@ async function main(): Promise { for await (const message of query({ prompt: trimmedPrompt, options: { - cwd: '/workspace/group', + cwd: GROUP_DIR, resume: sessionId, systemPrompt: undefined, allowedTools: [], diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts index 9de0138..d1ffe3c 100644 --- a/container/agent-runner/src/ipc-mcp-stdio.ts +++ b/container/agent-runner/src/ipc-mcp-stdio.ts @@ -194,7 +194,7 @@ server.tool( 'pause_task', 'Pause a scheduled task. It will not run until resumed.', { task_id: z.string().describe('The task ID to pause') }, - async (args) => { + async (args: { task_id: string }) => { const data = { type: 'pause_task', taskId: args.task_id, @@ -213,7 +213,7 @@ server.tool( 'resume_task', 'Resume a paused task.', { task_id: z.string().describe('The task ID to resume') }, - async (args) => { + async (args: { task_id: string }) => { const data = { type: 'resume_task', taskId: args.task_id, @@ -232,7 +232,7 @@ server.tool( 'cancel_task', 'Cancel and delete a scheduled task.', { task_id: z.string().describe('The task ID to cancel') }, - async (args) => { + async (args: { task_id: string }) => { const data = { type: 'cancel_task', taskId: args.task_id, @@ -256,7 +256,7 @@ server.tool( schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'), schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'), }, - async (args) => { + async (args: { task_id: string; prompt?: string; schedule_type?: 'cron' | 'interval' | 'once'; schedule_value?: string }) => { // Validate schedule_value if provided if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) { if (args.schedule_value) { @@ -308,7 +308,7 @@ Use available_groups.json to find the JID for a group. The folder name must be c folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'), trigger: z.string().describe('Trigger word (e.g., "@Andy")'), }, - async (args) => { + async (args: { jid: string; name: string; folder: string; trigger: string }) => { if (!isMain) { return { content: [{ type: 'text' as const, text: 'Only the main group can register new groups.' }], diff --git a/container/codex-runner/package-lock.json b/container/codex-runner/package-lock.json new file mode 100644 index 0000000..3c1b3f4 --- /dev/null +++ b/container/codex-runner/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "nanoclaw-codex-runner", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nanoclaw-codex-runner", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/container/codex-runner/src/index.ts b/container/codex-runner/src/index.ts index 9cd68f9..532d3b9 100644 --- a/container/codex-runner/src/index.ts +++ b/container/codex-runner/src/index.ts @@ -33,7 +33,10 @@ interface ContainerOutput { error?: string; } -const IPC_INPUT_DIR = '/workspace/ipc/input'; +// Paths configurable via env vars (defaults to container paths for backwards compat) +const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; +const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; +const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); const IPC_POLL_MS = 500; const MAX_TURNS = 100; @@ -140,7 +143,7 @@ async function runCodexExec(prompt: string, resume: boolean): Promise<{ result: args.push( 'exec', '--dangerously-bypass-approvals-and-sandbox', - '-C', '/workspace/group', + '-C', GROUP_DIR, '--skip-git-repo-check', '--color', 'never', prompt, @@ -151,7 +154,7 @@ async function runCodexExec(prompt: string, resume: boolean): Promise<{ result: const codex: ChildProcess = spawn('codex', args, { stdio: ['pipe', 'pipe', 'pipe'], - cwd: '/workspace/group', + cwd: GROUP_DIR, env: { ...process.env }, }); diff --git a/package.json b/package.json index 37bc97b..09bf3a6 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "main": "dist/index.js", "scripts": { "build": "tsc", + "build:runners": "cd container/agent-runner && npm install && npm run build && cd ../codex-runner && npm install && npm run build", "start": "node dist/index.js", "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit", diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index b09d0d3..ff11d93 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -161,9 +161,7 @@ function createMessage(overrides: { member: overrides.memberDisplayName ? { displayName: overrides.memberDisplayName } : null, - guild: overrides.guildName - ? { name: overrides.guildName } - : null, + guild: overrides.guildName ? { name: overrides.guildName } : null, channel: { name: overrides.channelName ?? 'general', messages: { @@ -646,8 +644,11 @@ describe('DiscordChannel', () => { await channel.sendMessage('dc:1234567890123456', 'Hello'); - const fetchedChannel = await currentClient().channels.fetch('1234567890123456'); - expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456'); + const fetchedChannel = + await currentClient().channels.fetch('1234567890123456'); + expect(currentClient().channels.fetch).toHaveBeenCalledWith( + '1234567890123456', + ); }); it('strips dc: prefix from JID', async () => { diff --git a/src/channels/discord.ts b/src/channels/discord.ts index db2d69a..83ddf3d 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -1,4 +1,10 @@ -import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js'; +import { + Client, + Events, + GatewayIntentBits, + Message, + TextChannel, +} from 'discord.js'; import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; import { readEnvFile } from '../env.js'; @@ -27,7 +33,11 @@ export class DiscordChannel implements Channel { private typingIntervals = new Map(); private agentTypeFilter?: AgentType; - constructor(botToken: string, opts: DiscordChannelOpts, agentTypeFilter?: AgentType) { + constructor( + botToken: string, + opts: DiscordChannelOpts, + agentTypeFilter?: AgentType, + ) { this.botToken = botToken; this.opts = opts; this.agentTypeFilter = agentTypeFilter; @@ -95,18 +105,20 @@ export class DiscordChannel implements Channel { // Handle attachments — store placeholders so the agent knows something was sent if (message.attachments.size > 0) { - const attachmentDescriptions = [...message.attachments.values()].map((att) => { - const contentType = att.contentType || ''; - if (contentType.startsWith('image/')) { - return `[Image: ${att.name || 'image'}]`; - } else if (contentType.startsWith('video/')) { - return `[Video: ${att.name || 'video'}]`; - } else if (contentType.startsWith('audio/')) { - return `[Audio: ${att.name || 'audio'}]`; - } else { - return `[File: ${att.name || 'file'}]`; - } - }); + const attachmentDescriptions = [...message.attachments.values()].map( + (att) => { + const contentType = att.contentType || ''; + if (contentType.startsWith('image/')) { + return `[Image: ${att.name || 'image'}]`; + } else if (contentType.startsWith('video/')) { + return `[Video: ${att.name || 'video'}]`; + } else if (contentType.startsWith('audio/')) { + return `[Audio: ${att.name || 'audio'}]`; + } else { + return `[File: ${att.name || 'file'}]`; + } + }, + ); if (content) { content = `${content}\n${attachmentDescriptions.join('\n')}`; } else { @@ -132,7 +144,13 @@ export class DiscordChannel implements Channel { // Store chat metadata for discovery const isGroup = message.guild !== null; - this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup); + this.opts.onChatMetadata( + chatJid, + timestamp, + chatName, + 'discord', + isGroup, + ); // Only deliver full message for registered groups matching our agent type const group = this.opts.registeredGroups()[chatJid]; @@ -143,7 +161,10 @@ export class DiscordChannel implements Channel { ); return; } - if (this.agentTypeFilter && (group.agentType || 'claude-code') !== this.agentTypeFilter) { + if ( + this.agentTypeFilter && + (group.agentType || 'claude-code') !== this.agentTypeFilter + ) { return; // This JID belongs to a different agent type's bot } @@ -278,14 +299,22 @@ registerChannel('discord', (opts: ChannelOpts) => { return null; } // If a second Codex bot token exists, this instance only handles claude-code groups - const hasCodexBot = !!(process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN); - return new DiscordChannel(token, opts, hasCodexBot ? 'claude-code' : undefined); + const hasCodexBot = !!( + process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN + ); + return new DiscordChannel( + token, + opts, + hasCodexBot ? 'claude-code' : undefined, + ); }); registerChannel('discord-codex', (opts: ChannelOpts) => { const envVars = readEnvFile(['DISCORD_CODEX_BOT_TOKEN']); const token = - process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN || ''; + process.env.DISCORD_CODEX_BOT_TOKEN || + envVars.DISCORD_CODEX_BOT_TOKEN || + ''; if (!token) return null; // Codex Discord bot is optional return new DiscordChannel(token, opts, 'codex'); }); diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index c830176..8f8d7a3 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -35,20 +35,25 @@ vi.mock('fs', async () => { ...actual, default: { ...actual, - existsSync: vi.fn(() => false), + existsSync: vi.fn((p: string) => { + // Return true for runner dist entry so tests proceed past the build check + if (typeof p === 'string' && p.includes('dist/index.js')) return true; + return false; + }), mkdirSync: vi.fn(), writeFileSync: vi.fn(), readFileSync: vi.fn(() => ''), readdirSync: vi.fn(() => []), statSync: vi.fn(() => ({ isDirectory: () => false })), copyFileSync: vi.fn(), + cpSync: vi.fn(), }, }; }); -// Mock mount-security -vi.mock('./mount-security.js', () => ({ - validateAdditionalMounts: vi.fn(() => []), +// Mock env +vi.mock('./env.js', () => ({ + readEnvFile: vi.fn(() => ({})), })); // Create a controllable fake ChildProcess diff --git a/src/container-runner.ts b/src/container-runner.ts index c28fb2a..c658bd3 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -1,35 +1,26 @@ /** - * Container Runner for NanoClaw - * Spawns agent execution in containers and handles IPC + * Agent Process Runner for NanoClaw + * Spawns agent execution as direct host processes and handles IPC */ -import { ChildProcess, exec, spawn } from 'child_process'; +import { ChildProcess, spawn } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { - CODEX_CONTAINER_IMAGE, - CONTAINER_IMAGE, CONTAINER_MAX_OUTPUT_SIZE, CONTAINER_TIMEOUT, - CREDENTIAL_PROXY_PORT, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, TIMEZONE, } from './config.js'; -import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; -import { logger } from './logger.js'; import { - CONTAINER_HOST_GATEWAY, - CONTAINER_RUNTIME_BIN, - hostGatewayArgs, - readonlyMountArgs, - stopContainer, -} from './container-runtime.js'; -import { detectAuthMode } from './credential-proxy.js'; + resolveGroupFolderPath, + resolveGroupIpcPath, +} from './group-folder.js'; +import { logger } from './logger.js'; import { readEnvFile } from './env.js'; -import { validateAdditionalMounts } from './mount-security.js'; import { RegisteredGroup } from './types.js'; // Sentinel markers for robust output parsing (must match agent-runner) @@ -54,60 +45,19 @@ export interface ContainerOutput { error?: string; } -interface VolumeMount { - hostPath: string; - containerPath: string; - readonly: boolean; -} - -function buildVolumeMounts( +/** + * Prepare the group's environment: directories, sessions, env vars. + * Returns the environment variables and paths for the runner process. + */ +function prepareGroupEnvironment( group: RegisteredGroup, isMain: boolean, -): VolumeMount[] { - const mounts: VolumeMount[] = []; +): { env: Record; groupDir: string; runnerDir: string } { const projectRoot = process.cwd(); const groupDir = resolveGroupFolderPath(group.folder); + fs.mkdirSync(groupDir, { recursive: true }); - if (isMain) { - // Main gets the project root read-only. Writable paths the agent needs - // (group folder, IPC, .claude/) are mounted separately below. - // Read-only prevents the agent from modifying host application code - // (src/, dist/, package.json, etc.) which would bypass the sandbox - // entirely on next restart. - mounts.push({ - hostPath: projectRoot, - containerPath: '/workspace/project', - readonly: true, - }); - - // Main also gets its group folder as the working directory - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - } else { - // Other groups only get their own folder - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - - // Global memory directory (read-only for non-main) - // Only directory mounts are supported, not file mounts - const globalDir = path.join(GROUPS_DIR, 'global'); - if (fs.existsSync(globalDir)) { - mounts.push({ - hostPath: globalDir, - containerPath: '/workspace/global', - readonly: true, - }); - } - } - - // Per-group Claude sessions directory (isolated from other groups) - // Each group gets their own .claude/ to prevent cross-group session access + // Per-group Claude sessions directory const groupSessionsDir = path.join( DATA_DIR, 'sessions', @@ -122,14 +72,8 @@ function buildVolumeMounts( JSON.stringify( { env: { - // Enable agent swarms (subagent orchestration) - // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - // Load CLAUDE.md from additional mounted directories - // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - // Enable Claude's memory feature (persists user preferences between sessions) - // https://code.claude.com/docs/en/memory#manage-auto-memory CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', }, }, @@ -140,7 +84,7 @@ function buildVolumeMounts( } // Sync skills from container/skills/ into each group's .claude/skills/ - const skillsSrc = path.join(process.cwd(), 'container', 'skills'); + const skillsSrc = path.join(projectRoot, 'container', 'skills'); const skillsDst = path.join(groupSessionsDir, 'skills'); if (fs.existsSync(skillsSrc)) { for (const skillDir of fs.readdirSync(skillsSrc)) { @@ -150,15 +94,72 @@ function buildVolumeMounts( fs.cpSync(srcDir, dstDir, { recursive: true }); } } - mounts.push({ - hostPath: groupSessionsDir, - containerPath: '/home/node/.claude', - readonly: false, - }); - // For Codex groups: create a per-session writable .codex/ directory - if (group.agentType === 'codex') { - const hostCodexDir = path.join(process.env.HOME || os.homedir(), '.codex'); + // Per-group IPC namespace + const groupIpcDir = resolveGroupIpcPath(group.folder); + fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); + + // Global memory directory (for non-main groups) + const globalDir = path.join(GROUPS_DIR, 'global'); + + // Additional mount directories (validated) + const extraDirs: string[] = []; + if (group.containerConfig?.additionalMounts) { + for (const mount of group.containerConfig.additionalMounts) { + if (fs.existsSync(mount.hostPath)) { + extraDirs.push(mount.hostPath); + } + } + } + + // Determine runner directory + const agentType = group.agentType || 'claude-code'; + const runnerDirName = + agentType === 'codex' ? 'codex-runner' : 'agent-runner'; + const runnerDir = path.join(projectRoot, 'container', runnerDirName); + + // Build environment variables for the runner process + const envVars = readEnvFile([ + 'ANTHROPIC_API_KEY', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'CLAUDE_MODEL', + 'CLAUDE_THINKING', + 'CLAUDE_THINKING_BUDGET', + 'CLAUDE_EFFORT', + 'OPENAI_API_KEY', + 'CODEX_OPENAI_API_KEY', + ]); + + const env: Record = { + ...process.env as Record, + TZ: TIMEZONE, + HOME: os.homedir(), + // Path configuration for the runner + NANOCLAW_GROUP_DIR: groupDir, + NANOCLAW_IPC_DIR: groupIpcDir, + NANOCLAW_GLOBAL_DIR: globalDir, + NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '', + // MCP server context + NANOCLAW_CHAT_JID: group.folder, + NANOCLAW_GROUP_FOLDER: group.folder, + NANOCLAW_IS_MAIN: isMain ? '1' : '0', + // Claude sessions directory — set CLAUDE_CONFIG_DIR so SDK uses per-group sessions + CLAUDE_CONFIG_DIR: groupSessionsDir, + }; + + // Pass credentials directly (no proxy needed on host) + if (agentType === 'codex') { + const openaiKey = + envVars.CODEX_OPENAI_API_KEY || + process.env.CODEX_OPENAI_API_KEY || + envVars.OPENAI_API_KEY || + process.env.OPENAI_API_KEY; + if (openaiKey) env.OPENAI_API_KEY = openaiKey; + + // Codex session directory + const hostCodexDir = path.join(os.homedir(), '.codex'); const sessionCodexDir = path.join( DATA_DIR, 'sessions', @@ -166,15 +167,9 @@ function buildVolumeMounts( '.codex', ); fs.mkdirSync(sessionCodexDir, { recursive: true }); - - // Always refresh auth credentials from host const authSrc = path.join(hostCodexDir, 'auth.json'); const authDst = path.join(sessionCodexDir, 'auth.json'); - if (fs.existsSync(authSrc)) { - fs.copyFileSync(authSrc, authDst); - } - - // Only copy config files if they don't exist yet (preserves per-session customization) + if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst); for (const file of ['config.toml', 'config.json']) { const src = path.join(hostCodexDir, file); const dst = path.join(sessionCodexDir, file); @@ -182,148 +177,37 @@ function buildVolumeMounts( fs.copyFileSync(src, dst); } } - - mounts.push({ - hostPath: sessionCodexDir, - containerPath: '/home/node/.codex', - readonly: false, - }); - } - - // Per-group IPC namespace: each group gets its own IPC directory - // This prevents cross-group privilege escalation via IPC - const groupIpcDir = resolveGroupIpcPath(group.folder); - fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); - mounts.push({ - hostPath: groupIpcDir, - containerPath: '/workspace/ipc', - readonly: false, - }); - - // Copy runner source into a per-group writable location so agents - // can customize it (add tools, change behavior) without affecting other - // groups. Recompiled on container startup via entrypoint.sh. - const agentType = group.agentType || 'claude-code'; - const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner'; - const runnerSrcSuffix = - agentType === 'codex' ? 'codex-runner-src' : 'agent-runner-src'; - const agentRunnerSrc = path.join( - projectRoot, - 'container', - runnerDirName, - 'src', - ); - const groupAgentRunnerDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - runnerSrcSuffix, - ); - if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); - } - mounts.push({ - hostPath: groupAgentRunnerDir, - containerPath: '/app/src', - readonly: false, - }); - - // Additional mounts validated against external allowlist (tamper-proof from containers) - if (group.containerConfig?.additionalMounts) { - const validatedMounts = validateAdditionalMounts( - group.containerConfig.additionalMounts, - group.name, - isMain, - ); - mounts.push(...validatedMounts); - } - - return mounts; -} - -function buildContainerArgs( - mounts: VolumeMount[], - containerName: string, - isMain: boolean, - agentType: 'claude-code' | 'codex' = 'claude-code', -): string[] { - const args: string[] = ['run', '-i', '--rm', '--name', containerName]; - - // Pass host timezone so container's local time matches the user's - args.push('-e', `TZ=${TIMEZONE}`); - - if (agentType === 'codex') { - // Codex uses OpenAI API — pass the key directly (no credential proxy) - const openaiKey = - process.env.CODEX_OPENAI_API_KEY || process.env.OPENAI_API_KEY; - if (openaiKey) { - args.push('-e', `OPENAI_API_KEY=${openaiKey}`); - } + env.CODEX_CONFIG_DIR = sessionCodexDir; } else { - // Route API traffic through the credential proxy (containers never see real secrets) - args.push( - '-e', - `ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, - ); - - // Mirror the host's auth method with a placeholder value. - const authMode = detectAuthMode(); - if (authMode === 'api-key') { - args.push('-e', 'ANTHROPIC_API_KEY=placeholder'); - } else { - args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder'); + // Claude Code — pass real credentials directly + if (envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) { + env.ANTHROPIC_API_KEY = + envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || ''; + } + if ( + envVars.CLAUDE_CODE_OAUTH_TOKEN || + process.env.CLAUDE_CODE_OAUTH_TOKEN + ) { + env.CLAUDE_CODE_OAUTH_TOKEN = + envVars.CLAUDE_CODE_OAUTH_TOKEN || + process.env.CLAUDE_CODE_OAUTH_TOKEN || + ''; + } + // Model/thinking config + for (const key of [ + 'CLAUDE_MODEL', + 'CLAUDE_THINKING', + 'CLAUDE_THINKING_BUDGET', + 'CLAUDE_EFFORT', + ]) { + const val = + envVars[key as keyof typeof envVars] || + process.env[key]; + if (val) env[key] = val; } } - // Runtime-specific args for host gateway resolution - args.push(...hostGatewayArgs()); - - // Pass model and thinking configuration to container agent (read from .env file) - const modelEnv = readEnvFile(['CLAUDE_MODEL', 'CLAUDE_THINKING', 'CLAUDE_THINKING_BUDGET', 'CLAUDE_EFFORT']); - if (modelEnv.CLAUDE_MODEL) { - args.push('-e', `CLAUDE_MODEL=${modelEnv.CLAUDE_MODEL}`); - } - if (modelEnv.CLAUDE_THINKING) { - args.push('-e', `CLAUDE_THINKING=${modelEnv.CLAUDE_THINKING}`); - } - if (modelEnv.CLAUDE_THINKING_BUDGET) { - args.push('-e', `CLAUDE_THINKING_BUDGET=${modelEnv.CLAUDE_THINKING_BUDGET}`); - } - if (modelEnv.CLAUDE_EFFORT) { - args.push('-e', `CLAUDE_EFFORT=${modelEnv.CLAUDE_EFFORT}`); - } - - // Run as host user so bind-mounted files are accessible. - // Skip when running as root (uid 0), as the container's node user (uid 1000), - // or when getuid is unavailable (native Windows without WSL). - const hostUid = process.getuid?.(); - const hostGid = process.getgid?.(); - if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { - if (isMain) { - // Main containers start as root so the entrypoint can mount --bind - // to shadow .env. Privileges are dropped via setpriv in entrypoint.sh. - args.push('-e', `RUN_UID=${hostUid}`); - args.push('-e', `RUN_GID=${hostGid}`); - } else { - args.push('--user', `${hostUid}:${hostGid}`); - } - args.push('-e', 'HOME=/home/node'); - } - - for (const mount of mounts) { - if (mount.readonly) { - args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); - } else { - args.push('-v', `${mount.hostPath}:${mount.containerPath}`); - } - } - - // Select container image based on agent type - args.push(agentType === 'codex' ? CODEX_CONTAINER_IMAGE : CONTAINER_IMAGE); - - return args; + return { env, groupDir, runnerDir }; } export async function runContainerAgent( @@ -333,71 +217,66 @@ export async function runContainerAgent( onOutput?: (output: ContainerOutput) => Promise, ): Promise { const startTime = Date.now(); - - const groupDir = resolveGroupFolderPath(group.folder); - fs.mkdirSync(groupDir, { recursive: true }); - - const mounts = buildVolumeMounts(group, input.isMain); - const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); - const containerName = `nanoclaw-${safeName}-${Date.now()}`; - const agentType = group.agentType || 'claude-code'; - const containerArgs = buildContainerArgs( - mounts, - containerName, + const { env, groupDir, runnerDir } = prepareGroupEnvironment( + group, input.isMain, - agentType, ); - logger.debug( - { - group: group.name, - containerName, - mounts: mounts.map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ), - containerArgs: containerArgs.join(' '), - }, - 'Container mount configuration', - ); + const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); + const processName = `nanoclaw-${safeName}-${Date.now()}`; + + // Check if runner is built + const distEntry = path.join(runnerDir, 'dist', 'index.js'); + if (!fs.existsSync(distEntry)) { + logger.error( + { runnerDir }, + 'Runner not built. Run: cd container/agent-runner && npm install && npm run build', + ); + return { + status: 'error', + result: null, + error: `Runner not built at ${distEntry}. Run npm run build:runners first.`, + }; + } logger.info( { group: group.name, - containerName, - mountCount: mounts.length, + processName, + agentType: group.agentType || 'claude-code', isMain: input.isMain, }, - 'Spawning container agent', + 'Spawning agent process', ); const logsDir = path.join(groupDir, 'logs'); fs.mkdirSync(logsDir, { recursive: true }); return new Promise((resolve) => { - const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { + const proc = spawn('node', [distEntry], { stdio: ['pipe', 'pipe', 'pipe'], + cwd: runnerDir, + env, }); - onProcess(container, containerName); + onProcess(proc, processName); let stdout = ''; let stderr = ''; let stdoutTruncated = false; let stderrTruncated = false; - container.stdin.write(JSON.stringify(input)); - container.stdin.end(); + proc.stdin.write(JSON.stringify(input)); + proc.stdin.end(); // Streaming output: parse OUTPUT_START/END marker pairs as they arrive let parseBuffer = ''; let newSessionId: string | undefined; let outputChain = Promise.resolve(); - container.stdout.on('data', (data) => { + proc.stdout.on('data', (data) => { const chunk = data.toString(); - // Always accumulate for logging if (!stdoutTruncated) { const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length; if (chunk.length > remaining) { @@ -405,20 +284,21 @@ export async function runContainerAgent( stdoutTruncated = true; logger.warn( { group: group.name, size: stdout.length }, - 'Container stdout truncated due to size limit', + 'Agent stdout truncated due to size limit', ); } else { stdout += chunk; } } - // Stream-parse for output markers if (onOutput) { parseBuffer += chunk; let startIdx: number; - while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { + while ( + (startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1 + ) { const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); - if (endIdx === -1) break; // Incomplete pair, wait for more data + if (endIdx === -1) break; const jsonStr = parseBuffer .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) @@ -431,10 +311,7 @@ export async function runContainerAgent( newSessionId = parsed.newSessionId; } hadStreamingOutput = true; - // Activity detected — reset the hard timeout resetTimeout(); - // Call onOutput for all markers (including null results) - // so idle timers start even for "silent" query completions. outputChain = outputChain.then(() => onOutput(parsed)); } catch (err) { logger.warn( @@ -446,23 +323,17 @@ export async function runContainerAgent( } }); - container.stderr.on('data', (data) => { + proc.stderr.on('data', (data) => { const chunk = data.toString(); const lines = chunk.trim().split('\n'); for (const line of lines) { - if (line) logger.debug({ container: group.folder }, line); + if (line) logger.debug({ agent: group.folder }, line); } - // Don't reset timeout on stderr — SDK writes debug logs continuously. - // Timeout only resets on actual output (OUTPUT_MARKER in stdout). if (stderrTruncated) return; const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length; if (chunk.length > remaining) { stderr += chunk.slice(0, remaining); stderrTruncated = true; - logger.warn( - { group: group.name, size: stderr.length }, - 'Container stderr truncated due to size limit', - ); } else { stderr += chunk; } @@ -471,240 +342,170 @@ export async function runContainerAgent( let timedOut = false; let hadStreamingOutput = false; const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT; - // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the - // graceful _close sentinel has time to trigger before the hard kill fires. const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000); const killOnTimeout = () => { timedOut = true; logger.error( - { group: group.name, containerName }, - 'Container timeout, stopping gracefully', + { group: group.name, processName }, + 'Agent timeout, sending SIGTERM', ); - exec(stopContainer(containerName), { timeout: 15000 }, (err) => { - if (err) { - logger.warn( - { group: group.name, containerName, err }, - 'Graceful stop failed, force killing', - ); - container.kill('SIGKILL'); - } - }); + proc.kill('SIGTERM'); + // Force kill after 15s if still alive + setTimeout(() => { + if (!proc.killed) proc.kill('SIGKILL'); + }, 15000); }; let timeout = setTimeout(killOnTimeout, timeoutMs); - // Reset the timeout whenever there's activity (streaming output) const resetTimeout = () => { clearTimeout(timeout); timeout = setTimeout(killOnTimeout, timeoutMs); }; - container.on('close', (code) => { + proc.on('close', (code) => { clearTimeout(timeout); const duration = Date.now() - startTime; if (timedOut) { const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const timeoutLog = path.join(logsDir, `container-${ts}.log`); fs.writeFileSync( - timeoutLog, + path.join(logsDir, `agent-${ts}.log`), [ - `=== Container Run Log (TIMEOUT) ===`, + `=== Agent Run Log (TIMEOUT) ===`, `Timestamp: ${new Date().toISOString()}`, `Group: ${group.name}`, - `Container: ${containerName}`, + `Process: ${processName}`, `Duration: ${duration}ms`, `Exit Code: ${code}`, `Had Streaming Output: ${hadStreamingOutput}`, ].join('\n'), ); - // Timeout after output = idle cleanup, not failure. - // The agent already sent its response; this is just the - // container being reaped after the idle period expired. if (hadStreamingOutput) { logger.info( - { group: group.name, containerName, duration, code }, - 'Container timed out after output (idle cleanup)', + { group: group.name, processName, duration, code }, + 'Agent timed out after output (idle cleanup)', ); outputChain.then(() => { - resolve({ - status: 'success', - result: null, - newSessionId, - }); + resolve({ status: 'success', result: null, newSessionId }); }); return; } - logger.error( - { group: group.name, containerName, duration, code }, - 'Container timed out with no output', - ); - resolve({ status: 'error', result: null, - error: `Container timed out after ${configTimeout}ms`, + error: `Agent timed out after ${configTimeout}ms`, }); return; } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logFile = path.join(logsDir, `container-${timestamp}.log`); + const logFile = path.join(logsDir, `agent-${timestamp}.log`); const isVerbose = - process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; + process.env.LOG_LEVEL === 'debug' || + process.env.LOG_LEVEL === 'trace'; const logLines = [ - `=== Container Run Log ===`, + `=== Agent Run Log ===`, `Timestamp: ${new Date().toISOString()}`, `Group: ${group.name}`, `IsMain: ${input.isMain}`, + `AgentType: ${group.agentType || 'claude-code'}`, `Duration: ${duration}ms`, `Exit Code: ${code}`, - `Stdout Truncated: ${stdoutTruncated}`, - `Stderr Truncated: ${stderrTruncated}`, ``, ]; const isError = code !== 0; - if (isVerbose || isError) { logLines.push( `=== Input ===`, JSON.stringify(input, null, 2), ``, - `=== Container Args ===`, - containerArgs.join(' '), - ``, - `=== Mounts ===`, - mounts - .map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ) - .join('\n'), - ``, - `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, + `=== Stderr ===`, stderr, ``, - `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, + `=== Stdout ===`, stdout, ); } else { logLines.push( - `=== Input Summary ===`, `Prompt length: ${input.prompt.length} chars`, `Session ID: ${input.sessionId || 'new'}`, - ``, - `=== Mounts ===`, - mounts - .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`) - .join('\n'), - ``, ); } fs.writeFileSync(logFile, logLines.join('\n')); - logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); if (code !== 0) { logger.error( - { - group: group.name, - code, - duration, - stderr, - stdout, - logFile, - }, - 'Container exited with error', + { group: group.name, code, duration, logFile }, + 'Agent exited with error', ); - resolve({ status: 'error', result: null, - error: `Container exited with code ${code}: ${stderr.slice(-200)}`, + error: `Agent exited with code ${code}: ${stderr.slice(-200)}`, }); return; } - // Streaming mode: wait for output chain to settle, return completion marker if (onOutput) { outputChain.then(() => { logger.info( { group: group.name, duration, newSessionId }, - 'Container completed (streaming mode)', + 'Agent completed (streaming mode)', ); - resolve({ - status: 'success', - result: null, - newSessionId, - }); + resolve({ status: 'success', result: null, newSessionId }); }); return; } - // Legacy mode: parse the last output marker pair from accumulated stdout + // Legacy mode: parse output from stdout try { - // Extract JSON between sentinel markers for robust parsing const startIdx = stdout.indexOf(OUTPUT_START_MARKER); const endIdx = stdout.indexOf(OUTPUT_END_MARKER); - let jsonLine: string; if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { jsonLine = stdout .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) .trim(); } else { - // Fallback: last non-empty line (backwards compatibility) const lines = stdout.trim().split('\n'); jsonLine = lines[lines.length - 1]; } - const output: ContainerOutput = JSON.parse(jsonLine); - logger.info( - { - group: group.name, - duration, - status: output.status, - hasResult: !!output.result, - }, - 'Container completed', + { group: group.name, duration, status: output.status }, + 'Agent completed', ); - resolve(output); } catch (err) { logger.error( - { - group: group.name, - stdout, - stderr, - error: err, - }, - 'Failed to parse container output', + { group: group.name, error: err }, + 'Failed to parse agent output', ); - resolve({ status: 'error', result: null, - error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`, + error: `Failed to parse agent output: ${err instanceof Error ? err.message : String(err)}`, }); } }); - container.on('error', (err) => { + proc.on('error', (err) => { clearTimeout(timeout); logger.error( - { group: group.name, containerName, error: err }, - 'Container spawn error', + { group: group.name, processName, error: err }, + 'Agent spawn error', ); resolve({ status: 'error', result: null, - error: `Container spawn error: ${err.message}`, + error: `Agent spawn error: ${err.message}`, }); }); }); @@ -723,15 +524,11 @@ export function writeTasksSnapshot( next_run: string | null; }>, ): void { - // Write filtered tasks to the group's IPC directory const groupIpcDir = resolveGroupIpcPath(groupFolder); fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all tasks, others only see their own const filteredTasks = isMain ? tasks : tasks.filter((t) => t.groupFolder === groupFolder); - const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); } @@ -743,11 +540,6 @@ export interface AvailableGroup { isRegistered: boolean; } -/** - * Write available groups snapshot for the container to read. - * Only main group can see all available groups (for activation). - * Non-main groups only see their own registration status. - */ export function writeGroupsSnapshot( groupFolder: string, isMain: boolean, @@ -756,18 +548,12 @@ export function writeGroupsSnapshot( ): void { const groupIpcDir = resolveGroupIpcPath(groupFolder); fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all groups; others see nothing (they can't activate groups) const visibleGroups = isMain ? groups : []; - const groupsFile = path.join(groupIpcDir, 'available_groups.json'); fs.writeFileSync( groupsFile, JSON.stringify( - { - groups: visibleGroups, - lastSync: new Date().toISOString(), - }, + { groups: visibleGroups, lastSync: new Date().toISOString() }, null, 2, ), diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 79b77a3..9312ed5 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -62,7 +62,9 @@ describe('ensureContainerRuntimeRunning', () => { `${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' }, ); - expect(logger.debug).toHaveBeenCalledWith('Container runtime already running'); + expect(logger.debug).toHaveBeenCalledWith( + 'Container runtime already running', + ); }); it('auto-starts when system status fails', () => { diff --git a/src/container-runtime.ts b/src/container-runtime.ts index de53cff..6c62041 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -30,8 +30,14 @@ export function hostGatewayArgs(): string[] { } /** 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`]; +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. */ @@ -47,7 +53,10 @@ export function ensureContainerRuntimeRunning(): void { } catch { logger.info('Starting container runtime...'); try { - execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 }); + 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'); @@ -87,17 +96,26 @@ export function cleanupOrphans(): void { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', }); - const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]'); + const containers: { status: string; configuration: { id: string } }[] = + JSON.parse(output || '[]'); const orphans = containers - .filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-')) + .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 */ } + } catch { + /* already stopped */ + } } if (orphans.length > 0) { - logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); + logger.info( + { count: orphans.length, names: orphans }, + 'Stopped orphaned containers', + ); } } catch (err) { logger.warn({ err }, 'Failed to clean up orphaned containers'); diff --git a/src/index.ts b/src/index.ts index c33f643..7548d53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,13 +3,11 @@ import path from 'path'; import { ASSISTANT_NAME, - CREDENTIAL_PROXY_PORT, IDLE_TIMEOUT, POLL_INTERVAL, TIMEZONE, TRIGGER_PATTERN, } from './config.js'; -import { startCredentialProxy } from './credential-proxy.js'; import './channels/index.js'; import { getChannelFactory, @@ -21,11 +19,6 @@ import { writeGroupsSnapshot, writeTasksSnapshot, } from './container-runner.js'; -import { - cleanupOrphans, - ensureContainerRuntimeRunning, - PROXY_BIND_HOST, -} from './container-runtime.js'; import { getAllChats, getAllRegisteredGroups, @@ -53,7 +46,11 @@ import { loadSenderAllowlist, shouldDropMessage, } from './sender-allowlist.js'; -import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js'; +import { + extractSessionCommand, + handleSessionCommand, + isSessionCommandAllowed, +} from './session-commands.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; @@ -175,18 +172,26 @@ async function processGroupMessages(chatJid: string): Promise { timezone: TIMEZONE, deps: { sendMessage: (text) => channel.sendMessage(chatJid, text), - setTyping: (typing) => channel.setTyping?.(chatJid, typing) ?? Promise.resolve(), - runAgent: (prompt, onOutput) => runAgent(group, prompt, chatJid, onOutput), + setTyping: (typing) => + channel.setTyping?.(chatJid, typing) ?? Promise.resolve(), + runAgent: (prompt, onOutput) => + runAgent(group, prompt, chatJid, onOutput), closeStdin: () => queue.closeStdin(chatJid), - advanceCursor: (ts) => { lastAgentTimestamp[chatJid] = ts; saveState(); }, + advanceCursor: (ts) => { + lastAgentTimestamp[chatJid] = ts; + saveState(); + }, formatMessages, canSenderInteract: (msg) => { const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim()); const reqTrigger = !isMainGroup && group.requiresTrigger !== false; - return isMainGroup || !reqTrigger || (hasTrigger && ( - msg.is_from_me || - isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist()) - )); + return ( + isMainGroup || + !reqTrigger || + (hasTrigger && + (msg.is_from_me || + isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist()))) + ); }, }, }); @@ -431,8 +436,7 @@ async function startMessageLoop(): Promise { const lastHuman = getLastHumanMessageTimestamp(chatJid); if ( !lastHuman || - Date.now() - new Date(lastHuman).getTime() > - BOT_COLLAB_TIMEOUT_MS + Date.now() - new Date(lastHuman).getTime() > BOT_COLLAB_TIMEOUT_MS ) { logger.info( { chatJid, lastHuman }, @@ -453,7 +457,12 @@ async function startMessageLoop(): Promise { // Only close active container if the sender is authorized — otherwise an // untrusted user could kill in-flight work by sending /compact (DoS). // closeStdin no-ops internally when no container is active. - if (isSessionCommandAllowed(isMainGroup, loopCmdMsg.is_from_me === true)) { + if ( + isSessionCommandAllowed( + isMainGroup, + loopCmdMsg.is_from_me === true, + ) + ) { queue.closeStdin(chatJid); } // Enqueue so processGroupMessages handles auth + cursor advancement. @@ -536,23 +545,11 @@ function recoverPendingMessages(): void { } } -function ensureContainerSystemRunning(): void { - ensureContainerRuntimeRunning(); - cleanupOrphans(); -} - async function main(): Promise { - ensureContainerSystemRunning(); initDatabase(); logger.info('Database initialized'); loadState(); - // Start credential proxy (containers route API calls through this) - await startCredentialProxy( - CREDENTIAL_PROXY_PORT, - PROXY_BIND_HOST, - ); - // Graceful shutdown handlers const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index 7cbc680..4a3cbfe 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; -import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js'; +import { + extractSessionCommand, + handleSessionCommand, + isSessionCommandAllowed, +} from './session-commands.js'; import type { NewMessage } from './types.js'; import type { SessionCommandDeps } from './session-commands.js'; @@ -23,7 +27,9 @@ describe('extractSessionCommand', () => { }); it('rejects regular messages', () => { - expect(extractSessionCommand('please compact the conversation', trigger)).toBeNull(); + expect( + extractSessionCommand('please compact the conversation', trigger), + ).toBeNull(); }); it('handles whitespace', () => { @@ -53,7 +59,10 @@ describe('isSessionCommandAllowed', () => { }); }); -function makeMsg(content: string, overrides: Partial = {}): NewMessage { +function makeMsg( + content: string, + overrides: Partial = {}, +): NewMessage { return { id: 'msg-1', chat_jid: 'group@test', @@ -65,7 +74,9 @@ function makeMsg(content: string, overrides: Partial = {}): NewMessa }; } -function makeDeps(overrides: Partial = {}): SessionCommandDeps { +function makeDeps( + overrides: Partial = {}, +): SessionCommandDeps { return { sendMessage: vi.fn().mockResolvedValue(undefined), setTyping: vi.fn().mockResolvedValue(undefined), @@ -105,7 +116,10 @@ describe('handleSessionCommand', () => { deps, }); expect(result).toEqual({ handled: true, success: true }); - expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function)); + expect(deps.runAgent).toHaveBeenCalledWith( + '/compact', + expect.any(Function), + ); expect(deps.advanceCursor).toHaveBeenCalledWith('100'); }); @@ -120,13 +134,17 @@ describe('handleSessionCommand', () => { deps, }); expect(result).toEqual({ handled: true, success: true }); - expect(deps.sendMessage).toHaveBeenCalledWith('Session commands require admin access.'); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'Session commands require admin access.', + ); expect(deps.runAgent).not.toHaveBeenCalled(); expect(deps.advanceCursor).toHaveBeenCalledWith('100'); }); it('silently consumes denied command when sender cannot interact', async () => { - const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false) }); + const deps = makeDeps({ + canSenderInteract: vi.fn().mockReturnValue(false), + }); const result = await handleSessionCommand({ missedMessages: [makeMsg('/compact', { is_from_me: false })], isMainGroup: false, @@ -158,8 +176,14 @@ describe('handleSessionCommand', () => { expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC'); // Two runAgent calls: pre-compact + /compact expect(deps.runAgent).toHaveBeenCalledTimes(2); - expect(deps.runAgent).toHaveBeenCalledWith('', expect.any(Function)); - expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function)); + expect(deps.runAgent).toHaveBeenCalledWith( + '', + expect.any(Function), + ); + expect(deps.runAgent).toHaveBeenCalledWith( + '/compact', + expect.any(Function), + ); }); it('allows is_from_me sender in non-main group', async () => { @@ -173,15 +197,20 @@ describe('handleSessionCommand', () => { deps, }); expect(result).toEqual({ handled: true, success: true }); - expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function)); + expect(deps.runAgent).toHaveBeenCalledWith( + '/compact', + expect.any(Function), + ); }); it('reports failure when command-stage runAgent returns error without streamed status', async () => { // runAgent resolves 'error' but callback never gets status: 'error' - const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => { - await onOutput({ status: 'success', result: null }); - return 'error'; - })}); + const deps = makeDeps({ + runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => { + await onOutput({ status: 'success', result: null }); + return 'error'; + }), + }); const result = await handleSessionCommand({ missedMessages: [makeMsg('/compact')], isMainGroup: true, @@ -191,7 +220,9 @@ describe('handleSessionCommand', () => { deps, }); expect(result).toEqual({ handled: true, success: true }); - expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('failed')); + expect(deps.sendMessage).toHaveBeenCalledWith( + expect.stringContaining('failed'), + ); }); it('returns success:false on pre-compact failure with no output', async () => { @@ -209,6 +240,8 @@ describe('handleSessionCommand', () => { deps, }); expect(result).toEqual({ handled: true, success: false }); - expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to process')); + expect(deps.sendMessage).toHaveBeenCalledWith( + expect.stringContaining('Failed to process'), + ); }); }); diff --git a/src/session-commands.ts b/src/session-commands.ts index 69ea041..2fcc8f2 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -5,7 +5,10 @@ import { logger } from './logger.js'; * Extract a session slash command from a message, stripping the trigger prefix if present. * Returns the slash command (e.g., '/compact') or null if not a session command. */ -export function extractSessionCommand(content: string, triggerPattern: RegExp): string | null { +export function extractSessionCommand( + content: string, + triggerPattern: RegExp, +): string | null { let text = content.trim(); text = text.replace(triggerPattern, '').trim(); if (text === '/compact') return '/compact'; @@ -16,7 +19,10 @@ export function extractSessionCommand(content: string, triggerPattern: RegExp): * Check if a session command sender is authorized. * Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group. */ -export function isSessionCommandAllowed(isMainGroup: boolean, isFromMe: boolean): boolean { +export function isSessionCommandAllowed( + isMainGroup: boolean, + isFromMe: boolean, +): boolean { return isMainGroup || isFromMe; } @@ -61,12 +67,21 @@ export async function handleSessionCommand(opts: { timezone: string; deps: SessionCommandDeps; }): Promise<{ handled: false } | { handled: true; success: boolean }> { - const { missedMessages, isMainGroup, groupName, triggerPattern, timezone, deps } = opts; + const { + missedMessages, + isMainGroup, + groupName, + triggerPattern, + timezone, + deps, + } = opts; const cmdMsg = missedMessages.find( (m) => extractSessionCommand(m.content, triggerPattern) !== null, ); - const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null; + const command = cmdMsg + ? extractSessionCommand(cmdMsg.content, triggerPattern) + : null; if (!command || !cmdMsg) return { handled: false }; @@ -109,8 +124,13 @@ export async function handleSessionCommand(opts: { }); if (preResult === 'error' || hadPreError) { - logger.warn({ group: groupName }, 'Pre-compact processing failed, aborting session command'); - await deps.sendMessage(`Failed to process messages before ${command}. Try again.`); + logger.warn( + { group: groupName }, + 'Pre-compact processing failed, aborting session command', + ); + await deps.sendMessage( + `Failed to process messages before ${command}. Try again.`, + ); if (preOutputSent) { // Output was already sent — don't retry or it will duplicate. // Advance cursor past pre-compact messages, leave command pending.