credentialVaultCoordinator.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. // @vitest-environment node
  2. import { afterEach, expect, it, vi } from 'vitest'
  3. import type Koa from 'koa'
  4. import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import { randomUUID } from 'node:crypto'
  8. import { once } from 'node:events'
  9. import { CredentialVaultStore } from '../../src/server/credentialVault/store'
  10. import { CredentialVaultBroker } from '../../src/server/credentialVault/broker'
  11. import type {CredentialExecutor} from '../../src/server/credentialVault/executor'
  12. import { CredentialVaultClient, serveCredentialVault } from '../../src/server/credentialVault/transport'
  13. import { CredentialVaultCoordinator } from '../../src/server/credentialVault/coordinator'
  14. import { LocalAuthStore } from '../../src/server/localAuth'
  15. import { HttpError } from '../../src/server/errors'
  16. import type { WorkspaceStore } from '../../src/server/workspaceStore'
  17. import type { RunnerHub } from '../../src/server/runnerHub'
  18. import type { Server } from 'node:http'
  19. const cleanups:Array<()=>Promise<void>>=[]
  20. const password='DUMMY-vault-master-only',secret='DUMMY-website-secret-only'
  21. function context(cookie='',path='',method='GET',body:unknown={}):Koa.Context{
  22. const headers:Record<string,any>={}
  23. return {state:{},get:(key:string)=>key.toLowerCase()==='cookie'?cookie:'',set:(key:string,value:any)=>{headers[key.toLowerCase()]=value},response:{headers},path,method,request:{body},params:{},secure:false,req:{socket:{remoteAddress:'127.0.0.1'}}} as unknown as Koa.Context
  24. }
  25. function cookie(ctx:Koa.Context){const value=ctx.response.headers['set-cookie'];return String(Array.isArray(value)?value.at(-1):value).split(';')[0]!}
  26. async function fixture(ipc=false,executor?:CredentialExecutor){
  27. const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-coordinator-')),auth=new LocalAuthStore(home),login=context(),user=auth.setupAdmin(login,'dummy-user','dummy-login-password'),cookies=cookie(login)
  28. const vault=await CredentialVaultStore.create(join(home,'vault')),broker=new CredentialVaultBroker(vault,Date.now,executor)
  29. let client:CredentialVaultClient,server:Server|undefined
  30. if(ipc){
  31. const socket=join(home,'dummy.sock'),tokenFile=join(home,'dummy-control'),token='DUMMY-CONTROL-TOKEN-NOT-PRODUCTION-00000'
  32. writeFileSync(tokenFile,token,{mode:0o600});server=serveCredentialVault(broker,token);server.listen(socket);await once(server,'listening');chmodSync(socket,0o600)
  33. // In-process fixtures do NOT prove OS isolation. Production executors remain disabled.
  34. client=new CredentialVaultClient({socket,tokenFile,hermesUid:process.getuid!()+1})
  35. }else{
  36. const epoch=randomUUID();broker.hello(epoch)
  37. client=new CredentialVaultClient()
  38. vi.spyOn(client,'call').mockImplementation(async(o,s,c,v)=>c==='execute'?broker.execute(epoch,o,s,(v as any).id,(v as any).input):broker.dispatch(epoch,o,s,c,v))
  39. }
  40. const agent={id:randomUUID(),name:'dummy-Bot',nodeId:'local',profile:'default',archived:false},run={id:randomUUID(),stopRequested:false},work={id:randomUUID(),agentId:agent.id,runId:run.id,status:'running',cancelRequested:false}
  41. const objects:any={agent,run,turn:work},store={require:(o:string,kind:string,id:string)=>{if(o!==user.id||objects[kind]?.id!==id)throw new HttpError(404,'fixture missing','not_found');return objects[kind]},list:(o:string,kind:string)=>o===user.id&&objects[kind]?[objects[kind]]:[]} as unknown as WorkspaceStore
  42. let binding={runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-epoch'}
  43. const hub={credentialBinding:()=>binding} as unknown as RunnerHub,coordinator=new CredentialVaultCoordinator(client,store,auth,hub),router=coordinator.router()
  44. const request=async(path:string,method='GET',body:unknown={},c=cookies)=>{const ctx=context(c,'/api/app/vault'+path,method,body);await router.routes()(ctx,async()=>{});return ctx.body as any}
  45. cleanups.push(async()=>{coordinator.close();if(server){await new Promise<void>(resolve=>server!.close(()=>resolve()));server.closeAllConnections()}else broker.close();rmSync(home,{recursive:true,force:true})})
  46. await request('/initialize','POST',{password})
  47. const unlock=()=>request('/unlock','POST',{password}),status=()=>request('')
  48. await unlock();const entry=await request('/entries','POST',{name:'dummy-site',username:'dummy-user',target:{kind:'website',origin:'https://example.test'},secret})
  49. const grant=()=>request('/leases','POST',{credentialRef:entry.id,agentId:agent.id,workId:work.id,operation:'website.login',seconds:60})
  50. const turn=(signal=new AbortController().signal,waiting=vi.fn())=>coordinator.openTurn(user.id,agent.id,work.id,signal,()=>{},waiting)
  51. return {home,auth,user,cookies,broker,client,coordinator,request,unlock,status,entry,agent,run,work,grant,turn,changeBinding:()=>{binding={...binding,runnerInstance:randomUUID()}},hub,server}
  52. }
  53. afterEach(async()=>{for(const cleanup of cleanups.splice(0))await cleanup();vi.restoreAllMocks()})
  54. it('uses private IPC for encrypted CRUD and short grants but returns manual takeover instead of a login',async()=>{
  55. const f=await fixture(true),lease=await f.grant(),t=f.turn()
  56. expect((await f.status()).leases).toHaveLength(1)
  57. expect(await t.call('credential_refs',{})).toEqual({references:[{credentialRef:f.entry.id,operation:'website.login',allowedTarget:f.entry.target}]})
  58. expect(await t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'})).toMatchObject({status:'manual_takeover_required',reason:'vault_executor_not_enabled'})
  59. const status=await f.status();expect(JSON.stringify(status)).not.toContain(secret);expect(status.execution).toBe('disabled');expect(lease.expiresAt-Date.now()).toBeLessThanOrEqual(60000)
  60. t.close();await vi.waitFor(async()=>expect((await f.status()).leases).toHaveLength(0))
  61. })
  62. it.each(['lock','logout','connection'] as const)('rejects a late unlock response following %s and leaves the vault locked',async cause=>{
  63. const f=await fixture();await f.request('/lock','POST',{})
  64. const original=f.client.call.bind(f.client),reached=Promise.withResolvers<void>(),release=Promise.withResolvers<void>()
  65. vi.spyOn(f.client,'call').mockImplementation(async(o,s,c,v)=>{const result=await original(o,s,c,v);if(c==='unlock'){reached.resolve();await release.promise}return result})
  66. const pending=f.unlock(),assertion=expect(pending).rejects.toMatchObject({code:'vault_session_revoked'});await reached.promise
  67. if(cause==='lock')await f.request('/lock','POST',{})
  68. if(cause==='logout'){await f.coordinator.logout(f.user.id,f.cookies);f.auth.logout(context(f.cookies))}
  69. if(cause==='connection')f.client.onDisconnect()
  70. release.resolve();await assertion
  71. expect(f.broker.status(f.user.id,f.auth.sessionBinding(f.cookies)!).unlocked).toBe(false)
  72. })
  73. it.each(['cancel','reconnect','permission'] as const)('revokes a grant created during %s before it can reach the Bot',async cause=>{
  74. const f=await fixture(),original=f.client.call.bind(f.client),reached=Promise.withResolvers<void>(),release=Promise.withResolvers<void>()
  75. vi.spyOn(f.client,'call').mockImplementation(async(o,s,c,v)=>{const result=await original(o,s,c,v);if(c==='grant'){reached.resolve();await release.promise}return result})
  76. const pending=f.grant(),assertion=expect(pending).rejects.toMatchObject({code:cause==='permission'?'vault_session_revoked':'vault_task_revoked'});await reached.promise
  77. if(cause==='cancel')f.work.cancelRequested=true
  78. if(cause==='reconnect')f.changeBinding()
  79. if(cause==='permission')vi.spyOn(f.auth,'pushAuthorizationVersion').mockReturnValue(1000)
  80. release.resolve();await assertion
  81. expect(f.broker.status(f.user.id,f.auth.sessionBinding(f.cookies)!).leases).toHaveLength(0)
  82. })
  83. it('a Bot gets no stale refs after revoke or Runner reconnect and cannot request another Bot task',async()=>{
  84. const f=await fixture(),lease=await f.grant(),t=f.turn()
  85. await f.request('/leases/'+lease.id,'DELETE');expect(await t.call('credential_refs',{})).toEqual({references:[]})
  86. await f.grant();f.changeBinding();expect(await t.call('credential_refs',{})).toEqual({references:[]})
  87. f.work.agentId=randomUUID();await expect(t.call('credential_refs',{})).rejects.toMatchObject({code:'vault_task_revoked'});t.close()
  88. })
  89. it('cancellation releases locked/offline waiting and prevents fallback or pending requests lingering',async()=>{
  90. const f=await fixture(),controller=new AbortController(),waiting=vi.fn();await f.request('/lock','POST',{})
  91. vi.spyOn(f.hub,'credentialBinding').mockImplementation(()=>{throw new HttpError(503,'offline','vault_runner_offline')})
  92. const t=f.turn(controller.signal,waiting),pending=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),assertion=expect(pending).rejects.toThrow()
  93. expect(waiting).toHaveBeenCalledWith(true);expect((await f.status()).requests).toHaveLength(1)
  94. controller.abort();await assertion;expect(waiting).toHaveBeenLastCalledWith(false);expect((await f.status()).requests).toHaveLength(0);t.close()
  95. })
  96. it('refuses old Runner capability and insecure remote UI immediately',async()=>{
  97. const f=await fixture();vi.spyOn(f.hub,'credentialBinding').mockImplementation(()=>{throw new HttpError(409,'upgrade','vault_runner_upgrade_required')})
  98. const t=f.turn();await expect(t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'})).rejects.toMatchObject({code:'vault_runner_upgrade_required'});t.close()
  99. const ctx=context(f.cookies,'/api/app/vault/unlock','POST',{password});Object.assign(ctx.req.socket,{remoteAddress:'192.0.2.10'})
  100. await expect(f.coordinator.router().routes()(ctx,async()=>{})).rejects.toMatchObject({code:'vault_https_required'})
  101. })
  102. it('default configuration remains offline and rejects same-UID control without reading a credential file',async()=>{
  103. await expect(new CredentialVaultClient().call('owner','a'.repeat(64),'status')).rejects.toMatchObject({code:'vault_offline'})
  104. await expect(new CredentialVaultClient({socket:'/not-a-real-socket',tokenFile:'/never-read',hermesUid:process.getuid!()}).call('owner','a'.repeat(64),'status')).rejects.toMatchObject({code:'vault_offline'})
  105. })
  106. it('binds the authenticated session token independent of unrelated cookie changes and ordering',async()=>{
  107. const f=await fixture(),status=await f.request('','GET',{},'dummy_csrf=changed; '+f.cookies+'; dummy_extra=1')
  108. expect(status.unlocked).toBe(true)
  109. await f.coordinator.logout(f.user.id,'dummy_extra=2; '+f.cookies)
  110. expect((await f.status()).unlocked).toBe(false)
  111. })
  112. it('parallel requests retain separate approvals and close cancels both waiting calls exactly once',async()=>{
  113. const f=await fixture(),waiting=vi.fn();await f.request('/lock','POST',{})
  114. const t=f.turn(undefined,waiting),one=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),two=t.call('credential_request',{credentialRef:randomUUID(),operation:'website.login'})
  115. const assertions=[expect(one).rejects.toThrow(),expect(two).rejects.toThrow()]
  116. expect((await f.status()).requests).toHaveLength(2);expect(waiting).toHaveBeenCalledTimes(1)
  117. t.close();await Promise.all(assertions);expect(waiting).toHaveBeenLastCalledWith(false);expect(waiting.mock.calls.filter(([v])=>!v)).toHaveLength(1)
  118. expect((await f.status()).requests).toHaveLength(0)
  119. })
  120. it('a Runner change after execution but before its receipt reaches the Bot leaves an unknown result and cannot replay',async()=>{
  121. const execute=vi.fn(async()=>({status:'complete' as const,operation:'website.login' as const})),f=await fixture(false,{mode:'protected-adapters',execute});await f.grant()
  122. const original=f.client.call.bind(f.client),reached=Promise.withResolvers<void>(),release=Promise.withResolvers<void>()
  123. vi.spyOn(f.client,'call').mockImplementation(async(o,s,c,v)=>{const result=await original(o,s,c,v);if(c==='execute'){reached.resolve();await release.promise}return result})
  124. const t=f.turn(),pending=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'})
  125. await reached.promise;f.changeBinding();release.resolve();await assertion
  126. expect(execute).toHaveBeenCalledOnce();expect((await f.status()).leases).toHaveLength(0);t.close()
  127. })
  128. it('losing the private execution connection aborts the lease and returns unknown once instead of waiting/retrying',async()=>{
  129. const reached=Promise.withResolvers<void>();let signal:AbortSignal|undefined
  130. const execute=vi.fn(async(_e,_i,s:AbortSignal)=>{signal=s;reached.resolve();await new Promise<void>((_r,reject)=>s.addEventListener('abort',()=>reject(new Error('DUMMY-private-error')),{once:true}));return {status:'complete' as const,operation:'website.login' as const}})
  131. const f=await fixture(true,{mode:'protected-adapters',execute});await f.grant()
  132. const t=f.turn(),pending=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'})
  133. await reached.promise;f.server!.closeAllConnections();await assertion
  134. await vi.waitFor(()=>expect(signal?.aborted).toBe(true));expect(execute).toHaveBeenCalledOnce()
  135. expect((await f.status()).leases).toHaveLength(0);t.close()
  136. })