Render credential reviews and improve responsive web views

This commit is contained in:
2026-11-12 08:57:30 +00:00
parent 0b79bb4227
commit 85080bb74e
6 changed files with 136 additions and 41 deletions
+5 -1
View File
@@ -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!(
+8
View File
@@ -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"))?;
+50 -26
View File
@@ -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');}
+23 -2
View File
@@ -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?'<ul>'+values.map(value=>'<li>'+value+'</li>').join('')+'</ul>':'<p>None.</p>';
const config=item=>'<h3>'+e(item.name)+'</h3><h4>Access rules</h4>'+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')))+'<h4>Fields</h4>'+list((item.fields||[]).map(field=>e(field.name)+' — '+(field.public?'Public':'Secret')+', '+e(field.field_type.replaceAll('_',' '))))+'<p>Transformation script: '+(item.transform_js?'Present':'None')+'.</p>';
let requested='';
if(grant.items)requested='<p>Grant access to:</p>'+grant.items.map(id=>{const item=items.find(item=>item.id===id);return item?config(item):'<p>Item '+e(id)+'</p>';}).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 '<p>'+e(request.summary)+'</p><h3>Requested by</h3><p>'+(subject.kind==='conversation'?'Conversation #':'Program ')+e(subject.id)+'</p><h3>Requested change</h3>'+requested+'<details><summary>Technical details</summary><pre>'+e(JSON.stringify(request,null,2))+'</pre></details>';
}
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,6 +49,7 @@ 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='<label>Passkey label <input name="label" required></label><button>Register passkey</button>';content.append(form);
@@ -46,11 +60,13 @@ async function credentialNavigate(){
const credential=await navigator.credentials.create(options(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 +77,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 +89,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 +97,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);}
+16 -10
View File
@@ -4,10 +4,10 @@
<style>
body{font:16px system-ui;margin:0;color:#202124;background:#fafafa}header{padding:1rem;border-bottom:1px solid #ccc}main{display:flex;height:calc(100dvh - 5rem)}nav{width:16rem;overflow:auto;padding:.6rem;border-right:1px solid #ccc}button,textarea{font:inherit}button{cursor:pointer;padding:.5rem}nav button{display:block;width:100%;text-align:left;margin-bottom:.4rem}button[aria-current=true]{background:#dceaff}section{flex:1;min-width:0;display:flex;flex-direction:column;padding:1rem}#entries{flex:1;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere}.entry{padding:.7rem;border-bottom:1px solid #ddd}.user{background:#eef4ff}pre{white-space:pre-wrap;overflow-wrap:anywhere}form{display:flex;gap:.5rem;padding-top:1rem}textarea{flex:1;min-width:0}#approvals{background:#fff1c1}#error{color:#a00}small{display:block}a{margin-right:1rem}@media(max-width:600px){nav{width:8rem}section{padding:.5rem}form{flex-wrap:wrap}}
.markdown{white-space:normal}.markdown pre,.markdown code,.tool pre{font-family:ui-monospace,monospace}.markdown pre,.tool pre{background:#f0f0f0;padding:.6rem}.markdown table{border-collapse:collapse}.markdown th,.markdown td{border:1px solid #ccc;padding:.4rem}.markdown a{margin-right:0}.thinking{display:block;color:#666;font-style:italic;white-space:pre-wrap}.tool{border:1px solid #ccc;border-radius:.4rem;padding:.6rem;margin:.6rem 0;white-space:normal}.tool.error{border-color:#b33}.tool.error summary{color:#a00}.tool summary{cursor:pointer}.tool pre{margin:.4rem 0}
[hidden]{display:none!important}#credential-view{position:fixed;inset:5rem 0 0;background:#fafafa;overflow:auto}#credential-view form{flex-wrap:wrap}#credential-view label{display:block}
[hidden]{display:none!important}*{box-sizing:border-box}body{height:100dvh;display:flex;flex-direction:column}header{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}header a{margin:0}#error:empty{display:none}#credential-notice,#approvals{width:100%;overflow-wrap:anywhere}#approvals:empty{display:none}#approvals a{display:block;padding:.5rem}main{height:auto;flex:1;min-height:0}section{min-height:0}h1{font-size:1.3rem;margin:0 0 .75rem}#entries:empty::before{content:attr(data-empty);color:#666}#credential-view{overflow:auto;flex:1;padding:1rem 1.5rem}body:has(#credential-view:not([hidden])) main{display:none}#credential-view>*{flex-shrink:0}#credential-content{width:100%;max-width:55rem}#credential-view form{flex-wrap:wrap;align-items:end}#credential-view label{display:block;margin:.5rem 0}#credential-view input,#credential-view select,#credential-view textarea{display:block;font:inherit;padding:.5rem;width:100%;max-width:100%}#credential-view fieldset{width:100%;min-width:0}#credential-view pre{max-height:25rem;overflow:auto}#credential-close{align-self:start}#credential-view h3{margin-top:1.5rem}#credential-view h4{margin-bottom:.5rem}#credential-view li{margin:.5rem 0;overflow-wrap:anywhere}.markdown table{display:block;max-width:100%;overflow:auto}.markdown img{max-width:100%}@media(max-width:600px){header{padding:.75rem}main{flex-direction:column}nav{display:flex;width:100%;max-height:6rem;min-height:4rem;border-right:0;border-bottom:1px solid #ccc;gap:.5rem}nav button{width:10rem;flex-shrink:0;margin:0}section{padding:.75rem}#compose textarea{flex-basis:100%}#credential-view{padding:1rem}#credential-view label{width:100%}}
</style>
<header><strong>Sausage chat</strong><span id="error" role="status"></span><a href="#passkeys">Passkeys</a><a href="#entry">Enter a secret</a><div id="credential-notice" hidden>No passkey exists. Register one to protect credential decisions. <button type="button" id="register-first">Register your first passkey</button></div><div id="approvals" aria-live="polite"></div></header>
<main><nav id="conversations" aria-label="Conversations"></nav><section><h1 id="title">Conversation</h1><div id="entries" role="log" aria-live="polite"></div><form id="compose"><textarea id="input" aria-label="Message" aria-keyshortcuts="Control+Enter Meta+Enter" title="Enter inserts a newline. Ctrl+Enter or Cmd+Enter sends." rows="3" required></textarea><button>Send</button><button id="stop" type="button">Stop</button></form></section></main>
<main><nav id="conversations" aria-label="Conversations"></nav><section><h1 id="title">Conversation</h1><div id="entries" role="log" aria-live="polite"></div><form id="compose"><textarea id="input" aria-label="Message" aria-keyshortcuts="Control+Enter Meta+Enter" title="Enter inserts a newline. Ctrl+Enter or Cmd+Enter sends." rows="3" placeholder="Message" required></textarea><button>Send</button><button id="stop" type="button">Stop</button></form></section></main>
<section id="credential-view" hidden aria-label="Credentials"></section>
<script>
/* Vendored Marked 15.0.12, MIT. See marked-LICENSE.md. */
@@ -23,6 +23,12 @@ body{font:16px system-ui;margin:0;color:#202124;background:#fafafa}header{paddin
const $=id=>document.getElementById(id);
let selected=null,after=0,pending=null,sequence=0;
const tools=new Map();
const decidedRequests=new Set();
function dismissCredentialRequest(id){
decidedRequests.add(id);
for(const link of $('approvals').childNodes)if(link.hash==='#approve/'+id)link.remove();
}
const visibleConversations=conversations=>conversations.filter(c=>c.id===1||!!c.agent?.model);
const escapeHTML=value=>String(value).replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
const markdownRenderer=new marked.Marked({renderer:{html({text}){return escapeHTML(text);}}});
const markdown=value=>markdownRenderer.parse(value);
@@ -83,17 +89,17 @@ function select(id){selected=id;after=0;tools.clear();$('entries').replaceChildr
async function refresh(){
try{
const {conversations,standing}=await api('conversations');
conversations.sort((a,b)=>a.id-b.id);
const visible=visibleConversations(conversations).sort((a,b)=>a.id-b.id);
if(selected===null&&standing)select(standing.conversationId);
$('conversations').replaceChildren();
const parent=c=>c.parent?.conversationId||c.owner?.conversationId||c.starter;
for(const c of conversations){
const button=document.createElement('button'),label=document.createElement('small'),p=parent(c);
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.textContent=c.title||'Conversation #'+c.id;
label.textContent=(c.running?'Running':c.concluded?'Concluded':'Idle')+(p?' · parent #'+p+' · child '+(conversations.filter(other=>parent(other)===p).findIndex(other=>other.id===c.id)+1):' · root');
button.append(label);button.onclick=()=>select(c.id);$('conversations').append(button);
if(c.id===selected)$('title').textContent=c.title||'Conversation #'+c.id;
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);
if(c.id===selected){$('title').textContent=title;$('compose').hidden=!c.agent?.model;$('entries').dataset.empty=c.agent?.model?'Send a message to start the conversation.':'This conversation has no messages.';}
}
if(selected!==null){
const id=selected,wasNearBottom=$('entries').scrollHeight-$('entries').scrollTop-$('entries').clientHeight<80;
@@ -101,7 +107,7 @@ async function refresh(){
if(id===selected){for(const item of entries){entry(item);after=Math.max(after,item.id);}if(wasNearBottom)$('entries').scrollTop=$('entries').scrollHeight;}
}
const {requests}=await api('credential-requests');$('approvals').replaceChildren();
for(const request of requests){
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);
}
$('error').textContent='';
+34 -2
View File
@@ -3,7 +3,8 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
class Element {
constructor(tag) { this.tag = tag; this.childNodes = []; this.innerHTML = ''; this.textContent = ''; }
constructor(tag) { this.tag = tag; this.childNodes = []; this.innerHTML = ''; this.textContent = ''; this.dataset = {}; this.attributes = {}; }
setAttribute(name, value) { this.attributes[name] = value; }
append(...nodes) { this.childNodes.push(...nodes); }
replaceChildren(...nodes) { this.childNodes = nodes; }
requestSubmit() { this.submits = (this.submits || 0) + 1; }
@@ -62,4 +63,35 @@ assert.equal(elements.get('compose').submits, 2);
run('select(2)');
assert.equal(run('tools.size'), 0);
assert.equal(elements.get('entries').childNodes.length, 0);
console.log('PASS markdown, escaped HTML, paired tools, errors, long results, thinking, and keyboard shortcuts');
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);
const config={id:'one',name:'Example <item>',rules:[{scheme:'https',hosts:['api.example.com'],port:443,path_prefixes:['/v1'],methods:['GET']}],fields:[{name:'token',public:false,field_type:'text'}],transform_js:'present'};
context.review={summary:'Create item',subject:{kind:'program',id:'/bin/curl'},payload:{create:config}};
context.items=[config];
let review=run('credentialReview(review,items)');
for(const value of ['Example &lt;item&gt;','https','api.example.com','443','/v1','GET','token — Secret, text','Transformation script: Present','Program /bin/curl'])assert.ok(review.includes(value),value);
assert.ok(review.includes('<details><summary>Technical details</summary><pre>'));
assert.ok(!review.split('<details>')[0].includes('"payload"'));
context.review={summary:'Grant access',subject:{kind:'conversation',id:'14'},payload:{items:['one'],duration_secs:3600}};
review=run('credentialReview(review,items)');
assert.ok(review.includes('Conversation #14'));
assert.ok(review.includes('Grant access to:'));
context.review={summary:'Empty rules',subject:{kind:'program',id:'<caller>'},payload:{create:{...config,rules:[],fields:[],transform_js:''}}};
review=run('credentialReview(review,items)');
assert.ok(review.includes('<h4>Access rules</h4><p>None.</p>'));
assert.ok(review.includes('Transformation script: None.'));
assert.ok(review.includes('&lt;caller&gt;'));
const link=new Element('a');link.hash='#approve/request';link.remove=()=>{link.removed=true;};document.getElementById('approvals').append(link);
run("dismissCredentialRequest('request')");assert.ok(link.removed);assert.ok(run("decidedRequests.has('request')"));
context.fetch=async url=>({ok:true,json:async()=>url.endsWith('/conversations')?{conversations:context.conversations,standing:{conversationId:14},main_agent:{conversationId:14}}:url.includes('/entries')?{entries:[]}:{requests:[]}});
(async()=>{
run('selected=null');await run('refresh()');
const buttons=elements.get('conversations').childNodes;
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');
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;});