Enable unsafe host paired runtime
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user