/** * Container runner for EJClaw reviewer agents. * * Uses persistent Docker containers per channel. The container is created * once on first reviewer call and reused across turns. Each turn runs via * `docker exec` inside the warm container — no startup overhead. * * - Source code mounted read-only (kernel-level write protection) * - tmpfs overlays for test runner caches (vitest, coverage) * - IPC via filesystem (input/ directory for follow-up messages) * - Credentials injected by the credential proxy (never exposed to container) */ import { ChildProcess, execFileSync, spawn } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js'; import { AGENT_MAX_OUTPUT_SIZE, AGENT_TIMEOUT, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, TIMEZONE, } from './config.js'; import { CONTAINER_RUNTIME_BIN, ensureContainerRuntimeRunning, hostGatewayArgs, readonlyMountArgs, tmpfsMountArgs, writableMountArgs, } from './container-runtime.js'; import { ensureClaudeGlobalSettingsFile } from './agent-runner-environment.js'; import { detectAuthMode } from './credential-proxy.js'; import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { logger } from './logger.js'; import type { AgentOutput } from './agent-runner.js'; import type { RegisteredGroup } from './types.js'; import { detectPnpmStorePath } from './workspace-package-manager.js'; // ── Config ──────────────────────────────────────────────────────── import { REVIEWER_CONTAINER_IMAGE as CONTAINER_IMAGE } from './config.js'; // ── Types ───────────────────────────────────────────────────────── export interface ReviewerContainerInput { prompt: string; sessionId?: string; groupFolder: string; chatJid: string; runId: string; isMain: boolean; assistantName?: string; roomRoleContext?: import('./types.js').RoomRoleContext; } interface VolumeMount { hostPath: string; containerPath: string; readonly: boolean; } interface InspectedContainerMount { Source?: string; Destination?: string; RW?: boolean; } interface InspectedContainer { Mounts?: InspectedContainerMount[]; } const PRIMARY_PROJECT_MOUNT = '/workspace/project'; function pushMountOnce(mounts: VolumeMount[], mount: VolumeMount): void { const exists = mounts.some( (entry) => entry.hostPath === mount.hostPath && entry.containerPath === mount.containerPath && entry.readonly === mount.readonly, ); if (!exists) { mounts.push(mount); } } function normalizeLocalGitPath(remoteUrl: string): string | null { if (!remoteUrl) { return null; } if (path.isAbsolute(remoteUrl)) { return path.resolve(remoteUrl); } if (remoteUrl.startsWith('file://')) { try { const parsed = new URL(remoteUrl); if (parsed.protocol === 'file:') { return path.resolve(parsed.pathname); } } catch { return null; } } return null; } function resolveLocalOriginTarget(workspaceDir: string): string | null { try { const remoteUrl = execFileSync( 'git', ['config', '--get', 'remote.origin.url'], { cwd: workspaceDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, }, ).trim(); const localPath = normalizeLocalGitPath(remoteUrl); if (!localPath || !fs.existsSync(localPath)) { return null; } return localPath; } catch { return null; } } function pushWorktreeGitMetadataMounts( mounts: VolumeMount[], repoDir: string, ): void { const dotGitPath = path.join(repoDir, '.git'); try { const stat = fs.statSync(dotGitPath); if (!stat.isFile()) { return; } const content = fs.readFileSync(dotGitPath, 'utf-8').trim(); const match = content.match(/^gitdir:\s*(.+)$/); if (!match) { return; } const worktreeGitDir = path.resolve(repoDir, match[1]); const parentGitDir = path.resolve(worktreeGitDir, '..', '..'); if (!fs.existsSync(parentGitDir)) { return; } pushMountOnce(mounts, { hostPath: parentGitDir, containerPath: parentGitDir, readonly: true, }); logger.debug( { parentGitDir, worktreeGitDir, repoDir }, 'Mounting parent .git for worktree resolution', ); } catch { // Not a git repo or .git missing — skip } } // ── Pre-flight checks ──────────────────────────────────────────── let containerRuntimeChecked = false; function ensureContainerReady(): void { if (containerRuntimeChecked) return; ensureContainerRuntimeRunning(); try { execFileSync(CONTAINER_RUNTIME_BIN, ['image', 'inspect', CONTAINER_IMAGE], { stdio: 'pipe', timeout: 10000, }); } catch { throw new Error( `Container image '${CONTAINER_IMAGE}' not found. Build it with: ./container/build.sh`, ); } containerRuntimeChecked = true; } // ── Persistent container management ────────────────────────────── function getContainerName(groupFolder: string): string { return `ejclaw-reviewer-${groupFolder.replace(/[^a-zA-Z0-9-]/g, '-')}`; } function isContainerRunning(name: string): boolean { try { const state = execFileSync( CONTAINER_RUNTIME_BIN, ['inspect', '--format', '{{.State.Running}}', name], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }, ).trim(); return state === 'true'; } catch { return false; } } function inspectContainer(name: string): InspectedContainer | null { try { const output = execFileSync(CONTAINER_RUNTIME_BIN, ['inspect', name], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000, }); const parsed = JSON.parse(output) as InspectedContainer[]; return parsed[0] ?? null; } catch { return null; } } function containerHasExpectedMounts( name: string, expectedMounts: VolumeMount[], ): boolean { const inspection = inspectContainer(name); const actualMounts = inspection?.Mounts ?? []; return expectedMounts.every((expected) => actualMounts.some( (actual) => actual.Source === expected.hostPath && actual.Destination === expected.containerPath && Boolean(actual.RW) === !expected.readonly, ), ); } function ensurePersistentContainer( group: RegisteredGroup, ownerWorkspaceDir: string, envOverrides?: Record, ): string { const containerName = getContainerName(group.folder); const mounts = buildReviewerMounts(group, ownerWorkspaceDir); if (isContainerRunning(containerName)) { if (containerHasExpectedMounts(containerName, mounts)) { return containerName; } logger.info( { containerName, group: group.name, groupFolder: group.folder, }, 'Recreating persistent reviewer container because mount layout changed', ); } // Remove stale stopped container with same name try { execFileSync(CONTAINER_RUNTIME_BIN, ['rm', '-f', containerName], { stdio: 'pipe', timeout: 10000, }); } catch { /* doesn't exist */ } const args = buildCreateArgs(mounts, containerName); logger.info( { containerName, group: group.name, groupFolder: group.folder, image: CONTAINER_IMAGE, mountCount: mounts.length, }, 'Creating persistent reviewer container', ); execFileSync(CONTAINER_RUNTIME_BIN, args, { stdio: 'pipe', timeout: 30000, }); return containerName; } /** Stop and remove a persistent reviewer container for a channel. */ export function stopReviewerContainer(groupFolder: string): void { const name = getContainerName(groupFolder); try { execFileSync(CONTAINER_RUNTIME_BIN, ['rm', '-f', name], { stdio: 'pipe', timeout: 10000, }); logger.info( { containerName: name }, 'Stopped persistent reviewer container', ); } catch { /* already gone */ } } /** * No-op — container recreation is no longer needed after token refresh. * Tokens are now synced to process.env, and `docker exec -e` injects * the latest token at each turn. Kept as a no-op to avoid breaking callers. */ export function recreateAllReviewerContainers(): void { // Intentionally empty — docker exec -e picks up refreshed tokens // from process.env without needing to recreate the container. } // ── Mount builder ───────────────────────────────────────────────── export function buildReviewerMounts( group: RegisteredGroup, ownerWorkspaceDir: string, ): VolumeMount[] { const mounts: VolumeMount[] = []; const groupDir = resolveGroupFolderPath(group.folder); const groupIpcDir = resolveGroupIpcPath(group.folder); // Source code: READ-ONLY (kernel-level protection) pushMountOnce(mounts, { hostPath: ownerWorkspaceDir, containerPath: PRIMARY_PROJECT_MOUNT, readonly: true, }); const canonicalRepoDir = resolveLocalOriginTarget(ownerWorkspaceDir); if ( canonicalRepoDir && canonicalRepoDir !== ownerWorkspaceDir && canonicalRepoDir !== PRIMARY_PROJECT_MOUNT ) { pushMountOnce(mounts, { hostPath: canonicalRepoDir, containerPath: canonicalRepoDir, readonly: true, }); } // Compatibility mount: expose the owner workspace at the same absolute path // inside the container so owner-authored absolute paths still resolve. if (ownerWorkspaceDir !== PRIMARY_PROJECT_MOUNT) { pushMountOnce(mounts, { hostPath: ownerWorkspaceDir, containerPath: ownerWorkspaceDir, readonly: true, }); } // Git worktree support: worktree's .git file references the parent repo's // .git directory via absolute path. Mount the parent .git at the same host // path so git commands resolve inside the container. pushWorktreeGitMetadataMounts(mounts, ownerWorkspaceDir); if (canonicalRepoDir) { pushWorktreeGitMetadataMounts(mounts, canonicalRepoDir); } // pnpm global store: mount at the same host path so hardlinks resolve. const pnpmStore = detectPnpmStorePath(ownerWorkspaceDir); if (pnpmStore) { pushMountOnce(mounts, { hostPath: pnpmStore, containerPath: pnpmStore, readonly: true, }); logger.debug({ pnpmStore }, 'Mounting pnpm store for container'); } // Shadow .env so reviewer cannot read secrets from mounted project const envFile = path.join(ownerWorkspaceDir, '.env'); const shadowPaths = new Set(); if (fs.existsSync(envFile)) { shadowPaths.add(path.join(PRIMARY_PROJECT_MOUNT, '.env')); shadowPaths.add(path.join(ownerWorkspaceDir, '.env')); } const canonicalEnvFile = canonicalRepoDir ? path.join(canonicalRepoDir, '.env') : null; if (canonicalEnvFile && fs.existsSync(canonicalEnvFile)) { shadowPaths.add(canonicalEnvFile); } for (const shadowPath of shadowPaths) { pushMountOnce(mounts, { hostPath: '/dev/null', containerPath: shadowPath, readonly: true, }); } // Attachments directory: read-only (Discord file uploads downloaded here) const attachmentsDir = path.join(DATA_DIR, 'attachments'); if (fs.existsSync(attachmentsDir)) { pushMountOnce(mounts, { hostPath: attachmentsDir, containerPath: attachmentsDir, readonly: true, }); } // Group folder: writable (logs, session data) pushMountOnce(mounts, { hostPath: groupDir, containerPath: '/workspace/group', readonly: false, }); // IPC directory: writable (output messages, task results) fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); pushMountOnce(mounts, { hostPath: groupIpcDir, containerPath: '/workspace/ipc', readonly: false, }); // Global memory: read-only const globalDir = path.join(GROUPS_DIR, 'global'); if (fs.existsSync(globalDir)) { pushMountOnce(mounts, { hostPath: globalDir, containerPath: '/workspace/global', readonly: true, }); } // Session directory for Claude/Codex state. // Use the reviewer-suffixed path to match paired-execution-context.ts // which writes CLAUDE.md (prompts) to {folder}-reviewer/.claude/. const groupSessionsDir = path.join( DATA_DIR, 'sessions', `${group.folder}-reviewer`, ); fs.mkdirSync(groupSessionsDir, { recursive: true }); ensureClaudeGlobalSettingsFile(groupSessionsDir); pushMountOnce(mounts, { hostPath: groupSessionsDir, containerPath: '/home/node/.claude', readonly: false, }); pushMountOnce(mounts, { hostPath: path.join(groupSessionsDir, '.claude.json'), containerPath: '/home/node/.claude.json', readonly: false, }); // Owner session directory: read-only so reviewer can verify runtime state // files (cron state, configs, etc.) that the owner references by absolute path. const ownerSessionDir = path.join(DATA_DIR, 'sessions', group.folder); if (fs.existsSync(ownerSessionDir)) { pushMountOnce(mounts, { hostPath: ownerSessionDir, containerPath: ownerSessionDir, readonly: true, }); } // Codex OAuth: mount host's ~/.codex (read-only) so codex-runner can authenticate const hostCodexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); if (fs.existsSync(hostCodexHome)) { pushMountOnce(mounts, { hostPath: hostCodexHome, containerPath: '/home/node/.codex', readonly: true, }); } return mounts; } // ── Container args builders ────────────────────────────────────── /** Build args for `docker run -d` (create persistent container). */ export function buildCreateArgs( mounts: VolumeMount[], containerName: string, ): string[] { // Start detached with sleep infinity — turns run via docker exec const args: string[] = [ 'run', '-d', '--name', containerName, '--entrypoint', 'sleep', ]; // Timezone args.push('-e', `TZ=${TIMEZONE}`); // Pass real credentials — Claude Code SDK handles OAuth internally, // raw Bearer tokens on api.anthropic.com are not supported. const authMode = detectAuthMode(); if (authMode === 'api-key') { const apiKey = process.env.ANTHROPIC_API_KEY || ''; args.push('-e', `ANTHROPIC_API_KEY=${apiKey}`); } else { const oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN || ''; args.push('-e', `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`); } // Sentry read-only token for reviewer to verify error fixes if (process.env.SENTRY_AUTH_TOKEN) { args.push('-e', `SENTRY_AUTH_TOKEN=${process.env.SENTRY_AUTH_TOKEN}`); } // Host gateway args.push(...hostGatewayArgs()); // Run as host user for bind-mount compatibility const hostUid = process.getuid?.(); const hostGid = process.getgid?.(); if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { args.push('--user', `${hostUid}:${hostGid}`); args.push('-e', 'HOME=/home/node'); } // Volume mounts for (const mount of mounts) { if (mount.readonly) { args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); } else { args.push(...writableMountArgs(mount.hostPath, mount.containerPath)); } } // Writable tmpfs for test runners and temp files args.push(...tmpfsMountArgs('/tmp')); args.push('-e', 'VITEST_CACHE_DIR=/tmp/.vitest'); args.push('-e', 'JEST_CACHE_DIR=/tmp/.jest'); args.push('-e', 'npm_config_cache=/tmp/.npm'); args.push(CONTAINER_IMAGE); args.push('infinity'); // argument to sleep return args; } export function appendExecEnvArgs( execArgs: string[], envOverrides: Record | undefined, isCodexAgent: boolean, ): void { if (!envOverrides) { return; } const ignoredKeys = new Set(['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']); const incompatibleModelKeys = isCodexAgent ? new Set(['CLAUDE_MODEL', 'CLAUDE_EFFORT']) : new Set(['CODEX_MODEL', 'CODEX_EFFORT']); for (const [key, value] of Object.entries(envOverrides)) { if (!value || ignoredKeys.has(key) || incompatibleModelKeys.has(key)) { continue; } if (key === 'CLAUDE_CONFIG_DIR') { execArgs.push('-e', 'CLAUDE_CONFIG_DIR=/home/node/.claude'); continue; } execArgs.push('-e', `${key}=${value}`); } } // ── Main runner ─────────────────────────────────────────────────── export async function runReviewerContainer(args: { group: RegisteredGroup; input: ReviewerContainerInput; ownerWorkspaceDir: string; envOverrides?: Record; onOutput?: (output: AgentOutput) => Promise; onProcess?: (proc: ChildProcess, containerName: string) => void; }): Promise { const { group, input, ownerWorkspaceDir, envOverrides, onOutput, onProcess } = args; const startTime = Date.now(); // Pre-flight: Docker running + image exists (cached after first check) ensureContainerReady(); // Ensure persistent container is running for this channel const containerName = ensurePersistentContainer( group, ownerWorkspaceDir, envOverrides, ); logger.info( { containerName, group: group.name, groupFolder: group.folder, chatJid: input.chatJid, runId: input.runId, }, 'Executing reviewer turn in persistent container', ); // Run a turn inside the persistent container via docker exec. // Inject credentials and model config at exec-time so token rotation // and per-role model overrides are picked up without recreating the container. const execArgs = ['exec', '-i']; const authMode = detectAuthMode(); if (authMode === 'api-key') { execArgs.push( '-e', `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY || ''}`, ); } else { const oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN || ''; execArgs.push('-e', `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`); } const isCodexAgent = (group.agentType || 'claude-code') === 'codex'; logger.info( { containerName, groupAgentType: group.agentType, isCodexAgent, runnerPath: isCodexAgent ? '/app/codex/dist/index.js' : '/app/agent/dist/index.js', }, 'Container exec runner selection', ); appendExecEnvArgs(execArgs, envOverrides, isCodexAgent); if (isCodexAgent) { // Use session-local .codex dir (contains AGENTS.md with role prompts) // instead of the host-mounted ~/.codex (which has owner-only config). execArgs.push('-e', 'CODEX_HOME=/home/node/.claude/.codex'); } const runnerPath = isCodexAgent ? '/app/codex/dist/index.js' : '/app/agent/dist/index.js'; execArgs.push(containerName, 'bun', runnerPath); return new Promise((resolve) => { const proc = spawn(CONTAINER_RUNTIME_BIN, execArgs, { stdio: ['pipe', 'pipe', 'pipe'], }); onProcess?.(proc, containerName); // Send input via stdin proc.stdin.write(JSON.stringify(input)); proc.stdin.end(); let stdout = ''; let stderr = ''; let lastOutput: AgentOutput | null = null; let totalOutputSize = 0; let parseBuffer = ''; let outputChain = Promise.resolve(); // Streaming output: parse OUTPUT_START/END marker pairs proc.stdout.on('data', (chunk: Buffer) => { const text = chunk.toString(); totalOutputSize += text.length; if (totalOutputSize > AGENT_MAX_OUTPUT_SIZE) { logger.warn( { containerName, size: totalOutputSize }, 'Container output exceeds max size, killing exec', ); try { proc.kill('SIGKILL'); } catch { /* ignore */ } return; } stdout += text; parseBuffer += text; resetTimeout(); let startIdx: number; while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); if (endIdx === -1) break; const json = parseBuffer .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) .trim(); parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); try { const parsed = JSON.parse(json) as AgentOutput; lastOutput = parsed; if (onOutput) { outputChain = outputChain.then(() => onOutput(parsed)); } } catch (err) { logger.warn( { containerName, err }, 'Failed to parse container output JSON', ); } } }); proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); resetTimeout(); }); // Activity-based timeout const timeoutMs = Math.max(AGENT_TIMEOUT, IDLE_TIMEOUT + 30_000); const killOnTimeout = () => { logger.warn( { containerName, timeoutMs }, 'Reviewer exec timed out, killing', ); try { proc.kill('SIGTERM'); } catch { /* ignore */ } }; let timeout = setTimeout(killOnTimeout, timeoutMs); const resetTimeout = () => { clearTimeout(timeout); timeout = setTimeout(killOnTimeout, timeoutMs); }; proc.on('close', (code, signal) => { clearTimeout(timeout); const durationMs = Date.now() - startTime; logger.info( { containerName, group: group.name, runId: input.runId, exitCode: code, signal, durationMs, stdoutLen: stdout.length, stderrLen: stderr.length, }, 'Reviewer exec completed', ); if (lastOutput) { outputChain.then(() => resolve(lastOutput!)); return; } // Fallback: try to parse from full stdout const sIdx = stdout.indexOf(OUTPUT_START_MARKER); const eIdx = stdout.indexOf(OUTPUT_END_MARKER); if (sIdx !== -1 && eIdx !== -1) { try { const json = stdout .slice(sIdx + OUTPUT_START_MARKER.length, eIdx) .trim(); resolve(JSON.parse(json) as AgentOutput); return; } catch { /* fall through */ } } const errorMsg = stderr.trim() || `Exec exited with code ${code}`; resolve({ status: code === 0 ? 'success' : 'error', result: null, error: errorMsg, phase: 'final', }); }); proc.on('error', (err) => { clearTimeout(timeout); logger.error({ containerName, err }, 'Failed to exec in container'); resolve({ status: 'error', result: null, error: `Container exec error: ${err.message}`, phase: 'final', }); }); }); }