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..e734140 100644 --- a/userland/web/browser-check.mjs +++ b/userland/web/browser-check.mjs @@ -1,9 +1,13 @@ -// 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. +// Manual QA against a fresh world on localhost:8080. Uses Chrome's virtual passkeys, not real secrets. import {execFileSync} from 'node:child_process'; +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(); +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 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/')); @@ -11,34 +15,54 @@ try{ await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;}); let next=0; const pending=new Map(); - 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);}}; + 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();}; 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/'); 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); + 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])'); - 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');} + 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); + 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.'); + 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); + 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'); + 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"); + // 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'); + 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');} diff --git a/userland/web/credentials-view.js b/userland/web/credentials-view.js index ba65992..4f5f909 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=>'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(request.summary)+'
'+(subject.kind==='conversation'?'Conversation #':'Program ')+e(subject.id)+'
'+e(JSON.stringify(request,null,2))+'