import fs from 'fs'; import path from 'path'; import { CronExpressionParser } from 'cron-parser'; import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js'; import { isHostEvidenceAction, runHostEvidenceRequest, writeHostEvidenceResponse, } from './host-evidence.js'; import { readJsonFile } from './utils.js'; import { AvailableGroup } from './agent-runner.js'; import { type AssignRoomInput, createTask, deleteTask, findDuplicateCiWatcher, getTaskById, rememberMemory, updateTask, } from './db.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; import { DEFAULT_WATCH_CI_MAX_DURATION_MS, isWatchCiTask, } from './task-watch-status.js'; import { AgentType, RegisteredGroup, RoomMode } from './types.js'; export interface IpcDeps { sendMessage: ( jid: string, text: string, senderRole?: string, runId?: string, ) => Promise; nudgeScheduler?: () => void; roomBindings: () => Record; assignRoom: (jid: string, room: AssignRoomInput) => void; syncGroups: (force: boolean) => Promise; getAvailableGroups: () => AvailableGroup[]; writeGroupsSnapshot: ( groupFolder: string, isMain: boolean, availableGroups: AvailableGroup[], ) => void; } export interface IpcMessagePayload { type?: string; chatJid?: string; text?: string; senderRole?: string; runId?: string; } export interface IpcMessageForwardResult { outcome: 'ignored' | 'sent' | 'blocked'; chatJid?: string; targetGroup?: string | null; isMainOverride?: boolean; senderRole?: string | null; } export async function forwardAuthorizedIpcMessage( msg: IpcMessagePayload, sourceGroup: string, isMain: boolean, roomBindings: Record, sendMessage: IpcDeps['sendMessage'], ): Promise { if (!(msg.type === 'message' && msg.chatJid && msg.text)) { return { outcome: 'ignored', senderRole: msg.senderRole ?? null }; } const targetGroup = roomBindings[msg.chatJid]; const isMainOverride = isMain === true; if ( !(isMainOverride || (targetGroup && targetGroup.folder === sourceGroup)) ) { return { outcome: 'blocked', chatJid: msg.chatJid, targetGroup: targetGroup?.folder ?? null, isMainOverride, senderRole: msg.senderRole ?? null, }; } await sendMessage(msg.chatJid, msg.text, msg.senderRole, msg.runId); return { outcome: 'sent', chatJid: msg.chatJid, targetGroup: targetGroup?.folder ?? null, isMainOverride, senderRole: msg.senderRole ?? null, }; } let ipcWatcherRunning = false; const IPC_PROCESSING_DIRNAME = '.processing'; function buildIpcErrorPath( errorDir: string, prefix: string, fileName: string, ): string { return path.join(errorDir, `${prefix}-${Date.now()}-${fileName}`); } export function claimIpcFile(filePath: string): string | null { const processingDir = path.join( path.dirname(filePath), IPC_PROCESSING_DIRNAME, ); fs.mkdirSync(processingDir, { recursive: true }); const claimedPath = path.join(processingDir, path.basename(filePath)); try { fs.renameSync(filePath, claimedPath); return claimedPath; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return null; } throw err; } } export function quarantineClaimedIpcFiles( ipcDir: string, errorDir: string, prefix: string, ): string[] { const processingDir = path.join(ipcDir, IPC_PROCESSING_DIRNAME); if (!fs.existsSync(processingDir)) { return []; } const movedPaths: string[] = []; for (const file of fs .readdirSync(processingDir) .filter((f) => f.endsWith('.json'))) { const claimedPath = path.join(processingDir, file); const errorPath = buildIpcErrorPath(errorDir, prefix, file); fs.renameSync(claimedPath, errorPath); movedPaths.push(errorPath); } return movedPaths; } function moveClaimedIpcFileToError( claimedPath: string, errorDir: string, prefix: string, ): void { fs.renameSync( claimedPath, buildIpcErrorPath(errorDir, prefix, path.basename(claimedPath)), ); } export function startIpcWatcher(deps: IpcDeps): void { if (ipcWatcherRunning) { logger.debug('IPC watcher already running, skipping duplicate start'); return; } ipcWatcherRunning = true; const ipcBaseDir = path.join(DATA_DIR, 'ipc'); fs.mkdirSync(ipcBaseDir, { recursive: true }); const processIpcFiles = async () => { // Scan all group IPC directories (identity determined by directory) let groupFolders: string[]; try { groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => { const stat = fs.statSync(path.join(ipcBaseDir, f)); return stat.isDirectory() && f !== 'errors'; }); } catch (err) { logger.error({ err }, 'Error reading IPC base directory'); setTimeout(processIpcFiles, IPC_POLL_INTERVAL); return; } const roomBindings = deps.roomBindings(); // Build folder→isMain lookup from registered groups const folderIsMain = new Map(); for (const group of Object.values(roomBindings)) { if (group.isMain) folderIsMain.set(group.folder, true); } for (const sourceGroup of groupFolders) { const isMain = folderIsMain.get(sourceGroup) === true; const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages'); const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks'); const errorDir = path.join(ipcBaseDir, 'errors'); fs.mkdirSync(errorDir, { recursive: true }); for (const quarantinedPath of quarantineClaimedIpcFiles( messagesDir, errorDir, `${sourceGroup}-message-stale`, )) { logger.warn( { sourceGroup, quarantinedPath }, 'Quarantined previously claimed IPC message after restart', ); } for (const quarantinedPath of quarantineClaimedIpcFiles( tasksDir, errorDir, `${sourceGroup}-task-stale`, )) { logger.warn( { sourceGroup, quarantinedPath }, 'Quarantined previously claimed IPC task after restart', ); } // Process messages from this group's IPC directory try { if (fs.existsSync(messagesDir)) { const messageFiles = fs .readdirSync(messagesDir) .filter((f) => f.endsWith('.json')); for (const file of messageFiles) { const filePath = path.join(messagesDir, file); const claimedPath = claimIpcFile(filePath); if (!claimedPath) continue; try { const data = readJsonFile(claimedPath); if (!data || typeof data !== 'object') throw new Error('Invalid JSON'); const msg = data as IpcMessagePayload; const forwardResult = await forwardAuthorizedIpcMessage( msg, sourceGroup, isMain, roomBindings, deps.sendMessage, ); if (forwardResult.outcome === 'sent') { logger.info( { transition: 'ipc:auth:allow', chatJid: forwardResult.chatJid, sourceGroup, targetGroup: forwardResult.targetGroup ?? null, isMainOverride: forwardResult.isMainOverride, senderRole: forwardResult.senderRole ?? null, }, 'IPC message sent', ); } else if (forwardResult.outcome === 'blocked') { logger.warn( { transition: 'ipc:auth:deny', chatJid: forwardResult.chatJid, sourceGroup, targetGroup: forwardResult.targetGroup ?? null, isMainOverride: forwardResult.isMainOverride, senderRole: forwardResult.senderRole ?? null, }, 'Unauthorized IPC message attempt blocked', ); } fs.unlinkSync(claimedPath); } catch (err) { logger.error( { file, sourceGroup, err }, 'Error processing IPC message', ); moveClaimedIpcFileToError( claimedPath, errorDir, `${sourceGroup}-message-error`, ); } } } } catch (err) { logger.error( { err, sourceGroup }, 'Error reading IPC messages directory', ); } // Process tasks from this group's IPC directory try { if (fs.existsSync(tasksDir)) { const taskFiles = fs .readdirSync(tasksDir) .filter((f) => f.endsWith('.json')); for (const file of taskFiles) { const filePath = path.join(tasksDir, file); const claimedPath = claimIpcFile(filePath); if (!claimedPath) continue; try { const data = readJsonFile(claimedPath); if (!data || typeof data !== 'object') throw new Error('Invalid JSON'); // Pass source group identity to processTaskIpc for authorization await processTaskIpc( data as Parameters[0], sourceGroup, isMain, deps, ); fs.unlinkSync(claimedPath); } catch (err) { logger.error( { file, sourceGroup, err }, 'Error processing IPC task', ); moveClaimedIpcFileToError( claimedPath, errorDir, `${sourceGroup}-task-error`, ); } } } } catch (err) { logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory'); } } setTimeout(processIpcFiles, IPC_POLL_INTERVAL); }; processIpcFiles(); logger.info('IPC watcher started (per-group namespaces)'); } export async function processTaskIpc( data: { type: string; taskId?: string; prompt?: string; schedule_type?: string; schedule_value?: string; context_mode?: string; ci_provider?: 'github'; ci_metadata?: string; groupFolder?: string; chatJid?: string; targetJid?: string; // For assign_room jid?: string; name?: string; folder?: string; room_mode?: RoomMode; owner_agent_type?: AgentType; isMain?: boolean; workDir?: string; scopeKind?: string; scopeKey?: string; content?: string; keywords?: string[]; memory_kind?: string | null; source_kind?: string; source_ref?: string | null; requestId?: string; action?: string; tail_lines?: number; profile?: string; expected_snapshot_id?: string; }, sourceGroup: string, // Verified identity from IPC directory isMain: boolean, // Verified from directory path deps: IpcDeps, ): Promise { const roomBindings = deps.roomBindings(); switch (data.type) { case 'schedule_task': if ( data.prompt && data.schedule_type && data.schedule_value && data.targetJid ) { // Resolve the target group from JID const targetJid = data.targetJid as string; const targetGroupEntry = roomBindings[targetJid] || Object.values(roomBindings).find( (group) => group.folder === targetJid, ); if (!targetGroupEntry) { logger.warn( { targetJid }, 'Cannot schedule task: target group not registered', ); break; } const targetFolder = targetGroupEntry.folder; const resolvedTargetJid = roomBindings[targetJid] !== undefined ? targetJid : Object.entries(roomBindings).find( ([, group]) => group.folder === targetFolder, )?.[0]; if (!resolvedTargetJid) { logger.warn( { targetJid, targetFolder }, 'Cannot resolve scheduled task target JID from folder', ); break; } // Authorization: non-main groups can only schedule for themselves if (!isMain && targetFolder !== sourceGroup) { logger.warn( { sourceGroup, targetFolder }, 'Unauthorized schedule_task attempt blocked', ); break; } const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once'; let nextRun: string | null = null; if (scheduleType === 'cron') { try { const interval = CronExpressionParser.parse(data.schedule_value, { tz: TIMEZONE, }); nextRun = interval.next().toISOString(); } catch { logger.warn( { scheduleValue: data.schedule_value }, 'Invalid cron expression', ); break; } } else if (scheduleType === 'interval') { const ms = parseInt(data.schedule_value, 10); if (isNaN(ms) || ms <= 0) { logger.warn( { scheduleValue: data.schedule_value }, 'Invalid interval', ); break; } nextRun = isWatchCiTask({ prompt: data.prompt }) ? new Date().toISOString() : new Date(Date.now() + ms).toISOString(); } else if (scheduleType === 'once') { const date = new Date(data.schedule_value); if (isNaN(date.getTime())) { logger.warn( { scheduleValue: data.schedule_value }, 'Invalid timestamp', ); break; } nextRun = date.toISOString(); } // Deduplicate CI watchers: if another agent already watches the same // channel + provider + run, skip creation to avoid duplicate notifications. if (data.ci_provider && data.ci_metadata) { const existing = findDuplicateCiWatcher( resolvedTargetJid, data.ci_provider, data.ci_metadata as string, ); if (existing) { logger.info( { existingTaskId: existing.id, existingAgentType: existing.agent_type, ciProvider: data.ci_provider, sourceGroup, }, 'Duplicate CI watcher skipped — another agent already watches this run', ); break; } } const taskId = data.taskId || `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const contextMode = data.context_mode === 'group' || data.context_mode === 'isolated' ? data.context_mode : 'isolated'; createTask({ id: taskId, group_folder: targetFolder, chat_jid: resolvedTargetJid, agent_type: targetGroupEntry.agentType || 'claude-code', ci_provider: data.ci_provider ?? null, ci_metadata: data.ci_metadata ?? null, max_duration_ms: isWatchCiTask({ prompt: data.prompt }) ? DEFAULT_WATCH_CI_MAX_DURATION_MS : null, prompt: data.prompt, schedule_type: scheduleType, schedule_value: data.schedule_value, context_mode: contextMode, next_run: nextRun, status: 'active', created_at: new Date().toISOString(), }); logger.info( { taskId, sourceGroup, targetFolder, contextMode, agentType: targetGroupEntry.agentType || 'claude-code', }, 'Task created via IPC', ); if (nextRun && new Date(nextRun).getTime() <= Date.now()) { deps.nudgeScheduler?.(); } } break; case 'pause_task': if (data.taskId) { const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'paused' }); logger.info( { taskId: data.taskId, sourceGroup }, 'Task paused via IPC', ); } else { logger.warn( { taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt', ); } } break; case 'resume_task': if (data.taskId) { const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'active' }); logger.info( { taskId: data.taskId, sourceGroup }, 'Task resumed via IPC', ); } else { logger.warn( { taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt', ); } } break; case 'cancel_task': if (data.taskId) { const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { deleteTask(data.taskId); logger.info( { taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC', ); } else { logger.warn( { taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt', ); } } break; case 'host_evidence_request': if (!data.requestId) { logger.warn( { sourceGroup }, 'Ignoring host_evidence_request without requestId', ); break; } if (!isHostEvidenceAction(data.action)) { writeHostEvidenceResponse(sourceGroup, { requestId: data.requestId, ok: false, action: 'ejclaw_service_status', command: '', stdout: '', stderr: '', exitCode: 1, error: `Unsupported host evidence action: ${String(data.action)}`, }); logger.warn( { sourceGroup, requestId: data.requestId, action: data.action }, 'Rejected unsupported host evidence action', ); break; } { const result = await runHostEvidenceRequest({ requestId: data.requestId, action: data.action, tailLines: typeof data.tail_lines === 'number' ? data.tail_lines : undefined, }); writeHostEvidenceResponse(sourceGroup, { requestId: data.requestId, ...result, }); logger.info( { sourceGroup, requestId: data.requestId, action: data.action, ok: result.ok, exitCode: result.exitCode, }, 'Processed host evidence request via IPC', ); } break; case 'update_task': if (data.taskId) { const task = getTaskById(data.taskId); if (!task) { logger.warn( { taskId: data.taskId, sourceGroup }, 'Task not found for update', ); break; } if (!isMain && task.group_folder !== sourceGroup) { logger.warn( { taskId: data.taskId, sourceGroup }, 'Unauthorized task update attempt', ); break; } const updates: Parameters[1] = {}; if (data.prompt !== undefined) updates.prompt = data.prompt; if (data.schedule_type !== undefined) updates.schedule_type = data.schedule_type as | 'cron' | 'interval' | 'once'; if (data.schedule_value !== undefined) updates.schedule_value = data.schedule_value; // Recompute next_run if schedule changed if (data.schedule_type || data.schedule_value) { const updatedTask = { ...task, ...updates, }; if (updatedTask.schedule_type === 'cron') { try { const interval = CronExpressionParser.parse( updatedTask.schedule_value, { tz: TIMEZONE }, ); updates.next_run = interval.next().toISOString(); } catch { logger.warn( { taskId: data.taskId, value: updatedTask.schedule_value }, 'Invalid cron in task update', ); break; } } else if (updatedTask.schedule_type === 'interval') { const ms = parseInt(updatedTask.schedule_value, 10); if (!isNaN(ms) && ms > 0) { updates.next_run = new Date(Date.now() + ms).toISOString(); } } } updateTask(data.taskId, updates); logger.info( { taskId: data.taskId, sourceGroup, updates }, 'Task updated via IPC', ); } break; case 'refresh_groups': // Only main group can request a refresh if (isMain) { logger.info( { sourceGroup }, 'Group metadata refresh requested via IPC', ); await deps.syncGroups(true); // Write updated snapshot immediately const availableGroups = deps.getAvailableGroups(); deps.writeGroupsSnapshot(sourceGroup, true, availableGroups); } else { logger.warn( { sourceGroup }, 'Unauthorized refresh_groups attempt blocked', ); } break; case 'assign_room': // Only main group can assign rooms if (!isMain) { logger.warn( { sourceGroup, type: data.type }, `Unauthorized ${data.type} attempt blocked`, ); break; } if (data.jid && data.name) { if (data.folder && !isValidGroupFolder(data.folder)) { logger.warn( { sourceGroup, folder: data.folder }, `Invalid ${data.type} request - unsafe folder name`, ); break; } if ( data.room_mode !== undefined && data.room_mode !== 'single' && data.room_mode !== 'tribunal' ) { logger.warn( { sourceGroup, roomMode: data.room_mode }, 'Invalid assign_room request - unknown room_mode', ); break; } if ( data.owner_agent_type !== undefined && data.owner_agent_type !== 'claude-code' && data.owner_agent_type !== 'codex' ) { logger.warn( { sourceGroup, ownerAgentType: data.owner_agent_type }, 'Invalid assign_room request - unknown owner_agent_type', ); break; } deps.assignRoom(data.jid, { name: data.name, roomMode: data.room_mode, ownerAgentType: data.owner_agent_type, folder: data.folder, isMain: data.isMain, workDir: data.workDir, }); } else { logger.warn( { data }, `Invalid ${data.type} request - missing required fields`, ); } break; case 'persist_memory': { if ( data.scopeKind !== 'room' || typeof data.scopeKey !== 'string' || typeof data.content !== 'string' ) { logger.warn( { sourceGroup, data }, 'Invalid persist_memory request - missing required fields', ); break; } const expectedScopeKey = `room:${sourceGroup}`; if (data.scopeKey !== expectedScopeKey) { logger.warn( { sourceGroup, scopeKey: data.scopeKey, expectedScopeKey }, 'Unauthorized persist_memory attempt blocked', ); break; } if ( data.source_kind !== undefined && data.source_kind !== 'compact' && data.source_kind !== 'explicit' && data.source_kind !== 'import' && data.source_kind !== 'system' ) { logger.warn( { sourceGroup, sourceKind: data.source_kind }, 'Invalid persist_memory request - unknown source_kind', ); break; } if ( Array.isArray(data.keywords) && !data.keywords.every((v) => typeof v === 'string') ) { logger.warn( { sourceGroup, keywords: data.keywords }, 'Invalid persist_memory request - keywords must be strings', ); break; } rememberMemory({ scopeKind: 'room', scopeKey: data.scopeKey, content: data.content, keywords: data.keywords, memoryKind: typeof data.memory_kind === 'string' ? data.memory_kind : null, sourceKind: (data.source_kind as | 'compact' | 'explicit' | 'import' | 'system' | undefined) ?? 'compact', sourceRef: typeof data.source_ref === 'string' ? data.source_ref : null, }); logger.info( { sourceGroup, scopeKey: data.scopeKey, sourceKind: data.source_kind ?? 'compact', }, 'Memory persisted via IPC', ); break; } default: logger.warn({ type: data.type }, 'Unknown IPC task type'); } }