Ver Fonte

支持指定 Bot 持续使用已授权凭据并独立锁定管理界面

Samien há 1 mês atrás
pai
commit
97d2a7f9e1

+ 9 - 1
docs/credential-vault.md

@@ -1,6 +1,14 @@
 # Bot 内置密码库(首期)
 
-默认安装提供夭夭服务器本机密码保险箱:登录后在「Bot 模式 → 设置 → 密码管理」创建主密码、手动解锁,即可添加网站密码、SSH 登录密码和私钥,无需先安装独立密码库服务。每个账号独立存储,密文位于夭夭数据目录的 `credential-vault/`;单实例锁防止其他 broker 同时写入。默认锁定,重启不自动解锁。用户配置固定使用计划并授权当前任务后,Bot 可以通过凭据引用执行受控操作;模型没有读取秘密的接口。
+默认安装提供夭夭服务器本机密码保险箱:登录后在「Bot 模式 → 设置 → 密码管理」创建主密码、手动解锁,即可添加网站密码、SSH 登录密码和私钥,无需先安装独立密码库服务。每个账号独立存储,密文位于夭夭数据目录的 `credential-vault/`;单实例锁防止其他 broker 同时写入。主密码用于管理凭据和授权,管理界面默认锁定,重启不自动解锁。用户配置固定使用计划并授权指定 Bot 后,该 Bot 可持续执行受控操作,直到撤销或修改凭据;模型没有读取秘密的接口。
+
+## 本机模式:一次授权,持续使用
+
+解锁管理界面后,选择凭据和 Bot,点击「授权此 Bot 使用」即可。管理页关闭、切后台、5 分钟管理解锁到期、退出管理登录或服务重启,均不会撤销这一授权。修改或删除凭据会撤销该凭据的原 Bot 授权,需重新核对并授权;「撤销 Bot 授权」立即阻止后续使用并中止在途执行。账号停用、Bot 不可用、任务取消或执行节点变化仍会阻止执行;在途取消可能已有外部效果,不能自动重试。
+
+持续授权只覆盖选中的账号、Bot、凭据版本、目标和固定使用计划。本机服务将被批准的执行材料单独加密存放在 `credential-vault/bot-access/`,执行密钥保存在同一可信服务器的 0600 私有文件,目录权限 0700;沿用本机模式对服务身份的信任,不宣称防御同 UID shell。主密码及秘密不进入工具目录、引用响应、执行回执或审计。原主密码加密库和其导出备份格式保持兼容;管理页导出的备份不含持续授权,恢复后需重新授权。整机服务数据备份包含执行材料,须完整保留私有权限。首次 SSH 授权仍自动记录服务器指纹,之后服务器身份变化会停止连接。
+
+独立密码库部署沿用原逐任务短授权流程;此持续授权实现只用于默认本机密码库。
 
 默认本机模式直接使用服务器上的受控执行器,不需要额外的 socket、服务身份或部署批准文件。配置 `HERMES_YAOYAO_VAULT_SOCKET` / `HERMES_YAOYAO_VAULT_TOKEN_FILE` 后,仍使用原有独立密码库服务;显式配置的服务不可用时保持离线,不切换到新的本机保险箱。独立服务可以部署在 Hermes 执行节点,并在隔离部署验收后启用受保护执行器。密码库不是 Hermes 的环境变量或工具配置。
 

+ 20 - 8
src/client/components/app/CredentialVaultPanel.vue

@@ -5,6 +5,7 @@ import type { JsonValue } from '@shared/types'
 import type { CredentialSummary, CredentialVaultStatus, CredentialOperation } from '@shared/credentialVault'
 
 interface View extends CredentialVaultStatus {
+  bots?:{id:string;name:string}[]
   tasks: { workId: string; agentId: string; name: string }[]
   requests: { workId: string; agentId: string; credentialRef: string; operation: CredentialOperation }[]
 }
@@ -13,6 +14,7 @@ const master=ref(''), confirmation=ref(''), replacement=ref(''), secret=ref(''),
 const name=ref(''), username=ref(''), kind=ref<'website'|'ssh'>('website'), origin=ref(''), host=ref(''), port=ref(22), hostKey=ref('')
 const editing=ref<CredentialSummary>(), showEditor=ref(false), taskId=ref(''), credentialId=ref(''), operation=ref<CredentialOperation>('website.login')
 const sshAuth=ref<'privateKey'|'password'>('password')
+const botId=ref('')
 const authenticationChanged=computed(()=>editing.value?.target.kind==='ssh'&&kind.value==='ssh'&&sshAuth.value!==(editing.value.target.auth??'privateKey'))
 const secretRequired=computed(()=>!editing.value||authenticationChanged.value)
 const selectedCredential=computed(()=>state.value?.entries.find(entry=>entry.id===credentialId.value))
@@ -39,8 +41,8 @@ async function action(work:()=>Promise<void>,message:string){
   finally{clearSecrets();busy.value=false}
 }
 async function create(){if(master.value!==confirmation.value){error.value='两次主密码不一致';return}await action(async()=>{await request('/initialize','POST',{password:master.value})},'密码库已创建,仍保持锁定')}
-async function unlock(){await action(async()=>{await request('/unlock','POST',{password:master.value,seconds:300})},'已手动解锁 5 分钟;Bot 使用凭据仍需要逐任务授权。')}
-async function lock(){clearSecrets();showEditor.value=false;await action(async()=>{await request('/lock','POST',{})},'密码库已锁定,任务授权已撤销')}
+async function unlock(){await action(async()=>{await request('/unlock','POST',{password:master.value,seconds:300})},state.value?.storageMode==='local'?'已解锁管理 5 分钟;已授权 Bot 的使用不受此期限影响。':'已手动解锁 5 分钟;Bot 使用凭据仍需要逐任务授权。')}
+async function lock(){clearSecrets();showEditor.value=false;await action(async()=>{await request('/lock','POST',{})},state.value?.storageMode==='local'?'管理界面已锁定;已授权 Bot 仍可使用。':'密码库已锁定,任务授权已撤销')}
 function edit(entry?:CredentialSummary){
   clearSecrets();editing.value=entry;name.value=entry?.name??'';username.value=entry?.username??'';kind.value=entry?.target.kind??'website'
   origin.value=entry?.target.kind==='website'?entry.target.origin:'';host.value=entry?.target.kind==='ssh'?entry.target.host:''
@@ -59,13 +61,14 @@ async function save(){
   const target:Record<string,JsonValue>=kind.value==='website'?{kind:'website',origin:origin.value}:{kind:'ssh',host:host.value,port:port.value,...(hostKey.value.trim()?{hostKey:hostKey.value.trim()}:{}),...(sshAuth.value==='password'?{auth:'password'}:{})}
   const usage:Record<string,JsonValue>|undefined=!configureUse.value?undefined:kind.value==='website'?{kind:'website.form',loginPath:loginPath.value,submitPath:submitPath.value,successPath:successPath.value,...(logoutPath.value?{logoutPath:logoutPath.value}:{}),formId:formId.value,usernameName:usernameName.value,passwordName:passwordName.value,successSelector:successSelector.value}:useKind.value==='ssh.exec'?{kind:'ssh.exec',command:command.value}:useKind.value==='sftp.read'?{kind:'sftp.read',remotePath:remotePath.value,maxBytes:maxBytes.value}:{kind:'sftp.write',remotePath:remotePath.value,...(contents.value||!editing.value||editing.value.usage?.kind!=='sftp.write'?{contents:contents.value}:{})}
   const body={name:name.value,username:username.value,target,...(usage?{usage}:{}),...(secret.value?{secret:secret.value}:{}),...(kind.value==='ssh'&&sshAuth.value==='privateKey'?(clearPassphrase.value?{passphrase:''}:passphrase.value?{passphrase:passphrase.value}:{}):{}),...(editing.value?{revision:editing.value.revision}:{})}
-  await action(async()=>{await request(editing.value?'/entries/'+editing.value.id:'/entries',editing.value?'PUT':'POST',body);showEditor.value=false},'凭据已加密保存,密码与私钥不回显')
+  await action(async()=>{await request(editing.value?'/entries/'+editing.value.id:'/entries',editing.value?'PUT':'POST',body);showEditor.value=false},editing.value&&state.value?.storageMode==='local'?'凭据已保存;原 Bot 授权已撤销,请重新授权。':'凭据已加密保存,密码与私钥不回显')
 }
 async function remove(entry:CredentialSummary){await action(async()=>{await request('/entries/'+entry.id,'DELETE')},'凭据已删除,相关任务授权已撤销')}
 async function grant(){
   const task=state.value?.tasks.find(t=>t.workId===taskId.value);if(!task)return
   await action(async()=>{await request('/leases','POST',{credentialRef:credentialId.value,agentId:task.agentId,workId:task.workId,operation:operation.value,seconds:60})},'已授予此任务 60 秒;只执行已配置的操作,密码与执行正文不回显。')
 }
+async function grantBot(){await action(async()=>{await request('/bot-grant','POST',{credentialRef:credentialId.value,agentId:botId.value})},'此 Bot 已获持续授权;返回聊天即可使用,直到你撤销或修改凭据。')}
 function selectCredential(){const plan=selectedCredential.value?.usage;if(plan)operation.value=plan.kind==='website.form'?'website.login':plan.kind}
 function selectRequest(value:View['requests'][number]){taskId.value=value.workId;credentialId.value=value.credentialRef;operation.value=value.operation}
 function describeUse(entry?:CredentialSummary){const p=entry?.usage;if(!p)return '尚未配置,将转人工接管';if(p.kind==='website.form')return '网站表单 '+p.loginPath+' → '+p.submitPath+' → '+p.successPath+(p.logoutPath?' → 退出 '+p.logoutPath:'(远端会话按站点规则到期)');if(p.kind==='ssh.exec')return 'SSH '+p.command+',固定无参数';return p.kind==='sftp.read'?'SFTP 读取校验 '+p.remotePath+',最多 '+p.maxBytes+' 字节':'SFTP 创建新文件 '+p.remotePath+','+p.bytes+' 字节'}
@@ -102,8 +105,8 @@ onBeforeUnmount(()=>{
 
 <template>
   <section class="credential-vault" :aria-busy="busy">
-    <p>秘密只在专用界面录入,由主密码加密保存;不发送给 Bot。密码保险箱默认锁定,重启或到期需重新手动解锁。</p>
-    <p v-if="state?.storageMode==='local'" role="status">本机保险箱:凭据加密保存在夭夭服务器,当前账号解锁后可以管理。Bot 可申请使用已配置的固定操作,需要你授权具体任务;操作在夭夭服务器执行,密码不交给模型。</p>
+    <p>秘密只在专用界面录入并加密保存,不发送给 Bot。主密码用于添加、修改凭据和管理授权。</p>
+    <p v-if="state?.storageMode==='local'" role="status">选择凭据和 Bot,授权一次即可使用,直到你撤销。关闭管理页、到期或重启服务不会撤销 Bot 授权;操作在夭夭服务器执行,密码不交给模型。</p>
     <p v-else role="status">{{state?.execution==='protected-adapters'?'受控执行器已取得人工部署批准:仅支持配置的标准表单、固定 SSH 命令和指定 SFTP 文件;仍须逐任务授权。':'受控网站与 SSH/SFTP 执行尚未启用。隔离部署批准前,由你人工接管。'}}</p>
     <p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p>
     <button type="button" :disabled="busy" @click="action(load,'状态已刷新')">刷新状态</button>
@@ -122,7 +125,7 @@ onBeforeUnmount(()=>{
         <p>先输入主密码解锁,即可添加和编辑凭据。</p><button :disabled="busy">手动解锁 5 分钟</button>
       </form>
       <template v-else>
-        <div class="actions"><button type="button" :disabled="busy" @click="lock">立即锁定并撤销授权</button><button type="button" :disabled="busy" @click="edit()">添加凭据</button><button type="button" :disabled="busy" @click="backup">导出加密备份</button></div>
+        <div class="actions"><button type="button" :disabled="busy" @click="lock">{{state.storageMode==='local'?'锁定管理界面':'立即锁定并撤销授权'}}</button><button type="button" :disabled="busy" @click="edit()">添加凭据</button><button type="button" :disabled="busy" @click="backup">导出加密备份</button></div>
         <ul><li v-for="entry in state.entries" :key="entry.id"><strong>{{entry.name}}</strong><p>{{entry.username}} · {{entry.target.kind==='website'?entry.target.origin:entry.target.host+':'+entry.target.port}}<span v-if="entry.target.kind==='ssh'"> · {{entry.target.auth==='password'?'密码登录':'私钥登录'}}</span></p><code>{{entry.id}}</code><div class="actions"><button type="button" :disabled="busy" @click="edit(entry)">编辑</button><button type="button" :disabled="busy" @click="remove(entry)">删除并撤权</button></div></li></ul>
         <form v-if="showEditor" @submit.prevent="save">
           <label>名称<input v-model="name" maxlength="120" required :disabled="busy" /></label>
@@ -156,7 +159,16 @@ onBeforeUnmount(()=>{
           </template>
           <div class="actions"><button :disabled="busy">加密保存</button><button type="button" :disabled="busy" @click="showEditor=false;clearSecrets()">取消</button></div>
         </form>
-        <form @submit.prevent="grant">
+        <form v-if="state.storageMode==='local'" @submit.prevent="grantBot">
+          <h4>授权 Bot 使用</h4><p>授权一次,之后直接使用;管理界面锁定不影响授权。修改凭据会撤销原授权。</p>
+          <label>凭据<select v-model="credentialId" required :disabled="busy" @change="selectCredential"><option disabled value="">请选择</option><option v-for="e in state.entries" :key="e.id" :value="e.id">{{e.name}}</option></select></label>
+          <label>Bot<select v-model="botId" required :disabled="busy"><option disabled value="">请选择</option><option v-for="bot in state.bots??[]" :key="bot.id" :value="bot.id">{{bot.name}}</option></select></label>
+          <p>允许操作:{{describeUse(selectedCredential)}}</p>
+          <p v-if="firstSshUse">首次授权会自动记住服务器指纹。</p>
+          <button :disabled="busy||!botId||!selectedCredential?.usage">授权此 Bot 使用</button>
+          <ul><li v-for="g in state.botGrants??[]" :key="g.credentialRef+g.agentId">{{g.name}} → {{state.bots?.find(b=>b.id===g.agentId)?.name??'Bot'}}<button type="button" :disabled="busy" @click="action(async()=>{await request('/bot-revoke','POST',{credentialRef:g.credentialRef,agentId:g.agentId})},'Bot 授权已撤销')">撤销 Bot 授权</button></li></ul>
+        </form>
+        <form v-else @submit.prevent="grant">
           <h4>本次任务授权</h4><p>先为凭据配置允许 Bot 执行的固定操作,再授权活动任务。每次只绑定一个 Bot、当前任务、执行节点和指定操作;最长 60 秒,不建立长期访问权限。</p>
           <ul><li v-for="pending in state.requests" :key="pending.workId+pending.credentialRef">
             {{state.tasks.find(t=>t.workId===pending.workId)?.name??'任务'}} · {{pending.operation}} · {{pending.credentialRef}}
@@ -172,7 +184,7 @@ onBeforeUnmount(()=>{
         <ul><li v-for="lease in state.leases" :key="lease.id">{{lease.workId.slice(0,8)}} · {{lease.operation}} · {{new Date(lease.expiresAt).toLocaleTimeString()}}<button type="button" :disabled="busy" @click="action(async()=>{await request('/leases/'+lease.id,'DELETE')},'任务授权已撤销')">撤销</button></li></ul>
         <form @submit.prevent="rotate"><label>新的主密码<input v-model="replacement" type="password" autocomplete="new-password" minlength="12" maxlength="1024" required :disabled="busy" /></label><button :disabled="busy">轮换主密码与加密密钥并锁定</button></form>
       </template>
-      <p v-if="state.requests.length">有 {{state.requests.length}} 个任务等待人工解锁或授权;不会自动登录。</p>
+      <p v-if="state.requests.length">有 {{state.requests.length}} 个任务等待{{state.storageMode==='local'?'你授权 Bot':'人工解锁或授权'}}。</p>
     </template>
   </section>
 </template>

+ 74 - 0
src/server/credentialVault/botAccess.ts

@@ -0,0 +1,74 @@
+import sodium from 'libsodium-wrappers-sumo'
+import { createHash, randomUUID } from 'node:crypto'
+import { existsSync, mkdirSync, lstatSync, readdirSync, renameSync, rmSync, writeFileSync, openSync, closeSync, fsyncSync } from 'node:fs'
+import { join } from 'node:path'
+import { z } from 'zod'
+import { HttpError } from '../errors.js'
+import { parse, storedEntry, type VaultEntry } from './schema.js'
+import { readPrivateUtf8 } from './privateFiles.js'
+import type { CredentialBotReference, CredentialBotGrantSummary } from '../../shared/credentialVault.js'
+
+const record = z.object({ owner:z.string().min(1).max(256), agentId:z.string().uuid(), entry:storedEntry.refine(e=>!!e.usage) }).strict()
+const envelope = z.object({ nonce:z.string(), ciphertext:z.string() }).strict()
+const encode = (v:Uint8Array) => sodium.to_base64(v,sodium.base64_variants.ORIGINAL)
+const decode = (v:string) => sodium.from_base64(v,sodium.base64_variants.ORIGINAL)
+
+/** Local server authority, deliberately independent of the master-password
+ * management session. Same-UID protection is the existing local-server trust
+ * boundary; private execution material stays encrypted and never enters tools. */
+export class BotCredentialAccess {
+  private key:Uint8Array
+  private active = new Map<AbortController,{owner:string;credentialRef:string;agentId:string}>()
+  constructor(private root:string) {
+    mkdirSync(root,{recursive:true,mode:0o700})
+    const s=lstatSync(root)
+    if(!s.isDirectory()||s.isSymbolicLink()||(s.mode&0o077)||s.uid!==process.getuid?.())throw new Error('private Bot access directory required')
+    const keyPath=join(root,'execution.key')
+    if(!existsSync(keyPath)){this.write(keyPath,encode(sodium.randombytes_buf(32)));this.sync()}
+    this.key=decode(readPrivateUtf8(keyPath,128))
+    if(this.key.length!==32)throw new Error('invalid Bot access key')
+  }
+  private write(path:string,value:string){const fd=openSync(path,'wx',0o600);try{writeFileSync(fd,value);fsyncSync(fd)}finally{closeSync(fd)}}
+  private sync(){const fd=openSync(this.root,'r');try{fsyncSync(fd)}finally{closeSync(fd)}}
+  private path(owner:string,agentId:string,id:string){return join(this.root,createHash('sha256').update(JSON.stringify([owner,agentId,id])).digest('hex')+'.access')}
+  private read(path:string){
+    const e=parse(envelope,JSON.parse(readPrivateUtf8(path,256*1024)))
+    const bytes=sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null,decode(e.ciphertext),'yaoyao-bot-access-v1',decode(e.nonce),this.key)
+    try{return parse(record,JSON.parse(sodium.to_string(bytes)))}finally{sodium.memzero(bytes)}
+  }
+  private records(owner:string){return readdirSync(this.root).filter(n=>n.endsWith('.access')).map(n=>({path:join(this.root,n),value:this.read(join(this.root,n))})).filter(r=>r.value.owner===owner)}
+  summaries(owner:string,agentId?:string):Array<CredentialBotGrantSummary&CredentialBotReference>{return this.records(owner).filter(r=>!agentId||r.value.agentId===agentId).map(({value:{entry,agentId}})=>({
+    credentialRef:entry.id,agentId,name:entry.name,operation:entry.usage!.kind==='website.form'?'website.login' as const:entry.usage!.kind,
+    allowedTarget:entry.target,allowedUse:entry.usage!.kind==='sftp.write'?{kind:entry.usage!.kind,remotePath:entry.usage!.remotePath,bytes:Buffer.byteLength(entry.usage!.contents)}:entry.usage!,authorized:true,
+  }))}
+  grant(owner:string,agentId:string,entry:Readonly<VaultEntry>){
+    if(!entry.usage)throw new HttpError(403,'请先配置允许执行的操作','vault_usage_forbidden')
+    const value=parse(record,{owner,agentId,entry})
+    if(!existsSync(this.path(owner,agentId,entry.id))&&this.records(owner).length>=1024)throw new HttpError(413,'Bot 授权数量已达到上限','vault_bot_grant_limit')
+    const bytes=sodium.from_string(JSON.stringify(value)),nonce=sodium.randombytes_buf(24)
+    const path=this.path(owner,agentId,entry.id),temp=path+'.'+randomUUID()
+    try{
+      const ciphertext=sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(bytes,'yaoyao-bot-access-v1',null,nonce,this.key)
+      this.write(temp,JSON.stringify({nonce:encode(nonce),ciphertext:encode(ciphertext)}))
+      renameSync(temp,path);this.sync();this.abort(owner,entry.id,agentId)
+    }catch(error){rmSync(path,{force:true});this.abort(owner,entry.id,agentId);throw error}
+    finally{sodium.memzero(bytes);rmSync(temp,{force:true})}
+  }
+  private abort(owner:string,id:string,agentId?:string){for(const [c,b] of this.active)if(b.owner===owner&&b.credentialRef===id&&(!agentId||b.agentId===agentId))c.abort()}
+  revoke(owner:string,id:string,agentId?:string){
+    this.abort(owner,id,agentId)
+    for(const r of this.records(owner))if(r.value.entry.id===id&&(!agentId||r.value.agentId===agentId))rmSync(r.path)
+    this.sync()
+  }
+  async execute<T>(owner:string,agentId:string,id:string,signal:AbortSignal,use:(entry:Readonly<VaultEntry>,signal:AbortSignal)=>Promise<T>){
+    const path=this.path(owner,agentId,id)
+    if(!existsSync(path))throw new HttpError(403,'此 Bot 尚未获准使用该凭据','vault_bot_forbidden')
+    const value=this.read(path)
+    if(value.owner!==owner||value.agentId!==agentId||value.entry.id!==id)throw new HttpError(403,'凭据授权范围不匹配','vault_scope_forbidden')
+    const controller=new AbortController(),combined=AbortSignal.any([signal,controller.signal])
+    this.active.set(controller,{owner,credentialRef:id,agentId})
+    try{combined.throwIfAborted();const result=await use(value.entry,combined);combined.throwIfAborted();return result}
+    finally{this.active.delete(controller)}
+  }
+  close(){for(const c of this.active.keys())c.abort();this.active.clear();sodium.memzero(this.key)}
+}

+ 1 - 0
src/server/credentialVault/broker.ts

@@ -45,6 +45,7 @@ export class CredentialVaultBroker {
       throw new HttpError(503, '密码库审计不可用,已锁定并撤销授权', 'vault_audit_unavailable')
     } finally { if (fd !== undefined) closeSync(fd) }
   }
+  recordBotAction(owner:string,action:'bot-grant'|'bot-revoke'|'bot-execute-started'|'bot-execute-complete'|'bot-execute-ended',ref:string,outcome='ok'){this.audit(owner,action,ref,outcome)}
   hello(epoch: string) {
     parse(z.string().uuid(), epoch)
     if (epoch !== this.controlEpoch) { this.store.lockAll(); this.revokeAll(); this.sessions.clear(); this.controlEpoch = epoch }

+ 40 - 5
src/server/credentialVault/coordinator.ts

@@ -9,16 +9,17 @@ import type { LocalAuthStore } from '../localAuth.js'
 import type { RunnerHub } from '../runnerHub.js'
 import type { WorkspaceAgent, WorkspaceRun } from '../../shared/workspace.js'
 import type { Work } from '../workspaceScheduler.js'
-import type { CredentialLeaseSummary, CredentialVaultStatus, CredentialTarget,CredentialUseSummary,CredentialSummary } from '../../shared/credentialVault.js'
+import type { CredentialLeaseSummary, CredentialVaultStatus, CredentialTarget,CredentialUseSummary,CredentialSummary,CredentialBotReference } from '../../shared/credentialVault.js'
 import { CredentialVaultClient } from './transport.js'
 import { parse, leaseInput } from './schema.js'
+import { LocalCredentialVaultClient } from './localClient.js'
 
 interface Session { owner: string; key: string; cookie: string; version: number | undefined }
 interface Grant { summary: CredentialLeaseSummary; target: CredentialTarget; usage?:CredentialUseSummary; session: Session; binding: ReturnType<RunnerHub['credentialBinding']> }
 const toolSchema = { type: 'object', properties: { credentialRef: { type: 'string' }, operation: { enum: ['website.login', 'ssh.exec', 'sftp.read', 'sftp.write'] } }, required: ['credentialRef', 'operation'], additionalProperties: false }
 export const CREDENTIAL_TOOLS = [
-  { id: 'credential_refs', name: 'credential_refs', description: '密码库解锁后,列出当前账号已配置允许 Bot 使用的凭据名称、引用、目标和固定操作。authorized 表示当前任务是否已获短期授权;发现引用不代表获准执行。不包含用户名、密码或私钥。', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
-  { id: 'credential_request', name: 'credential_request', description: '请求并使用当前任务的短凭据授权,只执行用户配置的标准网站表单、固定 SSH 命令或指定 SFTP 操作。锁定/离线时等待用户解锁与授权;本机密码库在夭夭服务器执行,复杂网站转人工接管。不得索取或读取密码,不得降级 fill/shell;未知结果不能自动重试。', inputSchema: toolSchema },
+  { id: 'credential_refs', name: 'credential_refs', description: '列出当前账号允许此 Bot 使用的凭据名称、引用、目标和固定操作。authorized=true 表示已获准使用;本机持续授权不受管理页锁定影响,独立部署沿用任务短授权。其他凭据需要用户授权。不包含用户名、密码或私钥。', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
+  { id: 'credential_request', name: 'credential_request', description: '使用已授权凭据,执行用户配置的标准网站表单、固定 SSH 命令或指定 SFTP 操作。已授权本机凭据直接执行,无需重复解锁或任务授权;未授权时等待用户在管理界面授权此 Bot。复杂网站转人工接管。不得索取或读取密码,不得降级 fill/shell;未知结果不能自动重试。', inputSchema: toolSchema },
 ]
 /** No read-secret tool or response. Local mode trusts the server; independent
  * deployment must separate its process/IPC identity from Hermes' shell identity. */
@@ -93,11 +94,23 @@ export class CredentialVaultCoordinator {
   async view(s: Session) {
     let status: CredentialVaultStatus
     try { status = await this.call(s, 'status') } catch (e) { if (!(e instanceof HttpError) || e.code !== 'vault_offline') throw e; status = this.offline() }
-    return { ...status, tasks: this.tasks(s.owner), requests: [...this.pending.values()].filter(p => p.owner === s.owner).map(({ owner: _owner, ...p }) => p) }
+    return { ...status, bots:this.store.list<WorkspaceAgent>(s.owner,'agent').filter(a=>!a.archived&&this.auth.canUseSource(s.owner,a.nodeId,a.profile)).map(a=>({id:a.id,name:a.name})), tasks: this.tasks(s.owner), requests: [...this.pending.values()].filter(p => p.owner === s.owner).map(({ owner: _owner, ...p }) => p) }
   }
   router() {
     const r = new Router(), root = '/api/app/vault'
     r.get(root, async ctx => { ctx.body = await this.view(this.scope(ctx)) })
+    for(const command of ['bot-grant','bot-revoke'] as const)r.post(root+'/'+command,async ctx=>{
+      const s=this.scope(ctx),body=parse(z.object({credentialRef:z.string().uuid(),agentId:z.string().uuid()}).strict(),(ctx.request as any).body)
+      if(!(this.client instanceof LocalCredentialVaultClient))throw new HttpError(409,'当前部署仍使用任务授权','vault_bot_grant_unavailable')
+      if(command==='bot-grant'){
+        const agent=this.store.require<WorkspaceAgent>(s.owner,'agent',body.agentId)
+        if(agent.archived||!this.auth.canUseSource(s.owner,agent.nodeId,agent.profile))throw new HttpError(403,'Bot 不可用','vault_bot_forbidden')
+      }
+      try{ctx.body=await this.call(s,command,body)}catch(error){
+        if(command==='bot-grant'&&error instanceof HttpError&&error.code==='vault_session_revoked')this.client.revokeBot(s.owner,body.credentialRef,body.agentId)
+        throw error
+      }
+    })
     for (const command of ['initialize', 'unlock', 'lock', 'rotate', 'backup', 'restore'] as const)
       r.post(root + '/' + command, async ctx => {
         const s = this.scope(ctx)
@@ -158,7 +171,11 @@ export class CredentialVaultCoordinator {
     }
     signal.addEventListener('abort', abort, { once: true })
     const refs = async () => {
-      const binding = this.binding(owner, agentId), entries = []
+      const binding = this.binding(owner, agentId), entries:CredentialBotReference[] = []
+      if(this.client instanceof LocalCredentialVaultClient){
+        const granted:CredentialBotReference[]=await this.client.call(owner,'','bot-refs',{agentId});check()
+        entries.push(...granted.map(({agentId:_agentId,...reference})=>reference))
+      }
       const session=this.sessions.get(owner)
       if(session&&this.valid(session)){
         let state:CredentialVaultStatus
@@ -166,6 +183,7 @@ export class CredentialVaultCoordinator {
         check()
         if(state.unlocked)for(const entry of state.entries){
           if(!entry.usage)continue
+          if(entries.some(e=>e.credentialRef===entry.id))continue
           const operation=entry.usage.kind==='website.form'?'website.login':entry.usage.kind
           const authorized=[...this.grants.values()].some(g=>g.session.owner===owner&&g.summary.agentId===agentId&&g.summary.workId===workId
             &&g.summary.credentialRef===entry.id&&g.summary.operation===operation&&g.summary.expiresAt>Date.now()
@@ -200,6 +218,23 @@ export class CredentialVaultCoordinator {
               const binding = this.binding(owner, agentId)
               if (initialBinding && JSON.stringify(binding) !== JSON.stringify(initialBinding)) throw new HttpError(410, '节点重连后需要新的任务授权', 'vault_connection_revoked')
               initialBinding ??= binding
+              if(this.client instanceof LocalCredentialVaultClient){
+                const references:CredentialBotReference[]=await this.client.call(owner,'','bot-refs',{agentId})
+                const allowed=references.find(r=>r.credentialRef===b.credentialRef&&r.operation===b.operation)
+                check()
+                if(allowed){
+                  const execution=new AbortController(),monitor=setInterval(()=>{
+                    try{check();if(JSON.stringify(this.binding(owner,agentId))!==JSON.stringify(binding))throw new Error('binding changed')}
+                    catch{execution.abort()}
+                  },100)
+                  try{
+                    const result=await this.client.executeBot(owner,{...b,agentId,workId,...binding,target:allowed.allowedTarget,seconds:60},AbortSignal.any([signal,execution.signal]))
+                    check()
+                    if(JSON.stringify(this.binding(owner,agentId))!==JSON.stringify(binding))throw new HttpError(409,'执行端在回执确认前变化,请人工核对','vault_operation_uncertain')
+                    return result
+                  }finally{clearInterval(monitor)}
+                }
+              }
               const g = [...this.grants.values()].find(g => g.session.owner === owner && g.summary.workId === workId && g.summary.credentialRef === b.credentialRef && g.summary.operation === b.operation)
               if (g) {
                 if (!this.valid(g.session) || g.summary.expiresAt <= Date.now() || JSON.stringify(g.binding) !== JSON.stringify(binding)) throw new HttpError(410, '凭据任务授权已结束', 'vault_lease_revoked')

+ 55 - 2
src/server/credentialVault/localClient.ts

@@ -8,6 +8,7 @@ import { acquireServiceInstance } from '../serviceInstance.js'
 import { LocalCredentialExecutor } from './localExecutor.js'
 import { z } from 'zod'
 import { parse, leaseInput } from './schema.js'
+import { BotCredentialAccess } from './botAccess.js'
 
 /** Default encrypted vault with task-scoped execution in the trusted server. */
 export class LocalCredentialVaultClient extends CredentialVaultClient {
@@ -16,6 +17,7 @@ export class LocalCredentialVaultClient extends CredentialVaultClient {
   private instance?: ReturnType<typeof acquireServiceInstance>
   private executor?: LocalCredentialExecutor
   private closed = false
+  private botAccess?: BotCredentialAccess
 
   constructor(private readonly home: string, private readonly testCA?: Buffer) { super() }
   override get configured(): boolean { return true }
@@ -27,10 +29,12 @@ export class LocalCredentialVaultClient extends CredentialVaultClient {
       if (this.closed) throw new Error('closed')
       this.instance = acquireServiceInstance(store.root, 'credential-vault-v1')
       this.executor = new LocalCredentialExecutor(join(store.root, 'executor'), this.testCA)
+      this.botAccess = new BotCredentialAccess(join(store.root,'bot-access'))
       const broker = new CredentialVaultBroker(store, Date.now, this.executor)
       broker.hello(this.controlEpoch)
       return broker
     }).catch(() => {
+      this.botAccess?.close();this.botAccess=undefined
       this.executor?.close(); this.executor = undefined
       this.instance?.release(); this.instance = undefined
       this.broker = undefined
@@ -39,12 +43,34 @@ export class LocalCredentialVaultClient extends CredentialVaultClient {
     const broker = await this.broker
     if (this.closed) throw new HttpError(503, '密码保险箱已关闭', 'vault_offline')
     try {
+      if(command==='bot-refs')return this.botAccess!.summaries(owner,parse(z.object({agentId:z.string().uuid().optional()}).strict(),value??{}).agentId)
+      if(command==='bot-grant'||command==='bot-revoke'){
+        if(!broker.status(owner,session).unlocked)throw new HttpError(423,'请先解锁管理界面','vault_locked')
+        const b=parse(z.object({credentialRef:z.string().uuid(),agentId:z.string().uuid()}).strict(),value)
+        if(command==='bot-revoke'){this.botAccess!.revoke(owner,b.credentialRef,b.agentId);broker.recordBotAction(owner,'bot-revoke',b.credentialRef);return {ok:true}}
+        let entry=broker.store.list(owner).find(e=>e.id===b.credentialRef)
+        if(!entry?.usage)throw new HttpError(403,'请先配置允许执行的操作','vault_usage_forbidden')
+        if(entry.target.kind==='ssh'&&!entry.target.hostKey){
+          entry=await broker.prepareHost(this.controlEpoch,owner,session,{id:entry.id,revision:entry.revision})
+          this.botAccess!.revoke(owner,b.credentialRef)
+        }
+        // Preparing the host can outlive the management unlock/session.
+        if(!broker.status(owner,session).unlocked)throw new HttpError(423,'请重新解锁管理界面','vault_locked')
+        broker.recordBotAction(owner,'bot-grant',b.credentialRef)
+        broker.store.withSecret(owner,b.credentialRef,e=>this.botAccess!.grant(owner,b.agentId,e))
+        return {ok:true}
+      }
+      if(command==='update'||command==='remove'){
+        const id=parse(z.object({id:z.string().uuid()}).passthrough(),value).id
+        // Invalidate before mutation, including a partially committed write.
+        if(broker.status(owner,session).unlocked)this.botAccess!.revoke(owner,id)
+      }
       const result = command === 'execute'
         ? await (() => { const body = parse(z.object({ id: z.string().uuid(), input: leaseInput }).strict(), value); return broker.execute(this.controlEpoch, owner, session, body.id, body.input) })()
         : command === 'prepare-host' ? await broker.prepareHost(this.controlEpoch,owner,session,value)
         : broker.dispatch(this.controlEpoch, owner, session, command, value)
       return ['status', 'initialize', 'unlock', 'restore'].includes(command)
-        ? { ...(result as object), storageMode: 'local' }
+        ? { ...(result as object), storageMode: 'local', botGrants:this.botAccess!.summaries(owner) }
         : result
     } catch (error) {
       if (error instanceof HttpError) throw error
@@ -54,10 +80,37 @@ export class LocalCredentialVaultClient extends CredentialVaultClient {
     }
   }
 
+  async executeBot(owner:string,input:unknown,signal:AbortSignal){
+    const b=parse(leaseInput,input)
+    await this.call(owner,'','bot-refs',{agentId:b.agentId}) // Initialize the local broker without unlocking management.
+    const broker=await this.broker!
+    let submitted=false
+    try{return await this.botAccess!.execute(owner,b.agentId,b.credentialRef,signal,async(entry,activeSignal)=>{
+      const operation=entry.usage?.kind==='website.form'?'website.login':entry.usage?.kind
+      if(operation!==b.operation||JSON.stringify(entry.target)!==JSON.stringify(b.target))throw new HttpError(403,'操作不属于已授权的使用计划','vault_usage_forbidden')
+      broker.recordBotAction(owner,'bot-execute-started',b.credentialRef)
+      submitted=true
+      const receipt=await this.executor!.execute(entry,b,activeSignal)
+      const result=parse(z.union([
+        z.object({status:z.literal('complete'),operation:leaseInput.shape.operation,exitCode:z.number().int().optional(),bytes:z.number().int().nonnegative().optional(),sha256:z.string().regex(/^[a-f0-9]{64}$/).optional()}).strict(),
+        z.object({status:z.literal('manual_takeover_required'),reason:z.enum(['unsupported_form_flow','usage_policy_required']),submitted:z.boolean()}).strict(),
+      ]),receipt)
+      if(result.status==='complete'&&result.operation!==b.operation)throw new Error('receipt scope changed')
+      broker.recordBotAction(owner,'bot-execute-complete',b.credentialRef)
+      return result
+    })}catch(error){
+      if(error instanceof HttpError&&['vault_bot_forbidden','vault_scope_forbidden','vault_usage_forbidden','vault_host_key_rejected','vault_host_key_required'].includes(error.code))throw error
+      if(!submitted)throw error
+      broker.recordBotAction(owner,'bot-execute-ended',b.credentialRef,'not-confirmed')
+      throw new HttpError(409,'操作结果未确认,请人工核对,不能自动重试','vault_operation_uncertain')
+    }
+  }
+  revokeBot(owner:string,credentialRef:string,agentId:string){this.botAccess?.revoke(owner,credentialRef,agentId)}
+
   override close(): void {
     this.closed = true
     void this.broker?.then(broker => {
-      try { broker.close() } finally { this.executor?.close(); this.executor = undefined; this.instance?.release(); this.instance = undefined }
+      try { broker.close() } finally { this.botAccess?.close();this.botAccess=undefined;this.executor?.close(); this.executor = undefined; this.instance?.release(); this.instance = undefined }
     }).catch(() => {})
   }
 }

+ 8 - 0
src/shared/credentialVault.ts

@@ -18,10 +18,18 @@ export interface CredentialLeaseSummary {
   id: string; credentialRef: string; agentId: string; workId: string
   runnerId: string; operation: CredentialOperation; expiresAt: number
 }
+export interface CredentialBotGrantSummary {
+  credentialRef:string;agentId:string;name:string;operation:CredentialOperation
+}
+export interface CredentialBotReference {
+  credentialRef:string;agentId?:string;name?:string;operation:CredentialOperation
+  allowedTarget:CredentialTarget;allowedUse?:CredentialUseSummary;authorized?:boolean
+}
 export interface CredentialVaultStatus {
   protocol: 1; online: boolean; initialized: boolean; unlocked: boolean
   unlockExpiresAt?: number
   storageMode?: 'local'
+  botGrants?:CredentialBotGrantSummary[]
   execution: 'disabled' | 'dummy-fixture' | 'protected-adapters' | 'local-adapters'; reason: 'executor_not_enabled' | 'vault_offline' | 'dummy_fixture_only' | 'operator_approved' | 'isolation_required' | 'local_controlled' | 'executor_unavailable'
   entries: CredentialSummary[]; leases: CredentialLeaseSummary[]
 }

+ 11 - 11
tests/client/credentialVaultPanel.test.ts

@@ -14,9 +14,9 @@ it('shows an offline state without offering secret entry and explicitly keeps re
   state.online=false;const w=await setup()
   expect(w.find('input[type=password]').exists()).toBe(false);expect(w.text()).toContain('尚未启用');expect(w.text()).toContain('不会自动安装或启动服务')
 })
-it('offers local credential management and task approval without requiring an independent service',async()=>{
+it('offers local credential management and persistent Bot approval without requiring an independent service',async()=>{
   state.initialized=false;state.storageMode='local';state.execution='local-adapters';const w=await setup()
-  expect(w.text()).toContain('需要你授权具体任务');expect(w.text()).toContain('创建密码库')
+  expect(w.text()).toContain('授权一次即可使用');expect(w.text()).toContain('创建密码库')
   state.initialized=true;state.unlocked=true
   await w.findAll('button').find(b=>b.text()==='刷新状态')!.trigger('click');await flushPromises()
   await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
@@ -25,7 +25,7 @@ it('offers local credential management and task approval without requiring an in
   await label('准确网站').get('input').setValue('https://example.test');await label('密码').get('input').setValue('DUMMY-new-secret')
   await editor.trigger('submit');await flushPromises()
   expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:{name:'Dummy site',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret:'DUMMY-new-secret'}}))
-  expect(w.findAll('button').find(b=>b.text().startsWith('授予本任务'))!.attributes('disabled')).toBeDefined()
+  expect(w.findAll('button').find(b=>b.text()==='授权此 Bot 使用')!.attributes('disabled')).toBeDefined()
   expect(w.text()).not.toContain('DUMMY-new-secret');expect(w.find('input[type=password]').exists()).toBe(true)
 })
 it('reports a wrong vault master password without expiring the application login or echoing errors',async()=>{
@@ -104,7 +104,7 @@ it('defaults to a masked SSH password and saves it without a host fingerprint or
   expect(editor.text()).not.toContain('私钥口令');expect(editor.find('textarea').exists()).toBe(false)
   await editor.trigger('submit');await flushPromises()
   expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:{name:'Dummy password SSH',username:'dummy',target:{kind:'ssh',host:'ssh.example.test',port:22,auth:'password'},secret:'DUMMY-ssh-login-password'}}))
-  expect(w.text()).not.toContain('DUMMY-ssh-login-password');expect(w.text()).toContain('本次任务授权')
+  expect(w.text()).not.toContain('DUMMY-ssh-login-password');expect(w.text()).toContain('授权 Bot 使用')
 })
 
 it('clears SSH secret drafts on authentication changes and requires a new secret when editing a legacy key entry',async()=>{
@@ -133,20 +133,20 @@ it('keeps an existing SSH password when its masked replacement field is left bla
   expect(body.target.auth).toBe('password');expect(body).not.toHaveProperty('secret');expect(body).not.toHaveProperty('passphrase')
 })
 
-it.each(['pinned','first-use'])('grants a %s local Bot task only its selected configured operation without sending secrets',async trust=>{
+it.each(['pinned','first-use'])('grants a %s local credential to a specified Bot without a task or secret in the approval request',async trust=>{
   state.unlocked=true;state.storageMode='local';state.execution='local-adapters'
   state.entries=[{id:'credential-id',name:'SSH test',username:'dummy',target:{kind:'ssh',host:'ssh.example.test',port:22,...(trust==='pinned'?{hostKey:'SHA256:'+'A'.repeat(43)}:{}),auth:'password'},usage:{kind:'ssh.exec',command:'/usr/bin/uptime'},revision:1,updatedAt:1}]
   state.tasks=[{workId:'work-id',agentId:'bot-id',name:'Current Bot task'}]
+  state.bots=[{id:'bot-id',name:'Selected Bot'}]
   state.requests=[{workId:'work-id',agentId:'bot-id',credentialRef:'credential-id',operation:'ssh.exec'}]
-  const w=await setup(),button=w.findAll('button').find(b=>b.text().startsWith('授予本任务'))!
+  const w=await setup(),button=w.findAll('button').find(b=>b.text()==='授权此 Bot 使用')!
   expect(button.attributes('disabled')).toBeDefined()
   const label=(text:string)=>w.findAll('label').find(l=>l.text().startsWith(text))!
   await label('凭据').get('select').setValue('credential-id')
-  if(trust==='first-use'){expect(button.text()).toBe('确认服务器并授权本任务 60 秒');expect(w.text()).toContain('之后身份变化会停止连接')}
-  expect(label('操作').get('select').element.value).toBe('ssh.exec')
-  await w.findAll('button').find(b=>b.text()==='选择此请求,核对后授权')!.trigger('click')
+  if(trust==='first-use')expect(w.text()).toContain('首次授权会自动记住服务器指纹')
+  await label('Bot').get('select').setValue('bot-id')
   expect(button.attributes('disabled')).toBeUndefined()
   await w.findAll('form')[0]!.trigger('submit');await flushPromises()
-  expect(api).toHaveBeenCalledWith('/api/app/vault/leases',expect.objectContaining({method:'POST',body:{credentialRef:'credential-id',agentId:'bot-id',workId:'work-id',operation:'ssh.exec',seconds:60}}))
-  expect(w.text()).toContain('已授予此任务');expect(w.text()).not.toContain('需要连接独立')
+  expect(api).toHaveBeenCalledWith('/api/app/vault/bot-grant',expect.objectContaining({method:'POST',body:{credentialRef:'credential-id',agentId:'bot-id'}}))
+  expect(w.text()).toContain('此 Bot 已获持续授权');expect(w.text()).not.toContain('本次任务授权')
 })

+ 13 - 7
tests/server/credentialVaultBotFlow.test.ts

@@ -27,8 +27,8 @@ import type { Server } from 'node:http'
 import type { WorkspaceRun } from '../../src/shared/workspace'
 
 const scenarios=['website.login','ssh.exec','sftp.read','sftp.write','website.login.cancel','ssh.exec.cancel','sftp.write.cancel','ssh.exec.password','sftp.read.password','sftp.write.password','ssh.exec.password.cancel','ssh.exec.password.first-use','sftp.read.password.first-use','sftp.write.password.first-use'] as const
-const cases=(['external','local'] as const).flatMap(storage=>scenarios.map(scenario=>({storage,scenario})))
-it.each(cases)('a Bot turn discovers and uses $storage vault references for dummy $scenario with task approval and sanitized receipts',async({storage,scenario})=>{
+const cases=[...(['external','local'] as const).flatMap(storage=>scenarios.map(scenario=>({storage,scenario,persistent:false}))),...(['website.login','ssh.exec.password.first-use','sftp.read.password','ssh.exec.password.cancel'] as const).map(scenario=>({storage:'local' as const,scenario,persistent:true}))]
+it.each(cases)('a Bot turn discovers and uses $storage vault references for dummy $scenario (persistent=$persistent) with sanitized receipts',async({storage,scenario,persistent})=>{
   const cancelled=scenario.endsWith('.cancel'),firstUse=scenario.endsWith('.first-use'),operation=scenario.replace('.password','').replace('.first-use','').replace(/\.cancel$/,'') as 'website.login'|'ssh.exec'|'sftp.read'|'sftp.write'
   const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-bot-flow-'))
   const website=await websiteFixture(home),ssh=await sshFixture(scenario.includes('.password')?'password':'privateKey')
@@ -44,7 +44,7 @@ it.each(cases)('a Bot turn discovers and uses $storage vault references for dumm
   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<string,any>(),prompts:string[]=[],results:any[]=[],catalogs:any[][]=[],references:any[]=[]
-  let integrationError:unknown,credentialRef=''
+  let integrationError:unknown,credentialRef='',persistentBotId=''
   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:[]}
@@ -85,15 +85,20 @@ it.each(cases)('a Bot turn discovers and uses $storage vault references for dumm
             const named=(name:string)=>catalog.tools.find((t:any)=>t.name===name)!.id
             expect(named('credential_request')).toBeTruthy();expect(named('credential_refs')).toBeTruthy()
             const discovered=(await tool('/tools/call',{toolId:named('credential_refs'),arguments:{},callId:'before-approval'})).structuredContent.references
-            expect(discovered).toHaveLength(1);expect(discovered[0]).toMatchObject({credentialRef,operation,authorized:false});references.push(discovered)
+            expect(discovered).toHaveLength(1);expect(discovered[0]).toMatchObject({credentialRef,operation,authorized:persistent});references.push(discovered)
             const call={toolId:named('credential_request'),arguments:{credentialRef:discovered[0].credentialRef,operation},callId:'single-dummy-login'}
             const pending=tool('/tools/call',call)
-            let view:any
+            if(!persistent){let view:any
             await vi.waitFor(async()=>{view=(await ui('')).body;expect(view.requests).toHaveLength(1)})
             expect(effectCount()).toBe(0)
             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,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)}
+            }
+            if(cancelled){
+              await (isWebsite?website.submitted:operation==='ssh.exec'?ssh.executed:ssh.written);expect(effectCount()).toBe(1)
+              if(persistent){await ui('/unlock',{password:master}).expect(200);await ui('/bot-revoke',{credentialRef,agentId:persistentBotId}).expect(200)}
+              else 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}
@@ -107,6 +112,7 @@ it.each(cases)('a Bot turn discovers and uses $storage vault references for dumm
     const entry=await ui('/entries',{name:'dummy-local-site',username,target:credentialTarget,usage,secret}).expect(200);credentialRef=entry.body.id
     expect(ssh.state.authMethods).toEqual([]) // Saving a password never opens a connection.
     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)!
+    if(persistent){persistentBotId=bot.id;await ui('/bot-grant',{credentialRef,agentId:bot.id}).expect(200);await ui('/lock',{}).expect(200)}
     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})
     expect(integrationError).toBeUndefined();expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('complete')
@@ -115,7 +121,7 @@ it.each(cases)('a Bot turn discovers and uses $storage vault references for dumm
       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)
-    if(firstUse){const saved=(await ui('')).body.entries.find((e:any)=>e.id===credentialRef);expect(saved.target.hostKey).toBe(ssh.target.hostKey);expect(saved.revision).toBe(2);expect(ssh.state.authMethods).toEqual(['password'])}
+    if(firstUse){if(persistent)await ui('/unlock',{password:master}).expect(200);const saved=(await ui('')).body.entries.find((e:any)=>e.id===credentialRef);expect(saved.target.hostKey).toBe(ssh.target.hostKey);expect(saved.revision).toBe(2);expect(ssh.state.authMethods).toEqual(['password'])}
   }finally{
     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()))

+ 57 - 1
tests/server/credentialVaultLocalClient.test.ts

@@ -1,5 +1,5 @@
 // @vitest-environment node
-import { afterEach, expect, it } from 'vitest'
+import { afterEach, expect, it, vi } from 'vitest'
 import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
@@ -95,3 +95,59 @@ it('prevents concurrent brokers from writing the same local vault', async () =>
   first.value.close(); await Promise.resolve()
   expect(await second.call('status')).toMatchObject({ initialized: true, unlocked: false })
 })
+
+it('keeps specified Bot approval across management lock and restart, without exposing secrets, and revokes it explicitly',async()=>{
+  const ssh=await sshFixture('password'),f=client(),agentId=randomUUID(),owner='dummy-owner'
+  const controller=new AbortController()
+  try{
+    await f.call('initialize',{password});await f.call('unlock',{password})
+    const {hostKey:_hostKey,...target}=ssh.target
+    const entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
+    await expect(f.call('bot-grant',{credentialRef:entry.id,agentId},owner,'b'.repeat(64))).rejects.toMatchObject({code:'vault_locked'})
+    await f.call('bot-grant',{credentialRef:entry.id,agentId})
+    expect(ssh.state.authMethods).toEqual([])
+    await f.call('lock')
+    await expect(f.call('bot-grant',{credentialRef:entry.id,agentId})).rejects.toMatchObject({code:'vault_locked'})
+    const refs=await f.call('bot-refs',{agentId})
+    expect(refs).toHaveLength(1);expect(refs[0]).toMatchObject({credentialRef:entry.id,agentId,authorized:true,allowedTarget:ssh.target})
+    expect((await f.call('status')).unlocked).toBe(false)
+    expect(await f.call('bot-refs',{agentId:randomUUID()})).toEqual([])
+    expect(await f.call('bot-refs',{agentId},'other-owner')).toEqual([])
+    const input={credentialRef:entry.id,agentId,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target:ssh.target,seconds:60}
+    await expect(f.value.executeBot('other-owner',input,controller.signal)).rejects.toMatchObject({code:'vault_bot_forbidden'})
+    await expect(f.value.executeBot(owner,{...input,agentId:randomUUID()},controller.signal)).rejects.toMatchObject({code:'vault_bot_forbidden'})
+    f.value.close();await Promise.resolve();const restarted=client(f.home)
+    expect(await restarted.call('status')).toMatchObject({unlocked:false,botGrants:[{credentialRef:entry.id,agentId}]})
+    expect(await restarted.value.executeBot(owner,input,controller.signal)).toEqual({status:'complete',operation:'ssh.exec',exitCode:0})
+    expect(ssh.state.commands).toEqual(['/usr/bin/uptime'])
+    const directory=join(f.home,'credential-vault','bot-access')
+    const disk=readdirSync(directory).map(n=>readFileSync(join(directory,n),'utf8')).join('')
+    for(const value of [ssh.secret,ssh.username,password,'Dummy SSH'])expect(disk).not.toContain(value)
+    const publicData=JSON.stringify([refs,(await restarted.call('status')).botGrants])
+    for(const value of [ssh.secret,ssh.username,password])expect(publicData).not.toContain(value)
+    await restarted.call('unlock',{password});await restarted.call('bot-revoke',{credentialRef:entry.id,agentId});await restarted.call('lock')
+    expect(await restarted.call('bot-refs',{agentId})).toEqual([])
+    await expect(restarted.value.executeBot(owner,input,controller.signal)).rejects.toMatchObject({code:'vault_bot_forbidden'})
+  }finally{await ssh.close()}
+},15000)
+
+it('management expiry preserves Bot approval while editing or deleting a credential revokes approval and in-flight use',async()=>{
+  const ssh=await sshFixture('password'),f=client(),agentId=randomUUID(),owner='dummy-owner'
+  try{
+    await f.call('initialize',{password});await f.call('unlock',{password})
+    const entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target:ssh.target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
+    const grant={credentialRef:entry.id,agentId};await f.call('bot-grant',grant)
+    const broker=await (f.value as any).broker
+    const future=vi.spyOn(broker.store,'now').mockReturnValue(Date.now()+301000)
+    expect(await f.call('status')).toMatchObject({unlocked:false});expect(await f.call('bot-refs',{agentId})).toHaveLength(1);future.mockRestore()
+    await f.call('unlock',{password})
+    await f.call('update',{id:entry.id,entry:{name:'Updated',username:ssh.username,target:entry.target,revision:entry.revision}})
+    expect(await f.call('bot-refs',{agentId})).toEqual([])
+    await f.call('bot-grant',grant);await f.call('lock')
+    ssh.state.delayExec=true
+    const input={...grant,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target:ssh.target,seconds:60}
+    const pending=f.value.executeBot(owner,input,new AbortController().signal),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'})
+    await ssh.executed;await f.call('unlock',{password});await f.call('remove',{id:entry.id});await assertion
+    expect(await f.call('bot-refs',{agentId})).toEqual([])
+  }finally{vi.restoreAllMocks();await ssh.close()}
+},15000)