hermesBridge.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. // @vitest-environment node
  2. import {afterEach,beforeEach,expect,it,vi} from 'vitest'
  3. import {mkdtempSync,writeFileSync,rmSync} from 'node:fs'
  4. import {tmpdir} from 'node:os'
  5. import {join,resolve} from 'node:path'
  6. import request from 'supertest'
  7. import {HermesBridgeManager} from '../../src/server/hermesBridge'
  8. import type {ServerConfig} from '../../src/server/config'
  9. import type {DashboardController} from '../../src/server/dashboardController'
  10. import {createAuthenticatedApplication,createUserAuthenticatedApplication} from './authenticatedApplication'
  11. let home:string,config:ServerConfig
  12. beforeEach(()=>{
  13. home=mkdtempSync(join(tmpdir(),'yaoyao-bridge-manager-'));writeFileSync(join(home,'config.yaml'),'{}')
  14. config={host:'127.0.0.1',port:15300,upstream:new URL('http://127.0.0.1:9119'),home,allowedHosts:new Set(),mediaRoot:home,attachmentsRoot:home,imagesRoot:home,mediaOwner:'fixture',allowInsecureLan:false,insecureLan:false,production:false}
  15. })
  16. afterEach(()=>{rmSync(home,{recursive:true,force:true});vi.restoreAllMocks();vi.unstubAllEnvs()})
  17. function fixture(dashboard?:DashboardController){
  18. let disk:any[]=[{profile:'default',exists:true,valid:true,enabled:true,installedVersion:'1.1.0',fingerprint:'old',filesCurrent:false},{profile:'writer',exists:true,valid:true,enabled:false,disabled:true,installedVersion:'1.2.0',fingerprint:'new',filesCurrent:true}]
  19. let live:any={ready:true,native_tools:true,in_process:true,computer_runtime_version:2,plugin_version:'1.1.0',plugin_fingerprint:'old'}
  20. let idle=true,gate:Promise<void>|undefined
  21. const run=vi.fn(async(_python:string,args:string[])=>{
  22. if(args.includes('--check'))return disk.map(row=>JSON.stringify(row)).join('\n')
  23. await gate
  24. const profile=args[args.indexOf('--profile')+1]
  25. disk=disk.map(row=>row.profile===profile?{...row,installedVersion:'1.2.0',fingerprint:'new',filesCurrent:true,enabled:true,disabled:false}:row)
  26. return JSON.stringify({profile,backup:join(home,'backups',profile),disabled:false})
  27. })
  28. const session={invalidateAuthentication:vi.fn(),request:vi.fn(async(path:string)=>({status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'},{name:'writer'}]}:live))}))}
  29. const manager=new HermesBridgeManager(config,session,{home,python:process.execPath,assetsRoot:resolve('.'),run,isIdle:()=>idle,dashboard})
  30. return {manager,run,session,setLive:(value:any)=>{live=value},ready:()=>{live={...live,plugin_fingerprint:'new',plugin_version:'1.2.0'}},busy:()=>{idle=false},wait:(value:Promise<void>)=>{gate=value}}
  31. }
  32. it('distinguishes stale files, disabled Profile, pending restart and the actually loaded plugin',async()=>{
  33. const f=fixture()
  34. const before=await f.manager.status();expect(before.profiles.map(p=>p.state)).toEqual(['outdated','disabled'])
  35. expect(before.profiles[1].canInstall).toBe(false)
  36. await expect(f.manager.install({profile:'writer',enable:true})).rejects.toMatchObject({code:'hermes_bridge_host_entry_required'})
  37. await f.manager.install({profile:'default'})
  38. const installed=await f.manager.install({profile:'writer',enable:true})
  39. expect(f.run.mock.calls.at(-2)?.[1]).toContain('--enable')
  40. expect(f.run.mock.calls.at(-2)?.[1]).toContain('--repair-profile-runtime')
  41. expect(installed.status.profiles.find(p=>p.profile==='writer')?.state).toBe('restart-required')
  42. expect(installed.status.profiles.find(p=>p.profile==='default')?.state).toBe('restart-required')
  43. f.ready();expect((await f.manager.status()).profiles.find(p=>p.profile==='writer')?.state).toBe('ready')
  44. expect(installed.backup).toBe(join(home,'backups','writer'))
  45. })
  46. it('restarts the managed Dashboard and verifies every Profile using fresh loaded fingerprints',async()=>{
  47. const dashboard={canRestart:true,restart:vi.fn(async()=>{f.ready()})},f=fixture(dashboard)
  48. await f.manager.install({profile:'default'});await f.manager.install({profile:'writer',enable:true})
  49. expect((await f.manager.status()).dashboard).toMatchObject({managed:true,canRestart:true,restarting:false})
  50. const result=await f.manager.restartDashboard()
  51. expect(dashboard.restart).toHaveBeenCalledTimes(1)
  52. expect(f.session.invalidateAuthentication).toHaveBeenCalledTimes(1)
  53. expect(result.status.profiles.every(p=>p.state==='ready'&&p.canInstall)).toBe(true)
  54. expect(result.status.dashboard).toMatchObject({canRestart:true,restarting:false})
  55. expect(result.message).toContain('工具桥已就绪')
  56. })
  57. it('discovers a server-managed Dashboard without desktop ownership and locks task admission while discovering',async()=>{
  58. let release!:()=>void
  59. const dashboard={canRestart:false,refresh:vi.fn(async()=>{await new Promise<void>(done=>{release=done});dashboard.canRestart=true}),restart:vi.fn(async()=>{})}
  60. const f=fixture(dashboard)
  61. const pending=f.manager.restartDashboard()
  62. expect(()=>f.manager.assertDashboardAvailable()).toThrow('正在重启')
  63. await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restarting'})
  64. await expect(f.manager.install({profile:'default'})).rejects.toMatchObject({code:'hermes_dashboard_restarting'})
  65. dashboard.refresh.mockImplementation(async()=>{})
  66. release();await pending
  67. expect(dashboard.restart).toHaveBeenCalledTimes(1)
  68. expect(f.manager.dashboardRestarting).toBe(false)
  69. })
  70. it('rejects client-supplied hosts, processes and commands before accessing a server service',async()=>{
  71. const dashboard={canRestart:true,refresh:vi.fn(async()=>{}),restart:vi.fn(async()=>{})},f=fixture(dashboard)
  72. for(const body of [{host:'client'},{pid:123},{command:'killall python'},{target:'gui/501/other'},{profile:'writer'}]){
  73. await expect(f.manager.restartDashboard(body)).rejects.toMatchObject({status:400,code:'invalid_dashboard_restart_request'})
  74. }
  75. expect(dashboard.refresh).not.toHaveBeenCalled();expect(dashboard.restart).not.toHaveBeenCalled()
  76. })
  77. it('keeps an unverified bridge pending after restart and releases the lock after failure',async()=>{
  78. const dashboard={canRestart:true,restart:vi.fn(async()=>{})},f=fixture(dashboard)
  79. await f.manager.install({profile:'default'})
  80. const result=await f.manager.restartDashboard()
  81. expect(result.status.profiles[0]!.state).toBe('restart-required')
  82. expect(result.message).toContain('尚未就绪')
  83. dashboard.restart.mockRejectedValueOnce(new Error('private process diagnostics'))
  84. await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restart_failed'})
  85. expect(f.manager.dashboardRestarting).toBe(false)
  86. expect(f.manager.idleForUpdate).toBe(true)
  87. expect((await f.manager.status()).dashboard?.canRestart).toBe(true)
  88. })
  89. it('blocks concurrent restart, installation and task admission until verification finishes',async()=>{
  90. let release!:()=>void
  91. const dashboard={canRestart:true,restart:vi.fn(()=>new Promise<void>(done=>{release=done}))},f=fixture(dashboard)
  92. const pending=f.manager.restartDashboard()
  93. expect(f.manager.idleForUpdate).toBe(false)
  94. expect(()=>f.manager.assertDashboardAvailable()).toThrow('正在重启')
  95. expect((await f.manager.status()).dashboard).toMatchObject({managed:true,canRestart:false,restarting:true})
  96. await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restarting'})
  97. await expect(f.manager.install({profile:'default'})).rejects.toMatchObject({code:'hermes_dashboard_restarting'})
  98. release();await pending
  99. expect(()=>f.manager.assertDashboardAvailable()).not.toThrow()
  100. expect(dashboard.restart).toHaveBeenCalledTimes(1)
  101. f.busy()
  102. expect((await f.manager.status()).dashboard?.canRestart).toBe(false)
  103. await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_bridge_tasks_running'})
  104. })
  105. it('refuses restart during installation and when process ownership is lost after the status check',async()=>{
  106. let release!:()=>void
  107. const dashboard={canRestart:true,restart:vi.fn(async()=>{})},f=fixture(dashboard)
  108. f.wait(new Promise<void>(done=>{release=done}))
  109. const installation=f.manager.install({profile:'default'})
  110. await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_bridge_install_busy'})
  111. release();await installation
  112. expect((await f.manager.status()).dashboard?.canRestart).toBe(true)
  113. dashboard.canRestart=false
  114. await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restart_unavailable'})
  115. expect(dashboard.restart).not.toHaveBeenCalled()
  116. })
  117. it('does not infer restart permission from local addresses or mounted directories',async()=>{
  118. const dashboard={canRestart:true,restart:vi.fn(async()=>{})},f=fixture()
  119. await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restart_unavailable'})
  120. for(const override of [
  121. {upstream:new URL('https://hermes.example')},
  122. {upstream:new URL('http://127.0.0.1:9120')},
  123. {localVmHost:'runner' as const},
  124. {hermesBridgeMount:{home,python:process.execPath}},
  125. ]){
  126. const manager=new HermesBridgeManager({...config,...override},f.session,{home,python:process.execPath,assetsRoot:resolve('.'),run:f.run,dashboard})
  127. expect((await manager.status()).dashboard?.managed).toBe(false)
  128. await expect(manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restart_unavailable'})
  129. }
  130. expect(dashboard.restart).not.toHaveBeenCalled()
  131. })
  132. it('requires admin and CSRF for restart, and blocks API and websocket task entry during restart',async()=>{
  133. let release!:()=>void
  134. const dashboard={canRestart:true,restart:vi.fn(()=>new Promise<void>(done=>{release=done}))},f=fixture(dashboard)
  135. const admin=createAuthenticatedApplication({config,hermesBridge:f.manager})
  136. try{
  137. const api=request.agent(admin.app.callback()),host='127.0.0.1:15300',path='/api/app/admin/hermes-bridge/restart'
  138. await api.post(path).set('Host',host).send({}).expect(403)
  139. const csrf=(await api.get('/api/app/bootstrap?csrfOnly=1').set('Host',host).expect(200)).body.csrfToken
  140. await api.post(path).set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({target:'client'}).expect(400)
  141. const post=(url:string)=>api.post(url).set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({})
  142. const pending=post(path).expect(200).then(response=>response)
  143. await vi.waitFor(()=>expect(dashboard.restart).toHaveBeenCalledTimes(1))
  144. expect((await post(path).expect(409)).body.code).toBe('hermes_dashboard_restarting')
  145. expect((await post('/api/app/agents').expect(409)).body.code).toBe('hermes_dashboard_restarting')
  146. expect(()=>admin.realtime.broker.command({} as any,'request',{})).toThrow('正在重启')
  147. vi.spyOn(admin.workspaceRuntime,'userActive').mockReturnValue(true)
  148. expect(()=>admin.workspaceRuntime.send('test-admin','unused',{})).toThrow('正在重启')
  149. const owners=vi.spyOn(admin.workspace,'owners');owners.mockClear()
  150. admin.workspaceRoutines.tick();expect(owners).not.toHaveBeenCalled();owners.mockRestore()
  151. release();const response=await pending
  152. expect(response.body.status.dashboard.restarting).toBe(false)
  153. expect(()=>admin.workspaceRuntime.assertCanSubmit()).not.toThrow()
  154. }finally{release?.();admin.close()}
  155. const user=createUserAuthenticatedApplication({config,hermesBridge:f.manager})
  156. try{
  157. const api=request.agent(user.app.callback()),host='127.0.0.1:15300'
  158. const csrf=(await api.get('/api/app/bootstrap?csrfOnly=1').set('Host',host).expect(200)).body.csrfToken
  159. await api.post('/api/app/admin/hermes-bridge/restart').set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({}).expect(403)
  160. expect(dashboard.restart).toHaveBeenCalledTimes(1)
  161. }finally{user.close()}
  162. })
  163. it('checks runtime activity and already admitted HTTP requests before acquiring the restart lock',async()=>{
  164. vi.stubEnv('HERMES_HOME',home)
  165. const dashboard={canRestart:true,restart:vi.fn(async()=>{})}
  166. const admin=createAuthenticatedApplication({config,dashboardSupervisor:dashboard})
  167. let release!:()=>void,entered=false
  168. const gate=new Promise<void>(done=>{release=done})
  169. admin.app.use(async ctx=>{if(ctx.path==='/restart-admission-fixture'){entered=true;await gate;ctx.body={ok:true}}})
  170. vi.spyOn(admin.upstreamSession,'request').mockImplementation(async()=>({status:200,headers:new Headers(),body:Buffer.from('{"profiles":[]}')}))
  171. try{
  172. const host='127.0.0.1:15300',api=request.agent(admin.app.callback())
  173. const csrf=(await api.get('/api/app/bootstrap?csrfOnly=1').set('Host',host).expect(200)).body.csrfToken
  174. const restart=()=>api.post('/api/app/admin/hermes-bridge/restart').set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({})
  175. for(const component of [admin.workspaceRuntime,admin.runners,admin.realtime.broker,admin.desktopEnvironments]){
  176. const idle=vi.spyOn(component,'idleForUpdate','get').mockReturnValue(false)
  177. expect((await restart().expect(409)).body.code).toBe('hermes_bridge_tasks_running')
  178. idle.mockRestore()
  179. }
  180. const pending=api.post('/restart-admission-fixture').set('Host',host).send({}).expect(200).then(r=>r)
  181. await vi.waitFor(()=>expect(entered).toBe(true))
  182. expect((await restart().expect(409)).body.code).toBe('hermes_bridge_tasks_running')
  183. expect(dashboard.restart).not.toHaveBeenCalled()
  184. release();await pending
  185. await restart().expect(200)
  186. expect(dashboard.restart).toHaveBeenCalledTimes(1)
  187. }finally{release();admin.close()}
  188. })
  189. it('prevents concurrent installs and rejects path injection, unknown profiles and installation during work',async()=>{
  190. const f=fixture();let release!:()=>void
  191. f.wait(new Promise<void>(done=>{release=done}))
  192. const pending=f.manager.install({profile:'default'})
  193. await vi.waitFor(()=>expect(f.run.mock.calls.some(([,args])=>!args.includes('--check'))).toBe(true))
  194. expect(f.manager.idleForUpdate).toBe(false)
  195. await expect(f.manager.install({profile:'writer'})).rejects.toMatchObject({code:'hermes_bridge_install_busy'})
  196. release();await pending
  197. expect(f.manager.idleForUpdate).toBe(true)
  198. await expect(f.manager.install({profile:'../other'})).rejects.toMatchObject({code:'invalid_bridge_install_request'})
  199. await expect(f.manager.install({profile:'default',home:'/wrong'})).rejects.toMatchObject({code:'invalid_bridge_install_request'})
  200. await expect(f.manager.install({profile:'missing'})).rejects.toMatchObject({code:'hermes_bridge_profile_missing'})
  201. f.busy();await expect(f.manager.install({profile:'default'})).rejects.toMatchObject({code:'hermes_bridge_tasks_running'})
  202. })
  203. it('checks remote Hermes but never invokes the local installer for it',async()=>{
  204. const f=fixture(),run=vi.fn()
  205. const manager=new HermesBridgeManager({...config,upstream:new URL('https://hermes.example')},f.session,{home,python:process.execPath,assetsRoot:resolve('.'),run})
  206. const status=await manager.status();expect(status.local).toBe(false);expect(status.profiles.every(p=>!p.canInstall)).toBe(true)
  207. await expect(manager.install({profile:'default'})).rejects.toMatchObject({code:'hermes_bridge_install_unavailable'})
  208. expect(run).not.toHaveBeenCalled()
  209. })
  210. it('exposes only admin/CSRF-protected management endpoints and returns the verified status after install',async()=>{
  211. const f=fixture(),admin=createAuthenticatedApplication({config,hermesBridge:f.manager})
  212. try{
  213. const api=request.agent(admin.app.callback()),host='127.0.0.1:15300'
  214. await api.get('/api/app/admin/hermes-bridge').set('Host',host).expect(200)
  215. await api.post('/api/app/admin/hermes-bridge/install').set('Host',host).send({profile:'default'}).expect(403)
  216. const csrf=(await api.get('/api/app/bootstrap?csrfOnly=1').set('Host',host).expect(200)).body.csrfToken
  217. const response=await api.post('/api/app/admin/hermes-bridge/install').set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({profile:'default'}).expect(200)
  218. expect(response.body.status.profiles[0].state).toBe('restart-required')
  219. }finally{admin.close()}
  220. const user=createUserAuthenticatedApplication({config,hermesBridge:f.manager})
  221. try{await request(user.app.callback()).get('/api/app/admin/hermes-bridge').set('Host','127.0.0.1:15300').expect(403)}finally{user.close()}
  222. })
  223. it('does not report an offline Hermes as loaded or expose installer diagnostic secrets',async()=>{
  224. const f=fixture()
  225. await f.manager.install({profile:'default'})
  226. f.session.request.mockRejectedValue(new Error('offline'))
  227. const state=await f.manager.status()
  228. expect(state.profiles[0].state).toBe('unavailable')
  229. expect(state.profiles[0].message).toContain('连接和权限')
  230. f.run.mockRejectedValue(new Error('sensitive diagnostic must stay private'))
  231. await expect(f.manager.install({profile:'default'})).rejects.not.toThrow('sensitive diagnostic')
  232. })
  233. it('uses explicitly mapped Hermes storage and the container interpreter with an external upstream',async()=>{
  234. const f=fixture()
  235. const mapped={...config,localVmHost:'runner' as const,upstream:new URL('http://hermes:9119'),hermesBridgeMount:{home,python:process.execPath}}
  236. const manager=new HermesBridgeManager(mapped,f.session,{assetsRoot:resolve('.'),run:f.run})
  237. expect(manager.home).toBe(home);expect(manager.python).toBe(process.execPath)
  238. const status=await manager.status()
  239. expect(status.mapped).toBe(true);expect(status.local).toBe(true);expect(status.profiles[0].canInstall).toBe(true)
  240. expect(status.message).toContain(home)
  241. const installed=await manager.install({profile:'default'})
  242. const call=f.run.mock.calls.find(([,args])=>args.includes('--profile'))!
  243. expect(call[0]).toBe(process.execPath)
  244. expect(call[1]).toContain(home)
  245. expect(call[1].join(' ')).not.toContain('/hermes-agent/venv/')
  246. expect(call[1]).not.toContain('--repair-profile-runtime')
  247. expect(installed.message).toContain('Profile 模型兼容修复需在 Hermes 所在容器或节点单独执行')
  248. expect(installed.message).not.toContain('已备份并应用 Profile 模型兼容修复')
  249. })