From 9c0de38c65c83f23ff5a6ee7157c05af7b9f59c5 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 6 Apr 2026 02:40:15 +0900 Subject: [PATCH] Enable unsafe host paired runtime --- runners/agent-runner/src/reviewer-runtime.ts | 3 + runners/codex-runner/src/reviewer-runtime.ts | 3 + setup/service-defs.ts | 8 +- setup/service.test.ts | 3 + src/agent-runner-environment.test.ts | 3 + src/agent-runner-environment.ts | 8 ++ src/agent-runner.test.ts | 125 ++++++++++++++++++- src/agent-runner.ts | 38 +++++- src/container-runner.test.ts | 14 +++ src/container-runner.ts | 7 ++ src/paired-execution-context.test.ts | 40 ++++++ src/paired-execution-context.ts | 14 ++- 12 files changed, 261 insertions(+), 5 deletions(-) diff --git a/runners/agent-runner/src/reviewer-runtime.ts b/runners/agent-runner/src/reviewer-runtime.ts index d217e6f..baf26da 100644 --- a/runners/agent-runner/src/reviewer-runtime.ts +++ b/runners/agent-runner/src/reviewer-runtime.ts @@ -34,6 +34,9 @@ const MUTATING_SHELL_PATTERNS = [ export function isReviewerRuntime( roomRoleContext?: RoomRoleContext, ): boolean { + if (process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1') { + return false; + } return roomRoleContext?.role === 'reviewer'; } diff --git a/runners/codex-runner/src/reviewer-runtime.ts b/runners/codex-runner/src/reviewer-runtime.ts index 012c0b1..8b1b0b3 100644 --- a/runners/codex-runner/src/reviewer-runtime.ts +++ b/runners/codex-runner/src/reviewer-runtime.ts @@ -31,6 +31,9 @@ const BLOCKED_GIT_SUBCOMMANDS = new Set([ export function isReviewerRuntime( roomRoleContext?: RoomRoleContext, ): boolean { + if (process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1') { + return false; + } return roomRoleContext?.role === 'reviewer'; } diff --git a/setup/service-defs.ts b/setup/service-defs.ts index cf3ecd6..2125a5a 100644 --- a/setup/service-defs.ts +++ b/setup/service-defs.ts @@ -54,6 +54,12 @@ const LEGACY_SERVICE_TEMPLATES: ServiceTemplate[] = [ function materializeServiceDef(template: ServiceTemplate): ServiceDef { const environmentFile = undefined; + const extraEnv = + template.kind === 'primary' + ? { + EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1', + } + : undefined; return { kind: template.kind, @@ -62,7 +68,7 @@ function materializeServiceDef(template: ServiceTemplate): ServiceDef { description: template.description, logName: template.logName, environmentFile, - extraEnv: undefined, + extraEnv, }; } diff --git a/setup/service.test.ts b/setup/service.test.ts index cb7f44e..eff0c9e 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -184,6 +184,9 @@ describe('service definitions', () => { expect(defs.map((def) => def.name)).toEqual(['ejclaw']); expect(defs.map((def) => def.kind)).toEqual(['primary']); + expect(defs[0]?.extraEnv).toMatchObject({ + EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1', + }); }); it('keeps legacy service identities available for migration guards', () => { diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index 7f91cb4..386cfdd 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -425,6 +425,9 @@ args = ["other.js"] expect( fs.readFileSync(path.join(sessionDir, '.codex', 'auth.json'), 'utf-8'), ).toContain('"auth_mode":"chatgpt"'); + expect( + fs.readFileSync(path.join(sessionDir, '.claude.json'), 'utf-8'), + ).toBe('{}\n'); const toml = fs.readFileSync( path.join(sessionDir, '.codex', 'config.toml'), 'utf-8', diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 49e18ba..9b5c1f9 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -80,6 +80,13 @@ function ensureClaudeSessionSettings(groupSessionsDir: string): void { ); } +export function ensureClaudeGlobalSettingsFile(sessionDir: string): void { + const settingsFile = path.join(sessionDir, '.claude.json'); + if (fs.existsSync(settingsFile)) return; + + fs.writeFileSync(settingsFile, '{}\n'); +} + function syncHostCodexSessionFiles(sessionCodexDir: string): void { const hostCodexDir = path.join(os.homedir(), '.codex'); fs.mkdirSync(sessionCodexDir, { recursive: true }); @@ -586,6 +593,7 @@ export function prepareContainerSessionEnvironment(args: { fs.mkdirSync(sessionDir, { recursive: true }); ensureClaudeSessionSettings(sessionDir); + ensureClaudeGlobalSettingsFile(sessionDir); // Sync skills from host const skillSources = [ diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index dba711c..d2a27de 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -105,7 +105,13 @@ vi.mock('child_process', async () => { }; }); -import { runAgentProcess, AgentOutput } from './agent-runner.js'; +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 = { @@ -133,6 +139,7 @@ function emitOutputMarker( describe('agent-runner timeout behavior', () => { beforeEach(() => { vi.useFakeTimers(); + vi.clearAllMocks(); fakeProc = createFakeProcess(); }); @@ -313,6 +320,103 @@ describe('agent-runner timeout behavior', () => { ); }); + it('uses host IPC and workdir in unsafe host paired mode reviewer sessions', async () => { + vi.useRealTimers(); + fakeProc = createFakeProcess(); + const prepareContainerSessionEnvironmentSpy = vi.spyOn( + agentRunnerEnvironment, + 'prepareContainerSessionEnvironment', + ); + const runReviewerContainerSpy = vi.spyOn( + containerRunner, + 'runReviewerContainer', + ); + + vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => { + const str = String(p); + return ( + str.includes('dist/index.js') || + str.includes('dist/ipc-mcp-stdio.js') || + str.endsWith('/.codex/config.toml') + ); + }); + + const resultPromise = runAgentProcess( + testGroup, + { + ...testInput, + roomRoleContext: { + serviceId: 'claude', + role: 'reviewer', + ownerServiceId: 'codex-main', + reviewerServiceId: 'claude', + failoverOwner: false, + }, + }, + () => {}, + async () => {}, + { + EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1', + CLAUDE_CONFIG_DIR: '/tmp/host-reviewer-session', + EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer', + }, + ); + + 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', + ipcDir: '/tmp/ejclaw-test-data/ipc/test-group', + hostIpcDir: '/tmp/ejclaw-test-data/ipc/test-group', + workDir: '/tmp/paired/task-1/reviewer', + role: 'reviewer', + }), + ); + }); + + it('uses role-scoped CODEX_HOME in unsafe host paired mode for codex reviewer', async () => { + vi.useRealTimers(); + fakeProc = createFakeProcess(); + + const codexGroup: RegisteredGroup = { + ...testGroup, + agentType: 'codex', + }; + + const resultPromise = runAgentProcess( + codexGroup, + { + ...testInput, + roomRoleContext: { + serviceId: 'codex-review', + role: 'reviewer', + ownerServiceId: 'codex-main', + reviewerServiceId: 'codex-review', + failoverOwner: false, + }, + }, + () => {}, + async () => {}, + { + EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1', + CLAUDE_CONFIG_DIR: '/tmp/host-reviewer-session', + EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer', + }, + ); + + fakeProc.emit('close', 0); + const result = await resultPromise; + expect(result.status).toBe('success'); + + const spawnEnv = vi.mocked(spawn).mock.calls.at(-1)?.[2]?.env as + | Record + | undefined; + expect(spawnEnv?.CODEX_HOME).toBe('/tmp/host-reviewer-session/.codex'); + }); + it('serializes roomRoleContext into the runner stdin payload', async () => { vi.useRealTimers(); fakeProc = createFakeProcess(); @@ -584,4 +688,23 @@ OUROBOROS_LLM_BACKEND = "codex" expect.objectContaining({ newSessionId: 'stale-session' }), ); }); + + it('mirrors .claude.json alongside codex session files for container reuse', () => { + vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => { + const str = String(p); + return ( + str === '/tmp/source/.codex' || + str === '/tmp/mounted' || + str === '/tmp/source/.codex/AGENTS.md' || + str === '/tmp/source/.claude.json' + ); + }); + + mirrorContainerCodexSessionFiles('/tmp/source', '/tmp/mounted'); + + expect(vi.mocked(fs.copyFileSync)).toHaveBeenCalledWith( + '/tmp/source/.claude.json', + '/tmp/mounted/.claude.json', + ); + }); }); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index cf1d218..eec92d4 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -13,6 +13,7 @@ import { IDLE_TIMEOUT, } from './config.js'; import { + ensureClaudeGlobalSettingsFile, prepareContainerSessionEnvironment, prepareGroupEnvironment, } from './agent-runner-environment.js'; @@ -78,6 +79,15 @@ export function mirrorContainerCodexSessionFiles( 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( @@ -91,12 +101,16 @@ export async function runAgentProcess( onOutput?: (output: AgentOutput) => Promise, envOverrides?: Record, ): Promise { + 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' || - envOverrides?.EJCLAW_ARBITER_RUNTIME === '1' + (!unsafeHostPairedMode && envOverrides?.EJCLAW_ARBITER_RUNTIME === '1') ) { const ownerWorkspaceDir = envOverrides?.EJCLAW_WORK_DIR || group.workDir || process.cwd(); @@ -171,6 +185,28 @@ export async function runAgentProcess( if (value) env[key] = value; } } + if ( + unsafeHostPairedMode && + envOverrides?.CLAUDE_CONFIG_DIR && + (input.roomRoleContext?.role === 'reviewer' || + input.roomRoleContext?.role === 'arbiter') + ) { + prepareContainerSessionEnvironment({ + sessionDir: envOverrides.CLAUDE_CONFIG_DIR, + chatJid: input.chatJid, + isMain: input.isMain, + groupFolder: group.folder, + agentType: group.agentType || 'claude-code', + memoryBriefing: input.memoryBriefing, + role: input.roomRoleContext.role, + ipcDir: env.EJCLAW_IPC_DIR, + hostIpcDir: env.EJCLAW_HOST_IPC_DIR, + workDir: envOverrides.EJCLAW_WORK_DIR || env.EJCLAW_WORK_DIR, + }); + if ((group.agentType || 'claude-code') === 'codex') { + env.CODEX_HOME = path.join(envOverrides.CLAUDE_CONFIG_DIR, '.codex'); + } + } if (input.runId) { env.EJCLAW_RUN_ID = input.runId; } diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index b587bf5..0f491da 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -40,6 +40,10 @@ vi.mock('./container-runtime.js', () => ({ tmpfsMountArgs: (containerPath: string) => ['--tmpfs', containerPath], })); +vi.mock('./agent-runner-environment.js', () => ({ + ensureClaudeGlobalSettingsFile: vi.fn(), +})); + vi.mock('./credential-proxy.js', () => ({ detectAuthMode: mockDetectAuthMode, })); @@ -149,6 +153,16 @@ describe('container-runner path compatibility', () => { 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', diff --git a/src/container-runner.ts b/src/container-runner.ts index 283fc38..7fb4771 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -32,6 +32,7 @@ import { 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'; @@ -435,11 +436,17 @@ export function buildReviewerMounts( `${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. diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index af7b99c..afb32d8 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -380,6 +380,46 @@ describe('paired execution context', () => { expect(result?.envOverrides.EJCLAW_WORK_DIR).toBeUndefined(); }); + it('routes reviewer to host mode when unsafe host paired mode is enabled', () => { + process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1'; + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue( + buildPairedTask({ + status: 'review_ready', + review_requested_at: '2026-03-28T00:00:00.000Z', + }), + ); + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'review_ready', + review_requested_at: '2026-03-28T00:00:00.000Z', + }), + ); + vi.mocked( + pairedWorkspaceManager.prepareReviewerWorkspaceForExecution, + ).mockReturnValue({ + workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'), + autoRefreshed: false, + }); + + const result = preparePairedExecutionContext({ + group, + chatJid: 'dc:test', + runId: 'run-host-reviewer', + roomRoleContext: reviewerContext, + }); + + expect(result?.envOverrides).toMatchObject({ + EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer', + EJCLAW_PAIRED_ROLE: 'reviewer', + EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1', + }); + expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined(); + expect(result?.envOverrides.CLAUDE_CONFIG_DIR).toContain( + '/data/sessions/paired-room-reviewer', + ); + delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE; + }); + it('completePairedExecutionContext logs without error', () => { completePairedExecutionContext({ taskId: 'task-1', diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 966702d..005b864 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -368,12 +368,13 @@ export function preparePairedExecutionContext(args: { EJCLAW_PAIRED_TASK_ID: task.id, EJCLAW_PAIRED_ROLE: roomRoleContext.role, }; + const unsafeHostPairedMode = + process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1'; if (workspace?.workspace_dir) { envOverrides.EJCLAW_WORK_DIR = workspace.workspace_dir; } if (roomRoleContext.role === 'reviewer') { - envOverrides.EJCLAW_REVIEWER_RUNTIME = '1'; // Use a separate Claude config dir so the reviewer's SDK session cache // doesn't collide with the owner's. Without this, the Claude SDK picks // up the owner's cached session from disk even when sessionId is undefined. @@ -384,8 +385,12 @@ export function preparePairedExecutionContext(args: { ); fs.mkdirSync(reviewerSessionDir, { recursive: true }); envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir; + if (unsafeHostPairedMode) { + envOverrides.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1'; + } else { + envOverrides.EJCLAW_REVIEWER_RUNTIME = '1'; + } } else if (roomRoleContext.role === 'arbiter') { - envOverrides.EJCLAW_ARBITER_RUNTIME = '1'; const arbiterSessionDir = path.join( DATA_DIR, 'sessions', @@ -396,6 +401,11 @@ export function preparePairedExecutionContext(args: { fs.rmSync(arbiterSessionDir, { recursive: true, force: true }); fs.mkdirSync(arbiterSessionDir, { recursive: true }); envOverrides.CLAUDE_CONFIG_DIR = arbiterSessionDir; + if (unsafeHostPairedMode) { + envOverrides.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1'; + } else { + envOverrides.EJCLAW_ARBITER_RUNTIME = '1'; + } } return {