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
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
// Run against a local Vite server with an existing Playwright/Chromium installation:
// PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs CHROME_EXECUTABLE=/path/to/chrome
// WEB_TEST_URL=http://127.0.0.1:5192 node tests/agent-attachments.browser.mjs
// Every API and Agent socket is mocked; this does not contact the deployed runtime.
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core');
const target = new URL(process.env.WEB_TEST_URL || 'http://127.0.0.1:5192/');
assert.ok(target.protocol === 'http:' && ['localhost','127.0.0.1','[::1]'].includes(target.hostname), 'Use a loopback development server');
const browser=await chromium.launch({executablePath:process.env.CHROME_EXECUTABLE || undefined,headless:true,
...(process.env.BROWSER_NO_SANDBOX === '1' ? {args:['--no-sandbox']} : {})});
const context=await browser.newContext({viewport:{width:1200,height:900},permissions:['clipboard-write']});
await context.addInitScript(()=>localStorage.setItem('sybil_admin_token','browser-fixture-token'));
const page=await context.newPage(), errors=[], uploads=[], sends=[], downloads=[];
page.on('pageerror',e=>errors.push(e.stack || e.message));
const cap={version:1,maxCount:8,maxTotalBytes:33554432,maxImageBytes:6291456,maxFileBytes:8388608,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp'],quotaBytes:1073741824,usedBytes:0,remainingBytes:1073741824,maxConcurrentUploads:2,maxConcurrentDecodes:1};
const idle={state:'idle',isThinking:false,queued:0,backgroundWorkers:0,tools:[],queuedMessages:[]};
let failedUpload=true, failedSend=true, live, cursor=0;const history=[];
const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=','base64');
const attachment=(name='notes.txt')=>({id:`att_12345678-1234-4234-8234-${name==='picture.png'?'123456789abd':'123456789abc'}`,filename:name,mimeType:name==='picture.png'?'image/png':'text/plain',kind:name==='picture.png'?'image':'text',sizeBytes:name==='picture.png'?image.length:4,sha256:createHash('sha256').update(name==='picture.png'?image:Buffer.from('data')).digest('hex'),workspacePath:`/data/workspace/uploads/test/${name}`});
await page.route('**/api/**',async route=>{
const req=route.request(),url=new URL(req.url()),p=url.pathname,m=req.method();let value;
if(p.startsWith('/api/v1/agent'))assert.equal(req.headers().authorization,'Bearer browser-fixture-token');
if(p.endsWith('/attachments')&&m==='POST'){
const body=req.postDataBuffer().toString();uploads.push(body);
if(failedUpload){failedUpload=false;await route.fulfill({status:507,json:{message:'Storage quota reached',code:'attachment_quota_exceeded'}});return;}
await route.fulfill({status:201,json:{attachment:attachment(body.includes('picture.png')?'picture.png':'notes.txt')}});return;
}
if(p.endsWith('/content')){downloads.push(req.url());await route.fulfill({contentType:p.includes('789abd')?'image/png':'text/plain',body:p.includes('789abd')?image:'data'});return;}
if(p.endsWith('/agent/messages')&&m==='POST'){
const body=req.postDataJSON();sends.push(body);
if(failedSend){failedSend=false;await route.fulfill({status:503,json:{message:'Temporarily unavailable'}});return;}
const files=(body.attachments||[]).map(f=>attachment(f.id.endsWith('abd')?'picture.png':'notes.txt'));
const row={id:`inbound:sybil:${body.requestId}`,role:'user',createdAt:new Date().toISOString(),content:body.content||`Attached: ${files.map(f=>f.filename).join(', ')}.`,metadata:{kind:'message',status:'complete',entryId:++cursor,agentAttachments:files}};history.push(row);
live?.send(JSON.stringify({type:'messages',conversationId:'1',messages:[row],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false}));
await route.fulfill({status:202,json:{id:cursor,status:'queued',messageId:row.id,attachmentIds:files.map(f=>f.id)}});return;
}
if(p.endsWith('/agent/messages'))value={conversationId:'1',messages:history,cursor:String(cursor),beforeCursor:null,hasMore:false};
else if(p.endsWith('/read-state'))value={cursor:String(cursor),updatedAt:null};
else if(p.endsWith('/agent'))value={id:'1',title:'Sybil Agent',activity:idle,capabilities:{attachments:cap},protocol:'pi-room.v1',websocket:'/v1/agent/ws'};
else if(p.includes('session'))value={mode:'token',authenticated:true};
else if(p.endsWith('/tools')||p.endsWith('/chat-tools'))value={tools:[]};
else if(p.endsWith('/models'))value={models:[],providers:{}};
else if(p.includes('stream'))value=[];
else value=[];
await route.fulfill({json:value});
});
await page.routeWebSocket('**/api/v1/agent/ws',socket=>{live=socket;socket.onMessage(raw=>{if(JSON.parse(String(raw)).type==='auth')socket.send(JSON.stringify({type:'ready',conversationId:'1'}));});});
try{
await page.goto(target.href,{waitUntil:'networkidle'});
await page.locator('[data-testid="agent-sidebar-item"]').click();
await page.getByRole('button',{name:'Attach files',exact:true}).waitFor();
const picker=page.getByLabel('Choose attachments');
await picker.setInputFiles({name:'notes.txt',mimeType:'text/plain',buffer:Buffer.from('data')});
await page.getByText('Storage quota reached',{exact:true}).waitFor();
assert.equal(await page.getByRole('button',{name:'Send message',exact:true}).isDisabled(),true);
await page.getByRole('button',{name:'Retry upload',exact:true}).click();
await page.getByText('Uploaded',{exact:true}).waitFor();
await page.getByRole('button',{name:/^New chat/}).first().click();await page.locator('.agent-room__composer-area').waitFor({state:'detached'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByText('Uploaded',{exact:true}).waitFor();
await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByText('Uploaded',{exact:true}).waitFor();
const uuid=body=>body.match(/name="uploadId"\r\n\r\n([^\r]+)/)?.[1];assert.equal(uuid(uploads[0]),uuid(uploads[1]));
await page.getByRole('button',{name:'Remove notes.txt'}).click();assert.equal(await page.locator('.agent-room__upload').count(),0);
await picker.setInputFiles([{name:'notes.txt',mimeType:'text/plain',buffer:Buffer.from('data')},{name:'picture.png',mimeType:'image/png',buffer:image}]);
await page.waitForFunction(()=>document.querySelectorAll('.agent-room__upload [role="status"]').length===2&&Array.from(document.querySelectorAll('.agent-room__upload [role="status"]')).every(e=>e.textContent==='Uploaded'));
await page.getByRole('button',{name:'Send message',exact:true}).click();await page.getByRole('button',{name:'Retry message',exact:true}).waitFor();
assert.deepEqual(sends[0].attachments,[{id:attachment().id},{id:attachment('picture.png').id}]);assert.equal(sends[0].content,'');
await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByRole('button',{name:'Retry message',exact:true}).click();
await page.getByText('Attached: notes.txt, picture.png.',{exact:true}).waitFor();assert.equal(sends[1].requestId,sends[0].requestId);
await page.getByRole('button',{name:'Preview',exact:true}).click();await page.locator('.agent-attachment__preview').waitFor();assert.ok((await page.locator('.agent-attachment__preview').getAttribute('src')).startsWith('blob:'));
await page.getByRole('button',{name:'Hide preview',exact:true}).click();assert.equal(await page.locator('.agent-attachment__preview').count(),0);
const download=page.waitForEvent('download');await page.getByRole('button',{name:'Download',exact:true}).first().click();assert.equal((await download).suggestedFilename(),'notes.txt');assert.ok(downloads.every(url=>!url.includes('token')));
await page.getByRole('button',{name:'Copy path for notes.txt'}).click();
// Real paste/drop DOM events feed the same upload queue.
await page.getByRole('textbox',{name:'Message Sybil Agent'}).evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new ClipboardEvent('paste',{clipboardData:data,bubbles:true,cancelable:true}));});
await page.getByText('Uploaded',{exact:true}).waitFor();await page.getByRole('button',{name:'Remove notes.txt'}).click();
await page.locator('.agent-room__composer-area').evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true}));});
await page.getByText('Uploaded',{exact:true}).waitFor();
await page.setViewportSize({width:390,height:844});if(process.env.ATTACHMENT_SCREENSHOT)await page.screenshot({path:process.env.ATTACHMENT_SCREENSHOT});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false);
assert.deepEqual(errors,[]);console.log('PASS browser picker/retry/quota/remove, file-only send/reload, authenticated preview/download, copy, paste/drop, mobile layout');
}finally{await context.close();await browser.close();}
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import { createHash, randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
import { createAgentUploadQueue, agentAttachments, parseAgentAttachment } from '../src/lib/agent-attachments.ts';
import { AgentRoomClient } from '../src/lib/agent-room.ts';
import { createAgentOptimisticOutbox } from '../src/lib/agent-optimistic-outbox.ts';
import { agentTranscriptMessages } from '../src/lib/agent-room-presentation.ts';
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'notes.txt',mimeType:'text/plain',sizeBytes:4,sha256:createHash('sha256').update('data').digest('hex'),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt' };
const cap = {version:1,maxCount:8,maxTotalBytes:32*1024*1024,maxImageBytes:6*1024*1024,maxFileBytes:8*1024*1024,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp']};
const tick=()=>new Promise(r=>setTimeout(r,0));
const waitFor=async check=>{const until=Date.now()+5000;while(!check()){assert.ok(Date.now()<until,'async condition timed out');await tick();}};
test('composer gates unsupported servers, count/type/size and batches before upload; preserves retry ID and remove races',async()=>{
let capability, attempts=[], next=0, changed=0, finish;
const queue=createAgentUploadQueue({capability:()=>capability,newId:()=>`upload-${++next}`,changed:()=>changed++,upload:async(_file,id,signal)=>{attempts.push({id,signal});if(attempts.length===1)throw new Error('Quota full');return new Promise(resolve=>finish=resolve);}});
const input=new File(['data'],'notes.txt',{type:'text/plain'});
assert.throws(()=>queue.add([input]),/not available/);capability=cap;
assert.throws(()=>queue.add(Array(9).fill(input)),/8 files/);
assert.throws(()=>queue.add([new File([new Uint8Array(6*1024*1024+1)],'big.png',{type:'image/png'})]),/6 MiB/);
assert.throws(()=>queue.add(Array(5).fill(new File([new Uint8Array(7*1024*1024)],'large.bin'))),/32 MiB/);
queue.add([input]);assert.throws(()=>queue.ready(),/Wait/);await waitFor(()=>queue.snapshot()[0].status==='failed');
assert.equal(queue.snapshot()[0].status,'failed');assert.match(queue.snapshot()[0].error,/Quota/);
queue.retry('upload-1');await waitFor(()=>attempts.length===2);assert.deepEqual(attempts.map(a=>a.id),['upload-1','upload-1']);
queue.remove('upload-1');assert.equal(attempts.at(-1).signal.aborted,true);finish(file);await tick();assert.deepEqual(queue.ready(),[]);
assert.ok(changed>0);queue.dispose();
});
test('composer serializes transfers, keeps ready descriptors, and clears without orphaning pending state',async()=>{
const done=[];let count=0;
const queue=createAgentUploadQueue({capability:()=>cap,changed:()=>{},newId:()=>`id-${++count}`,upload:async()=>new Promise(r=>done.push(r))});
queue.add([new File(['data'],'notes.txt'),new File(['data'],'notes.txt')]);await waitFor(()=>done.length===1);assert.deepEqual(queue.snapshot().map(r=>r.status),['uploading','waiting']);
done[0](file);await waitFor(()=>done.length===2);assert.deepEqual(queue.snapshot().map(r=>r.status),['ready','uploading']);done[1]({...file,id:file.id.replace('12345678','12345679')});await waitFor(()=>queue.snapshot().every(row=>row.status==='ready'));assert.equal(queue.ready().length,2);queue.clear();assert.deepEqual(queue.ready(),[]);queue.dispose();
});
test('transport uses auth headers/FormData, ID-only send and checked receipt, authenticated download with no token URLs',async()=>{
const calls=[];let acknowledge=true;
const client=new AgentRoomClient({baseUrl:'/api',token:()=> 'browser-token',fetch:async(url,init)=>{
calls.push({url,init});assert.ok(!url.includes('token'));assert.equal(init.headers.get('Authorization'),'Bearer browser-token');
if(url.endsWith('/attachments')){assert.ok(init.body instanceof FormData);assert.equal(init.headers.get('Content-Type'),null);assert.equal(init.body.get('uploadId'),'uuid');return Response.json({attachment:file},{status:201});}
if(url.endsWith('/content'))return new Response('data',{headers:{'content-type':'text/plain'}});
const body=JSON.parse(init.body);assert.deepEqual(body,{content:'',requestId:'send-id',attachments:[{id:file.id}]});
return Response.json({id:1,status:'queued',messageId:'inbound:sybil:send-id',...(acknowledge?{attachmentIds:[file.id]}:{})});
}});
assert.deepEqual(await client.upload(new File(['data'],'notes.txt'),'uuid'),file);
assert.equal((await client.submit('','send-id',[file])).status,'queued');acknowledge=false;await assert.rejects(client.submit('','send-id',[file]),/invalid message receipt/);
assert.equal(await (await client.download(file)).text(),'data');assert.equal(calls.at(-1).init.cache,'no-store');assert.equal(calls.at(-1).init.redirect,'error');
});
test('file-only outbox survives reload and retry without sending paths, confirms matching attachment echoes and retains tool filtering',async()=>{
let saved, call, fail=true;
const options={storage:{read:()=>saved,write:rows=>saved=structuredClone(rows)},changed:()=>{},newId:()=> 'request',submit:async(content,requestId,attachments)=>{call={content,requestId,attachments};if(fail)throw new Error('lost response');return {status:'queued',messageId:'inbound:sybil:request'};}};
let outbox=createAgentOptimisticOutbox(options);await assert.rejects(outbox.send('',[file]));assert.deepEqual(saved[0].attachments,[file]);
outbox=createAgentOptimisticOutbox(options);assert.equal(outbox.snapshot().length,1);fail=false;await outbox.retry('inbound:sybil:request');assert.equal(call.content,'');assert.deepEqual(call.attachments,[file]);
const row=outbox.display([])[0];assert.equal(row.content,'Attached: notes.txt.');assert.deepEqual(agentAttachments(row.metadata),[file]);
outbox.reconcile({messages:[{...row,metadata:{kind:'message',status:'complete'}}]});assert.equal(outbox.snapshot()[0].confirmed,false);
const mismatch=outbox.display([{...row,metadata:{kind:'message',status:'complete'}}])[0];assert.deepEqual(agentAttachments(mismatch.metadata),[file]);assert.equal(mismatch.metadata.status,'queued');
outbox.reconcile({messages:[{...row,metadata:{...row.metadata,status:'complete'}}]});assert.equal(outbox.snapshot()[0].confirmed,true);
const raw=[row,{...row,id:'tool',role:'tool',metadata:{kind:'tool_call'}}];assert.equal(agentTranscriptMessages(raw).length,1);assert.equal(raw.length,2);
assert.throws(()=>parseAgentAttachment({...file,workspacePath:'https://bad'}));
});
test('attachment drafts preserve completed receipts and unresolved IDs/hash across disposal/reload, verified reselection resumes without a new ID',async()=>{
let saved=[], attempts=[], complete;
const storage={read:()=>saved,write:rows=>saved=structuredClone(rows)};
const options={capability:()=>cap,storage,changed:()=>{},upload:async(_file,id,signal)=>{attempts.push({id,signal});return new Promise(r=>complete=r);}};
let queue=createAgentUploadQueue(options);queue.add([new File(['data'],'notes.txt',{type:'text/plain'})]);
await waitFor(()=>Boolean(complete));const original=attempts[0].id;assert.equal(saved[0].sha256,file.sha256);
queue.dispose();assert.equal(attempts[0].signal.aborted,true);
queue=createAgentUploadQueue(options);assert.equal(queue.snapshot()[0].status,'reselect');assert.equal(queue.snapshot()[0].uploadId,original);assert.throws(()=>queue.ready(),/Wait/);
await assert.rejects(queue.reselect(original,new File(['evil'],'notes.txt',{type:'text/plain'})),/different contents/);assert.equal(attempts.length,1);
await queue.reselect(original,new File(['data'],'notes.txt',{type:'text/plain'}));await waitFor(()=>attempts.length===2);assert.equal(attempts[1].id,original);
complete(file);await tick();assert.equal(queue.ready()[0].id,file.id);queue.dispose();
const restored=createAgentUploadQueue(options);assert.deepEqual(restored.ready(),[file]);assert.equal(attempts.length,2,'completed upload is never repeated');restored.clear();assert.deepEqual(saved,[]);
});
test('mismatched complete echoes cannot hide local attachment errors or the retry action',async()=>{
const box=createAgentOptimisticOutbox({storage:{read:()=>null,write:()=>{}},changed:()=>{},newId:()=> 'mismatch',submit:async()=>{throw new Error('quota failure');}});
await assert.rejects(box.send('caption',[file]));const initial=box.display([])[0];
for(const attachments of [undefined,[{...file,id:file.id.replace('12345678','12345679')}]]){
const echo={...initial,metadata:{kind:'message',status:'complete',...(attachments?{agentAttachments:attachments}:{})}};
box.reconcile({messages:[echo]});const shown=box.display([echo])[0];assert.equal(shown.metadata.status,'failed');assert.equal(shown.metadata.error,'quota failure');assert.deepEqual(shown.metadata.agentAttachments,[file]);assert.equal(box.snapshot()[0].confirmed,false);
}
});
test('storage refusal prevents uploads and outbox handoff; no attachment draft is cleared before persistence',async()=>{
let uploads=0;const queue=createAgentUploadQueue({capability:()=>cap,storage:{read:()=>null,write:()=>{throw new Error('storage full');}},changed:()=>{},upload:async()=>{uploads++;return file;}});
assert.throws(()=>queue.add([new File(['data'],'notes.txt')]),/storage full/);await tick();assert.equal(uploads,0);
let cleared=false;const box=createAgentOptimisticOutbox({storage:{read:()=>null,write:()=>{throw new Error('storage full');}},changed:()=>{},submit:async()=>({status:'queued'})});
await assert.rejects(box.send('draft',[file],()=>{cleared=true;}));assert.equal(cleared,false);
});
test('a late canceled queue completion cannot overwrite a restored draft',async()=>{
let saved=[];const pending=[];
const options={capability:()=>cap,changed:()=>{},storage:{read:()=>saved,write:rows=>saved=structuredClone(rows)},upload:async()=>new Promise(resolve=>pending.push(resolve))};
const old=createAgentUploadQueue(options);old.add([new File(['data'],'notes.txt',{type:'text/plain'})]);await waitFor(()=>pending.length===1);
const oldId=old.snapshot()[0].uploadId;old.dispose();
const restored=createAgentUploadQueue(options);restored.remove(oldId);restored.add([new File(['data'],'notes.txt',{type:'text/plain'})]);await waitFor(()=>pending.length===2);
pending[1](file);await tick();const current=structuredClone(saved);assert.equal(current[0].status,'ready');assert.notEqual(current[0].uploadId,oldId);
pending[0](file);await tick();assert.deepEqual(saved,current);assert.deepEqual(restored.ready(),[file]);restored.dispose();
});