workerResolution.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. // @vitest-environment node
  2. import {afterEach,beforeEach,expect,it,vi} from 'vitest'
  3. import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises'
  4. import {tmpdir,homedir} from 'node:os'
  5. import {join,resolve} from 'node:path'
  6. import {HermesWorkerProcess} from '../../src/runner/worker/process'
  7. let root:string,source:string,home:string
  8. const workers:HermesWorkerProcess[]=[]
  9. beforeEach(async()=>{
  10. root=await mkdtemp(join(tmpdir(),'yaoyao-resolver-'));source=join(root,'source');home=join(root,'profile')
  11. await mkdir(join(source,'hermes_cli'),{recursive:true});await mkdir(home)
  12. // Only replace the installed Hermes config/SDK boundary. Exercise the real
  13. // Python reader and Node protocol, including failures and secret filtering.
  14. await writeFile(join(source,'dotenv.py'),'import json\ndef dotenv_values(path): return json.loads(path.read_text()) if path.exists() else {}\n')
  15. await writeFile(join(source,'hermes_constants.py'),'def set_hermes_home_override(path): pass\n')
  16. await writeFile(join(source,'hermes_cli','config.py'),'import os,json\nfrom pathlib import Path\ndef load_config_readonly(): return json.loads((Path(os.environ["HERMES_HOME"])/"config.yaml").read_text())\n')
  17. await writeFile(join(source,'hermes_cli','runtime_provider.py'),'raise RuntimeError("provider secret must never escape")\n')
  18. })
  19. afterEach(async()=>{await Promise.all(workers.splice(0).map(w=>w.close()));vi.unstubAllEnvs();await rm(root,{recursive:true,force:true})})
  20. function read(mode='resolve-workspace',profile='default'){
  21. const worker=new HermesWorkerProcess('python3',resolve('src/runner/worker/hermes_worker.py'),{mode,profile,hermesSource:source,hermesHome:home})
  22. workers.push(worker);return worker.wait('resolved')
  23. }
  24. it.each([
  25. ['.','/home/cua/workspace'],['auto','/home/cua/workspace'],['cwd','/home/cua/workspace'],
  26. ['projects/demo','/home/cua/workspace/projects/demo'],['/projects/demo','/projects/demo'],['~/projects',join(homedir(),'projects')],
  27. ])('resolves %s without importing the model SDK',async(configured,cwd)=>{
  28. await writeFile(join(home,'config.yaml'),JSON.stringify({terminal:{cwd:configured}}))
  29. expect(await read()).toMatchObject({cwd,configuredCwd:configured})
  30. })
  31. it('handles default/missing cwd and named Profiles without mixing Profile settings',async()=>{
  32. await writeFile(join(home,'config.yaml'),'{}')
  33. expect(await read()).toMatchObject({cwd:'/home/cua/workspace',configuredCwd:'.'})
  34. await mkdir(join(home,'profiles','writer'),{recursive:true})
  35. await writeFile(join(home,'profiles','writer','config.yaml'),JSON.stringify({terminal:{cwd:'/projects/writer'}}))
  36. expect(await read('resolve-workspace','writer')).toMatchObject({cwd:'/projects/writer'})
  37. })
  38. it('reports directory and Profile errors without mislabelling them as model errors',async()=>{
  39. for(const cwd of ['../../escape','\u0000',32]){
  40. await writeFile(join(home,'config.yaml'),JSON.stringify({terminal:{cwd}}))
  41. await expect(read()).rejects.toMatchObject({code:'computer_cwd_invalid'})
  42. }
  43. await expect(read('resolve-workspace','missing')).rejects.toMatchObject({code:'computer_profile_missing'})
  44. await expect(read('resolve-workspace','../escape')).rejects.toMatchObject({code:'computer_profile_invalid'})
  45. await writeFile(join(home,'config.yaml'),'not valid config')
  46. await expect(read()).rejects.toMatchObject({code:'computer_profile_config_invalid'})
  47. })
  48. it('still requires a usable model for an Agent run and does not expose SDK details',async()=>{
  49. await writeFile(join(home,'config.yaml'),'{}')
  50. await expect(read('resolve')).rejects.toMatchObject({code:'computer_model_missing'})
  51. await writeFile(join(home,'config.yaml'),JSON.stringify({model:{default:'fixture',provider:'fixture'}}))
  52. await expect(read('resolve')).rejects.toMatchObject({code:'computer_model_unavailable'})
  53. try{await read('resolve')}catch(error){expect(String(error)).not.toContain('provider secret')}
  54. })
  55. it('resolves only each selected Profile proxy snapshot, preserving case and explicit empty values',async()=>{
  56. const profiles={
  57. default:{HTTP_PROXY:'http://user:proxy-secret@127.0.0.1:7890',HTTPS_PROXY:'http://127.0.0.1:7891',ALL_PROXY:'socks5://127.0.0.1:7892',NO_PROXY:'localhost,127.0.0.1,::1'},
  58. writer:{http_proxy:'http://127.0.0.1:8890',https_proxy:'http://127.0.0.1:8891',all_proxy:'',no_proxy:'internal.test'},
  59. direct:{},
  60. }
  61. vi.stubEnv('HTTPS_PROXY','http://runner-secret@127.0.0.1:9999')
  62. // A provider import must not change the already captured Profile snapshot.
  63. await writeFile(join(source,'hermes_cli','runtime_provider.py'),'import os\nos.environ["HTTPS_PROXY"]="http://fallback-secret@127.0.0.1:9998"\ndef resolve_runtime_provider(**kwargs): return {"provider":"custom","api_mode":"chat_completions","api_key":"model-private-key"}\n')
  64. for(const [profile,proxyEnv] of Object.entries(profiles)){
  65. const directory=profile==='default'?home:join(home,'profiles',profile)
  66. await mkdir(directory,{recursive:true})
  67. await writeFile(join(directory,'config.yaml'),JSON.stringify({model:{default:'fixture',provider:'custom'}}))
  68. await writeFile(join(directory,'.env'),JSON.stringify({...proxyEnv,UNRELATED_SECRET:'profile-private-key',BARE_KEY:null}))
  69. }
  70. const frames=await Promise.all(Object.keys(profiles).map(profile=>read('resolve',profile)))
  71. frames.forEach((frame,index)=>{
  72. expect(frame.proxyEnv??{}).toEqual(Object.values(profiles)[index])
  73. expect(frame.model).toEqual({provider:'custom',api_mode:'chat_completions',api_key:'model-private-key',model:'fixture'})
  74. expect(JSON.stringify(frame)).not.toMatch(/profile-private-key|runner-secret|fallback-secret/)
  75. })
  76. expect(frames[2]).not.toHaveProperty('proxyEnv')
  77. expect(await read('resolve-workspace')).not.toHaveProperty('proxyEnv')
  78. await rm(join(home,'.env'))
  79. expect(await read('resolve')).not.toHaveProperty('proxyEnv')
  80. })
  81. it('starts Python with only explicit proxy keys and never inherits Runner secrets or proxy variables',async()=>{
  82. const proxyEnv={HTTP_PROXY:'http://user:proxy-secret@127.0.0.1:7890',HTTPS_PROXY:'',ALL_PROXY:'socks5://127.0.0.1:7891',NO_PROXY:'localhost,127.0.0.1',http_proxy:'',https_proxy:'http://127.0.0.1:7892',all_proxy:'',no_proxy:'internal.test'}
  83. for(const key of Object.keys(proxyEnv))vi.stubEnv(key,'runner-private-proxy')
  84. vi.stubEnv('WORKER_SECRET','runner-private-key')
  85. const script=join(root,'environment.py')
  86. await writeFile(script,'import os,sys,json\nboot=json.loads(sys.stdin.readline())\nprint(json.dumps({"nonce":boot["nonce"],"type":"resolved","env":dict(os.environ),"boot":boot}),flush=True)\n')
  87. for(const configured of [proxyEnv,undefined]){
  88. const worker=new HermesWorkerProcess('python3',script,{proxyEnv:configured?{...configured,WORKER_SECRET:'injected-private-key',PYTHONPATH:'/forbidden',HOME:'/forbidden',PATH:'/forbidden'}:undefined})
  89. workers.push(worker)
  90. const frame=await worker.wait('resolved')
  91. for(const key of Object.keys(proxyEnv))expect(frame.env[key]).toBe(configured?.[key as keyof typeof proxyEnv])
  92. expect(frame.env).toMatchObject({HOME:homedir(),PATH:process.env.PATH,PYTHONNOUSERSITE:'1',PYTHONDONTWRITEBYTECODE:'1'})
  93. expect(frame.env).not.toHaveProperty('WORKER_SECRET')
  94. expect(frame.env).not.toHaveProperty('PYTHONPATH')
  95. expect(frame.boot).not.toHaveProperty('proxyEnv')
  96. }
  97. })
  98. it('inherits only compression controls and model limits, preserving explicit disable and Hermes defaults',async()=>{
  99. await writeFile(join(source,'hermes_cli','runtime_provider.py'),'def resolve_runtime_provider(**kwargs): return {"provider":"custom","api_mode":"chat_completions","api_key":"private-model-key"}\n')
  100. const model={default:'fixture',provider:'custom',context_length:32768,max_tokens:4096,api_key:'private-config-key'}
  101. await writeFile(join(home,'config.yaml'),JSON.stringify({model,compression:{enabled:true,threshold:0.65,target_ratio:0.25,protect_last_n:8,in_place:false,context_timeout_seconds:60,model_thresholds:{fixture:0.75},api_key:'private-other-key'},plugins:{enabled:['unsafe']},terminal:{cwd:'.'}}))
  102. expect((await read('resolve')).contextConfig).toEqual({compression:{enabled:true,threshold:0.65,target_ratio:0.25,protect_last_n:8,in_place:false,context_timeout_seconds:60,model_thresholds:{fixture:0.75}},model:{context_length:32768,max_tokens:4096}})
  103. await writeFile(join(home,'config.yaml'),JSON.stringify({model,compression:{enabled:false,threshold:0.5}}))
  104. expect((await read('resolve')).contextConfig.compression).toEqual({enabled:false,threshold:0.5})
  105. await writeFile(join(home,'config.yaml'),JSON.stringify({model:{default:'fixture'}}))
  106. expect((await read('resolve')).contextConfig).toEqual({compression:{},model:{}})
  107. expect(await read('resolve-workspace')).not.toHaveProperty('contextConfig')
  108. })