feat(agent): add authenticated durable uploads to backend and web

This commit is contained in:
2026-10-14 17:25:52 -07:00
parent 4cbab487d0
commit 8b125c13a5
16 changed files with 838 additions and 54 deletions
+141
View File
@@ -0,0 +1,141 @@
import { Readable } from 'node:stream';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { requireAdmin } from './auth.js';
import { env } from './env.js';
export const AGENT_UPLOAD_LIMIT = 8 * 1024 * 1024 + 64 * 1024;
export const AgentAttachmentId = z.string().regex(/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
export const AgentAttachmentReferences = z.array(z.object({ id: AgentAttachmentId }).strict()).max(8)
.refine(items => new Set(items.map(item => item.id)).size === items.length, 'Attachment IDs must be unique');
export const AgentAttachmentDescriptor = z.object({
id: AgentAttachmentId, kind: z.enum(['image', 'text', 'file']), filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(127), sizeBytes: z.number().int().min(0).max(8 * 1024 * 1024),
sha256: z.string().regex(/^[0-9a-f]{64}$/), workspacePath: z.string().startsWith('/').max(4096).optional(),
});
export type AgentProxyJson = (path: string, method?: string, body?: unknown, signal?: AbortSignal) => Promise<{ status: number; data: unknown }>;
function apiError(statusCode: number, code: string, message: string) {
return Object.assign(new Error(message), { statusCode, code });
}
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
const value = descriptor.data as { capabilities?: { attachments?: { version?: number } } } | null;
if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1)
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
}
/** Authentication precedes parsing; bounded streaming keeps concurrent upload memory bounded. */
export async function registerAgentAttachmentRoutes(app: FastifyInstance, options: { baseUrl: string; apiKey?: string; proxyJson: AgentProxyJson }) {
await app.register(async routes => {
let uploads = 0, downloads = 0;
type Transfer = { controller: AbortController; release(): void };
const transfers = new WeakMap<FastifyRequest, Transfer>();
const transfer = (req: FastifyRequest) => { const value = transfers.get(req)!; value.controller.signal.throwIfAborted(); return value; };
routes.addHook('onRequest', async (req, reply) => {
reply.header('Cache-Control', 'private, no-store'); requireAdmin(req);
if (!options.apiKey) throw apiError(503, 'attachments_unavailable', 'Sybil Agent is not configured.');
const uploading = req.method === 'POST';
if (uploading ? uploads >= 2 : downloads >= 4) throw apiError(429, 'attachments_busy', 'Attachment transfers are busy. Retry shortly.');
if (uploading) uploads++; else downloads++;
const controller = new AbortController(); let released = false;
const timeout = setTimeout(() => { release(); reply.raw.destroy(); }, 60_000);
const release = () => {
if (released) return; released = true;
if (uploading) uploads--; else downloads--;
clearTimeout(timeout); controller.abort();
req.raw.off('aborted', release); reply.raw.off('close', release); reply.raw.off('finish', release); reply.raw.off('error', release);
};
transfers.set(req, { controller, release });
req.raw.once('aborted', release); reply.raw.once('close', release); reply.raw.once('finish', release); reply.raw.once('error', release);
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
await requireAgentAttachments(options.proxyJson, controller.signal);
controller.signal.throwIfAborted();
});
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
routes.setErrorHandler((error: Error & { statusCode?: number; code?: string }, _req, reply) => {
if (reply.raw.destroyed) return;
if (_req.method === 'POST') reply.header('Connection', 'close');
const statusCode = error.statusCode || 502;
const code = typeof error.code === 'string' ? error.code : undefined;
if (statusCode === 429) reply.header('Retry-After', '2');
reply.code(statusCode).send({ error: true, message: statusCode >= 500 && !code ? 'Sybil Agent is unavailable.' : error.message,
statusCode, code: code || 'agent_unavailable' });
});
routes.addContentTypeParser(/^multipart\/form-data(?:;|$)/i, (req, payload, done) => {
if (String(req.headers['content-type']).length > 512) done(apiError(400, 'invalid_upload', 'Invalid multipart content type.'));
else done(null, payload);
});
const json = async (response: Response) => {
let size = 0; const chunks: Uint8Array[] = [];
if (!response.body) throw apiError(502, 'invalid_upstream_response', 'The Agent returned an empty response.');
const reader = response.body.getReader();
try {
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
if (size > 64 * 1024) { await reader.cancel(); throw new Error('Response too large'); } chunks.push(part.value); }
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
} catch { throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid response.'); }
finally { reader.releaseLock(); }
};
routes.post('/v1/agent/attachments', { bodyLimit: AGENT_UPLOAD_LIMIT }, async (req, reply) => {
if (!(req.body instanceof Readable)) throw apiError(400, 'invalid_upload', 'A multipart/form-data upload is required.');
let oversized = false;
const incoming = req.body;
const { controller } = transfer(req);
const body = Readable.from((async function* () {
let count = 0;
for await (const chunk of incoming) {
count += chunk.length;
if (count > AGENT_UPLOAD_LIMIT) { oversized = true; throw new Error('Upload limit exceeded'); }
yield chunk;
}
})());
try {
const response = await fetch(new URL('/api/conversations/root/attachments', options.baseUrl), {
method: 'POST', headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': String(req.headers['content-type']) },
body: body as unknown as RequestInit['body'], duplex: 'half', signal: controller.signal, redirect: 'error',
} as RequestInit & { duplex: string });
const value = await json(response); controller.signal.throwIfAborted();
if (response.ok && (![200, 201].includes(response.status) || !AgentAttachmentDescriptor.safeParse((value as { attachment?: unknown })?.attachment).success))
throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid attachment descriptor.');
if (response.status === 429) reply.header('Retry-After', '2');
return reply.code(response.status).send(value);
} catch (error) {
if (oversized) throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
if (error && typeof error === 'object' && 'statusCode' in error) throw error;
throw apiError(502, 'agent_unavailable', 'The upload could not be confirmed. Retry with the same uploadId.');
} finally { body.destroy(); }
});
routes.get<{ Params: { id: string } }>('/v1/agent/attachments/:id/content', async (req, reply) => {
if (!AgentAttachmentId.safeParse(req.params.id).success) throw apiError(404, 'attachment_not_found', 'Attachment not found.');
const { controller } = transfer(req);
let response: Response;
try { response = await fetch(new URL(`/api/conversations/root/attachments/${req.params.id}/content`, options.baseUrl), {
headers: { Authorization: `Bearer ${options.apiKey}` }, signal: controller.signal, redirect: 'error',
}); } catch { throw apiError(502, 'agent_unavailable', 'Sybil Agent is unavailable.'); }
controller.signal.throwIfAborted();
if (!response.ok) return reply.code(response.status).send(await json(response));
const length = Number(response.headers.get('content-length'));
const disposition = response.headers.get('content-disposition');
const type = response.headers.get('content-type') || '';
if (!response.body || !Number.isSafeInteger(length) || length < 0 || length > 8 * 1024 * 1024
|| !disposition?.startsWith('attachment;') || !/^(image\/(png|jpeg|gif|webp)|text\/plain(?:; charset=utf-8)?|application\/octet-stream)$/.test(type)) {
await response.body?.cancel(); throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid download.');
}
// Originals are bounded to 8 MiB. Verify the received length before sending a success response.
const reader = response.body.getReader(); let size = 0; const chunks: Uint8Array[] = [];
try {
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
if (size > length) throw new Error('Download length mismatch'); chunks.push(part.value); }
if (size !== length) throw new Error('Download length mismatch');
} catch { await reader.cancel().catch(() => {}); throw apiError(502, 'invalid_upstream_response', 'The attachment download was incomplete.'); }
finally { reader.releaseLock(); }
controller.signal.throwIfAborted();
reply.header('Content-Type', type).header('Content-Length', length).header('Content-Disposition', disposition).header('X-Content-Type-Options', 'nosniff');
return reply.send(Buffer.concat(chunks, size));
});
});
}
+34 -10
View File
@@ -6,17 +6,19 @@ import { z } from 'zod';
import { requireAdmin } from './auth.js';
import { prisma } from './db.js';
import { env } from './env.js';
import { AgentAttachmentReferences, registerAgentAttachmentRoutes, requireAgentAttachments } from './agent-attachments.js';
const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor')
.transform(value => String(Number(value)));
const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() })
.refine(value => !(value.after && value.before), 'Use after or before, not both');
const MessageBody = z.object({ content: z.string().min(1).max(40_000).refine(value => value.trim().length > 0, 'content is required'), requestId: z.string().min(1).max(256) });
const MessageBody = z.object({ content: z.string().max(40_000), requestId: z.string().min(1).max(256), attachments: AgentAttachmentReferences.optional() })
.refine(value => value.content.trim().length > 0 || Boolean(value.attachments?.length), 'content or attachments are required');
const ReadBody = z.object({ cursor: Cursor });
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() });
const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() });
const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string() });
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string(), attachmentIds: z.array(z.string()).optional() });
type AgentOptions = { baseUrl?: string; apiKey?: string };
const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
@@ -34,13 +36,26 @@ async function readState() {
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
const proxyJson = async (path: string, method = 'GET', body?: unknown) => {
// An open development proxy must not expose a token-protected attachment runtime's
// history/model context using its server-side credential.
const guardOpenProxy = async (signal?: AbortSignal) => {
if (env.ADMIN_TOKEN) return;
try {
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
headers: { Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
});
const descriptor = await response.json() as { capabilities?: { attachments?: unknown } };
if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime');
} catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); }
};
const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => {
if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
await guardOpenProxy(signal);
let response: Response;
try {
response = await fetch(new URL(path, baseUrl), { method,
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000),
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
});
} catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
let data: unknown;
@@ -55,7 +70,9 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
const result = await proxyJson('/api/conversations/root');
reply.code(result.status);
if (result.status !== 200) return result.data;
return { ...result.data as object, websocket: '/v1/agent/ws' };
const descriptor: Record<string, unknown> = { ...result.data as Record<string, unknown>, websocket: '/v1/agent/ws' };
if (!env.ADMIN_TOKEN) delete descriptor.capabilities;
return descriptor;
});
app.get('/v1/agent/messages', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
@@ -71,10 +88,12 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const body = parse(MessageBody, req.body);
if (body.attachments?.length) await requireAgentAttachments(proxyJson);
const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false });
if (result.status >= 200 && result.status < 300) {
const receipt = SubmissionReceipt.safeParse(result.data);
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) {
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`
|| (body.attachments?.length && JSON.stringify(receipt.data.attachmentIds) !== JSON.stringify(body.attachments.map(item => item.id)))) {
throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt');
}
}
@@ -99,10 +118,11 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
return readState();
});
attachAgentSocket(app, baseUrl, apiKey);
await registerAgentAttachmentRoutes(app, { baseUrl, apiKey, proxyJson });
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy);
}
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: string) {
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise<void>) {
const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
const cleanup = new Set<() => void>();
const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
@@ -114,7 +134,7 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: strin
if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
sockets.handleUpgrade(request, socket, head, browser => {
let upstream: WebSocket | undefined;
let authenticated = false, disposed = false, alive = true;
let authenticated = false, authenticating = false, disposed = false, alive = true;
const send = (value: unknown) => {
if (browser.readyState !== WebSocket.OPEN) return;
if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; }
@@ -130,14 +150,18 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: strin
};
cleanup.add(dispose);
browser.on('close', dispose); browser.on('error', dispose);
browser.on('message', (raw, isBinary) => {
browser.on('message', async (raw, isBinary) => {
try {
if (isBinary) throw new Error('JSON text frames are required');
const frame = JSON.parse(raw.toString());
if (!authenticated) {
if (authenticating) { browser.close(4400, 'Authentication is still pending'); return; }
authenticating = true;
const auth = parse(AuthFrame, frame);
if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; }
if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; }
try { await guardOpenProxy(); } catch { browser.close(4503, 'Authenticated Sybil access is required'); return; }
if (disposed || browser.readyState !== WebSocket.OPEN) return;
authenticated = true; clearTimeout(authTimeout);
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
+1
View File
@@ -79,6 +79,7 @@ app.setErrorHandler((err, req, reply) => {
error: true,
message: e.message ?? String(e),
statusCode,
...(typeof e.code === 'string' ? { code: e.code } : {}),
});
});
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer, request as httpRequest, type ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
import test from 'node:test';
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
process.env.ADMIN_TOKEN = 'attachments-browser-token-test';
process.env.DATABASE_URL = 'file:/tmp/sybil-attachment-proxy-unused.sqlite';
const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/agent.js'), import('../src/env.js')]);
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key';
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' };
const bodies: any[] = [];
let capable=true, badReceipt=false, errorStatus=0, uploadCalls=0;
let holdDescriptor=false, holdDownload=false;const held:ServerResponse[]=[], closed:ServerResponse[]=[];
const source=createServer(async(req,res)=>{
assert.equal(req.headers.authorization,`Bearer ${key}`);
const chunks: Buffer[]=[];
try { for await(const chunk of req) chunks.push(chunk); } catch { return; }
const bytes=Buffer.concat(chunks);res.setHeader('Content-Type','application/json');
if(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',...(capable?{capabilities:{attachments:{version:1}}}:{})}));return;}
if(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;}
if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;}
if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;}
if(req.method==='POST'){const body=JSON.parse(bytes.toString());bodies.push(body);res.statusCode=202;res.end(JSON.stringify({id:1,status:'queued',messageId:`inbound:sybil:${body.requestId}`,...(!badReceipt?{attachmentIds:body.attachments.map((f:any)=>f.id)}:{})}));return;}
res.end(JSON.stringify({conversationId:'1',messages:[{id:'inbound:sybil:send',role:'user',content:'Attached: résumé.txt.',metadata:{agentAttachments:[file]}},{id:'tool:2',role:'tool',metadata:{kind:'tool_call'}}],cursor:'2',hasMore:false}));
});
source.listen(0,'127.0.0.1');await once(source,'listening');
const app=Fastify();await app.register(sensible);await registerAgentRoutes(app,{baseUrl:`http://127.0.0.1:${(source.address() as {port:number}).port}`,apiKey:key});
await app.listen({port:0,host:'127.0.0.1'});const base=`http://127.0.0.1:${(app.server.address() as {port:number}).port}`;
test.after(async()=>{await app.close();source.closeAllConnections();await new Promise<void>(r=>source.close(()=>r()));});
function form(){const body=new FormData();body.append('file',new Blob(['data'],{type:'text/plain'}),'résumé.txt');body.append('uploadId','12345678-1234-4234-8234-123456789abc');return body;}
test('auth precedes parsing/forwarding; capability gates old or unsafe upstream and open backend',async()=>{
const before=uploadCalls;
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,401);assert.equal(uploadCalls,before);
capable=false;assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503);
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'x',attachments:[{id:file.id}]}})).statusCode,503);
capable=true;const saved=env.ADMIN_TOKEN;env.ADMIN_TOKEN=undefined;
try {assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,503);assert.equal((await app.inject({url:'/v1/agent'})).statusCode,503);assert.equal((await app.inject({url:'/v1/agent/messages'})).statusCode,503);}finally{env.ADMIN_TOKEN=saved;}
});
test('multipart forwards Unicode filename/uploadId with private auth; quota details and authenticated originals survive',async()=>{
const response=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(response.status,201);assert.deepEqual(await response.json(),{attachment:file});assert.match(bodies.at(-1),/12345678-1234-4234-8234-123456789abc/);
errorStatus=507;try{const full=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(full.status,507);assert.equal((await full.json() as any).remainingBytes,12);}finally{errorStatus=0;}
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
const download=await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth});assert.equal(download.status,200);assert.equal(await download.text(),'data');assert.match(download.headers.get('cache-control')!,/no-store/);assert.equal(download.headers.get('x-content-type-options'),'nosniff');
assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404);
});
test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{
const payload={content:'',requestId:'send',attachments:[{id:file.id}]};
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false});
badReceipt=true;try{assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,502);}finally{badReceipt=false;}
for(const attachments of [[{...file}],[{id:file.id,workspacePath:'/etc/passwd'}],[{id:file.id},{id:file.id}],Array(9).fill({id:file.id})]) assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{...payload,attachments}})).statusCode,400);
const page=(await app.inject({url:'/v1/agent/messages',headers:auth})).json();assert.deepEqual(page.messages[0].metadata.agentAttachments,[file]);assert.equal(page.messages[1].role,'tool');
});
test('proxy rejects oversized declared bodies and chunked bodies',async()=>{
const before=uploadCalls;
const large=await app.inject({method:'POST',url:'/v1/agent/attachments',headers:{...auth,'content-type':'multipart/form-data; boundary=x','content-length':String(9*1024*1024)}});assert.equal(large.statusCode,413);assert.equal(uploadCalls,before);
const chunks=Readable.from((async function*(){for(let i=0;i<145;i++)yield Buffer.alloc(64*1024);})());
const result=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'},body:chunks as unknown as RequestInit['body'],duplex:'half'} as RequestInit & {duplex:string});
assert.equal(result.status,413);assert.equal((await result.json() as any).code,'attachment_limit_exceeded');
});
async function eventually(check:()=>boolean){const end=Date.now()+3000;while(!check()&&Date.now()<end)await new Promise(r=>setTimeout(r,10));assert.ok(check(),'Expected cancellation to finish');}
test('disconnect releases upload/download slots exactly once, cancels upstream and never continues after capability cancellation',async()=>{
const before=uploadCalls;
holdDescriptor=true;
for(let n=0;n<3;n++){
const expected=held.length+1,terminal=closed.length+1;
const req=httpRequest(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'}},()=>{});req.on('error',()=>{});req.end('--x--');
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
}
holdDescriptor=false;assert.equal(uploadCalls,before,'canceled capability checks never forward the upload');
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,201);
holdDownload=true;
for(let n=0;n<5;n++){
const expected=held.length+1,terminal=closed.length+1;
const req=httpRequest(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth},()=>{});req.on('error',()=>{});req.end();
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
}
holdDownload=false;assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
});