runner.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  3. import Koa from 'koa'
  4. import { createServer, type Server } from 'node:http'
  5. import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  6. import { spawn } from 'node:child_process'
  7. import { once } from 'node:events'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import { randomUUID } from 'node:crypto'
  11. import { WebSocketServer, type WebSocket } from 'ws'
  12. import { WorkspaceStore } from '../../src/server/workspaceStore'
  13. import { LocalAuthStore } from '../../src/server/localAuth'
  14. import { RunnerHub } from '../../src/server/runnerHub'
  15. import { RunnerAgent } from '../../src/runner/agent'
  16. import { WorkspaceGateway, type GatewayTarget, type GatewayFrame } from '../../src/server/workspaceGateway'
  17. import { createWorkspaceToolLease } from '../../src/server/workspaceToolLease'
  18. import { HttpError } from '../../src/server/errors'
  19. import type { RunnerCommand, RunnerConfiguration } from '../../src/shared/runner'
  20. class Auth extends LocalAuthStore {
  21. version=1
  22. allowed=true
  23. override isUserActive(){return true}
  24. override pushAuthorizationVersion(){return this.version}
  25. override canUseSource(){return this.allowed}
  26. }
  27. let home:string,store:WorkspaceStore,auth:Auth,hub:RunnerHub,runner:RunnerAgent,config:RunnerConfiguration
  28. let web:Server,hermes:Server,ws:WebSocketServer,controller:AbortController,running:Promise<void>
  29. let target:GatewayTarget,bindings:any[],rpc:Array<{method:string;params:any}>,frames:GatewayFrame[],sockets:WebSocket[]
  30. const gateways:WorkspaceGateway[]=[]
  31. const listen=(server:Server)=>new Promise<string>(resolve=>server.listen(0,'127.0.0.1',()=>resolve(`http://127.0.0.1:${(server.address() as {port:number}).port}`)))
  32. const close=(server:Server)=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
  33. beforeEach(async()=>{
  34. home=mkdtempSync(join(tmpdir(),'yaoyao-runner-'));store=new WorkspaceStore(home);auth=new Auth(home)
  35. bindings=[];rpc=[];frames=[];sockets=[]
  36. hermes=createServer(async(req,res)=>{
  37. let body='';for await(const part of req)body+=part
  38. if(req.url?.endsWith('/bind'))bindings.push(JSON.parse(body))
  39. res.setHeader('Content-Type','application/json')
  40. res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'fixture'}:req.url==='/api/profiles'?{profiles:['default',{name:'writer'},{name:'secret'}]}:req.url?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true}:{ok:true,native_tools:true,workspace_memory:body?JSON.parse(body).workspace_memory===true:false,terminal:{cwd:'/fixture/workspace'}}))
  41. })
  42. const hermesURL=await listen(hermes)
  43. ws=new WebSocketServer({server:hermes})
  44. ws.on('connection',socket=>{
  45. sockets.push(socket);socket.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
  46. socket.on('message',raw=>{
  47. const value=JSON.parse(String(raw));rpc.push(value)
  48. const result=['session.create','session.resume'].includes(value.method)?{session_id:randomUUID(),stored_session_id:randomUUID(),info:{profile_name:value.params.profile}}:{ok:true}
  49. socket.send(JSON.stringify({id:value.id,result}))
  50. })
  51. })
  52. hub=new RunnerHub(store,auth,{url:new URL(hermesURL)} as GatewayTarget)
  53. const registered=hub.enroll('owner',{name:'测试电脑',allowedProfiles:['default','writer']})
  54. const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(error){ctx.status=error instanceof HttpError?error.status:500;ctx.body={code:error instanceof HttpError?error.code:'error'}}});app.use(hub.middleware())
  55. web=createServer(app.callback());const serverURL=await listen(web)
  56. config={protocol:1,serverURL,runnerId:registered.runner.id,token:registered.token,hermesURL,allowedProfiles:['default','writer'],artifactRoots:[]}
  57. runner=new RunnerAgent(config,home);controller=new AbortController();running=runner.run(controller.signal);void running.catch(()=>{})
  58. await vi.waitFor(()=>expect(hub.summary(hub.records()[0]!).online).toBe(true))
  59. await vi.waitFor(()=>expect((runner as any).serverEpoch).toBeTruthy())
  60. target=hub.target('owner','local')!
  61. })
  62. afterEach(async()=>{
  63. for(const gateway of gateways.splice(0))gateway.close()
  64. controller.abort();await running.catch(()=>{});hub.close()
  65. for(const socket of ws.clients)socket.terminate()
  66. await new Promise<void>(resolve=>ws.close(()=>resolve()));await close(hermes);await close(web)
  67. store.close();rmSync(home,{recursive:true,force:true})
  68. })
  69. async function channel(){
  70. const gateway=new WorkspaceGateway(target);gateways.push(gateway);gateway.onEvent=frame=>frames.push(frame)
  71. await gateway.connect();const session=await gateway.rpc('session.create',{profile:'default'})
  72. return {gateway,session}
  73. }
  74. it('requires an upgraded Runner before granting mixed host and VM tools',()=>{
  75. const id=randomUUID()
  76. expect(()=>hub.target('owner','local',{environmentId:id,agentId:id,ownerKey:'owner',hostAccess:true})).toThrow('不支持同时使用本机和虚拟机')
  77. })
  78. it('distinguishes a missing or disabled VM runner, an offline runner and incompatible features',async()=>{
  79. const agent=store.createAgent('owner',{name:'虚拟机状态',profile:'default'})
  80. expect(()=>hub.computerRunner('owner',agent)).toThrow('请启用隔离电脑 Worker 或更新 Runner')
  81. controller.abort();await running
  82. const record=hub.records()[0]!
  83. hub.remove(record.id)
  84. expect(()=>hub.computerRunner('owner',agent)).toThrow('本地虚拟机执行节点尚未启用')
  85. store.put('_system','runner',record.id,{...record,enabled:true})
  86. expect(()=>hub.computerRunner('owner',agent)).toThrow('本地虚拟机执行节点未连接')
  87. store.remove('_system','runner',record.id)
  88. expect(()=>hub.computerRunner('owner',agent)).toThrow('本地虚拟机执行节点尚未启用')
  89. })
  90. it('rejects Profile collaboration on a Runner that does not advertise the execution backend',()=>{
  91. const id=randomUUID()
  92. expect(()=>hub.target('owner','local',{environmentId:id,agentId:id,ownerKey:'owner',hostAccess:true,profileSession:true})).toThrow('不支持本机协作模式')
  93. })
  94. it.each([false,true])('admits Profile collaboration after an actual computer Runner handshake (Compose: %s)',async compose=>{
  95. controller.abort();await running
  96. config.computers={runtime:'docker',imageId:'sha256:'+'0'.repeat(64),python:'/fixture/python',hermesSource:home,hermesHome:home,...(compose?{managedBy:'compose' as const}:{})}
  97. runner=new RunnerAgent(config,home);controller=new AbortController();running=runner.run(controller.signal);void running.catch(()=>{})
  98. await vi.waitFor(()=>expect((runner as any).serverEpoch).toBeTruthy())
  99. const features=hub.summary(hub.records()[0]!).features
  100. expect(features).toContain('profile-computer-v1')
  101. expect(features).toEqual(expect.arrayContaining(['host-computer-tools-v1','computer-worker-v1','computer-control-v1']))
  102. if(compose)expect(features).toContain('compose-desktops-v1')
  103. const agent=store.createAgent('owner',{name:'协作握手',profile:'default',computer:'vm',execution:'computer',vmExecution:'profile'})
  104. expect(()=>hub.target('owner','local',{agentId:agent.id,environmentId:agent.id,ownerKey:'owner',hostAccess:true,profileSession:true})).not.toThrow()
  105. })
  106. async function machine(path:string,body?:unknown,overrides:Record<string,string>={}){
  107. return fetch(`${config.serverURL}/api/runner/v1/${config.runnerId}/${path}`,{
  108. method:body===undefined?'GET':'POST',headers:{Authorization:`Bearer ${config.token}`,'x-runner-instance':runner.instance,'x-runner-protocol':'1','x-runner-epoch':(runner as any).serverEpoch,'Content-Type':'application/json',...overrides},...(body===undefined?{}:{body:JSON.stringify(body)})
  109. })
  110. }
  111. it('connects through outbound transport, filters profiles and forwards only owned session events',async()=>{
  112. const profiles=await target.session.request('/api/profiles')
  113. expect(JSON.parse(profiles.body.toString()).profiles).toEqual(['default',{name:'writer'}])
  114. const {gateway,session}=await channel()
  115. await expect(gateway.rpc('session.create',{profile:'secret'})).rejects.toMatchObject({code:'runner_profile_forbidden'})
  116. await expect(gateway.rpc('prompt.submit',{session_id:'someone-else',text:'no'})).rejects.toMatchObject({code:'runner_session_forbidden'})
  117. await gateway.rpc('prompt.submit',{session_id:session.session_id,text:'hello'})
  118. sockets[0]!.send(JSON.stringify({method:'event',params:{type:'message.delta',session_id:'someone-else',payload:{text:'private'}}}))
  119. sockets[0]!.send(JSON.stringify({method:'event',params:{type:'message.delta',session_id:session.session_id,payload:{text:'hello'}}}))
  120. await vi.waitFor(()=>expect(frames.some(f=>f.payload?.text==='hello')).toBe(true))
  121. expect(frames.some(f=>f.payload?.text==='private')).toBe(false)
  122. })
  123. it('mounts loopback-only tools with stable call ids and revokes an active grant',async()=>{
  124. const {session}=await channel(),abort=new AbortController(),call=vi.fn(async()=>({agent:{id:'created'}}))
  125. let active=true
  126. const lease=await createWorkspaceToolLease({target,profile:'default',workId:'work',session:()=>({runtimeId:session.session_id,storedId:session.stored_session_id}),signal:abort.signal,
  127. assertActive:()=>{if(!active)throw new HttpError(403,'撤权','revoked')},catalog:()=>[{id:'workspace_create_agent',name:'workspace_create_agent',description:'create',inputSchema:{type:'object'}}],call,onFailure:()=>{}})
  128. await lease.bind();const binding=bindings[0]
  129. const toolHTTP=(path:string,body:unknown)=>fetch(binding.bridge_url+path,{method:'POST',headers:{Authorization:`Bearer ${binding.token}`,'Content-Type':'application/json'},body:JSON.stringify(body)})
  130. const catalog=await (await toolHTTP('/tools/list',{})).json() as any
  131. const body={callId:'same-call',toolId:catalog.tools[0].id,arguments:{name:'worker'}}
  132. const first=await (await toolHTTP('/tools/call',body)).json()
  133. expect(await (await toolHTTP('/tools/call',body)).json()).toEqual(first)
  134. expect(call).toHaveBeenCalledExactlyOnceWith('workspace_create_agent',{name:'worker'},'same-call')
  135. active=false
  136. expect(await (await toolHTTP('/tools/call',{...body,callId:'late-call'})).json()).toMatchObject({isError:true})
  137. expect(call).toHaveBeenCalledTimes(1)
  138. await lease.dispose()
  139. })
  140. it('denies browser credentials, stale epochs, foreign lease ids and unauthorized HTTP operations',async()=>{
  141. expect((await machine('admit',{id:randomUUID()},{Origin:config.serverURL})).status).toBe(403)
  142. expect((await machine('admit',{id:randomUUID()},{Authorization:'Bearer wrong'})).status).toBe(403)
  143. expect((await machine('admit',{id:randomUUID()},{'x-runner-epoch':'old'})).status).toBe(409)
  144. expect((await machine('tool',{leaseId:randomUUID(),callId:'test',toolId:'workspace_create_agent',arguments:{}})).status).toBe(410)
  145. await expect(target.session.request('/api/config',{method:'POST',body:{}})).rejects.toMatchObject({code:'runner_http_forbidden'})
  146. await expect(target.session.request('/api/files/download',{search:new URLSearchParams({profile:'default',path:'/etc/hosts'})})).rejects.toMatchObject({code:'runner_artifact_forbidden'})
  147. })
  148. it('does not execute a queued command after account authorization changes',async()=>{
  149. const {gateway,session}=await channel()
  150. auth.version++
  151. await expect(gateway.rpc('prompt.submit',{session_id:session.session_id,text:'late'})).rejects.toMatchObject({code:'runner_command_not_admitted'})
  152. expect(rpc.filter(r=>r.method==='prompt.submit')).toHaveLength(0)
  153. })
  154. it('keeps one result for a command and refuses conflicting or unfinished replay after restart',async()=>{
  155. const seen:RunnerCommand[]=[];const execute=(runner as any).execute.bind(runner)
  156. vi.spyOn(runner as any,'execute').mockImplementation(async(command:any)=>{seen.push(command);return execute(command)})
  157. await channel()
  158. const command=seen.find(c=>c.kind==='gateway.rpc')!
  159. const before=rpc.length
  160. expect(await runner.handle(command)).toHaveProperty('result.session_id')
  161. expect(rpc).toHaveLength(before)
  162. await expect(runner.handle({...command,payload:{changed:true}})).rejects.toMatchObject({code:'idempotency_conflict'})
  163. const incomplete={...command,id:randomUUID()}
  164. const {createHash}=await import('node:crypto')
  165. ;(runner as any).db.prepare("INSERT INTO commands VALUES(?,?,'started',NULL,?)").run(incomplete.id,createHash('sha256').update(JSON.stringify(incomplete)).digest('hex'),Date.now())
  166. controller.abort();await running
  167. runner=new RunnerAgent(config,home);controller=new AbortController();running=runner.run(controller.signal);void running.catch(()=>{})
  168. await expect(runner.handle(incomplete)).rejects.toMatchObject({code:'runner_command_uncertain'})
  169. expect(await runner.handle(command)).toHaveProperty('result.session_id')
  170. expect(rpc).toHaveLength(before)
  171. })
  172. it('closes a pending gateway before a delayed handshake can revive it',async()=>{
  173. const connect=WorkspaceGateway.prototype.connect
  174. let release!:()=>void
  175. const gate=new Promise<void>(resolve=>{release=resolve})
  176. vi.spyOn(WorkspaceGateway.prototype,'connect').mockImplementationOnce(async function(this:WorkspaceGateway){await gate;return connect.call(this)})
  177. const connectionId=randomUUID(),request=(hub as any).request.bind(hub)
  178. // The control plane normally installs this owner record before opening.
  179. ;(hub as any).connections.set(connectionId,{runnerId:config.runnerId,valid:()=>true,onEvent:()=>{},onDisconnect:()=>{}})
  180. const opening=request(config.runnerId,'gateway.open',{connectionId}).catch((error:unknown)=>error)
  181. await vi.waitFor(()=>expect((runner as any).connections.has(connectionId)).toBe(true))
  182. await request(config.runnerId,'gateway.close',{connectionId})
  183. release()
  184. expect(await opening).toBeInstanceOf(Error)
  185. expect((runner as any).connections.has(connectionId)).toBe(false)
  186. })
  187. it('fences a replaced process and does not route to the host when its runner goes offline',async()=>{
  188. const {gateway}=await channel();const disconnected=vi.fn();gateway.onDisconnect=disconnected
  189. const oldInstance=runner.instance
  190. controller.abort();await running
  191. runner=new RunnerAgent(config,home);controller=new AbortController();running=runner.run(controller.signal);void running.catch(()=>{})
  192. await vi.waitFor(()=>expect(disconnected).toHaveBeenCalled())
  193. expect((await machine('poll',undefined,{'x-runner-instance':oldInstance})).status).toBe(409)
  194. hub.remove(config.runnerId)
  195. await expect(target.session.request('/api/status')).rejects.toMatchObject({code:'runner_offline'})
  196. })
  197. it('resets the hub epoch after a transport failure and closes the obsolete gateways',async()=>{
  198. const {gateway}=await channel(),disconnected=vi.fn();gateway.onDisconnect=disconnected
  199. const epoch=(runner as any).serverEpoch
  200. await (runner as any).disconnect()
  201. // Wake the outstanding poll. Its response must be ignored after disconnect.
  202. ;(hub as any).online.get(config.runnerId).wake?.()
  203. await vi.waitFor(()=>expect((runner as any).serverEpoch).not.toBe(epoch))
  204. expect(disconnected).toHaveBeenCalledTimes(1)
  205. const next=await channel()
  206. expect(await next.gateway.rpc('session.usage',{session_id:next.session.session_id})).toEqual({ok:true})
  207. })
  208. it('a closed account channel cannot interrupt an unrelated connection',async()=>{
  209. const first=await channel(),second=await channel()
  210. const entries=[...(hub as any).connections.entries()] as Array<[string,any]>
  211. entries[0]![1].valid=()=>false
  212. sockets[0]!.send(JSON.stringify({method:'event',params:{type:'message.delta',session_id:first.session.session_id,payload:{text:'revoked'}}}))
  213. await vi.waitFor(()=>expect((runner as any).connections.size).toBe(1))
  214. expect((runner as any).connected).toBe(true)
  215. expect(await second.gateway.rpc('prompt.submit',{session_id:second.session.session_id,text:'continue'})).toEqual({ok:true})
  216. expect(rpc.filter(r=>r.method==='session.interrupt'&&r.params.session_id===second.session.session_id)).toHaveLength(0)
  217. })
  218. it('rechecks admission after a queued prompt loses its account permission',async()=>{
  219. const {gateway,session}=await channel()
  220. const api=(runner as any).api.bind(runner)
  221. let release!:()=>void,waiting=false
  222. const gate=new Promise<void>(resolve=>{release=resolve})
  223. vi.spyOn(runner as any,'api').mockImplementation(async(path:any,body:any)=>{if(path==='admit'){waiting=true;await gate}return api(path,body)})
  224. const result=gateway.rpc('prompt.submit',{session_id:session.session_id,text:'must not run'}).catch(error=>error)
  225. await vi.waitFor(()=>expect(waiting).toBe(true));auth.version++;release()
  226. expect(await result).toMatchObject({code:'runner_command_not_admitted'})
  227. expect(rpc.filter(r=>r.method==='prompt.submit')).toHaveLength(0)
  228. })
  229. it('does not recreate a lease when cancellation overtakes its admitted creation',async()=>{
  230. const {session}=await channel(),abort=new AbortController()
  231. const execute=(runner as any).execute.bind(runner)
  232. let release!:()=>void,waiting=false
  233. const gate=new Promise<void>(resolve=>{release=resolve})
  234. vi.spyOn(runner as any,'execute').mockImplementation(async(command:any)=>{if(command.kind==='lease.create'){waiting=true;await gate}return execute(command)})
  235. const result=createWorkspaceToolLease({target,profile:'default',workId:'cancel-race',session:()=>({runtimeId:session.session_id,storedId:session.stored_session_id}),signal:abort.signal,
  236. assertActive:()=>{if(abort.signal.aborted)throw new Error('stopped')},catalog:()=>[],call:async()=>({}),onFailure:()=>{}}).catch(error=>error)
  237. await vi.waitFor(()=>expect(waiting).toBe(true));abort.abort()
  238. await vi.waitFor(()=>expect((runner as any).closedLeases.size).toBe(1));release()
  239. expect(await result).toBeInstanceOf(Error)
  240. expect((runner as any).leases.size).toBe(0)
  241. expect(bindings).toHaveLength(0)
  242. })
  243. it.each([0o600,0o644])('runs the CLI with private configuration and rejects broad permissions (%s)',async(mode)=>{
  244. controller.abort();await running
  245. const file=join(home,'runner.json');writeFileSync(file,JSON.stringify(config),{mode})
  246. const bundle=process.env.RUNNER_BUNDLE_PATH
  247. const args=bundle?[bundle,'--config',file]:['--import','tsx','src/runner/index.ts','--config',file]
  248. const child=spawn(process.execPath,args,{stdio:['ignore','ignore','pipe']})
  249. let output='';child.stderr?.on('data',data=>{output+=String(data)})
  250. const exited=once(child,'exit')
  251. try {
  252. if(mode===0o644){expect((await exited)[0]).not.toBe(0);expect(output).toContain('chmod 600');return}
  253. await vi.waitFor(()=>{if(child.exitCode!==null)throw new Error(`Runner 提前退出 ${child.exitCode}: ${output}`);expect(output).toContain('执行节点已连接')},{timeout:7000})
  254. expect((await target.session.request('/api/profiles')).status).toBe(200)
  255. child.kill('SIGTERM')
  256. expect((await exited)[0]).toBe(0)
  257. expect(output).toContain('执行节点已停止')
  258. expect(output).not.toContain(config.token)
  259. }finally{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGTERM');await exited}}
  260. },15000)
  261. it('refuses a computer gateway on a legacy Runner before any native session is opened',async()=>{
  262. const target=hub.target('owner','local',{environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'a'.repeat(64)})!
  263. const gateway=new WorkspaceGateway(target,{workId:randomUUID(),authorize:()=>{}});gateways.push(gateway)
  264. await expect(gateway.connect()).rejects.toMatchObject({code:'computer_unavailable'})
  265. expect(rpc.some(call=>call.method==='session.create')).toBe(false)
  266. })
  267. it('binds chunked artifacts to one live connection, validates bytes and publishes exactly once',async()=>{
  268. const {createHash}=await import('node:crypto'),bytes=Buffer.from('artifact payload'),id=randomUUID()
  269. const publish=vi.fn(async(name:string,data:Buffer)=>({id:randomUUID(),name,size:data.length}))
  270. const gateway=new WorkspaceGateway(target,{workId:randomUUID(),authorize:()=>{},publishArtifact:publish});gateways.push(gateway)
  271. await gateway.connect()
  272. const connectionId=[...(hub as any).connections.keys()][0]
  273. const send=(body:object)=>machine('artifact',{connectionId,id,...body})
  274. const metadata={phase:'begin',name:'report.txt',size:bytes.length,digest:createHash('sha256').update(bytes).digest('hex')}
  275. expect((await send(metadata)).status).toBe(200)
  276. expect((await send({phase:'finish'})).status).toBe(409)
  277. expect((await send({phase:'append',index:0,data:bytes.toString('base64')})).status).toBe(200)
  278. expect((await send({phase:'append',index:0,data:bytes.toString('base64')})).status).toBe(200)
  279. expect((await send({phase:'append',index:0,data:Buffer.from('wrong').toString('base64')})).status).toBe(409)
  280. const first=await (await send({phase:'finish'})).json()
  281. expect(await (await send({phase:'finish'})).json()).toEqual(first)
  282. expect(publish).toHaveBeenCalledExactlyOnceWith('report.txt',bytes)
  283. expect((await machine('artifact',{...metadata,connectionId:randomUUID(),id:randomUUID()})).status).toBe(403)
  284. auth.version++
  285. expect((await send({phase:'finish'})).status).toBe(403)
  286. })
  287. it('keeps only stop operations available on an existing channel after task permission is revoked',async()=>{
  288. let allowed=true
  289. const gateway=new WorkspaceGateway(target,{workId:randomUUID(),authorize:()=>{if(!allowed)throw new Error('revoked')}});gateways.push(gateway)
  290. await gateway.connect();const session=await gateway.rpc('session.create',{profile:'default'})
  291. allowed=false
  292. await expect(gateway.rpc('prompt.submit',{session_id:session.session_id,text:'forbidden'})).rejects.toMatchObject({code:'runner_command_not_admitted'})
  293. expect(await gateway.rpc('session.interrupt',{session_id:session.session_id})).toEqual({ok:true})
  294. expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(0)
  295. })
  296. it('carries workspace memory policy through the Runner to the authenticated Hermes binding',async()=>{
  297. const {session}=await channel(),abort=new AbortController()
  298. const lease=await createWorkspaceToolLease({target,profile:'default',workId:'memory-work',workspaceMemory:true,
  299. session:()=>({runtimeId:session.session_id,storedId:session.stored_session_id}),signal:abort.signal,
  300. assertActive:()=>{},catalog:()=>[],call:async()=>({}),onFailure:()=>{}})
  301. try{await lease.bind();expect(bindings.at(-1)).toMatchObject({workspace_memory:true})}finally{await lease.dispose()}
  302. })