Files
EJClaw/src/agent-runner-environment.ts
ejclaw 03d4c81192 fix: recover Codex rotation auth failures
- mark Codex bearer/refresh failures as terminal auth-expired states

- sync refreshed session auth back to rotation slots and revive refreshed dead_auth slots

- stop paired arbiter retry loops when Codex accounts are unavailable

- add regression coverage for rotation leases, follow-up suppression, and arbiter closure
2026-06-02 00:21:53 +09:00

879 lines
26 KiB
TypeScript

import fs from 'fs';
import os from 'os';
import path from 'path';
import { EJCLAW_ENV } from 'ejclaw-runners-shared';
import {
GROUPS_DIR,
SERVICE_ID,
SERVICE_SESSION_SCOPE,
TIMEZONE,
isReviewService,
} from './config.js';
import { logger } from './logger.js';
import { readEnvFile } from './env.js';
import {
claimCodexAuthLease,
findCodexAccountIndexByAuthPath,
getActiveCodexAuthPath,
getCodexAccountCount,
type CodexAuthLease,
} from './codex-token-rotation.js';
import { readCodexFeatureFromFile } from './codex-config-features.js';
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
import {
getConfiguredClaudeTokens,
getCurrentToken,
} from './token-rotation.js';
import {
resolveGroupFolderPath,
resolveGroupIpcPath,
resolveServiceGroupSessionsPath,
resolveTaskRuntimeIpcPath,
resolveServiceTaskSessionsPath,
} from './group-folder.js';
import {
readArbiterPrompt,
readPairedRoomPrompt,
readPlatformPrompt,
} from './platform-prompts.js';
import {
getEffectiveChannelLease,
hasReviewerLease,
} from './service-routing.js';
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
import type { StoredRoomSkillOverride } from './db/rooms.js';
// writeCodexApiKeyAuth removed — Codex uses OAuth only.
// API key auth caused unintended billing.
function syncDirectoryEntries(sources: string[], destination: string): void {
for (const source of sources) {
if (!fs.existsSync(source)) continue;
for (const entry of fs.readdirSync(source)) {
const srcPath = path.join(source, entry);
const dstPath = path.join(destination, entry);
if (fs.statSync(srcPath).isDirectory()) {
fs.cpSync(srcPath, dstPath, { recursive: true });
} else {
fs.mkdirSync(destination, { recursive: true });
fs.copyFileSync(srcPath, dstPath);
}
}
}
}
type SkillSyncScope = 'codex-user' | 'claude-user' | 'runner' | 'workdir';
interface SkillSyncSource {
dir: string;
scope: SkillSyncScope;
}
function getDisabledSkillNamesByScope(
overrides: StoredRoomSkillOverride[] | undefined,
agentType: AgentType,
): Map<SkillSyncScope, Set<string>> {
const disabled = new Map<SkillSyncScope, Set<string>>();
for (const override of overrides ?? []) {
if (override.agentType !== agentType || override.enabled !== false)
continue;
if (
override.skillScope !== 'codex-user' &&
override.skillScope !== 'claude-user' &&
override.skillScope !== 'runner'
) {
continue;
}
const names = disabled.get(override.skillScope) ?? new Set<string>();
names.add(override.skillName);
disabled.set(override.skillScope, names);
}
return disabled;
}
function hasDisabledSkillOverrides(
overrides: StoredRoomSkillOverride[] | undefined,
agentType: AgentType,
): boolean {
return (overrides ?? []).some(
(override) =>
override.agentType === agentType && override.enabled === false,
);
}
function syncRoomSkillDirectories(args: {
sources: SkillSyncSource[];
destination: string;
agentType: AgentType;
overrides?: StoredRoomSkillOverride[];
}): void {
const disabledNamesByScope = getDisabledSkillNamesByScope(
args.overrides,
args.agentType,
);
fs.rmSync(args.destination, { recursive: true, force: true });
fs.mkdirSync(args.destination, { recursive: true });
for (const source of args.sources) {
if (!fs.existsSync(source.dir)) continue;
for (const entry of fs.readdirSync(source.dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (disabledNamesByScope.get(source.scope)?.has(entry.name)) continue;
const srcPath = path.join(source.dir, entry.name);
const dstPath = path.join(args.destination, entry.name);
fs.cpSync(srcPath, dstPath, { recursive: true });
}
}
}
function readOptionalPromptFile(
projectRoot: string,
filename: string,
): string | undefined {
const promptPath = path.join(projectRoot, 'prompts', filename);
if (!fs.existsSync(promptPath)) return undefined;
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
return prompt || undefined;
}
function ensureClaudeGlobalSettingsFile(sessionDir: string): void {
const settingsFile = path.join(sessionDir, '.claude.json');
if (fs.existsSync(settingsFile)) return;
fs.writeFileSync(settingsFile, '{}\n');
}
export interface PreparedCodexSessionAuth {
canonicalAuthPath: string;
sessionAuthPath: string;
accountIndex: number | null;
lease?: CodexAuthLease;
}
function syncHostCodexSessionFiles(
sessionCodexDir: string,
): PreparedCodexSessionAuth | null {
const hostCodexDir = path.join(os.homedir(), '.codex');
fs.mkdirSync(sessionCodexDir, { recursive: true });
const authDst = path.join(sessionCodexDir, 'auth.json');
const hasRotationAccounts = getCodexAccountCount() > 0;
const lease = claimCodexAuthLease();
const rotatedAuthSrc =
lease?.authPath ?? (!hasRotationAccounts ? getActiveCodexAuthPath() : null);
const fallbackAuthSrc = path.join(hostCodexDir, 'auth.json');
const authSrc =
rotatedAuthSrc && fs.existsSync(rotatedAuthSrc)
? rotatedAuthSrc
: !hasRotationAccounts && fs.existsSync(fallbackAuthSrc)
? fallbackAuthSrc
: null;
if (authSrc) {
fs.copyFileSync(authSrc, authDst);
} else if (fs.existsSync(authDst)) {
fs.unlinkSync(authDst);
}
for (const file of ['config.toml', 'config.json']) {
const src = path.join(hostCodexDir, file);
const dst = path.join(sessionCodexDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dst);
}
}
if (
!rotatedAuthSrc ||
authSrc !== rotatedAuthSrc ||
!fs.existsSync(authDst)
) {
lease?.release();
if (hasRotationAccounts) {
throw new Error(
'auth-expired: All Codex rotation accounts unavailable; re-auth required before launching Codex',
);
}
return null;
}
return {
canonicalAuthPath: rotatedAuthSrc,
sessionAuthPath: authDst,
accountIndex:
lease?.accountIndex ?? findCodexAccountIndexByAuthPath(rotatedAuthSrc),
...(lease ? { lease } : {}),
};
}
function upsertEjclawMcpServerSection(args: {
sessionConfigPath: string;
mcpServerPath: string;
ipcDir: string;
hostIpcDir: string;
chatJid: string;
groupFolder: string;
isMain: boolean;
agentType: AgentType;
roomRole?: PairedRoomRole;
workDir?: string;
}): void {
const stripEjclawMcpServerSections = (input: string): string => {
const lines = input.split('\n');
const kept: string[] = [];
let skipping = false;
for (const line of lines) {
const trimmed = line.trim();
const isSectionHeader = trimmed.startsWith('[') && trimmed.endsWith(']');
const isEjclawSection = /^\[mcp_servers\.ejclaw(?:\.[^\]]+)?\]$/.test(
trimmed,
);
if (isSectionHeader) {
if (isEjclawSection) {
skipping = true;
continue;
}
if (skipping) {
skipping = false;
}
}
if (!skipping) {
kept.push(line);
}
}
return kept.join('\n').replace(/^\n+/, '');
};
let toml = fs.existsSync(args.sessionConfigPath)
? fs.readFileSync(args.sessionConfigPath, 'utf-8')
: '';
toml = stripEjclawMcpServerSections(toml);
const tomlEnvLine = (name: string, value: string): string =>
`${name} = ${JSON.stringify(value)}`;
const mcpSection = `
[mcp_servers.ejclaw]
command = "node"
args = [${JSON.stringify(args.mcpServerPath)}]
[mcp_servers.ejclaw.env]
${tomlEnvLine(EJCLAW_ENV.ipcDir, args.ipcDir)}
${tomlEnvLine(EJCLAW_ENV.hostIpcDir, args.hostIpcDir)}
${tomlEnvLine(EJCLAW_ENV.chatJid, args.chatJid)}
${tomlEnvLine(EJCLAW_ENV.groupFolder, args.groupFolder)}
${tomlEnvLine(EJCLAW_ENV.isMain, args.isMain ? '1' : '0')}
${tomlEnvLine(EJCLAW_ENV.agentType, args.agentType)}
${args.roomRole ? `${tomlEnvLine(EJCLAW_ENV.roomRole, args.roomRole)}\n` : ''}
${args.workDir ? `${tomlEnvLine(EJCLAW_ENV.workDir, args.workDir)}\n` : ''}
`;
fs.writeFileSync(args.sessionConfigPath, toml.trimEnd() + '\n' + mcpSection);
}
function buildBaseRunnerEnv(args: {
group: RegisteredGroup;
chatJid: string;
isMain: boolean;
groupDir: string;
groupIpcDir: string;
hostIpcDir: string;
globalDir: string;
groupSessionsDir: string;
agentType: AgentType;
envVars: Record<string, string>;
runtimeTaskId?: string;
}): Record<string, string> {
const cleanEnv = { ...(process.env as Record<string, string>) };
for (const [key, value] of Object.entries(args.envVars)) {
if (value && !cleanEnv[key]) cleanEnv[key] = value;
}
delete cleanEnv.CLAUDECODE;
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
const nodeBin = path.dirname(process.execPath);
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin');
const currentPath = cleanEnv.PATH || '/usr/local/bin:/usr/bin:/bin';
const extraPaths = [nodeBin, npmGlobalBin].filter(
(candidate) => !currentPath.includes(candidate) && fs.existsSync(candidate),
);
return {
...cleanEnv,
PATH:
extraPaths.length > 0
? `${extraPaths.join(':')}:${currentPath}`
: currentPath,
TZ: TIMEZONE,
HOME: os.homedir(),
[EJCLAW_ENV.groupDir]: args.groupDir,
[EJCLAW_ENV.ipcDir]: args.groupIpcDir,
[EJCLAW_ENV.hostIpcDir]: args.hostIpcDir,
[EJCLAW_ENV.globalDir]: args.globalDir,
...(args.group.workDir ? { [EJCLAW_ENV.workDir]: args.group.workDir } : {}),
[EJCLAW_ENV.chatJid]: args.chatJid,
[EJCLAW_ENV.groupFolder]: args.group.folder,
[EJCLAW_ENV.isMain]: args.isMain ? '1' : '0',
[EJCLAW_ENV.agentType]: args.agentType,
CLAUDE_CONFIG_DIR: args.groupSessionsDir,
...(args.runtimeTaskId
? { [EJCLAW_ENV.runtimeTaskId]: args.runtimeTaskId }
: {}),
};
}
function prepareClaudeEnvironment(args: {
env: Record<string, string>;
envVars: Record<string, string>;
group: RegisteredGroup;
}): void {
if (args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) {
args.env.ANTHROPIC_API_KEY =
args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';
}
if (args.envVars.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN) {
args.env.ANTHROPIC_AUTH_TOKEN =
args.envVars.ANTHROPIC_AUTH_TOKEN ||
process.env.ANTHROPIC_AUTH_TOKEN ||
'';
}
if (args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL) {
args.env.ANTHROPIC_BASE_URL =
args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || '';
}
{
// Token rotation takes priority over static .env value
const oauthToken =
getCurrentToken() ||
getConfiguredClaudeTokens({
multi:
args.envVars.CLAUDE_CODE_OAUTH_TOKENS ||
process.env.CLAUDE_CODE_OAUTH_TOKENS,
single:
args.envVars.CLAUDE_CODE_OAUTH_TOKEN ||
process.env.CLAUDE_CODE_OAUTH_TOKEN,
})[0];
if (oauthToken) {
args.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
}
}
for (const key of [
'CLAUDE_MODEL',
'CLAUDE_THINKING',
'CLAUDE_THINKING_BUDGET',
'CLAUDE_EFFORT',
]) {
const value =
args.envVars[key as keyof typeof args.envVars] || process.env[key];
if (value) args.env[key] = value;
}
if (args.group.agentConfig?.claudeModel) {
args.env.CLAUDE_MODEL = args.group.agentConfig.claudeModel;
}
if (args.group.agentConfig?.claudeEffort) {
args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort;
}
if (args.group.agentConfig?.claudeThinking) {
args.env.CLAUDE_THINKING = args.group.agentConfig.claudeThinking;
}
if (args.group.agentConfig?.claudeThinkingBudget) {
args.env.CLAUDE_THINKING_BUDGET = String(
args.group.agentConfig.claudeThinkingBudget,
);
}
}
function prepareCodexSessionEnvironment(args: {
env: Record<string, string>;
envVars: Record<string, string>;
projectRoot: string;
group: RegisteredGroup;
groupDir: string;
sessionRootDir: string;
chatJid: string;
isMain: boolean;
roomRole?: PairedRoomRole;
isPairedRoom: boolean;
useFailoverPromptPack: boolean;
memoryBriefing?: string;
skillOverrides?: StoredRoomSkillOverride[];
}): PreparedCodexSessionAuth | null {
// API key auth intentionally removed — Codex uses OAuth only.
// Never pass any API key to Codex child process to prevent API billing.
delete args.env.OPENAI_API_KEY;
delete args.env.CODEX_OPENAI_API_KEY;
const codexModel =
args.group.agentConfig?.codexModel ||
args.envVars.CODEX_MODEL ||
process.env.CODEX_MODEL;
if (codexModel) args.env.CODEX_MODEL = codexModel;
const codexEffort =
args.group.agentConfig?.codexEffort ||
args.envVars.CODEX_EFFORT ||
process.env.CODEX_EFFORT;
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
const codexSessionAuth = syncHostCodexSessionFiles(sessionCodexDir);
const overlayPath = path.join(args.groupDir, '.codex', 'config.toml');
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
if (fs.existsSync(overlayPath)) {
const overlayToml = fs.readFileSync(overlayPath, 'utf-8').trim();
if (overlayToml) {
const baseToml = fs.existsSync(sessionConfigPath)
? fs.readFileSync(sessionConfigPath, 'utf-8').trimEnd()
: '';
fs.writeFileSync(
sessionConfigPath,
[baseToml, overlayToml].filter(Boolean).join('\n\n') + '\n',
);
}
}
const goalsFromConfig = readCodexFeatureFromFile(sessionConfigPath, 'goals');
const codexGoals =
args.group.agentConfig?.codexGoals ??
(goalsFromConfig ||
args.envVars.CODEX_GOALS === 'true' ||
process.env.CODEX_GOALS === 'true');
if (codexGoals) {
args.env.CODEX_GOALS = 'true';
} else {
delete args.env.CODEX_GOALS;
}
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
const sessionAgents = (
args.useFailoverPromptPack
? [
readOptionalPromptFile(args.projectRoot, 'owner-common-platform.md'),
readOptionalPromptFile(
args.projectRoot,
'codex-review-failover-platform.md',
),
args.isPairedRoom
? readOptionalPromptFile(
args.projectRoot,
'owner-common-paired-room.md',
)
: undefined,
args.memoryBriefing,
]
: [
readPlatformPrompt('codex', args.projectRoot),
args.isPairedRoom
? readOptionalPromptFile(
args.projectRoot,
'owner-common-paired-room.md',
)
: undefined,
args.memoryBriefing,
]
)
.filter((value): value is string => Boolean(value))
.join('\n\n---\n\n')
.trim();
if (sessionAgents) {
fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n');
} else if (fs.existsSync(sessionAgentsPath)) {
fs.unlinkSync(sessionAgentsPath);
}
// Codex reads skills from ~/.agents/skills/ (user-level) and
// {workDir}/.agents/skills/ (project-level), NOT from .codex/skills/.
if (hasDisabledSkillOverrides(args.skillOverrides, 'codex')) {
const sessionHomeDir = path.join(args.sessionRootDir, 'home');
args.env.HOME = sessionHomeDir;
syncRoomSkillDirectories({
sources: [
{
dir: path.join(os.homedir(), '.claude', 'skills'),
scope: 'codex-user',
},
{
dir: path.join(os.homedir(), '.agents', 'skills'),
scope: 'codex-user',
},
{
dir: path.join(args.projectRoot, 'runners', 'skills'),
scope: 'runner',
},
],
destination: path.join(sessionHomeDir, '.agents', 'skills'),
agentType: 'codex',
overrides: args.skillOverrides,
});
} else {
// Preserve the historical global sync path when no room override exists.
const codexSkillsDir = path.join(os.homedir(), '.agents', 'skills');
syncDirectoryEntries(
[
path.join(os.homedir(), '.claude', 'skills'),
path.join(args.projectRoot, 'runners', 'skills'),
],
codexSkillsDir,
);
}
const mcpServerPath = path.join(
args.projectRoot,
'runners',
'agent-runner',
'dist',
'ipc-mcp-stdio.js',
);
if (fs.existsSync(mcpServerPath)) {
upsertEjclawMcpServerSection({
sessionConfigPath,
mcpServerPath,
ipcDir: args.env[EJCLAW_ENV.ipcDir],
hostIpcDir: args.env[EJCLAW_ENV.hostIpcDir],
chatJid: args.chatJid,
groupFolder: args.group.folder,
isMain: args.isMain,
agentType: 'codex',
roomRole: args.roomRole,
workDir:
args.env[EJCLAW_ENV.workDir] || args.group.workDir || args.projectRoot,
});
}
delete args.env.ANTHROPIC_API_KEY;
delete args.env.ANTHROPIC_AUTH_TOKEN;
delete args.env.ANTHROPIC_BASE_URL;
delete args.env.CLAUDE_CODE_OAUTH_TOKEN;
args.env.CODEX_HOME = sessionCodexDir;
return codexSessionAuth;
}
export interface PreparedGroupEnvironment {
env: Record<string, string>;
groupDir: string;
runnerDir: string;
codexSessionAuth?: PreparedCodexSessionAuth | null;
}
export interface PreparedReadonlySessionEnvironment {
codexHomeDir?: string;
codexSessionAuth?: PreparedCodexSessionAuth | null;
}
export function prepareGroupEnvironment(
group: RegisteredGroup,
isMain: boolean,
chatJid: string,
options?: {
memoryBriefing?: string;
runtimeTaskId?: string;
useTaskScopedSession?: boolean;
skillOverrides?: StoredRoomSkillOverride[];
roomRole?: PairedRoomRole;
},
): PreparedGroupEnvironment {
const projectRoot = process.cwd();
const groupDir = resolveGroupFolderPath(group.folder);
fs.mkdirSync(groupDir, { recursive: true });
const runtimeTaskId = options?.runtimeTaskId;
const useTaskScopedSession =
options?.useTaskScopedSession === true && Boolean(runtimeTaskId);
const sessionRootDir =
runtimeTaskId && useTaskScopedSession
? resolveServiceTaskSessionsPath(
group.folder,
SERVICE_SESSION_SCOPE,
runtimeTaskId,
)
: resolveServiceGroupSessionsPath(group.folder, SERVICE_SESSION_SCOPE);
const groupSessionsDir = path.join(sessionRootDir, '.claude');
fs.mkdirSync(groupSessionsDir, { recursive: true });
ensureClaudeSessionSettings(groupSessionsDir);
const workDirClaude = group.workDir
? path.join(group.workDir, '.claude')
: null;
syncRoomSkillDirectories({
sources: [
{
dir: path.join(os.homedir(), '.claude', 'skills'),
scope: 'claude-user',
},
...(workDirClaude
? [
{
dir: path.join(workDirClaude, 'skills'),
scope: 'workdir' as const,
},
]
: []),
{ dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
],
destination: path.join(groupSessionsDir, 'skills'),
agentType: 'claude-code',
overrides: options?.skillOverrides,
});
const groupIpcDir = runtimeTaskId
? resolveTaskRuntimeIpcPath(group.folder, runtimeTaskId)
: resolveGroupIpcPath(group.folder);
const hostIpcDir = resolveGroupIpcPath(group.folder);
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
const globalDir = path.join(GROUPS_DIR, 'global');
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
const isPairedRoom = hasReviewerLease(chatJid);
const effectiveLease = getEffectiveChannelLease(chatJid);
// Canonical lease state now exposes owner failover directly, so prefer the
// explicit flag over the older CODEX_REVIEW_SERVICE_ID shadow heuristic.
const useCodexReviewFailoverPromptPack =
isReviewService(SERVICE_ID) &&
effectiveLease.owner_failover_active === true;
const ownerCommonPlatformPrompt = readOptionalPromptFile(
projectRoot,
'owner-common-platform.md',
);
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
const ownerCommonPairedRoomPrompt = isPairedRoom
? readOptionalPromptFile(projectRoot, 'owner-common-paired-room.md')
: undefined;
const globalClaudeMemory =
!isMain && fs.existsSync(globalClaudeMdPath)
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
: undefined;
// Owner CLAUDE.md: platform rules + owner paired room rules.
// Reviewer paired room rules are NOT included — those belong to the
// Read-only reviewer/arbiter session only (via prepareReadonlySessionEnvironment).
const sessionClaudeMd = [
ownerCommonPlatformPrompt,
claudePlatformPrompt,
ownerCommonPairedRoomPrompt,
globalClaudeMemory,
options?.memoryBriefing,
]
.filter((value): value is string => Boolean(value))
.join('\n\n---\n\n')
.trim();
const sessionClaudeMdPath = path.join(groupSessionsDir, 'CLAUDE.md');
if (sessionClaudeMd) {
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
} else if (fs.existsSync(sessionClaudeMdPath)) {
fs.unlinkSync(sessionClaudeMdPath);
}
const agentType = group.agentType || 'claude-code';
const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner';
const runnerDir = path.join(projectRoot, 'runners', runnerDirName);
const envVars = readEnvFile([
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
'CLAUDE_CODE_OAUTH_TOKENS',
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_MODEL',
'CLAUDE_THINKING',
'CLAUDE_THINKING_BUDGET',
'CLAUDE_EFFORT',
'CODEX_MODEL',
'CODEX_EFFORT',
'CODEX_GOALS',
]);
const env = buildBaseRunnerEnv({
group,
chatJid,
isMain,
groupDir,
groupIpcDir,
hostIpcDir,
globalDir,
groupSessionsDir,
agentType,
envVars,
runtimeTaskId,
});
let codexSessionAuth: PreparedCodexSessionAuth | null = null;
if (agentType === 'codex') {
codexSessionAuth = prepareCodexSessionEnvironment({
env,
envVars,
projectRoot,
group,
groupDir,
sessionRootDir,
chatJid,
isMain,
roomRole: options?.roomRole,
isPairedRoom,
useFailoverPromptPack: useCodexReviewFailoverPromptPack,
memoryBriefing: options?.memoryBriefing,
skillOverrides: options?.skillOverrides,
});
} else {
prepareClaudeEnvironment({ env, envVars, group });
}
return { env, groupDir, runnerDir, codexSessionAuth };
}
/**
* Prepare a role-scoped session directory for a host-based read-only reviewer
* or arbiter run.
*
* Writes CLAUDE.md (platform + paired room prompts + global memory + briefing),
* syncs skills, and ensures settings.json exist — the same steps that
* `prepareGroupEnvironment` does for regular host-mode agents, but targeted at
* an externally provided session directory.
*/
export function prepareReadonlySessionEnvironment(args: {
sessionDir: string;
chatJid: string;
isMain: boolean;
groupFolder: string;
agentType: AgentType;
memoryBriefing?: string;
role?: 'reviewer' | 'arbiter';
ipcDir?: string;
hostIpcDir?: string;
workDir?: string;
skillOverrides?: StoredRoomSkillOverride[];
}): PreparedReadonlySessionEnvironment {
const {
sessionDir,
chatJid,
isMain,
groupFolder,
agentType,
memoryBriefing,
role = 'reviewer',
ipcDir = '/workspace/ipc',
hostIpcDir = ipcDir,
workDir = '/workspace/project',
skillOverrides,
} = args;
const projectRoot = process.cwd();
let codexSessionAuth: PreparedCodexSessionAuth | null = null;
fs.mkdirSync(sessionDir, { recursive: true });
ensureClaudeSessionSettings(sessionDir);
ensureClaudeGlobalSettingsFile(sessionDir);
// Sync skills from host
syncRoomSkillDirectories({
sources: [
{
dir: path.join(os.homedir(), '.claude', 'skills'),
scope: agentType === 'codex' ? 'codex-user' : 'claude-user',
},
{ dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
],
destination: path.join(sessionDir, 'skills'),
agentType,
overrides: skillOverrides,
});
const codexHomeDir =
agentType === 'codex' && hasDisabledSkillOverrides(skillOverrides, 'codex')
? sessionDir
: undefined;
if (codexHomeDir) {
syncRoomSkillDirectories({
sources: [
{
dir: path.join(os.homedir(), '.claude', 'skills'),
scope: 'codex-user',
},
{
dir: path.join(os.homedir(), '.agents', 'skills'),
scope: 'codex-user',
},
{ dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
],
destination: path.join(codexHomeDir, '.agents', 'skills'),
agentType,
overrides: skillOverrides,
});
}
// Build CLAUDE.md with role-appropriate prompts (reviewer or arbiter)
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
const claudePairedRoomPrompt = hasReviewerLease(chatJid)
? role === 'arbiter'
? readArbiterPrompt(projectRoot)
: readPairedRoomPrompt('claude-code', projectRoot)
: undefined;
const globalDir = path.join(GROUPS_DIR, 'global');
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
const globalClaudeMemory =
!isMain && fs.existsSync(globalClaudeMdPath)
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
: undefined;
const sessionClaudeMd = [
claudePlatformPrompt,
claudePairedRoomPrompt,
globalClaudeMemory,
memoryBriefing,
]
.filter((value): value is string => Boolean(value))
.join('\n\n---\n\n')
.trim();
const sessionClaudeMdPath = path.join(sessionDir, 'CLAUDE.md');
if (sessionClaudeMd) {
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
const sessionCodexDir = path.join(sessionDir, '.codex');
codexSessionAuth = syncHostCodexSessionFiles(sessionCodexDir);
fs.writeFileSync(
path.join(sessionCodexDir, 'AGENTS.md'),
sessionClaudeMd + '\n',
);
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
const mcpServerPath = path.join(
projectRoot,
'runners',
'agent-runner',
'dist',
'ipc-mcp-stdio.js',
);
if (fs.existsSync(mcpServerPath)) {
upsertEjclawMcpServerSection({
sessionConfigPath,
mcpServerPath,
ipcDir,
hostIpcDir,
chatJid,
groupFolder,
isMain,
agentType,
roomRole: role,
workDir,
});
}
logger.info(
{
sessionDir,
claudeMdSize: sessionClaudeMd.length,
hasPlatform: !!claudePlatformPrompt,
hasPairedRoom: !!claudePairedRoomPrompt,
hasGlobalMemory: !!globalClaudeMemory,
hasMemoryBriefing: !!memoryBriefing,
},
'Readonly session CLAUDE.md written',
);
} else if (fs.existsSync(sessionClaudeMdPath)) {
fs.unlinkSync(sessionClaudeMdPath);
}
return { codexHomeDir, codexSessionAuth };
}