From a37e02b3746866f04c90bf94312bd6ac2e87b2ab Mon Sep 17 00:00:00 2001 From: Surma Date: Thu, 12 Nov 2026 09:35:20 +0000 Subject: [PATCH] Complete browser QA with staged passkey enrollment and immediate selection --- userland/web/browser-check.mjs | 81 +++++++++++++++++--------------- userland/web/credentials-view.js | 16 ++++--- userland/web/index.html | 7 +-- userland/web/render.test.cjs | 4 +- 4 files changed, 59 insertions(+), 49 deletions(-) diff --git a/userland/web/browser-check.mjs b/userland/web/browser-check.mjs index e734140..1d77ca5 100644 --- a/userland/web/browser-check.mjs +++ b/userland/web/browser-check.mjs @@ -1,21 +1,23 @@ // Manual QA against a fresh world on localhost:8080. Uses Chrome's virtual passkeys, not real secrets. -import {execFileSync} from 'node:child_process'; +import {execFile} from 'node:child_process'; +import {promisify} from 'node:util'; import {mkdirSync} from 'node:fs'; import assert from 'node:assert/strict'; const session='sausage-credential-ui-check'; -const browser=(...args)=>execFileSync('agent-browser',['--session',session,...args],{encoding:'utf8'}).trim(); +const execute=promisify(execFile); +const browser=async(...args)=>(await execute('agent-browser',['--session',session,...args],{encoding:'utf8'})).stdout.trim(); mkdirSync('/tmp/T91-shots',{recursive:true}); -const shot=(name,width=1100,height=1000)=>{browser('set','viewport',String(width),String(height));console.log(browser('screenshot','/tmp/T91-shots/'+name+'.png'));}; +const shot=async(name,width=1100,height=1000)=>{await browser('set','viewport',String(width),String(height));console.log(await browser('screenshot','/tmp/T91-shots/'+name+'.png'));}; const get=async path=>(await fetch('http://localhost:8080/api/cred/'+path)).json(); const file=async body=>{const response=await fetch('http://localhost:8080/api/cred/requests',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});assert.equal(response.status,200);return (await response.json()).request;}; let socket; try{ - console.log(browser('open','http://localhost:8080/')); - socket=new WebSocket(browser('get','cdp-url')); + console.log(await browser('open','http://localhost:8080/')); + socket=new WebSocket(await browser('get','cdp-url')); await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;}); let next=0; const pending=new Map(); - socket.onmessage=async event=>{const message=JSON.parse(event.data);const promise=pending.get(message.id);if(promise){pending.delete(message.id);message.error?promise.reject(new Error(JSON.stringify(message.error))):promise.resolve(message.result);}else if(message.method==='Runtime.bindingCalled'&&message.params.name==='qaNewDevice')await switchDevice();}; + socket.onmessage=event=>{const message=JSON.parse(event.data);const promise=pending.get(message.id);if(promise){pending.delete(message.id);message.error?promise.reject(new Error(JSON.stringify(message.error))):promise.resolve(message.result);}}; const cdp=(method,params={},sessionId)=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});socket.send(JSON.stringify({id,method,params,sessionId}));}); const {targetInfos}=await cdp('Target.getTargets'); const page=targetInfos.find(target=>target.type==='page'&&target.url==='http://localhost:8080/'); @@ -24,45 +26,46 @@ try{ await cdp('WebAuthn.enable',{},sessionId); const authenticatorOptions={protocol:'ctap2',transport:'internal',hasResidentKey:true,hasUserVerification:true,isUserVerified:true,automaticPresenceSimulation:true}; let {authenticatorId}=await cdp('WebAuthn.addVirtualAuthenticator',{options:authenticatorOptions},sessionId); - // Enrollment verifies the old device, then creates a passkey on a different device. - // A device cannot enroll twice because the host excludes its existing credentials. - const switchDevice=async()=>{await cdp('WebAuthn.removeVirtualAuthenticator',{authenticatorId},sessionId);({authenticatorId}=await cdp('WebAuthn.addVirtualAuthenticator',{options:authenticatorOptions},sessionId));await cdp('Runtime.evaluate',{expression:'window.qaDeviceReady()'},sessionId);}; - await cdp('Runtime.enable',{},sessionId); - await cdp('Runtime.addBinding',{name:'qaNewDevice'},sessionId); - browser('wait','#credential-notice:not([hidden])'); - shot('chat-empty');shot('chat-empty-narrow',400,850); - const mainConversation=Number(browser('eval','selected')); - browser('click','#conversations button:first-child');browser('wait','--fn',"document.getElementById('compose').hidden");shot('chat-world-log',400,850); - browser('click','#conversations button[aria-current=false]'); - browser('click','#register-first');browser('wait','input[name=label]');shot('passkeys-none',400,850); - browser('fill','input[name=label]','Laptop');browser('click','#credential-view form button');browser('wait','--text','Passkey registered.');shot('passkeys-one',400,850); - browser('eval',"(()=>{const create=navigator.credentials.create.bind(navigator.credentials);navigator.credentials.create=async options=>{await new Promise(resolve=>{window.qaDeviceReady=resolve;window.qaNewDevice('');});return create(options);};})()"); - for(const label of ['Phone','Backup key']){browser('fill','input[name=label]',label);browser('click','#credential-view form button');browser('wait','--text','Passkey registered.');} - assert.equal((await get('passkeys')).length,3);shot('passkeys-several',400,850); - browser('eval',"location.hash='entry'");browser('wait','--text','No secret fields exist.');shot('entry-empty',400,850); + await browser('wait','#credential-notice:not([hidden])'); + await shot('chat-empty');await shot('chat-empty-narrow',400,850); + const {conversations}=await (await fetch('http://localhost:8080/api/harness/conversations')).json(); + const mainConversation=conversations.find(conversation=>conversation.id!==1&&conversation.agent?.model).id; + await browser('click','#conversations button:first-child');await browser('wait','--fn',"document.getElementById('compose').hidden");await shot('chat-world-log',400,850); + await browser('click','#conversations button[aria-current=false]'); + await browser('click','#register-first');await browser('wait','input[name=label]');await shot('passkeys-none',400,850); + await browser('fill','input[name=label]','Laptop');await browser('click','#credential-view form button');await browser('wait','--text','Passkey registered.');await shot('passkeys-one',400,850); + for(const label of ['Phone','Backup key']){ + await browser('fill','input[name=label]',label);await browser('click','#credential-view form button');await browser('wait','--text','Existing passkey verified.'); + if(label==='Phone')await shot('passkey-verified',400,850); + await cdp('WebAuthn.removeVirtualAuthenticator',{authenticatorId},sessionId); + ({authenticatorId}=await cdp('WebAuthn.addVirtualAuthenticator',{options:authenticatorOptions},sessionId)); + await browser('click','#credential-view form button');await browser('wait','--text','Passkey registered.'); + } + assert.equal((await get('passkeys')).length,3);await shot('passkeys-several',400,850); + await browser('eval',"location.hash='entry'");await browser('wait','--text','No secret fields exist.');await shot('entry-empty',400,850); const name='Example API '+Date.now(); const request=await file({kind:'edit',key:name,request:{item:null,base_version:null,change:[{op:'create_item',config:{name,rules:[{scheme:'https',hosts:['api.example.com'],port:443,path_prefixes:['/v1'],methods:['GET','POST']}],transform_js:'',fields:[{name:'token',public:false,field_type:'text'}]}}]}}); - browser('eval',"location.hash=''");browser('wait',`#approvals a[href="/#approve/${request}"]`);browser('click',`#approvals a[href="/#approve/${request}"]`); - browser('wait','#credential-view button[value=approve]');shot('approval-create');shot('approval-create-narrow',400,1000); - assert.equal(browser('eval',"document.querySelectorAll('#credential-content > pre').length"),'0'); - browser('click','#credential-view button[value=approve]');browser('wait','--text','Request approved.'); + await browser('eval',"location.hash=''");await browser('wait',`#approvals a[href="/#approve/${request}"]`);await browser('click',`#approvals a[href="/#approve/${request}"]`); + await browser('wait','#credential-view button[value=approve]');await shot('approval-create');await shot('approval-create-narrow',400,1000); + assert.equal(await browser('eval',"document.querySelectorAll('#credential-content > pre').length"),'0'); + await browser('click','#credential-view button[value=approve]');await browser('wait','--text','Request approved.'); assert.equal((await get('requests/'+request)).state,'approved'); - assert.equal(browser('get','count','#credential-view form'),'0','Decided requests are read-only'); - assert.equal(browser('eval',`!!document.querySelector('#approvals a[href="/#approve/${request}"]')`),'false');shot('approval-complete',400,850); + assert.equal(await browser('get','count','#credential-view form'),'0','Decided requests are read-only'); + assert.equal(await browser('eval',`!!document.querySelector('#approvals a[href="/#approve/${request}"]')`),'false');await shot('approval-complete',400,850); const item=(await get('items')).find(item=>item.name===name);assert.ok(item); const grant=await file({kind:'grant',key:'grant-'+name,request:{items:[item.id],duration_secs:3600,max_uses:5}}); - browser('eval',`location.hash='approve/${grant}'`);browser('wait','#credential-view fieldset:not([hidden])');shot('approval-grant');shot('approval-grant-narrow',400,1000); - browser('fill','input[name=duration]','600');browser('fill','input[name=uses]','2');browser('click','#credential-view button[value=approve]');browser('wait','--text','Request approved.');assert.equal((await get('requests/'+grant)).state,'approved'); + await browser('eval',`location.hash='approve/${grant}'`);await browser('wait','#credential-view fieldset:not([hidden])');await shot('approval-grant');await shot('approval-grant-narrow',400,1000); + await browser('fill','input[name=duration]','600');await browser('fill','input[name=uses]','2');await browser('click','#credential-view button[value=approve]');await browser('wait','--text','Request approved.');assert.equal((await get('requests/'+grant)).state,'approved'); const issued=(await get('grants')).find(grant=>grant.items.includes(item.id));assert.equal(issued.max_uses,2);assert.ok(issued.exp-Math.floor(Date.now()/1000)<=600); const denied=await file({kind:'grant',key:'deny-'+name,request:{items:[item.id],duration_secs:3600}}); - browser('eval',`location.hash='approve/${denied}'`);browser('wait','#credential-view button[value=deny]');browser('fill','input[name=duration]','');browser('click','#credential-view button[value=deny]');browser('wait','--text','Request denied.');assert.equal((await get('requests/'+denied)).state,'denied');shot('approval-denied',400,1000); - browser('eval',"location.hash='entry'");browser('wait','select[name=field]');browser('fill','textarea[name=secret]','QA fixture value');shot('entry-secret',400,850); - browser('click','#credential-view form button');browser('wait','--text','Secret saved.');assert.equal(browser('get','value','textarea[name=secret]'),'');assert.equal((await get('items')).find(value=>value.id===item.id).fields[0].needs_value,false);shot('entry-complete',400,850); - browser('eval',"location.hash=''");browser('wait','--fn',"document.getElementById('credential-view').hidden"); + await browser('eval',`location.hash='approve/${denied}'`);await browser('wait','#credential-view button[value=deny]');await browser('fill','input[name=duration]','');await browser('click','#credential-view button[value=deny]');await browser('wait','--text','Request denied.');assert.equal((await get('requests/'+denied)).state,'denied');await shot('approval-denied',400,1000); + await browser('eval',"location.hash='entry'");await browser('wait','select[name=field]');await browser('fill','textarea[name=secret]','QA fixture value');await shot('entry-secret',400,850); + await browser('click','#credential-view form button');await browser('wait','--text','Secret saved.');assert.equal(await browser('get','value','textarea[name=secret]'),'');assert.equal((await get('items')).find(value=>value.id===item.id).fields[0].needs_value,false);await shot('entry-complete',400,850); + await browser('eval',"location.hash=''");await browser('wait','--fn',"document.getElementById('credential-view').hidden"); // Client-only transcript fixtures exercise rendering without a model call or durable state changes. - browser('eval',`(()=>{select(${mainConversation});$('entries').replaceChildren();entry({kind:'pi.user',model:{content:'Please review the API changes.'}});entry({kind:'pi.assistant',model:{content:[{type:'thinking',thinking:'I will inspect the files and compare the results.'},{type:'toolCall',id:'qa-tool',name:'bash',arguments:{command:'cargo test --workspace'}},{type:'text',text:'# Review result\\n\\n| Check | Result |\\n| --- | --- |\\n| API tests | Passed |\\n| Build | Passed |'}]}});entry({kind:'pi.tool-result',model:{toolCallId:'qa-tool',toolName:'bash',isError:true,content:[{type:'text',text:'A test failed: expected status 200, received 404.'}]}});entry({kind:'pi.assistant',model:{content:'The route needs a correction.\\n\\n'+ '\`\`\`rust\\n'+Array.from({length:35},(_,i)=>'// Code example line '+(i+1)).join('\\n')+'\\n\`\`\`'}});$('entries').scrollTop=0;})()`); - shot('chat-tools');shot('chat-tools-narrow',400,1000); - browser('eval',"$('entries').scrollTop=$('entries').scrollHeight");shot('chat-code-narrow',400,1000); - assert.equal(browser('eval',"document.documentElement.scrollWidth > innerWidth"),'false'); + await browser('eval',`(()=>{select(${mainConversation});$('entries').replaceChildren();entry({kind:'pi.user',model:{content:'Please review the API changes.'}});entry({kind:'pi.assistant',model:{content:[{type:'thinking',thinking:'I will inspect the files and compare the results.'},{type:'toolCall',id:'qa-tool',name:'bash',arguments:{command:'cargo test --workspace'}},{type:'text',text:'# Review result\\n\\n| Check | Result |\\n| --- | --- |\\n| API tests | Passed |\\n| Build | Passed |'}]}});entry({kind:'pi.tool-result',model:{toolCallId:'qa-tool',toolName:'bash',isError:true,content:[{type:'text',text:'A test failed: expected status 200, received 404.'}]}});entry({kind:'pi.assistant',model:{content:'The route needs a correction.\\n\\n'+ '\`\`\`rust\\n'+Array.from({length:35},(_,i)=>'// Code example line '+(i+1)).join('\\n')+'\\n\`\`\`'}});$('entries').scrollTop=0;})()`); + await shot('chat-tools');await shot('chat-tools-narrow',400,1000); + await browser('eval',"$('entries').scrollTop=$('entries').scrollHeight");await shot('chat-code-narrow',400,1000); + assert.equal(await browser('eval',"document.documentElement.scrollWidth > innerWidth"),'false'); console.log('PASS: registration, multiple passkeys, item creation, immediate banner removal, grant limits, encrypted secret entry, and transcript QA.'); -}catch(error){console.error(browser('snapshot'));console.error(browser('errors'));console.error(browser('screenshot','/tmp/t91-qa-failure.png'));throw error;}finally{socket?.close();browser('close');} +}catch(error){console.error(await browser('snapshot'));console.error(await browser('errors'));console.error(await browser('screenshot','/tmp/t91-qa-failure.png'));throw error;}finally{socket?.close();await browser('close');} diff --git a/userland/web/credentials-view.js b/userland/web/credentials-view.js index 4f5f909..a7a2a35 100644 --- a/userland/web/credentials-view.js +++ b/userland/web/credentials-view.js @@ -9,7 +9,7 @@ function credentialReview(request,items){ else if(p.old)requested=config(p.old); else requested=list((p.requested?.change||[]).map(change=>e(change.op.replaceAll('_',' '))+(change.config?config(change.config):''))); const subject=request.subject; - return '

'+e(request.summary)+'

Requested by

'+(subject.kind==='conversation'?'Conversation #':'Program ')+e(subject.id)+'

Requested change

'+requested+'
Technical details
'+e(JSON.stringify(request,null,2))+'
'; + return '

'+e(requestSummary(request.summary))+'

Requested by

'+(subject.kind==='conversation'?'Conversation #':'Program ')+e(subject.id)+'

Requested change

'+requested+'
Technical details
'+e(JSON.stringify(request,null,2))+'
'; } const credentialView=document.getElementById('credential-view'); const credentialNotice=document.getElementById('credential-notice'); @@ -52,12 +52,16 @@ async function credentialNavigate(){ if(!passkeys.length){const empty=document.createElement('p');empty.textContent='No passkeys registered.';content.append(empty);} for(const key of passkeys){const row=document.createElement('li');row.textContent=key.label;list.append(row);} content.append(list); - const form=document.createElement('form');form.innerHTML='';content.append(form); + const form=document.createElement('form');form.innerHTML='';content.append(form); + let challenge; credentialForm(async data=>{ - const label=data.get('label');let gate; - if((await credentialGet('passkeys')).length){const proof=await assertion(await post('/cred/passkeys/register/challenge',{label}));gate={id:proof.challenge_id,response:proof.assertion};} - const challenge=await post('/cred/passkeys/register/start',{label,gate}); - const credential=await navigator.credentials.create(options(challenge)); + if(!challenge){ + const label=data.get('label');let gate; + if(passkeys.length){const proof=await assertion(await post('/cred/passkeys/register/challenge',{label}));gate={id:proof.challenge_id,response:proof.assertion};} + challenge=await post('/cred/passkeys/register/start',{label,gate}); + if(passkeys.length){form.elements.label.readOnly=true;form.querySelector('button').textContent='Register new passkey';return 'Existing passkey verified. Choose a new passkey or device.';} + } + const credential=await navigator.credentials.create(options(structuredClone(challenge))); await post('/cred/passkeys/register/finish',{challenge_id:challenge.id,response:serialize(credential)}); await credentialNavigate(); return 'Passkey registered.'; diff --git a/userland/web/index.html b/userland/web/index.html index a842c07..17b5cd3 100644 --- a/userland/web/index.html +++ b/userland/web/index.html @@ -24,6 +24,7 @@ const $=id=>document.getElementById(id); let selected=null,after=0,pending=null,sequence=0; const tools=new Map(); const decidedRequests=new Set(); +const requestSummary=value=>value.replace(/^Give access to 1 credential items\b/,'Give access to 1 credential item'); function dismissCredentialRequest(id){ decidedRequests.add(id); for(const link of $('approvals').childNodes)if(link.hash==='#approve/'+id)link.remove(); @@ -85,7 +86,7 @@ function entry(record){ } if(box.childNodes.length)$('entries').append(box); } -function select(id){selected=id;after=0;tools.clear();$('entries').replaceChildren();} +function select(id){selected=id;after=0;tools.clear();$('entries').replaceChildren();for(const button of $('conversations').childNodes)button.setAttribute('aria-current',String(button.dataset.id===String(id)));} async function refresh(){ try{ const {conversations,standing}=await api('conversations'); @@ -95,7 +96,7 @@ async function refresh(){ const parent=c=>c.parent?.conversationId||c.owner?.conversationId||c.starter; for(const c of visible){ const button=document.createElement('button'),label=document.createElement('small'),p=parent(c),title=c.title||(c.id===1?'World log':'Conversation #'+c.id); - button.type='button';button.setAttribute('aria-current',String(c.id===selected)); + button.type='button';button.dataset.id=String(c.id);button.setAttribute('aria-current',String(c.id===selected)); button.textContent=title; label.textContent=(c.running?'Running':c.concluded?'Concluded':'Idle')+(p?' · parent #'+p+' · child '+(visible.filter(other=>parent(other)===p).findIndex(other=>other.id===c.id)+1):' · root'); button.append(label);button.onclick=()=>{select(c.id);$('compose').hidden=!c.agent?.model;$('title').textContent=title;$('entries').dataset.empty=c.agent?.model?'Send a message to start the conversation.':'This conversation has no messages.';};$('conversations').append(button); @@ -108,7 +109,7 @@ async function refresh(){ } const {requests}=await api('credential-requests');$('approvals').replaceChildren(); for(const request of requests.filter(request=>!decidedRequests.has(request.id))){ - const link=document.createElement('a');link.textContent='Approve: '+(request.summary||request.id);link.href=request.approvalUrl;$('approvals').append(link); + const link=document.createElement('a');link.textContent='Approve: '+requestSummary(request.summary||request.id);link.href=request.approvalUrl;$('approvals').append(link); } $('error').textContent=''; }catch(error){$('error').textContent=' '+error.message;} diff --git a/userland/web/render.test.cjs b/userland/web/render.test.cjs index d176955..382f4fc 100644 --- a/userland/web/render.test.cjs +++ b/userland/web/render.test.cjs @@ -63,6 +63,8 @@ assert.equal(elements.get('compose').submits, 2); run('select(2)'); assert.equal(run('tools.size'), 0); assert.equal(elements.get('entries').childNodes.length, 0); +assert.equal(run("requestSummary('Give access to 1 credential items for 1 hour.')"),'Give access to 1 credential item for 1 hour.'); +assert.equal(run("requestSummary('Give access to 11 credential items for 1 hour.')"),'Give access to 11 credential items for 1 hour.'); context.conversations=[{id:1,agent:null},{id:24,agent:null},{id:25,agent:{}},{id:14,agent:{model:{provider:'faux'}}}]; assert.equal(run('visibleConversations(conversations).map(c=>c.id).join()'),'1,14'); vm.runInContext(fs.readFileSync(__dirname+'/credentials-view.js','utf8').split('const credentialView=')[0],context); @@ -91,7 +93,7 @@ context.fetch=async url=>({ok:true,json:async()=>url.endsWith('/conversations')? assert.equal(buttons.length,2,'Event-only conversations do not render buttons'); assert.equal(buttons[0].textContent,'World log'); assert.equal(buttons[1].textContent,'Conversation #14'); - buttons[0].onclick();assert.ok(elements.get('compose').hidden,'A conversation without a model is read-only'); + buttons[0].onclick();assert.equal(buttons[0].attributes['aria-current'],'true');assert.equal(buttons[1].attributes['aria-current'],'false');assert.ok(elements.get('compose').hidden,'A conversation without a model is read-only'); assert.equal(elements.get('entries').dataset.empty,'This conversation has no messages.'); console.log('PASS chat rendering, approval review, immediate decisions, and conversation filter'); })().catch(error=>{console.error(error);process.exitCode=1;});