executionSettings.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. // @vitest-environment node
  2. import {afterEach,beforeEach,expect,it,vi} from 'vitest'
  3. import {mkdtempSync,rmSync,readFileSync} from 'node:fs'
  4. import {tmpdir} from 'node:os'
  5. import {join} from 'node:path'
  6. import {randomUUID} from 'node:crypto'
  7. import {WorkspaceStore} from '../../src/server/workspaceStore'
  8. import {WorkspaceNodes} from '../../src/server/workspaceGateway'
  9. import {loadServerConfig} from '../../src/server/config'
  10. import {ExecutionSettings} from '../../src/server/executionSettings'
  11. import {buildWorkspaceEnvironment,workspaceEnvironmentTools} from '../../src/server/workspaceEnvironment'
  12. import {parseHostTools} from '../../src/server/hostToolSettings'
  13. import {TASK_COMMAND,redactTaskOutput} from '../../src/runner/worker/taskEnvironment'
  14. import {spawnSync} from 'node:child_process'
  15. import Koa from 'koa'
  16. import {bodyParser} from '@koa/bodyparser'
  17. import request from 'supertest'
  18. import {LocalAuthStore} from '../../src/server/localAuth'
  19. let home:string,store:WorkspaceStore,settings:ExecutionSettings
  20. const id=randomUUID(),other=randomUUID()
  21. beforeEach(()=>{
  22. home=mkdtempSync(join(tmpdir(),'yaoyao-execution-'));store=new WorkspaceStore(home)
  23. const config=loadServerConfig({HERMES_YAOYAO_HOME:home,HERMES_YAOYAO_UPSTREAM:'http://127.0.0.1:9119'})
  24. const nodes=new WorkspaceNodes(store,config,{} as any,randomUUID())
  25. settings=new ExecutionSettings(store,nodes,{isUserActive:()=>true} as any)
  26. for(const [owner,agentId] of [['alice',id],['bob',other]])store.put(owner!,'agent',agentId!,{id:agentId,name:owner,nodeId:'local',profile:'default',archived:false})
  27. })
  28. afterEach(()=>{settings.close();vi.restoreAllMocks();store.db.close();rmSync(home,{recursive:true,force:true})})
  29. it('defaults a new installation to no environment; shared resources require explicit Bot grants',()=>{
  30. expect(settings.selection()).toMatchObject({mode:'none',credentialSource:'yaoyao'})
  31. const resource=settings.saveResource({name:'API',kind:'api',baseUrl:'https://api.example.test/v1/',token:'fixture-sensitive-token',tokenEnv:'XAI_API_KEY',agentIds:[id]})
  32. expect(settings.openTurn('alice',id).vmTaskEnvironment().variables.XAI_API_KEY).toBe('fixture-sensitive-token')
  33. expect(settings.openTurn('bob',other).vmTaskEnvironment().variables).toEqual({})
  34. expect(JSON.stringify(settings.view())).not.toContain('fixture-sensitive-token')
  35. expect(JSON.stringify(store.list('_system','execution-resource'))).not.toContain('fixture-sensitive-token')
  36. expect(()=>settings.saveResource({...resource,envKeys:undefined,configured:undefined,revision:resource.revision} as any)).toThrow() // public summaries cannot overwrite secrets
  37. })
  38. it('keeps VM resource grants when the default target changes and still revokes old turns',()=>{
  39. const resource=settings.saveResource({name:'Variables',kind:'variables',variables:{SERVICE_SECRET:'fixture-secret'},agentIds:[id]})
  40. const turn=settings.openTurn('alice',id)
  41. settings.saveSelection({mode:'server',revision:1})
  42. const serverDefaultTurn=settings.openTurn('alice',id)
  43. expect(serverDefaultTurn.vmTaskEnvironment()).toEqual({variables:{SERVICE_SECRET:'fixture-secret'},files:[]})
  44. expect(process.env.SERVICE_SECRET).toBeUndefined()
  45. expect(turn.vmTaskEnvironment().variables.SERVICE_SECRET).toBe('fixture-secret') // default target does not revoke resource grants
  46. settings.removeResource(resource.id)
  47. expect(()=>turn.vmTaskEnvironment()).toThrow('授权已变化')
  48. expect(()=>serverDefaultTurn.vmTaskEnvironment()).toThrow('授权已变化')
  49. })
  50. it('preserves write-only secrets, rejects conflicting variables and internal environment overrides',()=>{
  51. const r=settings.saveResource({name:'Variables',kind:'variables',variables:{EXTERNAL_KEY:'saved'},agentIds:[id]})
  52. settings.saveResource({name:'Renamed',kind:'variables',agentIds:[id],revision:r.revision},r.id)
  53. expect(settings.openTurn('alice',id).vmTaskEnvironment().variables.EXTERNAL_KEY).toBe('saved')
  54. expect(()=>settings.saveResource({name:'Conflict',kind:'variables',variables:{EXTERNAL_KEY:'new'},agentIds:[id]})).toThrow('重复')
  55. for(const key of ['YAOYAO_ADMIN_TOKEN','NODE_OPTIONS','LD_PRELOAD','HTTPS_PROXY','CODEX_HOME'])expect(()=>settings.saveResource({name:'unsafe',kind:'variables',variables:{[key]:'bad'},agentIds:[id]})).toThrow()
  56. })
  57. it.each(['none','server','virtual'] as const)('keeps authorized API requests scoped and redacted with default %s',async mode=>{
  58. settings.saveSelection({mode,revision:1})
  59. const r=settings.saveResource({name:'Grok API',kind:'api',baseUrl:'https://api.example.test/v1/',token:'secret-fixture',agentIds:[id]})
  60. const fetcher=vi.spyOn(globalThis,'fetch').mockResolvedValue(new Response('secret-fixture'))
  61. const turn=settings.openTurn('alice',id)
  62. expect(await turn.call({serviceId:r.id,path:'images/generations',method:'POST',body:{prompt:'test'}},new AbortController().signal)).toEqual({status:200,body:'[已隐藏授权]'})
  63. expect(turn.catalog().map(tool=>tool.id)).toContain('yaoyao_service_request')
  64. await expect(settings.openTurn('bob',other).call({serviceId:r.id,path:'images',method:'GET'},new AbortController().signal)).rejects.toThrow('未授权')
  65. expect(fetcher.mock.calls[0]?.[1]).toMatchObject({headers:{authorization:'Bearer secret-fixture'},redirect:'error'})
  66. for(const path of ['https://evil.test','../admin','/%2e%2e/admin','..%2fadmin','..%5cadmin','%252e%252e/admin'])await expect(turn.call({serviceId:r.id,path,method:'GET'},new AbortController().signal)).rejects.toThrow()
  67. })
  68. it('requires an administrator on every settings endpoint and never returns credential values',async()=>{
  69. const auth=Object.create(LocalAuthStore.prototype)
  70. auth.current=(ctx:Koa.Context)=>ctx.get('x-role')?{id:'fixture',role:ctx.get('x-role'),mustChangePassword:false}:undefined
  71. settings.auth.requireAdmin=ctx=>auth.requireAdmin(ctx)
  72. settings.saveResource({name:'Private',kind:'variables',variables:{API_KEY:'private-fixture-key'},agentIds:[id]})
  73. const app=new Koa(),router=settings.router()
  74. app.use(async(ctx,next)=>{try{await next()}catch(error){ctx.status=(error as any).status??500;ctx.body={error:'rejected'}}})
  75. app.use(router.routes())
  76. for(const role of ['', 'member']){
  77. const status=role?403:401
  78. for(const [method,path] of [['get',''],['put','/environment'],['put','/proxy'],['post','/proxy/test'],['post','/resources'],['put','/resources/'+randomUUID()],['delete','/resources/'+randomUUID()],['post','/codex-auth'],['get','/codex-auth/'+randomUUID()],['delete','/codex-auth/'+randomUUID()]]){
  79. await (request(app.callback()) as any)[method!]('/api/app/admin/execution'+path).set('x-role',role).expect(status)
  80. }
  81. }
  82. const response=await request(app.callback()).get('/api/app/admin/execution').set('x-role','admin').expect(200)
  83. expect(response.headers['cache-control']).toBe('no-store')
  84. expect(JSON.stringify(response.body)).not.toContain('private-fixture-key')
  85. })
  86. it('saves a browser login through the encrypted resource store and only consumes it after successful validation',async()=>{
  87. const secret=JSON.stringify({tokens:{access_token:'browser-fixture-secret'}}),attemptId=randomUUID()
  88. settings.auth.requireAdmin=()=>({id:'alice',role:'admin'}) as any
  89. const credentials=vi.spyOn(settings.codexAuthorization,'credentials').mockReturnValue(secret)
  90. const consume=vi.spyOn(settings.codexAuthorization,'cancel').mockReturnValue({id:attemptId,status:'cancelled',expiresAt:0})
  91. const app=new Koa(),router=settings.router()
  92. app.use(async(ctx,next)=>{try{await next()}catch(error){ctx.status=(error as any).status??500;ctx.body={error:'rejected'}}})
  93. app.use(bodyParser());app.use(router.routes())
  94. const body={name:'浏览器登录',kind:'codex',agentIds:[id],authAttemptId:attemptId}
  95. await request(app.callback()).post('/api/app/admin/execution/resources').send({...body,revision:9}).expect(409)
  96. expect(consume).not.toHaveBeenCalled()
  97. await request(app.callback()).post('/api/app/admin/execution/resources').send({...body,authJson:secret}).expect(400)
  98. const result=await request(app.callback()).post('/api/app/admin/execution/resources').send(body).expect(201)
  99. expect(credentials).toHaveBeenCalledWith('alice',attemptId);expect(consume).toHaveBeenCalledWith('alice',attemptId)
  100. expect(JSON.stringify(result.body)).not.toContain('browser-fixture-secret')
  101. expect(JSON.stringify(store.list('_system','execution-resource'))).not.toContain('browser-fixture-secret')
  102. expect(settings.openTurn('alice',id).vmTaskEnvironment().files).toEqual([{name:'auth.json',content:secret,envKey:'CODEX_HOME'}])
  103. expect(settings.openTurn('bob',other).vmTaskEnvironment().files).toEqual([])
  104. })
  105. it('requires every co-resident Bot to be granted credentials before injecting them into a shared VM',()=>{
  106. const environment=randomUUID()
  107. for(const [owner,agentId] of [['alice',id],['bob',other]])store.put(owner!,'agent',agentId!,{...store.require<any>(owner!,'agent',agentId!),computerEnvironmentId:environment})
  108. const resource=settings.saveResource({name:'Shared',kind:'variables',variables:{API_KEY:'fixture'},agentIds:[id]})
  109. expect(()=>settings.openTurn('alice',id).vmTaskEnvironment()).toThrow('共享虚拟环境')
  110. settings.saveResource({name:'Shared',kind:'variables',agentIds:[id,other],revision:resource.revision},resource.id)
  111. expect(settings.openTurn('alice',id).vmTaskEnvironment().variables.API_KEY).toBe('fixture')
  112. })
  113. it('migrates existing installations to the server environment without promoting account credentials',()=>{
  114. store.remove('_system','execution-settings','selection')
  115. store.put('alice','account-authorization','fixture',{token:'account-private-fixture'})
  116. const migrated=new ExecutionSettings(store,settings.nodes,settings.auth)
  117. expect(migrated.selection()).toMatchObject({mode:'server',credentialSource:'native'})
  118. expect(migrated.view().resources).toEqual([])
  119. expect(store.get('alice','account-authorization','fixture')).toEqual({token:'account-private-fixture'})
  120. })
  121. it('isolates credential files per command and removes them on nonzero exits',()=>{
  122. const environment={variables:{EXTERNAL_KEY:'fixture-secret'},files:[{name:'auth.json',content:'{"tokens":{"access_token":"codex-fixture-secret"}}',envKey:'CODEX_HOME'}]}
  123. const result=spawnSync('python3',['-c',TASK_COMMAND],{input:JSON.stringify({environment,command:'printf "%s\\n" "$EXTERNAL_KEY" "$CODEX_HOME"; cat "$CODEX_HOME/auth.json"; exit 7'}),encoding:'utf8'})
  124. expect(result.status).toBe(7);expect(result.stdout).toContain('fixture-secret')
  125. const path=result.stdout.split('\n')[1]!
  126. expect(()=>readFileSync(join(path,'auth.json'))).toThrow()
  127. expect(redactTaskOutput({stdout:result.stdout},environment).stdout).not.toContain('fixture-secret')
  128. })
  129. it.each(['none','server','virtual'] as const)('mounts all allowed environments regardless of default %s',mode=>{
  130. const agent=store.require<any>('alice','agent',id)
  131. const env=buildWorkspaceEnvironment({agent,globals:parseHostTools({managedBrowser:true}),execution:{mode,revision:1,credentialSource:mode==='server'?'native':'yaoyao'},desktop:{capturedAt:0,hosts:[]},bridge:true,cloud:true,plugins:true,managedBrowser:true})
  132. const names=workspaceEnvironmentTools(env).map(t=>t.id)
  133. expect(names).toEqual(expect.arrayContaining(['computer_shell','cloud_computer_shell','managed_browser_open','managed_browser_to_vm']))
  134. expect(env.tools.plugins).toBe(true)
  135. const disabled=buildWorkspaceEnvironment({agent,globals:parseHostTools({vm:false,cloud:false,managedBrowser:false}),execution:env.selection,desktop:{capturedAt:0,hosts:[]},bridge:true,cloud:true,plugins:false,managedBrowser:true})
  136. expect(workspaceEnvironmentTools(disabled)).toEqual([])
  137. expect(disabled.virtual.vm.status).toBe('disabled')
  138. })