Files
EJClaw/src/container-runtime.ts
Eyejoker fc9f2867b9 refactor: simplify paired review system and add reviewer container isolation
Phase 1 — Strip over-engineering (-3,665 lines):
- DB tables: 7 → 3 (remove executions, approvals, artifacts, events)
- Task statuses: 11 → 5 (active, review_ready, in_review, merge_ready, completed)
- Remove plan governance, event sourcing, gate/verdict, freshness guards
- paired-execution-context: 980 → 299 lines
- Session commands: remove /risk, /plan, /approve-plan, /request-plan-changes

Phase 2 — Container isolation for reviewers:
- Add container-runtime.ts (Docker abstraction)
- Add credential-proxy.ts (API key injection without container exposure)
- Add container-runner.ts (reviewer-specific read-only mount + tmpfs)
- Add container/Dockerfile + agent-runner (Chromium, Claude Code, Codex)
- agent-runner.ts: auto-route reviewer execution to container mode
2026-03-29 18:24:56 +09:00

104 lines
2.9 KiB
TypeScript

/**
* 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');
}
}