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:
Eyejoker
2026-04-28 01:35:26 +09:00
committed by GitHub
parent 20e1a1e5e1
commit 8bb77ffaa3
11 changed files with 291 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(/&quot;|&#34;|&#x22;/gi, '"')
.replace(/&apos;|&#39;|&#x27;/gi, "'")
.replace(/&lt;|&#60;|&#x3c;/gi, '<')
.replace(/&gt;|&#62;|&#x3e;/gi, '>')
.replace(/&amp;|&#38;|&#x26;/gi, '&');
}
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;
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,

View 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, '&quot;');
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',
};
}