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
104 lines
2.9 KiB
TypeScript
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');
|
|
}
|
|
}
|