/** * Container runtime abstraction for EJClaw. * Reviewer agents run inside Docker containers for read-only isolation. * All runtime-specific logic lives here so swapping runtimes means changing one file. */ import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import { logger } from './logger.js'; export const CONTAINER_RUNTIME_BIN = 'docker'; export const CONTAINER_HOST_GATEWAY = 'host.docker.internal'; /** * Address the credential proxy binds to. * Docker Desktop (macOS): 127.0.0.1 * Docker (Linux): docker0 bridge IP (containers can reach it via host.docker.internal) */ export const PROXY_BIND_HOST = process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost(); function detectProxyBindHost(): string { if (os.platform() === 'darwin') return '127.0.0.1'; if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1'; const ifaces = os.networkInterfaces(); const docker0 = ifaces['docker0']; if (docker0) { const ipv4 = docker0.find((a) => a.family === 'IPv4'); if (ipv4) return ipv4.address; } return '0.0.0.0'; } export function hostGatewayArgs(): string[] { if (os.platform() === 'linux') { return ['--add-host=host.docker.internal:host-gateway']; } return []; } export function readonlyMountArgs( hostPath: string, containerPath: string, ): string[] { return ['-v', `${hostPath}:${containerPath}:ro`]; } export function writableMountArgs( hostPath: string, containerPath: string, ): string[] { return ['-v', `${hostPath}:${containerPath}`]; } export function tmpfsMountArgs(containerPath: string): string[] { return ['--tmpfs', containerPath]; } export function stopContainer(name: string): string { return `${CONTAINER_RUNTIME_BIN} stop ${name}`; } export function ensureContainerRuntimeRunning(): void { try { execSync(`${CONTAINER_RUNTIME_BIN} info`, { stdio: 'pipe', timeout: 10000, }); logger.debug('Container runtime already running'); } catch (err) { logger.error({ err }, 'Failed to reach container runtime'); throw new Error( 'Container runtime (Docker) is required for reviewer isolation but failed to start. Run: docker info', ); } } export function cleanupOrphans(): void { try { const output = execSync( `${CONTAINER_RUNTIME_BIN} ps --filter name=ejclaw-reviewer- --format '{{.Names}}'`, { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }, ); const orphans = output.trim().split('\n').filter(Boolean); 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 reviewer containers', ); } } catch (err) { logger.warn({ err }, 'Failed to clean up orphaned containers'); } }