Remove paired reviewer container execution path

This commit is contained in:
ejclaw
2026-04-06 07:56:24 +09:00
parent d75d26c23d
commit aaa9eeb50a
12 changed files with 21 additions and 1446 deletions

View File

@@ -89,7 +89,7 @@ No extra SDK processes. External references use lightweight API calls (Anthropic
- **Discord-independent communication** — Agent-to-agent data flows directly via DB, Discord is display-only
- **Mixture of Agents** — External model opinions (Kimi, GLM) enrich arbiter verdicts
- **Per-role model selection** — `OWNER_MODEL`, `REVIEWER_MODEL`, `ARBITER_MODEL` + effort + fallback toggle
- **Container-isolated reviewer** — Persistent Docker container with read-only source mount
- **Host reviewer with read-only guards** — Reviewer runs on host with role-scoped sandbox and guard policy
- **Global failover** — Account-level Claude failure → all channels switch to codex, auto-recovers
- **Post-approval change detection** — Re-triggers review if owner modifies code after approval
- **Auto user notification** — @mention on task completion (✅ done, ⚠️ escalated)
@@ -109,7 +109,7 @@ No extra SDK processes. External references use lightweight API calls (Anthropic
Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (host process)
│ │
│ ▼ (auto-trigger)
├──► Reviewer (Docker container, :ro mount)
├──► Reviewer (host process, read-only guarded)
│ │
│ Verdict routing
│ ├─ DONE → change detection → finalize or re-review
@@ -140,7 +140,7 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho
- Linux (Ubuntu 22.04+) or macOS
- [Bun](https://bun.sh/) 1.3+
- Docker (required for reviewer container isolation)
- Docker (currently used for isolated verification runs)
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
- [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`)
- Discord bot tokens (3: owner, reviewer, arbiter)
@@ -153,12 +153,12 @@ cd EJClaw
bun install
bun run build:runners
bun run build
bun run build:container # Build reviewer Docker image
bun run build:container # Build verification container image
```
## Documentation
- [Architecture](docs/architecture.md) — Data flow, room model, container isolation, key files
- [Architecture](docs/architecture.md) — Data flow, room model, verification isolation, key files
- [Configuration](docs/configuration.md) — Full `.env` reference, debugging paths
### Environment
@@ -213,7 +213,7 @@ bun run deploy
```bash
bun run build # Build main project
bun run build:runners # Install + build both runners
bun run build:container # Rebuild reviewer Docker image
bun run build:container # Rebuild verification container image
bun run dev # Dev mode with hot reload
bun test # Run tests
```

View File

@@ -17,7 +17,7 @@ Single unified service (`ejclaw`) manages all three Discord bots in one process:
Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (host process)
│ │
│ ▼ (auto-trigger)
├──► Reviewer (Docker container, :ro mount)
├──► Reviewer (host process, read-only guarded)
│ │
│ Verdict routing
│ ├─ DONE → change detection → finalize or re-review
@@ -77,16 +77,15 @@ User message
→ Arbiter summoned → binding verdict → loop resumes
```
## Container Isolation
## Verification Isolation
Reviewer and arbiter run inside a Docker container with:
Reviewer and arbiter now run as host processes with role-scoped read-only
guards and sandbox settings. Docker remains in use for verification profiles:
- Read-only source mount (kernel-level write protection)
- Both Claude (agent-runner) and Codex (codex-runner) supported
- Runner selected at `docker exec` time based on agent type
- tmpfs overlays for test runner caches
- IPC via filesystem for follow-up messages
- Credentials injected per-exec (never baked into container)
- Scratch workspace copied before execution
- Verification command run inside the container image
- Isolated filesystem view for test/typecheck/build checks
- Runtime image shared with host tooling and evidence inspection
## Key Files
@@ -94,7 +93,8 @@ Reviewer and arbiter run inside a Docker container with:
|------|---------|
| `src/index.ts` | Orchestrator: state, message loop, agent invocation |
| `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills |
| `src/container-runner.ts` | Docker container execution for reviewer/arbiter |
| `src/verification.ts` | Verification execution using the container image |
| `src/container-runtime.ts` | Shared Docker runtime helpers for verification |
| `src/channels/discord.ts` | Discord channel (8s typing refresh, Whisper transcription) |
| `src/ipc.ts` | IPC watcher and task processing |
| `src/router.ts` | Message formatting and outbound routing |

View File

@@ -108,10 +108,8 @@ vi.mock('child_process', async () => {
import {
runAgentProcess,
AgentOutput,
mirrorContainerCodexSessionFiles,
} from './agent-runner.js';
import * as agentRunnerEnvironment from './agent-runner-environment.js';
import * as containerRunner from './container-runner.js';
import type { RegisteredGroup } from './types.js';
const testGroup: RegisteredGroup = {
@@ -327,10 +325,6 @@ describe('agent-runner timeout behavior', () => {
agentRunnerEnvironment,
'prepareContainerSessionEnvironment',
);
const runReviewerContainerSpy = vi.spyOn(
containerRunner,
'runReviewerContainer',
);
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
const str = String(p);
@@ -365,7 +359,6 @@ describe('agent-runner timeout behavior', () => {
fakeProc.emit('close', 0);
const result = await resultPromise;
expect(result.status).toBe('success');
expect(runReviewerContainerSpy).not.toHaveBeenCalled();
expect(prepareContainerSessionEnvironmentSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionDir: '/tmp/host-reviewer-session',
@@ -689,22 +682,4 @@ OUROBOROS_LLM_BACKEND = "codex"
);
});
it('mirrors .claude.json alongside codex session files for container reuse', () => {
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
const str = String(p);
return (
str === '/tmp/source/.codex' ||
str === '/tmp/mounted' ||
str === '/tmp/source/.codex/AGENTS.md' ||
str === '/tmp/source/.claude.json'
);
});
mirrorContainerCodexSessionFiles('/tmp/source', '/tmp/mounted');
expect(vi.mocked(fs.copyFileSync)).toHaveBeenCalledWith(
'/tmp/source/.claude.json',
'/tmp/mounted/.claude.json',
);
});
});

View File

@@ -13,7 +13,6 @@ import {
IDLE_TIMEOUT,
} from './config.js';
import {
ensureClaudeGlobalSettingsFile,
prepareContainerSessionEnvironment,
prepareGroupEnvironment,
} from './agent-runner-environment.js';
@@ -25,7 +24,6 @@ export {
} from './agent-runner-snapshot.js';
import { logger } from './logger.js';
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
import { runReviewerContainer } from './container-runner.js';
import {
AgentOutputPhase,
AgentType,
@@ -62,34 +60,6 @@ export interface AgentOutput {
error?: string;
}
export function mirrorContainerCodexSessionFiles(
sourceSessionDir: string,
mountedSessionDir: string,
): void {
const sourceCodexDir = path.join(sourceSessionDir, '.codex');
const mountedCodexDir = path.join(mountedSessionDir, '.codex');
if (!fs.existsSync(sourceCodexDir) || !fs.existsSync(mountedSessionDir)) {
return;
}
fs.mkdirSync(mountedCodexDir, { recursive: true });
for (const file of ['AGENTS.md', 'config.toml', 'config.json', 'auth.json']) {
const sourcePath = path.join(sourceCodexDir, file);
if (fs.existsSync(sourcePath)) {
fs.copyFileSync(sourcePath, path.join(mountedCodexDir, file));
}
}
const sourceClaudeJson = path.join(sourceSessionDir, '.claude.json');
if (fs.existsSync(sourceClaudeJson)) {
ensureClaudeGlobalSettingsFile(mountedSessionDir);
fs.copyFileSync(
sourceClaudeJson,
path.join(mountedSessionDir, '.claude.json'),
);
}
}
export async function runAgentProcess(
group: RegisteredGroup,
input: AgentInput,
@@ -104,67 +74,6 @@ export async function runAgentProcess(
const unsafeHostPairedMode =
envOverrides?.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1';
// ── Reviewer container mode ─────────────────────────────────────
// Reviewers always run inside a Docker container with read-only source
// mount for kernel-level write protection. Docker is required.
if (
(!unsafeHostPairedMode && envOverrides?.EJCLAW_REVIEWER_RUNTIME === '1') ||
(!unsafeHostPairedMode && envOverrides?.EJCLAW_ARBITER_RUNTIME === '1')
) {
const ownerWorkspaceDir =
envOverrides?.EJCLAW_WORK_DIR || group.workDir || process.cwd();
// Prepare session directory for the container (CLAUDE.md, skills, settings)
// so the Claude SDK inside the container has platform & paired room prompts.
const sessionDir = envOverrides?.CLAUDE_CONFIG_DIR;
if (sessionDir) {
const containerRole =
envOverrides?.EJCLAW_ARBITER_RUNTIME === '1'
? ('arbiter' as const)
: ('reviewer' as const);
prepareContainerSessionEnvironment({
sessionDir,
chatJid: input.chatJid,
isMain: input.isMain,
groupFolder: group.folder,
agentType: group.agentType || 'claude-code',
memoryBriefing: input.memoryBriefing,
role: containerRole,
});
// The persistent container always mounts the reviewer session path at
// /home/node/.claude. When the arbiter stages a separate session dir,
// mirror the Codex session files into the mounted reviewer dir so the
// container sees the arbiter prompt/config, including MCP servers.
if (containerRole === 'arbiter') {
const reviewerSessionDir = path.join(
path.dirname(sessionDir),
`${group.folder}-reviewer`,
);
mirrorContainerCodexSessionFiles(sessionDir, reviewerSessionDir);
}
}
return runReviewerContainer({
group,
input: {
prompt: input.prompt,
sessionId: input.sessionId,
groupFolder: input.groupFolder,
chatJid: input.chatJid,
runId: input.runId || `${Date.now()}`,
isMain: input.isMain,
assistantName: input.assistantName,
roomRoleContext: input.roomRoleContext,
},
ownerWorkspaceDir,
envOverrides,
onOutput,
onProcess: (proc, containerName) => {
onProcess(proc, containerName, '');
},
});
}
// ── Host process mode (owner) ───────────────────────────────────
const startTime = Date.now();
const { env, groupDir, runnerDir } = prepareGroupEnvironment(

View File

@@ -210,10 +210,6 @@ export const PAIRED_MAX_ROUND_TRIPS =
// ── Container isolation ───────────────────────────────────────────
export const REVIEWER_CONTAINER_IMAGE =
getEnv('REVIEWER_CONTAINER_IMAGE') || 'ejclaw-reviewer:latest';
export const CREDENTIAL_PROXY_PORT = parseInt(
getEnv('CREDENTIAL_PROXY_PORT') || '3001',
10,
);
export const RECOVERY_STAGGER_MS = parseInt(
getEnv('RECOVERY_STAGGER_MS') || '2000',
10,

View File

@@ -1,264 +0,0 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { TEST_ROOT, TEST_DATA_DIR, TEST_GROUPS_DIR, mockDetectAuthMode } =
vi.hoisted(() => {
const testRoot = '/tmp/ejclaw-container-runner-test';
return {
TEST_ROOT: testRoot,
TEST_DATA_DIR: `${testRoot}/data`,
TEST_GROUPS_DIR: `${testRoot}/groups`,
mockDetectAuthMode: vi.fn<() => 'oauth' | 'api-key'>(),
};
});
vi.mock('./config.js', () => ({
AGENT_MAX_OUTPUT_SIZE: 1024 * 1024,
AGENT_TIMEOUT: 60_000,
DATA_DIR: TEST_DATA_DIR,
GROUPS_DIR: TEST_GROUPS_DIR,
IDLE_TIMEOUT: 1_000,
REVIEWER_CONTAINER_IMAGE: 'ejclaw-reviewer:latest',
TIMEZONE: 'Asia/Seoul',
}));
vi.mock('./container-runtime.js', () => ({
CONTAINER_RUNTIME_BIN: 'docker',
ensureContainerRuntimeRunning: vi.fn(),
hostGatewayArgs: () => ['--add-host=host.docker.internal:host-gateway'],
readonlyMountArgs: (hostPath: string, containerPath: string) => [
'-v',
`${hostPath}:${containerPath}:ro`,
],
writableMountArgs: (hostPath: string, containerPath: string) => [
'-v',
`${hostPath}:${containerPath}`,
],
tmpfsMountArgs: (containerPath: string) => ['--tmpfs', containerPath],
}));
vi.mock('./agent-runner-environment.js', () => ({
ensureClaudeGlobalSettingsFile: vi.fn(),
}));
vi.mock('./credential-proxy.js', () => ({
detectAuthMode: mockDetectAuthMode,
}));
vi.mock('./group-folder.js', () => ({
resolveGroupFolderPath: (folder: string) =>
path.join(TEST_GROUPS_DIR, folder),
resolveGroupIpcPath: (folder: string) =>
path.join(TEST_DATA_DIR, 'ipc', folder),
}));
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import {
appendExecEnvArgs,
buildCreateArgs,
buildReviewerMounts,
} from './container-runner.js';
import type { RegisteredGroup } from './types.js';
function makeGroup(): RegisteredGroup {
return {
name: 'Test Group',
folder: 'brain',
trigger: '@Codex',
added_at: new Date().toISOString(),
agentType: 'codex',
};
}
function initGitRepo(repoDir: string): void {
fs.mkdirSync(repoDir, { recursive: true });
execFileSync('git', ['init'], {
cwd: repoDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
cwd: repoDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
execFileSync('git', ['config', 'user.name', 'EJClaw Test'], {
cwd: repoDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}
describe('container-runner path compatibility', () => {
const previousCodeXHome = process.env.CODEX_HOME;
const previousOauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
fs.mkdirSync(TEST_GROUPS_DIR, { recursive: true });
process.env.CODEX_HOME = path.join(TEST_ROOT, 'codex-home');
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-token';
mockDetectAuthMode.mockReset();
mockDetectAuthMode.mockReturnValue('oauth');
});
afterEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
if (previousCodeXHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = previousCodeXHome;
}
if (previousOauthToken === undefined) {
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
} else {
process.env.CLAUDE_CODE_OAUTH_TOKEN = previousOauthToken;
}
});
it('mounts the owner workspace at both canonical and host absolute paths and shadows both .env paths', () => {
const group = makeGroup();
const ownerWorkspaceDir = path.join(TEST_ROOT, 'workspace', 'owner');
fs.mkdirSync(ownerWorkspaceDir, { recursive: true });
fs.writeFileSync(path.join(ownerWorkspaceDir, '.env'), 'SECRET=1\n');
fs.mkdirSync(path.join(TEST_GROUPS_DIR, group.folder), { recursive: true });
fs.mkdirSync(path.join(TEST_DATA_DIR, 'ipc', group.folder), {
recursive: true,
});
const mounts = buildReviewerMounts(group, ownerWorkspaceDir);
expect(mounts).toEqual(
expect.arrayContaining([
expect.objectContaining({
hostPath: ownerWorkspaceDir,
containerPath: '/workspace/project',
readonly: true,
}),
expect.objectContaining({
hostPath: ownerWorkspaceDir,
containerPath: ownerWorkspaceDir,
readonly: true,
}),
expect.objectContaining({
hostPath: path.join(
TEST_DATA_DIR,
'sessions',
`${group.folder}-reviewer`,
'.claude.json',
),
containerPath: '/home/node/.claude.json',
readonly: false,
}),
expect.objectContaining({
hostPath: '/dev/null',
containerPath: '/workspace/project/.env',
readonly: true,
}),
expect.objectContaining({
hostPath: '/dev/null',
containerPath: path.join(ownerWorkspaceDir, '.env'),
readonly: true,
}),
]),
);
});
it('mounts a local absolute origin target and shadows its .env', () => {
const group = makeGroup();
const canonicalRepoDir = path.join(TEST_ROOT, 'canonical');
const ownerWorkspaceDir = path.join(TEST_ROOT, 'workspace', 'owner');
initGitRepo(canonicalRepoDir);
initGitRepo(ownerWorkspaceDir);
execFileSync('git', ['remote', 'add', 'origin', canonicalRepoDir], {
cwd: ownerWorkspaceDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
fs.writeFileSync(path.join(canonicalRepoDir, '.env'), 'CANONICAL=1\n');
fs.mkdirSync(path.join(TEST_GROUPS_DIR, group.folder), { recursive: true });
fs.mkdirSync(path.join(TEST_DATA_DIR, 'ipc', group.folder), {
recursive: true,
});
const mounts = buildReviewerMounts(group, ownerWorkspaceDir);
expect(mounts).toEqual(
expect.arrayContaining([
expect.objectContaining({
hostPath: canonicalRepoDir,
containerPath: canonicalRepoDir,
readonly: true,
}),
expect.objectContaining({
hostPath: '/dev/null',
containerPath: path.join(canonicalRepoDir, '.env'),
readonly: true,
}),
]),
);
});
it('keeps dynamic workdir and role env on docker exec instead of baking them into container creation', () => {
const createArgs = buildCreateArgs([], 'ejclaw-reviewer-brain');
const createText = createArgs.join(' ');
expect(createText).not.toContain('EJCLAW_WORK_DIR=');
expect(createText).not.toContain('EJCLAW_PAIRED_ROLE=');
expect(createText).not.toContain('CLAUDE_CONFIG_DIR=');
expect(createText).not.toContain('EJCLAW_REVIEWER_RUNTIME=');
const reviewerExecArgs = ['exec', '-i'];
appendExecEnvArgs(
reviewerExecArgs,
{
EJCLAW_WORK_DIR: '/home/clone-ej/project',
EJCLAW_PAIRED_TASK_ID: 'task-1',
EJCLAW_PAIRED_ROLE: 'reviewer',
EJCLAW_REVIEWER_RUNTIME: '1',
CLAUDE_CONFIG_DIR: '/host/reviewer-session',
CLAUDE_MODEL: 'claude-sonnet',
CODEX_MODEL: 'gpt-5-codex',
},
false,
);
const reviewerExecText = reviewerExecArgs.join(' ');
expect(reviewerExecText).toContain(
'EJCLAW_WORK_DIR=/home/clone-ej/project',
);
expect(reviewerExecText).toContain('EJCLAW_PAIRED_TASK_ID=task-1');
expect(reviewerExecText).toContain('EJCLAW_PAIRED_ROLE=reviewer');
expect(reviewerExecText).toContain('EJCLAW_REVIEWER_RUNTIME=1');
expect(reviewerExecText).toContain('CLAUDE_CONFIG_DIR=/home/node/.claude');
expect(reviewerExecText).toContain('CLAUDE_MODEL=claude-sonnet');
expect(reviewerExecText).not.toContain('CODEX_MODEL=gpt-5-codex');
const codexExecArgs = ['exec', '-i'];
appendExecEnvArgs(
codexExecArgs,
{
CODEX_MODEL: 'gpt-5-codex',
CLAUDE_MODEL: 'claude-sonnet',
},
true,
);
const codexExecText = codexExecArgs.join(' ');
expect(codexExecText).toContain('CODEX_MODEL=gpt-5-codex');
expect(codexExecText).not.toContain('CLAUDE_MODEL=claude-sonnet');
});
});

View File

@@ -1,795 +0,0 @@
/**
* 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, string>,
): 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<string>();
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<string, string> | 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<string, string>;
onOutput?: (output: AgentOutput) => Promise<void>;
onProcess?: (proc: ChildProcess, containerName: string) => void;
}): Promise<AgentOutput> {
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<AgentOutput>((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',
});
});
});
}

View File

@@ -1,7 +1,7 @@
/**
* 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.
* Container runtime abstraction for EJClaw verification.
* Verification profiles and related host evidence still use Docker.
* Runtime-specific logic lives here so swapping runtimes means changing one file.
*/
import { execSync } from 'child_process';
import fs from 'fs';

View File

@@ -1,170 +0,0 @@
/**
* Credential proxy for reviewer container isolation.
* Containers connect here instead of directly to AI APIs.
* The proxy injects real credentials so containers never see them.
*
* Supports both Claude (Anthropic) and Codex (OpenAI) APIs:
* Claude API key: Proxy injects x-api-key on every request.
* Claude OAuth: Proxy replaces placeholder Bearer token with real one.
* Codex: Proxy injects Authorization: Bearer {OPENAI_API_KEY}.
*/
import { createServer, Server } from 'http';
import { request as httpsRequest } from 'https';
import { request as httpRequest, RequestOptions } from 'http';
import { getEnv } from './env.js';
import { logger } from './logger.js';
export type AuthMode = 'api-key' | 'oauth';
export function startCredentialProxy(
port: number,
host = '127.0.0.1',
): Promise<Server> {
const anthropicApiKey = getEnv('ANTHROPIC_API_KEY') || '';
const oauthToken =
getEnv('CLAUDE_CODE_OAUTH_TOKEN') || getEnv('ANTHROPIC_AUTH_TOKEN') || '';
const anthropicBaseUrl =
getEnv('ANTHROPIC_BASE_URL') || 'https://api.anthropic.com';
const openaiApiKey = getEnv('OPENAI_API_KEY') || '';
const authMode: AuthMode = anthropicApiKey ? 'api-key' : 'oauth';
const anthropicUrl = new URL(anthropicBaseUrl);
const anthropicIsHttps = anthropicUrl.protocol === 'https:';
const makeAnthropicRequest = anthropicIsHttps ? httpsRequest : httpRequest;
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks);
// Route: Codex/OpenAI requests go to OpenAI API
const isOpenAI =
req.headers['x-ejclaw-provider'] === 'openai' ||
(req.url || '').startsWith('/v1/chat/completions');
if (isOpenAI) {
proxyToOpenAI(req, res, body, openaiApiKey);
return;
}
// Default: Anthropic/Claude API
const headers: Record<string, string | number | string[] | undefined> =
{
...(req.headers as Record<string, string>),
host: anthropicUrl.host,
'content-length': body.length,
};
delete headers['connection'];
delete headers['keep-alive'];
delete headers['transfer-encoding'];
delete headers['x-ejclaw-provider'];
if (authMode === 'api-key') {
delete headers['x-api-key'];
headers['x-api-key'] = anthropicApiKey;
} else if (headers['authorization']) {
delete headers['authorization'];
if (oauthToken) {
headers['authorization'] = `Bearer ${oauthToken}`;
}
}
const upstream = makeAnthropicRequest(
{
hostname: anthropicUrl.hostname,
port: anthropicUrl.port || (anthropicIsHttps ? 443 : 80),
path: req.url,
method: req.method,
headers,
} as RequestOptions,
(upRes) => {
res.writeHead(upRes.statusCode!, upRes.headers);
upRes.pipe(res);
},
);
upstream.on('error', (err) => {
logger.error(
{ err, url: req.url },
'Credential proxy upstream error (Anthropic)',
);
if (!res.headersSent) {
res.writeHead(502);
res.end('Bad Gateway');
}
});
upstream.write(body);
upstream.end();
});
});
server.listen(port, host, () => {
logger.info({ port, host, authMode }, 'Credential proxy started');
resolve(server);
});
server.on('error', reject);
});
}
function proxyToOpenAI(
req: import('http').IncomingMessage,
res: import('http').ServerResponse,
body: Buffer,
apiKey: string,
): void {
const headers: Record<string, string | number | string[] | undefined> = {
...(req.headers as Record<string, string>),
host: 'api.openai.com',
'content-length': body.length,
};
delete headers['connection'];
delete headers['keep-alive'];
delete headers['transfer-encoding'];
delete headers['x-ejclaw-provider'];
// Inject real OpenAI API key
delete headers['authorization'];
if (apiKey) {
headers['authorization'] = `Bearer ${apiKey}`;
}
const upstream = httpsRequest(
{
hostname: 'api.openai.com',
port: 443,
path: req.url,
method: req.method,
headers,
} as RequestOptions,
(upRes) => {
res.writeHead(upRes.statusCode!, upRes.headers);
upRes.pipe(res);
},
);
upstream.on('error', (err) => {
logger.error(
{ err, url: req.url },
'Credential proxy upstream error (OpenAI)',
);
if (!res.headersSent) {
res.writeHead(502);
res.end('Bad Gateway');
}
});
upstream.write(body);
upstream.end();
}
export function detectAuthMode(): AuthMode {
return getEnv('ANTHROPIC_API_KEY') ? 'api-key' : 'oauth';
}

View File

@@ -3,7 +3,6 @@ import path from 'path';
import {
ASSISTANT_NAME,
CREDENTIAL_PROXY_PORT,
DATA_DIR,
IDLE_TIMEOUT,
POLL_INTERVAL,
@@ -72,11 +71,7 @@ import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
import { normalizeStoredSeqCursor } from './message-cursor.js';
import { initCodexTokenRotation } from './codex-token-rotation.js';
import {
hasAvailableClaudeToken,
initTokenRotation,
onTokenRotated,
} from './token-rotation.js';
import { hasAvailableClaudeToken, initTokenRotation } from './token-rotation.js';
function isTerminalStatusMessage(text: string): boolean {
const trimmed = text.trimStart();
@@ -85,7 +80,6 @@ function isTerminalStatusMessage(text: string): boolean {
);
}
import {
onTokenRefreshed,
startTokenRefreshLoop,
stopTokenRefreshLoop,
} from './token-refresh.js';
@@ -94,9 +88,6 @@ import {
getGlobalFailoverInfo,
} from './service-routing.js';
import { FAILOVER_MIN_DURATION_MS } from './config.js';
import { startCredentialProxy } from './credential-proxy.js';
import { recreateAllReviewerContainers } from './container-runner.js';
import { cleanupOrphans, PROXY_BIND_HOST } from './container-runtime.js';
// Token rotation is initialized lazily on first use or at startup below
@@ -331,21 +322,6 @@ async function main(): Promise<void> {
logger.info('Database initialized');
initTokenRotation();
initCodexTokenRotation();
// Start credential proxy for container isolation and clean up orphaned containers
startCredentialProxy(CREDENTIAL_PROXY_PORT, PROXY_BIND_HOST).catch((err) =>
logger.warn(
{ err },
'Failed to start credential proxy (may already be running)',
),
);
cleanupOrphans();
// Recreate reviewer containers when OAuth tokens are rotated or refreshed.
// Persistent containers hold the old token in their running SDK process;
// removing them forces re-creation with fresh credentials on next turn.
onTokenRotated(recreateAllReviewerContainers);
onTokenRefreshed(recreateAllReviewerContainers);
startTokenRefreshLoop();
loadState();
@@ -392,7 +368,6 @@ async function main(): Promise<void> {
);
}
await queue.shutdown(10000);
cleanupOrphans();
for (const ch of channels) await ch.disconnect();
process.exit(0);
};

View File

@@ -322,7 +322,7 @@ async function checkAndRefreshAll(): Promise<void> {
}
if (anyRefreshed) {
// Sync refreshed tokens to process.env so docker exec picks them up
// Sync refreshed tokens to process.env so subsequent turns pick them up
const refreshedTokens = getAllTokens();
if (refreshedTokens.length > 0) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = refreshedTokens[0].token;
@@ -333,29 +333,7 @@ async function checkAndRefreshAll(): Promise<void> {
}
}
updateEnvTokens();
if (onTokenRefreshedCallback) {
try {
onTokenRefreshedCallback();
} catch (err) {
logger.warn(
{ err: getErrorMessage(err) },
'Post-token-refresh callback failed',
);
}
}
}
}
// ── Post-refresh callback ───────────────────────────────────────
// Allows callers (e.g. index.ts) to register a hook that runs after
// any token is refreshed — used to recreate reviewer containers whose
// persistent Claude Code SDK process still holds the old token.
type TokenRefreshCallback = () => void;
let onTokenRefreshedCallback: TokenRefreshCallback | null = null;
/** Register a callback to be invoked after a successful token refresh. */
export function onTokenRefreshed(cb: TokenRefreshCallback): void {
onTokenRefreshedCallback = cb;
}
let refreshInterval: ReturnType<typeof setInterval> | null = null;
@@ -421,16 +399,6 @@ export async function forceRefreshToken(
if (tokenIndex < allTokens.length) {
updateTokenValue(tokenIndex, newAccessToken);
updateEnvTokens();
if (onTokenRefreshedCallback) {
try {
onTokenRefreshedCallback();
} catch (err) {
logger.warn(
{ err: getErrorMessage(err) },
'Post-token-refresh callback failed (force refresh)',
);
}
}
}
}
return newAccessToken;

View File

@@ -33,18 +33,6 @@ const tokens: TokenState[] = [];
let currentIndex = 0;
let initialized = false;
// ── Post-rotation callback ──────────────────────────────────────
// Invoked after a successful token rotation so that callers (e.g.
// index.ts) can tear down reviewer containers whose SDK process
// still holds the old token.
type TokenRotationCallback = () => void;
let onTokenRotatedCallback: TokenRotationCallback | null = null;
/** Register a callback to be invoked after a successful token rotation. */
export function onTokenRotated(cb: TokenRotationCallback): void {
onTokenRotatedCallback = cb;
}
export function initTokenRotation(): void {
if (initialized) return;
initialized = true;
@@ -194,13 +182,6 @@ export function rotateToken(
`Rotated to token #${currentIndex + 1}/${tokens.length}`,
);
saveState();
if (onTokenRotatedCallback) {
try {
onTokenRotatedCallback();
} catch {
/* best effort — don't break rotation flow */
}
}
return true;
}