From aaa9eeb50a1b6bbf5175b01209de76a0a62588be Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 6 Apr 2026 07:56:24 +0900 Subject: [PATCH] Remove paired reviewer container execution path --- README.md | 12 +- docs/architecture.md | 20 +- src/agent-runner.test.ts | 25 -- src/agent-runner.ts | 91 ---- src/config.ts | 4 - src/container-runner.test.ts | 264 ------------ src/container-runner.ts | 795 ----------------------------------- src/container-runtime.ts | 6 +- src/credential-proxy.ts | 170 -------- src/index.ts | 27 +- src/token-refresh.ts | 34 +- src/token-rotation.ts | 19 - 12 files changed, 21 insertions(+), 1446 deletions(-) delete mode 100644 src/container-runner.test.ts delete mode 100644 src/container-runner.ts delete mode 100644 src/credential-proxy.ts diff --git a/README.md b/README.md index 5bab52a..60e564f 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/architecture.md b/docs/architecture.md index 542151c..d259d09 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index d2a27de..b7744cf 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -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', - ); - }); }); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 8978e8c..e275282 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -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( diff --git a/src/config.ts b/src/config.ts index c208b14..eacb69d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts deleted file mode 100644 index 0f491da..0000000 --- a/src/container-runner.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/src/container-runner.ts b/src/container-runner.ts deleted file mode 100644 index 7fb4771..0000000 --- a/src/container-runner.ts +++ /dev/null @@ -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 { - 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', - }); - }); - }); -} diff --git a/src/container-runtime.ts b/src/container-runtime.ts index ade0ddf..e488e9e 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -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'; diff --git a/src/credential-proxy.ts b/src/credential-proxy.ts deleted file mode 100644 index 3d87052..0000000 --- a/src/credential-proxy.ts +++ /dev/null @@ -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 { - 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 = - { - ...(req.headers as Record), - 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 = { - ...(req.headers as Record), - 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'; -} diff --git a/src/index.ts b/src/index.ts index 5fddaf8..9479238 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { 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 { ); } await queue.shutdown(10000); - cleanupOrphans(); for (const ch of channels) await ch.disconnect(); process.exit(0); }; diff --git a/src/token-refresh.ts b/src/token-refresh.ts index 63a0ab4..a913785 100644 --- a/src/token-refresh.ts +++ b/src/token-refresh.ts @@ -322,7 +322,7 @@ async function checkAndRefreshAll(): Promise { } 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,31 +333,9 @@ async function checkAndRefreshAll(): Promise { } } 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 | null = null; export function startTokenRefreshLoop(): void { @@ -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; diff --git a/src/token-rotation.ts b/src/token-rotation.ts index 6f68598..4bc88e0 100644 --- a/src/token-rotation.ts +++ b/src/token-rotation.ts @@ -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; }