Support durable assistant image attachments in Agent clients
This commit is contained in:
@@ -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(); });
|
||||
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user