From 8bb77ffaa3600c264d261f7df4a41a1056c2c09e Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 01:35:26 +0900 Subject: [PATCH] Fix structured IPC envelope leakage Normalize EJClaw structured send_message envelopes, preserve attachments, unwrap stored room payloads, and add regression tests. --- runners/agent-runner/src/ipc-message.ts | 28 ++++++- runners/agent-runner/test/ipc-message.test.ts | 48 ++++++++++++ src/db/tasks.ts | 5 +- src/index.ts | 4 +- src/ipc-auth.test.ts | 34 ++++++++ src/ipc-message-forwarding.ts | 9 ++- src/ipc-types.ts | 3 + src/outbound-attachments.test.ts | 29 +++++++ src/outbound-attachments.ts | 19 ++++- src/web-dashboard-data.ts | 49 ++++++++++-- src/web-dashboard-room-envelope.test.ts | 77 +++++++++++++++++++ 11 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 src/web-dashboard-room-envelope.test.ts diff --git a/runners/agent-runner/src/ipc-message.ts b/runners/agent-runner/src/ipc-message.ts index 0640212..5124925 100644 --- a/runners/agent-runner/src/ipc-message.ts +++ b/runners/agent-runner/src/ipc-message.ts @@ -1,3 +1,8 @@ +import { + normalizeEjclawStructuredOutput, + type RunnerOutputAttachment, +} from 'ejclaw-runners-shared'; + export interface SendMessageIpcPayloadInput { chatJid: string; text: string; @@ -8,17 +13,36 @@ export interface SendMessageIpcPayloadInput { timestamp?: string; } +export interface SendMessageIpcPayload { + type: 'message'; + chatJid: string; + text: string; + sender?: string; + senderRole?: string; + runId?: string; + groupFolder: string; + timestamp: string; + attachments?: RunnerOutputAttachment[]; +} + export function buildSendMessageIpcPayload( input: SendMessageIpcPayloadInput, -): Record { +): SendMessageIpcPayload { + const normalized = normalizeEjclawStructuredOutput(input.text); + const output = + normalized.output?.visibility === 'public' ? normalized.output : null; + const text = output?.text ?? normalized.result ?? ''; + const attachments = output?.attachments ?? []; + return { type: 'message', chatJid: input.chatJid, - text: input.text, + text, sender: input.sender || undefined, senderRole: input.senderRole || undefined, runId: input.runId || undefined, groupFolder: input.groupFolder, timestamp: input.timestamp || new Date().toISOString(), + ...(attachments.length > 0 ? { attachments } : {}), }; } diff --git a/runners/agent-runner/test/ipc-message.test.ts b/runners/agent-runner/test/ipc-message.test.ts index b2df47f..3d176ec 100644 --- a/runners/agent-runner/test/ipc-message.test.ts +++ b/runners/agent-runner/test/ipc-message.test.ts @@ -37,4 +37,52 @@ describe('agent runner IPC message payload', () => { }).senderRole, ).toBeUndefined(); }); + + it('normalizes structured EJClaw envelopes instead of leaking raw JSON', () => { + expect( + buildSendMessageIpcPayload({ + chatJid: 'dc:123', + text: JSON.stringify({ + ejclaw: { + visibility: 'public', + text: '스크린샷입니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/ejclaw-room-mobile-list-390.png', + name: 'room.png', + mime: 'image/png', + }, + ], + }, + }), + groupFolder: 'discord-review', + timestamp: '2026-04-04T13:45:00.000Z', + }), + ).toEqual({ + type: 'message', + chatJid: 'dc:123', + text: '스크린샷입니다.', + groupFolder: 'discord-review', + timestamp: '2026-04-04T13:45:00.000Z', + attachments: [ + { + path: '/tmp/ejclaw-room-mobile-list-390.png', + name: 'room.png', + mime: 'image/png', + }, + ], + }); + }); + + it('turns silent EJClaw envelopes into empty no-op messages', () => { + expect( + buildSendMessageIpcPayload({ + chatJid: 'dc:123', + text: '{"ejclaw":{"visibility":"silent","verdict":"silent"}}', + groupFolder: 'discord-review', + timestamp: '2026-04-04T13:45:00.000Z', + }).text, + ).toBe(''); + }); }); diff --git a/src/db/tasks.ts b/src/db/tasks.ts index a721521..dd47070 100644 --- a/src/db/tasks.ts +++ b/src/db/tasks.ts @@ -73,9 +73,10 @@ export function getTaskByIdFromDatabase( database: Database, id: string, ): ScheduledTask | undefined { - return database + const row = database .prepare('SELECT * FROM scheduled_tasks WHERE id = ?') - .get(id) as ScheduledTask | undefined; + .get(id) as ScheduledTask | null | undefined; + return row ?? undefined; } export function findDuplicateCiWatcherInDatabase( diff --git a/src/index.ts b/src/index.ts index b749aba..168fbec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -387,7 +387,7 @@ async function main(): Promise { editFormattedTrackedChannelMessage(channels, jid, messageId, rawText), }); startIpcWatcher({ - sendMessage: async (jid, text, senderRole, runId) => { + sendMessage: async (jid, text, senderRole, runId, attachments) => { if ( runId && (senderRole === 'reviewer' || senderRole === 'arbiter') && @@ -419,7 +419,7 @@ async function main(): Promise { }, 'IPC relay routed message to channel', ); - await route.channel.sendMessage(jid, text); + await route.channel.sendMessage(jid, text, { attachments }); if ( (senderRole === 'reviewer' || senderRole === 'arbiter') && isTerminalStatusMessage(text) diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index d79495a..8a8d73a 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -556,6 +556,7 @@ describe('IPC message authorization', () => { 'review text', 'reviewer', 'run-reviewer-ipc', + undefined, ); expect(result).toEqual( expect.objectContaining({ @@ -565,6 +566,39 @@ describe('IPC message authorization', () => { ); }); + it('forwards structured attachments through authorized IPC messages', async () => { + const sendMessage = vi.fn(async () => {}); + const attachments = [ + { + path: '/tmp/ejclaw-room-mobile-list-390.png', + name: 'room.png', + mime: 'image/png', + }, + ]; + + const result = await forwardAuthorizedIpcMessage( + { + type: 'message', + chatJid: 'other@g.us', + text: '스크린샷입니다.', + attachments, + }, + 'other-group', + false, + groups, + sendMessage, + ); + + expect(sendMessage).toHaveBeenCalledWith( + 'other@g.us', + '스크린샷입니다.', + undefined, + undefined, + attachments, + ); + expect(result.outcome).toBe('sent'); + }); + it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => { const sendMessage = vi.fn(async () => {}); const injectInboundMessage = vi.fn(async () => {}); diff --git a/src/ipc-message-forwarding.ts b/src/ipc-message-forwarding.ts index 6a4f8fb..f82f567 100644 --- a/src/ipc-message-forwarding.ts +++ b/src/ipc-message-forwarding.ts @@ -14,6 +14,7 @@ export async function forwardAuthorizedIpcMessage( text: string, senderRole?: string, runId?: string, + attachments?: import('./types.js').OutboundAttachment[], ) => Promise, injectInboundMessage?: (payload: { chatJid: string; @@ -79,7 +80,13 @@ export async function forwardAuthorizedIpcMessage( }; } - await sendMessage(msg.chatJid, msg.text, msg.senderRole, msg.runId); + await sendMessage( + msg.chatJid, + msg.text, + msg.senderRole, + msg.runId, + msg.attachments, + ); return { outcome: 'sent', chatJid: msg.chatJid, diff --git a/src/ipc-types.ts b/src/ipc-types.ts index fb979a8..8a3af35 100644 --- a/src/ipc-types.ts +++ b/src/ipc-types.ts @@ -3,6 +3,7 @@ import type { AssignRoomInput } from './db.js'; import type { AgentType, MessageSourceKind, + OutboundAttachment, PairedTask, RegisteredGroup, RoomMode, @@ -67,6 +68,7 @@ export interface IpcDeps { text: string, senderRole?: string, runId?: string, + attachments?: OutboundAttachment[], ) => Promise; nudgeScheduler?: () => void; roomBindings: () => Record; @@ -100,6 +102,7 @@ export interface IpcMessagePayload { timestamp?: string; treatAsHuman?: boolean; sourceKind?: MessageSourceKind; + attachments?: OutboundAttachment[]; } export interface IpcMessageForwardResult { diff --git a/src/outbound-attachments.test.ts b/src/outbound-attachments.test.ts index 85c0fa3..d0e1bcd 100644 --- a/src/outbound-attachments.test.ts +++ b/src/outbound-attachments.test.ts @@ -12,6 +12,7 @@ const ONE_PIXEL_PNG = Buffer.from( ); const cleanupDirs: string[] = []; +const cleanupFiles: string[] = []; function makeTempDir(baseDir: string, prefix: string): string { const dir = fs.mkdtempSync(path.join(baseDir, prefix)); @@ -33,6 +34,9 @@ afterEach(() => { for (const dir of cleanupDirs.splice(0)) { fs.rmSync(dir, { force: true, recursive: true }); } + for (const file of cleanupFiles.splice(0)) { + fs.rmSync(file, { force: true }); + } }); describe('validateOutboundAttachments', () => { @@ -57,6 +61,31 @@ describe('validateOutboundAttachments', () => { ]); }); + it('accepts direct EJClaw screenshot files under the temp directory', () => { + const imagePath = path.join( + os.tmpdir(), + `ejclaw-room-mobile-list-${Date.now()}.png`, + ); + fs.writeFileSync(imagePath, ONE_PIXEL_PNG); + cleanupFiles.push(imagePath); + + const result = validateOutboundAttachments([ + { + path: imagePath, + name: 'room-list.png', + mime: 'image/png', + }, + ]); + + expect(result.rejected).toEqual([]); + expect(result.files).toEqual([ + { + attachment: fs.realpathSync(imagePath), + name: 'room-list.png', + }, + ]); + }); + it('requires workspace paths to be explicitly allowlisted', () => { const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG); diff --git a/src/outbound-attachments.ts b/src/outbound-attachments.ts index fffd8e3..ab97124 100644 --- a/src/outbound-attachments.ts +++ b/src/outbound-attachments.ts @@ -21,6 +21,7 @@ const DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES = [ 'ejclaw-attachment-', 'ejclaw-discord-image-', ] as const; +const DEFAULT_TEMP_ATTACHMENT_FILE_PREFIXES = ['ejclaw-'] as const; function unique(values: Array): string[] { return [ @@ -83,6 +84,21 @@ function isWithinDefaultTempAttachmentDir( ); } +function isDefaultTempAttachmentFile( + realPath: string, + tempDir: string | null, +): boolean { + if (!tempDir) return false; + const relative = path.relative(tempDir, realPath); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + return false; + } + if (relative.includes(path.sep)) return false; + return DEFAULT_TEMP_ATTACHMENT_FILE_PREFIXES.some((prefix) => + relative.startsWith(prefix), + ); +} + function detectImageMime(filePath: string): string | null { const handle = fs.openSync(filePath, 'r'); try { @@ -171,7 +187,8 @@ export function validateOutboundAttachments( } if ( !matchesAllowedBaseDir(realPath, baseDirs) && - !isWithinDefaultTempAttachmentDir(realPath, defaultTempDir) + !isWithinDefaultTempAttachmentDir(realPath, defaultTempDir) && + !isDefaultTempAttachmentFile(realPath, defaultTempDir) ) { rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' }); continue; diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index 4ba22bc..df33b09 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -1,3 +1,4 @@ +import { normalizeEjclawStructuredOutput } from './agent-protocol.js'; import { isWatchCiTask } from './task-watch-status.js'; import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js'; @@ -182,6 +183,37 @@ function buildRoomPreview(value: string, maxLength: number): string { return truncateText(redactSensitiveText(value).trim(), maxLength); } +function decodeCommonHtmlEntities(value: string): string { + return value + .replace(/"|"|"/gi, '"') + .replace(/'|'|'/gi, "'") + .replace(/<|<|</gi, '<') + .replace(/>|>|>/gi, '>') + .replace(/&|&|&/gi, '&'); +} + +function hasStructuredOutputHint(value: string): boolean { + return value.includes('"ejclaw"') || /"ejclaw"/i.test(value); +} + +function normalizeRoomMessageContent(value: string): string | null { + if (!hasStructuredOutputHint(value)) return value; + + const candidates = [value, decodeCommonHtmlEntities(value)]; + for (const candidate of candidates) { + const normalized = normalizeEjclawStructuredOutput(candidate); + if (normalized.output?.visibility === 'silent') return null; + if ( + normalized.output?.visibility === 'public' && + normalized.output.text !== candidate + ) { + return normalized.output.text; + } + } + + return value; +} + function stableHash(value: string): string { let hash = 0; for (let index = 0; index < value.length; index += 1) { @@ -405,15 +437,17 @@ export function sanitizeScheduledTask( }; } -function sanitizeRoomMessage(message: NewMessage): WebDashboardRoomMessage { +function sanitizeRoomMessage( + message: NewMessage, +): WebDashboardRoomMessage | null { + const content = normalizeRoomMessageContent(message.content ?? ''); + if (content === null) return null; + return { id: message.id, sender: message.sender, senderName: message.sender_name || message.sender, - content: buildRoomPreview( - message.content ?? '', - ROOM_MESSAGE_PREVIEW_MAX_LENGTH, - ), + content: buildRoomPreview(content, ROOM_MESSAGE_PREVIEW_MAX_LENGTH), timestamp: message.timestamp, isFromMe: !!message.is_from_me, isBotMessage: !!message.is_bot_message, @@ -568,7 +602,10 @@ export function buildWebDashboardRoomActivity(args: { pendingTasks: args.entry.pendingTasks, messages: args.messages .filter((message) => !isTaskStatusMessage(message)) - .map(sanitizeRoomMessage), + .map(sanitizeRoomMessage) + .filter((message): message is WebDashboardRoomMessage => + Boolean(message), + ), pairedTask: args.pairedTask ? { id: args.pairedTask.id, diff --git a/src/web-dashboard-room-envelope.test.ts b/src/web-dashboard-room-envelope.test.ts new file mode 100644 index 0000000..3d55198 --- /dev/null +++ b/src/web-dashboard-room-envelope.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import type { NewMessage } from './types.js'; +import { buildWebDashboardRoomActivity } from './web-dashboard-data.js'; + +describe('web dashboard room envelope rendering', () => { + it('unwraps EJClaw structured envelopes in room messages', () => { + const envelope = JSON.stringify({ + ejclaw: { + visibility: 'public', + text: 'PR #52 모바일/룸 parity 검증 스크린샷입니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/ejclaw-room-mobile-list-390.png', + name: 'room.png', + mime: 'image/png', + }, + ], + }, + }); + const htmlEscapedEnvelope = envelope.replace(/"/g, '"'); + const messages: NewMessage[] = [ + makeMessage('msg-json', envelope, 'owner'), + makeMessage('msg-html-json', htmlEscapedEnvelope, 'reviewer'), + makeMessage( + 'msg-silent', + '{"ejclaw":{"visibility":"silent","verdict":"silent"}}', + 'arbiter', + ), + ]; + + const activity = buildWebDashboardRoomActivity({ + serviceId: 'codex-main', + entry: { + jid: 'dc:ops', + name: '#ops', + folder: 'ops', + agentType: 'codex', + status: 'processing', + elapsedMs: 15_000, + pendingMessages: true, + pendingTasks: 1, + }, + pairedTask: null, + turns: [], + attempts: [], + outputs: [], + messages, + }); + + expect(activity.messages).toHaveLength(2); + expect(activity.messages.map((message) => message.content)).toEqual([ + 'PR #52 모바일/룸 parity 검증 스크린샷입니다.', + 'PR #52 모바일/룸 parity 검증 스크린샷입니다.', + ]); + expect(JSON.stringify(activity.messages)).not.toContain('"ejclaw"'); + }); +}); + +function makeMessage( + id: string, + content: string, + senderName: string, +): NewMessage { + return { + id, + chat_jid: 'dc:ops', + sender: senderName, + sender_name: senderName, + content, + timestamp: '2026-04-26T05:29:00.000Z', + is_from_me: false, + is_bot_message: true, + message_source_kind: 'bot', + }; +}