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
|
||||
- **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
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
* 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';
|
||||
|
||||
@@ -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 {
|
||||
ASSISTANT_NAME,
|
||||
CREDENTIAL_PROXY_PORT,
|
||||
DATA_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
POLL_INTERVAL,
|
||||
@@ -72,11 +71,7 @@ import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
||||
import { initCodexTokenRotation } from './codex-token-rotation.js';
|
||||
import {
|
||||
hasAvailableClaudeToken,
|
||||
initTokenRotation,
|
||||
onTokenRotated,
|
||||
} from './token-rotation.js';
|
||||
import { hasAvailableClaudeToken, initTokenRotation } from './token-rotation.js';
|
||||
|
||||
function isTerminalStatusMessage(text: string): boolean {
|
||||
const trimmed = text.trimStart();
|
||||
@@ -85,7 +80,6 @@ function isTerminalStatusMessage(text: string): boolean {
|
||||
);
|
||||
}
|
||||
import {
|
||||
onTokenRefreshed,
|
||||
startTokenRefreshLoop,
|
||||
stopTokenRefreshLoop,
|
||||
} from './token-refresh.js';
|
||||
@@ -94,9 +88,6 @@ import {
|
||||
getGlobalFailoverInfo,
|
||||
} from './service-routing.js';
|
||||
import { FAILOVER_MIN_DURATION_MS } from './config.js';
|
||||
import { startCredentialProxy } from './credential-proxy.js';
|
||||
import { recreateAllReviewerContainers } from './container-runner.js';
|
||||
import { cleanupOrphans, PROXY_BIND_HOST } from './container-runtime.js';
|
||||
|
||||
// Token rotation is initialized lazily on first use or at startup below
|
||||
|
||||
@@ -331,21 +322,6 @@ async function main(): Promise<void> {
|
||||
logger.info('Database initialized');
|
||||
initTokenRotation();
|
||||
initCodexTokenRotation();
|
||||
|
||||
// Start credential proxy for container isolation and clean up orphaned containers
|
||||
startCredentialProxy(CREDENTIAL_PROXY_PORT, PROXY_BIND_HOST).catch((err) =>
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Failed to start credential proxy (may already be running)',
|
||||
),
|
||||
);
|
||||
cleanupOrphans();
|
||||
|
||||
// Recreate reviewer containers when OAuth tokens are rotated or refreshed.
|
||||
// Persistent containers hold the old token in their running SDK process;
|
||||
// removing them forces re-creation with fresh credentials on next turn.
|
||||
onTokenRotated(recreateAllReviewerContainers);
|
||||
onTokenRefreshed(recreateAllReviewerContainers);
|
||||
startTokenRefreshLoop();
|
||||
|
||||
loadState();
|
||||
@@ -392,7 +368,6 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
await queue.shutdown(10000);
|
||||
cleanupOrphans();
|
||||
for (const ch of channels) await ch.disconnect();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
@@ -322,7 +322,7 @@ async function checkAndRefreshAll(): Promise<void> {
|
||||
}
|
||||
|
||||
if (anyRefreshed) {
|
||||
// Sync refreshed tokens to process.env so docker exec picks them up
|
||||
// Sync refreshed tokens to process.env so subsequent turns pick them up
|
||||
const refreshedTokens = getAllTokens();
|
||||
if (refreshedTokens.length > 0) {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = refreshedTokens[0].token;
|
||||
@@ -333,31 +333,9 @@ async function checkAndRefreshAll(): Promise<void> {
|
||||
}
|
||||
}
|
||||
updateEnvTokens();
|
||||
if (onTokenRefreshedCallback) {
|
||||
try {
|
||||
onTokenRefreshedCallback();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err: getErrorMessage(err) },
|
||||
'Post-token-refresh callback failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post-refresh callback ───────────────────────────────────────
|
||||
// Allows callers (e.g. index.ts) to register a hook that runs after
|
||||
// any token is refreshed — used to recreate reviewer containers whose
|
||||
// persistent Claude Code SDK process still holds the old token.
|
||||
type TokenRefreshCallback = () => void;
|
||||
let onTokenRefreshedCallback: TokenRefreshCallback | null = null;
|
||||
|
||||
/** Register a callback to be invoked after a successful token refresh. */
|
||||
export function onTokenRefreshed(cb: TokenRefreshCallback): void {
|
||||
onTokenRefreshedCallback = cb;
|
||||
}
|
||||
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user