|
@@ -2,13 +2,11 @@
|
|
|
import { expect, it, vi } from 'vitest'
|
|
import { expect, it, vi } from 'vitest'
|
|
|
import Koa from 'koa'
|
|
import Koa from 'koa'
|
|
|
import request from 'supertest'
|
|
import request from 'supertest'
|
|
|
-import { createServer as createHttpsServer } from 'node:https'
|
|
|
|
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'
|
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'
|
|
|
-import { execFileSync } from 'node:child_process'
|
|
|
|
|
import { tmpdir } from 'node:os'
|
|
import { tmpdir } from 'node:os'
|
|
|
import { join } from 'node:path'
|
|
import { join } from 'node:path'
|
|
|
import { once } from 'node:events'
|
|
import { once } from 'node:events'
|
|
|
-import { randomUUID, randomBytes } from 'node:crypto'
|
|
|
|
|
|
|
+import { randomUUID, randomBytes, createHash } from 'node:crypto'
|
|
|
import { WebSocketServer } from 'ws'
|
|
import { WebSocketServer } from 'ws'
|
|
|
import { WorkspaceStore } from '../../src/server/workspaceStore'
|
|
import { WorkspaceStore } from '../../src/server/workspaceStore'
|
|
|
import { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
|
|
import { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
|
|
@@ -21,30 +19,22 @@ import { CredentialVaultStore } from '../../src/server/credentialVault/store'
|
|
|
import { CredentialVaultBroker } from '../../src/server/credentialVault/broker'
|
|
import { CredentialVaultBroker } from '../../src/server/credentialVault/broker'
|
|
|
import { CredentialVaultClient, serveCredentialVault } from '../../src/server/credentialVault/transport'
|
|
import { CredentialVaultClient, serveCredentialVault } from '../../src/server/credentialVault/transport'
|
|
|
import { CredentialVaultCoordinator } from '../../src/server/credentialVault/coordinator'
|
|
import { CredentialVaultCoordinator } from '../../src/server/credentialVault/coordinator'
|
|
|
-import { FixtureWebsiteLoginExecutor } from '../../src/server/credentialVault/fixtureWebsiteExecutor'
|
|
|
|
|
|
|
+import { ProtectedCredentialExecutor } from '../../src/server/credentialVault/protectedExecutor'
|
|
|
|
|
+import { websiteFixture, sshFixture } from '../fixtures/credentialProtocols'
|
|
|
import type { RunnerHub } from '../../src/server/runnerHub'
|
|
import type { RunnerHub } from '../../src/server/runnerHub'
|
|
|
import type { WorkspaceRun } from '../../src/shared/workspace'
|
|
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()=>{
|
|
|
|
|
|
|
+it.each(['website.login','ssh.exec','sftp.read','sftp.write','website.login.cancel','ssh.exec.cancel','sftp.write.cancel'] as const)('a Bot turn uses its real tool catalog, task approval and protected adapter for local dummy %s, returning only a sanitized receipt',async scenario=>{
|
|
|
|
|
+ const cancelled=scenario.endsWith('.cancel'),operation=scenario.replace(/\.cancel$/,'') as 'website.login'|'ssh.exec'|'sftp.read'|'sftp.write'
|
|
|
const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-bot-flow-'))
|
|
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 website=await websiteFixture(home),ssh=await sshFixture()
|
|
|
|
|
+ if(cancelled){website.state.delay=true;ssh.state.delayExec=operation==='ssh.exec';ssh.state.delayWrite=true}
|
|
|
|
|
+ const isWebsite=operation==='website.login',master='DUMMY-master-password-only',secret=isWebsite?website.secret:ssh.secret,username=isWebsite?website.username:ssh.username,sessionCookie=website.cookie
|
|
|
|
|
+ const usage=operation==='website.login'?website.usage:operation==='ssh.exec'?{kind:'ssh.exec',command:'/usr/bin/uptime'}:operation==='sftp.read'?{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:64}:{kind:'sftp.write',remotePath:'/approved/output.txt',contents:'DUMMY-fixed-transfer-body'}
|
|
|
|
|
+ const credentialTarget=isWebsite?{kind:'website',origin:website.origin}:ssh.target
|
|
|
|
|
+ const expected=operation==='website.login'?{status:'complete',operation}:operation==='ssh.exec'?{status:'complete',operation,exitCode:0}:operation==='sftp.read'?{status:'complete',operation,bytes:ssh.files.get('/approved/input.txt')!.length,sha256:createHash('sha256').update(ssh.files.get('/approved/input.txt')!).digest('hex')}:{status:'complete',operation,bytes:Buffer.byteLength('DUMMY-fixed-transfer-body'),sha256:createHash('sha256').update('DUMMY-fixed-transfer-body').digest('hex')}
|
|
|
|
|
+ const effectCount=()=>isWebsite?website.state.logins:operation==='ssh.exec'?ssh.state.commands.length:ssh.state.opens.length
|
|
|
|
|
+ const store=new WorkspaceStore(home),uploads=new UploadStore(home),auth=new LocalAuthStore(home)
|
|
|
const loginHeaders:Record<string,any>={},login={state:{},response:{headers:loginHeaders},get:()=>'',set:(k:string,v:any)=>{loginHeaders[k.toLowerCase()]=v}} as any
|
|
const loginHeaders:Record<string,any>={},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
|
|
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})
|
|
saveHostTools(home,{managedBrowser:false,vm:false,serverComputer:false,scriptMachine:false,cloud:false})
|
|
@@ -59,7 +49,7 @@ it('a Bot turn uses its real tool catalog and task approval to authenticate a du
|
|
|
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 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')
|
|
const socket=join(home,'dummy-vault.sock'),tokenFile=join(home,'dummy-control-token'),token=randomBytes(32).toString('base64url')
|
|
|
writeFileSync(tokenFile,token,{mode:0o600})
|
|
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)
|
|
|
|
|
|
|
+ const vault=await CredentialVaultStore.create(join(home,'vault')),broker=new CredentialVaultBroker(vault,Date.now,new ProtectedCredentialExecutor(join(home,'private-executor'),{assert(){}},website.cert)),ipc=serveCredentialVault(broker,token)
|
|
|
ipc.listen(socket);await once(ipc,'listening');chmodSync(socket,0o600)
|
|
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'}
|
|
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.
|
|
// This same-process dummy wiring exercises protocol and turn routing, not OS isolation.
|
|
@@ -84,15 +74,16 @@ it('a Bot turn uses its real tool catalog and task approval to authenticate a du
|
|
|
const named=(name:string)=>catalog.tools.find((t:any)=>t.name===name)!.id
|
|
const named=(name:string)=>catalog.tools.find((t:any)=>t.name===name)!.id
|
|
|
expect(named('credential_request')).toBeTruthy();expect(named('credential_refs')).toBeTruthy()
|
|
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([])
|
|
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 call={toolId:named('credential_request'),arguments:{credentialRef,operation},callId:'single-dummy-login'}
|
|
|
const pending=tool('/tools/call',call)
|
|
const pending=tool('/tools/call',call)
|
|
|
let view:any
|
|
let view:any
|
|
|
await vi.waitFor(async()=>{view=(await ui('')).body;expect(view.requests).toHaveLength(1)})
|
|
await vi.waitFor(async()=>{view=(await ui('')).body;expect(view.requests).toHaveLength(1)})
|
|
|
- expect(logins).toBe(0)
|
|
|
|
|
|
|
+ expect(effectCount()).toBe(0)
|
|
|
const waiting=store.require<any>(owner,'turn',view.requests[0].workId);expect(waiting.status).toBe('waiting')
|
|
const waiting=store.require<any>(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.
|
|
|
|
|
|
|
+ await ui('/leases',{credentialRef,agentId:view.requests[0].agentId,workId:view.requests[0].workId,operation,seconds:60}).expect(200)
|
|
|
|
|
+ if(cancelled){await (isWebsite?website.submitted:operation==='ssh.exec'?ssh.executed:ssh.written);expect(effectCount()).toBe(1);await ui('/lock',{}).expect(200)}
|
|
|
|
|
+ const result=await pending;if(cancelled){expect(result.isError).toBe(true);expect(JSON.stringify(result)).toContain('vault_operation_uncertain')}else expect(result.structuredContent).toEqual(expected);results.push(result)
|
|
|
|
|
+ expect(await tool('/tools/call',call)).toEqual(result);expect(effectCount()).toBe(1) // Same call ID cannot log in twice.
|
|
|
}catch(e){integrationError=e}
|
|
}catch(e){integrationError=e}
|
|
|
ws.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:f.params.session_id,payload:{text:'本地测试登录完成,凭据未展示。',status:'complete'}}}))
|
|
ws.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:f.params.session_id,payload:{text:'本地测试登录完成,凭据未展示。',status:'complete'}}}))
|
|
|
})()
|
|
})()
|
|
@@ -101,20 +92,20 @@ it('a Bot turn uses its real tool catalog and task approval to authenticate a du
|
|
|
})
|
|
})
|
|
|
try{
|
|
try{
|
|
|
await ui('/initialize',{password:master}).expect(200);await ui('/unlock',{password:master}).expect(200)
|
|
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 entry=await ui('/entries',{name:'dummy-local-site',username,target:credentialTarget,usage,secret}).expect(200);credentialRef=entry.body.id
|
|
|
const bot=store.createAgent(owner,{name:'dummy-login-Bot',profile:'default'}),conversation=store.list<any>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)!
|
|
const bot=store.createAgent(owner,{name:'dummy-login-Bot',profile:'default'}),conversation=store.list<any>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)!
|
|
|
const run=runtime.send(owner,conversation.id,{requestId:randomUUID(),content:'请使用我授予当前任务的凭据引用完成本地测试登录。'})
|
|
const run=runtime.send(owner,conversation.id,{requestId:randomUUID(),content:'请使用我授予当前任务的凭据引用完成本地测试登录。'})
|
|
|
await vi.waitFor(()=>expect(['complete','failed']).toContain(store.require<WorkspaceRun>(owner,'run',run.id).status),{timeout:8000})
|
|
await vi.waitFor(()=>expect(['complete','failed']).toContain(store.require<WorkspaceRun>(owner,'run',run.id).status),{timeout:8000})
|
|
|
expect(integrationError).toBeUndefined();expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('complete')
|
|
expect(integrationError).toBeUndefined();expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('complete')
|
|
|
- expect(logins).toBe(1);expect(proofs).toBe(1);expect(results).toHaveLength(1);expect(catalogs).toHaveLength(1)
|
|
|
|
|
|
|
+ expect(effectCount()).toBe(1);if(isWebsite){expect(website.state.proofs).toBe(cancelled?0:1);expect(website.state.logouts).toBe(cancelled?0: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 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)
|
|
|
|
|
|
|
+ for(const privateValue of [secret,master,username,sessionCookie,token,'DUMMY-private-command-output','DUMMY-private-stderr','DUMMY-private-file-contents','DUMMY-fixed-transfer-body'])expect(blob).not.toContain(privateValue)
|
|
|
}
|
|
}
|
|
|
expect((await ui('')).body.leases).toHaveLength(0)
|
|
expect((await ui('')).body.leases).toHaveLength(0)
|
|
|
}finally{
|
|
}finally{
|
|
|
runtime.close();coordinator.close();await new Promise(resolve=>setTimeout(resolve,10))
|
|
runtime.close();coordinator.close();await new Promise(resolve=>setTimeout(resolve,10))
|
|
|
for(const ws of hermes.clients)ws.terminate();await new Promise<void>(resolve=>hermes.close(()=>resolve()))
|
|
for(const ws of hermes.clients)ws.terminate();await new Promise<void>(resolve=>hermes.close(()=>resolve()))
|
|
|
- await new Promise<void>(resolve=>ipc.close(()=>resolve()));website.closeAllConnections();await new Promise<void>(resolve=>website.close(()=>resolve()))
|
|
|
|
|
|
|
+ await new Promise<void>(resolve=>ipc.close(()=>resolve()));await website.close();await ssh.close()
|
|
|
nodes.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true})
|
|
nodes.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true})
|
|
|
}
|
|
}
|
|
|
},15000)
|
|
},15000)
|