Enable unsafe host paired runtime

This commit is contained in:
ejclaw
2026-04-06 02:40:15 +09:00
parent 446b194e2c
commit 994e957767
12 changed files with 261 additions and 5 deletions

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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,
};
}

View File

@@ -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', () => {

View File

@@ -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',

View File

@@ -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 = [

View File

@@ -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<string, string>
| 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',
);
});
});

View File

@@ -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<void>,
envOverrides?: Record<string, string>,
): Promise<AgentOutput> {
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;
}

View File

@@ -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',

View File

@@ -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.

View File

@@ -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',

View File

@@ -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 {