Files
EJClaw/src/message-runtime-final-delivery.ts
Codex 9c46cf6761 fix: relocate out-of-allowed outbound attachments so files actually send
Agent-generated files written to an arbitrary working path (e.g. TTS audio
under /home/claude/jarvis-tts) were rejected by validateOutboundAttachments as
"outside-allowed-dirs". The rejection was only logged; the MEDIA: directive had
already been stripped from the text, so the user got a message claiming a file
was attached with no file and no error.

- Stage attachments outside the room's allowed dirs into a safe per-group dir
  (data/attachments/outbound/<group>) at the universal delivery choke point, so
  the path delivery uses and revalidates is one the validator accepts. Files
  already inside an allowed dir are untouched, preserving isolation checks.
- Surface any still-rejected attachment in the visible Discord body via
  appendRejectionNotice, so delivery can never again silently drop a file.

Verified: outbound-attachments 22/22, discord 46/46 (incl. new integration test
asserting the notice lands in the sent body), final-delivery 5/5, tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 00:39:46 +09:00

92 lines
2.9 KiB
TypeScript

import { createProducedWorkItem } from './db.js';
import { resolveRuntimeAttachmentBaseDirs } from './attachment-base-dirs.js';
import { logger } from './logger.js';
import { deliverOpenWorkItem } from './message-runtime-delivery.js';
import {
getOutboundStageDir,
stageOutboundAttachments,
} from './outbound-attachments.js';
import type {
AgentType,
Channel,
OutboundAttachment,
PairedRoomRole,
RegisteredGroup,
} from './types.js';
export async function deliverMessageRuntimeFinalText(args: {
text: string;
attachments?: OutboundAttachment[];
chatJid: string;
runId: string;
channel: Channel;
group: RegisteredGroup;
startSeq: number | null;
endSeq: number | null;
forcedAgentType?: AgentType;
deliveryRole: PairedRoomRole | null;
deliveryServiceId: string | null;
replaceMessageId?: string | null;
hasDirectTerminalDeliveryForRun?: (
chatJid: string,
runId: string,
deliveryRole: PairedRoomRole | null,
) => boolean;
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
openContinuation: (chatJid: string) => void;
}): Promise<boolean> {
if (
(args.deliveryRole === 'reviewer' || args.deliveryRole === 'arbiter') &&
args.hasDirectTerminalDeliveryForRun?.(
args.chatJid,
args.runId,
args.deliveryRole,
)
) {
logger.info(
{
chatJid: args.chatJid,
runId: args.runId,
deliveryRole: args.deliveryRole,
},
'Skipping final work item delivery because this run already sent a direct terminal IPC message',
);
return true;
}
const attachmentBaseDirs = resolveRuntimeAttachmentBaseDirs(args.group);
// Relocate attachments that live outside the room's allowed directories into
// a safe staging dir before persisting the work item, so the path delivery
// actually uses (and revalidates) is one the validator accepts. Without this,
// agent-generated files written to an arbitrary working path are rejected as
// "outside-allowed-dirs" and silently dropped.
const attachments = args.attachments?.length
? stageOutboundAttachments(args.attachments, {
baseDirs: attachmentBaseDirs,
stageDir: getOutboundStageDir(args.group.folder),
})
: args.attachments;
const workItem = createProducedWorkItem({
group_folder: args.group.folder,
chat_jid: args.chatJid,
agent_type: args.forcedAgentType ?? args.group.agentType ?? 'claude-code',
service_id: args.deliveryServiceId ?? undefined,
delivery_role: args.deliveryRole,
start_seq: args.startSeq,
end_seq: args.endSeq,
result_payload: args.text,
attachments,
});
return deliverOpenWorkItem({
channel: args.channel,
item: workItem,
log: logger,
attachmentBaseDirs,
replaceMessageId: args.replaceMessageId,
isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal,
openContinuation: args.openContinuation,
});
}