Support durable assistant image attachments in Agent clients

This commit is contained in:
2026-10-20 14:14:09 -07:00
parent 7b48201e28
commit 489c347976
15 changed files with 227 additions and 40 deletions
+5 -4
View File
@@ -18,11 +18,12 @@ export type AgentProxyJson = (path: string, method?: string, body?: unknown, sig
function apiError(statusCode: number, code: string, message: string) {
return Object.assign(new Error(message), { statusCode, code });
}
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal, download = false) {
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)
const value = descriptor.data as { capabilities?: { attachments?: { version?: number }; assistantImages?: { version?: number } } } | null;
if (descriptor.status !== 200 || !(value?.capabilities?.attachments?.version === 1
|| (download && value?.capabilities?.assistantImages?.version === 1)))
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
}
@@ -52,7 +53,7 @@ export async function registerAgentAttachmentRoutes(app: FastifyInstance, option
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);
await requireAgentAttachments(options.proxyJson, controller.signal, !uploading);
controller.signal.throwIfAborted();
});
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
+6 -2
View File
@@ -14,7 +14,8 @@ export type MessageText = (message: PublicMessage) => Promise<string>;
const Page = z.object({ conversationId: z.string(), cursor: Cursor, beforeCursor: Cursor,
messages: z.array(z.unknown()).max(1000) });
const Message = z.object({ id: z.string(), role: z.literal('assistant'), content: z.string(),
metadata: z.object({ kind: z.literal('message'), status: z.literal('complete'), entryId: z.number().int().safe() }) });
metadata: z.object({ kind: z.literal('message'), status: z.literal('complete'), entryId: z.number().int().safe(),
agentAttachments: z.unknown().optional() }) });
/** Resolve only an already eligible candidate from one immutable root public entry. */
export function createMessageText(upstream: { baseUrl: string; apiKey?: string }): MessageText {
@@ -47,7 +48,10 @@ export function createMessageText(upstream: { baseUrl: string; apiKey?: string }
const matches = page.messages.filter(value => value && typeof value === 'object' && 'id' in value && value.id === candidate.messageId);
const message = matches.length === 1 ? Message.safeParse(matches[0]) : undefined;
if (!message?.success || message.data.metadata.entryId !== Number(candidate.cursor)) throw new Error('Public Agent message unavailable');
// Never inspect metadata, tool results, activity, reasoning blocks or linked resources for text.
// Attachment messages use the generic alert. Never expose captions, filenames,
// paths, image data or linked resources in an image notification.
if (message.data.metadata.agentAttachments !== undefined) return '';
// Never inspect tool results, activity, reasoning blocks or linked resources for text.
return message.data.content;
};
}
+3 -2
View File
@@ -43,8 +43,8 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
headers: { 'X-Sybil-Client': 'backend-proxy-v1', 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');
const descriptor = await response.json() as { capabilities?: { attachments?: unknown; assistantImages?: unknown } };
if (!response.ok || descriptor?.capabilities?.attachments || descriptor?.capabilities?.assistantImages) 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) => {
@@ -53,6 +53,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
let response: Response;
try {
response = await fetch(new URL(path, baseUrl), { method,
redirect: 'error',
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
});
+18 -2
View File
@@ -20,14 +20,14 @@ const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/age
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 capable=true, assistantImages=false, 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(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',capabilities:{...(capable?{attachments:{version:1}}:{}),...(assistantImages?{assistantImages:{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;}
@@ -57,6 +57,22 @@ test('multipart forwards Unicode filename/uploadId with private auth; quota deta
assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404);
});
test('assistant-only capability allows authenticated retrieval without granting input uploads or open history',async()=>{
capable=false;assistantImages=true;
const saved=env.ADMIN_TOKEN;
try {
const descriptor=(await app.inject({url:'/v1/agent',headers:auth})).json();
assert.deepEqual(descriptor.capabilities,{assistantImages:{version:1}});
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
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:'assistant-only',attachments:[{id:file.id}]}})).statusCode,503);
assert.equal((await app.inject({method:'POST',url:'/v1/agent/output_image',headers:auth,payload:{path:'https://never-fetch.invalid/image'}})).statusCode,404);
env.ADMIN_TOKEN=undefined;
for(const url of ['/v1/agent','/v1/agent/messages',`/v1/agent/attachments/${file.id}/content`]) assert.equal((await app.inject({url})).statusCode,503);
} finally {env.ADMIN_TOKEN=saved;capable=true;assistantImages=false;}
});
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});
+5
View File
@@ -41,6 +41,11 @@ test('preview lookup uses only the exact eligible completed root assistant conte
]) { page = invalid; await assert.rejects(load(candidate)); }
page = { ...original, messages: [{ ...message, content: '' }] };
assert.equal(await load(candidate), '', 'empty content never borrows metadata, tool output or reasoning');
page = { ...original, messages: [{ ...message, content: 'Caption stays inside authenticated chat.', metadata: { ...message.metadata,
agentAttachments: [{ filename: 'private-name.png', workspacePath: '/private/source.png', url: 'https://never-fetch.invalid/image' }] } }] };
const imageText = await load(candidate);
assert.equal(imageText, '', 'image captions and metadata use only a generic notification');
assert.deepEqual(JSON.parse(previewPayload(imageText, body => ({ body }), 4096).toString()), { body: 'You have a new message.' });
const before = requests.length;
for (const messageId of ['tool:1:11', 'outbound:12', 'https://never-fetch.invalid/', 'entry:11:0/../../state']) {
await assert.rejects(load({ ...candidate, messageId }));