Merge remote-tracking branch 'origin/main' into codex/owner/ejclaw

This commit is contained in:
ejclaw
2026-04-28 01:35:44 +09:00
24 changed files with 1440 additions and 37 deletions

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

@@ -356,6 +356,99 @@ describe('web dashboard data', () => {
]);
});
it('uses Discord live progress messages without exposing them as recent chat', () => {
const task = makePairedTask({
id: 'paired-room-progress',
chat_jid: 'dc:ops',
status: 'active',
updated_at: '2026-04-26T06:10:00.000Z',
});
const turn: PairedTurnRecord = {
turn_id: 'turn-progress',
task_id: task.id,
task_updated_at: task.updated_at,
role: 'owner',
intent_kind: 'owner-turn',
state: 'running',
executor_service_id: 'codex-main',
executor_agent_type: 'codex',
attempt_no: 1,
created_at: '2026-04-26T06:00:00.000Z',
updated_at: '2026-04-26T06:10:00.000Z',
completed_at: null,
last_error: null,
};
const statusPrefix = '';
const messages: NewMessage[] = [
{
id: 'msg-latest',
chat_jid: 'dc:ops',
sender: 'user-1',
sender_name: '눈쟁이',
content: 'recent human message',
timestamp: '2026-04-26T06:09:00.000Z',
is_from_me: false,
is_bot_message: false,
message_source_kind: 'human',
},
];
const progressMessages: NewMessage[] = [
{
id: 'msg-stale-progress',
chat_jid: 'dc:ops',
sender: 'bot-1',
sender_name: '오너',
content: `${statusPrefix}stale progress`,
timestamp: '2026-04-26T05:59:00.000Z',
is_from_me: true,
is_bot_message: true,
message_source_kind: 'bot',
},
{
id: 'msg-live-progress',
chat_jid: 'dc:ops',
sender: 'bot-1',
sender_name: '오너',
content: `${statusPrefix}building mobile parity\n\n12s`,
timestamp: '2026-04-26T06:08:00.000Z',
is_from_me: true,
is_bot_message: true,
message_source_kind: 'bot',
},
];
const activity = buildWebDashboardRoomActivity({
serviceId: 'codex-main',
entry: {
jid: 'dc:ops',
name: '#ops',
folder: 'ops',
agentType: 'codex',
status: 'processing',
elapsedMs: 20_000,
pendingMessages: false,
pendingTasks: 0,
},
pairedTask: task,
turns: [turn],
attempts: [],
outputs: [],
messages,
progressMessages,
});
expect(activity.pairedTask?.currentTurn).toMatchObject({
progressText: 'building mobile parity',
progressUpdatedAt: '2026-04-26T06:08:00.000Z',
});
expect(activity.messages).toEqual([
expect.objectContaining({
senderName: '눈쟁이',
content: 'recent human message',
}),
]);
});
it('builds typed inbox items from pending rooms, paired tasks, and CI failures', () => {
const snapshots: StatusSnapshot[] = [
{

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,
@@ -431,6 +465,10 @@ function sanitizeRoomMessage(message: NewMessage): WebDashboardRoomMessage {
*/
const TASK_STATUS_PREFIX = '';
function isTaskStatusMessage(message: NewMessage): boolean {
return (message.content ?? '').startsWith(TASK_STATUS_PREFIX);
}
function turnRoleFromSenderName(name: string | null | undefined): string {
const v = (name ?? '').toLowerCase();
if (v.includes('오너') || v.includes('owner')) return 'owner';
@@ -533,6 +571,7 @@ export function buildWebDashboardRoomActivity(args: {
attempts: PairedTurnAttemptRecord[];
outputs: PairedTurnOutput[];
messages: NewMessage[];
progressMessages?: NewMessage[];
outputLimit?: number;
}): WebDashboardRoomActivity {
const latestAttemptByTurnId = new Map<string, PairedTurnAttemptRecord>();
@@ -561,7 +600,12 @@ export function buildWebDashboardRoomActivity(args: {
elapsedMs: args.entry.elapsedMs,
pendingMessages: args.entry.pendingMessages,
pendingTasks: args.entry.pendingTasks,
messages: args.messages.map(sanitizeRoomMessage),
messages: args.messages
.filter((message) => !isTaskStatusMessage(message))
.map(sanitizeRoomMessage)
.filter((message): message is WebDashboardRoomMessage =>
Boolean(message),
),
pairedTask: args.pairedTask
? {
id: args.pairedTask.id,
@@ -570,7 +614,11 @@ export function buildWebDashboardRoomActivity(args: {
roundTripCount: args.pairedTask.round_trip_count,
updatedAt: args.pairedTask.updated_at,
currentTurn: currentTurn
? sanitizeRoomTurn(currentTurn, currentAttempt, args.messages)
? sanitizeRoomTurn(
currentTurn,
currentAttempt,
args.progressMessages ?? args.messages,
)
: null,
outputs: args.outputs.slice(-outputLimit).map(sanitizeRoomTurnOutput),
}

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

View File

@@ -1506,6 +1506,22 @@ describe('web dashboard server handler', () => {
message_source_kind: 'human',
},
];
const statusPrefix = '';
const progressMessages: NewMessage[] = [
...messages,
{
id: 'msg-progress',
chat_jid: 'dc:ops',
sender: 'reviewer-bot',
sender_name: '리뷰어',
content: `${statusPrefix}checking current output\n\n9s`,
timestamp: '2026-04-26T05:20:00.000Z',
is_from_me: true,
is_bot_message: true,
message_source_kind: 'bot',
},
];
const requestedMessageLimits: number[] = [];
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [
{
@@ -1535,7 +1551,10 @@ describe('web dashboard server handler', () => {
getPairedTurnAttempts: (turnId) =>
turnId === turns[0]!.turn_id ? attempts : [],
getPairedTurnOutputs: () => outputs,
getRecentChatMessages: () => messages,
getRecentChatMessages: (_jid, limit) => {
requestedMessageLimits.push(limit ?? 20);
return limit && limit > 8 ? progressMessages : messages;
},
});
const response = await handler(
@@ -1555,6 +1574,8 @@ describe('web dashboard server handler', () => {
state: string;
attemptNo: number;
lastError: string;
progressText: string;
progressUpdatedAt: string;
};
outputs: Array<{ outputText: string; turnNumber: number }>;
};
@@ -1568,12 +1589,16 @@ describe('web dashboard server handler', () => {
state: 'running',
attemptNo: 2,
lastError: 'OPENAI_API_KEY=<redacted>',
progressText: 'checking current output',
progressUpdatedAt: '2026-04-26T05:20:00.000Z',
});
expect(body.pairedTask.outputs).toMatchObject([
{ turnNumber: 1, outputText: 'owner final output' },
]);
expect(requestedMessageLimits).toEqual([8, 40]);
expect(body.messages[0]?.content).toContain('BOT_TOKEN=<redacted>');
expect(body.messages[0]?.senderName).toBe('눈쟁이');
expect(body.messages).toHaveLength(1);
});
it('returns 404 for missing room timelines', async () => {

View File

@@ -781,6 +781,9 @@ export function createWebDashboardHandler(
for (const [jid, { snapshot, entry }] of uniqueByJid) {
const pairedTask = loadLatestPairedTaskForChat(jid) ?? null;
const messages = loadRecentChatMessages(jid, 8);
const progressMessages = pairedTask
? loadRecentChatMessages(jid, 40)
: messages;
const outputs = loadRecentPairedTurnOutputsForChat(jid, 8);
if (!pairedTask && messages.length === 0 && outputs.length === 0)
continue;
@@ -796,6 +799,7 @@ export function createWebDashboardHandler(
attempts: [],
outputs,
messages,
progressMessages,
outputLimit: 8,
});
}
@@ -1171,6 +1175,7 @@ export function createWebDashboardHandler(
attempts,
outputs: pairedTask ? loadPairedTurnOutputs(pairedTask.id) : [],
messages: loadRecentChatMessages(timelineRoomJid, 8),
progressMessages: loadRecentChatMessages(timelineRoomJid, 40),
}),
);
}