diff --git a/aesel/src/claude-server.mjs b/aesel/src/claude-server.mjs index af504a5153..1973e5532f 100644 --- a/aesel/src/claude-server.mjs +++ b/aesel/src/claude-server.mjs @@ -89,11 +89,15 @@ export class ClaudeServer extends EventEmitter { recoveryInstructions = "", // aesel's native tools (ac_api, ac_examples, ac_outline, ac_symbol). tools = true, + clientMcp = null, + persistSession = true, passthrough = false, }) { super(); this.cwd = cwd; this.tools = tools; + this.clientMcp = clientMcp; + this.persistSession = persistSession; this.command = command; this.args = args; this.environment = environment; @@ -120,7 +124,7 @@ export class ClaudeServer extends EventEmitter { // Text streaming state for the message being written right now. this.messageId = ""; this.textItems = new Map(); - this.codeItems = new Set(); + this.codeItems = new Map(); this.streamedText = false; } @@ -241,6 +245,7 @@ export class ClaudeServer extends EventEmitter { #launchArguments(resume) { const args = [ "--print", + ...(this.persistSession ? [] : ["--no-session-persistence"]), "--input-format", "stream-json", "--output-format", @@ -271,7 +276,9 @@ export class ClaudeServer extends EventEmitter { // nothing else, so they are allowed up front — an approval prompt for // "what does circle take?" would cost the round trip the tool exists to // save. - if (this.tools) { + if (this.clientMcp) { + args.push("--tools", "", "--mcp-config", JSON.stringify(this.clientMcp), "--allowedTools", "mcp__phone"); + } else if (this.tools) { args.push("--mcp-config", JSON.stringify(mcpConfig(this.cwd,this.environment))); args.push("--allowedTools", `mcp__${SERVER_NAME}`, 'mcp__easel-media'); } @@ -448,9 +455,13 @@ export class ClaudeServer extends EventEmitter { this.streamedText = false; return; } - if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use' && ['Write','Edit','MultiEdit','write_piece'].includes(event.content_block.name)) this.codeItems.add(event.index); + if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') { + const tool=event.content_block.name.replace(/^mcp__phone__/, ''); + if (['Write','Edit','MultiEdit','write_piece','edit_piece'].includes(tool)) + this.codeItems.set(event.index,{tool,itemId:event.content_block.id}); + } if (event.type === 'content_block_delta' && event.delta?.type === 'input_json_delta' && this.codeItems.has(event.index)) { - this.emit('notification',{method:'item/modelCode/delta',params:{delta:event.delta.partial_json || ''}}); + this.emit('notification',{method:'item/modelCode/delta',params:{...this.codeItems.get(event.index),delta:event.delta.partial_json || ''}}); } if (event.type === 'content_block_stop') this.codeItems.delete(event.index); if (event.type === "content_block_start" && event.content_block?.type === "text") { diff --git a/help/relay/README.md b/help/relay/README.md index c0d734f9bf..7d7795a204 100644 --- a/help/relay/README.md +++ b/help/relay/README.md @@ -17,13 +17,20 @@ Work runs in a separate directory on the VPS, not the client's local checkout. Tool approvals return to the terminal. Closing the client detaches without cancelling the turn. `--resume ` reconnects; the relay replays its saved events and only unresolved approvals. No filesystem synchronization -or phone integration is implicit in this backend. +is implicit in this terminal backend. + +Whistlegraph's verified Jeffrey account uses a separate phone adapter with +Claude Opus 5. Its four permitted tools (`write_piece`, `edit_piece`, +`ac_preview`, `ac_api`) execute on the phone; built-in server tools are disabled. +The phone journals request IDs and tool results so reconnecting can recover +accepted work. Generation and four-frame visual review both use the relay. +Unsent chalk is saved atomically in the native app's local storage. ## Protocol Every private request needs `Authorization: Bearer `. -- `POST /api/aesel/sessions`: `{provider:"claude"|"codex", model?, effort?, instructions?}`. +- `POST /api/aesel/sessions`: `{provider:"claude"|"codex", model?, effort?, instructions?, clientTools?}`. - `POST /api/aesel/sessions/:id/turn`: `{requestId:, text, images?:[{mimeType,data:}]}`. - `GET /api/aesel/sessions/:id?after=`: events (up to 500), pending approvals, request statuses. - `POST /api/aesel/sessions/:id/respond`: `{id, result}` matching an outstanding engine request. @@ -35,7 +42,10 @@ bytes, is written before acknowledgement. Events are appended as they arrive; a service restart marks unfinished attempts interrupted and preserves input. A new request UUID is an explicit retry. State lives under `/var/lib/aesel-relay/sessions`, private to the service account. Nothing deletes -saved drawings automatically. This does not save an unsent phone draft. +saved drawings automatically. Tool-free phone review (`clientTools: []`) +keeps image bytes transient and disables Claude session persistence. Its +request record retains only the image count. Native local draft storage is +independent of the relay. ## Host setup @@ -76,5 +86,6 @@ handle { Validate with `node --test help/relay/service.test.mjs` and the existing Aesel provider adapter tests. Production checks must include unauthorized rejection -and a real authenticated model turn. Whistlegraph still uses the messages API; -it needs a client adapter for this session protocol before switching traffic. +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. diff --git a/help/relay/phone-tools.mjs b/help/relay/phone-tools.mjs new file mode 100644 index 0000000000..d23ca3c35b --- /dev/null +++ b/help/relay/phone-tools.mjs @@ -0,0 +1,17 @@ +// MCP adapter for tools that execute in the owner's phone, never on this host. +import {createInterface} from 'node:readline'; +async function call(path,input) { + const r=await fetch(process.env.AESEL_PHONE_URL+'/'+path,{method:input?'POST':'GET',headers:{authorization:'Bearer '+process.env.AESEL_PHONE_SECRET,'content-type':'application/json'},...(input?{body:JSON.stringify(input)}:{})}); + if(!r.ok)throw Error('Phone tool channel unavailable');return r.json(); +} +createInterface({input:process.stdin}).on('line',async line=>{ + let m;try{m=JSON.parse(line);}catch{return;}if(m.id===undefined)return; + try { + let result; + if(m.method==='initialize')result={protocolVersion:m.params?.protocolVersion||'2025-06-18',capabilities:{tools:{}},serverInfo:{name:'phone',version:'1'}}; + else if(m.method==='tools/list')result=await call('tools'); + else if(m.method==='tools/call')result=await call('call',m.params); + else if(m.method==='ping')result={};else throw Error('Unknown method'); + process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result})+'\n'); + }catch(e){process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,error:{code:-32000,message:e.message}})+'\n');} +}); diff --git a/help/relay/service.mjs b/help/relay/service.mjs index db18a585f2..89bd626c73 100644 --- a/help/relay/service.mjs +++ b/help/relay/service.mjs @@ -3,7 +3,7 @@ import http from 'node:http'; import {randomUUID, createHash} from 'node:crypto'; import {mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync, existsSync} from 'node:fs'; import {join} from 'node:path'; -import {pathToFileURL} from 'node:url'; +import {fileURLToPath, pathToFileURL} from 'node:url'; import {ClaudeServer} from '../../aesel/src/claude-server.mjs'; import {AppServer} from '../../aesel/src/app-server.mjs'; @@ -37,6 +37,7 @@ export function createRelay({root, authorize, factory, maxActive=2}={}) { if (!root || !authorize) throw Error('State directory and authorization are required'); mkdirSync(root,{recursive:true,mode:0o700}); const sessions=new Map(); + const toolReplies=new Map(); function persist(s) {save(join(s.dir,'session.json'),s.meta);} function event(s,type,value) { const entry={seq:++s.seq,at:new Date().toISOString(),type,value}; @@ -66,7 +67,8 @@ export function createRelay({root, authorize, factory, maxActive=2}={}) { if(s.opening) return s.opening; s.opening=(async()=>{ const options={cwd:join(s.dir,'workspace'),resumeThreadId:s.meta.engineThreadId||'',model:s.meta.model,effort:s.meta.effort, - developerInstructions:s.meta.instructions,tools:true}; + developerInstructions:s.meta.instructions,tools:!s.meta.clientTools,persistSession:s.meta.clientTools?.length!==0, + ...(s.meta.clientTools?{clientMcp:{mcpServers:{phone:{command:process.execPath,args:[fileURLToPath(new URL('./phone-tools.mjs',import.meta.url))],env:{AESEL_PHONE_URL:`http://127.0.0.1:${server.address().port}/internal/phone/${s.meta.id}`,AESEL_PHONE_SECRET:s.meta.phoneSecret}}}}}:{})}; // Only the service's subscription credentials reach the child. Never // forward HTTP credentials, client environment, commands or local paths. const e=factory?factory(s.meta.provider,options):s.meta.provider==='claude' @@ -90,7 +92,7 @@ export function createRelay({root, authorize, factory, maxActive=2}={}) { } event(s,'notification',value);persist(s); }); - e.on('request',value=>{s.pending.set(String(value.id),value);event(s,'request',value);}); + e.on('request',value=>{if(s.meta.clientTools){e.respond(value.id,{decision:'decline'});return;}s.pending.set(String(value.id),value);event(s,'request',value);}); e.on('fatal',()=>{ s.meta.busy=false; const request=s.meta.requests[s.meta.currentRequest];if(request)request.status='failed'; @@ -106,15 +108,36 @@ export function createRelay({root, authorize, factory, maxActive=2}={}) { try { const url=new URL(req.url,'http://localhost'); if(url.pathname==='/health' && req.method==='GET') return json(res,200,{ok:true,service:'aesel-relay',version:1,busy:[...sessions.values()].filter(s=>s.meta.busy).length}); + const origin=req.headers.origin; + if(origin && (['null','walkieware://app','https://aesthetic.computer'].includes(origin) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin))) { + res.setHeader('Access-Control-Allow-Origin',origin);res.setHeader('Vary','Origin'); + res.setHeader('Access-Control-Allow-Headers','Authorization,Content-Type');res.setHeader('Access-Control-Allow-Methods','GET,POST,OPTIONS'); + } + if(req.method==='OPTIONS') {res.writeHead(204);res.end();return;} + const internal=url.pathname.match(/^\/internal\/phone\/([^/]+)\/(tools|call)$/); + if(internal) { + const s=load(internal[1]); + if(!s.meta.phoneSecret || req.headers.authorization!==`Bearer ${s.meta.phoneSecret}`)throw fail(403,'Private tool channel'); + if(internal[2]==='tools')return json(res,200,{tools:s.meta.clientTools}); + const input=await body(req); + if(!s.meta.busy || !s.meta.clientTools.some(t=>t.name===input.name))throw fail(400,'Tool unavailable'); + const id=randomUUID(),request={id,method:'phone/tool',params:{name:input.name,input:input.arguments||{}}}; + s.pending.set(id,request);event(s,'request',request); + 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); 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(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'?64000:100)))throw fail(400,`Invalid ${key}`); + 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) { + if(input.provider!=='claude' || !Array.isArray(input.clientTools) || input.clientTools.length>4 || input.clientTools.some(t=>!t||!['write_piece','edit_piece','ac_preview','ac_api'].includes(t.name)||typeof t.description!=='string'||!t.inputSchema||typeof t.inputSchema!=='object'))throw fail(400,'Invalid phone tools'); + } 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||'',requests:{},busy:false,created:new Date().toISOString()}; + 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()}; save(join(dir,'session.json'),meta); return json(res,201,{thread:{id},provider:meta.provider}); } @@ -138,7 +161,7 @@ export function createRelay({root, authorize, factory, maxActive=2}={}) { if([...sessions.values()].filter(v=>v.meta.busy).length>=maxActive)throw fail(429,'Relay is busy; retry shortly'); // Save the complete request before spawning or acknowledging it. Losing // a network connection never loses an accepted drawing or repeats a turn. - save(join(s.dir,input.requestId+'.json'),input); + save(join(s.dir,input.requestId+'.json'),s.meta.clientTools?.length===0?{...input,images:[],transientImageCount:images.length}:input); s.meta.busy=true;s.meta.currentRequest=input.requestId; s.meta.requests[input.requestId]={requestId:input.requestId,status:'running'};persist(s); json(res,202,s.meta.requests[input.requestId]); @@ -153,14 +176,19 @@ export function createRelay({root, authorize, factory, maxActive=2}={}) { const pending=s.pending.get(String(input.id)); if(!input.result || typeof input.result!=='object' || Array.isArray(input.result))throw fail(400,'Invalid response'); if(pending.method.endsWith('/requestApproval')&&!['accept','acceptForSession','decline','cancel'].includes(input.result.decision))throw fail(400,'Invalid approval decision'); - s.engine.respond(input.id,input.result);s.pending.delete(String(input.id)); + if(pending.method==='phone/tool') { + const reply=toolReplies.get(String(input.id));if(!reply || reply.s!==s)throw fail(409,'Tool is no longer pending'); + if(!Array.isArray(input.result.content)||input.result.content.some(b=>b.type!=='text'||typeof b.text!=='string'))throw fail(400,'Invalid tool result'); + clearTimeout(reply.timer);toolReplies.delete(String(input.id));json(reply.res,200,input.result); + } else s.engine.respond(input.id,input.result); + s.pending.delete(String(input.id)); event(s,'approval',{id:input.id,decision:input.result.decision});return json(res,200,{ok:true}); } if(action==='interrupt') {await s.engine?.interrupt();return json(res,200,{ok:true});} throw fail(404,'Not found'); } catch(error) {if(!res.headersSent)json(res,error.status||500,{error:error.status?error.message:'Relay request failed'});else res.end();} }); - server.on('close',()=>{for(const s of sessions.values()){clearTimeout(s.idleTimer);s.engine?.close();}}); + server.on('close',()=>{for(const {res,timer} of toolReplies.values()){clearTimeout(timer);res.end();}toolReplies.clear();for(const s of sessions.values()){clearTimeout(s.idleTimer);s.engine?.close();}}); return server; } diff --git a/help/relay/service.test.mjs b/help/relay/service.test.mjs index 1ad039386f..346acb07ae 100644 --- a/help/relay/service.test.mjs +++ b/help/relay/service.test.mjs @@ -8,15 +8,16 @@ import {randomUUID} from 'node:crypto'; import {createRelay,ownerAuth} from './service.mjs'; import {RemoteServer} from '../../aesel/src/remote-server.mjs'; class Engine extends EventEmitter { + constructor(phone=false){super();this.phone=phone;} async connect(){this.threadId=randomUUID();return {thread:{id:this.threadId}};} - async startTurn(text){this.starts=(this.starts||0)+1;this.text=text;this.emit('notification',{method:'turn/started',params:{turn:{id:'turn-1'}}});this.emit('request',{id:'approval-1',method:'item/commandExecution/requestApproval',params:{command:'edit piece'}});} + async startTurn(text){this.starts=(this.starts||0)+1;this.text=text;this.emit('notification',{method:'turn/started',params:{turn:{id:'turn-1'}}});if(!this.phone)this.emit('request',{id:'approval-1',method:'item/commandExecution/requestApproval',params:{command:'edit piece'}});} respond(id,result){this.decision=result.decision;this.emit('notification',{method:'item/agentMessage/delta',params:{delta:'Saved.'}});this.emit('notification',{method:'turn/completed',params:{turn:{id:'turn-1',status:'completed'}}});} close(){this.closed=true;} } 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 start=async()=>{const server=createRelay({root,authorize,factory:()=>engine=new Engine()});server.listen(0,'127.0.0.1');await once(server,'listening');return server;}; + 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}`; const call=async(path,data,token='owner')=>{const r=await fetch(url()+path,{method:data?'POST':'GET',headers:{authorization:`Bearer ${token}`,'content-type':'application/json'},...(data?{body:JSON.stringify(data)}:{})});return {status:r.status,...await r.json()};}; @@ -65,3 +66,23 @@ test('lost acknowledgement retries the same request without starting twice',asyn const result=await remote.startTurn('Exactly once');assert.equal(result.turn.status,'inProgress');assert.equal(f.engine().starts,1); const state=await f.call(`${base}/${remote.threadId}`);assert.equal(state.events[0].value.params.turn.id,result.turn.id); }); +test('phone MCP only exposes declared tools and returns the owner response',async t=>{ + const f=await setup(t); + const tools=[{name:'write_piece',description:'Save a piece',inputSchema:{type:'object'}}]; + const {thread}=await f.call(base,{provider:'claude',clientTools:tools}); + const meta=JSON.parse(readFileSync(join(f.root,thread.id,'session.json'))); + const internal=f.url()+'/internal/phone/'+thread.id; + assert.equal((await fetch(internal+'/tools')).status,403); + const headers={authorization:'Bearer '+meta.phoneSecret,'content-type':'application/json'}; + assert.deepEqual(await(await fetch(internal+'/tools',{headers})).json(),{tools}); + await f.call(`${base}/${thread.id}/turn`,{requestId:randomUUID(),text:'Draw'}); + const denied=await fetch(internal+'/call',{method:'POST',headers,body:JSON.stringify({name:'Bash',arguments:{command:'no'}})});assert.equal(denied.status,400); + const result=fetch(internal+'/call',{method:'POST',headers,body:JSON.stringify({name:'write_piece',arguments:{source:'paint'}})}); + let pending;for(let i=0;i<20&&!pending;i++){await new Promise(r=>setTimeout(r,5));pending=(await f.call(`${base}/${thread.id}`)).pending.find(p=>p.method==='phone/tool');} + assert.equal(pending.params.name,'write_piece'); + const output={content:[{type:'text',text:'Saved locally'}]}; + await f.call(`${base}/${thread.id}/respond`,{id:pending.id,result:output}); + assert.deepEqual(await(await result).json(),output); + const preflight=await fetch(f.url()+base,{method:'OPTIONS',headers:{origin:'walkieware://app'}}); + assert.equal(preflight.headers.get('access-control-allow-origin'),'walkieware://app'); +});