workspacePanels.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  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,grokComputerRules} 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('persists the separate host option, checks the Runner grant and refuses changes during active work',async()=>{
  108. const {cloud}=fakeCloud(),agent=store.createAgent('owner',{name:'双环境',profile:'default',computer:'vm',execution:'computer'})
  109. const target=vi.fn(()=>({runner:{id:'fixture'}} as any));nodes.runnerTarget=target
  110. 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())
  111. const path=`/api/app/agents/${agent.id}/computer-selection`
  112. const response=await request(app.callback()).put(path).send({computer:'vm',allowHostEnvironment:true}).expect(200)
  113. expect(response.body.agent).toMatchObject({computer:'vm',execution:'computer',allowHostEnvironment:true})
  114. expect(target).toHaveBeenCalledWith('owner','local',expect.objectContaining({agentId:agent.id,hostAccess:true}))
  115. await request(app.callback()).put(path).set('x-user','other').send({computer:'vm',allowHostEnvironment:true}).expect(404)
  116. store.put('owner','turn','active-hybrid',{agentId:agent.id,status:'running'})
  117. await request(app.callback()).put(path).send({computer:'vm',allowHostEnvironment:false}).expect(409)
  118. expect(store.require<any>('owner','agent',agent.id).allowHostEnvironment).toBe(true)
  119. store.remove('owner','turn','active-hybrid');await request(app.callback()).put(path).send({computer:'vm',allowHostEnvironment:false}).expect(200)
  120. target.mockClear();nodes.targetForAgent('owner',store.require<any>('owner','agent',agent.id))
  121. expect(target.mock.calls[0]?.[2]).not.toHaveProperty('hostAccess')
  122. })
  123. it('persists the separate Profile execution mode, checks its Runner capability and prevents running or inherited changes',async()=>{
  124. const {cloud}=fakeCloud(),agent=store.createAgent('owner',{name:'本机协作',profile:'default',computer:'vm',execution:'computer'})
  125. const target=vi.fn(()=>({runner:{id:'fixture'}} as any));nodes.runnerTarget=target
  126. 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())
  127. const path=`/api/app/agents/${agent.id}/computer-selection`
  128. const changed=await request(app.callback()).put(path).send({computer:'vm',vmExecution:'profile'}).expect(200)
  129. expect(changed.body.agent).toMatchObject({computer:'vm',execution:'computer',vmExecution:'profile',allowHostEnvironment:true})
  130. expect(target).toHaveBeenCalledWith('owner','local',expect.objectContaining({profileSession:true,hostAccess:true}))
  131. store.put('owner','turn','active-profile',{agentId:agent.id,status:'running'})
  132. await request(app.callback()).put(path).send({computer:'vm',vmExecution:'worker'}).expect(409)
  133. expect(()=>store.updateAgent('owner',agent.id,{vmExecution:'worker'})).toThrow('先停止当前任务')
  134. store.remove('owner','turn','active-profile')
  135. const member=store.createAgent('owner',{name:'不继承模式',profile:'default',computer:'vm',execution:'computer',vmExecution:'profile'},{createdByAgentId:agent.id,createdFromRunId:'run'})
  136. expect(member.vmExecution).toBe('worker');expect(member.allowHostEnvironment).toBe(false)
  137. const worker=await request(app.callback()).put(path).send({computer:'vm',vmExecution:'worker'}).expect(200)
  138. expect(worker.body.agent).toMatchObject({vmExecution:'worker',allowHostEnvironment:false})
  139. await request(app.callback()).put(path).send({computer:'vm',vmExecution:'profile',allowHostEnvironment:false}).expect(400)
  140. await request(app.callback()).put(path).send({computer:'vm',vmExecution:'profile'}).expect(200)
  141. const legacy=await request(app.callback()).put(path).send({computer:'vm',allowHostEnvironment:false}).expect(200)
  142. expect(legacy.body.agent).toMatchObject({vmExecution:'worker',allowHostEnvironment:false})
  143. store.updateAgent('owner',agent.id,{vmExecution:'profile'})
  144. expect(store.updateAgent('owner',agent.id,{allowHostEnvironment:false})).toMatchObject({vmExecution:'worker',allowHostEnvironment:false})
  145. await request(app.callback()).put(path).send({computer:'vm',vmExecution:'profile'}).expect(200)
  146. await request(app.callback()).put(path).send({computer:'off'}).expect(200)
  147. expect(store.require<any>('owner','agent',agent.id)).toMatchObject({vmExecution:'worker',allowHostEnvironment:false})
  148. })
  149. it('allows the host option for cloud and VM only and clears it when selecting another environment',async()=>{
  150. const {cloud}=fakeCloud(),agent=store.createAgent('owner',{name:'云端本机选项',profile:'default',computer:'cloud'})
  151. const available=vi.spyOn(cloud,'requireAvailable').mockResolvedValue(),connect=vi.spyOn(cloud,'connect').mockResolvedValue({} as any)
  152. nodes.runnerTarget=()=>({runner:{id:'fixture'}} as any)
  153. 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())
  154. const path=`/api/app/agents/${agent.id}/computer-selection`
  155. for(const computer of ['cloud','vm']){
  156. const enabled=await request(app.callback()).put(path).send({computer,allowHostEnvironment:true}).expect(200)
  157. expect(enabled.body.agent).toMatchObject({computer,allowHostEnvironment:true})
  158. }
  159. expect(connect).not.toHaveBeenCalled();expect(available).not.toHaveBeenCalled()
  160. for(const computer of ['auto','off','local','browser']){
  161. store.updateAgent('owner',agent.id,{computer:'vm',execution:'computer',allowHostEnvironment:true})
  162. const switched=await request(app.callback()).put(path).send({computer,allowHostEnvironment:true}).expect(200)
  163. expect(switched.body.agent.allowHostEnvironment).toBe(false)
  164. const returned=await request(app.callback()).put(path).send({computer:'vm'}).expect(200)
  165. expect(returned.body.agent.allowHostEnvironment).toBe(false)
  166. }
  167. await request(app.callback()).put(path).send({computer:'hybrid'}).expect(400)
  168. expect(grokComputerRules(true)).toContain('基础 Profile 已授权的本机文件和终端工具')
  169. expect(grokComputerRules(false)).toContain('本轮不要使用本机终端')
  170. })
  171. it('clears inapplicable host grants through generic updates and migrates the previous combined choice once',()=>{
  172. const agent=store.createAgent('owner',{name:'权限迁移',profile:'default',computer:'vm',execution:'computer',allowHostEnvironment:true})
  173. expect(store.updateAgent('owner',agent.id,{computer:'auto'}).allowHostEnvironment).toBe(false)
  174. store.put('owner','agent',agent.id,{...agent,computer:'hybrid',allowHostEnvironment:undefined})
  175. store.db.prepare('DELETE FROM workspace_migrations WHERE id=?').run('host-environment-option-v1')
  176. store.close();store=new WorkspaceStore(home)
  177. expect(store.require<any>('owner','agent',agent.id)).toMatchObject({computer:'vm',allowHostEnvironment:true})
  178. store.updateAgent('owner',agent.id,{computer:'off'});store.close();store=new WorkspaceStore(home)
  179. expect(store.require<any>('owner','agent',agent.id)).toMatchObject({computer:'off',allowHostEnvironment:false})
  180. })
  181. it('auto observation never provisions; multiple robots share one cloud connection with private credentials',async()=>{
  182. const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')
  183. await cloud.state('owner');await cloud.status('owner');expect(counts()).toEqual({ensure:0,exec:0})
  184. const a=store.createAgent('owner',{name:'甲',profile:'default',computer:'cloud'}),b=store.createAgent('owner',{name:'乙',profile:'different',computer:'cloud'})
  185. await Promise.all([cloud.call('owner',a.id,'cloud_computer_shell',{command:'pwd'}),cloud.call('owner',b.id,'cloud_computer_shell',{command:'pwd'})])
  186. expect(counts()).toEqual({ensure:1,exec:2})
  187. expect(JSON.stringify(store.list('owner','grok-cloud'))).not.toContain(token)
  188. await expect(cloud.call('other',a.id,'cloud_computer_shell',{command:'pwd'})).rejects.toThrow()
  189. await expect(cloud.configure('other',token,'0.47.0')).rejects.toThrow('其他用户')
  190. })
  191. it('allows only one human controller for a shared cloud computer and resumes queued robot tools on return',async()=>{
  192. const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')
  193. const a=store.createAgent('owner',{name:'甲',profile:'default',computer:'cloud'}),b=store.createAgent('owner',{name:'乙',profile:'default',computer:'cloud'})
  194. 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())
  195. const take=await request(app.callback()).post(`/api/app/agents/${a.id}/computer/take`).send({requestId:randomUUID()})
  196. expect(take.status).toBe(200);expect(take.body.mode).toBe('human')
  197. const denied=await request(app.callback()).post(`/api/app/agents/${b.id}/computer/take`).send({requestId:randomUUID()});expect(denied.status).toBe(409)
  198. const work=cloud.call('owner',b.id,'cloud_computer_shell',{command:'pwd'})
  199. await new Promise(resolve=>setTimeout(resolve,30));expect(counts().exec).toBe(0)
  200. const returned=await request(app.callback()).post(`/api/app/agents/${a.id}/computer/giveback`).send({controlId:take.body.controlId,token:take.body.token,notes:'完成登录'})
  201. expect(returned.status).toBe(200);await work;expect(counts().exec).toBe(1)
  202. expect(store.list('_system','computer-control')).toEqual([])
  203. })
  204. it('keeps cloud tools paused after a lost human lease until a new explicit handback',async()=>{
  205. const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')
  206. const agent=store.createAgent('owner',{name:'交还验收',profile:'default',computer:'cloud'})
  207. 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())
  208. const base=`/api/app/agents/${agent.id}/computer`
  209. await request(app.callback()).post(base+'/take').send({requestId:randomUUID()})
  210. ;(cloud as any).manual.get('owner').expiresAt=Date.now()-1
  211. expect(await cloud.status('owner')).toMatchObject({mode:'error'})
  212. const work=cloud.call('owner',agent.id,'cloud_computer_shell',{command:'pwd'})
  213. await new Promise(resolve=>setTimeout(resolve,30));expect(counts().exec).toBe(0)
  214. const renewed=await request(app.callback()).post(base+'/take').send({requestId:randomUUID()});expect(renewed.status).toBe(200)
  215. await request(app.callback()).post(base+'/giveback').send({controlId:renewed.body.controlId,token:renewed.body.token,notes:'已完成登录'})
  216. expect(await work).toMatchObject({handoffNote:'已完成登录'});expect(counts().exec).toBe(1)
  217. })