From f96a8476eea14550b6457e9082135bd973987a22 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 20:25:16 -0700 Subject: [PATCH] Scope personal relay trials to account-owned phone sessions --- aesel/src/app-server.mjs | 2 +- aesel/test/app-server.test.mjs | 8 +++++ help/relay/README.md | 17 +++++++++++ help/relay/service.mjs | 49 +++++++++++++++++++++---------- help/relay/service.test.mjs | 37 +++++++++++++++++++++-- help/relay/transcription.test.mjs | 2 +- 6 files changed, 96 insertions(+), 19 deletions(-) diff --git a/aesel/src/app-server.mjs b/aesel/src/app-server.mjs index f59f088fd2..cd7375c50a 100644 --- a/aesel/src/app-server.mjs +++ b/aesel/src/app-server.mjs @@ -26,7 +26,7 @@ export class AppServer extends EventEmitter { this.cwd = cwd; this.dynamicTools = dynamicTools; this.command = command; - this.args = args.includes("app-server") ? [...args, ...codexMcpArgs(cwd,environment)] : args; + this.args = args.includes("app-server") && !dynamicTools ? [...args, ...codexMcpArgs(cwd,environment)] : args; this.environment = environment; this.resumeThreadId = resumeThreadId; this.developerInstructions = developerInstructions; diff --git a/aesel/test/app-server.test.mjs b/aesel/test/app-server.test.mjs index e89f607656..5095be2924 100644 --- a/aesel/test/app-server.test.mjs +++ b/aesel/test/app-server.test.mjs @@ -103,3 +103,11 @@ test("a bridge exit fails waiters once and subsequent writes fail immediately", await assert.rejects(engine.request('test/silent',{}),/not writable/); assert.equal(fatals,1);assert.equal(engine.pending.size,0); }); + + +test("phone dynamic tools never start server-side AC or media MCPs", () => { + const phone = new AppServer({cwd:directory,dynamicTools:[]}); + assert.deepEqual(phone.args,["app-server","--listen","stdio://"]); + const desktop = new AppServer({cwd:directory}); + assert.ok(desktop.args.some(arg=>arg.startsWith("mcp_servers.ac.command="))); +}); diff --git a/help/relay/README.md b/help/relay/README.md index c685c3e717..4c17bce806 100644 --- a/help/relay/README.md +++ b/help/relay/README.md @@ -104,3 +104,20 @@ provider adapter tests. Production checks must include unauthorized rejection and a real authenticated model turn. In the phone checkout, run `node apple/whistlegraph/Tests/personal-relay-live.mjs` after bundling to check real runtime generation and four-frame review without the messages API. + + +## Whistlegraph trials + +`AESEL_RELAY_TRIALS_FILE` points to a mode-0640 root:aesel JSON file containing +`[{"sub":"verified Auth0 subject","expiresAt":"ISO-8601 UTC expiry"}]`. +Resolve subjects from the account database; handles supplied by a client are +never grants. Remove the entry to revoke immediately. Identity lookups cache +for one minute, but grants and expiry are checked on every request. + +`GET /api/aesel/access` returns personal-model capabilities to a verified account. +Trial access permits only Claude Opus 5 / GPT-6 Astra with declared phone tools, +including an empty list for visual review. Sessions belong to the creating +subject; legacy sessions belong only to the owner. Trial access never permits +terminal tools or the unmetered speech endpoints. The service Codex config must +remain free of MCP servers; phone sessions do not inject AC/media MCPs. +Whisper speech uses Lith's separately metered `/api/whistlegraph-transcribe`. diff --git a/help/relay/service.mjs b/help/relay/service.mjs index 4bd3979be9..b0419e8f57 100644 --- a/help/relay/service.mjs +++ b/help/relay/service.mjs @@ -18,21 +18,31 @@ async function body(req) { try {return JSON.parse(text || '{}');} catch {throw fail(400,'Invalid JSON');} } -export function ownerAuth({adminSub, domain='aesthetic.us.auth0.com', fetch=globalThis.fetch}) { +// Cache identity verification only. Re-read grants on every request so removal +// or expiry revokes access even while the Auth0 cache is warm. +export function ownerAuth({adminSub, domain='aesthetic.us.auth0.com', fetch=globalThis.fetch, trials=()=>[], now=Date.now}) { if (!adminSub) throw Error('ADMIN_SUB is required'); const cache = new Map(); return async header => { if (!/^Bearer \S+$/.test(header || '')) throw fail(401,'Sign in to Aesthetic Computer'); const key=createHash('sha256').update(header).digest('hex'); - if ((cache.get(key)||0)>Date.now()) return; - const response=await fetch(`https://${domain}/userinfo`, {headers:{authorization:header},signal:AbortSignal.timeout(10000)}); - if (!response.ok) throw fail(401,'Session expired'); - const user=await response.json(); - if (user.sub!==adminSub || user.email_verified!==true) throw fail(403,'This relay is private'); - if (cache.size>=100) cache.clear(); - cache.set(key,Date.now()+60000); + let user=cache.get(key)?.until>now()?cache.get(key).user:null; + if(!user) { + const response=await fetch(`https://${domain}/userinfo`, {headers:{authorization:header},signal:AbortSignal.timeout(10000)}); + if (!response.ok) throw fail(401,'Session expired'); + user=await response.json(); + if(typeof user.sub!=='string'||!user.sub||user.email_verified!==true)throw fail(403,'Verify your email first'); + if (cache.size>=100) cache.clear(); + cache.set(key,{user:{sub:user.sub,email_verified:true},until:now()+60000}); + } + if(user.sub===adminSub)return {sub:user.sub,owner:true,personal:true,providers:['claude','codex'],expiresAt:null}; + const grant=(await trials()).find(g=>g.sub===user.sub&&Date.parse(g.expiresAt)>now()); + return {sub:user.sub,owner:false,personal:!!grant,providers:grant?['claude','codex']:[],expiresAt:grant?.expiresAt||null}; }; } +export function trialFile(path) { + return ()=>{if(!path)return [];try {const value=JSON.parse(readFileSync(path,'utf8'));return Array.isArray(value)?value:[];}catch{return [];}}; +} export function createRelay({root, authorize, factory, transcribe, speechSession, maxActive=2}={}) { if (!root || !authorize) throw Error('State directory and authorization are required'); @@ -45,12 +55,15 @@ export function createRelay({root, authorize, factory, transcribe, speechSession appendFileSync(join(s.dir,'events.jsonl'),JSON.stringify(entry)+'\n',{mode:0o600}); s.events.push(entry); } - function load(id) { + function owns(meta,principal) { + if(principal && (meta.ownerSub?meta.ownerSub!==principal.sub:!principal.owner))throw fail(404,'Session not found'); + } + function load(id,principal) { if (!uuid.test(id)) throw fail(400,'Invalid session id'); - if (sessions.has(id)) return sessions.get(id); + if (sessions.has(id)) {const s=sessions.get(id);owns(s.meta,principal);return s;} const dir=join(root,id); if (!existsSync(join(dir,'session.json'))) throw fail(404,'Session not found'); - const meta=JSON.parse(readFileSync(join(dir,'session.json'),'utf8')); + const meta=JSON.parse(readFileSync(join(dir,'session.json'),'utf8'));owns(meta,principal); const events=existsSync(join(dir,'events.jsonl'))?readFileSync(join(dir,'events.jsonl'),'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)):[]; const s={dir,meta,events,seq:events.at(-1)?.seq||0,engine:null,pending:new Map()}; sessions.set(id,s); @@ -136,18 +149,24 @@ export function createRelay({root, authorize, factory, transcribe, speechSession const timer=setTimeout(()=>{s.pending.delete(id);toolReplies.delete(id);json(res,200,{isError:true,content:[{type:'text',text:'Phone tool timed out; reconnect the phone.'}]});},180000); toolReplies.set(id,{s,res,timer});res.on('close',()=>{clearTimeout(timer);toolReplies.delete(id);s.pending.delete(id);});return; } - await authorize(req.headers.authorization); + const principal=await authorize(req.headers.authorization); + if(!principal?.sub)throw fail(403,'This relay is private'); + if(url.pathname==='/api/aesel/access'&&req.method==='GET')return json(res,200,{personal:principal.personal,providers:principal.providers,expiresAt:principal.expiresAt}); + if(!principal.personal)throw fail(403,'Personal relay access is unavailable or expired'); if(url.pathname==='/api/aesel/transcription-session' && req.method==='POST') { + if(!principal.owner)throw fail(403,'Use AC metered speech'); if(!speechSession)throw fail(503,'Live speech is unavailable'); return json(res,200,await speechSession()); } if(url.pathname==='/api/aesel/transcribe' && req.method==='POST') { + if(!principal.owner)throw fail(403,'Use AC metered speech'); if(!transcribe)throw fail(503,'Speech transcription is unavailable'); return json(res,200,await transcribe(await body(req))); } if(url.pathname==='/api/aesel/sessions' && req.method==='POST') { const input=await body(req); if(!['claude','codex'].includes(input.provider))throw fail(400,'Choose claude or codex'); + if(!principal.owner && (!Array.isArray(input.clientTools) || !['claude-opus-5','gpt-6-astra'].includes(input.model) || input.model!==({claude:'claude-opus-5',codex:'gpt-6-astra'})[input.provider]))throw fail(403,'Trial access is for Whistlegraph phone tools'); if(input.provider==='codex' && !factory && process.env.CODEX_ENABLED!=='1') throw fail(503,'Codex needs a server-side login'); for(const key of ['model','effort','instructions'])if(input[key]!=null && (typeof input[key]!=='string'||input[key].length>(key==='instructions'?512000:100)))throw fail(400,`Invalid ${key}`); if(input.clientTools!==undefined) { @@ -155,13 +174,13 @@ export function createRelay({root, authorize, factory, transcribe, speechSession } const id=randomUUID(),dir=join(root,id); mkdirSync(join(dir,'workspace'),{recursive:true,mode:0o700}); - const meta={id,provider:input.provider,model:input.model||undefined,effort:input.effort||'',instructions:input.instructions||'',...(input.clientTools?{clientTools:input.clientTools,phoneSecret:randomUUID()+randomUUID()}:{}),requests:{},busy:false,created:new Date().toISOString()}; + const meta={id,ownerSub:principal.sub,provider:input.provider,model:input.model||undefined,effort:input.effort||'',instructions:input.instructions||'',...(input.clientTools?{clientTools:input.clientTools,phoneSecret:randomUUID()+randomUUID()}:{}),requests:{},busy:false,created:new Date().toISOString()}; save(join(dir,'session.json'),meta); return json(res,201,{thread:{id},provider:meta.provider}); } const match=url.pathname.match(/^\/api\/aesel\/sessions\/([^/]+)(?:\/(turn|respond|interrupt))?$/); if(!match)throw fail(404,'Not found'); - const s=load(match[1]),action=match[2]; + const s=load(match[1],principal),action=match[2]; if(req.method==='GET' && !action) { const after=Number(url.searchParams.get('after')||0); if(!Number.isSafeInteger(after)||after<0)throw fail(400,'Invalid event cursor'); @@ -219,7 +238,7 @@ if(process.argv[1] && import.meta.url===pathToFileURL(process.argv[1]).href) { delete process.env.WHISTLEGRAPH_TRANSCRIPTION_KEY; // A private subscription relay must never silently fall through to paid API keys. for(const key of ['ANTHROPIC_API_KEY','ANTHROPIC_AUTH_TOKEN','OPENAI_API_KEY','OPENROUTER_API_KEY'])delete process.env[key]; - const server=createRelay({root:process.env.AESEL_RELAY_STATE||'/var/lib/aesel-relay',transcribe,speechSession,authorize:ownerAuth({adminSub:process.env.ADMIN_SUB,domain:process.env.AUTH0_DOMAIN})}); + const server=createRelay({root:process.env.AESEL_RELAY_STATE||'/var/lib/aesel-relay',transcribe,speechSession,authorize:ownerAuth({adminSub:process.env.ADMIN_SUB,domain:process.env.AUTH0_DOMAIN,trials:trialFile(process.env.AESEL_RELAY_TRIALS_FILE)})}); server.listen(Number(process.env.AESEL_RELAY_PORT||3006),'127.0.0.1',()=>console.log('Aesel relay listening on loopback')); for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>{server.close();setTimeout(()=>process.exit(0),1000).unref();}); } diff --git a/help/relay/service.test.mjs b/help/relay/service.test.mjs index fa608a6940..67ebfa3d6c 100644 --- a/help/relay/service.test.mjs +++ b/help/relay/service.test.mjs @@ -16,7 +16,7 @@ class Engine extends EventEmitter { } async function setup(t){ const root=mkdtempSync(join(tmpdir(),'aesel-relay-'));let engine; - const authorize=async header=>{if(header!=='Bearer owner')throw Object.assign(Error('Private'),{status:403});}; + const authorize=async header=>{if(!['Bearer owner','Bearer trial','Bearer outsider'].includes(header))throw Object.assign(Error('Private'),{status:403});return {sub:header.slice(7),owner:header==='Bearer owner',personal:header!=='Bearer outsider',providers:['claude','codex']};}; const start=async()=>{const server=createRelay({root,authorize,factory:(_provider,options)=>engine=new Engine(!!options.clientMcp)});server.listen(0,'127.0.0.1');await once(server,'listening');return server;}; let server=await start(); const url=()=>`http://127.0.0.1:${server.address().port}`; @@ -28,7 +28,7 @@ const base='/api/aesel/sessions'; test('only the verified owner can enter, with no token cached in plaintext',async()=>{ let calls=0;const auth=ownerAuth({adminSub:'owner',fetch:async()=>{calls++;return {ok:true,json:async()=>({sub:'owner',email_verified:true})};}}); await assert.rejects(auth(),{status:401});await auth('Bearer ok');await auth('Bearer ok');assert.equal(calls,1); - const denied=ownerAuth({adminSub:'owner',fetch:async()=>({ok:true,json:async()=>({sub:'someone-else',email_verified:true})})});await assert.rejects(denied('Bearer bad'),{status:403}); + const denied=ownerAuth({adminSub:'owner',fetch:async()=>({ok:true,json:async()=>({sub:'someone-else',email_verified:true})})});assert.equal((await denied('Bearer bad')).personal,false); }); test('saved drawing, idempotent submission, approvals and restart recovery',async t=>{ const f=await setup(t);assert.equal((await f.call(base,{provider:'claude'},'other')).status,403); @@ -104,3 +104,36 @@ test('Astra phone tools use the same owner gate and preserve dynamic tool result assert.equal(response.result.success,false); assert.equal((await f.call(`${base}/${thread.id}`)).pending.length,0); }); + +test('trial grants expire and revoke even with cached verified identity',async()=>{ + let clock=100000, grants=[{sub:'fifi',expiresAt:new Date(101000).toISOString()}], calls=0; + const auth=ownerAuth({adminSub:'owner',now:()=>clock,trials:()=>grants,fetch:async()=>{calls++;return {ok:true,json:async()=>({sub:'fifi',email_verified:true})}}}); + assert.equal((await auth('Bearer trial')).personal,true); + grants=[];assert.equal((await auth('Bearer trial')).personal,false); + grants=[{sub:'fifi',expiresAt:new Date(101000).toISOString()}];clock=101000; + assert.equal((await auth('Bearer trial')).personal,false);assert.equal(calls,1); + const unverified=ownerAuth({adminSub:'owner',trials:()=>grants,fetch:async()=>({ok:true,json:async()=>({sub:'fifi',email_verified:false})})}); + await assert.rejects(unverified('Bearer trial'),{status:403}); +}); +test('trial phone access cannot enter terminal sessions or another account session',async t=>{ + const f=await setup(t); + assert.equal((await f.call('/api/aesel/access',null,'trial')).personal,true); + assert.equal((await f.call('/api/aesel/access',null,'outsider')).personal,false); + assert.equal((await f.call(base,{provider:'claude'},'trial')).status,403); + assert.equal((await f.call(base,{provider:'codex',model:'gpt-6-astra',clientTools:[]},'outsider')).status,403); + const owner=await f.call(base,{provider:'claude'}); + const trial=await f.call(base,{provider:'claude',model:'claude-opus-5',clientTools:[]},'trial'); + assert.equal(trial.status,201); + for(const [id,who] of [[owner.thread.id,'trial'],[trial.thread.id,'owner']]){ + assert.equal((await f.call(`${base}/${id}`,null,who)).status,404); + for(const action of ['turn','respond','interrupt'])assert.equal((await f.call(`${base}/${id}/${action}`,{requestId:randomUUID(),text:'no'},who)).status,404); + } + const state=await f.call(`${base}/${trial.thread.id}`,null,'trial'); + assert.equal(state.status,200);assert.ok(!JSON.stringify(state).includes('ownerSub'));assert.ok(!JSON.stringify(state).includes('phoneSecret')); + // Existing files without an owner remain owner-only after deployment. + const path=join(f.root,owner.thread.id,'session.json'), meta=JSON.parse(readFileSync(path));delete meta.ownerSub; + const {writeFileSync}=await import('node:fs');writeFileSync(path,JSON.stringify(meta));await f.restart(); + assert.equal((await f.call(`${base}/${owner.thread.id}`,null,'trial')).status,404); + assert.equal((await f.call(`${base}/${owner.thread.id}`)).status,200); + for(const route of ['transcribe','transcription-session'])assert.equal((await f.call('/api/aesel/'+route,{},'trial')).status,403); +}); diff --git a/help/relay/transcription.test.mjs b/help/relay/transcription.test.mjs index e482ed000b..de0f1e1751 100644 --- a/help/relay/transcription.test.mjs +++ b/help/relay/transcription.test.mjs @@ -25,7 +25,7 @@ test('ephemeral sessions use live transcription with manual end-of-take commit', }); test('both speech routes require owner auth and leave no recording/session files',async t=>{ const root=mkdtempSync(join(tmpdir(),'speech-'));let calls=0; - const server=createRelay({root,authorize:async h=>{if(h!=='Bearer owner')throw Object.assign(Error('Private'),{status:403});},speechSession:async()=>{calls++;return {value:'ephemeral'}},transcribe:async()=>{calls++;return {transcript:'hello',words:[]}}}); + const server=createRelay({root,authorize:async h=>{if(h!=='Bearer owner')throw Object.assign(Error('Private'),{status:403});return {sub:'owner',owner:true,personal:true};},speechSession:async()=>{calls++;return {value:'ephemeral'}},transcribe:async()=>{calls++;return {transcript:'hello',words:[]}}}); server.listen(0,'127.0.0.1');await once(server,'listening');t.after(()=>{server.closeAllConnections();server.close();rmSync(root,{recursive:true,force:true});}); for(const route of ['transcribe','transcription-session']){ const url=`http://127.0.0.1:${server.address().port}/api/aesel/${route}`; -- 2.51.2