diff --git a/host/src/cred/summary.rs b/host/src/cred/summary.rs index 2dec79d..08c1152 100644 --- a/host/src/cred/summary.rs +++ b/host/src/cred/summary.rs @@ -68,7 +68,11 @@ fn rules_text(rules: &[super::items::Rule]) -> String { )); } } - format!("Rules: {}.", hosts.join(", ")) + if hosts.is_empty() { + "No access rules. This item cannot match a request.".into() + } else { + format!("Rules: {}.", hosts.join(", ")) + } } fn creation(config: &super::items::Item) -> String { format!( diff --git a/host/tests/it/chat.rs b/host/tests/it/chat.rs index 9e84f53..25f2a3d 100644 --- a/host/tests/it/chat.rs +++ b/host/tests/it/chat.rs @@ -32,6 +32,14 @@ async fn request(port: u16, path: &str, body: Option<&str>) -> anyhow::Result<(u Ok((status, bytes[end..].to_vec())) } #[test] +fn empty_rule_summary() -> anyhow::Result<()> { + let item = serde_json::from_value(serde_json::json!({"name":"Example","rules":[],"transform_js":"","fields":[]}))?; + let summary = sausage::cred::summary::operation_summary(&sausage::cred::edits::Operation::CreateItem { config: item }); + assert!(summary.contains("No access rules.")); + assert!(!summary.contains("Rules: .")); + Ok(()) +} +#[test] fn page_rendering() -> anyhow::Result<()> { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/web"); let page = std::fs::read_to_string(source.join("index.html"))?; diff --git a/userland/web/browser-check.mjs b/userland/web/browser-check.mjs index c266a60..1d77ca5 100644 --- a/userland/web/browser-check.mjs +++ b/userland/web/browser-check.mjs @@ -1,13 +1,19 @@ -// Manual check: run against a fresh world on localhost:8080. Requires agent-browser and Chrome. -// Set AGENT_BROWSER_EXECUTABLE_PATH if Chrome is not available at the default path. -import {execFileSync} from 'node:child_process'; +// Manual QA against a fresh world on localhost:8080. Uses Chrome's virtual passkeys, not real secrets. +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=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(); @@ -18,27 +24,48 @@ try{ assert.ok(page,'Chat tab exists'); const {sessionId}=await cdp('Target.attachToTarget',{targetId:page.targetId,flatten:true}); await cdp('WebAuthn.enable',{},sessionId); - await cdp('WebAuthn.addVirtualAuthenticator',{options:{protocol:'ctap2',transport:'internal',hasResidentKey:true,hasUserVerification:true,isUserVerified:true,automaticPresenceSimulation:true}},sessionId); - browser('wait','#credential-notice:not([hidden])'); - browser('click','#register-first'); - browser('wait','input[name=label]'); - browser('fill','input[name=label]','Browser check'); - browser('click','#credential-view form button'); - browser('wait','--text','Complete.'); - const name='Browser check '+Date.now(); - const filed=await fetch('http://localhost:8080/api/cred/requests',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'edit',key:name,request:{item:null,base_version:null,change:[{op:'create_item',config:{name,rules:[],transform_js:'',fields:[]}}]}})}); - assert.equal(filed.status,200); - const {request}=await filed.json(); - browser('click','#credential-close'); - browser('wait',`#approvals a[href="/#approve/${request}"]`); - browser('click',`#approvals a[href="/#approve/${request}"]`); - browser('wait','#credential-view button[value=approve]'); - browser('click','#credential-view button[value=approve]'); - browser('wait','--text','Complete.'); - const record=await (await fetch('http://localhost:8080/api/cred/requests/'+request)).json(); - assert.equal(record.state,'approved'); - const items=await (await fetch('http://localhost:8080/api/cred/items')).json(); - assert.ok(items.some(item=>item.name===name)); - console.log('PASS: first registration, passkey approval, approved request, and created item.'); - console.log(browser('screenshot','/tmp/sausage-credential-check.png')); -}finally{socket?.close();browser('close');} + const authenticatorOptions={protocol:'ctap2',transport:'internal',hasResidentKey:true,hasUserVerification:true,isUserVerified:true,automaticPresenceSimulation:true}; + let {authenticatorId}=await cdp('WebAuthn.addVirtualAuthenticator',{options:authenticatorOptions},sessionId); + 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'}]}}]}}); + 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(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}}); + 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}}); + 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. + 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(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 ba65992..a7a2a35 100644 --- a/userland/web/credentials-view.js +++ b/userland/web/credentials-view.js @@ -1,4 +1,16 @@ // Credential views use only the host's JSON API. Secrets leave the browser encrypted. +function credentialReview(request,items){ + const e=escapeHTML,p=request.payload,grant=p.grant_create||p; + const list=values=>values.length?'':'

None.

'; + const config=item=>'

'+e(item.name)+'

Access rules

'+list((item.rules||[]).map(rule=>e(rule.scheme)+' · '+e(rule.hosts.join(', '))+' · port '+e(rule.port??({https:443,http:80,ssh:22}[rule.scheme.toLowerCase()]||'default'))+' · paths '+e(rule.path_prefixes.join(', ')||'None')+' · methods '+e(rule.methods==null?'All':rule.methods.join(', ')||'None')))+'

Fields

'+list((item.fields||[]).map(field=>e(field.name)+' — '+(field.public?'Public':'Secret')+', '+e(field.field_type.replaceAll('_',' '))))+'

Transformation script: '+(item.transform_js?'Present':'None')+'.

'; + let requested=''; + if(grant.items)requested='

Grant access to:

'+grant.items.map(id=>{const item=items.find(item=>item.id===id);return item?config(item):'

Item '+e(id)+'

';}).join(''); + else if(p.create||p.edit?.config)requested=config(p.create||p.edit.config); + 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(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'); document.getElementById('register-first').onclick=()=>{location.hash='passkeys';}; @@ -12,9 +24,10 @@ function credentialError(error){(document.getElementById('credential-status')||d function credentialForm(action){ credentialView.querySelector('form').onsubmit=async event=>{ event.preventDefault(); + document.getElementById('credential-status').textContent=''; const buttons=[...event.target.querySelectorAll('button')]; buttons.forEach(button=>button.disabled=true); - try{await action(new FormData(event.target),event.submitter.value);document.getElementById('credential-status').textContent='Complete.';await credentialState();} + try{const status=await action(new FormData(event.target),event.submitter.value);document.getElementById('credential-status').textContent=status;await credentialState();} catch(error){credentialError(error);} finally{buttons.forEach(button=>button.disabled=false);} }; @@ -36,21 +49,28 @@ async function credentialNavigate(){ credentialView.querySelector('h2').textContent='Passkeys'; const passkeys=await credentialState(); const list=document.createElement('ul'); + 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.'; }); }else if(parts[0]==='approve'){ credentialView.querySelector('h2').textContent='Review credential request'; const id=parts[1],request=await credentialGet('requests/'+encodeURIComponent(id)); - const summary=document.createElement('p'),details=document.createElement('pre');summary.textContent=request.summary;details.textContent=JSON.stringify(request,null,2);content.append(summary,details); + content.innerHTML=credentialReview(request,await credentialGet('items')); + if(request.state!=='pending'){document.getElementById('credential-status').textContent='Request '+request.state+'.';return;} const grant=request.payload.grant_create||request.payload; const limits=!!request.payload.grant_create||!!request.payload.items?.length; const form=document.createElement('form'); @@ -61,6 +81,9 @@ async function credentialNavigate(){ const overrides=decision==='deny'||!limits?{}:{duration_secs:Number(data.get('duration')),max_uses:data.get('uses')?Number(data.get('uses')):null}; const challenge=await post(path+'/challenge',{decision,overrides}); await post(path+'/decide',challenge.unlocked?{decision,overrides}:await assertion(challenge)); + dismissCredentialRequest(id); + await credentialNavigate(); + return decision==='approve'?'Request approved.':'Request denied.'; }); }else{ credentialView.querySelector('h2').textContent='Enter a credential secret'; @@ -70,6 +93,7 @@ async function credentialNavigate(){ const option=document.createElement('option');option.value=JSON.stringify([item.id,field.field_id]);option.textContent=item.name+' / '+field.name;form.elements.field.append(option); if(item.id===parts[1]&&field.field_id===parts[2])option.selected=true; } + if(!form.elements.field.options.length){form.hidden=true;content.append(document.createTextNode('No secret fields exist. Create a credential item with a secret field first.'));return;} credentialForm(async data=>{ const [item,field]=JSON.parse(data.get('field')); const path='/cred/items/'+encodeURIComponent(item)+'/fields/'+encodeURIComponent(field); @@ -77,6 +101,7 @@ async function credentialNavigate(){ const encrypted=await encrypt(public_key,data.get('secret'));form.elements.secret.value=''; const proof=await assertion(await post(path+'/challenge',{ciphertext_sha256:encrypted.ciphertext_sha256})); await post(path,{...proof,envelope:encrypted.envelope}); + return 'Secret saved.'; }); } }catch(error){credentialError(error);} diff --git a/userland/web/index.html b/userland/web/index.html index 804e562..6bb49e2 100644 --- a/userland/web/index.html +++ b/userland/web/index.html @@ -4,10 +4,10 @@
Sausage chatPasskeysEnter a secret
-

Conversation

+

Conversation