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();}