diff --git a/apps/dashboard/src/RoomAttachmentGallery.tsx b/apps/dashboard/src/RoomAttachmentGallery.tsx new file mode 100644 index 0000000..bc2396b --- /dev/null +++ b/apps/dashboard/src/RoomAttachmentGallery.tsx @@ -0,0 +1,38 @@ +import type { RoomThreadEntry } from './roomThread'; + +type RoomAttachment = NonNullable[number]; + +function attachmentDisplayName(attachment: RoomAttachment): string { + if (attachment.name) return attachment.name; + const segments = attachment.path.split(/[\\/]/); + return segments.at(-1) || 'attachment'; +} + +function attachmentUrl(attachment: RoomAttachment): string { + return `/api/attachments?path=${encodeURIComponent(attachment.path)}`; +} + +export function RoomAttachmentGallery({ + attachments, +}: { + attachments?: RoomThreadEntry['attachments']; +}) { + if (!attachments || attachments.length === 0) return null; + + return ( + + ); +} diff --git a/apps/dashboard/src/RoomCardV2.test.ts b/apps/dashboard/src/RoomCardV2.test.ts index 31be0d3..6da276c 100644 --- a/apps/dashboard/src/RoomCardV2.test.ts +++ b/apps/dashboard/src/RoomCardV2.test.ts @@ -161,4 +161,51 @@ describe('RoomCardV2', () => { expect(html).toContain('pnpm openapi:sync'); expect(html).not.toContain('`origin/dev`'); }); + + it('renders message attachments as dashboard images', () => { + const html = renderToStaticMarkup( + createElement(RoomCardV2, { + activity: activity({ + messages: [ + { + id: 'bot-attachment', + sender: 'bot-1', + senderName: 'owner', + content: '라벨 좌측 클리핑 회귀 수정했습니다.', + timestamp: '2026-04-28T02:00:00.000Z', + isFromMe: false, + isBotMessage: true, + sourceKind: 'bot', + attachments: [ + { + path: '/tmp/bar-chart-label-fit-playwright.png', + name: 'bar-chart-label-fit-playwright.png', + mime: 'image/png', + }, + ], + }, + ], + }), + activityLoading: false, + busy: false, + draft: '', + entry, + expanded: true, + inboxItems: [], + locale: 'ko', + onDraftChange: () => {}, + onSendMessage: () => {}, + onToggle: () => {}, + pinned: true, + t, + ...formatters, + }), + ); + + expect(html).toContain('class="room-attachments"'); + expect(html).toContain( + '/api/attachments?path=%2Ftmp%2Fbar-chart-label-fit-playwright.png', + ); + expect(html).toContain('bar-chart-label-fit-playwright.png'); + }); }); diff --git a/apps/dashboard/src/RoomCardV2.tsx b/apps/dashboard/src/RoomCardV2.tsx index 758b62a..c3edd5a 100644 --- a/apps/dashboard/src/RoomCardV2.tsx +++ b/apps/dashboard/src/RoomCardV2.tsx @@ -4,6 +4,7 @@ import { Send } from 'lucide-react'; import type { DashboardOverview, DashboardRoomActivity } from './api'; import type { Locale, Messages } from './i18n'; import { ParsedBody } from './ParsedBody'; +import { RoomAttachmentGallery } from './RoomAttachmentGallery'; import { buildRoomThreadEntries, isWatcherRoomMessage, @@ -779,6 +780,7 @@ function RoomTimelineEntry({
+
); diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 4fc84ae..b119a57 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -107,6 +107,11 @@ export interface DashboardRoomActivity { sender: string; senderName: string; content: string; + attachments?: Array<{ + path: string; + name?: string; + mime?: string; + }>; timestamp: string; isFromMe: boolean; isBotMessage: boolean; @@ -141,6 +146,11 @@ export interface DashboardRoomActivity { verdict: string | null; createdAt: string; outputText: string; + attachments?: Array<{ + path: string; + name?: string; + mime?: string; + }>; }>; } | null; } diff --git a/apps/dashboard/src/roomThread.ts b/apps/dashboard/src/roomThread.ts index 8bf3eab..4bdba8f 100644 --- a/apps/dashboard/src/roomThread.ts +++ b/apps/dashboard/src/roomThread.ts @@ -9,6 +9,7 @@ export type RoomThreadEntry = { id: string; senderName: string; content: string; + attachments?: RoomMessage['attachments']; timestamp: string; isFromMe: boolean; isBotMessage: boolean; @@ -51,6 +52,7 @@ function toMessageEntry(message: RoomMessage): RoomThreadEntry { id: message.id, senderName: message.senderName, content: message.content, + attachments: message.attachments, timestamp: message.timestamp, isFromMe: message.isFromMe, isBotMessage: message.isBotMessage, @@ -64,6 +66,7 @@ function toOutputEntry(output: RoomOutput): RoomThreadEntry | null { id: `out:${output.id}`, senderName: output.role, content: output.outputText, + attachments: output.attachments, timestamp: output.createdAt, isFromMe: false, isBotMessage: true, diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index e472c21..bf286dc 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -4039,6 +4039,38 @@ progress::-moz-progress-bar { max-height: none; } +.room-attachments { + list-style: none; + margin: 6px 0 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.room-attachment a { + display: grid; + gap: 4px; + width: min(220px, 100%); + color: var(--muted); + text-decoration: none; +} + +.room-attachment img { + max-width: 100%; + max-height: 180px; + border: 1px solid var(--panel-border); + border-radius: 10px; + object-fit: contain; + background: rgba(255, 255, 255, 0.04); +} + +.room-attachment span { + overflow-wrap: anywhere; + font-size: 11px; + color: var(--muted-soft); +} + /* thread — flat */ .room-thread { list-style: none; diff --git a/src/channels/discord-outbound.ts b/src/channels/discord-outbound.ts new file mode 100644 index 0000000..9447b61 --- /dev/null +++ b/src/channels/discord-outbound.ts @@ -0,0 +1,101 @@ +import path from 'path'; + +import { + extractImageTagPaths, + normalizeEjclawStructuredOutput, +} from '../agent-protocol.js'; +import type { OutboundAttachment } from '../types.js'; + +const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; +const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g; + +export interface PreparedDiscordOutbound { + text: string; + cleanText: string; + attachments: OutboundAttachment[]; + attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'none'; + silent: boolean; +} + +function extractMarkdownImageAttachments(text: string): { + cleanText: string; + attachments: OutboundAttachment[]; +} { + const attachments: OutboundAttachment[] = []; + const seen = new Set(); + + const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => { + const trimmed = rawPath.trim(); + if (IMAGE_EXTS.test(trimmed)) { + if (!seen.has(trimmed)) { + attachments.push({ + path: trimmed, + name: path.basename(trimmed), + }); + seen.add(trimmed); + } + return ''; + } + + const basename = path.basename(trimmed.replace(/#.*$/, '')); + const lineMatch = trimmed.match(/#L(\d+)/); + return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``; + }); + + return { cleanText, attachments }; +} + +function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] { + return paths + .filter((filePath) => IMAGE_EXTS.test(filePath)) + .map((filePath) => ({ + path: filePath, + name: path.basename(filePath), + })); +} + +export function prepareDiscordOutbound( + text: string, + optionAttachments: OutboundAttachment[] | undefined, +): PreparedDiscordOutbound { + const normalized = normalizeEjclawStructuredOutput(text); + if (normalized.output?.visibility === 'silent') { + return { + text: '', + cleanText: '', + attachments: [], + attachmentSource: 'none', + silent: true, + }; + } + + const structuredOutput = + normalized.output?.visibility === 'public' ? normalized.output : null; + const outboundText = structuredOutput?.text ?? normalized.result ?? text; + const structuredAttachments = + optionAttachments && optionAttachments.length > 0 + ? optionAttachments + : (structuredOutput?.attachments ?? []); + const hasStructuredAttachments = structuredAttachments.length > 0; + const markdownExtracted = extractMarkdownImageAttachments(outboundText); + const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText); + const legacyImageTagAttachments = imageTagPathsToAttachments( + imageTagExtracted.imagePaths, + ); + + return { + text: outboundText, + cleanText: imageTagExtracted.cleanText, + attachments: hasStructuredAttachments + ? structuredAttachments + : [...markdownExtracted.attachments, ...legacyImageTagAttachments], + attachmentSource: hasStructuredAttachments + ? 'structured' + : markdownExtracted.attachments.length > 0 + ? 'md-link' + : legacyImageTagAttachments.length > 0 + ? 'image-tag' + : 'none', + silent: false, + }; +} diff --git a/src/channels/discord-structured-output.test.ts b/src/channels/discord-structured-output.test.ts new file mode 100644 index 0000000..f97c16f --- /dev/null +++ b/src/channels/discord-structured-output.test.ts @@ -0,0 +1,171 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./registry.js', () => ({ + registerChannel: vi.fn(), +})); + +vi.mock('../env.js', () => ({ + readEnvFile: vi.fn(() => ({})), + getEnv: vi.fn(() => undefined), +})); + +vi.mock('../config.js', () => ({ + ASSISTANT_NAME: 'Andy', + TRIGGER_PATTERN: /^@Andy\b/i, + DATA_DIR: '/tmp/ejclaw-test-data', + CACHE_DIR: '/tmp/ejclaw-test-cache', +})); + +vi.mock('../logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../service-routing.js', () => ({ + hasReviewerLease: vi.fn(() => false), +})); + +type Handler = (...args: any[]) => any; + +const clientRef = vi.hoisted(() => ({ current: null as any })); + +vi.mock('discord.js', () => { + class MockClient { + eventHandlers = new Map(); + user: any = { id: '999888777', tag: 'Andy#1234' }; + private _ready = false; + + constructor(_opts: any) { + clientRef.current = this; + } + + on(event: string, handler: Handler) { + this.eventHandlers.set(event, [ + ...(this.eventHandlers.get(event) ?? []), + handler, + ]); + return this; + } + + once(event: string, handler: Handler) { + return this.on(event, handler); + } + + async login(_token: string) { + this._ready = true; + for (const handler of this.eventHandlers.get('ready') ?? []) { + handler({ user: this.user }); + } + } + + isReady() { + return this._ready; + } + + channels = { + fetch: vi.fn().mockResolvedValue({ + send: vi.fn().mockResolvedValue(undefined), + sendTyping: vi.fn().mockResolvedValue(undefined), + }), + }; + } + + return { + Client: MockClient, + Events: { + MessageCreate: 'messageCreate', + ClientReady: 'ready', + Error: 'error', + }, + GatewayIntentBits: { + Guilds: 1, + GuildMessages: 2, + MessageContent: 4, + DirectMessages: 8, + }, + MessageFlags: { SuppressEmbeds: 1 << 2, IsVoiceMessage: 1 << 13 }, + TextChannel: class TextChannel {}, + }; +}); + +import { DiscordChannel, type DiscordChannelOpts } from './discord.js'; + +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +const tempFiles: string[] = []; + +afterEach(() => { + vi.clearAllMocks(); + for (const file of tempFiles.splice(0)) { + fs.rmSync(file, { force: true }); + } +}); + +function createTestOpts(): DiscordChannelOpts { + return { + onMessage: vi.fn(), + onChatMetadata: vi.fn(), + roomBindings: vi.fn(() => ({})), + }; +} + +describe('DiscordChannel structured output', () => { + it('normalizes raw EJClaw JSON and sends direct temp images as files', async () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + await channel.connect(); + const filePath = path.join( + os.tmpdir(), + `bar-chart-label-fit-playwright-${Date.now()}.png`, + ); + fs.writeFileSync(filePath, ONE_PIXEL_PNG); + tempFiles.push(filePath); + const mockChannel = { + send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), + sendTyping: vi.fn(), + }; + clientRef.current.channels.fetch.mockResolvedValue(mockChannel); + + await channel.sendMessage( + 'dc:1234567890123456', + JSON.stringify({ + ejclaw: { + visibility: 'public', + text: '라벨 좌측 클리핑 회귀 수정했습니다.', + verdict: 'done', + attachments: [ + { + path: filePath, + name: 'bar-chart-label-fit-playwright.png', + mime: 'image/png', + }, + ], + }, + }), + ); + + expect(mockChannel.send).toHaveBeenCalledWith({ + content: '라벨 좌측 클리핑 회귀 수정했습니다.', + files: [ + { + attachment: fs.realpathSync(filePath), + name: 'bar-chart-label-fit-playwright.png', + }, + ], + flags: 1 << 2, + }); + expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain( + '"ejclaw"', + ); + }); +}); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 84a64f9..755d005 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -19,11 +19,11 @@ import { } from '../config.js'; import { getEnv } from '../env.js'; import { logger } from '../logger.js'; -import { extractImageTagPaths } from '../agent-protocol.js'; import { validateOutboundAttachments } from '../outbound-attachments.js'; import { formatOutbound } from '../router.js'; import { hasReviewerLease } from '../service-routing.js'; -import type { OutboundAttachment, SendMessageOptions } from '../types.js'; +import type { SendMessageOptions } from '../types.js'; +import { prepareDiscordOutbound } from './discord-outbound.js'; const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions'); @@ -33,45 +33,6 @@ const DISCORD_ARBITER_CHANNEL = 'discord-arbiter'; const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN'; const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN'; const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN'; -const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; -const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g; - -function extractMarkdownImageAttachments(text: string): { - cleanText: string; - attachments: OutboundAttachment[]; -} { - const attachments: OutboundAttachment[] = []; - const seen = new Set(); - - const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => { - const trimmed = rawPath.trim(); - if (IMAGE_EXTS.test(trimmed)) { - if (!seen.has(trimmed)) { - attachments.push({ - path: trimmed, - name: path.basename(trimmed), - }); - seen.add(trimmed); - } - return ''; - } - - const basename = path.basename(trimmed.replace(/#.*$/, '')); - const lineMatch = trimmed.match(/#L(\d+)/); - return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``; - }); - - return { cleanText, attachments }; -} - -function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] { - return paths - .filter((filePath) => IMAGE_EXTS.test(filePath)) - .map((filePath) => ({ - path: filePath, - name: path.basename(filePath), - })); -} /** * Download a Discord attachment to local disk. @@ -466,26 +427,15 @@ export class DiscordChannel implements Channel { const textChannel = channel as TextChannel; - const structuredAttachments = options.attachments ?? []; - const hasStructuredAttachments = structuredAttachments.length > 0; - const markdownExtracted = extractMarkdownImageAttachments(text); - const imageTagExtracted = extractImageTagPaths( - markdownExtracted.cleanText, - ); - const legacyImageTagAttachments = imageTagPathsToAttachments( - imageTagExtracted.imagePaths, - ); - const outboundAttachments = hasStructuredAttachments - ? structuredAttachments - : [...markdownExtracted.attachments, ...legacyImageTagAttachments]; - const attachmentSource = hasStructuredAttachments - ? 'structured' - : markdownExtracted.attachments.length > 0 - ? 'md-link' - : legacyImageTagAttachments.length > 0 - ? 'image-tag' - : 'none'; - const validation = validateOutboundAttachments(outboundAttachments, { + const outbound = prepareDiscordOutbound(text, options.attachments); + if (outbound.silent) { + logger.debug( + { jid, channelName: this.name }, + 'Skipping silent structured Discord outbound message', + ); + return; + } + const validation = validateOutboundAttachments(outbound.attachments, { baseDirs: options.attachmentBaseDirs, }); const files = validation.files; @@ -495,14 +445,14 @@ export class DiscordChannel implements Channel { { jid, channelName: this.name, - attachmentSource, + attachmentSource: outbound.attachmentSource, rejected: validation.rejected, }, 'Rejected outbound Discord attachments', ); } - let cleaned = imageTagExtracted.cleanText + let cleaned = outbound.cleanText .replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines .replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines .trim(); @@ -588,11 +538,11 @@ export class DiscordChannel implements Channel { { jid, channelName: this.name, - length: text.length, + length: outbound.text.length, deliveryMode: 'send', chunkCount, attachmentCount: files.length, - attachmentSource, + attachmentSource: outbound.attachmentSource, messageId: sentMessageIds[0] ?? null, messageIds: sentMessageIds, botUserId: this.client.user?.id ?? null, diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 8a8d73a..01b6ecc 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -599,6 +599,50 @@ describe('IPC message authorization', () => { expect(result.outcome).toBe('sent'); }); + it('normalizes raw EJClaw envelopes in authorized IPC messages', async () => { + const sendMessage = vi.fn(async () => {}); + + const result = await forwardAuthorizedIpcMessage( + { + type: 'message', + chatJid: 'other@g.us', + text: JSON.stringify({ + ejclaw: { + visibility: 'public', + text: '첨부를 렌더링했습니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/bar-chart-label-fit-playwright.png', + name: 'bar-chart-label-fit-playwright.png', + mime: 'image/png', + }, + ], + }, + }), + }, + 'other-group', + false, + groups, + sendMessage, + ); + + expect(sendMessage).toHaveBeenCalledWith( + 'other@g.us', + '첨부를 렌더링했습니다.', + undefined, + undefined, + [ + { + path: '/tmp/bar-chart-label-fit-playwright.png', + name: 'bar-chart-label-fit-playwright.png', + mime: 'image/png', + }, + ], + ); + 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 f82f567..1a5c317 100644 --- a/src/ipc-message-forwarding.ts +++ b/src/ipc-message-forwarding.ts @@ -2,6 +2,7 @@ import type { IpcMessageForwardResult, IpcMessagePayload, } from './ipc-types.js'; +import { normalizeEjclawStructuredOutput } from './agent-protocol.js'; import type { RegisteredGroup } from './types.js'; export async function forwardAuthorizedIpcMessage( @@ -80,13 +81,25 @@ export async function forwardAuthorizedIpcMessage( }; } - await sendMessage( - msg.chatJid, - msg.text, - msg.senderRole, - msg.runId, - msg.attachments, - ); + const normalized = normalizeEjclawStructuredOutput(msg.text); + if (normalized.output?.visibility === 'silent') { + return { + outcome: 'sent', + chatJid: msg.chatJid, + targetGroup: targetGroup?.folder ?? null, + isMainOverride, + senderRole: msg.senderRole ?? null, + }; + } + const structured = + normalized.output?.visibility === 'public' ? normalized.output : null; + const text = structured?.text ?? normalized.result ?? msg.text; + const attachments = + msg.attachments && msg.attachments.length > 0 + ? msg.attachments + : (structured?.attachments ?? undefined); + + await sendMessage(msg.chatJid, text, msg.senderRole, msg.runId, attachments); return { outcome: 'sent', chatJid: msg.chatJid, diff --git a/src/outbound-attachments.test.ts b/src/outbound-attachments.test.ts index d0e1bcd..a7f97fe 100644 --- a/src/outbound-attachments.test.ts +++ b/src/outbound-attachments.test.ts @@ -86,6 +86,31 @@ describe('validateOutboundAttachments', () => { ]); }); + it('accepts direct generated screenshot files under the temp directory', () => { + const imagePath = path.join( + os.tmpdir(), + `bar-chart-label-fit-playwright-${Date.now()}.png`, + ); + fs.writeFileSync(imagePath, ONE_PIXEL_PNG); + cleanupFiles.push(imagePath); + + const result = validateOutboundAttachments([ + { + path: imagePath, + name: 'bar-chart-label-fit-playwright.png', + mime: 'image/png', + }, + ]); + + expect(result.rejected).toEqual([]); + expect(result.files).toEqual([ + { + attachment: fs.realpathSync(imagePath), + name: 'bar-chart-label-fit-playwright.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 ab97124..96a05ab 100644 --- a/src/outbound-attachments.ts +++ b/src/outbound-attachments.ts @@ -21,7 +21,6 @@ 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 [ @@ -44,8 +43,8 @@ export function getDefaultAttachmentBaseDirs(): string[] { process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null); // Keep defaults narrow. Runtime-specific workspaces must be passed via // attachmentBaseDirs so one room cannot attach another room's files by path. - // Ad-hoc generated files under os.tmpdir()/ejclaw-attachment-* are handled - // separately after resolving symlinks, without allowlisting all of /tmp. + // Ad-hoc generated files under os.tmpdir() are handled separately after + // resolving symlinks, without allowlisting all of /tmp recursively. return unique([ path.join(DATA_DIR, 'attachments'), codexHome ? path.join(codexHome, 'generated_images') : null, @@ -84,7 +83,7 @@ function isWithinDefaultTempAttachmentDir( ); } -function isDefaultTempAttachmentFile( +function isDirectTempAttachmentFile( realPath: string, tempDir: string | null, ): boolean { @@ -93,10 +92,10 @@ function isDefaultTempAttachmentFile( 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), - ); + // Agents commonly write Playwright screenshots and generated images directly + // under /tmp with task-specific names. Keep this limited to direct children; + // nested temp directories still need an EJClaw prefix or explicit baseDir. + return !relative.includes(path.sep); } function detectImageMime(filePath: string): string | null { @@ -188,7 +187,7 @@ export function validateOutboundAttachments( if ( !matchesAllowedBaseDir(realPath, baseDirs) && !isWithinDefaultTempAttachmentDir(realPath, defaultTempDir) && - !isDefaultTempAttachmentFile(realPath, defaultTempDir) + !isDirectTempAttachmentFile(realPath, defaultTempDir) ) { rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' }); continue; diff --git a/src/web-dashboard-attachments.test.ts b/src/web-dashboard-attachments.test.ts new file mode 100644 index 0000000..6cfdb28 --- /dev/null +++ b/src/web-dashboard-attachments.test.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createWebDashboardHandler } from './web-dashboard-server.js'; + +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +const tempFiles: string[] = []; + +afterEach(() => { + for (const file of tempFiles.splice(0)) { + fs.rmSync(file, { force: true }); + } +}); + +describe('web dashboard attachment previews', () => { + it('serves validated direct temp image attachments', async () => { + const filePath = path.join( + os.tmpdir(), + `bar-chart-label-fit-playwright-${Date.now()}.png`, + ); + fs.writeFileSync(filePath, ONE_PIXEL_PNG); + tempFiles.push(filePath); + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [], + }); + + const response = await handler( + new Request( + `http://localhost/api/attachments?path=${encodeURIComponent(filePath)}`, + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(Buffer.from(await response.arrayBuffer())).toEqual(ONE_PIXEL_PNG); + }); +}); diff --git a/src/web-dashboard-attachments.ts b/src/web-dashboard-attachments.ts new file mode 100644 index 0000000..c3b0885 --- /dev/null +++ b/src/web-dashboard-attachments.ts @@ -0,0 +1,50 @@ +import fs from 'fs'; +import path from 'path'; + +import { validateOutboundAttachments } from './outbound-attachments.js'; + +function attachmentJsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + ...init, + headers: { + 'content-type': 'application/json; charset=utf-8', + ...(init?.headers as Record | undefined), + }, + }); +} + +function getAttachmentContentType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.png') return 'image/png'; + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; + if (ext === '.gif') return 'image/gif'; + if (ext === '.webp') return 'image/webp'; + if (ext === '.bmp') return 'image/bmp'; + return 'application/octet-stream'; +} + +export function serveValidatedAttachment(url: URL): Response { + const requestedPath = url.searchParams.get('path'); + if (!requestedPath) { + return attachmentJsonResponse( + { error: 'Missing attachment path' }, + { status: 400 }, + ); + } + + const validation = validateOutboundAttachments([{ path: requestedPath }]); + const file = validation.files[0]; + if (!file) { + return attachmentJsonResponse( + { error: 'Attachment not found or not allowed' }, + { status: 404 }, + ); + } + + return new Response(fs.readFileSync(file.attachment), { + headers: { + 'content-type': getAttachmentContentType(file.attachment), + 'cache-control': 'private, max-age=300', + }, + }); +} diff --git a/src/web-dashboard-data-attachments.test.ts b/src/web-dashboard-data-attachments.test.ts new file mode 100644 index 0000000..d37ef04 --- /dev/null +++ b/src/web-dashboard-data-attachments.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; +import { buildWebDashboardRoomActivity } from './web-dashboard-data.js'; + +function makePairedTask(overrides: Partial): PairedTask { + return { + id: 'paired-1', + chat_jid: 'dc:ops', + group_folder: 'ops', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude-reviewer', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + title: 'Dashboard PR', + source_ref: null, + plan_notes: null, + review_requested_at: null, + round_trip_count: 0, + owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: 0, + task_done_then_user_reopen_count: 0, + empty_step_done_streak: 0, + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-26T04:00:00.000Z', + updated_at: '2026-04-26T04:30:00.000Z', + ...overrides, + }; +} + +const roomEntry = { + jid: 'dc:ops', + name: '#ops', + folder: 'ops', + agentType: 'codex' as const, + status: 'inactive' as const, + elapsedMs: null, + pendingMessages: false, + pendingTasks: 0, +}; + +describe('web dashboard attachment data', () => { + it('normalizes structured EJClaw envelopes in room messages and outputs', () => { + const task = makePairedTask({ id: 'paired-structured' }); + const structured = JSON.stringify({ + ejclaw: { + visibility: 'public', + text: '라벨 좌측 클리핑 회귀 수정했습니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/bar-chart-label-fit-playwright.png', + name: 'bar-chart-label-fit-playwright.png', + mime: 'image/png', + }, + ], + }, + }); + const output: PairedTurnOutput = { + id: 1, + task_id: task.id, + turn_number: 1, + role: 'owner', + output_text: structured, + verdict: 'done', + created_at: '2026-04-26T05:30:00.000Z', + }; + const message: NewMessage = { + id: 'msg-structured', + chat_jid: 'dc:ops', + sender: 'bot-1', + sender_name: 'owner', + content: structured, + timestamp: '2026-04-26T05:31:00.000Z', + is_from_me: true, + is_bot_message: true, + message_source_kind: 'bot', + }; + + const activity = buildWebDashboardRoomActivity({ + serviceId: 'codex-main', + entry: roomEntry, + pairedTask: task, + turns: [], + attempts: [], + outputs: [output], + messages: [message], + }); + + expect(activity.messages[0]).toMatchObject({ + content: '라벨 좌측 클리핑 회귀 수정했습니다.', + attachments: [ + { + path: '/tmp/bar-chart-label-fit-playwright.png', + name: 'bar-chart-label-fit-playwright.png', + mime: 'image/png', + }, + ], + }); + expect(activity.messages[0]?.content).not.toContain('"ejclaw"'); + expect(activity.pairedTask?.outputs[0]).toMatchObject({ + outputText: '라벨 좌측 클리핑 회귀 수정했습니다.', + attachments: [ + { + path: '/tmp/bar-chart-label-fit-playwright.png', + name: 'bar-chart-label-fit-playwright.png', + mime: 'image/png', + }, + ], + }); + expect(activity.pairedTask?.outputs[0]?.outputText).not.toContain( + '"ejclaw"', + ); + }); + + it('turns legacy Discord image placeholders into dashboard attachments', () => { + const message: NewMessage = { + id: 'msg-image', + chat_jid: 'dc:ops', + sender: 'bot-1', + sender_name: 'owner', + content: + '라벨 좌측 클리핑 회귀 수정했습니다.\n[Image: screenshot.png → /tmp/bar-chart-label-fit-playwright.png]', + timestamp: '2026-04-26T05:31:00.000Z', + is_from_me: true, + is_bot_message: true, + message_source_kind: 'bot', + }; + + const activity = buildWebDashboardRoomActivity({ + serviceId: 'codex-main', + entry: roomEntry, + pairedTask: null, + turns: [], + attempts: [], + outputs: [], + messages: [message], + }); + + expect(activity.messages[0]).toMatchObject({ + content: '라벨 좌측 클리핑 회귀 수정했습니다.', + attachments: [ + { + path: '/tmp/bar-chart-label-fit-playwright.png', + name: 'bar-chart-label-fit-playwright.png', + }, + ], + }); + }); +}); diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index df33b09..ab0ae00 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -1,9 +1,13 @@ -import { normalizeEjclawStructuredOutput } from './agent-protocol.js'; +import { + extractImageTagPaths, + 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'; import type { NewMessage, + OutboundAttachment, PairedTask, PairedTurnOutput, ScheduledTask, @@ -69,6 +73,7 @@ export interface WebDashboardRoomMessage { sender: string; senderName: string; content: string; + attachments: OutboundAttachment[]; timestamp: string; isFromMe: boolean; isBotMessage: boolean; @@ -99,6 +104,7 @@ export interface WebDashboardRoomTurnOutput { verdict: PairedTurnOutput['verdict'] | null; createdAt: string; outputText: string; + attachments: OutboundAttachment[]; } export interface WebDashboardRoomActivity { @@ -196,22 +202,54 @@ function hasStructuredOutputHint(value: string): boolean { return value.includes('"ejclaw"') || /"ejclaw"/i.test(value); } -function normalizeRoomMessageContent(value: string): string | null { - if (!hasStructuredOutputHint(value)) return value; +function imagePathsToAttachments(paths: string[]): OutboundAttachment[] { + return paths.map((filePath) => ({ + path: filePath, + name: filePath.split(/[\\/]/).at(-1) || undefined, + })); +} + +function splitLegacyImageTags( + text: string, + attachments: OutboundAttachment[] = [], +): { text: string; attachments: OutboundAttachment[] } { + const extracted = extractImageTagPaths(text); + if (extracted.imagePaths.length === 0) { + return { text, attachments }; + } + return { + text: extracted.cleanText, + attachments: [ + ...attachments, + ...imagePathsToAttachments(extracted.imagePaths), + ], + }; +} + +function normalizeStructuredVisibleContent(value: string): { + text: string | null; + attachments: OutboundAttachment[]; +} { + if (!hasStructuredOutputHint(value)) return splitLegacyImageTags(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 === 'silent') { + return { text: null, attachments: [] }; + } if ( normalized.output?.visibility === 'public' && normalized.output.text !== candidate ) { - return normalized.output.text; + return splitLegacyImageTags( + normalized.output.text, + normalized.output.attachments ?? [], + ); } } - return value; + return splitLegacyImageTags(value); } function stableHash(value: string): string { @@ -440,14 +478,15 @@ export function sanitizeScheduledTask( function sanitizeRoomMessage( message: NewMessage, ): WebDashboardRoomMessage | null { - const content = normalizeRoomMessageContent(message.content ?? ''); - if (content === null) return null; + const normalized = normalizeStructuredVisibleContent(message.content ?? ''); + if (normalized.text === null) return null; return { id: message.id, sender: message.sender, senderName: message.sender_name || message.sender, - content: buildRoomPreview(content, ROOM_MESSAGE_PREVIEW_MAX_LENGTH), + content: buildRoomPreview(normalized.text, ROOM_MESSAGE_PREVIEW_MAX_LENGTH), + attachments: normalized.attachments, timestamp: message.timestamp, isFromMe: !!message.is_from_me, isBotMessage: !!message.is_bot_message, @@ -549,7 +588,10 @@ function sanitizeRoomTurn( function sanitizeRoomTurnOutput( output: PairedTurnOutput, -): WebDashboardRoomTurnOutput { +): WebDashboardRoomTurnOutput | null { + const normalized = normalizeStructuredVisibleContent(output.output_text); + if (normalized.text === null) return null; + return { id: output.id, turnNumber: output.turn_number, @@ -557,9 +599,10 @@ function sanitizeRoomTurnOutput( verdict: output.verdict ?? null, createdAt: output.created_at, outputText: buildRoomPreview( - output.output_text, + normalized.text, ROOM_OUTPUT_PREVIEW_MAX_LENGTH, ), + attachments: normalized.attachments, }; } @@ -620,7 +663,12 @@ export function buildWebDashboardRoomActivity(args: { args.progressMessages ?? args.messages, ) : null, - outputs: args.outputs.slice(-outputLimit).map(sanitizeRoomTurnOutput), + outputs: args.outputs + .slice(-outputLimit) + .map(sanitizeRoomTurnOutput) + .filter((output): output is WebDashboardRoomTurnOutput => + Boolean(output), + ), } : null, }; diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 626cd17..935fb4d 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -46,6 +46,7 @@ import { buildWebDashboardOverview, sanitizeScheduledTask, } from './web-dashboard-data.js'; +import { serveValidatedAttachment } from './web-dashboard-attachments.js'; import { addClaudeAccountFromToken, getActiveCodexSettingsIndex, @@ -1570,9 +1571,15 @@ export function createWebDashboardHandler( return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); } - if (url.pathname === '/api/health') { - return jsonResponse({ ok: true }); - } + const simpleGetRoutes: Record Response> = { + '/api/health': () => jsonResponse({ ok: true }), + '/api/status-snapshots': () => + jsonResponse(readSnapshots(statusMaxAgeMs)), + '/api/tasks': () => jsonResponse(loadTasks().map(sanitizeScheduledTask)), + '/api/attachments': () => serveValidatedAttachment(url), + }; + const simpleGetRoute = simpleGetRoutes[url.pathname]; + if (simpleGetRoute) return simpleGetRoute(); if (url.pathname === '/api/overview') { const snapshots = readSnapshots(statusMaxAgeMs); @@ -1593,14 +1600,6 @@ export function createWebDashboardHandler( }); } - if (url.pathname === '/api/status-snapshots') { - return jsonResponse(readSnapshots(statusMaxAgeMs)); - } - - if (url.pathname === '/api/tasks') { - return jsonResponse(loadTasks().map(sanitizeScheduledTask)); - } - if (url.pathname === '/api/settings/accounts') { return jsonResponse({ claude: listClaudeAccounts(),