// @vitest-environment node import { expect, it, vi } from 'vitest' import Koa from 'koa' import request from 'supertest' import { createServer as createHttpsServer } from 'node:https' import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { once } from 'node:events' import { randomUUID, randomBytes } from 'node:crypto' import { WebSocketServer } from 'ws' import { WorkspaceStore } from '../../src/server/workspaceStore' import { WorkspaceRuntime } from '../../src/server/workspaceRuntime' import { WorkspaceNodes, type GatewayTarget } from '../../src/server/workspaceGateway' import { UploadStore } from '../../src/server/uploads' import { loadServerConfig } from '../../src/server/config' import { saveHostTools } from '../../src/server/hostToolSettings' import { LocalAuthStore } from '../../src/server/localAuth' import { CredentialVaultStore } from '../../src/server/credentialVault/store' import { CredentialVaultBroker } from '../../src/server/credentialVault/broker' import { CredentialVaultClient, serveCredentialVault } from '../../src/server/credentialVault/transport' import { CredentialVaultCoordinator } from '../../src/server/credentialVault/coordinator' import { FixtureWebsiteLoginExecutor } from '../../src/server/credentialVault/fixtureWebsiteExecutor' import type { RunnerHub } from '../../src/server/runnerHub' import type { WorkspaceRun } from '../../src/shared/workspace' it('a Bot turn uses its real tool catalog and task approval to authenticate a dummy HTTPS session, returning only a sanitized receipt',async()=>{ const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-bot-flow-')) const key=join(home,'dummy-key.pem'),cert=join(home,'dummy-cert.pem'),cnf=join(home,'dummy-openssl.cnf') writeFileSync(cnf,'[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=127.0.0.1\n[ext]\nsubjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:TRUE\n') // Ephemeral test TLS only. No real certificate store or identity is changed. execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-days','1','-keyout',key,'-out',cert,'-config',cnf],{stdio:'ignore'}) const secret='DUMMY-ONLY-password-for-local-fixture',username='dummy-site-user',master='DUMMY-master-password-only',sessionCookie=randomBytes(32).toString('hex') let logins=0,proofs=0 const website=createHttpsServer({key:readFileSync(key),cert:readFileSync(cert)},async(req,res)=>{ if(req.url==='/login'&&req.method==='POST'){ let body='';for await(const chunk of req)body+=chunk const form=new URLSearchParams(body) if(form.get('username')!==username||form.get('password')!==secret){res.writeHead(401);res.end();return} logins++;res.setHeader('Set-Cookie','fixture_auth='+sessionCookie+'; HttpOnly; Secure; SameSite=Strict');res.end('dummy-login-accepted');return } if(req.url==='/protected'&&req.headers.cookie==='fixture_auth='+sessionCookie){proofs++;res.end('dummy-authenticated');return} res.writeHead(401);res.end() }) website.listen(0,'127.0.0.1');await once(website,'listening') const origin='https://127.0.0.1:'+(website.address() as any).port,store=new WorkspaceStore(home),uploads=new UploadStore(home),auth=new LocalAuthStore(home) const loginHeaders:Record={},login={state:{},response:{headers:loginHeaders},get:()=>'',set:(k:string,v:any)=>{loginHeaders[k.toLowerCase()]=v}} as any const user=auth.setupAdmin(login,'dummy-admin','DUMMY-local-account-password'),cookies=String(loginHeaders['set-cookie']).split(';')[0]!,owner=user.id saveHostTools(home,{managedBrowser:false,vm:false,serverComputer:false,scriptMachine:false,cloud:false}) const hermes=new WebSocketServer({port:0,host:'127.0.0.1'});await once(hermes,'listening') const bindings=new Map(),prompts:string[]=[],results:any[]=[],catalogs:any[][]=[] let integrationError:unknown,credentialRef='' const target={url:new URL('http://127.0.0.1:'+(hermes.address() as any).port),client:{directAgent:undefined},session:{webSocketCredential:async()=>({name:'ticket',value:'dummy-ticket'}),request:async(path:string,input?:{body?:any})=>{ if(path.endsWith('/bind'))bindings.set(input?.body.session_id,input?.body) const value=path.startsWith('/api/plugins/yaoyao-bot-bridge/')?{ok:true,version:1,ready:true,in_process:true,native_tools:true}:path==='/api/config'?{terminal:{}}:{messages:[]} return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(value))} }}} as unknown as GatewayTarget const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),target),runtime=new WorkspaceRuntime(store,nodes,uploads,owner=>auth.isUserActive(owner),owner=>auth.pushAuthorizationVersion(owner)??0) const socket=join(home,'dummy-vault.sock'),tokenFile=join(home,'dummy-control-token'),token=randomBytes(32).toString('base64url') writeFileSync(tokenFile,token,{mode:0o600}) const vault=await CredentialVaultStore.create(join(home,'vault')),broker=new CredentialVaultBroker(vault,Date.now,new FixtureWebsiteLoginExecutor(origin,readFileSync(cert))),ipc=serveCredentialVault(broker,token) ipc.listen(socket);await once(ipc,'listening');chmodSync(socket,0o600) const client=new CredentialVaultClient({socket,tokenFile,hermesUid:process.getuid!()+1}),binding={runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-binding-epoch'} // This same-process dummy wiring exercises protocol and turn routing, not OS isolation. const coordinator=new CredentialVaultCoordinator(client,store,auth,{credentialBinding:()=>binding} as unknown as RunnerHub);runtime.credentialVault=coordinator const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status??500;ctx.body={code:(e as any).code}}}) app.use(async(ctx,next)=>{const chunks:Buffer[]=[];for await(const c of ctx.req)chunks.push(Buffer.from(c));(ctx.request as any).body=chunks.length?JSON.parse(Buffer.concat(chunks).toString()):{};await next()}) const router=coordinator.router();app.use(router.routes()) const ui=(path:string,body?:unknown)=>body===undefined?request(app.callback()).get('/api/app/vault'+path).set('Cookie',cookies):request(app.callback()).post('/api/app/vault'+path).set('Cookie',cookies).send(body as any) hermes.on('connection',ws=>{ ws.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}})) ws.on('message',raw=>{ const f=JSON.parse(String(raw)),respond=(result:unknown)=>ws.send(JSON.stringify({id:f.id,result})) if(['session.create','session.resume'].includes(f.method))respond({session_id:randomUUID(),stored_session_id:randomUUID(),running:false,info:{profile_name:f.params.profile}}) else if(f.method==='session.cwd.set')respond({cwd:f.params.cwd}) else if(f.method==='session.usage')respond({context_used:10,context_max:1000}) else if(f.method==='prompt.submit'){ prompts.push(f.params.text);respond({status:'streaming'}) void(async()=>{ try{ const bound=bindings.get(f.params.session_id)!,tool=async(path:string,value:any)=>{const response=await fetch(bound.bridge_url+path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+bound.token},body:JSON.stringify(value)});expect(response.status).toBe(200);return response.json() as Promise} const catalog=await tool('/tools/list',{});catalogs.push(catalog.tools) const named=(name:string)=>catalog.tools.find((t:any)=>t.name===name)!.id expect(named('credential_request')).toBeTruthy();expect(named('credential_refs')).toBeTruthy() expect((await tool('/tools/call',{toolId:named('credential_refs'),arguments:{},callId:'before-approval'})).structuredContent.references).toEqual([]) const call={toolId:named('credential_request'),arguments:{credentialRef,operation:'website.login'},callId:'single-dummy-login'} const pending=tool('/tools/call',call) let view:any await vi.waitFor(async()=>{view=(await ui('')).body;expect(view.requests).toHaveLength(1)}) expect(logins).toBe(0) const waiting=store.require(owner,'turn',view.requests[0].workId);expect(waiting.status).toBe('waiting') await ui('/leases',{credentialRef,agentId:view.requests[0].agentId,workId:view.requests[0].workId,operation:'website.login',seconds:60}).expect(200) const result=await pending;expect(result.structuredContent).toEqual({status:'complete',operation:'website.login'});results.push(result) expect(await tool('/tools/call',call)).toEqual(result);expect(logins).toBe(1) // Same call ID cannot log in twice. }catch(e){integrationError=e} ws.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:f.params.session_id,payload:{text:'本地测试登录完成,凭据未展示。',status:'complete'}}})) })() }else respond({ok:true,status:'interrupted'}) }) }) try{ await ui('/initialize',{password:master}).expect(200);await ui('/unlock',{password:master}).expect(200) const entry=await ui('/entries',{name:'dummy-local-site',username,target:{kind:'website',origin},secret}).expect(200);credentialRef=entry.body.id const bot=store.createAgent(owner,{name:'dummy-login-Bot',profile:'default'}),conversation=store.list(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)! const run=runtime.send(owner,conversation.id,{requestId:randomUUID(),content:'请使用我授予当前任务的凭据引用完成本地测试登录。'}) await vi.waitFor(()=>expect(['complete','failed']).toContain(store.require(owner,'run',run.id).status),{timeout:8000}) expect(integrationError).toBeUndefined();expect(store.require(owner,'run',run.id).status).toBe('complete') expect(logins).toBe(1);expect(proofs).toBe(1);expect(results).toHaveLength(1);expect(catalogs).toHaveLength(1) for(const blob of [JSON.stringify(results),JSON.stringify(catalogs),JSON.stringify(prompts),JSON.stringify(store.messages(owner,conversation.id)),readFileSync(join(home,'vault','audit.jsonl'),'utf8')]){ for(const privateValue of [secret,master,username,sessionCookie,token])expect(blob).not.toContain(privateValue) } expect((await ui('')).body.leases).toHaveLength(0) }finally{ runtime.close();coordinator.close();await new Promise(resolve=>setTimeout(resolve,10)) for(const ws of hermes.clients)ws.terminate();await new Promise(resolve=>hermes.close(()=>resolve())) await new Promise(resolve=>ipc.close(()=>resolve()));website.closeAllConnections();await new Promise(resolve=>website.close(()=>resolve())) nodes.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true}) } },15000)