From 7f0fbd581165e20bbadaf37286c7fc9c60e3b09c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 4 Apr 2026 23:02:30 +0900 Subject: [PATCH] Route IPC send_message by paired room role --- runners/agent-runner/src/index.ts | 1 + runners/agent-runner/src/ipc-mcp-stdio.ts | 8 +- runners/agent-runner/src/ipc-message.ts | 22 ++++ runners/agent-runner/test/ipc-message.test.ts | 38 ++++++ src/index.ts | 5 +- src/ipc-auth.test.ts | 45 ++++++- src/ipc.ts | 119 ++++++++++++------ src/router.test.ts | 54 ++++++++ src/router.ts | 20 +++ 9 files changed, 268 insertions(+), 44 deletions(-) create mode 100644 runners/agent-runner/src/ipc-message.ts create mode 100644 runners/agent-runner/test/ipc-message.test.ts create mode 100644 src/router.test.ts diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 85c5394..1f7a38d 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -669,6 +669,7 @@ async function runQuery( EJCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', EJCLAW_AGENT_TYPE: process.env.EJCLAW_AGENT_TYPE || 'claude-code', + EJCLAW_ROOM_ROLE: containerInput.roomRoleContext?.role || '', ...(process.env.EJCLAW_IPC_DIR && { EJCLAW_IPC_DIR: process.env.EJCLAW_IPC_DIR, }), diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index a696181..83aab69 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -30,6 +30,7 @@ import { waitForVerificationResponse, } from './verification.js'; import { resolveIpcDirectories } from './ipc-paths.js'; +import { buildSendMessageIpcPayload } from './ipc-message.js'; const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } = resolveIpcDirectories(process.env); @@ -76,14 +77,13 @@ server.tool( sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'), }, async (args) => { - const data: Record = { - type: 'message', + const data = buildSendMessageIpcPayload({ chatJid, text: args.text, sender: args.sender || undefined, + senderRole: process.env.EJCLAW_ROOM_ROLE || undefined, groupFolder, - timestamp: new Date().toISOString(), - }; + }); writeIpcFile(MESSAGES_DIR, data); diff --git a/runners/agent-runner/src/ipc-message.ts b/runners/agent-runner/src/ipc-message.ts new file mode 100644 index 0000000..712b99f --- /dev/null +++ b/runners/agent-runner/src/ipc-message.ts @@ -0,0 +1,22 @@ +export interface SendMessageIpcPayloadInput { + chatJid: string; + text: string; + sender?: string; + senderRole?: string; + groupFolder: string; + timestamp?: string; +} + +export function buildSendMessageIpcPayload( + input: SendMessageIpcPayloadInput, +): Record { + return { + type: 'message', + chatJid: input.chatJid, + text: input.text, + sender: input.sender || undefined, + senderRole: input.senderRole || undefined, + groupFolder: input.groupFolder, + timestamp: input.timestamp || new Date().toISOString(), + }; +} diff --git a/runners/agent-runner/test/ipc-message.test.ts b/runners/agent-runner/test/ipc-message.test.ts new file mode 100644 index 0000000..e45a360 --- /dev/null +++ b/runners/agent-runner/test/ipc-message.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSendMessageIpcPayload } from '../src/ipc-message.js'; + +describe('agent runner IPC message payload', () => { + it('includes senderRole when provided', () => { + expect( + buildSendMessageIpcPayload({ + chatJid: 'dc:123', + text: 'hello', + sender: 'Reviewer', + senderRole: 'reviewer', + groupFolder: 'discord-review', + timestamp: '2026-04-04T13:45:00.000Z', + }), + ).toEqual({ + type: 'message', + chatJid: 'dc:123', + text: 'hello', + sender: 'Reviewer', + senderRole: 'reviewer', + groupFolder: 'discord-review', + timestamp: '2026-04-04T13:45:00.000Z', + }); + }); + + it('omits empty senderRole values', () => { + expect( + buildSendMessageIpcPayload({ + chatJid: 'dc:123', + text: 'hello', + senderRole: '', + groupFolder: 'discord-review', + timestamp: '2026-04-04T13:45:00.000Z', + }).senderRole, + ).toBeUndefined(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 7f6e61a..48eb6a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ import { startIpcWatcher } from './ipc.js'; import { findChannel, findChannelByName, + findChannelForDeliveryRole, formatOutbound, normalizeMessageForDedupe, } from './router.js'; @@ -471,8 +472,8 @@ async function main(): Promise { editFormattedTrackedChannelMessage(channels, jid, messageId, rawText), }); startIpcWatcher({ - sendMessage: (jid, text) => { - const channel = findChannel(channels, jid); + sendMessage: (jid, text, senderRole) => { + const channel = findChannelForDeliveryRole(channels, jid, senderRole); if (!channel) throw new Error(`No channel for JID: ${jid}`); return channel.sendMessage(jid, text); }, diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index b839a53..dc9f9ec 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -13,7 +13,7 @@ import { getStoredRoomSettings, getTaskById, } from './db.js'; -import { processTaskIpc, IpcDeps } from './ipc.js'; +import { forwardAuthorizedIpcMessage, processTaskIpc, IpcDeps } from './ipc.js'; import { DEFAULT_WATCH_CI_MAX_DURATION_MS } from './task-watch-status.js'; import { RegisteredGroup } from './types.js'; @@ -536,6 +536,49 @@ describe('IPC message authorization', () => { isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups), ).toBe(true); }); + + it('forwards senderRole through authorized IPC messages', async () => { + const sendMessage = vi.fn(async () => {}); + + await forwardAuthorizedIpcMessage( + { + type: 'message', + chatJid: 'other@g.us', + text: 'review text', + senderRole: 'reviewer', + }, + 'other-group', + false, + groups, + sendMessage, + ); + + expect(sendMessage).toHaveBeenCalledWith( + 'other@g.us', + 'review text', + 'reviewer', + ); + }); + + it('does not forward unauthorized IPC messages even when senderRole exists', async () => { + const sendMessage = vi.fn(async () => {}); + + const result = await forwardAuthorizedIpcMessage( + { + type: 'message', + chatJid: 'third@g.us', + text: 'review text', + senderRole: 'reviewer', + }, + 'other-group', + false, + groups, + sendMessage, + ); + + expect(result.outcome).toBe('blocked'); + expect(sendMessage).not.toHaveBeenCalled(); + }); }); // --- schedule_task with cron and interval types --- diff --git a/src/ipc.ts b/src/ipc.ts index 4daba58..874a7e2 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -34,7 +34,11 @@ import { import { AgentType, RegisteredGroup, RoomMode } from './types.js'; export interface IpcDeps { - sendMessage: (jid: string, text: string) => Promise; + sendMessage: ( + jid: string, + text: string, + senderRole?: string, + ) => Promise; nudgeScheduler?: () => void; registeredGroups: () => Record; assignRoom: (jid: string, room: AssignRoomInput) => void; @@ -47,6 +51,53 @@ export interface IpcDeps { ) => void; } +export interface IpcMessagePayload { + type?: string; + chatJid?: string; + text?: string; + senderRole?: string; +} + +export interface IpcMessageForwardResult { + outcome: 'ignored' | 'sent' | 'blocked'; + chatJid?: string; + targetGroup?: string | null; + isMainOverride?: boolean; +} + +export async function forwardAuthorizedIpcMessage( + msg: IpcMessagePayload, + sourceGroup: string, + isMain: boolean, + registeredGroups: Record, + sendMessage: IpcDeps['sendMessage'], +): Promise { + if (!(msg.type === 'message' && msg.chatJid && msg.text)) { + return { outcome: 'ignored' }; + } + + const targetGroup = registeredGroups[msg.chatJid]; + const isMainOverride = isMain === true; + if ( + !(isMainOverride || (targetGroup && targetGroup.folder === sourceGroup)) + ) { + return { + outcome: 'blocked', + chatJid: msg.chatJid, + targetGroup: targetGroup?.folder ?? null, + isMainOverride, + }; + } + + await sendMessage(msg.chatJid, msg.text, msg.senderRole); + return { + outcome: 'sent', + chatJid: msg.chatJid, + targetGroup: targetGroup?.folder ?? null, + isMainOverride, + }; +} + let ipcWatcherRunning = false; const IPC_PROCESSING_DIRNAME = '.processing'; @@ -186,42 +237,36 @@ export function startIpcWatcher(deps: IpcDeps): void { const data = readJsonFile(claimedPath); if (!data || typeof data !== 'object') throw new Error('Invalid JSON'); - const msg = data as { - type?: string; - chatJid?: string; - text?: string; - }; - if (msg.type === 'message' && msg.chatJid && msg.text) { - // Authorization: verify this group can send to this chatJid - const targetGroup = registeredGroups[msg.chatJid]; - const isMainOverride = isMain === true; - if ( - isMainOverride || - (targetGroup && targetGroup.folder === sourceGroup) - ) { - await deps.sendMessage(msg.chatJid, msg.text); - logger.info( - { - transition: 'ipc:auth:allow', - chatJid: msg.chatJid, - sourceGroup, - targetGroup: targetGroup?.folder ?? null, - isMainOverride, - }, - 'IPC message sent', - ); - } else { - logger.warn( - { - transition: 'ipc:auth:deny', - chatJid: msg.chatJid, - sourceGroup, - targetGroup: targetGroup?.folder ?? null, - isMainOverride, - }, - 'Unauthorized IPC message attempt blocked', - ); - } + const msg = data as IpcMessagePayload; + const forwardResult = await forwardAuthorizedIpcMessage( + msg, + sourceGroup, + isMain, + registeredGroups, + deps.sendMessage, + ); + if (forwardResult.outcome === 'sent') { + logger.info( + { + transition: 'ipc:auth:allow', + chatJid: forwardResult.chatJid, + sourceGroup, + targetGroup: forwardResult.targetGroup ?? null, + isMainOverride: forwardResult.isMainOverride, + }, + '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, + }, + 'Unauthorized IPC message attempt blocked', + ); } fs.unlinkSync(claimedPath); } catch (err) { diff --git a/src/router.test.ts b/src/router.test.ts new file mode 100644 index 0000000..6bb6b18 --- /dev/null +++ b/src/router.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { findChannelForDeliveryRole } from './router.js'; +import { type Channel } from './types.js'; + +function createChannel(name: string, ownedJids: string[]): Channel { + return { + name, + connect: async () => {}, + sendMessage: async () => {}, + isConnected: () => true, + ownsJid: (jid) => ownedJids.includes(jid), + disconnect: async () => {}, + }; +} + +describe('findChannelForDeliveryRole', () => { + const jid = 'dc:123'; + + it('prefers the reviewer channel for reviewer IPC messages', () => { + const ownerChannel = createChannel('discord-main', [jid]); + const reviewerChannel = createChannel('discord-review', [jid]); + + expect( + findChannelForDeliveryRole( + [ownerChannel, reviewerChannel], + jid, + 'reviewer', + ), + ).toBe(reviewerChannel); + }); + + it('prefers the arbiter channel for arbiter IPC messages', () => { + const ownerChannel = createChannel('discord-main', [jid]); + const reviewerChannel = createChannel('discord-review', [jid]); + const arbiterChannel = createChannel('discord-arbiter', [jid]); + + expect( + findChannelForDeliveryRole( + [ownerChannel, reviewerChannel, arbiterChannel], + jid, + 'arbiter', + ), + ).toBe(arbiterChannel); + }); + + it('falls back to the first JID-owning channel when no role channel exists', () => { + const ownerChannel = createChannel('discord-main', [jid]); + + expect(findChannelForDeliveryRole([ownerChannel], jid, 'reviewer')).toBe( + ownerChannel, + ); + }); +}); diff --git a/src/router.ts b/src/router.ts index 46decf8..1a8eced 100644 --- a/src/router.ts +++ b/src/router.ts @@ -93,6 +93,26 @@ export function findChannel( return channels.find((c) => c.ownsJid(jid)); } +export function findChannelForDeliveryRole( + channels: Channel[], + jid: string, + senderRole?: string, +): Channel | undefined { + if (senderRole === 'reviewer') { + return ( + findChannelByName(channels, 'discord-review') || + findChannel(channels, jid) + ); + } + if (senderRole === 'arbiter') { + return ( + findChannelByName(channels, 'discord-arbiter') || + findChannel(channels, jid) + ); + } + return findChannel(channels, jid); +} + export function findChannelByName( channels: Channel[], name: string,