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 {
|
export interface SendMessageIpcPayloadInput {
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -8,17 +13,36 @@ export interface SendMessageIpcPayloadInput {
|
|||||||
timestamp?: string;
|
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(
|
export function buildSendMessageIpcPayload(
|
||||||
input: SendMessageIpcPayloadInput,
|
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 {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
chatJid: input.chatJid,
|
chatJid: input.chatJid,
|
||||||
text: input.text,
|
text,
|
||||||
sender: input.sender || undefined,
|
sender: input.sender || undefined,
|
||||||
senderRole: input.senderRole || undefined,
|
senderRole: input.senderRole || undefined,
|
||||||
runId: input.runId || undefined,
|
runId: input.runId || undefined,
|
||||||
groupFolder: input.groupFolder,
|
groupFolder: input.groupFolder,
|
||||||
timestamp: input.timestamp || new Date().toISOString(),
|
timestamp: input.timestamp || new Date().toISOString(),
|
||||||
|
...(attachments.length > 0 ? { attachments } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,52 @@ describe('agent runner IPC message payload', () => {
|
|||||||
}).senderRole,
|
}).senderRole,
|
||||||
).toBeUndefined();
|
).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,
|
database: Database,
|
||||||
id: string,
|
id: string,
|
||||||
): ScheduledTask | undefined {
|
): ScheduledTask | undefined {
|
||||||
return database
|
const row = database
|
||||||
.prepare('SELECT * FROM scheduled_tasks WHERE id = ?')
|
.prepare('SELECT * FROM scheduled_tasks WHERE id = ?')
|
||||||
.get(id) as ScheduledTask | undefined;
|
.get(id) as ScheduledTask | null | undefined;
|
||||||
|
return row ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findDuplicateCiWatcherInDatabase(
|
export function findDuplicateCiWatcherInDatabase(
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ async function main(): Promise<void> {
|
|||||||
editFormattedTrackedChannelMessage(channels, jid, messageId, rawText),
|
editFormattedTrackedChannelMessage(channels, jid, messageId, rawText),
|
||||||
});
|
});
|
||||||
startIpcWatcher({
|
startIpcWatcher({
|
||||||
sendMessage: async (jid, text, senderRole, runId) => {
|
sendMessage: async (jid, text, senderRole, runId, attachments) => {
|
||||||
if (
|
if (
|
||||||
runId &&
|
runId &&
|
||||||
(senderRole === 'reviewer' || senderRole === 'arbiter') &&
|
(senderRole === 'reviewer' || senderRole === 'arbiter') &&
|
||||||
@@ -419,7 +419,7 @@ async function main(): Promise<void> {
|
|||||||
},
|
},
|
||||||
'IPC relay routed message to channel',
|
'IPC relay routed message to channel',
|
||||||
);
|
);
|
||||||
await route.channel.sendMessage(jid, text);
|
await route.channel.sendMessage(jid, text, { attachments });
|
||||||
if (
|
if (
|
||||||
(senderRole === 'reviewer' || senderRole === 'arbiter') &&
|
(senderRole === 'reviewer' || senderRole === 'arbiter') &&
|
||||||
isTerminalStatusMessage(text)
|
isTerminalStatusMessage(text)
|
||||||
|
|||||||
@@ -556,6 +556,7 @@ describe('IPC message authorization', () => {
|
|||||||
'review text',
|
'review text',
|
||||||
'reviewer',
|
'reviewer',
|
||||||
'run-reviewer-ipc',
|
'run-reviewer-ipc',
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
expect(result).toEqual(
|
expect(result).toEqual(
|
||||||
expect.objectContaining({
|
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 () => {
|
it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => {
|
||||||
const sendMessage = vi.fn(async () => {});
|
const sendMessage = vi.fn(async () => {});
|
||||||
const injectInboundMessage = vi.fn(async () => {});
|
const injectInboundMessage = vi.fn(async () => {});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export async function forwardAuthorizedIpcMessage(
|
|||||||
text: string,
|
text: string,
|
||||||
senderRole?: string,
|
senderRole?: string,
|
||||||
runId?: string,
|
runId?: string,
|
||||||
|
attachments?: import('./types.js').OutboundAttachment[],
|
||||||
) => Promise<void>,
|
) => Promise<void>,
|
||||||
injectInboundMessage?: (payload: {
|
injectInboundMessage?: (payload: {
|
||||||
chatJid: string;
|
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 {
|
return {
|
||||||
outcome: 'sent',
|
outcome: 'sent',
|
||||||
chatJid: msg.chatJid,
|
chatJid: msg.chatJid,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AssignRoomInput } from './db.js';
|
|||||||
import type {
|
import type {
|
||||||
AgentType,
|
AgentType,
|
||||||
MessageSourceKind,
|
MessageSourceKind,
|
||||||
|
OutboundAttachment,
|
||||||
PairedTask,
|
PairedTask,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
RoomMode,
|
RoomMode,
|
||||||
@@ -67,6 +68,7 @@ export interface IpcDeps {
|
|||||||
text: string,
|
text: string,
|
||||||
senderRole?: string,
|
senderRole?: string,
|
||||||
runId?: string,
|
runId?: string,
|
||||||
|
attachments?: OutboundAttachment[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
nudgeScheduler?: () => void;
|
nudgeScheduler?: () => void;
|
||||||
roomBindings: () => Record<string, RegisteredGroup>;
|
roomBindings: () => Record<string, RegisteredGroup>;
|
||||||
@@ -100,6 +102,7 @@ export interface IpcMessagePayload {
|
|||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
treatAsHuman?: boolean;
|
treatAsHuman?: boolean;
|
||||||
sourceKind?: MessageSourceKind;
|
sourceKind?: MessageSourceKind;
|
||||||
|
attachments?: OutboundAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpcMessageForwardResult {
|
export interface IpcMessageForwardResult {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const ONE_PIXEL_PNG = Buffer.from(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const cleanupDirs: string[] = [];
|
const cleanupDirs: string[] = [];
|
||||||
|
const cleanupFiles: string[] = [];
|
||||||
|
|
||||||
function makeTempDir(baseDir: string, prefix: string): string {
|
function makeTempDir(baseDir: string, prefix: string): string {
|
||||||
const dir = fs.mkdtempSync(path.join(baseDir, prefix));
|
const dir = fs.mkdtempSync(path.join(baseDir, prefix));
|
||||||
@@ -33,6 +34,9 @@ afterEach(() => {
|
|||||||
for (const dir of cleanupDirs.splice(0)) {
|
for (const dir of cleanupDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { force: true, recursive: true });
|
fs.rmSync(dir, { force: true, recursive: true });
|
||||||
}
|
}
|
||||||
|
for (const file of cleanupFiles.splice(0)) {
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateOutboundAttachments', () => {
|
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', () => {
|
it('requires workspace paths to be explicitly allowlisted', () => {
|
||||||
const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
|
const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
|
||||||
const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG);
|
const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES = [
|
|||||||
'ejclaw-attachment-',
|
'ejclaw-attachment-',
|
||||||
'ejclaw-discord-image-',
|
'ejclaw-discord-image-',
|
||||||
] as const;
|
] as const;
|
||||||
|
const DEFAULT_TEMP_ATTACHMENT_FILE_PREFIXES = ['ejclaw-'] as const;
|
||||||
|
|
||||||
function unique(values: Array<string | null | undefined>): string[] {
|
function unique(values: Array<string | null | undefined>): string[] {
|
||||||
return [
|
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 {
|
function detectImageMime(filePath: string): string | null {
|
||||||
const handle = fs.openSync(filePath, 'r');
|
const handle = fs.openSync(filePath, 'r');
|
||||||
try {
|
try {
|
||||||
@@ -171,7 +187,8 @@ export function validateOutboundAttachments(
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!matchesAllowedBaseDir(realPath, baseDirs) &&
|
!matchesAllowedBaseDir(realPath, baseDirs) &&
|
||||||
!isWithinDefaultTempAttachmentDir(realPath, defaultTempDir)
|
!isWithinDefaultTempAttachmentDir(realPath, defaultTempDir) &&
|
||||||
|
!isDefaultTempAttachmentFile(realPath, defaultTempDir)
|
||||||
) {
|
) {
|
||||||
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
|
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { normalizeEjclawStructuredOutput } from './agent-protocol.js';
|
||||||
import { isWatchCiTask } from './task-watch-status.js';
|
import { isWatchCiTask } from './task-watch-status.js';
|
||||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
||||||
import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.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);
|
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 {
|
function stableHash(value: string): string {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
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 {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
sender: message.sender,
|
sender: message.sender,
|
||||||
senderName: message.sender_name || message.sender,
|
senderName: message.sender_name || message.sender,
|
||||||
content: buildRoomPreview(
|
content: buildRoomPreview(content, ROOM_MESSAGE_PREVIEW_MAX_LENGTH),
|
||||||
message.content ?? '',
|
|
||||||
ROOM_MESSAGE_PREVIEW_MAX_LENGTH,
|
|
||||||
),
|
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
isFromMe: !!message.is_from_me,
|
isFromMe: !!message.is_from_me,
|
||||||
isBotMessage: !!message.is_bot_message,
|
isBotMessage: !!message.is_bot_message,
|
||||||
@@ -568,7 +602,10 @@ export function buildWebDashboardRoomActivity(args: {
|
|||||||
pendingTasks: args.entry.pendingTasks,
|
pendingTasks: args.entry.pendingTasks,
|
||||||
messages: args.messages
|
messages: args.messages
|
||||||
.filter((message) => !isTaskStatusMessage(message))
|
.filter((message) => !isTaskStatusMessage(message))
|
||||||
.map(sanitizeRoomMessage),
|
.map(sanitizeRoomMessage)
|
||||||
|
.filter((message): message is WebDashboardRoomMessage =>
|
||||||
|
Boolean(message),
|
||||||
|
),
|
||||||
pairedTask: args.pairedTask
|
pairedTask: args.pairedTask
|
||||||
? {
|
? {
|
||||||
id: args.pairedTask.id,
|
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