Fix structured IPC envelope leakage
Normalize EJClaw structured send_message envelopes, preserve attachments, unwrap stored room payloads, and add regression tests.
This commit is contained in:
@@ -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<string, string | undefined> {
|
||||
): 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -387,7 +387,7 @@ async function main(): Promise<void> {
|
||||
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<void> {
|
||||
},
|
||||
'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)
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function forwardAuthorizedIpcMessage(
|
||||
text: string,
|
||||
senderRole?: string,
|
||||
runId?: string,
|
||||
attachments?: import('./types.js').OutboundAttachment[],
|
||||
) => Promise<void>,
|
||||
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,
|
||||
|
||||
@@ -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<void>;
|
||||
nudgeScheduler?: () => void;
|
||||
roomBindings: () => Record<string, RegisteredGroup>;
|
||||
@@ -100,6 +102,7 @@ export interface IpcMessagePayload {
|
||||
timestamp?: string;
|
||||
treatAsHuman?: boolean;
|
||||
sourceKind?: MessageSourceKind;
|
||||
attachments?: OutboundAttachment[];
|
||||
}
|
||||
|
||||
export interface IpcMessageForwardResult {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 | null | undefined>): 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
77
src/web-dashboard-room-envelope.test.ts
Normal file
77
src/web-dashboard-room-envelope.test.ts
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user