Render credential reviews and improve responsive web views
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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');}
|
||||
|
||||
@@ -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
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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='';
|
||||
|
||||
@@ -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 <item>','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('<caller>'));
|
||||
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;});
|
||||
|
||||
Reference in New Issue
Block a user