Browse Source

修复凭据修改后的持续 Bot 授权并简化保存授权流程

Samien 1 month ago
parent
commit
1d99039287

+ 5 - 3
docs/credential-vault.md

@@ -1,15 +1,17 @@
 # Bot 内置密码库(首期)
 
-默认安装提供夭夭服务器本机密码保险箱:登录后在「Bot 模式 → 设置 → 密码管理」创建主密码、手动解锁,即可添加网站密码、SSH 登录密码和私钥,无需先安装独立密码库服务。每个账号独立存储,密文位于夭夭数据目录的 `credential-vault/`;单实例锁防止其他 broker 同时写入。主密码用于管理凭据和授权,管理界面默认锁定,重启不自动解锁。用户配置固定使用计划并授权指定 Bot 后,该 Bot 可持续执行受控操作,直到撤销或修改凭据;模型没有读取秘密的接口。
+默认安装提供夭夭服务器本机密码保险箱:登录后在「Bot 模式 → 设置 → 密码管理」创建主密码、手动解锁,即可添加网站密码、SSH 登录密码和私钥,无需先安装独立密码库服务。每个账号独立存储,密文位于夭夭数据目录的 `credential-vault/`;单实例锁防止其他 broker 同时写入。主密码用于管理凭据和授权,管理界面默认锁定,重启不自动解锁。用户配置固定使用计划并授权指定 Bot 后,该 Bot 可持续执行受控操作,直到撤销或删除凭据;模型没有读取秘密的接口。
 
 ## 本机模式:一次授权,持续使用
 
-解锁管理界面后,选择凭据和 Bot,点击「授权此 Bot 使用」即可。管理页关闭、切后台、5 分钟管理解锁到期、退出管理登录或服务重启,均不会撤销这一授权。修改或删除凭据会撤销该凭据的原 Bot 授权,需重新核对并授权;「撤销 Bot 授权」立即阻止后续使用并中止在途执行。账号停用、Bot 不可用、任务取消或执行节点变化仍会阻止执行;在途取消可能已有外部效果,不能自动重试。
+解锁管理界面后,在添加或编辑凭据中选择「授权给 Bot」,点击「保存并授权 Bot」即可;也可用单独的「授权此 Bot 使用」入口。锁定状态仍显示持续授权清单,没有授权时明确提示,不把仅配置固定操作当作授权成功。管理页关闭、切后台、5 分钟管理解锁到期、退出管理登录或服务重启,均不会撤销这一授权。修改凭据后,已授权 Bot 自动使用最新配置,无需重新授权;删除凭据会撤销该凭据的 Bot 授权。「撤销 Bot 授权」立即阻止后续使用并中止在途执行。账号停用、Bot 不可用、任务取消或执行节点变化仍会阻止执行;在途取消可能已有外部效果,不能自动重试。
 
-持续授权只覆盖选中的账号、Bot、凭据版本、目标和固定使用计划。本机服务将被批准的执行材料单独加密存放在 `credential-vault/bot-access/`,执行密钥保存在同一可信服务器的 0600 私有文件,目录权限 0700;沿用本机模式对服务身份的信任,不宣称防御同 UID shell。主密码及秘密不进入工具目录、引用响应、执行回执或审计。原主密码加密库和其导出备份格式保持兼容;管理页导出的备份不含持续授权,恢复后需重新授权。整机服务数据备份包含执行材料,须完整保留私有权限。首次 SSH 授权仍自动记录服务器指纹,之后服务器身份变化会停止连接。
+持续授权只覆盖选中的账号、Bot、凭据版本、目标和固定使用计划。本机服务将被批准的执行材料单独加密存放在 `credential-vault/bot-access/`,执行密钥保存在同一可信服务器的 0600 私有文件,目录权限 0700;沿用本机模式对服务身份的信任,不宣称防御同 UID shell。主密码及秘密不进入工具目录、引用响应、执行回执或审计。原主密码加密库和其导出备份格式保持兼容;管理页导出的备份不含持续授权,恢复后需重新授权。整机服务数据备份包含执行材料,须完整保留私有权限。SSH 授权可离线保存,首次执行只做密钥交换后在该 Bot 的加密执行材料中记住指纹,再建立认证连接。未改地址和端口时保留指纹;明确修改地址或端口后按新目标重新记住指纹;身份变化仍在发送密码前停止连接。修改中止正在执行的旧版本操作,后续任务使用新版本。
 
 独立密码库部署沿用原逐任务短授权流程;此持续授权实现只用于默认本机密码库。
 
+2026-10-02 持续授权回归:8 个相关测试文件 126 项通过,类型检查与 Web/服务端构建通过。Chromium 实际操作专用管理 UI 创建并授权 dummy SSH 密码、修改端口、关闭设置及重启整个 Web 服务进程;锁定管理页时通过真实 Bot 凭据工具调用 SSH,旧端口一次、新端口重启前后各一次,均返回退出码 0;显式撤销后引用为空。另验证离线授权、连接失败仍保留授权、多个已授权 Bot 的配置同步、管理到期、修改/删除中止在途操作、保留已学习的指纹,以及更换服务器身份时认证前拒绝。上游模型为夹具,SSH 协议与浏览器及服务进程是真实运行;不代表真实 TrueNAS 账号已登录。浏览器证据在 `test-results/credential-vault-persistent/mobile-after-restart.png`。
+
 默认本机模式直接使用服务器上的受控执行器,不需要额外的 socket、服务身份或部署批准文件。配置 `HERMES_YAOYAO_VAULT_SOCKET` / `HERMES_YAOYAO_VAULT_TOKEN_FILE` 后,仍使用原有独立密码库服务;显式配置的服务不可用时保持离线,不切换到新的本机保险箱。独立服务可以部署在 Hermes 执行节点,并在隔离部署验收后启用受保护执行器。密码库不是 Hermes 的环境变量或工具配置。
 
 ## 本机 Bot 使用流程

+ 25 - 10
src/client/components/app/CredentialVaultPanel.vue

@@ -15,6 +15,8 @@ const name=ref(''), username=ref(''), kind=ref<'website'|'ssh'>('website'), orig
 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 saveBotId=ref('')
+function approvedBots(id:string){return (state.value?.botGrants??[]).filter(g=>g.credentialRef===id).map(g=>state.value?.bots?.find(b=>b.id===g.agentId)?.name??'Bot').join('、')}
 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))
@@ -26,7 +28,7 @@ const inflight=new Set<AbortController>()
 function clearSecrets(){master.value='';confirmation.value='';replacement.value='';secret.value='';passphrase.value='';contents.value='';clearPassphrase.value=false}
 function failure(cause:unknown){
   const code=cause&&typeof cause==='object'&&'code' in cause?String(cause.code):''
-  return ({vault_offline:'密码保险箱连接不可用,请刷新状态或检查数据目录权限。',vault_locked:'密码保险箱已锁定,请重新手动解锁后添加凭据。',vault_unlock_failed:'主密码错误或密码库已损坏,请核对主密码。',vault_invalid_request:'请检查填写内容:主密码至少 12 位,网站使用 HTTPS 地址,SSH 地址和端口需有效。手填指纹时请使用 SHA256 格式。',vault_auth_secret_required:'更换 SSH 认证方式后,请重新填写密码或私钥。',vault_executor_not_enabled:'凭据执行器暂时不可用,请刷新状态或检查服务。',vault_host_probe_failed:'无法连接 SSH 服务器;已保存的密码不受影响,请检查主机地址和端口后重试授权。',vault_host_key_required:'首次使用时请选择确认服务器并授权任务。',vault_host_key_rejected:'服务器身份已改变,已停止连接。请核对服务器后再修改高级设置中的指纹。'} as Record<string,string>)[code]??'操作未完成,请检查连接和主密码;秘密不会回显。'
+  return ({vault_saved_but_not_granted:'凭据已保存,但 Bot 授权未完成。请刷新后选择 Bot 重新授权。',vault_offline:'密码保险箱连接不可用,请刷新状态或检查数据目录权限。',vault_locked:'密码保险箱已锁定,请重新手动解锁后添加凭据。',vault_unlock_failed:'主密码错误或密码库已损坏,请核对主密码。',vault_invalid_request:'请检查填写内容:主密码至少 12 位,网站使用 HTTPS 地址,SSH 地址和端口需有效。手填指纹时请使用 SHA256 格式。',vault_auth_secret_required:'更换 SSH 认证方式后,请重新填写密码或私钥。',vault_executor_not_enabled:'凭据执行器暂时不可用,请刷新状态或检查服务。',vault_host_probe_failed:'无法连接 SSH 服务器;已保存的密码不受影响,请检查主机地址和端口后重试授权。',vault_host_key_required:'首次使用时请选择确认服务器并授权任务。',vault_host_key_rejected:'服务器身份已改变,已停止连接。请核对服务器后再修改高级设置中的指纹。'} as Record<string,string>)[code]??'操作未完成,请检查连接和主密码;秘密不会回显。'
 }
 async function request<T>(path:string,method:'GET'|'POST'|'PUT'|'DELETE'='GET',body?:Record<string,JsonValue>){
   const c=new AbortController();inflight.add(c)
@@ -44,6 +46,7 @@ async function create(){if(master.value!==confirmation.value){error.value='两
 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){
+  saveBotId.value=''
   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:''
   port.value=entry?.target.kind==='ssh'?entry.target.port:22;hostKey.value=entry?.target.kind==='ssh'?entry.target.hostKey??'':''
@@ -61,14 +64,19 @@ 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},editing.value&&state.value?.storageMode==='local'?'凭据已保存;原 Bot 授权已撤销,请重新授权。':'凭据已加密保存,密码与私钥不回显')
+  const selectedBot=state.value?.storageMode==='local'&&configureUse.value?saveBotId.value:''
+  await action(async()=>{
+    const saved=await request<CredentialSummary>(editing.value?'/entries/'+editing.value.id:'/entries',editing.value?'PUT':'POST',body)
+    showEditor.value=false
+    if(selectedBot){try{await request('/bot-grant','POST',{credentialRef:saved.id,agentId:selectedBot})}catch{throw {code:'vault_saved_but_not_granted'}}}
+  },selectedBot?'凭据已加密保存并授权所选 Bot,返回聊天即可使用。':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 已获持续授权;返回聊天即可使用,直到你撤销或修改凭据。')}
+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+' 字节'}
@@ -113,6 +121,10 @@ onBeforeUnmount(()=>{
     <p v-if="!state?.online">密码保险箱暂时不可用,请检查夭夭数据目录权限;已配置独立密码库时,请检查该服务的私有连接。此处不会自动安装或启动服务。</p>
     <template v-else>
       <p>{{state.unlocked?'已解锁':'已锁定'}}<span v-if="state.unlockExpiresAt"> · {{new Date(state.unlockExpiresAt).toLocaleTimeString()}} 到期</span></p>
+      <div v-if="state.storageMode==='local'" class="bot-approvals">
+        <p v-if="!state.botGrants?.length">尚未授权任何 Bot。解锁后编辑凭据,选择允许使用的 Bot 并保存。</p>
+        <ul v-else><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||!state.unlocked" @click="action(async()=>{await request('/bot-revoke','POST',{credentialRef:g.credentialRef,agentId:g.agentId})},'Bot 授权已撤销')">撤销 Bot 授权</button></li></ul>
+      </div>
       <form v-if="!state.initialized" @submit.prevent="create">
         <label>主密码<input v-model="master" type="password" autocomplete="new-password" minlength="12" maxlength="1024" required :disabled="busy" /></label>
         <label>确认主密码<input v-model="confirmation" type="password" autocomplete="new-password" minlength="12" required :disabled="busy" /></label>
@@ -126,7 +138,7 @@ onBeforeUnmount(()=>{
       </form>
       <template v-else>
         <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>
+        <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><p v-if="state.storageMode==='local'">{{approvedBots(entry.id)?'已持续授权:'+approvedBots(entry.id):'未授权给 Bot'}}</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>
           <label>用户名<input v-model="username" maxlength="256" autocomplete="off" required :disabled="busy" /></label>
@@ -140,7 +152,7 @@ onBeforeUnmount(()=>{
           <details v-if="kind==='ssh'" class="advanced-connection">
             <summary>高级连接设置(可选)</summary>
             <label>端口<input v-model.number="port" type="number" min="1" max="65535" required :disabled="busy" /></label>
-            <label>服务器指纹(可选)<input v-model="hostKey" placeholder="SHA256:…" :disabled="busy" /><small>留空即可保存。首次授权会自动获取并记住服务器指纹;已有指纹会继续校验。</small></label>
+            <label>服务器指纹(可选)<input v-model="hostKey" placeholder="SHA256:…" :disabled="busy" /><small>留空即可保存。首次使用会自动获取并记住服务器指纹;已有指纹会继续校验。</small></label>
           </details>
           <label class="checkbox-field"><input v-model="configureUse" type="checkbox" :disabled="busy||!!editing?.usage" />配置允许 Bot 执行的固定操作</label>
           <template v-if="configureUse&&kind==='website'">
@@ -155,18 +167,21 @@ onBeforeUnmount(()=>{
             <label v-else>批准的远端绝对路径<input v-model="remotePath" required :disabled="busy" /></label>
             <label v-if="useKind==='sftp.read'">最大读取字节数<input v-model.number="maxBytes" type="number" min="1" max="1048576" required :disabled="busy" /></label>
             <label v-if="useKind==='sftp.write'">固定写入正文(加密保存;最多 64 KiB)<textarea v-model="contents" maxlength="65536" autocomplete="off" :disabled="busy" /><small v-if="editing?.usage?.kind==='sftp.write'">留空保留原正文,不回显。写入仅创建新文件,不覆盖;中断需人工核对。</small></label>
-            <p>首次授权会记住服务器,以后身份改变就停止连接。Bot 只收到退出码或字节数和校验,收不到密码、密钥、输出正文或文件正文。</p>
+            <p>首次使用会记住服务器,以后身份改变就停止连接。Bot 只收到退出码或字节数和校验,收不到密码、密钥、输出正文或文件正文。</p>
+          </template>
+          <template v-if="state.storageMode==='local'&&configureUse">
+            <p v-if="editing&&approvedBots(editing.id)">已持续授权:{{approvedBots(editing.id)}}。修改后继续可用,无需重新授权。</p>
+            <label>授权给 Bot<select v-model="saveBotId" :disabled="busy"><option value="">{{editing&&approvedBots(editing.id)?'保留已有授权':'暂不授权'}}</option><option v-for="bot in state.bots??[]" :key="bot.id" :value="bot.id">{{bot.name}}</option></select><small>选择 Bot 后保存即授权,直到你撤销。</small></label>
           </template>
-          <div class="actions"><button :disabled="busy">加密保存</button><button type="button" :disabled="busy" @click="showEditor=false;clearSecrets()">取消</button></div>
+          <div class="actions"><button :disabled="busy">{{saveBotId&&configureUse?'保存并授权 Bot':'加密保存'}}</button><button type="button" :disabled="busy" @click="showEditor=false;clearSecrets()">取消</button></div>
         </form>
         <form v-if="state.storageMode==='local'" @submit.prevent="grantBot">
-          <h4>授权 Bot 使用</h4><p>授权一次,之后直接使用;管理界面锁定不影响授权。修改凭据会撤销原授权。</p>
+          <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>
+          <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>

+ 28 - 1
src/server/credentialVault/botAccess.ts

@@ -44,16 +44,43 @@ export class BotCredentialAccess {
   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})
+    const existing=this.path(owner,agentId,entry.id)
+    if(existsSync(existing)){
+      const old=this.read(existing).entry
+      if(old.revision===entry.revision&&old.target.kind==='ssh'&&value.entry.target.kind==='ssh'&&!value.entry.target.hostKey
+        &&old.target.host===value.entry.target.host&&old.target.port===value.entry.target.port)value.entry.target.hostKey=old.target.hostKey
+    }
     if(!existsSync(this.path(owner,agentId,entry.id))&&this.records(owner).length>=1024)throw new HttpError(413,'Bot 授权数量已达到上限','vault_bot_grant_limit')
+    this.save(value)
+    this.abort(owner,entry.id,agentId)
+  }
+  private save(value:z.infer<typeof record>){
+    const {owner,agentId,entry}=value
     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)
+      renameSync(temp,path);this.sync()
     }catch(error){rmSync(path,{force:true});this.abort(owner,entry.id,agentId);throw error}
     finally{sodium.memzero(bytes);rmSync(temp,{force:true})}
   }
+  refresh(owner:string,entry:Readonly<VaultEntry>,preservePin:boolean){
+    try{for(const r of this.records(owner).filter(r=>r.value.entry.id===entry.id)){
+      const previous=r.value.entry.target
+      const target=preservePin&&entry.target.kind==='ssh'&&previous.kind==='ssh'&&previous.hostKey
+        ? {...entry.target,hostKey:previous.hostKey}:entry.target
+      this.grant(owner,r.value.agentId,{...entry,target})
+    }}catch(error){this.revoke(owner,entry.id);throw error}
+  }
+  pinSshHost(owner:string,agentId:string,id:string,revision:number,hostKey:string){
+    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.entry.revision!==revision||value.entry.target.kind!=='ssh')throw new HttpError(409,'凭据已修改,请重新查询引用','vault_revision_conflict')
+    if(!value.entry.target.hostKey){value.entry.target.hostKey=hostKey;this.save(value)}
+    return value.entry
+  }
   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)

+ 10 - 2
src/server/credentialVault/coordinator.ts

@@ -203,7 +203,13 @@ export class CredentialVaultCoordinator {
     return { close: () => { controller.abort(); signal.removeEventListener('abort', abort) },
       call: async (name: string, value: unknown) => {
         check()
-        if (name === 'credential_refs') { parse(z.object({}).strict(), value); return { references: await refs() } }
+        if (name === 'credential_refs') {
+          parse(z.object({}).strict(), value)
+          const references=await refs()
+          return {references,...(this.client instanceof LocalCredentialVaultClient&&!references.some(r=>r.authorized)?{
+            reason:'bot_not_authorized',message:'此 Bot 尚未获持续授权。请在密码管理中编辑凭据,选择此 Bot 并保存授权;授权成功后无需保持管理界面解锁。',
+          }:{})}
+        }
         if (name !== 'credential_request') throw new HttpError(404, '凭据工具不存在', 'vault_command_unknown')
         const b = parse(z.object({ credentialRef: z.string().uuid(), operation: leaseInput.shape.operation }).strict(), value)
         const id=randomUUID();pendingIds.add(id);this.pending.set(id, { owner, agentId, workId, ...b })
@@ -255,7 +261,9 @@ export class CredentialVaultCoordinator {
             } catch (e) { if (!(e instanceof HttpError) || !['vault_offline', 'vault_runner_offline', 'vault_locked'].includes(e.code)) throw e }
             await delay(1000, undefined, { signal })
           }
-          return { status: 'waiting_for_user', reason: 'vault_locked_or_offline', message: '仍需用户连接、解锁和本任务授权;未执行登录。' }
+          return this.client instanceof LocalCredentialVaultClient
+            ? {status:'waiting_for_user',reason:'bot_not_authorized',message:'此 Bot 尚未获得该凭据的持续授权。请在密码管理中选择此 Bot 并保存授权;之后无需保持管理页解锁。未执行登录。'}
+            : { status: 'waiting_for_user', reason: 'vault_locked_or_offline', message: '仍需用户连接、解锁和本任务授权;未执行登录。' }
         } finally { this.pending.delete(id);pendingIds.delete(id);if(--waitingCalls===0)waiting(false) }
       },
     }

+ 30 - 11
src/server/credentialVault/localClient.ts

@@ -1,5 +1,6 @@
 import { join } from 'node:path'
 import { randomUUID } from 'node:crypto'
+import { isDeepStrictEqual } from 'node:util'
 import { CredentialVaultClient } from './transport.js'
 import { CredentialVaultStore } from './store.js'
 import { CredentialVaultBroker } from './broker.js'
@@ -7,7 +8,7 @@ import { HttpError } from '../errors.js'
 import { acquireServiceInstance } from '../serviceInstance.js'
 import { LocalCredentialExecutor } from './localExecutor.js'
 import { z } from 'zod'
-import { parse, leaseInput } from './schema.js'
+import { parse, leaseInput, updateInput } from './schema.js'
 import { BotCredentialAccess } from './botAccess.js'
 
 /** Default encrypted vault with task-scoped execution in the trusted server. */
@@ -48,19 +49,31 @@ export class LocalCredentialVaultClient extends CredentialVaultClient {
         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)
+        const 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'){
+      if(command==='update'){
+        if(!broker.status(owner,session).unlocked)throw new HttpError(423,'请先解锁管理界面','vault_locked')
+        const b=parse(z.object({id:z.string().uuid(),entry:updateInput}).strict(),value)
+        const old=broker.store.list(owner).find(e=>e.id===b.id)
+        const changedEndpoint=old?.target.kind==='ssh'&&b.entry.target.kind==='ssh'&&(old.target.host!==b.entry.target.host||old.target.port!==b.entry.target.port)
+        // An automatic pin belongs to an endpoint, not to a credential's name.
+        if(changedEndpoint&&old?.target.kind==='ssh'&&b.entry.target.kind==='ssh'&&b.entry.target.hostKey===old.target.hostKey)delete b.entry.target.hostKey
+        const preservePin=old?.target.kind==='ssh'&&b.entry.target.kind==='ssh'&&!changedEndpoint&&!old.target.hostKey&&!b.entry.target.hostKey
+        try{
+          const result=broker.dispatch(this.controlEpoch,owner,session,command,b)
+          broker.store.withSecret(owner,b.id,e=>this.botAccess!.refresh(owner,e,!!preservePin))
+          return result
+        }catch(error){
+          // Validation conflicts leave approval intact; uncertain writes cannot use old material.
+          if(!(error instanceof HttpError))this.botAccess!.revoke(owner,b.id)
+          throw error
+        }
+      }
+      if(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)
@@ -87,10 +100,16 @@ export class LocalCredentialVaultClient extends CredentialVaultClient {
     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')
+      const requestedTarget=b.target.kind==='ssh'&&!b.target.hostKey&&entry.target.kind==='ssh'?{...b.target,...(entry.target.hostKey?{hostKey:entry.target.hostKey}:{})}:b.target
+      if(operation!==b.operation||!isDeepStrictEqual(entry.target,requestedTarget))throw new HttpError(403,'操作不属于已授权的使用计划','vault_usage_forbidden')
+      if(entry.target.kind==='ssh'&&!entry.target.hostKey){
+        const hostKey=await this.executor!.prepareSshHost(entry.target,activeSignal)
+        activeSignal.throwIfAborted()
+        entry=this.botAccess!.pinSshHost(owner,b.agentId,entry.id,entry.revision,hostKey)
+      }
       broker.recordBotAction(owner,'bot-execute-started',b.credentialRef)
       submitted=true
-      const receipt=await this.executor!.execute(entry,b,activeSignal)
+      const receipt=await this.executor!.execute(entry,{...b,target:entry.target},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(),

+ 18 - 1
tests/client/credentialVaultPanel.test.ts

@@ -143,10 +143,27 @@ it.each(['pinned','first-use'])('grants a %s local credential to a specified 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(w.text()).toContain('首次授权会自动记住服务器指纹')
+  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/bot-grant',expect.objectContaining({method:'POST',body:{credentialRef:'credential-id',agentId:'bot-id'}}))
   expect(w.text()).toContain('此 Bot 已获持续授权');expect(w.text()).not.toContain('本次任务授权')
 })
+
+it('shows persistent approval while locked and saves explicit Bot approval together with a configured entry',async()=>{
+  state.storageMode='local';state.bots=[{id:'selected-bot',name:'竹儿'}];state.botGrants=[{credentialRef:'entry-id',agentId:'selected-bot',name:'SSH test',operation:'ssh.exec'}]
+  const w=await setup();expect(w.text()).toContain('SSH test → 竹儿 · 已持续授权')
+  expect(w.findAll('button').find(b=>b.text()==='撤销 Bot 授权')!.attributes('disabled')).toBeDefined()
+  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')
+  const editor=w.get('form'),label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
+  await label('名称').get('input').setValue('SSH new');await label('用户名').get('input').setValue('dummy')
+  await label('用途').get('select').setValue('ssh');await label('SSH 主机').get('input').setValue('127.0.0.1')
+  await label('SSH 登录密码').get('input').setValue('DUMMY-new-password');await editor.get('input[type=checkbox]').setValue(true)
+  await label('授权给 Bot').get('select').setValue('selected-bot')
+  api.mockImplementation(async(p,o)=>p.endsWith('/entries')?{id:'new-entry'}:o?.method==='POST'?{}:structuredClone(state))
+  await editor.trigger('submit');await flushPromises()
+  expect(api).toHaveBeenCalledWith('/api/app/vault/bot-grant',expect.objectContaining({body:{credentialRef:'new-entry',agentId:'selected-bot'}}))
+  expect(w.text()).toContain('凭据已加密保存并授权所选 Bot');expect(w.text()).not.toContain('DUMMY-new-password')
+})

+ 60 - 4
tests/e2e/credential-vault.spec.ts

@@ -12,7 +12,7 @@ test('a default installation creates and saves encrypted passwords through the s
   await page.getByRole('menuitem',{name:'我的设置',exact:true}).click()
   await page.getByRole('button',{name:'密码管理',exact:true}).click()
   const panel=page.locator('.credential-vault')
-  await expect(panel.getByText(/需要你授权具体任务/)).toBeVisible()
+  await expect(panel.getByText(/授权一次即可使用/)).toBeVisible()
   await panel.getByLabel('主密码',{exact:true}).fill(master)
   await panel.getByLabel('确认主密码',{exact:true}).fill(master)
   await panel.getByRole('button',{name:'创建密码库',exact:true}).click()
@@ -27,7 +27,7 @@ test('a default installation creates and saves encrypted passwords through the s
   await panel.getByRole('button',{name:'加密保存',exact:true}).click()
   await expect(panel.locator('li strong').filter({hasText:'浏览器测试网站'})).toBeVisible()
   await expect(panel.getByRole('status').filter({hasText:'凭据已加密保存'})).toBeVisible()
-  await expect(panel.getByRole('button',{name:'授予本任务 60 秒引用权限'})).toBeDisabled()
+  await expect(panel.getByRole('button',{name:'授权此 Bot 使用'})).toBeDisabled()
   expect(await panel.textContent()).not.toContain(secret)
   await panel.getByRole('button',{name:'添加凭据',exact:true}).click()
   await panel.getByRole('combobox',{name:/^用途/}).selectOption('ssh')
@@ -48,7 +48,7 @@ test('a default installation creates and saves encrypted passwords through the s
   await panel.getByRole('button',{name:'加密保存',exact:true}).click()
   const sshEntry=panel.locator('li').filter({hasText:'浏览器测试 SSH 密码'})
   await expect(sshEntry).toContainText('密码登录')
-  await expect(panel.getByRole('heading',{name:'本次任务授权',exact:true})).toBeVisible()
+  await expect(panel.getByRole('heading',{name:'授权 Bot 使用',exact:true})).toBeVisible()
   expect(await panel.textContent()).not.toContain('DUMMY-browser-ssh-password')
   await sshEntry.getByRole('button',{name:'编辑',exact:true}).click()
   await expect(sshPassword).toHaveValue('')
@@ -64,7 +64,7 @@ test('a default installation creates and saves encrypted passwords through the s
   expect(await panel.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
   await page.screenshot({path:evidence+'/mobile-saved.png'})
   await page.setViewportSize({width:1440,height:1000})
-  await panel.getByRole('button',{name:'立即锁定并撤销授权',exact:true}).click()
+  await panel.getByRole('button',{name:'锁定管理界面',exact:true}).click()
   await expect(panel.getByText('浏览器测试网站',{exact:true})).toHaveCount(0)
   await panel.getByLabel('主密码',{exact:true}).fill('DUMMY-wrong-master-password')
   await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click()
@@ -73,3 +73,59 @@ test('a default installation creates and saves encrypted passwords through the s
   await expect(panel.getByLabel('主密码',{exact:true})).toHaveValue('')
   await page.screenshot({path:evidence+'/wrong-master-stays-signed-in.png'})
 })
+
+test('UI approval survives port edits, closing settings and a full server process restart with real dummy SSH execution',async({page})=>{
+  const {mkdtempSync,rmSync}=await import('node:fs'),{tmpdir}=await import('node:os'),{join}=await import('node:path'),{spawn}=await import('node:child_process')
+  const {sshFixture}=await import('../fixtures/credentialProtocols')
+  const first=await sshFixture('password'),second=await sshFixture('password')
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-browser-restart-')),base='http://127.0.0.1:18837',master='DUMMY-browser-persistent-master'
+  let child:ReturnType<typeof spawn>|undefined,logs=''
+  const start=async()=>{
+    child=spawn(process.execPath,['--import','tsx','tests/fixtures/workspace-server.ts'],{env:{...process.env,WORKSPACE_FIXTURE_HOME:home,WORKSPACE_FIXTURE_PORT:'18837',WORKSPACE_FIXTURE_UPSTREAM_PORT:'19137',WORKSPACE_FIXTURE_VAULT:'1'},stdio:['ignore','pipe','pipe']})
+    child.stdout!.on('data',c=>logs+=c);child.stderr!.on('data',c=>logs+=c)
+    await expect.poll(async()=>{try{return (await fetch(base+'/healthz')).status}catch{return 0}},{timeout:20000,message:logs}).toBe(200)
+  }
+  const stop=async()=>{if(!child||child.exitCode!==null)return;await page.goto('about:blank').catch(()=>{});const current=child,exited=new Promise<void>(r=>current.once('exit',()=>r()));current.kill('SIGTERM');const timeout=setTimeout(()=>current.kill('SIGKILL'),3000);await exited;clearTimeout(timeout);child=undefined}
+  const login=async()=>{
+    await page.goto(base+'/conversations')
+    if(await page.getByRole('textbox',{name:'账号',exact:true}).isVisible()){
+      await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
+    }
+    await expect(page.locator('.desktop-sidebar .sidebar-account-switcher__main')).toBeVisible()
+  }
+  const settings=async()=>{await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click();await page.getByRole('menuitem',{name:'我的设置',exact:true}).click();await page.getByRole('button',{name:'密码管理',exact:true}).click()}
+  const headers=async()=>({Origin:base,'X-CSRF-Token':(await(await page.request.get(base+'/api/realtime/capabilities')).json()).csrfToken})
+  try{
+    await start();await login()
+    const agentResponse=await page.request.post(base+'/api/app/agents',{headers:await headers(),data:{name:'竹儿测试',profile:'default'}});expect(agentResponse.ok()).toBe(true)
+    const agentId=(await agentResponse.json()).agent.id
+    await settings();const panel=page.locator('.credential-vault')
+    await panel.getByLabel('主密码',{exact:true}).fill(master);await panel.getByLabel('确认主密码',{exact:true}).fill(master);await panel.getByRole('button',{name:'创建密码库',exact:true}).click()
+    await panel.getByLabel('主密码',{exact:true}).fill(master);await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click();await panel.getByRole('button',{name:'添加凭据',exact:true}).click()
+    await panel.getByRole('combobox',{name:/^用途/}).selectOption('ssh')
+    await panel.getByLabel('名称',{exact:true}).fill('TrueNAS 流程测试');await panel.getByLabel('用户名',{exact:true}).fill(first.username);await panel.getByLabel('SSH 主机',{exact:true}).fill('127.0.0.1');await panel.getByLabel(/^SSH 登录密码/).fill(first.secret)
+    await panel.getByText('高级连接设置(可选)',{exact:true}).click();await panel.getByLabel('端口',{exact:true}).fill(String(first.target.port))
+    await panel.getByLabel('配置允许 Bot 执行的固定操作',{exact:true}).check();await panel.getByRole('combobox',{name:/^授权给 Bot/}).selectOption(agentId)
+    await panel.getByRole('button',{name:'保存并授权 Bot',exact:true}).click();await expect(panel.getByText('TrueNAS 流程测试 → 竹儿测试 · 已持续授权')).toBeVisible()
+    expect(first.state.authMethods).toEqual([])
+    const state=await(await page.request.get(base+'/api/app/vault')).json(),credentialRef=state.entries[0].id
+    const use=async(discoverOnly=false)=>{const response=await page.request.post(base+'/__test/vault-use',{headers:await headers(),data:{agentId,credentialRef,discoverOnly}});expect(response.ok(),await response.text()).toBe(true);return response.json()}
+    await panel.getByRole('button',{name:'锁定管理界面',exact:true}).click()
+    expect((await use()).result).toEqual({status:'complete',operation:'ssh.exec',exitCode:0});expect(first.state.commands).toEqual(['/usr/bin/uptime'])
+    await panel.getByLabel('主密码',{exact:true}).fill(master);await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click()
+    await panel.locator('li').filter({has:page.locator('strong', {hasText:'TrueNAS 流程测试'})}).getByRole('button',{name:'编辑',exact:true}).click()
+    await expect(panel.getByText('已持续授权:竹儿测试。修改后继续可用,无需重新授权。')).toBeVisible()
+    await panel.getByText('高级连接设置(可选)',{exact:true}).click();await panel.getByLabel('端口',{exact:true}).fill(String(second.target.port));await panel.getByRole('button',{name:'加密保存',exact:true}).click()
+    await page.getByRole('button',{name:'关闭我的设置',exact:true}).filter({visible:true}).first().click();await expect(panel).toHaveCount(0);await expect.poll(async()=>(await(await page.request.get(base+'/api/app/vault')).json()).unlocked).toBe(false)
+    const edited=await use();expect(edited.refs.references[0]).toMatchObject({authorized:true,allowedTarget:{port:second.target.port}});expect(edited.result.exitCode).toBe(0)
+    expect(first.state.commands).toHaveLength(1);expect(second.state.commands).toHaveLength(1)
+    await stop();await start();await login()
+    const restarted=await(await page.request.get(base+'/api/app/vault')).json();expect(restarted).toMatchObject({unlocked:false,botGrants:[{credentialRef,agentId}]})
+    expect((await use()).result).toEqual({status:'complete',operation:'ssh.exec',exitCode:0});expect(second.state.commands).toHaveLength(2)
+    await settings();await expect(panel.getByText('TrueNAS 流程测试 → 竹儿测试 · 已持续授权')).toBeVisible()
+    await page.setViewportSize({width:375,height:812});expect(await panel.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
+    mkdirSync('test-results/credential-vault-persistent',{recursive:true});await page.screenshot({path:'test-results/credential-vault-persistent/mobile-after-restart.png'})
+    await panel.getByLabel('主密码',{exact:true}).fill(master);await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click();await panel.getByRole('button',{name:'撤销 Bot 授权',exact:true}).click();await panel.getByRole('button',{name:'锁定管理界面',exact:true}).click()
+    expect(await use(true)).toMatchObject({references:[],reason:'bot_not_authorized'});expect(second.state.commands).toHaveLength(2)
+  }finally{await stop();await first.close();await second.close();rmSync(home,{recursive:true,force:true})}
+})

+ 7 - 5
tests/fixtures/credentialProtocols.ts

@@ -5,7 +5,9 @@ import {execFileSync} from 'node:child_process'
 import {readFileSync,writeFileSync} from 'node:fs'
 import {join} from 'node:path'
 import {createHash,randomBytes} from 'node:crypto'
-import {Server,utils,type Connection} from 'ssh2'
+import ssh2 from 'ssh2'
+import type {Connection,utils} from 'ssh2'
+const {Server,utils:keyUtils}=ssh2
 export async function websiteFixture(home:string){
   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')
@@ -36,14 +38,14 @@ function dummySshKey(){
   // ssh2 1.17.0 strips leading zero bytes when serializing Ed25519 public
   // material. Validate generated dummy input, never retry an actual operation.
   for(let attempt=0;attempt<8;attempt++){
-    const pair=utils.generateKeyPairSync('ed25519')
-    if(!(utils.parseKey(pair.private) instanceof Error)&&!(utils.parseKey(pair.public) instanceof Error))return pair
+    const pair=keyUtils.generateKeyPairSync('ed25519')
+    if(!(keyUtils.parseKey(pair.private) instanceof Error)&&!(keyUtils.parseKey(pair.public) instanceof Error))return pair
   }
   throw new Error('Unable to generate a valid dummy SSH key')
 }
 export async function sshFixture(auth: 'privateKey' | 'password' = 'privateKey',port=0){
-  const host=dummySshKey(),key=dummySshKey(),parsed=utils.parseKey(key.public) as utils.ParsedKey
-  const fingerprint='SHA256:'+createHash('sha256').update((utils.parseKey(host.private) as utils.ParsedKey).getPublicSSH()).digest('base64').replace(/=+$/,'')
+  const host=dummySshKey(),key=dummySshKey(),parsed=keyUtils.parseKey(key.public) as utils.ParsedKey
+  const fingerprint='SHA256:'+createHash('sha256').update((keyUtils.parseKey(host.private) as utils.ParsedKey).getPublicSSH()).digest('base64').replace(/=+$/,'')
   const clients=new Set<Connection>(),files=new Map<string,Buffer>([['/approved/input.txt',Buffer.from('DUMMY-private-file-contents')]])
   const executed=Promise.withResolvers<void>(),written=Promise.withResolvers<void>()
   const password='DUMMY-ssh-login-password-only'

+ 17 - 0
tests/fixtures/workspace-server.ts

@@ -379,6 +379,23 @@ const auth = new LocalAuthStore(home, false)
 const grokAuth=process.env.WORKSPACE_FIXTURE_GROK_AUTH==='1'?new FixtureGrokAuthProvider():undefined
 const runtime = createApplication({ config, auth, grokFetch:grokAuth?.fetch, pluginFetch: pluginsFixture?.fetch }),
   node = createNodeServer(runtime)
+if(process.env.WORKSPACE_FIXTURE_VAULT==='1'){
+  // Test-only entry into real Bot tools, with disposable tasks and dummy credentials.
+  runtime.app.use(async(ctx,next)=>{
+    if(ctx.path!=='/__test/vault-use'||ctx.method!=='POST')return next()
+    const owner=auth.require(ctx).id,body=(ctx.request as any).body
+    const agent=runtime.workspace.require<any>(owner,'agent',body.agentId),workId=randomUUID(),runId=randomUUID()
+    const run={id:runId,status:'running',stopRequested:false},work={id:workId,agentId:agent.id,runId,status:'running',cancelRequested:false}
+    runtime.workspace.put(owner,'run',runId,run);runtime.workspace.put(owner,'turn',workId,work)
+    const controller=new AbortController(),tools=runtime.credentialVault.openTurn(owner,agent.id,workId,controller.signal,()=>{
+      if(!auth.canUseSource(owner,agent.nodeId,agent.profile))throw new Error('fixture Bot denied')
+    },waiting=>runtime.workspace.put(owner,'turn',workId,{...work,status:waiting?'waiting':'running'}))
+    try{
+      const refs=await tools.call('credential_refs',{})
+      ctx.body=body.discoverOnly?refs:{refs,result:await tools.call('credential_request',{credentialRef:body.credentialRef,operation:'ssh.exec'})}
+    }finally{tools.close();runtime.workspace.put(owner,'turn',workId,{...work,status:'complete'});runtime.workspace.put(owner,'run',runId,{...run,status:'complete'})}
+  })
+}
 const closeNative=process.env.WORKSPACE_FIXTURE_NATIVE==='1'?(await import('./native-environment.js')).nativeEnvironmentFixture(runtime,home,port):undefined
 if (process.env.WORKSPACE_FIXTURE_COMMUNICATION === '1') {
   const owner = JSON.parse(readFileSync(usersPath, 'utf8')).users[0].id as string

+ 1 - 1
tests/server/credentialVaultBotFlow.test.ts

@@ -121,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){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'])}
+    if(firstUse){if(persistent)await ui('/unlock',{password:master}).expect(200);const saved=(await ui('')).body.entries.find((e:any)=>e.id===credentialRef);if(persistent){expect(saved.target.hostKey).toBeUndefined();expect((await (client as LocalCredentialVaultClient).call(owner,'','bot-refs',{agentId:persistentBotId}))[0].allowedTarget.hostKey).toBe(ssh.target.hostKey);expect(saved.revision).toBe(1)}else{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()))

+ 53 - 5
tests/server/credentialVaultLocalClient.test.ts

@@ -109,11 +109,11 @@ it('keeps specified Bot approval across management lock and restart, without exp
     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(refs).toHaveLength(1);expect(refs[0]).toMatchObject({credentialRef:entry.id,agentId,authorized:true,allowedTarget: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}
+    const input={credentialRef:entry.id,agentId,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',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)
@@ -131,7 +131,7 @@ it('keeps specified Bot approval across management lock and restart, without exp
   }finally{await ssh.close()}
 },15000)
 
-it('management expiry preserves Bot approval while editing or deleting a credential revokes approval and in-flight use',async()=>{
+it('management expiry and editing preserve Bot approval while deletion aborts 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})
@@ -142,8 +142,8 @@ it('management expiry preserves Bot approval while editing or deleting a credent
     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')
+    expect(await f.call('bot-refs',{agentId})).toMatchObject([{name:'Updated',authorized:true}])
+    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'})
@@ -151,3 +151,51 @@ it('management expiry preserves Bot approval while editing or deleting a credent
     expect(await f.call('bot-refs',{agentId})).toEqual([])
   }finally{vi.restoreAllMocks();await ssh.close()}
 },15000)
+
+it('keeps two Bots authorized when the SSH port changes offline, uses the new port after restart, and retains a learned pin on later edits',async()=>{
+  let ssh=await sshFixture('password');const second=await sshFixture('password'),f=client(),owner='dummy-owner',bots=[randomUUID(),randomUUID()]
+  try{
+    await f.call('initialize',{password});await f.call('unlock',{password})
+    let 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'}})
+    for(const agentId of bots)await f.call('bot-grant',{credentialRef:entry.id,agentId})
+    await expect(f.call('update',{id:entry.id,entry:{name:'Conflict',username:ssh.username,target:entry.target,revision:99}})).rejects.toMatchObject({code:'vault_revision_conflict'})
+    expect(await f.call('bot-refs')).toHaveLength(2)
+    const newPort=second.target.port;await second.close()
+    // An editor sends the existing pin; changing the endpoint discards that old pin.
+    entry=await f.call('update',{id:entry.id,entry:{name:'New port',username:ssh.username,target:{...entry.target,port:newPort},revision:entry.revision}})
+    expect(entry.target.hostKey).toBeUndefined()
+    await f.call('lock');f.value.close();await Promise.resolve();const restarted=client(f.home)
+    const input=(agentId:string,target:any)=>({credentialRef:entry.id,agentId,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target,seconds:60})
+    expect((await restarted.call('status')).unlocked).toBe(false);expect((await restarted.call('status')).botGrants).toHaveLength(2)
+    // Server offline means connection failure, never an empty authorization list.
+    await expect(restarted.value.executeBot(owner,input(bots[0]!,entry.target),new AbortController().signal)).rejects.toMatchObject({code:'vault_host_probe_failed'})
+    expect(await restarted.call('bot-refs')).toHaveLength(2)
+    await ssh.close();ssh=await sshFixture('password',newPort)
+    for(const agentId of bots)expect(await restarted.value.executeBot(owner,input(agentId,entry.target),new AbortController().signal)).toMatchObject({status:'complete',exitCode:0})
+    expect(ssh.state.commands).toEqual(['/usr/bin/uptime','/usr/bin/uptime'])
+    await restarted.call('unlock',{password})
+    entry=await restarted.call('update',{id:entry.id,entry:{name:'Rename only',username:ssh.username,target:entry.target,revision:entry.revision}})
+    for(const agentId of bots)expect((await restarted.call('bot-refs',{agentId}))[0].allowedTarget.hostKey).toBe(ssh.target.hostKey)
+    await restarted.call('lock')
+    const port=ssh.target.port;await ssh.close();ssh=await sshFixture('password',port)
+    await expect(restarted.value.executeBot(owner,input(bots[0]!,entry.target),new AbortController().signal)).rejects.toMatchObject({code:'vault_host_key_rejected'})
+    expect(ssh.state.authMethods).toEqual([]);expect(await restarted.call('bot-refs')).toHaveLength(2)
+  }finally{await ssh.close()}
+},15000)
+
+it('updates cancel in-flight old operations without dropping approval for the next operation',async()=>{
+  const ssh=await sshFixture('password'),f=client(),agentId=randomUUID()
+  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'}})
+    await f.call('bot-grant',{credentialRef:entry.id,agentId});await f.call('lock');ssh.state.delayExec=true
+    const input={credentialRef:entry.id,agentId,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target:ssh.target,seconds:60}
+    const pending=f.value.executeBot('dummy-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('update',{id:entry.id,entry:{name:'New operation',username:ssh.username,target:ssh.target,revision:entry.revision,usage:{kind:'ssh.exec',command:'/usr/bin/true'}}})
+    await assertion;await f.call('lock');ssh.state.delayExec=false
+    expect(await f.value.executeBot('dummy-owner',input,new AbortController().signal)).toMatchObject({status:'complete',exitCode:0})
+    expect(ssh.state.commands).toEqual(['/usr/bin/uptime','/usr/bin/true'])
+    expect(await f.call('bot-refs',{agentId})).toMatchObject([{authorized:true,name:'New operation'}])
+  }finally{await ssh.close()}
+},15000)