Fix structured attachment rendering (#69)

* review: harden readonly git checks and lint verification

* test: satisfy readonly reviewer sandbox typing

* test: fix readonly reviewer sandbox assertions

* test: remove impossible readonly sandbox guards

* test: relax readonly sandbox assertions

* test: cast readonly sandbox expectation shape

* test: cast readonly sandbox expectation through unknown

* Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke

* Add STEP_DONE guards, verdict storage, and stale delivery suppression

* style: format paired stepdone telemetry files

* Route STEP_DONE through reviewer

* Add structured Discord attachments

* style: format structured attachment test

* Fix room thread bot output parity

* Remove duplicate work item attachment migration from owner branch

* Remove legacy dashboard rooms renderer

* Extract dashboard parsed body renderer

* Extract dashboard redaction helpers

* Extract dashboard RoomCardV2 component

* Include RoomCardV2 test in vitest suite

* Extract dashboard RoomBoardV2 component

* Extract dashboard EmptyState component

* Extract dashboard InboxPanel component

* Split dashboard InboxPanel card renderer

* Extract dashboard TaskPanel component

* Extract dashboard UsagePanel component

* Fix dashboard room thread chunk rendering

* Extract dashboard ServicePanel component

* Render dashboard live progress markdown

* Extract dashboard SettingsPanel component

* Fix structured attachment rendering
This commit is contained in:
Eyejoker
2026-04-28 17:36:11 +09:00
committed by GitHub
parent 96d99f63e0
commit 6197e90c8c
18 changed files with 837 additions and 104 deletions

View File

@@ -0,0 +1,38 @@
import type { RoomThreadEntry } from './roomThread';
type RoomAttachment = NonNullable<RoomThreadEntry['attachments']>[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 (
<ul className="room-attachments">
{attachments.map((attachment) => {
const name = attachmentDisplayName(attachment);
const url = attachmentUrl(attachment);
return (
<li className="room-attachment" key={`${attachment.path}:${name}`}>
<a href={url} rel="noreferrer" target="_blank">
<img alt={name} loading="lazy" src={url} />
<span>{name}</span>
</a>
</li>
);
})}
</ul>
);
}

View File

@@ -161,4 +161,51 @@ describe('RoomCardV2', () => {
expect(html).toContain('<code>pnpm openapi:sync</code>');
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');
});
});

View File

@@ -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({
</header>
<div className="room-timeline-body">
<ParsedBody text={entry.content} />
<RoomAttachmentGallery attachments={entry.attachments} />
</div>
</li>
);

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<string>();
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,
};
}

View File

@@ -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<string, Handler[]>();
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"',
);
});
});

View File

@@ -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<string>();
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,

View File

@@ -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 () => {});

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 | null | undefined>): 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;

View File

@@ -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);
});
});

View File

@@ -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<string, string> | 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',
},
});
}

View File

@@ -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>): 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',
},
],
});
});
});

View File

@@ -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"') || /&quot;ejclaw&quot;/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,
};

View File

@@ -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<string, () => 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(),