fix: support internal AgentBus attachments

This commit is contained in:
inman
2026-08-31 10:36:57 +08:00
parent cd45ce17d0
commit 161f90d09d
12 changed files with 96 additions and 86 deletions

View File

@@ -21,7 +21,7 @@
- Chrome 插件最低兼容版本由正式操作台与根目录发布清单共同门禁。插件包含分段前门禁、ERP 只读唯一解析、严格写前门禁、当前窄生命周期适配、写入前 `write_started` 持久化和写后回查;扩展后台重启后也不会重跑同一任务。本次双轨解析不修改插件执行契约或扩展版本。
- 渠道 Adapter 由 AgentBus 负责;控制平面只作为 AgentBus Bot 连接到文档中的 WebSocket不实现微信、个人微信或其他渠道协议。
- 微信桥接器把正文放在严格的 `New WeChat message` / `Conversation:` / `Text:` 三行传输信封中AgentBus listener 会在任务快照前只解开这一已知信封,把 `Text:` 同行值及后续行作为业务正文,并在帧没有显式 `conversation_id` 时使用信封中的 `Conversation:` 值。显式字段仍优先;近似、缺失字段或空正文的包装保持原文,不能通过忽略任意未知标签来绕过 Program parser 的失败关闭。
- 微信侧的 `[WeChat attachment: 文件名]` 只是一段传输占位文字,不代表控制面已经收到文件。若同一帧没有符合契约的 `payload.attachments[]`listener 会在进入任务服务前失败关闭、保留原名单任务的等待状态并返回“附件内容未传到平台”不会把占位文字创建成新业务任务。附件元数据、HTTPS URL、DNS、大小或摘要校验失败时返回对应的安全摘要仍不回显 URL、文件字节或名单内容。
- 微信侧的 `[WeChat attachment: 文件名]` 只是一段传输占位文字,不代表控制面已经收到文件。若同一帧没有符合契约的 `payload.attachments[]`listener 会在进入任务服务前失败关闭、保留原名单任务的等待状态并返回“附件内容未传到平台”不会把占位文字创建成新业务任务。附件元数据、HTTPS URL、DNS、大小或摘要校验失败时返回对应的安全摘要仍不回显 URL、文件字节或名单内容。当前生产部署位于受信内网,入站附件 URL 可以使用内网域名、私网 IPv4/IPv6 或 localhost因此 AgentBus 渠道和上游桥接器必须被视为受信输入边界。
- AgentBus 入站消息会复用 `TaskService` 的任务/会话/解析队列,解析完成后通过同一 WebSocket 返回一次 `task.result`。组织级“全自动化”关闭时,手工与 AgentBus 新任务都要求管理员确认;开启后,两种来源的合法解析结果都自动进入 ERP 队列,不再按来源或创建、名单、安排、修改、取消/恢复、导出等业务类型保留人工例外。操作台在 EventSource 建连/重连、30 秒后台刷新以及页面重新可见或聚焦时重新读取数据库权威开关,避免后台变更后按钮仍显示旧值。缺资料、解析失败、歧义、插件校验失败或 ERP 回查不确定时仍会停止,不会绕过校验或重试不确定写入。
- 一个组织可以维护多个“用户渠道”。渠道代表外部 AgentBus 用户身份,不等同于平台管理员账号;管理员在独立根路径 `/channels` 的“AgentBus 渠道”目录中创建、停用、启用、轮换或删除渠道。删除会停止对应 listener、移除服务端保存的 key 和该渠道尚存的持久化回执;历史任务本体保留,其 `channel_id` 按数据库契约置空。每个渠道独立保存加密后的 AgentBus key并建立独立 WebSocket listener列表和日志都不会回显 key。`AGENTBUS_WS_URL`、重连策略和客户端类型仍是全局连接配置,`AGENTBUS_BOT_ADDRESS` 可作为渠道 bot address 的默认值。仍由完整旧环境变量托管的兼容渠道会自动重建,必须先移除环境配置并重启服务,才允许删除其数据库记录。
- `/history` 支持逐条彻底删除,以及勾选当前页后批量删除。单条使用 `DELETE /api/tasks/:taskId`,批量使用 `POST /api/tasks/bulk-delete`(一次 1100 个且不能重复);两者都要求管理员 mutation 会话、same-origin 与 CSRF 门禁。批量删除会在同一数据库事务中按组织锁定并核对全部目标,任一任务不存在或不属于当前组织时整批回滚;成功后任务、生命周期、尝试、会话、附件元数据和 AgentBus 回执按外键级联删除task-scoped audit/outbox 行显式删除。OSS 附件对象在事务提交后使用已冻结的 storage key 逐一清理,清理异常写入服务日志。对已确认或正在插件流程中的任务,页面会明确警告:停止插件只是尽力而为,已经发生的 ERP 写入及已投递到外部渠道的副本不会因删除平台历史而撤回。
@@ -97,7 +97,7 @@ Auto 一旦发生 AI fallback任务会永久绑定原 AI 会话。每次解
对微信来源listener 在调用 `TaskService.ingestMessage()` 前执行上述严格信封解包,因此手工正文与 AgentBus 正文进入同一个业务 route resolver、任务级 mode snapshot 和 parser orchestrator`Conversation` 只属于传输路由,不会再污染业务字段签名。
AgentBus 全链路日志使用控制平面 stdout/Pino 输出,同时带 `diagnostic_event=agentbus.<agentbus_event>` 与原有 `agentbus_event`。日志覆盖连接尝试、socket 生命周期、session.ready、每个收发帧、帧忽略原因、任务入队、解析队列、持久化回执出队、最终回复和发送错误。名单附件另外记录元数据存在性、DNS 开始/通过、公网地址数量与 IP family、HTTPS 状态、重定向次数、接收字节数、大小/摘要校验和各阶段耗时;不记录 URL、hostname、IP、文件名、附件字节或名单值。开发/测试环境可临时设置 `AGENTBUS_LOG_PAYLOADS=true` 记录最多 2,000 个字符的正文预览,生产环境会拒绝以该值启动。渠道 key、WebSocket Token、Invoke Token 和 Authorization header 永不写入日志。
AgentBus 全链路日志使用控制平面 stdout/Pino 输出,同时带 `diagnostic_event=agentbus.<agentbus_event>` 与原有 `agentbus_event`。日志覆盖连接尝试、socket 生命周期、session.ready、每个收发帧、帧忽略原因、任务入队、解析队列、持久化回执出队、最终回复和发送错误。名单附件另外记录元数据存在性、DNS 开始/通过、解析地址数量与 IP family、HTTPS 状态、重定向次数、接收字节数、大小/摘要校验和各阶段耗时;不记录 URL、hostname、IP、文件名、附件字节或名单值。开发/测试环境可临时设置 `AGENTBUS_LOG_PAYLOADS=true` 记录最多 2,000 个字符的正文预览,生产环境会拒绝以该值启动。渠道 key、WebSocket Token、Invoke Token 和 Authorization header 永不写入日志。
监听器只使用每个渠道的 WebSocket key文档中的 Invoke Token 仅用于另一服务通过 Function Call API 主动向 Bot 投递任务,本服务的监听链路不会使用它。

View File

@@ -158,41 +158,6 @@ export function decodeInlineInputAttachment(
};
}
function ipv4Number(address: string): number | null {
const parts = address.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
return (((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3]) >>> 0;
}
function ipv4InCidr(address: string, network: string, prefix: number): boolean {
const value = ipv4Number(address);
const base = ipv4Number(network);
if (value === null || base === null) return false;
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return (value & mask) === (base & mask);
}
export function isPrivateOrReservedIp(address: string): boolean {
const family = isIP(address);
if (family === 4) {
return [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
['224.0.0.0', 4], ['240.0.0.0', 4]
].some(([network, prefix]) => ipv4InCidr(address, String(network), Number(prefix)));
}
if (family !== 6) return true;
const normalized = address.toLowerCase().split('%')[0];
if (normalized === '::' || normalized === '::1') return true;
const mapped = /^(?:::ffff:)?(\d+\.\d+\.\d+\.\d+)$/.exec(normalized);
if (mapped) return isPrivateOrReservedIp(mapped[1]);
return /^(?:fc|fd)/.test(normalized)
|| /^fe[89ab]/.test(normalized)
|| /^ff/.test(normalized)
|| normalized.startsWith('2001:db8:');
}
export function validateAgentBusAttachmentUrl(value: unknown): URL {
let url: URL;
try {
@@ -203,12 +168,8 @@ export function validateAgentBusAttachmentUrl(value: unknown): URL {
if (url.protocol !== 'https:' || url.username || url.password) {
throw new InputAttachmentError('roster_attachment_url_unsafe', '名单附件必须使用不含用户名密码的 HTTPS URL。');
}
const hostname = url.hostname.replace(/^\[|\]$/g, '');
if (!hostname || hostname.toLowerCase() === 'localhost') {
throw new InputAttachmentError('roster_attachment_url_unsafe', '名单附件 URL 指向了不允许的地址。');
}
if (isIP(hostname) && isPrivateOrReservedIp(hostname)) {
throw new InputAttachmentError('roster_attachment_url_unsafe', '名单附件 URL 指向了不允许的地址。');
if (!url.hostname.replace(/^\[|\]$/g, '')) {
throw new InputAttachmentError('roster_attachment_url_invalid', '名单附件 URL 无效。');
}
return url;
}
@@ -230,7 +191,9 @@ export function parseAgentBusInputAttachment(
};
}
async function publicAddresses(hostname: string): Promise<Array<{ address: string; family: number }>> {
export async function resolveAgentBusAttachmentAddresses(
hostname: string
): Promise<Array<{ address: string; family: number }>> {
if (isIP(hostname)) return [{ address: hostname, family: isIP(hostname) }];
let addresses: Array<{ address: string; family: number }>;
try {
@@ -238,8 +201,8 @@ async function publicAddresses(hostname: string): Promise<Array<{ address: strin
} catch {
throw new InputAttachmentError('roster_attachment_host_unresolved', '名单附件地址无法解析。');
}
if (!addresses.length || addresses.some((item) => isPrivateOrReservedIp(item.address))) {
throw new InputAttachmentError('roster_attachment_url_unsafe', '名单附件地址解析到了不允许的网络。');
if (!addresses.length) {
throw new InputAttachmentError('roster_attachment_host_unresolved', '名单附件地址无法解析。');
}
return addresses;
}
@@ -319,7 +282,7 @@ export async function downloadAgentBusInputAttachment(
redirectCount = redirects;
const dnsStartedAt = process.hrtime.bigint();
emitAttachmentDiagnostic(diagnostic, 'dns_started', { redirect_count: redirects });
const addresses = await publicAddresses(url.hostname.replace(/^\[|\]$/g, ''));
const addresses = await resolveAgentBusAttachmentAddresses(url.hostname.replace(/^\[|\]$/g, ''));
emitAttachmentDiagnostic(diagnostic, 'dns_validated', {
redirect_count: redirects,
address_count: addresses.length,

View File

@@ -6,8 +6,8 @@ import {
InputAttachmentError,
decodeInlineInputAttachment,
downloadAgentBusInputAttachment,
isPrivateOrReservedIp,
parseAgentBusInputAttachment,
resolveAgentBusAttachmentAddresses,
validateAgentBusAttachmentUrl
} from '../src/input-attachment.js';
@@ -46,26 +46,23 @@ test('manual roster attachment rejects unsupported names, invalid base64 and mis
}, 1_000)), 'roster_file_sha256_mismatch');
});
test('AgentBus attachment URLs require public HTTPS endpoints', () => {
test('AgentBus attachment URLs allow private HTTPS endpoints but reject unsafe URL syntax', () => {
for (const value of [
'http://files.example.test/list.xlsx',
'https://localhost/list.xlsx',
'https://127.0.0.1/list.xlsx',
'https://10.0.0.8/list.xlsx',
'https://[::1]/list.xlsx',
'ftp://files.example.test/list.xlsx',
'https://user:secret@files.example.test/list.xlsx'
]) {
assert.equal(codeOf(() => validateAgentBusAttachmentUrl(value)), 'roster_attachment_url_unsafe');
}
assert.equal(validateAgentBusAttachmentUrl('https://files.example.com/list.xlsx').protocol, 'https:');
});
test('private and documentation address ranges are rejected', () => {
for (const address of ['0.0.0.1', '10.1.2.3', '100.64.0.1', '127.0.0.1', '169.254.1.1', '172.31.0.1', '192.168.1.1', '198.51.100.1', '203.0.113.8', '::1', 'fc00::1', 'fe80::1', '2001:db8::1']) {
assert.equal(isPrivateOrReservedIp(address), true, address);
for (const value of [
'https://files.example.com/list.xlsx',
'https://localhost/list.xlsx',
'https://127.0.0.1/list.xlsx',
'https://10.0.0.8/list.xlsx',
'https://[::1]/list.xlsx'
]) {
assert.equal(validateAgentBusAttachmentUrl(value).protocol, 'https:', value);
}
assert.equal(isPrivateOrReservedIp('8.8.8.8'), false);
assert.equal(isPrivateOrReservedIp('2606:4700:4700::1111'), false);
});
test('AgentBus attachment metadata is normalized without exposing URL credentials', () => {
@@ -81,6 +78,18 @@ test('AgentBus attachment metadata is normalized without exposing URL credential
assert.equal(reference.sha256, 'a'.repeat(64));
});
test('AgentBus attachment DNS accepts internal names and private literal addresses', async () => {
assert.deepEqual(await resolveAgentBusAttachmentAddresses('10.0.0.8'), [
{ address: '10.0.0.8', family: 4 }
]);
assert.deepEqual(await resolveAgentBusAttachmentAddresses('fd00::8'), [
{ address: 'fd00::8', family: 6 }
]);
const localhost = await resolveAgentBusAttachmentAddresses('localhost');
assert.ok(localhost.length > 0);
assert.ok(localhost.every((entry) => entry.family === 4 || entry.family === 6));
});
test('AgentBus attachment diagnostics expose stages and codes without URL data', async () => {
const events: Array<{ event: string; metadata: Record<string, unknown> }> = [];
await assert.rejects(
@@ -88,7 +97,7 @@ test('AgentBus attachment diagnostics expose stages and codes without URL data',
name: 'synthetic.xls',
contentType: 'application/vnd.ms-excel',
size: 128,
url: 'https://127.0.0.1/private-roster.xls?token=secret'
url: 'https://user:secret@127.0.0.1/private-roster.xls?token=secret'
}, 1_000, (event, metadata) => events.push({ event, metadata })),
(error: unknown) => error instanceof InputAttachmentError
&& error.code === 'roster_attachment_url_unsafe'