Remove paired reviewer container execution path
This commit is contained in:
12
README.md
12
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
|
- **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
|
- **Mixture of Agents** — External model opinions (Kimi, GLM) enrich arbiter verdicts
|
||||||
- **Per-role model selection** — `OWNER_MODEL`, `REVIEWER_MODEL`, `ARBITER_MODEL` + effort + fallback toggle
|
- **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
|
- **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
|
- **Post-approval change detection** — Re-triggers review if owner modifies code after approval
|
||||||
- **Auto user notification** — @mention on task completion (✅ done, ⚠️ escalated)
|
- **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)
|
Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (host process)
|
||||||
│ │
|
│ │
|
||||||
│ ▼ (auto-trigger)
|
│ ▼ (auto-trigger)
|
||||||
├──► Reviewer (Docker container, :ro mount)
|
├──► Reviewer (host process, read-only guarded)
|
||||||
│ │
|
│ │
|
||||||
│ Verdict routing
|
│ Verdict routing
|
||||||
│ ├─ DONE → change detection → finalize or re-review
|
│ ├─ DONE → change detection → finalize or re-review
|
||||||
@@ -140,7 +140,7 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho
|
|||||||
|
|
||||||
- Linux (Ubuntu 22.04+) or macOS
|
- Linux (Ubuntu 22.04+) or macOS
|
||||||
- [Bun](https://bun.sh/) 1.3+
|
- [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)
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
|
||||||
- [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`)
|
- [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`)
|
||||||
- Discord bot tokens (3: owner, reviewer, arbiter)
|
- Discord bot tokens (3: owner, reviewer, arbiter)
|
||||||
@@ -153,12 +153,12 @@ cd EJClaw
|
|||||||
bun install
|
bun install
|
||||||
bun run build:runners
|
bun run build:runners
|
||||||
bun run build
|
bun run build
|
||||||
bun run build:container # Build reviewer Docker image
|
bun run build:container # Build verification container image
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## 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
|
- [Configuration](docs/configuration.md) — Full `.env` reference, debugging paths
|
||||||
|
|
||||||
### Environment
|
### Environment
|
||||||
@@ -213,7 +213,7 @@ bun run deploy
|
|||||||
```bash
|
```bash
|
||||||
bun run build # Build main project
|
bun run build # Build main project
|
||||||
bun run build:runners # Install + build both runners
|
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 run dev # Dev mode with hot reload
|
||||||
bun test # Run tests
|
bun test # Run tests
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Single unified service (`ejclaw`) manages all three Discord bots in one process:
|
|||||||
Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (host process)
|
Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (host process)
|
||||||
│ │
|
│ │
|
||||||
│ ▼ (auto-trigger)
|
│ ▼ (auto-trigger)
|
||||||
├──► Reviewer (Docker container, :ro mount)
|
├──► Reviewer (host process, read-only guarded)
|
||||||
│ │
|
│ │
|
||||||
│ Verdict routing
|
│ Verdict routing
|
||||||
│ ├─ DONE → change detection → finalize or re-review
|
│ ├─ DONE → change detection → finalize or re-review
|
||||||
@@ -77,16 +77,15 @@ User message
|
|||||||
→ Arbiter summoned → binding verdict → loop resumes
|
→ 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)
|
- Scratch workspace copied before execution
|
||||||
- Both Claude (agent-runner) and Codex (codex-runner) supported
|
- Verification command run inside the container image
|
||||||
- Runner selected at `docker exec` time based on agent type
|
- Isolated filesystem view for test/typecheck/build checks
|
||||||
- tmpfs overlays for test runner caches
|
- Runtime image shared with host tooling and evidence inspection
|
||||||
- IPC via filesystem for follow-up messages
|
|
||||||
- Credentials injected per-exec (never baked into container)
|
|
||||||
|
|
||||||
## Key Files
|
## 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/index.ts` | Orchestrator: state, message loop, agent invocation |
|
||||||
| `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills |
|
| `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/channels/discord.ts` | Discord channel (8s typing refresh, Whisper transcription) |
|
||||||
| `src/ipc.ts` | IPC watcher and task processing |
|
| `src/ipc.ts` | IPC watcher and task processing |
|
||||||
| `src/router.ts` | Message formatting and outbound routing |
|
| `src/router.ts` | Message formatting and outbound routing |
|
||||||
|
|||||||
@@ -108,10 +108,8 @@ vi.mock('child_process', async () => {
|
|||||||
import {
|
import {
|
||||||
runAgentProcess,
|
runAgentProcess,
|
||||||
AgentOutput,
|
AgentOutput,
|
||||||
mirrorContainerCodexSessionFiles,
|
|
||||||
} from './agent-runner.js';
|
} from './agent-runner.js';
|
||||||
import * as agentRunnerEnvironment from './agent-runner-environment.js';
|
import * as agentRunnerEnvironment from './agent-runner-environment.js';
|
||||||
import * as containerRunner from './container-runner.js';
|
|
||||||
import type { RegisteredGroup } from './types.js';
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
const testGroup: RegisteredGroup = {
|
const testGroup: RegisteredGroup = {
|
||||||
@@ -327,10 +325,6 @@ describe('agent-runner timeout behavior', () => {
|
|||||||
agentRunnerEnvironment,
|
agentRunnerEnvironment,
|
||||||
'prepareContainerSessionEnvironment',
|
'prepareContainerSessionEnvironment',
|
||||||
);
|
);
|
||||||
const runReviewerContainerSpy = vi.spyOn(
|
|
||||||
containerRunner,
|
|
||||||
'runReviewerContainer',
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
|
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
|
||||||
const str = String(p);
|
const str = String(p);
|
||||||
@@ -365,7 +359,6 @@ describe('agent-runner timeout behavior', () => {
|
|||||||
fakeProc.emit('close', 0);
|
fakeProc.emit('close', 0);
|
||||||
const result = await resultPromise;
|
const result = await resultPromise;
|
||||||
expect(result.status).toBe('success');
|
expect(result.status).toBe('success');
|
||||||
expect(runReviewerContainerSpy).not.toHaveBeenCalled();
|
|
||||||
expect(prepareContainerSessionEnvironmentSpy).toHaveBeenCalledWith(
|
expect(prepareContainerSessionEnvironmentSpy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
sessionDir: '/tmp/host-reviewer-session',
|
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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import {
|
import {
|
||||||
ensureClaudeGlobalSettingsFile,
|
|
||||||
prepareContainerSessionEnvironment,
|
prepareContainerSessionEnvironment,
|
||||||
prepareGroupEnvironment,
|
prepareGroupEnvironment,
|
||||||
} from './agent-runner-environment.js';
|
} from './agent-runner-environment.js';
|
||||||
@@ -25,7 +24,6 @@ export {
|
|||||||
} from './agent-runner-snapshot.js';
|
} from './agent-runner-snapshot.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
|
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
|
||||||
import { runReviewerContainer } from './container-runner.js';
|
|
||||||
import {
|
import {
|
||||||
AgentOutputPhase,
|
AgentOutputPhase,
|
||||||
AgentType,
|
AgentType,
|
||||||
@@ -62,34 +60,6 @@ export interface AgentOutput {
|
|||||||
error?: string;
|
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(
|
export async function runAgentProcess(
|
||||||
group: RegisteredGroup,
|
group: RegisteredGroup,
|
||||||
input: AgentInput,
|
input: AgentInput,
|
||||||
@@ -104,67 +74,6 @@ export async function runAgentProcess(
|
|||||||
const unsafeHostPairedMode =
|
const unsafeHostPairedMode =
|
||||||
envOverrides?.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1';
|
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) ───────────────────────────────────
|
// ── Host process mode (owner) ───────────────────────────────────
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
||||||
|
|||||||
@@ -210,10 +210,6 @@ export const PAIRED_MAX_ROUND_TRIPS =
|
|||||||
// ── Container isolation ───────────────────────────────────────────
|
// ── Container isolation ───────────────────────────────────────────
|
||||||
export const REVIEWER_CONTAINER_IMAGE =
|
export const REVIEWER_CONTAINER_IMAGE =
|
||||||
getEnv('REVIEWER_CONTAINER_IMAGE') || 'ejclaw-reviewer:latest';
|
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(
|
export const RECOVERY_STAGGER_MS = parseInt(
|
||||||
getEnv('RECOVERY_STAGGER_MS') || '2000',
|
getEnv('RECOVERY_STAGGER_MS') || '2000',
|
||||||
10,
|
10,
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Container runtime abstraction for EJClaw.
|
* Container runtime abstraction for EJClaw verification.
|
||||||
* Reviewer agents run inside Docker containers for read-only isolation.
|
* Verification profiles and related host evidence still use Docker.
|
||||||
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
* Runtime-specific logic lives here so swapping runtimes means changing one file.
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}
|
|
||||||
27
src/index.ts
27
src/index.ts
@@ -3,7 +3,6 @@ import path from 'path';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ASSISTANT_NAME,
|
ASSISTANT_NAME,
|
||||||
CREDENTIAL_PROXY_PORT,
|
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
POLL_INTERVAL,
|
POLL_INTERVAL,
|
||||||
@@ -72,11 +71,7 @@ import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
||||||
import { initCodexTokenRotation } from './codex-token-rotation.js';
|
import { initCodexTokenRotation } from './codex-token-rotation.js';
|
||||||
import {
|
import { hasAvailableClaudeToken, initTokenRotation } from './token-rotation.js';
|
||||||
hasAvailableClaudeToken,
|
|
||||||
initTokenRotation,
|
|
||||||
onTokenRotated,
|
|
||||||
} from './token-rotation.js';
|
|
||||||
|
|
||||||
function isTerminalStatusMessage(text: string): boolean {
|
function isTerminalStatusMessage(text: string): boolean {
|
||||||
const trimmed = text.trimStart();
|
const trimmed = text.trimStart();
|
||||||
@@ -85,7 +80,6 @@ function isTerminalStatusMessage(text: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
import {
|
import {
|
||||||
onTokenRefreshed,
|
|
||||||
startTokenRefreshLoop,
|
startTokenRefreshLoop,
|
||||||
stopTokenRefreshLoop,
|
stopTokenRefreshLoop,
|
||||||
} from './token-refresh.js';
|
} from './token-refresh.js';
|
||||||
@@ -94,9 +88,6 @@ import {
|
|||||||
getGlobalFailoverInfo,
|
getGlobalFailoverInfo,
|
||||||
} from './service-routing.js';
|
} from './service-routing.js';
|
||||||
import { FAILOVER_MIN_DURATION_MS } from './config.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
|
// 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');
|
logger.info('Database initialized');
|
||||||
initTokenRotation();
|
initTokenRotation();
|
||||||
initCodexTokenRotation();
|
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();
|
startTokenRefreshLoop();
|
||||||
|
|
||||||
loadState();
|
loadState();
|
||||||
@@ -392,7 +368,6 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await queue.shutdown(10000);
|
await queue.shutdown(10000);
|
||||||
cleanupOrphans();
|
|
||||||
for (const ch of channels) await ch.disconnect();
|
for (const ch of channels) await ch.disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ async function checkAndRefreshAll(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (anyRefreshed) {
|
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();
|
const refreshedTokens = getAllTokens();
|
||||||
if (refreshedTokens.length > 0) {
|
if (refreshedTokens.length > 0) {
|
||||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = refreshedTokens[0].token;
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = refreshedTokens[0].token;
|
||||||
@@ -333,31 +333,9 @@ async function checkAndRefreshAll(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateEnvTokens();
|
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;
|
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
export function startTokenRefreshLoop(): void {
|
export function startTokenRefreshLoop(): void {
|
||||||
@@ -421,16 +399,6 @@ export async function forceRefreshToken(
|
|||||||
if (tokenIndex < allTokens.length) {
|
if (tokenIndex < allTokens.length) {
|
||||||
updateTokenValue(tokenIndex, newAccessToken);
|
updateTokenValue(tokenIndex, newAccessToken);
|
||||||
updateEnvTokens();
|
updateEnvTokens();
|
||||||
if (onTokenRefreshedCallback) {
|
|
||||||
try {
|
|
||||||
onTokenRefreshedCallback();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
{ err: getErrorMessage(err) },
|
|
||||||
'Post-token-refresh callback failed (force refresh)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newAccessToken;
|
return newAccessToken;
|
||||||
|
|||||||
@@ -33,18 +33,6 @@ const tokens: TokenState[] = [];
|
|||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
let initialized = false;
|
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 {
|
export function initTokenRotation(): void {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
@@ -194,13 +182,6 @@ export function rotateToken(
|
|||||||
`Rotated to token #${currentIndex + 1}/${tokens.length}`,
|
`Rotated to token #${currentIndex + 1}/${tokens.length}`,
|
||||||
);
|
);
|
||||||
saveState();
|
saveState();
|
||||||
if (onTokenRotatedCallback) {
|
|
||||||
try {
|
|
||||||
onTokenRotatedCallback();
|
|
||||||
} catch {
|
|
||||||
/* best effort — don't break rotation flow */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user