Merge branch 'main' into fix/register-channel-wiring
This commit is contained in:
46
src/modules/agent-to-agent/agent-route.test.ts
Normal file
46
src/modules/agent-to-agent/agent-route.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isSafeAttachmentName } from './agent-route.js';
|
||||
|
||||
/**
|
||||
* `forwardAttachedFiles` has a filesystem side that's awkward to unit-test
|
||||
* without mocking DATA_DIR. The guarantee worth pinning is that the
|
||||
* filename validator rejects everything that could escape the inbox dir —
|
||||
* `forwardAttachedFiles` runs this guard before any I/O, so traversal is
|
||||
* impossible as long as this matrix holds.
|
||||
*/
|
||||
describe('isSafeAttachmentName', () => {
|
||||
it('accepts plain filenames', () => {
|
||||
expect(isSafeAttachmentName('baby-duck.png')).toBe(true);
|
||||
expect(isSafeAttachmentName('file with spaces.pdf')).toBe(true);
|
||||
expect(isSafeAttachmentName('report.v2.docx')).toBe(true);
|
||||
expect(isSafeAttachmentName('.hidden')).toBe(true); // leading dot is fine, just not `.` / `..`
|
||||
});
|
||||
|
||||
it('rejects empty / sentinel values', () => {
|
||||
expect(isSafeAttachmentName('')).toBe(false);
|
||||
expect(isSafeAttachmentName('.')).toBe(false);
|
||||
expect(isSafeAttachmentName('..')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects path separators', () => {
|
||||
expect(isSafeAttachmentName('../evil.png')).toBe(false);
|
||||
expect(isSafeAttachmentName('/etc/passwd')).toBe(false);
|
||||
expect(isSafeAttachmentName('nested/file.txt')).toBe(false);
|
||||
expect(isSafeAttachmentName('windows\\path.exe')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects NUL bytes', () => {
|
||||
expect(isSafeAttachmentName('clean\0.png')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects anything path.basename would strip', () => {
|
||||
expect(isSafeAttachmentName('a/b')).toBe(false);
|
||||
expect(isSafeAttachmentName('./thing')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-string input', () => {
|
||||
expect(isSafeAttachmentName(null as unknown as string)).toBe(false);
|
||||
expect(isSafeAttachmentName(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,13 @@
|
||||
*
|
||||
* Outbound messages with `channel_type === 'agent'` target another agent
|
||||
* group rather than a channel. Permission is enforced via `agent_destinations` —
|
||||
* the source agent must have a row for the target. Content is copied verbatim;
|
||||
* the target's formatter looks up the source agent in its own local map to
|
||||
* display a name.
|
||||
* the source agent must have a row for the target. Content is copied into the
|
||||
* target's inbound DB; if the source message had `files` (from `send_file`),
|
||||
* the actual bytes are copied from the source's outbox into the target's
|
||||
* `inbox/<a2a-msg-id>/` directory and surfaced to the target agent as
|
||||
* `attachments` (existing formatter convention — see formatter.ts:230).
|
||||
* The target agent can then forward the file onward via its own `send_file`
|
||||
* call using the absolute `/workspace/inbox/<a2a-msg-id>/<filename>` path.
|
||||
*
|
||||
* Self-messages are always allowed (used for system notes injected back into
|
||||
* an agent's own session, e.g. post-approval follow-up prompts).
|
||||
@@ -14,14 +18,102 @@
|
||||
* `channel_type === 'agent'` check. When the module is absent the check in
|
||||
* core throws with a "module not installed" message so retry → mark failed.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { log } from '../../log.js';
|
||||
import { resolveSession, writeSessionMessage } from '../../session-manager.js';
|
||||
import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { hasDestination } from './db/agent-destinations.js';
|
||||
|
||||
export interface ForwardedAttachment {
|
||||
name: string;
|
||||
filename: string;
|
||||
type: 'file';
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `name` safe to use as the last segment of a path inside the target
|
||||
* agent's inbox directory? Filenames arrive in messages_out content from
|
||||
* the source agent — under a multi-agent setup with heterogenous providers
|
||||
* (or a compromised / hallucinating sub-agent) they can't be trusted.
|
||||
*
|
||||
* Rejects:
|
||||
* - empty string
|
||||
* - `.` / `..` (traversal sentinels that path.basename returns as-is)
|
||||
* - anything containing a path separator (`/` or `\`) or NUL
|
||||
* - any value where `path.basename(name) !== name`, catching OS-specific
|
||||
* separators and covering drives/prefixes on Windows runtimes
|
||||
*/
|
||||
export function isSafeAttachmentName(name: string): boolean {
|
||||
if (typeof name !== 'string' || name.length === 0) return false;
|
||||
if (name === '.' || name === '..') return false;
|
||||
if (/[\\/\0]/.test(name)) return false;
|
||||
return path.basename(name) === name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file attachments from the source agent's outbox into the target
|
||||
* agent's inbox. Returns attachments using the formatter's existing
|
||||
* `{name, type, localPath}` convention — target agent reads `localPath`
|
||||
* as relative to `/workspace/`, matching how channel-inbound attachments
|
||||
* are surfaced today.
|
||||
*
|
||||
* Missing source files and unsafe (path-traversal) filenames are skipped
|
||||
* with a warning rather than failing the whole route — a bad filename
|
||||
* reference shouldn't kill the accompanying text.
|
||||
*/
|
||||
export function forwardAttachedFiles(
|
||||
source: { agentGroupId: string; sessionId: string; messageId: string; filenames: string[] },
|
||||
target: { agentGroupId: string; sessionId: string; messageId: string },
|
||||
): ForwardedAttachment[] {
|
||||
if (source.filenames.length === 0) return [];
|
||||
|
||||
const sourceDir = path.join(sessionDir(source.agentGroupId, source.sessionId), 'outbox', source.messageId);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
log.warn('agent-route: source outbox dir missing, no files forwarded', {
|
||||
sourceMsgId: source.messageId,
|
||||
sourceDir,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
|
||||
fs.mkdirSync(targetInboxDir, { recursive: true });
|
||||
|
||||
const attachments: ForwardedAttachment[] = [];
|
||||
for (const filename of source.filenames) {
|
||||
if (!isSafeAttachmentName(filename)) {
|
||||
log.warn('agent-route: rejecting unsafe attachment filename (path traversal attempt?)', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const src = path.join(sourceDir, filename);
|
||||
if (!fs.existsSync(src)) {
|
||||
log.warn('agent-route: referenced file missing in source outbox, skipped', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dst = path.join(targetInboxDir, filename);
|
||||
fs.copyFileSync(src, dst);
|
||||
attachments.push({
|
||||
name: filename,
|
||||
filename,
|
||||
type: 'file',
|
||||
localPath: `inbox/${target.messageId}/${filename}`,
|
||||
});
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
export interface RoutableAgentMessage {
|
||||
id: string;
|
||||
platform_id: string | null;
|
||||
@@ -45,20 +137,87 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess
|
||||
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
||||
}
|
||||
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
|
||||
const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// If the source message references files (via `send_file`), forward the
|
||||
// bytes from the source's outbox into the target's inbox so the target
|
||||
// agent can actually see and re-send them. Without this, agent-to-agent
|
||||
// file attachments look like they arrive but the target has no way to
|
||||
// read the bytes — they live in a session dir it doesn't mount.
|
||||
const forwardedContent = forwardFileAttachments(msg, a2aMsgId, session, targetAgentGroupId, targetSession.id);
|
||||
|
||||
writeSessionMessage(targetAgentGroupId, targetSession.id, {
|
||||
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: a2aMsgId,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: msg.content,
|
||||
content: forwardedContent,
|
||||
});
|
||||
log.info('Agent message routed', {
|
||||
from: session.agent_group_id,
|
||||
to: targetAgentGroupId,
|
||||
targetSession: targetSession.id,
|
||||
a2aMsgId,
|
||||
forwardedFileCount: countForwardedFiles(forwardedContent),
|
||||
});
|
||||
const fresh = getSession(targetSession.id);
|
||||
if (fresh) await wakeContainer(fresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse source content, copy any referenced `files` from source outbox to
|
||||
* target inbox, and return a JSON string with an `attachments` array added
|
||||
* (formatter.ts:223 already knows how to render this shape).
|
||||
*
|
||||
* If the source content isn't JSON or has no files, returns the original
|
||||
* content string unchanged — this is safe to call on every route.
|
||||
*/
|
||||
function forwardFileAttachments(
|
||||
msg: RoutableAgentMessage,
|
||||
a2aMsgId: string,
|
||||
sourceSession: Session,
|
||||
targetAgentGroupId: string,
|
||||
targetSessionId: string,
|
||||
): string {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(msg.content);
|
||||
} catch {
|
||||
return msg.content;
|
||||
}
|
||||
const files = parsed.files as unknown;
|
||||
if (!Array.isArray(files) || files.length === 0) return msg.content;
|
||||
const filenames = files.filter((f): f is string => typeof f === 'string');
|
||||
if (filenames.length === 0) return msg.content;
|
||||
|
||||
const attachments = forwardAttachedFiles(
|
||||
{
|
||||
agentGroupId: sourceSession.agent_group_id,
|
||||
sessionId: sourceSession.id,
|
||||
messageId: msg.id,
|
||||
filenames,
|
||||
},
|
||||
{
|
||||
agentGroupId: targetAgentGroupId,
|
||||
sessionId: targetSessionId,
|
||||
messageId: a2aMsgId,
|
||||
},
|
||||
);
|
||||
|
||||
// Merge into any existing `attachments` (unlikely in a2a context but safe).
|
||||
const existing = Array.isArray(parsed.attachments) ? (parsed.attachments as Record<string, unknown>[]) : [];
|
||||
parsed.attachments = [...existing, ...attachments];
|
||||
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
function countForwardedFiles(contentStr: string): number {
|
||||
try {
|
||||
const parsed = JSON.parse(contentStr);
|
||||
return Array.isArray(parsed.attachments) ? parsed.attachments.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user