workspacePanels.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. // @vitest-environment node
  2. import {beforeEach,afterEach,expect,it,vi} from 'vitest'
  3. import {mkdtempSync,rmSync} from 'node:fs'
  4. import {join} from 'node:path'
  5. import {tmpdir} from 'node:os'
  6. import {randomUUID} from 'node:crypto'
  7. import Koa from 'koa'
  8. import {bodyParser} from '@koa/bodyparser'
  9. import request from 'supertest'
  10. import {WorkspaceStore} from '../../src/server/workspaceStore'
  11. import {WorkspaceNodes} from '../../src/server/workspaceGateway'
  12. import {loadServerConfig} from '../../src/server/config'
  13. import {WorkspaceRoutines,nextRoutineAt} from '../../src/server/workspaceRoutines'
  14. import {WorkspaceInspector,redactInspector} from '../../src/server/workspaceInspector'
  15. import {GrokCloud} from '../../src/server/grokCloud'
  16. let home:string,store:WorkspaceStore,nodes:WorkspaceNodes
  17. const auth={require:(ctx:any)=>({id:ctx.get('x-user')||'owner',role:'admin'}),requireAdmin:(ctx:any)=>({id:ctx.get('x-user')||'owner',role:'admin'}),isUserActive:()=>true,pushAuthorizationVersion:()=>1} as any
  18. beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'workspace-panels-'));store=new WorkspaceStore(home);nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any)})
  19. afterEach(()=>{nodes.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
  20. it('changes the foundation only when idle and retires old session bindings without changing history',()=>{
  21. const agent=store.createAgent('owner',{name:'甲',profile:'default'}),conversation=store.list<any>('owner','conversation')[0]
  22. store.put('owner','binding',`${conversation.id}:${agent.id}`,{id:`${conversation.id}:${agent.id}`,storedId:'old-session'})
  23. store.put('owner','turn','busy',{agentId:agent.id,status:'queued'})
  24. expect(()=>store.updateAgent('owner',agent.id,{profile:'other'})).toThrow('先停止')
  25. store.remove('owner','turn','busy')
  26. expect(store.updateAgent('owner',agent.id,{profile:'other'}).profile).toBe('other')
  27. expect(store.list('owner','binding')).toEqual([])
  28. expect(store.require('owner','conversation',conversation.id)).toMatchObject({id:conversation.id,name:'甲'})
  29. })
  30. it('calculates daily and weekly schedules in the requested timezone, including DST',()=>{
  31. const after=Date.parse('2026-09-10T00:00:00Z')
  32. expect(nextRoutineAt({kind:'daily',timezone:'Asia/Shanghai',time:'09:00'},after)).toBe(Date.parse('2026-09-10T01:00:00Z'))
  33. expect(nextRoutineAt({kind:'weekly',timezone:'Asia/Shanghai',time:'09:00',weekdays:[1]},after)).toBe(Date.parse('2026-09-14T01:00:00Z'))
  34. expect(nextRoutineAt({kind:'daily',timezone:'America/New_York',time:'09:00'},Date.parse('2026-03-07T15:00:00Z'))).toBe(Date.parse('2026-03-08T13:00:00Z'))
  35. })
  36. it('returns an account-scoped automation overview with only currently permitted Bots and current run states',async()=>{
  37. const own=store.createAgent('owner',{name:'自己的 Bot',profile:'default'}),blocked=store.createAgent('owner',{name:'未授权 Bot',profile:'blocked'}),foreign=store.createAgent('other',{name:'其他账号 Bot',profile:'default'})
  38. const service=new WorkspaceRoutines(store,auth,nodes,{} as any)
  39. const body={name:'日常核对',prompt:'核对内容',enabled:true,schedule:{kind:'interval',timezone:'UTC',everyMinutes:60}}
  40. const routine=service.save('owner',own.id,body)
  41. service.save('owner',blocked.id,body);service.save('other',foreign.id,body)
  42. const id=randomUUID();store.put('owner','run',id,{id,status:'complete'})
  43. store.put('owner','routine-run','receipt',{id:'receipt',agentId:own.id,routineId:routine.id,runId:id,status:'queued',startedAt:1,scheduledAt:0})
  44. nodes.sourceAllowed=(_owner,_node,profile)=>profile!=='blocked'
  45. const app=new Koa();app.use(service.router().routes())
  46. const result=await request(app.callback()).get('/api/app/bot-tools/automations')
  47. expect(result.status).toBe(200)
  48. expect(result.body.agents.map((a:any)=>a.id)).toEqual([own.id])
  49. expect(result.body.routines.map((r:any)=>r.id)).toEqual([routine.id])
  50. expect(result.body.runs).toMatchObject([{id:'receipt',status:'complete'}])
  51. const other=await request(app.callback()).get('/api/app/bot-tools/automations').set('x-user','other')
  52. expect(other.body.agents.map((a:any)=>a.id)).toEqual([foreign.id])
  53. expect(other.body.runs).toEqual([])
  54. })
  55. it('keeps the original next occurrence when only a routine title or prompt changes',()=>{
  56. vi.useFakeTimers();vi.setSystemTime(new Date('2026-09-10T00:00:00Z'))
  57. const agent=store.createAgent('owner',{name:'排期验收',profile:'default'}),service=new WorkspaceRoutines(store,auth,nodes,{} as any)
  58. const body={name:'项目核对',prompt:'核对状态',enabled:true,schedule:{kind:'interval',timezone:'UTC',everyMinutes:60}}
  59. const routine=service.save('owner',agent.id,body)
  60. vi.setSystemTime(new Date('2026-09-10T00:25:00Z'))
  61. expect(service.save('owner',agent.id,{...body,name:'每日项目核对'},routine.id).nextAt).toBe(routine.nextAt)
  62. service.close()
  63. })
  64. it('persists due schedules, prevents overlap and deduplicates manual runs after restart',()=>{
  65. vi.useFakeTimers();vi.setSystemTime(new Date('2026-09-10T00:00:00Z'))
  66. const agent=store.createAgent('owner',{name:'日报机器人',profile:'default'})
  67. const send=vi.fn((_owner:string,_conversation:string,input:any)=>{const run={id:input.requestId,status:'queued'};store.put('owner','run',run.id,run);return run})
  68. const routines=new WorkspaceRoutines(store,auth,nodes,{send} as any)
  69. const routine=routines.save('owner',agent.id,{name:'检查',prompt:'检查任务',enabled:true,schedule:{kind:'interval',timezone:'UTC',everyMinutes:1}})
  70. vi.setSystemTime(new Date('2026-09-10T00:01:01Z'));routines.tick();routines.tick()
  71. expect(send).toHaveBeenCalledTimes(1)
  72. const replacement=new WorkspaceRoutines(store,auth,nodes,{send} as any);replacement.tick();expect(send).toHaveBeenCalledTimes(1)
  73. vi.setSystemTime(new Date('2026-09-10T00:02:02Z'));replacement.tick()
  74. expect(send).toHaveBeenCalledTimes(1);expect(replacement.runs('owner',agent.id).some(run=>run.status==='skipped')).toBe(true)
  75. const first=replacement.runs('owner',agent.id).find(run=>run.runId)!
  76. store.put('owner','run',first.runId!,{id:first.runId,status:'complete'})
  77. const requestId=randomUUID();replacement.run('owner',routine,0,requestId);replacement.run('owner',routine,0,requestId)
  78. expect(send).toHaveBeenCalledTimes(2)
  79. expect(()=>replacement.save('other',agent.id,{name:'forbidden'})).toThrow()
  80. routines.close();replacement.close()
  81. })
  82. it('records redacted requests and enforces conversation ownership and task scope',async()=>{
  83. const agent=store.createAgent('owner',{name:'检查机器人',profile:'default'}),conversation=store.list<any>('owner','conversation')[0]
  84. const inspector=new WorkspaceInspector(store,auth)
  85. inspector.record('owner',conversation.id,{agentId:agent.id,runId:randomUUID(),method:'prompt.submit',direction:'request',data:{authorization:'Bearer secret',nested:{access_token:'secret'},prompt:'hello',content_base64:'screenshot'}})
  86. const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(error:any){ctx.status=error.status??500;ctx.body={error:error.message}}});app.use(inspector.router().routes())
  87. const own=await request(app.callback()).get(`/api/app/conversations/${conversation.id}/inspector`)
  88. expect(own.status).toBe(200);expect(own.body.entries[0].data).toMatchObject({authorization:'[已隐藏]',prompt:'hello',nested:{access_token:'[已隐藏]'}})
  89. expect(JSON.stringify(own.body)).not.toContain('secret')
  90. expect((await request(app.callback()).get(`/api/app/conversations/${conversation.id}/inspector`).set('x-user','other')).status).toBe(404)
  91. expect(redactInspector({prompt:'Bearer abc-secret'})).toEqual({prompt:'Bearer [已隐藏]'})
  92. })
  93. function fakeCloud(){
  94. let ensure=0,exec=0
  95. const fetcher=vi.fn(async(url:any,init:any)=>{
  96. const path=new URL(String(url)).pathname
  97. if(path.endsWith('GetSandBoxRunState'))return Response.json({state:'SAND_BOX_RUN_STATE_RUNNING'})
  98. if(path.endsWith('EnsureSandBox')){ensure++;return Response.json({gatewayUrl:'https://gateway.cursorvm.com',gatewayToken:'private-gateway',networkToken:'private-network',execDaemonUrl:'https://exec.cursorvm.com',execDaemonAuthToken:'private-exec'})}
  99. if(path==='/health')return Response.json({ok:true,isBusy:false})
  100. if(path.endsWith('/Exec')){exec++;const frames=[{stdoutEvent:{data:'remote result'}},{exitEvent:{}},{}].map((event,i)=>{const text=Buffer.from(JSON.stringify(event)),frame=Buffer.alloc(text.length+5);frame[0]=i===2?2:0;frame.writeUInt32BE(text.length,1);text.copy(frame,5);return frame});return new Response(Buffer.concat(frames),{headers:{'content-type':'application/connect+json'}})}
  101. throw new Error('Unexpected request '+path)
  102. }) as typeof fetch
  103. const cloud=new GrokCloud(store,auth,nodes,{assertLocalVmIdle:()=>{}} as any,fetcher)
  104. const token='header.'+Buffer.from(JSON.stringify({sub:'fixture',exp:Math.floor(Date.now()/1000)+3600})).toString('base64url')+'.signature'
  105. return {cloud,token,fetcher,counts:()=>({ensure,exec})}
  106. }
  107. it('auto observation never provisions; multiple robots share one cloud connection with private credentials',async()=>{
  108. const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')
  109. await cloud.state('owner');await cloud.status('owner');expect(counts()).toEqual({ensure:0,exec:0})
  110. const a=store.createAgent('owner',{name:'甲',profile:'default',computer:'cloud'}),b=store.createAgent('owner',{name:'乙',profile:'different',computer:'cloud'})
  111. await Promise.all([cloud.call('owner',a.id,'cloud_computer_shell',{command:'pwd'}),cloud.call('owner',b.id,'cloud_computer_shell',{command:'pwd'})])
  112. expect(counts()).toEqual({ensure:1,exec:2})
  113. expect(JSON.stringify(store.list('owner','grok-cloud'))).not.toContain(token)
  114. await expect(cloud.call('other',a.id,'cloud_computer_shell',{command:'pwd'})).rejects.toThrow()
  115. await expect(cloud.configure('other',token,'0.47.0')).rejects.toThrow('其他用户')
  116. })
  117. it('allows only one human controller for a shared cloud computer and resumes queued robot tools on return',async()=>{
  118. const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')
  119. const a=store.createAgent('owner',{name:'甲',profile:'default',computer:'cloud'}),b=store.createAgent('owner',{name:'乙',profile:'default',computer:'cloud'})
  120. const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(error:any){ctx.status=error.status??500;ctx.body={error:error.message}}});app.use(bodyParser());app.use(cloud.router().routes())
  121. const take=await request(app.callback()).post(`/api/app/agents/${a.id}/computer/take`).send({requestId:randomUUID()})
  122. expect(take.status).toBe(200);expect(take.body.mode).toBe('human')
  123. const denied=await request(app.callback()).post(`/api/app/agents/${b.id}/computer/take`).send({requestId:randomUUID()});expect(denied.status).toBe(409)
  124. const work=cloud.call('owner',b.id,'cloud_computer_shell',{command:'pwd'})
  125. await new Promise(resolve=>setTimeout(resolve,30));expect(counts().exec).toBe(0)
  126. const returned=await request(app.callback()).post(`/api/app/agents/${a.id}/computer/giveback`).send({controlId:take.body.controlId,token:take.body.token,notes:'完成登录'})
  127. expect(returned.status).toBe(200);await work;expect(counts().exec).toBe(1)
  128. expect(store.list('_system','computer-control')).toEqual([])
  129. })
  130. it('keeps cloud tools paused after a lost human lease until a new explicit handback',async()=>{
  131. const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')
  132. const agent=store.createAgent('owner',{name:'交还验收',profile:'default',computer:'cloud'})
  133. const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(error:any){ctx.status=error.status??500;ctx.body={error:error.message}}});app.use(bodyParser());app.use(cloud.router().routes())
  134. const base=`/api/app/agents/${agent.id}/computer`
  135. await request(app.callback()).post(base+'/take').send({requestId:randomUUID()})
  136. ;(cloud as any).manual.get('owner').expiresAt=Date.now()-1
  137. expect(await cloud.status('owner')).toMatchObject({mode:'error'})
  138. const work=cloud.call('owner',agent.id,'cloud_computer_shell',{command:'pwd'})
  139. await new Promise(resolve=>setTimeout(resolve,30));expect(counts().exec).toBe(0)
  140. const renewed=await request(app.callback()).post(base+'/take').send({requestId:randomUUID()});expect(renewed.status).toBe(200)
  141. await request(app.callback()).post(base+'/giveback').send({controlId:renewed.body.controlId,token:renewed.body.token,notes:'已完成登录'})
  142. expect(await work).toMatchObject({handoffNote:'已完成登录'});expect(counts().exec).toBe(1)
  143. })