profileComputer.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. // @vitest-environment node
  2. import {expect,it,vi} from 'vitest'
  3. import {createServer} from 'node:http'
  4. import {DatabaseSync} from 'node:sqlite'
  5. import {mkdtemp,rm,writeFile,mkdir,readFile} from 'node:fs/promises'
  6. import {tmpdir} from 'node:os'
  7. import {join} from 'node:path'
  8. import {randomUUID} from 'node:crypto'
  9. import {WebSocketServer,type WebSocket} from 'ws'
  10. import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
  11. import {FileTransferFiles} from '../../src/shared/fileTransferEndpoint.mjs'
  12. import {VM_FILE_TRANSFER_SCRIPT} from '../../src/runner/worker/fileTransfer'
  13. import {ProfileSkillSession} from '../../src/runner/worker/skills'
  14. import {UpstreamClient} from '../../src/server/upstream'
  15. import {UpstreamServiceSession} from '../../src/server/localAuth'
  16. import type {GatewayFrame} from '../../src/server/workspaceGateway'
  17. import * as sessionStop from '../../src/server/sessionStop'
  18. import {HttpError} from '../../src/server/errors'
  19. async function fixture(hermesManaged=false,bridgeVersion=2){
  20. const home=await mkdtemp(join(tmpdir(),'yaoyao-profile-computer-')),db=new DatabaseSync(join(home,'runner.sqlite'))
  21. const vmRoot=join(home,'vm'),hermesRoot=join(home,'hermes');await mkdir(vmRoot);await mkdir(hermesRoot);await writeFile(join(hermesRoot,'input.txt'),'hermes-authorized-file')
  22. const vmFiles=new FileTransferFiles(vmRoot),hermesFiles=new FileTransferFiles(hermesRoot)
  23. const calls:Array<{method:string;params:any}>=[],bindings:any[]=[],frames:GatewayFrame[]=[],gateways:ComputerGateway[]=[]
  24. const sessions=new Map<string,{stored:string;running:boolean}>()
  25. let socket:WebSocket,interruptStops=true,allowed=true
  26. const server=createServer(async(req,res)=>{
  27. let body='';for await(const chunk of req)body+=chunk
  28. if(req.url?.endsWith('/bind'))bindings.push(JSON.parse(body))
  29. if(req.url?.endsWith('/computer-file')){res.setHeader('Content-Type','application/json');const data=JSON.parse(body);if(data.action==='transfer'){const action={...data.transfer,...(data.transfer.path?{path:'input.txt'}:{})};const result=await hermesFiles.call(action);res.end(JSON.stringify({ok:true,...result,...(result.path?{path:data.path}:{})}));return}res.end(JSON.stringify(data.action==='read'?{ok:true,data:Buffer.from('hermes-authorized-file').toString('base64')}:{ok:true}));return}
  30. res.setHeader('Content-Type','application/json')
  31. res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'test-ticket'}:req.url?.split('?')[0]?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true,computer_runtime_version:bridgeVersion,file_transfer_version:1}:{ok:true,native_tools:true,computer_runtime_version:bridgeVersion,workspace_memory:body?JSON.parse(body).workspace_memory===true:false}))
  32. })
  33. await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve))
  34. const url=new URL(`http://127.0.0.1:${(server.address() as {port:number}).port}`)
  35. const ws=new WebSocketServer({server})
  36. const emit=(type:string,payload:Record<string,unknown>={},runtimeId=[...sessions.keys()].at(-1)!)=>socket.send(JSON.stringify({method:'event',params:{type,session_id:runtimeId,payload}}))
  37. ws.on('connection',connected=>{
  38. socket=connected;emit('gateway.ready')
  39. connected.on('message',raw=>{
  40. const request=JSON.parse(String(raw));calls.push(request)
  41. let result:any={ok:true}
  42. if(['session.create','session.resume'].includes(request.method)){
  43. const id=randomUUID(),stored=request.method==='session.resume'?request.params.session_id:randomUUID()
  44. sessions.set(id,{stored,running:false})
  45. result={session_id:id,stored_session_id:stored,running:false,info:{profile_name:request.params.profile,cwd:home}}
  46. }else if(request.method==='session.active_list'){
  47. const unknown=Object.keys(request.params??{}).filter(key=>!['profile','current_session_id'].includes(key))
  48. if(unknown.length){connected.send(JSON.stringify({id:request.id,error:{message:`invalid params for session.active_list: ${unknown[0]}: Extra inputs are not permitted`}}));return}
  49. result={sessions:[...sessions].map(([id,s])=>({id,status:s.running?'working':'idle'}))}
  50. }
  51. else if(request.method==='session.usage')result={context_used:123,context_max:32000}
  52. else if(request.method==='image.attach_bytes')result={ref_text:'[image](/hermes/attachments/image.png)'}
  53. else if(request.method==='file.attach')result={ref_text:`[host attachment](${home}/input.txt)`}
  54. else if(request.method==='prompt.submit')sessions.get(request.params.session_id)!.running=true
  55. else if(request.method==='session.interrupt'&&interruptStops){sessions.get(request.params.session_id)!.running=false;emit('message.complete',{status:'interrupted'},request.params.session_id)}
  56. connected.send(JSON.stringify({id:request.id,result}))
  57. })
  58. })
  59. const client=new UpstreamClient(url),target={url,client,session:new UpstreamServiceSession(client,()=>undefined)}
  60. const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:url.href,hermesURL:url.href,token:'test',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'/must-not-start-worker',hermesSource:home,hermesHome:home,network:'none'}},home)
  61. await runtime.ready;runtime.retainDesktops=true
  62. const resolver=vi.spyOn(runtime,'resolve').mockRejectedValue(new Error('isolated Worker must not be used'))
  63. vi.spyOn(runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:home})
  64. vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'vm',containerId:'vm',running:true,workspace:'/home/cua/workspace',isolation:'container'})
  65. vi.spyOn(runtime.provider,'inspect').mockResolvedValue({id:'vm',containerId:'vm',running:true,workspace:'/home/cua/workspace',isolation:'container'})
  66. vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
  67. vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'image',width:100,height:80,capturedAt:Date.now()})
  68. const execute=vi.spyOn(runtime.provider,'execute').mockImplementation(async(_spec,argv,options)=>argv[2]===VM_FILE_TRANSFER_SCRIPT?{stdout:JSON.stringify(await vmFiles.call(JSON.parse(options.input!.toString()))),stderr:''}:{stdout:'vm result',stderr:''})
  69. const id=randomUUID(),meta={environmentId:id,agentId:id,ownerKey:'owner',profileSession:!hermesManaged,hostAccess:!hermesManaged,...(hermesManaged?{hermesRuntime:true}:{})}
  70. const gateway=()=>{
  71. const instance=new ComputerGateway(runtime,meta,randomUUID(),async()=>{if(!allowed)throw new Error('revoked')},async()=>({}),target)
  72. instance.onEvent=frame=>frames.push(frame);gateways.push(instance);return instance
  73. }
  74. const post=async(path:string,body:unknown)=>{
  75. const binding=bindings.at(-1)
  76. const response=await fetch(binding.bridge_url+path,{method:'POST',headers:{Authorization:`Bearer ${binding.token}`,'Content-Type':'application/json'},body:JSON.stringify(body)})
  77. return {status:response.status,body:await response.json() as any}
  78. }
  79. const tool=async(name:string,args:unknown)=>{
  80. const catalog=await post('/tools/list',{})
  81. const found=catalog.body.tools.find((t:any)=>t.name===name)
  82. expect(found,JSON.stringify(catalog)).toBeDefined()
  83. return post('/tools/call',{toolId:found.id,arguments:args,callId:randomUUID()})
  84. }
  85. return {home,vmRoot,runtime,resolver,execute,meta,gateway,calls,frames,bindings,sessions,post,tool,emit,target,
  86. setInterruptStops(value:boolean){interruptStops=value},revoke(){allowed=false},
  87. async close(){
  88. interruptStops=true;for(const session of sessions.values())session.running=false
  89. await Promise.all(gateways.map(g=>g.close().catch(()=>{})))
  90. await vmFiles.close();await hermesFiles.close();await runtime.controls.close();await runtime.pool.close();db.close();client.close()
  91. for(const c of ws.clients)c.terminate()
  92. await new Promise<void>(resolve=>ws.close(()=>resolve()))
  93. await new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
  94. vi.restoreAllMocks();await rm(home,{recursive:true,force:true})
  95. },
  96. }
  97. }
  98. it('uses a native Profile session and bridge for VM tools, preserves rotated history and keeps Worker sessions separate',async()=>{
  99. const f=await fixture()
  100. try{
  101. const g=f.gateway();await g.connect();const opened=await g.rpc('session.create',{profile:'default'})
  102. g.installTeamLease('team',[{id:'team_test',name:'team_test',description:'team fixture',inputSchema:{}}],async()=>({proof:'team-result'}))
  103. const attached=await g.rpc('file.attach',{session_id:opened.session_id,name:'input.txt',data_url:'data:text/plain;base64,aGk='})
  104. expect(attached.ref_text).toContain(f.home)
  105. await g.rpc('prompt.submit',{session_id:opened.session_id,text:'Use the Profile and its local VM'})
  106. expect(f.resolver).not.toHaveBeenCalled();expect(f.runtime.workers.size).toBe(0)
  107. expect(f.calls.find(c=>c.method==='session.create')?.params).toMatchObject({profile:'default',close_on_disconnect:false})
  108. expect(f.calls.find(c=>c.method==='prompt.submit')?.params.text).toContain('本机协作模式')
  109. expect(f.bindings.at(-1)).toMatchObject({native_tools:true,profile:'default'})
  110. const catalog=(await f.post('/tools/list',{})).body.tools.map((t:any)=>t.name)
  111. expect(catalog).toEqual(expect.arrayContaining(['computer_shell','computer_copy_file','team_test']))
  112. expect(catalog).not.toContain('host_shell')
  113. const skills=vi.spyOn(ProfileSkillSession.prototype,'call').mockResolvedValue({skills:[{name:'profile-procedure'}]})
  114. expect((await f.tool('computer_skills_list',{})).body.structuredContent).toEqual({skills:[{name:'profile-procedure'}]})
  115. expect(skills).toHaveBeenCalledWith('computer_skills_list',{})
  116. expect(skills.mock.instances[0]?.options.profile).toBe('default')
  117. expect(skills.mock.instances[0]?.options.agentId).toBe(f.meta.agentId)
  118. skills.mockRestore()
  119. expect((await f.tool('computer_shell',{command:'pwd'})).body.structuredContent).toMatchObject({stdout:'vm result'})
  120. expect(f.execute).toHaveBeenCalledWith(expect.objectContaining({cwd:'/home/cua/workspace'}),['/bin/bash','-lc','pwd'],expect.anything())
  121. expect((await f.tool('team_test',{})).body.structuredContent).toEqual({proof:'team-result'})
  122. await writeFile(join(f.home,'source.bin'),Buffer.from([0,1,255]))
  123. expect((await f.tool('computer_copy_file',{from:'host',source:'source.bin',destination:'target.bin'})).body.structuredContent).toMatchObject({ok:true})
  124. expect(await readFile(join(f.vmRoot,'target.bin'))).toEqual(Buffer.from([0,1,255]))
  125. const rotated=randomUUID();f.emit('session.info',{stored_session_id:rotated})
  126. await vi.waitFor(()=>expect(f.runtime.session(opened.session_id,f.meta,'default').profileSessionId).toBe(rotated))
  127. expect(f.frames.at(-1)?.payload.stored_session_id).toBe(opened.session_id)
  128. f.sessions.values().next().value!.running=false;f.emit('message.complete',{text:'Profile finished',status:'complete'})
  129. await vi.waitFor(()=>expect(f.frames.at(-1)?.payload.text).toBe('Profile finished'))
  130. expect((await f.runtime.controls.status(f.meta)).mode).toBe('idle')
  131. expect(await g.rpc('session.usage',{session_id:opened.session_id})).toMatchObject({context_used:123})
  132. await g.close()
  133. const resumed=f.gateway();await resumed.connect();await resumed.rpc('session.resume',{profile:'default',session_id:opened.session_id})
  134. expect(f.calls.findLast(c=>c.method==='session.resume')?.params.session_id).toBe(rotated)
  135. expect(()=>f.runtime.session(opened.session_id,{...f.meta,profileSession:false},'default')).toThrow('执行方式已改变')
  136. }finally{await f.close()}
  137. })
  138. it('waits for native termination and VM calls before human takeover, then resumes only after giveback',async()=>{
  139. const f=await fixture()
  140. try{
  141. const g=f.gateway();await g.connect();const session=await g.rpc('session.create',{profile:'default'})
  142. await g.rpc('prompt.submit',{session_id:session.session_id,text:'Work'})
  143. let finishTool!:()=>void
  144. f.execute.mockImplementationOnce(async()=>{await new Promise<void>(done=>{finishTool=done});return {stdout:'finished',stderr:''}})
  145. const pending=f.tool('computer_shell',{command:'long command'})
  146. await vi.waitFor(()=>expect(finishTool).toBeTypeOf('function'))
  147. f.setInterruptStops(false)
  148. const controlId=randomUUID();await f.runtime.controls.take(f.meta,'default',controlId,async()=>{})
  149. await vi.waitFor(()=>expect(f.calls.some(c=>c.method==='session.interrupt')).toBe(true))
  150. expect((await f.runtime.controls.status(f.meta)).mode).toBe('pausing')
  151. for(const native of f.sessions.values())native.running=false
  152. f.emit('message.complete',{status:'interrupted'})
  153. await new Promise(done=>setTimeout(done,150))
  154. expect((await f.runtime.controls.status(f.meta)).mode).toBe('pausing')
  155. finishTool();await pending
  156. await g.takeControl(controlId,()=>{})
  157. await vi.waitFor(async()=>expect((await f.runtime.controls.status(f.meta)).mode).toBe('human'))
  158. expect(f.frames.some(frame=>frame.type==='message.complete')).toBe(false)
  159. expect((await f.tool('computer_shell',{command:'must not execute'})).body.isError).toBe(true)
  160. expect(f.calls.filter(c=>c.method==='prompt.submit')).toHaveLength(1)
  161. await f.runtime.controls.giveBack(f.meta,controlId,'已完成登录')
  162. expect(f.calls.filter(c=>c.method==='prompt.submit')).toHaveLength(2)
  163. expect(f.calls.at(-1)?.params.text).toContain('已完成登录')
  164. expect((await f.runtime.controls.status(f.meta)).mode).toBe('agent')
  165. f.setInterruptStops(true);await g.rpc('session.interrupt',{session_id:session.session_id})
  166. expect((await f.runtime.controls.status(f.meta)).mode).toBe('off')
  167. const poll=f.calls.find(c=>c.method==='session.active_list')
  168. expect(poll).toBeDefined()
  169. expect(poll!.params).not.toHaveProperty('session_id')
  170. expect(poll!.params).toMatchObject({profile:'default'})
  171. await expect(f.post('/tools/list',{})).rejects.toThrow()
  172. }finally{await f.close()}
  173. })
  174. it('keeps the holder and durable stop barrier on timeout or close, and retries the same gateway safely',async()=>{
  175. const f=await fixture()
  176. try{
  177. const g=f.gateway();await g.connect();const session=await g.rpc('session.create',{profile:'default'})
  178. await g.rpc('prompt.submit',{session_id:session.session_id,text:'Work until stopped'})
  179. await f.tool('computer_shell',{command:'pwd'})
  180. const holder=g.lease!.holderId,stored=f.runtime.session(session.session_id,f.meta,'default').profileSessionId
  181. f.setInterruptStops(false)
  182. const timeout=vi.spyOn(sessionStop,'waitForHermesSessionIdle').mockRejectedValue(new HttpError(409,'native stop timeout','session_still_stopping'))
  183. await expect(g.rpc('session.interrupt',{session_id:session.session_id})).rejects.toMatchObject({code:'session_still_stopping'})
  184. expect(f.runtime.pool.status(f.meta.ownerKey)[0]?.holderIds).toContain(holder)
  185. expect(f.runtime.session(session.session_id,f.meta,'default')).toMatchObject({stopPending:true,profileSessionId:stored})
  186. expect(()=>f.runtime.pool.renew(g.lease!)).not.toThrow()
  187. await expect(g.rpc('prompt.submit',{session_id:session.session_id,text:'forbidden retry'})).rejects.toMatchObject({code:'computer_cancelled'})
  188. await expect(g.takeControl(randomUUID(),()=>{})).rejects.toMatchObject({code:'computer_cancelled'})
  189. await expect(f.runtime.controls.take(f.meta,'default',randomUUID(),async()=>{})).rejects.toMatchObject({code:'computer_stopping'})
  190. const next=f.gateway();await next.connect()
  191. await expect(next.rpc('session.create',{profile:'default',toolsOnly:true})).rejects.toMatchObject({code:'computer_stopping'})
  192. await expect(g.close()).rejects.toMatchObject({code:'session_still_stopping'})
  193. expect(f.runtime.pool.status(f.meta.ownerKey)[0]?.holderIds).toContain(holder)
  194. expect(f.runtime.gateways.get(f.meta.environmentId)?.has(g)).toBe(true)
  195. timeout.mockRestore();f.setInterruptStops(true)
  196. await g.interrupt();await g.close()
  197. expect(f.runtime.session(session.session_id,f.meta,'default').stopPending).toBe(false)
  198. expect(f.runtime.pool.status(f.meta.ownerKey)[0]?.holderIds).not.toContain(holder)
  199. expect(f.calls.filter(call=>call.method==='prompt.submit')).toHaveLength(1)
  200. }finally{await f.close()}
  201. })
  202. it('restores a persisted stop barrier and clears it only through cleanup resume without submitting again',async()=>{
  203. const f=await fixture()
  204. try{
  205. const g=f.gateway();await g.connect();const opened=await g.rpc('session.create',{profile:'default'})
  206. await g.rpc('prompt.submit',{session_id:opened.session_id,text:'original work'})
  207. await f.tool('computer_shell',{command:'pwd'})
  208. const persisted=f.runtime.session(opened.session_id,f.meta,'default')
  209. await g.close()
  210. // Crash checkpoint: native work stopped, but its durable cleanup receipt
  211. // was not committed yet. Re-open the real temporary sqlite file.
  212. persisted.stopPending=true;f.runtime.save(persisted)
  213. const db=new DatabaseSync(join(f.home,'runner.sqlite'))
  214. const restarted=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:f.target.url.href,hermesURL:f.target.url.href,token:'test',allowedProfiles:['default'],artifactRoots:[],computers:f.runtime.config},f.home)
  215. const recovering=new ComputerGateway(restarted,f.meta,g.workId,async()=>{},async()=>({}),f.target)
  216. try{
  217. await restarted.ready;await recovering.connect()
  218. await expect(recovering.rpc('session.resume',{profile:'default',session_id:opened.session_id})).rejects.toMatchObject({code:'computer_stopping'})
  219. await recovering.rpc('session.resume',{profile:'default',session_id:opened.session_id,recoverOnly:true})
  220. expect(restarted.session(opened.session_id,f.meta,'default').stopPending).toBe(false)
  221. expect(f.calls.filter(call=>call.method==='prompt.submit')).toHaveLength(1)
  222. expect(f.calls.findLast(call=>call.method==='session.resume')?.params.session_id).toBe(persisted.profileSessionId)
  223. }finally{await recovering.close();await restarted.controls.close();await restarted.pool.close();db.close()}
  224. }finally{await f.close()}
  225. })
  226. it('uses Hermes for an isolated Bot without reading Profile config or running a Worker; native skills and authorized files stay in Hermes',async()=>{
  227. const f=await fixture(true)
  228. try{
  229. const workspace=vi.mocked(f.runtime.resolveWorkspace);workspace.mockRejectedValue(new Error('must not read Profile config'))
  230. const skills=vi.spyOn(ProfileSkillSession.prototype,'call').mockRejectedValue(new Error('must not launch a skills reader'))
  231. const g=f.gateway();await g.connect();const session=await g.rpc('session.create',{profile:'default'})
  232. await g.rpc('image.attach_bytes',{session_id:session.session_id,filename:'image.png',content_base64:'aW1hZ2U='})
  233. await g.rpc('prompt.submit',{session_id:session.session_id,text:'Use the authorized Profile skill'})
  234. expect(f.calls.find(c=>c.method==='image.attach_bytes')?.params.content_base64).toBe('aW1hZ2U=')
  235. expect(f.bindings.at(-1)).toMatchObject({computer_policy:{mode:'isolated',hostAccess:false}})
  236. expect(f.calls.find(c=>c.method==='prompt.submit')?.params.text).toContain('Hermes 原生 skills_list')
  237. const catalog=(await f.post('/tools/list',{})).body.tools.map((t:any)=>t.name)
  238. expect(catalog).toContain('computer_shell');expect(catalog).not.toContain('computer_skill_view');expect(catalog).not.toContain('host_shell')
  239. const transfer=await f.tool('computer_copy_file',{from:'host',source:'/only-hermes-can-read/input.txt',destination:'input.txt'});expect(transfer.body.structuredContent,JSON.stringify(transfer)).toMatchObject({ok:true})
  240. expect(await readFile(join(f.vmRoot,'input.txt'),'utf8')).toBe('hermes-authorized-file')
  241. expect(workspace).not.toHaveBeenCalled();expect(f.resolver).not.toHaveBeenCalled();expect(skills).not.toHaveBeenCalled();expect(f.runtime.workers.size).toBe(0)
  242. expect(f.runtime.session(session.session_id,f.meta,'default')).toMatchObject({hermesRuntime:true,profileSessionId:expect.any(String)})
  243. f.revoke()
  244. await expect(g.rpc('prompt.submit',{session_id:session.session_id,text:'must not run'})).rejects.toThrow()
  245. }finally{await f.close()}
  246. })
  247. it('rejects an old Hermes plugin before prompt submission and never falls back to the Worker',async()=>{
  248. const f=await fixture(true,1)
  249. try{
  250. const g=f.gateway();await g.connect()
  251. await expect(g.rpc('session.create',{profile:'default'})).rejects.toMatchObject({code:'computer_bridge_upgrade_required'})
  252. expect(f.runtime.workers.size).toBe(0);expect(f.resolver).not.toHaveBeenCalled()
  253. expect(f.calls.some(c=>c.method==='prompt.submit')).toBe(false)
  254. }finally{await f.close()}
  255. })
  256. it('sends workspace memory policy through the private bridge instead of session.create',async()=>{
  257. const f=await fixture(true)
  258. try{
  259. const g=f.gateway();await g.connect();const session=await g.rpc('session.create',{profile:'default',hidden:true,room_plumbing:true})
  260. g.installTeamLease('memory',[],async()=>({}),true)
  261. await g.rpc('prompt.submit',{session_id:session.session_id,text:'Use scoped memory'})
  262. expect(f.calls.find(call=>call.method==='session.create')?.params).not.toHaveProperty('skip_memory')
  263. expect(f.calls.find(call=>call.method==='session.create')?.params).not.toHaveProperty('workspace_memory')
  264. expect(f.bindings.at(-1)).toMatchObject({workspace_memory:true})
  265. }finally{await f.close()}
  266. })
  267. it('uses the internal chunk RPC without opening a model session and checks session and permission on every call', async () => {
  268. const f=await fixture()
  269. try {
  270. const g=f.gateway(); await g.connect(); const session=await g.rpc('session.create',{profile:'default',toolsOnly:true})
  271. const transferId=randomUUID(); await writeFile(join(f.vmRoot,'source'),'proof')
  272. await expect(g.rpc('computer.transfer',{session_id:'other',action:{op:'transfer-read-open',transferId,path:'source',maxBytes:1024*1024}})).rejects.toMatchObject({code:'computer_session_forbidden'})
  273. expect(await g.rpc('computer.transfer',{session_id:session.session_id,action:{op:'transfer-read-open',transferId,path:'source',maxBytes:1024*1024}})).toMatchObject({size:5})
  274. expect(f.calls.filter(c=>c.method==='session.create')).toHaveLength(0)
  275. const status=vi.spyOn(f.runtime.controls,'status').mockResolvedValue({mode:'human'})
  276. await expect(g.rpc('computer.transfer',{session_id:session.session_id,action:{op:'transfer-read',transferId,offset:0}})).rejects.toMatchObject({code:'computer_paused'})
  277. status.mockRestore()
  278. f.revoke()
  279. await expect(g.rpc('computer.transfer',{session_id:session.session_id,action:{op:'transfer-read',transferId,offset:0}})).rejects.toThrow('revoked')
  280. } finally { await f.close() }
  281. })