Преглед на файлове

接入 Bot 密码库管理与短任务授权,打通 dummy 网站登录工具链

Samien преди 1 месец
родител
ревизия
771c72ce78

+ 16 - 0
licenses/libsodium.js/LICENSE

@@ -0,0 +1,16 @@
+Copyright (c) 2015-2026
+Ahmad Ben Mrad <batikhsouri at gmail dot org>
+Frank Denis <j at pureftpd dot org>
+Ryan Lester <ryan at cyph dot com>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

+ 135 - 0
src/client/components/app/CredentialVaultPanel.vue

@@ -0,0 +1,135 @@
+<script setup lang="ts">
+import { onMounted, onBeforeUnmount, ref } from 'vue'
+import { apiRequest } from '@/api/client'
+import type { JsonValue } from '@shared/types'
+import type { CredentialSummary, CredentialVaultStatus, CredentialOperation } from '@shared/credentialVault'
+
+interface View extends CredentialVaultStatus {
+  tasks: { workId: string; agentId: string; name: string }[]
+  requests: { workId: string; agentId: string; credentialRef: string; operation: CredentialOperation }[]
+}
+const root='/api/app/vault', state=ref<View>(), busy=ref(false), error=ref(''), notice=ref('')
+const master=ref(''), confirmation=ref(''), replacement=ref(''), secret=ref(''), passphrase=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')
+let timer:ReturnType<typeof setInterval>|undefined,alive=true
+const inflight=new Set<AbortController>()
+function clearSecrets(){master.value='';confirmation.value='';replacement.value='';secret.value='';passphrase.value=''}
+async function request<T>(path:string,method:'GET'|'POST'|'PUT'|'DELETE'='GET',body?:Record<string,JsonValue>){
+  const c=new AbortController();inflight.add(c)
+  try{return await apiRequest<T>(root+path,{method,...(body?{body}:{}),signal:c.signal,timeoutMs:20000})}
+  finally{inflight.delete(c)}
+}
+async function load(){const value=await request<View>('');if(!alive)return;const wasUnlocked=state.value?.unlocked;state.value=value;if(!value.online||(wasUnlocked&&!value.unlocked)){clearSecrets();showEditor.value=false}}
+async function action(work:()=>Promise<void>,message:string){
+  if(busy.value)return;busy.value=true;error.value='';notice.value=''
+  try{await work();if(alive){notice.value=message;await load()}}
+  catch{if(alive)error.value='操作未完成,请检查连接、主密码和本任务授权;秘密不会回显。'}
+  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 分钟;任务仍需要逐次授权')}
+async function lock(){clearSecrets();showEditor.value=false;await action(async()=>{await request('/lock','POST',{})},'密码库已锁定,任务授权已撤销')}
+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:''
+  port.value=entry?.target.kind==='ssh'?entry.target.port:22;hostKey.value=entry?.target.kind==='ssh'?entry.target.hostKey:''
+  showEditor.value=true
+}
+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:hostKey.value}
+  const body={name:name.value,username:username.value,target,...(secret.value?{secret:secret.value}:{}),...(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},'凭据已加密保存,密码与私钥不回显')
+}
+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 秒引用权限;正式登录执行器尚未启用')
+}
+function selectRequest(value:View['requests'][number]){taskId.value=value.workId;credentialId.value=value.credentialRef;operation.value=value.operation}
+async function backup(){
+  await action(async()=>{
+    const data=await request<{archive:string}>('/backup','POST',{});if(!alive)return
+    const url=URL.createObjectURL(new Blob([data.archive],{type:'application/json'})),a=document.createElement('a')
+    a.href=url;a.download='yaoyao-vault-encrypted-backup.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),0)
+  },'加密备份已导出;需要导出时的主密码恢复')
+}
+async function restore(event:Event){
+  const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return
+  if(file.size>2*1024*1024){error.value='备份文件超过大小限制';input.value='';return}
+  try{await action(async()=>{const archive=await file.text();if(!alive)return;await request('/restore','POST',{archive,password:master.value})},'备份已验证并恢复,仍保持锁定')}
+  finally{input.value=''}
+}
+async function rotate(){await action(async()=>{await request('/rotate','POST',{password:replacement.value})},'主密码与加密密钥已轮换,密码库已锁定;旧备份仍使用旧主密码')}
+function abandonAndLock(){
+  clearSecrets();showEditor.value=false
+  for(const c of inflight)c.abort();inflight.clear()
+  if(state.value?.unlocked||busy.value)void apiRequest(root+'/lock',{method:'POST',body:{},timeoutMs:5000}).catch(()=>{})
+}
+function hidden(){if(document.hidden)abandonAndLock()}
+onMounted(async()=>{
+  try{await load()}catch{error.value='无法读取执行节点密码库状态'}
+  timer=setInterval(()=>{if(alive&&!busy.value)void load().catch(()=>{state.value=undefined;clearSecrets()})},3000)
+  document.addEventListener('visibilitychange',hidden)
+})
+onBeforeUnmount(()=>{
+  alive=false;clearInterval(timer);document.removeEventListener('visibilitychange',hidden);abandonAndLock()
+  state.value=undefined
+})
+</script>
+
+<template>
+  <section class="credential-vault" :aria-busy="busy">
+    <p>秘密只在专用界面录入,加密保存在 Hermes 执行节点;不发送给 Bot。密码库默认锁定,重启或到期需重新手动解锁。</p>
+    <p role="status">正式网站与 SSH 登录尚未启用。隔离执行器验收前,Bot 只能请求授权并由你人工接管。</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>
+    <p v-if="!state?.online">执行节点密码库离线或尚未配置。需要管理员先批准独立服务身份和私有连接;此处不会自动安装或启动服务。</p>
+    <template v-else>
+      <p>{{state.unlocked?'已解锁':'已锁定'}}<span v-if="state.unlockExpiresAt"> · {{new Date(state.unlockExpiresAt).toLocaleTimeString()}} 到期</span></p>
+      <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>
+        <p>请妥善保存主密码;没有主密码找回功能。备份只能恢复到身份相同、尚未初始化的密码库。</p>
+        <button :disabled="busy">创建密码库</button>
+        <label>或恢复加密备份<input type="file" accept=".json,application/json" :disabled="busy||master.length<12" @change="restore" /></label>
+      </form>
+      <form v-else-if="!state.unlocked" @submit.prevent="unlock">
+        <label>主密码<input v-model="master" type="password" autocomplete="off" minlength="12" maxlength="1024" required :disabled="busy" /></label>
+        <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>
+        <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}}</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>
+          <label>用途<select v-model="kind" :disabled="busy||!!editing"><option value="website">HTTPS 网站</option><option value="ssh">SSH 密钥 / SFTP</option></select></label>
+          <label v-if="kind==='website'">准确网站 origin<input v-model="origin" type="url" placeholder="https://example.com" required :disabled="busy" /></label>
+          <template v-else><label>SSH 主机<input v-model="host" required :disabled="busy" /></label><label>端口<input v-model.number="port" type="number" min="1" max="65535" required :disabled="busy" /></label><label>已人工确认的 host key 指纹<input v-model="hostKey" placeholder="SHA256:…" required :disabled="busy" /></label></template>
+          <label>{{kind==='website'?'密码':'私钥'}}<input v-if="kind==='website'" v-model="secret" type="password" autocomplete="new-password" :required="!editing" maxlength="32768" :disabled="busy" /><textarea v-else v-model="secret" autocomplete="off" :required="!editing" maxlength="32768" :disabled="busy" /><small v-if="editing">留空保留原值;没有秘密读取或回显接口。</small></label>
+          <label v-if="kind==='ssh'">私钥口令(可选)<input v-model="passphrase" type="password" autocomplete="new-password" :disabled="busy" /></label>
+          <div class="actions"><button :disabled="busy">加密保存</button><button type="button" :disabled="busy" @click="showEditor=false;clearSecrets()">取消</button></div>
+        </form>
+        <form @submit.prevent="grant">
+          <h4>本次任务授权</h4><p>仅绑定一个 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}}
+            <button type="button" :disabled="busy||!state.entries.some(e=>e.id===pending.credentialRef)" @click="selectRequest(pending)">选择此请求,核对后授权</button>
+          </li></ul>
+          <label>凭据<select v-model="credentialId" required :disabled="busy"><option disabled value="">请选择</option><option v-for="e in state.entries" :key="e.id" :value="e.id">{{e.name}}</option></select></label>
+          <label>活动任务<select v-model="taskId" required :disabled="busy"><option disabled value="">请选择</option><option v-for="t in state.tasks" :key="t.workId" :value="t.workId">{{t.name}} · {{t.workId.slice(0,8)}}</option></select></label>
+          <label>操作<select v-model="operation" :disabled="busy"><option value="website.login">网站登录</option><option value="ssh.exec">SSH 执行</option><option value="sftp.read">SFTP 读取</option><option value="sftp.write">SFTP 写入</option></select></label>
+          <button :disabled="busy||!state.tasks.length||!state.entries.length">授予本任务 60 秒引用权限</button>
+        </form>
+        <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>
+    </template>
+  </section>
+</template>
+
+<style scoped>
+.credential-vault{display:grid;gap:16px}.credential-vault p{margin:0;color:var(--text-secondary);line-height:1.6}.credential-vault form{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:12px}.credential-vault label{display:grid;gap:6px}.credential-vault input,.credential-vault textarea,.credential-vault select{width:100%;box-sizing:border-box;min-height:40px;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary)}.credential-vault textarea{min-height:120px}.credential-vault button{min-height:40px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);cursor:pointer}.credential-vault button:disabled{opacity:.55;cursor:default}.credential-vault ul{list-style:none;margin:0;padding:0;display:grid;gap:12px}.credential-vault li{padding:12px;border:1px solid var(--line);border-radius:10px}.credential-vault code{overflow-wrap:anywhere}.credential-vault [role=alert]{color:var(--danger)}.actions{display:flex;flex-wrap:wrap;gap:8px}
+</style>

+ 5 - 0
src/client/components/app/SettingsCenterDialog.vue

@@ -2,6 +2,7 @@
 import { computed, nextTick, reactive, ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
 import ChatAppearancePanel from '@/components/app/ChatAppearancePanel.vue'
+import CredentialVaultPanel from '@/components/app/CredentialVaultPanel.vue'
 import AccountSecurityPanel from '@/components/app/AccountSecurityPanel.vue'
 import DesktopModePanel from '@/components/app/DesktopModePanel.vue'
 import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
@@ -22,6 +23,7 @@ type SettingsPage =
   | 'agent-models'
   | 'account-profile'
   | 'account-security'
+  | 'credential-vault'
   | 'account-mobile'
   | 'appearance'
   | 'chat-appearance'
@@ -106,6 +108,7 @@ const agentItems = computed<NavigationItem[]>(() => !props.isAdmin || props.botM
 const accountItems = computed<NavigationItem[]>(() => [
   { key: 'account-profile', label: '账号资料', icon: 'users' },
   { key: 'account-security', label: '登录与安全', icon: 'settings' },
+  ...(props.botMode ? [{ key:'credential-vault',label:'密码管理',icon:'settings' } satisfies NavigationItem] : []),
   ...(desktopModesAvailable ? [{ key: 'desktop-mode', label: '运行模式', icon: 'monitor' } satisfies NavigationItem] : []),
   { key: 'appearance', label: '外观', icon: 'sun' },
   ...(props.isAdmin ? [{ key: 'account-mobile', label: '手机登录', icon: 'panel' } satisfies NavigationItem] : []),
@@ -136,6 +139,7 @@ const activeTitle = computed(() => ({
   'agent-models': '模型与 Provider',
   'account-profile': '账号资料',
   'account-security': '登录与安全',
+  'credential-vault': '密码管理',
   'account-mobile': '手机登录',
   appearance: '外观',
   'chat-appearance': '聊天气泡',
@@ -358,6 +362,7 @@ function requestModeSwitch() {
                 />
                 <p v-else-if="activePage === 'agent-identity'" class="settings-empty">尚未选择机器人。</p>
                 <ModelServicesPanel v-else-if="activePage === 'agent-models' && activeProfile && isAdmin && !botMode" :key="activeProfile.name" :profile="activeProfile.name" @dirty-change="setDirty('agent-models', $event)" />
+                <CredentialVaultPanel v-else-if="activePage === 'credential-vault' && botMode" />
                 <AccountSecurityPanel
                   v-else-if="activePage === 'account-security' || activePage === 'account-profile'"
                   :key="activePage"

+ 1 - 0
src/client/components/app/WorkspaceShell.vue

@@ -27,6 +27,7 @@ type SettingsPage =
   | 'agent-models'
   | 'account-profile'
   | 'account-security'
+  | 'credential-vault'
   | 'account-mobile'
   | 'appearance'
   | 'system-overview'

+ 2 - 2
src/runner/agent.ts

@@ -67,8 +67,8 @@ export class RunnerAgent {
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const toolId=body&&typeof body==='object'&&'toolId' in body?body.toolId:undefined
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
-      method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:path==='tool'&&typeof toolId==='string'&&toolId.startsWith('managed_browser_')?600000:25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'execution-context-v1,vm-egress-v1,workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'execution-context-v1,workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1,managed-browser-retention-v1':',managed-browser-disabled-v1')+(this.browsers?.available?',managed-browser-v1':''),...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:path==='tool'&&typeof toolId==='string'&&(toolId.startsWith('managed_browser_')||toolId.startsWith('credential_'))?600000:25000)]),
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'execution-context-v1,vm-egress-v1,workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'execution-context-v1,workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1,managed-browser-retention-v1':',managed-browser-disabled-v1')+(this.browsers?.available?',managed-browser-v1':'')+',credential-ref-v1',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok){

+ 13 - 1
src/server/app.ts

@@ -1,4 +1,6 @@
 import {ExecutionSettings} from './executionSettings.js'
+import {CredentialVaultCoordinator} from './credentialVault/coordinator.js'
+import {CredentialVaultClient} from './credentialVault/transport.js'
 import {ManagedBrowsers} from './managedBrowsers.js'
 import { isWorkspaceAccountPath } from './subaccountAccess.js'
 import { chatTranscriptRouter } from './chatTranscriptApi.js'
@@ -98,6 +100,7 @@ export interface ApplicationOptions {
 }
 
 export interface ApplicationRuntime {
+  credentialVault: CredentialVaultCoordinator
   hermesBridge: HermesBridgeManager
   runners: RunnerHub
   workspace: WorkspaceStore
@@ -259,8 +262,10 @@ export function createApplication(options: ApplicationOptions = {}): Application
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   const executionSettings=new ExecutionSettings(workspace,workspaceNodes,auth)
+  const credentialVault=new CredentialVaultCoordinator(new CredentialVaultClient(config.credentialVault),workspace,auth,runners)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0, openVikingService)
   workspaceRuntime.executionSettings=executionSettings
+  workspaceRuntime.credentialVault=credentialVault
   runners.executionSettings=executionSettings
   executionSettings.onProxyChange=()=>runners.configureNetworks()
   executionSettings.testProxy=()=>runners.testNetworks()
@@ -491,7 +496,12 @@ export function createApplication(options: ApplicationOptions = {}): Application
     } else await next()
   })
 
-  for(const router of [executionSettings.router(),managedBrowsers.router(),desktopEnvironments.router(),grokCloud.authorization.router(),grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router(),workspacePlugins.router()]){app.use(router.routes());app.use(router.allowedMethods())}
+  app.use(async(ctx,next)=>{
+    const user=ctx.state.localUser as {id:string}|undefined,cookie=ctx.get('cookie')
+    if(['/api/app/logout','/auth/logout'].includes(ctx.path)&&ctx.method==='POST'&&user)await credentialVault.logout(user.id,cookie)
+    await next()
+  })
+  for(const router of [credentialVault.router(),executionSettings.router(),managedBrowsers.router(),desktopEnvironments.router(),grokCloud.authorization.router(),grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router(),workspacePlugins.router()]){app.use(router.routes());app.use(router.allowedMethods())}
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
   const localVmRouter=localVm.router();app.use(localVmRouter.routes());app.use(localVmRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
@@ -560,6 +570,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
 
   return {
+    credentialVault,
     runners,
     hermesBridge,
     localVm,
@@ -594,6 +605,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     chatCache,
     startBackground,
     close: () => {
+      credentialVault.close()
       closed = true
       openVikingSessionSync.close()
       workspaceMemory.close()

+ 4 - 0
src/server/config.ts

@@ -31,6 +31,7 @@ export interface FCMProviderConfig {
 }
 
 export interface ServerConfig {
+  credentialVault?: { socket: string; tokenFile: string; hermesUid: number }
   host: string
   port: number
   upstream: URL
@@ -211,6 +212,9 @@ export function loadServerConfig(
     attachmentsRoot,
     imagesRoot,
     mediaOwner: basename(homedir()),
+    ...(env.HERMES_YAOYAO_VAULT_SOCKET || env.HERMES_YAOYAO_VAULT_TOKEN_FILE ? { credentialVault: {
+      socket: env.HERMES_YAOYAO_VAULT_SOCKET || '', tokenFile: env.HERMES_YAOYAO_VAULT_TOKEN_FILE || '', hermesUid: Number(env.HERMES_YAOYAO_VAULT_HERMES_UID),
+    } } : {}),
     tlsCert,
     tlsKey,
     allowInsecureLan,

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

@@ -0,0 +1,146 @@
+import { randomUUID } from 'node:crypto'
+import { constants, openSync, closeSync, fsyncSync, fstatSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { z } from 'zod'
+import { HttpError } from '../errors.js'
+import { CredentialVaultStore } from './store.js'
+import { parse, leaseInput, type LeaseInput, type VaultEntry } from './schema.js'
+import type { CredentialLeaseSummary, CredentialVaultStatus } from '../../shared/credentialVault.js'
+interface Lease extends CredentialLeaseSummary {
+  owner: string; session: string; version: number; epoch: number; runnerInstance: string; runnerEpoch: string
+  target: LeaseInput['target']; consumed: boolean; controller: AbortController
+}
+/** Intentionally no production adapters. Only tests inject this interface, with
+ * dummy values. CLI configuration cannot enable an executor or claim isolation. */
+export interface CredentialExecutorFixture {
+  execute(entry: Readonly<VaultEntry>, input: LeaseInput, signal: AbortSignal): Promise<void>
+}
+export class CredentialVaultBroker {
+  private sessions = new Map<string, string>()
+  private epochs = new Map<string, number>()
+  private leases = new Map<string, Lease>()
+  private controlEpoch = ''
+  private timer: ReturnType<typeof setInterval>
+  constructor(readonly store: CredentialVaultStore, private now = Date.now, private fixture?: CredentialExecutorFixture) {
+    store.onLock = owner => this.revokeOwner(owner)
+    this.timer = setInterval(() => {
+      for (const owner of this.sessions.keys()) {
+        try { store.state(owner) }
+        catch { store.lock(owner); this.sessions.delete(owner); this.revokeOwner(owner) }
+      }
+      for (const [id, lease] of this.leases) if (lease.expiresAt <= this.now()) this.revoke(id)
+    }, 1000); this.timer.unref()
+  }
+  private audit(owner: string, action: string, ref?: string, outcome = 'ok') {
+    // No request body, username, target, secret, transport error or task output.
+    const row = { at: this.now(), owner, action, ...(ref ? { ref } : {}), outcome }
+    const path = join(this.store.root, 'audit.jsonl')
+    let fd: number | undefined
+    try {
+      fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600)
+      const stat = fstatSync(fd)
+      if (!stat.isFile() || (stat.mode & 0o077) || stat.uid !== process.getuid?.()) throw new Error('密码库审计文件权限不安全')
+      writeFileSync(fd, JSON.stringify(row) + '\n'); fsyncSync(fd)
+    } catch {
+      this.store.lock(owner); this.sessions.delete(owner); this.revokeOwner(owner)
+      throw new HttpError(503, '密码库审计不可用,已锁定并撤销授权', 'vault_audit_unavailable')
+    } finally { if (fd !== undefined) closeSync(fd) }
+  }
+  hello(epoch: string) {
+    parse(z.string().uuid(), epoch)
+    if (epoch !== this.controlEpoch) { this.store.lockAll(); this.revokeAll(); this.sessions.clear(); this.controlEpoch = epoch }
+    return { protocol: 1, execution: this.fixture ? 'dummy-fixture' : 'disabled' }
+  }
+  private authorize(owner: string, session: string) {
+    if (this.sessions.get(owner) !== session || !this.store.state(owner).unlocked)
+      throw new HttpError(423, '请在本次登录的密码管理界面手动解锁', 'vault_locked')
+  }
+  status(owner: string, session: string): CredentialVaultStatus {
+    const state = this.store.state(owner), own = this.sessions.get(owner) === session && state.unlocked
+    return { protocol: 1, online: true, initialized: state.initialized, unlocked: own,
+      ...(own ? { unlockExpiresAt: state.unlockExpiresAt } : {}), execution: this.fixture ? 'dummy-fixture' : 'disabled', reason: this.fixture ? 'dummy_fixture_only' : 'executor_not_enabled',
+      entries: own ? this.store.list(owner) : [], leases: own ? [...this.leases.values()].filter(l => l.owner === owner).map(({ id, credentialRef, agentId, workId, runnerId, operation, expiresAt }) =>
+        ({ id, credentialRef, agentId, workId, runnerId, operation, expiresAt })) : [] }
+  }
+  revokeOwner(owner: string) {
+    this.epochs.set(owner, (this.epochs.get(owner) ?? 0) + 1)
+    for (const [id, l] of this.leases) if (l.owner === owner) this.revoke(id)
+  }
+  private revokeAll() { for (const id of [...this.leases.keys()]) this.revoke(id) }
+  private revoke(id: string) { const l = this.leases.get(id); this.leases.delete(id); l?.controller.abort() }
+  disconnect() { this.store.lockAll(); this.revokeAll(); this.sessions.clear() }
+  close() { clearInterval(this.timer); this.disconnect() }
+  dispatch(epoch: string, owner: string, session: string, command: string, value: unknown): unknown {
+    parse(z.string().min(1).max(256), owner); parse(z.string().regex(/^[a-f0-9]{64}$/), session)
+    if (epoch !== this.controlEpoch) throw new HttpError(410, '控制连接已更换,旧授权失效', 'vault_control_changed')
+    if (command === 'status') return this.status(owner, session)
+    if (command === 'initialize') { const body = parse(z.object({ password: z.string() }).strict(), value); this.store.initialize(owner, body.password); this.audit(owner, 'initialize'); return this.status(owner, session) }
+    if (command === 'restore') {
+      const b = parse(z.object({ archive: z.string(), password: z.string() }).strict(), value)
+      this.store.restore(owner, b.archive, b.password); this.audit(owner, 'restore'); return this.status(owner, session)
+    }
+    if (command === 'unlock') {
+      const b = parse(z.object({ password: z.string(), seconds: z.number().int().min(30).max(900).default(300) }).strict(), value)
+      try { this.store.unlock(owner, b.password, b.seconds); this.sessions.set(owner, session); this.audit(owner, 'unlock') }
+      catch (e) { this.audit(owner, 'unlock', undefined, 'denied'); throw e }
+      return this.status(owner, session)
+    }
+    if (command === 'logout') { if (this.sessions.get(owner) === session) { this.store.lock(owner); this.sessions.delete(owner); this.audit(owner, 'logout') }; return { ok: true } }
+    if (command === 'lock') { this.store.lock(owner); this.sessions.delete(owner); this.revokeOwner(owner); this.audit(owner, 'lock'); return { ok: true } }
+    if (command === 'revoke-work') {
+      const b = parse(z.object({ workId: z.string().uuid() }).strict(), value)
+      for (const [id, l] of this.leases) if (l.owner === owner && l.workId === b.workId) this.revoke(id)
+      this.audit(owner, 'revoke-work', b.workId); return { ok: true }
+    }
+    this.authorize(owner, session)
+    if (command === 'add') { const e = this.store.add(owner, value); this.audit(owner, 'add', e.id); return e }
+    if (command === 'update') { const b = parse(z.object({ id: z.string().uuid(), entry: z.unknown() }).strict(), value); const e = this.store.update(owner, b.id, b.entry); this.audit(owner, 'update', e.id); return e }
+    if (command === 'remove') { const b = parse(z.object({ id: z.string().uuid() }).strict(), value); this.store.remove(owner, b.id); this.audit(owner, 'remove', b.id); return { ok: true } }
+    if (command === 'backup') { this.audit(owner, 'backup'); return { archive: this.store.backup(owner) } }
+    if (command === 'rotate') { const b = parse(z.object({ password: z.string() }).strict(), value); this.store.rotate(owner, b.password); this.audit(owner, 'rotate'); return { ok: true } }
+    if (command === 'grant') {
+      const b = parse(leaseInput, value)
+      const entry = this.store.list(owner).find(e => e.id === b.credentialRef)
+      if (!entry || JSON.stringify(entry.target) !== JSON.stringify(b.target) || (entry.target.kind === 'website') !== (b.operation === 'website.login'))
+        throw new HttpError(403, '凭据目标或操作不匹配', 'vault_target_forbidden')
+      if ([...this.leases.values()].filter(l => l.owner === owner).length >= 64) throw new HttpError(429, '任务授权数量已达到上限', 'vault_lease_limit')
+      const { seconds: _seconds, target: _target, runnerInstance: _instance, runnerEpoch: _epoch, ...publicInput } = b
+      const result: CredentialLeaseSummary = { ...publicInput, id: randomUUID(), expiresAt: Math.min(this.now() + b.seconds * 1000, this.store.state(owner).unlockExpiresAt!) }
+      this.leases.set(result.id, { ...result, owner, session, version: entry.revision, target: entry.target, runnerInstance: b.runnerInstance,
+        runnerEpoch: b.runnerEpoch, epoch: this.epochs.get(owner) ?? 0, consumed: false, controller: new AbortController() })
+      this.audit(owner, 'grant', result.id); return result
+    }
+    if (command === 'revoke') {
+      const b = parse(z.object({ id: z.string().uuid() }).strict(), value)
+      if (this.leases.get(b.id)?.owner !== owner) throw new HttpError(404, '授权不存在', 'vault_lease_missing')
+      this.revoke(b.id); this.audit(owner, 'revoke', b.id); return { ok: true }
+    }
+    throw new HttpError(404, '密码库操作不存在', 'vault_command_unknown')
+  }
+  async execute(epoch: string, owner: string, session: string, id: string, input: LeaseInput) {
+    const b = parse(leaseInput, input), l = this.leases.get(id)
+    const check = () => {
+      if (epoch !== this.controlEpoch || !l || this.leases.get(id) !== l || l.owner !== owner || l.epoch !== (this.epochs.get(owner) ?? 0)
+        || l.expiresAt <= this.now() || l.controller.signal.aborted) throw new HttpError(410, '凭据任务授权已失效', 'vault_lease_revoked')
+      this.authorize(owner, l.session)
+      if (session !== l.session) throw new HttpError(403, '执行请求不属于解锁会话', 'vault_scope_forbidden')
+      if (['agentId', 'workId', 'runnerId', 'runnerInstance', 'runnerEpoch', 'credentialRef', 'operation'].some(k => l[k as keyof Lease] !== b[k as keyof LeaseInput])
+        || JSON.stringify(l.target) !== JSON.stringify(b.target)) throw new HttpError(403, '凭据任务范围不匹配', 'vault_scope_forbidden')
+      const entry = this.store.list(owner).find(e => e.id === l.credentialRef)
+      if (entry?.revision !== l.version) throw new HttpError(410, '凭据版本已变化', 'vault_lease_revoked')
+    }
+    check()
+    if (!this.fixture) throw new HttpError(409, '受保护的浏览器与 SSH 执行器尚未启用,请人工接管', 'vault_executor_not_enabled')
+    if (l!.consumed) throw new HttpError(409, '此授权已提交,不能重复执行', 'vault_operation_uncertain')
+    l!.consumed = true
+    this.audit(owner, 'execute-started', id)
+    try {
+      await this.store.withSecret(owner, l!.credentialRef, entry => this.fixture!.execute(entry, b, l!.controller.signal))
+      check(); this.audit(owner, 'execute-complete', id)
+      return { status: 'complete', operation: b.operation } // No executor output, URL, exception or secret.
+    } catch {
+      this.audit(owner, 'execute-ended', id, 'not-confirmed')
+      throw new HttpError(409, '操作结果未确认,授权不再允许重试,请人工核对', 'vault_operation_uncertain')
+    } finally { this.revoke(id) }
+  }
+}

+ 205 - 0
src/server/credentialVault/coordinator.ts

@@ -0,0 +1,205 @@
+import Router from '@koa/router'
+import type Koa from 'koa'
+import { randomUUID } from 'node:crypto'
+import { setTimeout as delay } from 'node:timers/promises'
+import { z } from 'zod'
+import { HttpError } from '../errors.js'
+import type { WorkspaceStore } from '../workspaceStore.js'
+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 } from '../../shared/credentialVault.js'
+import { CredentialVaultClient } from './transport.js'
+import { parse, leaseInput } from './schema.js'
+
+interface Session { owner: string; key: string; cookie: string; version: number | undefined }
+interface Grant { summary: CredentialLeaseSummary; target: CredentialTarget; 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/任务的凭据引用与允许目标,不包含账号密码或私钥。', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
+  { id: 'credential_request', name: 'credential_request', description: '请求当前任务的凭据使用授权。锁定/离线时等待用户在密码管理界面操作;当前正式网站/SSH executor 未启用,只能人工接管。不得向用户索取或通过其他工具读取密码,不得降级 fill/shell。', inputSchema: toolSchema },
+]
+/** The Web control plane never receives a read-secret response. Deployment must
+ * keep this process/IPC identity separate from Hermes' arbitrary shell identity. */
+export class CredentialVaultCoordinator {
+  private sessions = new Map<string, Session>()
+  private grants = new Map<string, Grant>()
+  private pending = new Map<string, { owner: string; agentId: string; workId: string; credentialRef: string; operation: string }>()
+  private generations = new Map<string, number>()
+  private logoutGenerations = new Map<string, number>()
+  private connectionGeneration = 0
+  private closed = false
+  private turnControllers = new Set<AbortController>()
+  private timer: ReturnType<typeof setInterval>
+  constructor(readonly client: CredentialVaultClient, private store: WorkspaceStore, private auth: LocalAuthStore, private hub: RunnerHub) {
+    client.onDisconnect = () => { this.connectionGeneration++; this.grants.clear(); this.sessions.clear() }
+    this.timer = setInterval(() => {
+      for (const s of [...this.sessions.values()]) if (!this.valid(s)) void this.endSession(s)
+      for (const [id, g] of this.grants) {
+        try { this.task(g.session.owner, g.summary.agentId, g.summary.workId); const current = this.binding(g.session.owner, g.summary.agentId); if (JSON.stringify(current) !== JSON.stringify(g.binding) || g.summary.expiresAt <= Date.now()) throw new Error() }
+        catch { this.grants.delete(id); void this.client.call(g.session.owner, g.session.key, 'revoke', { id }).catch(() => {}) }
+      }
+    }, 1000); this.timer.unref()
+  }
+  private valid(s: Session) { return this.auth.currentFromCookieHeader(s.cookie)?.id === s.owner && this.auth.isUserActive(s.owner) && this.auth.pushAuthorizationVersion(s.owner) === s.version }
+  private scope(ctx: Koa.Context) {
+    if (this.closed) throw new HttpError(503, '密码库连接已关闭', 'vault_offline')
+    const user = this.auth.require(ctx), cookie = ctx.get('cookie')
+    if (this.auth.currentFromCookieHeader(cookie)?.id !== user.id) throw new HttpError(401, '当前会话已失效', 'vault_session_revoked')
+    const key = this.auth.sessionBinding(cookie)
+    if (!key) throw new HttpError(401, '当前会话已失效', 'vault_session_revoked')
+    const s: Session = { owner: user.id, key, cookie, version: this.auth.pushAuthorizationVersion(user.id) }
+    // Secrets may not traverse explicitly enabled insecure LAN HTTP.
+    const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(ctx.req.socket.remoteAddress ?? '')
+    if (!ctx.secure && !local) throw new HttpError(403, '密码库管理需要 HTTPS 或本机回环连接', 'vault_https_required')
+    ctx.set('Cache-Control', 'no-store'); return s
+  }
+  private async call(s: Session, command: string, value?: unknown) {
+    if (!this.valid(s)) throw new HttpError(401, '当前会话已失效', 'vault_session_revoked')
+    const generation=this.generations.get(s.owner)??0,logout=this.logoutGenerations.get(s.owner+':'+s.key)??0,connection=this.connectionGeneration
+    const result = await this.client.call(s.owner, s.key, command, value)
+    if (!this.valid(s)||generation!==(this.generations.get(s.owner)??0)||logout!==(this.logoutGenerations.get(s.owner+':'+s.key)??0)||connection!==this.connectionGeneration) {
+      await this.endSession(s); throw new HttpError(401, '当前会话已失效', 'vault_session_revoked')
+    }
+    return result
+  }
+  async endSession(s: Session) {
+    if (this.sessions.get(s.owner)?.key === s.key) this.sessions.delete(s.owner)
+    for (const [id, g] of this.grants) if (g.session.key === s.key) this.grants.delete(id)
+    await this.client.call(s.owner, s.key, 'logout').catch(() => {})
+  }
+  async logout(owner: string, cookie: string) {
+    const binding=this.auth.sessionBinding(cookie);if(!binding)return
+    const key=owner+':'+binding;this.logoutGenerations.set(key,(this.logoutGenerations.get(key)??0)+1)
+    const s = this.sessions.get(owner)
+    if (s?.key === binding) await this.endSession(s)
+    else await this.client.call(owner, binding, 'logout').catch(() => {})
+  }
+  private task(owner: string, agentId: string, workId: string) {
+    const agent = this.store.require<WorkspaceAgent>(owner, 'agent', agentId), work = this.store.require<Work>(owner, 'turn', workId)
+    const run = this.store.require<WorkspaceRun>(owner, 'run', work.runId)
+    if (!this.auth.isUserActive(owner) || agent.archived || work.agentId !== agent.id || !['running', 'waiting'].includes(work.status) || work.cancelRequested || run.stopRequested
+      || !this.auth.canUseSource(owner, agent.nodeId, agent.profile)) throw new HttpError(410, 'Bot 任务授权已结束', 'vault_task_revoked')
+    return { agent, work }
+  }
+  private binding(owner: string, agentId: string) { return this.hub.credentialBinding(owner, this.store.require<WorkspaceAgent>(owner, 'agent', agentId)) }
+  private tasks(owner: string) {
+    return this.store.list<Work>(owner, 'turn').flatMap(w => {
+      try { const { agent } = this.task(owner, w.agentId, w.id); return [{ workId: w.id, agentId: agent.id, name: agent.name }] } catch { return [] }
+    })
+  }
+  private offline(): CredentialVaultStatus { return { protocol: 1, online: false, initialized: false, unlocked: false, execution: 'disabled', reason: 'vault_offline', entries: [], leases: [] } }
+  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) }
+  }
+  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 ['initialize', 'unlock', 'lock', 'rotate', 'backup', 'restore'] as const)
+      r.post(root + '/' + command, async ctx => {
+        const s = this.scope(ctx)
+        if(['unlock','lock','rotate'].includes(command))this.generations.set(s.owner,(this.generations.get(s.owner)??0)+1)
+        if (['unlock','lock','rotate'].includes(command)) { this.grants.forEach((g, id) => { if (g.session.owner === s.owner) this.grants.delete(id) }) }
+        const result = await this.call(s, command, (ctx.request as any).body ?? {})
+        if (command === 'unlock') this.sessions.set(s.owner, s)
+        if (command === 'lock' || command === 'rotate') this.sessions.delete(s.owner)
+        ctx.body = result
+      })
+    r.post(root + '/entries', async ctx => { ctx.body = await this.call(this.scope(ctx), 'add', (ctx.request as any).body) })
+    r.put(root + '/entries/:id', async ctx => {
+      const s = this.scope(ctx); this.grants.forEach((g, id) => { if (g.summary.credentialRef === ctx.params.id && g.session.owner === s.owner) this.grants.delete(id) })
+      ctx.body = await this.call(s, 'update', { id: ctx.params.id, entry: (ctx.request as any).body })
+    })
+    r.delete(root + '/entries/:id', async ctx => {
+      const s = this.scope(ctx); this.grants.forEach((g, id) => { if (g.summary.credentialRef === ctx.params.id && g.session.owner === s.owner) this.grants.delete(id) })
+      ctx.body = await this.call(s, 'remove', { id: ctx.params.id })
+    })
+    r.post(root + '/leases', async ctx => {
+      const s = this.scope(ctx), body = parse(z.object({ credentialRef: z.string().uuid(), agentId: z.string().uuid(), workId: z.string().uuid(),
+        operation: leaseInput.shape.operation, seconds: leaseInput.shape.seconds }).strict(), (ctx.request as any).body)
+      this.task(s.owner, body.agentId, body.workId)
+      const binding = this.binding(s.owner, body.agentId), state: CredentialVaultStatus = await this.call(s, 'status'), entry = state.entries.find(e => e.id === body.credentialRef)
+      if (!entry) throw new HttpError(403, '请解锁并选择当前账号的凭据', 'vault_credential_forbidden')
+      const summary = await this.call(s, 'grant', { ...body, ...binding, target: entry.target }) as CredentialLeaseSummary
+      try {
+        this.task(s.owner, body.agentId, body.workId)
+        if (JSON.stringify(this.binding(s.owner, body.agentId)) !== JSON.stringify(binding)) throw new Error()
+        this.grants.set(summary.id, { summary, target:entry.target, session: s, binding }); ctx.body = summary
+      } catch {
+        await this.client.call(s.owner, s.key, 'revoke', { id: summary.id }).catch(() => {})
+        throw new HttpError(410, '任务或执行节点已变化,授权未保留', 'vault_task_revoked')
+      }
+    })
+    r.delete(root + '/leases/:id', async ctx => {
+      const s = this.scope(ctx); this.grants.delete(ctx.params.id); ctx.body = await this.call(s, 'revoke', { id: ctx.params.id })
+    })
+    return r
+  }
+  openTurn(owner: string, agentId: string, workId: string, externalSignal: AbortSignal, authorize: () => void, waiting: (value: boolean) => void) {
+    const controller=new AbortController(),signal=AbortSignal.any([externalSignal,controller.signal]),pendingIds=new Set<string>()
+    let waitingCalls=0
+    this.turnControllers.add(controller)
+    const check = () => { signal.throwIfAborted(); if (this.closed) throw new HttpError(503, '密码库连接已关闭', 'vault_offline'); authorize(); this.task(owner, agentId, workId) }
+    const abort = () => {
+      this.turnControllers.delete(controller)
+      for(const id of pendingIds)this.pending.delete(id)
+      for (const [key, g] of this.grants) if (g.summary.workId === workId && g.session.owner === owner) this.grants.delete(key)
+      const s = this.sessions.get(owner); if (s) void this.client.call(owner, s.key, 'revoke-work', { workId }).catch(() => {})
+    }
+    signal.addEventListener('abort', abort, { once: true })
+    const refs = async () => {
+      const binding = this.binding(owner, agentId), entries = []
+      for (const g of this.grants.values()) {
+        if (g.session.owner !== owner || g.summary.agentId !== agentId || g.summary.workId !== workId || g.summary.expiresAt <= Date.now()
+          || !this.valid(g.session) || JSON.stringify(g.binding) !== JSON.stringify(binding)) continue
+        const state: CredentialVaultStatus = await this.call(g.session, 'status'); check()
+        if (state.unlocked && state.leases.some(l => l.id === g.summary.id)) entries.push({ credentialRef:g.summary.credentialRef, operation:g.summary.operation, allowedTarget:g.target })
+      }
+      return entries
+    }
+    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_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 })
+        if(++waitingCalls===1)waiting(true)
+        try {
+          let initialBinding: ReturnType<RunnerHub['credentialBinding']> | undefined
+          // Bounded waiting is a state, never permission to execute or replay.
+          const end = Date.now() + 120000
+          while (Date.now() < end) {
+            check()
+            try {
+              const binding = this.binding(owner, agentId)
+              if (initialBinding && JSON.stringify(binding) !== JSON.stringify(initialBinding)) throw new HttpError(410, '节点重连后需要新的任务授权', 'vault_connection_revoked')
+              initialBinding ??= binding
+              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')
+                const state: CredentialVaultStatus = await this.call(g.session, 'status'); check()
+                if (state.unlocked && state.leases.some(l => l.id === g.summary.id)) {
+                  if(JSON.stringify(this.binding(owner,agentId))!==JSON.stringify(binding))throw new HttpError(410,'节点重连后需要新授权','vault_connection_revoked')
+                  if (state.execution === 'dummy-fixture') {
+                    try {
+                      const result = await this.call(g.session,'execute',{id:g.summary.id,input:{...b,agentId,workId,...binding,target:g.target,seconds:60}})
+                      check();return result
+                    } finally { this.grants.delete(g.summary.id) }
+                  }
+                  return { status: 'manual_takeover_required', reason: 'vault_executor_not_enabled', message: '正式网站/SSH 受保护执行器尚未启用;请人工接管,不能使用通用 fill 或 shell 替代。' }
+                }
+              }
+            } 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: '仍需用户连接、解锁和本任务授权;未执行登录。' }
+        } finally { this.pending.delete(id);pendingIds.delete(id);if(--waitingCalls===0)waiting(false) }
+      },
+    }
+  }
+  close() { this.closed=true; clearInterval(this.timer); for(const c of this.turnControllers)c.abort();this.turnControllers.clear();this.grants.clear(); this.pending.clear(); for (const s of this.sessions.values()) void this.endSession(s); this.sessions.clear() }
+}

+ 40 - 0
src/server/credentialVault/fixtureWebsiteExecutor.ts

@@ -0,0 +1,40 @@
+/** A working, deliberately local-only HTTPS login adapter for dummy integration
+ * fixtures. Not wired into the production CLI and not a generic browser login.
+ * It never uses global fetch/proxies, redirects, shell, CDP or model form input. */
+import { request } from 'node:https'
+import type { CredentialExecutorFixture } from './broker.js'
+import type { VaultEntry, LeaseInput } from './schema.js'
+export class FixtureWebsiteLoginExecutor implements CredentialExecutorFixture {
+  private origin: string
+  constructor(origin: string, private ca: Buffer) {
+    const u=new URL(origin)
+    if(u.protocol!=='https:'||u.hostname!=='127.0.0.1'||u.username||u.password||u.pathname!=='/'||u.search||u.hash||!u.port)throw new Error('dummy executor requires exact loopback HTTPS origin')
+    this.origin=u.origin
+  }
+  private send(path:string,signal:AbortSignal,body?:string,cookie?:string):Promise<{status:number;cookies:string[];body:string}>{
+    return new Promise((resolve,reject)=>{
+      signal.throwIfAborted()
+      const req=request(new URL(path,this.origin),{method:body?'POST':'GET',ca:this.ca,rejectUnauthorized:true,signal,
+        agent:false,headers:{...(body?{'Content-Type':'application/x-www-form-urlencoded','Content-Length':Buffer.byteLength(body)}:{}),...(cookie?{Cookie:cookie}:{})}},res=>{
+        let size=0;const chunks:Buffer[]=[]
+        res.on('data',chunk=>{size+=chunk.length;if(size>8192)req.destroy(new Error('dummy response limit'));else chunks.push(Buffer.from(chunk))})
+        res.on('error',reject);res.on('aborted',()=>reject(new Error('dummy response interrupted')))
+        res.on('end',()=>resolve({status:res.statusCode??0,cookies:res.headers['set-cookie']??[],body:Buffer.concat(chunks).toString()}))
+      })
+      req.setTimeout(3000,()=>req.destroy(new Error('dummy login timeout')));req.on('error',reject);req.end(body)
+    })
+  }
+  async execute(entry:Readonly<VaultEntry>,input:LeaseInput,signal:AbortSignal){
+    if(input.operation!=='website.login'||entry.target.kind!=='website'||input.target.kind!=='website'||entry.target.origin!==this.origin||input.target.origin!==this.origin)
+      throw new Error('dummy login target forbidden')
+    const form=new URLSearchParams({username:entry.username,password:entry.secret}).toString()
+    const login=await this.send('/login',signal,form)
+    if(login.status!==200)throw new Error('dummy login rejected') // Never follow redirects.
+    const cookie=login.cookies.map(v=>v.split(';')[0]!).find(v=>/^fixture_auth=[a-f0-9]{64}$/.test(v))
+    if(!cookie)throw new Error('dummy authentication receipt missing')
+    signal.throwIfAborted()
+    const proof=await this.send('/protected',signal,undefined,cookie)
+    if(proof.status!==200||proof.body!=='dummy-authenticated')throw new Error('dummy authenticated session unverified')
+    signal.throwIfAborted() // No cookies, body, username or password returned to the Bot.
+  }
+}

+ 34 - 0
src/server/credentialVault/index.ts

@@ -0,0 +1,34 @@
+/** Operator-started foreground service. Never started by Web/Runner or the model.
+ * No setup of OS users, permissions, service manager or network rules is performed. */
+import { chmodSync } from 'node:fs'
+import { isAbsolute } from 'node:path'
+import { z } from 'zod'
+import { parse } from './schema.js'
+import { CredentialVaultStore } from './store.js'
+import { CredentialVaultBroker } from './broker.js'
+import { serveCredentialVault } from './transport.js'
+import { acquireServiceInstance } from '../serviceInstance.js'
+import { readPrivateUtf8 } from './privateFiles.js'
+try {
+  const index = process.argv.indexOf('--config'), path = index < 0 ? undefined : process.argv[index + 1]
+  if (!path) throw new Error('用法:node credentialVault/index.js --config /私有路径/vault.json')
+  const config = parse(z.object({ home: z.string().refine(isAbsolute), socket: z.string().refine(isAbsolute), tokenFile: z.string().refine(isAbsolute),
+    hermesUid: z.number().int().positive() }).strict(), JSON.parse(readPrivateUtf8(path,8192)))
+  if (!process.getuid || process.getuid() === 0 || process.getuid() === config.hermesUid) throw new Error('密码库需要独立非 root OS 身份,不能与 Hermes 同用户运行')
+  const controlToken = readPrivateUtf8(config.tokenFile,4096).trim()
+  const store = await CredentialVaultStore.create(config.home)
+  process.umask(0o077)
+  const instance = acquireServiceInstance(store.root, 'credential-vault-v1')
+  const broker = new CredentialVaultBroker(store) // No production executor, no enable-isolation flag.
+  const server = serveCredentialVault(broker, controlToken)
+  process.once('exit', () => { broker.close(); instance.release() })
+  server.listen(config.socket, () => {
+    try { chmodSync(config.socket, 0o600); process.stdout.write('密码库私有服务已启动,默认锁定,真实登录执行未启用\n') }
+    catch { broker.close(); server.close(); process.exitCode = 1 }
+  })
+  const stop = () => { broker.close(); server.close(() => { instance.release() }); server.closeAllConnections() }
+  server.once('error', () => { stop(); process.exitCode = 1; process.stderr.write('密码库私有服务无法监听,请检查路径或已有实例\n') })
+  process.once('SIGTERM', stop); process.once('SIGINT', stop)
+} catch {
+  process.stderr.write('密码库启动失败,请检查私有配置和独立 OS 身份;不会自动修改系统设置\n'); process.exitCode = 1
+}

+ 12 - 0
src/server/credentialVault/privateFiles.ts

@@ -0,0 +1,12 @@
+import { constants, openSync, fstatSync, readFileSync, closeSync } from 'node:fs'
+/** Open first without following a final symlink, then check the actual inode. */
+export function readPrivateUtf8(path:string,maxBytes:number){
+  const fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW)
+  try{
+    const s=fstatSync(fd)
+    if(!s.isFile()||(s.mode&0o077)||s.uid!==process.getuid?.()||s.size>maxBytes)throw new Error('private file required')
+    const bytes=readFileSync(fd)
+    if(bytes.length>maxBytes)throw new Error('private file size limit')
+    return bytes.toString('utf8')
+  }finally{closeSync(fd)}
+}

+ 7 - 0
src/server/credentialVault/schema.ts

@@ -10,6 +10,13 @@ export const website = z.object({ kind: z.literal('website'), origin: z.string()
 export const ssh = z.object({ kind: z.literal('ssh'), host: z.string().min(1).max(253).regex(/^[A-Za-z0-9.:[\]_-]+$/).transform(v => v.toLowerCase()),
   port: z.number().int().min(1).max(65535), hostKey: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}=?$/) }).strict()
 export const target = z.discriminatedUnion('kind', [website, ssh])
+export const leaseInput = z.object({
+  credentialRef: z.string().uuid(), agentId: z.string().uuid(), workId: z.string().uuid(),
+  runnerId: z.string().uuid(), runnerInstance: z.string().uuid(), runnerEpoch: z.string().min(1).max(128),
+  operation: z.enum(['website.login', 'ssh.exec', 'sftp.read', 'sftp.write']), target,
+  seconds: z.number().int().min(15).max(120).default(60),
+}).strict()
+export type LeaseInput = z.infer<typeof leaseInput>
 export const entryInput = z.object({ name: z.string().trim().min(1).max(120), username: z.string().min(1).max(256),
   target, secret: z.string().min(1).max(32768), passphrase: z.string().max(4096).optional() }).strict()
 export const updateInput = entryInput.extend({ secret: entryInput.shape.secret.optional(), revision: z.number().int().positive() })

+ 8 - 7
src/server/credentialVault/store.ts

@@ -1,11 +1,12 @@
 import sodium from 'libsodium-wrappers-sumo'
 import { randomUUID, createHash } from 'node:crypto'
-import { mkdirSync, lstatSync, readFileSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, rmSync } from 'node:fs'
+import { mkdirSync, lstatSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, rmSync } from 'node:fs'
 import { join, resolve } from 'node:path'
 import { z } from 'zod'
 import { HttpError } from '../errors.js'
 import { entryInput, updateInput, storedEntry, parse, masterPassword, type VaultEntry } from './schema.js'
 import type { CredentialSummary } from '../../shared/credentialVault.js'
+import { readPrivateUtf8 } from './privateFiles.js'
 
 const MAX_BYTES = 2 * 1024 * 1024
 const bytes = z.string().max(MAX_BYTES).regex(/^[A-Za-z0-9+/]*={0,2}$/)
@@ -55,7 +56,7 @@ export class CredentialVaultStore {
     this.root = resolve(root)
     mkdirSync(this.root, { recursive: true, mode: 0o700 })
     const stat = lstatSync(this.root)
-    if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Error('密码库目录必须为私有目录')
+    if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) || stat.uid !== process.getuid?.()) throw new Error('密码库目录必须为当前服务身份的私有目录')
   }
   static async create(root: string, now = Date.now, beforeRename?: () => void) {
     await sodium.ready
@@ -68,9 +69,7 @@ export class CredentialVaultStore {
   private load(owner: string): Envelope | undefined {
     const path = this.path(owner)
     try {
-      const stat = lstatSync(path)
-      if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES || (stat.mode & 0o077)) throw new Error()
-      const e = parse(envelopeSchema, JSON.parse(readFileSync(path, 'utf8')))
+      const e = parse(envelopeSchema, JSON.parse(readPrivateUtf8(path, MAX_BYTES)))
       if (e.owner !== owner) throw new Error()
       return e
     } catch (error) {
@@ -157,7 +156,8 @@ export class CredentialVaultStore {
   private commit(owner: string, entries: VaultEntry[]) {
     const s = this.active(owner)
     const next = this.encodeDocument({ ...s.envelope, revision: s.envelope.revision + 1 }, s.key, entries)
-    this.save(next); s.envelope = next; s.entries = entries
+    try { this.save(next); s.envelope = next; s.entries = entries }
+    catch (error) { this.lock(owner); throw error } // A rename may have happened before a failed directory fsync.
   }
   add(owner: string, value: unknown) {
     const s = this.active(owner), input = parse(entryInput, value)
@@ -208,6 +208,7 @@ export class CredentialVaultStore {
       kek = derive(password, next); next.wrapping = encrypt(key, kek, aad(next, 'key'))
       this.save(this.encodeDocument(next, key, s.entries))
       this.lock(owner)
-    } finally { wipe(kek); wipe(key) }
+    } catch (error) { this.lock(owner); throw error }
+    finally { wipe(kek); wipe(key) }
   }
 }

+ 87 - 0
src/server/credentialVault/transport.ts

@@ -0,0 +1,87 @@
+import { createServer, request, type Server } from 'node:http'
+import { createHash, timingSafeEqual, randomUUID } from 'node:crypto'
+import { lstatSync } from 'node:fs'
+import { isAbsolute } from 'node:path'
+import { z } from 'zod'
+import type { CredentialVaultBroker } from './broker.js'
+import { parse } from './schema.js'
+import { HttpError } from '../errors.js'
+import { readPrivateUtf8 } from './privateFiles.js'
+
+const MAX_BYTES = 3 * 1024 * 1024
+export function serveCredentialVault(broker: CredentialVaultBroker, token: string): Server {
+  if (token.length < 32) throw new Error('密码库控制凭据格式无效')
+  const expected = createHash('sha256').update(token).digest()
+  const server = createServer((req, res) => { void (async () => {
+    res.setHeader('Cache-Control', 'no-store')
+    if (req.method !== 'POST' || req.url !== '/v1' || req.headers.origin) throw new HttpError(403, '密码库只接受私有控制请求', 'vault_control_forbidden')
+    const received = createHash('sha256').update(String(req.headers.authorization ?? '').replace(/^Bearer /, '')).digest()
+    if (!req.headers.authorization?.startsWith('Bearer ') || !timingSafeEqual(expected, received)) throw new HttpError(403, '密码库控制授权无效', 'vault_control_forbidden')
+    const chunks: Buffer[] = []; let size = 0
+    for await (const chunk of req) { size += chunk.length; if (size > MAX_BYTES) throw new HttpError(413, '密码库请求过大', 'vault_size_limit'); chunks.push(Buffer.from(chunk)) }
+    const body = parse(z.object({ epoch: z.string().uuid(), owner: z.string().min(1).max(256), session: z.string().regex(/^[a-f0-9]{64}$/),
+      command: z.string().max(64), value: z.unknown().optional() }).strict(), JSON.parse(Buffer.concat(chunks).toString('utf8')))
+    const result = body.command === 'hello' ? broker.hello(body.epoch) : body.command === 'execute'
+      ? await (() => { const v=parse(z.object({id:z.string().uuid(),input:z.unknown()}).strict(),body.value); return broker.execute(body.epoch,body.owner,body.session,v.id,v.input as any) })()
+      : broker.dispatch(body.epoch, body.owner, body.session, body.command, body.value)
+    res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ result }))
+  })().catch(error => {
+    // Never forward parser, sodium, filesystem or executor exception messages.
+    res.statusCode = error instanceof HttpError ? error.status : 400
+    res.setHeader('Content-Type', 'application/json')
+    res.end(JSON.stringify({ error: error instanceof HttpError ? error.message : '密码库请求失败', code: error instanceof HttpError ? error.code : 'vault_request_failed' }))
+  }) })
+  server.requestTimeout = 15000; server.headersTimeout = 10000; server.keepAliveTimeout = 1000
+  server.on('close', () => broker.close())
+  server.on('clientError', (_e, socket) => socket.destroy())
+  return server
+}
+/** Private IPC; no global fetch proxy, environment/argv secret or general Runner payload. */
+export class CredentialVaultClient {
+  private epoch = randomUUID()
+  private hello?: Promise<unknown>
+  onDisconnect = () => {}
+  constructor(private config?: { socket: string; tokenFile: string; hermesUid: number }) {}
+  get configured() { return !!this.config }
+  private async send(owner: string, session: string, command: string, value?: unknown): Promise<any> {
+    const c = this.config
+    if (!c) throw new HttpError(503, '执行节点密码库尚未连接', 'vault_offline')
+    if (!process.getuid || process.getuid() === 0 || !Number.isSafeInteger(c.hermesUid) || c.hermesUid < 1 || process.getuid() === c.hermesUid)
+      throw new HttpError(503, '密码库控制面需要与 Hermes 分离的非 root OS 身份', 'vault_offline')
+    if (!isAbsolute(c.socket) || !isAbsolute(c.tokenFile)) throw new HttpError(503, '密码库控制配置无效', 'vault_offline')
+    let token: string
+    try {
+      const socket = lstatSync(c.socket)
+      if (!socket.isSocket() || (socket.mode & 0o077) || socket.uid !== process.getuid?.()) throw new Error()
+      token = readPrivateUtf8(c.tokenFile,4096).trim(); if (token.length < 32) throw new Error()
+    } catch { throw new HttpError(503, '密码库私有连接不可用,请检查部署', 'vault_offline') }
+    return new Promise((done, reject) => {
+      const req = request({ socketPath: c.socket, path: '/v1', method: 'POST', headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' } }, res => {
+        const chunks: Buffer[] = []; let size = 0
+        res.on('data', chunk => { size += chunk.length; if (size > MAX_BYTES) req.destroy(); else chunks.push(Buffer.from(chunk)) })
+        res.on('error', () => reject(new HttpError(503, '密码库连接中断', 'vault_offline')))
+        res.on('aborted', () => reject(new HttpError(503, '密码库连接中断', 'vault_offline')))
+        res.on('end', () => {
+          try { const body = JSON.parse(Buffer.concat(chunks).toString()); if (res.statusCode !== 200) reject(new HttpError(res.statusCode ?? 503,
+            '密码库操作未完成,请检查解锁和任务授权', typeof body.code === 'string' && /^vault_[a-z_]+$/.test(body.code) ? body.code : 'vault_request_failed')); else done(body.result) }
+          catch { reject(new HttpError(503, '密码库响应无效', 'vault_offline')) }
+        })
+      })
+      req.setTimeout(15000, () => req.destroy())
+      req.on('error', () => reject(new HttpError(503, '密码库连接中断', 'vault_offline')))
+      req.end(JSON.stringify({ epoch: this.epoch, owner, session, command, value }))
+    })
+  }
+  async call(owner: string, session: string, command: string, value?: unknown): Promise<any> {
+    try {
+      this.hello ??= this.send(owner, session, 'hello')
+      await this.hello
+      return await this.send(owner, session, command, value)
+    } catch (e) {
+      if (e instanceof HttpError && (e.code === 'vault_offline' || e.code === 'vault_control_changed')) {
+        this.hello = undefined; this.epoch = randomUUID(); this.onDisconnect()
+      }
+      throw e
+    }
+  }
+}

+ 6 - 0
src/server/localAuth.ts

@@ -232,6 +232,12 @@ export class LocalAuthStore {
     return this.currentFromCookieHeader(ctx.get('cookie'))
   }
 
+  /** Opaque vault binding; callers must separately validate the session. */
+  sessionBinding(cookieHeader: string | undefined): string | undefined {
+    const token = parse(cookieHeader ?? '')[SESSION_COOKIE]
+    return token ? createHash('sha256').update('yaoyao-vault-session-v1\0').update(token).digest('hex') : undefined
+  }
+
   /** Resolve the same authenticated user for non-Koa transports such as WS Upgrade. */
   currentFromCookieHeader(cookieHeader: string | undefined): LocalUser | undefined {
     const token = parse(cookieHeader ?? '')[SESSION_COOKIE]

+ 10 - 1
src/server/runnerHub.ts

@@ -95,6 +95,15 @@ export class RunnerHub {
     return result
   }
   records():RunnerRecord[] {return this.store.list('_system','runner')}
+  credentialBinding(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent) {
+    if(agent.archived||!this.auth.canUseSource(owner,agent.nodeId,agent.profile))throw new HttpError(403,'Bot 凭据授权已结束','vault_agent_forbidden')
+    const record=this.records().find(r=>r.enabled&&r.sourceNodeId===agent.nodeId&&(r.sourceOwner==='_system'||r.sourceOwner===owner))
+    const online=record&&this.online.get(record.id)
+    if(!record||!online||Date.now()-online.seen>=35000)throw new HttpError(503,'执行节点离线,请等待节点连接','vault_runner_offline')
+    if(!record.allowedProfiles.includes(agent.profile))throw new HttpError(403,'执行节点未授权当前 Profile','vault_agent_forbidden')
+    if(!online.features.includes('credential-ref-v1'))throw new HttpError(409,'请更新 Runner,旧节点不支持凭据引用授权','vault_runner_upgrade_required')
+    return {runnerId:record.id,runnerInstance:online.instance,runnerEpoch:online.epoch}
+  }
   enroll(owner:string,value:unknown) {
     const body=parse(input,value)
     body.allowedProfiles=this.auth.validateAssignedProfiles(body.allowedProfiles)
@@ -307,7 +316,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['execution-context-v1','vm-egress-v1','workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1','managed-browser-v1','managed-browser-setup-v1','managed-browser-disabled-v1','managed-browser-retention-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['execution-context-v1','vm-egress-v1','workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1','managed-browser-v1','managed-browser-setup-v1','managed-browser-disabled-v1','managed-browser-retention-v1','credential-ref-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')

+ 4 - 0
src/server/subaccountAccess.ts

@@ -1,6 +1,10 @@
 /** Owner-scoped Bot endpoints. Keep native and administrative surfaces denied. */
 export function isWorkspaceAccountPath(method: string, path: string): boolean {
   const read = method === 'GET' || method === 'HEAD'
+  if(read&&path==='/api/app/vault')return true
+  if(method==='POST'&&/^\/api\/app\/vault\/(initialize|unlock|lock|rotate|backup|restore|entries|leases)$/.test(path))return true
+  if(['PUT','DELETE'].includes(method)&&/^\/api\/app\/vault\/entries\/[0-9a-f-]{36}$/.test(path))return true
+  if(method==='DELETE'&&/^\/api\/app\/vault\/leases\/[0-9a-f-]{36}$/.test(path))return true
   if (read && /^\/api\/app\/workspace\/(snapshot|projects|memories|memory-export|memory-jobs|collaboration)$/.test(path)) return true
   if (read && /^\/api\/app\/workspace\/memories\/[^/]+\/revisions$/.test(path)) return true
   return method === 'POST' && /^\/api\/app\/workspace\/(projects|memories|memories\/forget|memory-jobs\/[^/]+\/retry|collaboration\/[^/]+\/stop)$/.test(path)

+ 14 - 2
src/server/workspaceRuntime.ts

@@ -1,4 +1,5 @@
 import type {ExecutionSettings} from './executionSettings.js'
+import {CREDENTIAL_TOOLS,type CredentialVaultCoordinator} from './credentialVault/coordinator.js'
 import {isLocalAuthorizationTarget} from './loopbackAuthorization.js'
 import {type ManagedBrowsers,type BrowserTurn} from './managedBrowsers.js'
 import {ToolOperationJournal} from './toolOperationJournal.js'
@@ -123,6 +124,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   private executionTurns=new Map<string,{agentId:string;mode:import('../shared/executionEnvironment.js').ExecutionMode;revision:number}>()
   activeExecutionTurns(){return [...this.executionTurns.values()]}
   executionSettings?:ExecutionSettings
+  credentialVault?:CredentialVaultCoordinator
   plugins?: WorkspacePlugins
   managedBrowsers?:ManagedBrowsers
   private operationJournal:ToolOperationJournal
@@ -454,6 +456,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     let reconnecting: Promise<void> | undefined
     const toolController = new AbortController()
     let toolLease: WorkspaceToolLease | undefined
+    let credentialTurn: ReturnType<CredentialVaultCoordinator['openTurn']> | undefined
     let pluginLease: OpenBotPlugins | undefined
     let resolveTurn!: (m: Message) => void, rejectTurn!: (e: Error) => void
     const completion = new Promise<Message>((resolve, reject) => {
@@ -471,6 +474,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       try { retainBrowser=!error&&!this.getWork(owner,run.id).cancelRequested&&!this.store.require<Run>(owner,'run',run.runId).stopRequested } catch { /* Missing/revoked work must not retain a browser. */ }
       void browserTurn?.close({retain:retainBrowser}).catch(()=>{})
       toolController.abort()
+      credentialTurn?.close()
       void toolLease?.dispose()
       void pluginLease?.dispose()
       clearTimeout(flushTimer)
@@ -893,7 +897,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           }
           return name==='__file_transfer'?vmHolder.session.transfer(args as Record<string,unknown>):vmHolder.session.call(name,args,callId)
         }
-        if(team||cloud||desktop||plugins||knowledge||routines||dispatchedVm||environment.tools.managedBrowser||(execution&&botCapabilities.tools)){
+        if(team||cloud||desktop||plugins||knowledge||routines||dispatchedVm||environment.tools.managedBrowser||(execution&&botCapabilities.tools)||(this.credentialVault?.client.configured&&botCapabilities.tools)){
           if(team){const granted=this.getWork(owner,run.id);granted.teamManagementRevision=agent.revision;this.saveWork(owner,granted)}
           if(desktop)await this.desktopEnvironments!.requireAvailable(owner,agent,target)
           if(cloud)await this.cloud!.requireAvailable(owner,agent,target)
@@ -915,9 +919,17 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             // the preferred target. Each dispatched environment checks its grants.
             ...(execution?{computerPolicy:{mode:'profile',hostAccess:true}}:{}),
             session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
-            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(routines?this.routineTools!.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...environmentCatalog,...(systemTurn?.catalog()??[]),...(pluginLease?.catalog()??[])],
+            catalog:()=>[...(this.credentialVault?.client.configured&&botCapabilities.tools?CREDENTIAL_TOOLS:[]),...(team?this.teamTools.catalog(owner,run.id):[]),...(routines?this.routineTools!.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...environmentCatalog,...(systemTurn?.catalog()??[]),...(pluginLease?.catalog()??[])],
             call:async(toolId,args,callId)=>{
               assertActive()
+              if(toolId.startsWith('credential_')&&this.credentialVault?.client.configured&&botCapabilities.tools){
+                credentialTurn??=this.credentialVault.openTurn(owner,agent.id,run.id,toolController.signal,assertActive,value=>{
+                  if(settled||toolController.signal.aborted)return
+                  const work=this.getWork(owner,run.id);if(!['running','waiting'].includes(work.status))return
+                  work.status=value?'waiting':'running';this.saveWork(owner,work)
+                })
+                return credentialTurn.call(toolId,args)
+              }
               if(toolId==='yaoyao_service_request'&&systemTurn)return systemTurn.call(args,toolController.signal)
               if(this.routineTools?.handles(toolId)) return this.routineTools.call(owner,run.id,toolId,args)
               if(toolId.startsWith('managed_browser_')&&environment.tools.managedBrowser&&this.managedBrowsers){

+ 1 - 1
src/shared/credentialVault.ts

@@ -15,6 +15,6 @@ export interface CredentialLeaseSummary {
 export interface CredentialVaultStatus {
   protocol: 1; online: boolean; initialized: boolean; unlocked: boolean
   unlockExpiresAt?: number
-  execution: 'disabled'; reason: 'executor_not_enabled' | 'vault_offline'
+  execution: 'disabled' | 'dummy-fixture'; reason: 'executor_not_enabled' | 'vault_offline' | 'dummy_fixture_only'
   entries: CredentialSummary[]; leases: CredentialLeaseSummary[]
 }

+ 54 - 0
tests/client/credentialVaultPanel.test.ts

@@ -0,0 +1,54 @@
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import CredentialVaultPanel from '@/components/app/CredentialVaultPanel.vue'
+const api=vi.hoisted(()=>vi.fn())
+vi.mock('@/api/client',()=>({apiRequest:api}))
+let wrapper:VueWrapper|undefined
+const initial={protocol:1,online:true,initialized:true,unlocked:false,execution:'disabled',entries:[],leases:[],tasks:[],requests:[]}
+let state:any
+function deferred(){let resolve!:(value:any)=>void;const promise=new Promise<any>(r=>{resolve=r});return {resolve,promise}}
+beforeEach(()=>{state=structuredClone(initial);api.mockImplementation(async(_path,options)=>options?.method==='POST'?{}:structuredClone(state))})
+afterEach(()=>{wrapper?.unmount();wrapper=undefined;vi.useRealTimers();vi.restoreAllMocks();api.mockReset()})
+async function setup(){wrapper=mount(CredentialVaultPanel);await flushPromises();return wrapper}
+it('shows an offline state without offering secret entry and explicitly keeps real login disabled',async()=>{
+  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('preserves a master password draft while locked status is polled, then clears it after manual unlock',async()=>{
+  vi.useFakeTimers();const storage=vi.spyOn(Storage.prototype,'setItem'),w=await setup(),input=w.get<HTMLInputElement>('input[type=password]')
+  await input.setValue('DUMMY-ONLY-master');await vi.advanceTimersByTimeAsync(3000);await flushPromises();expect(input.element.value).toBe('DUMMY-ONLY-master')
+  await w.get('form').trigger('submit');await flushPromises()
+  expect(api).toHaveBeenCalledWith('/api/app/vault/unlock',expect.objectContaining({method:'POST',body:{password:'DUMMY-ONLY-master',seconds:300}}))
+  expect(input.element.value).toBe('');expect(storage).not.toHaveBeenCalled()
+})
+it('checks confirmation before initialization and leaves no draft in unmounted UI',async()=>{
+  state.initialized=false;const w=await setup(),inputs=w.findAll('input[type=password]')
+  await inputs[0]!.setValue('dummy-master-A');await inputs[1]!.setValue('dummy-master-B');await w.get('form').trigger('submit')
+  expect(w.get('[role=alert]').text()).toContain('不一致');expect(api.mock.calls.filter(([,o])=>o?.method==='POST')).toHaveLength(0)
+  w.unmount();wrapper=undefined
+})
+it('editing exposes no saved secret and omits a blank replacement in the request',async()=>{
+  state.unlocked=true;state.entries=[{id:'dummy-id',name:'dummy',username:'fixture',target:{kind:'website',origin:'https://example.test'},revision:3,updatedAt:1}]
+  const w=await setup();await w.findAll('button').find(b=>b.text()==='编辑')!.trigger('click')
+  const password=w.get<HTMLInputElement>('input[type=password]');expect(password.element.value).toBe('')
+  await w.findAll('form')[0]!.trigger('submit');await flushPromises()
+  const call=api.mock.calls.find(([p,o])=>p==='/api/app/vault/entries/dummy-id'&&o.method==='PUT')!
+  expect(call[1].body).not.toHaveProperty('secret');expect(call[1].body.revision).toBe(3)
+  expect(api.mock.calls.some(([p])=>p.includes('read-secret'))).toBe(false)
+})
+it('unmount aborts an in-flight unlock and sends an explicit lock without a password',async()=>{
+  const w=await setup();await w.get('input[type=password]').setValue('dummy-master-only')
+  const pending=deferred();api.mockImplementation((p)=>p.endsWith('/unlock')?pending.promise:Promise.resolve({}))
+  await w.get('form').trigger('submit');const call=api.mock.calls.find(([p])=>p.endsWith('/unlock'))!
+  w.unmount();wrapper=undefined;expect(call[1].signal.aborted).toBe(true)
+  expect(api).toHaveBeenCalledWith('/api/app/vault/lock',expect.objectContaining({body:{}}));pending.resolve({});await flushPromises()
+})
+it('hiding the page locks even while unlock is still pending',async()=>{
+  const w=await setup();await w.get('input[type=password]').setValue('dummy-master-only')
+  const pending=deferred();api.mockImplementation(p=>p.endsWith('/unlock')?pending.promise:Promise.resolve({}))
+  await w.get('form').trigger('submit');const call=api.mock.calls.find(([p])=>p.endsWith('/unlock'))!
+  vi.spyOn(document,'hidden','get').mockReturnValue(true);document.dispatchEvent(new Event('visibilitychange'))
+  await w.vm.$nextTick()
+  expect(call[1].signal.aborted).toBe(true);expect(api).toHaveBeenCalledWith('/api/app/vault/lock',expect.objectContaining({body:{}}))
+  expect(w.get<HTMLInputElement>('input[type=password]').element.value).toBe('');pending.resolve({});await flushPromises()
+})

+ 120 - 0
tests/server/credentialVaultBotFlow.test.ts

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

+ 79 - 0
tests/server/credentialVaultBroker.test.ts

@@ -0,0 +1,79 @@
+// @vitest-environment node
+import { afterEach, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { CredentialVaultStore } from '../../src/server/credentialVault/store'
+import { CredentialVaultBroker, type CredentialExecutorFixture } from '../../src/server/credentialVault/broker'
+import type { LeaseInput } from '../../src/server/credentialVault/schema'
+const homes:string[]=[], brokers:CredentialVaultBroker[]=[]
+const owner='dummy-owner', session='a'.repeat(64), otherSession='b'.repeat(64), password='dummy-master-password', secret='DUMMY-CREDENTIAL-NEVER-REAL'
+async function fixture(executor?:CredentialExecutorFixture){
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-broker-'));homes.push(home)
+  let now=Date.now();const store=await CredentialVaultStore.create(home,()=>now),broker=new CredentialVaultBroker(store,()=>now,executor);brokers.push(broker)
+  let epoch=randomUUID();broker.hello(epoch)
+  const dispatch=(command:string,value?:unknown,s=session,o=owner)=>broker.dispatch(epoch,o,s,command,value) as any
+  dispatch('initialize',{password});dispatch('unlock',{password})
+  const entry=dispatch('add',{name:'dummy-site',username:'dummy-username',target:{kind:'website',origin:'https://example.test'},secret})
+  const input:LeaseInput={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-runner-epoch',operation:'website.login',target:entry.target,seconds:60}
+  return {home,store,broker,dispatch,entry,input,grant:()=>dispatch('grant',input),execute:(id:string,b=input,o=owner,s=session)=>broker.execute(epoch,o,s,id,b),advance:(ms:number)=>{now+=ms},reconnect:()=>{epoch=randomUUID();broker.hello(epoch)}}
+}
+afterEach(()=>{brokers.splice(0).forEach(b=>b.close());homes.splice(0).forEach(h=>rmSync(h,{recursive:true,force:true}))})
+it('production has no execution adapter or read-secret RPC, even after user unlock and grant',async()=>{
+  const f=await fixture(),lease=f.grant()
+  await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_executor_not_enabled'})
+  expect(()=>f.dispatch('read-secret',{id:f.entry.id})).toThrowError(expect.objectContaining({code:'vault_command_unknown'}))
+  const state=f.dispatch('status');expect(JSON.stringify(state)).not.toContain(secret)
+  expect(state.execution).toBe('disabled');expect(f.dispatch('status',{},otherSession).entries).toEqual([])
+})
+it('binds a lease to owner, Bot, task, exact target, operation and Runner incarnation',async()=>{
+  const execute=vi.fn(async()=>{}),f=await fixture({execute}),lease=f.grant()
+  for(const patch of [{agentId:randomUUID()},{workId:randomUUID()},{runnerId:randomUUID()},{runnerInstance:randomUUID()},{runnerEpoch:'new-epoch'},{operation:'ssh.exec' as const},{target:{kind:'website' as const,origin:'https://other.test'}}])
+    await expect(f.execute(lease.id,{...f.input,...patch})).rejects.toMatchObject({code:'vault_scope_forbidden'})
+  await expect(f.execute(lease.id,f.input,'other-owner')).rejects.toMatchObject({code:'vault_lease_revoked'})
+  await expect(f.execute(lease.id,f.input,owner,otherSession)).rejects.toMatchObject({code:'vault_scope_forbidden'})
+  expect(()=>f.dispatch('grant',{...f.input,target:{kind:'website',origin:'http://example.test'}})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
+  expect(()=>f.dispatch('grant',{...f.input,target:{kind:'website',origin:'https://other.test'}})).toThrowError(expect.objectContaining({code:'vault_target_forbidden'}))
+  expect(execute).not.toHaveBeenCalled()
+  expect(await f.execute(lease.id)).toEqual({status:'complete',operation:'website.login'})
+  await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_lease_revoked'})
+  expect(execute).toHaveBeenCalledOnce()
+})
+it('binds SSH grants to a pinned host key and port and rejects plaintext FTP',async()=>{
+  const f=await fixture({execute:async()=>{}}),target={kind:'ssh',host:'ssh.example.test',port:22,hostKey:'SHA256:'+'A'.repeat(43)}
+  const entry=f.dispatch('add',{name:'dummy-ssh',username:'dummy',target,secret:'DUMMY-PRIVATE-KEY'})
+  const input={...f.input,credentialRef:entry.id,target,operation:'sftp.read',seconds:60} as LeaseInput
+  const l=f.dispatch('grant',input)
+  await expect(f.execute(l.id,{...input,target:{...input.target,port:2222} as any})).rejects.toMatchObject({code:'vault_scope_forbidden'})
+  expect(()=>f.dispatch('grant',{...input,operation:'ftp.login'})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
+  expect(()=>f.dispatch('grant',{...input,target:{...target,hostKey:'SHA256:'+'B'.repeat(43)}})).toThrowError(expect.objectContaining({code:'vault_target_forbidden'}))
+})
+it.each(['lock','logout','revoke','cancel','reconnect','update','remove','expiry'] as const)('aborts an in-flight dummy executor on %s, hides errors and prevents retry',async cause=>{
+  let release!:()=>void,signal:AbortSignal|undefined
+  const started=Promise.withResolvers<void>(),f=await fixture({execute:async(entry,_input,s)=>{expect(entry.secret).toBe(secret);signal=s;started.resolve();await new Promise<void>(r=>{release=r})}}),lease=f.grant()
+  const run=f.execute(lease.id),result=expect(run).rejects.toMatchObject({code:'vault_operation_uncertain'})
+  await started.promise
+  await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_operation_uncertain'})
+  if(cause==='lock'||cause==='logout')f.dispatch(cause)
+  if(cause==='revoke')f.dispatch('revoke',{id:lease.id})
+  if(cause==='cancel')f.dispatch('revoke-work',{workId:f.input.workId})
+  if(cause==='reconnect')f.reconnect()
+  if(cause==='update')f.dispatch('update',{id:f.entry.id,entry:{name:'new-name',username:'dummy',target:f.entry.target,revision:1}})
+  if(cause==='remove')f.dispatch('remove',{id:f.entry.id})
+  if(cause==='expiry'){f.advance(300000);f.dispatch('status')}
+  expect(signal?.aborted).toBe(true);release();await result
+  await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_lease_revoked'})
+  const audit=readFileSync(join(f.home,'audit.jsonl'),'utf8')
+  for(const privateValue of [secret,password,'dummy-username','https://example.test'])expect(audit).not.toContain(privateValue)
+  expect(audit).toContain('not-confirmed')
+})
+it('logout from another login cannot lock the session that unlocked the vault',async()=>{
+  const f=await fixture();f.grant();f.dispatch('logout',{},otherSession)
+  expect(f.dispatch('status').unlocked).toBe(true);f.dispatch('logout');expect(f.dispatch('status').unlocked).toBe(false)
+})
+it('refuses symlink audit sinks and leaves their destination unchanged',async()=>{
+  const f=await fixture(),path=join(f.home,'audit.jsonl'),destination=join(f.home,'dummy-destination')
+  writeFileSync(destination,'unchanged');rmSync(path);symlinkSync(destination,path)
+  expect(()=>f.dispatch('backup')).toThrow();expect(readFileSync(destination,'utf8')).toBe('unchanged')
+})

+ 118 - 0
tests/server/credentialVaultCoordinator.test.ts

@@ -0,0 +1,118 @@
+// @vitest-environment node
+import { afterEach, expect, it, vi } from 'vitest'
+import type Koa from 'koa'
+import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { once } from 'node:events'
+import { CredentialVaultStore } from '../../src/server/credentialVault/store'
+import { CredentialVaultBroker } from '../../src/server/credentialVault/broker'
+import { CredentialVaultClient, serveCredentialVault } from '../../src/server/credentialVault/transport'
+import { CredentialVaultCoordinator } from '../../src/server/credentialVault/coordinator'
+import { LocalAuthStore } from '../../src/server/localAuth'
+import { HttpError } from '../../src/server/errors'
+import type { WorkspaceStore } from '../../src/server/workspaceStore'
+import type { RunnerHub } from '../../src/server/runnerHub'
+import type { Server } from 'node:http'
+const cleanups:Array<()=>Promise<void>>=[]
+const password='DUMMY-vault-master-only',secret='DUMMY-website-secret-only'
+function context(cookie='',path='',method='GET',body:unknown={}):Koa.Context{
+  const headers:Record<string,any>={}
+  return {state:{},get:(key:string)=>key.toLowerCase()==='cookie'?cookie:'',set:(key:string,value:any)=>{headers[key.toLowerCase()]=value},response:{headers},path,method,request:{body},params:{},secure:false,req:{socket:{remoteAddress:'127.0.0.1'}}} as unknown as Koa.Context
+}
+function cookie(ctx:Koa.Context){const value=ctx.response.headers['set-cookie'];return String(Array.isArray(value)?value.at(-1):value).split(';')[0]!}
+async function fixture(ipc=false){
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-coordinator-')),auth=new LocalAuthStore(home),login=context(),user=auth.setupAdmin(login,'dummy-user','dummy-login-password'),cookies=cookie(login)
+  const vault=await CredentialVaultStore.create(join(home,'vault')),broker=new CredentialVaultBroker(vault)
+  let client:CredentialVaultClient,server:Server|undefined
+  if(ipc){
+    const socket=join(home,'dummy.sock'),tokenFile=join(home,'dummy-control'),token='DUMMY-CONTROL-TOKEN-NOT-PRODUCTION-00000'
+    writeFileSync(tokenFile,token,{mode:0o600});server=serveCredentialVault(broker,token);server.listen(socket);await once(server,'listening');chmodSync(socket,0o600)
+    // In-process fixtures do NOT prove OS isolation. Production executors remain disabled.
+    client=new CredentialVaultClient({socket,tokenFile,hermesUid:process.getuid!()+1})
+  }else{
+    const epoch=randomUUID();broker.hello(epoch)
+    client=new CredentialVaultClient()
+    vi.spyOn(client,'call').mockImplementation(async(o,s,c,v)=>broker.dispatch(epoch,o,s,c,v))
+  }
+  const agent={id:randomUUID(),name:'dummy-Bot',nodeId:'local',profile:'default',archived:false},run={id:randomUUID(),stopRequested:false},work={id:randomUUID(),agentId:agent.id,runId:run.id,status:'running',cancelRequested:false}
+  const objects:any={agent,run,turn:work},store={require:(o:string,kind:string,id:string)=>{if(o!==user.id||objects[kind]?.id!==id)throw new HttpError(404,'fixture missing','not_found');return objects[kind]},list:(o:string,kind:string)=>o===user.id&&objects[kind]?[objects[kind]]:[]} as unknown as WorkspaceStore
+  let binding={runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-epoch'}
+  const hub={credentialBinding:()=>binding} as unknown as RunnerHub,coordinator=new CredentialVaultCoordinator(client,store,auth,hub),router=coordinator.router()
+  const request=async(path:string,method='GET',body:unknown={},c=cookies)=>{const ctx=context(c,'/api/app/vault'+path,method,body);await router.routes()(ctx,async()=>{});return ctx.body as any}
+  cleanups.push(async()=>{coordinator.close();if(server){await new Promise<void>(resolve=>server!.close(()=>resolve()));server.closeAllConnections()}else broker.close();rmSync(home,{recursive:true,force:true})})
+  await request('/initialize','POST',{password})
+  const unlock=()=>request('/unlock','POST',{password}),status=()=>request('')
+  await unlock();const entry=await request('/entries','POST',{name:'dummy-site',username:'dummy-user',target:{kind:'website',origin:'https://example.test'},secret})
+  const grant=()=>request('/leases','POST',{credentialRef:entry.id,agentId:agent.id,workId:work.id,operation:'website.login',seconds:60})
+  const turn=(signal=new AbortController().signal,waiting=vi.fn())=>coordinator.openTurn(user.id,agent.id,work.id,signal,()=>{},waiting)
+  return {home,auth,user,cookies,broker,client,coordinator,request,unlock,status,entry,agent,run,work,grant,turn,changeBinding:()=>{binding={...binding,runnerInstance:randomUUID()}},hub}
+}
+afterEach(async()=>{for(const cleanup of cleanups.splice(0))await cleanup();vi.restoreAllMocks()})
+it('uses private IPC for encrypted CRUD and short grants but returns manual takeover instead of a login',async()=>{
+  const f=await fixture(true),lease=await f.grant(),t=f.turn()
+  expect((await f.status()).leases).toHaveLength(1)
+  expect(await t.call('credential_refs',{})).toEqual({references:[{credentialRef:f.entry.id,operation:'website.login',allowedTarget:f.entry.target}]})
+  expect(await t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'})).toMatchObject({status:'manual_takeover_required',reason:'vault_executor_not_enabled'})
+  const status=await f.status();expect(JSON.stringify(status)).not.toContain(secret);expect(status.execution).toBe('disabled');expect(lease.expiresAt-Date.now()).toBeLessThanOrEqual(60000)
+  t.close();await vi.waitFor(async()=>expect((await f.status()).leases).toHaveLength(0))
+})
+it.each(['lock','logout','connection'] as const)('rejects a late unlock response following %s and leaves the vault locked',async cause=>{
+  const f=await fixture();await f.request('/lock','POST',{})
+  const original=f.client.call.bind(f.client),reached=Promise.withResolvers<void>(),release=Promise.withResolvers<void>()
+  vi.spyOn(f.client,'call').mockImplementation(async(o,s,c,v)=>{const result=await original(o,s,c,v);if(c==='unlock'){reached.resolve();await release.promise}return result})
+  const pending=f.unlock(),assertion=expect(pending).rejects.toMatchObject({code:'vault_session_revoked'});await reached.promise
+  if(cause==='lock')await f.request('/lock','POST',{})
+  if(cause==='logout'){await f.coordinator.logout(f.user.id,f.cookies);f.auth.logout(context(f.cookies))}
+  if(cause==='connection')f.client.onDisconnect()
+  release.resolve();await assertion
+  expect(f.broker.status(f.user.id,f.auth.sessionBinding(f.cookies)!).unlocked).toBe(false)
+})
+it.each(['cancel','reconnect','permission'] as const)('revokes a grant created during %s before it can reach the Bot',async cause=>{
+  const f=await fixture(),original=f.client.call.bind(f.client),reached=Promise.withResolvers<void>(),release=Promise.withResolvers<void>()
+  vi.spyOn(f.client,'call').mockImplementation(async(o,s,c,v)=>{const result=await original(o,s,c,v);if(c==='grant'){reached.resolve();await release.promise}return result})
+  const pending=f.grant(),assertion=expect(pending).rejects.toMatchObject({code:cause==='permission'?'vault_session_revoked':'vault_task_revoked'});await reached.promise
+  if(cause==='cancel')f.work.cancelRequested=true
+  if(cause==='reconnect')f.changeBinding()
+  if(cause==='permission')vi.spyOn(f.auth,'pushAuthorizationVersion').mockReturnValue(1000)
+  release.resolve();await assertion
+  expect(f.broker.status(f.user.id,f.auth.sessionBinding(f.cookies)!).leases).toHaveLength(0)
+})
+it('a Bot gets no stale refs after revoke or Runner reconnect and cannot request another Bot task',async()=>{
+  const f=await fixture(),lease=await f.grant(),t=f.turn()
+  await f.request('/leases/'+lease.id,'DELETE');expect(await t.call('credential_refs',{})).toEqual({references:[]})
+  await f.grant();f.changeBinding();expect(await t.call('credential_refs',{})).toEqual({references:[]})
+  f.work.agentId=randomUUID();await expect(t.call('credential_refs',{})).rejects.toMatchObject({code:'vault_task_revoked'});t.close()
+})
+it('cancellation releases locked/offline waiting and prevents fallback or pending requests lingering',async()=>{
+  const f=await fixture(),controller=new AbortController(),waiting=vi.fn();await f.request('/lock','POST',{})
+  vi.spyOn(f.hub,'credentialBinding').mockImplementation(()=>{throw new HttpError(503,'offline','vault_runner_offline')})
+  const t=f.turn(controller.signal,waiting),pending=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),assertion=expect(pending).rejects.toThrow()
+  expect(waiting).toHaveBeenCalledWith(true);expect((await f.status()).requests).toHaveLength(1)
+  controller.abort();await assertion;expect(waiting).toHaveBeenLastCalledWith(false);expect((await f.status()).requests).toHaveLength(0);t.close()
+})
+it('refuses old Runner capability and insecure remote UI immediately',async()=>{
+  const f=await fixture();vi.spyOn(f.hub,'credentialBinding').mockImplementation(()=>{throw new HttpError(409,'upgrade','vault_runner_upgrade_required')})
+  const t=f.turn();await expect(t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'})).rejects.toMatchObject({code:'vault_runner_upgrade_required'});t.close()
+  const ctx=context(f.cookies,'/api/app/vault/unlock','POST',{password});Object.assign(ctx.req.socket,{remoteAddress:'192.0.2.10'})
+  await expect(f.coordinator.router().routes()(ctx,async()=>{})).rejects.toMatchObject({code:'vault_https_required'})
+})
+it('default configuration remains offline and rejects same-UID control without reading a credential file',async()=>{
+  await expect(new CredentialVaultClient().call('owner','a'.repeat(64),'status')).rejects.toMatchObject({code:'vault_offline'})
+  await expect(new CredentialVaultClient({socket:'/not-a-real-socket',tokenFile:'/never-read',hermesUid:process.getuid!()}).call('owner','a'.repeat(64),'status')).rejects.toMatchObject({code:'vault_offline'})
+})
+it('binds the authenticated session token independent of unrelated cookie changes and ordering',async()=>{
+  const f=await fixture(),status=await f.request('','GET',{},'dummy_csrf=changed; '+f.cookies+'; dummy_extra=1')
+  expect(status.unlocked).toBe(true)
+  await f.coordinator.logout(f.user.id,'dummy_extra=2; '+f.cookies)
+  expect((await f.status()).unlocked).toBe(false)
+})
+it('parallel requests retain separate approvals and close cancels both waiting calls exactly once',async()=>{
+  const f=await fixture(),waiting=vi.fn();await f.request('/lock','POST',{})
+  const t=f.turn(undefined,waiting),one=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),two=t.call('credential_request',{credentialRef:randomUUID(),operation:'website.login'})
+  const assertions=[expect(one).rejects.toThrow(),expect(two).rejects.toThrow()]
+  expect((await f.status()).requests).toHaveLength(2);expect(waiting).toHaveBeenCalledTimes(1)
+  t.close();await Promise.all(assertions);expect(waiting).toHaveBeenLastCalledWith(false);expect(waiting.mock.calls.filter(([v])=>!v)).toHaveLength(1)
+  expect((await f.status()).requests).toHaveLength(0)
+})

+ 39 - 0
tests/server/credentialVaultRoutes.test.ts

@@ -0,0 +1,39 @@
+// @vitest-environment node
+import { afterEach, expect, it, vi } from 'vitest'
+import type Koa from 'koa'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app'
+import { LocalAuthStore } from '../../src/server/localAuth'
+import type { ServerConfig } from '../../src/server/config'
+const runtimes:ApplicationRuntime[]=[],homes:string[]=[]
+function context():Koa.Context{const headers:Record<string,any>={};return {state:{},get:()=>'',set:(k:string,v:any)=>{headers[k.toLowerCase()]=v},response:{headers}} as any}
+function cookie(response:any){const values=response.headers['set-cookie'];return (Array.isArray(values)?values:[values]).filter(Boolean).map((v:string)=>v.split(';')[0]).join('; ')}
+async function fixture(){
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-routes-'));homes.push(home)
+  const auth=new LocalAuthStore(home),login=context();auth.setupAdmin(login,'dummy-admin','DUMMY-only-login-password')
+  const config={host:'127.0.0.1',port:15300,upstream:new URL('http://127.0.0.1:9119'),allowedHosts:new Set(),home,mediaRoot:join(home,'media'),attachmentsRoot:join(home,'attachments'),imagesRoot:join(home,'images'),mediaOwner:'dummy',allowInsecureLan:false,insecureLan:false,production:true} as ServerConfig
+  const fetchImpl=vi.fn(async()=>new Response(JSON.stringify({profiles:['default']}),{headers:{'content-type':'application/json'}})) as unknown as typeof fetch
+  const runtime=createApplication({config,auth,fetchImpl});runtimes.push(runtime)
+  const callback=runtime.app.callback(),bootstrap=await request(callback).get('/api/app/bootstrap?inspectOnly=1').set('Host','127.0.0.1:15300').set('Cookie',cookie(login.response)).expect(200)
+  const cookies=cookie(login.response)+'; '+cookie(bootstrap),token=bootstrap.body.csrfToken
+  return {runtime,callback,fetchImpl,cookies,token,mutation:(path:string)=>request(callback).post(path).set('Host','127.0.0.1:15300').set('Cookie',cookies).set('Origin','http://127.0.0.1:15300').set('X-CSRF-Token',token)}
+}
+afterEach(()=>{runtimes.splice(0).forEach(r=>r.close());homes.splice(0).forEach(h=>rmSync(h,{recursive:true,force:true}));vi.restoreAllMocks()})
+it('vault routes enforce authentication, exact Origin and CSRF and stay offline without configuration',async()=>{
+  const f=await fixture()
+  await request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').expect(401)
+  const status=await request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').set('Cookie',f.cookies).expect(200)
+  expect(status.body).toMatchObject({online:false,unlocked:false,execution:'disabled',entries:[]});expect(status.headers['cache-control']).toBe('no-store')
+  await request(f.callback).post('/api/app/vault/unlock').set('Host','127.0.0.1:15300').set('Cookie',f.cookies).set('Origin','http://127.0.0.1:15300').send({password:'DUMMY-master'}).expect(403)
+  await f.mutation('/api/app/vault/unlock').set('Origin','https://evil.test').send({password:'DUMMY-master'}).expect(403)
+  const response=await f.mutation('/api/app/vault/unlock').send({password:'DUMMY-master'}).expect(503)
+  expect(response.body.code).toBe('vault_offline');expect(JSON.stringify(response.body)).not.toContain('DUMMY-master');expect(f.fetchImpl).not.toHaveBeenCalled()
+})
+it.each(['/api/app/logout','/auth/logout'])('logout path %s revokes vault before invalidating the authenticated session',async path=>{
+  const f=await fixture(),logout=vi.spyOn(f.runtime.credentialVault,'logout').mockImplementation(async(owner,c)=>{expect(f.runtime.auth.currentFromCookieHeader(c)?.id).toBe(owner)})
+  await f.mutation(path).send({}).expect(200);expect(logout).toHaveBeenCalledOnce()
+  await request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').set('Cookie',f.cookies).expect(401)
+})

+ 1 - 0
tests/server/credentialVaultStore.test.ts

@@ -64,6 +64,7 @@ it('locks on a fixed deadline and preserves the old decryptable file if rotation
   const guarded = await CredentialVaultStore.create(home, () => now, () => { if (fail) throw new Error('dummy write interruption') }); stores.push(guarded)
   guarded.unlock(owner, password, 30); guarded.add(owner, input); const original = guarded.backup(owner)
   fail = true; expect(() => guarded.rotate(owner, 'dummy-new-master-password')).toThrow('dummy write interruption')
+  expect(guarded.state(owner).unlocked).toBe(false);guarded.unlock(owner,password,30)
   expect(guarded.backup(owner)).toBe(original); expect(readdirSync(home)).toHaveLength(1)
   now += 30000; expect(guarded.state(owner).unlocked).toBe(false)
   fail = false; guarded.unlock(owner, password); expect(guarded.list(owner)).toHaveLength(1)

+ 9 - 0
tests/server/runner.test.ts

@@ -396,3 +396,12 @@ it('distinguishes automatically supported browsers from explicit node opt-out, o
     expect(()=>hub.browserRunner('owner',agent)).toThrow('尚未找到此 Bot 的执行节点')
   }finally{auth.allowed=true;state.features=features;states.set(record.id,state);store.put('_system','runner',record.id,record)}
 })
+it('advertises only vault references and binds them to the current Runner incarnation and source permission',()=>{
+  const agent={id:randomUUID(),nodeId:'local',profile:'default',archived:false} as any
+  const state=(hub as any).online.get(config.runnerId)
+  expect(state.features).toContain('credential-ref-v1');expect(state.features).not.toContain('credential-executor-v1')
+  expect(hub.credentialBinding('owner',agent)).toEqual({runnerId:config.runnerId,runnerInstance:state.instance,runnerEpoch:state.epoch})
+  auth.allowed=false;expect(()=>hub.credentialBinding('owner',agent)).toThrowError(expect.objectContaining({code:'vault_agent_forbidden'}));auth.allowed=true
+  state.features=state.features.filter((f:string)=>f!=='credential-ref-v1')
+  expect(()=>hub.credentialBinding('owner',agent)).toThrowError(expect.objectContaining({code:'vault_runner_upgrade_required'}))
+})