Add structured Discord attachments

This commit is contained in:
ejclaw
2026-04-25 09:20:28 +09:00
parent bdaecb2552
commit 83b7aef494
25 changed files with 892 additions and 53 deletions

View File

@@ -4,3 +4,25 @@ You have a `send_message` tool that sends a message immediately while you are st
Use it to acknowledge a request before starting longer work. Use it to acknowledge a request before starting longer work.
When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to. When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to.
## Image attachments
When a locally generated image or screenshot should appear in Discord, return an EJClaw structured output with `attachments` instead of only writing the file path in prose:
```json
{
"ejclaw": {
"visibility": "public",
"text": "스크린샷을 첨부했습니다.",
"attachments": [
{
"path": "/absolute/path/screenshot.png",
"name": "screenshot.png",
"mime": "image/png"
}
]
}
}
```
Use absolute local paths only, and do not repeat the same path in the visible text. Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.

View File

@@ -28,9 +28,36 @@ Your output is sent directly to the Discord group.
- Do not use generic recurring task registration from Codex - Do not use generic recurring task registration from Codex
- If the user wants a reminder or other non-CI recurring task, tell them to ask Claude/클코 to schedule it - If the user wants a reminder or other non-CI recurring task, tell them to ask Claude/클코 to schedule it
## Image attachments
When you need to show a locally generated image, screenshot, or other raster output in Discord, do not rely on a raw file path in prose. Emit an EJClaw structured output with `attachments`.
```json
{
"ejclaw": {
"visibility": "public",
"text": "이미지를 생성했습니다.",
"verdict": "done",
"attachments": [
{
"path": "/absolute/path/image.png",
"name": "image.png",
"mime": "image/png"
}
]
}
}
```
- `attachments.path` must be an absolute local path; URLs and relative paths are ignored
- Do not repeat the same file path in the visible text
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file
## CI 감시 (watch_ci) ## CI 감시 (watch_ci)
GitHub Actions run 감시는 structured 필드를 우선 사용: GitHub Actions run 감시는 structured 필드를 우선 사용:
- ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID - ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID
- 이 조합 → host-driven fast path (LLM 토큰 소모 없음, 15초 polling) - 이 조합 → host-driven fast path (LLM 토큰 소모 없음, 15초 polling)
- structured 필드 없이 generic 등록 시 매 tick LLM 실행됨 - structured 필드 없이 generic 등록 시 매 tick LLM 실행됨

View File

@@ -20,9 +20,36 @@ Do not use markdown headings in chat replies. Keep messages clean and readable f
The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context. The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context.
## Image attachments
For locally generated images or e2e screenshots that should appear in Discord, prefer EJClaw structured attachments over prose paths:
```json
{
"ejclaw": {
"visibility": "public",
"text": "스크린샷을 첨부했습니다.",
"verdict": "done",
"attachments": [
{
"path": "/absolute/path/screenshot.png",
"name": "screenshot.png",
"mime": "image/png"
}
]
}
}
```
- Use absolute local paths only
- Do not duplicate the same path in the visible text
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
- The channel harness validates and uploads attachments; plain prose paths are not reliable
## CI monitoring (watch_ci) ## CI monitoring (watch_ci)
GitHub Actions run monitoring uses structured fields first: GitHub Actions run monitoring uses structured fields first:
- ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID - ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID
- This combination → host-driven fast path (no LLM token cost, 15s polling) - This combination → host-driven fast path (no LLM token cost, 15s polling)
- Without structured fields → generic path, each tick runs LLM - Without structured fields → generic path, each tick runs LLM

View File

@@ -3,7 +3,7 @@ import path from 'path';
import { import {
extractImageTagPaths, extractImageTagPaths,
normalizePublicTextOutput, normalizeEjclawStructuredOutput,
type RunnerStructuredOutput, type RunnerStructuredOutput,
writeProtocolOutput, writeProtocolOutput,
} from 'ejclaw-runners-shared'; } from 'ejclaw-runners-shared';
@@ -151,7 +151,7 @@ export function normalizeStructuredOutput(result: string | null): {
result: string | null; result: string | null;
output?: RunnerOutput['output']; output?: RunnerOutput['output'];
} { } {
return normalizePublicTextOutput(result); return normalizeEjclawStructuredOutput(result);
} }
export function extractAssistantText(message: unknown): string | null { export function extractAssistantText(message: unknown): string | null {

View File

@@ -1,7 +1,8 @@
export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---'; export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---';
export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
export const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g; export const IMAGE_TAG_RE =
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
export const IPC_POLL_MS = 500; export const IPC_POLL_MS = 500;
export const IPC_INPUT_SUBDIR = 'input'; export const IPC_INPUT_SUBDIR = 'input';
@@ -21,11 +22,18 @@ export type RunnerOutputVerdict =
export type RunnerOutputVisibility = 'public' | 'silent'; export type RunnerOutputVisibility = 'public' | 'silent';
export interface RunnerOutputAttachment {
path: string;
name?: string;
mime?: string;
}
export type RunnerStructuredOutput = export type RunnerStructuredOutput =
| { | {
visibility: 'public'; visibility: 'public';
text: string; text: string;
verdict?: Exclude<RunnerOutputVerdict, 'silent'>; verdict?: Exclude<RunnerOutputVerdict, 'silent'>;
attachments?: RunnerOutputAttachment[];
} }
| { | {
visibility: 'silent'; visibility: 'silent';
@@ -89,6 +97,33 @@ function isVisibleVerdict(
); );
} }
function normalizeAttachments(value: unknown): RunnerOutputAttachment[] {
if (!Array.isArray(value)) return [];
const attachments: RunnerOutputAttachment[] = [];
for (const item of value) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const candidate = item as {
path?: unknown;
name?: unknown;
mime?: unknown;
};
if (typeof candidate.path !== 'string' || candidate.path.length === 0) {
continue;
}
attachments.push({
path: candidate.path,
...(typeof candidate.name === 'string' && candidate.name.length > 0
? { name: candidate.name }
: {}),
...(typeof candidate.mime === 'string' && candidate.mime.length > 0
? { mime: candidate.mime }
: {}),
});
}
return attachments;
}
export function normalizeEjclawStructuredOutput( export function normalizeEjclawStructuredOutput(
result: string | null, result: string | null,
): NormalizedRunnerOutput { ): NormalizedRunnerOutput {
@@ -99,7 +134,12 @@ export function normalizeEjclawStructuredOutput(
const trimmed = result.trim(); const trimmed = result.trim();
try { try {
const parsed = JSON.parse(trimmed) as { const parsed = JSON.parse(trimmed) as {
ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown }; ejclaw?: {
visibility?: unknown;
text?: unknown;
verdict?: unknown;
attachments?: unknown;
};
}; };
const envelope = parsed?.ejclaw; const envelope = parsed?.ejclaw;
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) { if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
@@ -128,6 +168,7 @@ export function normalizeEjclawStructuredOutput(
) { ) {
return normalizePublicTextOutput(result); return normalizePublicTextOutput(result);
} }
const attachments = normalizeAttachments(envelope.attachments);
return { return {
result: envelope.text, result: envelope.text,
output: { output: {
@@ -136,6 +177,7 @@ export function normalizeEjclawStructuredOutput(
verdict: isVisibleVerdict(envelope.verdict) verdict: isVisibleVerdict(envelope.verdict)
? envelope.verdict ? envelope.verdict
: undefined, : undefined,
...(attachments.length > 0 ? { attachments } : {}),
}, },
}; };
} }

View File

@@ -15,6 +15,7 @@ export {
writeProtocolOutput, writeProtocolOutput,
type NormalizedRunnerOutput, type NormalizedRunnerOutput,
type RunnerOutputPhase, type RunnerOutputPhase,
type RunnerOutputAttachment,
type RunnerOutputVerdict, type RunnerOutputVerdict,
type RunnerOutputVisibility, type RunnerOutputVisibility,
type RunnerStructuredOutput, type RunnerStructuredOutput,

View File

@@ -13,6 +13,12 @@ describe('shared agent protocol helpers', () => {
cleanText: 'hello', cleanText: 'hello',
imagePaths: ['/tmp/a.png'], imagePaths: ['/tmp/a.png'],
}); });
expect(
extractImageTagPaths('hello [Image: screenshot.png → /tmp/a.png]'),
).toEqual({
cleanText: 'hello',
imagePaths: ['/tmp/a.png'],
});
expect(extractImageTagPaths('[Image: /tmp/b.png] second')).toEqual({ expect(extractImageTagPaths('[Image: /tmp/b.png] second')).toEqual({
cleanText: 'second', cleanText: 'second',
imagePaths: ['/tmp/b.png'], imagePaths: ['/tmp/b.png'],
@@ -45,6 +51,41 @@ describe('shared agent protocol helpers', () => {
}); });
}); });
it('parses public ejclaw attachments', () => {
expect(
normalizeEjclawStructuredOutput(
JSON.stringify({
ejclaw: {
visibility: 'public',
text: '이미지를 생성했습니다.',
verdict: 'done',
attachments: [
{
path: '/tmp/image.png',
name: 'image.png',
mime: 'image/png',
},
],
},
}),
),
).toEqual({
result: '이미지를 생성했습니다.',
output: {
visibility: 'public',
text: '이미지를 생성했습니다.',
verdict: 'done',
attachments: [
{
path: '/tmp/image.png',
name: 'image.png',
mime: 'image/png',
},
],
},
});
});
it('falls back to visible raw text on invalid public verdicts', () => { it('falls back to visible raw text on invalid public verdicts', () => {
const raw = JSON.stringify({ const raw = JSON.stringify({
ejclaw: { ejclaw: {

View File

@@ -34,6 +34,16 @@ export function getAgentOutputText(output: {
return stringifyLegacyAgentResult(output.result); return stringifyLegacyAgentResult(output.result);
} }
export function getAgentOutputAttachments(output: {
output?: StructuredAgentOutput;
}): NonNullable<
Extract<StructuredAgentOutput, { visibility: 'public' }>['attachments']
> {
const structured = getStructuredAgentOutput(output);
if (structured?.visibility !== 'public') return [];
return structured.attachments ?? [];
}
export function hasAgentOutputPayload(output: { export function hasAgentOutputPayload(output: {
output?: StructuredAgentOutput; output?: StructuredAgentOutput;
result?: string | object | null; result?: string | object | null;

View File

@@ -10,6 +10,7 @@ export {
OUTPUT_START_MARKER, OUTPUT_START_MARKER,
writeProtocolOutput, writeProtocolOutput,
type NormalizedRunnerOutput, type NormalizedRunnerOutput,
type RunnerOutputAttachment,
type RunnerOutputPhase, type RunnerOutputPhase,
type RunnerOutputVerdict, type RunnerOutputVerdict,
type RunnerOutputVisibility, type RunnerOutputVisibility,

View File

@@ -1,3 +1,7 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks --- // --- Mocks ---
@@ -127,6 +131,18 @@ import { logger } from '../logger.js';
// --- Test helpers --- // --- Test helpers ---
const ONE_PIXEL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64',
);
function createTempPng(name = 'image.png'): { dir: string; filePath: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-discord-image-'));
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
return { dir, filePath };
}
function createTestOpts( function createTestOpts(
overrides?: Partial<DiscordChannelOpts>, overrides?: Partial<DiscordChannelOpts>,
): DiscordChannelOpts { ): DiscordChannelOpts {
@@ -839,6 +855,59 @@ describe('DiscordChannel', () => {
}); });
}); });
it('sends structured attachments as Discord files', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const { dir, filePath } = createTempPng('structured.png');
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
sendTyping: vi.fn(),
};
currentClient().channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
'이미지를 생성했습니다.',
{
attachments: [
{ path: filePath, name: 'result.png', mime: 'image/png' },
],
},
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: '이미지를 생성했습니다.',
files: [{ attachment: filePath, name: 'result.png' }],
flags: 1 << 2,
});
fs.rmSync(dir, { recursive: true, force: true });
});
it('uses legacy image tags as Discord attachment fallback', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const { dir, filePath } = createTempPng('screenshot.png');
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
sendTyping: vi.fn(),
};
currentClient().channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
`스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`,
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: '스크린샷입니다.',
files: [{ attachment: filePath, name: 'screenshot.png' }],
flags: 1 << 2,
});
fs.rmSync(dir, { recursive: true, force: true });
});
it('logs channel name and Discord message ids after sending', async () => { it('logs channel name and Discord message ids after sending', async () => {
const opts = createTestOpts(); const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts); const channel = new DiscordChannel('test-token', opts);

View File

@@ -19,8 +19,11 @@ import {
} from '../config.js'; } from '../config.js';
import { getEnv } from '../env.js'; import { getEnv } from '../env.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { extractImageTagPaths } from '../agent-protocol.js';
import { validateOutboundAttachments } from '../outbound-attachments.js';
import { formatOutbound } from '../router.js'; import { formatOutbound } from '../router.js';
import { hasReviewerLease } from '../service-routing.js'; import { hasReviewerLease } from '../service-routing.js';
import type { OutboundAttachment, SendMessageOptions } from '../types.js';
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions'); const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
@@ -30,6 +33,45 @@ const DISCORD_ARBITER_CHANNEL = 'discord-arbiter';
const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN'; const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN';
const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN'; const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN';
const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_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. * Download a Discord attachment to local disk.
@@ -403,7 +445,11 @@ export class DiscordChannel implements Channel {
}); });
} }
async sendMessage(jid: string, text: string): Promise<void> { async sendMessage(
jid: string,
text: string,
options: SendMessageOptions = {},
): Promise<void> {
if (!this.client) { if (!this.client) {
logger.warn('Discord client not initialized'); logger.warn('Discord client not initialized');
return; return;
@@ -420,37 +466,43 @@ export class DiscordChannel implements Channel {
const textChannel = channel as TextChannel; const textChannel = channel as TextChannel;
// Extract image attachments from markdown links with image extensions const structuredAttachments = options.attachments ?? [];
// e.g. [name.png](/absolute/path/name.png) const hasStructuredAttachments = structuredAttachments.length > 0;
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|bmp)$/i; const markdownExtracted = extractMarkdownImageAttachments(text);
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g; const imageTagExtracted = extractImageTagPaths(
const imageFiles: string[] = []; markdownExtracted.cleanText,
const seen = new Set<string>(); );
let match; 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, {
baseDirs: options.attachmentBaseDirs,
});
const files = validation.files;
while ((match = MD_LINK_RE.exec(text)) !== null) { if (validation.rejected.length > 0) {
const imgPath = match[1].trim(); logger.warn(
if ( {
!seen.has(imgPath) && jid,
IMAGE_EXTS.test(imgPath) && channelName: this.name,
fs.existsSync(imgPath) attachmentSource,
) { rejected: validation.rejected,
imageFiles.push(imgPath); },
seen.add(imgPath); 'Rejected outbound Discord attachments',
);
} }
}
let cleaned = text let cleaned = imageTagExtracted.cleanText
.replace(MD_LINK_RE, (full, p, _offset, _str, groups) => {
const trimmed = p.trim();
// Image links: remove entirely (attached as files)
if (IMAGE_EXTS.test(trimmed) && seen.has(trimmed)) return '';
// Non-image local path links: convert to readable filename
const basename = path.basename(trimmed.replace(/#.*$/, ''));
const lineMatch = trimmed.match(/#L(\d+)/);
return lineMatch
? `\`${basename}:${lineMatch[1]}\``
: `\`${basename}\``;
})
.replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines .replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines
.replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines .replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines
.trim(); .trim();
@@ -467,10 +519,6 @@ export class DiscordChannel implements Channel {
// Discord has a 2000 character limit per message and 10 attachments per message // Discord has a 2000 character limit per message and 10 attachments per message
const MAX_LENGTH = 2000; const MAX_LENGTH = 2000;
const MAX_ATTACHMENTS = 10; const MAX_ATTACHMENTS = 10;
const files = imageFiles.map((f) => ({
attachment: f,
name: path.basename(f),
}));
const sentMessageIds: string[] = []; const sentMessageIds: string[] = [];
let chunkCount = 0; let chunkCount = 0;
@@ -544,6 +592,7 @@ export class DiscordChannel implements Channel {
deliveryMode: 'send', deliveryMode: 'send',
chunkCount, chunkCount,
attachmentCount: files.length, attachmentCount: files.length,
attachmentSource,
messageId: sentMessageIds[0] ?? null, messageId: sentMessageIds[0] ?? null,
messageIds: sentMessageIds, messageIds: sentMessageIds,
botUserId: this.client.user?.id ?? null, botUserId: this.client.user?.id ?? null,

View File

@@ -9106,6 +9106,38 @@ describe('work items', () => {
).toBeUndefined(); ).toBeUndefined();
}); });
it('stores produced work item attachments for delivery retries', () => {
const item = createProducedWorkItem({
group_folder: 'discord_test',
chat_jid: 'dc:attachments',
agent_type: 'claude-code',
delivery_role: 'owner',
start_seq: 1,
end_seq: 2,
result_payload: 'image ready',
attachments: [
{
path: '/tmp/image.png',
name: 'image.png',
mime: 'image/png',
},
],
});
const stored = getOpenWorkItem(
'dc:attachments',
'claude-code',
item.service_id,
);
expect(stored?.attachments).toEqual([
{
path: '/tmp/image.png',
name: 'image.png',
mime: 'image/png',
},
]);
});
it('finds pending delivery retries even when they were created by a fallback agent type', () => { it('finds pending delivery retries even when they were created by a fallback agent type', () => {
const fallbackItem = createProducedWorkItem({ const fallbackItem = createProducedWorkItem({
group_folder: 'discord_test', group_folder: 'discord_test',

View File

@@ -39,6 +39,7 @@ export function applyBaseSchema(database: Database): void {
start_seq INTEGER, start_seq INTEGER,
end_seq INTEGER, end_seq INTEGER,
result_payload TEXT NOT NULL, result_payload TEXT NOT NULL,
attachment_payload TEXT,
delivery_attempts INTEGER NOT NULL DEFAULT 0, delivery_attempts INTEGER NOT NULL DEFAULT 0,
delivery_message_id TEXT, delivery_message_id TEXT,
last_error TEXT, last_error TEXT,

View File

@@ -38,6 +38,7 @@ function getExpectedSchemaMigrations(): Array<{
{ version: 11, name: 'owner_failure_count' }, { version: 11, name: 'owner_failure_count' },
{ version: 12, name: 'paired_verdict_and_step_telemetry' }, { version: 12, name: 'paired_verdict_and_step_telemetry' },
{ version: 13, name: 'message_source_kind' }, { version: 13, name: 'message_source_kind' },
{ version: 14, name: 'work_item_attachments' },
]; ];
} }

View File

@@ -0,0 +1,17 @@
import type { Database } from 'bun:sqlite';
import { tableHasColumn } from './helpers.js';
import type { SchemaMigrationDefinition } from './types.js';
export const WORK_ITEM_ATTACHMENTS_MIGRATION: SchemaMigrationDefinition = {
version: 14,
name: 'work_item_attachments',
apply(database: Database) {
if (!tableHasColumn(database, 'work_items', 'attachment_payload')) {
database.exec(`
ALTER TABLE work_items
ADD COLUMN attachment_payload TEXT
`);
}
},
};

View File

@@ -13,6 +13,7 @@ import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-prov
import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js'; import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js';
import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js'; import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js';
import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js'; import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js';
import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js';
import type { import type {
SchemaMigrationArgs, SchemaMigrationArgs,
SchemaMigrationDefinition, SchemaMigrationDefinition,
@@ -34,6 +35,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
OWNER_FAILURE_COUNT_MIGRATION, OWNER_FAILURE_COUNT_MIGRATION,
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION, PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
MESSAGE_SOURCE_KIND_MIGRATION, MESSAGE_SOURCE_KIND_MIGRATION,
WORK_ITEM_ATTACHMENTS_MIGRATION,
]; ];
function ensureSchemaMigrationsTable(database: Database): void { function ensureSchemaMigrationsTable(database: Database): void {

View File

@@ -6,7 +6,7 @@ import {
inferRoleFromServiceShadow, inferRoleFromServiceShadow,
resolveRoleServiceShadow, resolveRoleServiceShadow,
} from '../role-service-shadow.js'; } from '../role-service-shadow.js';
import { AgentType, PairedRoomRole } from '../types.js'; import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js';
export interface WorkItem { export interface WorkItem {
id: number; id: number;
@@ -19,6 +19,7 @@ export interface WorkItem {
start_seq: number | null; start_seq: number | null;
end_seq: number | null; end_seq: number | null;
result_payload: string; result_payload: string;
attachments?: OutboundAttachment[];
delivery_attempts: number; delivery_attempts: number;
delivery_message_id: string | null; delivery_message_id: string | null;
last_error: string | null; last_error: string | null;
@@ -29,10 +30,11 @@ export interface WorkItem {
interface StoredWorkItemRow extends Omit< interface StoredWorkItemRow extends Omit<
WorkItem, WorkItem,
'agent_type' | 'service_id' 'agent_type' | 'service_id' | 'attachments'
> { > {
agent_type: string; agent_type: string;
service_id?: string | null; service_id?: string | null;
attachment_payload?: string | null;
} }
export interface CreateProducedWorkItemInput { export interface CreateProducedWorkItemInput {
@@ -44,6 +46,7 @@ export interface CreateProducedWorkItemInput {
start_seq: number | null; start_seq: number | null;
end_seq: number | null; end_seq: number | null;
result_payload: string; result_payload: string;
attachments?: OutboundAttachment[];
} }
function normalizeStoredAgentType( function normalizeStoredAgentType(
@@ -96,9 +99,48 @@ function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem {
...row, ...row,
agent_type: agentType, agent_type: agentType,
service_id: readStoredWorkItemServiceId(row), service_id: readStoredWorkItemServiceId(row),
attachments: parseAttachmentPayload(row.attachment_payload),
}; };
} }
function parseAttachmentPayload(
payload: string | null | undefined,
): OutboundAttachment[] {
if (!payload) return [];
try {
const parsed = JSON.parse(payload) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(item): item is OutboundAttachment =>
item !== null &&
typeof item === 'object' &&
!Array.isArray(item) &&
typeof (item as { path?: unknown }).path === 'string',
)
.map((item) => ({
path: item.path,
...(typeof item.name === 'string' ? { name: item.name } : {}),
...(typeof item.mime === 'string' ? { mime: item.mime } : {}),
}));
} catch {
return [];
}
}
function serializeAttachmentPayload(
attachments: OutboundAttachment[] | undefined,
): string | null {
if (!attachments?.length) return null;
return JSON.stringify(
attachments.map((attachment) => ({
path: attachment.path,
...(attachment.name ? { name: attachment.name } : {}),
...(attachment.mime ? { mime: attachment.mime } : {}),
})),
);
}
function resolvePreferredWorkItemRole( function resolvePreferredWorkItemRole(
serviceId: string | null | undefined, serviceId: string | null | undefined,
): PairedRoomRole | null { ): PairedRoomRole | null {
@@ -220,10 +262,11 @@ export function createProducedWorkItemInDatabase(
start_seq, start_seq,
end_seq, end_seq,
result_payload, result_payload,
attachment_payload,
delivery_attempts, delivery_attempts,
created_at, created_at,
updated_at updated_at
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, 0, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`,
) )
.run( .run(
input.group_folder, input.group_folder,
@@ -234,6 +277,7 @@ export function createProducedWorkItemInDatabase(
input.start_seq, input.start_seq,
input.end_seq, input.end_seq,
input.result_payload, input.result_payload,
serializeAttachmentPayload(input.attachments),
now, now,
now, now,
); );

View File

@@ -28,11 +28,14 @@ export async function deliverOpenWorkItem(args: {
channel: Channel; channel: Channel;
item: WorkItem; item: WorkItem;
log: RuntimeDeliveryLog; log: RuntimeDeliveryLog;
attachmentBaseDirs?: string[];
replaceMessageId?: string | null; replaceMessageId?: string | null;
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean; isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
openContinuation: (chatJid: string) => void; openContinuation: (chatJid: string) => void;
}): Promise<boolean> { }): Promise<boolean> {
const replaceMessageId = args.replaceMessageId ?? null; const replaceMessageId = args.replaceMessageId ?? null;
const attachments = args.item.attachments ?? [];
const hasAttachments = attachments.length > 0;
const isDuplicate = args.isDuplicateOfLastBotFinal( const isDuplicate = args.isDuplicateOfLastBotFinal(
args.item.chat_jid, args.item.chat_jid,
@@ -52,7 +55,7 @@ export async function deliverOpenWorkItem(args: {
} }
try { try {
if (replaceMessageId && args.channel.editMessage) { if (replaceMessageId && args.channel.editMessage && !hasAttachments) {
args.log.info( args.log.info(
buildDeliveryLogContext(args.channel, args.item, { buildDeliveryLogContext(args.channel, args.item, {
deliveryAttempts: args.item.delivery_attempts + 1, deliveryAttempts: args.item.delivery_attempts + 1,
@@ -93,19 +96,32 @@ export async function deliverOpenWorkItem(args: {
try { try {
args.log.info( args.log.info(
buildDeliveryLogContext(args.channel, args.item, { buildDeliveryLogContext(args.channel, args.item, {
attachmentCount: attachments.length,
deliveryAttempts: args.item.delivery_attempts + 1, deliveryAttempts: args.item.delivery_attempts + 1,
deliveryMode: 'send', deliveryMode: 'send',
}), }),
'Attempting to deliver produced work item as a new message', 'Attempting to deliver produced work item as a new message',
); );
if (hasAttachments) {
await args.channel.sendMessage(
args.item.chat_jid,
args.item.result_payload,
{
attachmentBaseDirs: args.attachmentBaseDirs,
attachments,
},
);
} else {
await args.channel.sendMessage( await args.channel.sendMessage(
args.item.chat_jid, args.item.chat_jid,
args.item.result_payload, args.item.result_payload,
); );
}
markWorkItemDelivered(args.item.id); markWorkItemDelivered(args.item.id);
args.openContinuation(args.item.chat_jid); args.openContinuation(args.item.chat_jid);
args.log.info( args.log.info(
buildDeliveryLogContext(args.channel, args.item, { buildDeliveryLogContext(args.channel, args.item, {
attachmentCount: attachments.length,
deliveryAttempts: args.item.delivery_attempts + 1, deliveryAttempts: args.item.delivery_attempts + 1,
deliveryMode: 'send', deliveryMode: 'send',
}), }),
@@ -117,6 +133,7 @@ export async function deliverOpenWorkItem(args: {
markWorkItemDeliveryRetry(args.item.id, errorMessage); markWorkItemDeliveryRetry(args.item.id, errorMessage);
args.log.warn( args.log.warn(
buildDeliveryLogContext(args.channel, args.item, { buildDeliveryLogContext(args.channel, args.item, {
attachmentCount: attachments.length,
deliveryAttempts: args.item.delivery_attempts + 1, deliveryAttempts: args.item.delivery_attempts + 1,
deliveryMode: 'send', deliveryMode: 'send',
err, err,
@@ -135,6 +152,7 @@ export async function processOpenWorkItemDelivery(args: {
channel: Channel; channel: Channel;
roleToChannel: Record<'owner' | 'reviewer' | 'arbiter', Channel | null>; roleToChannel: Record<'owner' | 'reviewer' | 'arbiter', Channel | null>;
log: RuntimeDeliveryLog; log: RuntimeDeliveryLog;
attachmentBaseDirs?: string[];
isPairedRoom: boolean; isPairedRoom: boolean;
getMissingRoleChannelMessage: (role: 'reviewer' | 'arbiter') => string; getMissingRoleChannelMessage: (role: 'reviewer' | 'arbiter') => string;
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean; isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
@@ -186,6 +204,7 @@ export async function processOpenWorkItemDelivery(args: {
channel: deliveryChannel, channel: deliveryChannel,
item: openWorkItem, item: openWorkItem,
log: args.log, log: args.log,
attachmentBaseDirs: args.attachmentBaseDirs,
isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal, isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal,
openContinuation: args.openContinuation, openContinuation: args.openContinuation,
}); });

View File

@@ -11,9 +11,10 @@ import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js';
import { normalizeMessageForDedupe } from './router.js'; import { normalizeMessageForDedupe } from './router.js';
import type { ExecuteTurnFn } from './message-runtime-types.js'; import type { ExecuteTurnFn } from './message-runtime-types.js';
import type { import type {
AgentType,
Channel, Channel,
NewMessage, NewMessage,
AgentType, OutboundAttachment,
PairedRoomRole, PairedRoomRole,
RegisteredGroup, RegisteredGroup,
} from './types.js'; } from './types.js';
@@ -100,6 +101,7 @@ interface CreateExecuteTurnDeps {
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void; clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
deliverFinalText: (args: { deliverFinalText: (args: {
text: string; text: string;
attachments?: OutboundAttachment[];
chatJid: string; chatJid: string;
runId: string; runId: string;
channel: Channel; channel: Channel;
@@ -202,6 +204,9 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
try { try {
return await deps.deliverFinalText({ return await deps.deliverFinalText({
text, text,
...(options?.attachments?.length
? { attachments: options.attachments }
: {}),
chatJid, chatJid,
runId, runId,
channel, channel,

View File

@@ -210,6 +210,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
clearSession: deps.clearSession, clearSession: deps.clearSession,
deliverFinalText: async ({ deliverFinalText: async ({
text, text,
attachments,
chatJid, chatJid,
runId, runId,
channel, channel,
@@ -248,11 +249,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
start_seq: startSeq, start_seq: startSeq,
end_seq: endSeq, end_seq: endSeq,
result_payload: text, result_payload: text,
attachments,
}); });
return deliverOpenWorkItem({ return deliverOpenWorkItem({
channel, channel,
item: workItem, item: workItem,
log: logger, log: logger,
attachmentBaseDirs: group.workDir ? [group.workDir] : undefined,
replaceMessageId, replaceMessageId,
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal, isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
openContinuation: (targetChatJid) => openContinuation: (targetChatJid) =>
@@ -455,6 +458,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
channel, channel,
roleToChannel, roleToChannel,
log, log,
attachmentBaseDirs: group.workDir ? [group.workDir] : undefined,
isPairedRoom: hasReviewerLease(chatJid), isPairedRoom: hasReviewerLease(chatJid),
getMissingRoleChannelMessage, getMissingRoleChannelMessage,
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal, isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,

View File

@@ -317,6 +317,62 @@ describe('MessageTurnController outbound audit logging', () => {
); );
}); });
it('passes structured final attachments to final delivery', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
const attachments = [
{
path: '/tmp/e2e-screenshot.png',
name: 'e2e-screenshot.png',
mime: 'image/png',
},
];
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-review-attachments',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
deliveryRole: 'reviewer',
deliveryServiceId: 'codex-review',
pairedTurnIdentity: makeTurnIdentity(),
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'final',
result: '스크린샷을 첨부했습니다.',
output: {
visibility: 'public',
text: '스크린샷을 첨부했습니다.',
attachments,
},
} as any);
await controller.finish('success');
expect(deliverFinalText).toHaveBeenCalledWith(
'스크린샷을 첨부했습니다.',
{
replaceMessageId: null,
attachments,
},
);
expect(getAuditEntries()).toEqual(
expect.arrayContaining([
expect.objectContaining({
auditEvent: 'final-delivery-attempt',
attachmentCount: 1,
}),
]),
);
});
it('replaces the tracked progress message when an owner final arrives', async () => { it('replaces the tracked progress message when an owner final arrives', async () => {
const channel = { const channel = {
...makeChannel(), ...makeChannel(),

View File

@@ -1,5 +1,8 @@
import { type AgentOutput } from './agent-runner.js'; import { type AgentOutput } from './agent-runner.js';
import { getAgentOutputText } from './agent-output.js'; import {
getAgentOutputAttachments,
getAgentOutputText,
} from './agent-output.js';
import { createScopedLogger, logger } from './logger.js'; import { createScopedLogger, logger } from './logger.js';
import { formatOutbound } from './router.js'; import { formatOutbound } from './router.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
@@ -11,6 +14,7 @@ import {
toVisiblePhase, toVisiblePhase,
type AgentOutputPhase, type AgentOutputPhase,
type Channel, type Channel,
type OutboundAttachment,
type PairedRoomRole, type PairedRoomRole,
type RegisteredGroup, type RegisteredGroup,
type VisiblePhase, type VisiblePhase,
@@ -35,7 +39,10 @@ interface MessageTurnControllerOptions {
requestClose: (reason: string) => void; requestClose: (reason: string) => void;
deliverFinalText: ( deliverFinalText: (
text: string, text: string,
options?: { replaceMessageId?: string | null }, options?: {
attachments?: OutboundAttachment[];
replaceMessageId?: string | null;
},
) => Promise<boolean>; ) => Promise<boolean>;
canDeliverFinalText?: () => boolean; canDeliverFinalText?: () => boolean;
allowProgressReplayWithoutFinal?: boolean; allowProgressReplayWithoutFinal?: boolean;
@@ -160,6 +167,7 @@ export class MessageTurnController {
const raw = getAgentOutputText(result); const raw = getAgentOutputText(result);
const text = raw ? formatOutbound(raw) : null; const text = raw ? formatOutbound(raw) : null;
const attachments = getAgentOutputAttachments(result);
if (raw) { if (raw) {
this.log.info( this.log.info(
@@ -297,6 +305,7 @@ export class MessageTurnController {
// then discard the pending buffer so it never shows up. // then discard the pending buffer so it never shows up.
if (text) { if (text) {
await this.publishTerminalText(text, { await this.publishTerminalText(text, {
attachments,
flushPendingText: text, flushPendingText: text,
}); });
} else if (raw) { } else if (raw) {
@@ -648,14 +657,22 @@ export class MessageTurnController {
private async publishTerminalText( private async publishTerminalText(
text: string, text: string,
options?: { flushPendingText?: string | null }, options?: {
attachments?: OutboundAttachment[];
flushPendingText?: string | null;
},
): Promise<void> { ): Promise<void> {
if (options?.flushPendingText) { if (options?.flushPendingText) {
await this.flushPendingProgress(options.flushPendingText); await this.flushPendingProgress(options.flushPendingText);
} }
const replaceMessageId = this.consumeProgressForFinalDelivery(); const replaceMessageId = this.consumeProgressForFinalDelivery();
await this.deliverFinalText(text, { replaceMessageId }); await this.deliverFinalText(text, {
...(options?.attachments?.length
? { attachments: options.attachments }
: {}),
replaceMessageId,
});
} }
private consumeProgressForFinalDelivery(): string | null { private consumeProgressForFinalDelivery(): string | null {
@@ -675,7 +692,10 @@ export class MessageTurnController {
private async deliverFinalText( private async deliverFinalText(
text: string, text: string,
options?: { replaceMessageId?: string | null }, options?: {
attachments?: OutboundAttachment[];
replaceMessageId?: string | null;
},
): Promise<void> { ): Promise<void> {
await this.activateTyping('turn:deliver-final'); await this.activateTyping('turn:deliver-final');
this.visiblePhase = toVisiblePhase('final'); this.visiblePhase = toVisiblePhase('final');
@@ -697,14 +717,19 @@ export class MessageTurnController {
return; return;
} }
this.logOutboundAudit('final-delivery-attempt', { this.logOutboundAudit('final-delivery-attempt', {
attachmentCount: options?.attachments?.length ?? 0,
messageId: replaceMessageId, messageId: replaceMessageId,
textLength: text.length, textLength: text.length,
deliveryMode: replaceMessageId ? 'edit' : 'send', deliveryMode: replaceMessageId ? 'edit' : 'send',
}); });
const delivered = await this.options.deliverFinalText(text, { const delivered = await this.options.deliverFinalText(text, {
...(options?.attachments?.length
? { attachments: options.attachments }
: {}),
replaceMessageId, replaceMessageId,
}); });
this.logOutboundAudit('final-delivery-result', { this.logOutboundAudit('final-delivery-result', {
attachmentCount: options?.attachments?.length ?? 0,
messageId: replaceMessageId, messageId: replaceMessageId,
textLength: text.length, textLength: text.length,
deliveryMode: replaceMessageId ? 'edit' : 'send', deliveryMode: replaceMessageId ? 'edit' : 'send',

View File

@@ -0,0 +1,149 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { validateOutboundAttachments } from './outbound-attachments.js';
const ONE_PIXEL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64',
);
const cleanupDirs: string[] = [];
function makeTempDir(baseDir: string, prefix: string): string {
const dir = fs.mkdtempSync(path.join(baseDir, prefix));
cleanupDirs.push(dir);
return dir;
}
function writeFile(
dir: string,
name: string,
content: Buffer | string,
): string {
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, content);
return filePath;
}
afterEach(() => {
for (const dir of cleanupDirs.splice(0)) {
fs.rmSync(dir, { force: true, recursive: true });
}
});
describe('validateOutboundAttachments', () => {
it('accepts real image files under default attachment directories', () => {
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
const imagePath = writeFile(dir, 'screenshot.png', ONE_PIXEL_PNG);
const result = validateOutboundAttachments([
{
path: imagePath,
name: '../unsafe-name.png',
mime: 'image/png',
},
]);
expect(result.rejected).toEqual([]);
expect(result.files).toEqual([
{
attachment: fs.realpathSync(imagePath),
name: 'unsafe-name.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);
expect(validateOutboundAttachments([{ path: imagePath }])).toEqual({
files: [],
rejected: [{ path: imagePath, reason: 'outside-allowed-dirs' }],
});
const allowed = validateOutboundAttachments([{ path: imagePath }], {
baseDirs: [dir],
});
expect(allowed.rejected).toEqual([]);
expect(allowed.files).toEqual([
{
attachment: fs.realpathSync(imagePath),
name: 'workspace-shot.png',
},
]);
});
it('rejects symlink attempts that escape the allowed directory', () => {
const workspaceDir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
const targetPath = writeFile(
workspaceDir,
'secret-shot.png',
ONE_PIXEL_PNG,
);
const tmpDir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
const linkPath = path.join(tmpDir, 'linked-shot.png');
fs.symlinkSync(targetPath, linkPath);
const result = validateOutboundAttachments([{ path: linkPath }]);
expect(result.files).toEqual([]);
expect(result.rejected).toEqual([
{ path: linkPath, reason: 'outside-allowed-dirs' },
]);
});
it('rejects SVG attachments before inspecting file content', () => {
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
const svgPath = writeFile(dir, 'vector.svg', '<svg></svg>');
const result = validateOutboundAttachments([{ path: svgPath }]);
expect(result.files).toEqual([]);
expect(result.rejected).toEqual([
{ path: svgPath, reason: 'unsupported-extension' },
]);
});
it('rejects files whose extension and image signature do not match policy', () => {
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
const fakePng = writeFile(dir, 'fake.png', 'not an image');
const realPng = writeFile(dir, 'real.png', ONE_PIXEL_PNG);
expect(validateOutboundAttachments([{ path: fakePng }])).toEqual({
files: [],
rejected: [{ path: fakePng, reason: 'invalid-image-signature' }],
});
expect(
validateOutboundAttachments([{ path: realPng, mime: 'image/jpeg' }]),
).toEqual({
files: [],
rejected: [{ path: realPng, reason: 'mime-mismatch' }],
});
});
it('rejects non-files and files over the size cap', () => {
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
const nestedDir = path.join(dir, 'nested.png');
fs.mkdirSync(nestedDir);
const largePng = writeFile(
dir,
'large.png',
Buffer.concat([ONE_PIXEL_PNG, Buffer.alloc(8 * 1024 * 1024)]),
);
expect(validateOutboundAttachments([{ path: nestedDir }])).toEqual({
files: [],
rejected: [{ path: nestedDir, reason: 'not-file' }],
});
expect(validateOutboundAttachments([{ path: largePng }])).toEqual({
files: [],
rejected: [{ path: largePng, reason: 'too-large' }],
});
});
});

174
src/outbound-attachments.ts Normal file
View File

@@ -0,0 +1,174 @@
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
import type { OutboundAttachment } from './types.js';
export interface ValidatedOutboundAttachment {
attachment: string;
name: string;
}
export interface ValidateOutboundAttachmentsResult {
files: ValidatedOutboundAttachment[];
rejected: Array<{ path: string; reason: string }>;
}
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
function unique(values: Array<string | null | undefined>): string[] {
return [
...new Set(values.filter((value): value is string => Boolean(value))),
];
}
function resolveExistingDir(dir: string): string | null {
try {
if (!fs.existsSync(dir)) return null;
return fs.realpathSync(dir);
} catch {
return null;
}
}
export function getDefaultAttachmentBaseDirs(): string[] {
const home = process.env.HOME;
const codexHome =
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.
return unique([
'/tmp',
path.join(DATA_DIR, 'attachments'),
codexHome ? path.join(codexHome, 'generated_images') : null,
])
.map(resolveExistingDir)
.filter((dir): dir is string => Boolean(dir));
}
function isWithinBaseDir(realPath: string, baseDir: string): boolean {
const relative = path.relative(baseDir, realPath);
return (
relative === '' ||
(!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
);
}
function matchesAllowedBaseDir(realPath: string, baseDirs: string[]): boolean {
return baseDirs.some((baseDir) => isWithinBaseDir(realPath, baseDir));
}
function detectImageMime(filePath: string): string | null {
const handle = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(512);
const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0);
const header = buffer.subarray(0, bytesRead);
if (
header
.subarray(0, 8)
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
) {
return 'image/png';
}
if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) {
return 'image/jpeg';
}
if (
header.subarray(0, 6).toString('ascii') === 'GIF87a' ||
header.subarray(0, 6).toString('ascii') === 'GIF89a'
) {
return 'image/gif';
}
if (
header.subarray(0, 4).toString('ascii') === 'RIFF' &&
header.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'image/webp';
}
if (header[0] === 0x42 && header[1] === 0x4d) {
return 'image/bmp';
}
return null;
} finally {
fs.closeSync(handle);
}
}
function normalizeAttachmentName(
attachment: OutboundAttachment,
realPath: string,
): string {
const candidate = attachment.name
? path.basename(attachment.name)
: path.basename(realPath);
return candidate || 'attachment';
}
export function validateOutboundAttachments(
attachments: OutboundAttachment[] | undefined,
options: { baseDirs?: string[] } = {},
): ValidateOutboundAttachmentsResult {
const baseDirs = unique([
...getDefaultAttachmentBaseDirs(),
...(options.baseDirs ?? []).map(resolveExistingDir),
]).filter((dir): dir is string => Boolean(dir));
const files: ValidatedOutboundAttachment[] = [];
const rejected: Array<{ path: string; reason: string }> = [];
const seen = new Set<string>();
for (const attachment of attachments ?? []) {
const requestedPath = attachment.path;
try {
if (!path.isAbsolute(requestedPath)) {
rejected.push({ path: requestedPath, reason: 'not-absolute' });
continue;
}
if (!IMAGE_EXTS.test(requestedPath)) {
rejected.push({ path: requestedPath, reason: 'unsupported-extension' });
continue;
}
if (!fs.existsSync(requestedPath)) {
rejected.push({ path: requestedPath, reason: 'not-found' });
continue;
}
const realPath = fs.realpathSync(requestedPath);
if (seen.has(realPath)) continue;
const stat = fs.statSync(realPath);
if (!stat.isFile()) {
rejected.push({ path: requestedPath, reason: 'not-file' });
continue;
}
if (stat.size > MAX_ATTACHMENT_BYTES) {
rejected.push({ path: requestedPath, reason: 'too-large' });
continue;
}
if (!matchesAllowedBaseDir(realPath, baseDirs)) {
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
continue;
}
const detectedMime = detectImageMime(realPath);
if (!detectedMime) {
rejected.push({
path: requestedPath,
reason: 'invalid-image-signature',
});
continue;
}
if (attachment.mime && attachment.mime !== detectedMime) {
rejected.push({ path: requestedPath, reason: 'mime-mismatch' });
continue;
}
files.push({
attachment: realPath,
name: normalizeAttachmentName(attachment, realPath),
});
seen.add(realPath);
} catch {
rejected.push({ path: requestedPath, reason: 'validation-error' });
}
}
return { files, rejected };
}

View File

@@ -26,6 +26,22 @@ export type VisiblePhase = 'silent' | 'progress' | 'final';
export type AgentVisibility = 'public' | 'silent'; export type AgentVisibility = 'public' | 'silent';
export interface OutboundAttachment {
path: string;
name?: string;
mime?: string;
}
export interface SendMessageOptions {
attachments?: OutboundAttachment[];
/**
* Extra realpath roots that are valid for this delivery attempt. Runtime
* callers can pass the active project/workspace directory without widening
* the global Discord attachment allowlist.
*/
attachmentBaseDirs?: string[];
}
export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter';
export type PairedTaskStatus = export type PairedTaskStatus =
@@ -126,6 +142,7 @@ export type StructuredAgentOutput =
| { | {
visibility: 'public'; visibility: 'public';
text: string; text: string;
attachments?: OutboundAttachment[];
} }
| { | {
visibility: 'silent'; visibility: 'silent';
@@ -234,7 +251,11 @@ export interface ChannelOutboundAuditMeta {
export interface Channel { export interface Channel {
name: string; name: string;
connect(): Promise<void>; connect(): Promise<void>;
sendMessage(jid: string, text: string): Promise<void>; sendMessage(
jid: string,
text: string,
options?: SendMessageOptions,
): Promise<void>;
isConnected(): boolean; isConnected(): boolean;
ownsJid(jid: string): boolean; ownsJid(jid: string): boolean;
// Optional: whether a stored inbound message was authored by this channel's own bot/user. // Optional: whether a stored inbound message was authored by this channel's own bot/user.