Просмотр исходного кода

修复默认密码保险箱接入与本机 Dashboard 重启

Samien 1 месяц назад
Родитель
Сommit
daa393ae64

+ 6 - 2
docs/credential-vault.md

@@ -1,6 +1,8 @@
 # Bot 内置密码库(首期)
 
-部署目标是 Hermes 执行节点,用户通过专用界面手动输入主密码。默认锁定,重启不自动解锁。密码库不是 Hermes 的环境变量或工具配置。
+默认安装提供夭夭服务器本机密码保险箱:登录后在「Bot 模式 → 设置 → 密码管理」创建主密码、手动解锁,即可添加网站密码和 SSH 私钥,无需先安装独立密码库服务。每个账号独立存储,密文位于夭夭数据目录的 `credential-vault/`;单实例锁防止其他 broker 同时写入。默认锁定,重启不自动解锁。它不向 Bot 提供秘密读取、任务租约或自动登录执行。
+
+配置 `HERMES_YAOYAO_VAULT_SOCKET` / `HERMES_YAOYAO_VAULT_TOKEN_FILE` 后,仍使用原有独立密码库服务;显式配置的服务不可用时保持离线,不切换到新的本机保险箱。独立服务可以部署在 Hermes 执行节点,并在隔离部署验收后启用受保护执行器。密码库不是 Hermes 的环境变量或工具配置。
 
 ## 密码学与存储
 
@@ -12,6 +14,8 @@
 
 ## 执行边界
 
+本机保险箱复用 Web/BFF 内的加密存储与会话校验,不属于独立 OS 身份隔离。主密码和解锁后的材料会进入可信服务器进程内存;同 UID 的任意 shell、调试器或被控制的服务器进程仍可能访问它们。它提供密文落盘和管理 API 的账号/会话隔离,不能据此声称具备抵御同 UID 攻击的隔离保证。需要 Bot 自动使用真实凭据时,应配置并验收下述独立服务;本机模式不会签发任务租约,也不会执行网站、SSH 或 SFTP 操作。
+
 同 OS 用户的 Hermes 任意 shell 可以读取同用户文件或访问进程/IPC,因此单纯子进程、0600 权限、secretref 或加密 DB 不构成隔离。真实部署前需人工批准独立 OS 身份/可信沙箱、私有 IPC、控制面访问权限以及浏览器进程/资料目录边界。Web 管理面也不能与拥有任意本机 shell 的 Hermes 共用可信身份。
 
 正式适配器现已实现,默认仍关闭;只有经过人工审核的隔离部署、私有批准文件、实际 Hermes 进程身份检查均有效时,CLI 才声明 `protected-adapters`。批准文件是管理员对 OS/IPC/CDP/网络边界的信任声明,不是代码自动证明。未配置、过期、身份变化、撤销时锁库并中止授权,不能退回通用 fill、host_shell、环境变量或临时密钥文件。配置不会创建 OS 身份或安装服务。配置与审阅步骤见 [部署模板](../deploy/credential-vault/README.md)。
@@ -41,7 +45,7 @@ Bot 工具只有 `credential_refs` 与 `credential_request`,输入严格为引
 1. 选择 Hermes 所在的同一执行节点,确认 Hermes 非 root 身份,并为 Web 管理面与 vault 分配不同于 Hermes 的非 root 可信服务身份。首期为同主机 Unix IPC;远程 Web → vault 网络通道尚未实现,不能将 Unix socket 暴露为 TCP 或经通用 Runner 转发主密码。
 2. 人工审核 broker、Web 管理面、配置、socket 父目录、控制 token、库目录的 ownership、ACL、进程调试/core dump、备份访问及管理浏览器隔离。代码检查私有权限、批准时效及 Hermes PID 的实际 UID,但这些检查不是 OS 边界的完整证据;Hermes 的 shell 不得读取控制 token、管理面登录会话或访问私有 IPC。两者为同用户时直接拒绝,不自动改 OS 设置。
 3. 在该可信控制身份下人工准备 0700 私有目录,以及 0600 配置和高熵控制 token 文件;token 仅用于 IPC 认证,绝不是静态解密密钥。配置结构 `{ "home": "/私有库目录", "socket": "/私有连接目录/vault.sock", "tokenFile": "/私有连接目录/control.token", "hermesUid": 12345 }` 中的 UID 是文档示例,必须填实测 UID,且不能与控制服务身份相同。不要把这些文件放入仓库或 Hermes 工作目录。
-4. 经后续部署批准后,使用 Node >=24、生产依赖和构建输出,人工前台执行 `node dist-server/server/credentialVault/index.js --config /私有路径/vault.json`。单实例控制避免两个 broker 同时写库;启动会检查而不会创建 OS 身份/安装守护进程。Web 配置 `HERMES_YAOYAO_VAULT_SOCKET`、`HERMES_YAOYAO_VAULT_TOKEN_FILE`、`HERMES_YAOYAO_VAULT_HERMES_UID`,它们只有路径/UID。未配置时 UI 明确离线;锁定或离线不自动降级。
+4. 经部署批准后,使用 Node >=24、生产依赖和构建输出,人工前台执行 `node dist-server/server/credentialVault/index.js --config /私有路径/vault.json`。单实例控制避免两个 broker 同时写库;启动会检查而不会创建 OS 身份/安装守护进程。Web 配置 `HERMES_YAOYAO_VAULT_SOCKET`、`HERMES_YAOYAO_VAULT_TOKEN_FILE`、`HERMES_YAOYAO_VAULT_HERMES_UID`,它们只有路径/UID。未配置外部服务时使用仅存储的本机保险箱;配置后连接失败保持离线,不自动降级。
 5. 用户亲自在专用 UI 创建并解锁。上线前还需验证真实 OS/IPC 攻击边界、HTTPS 管理通道及备份恢复,不把本地同进程 dummy 测试作为隔离验收。本轮没有实际创建服务身份、控制凭据、生产密码库或录入真实秘密。
 
 受保护执行器的部署仍需要独立安全验收:限定操作及单次提交,执行前/后检查授权,撤销中止;结果只返回脱敏状态,执行断线或取消结果不明确时禁止自动重试。浏览器填充阶段必须独占并隔离 DOM、请求、Cookie、截图、CDP 与同 UID shell;登录后的站点操作也须走受限动作/脱敏返回,不能向模型开放可读 Cookie 的通用浏览器。SSH/SFTP 要独立短连接及已确认的 host key,不能允许 shell 自选命令、任意跳板/代理、端口转发、agent forwarding、密钥导出或明文 FTP。

+ 2 - 1
docs/local-vm.md

@@ -47,7 +47,8 @@ Bot 通过虚拟机工具执行命令或打开网页后,正常回复结束只
 - “安装并启用”只启用选中的工具桥及其工具集,保留其他插件、禁用项和模型配置。每次安装保留原插件与配置备份,界面可查看备份位置。
 - 检查会比较插件文件指纹与 Hermes 启动时加载的指纹;同版本修复后未重启也会显示“待重启”,不会仅凭安装文件判断就绪。
 - 安装不会自动重启 Hermes。页面中的“重启服务端 Hermes Dashboard”始终通过当前连接的 Yaoyao 服务端执行;页面显示的 `127.0.0.1:9119` 指服务端本机,手机、浏览器或桌面客户端不会在自己的电脑上执行重启。重启影响整个 Dashboard,而非单个 Profile,完成后自动检查各 Profile 的实际加载状态。
-- 支持 Yaoyao 服务端自己持有的 Dashboard 子进程,以及 macOS 服务端的 `com.samien.hermes.dashboard.local` / `ai.hermes.dashboard` LaunchAgent。系统服务须使用 Hermes 启动命令,且其运行 PID 必须与服务端 9119 的唯一监听进程匹配;重启前重新核对,重启后确认新 PID 和 HTTP 健康状态。无需开启桌面模式或 Dashboard 自动拉起。
+- 支持 Yaoyao 服务端自己持有的 Dashboard 子进程,以及 macOS 服务端当前账号的 Hermes LaunchAgent(包括自定义服务名)。服务的运行 PID 必须与 9119 的唯一监听进程匹配,支持 `hermes dashboard/serve`、Python 脚本和 `python -m hermes_cli.main` 入口及 Profile 参数。
+- macOS 同账号通过终端启动的 Hermes 也可重启:检查唯一监听 PID、UID、Hermes 入口、父进程及有限的启动参数后,只向该 PID 发出 SIGTERM,确认退出和端口空闲后按原参数和工作目录启动。不会调用停止所有 Dashboard 的命令,不接管未知服务包装器或 SSH 会话后端。重启前重新核对,重启后确认新 PID 和 HTTP 健康状态;本机健康检查不继承外网代理。无需开启桌面模式或 Dashboard 自动拉起。
 - 正在执行任务、安装工具桥或已有重启操作时不可重启;重启期间暂不接受新任务,定时任务等待下一次检查。无法确认归属的外部进程、其他系统的外部服务、Docker 目录映射与远程 Hermes 会保留禁用按钮并显示原因,需在 Hermes 所在节点重启,再点击“重新检查”。正在执行任务时暂不可安装。
 - 本机安装用于当前服务可访问的本机 Hermes。Docker 可显式映射对应 Hermes 数据目录以安装工具桥,详见 [Docker 目录映射](docker-install.md#映射-hermes-目录以安装工具桥);其他远程节点支持状态检查,需在对应节点安装。
 

+ 1 - 1
playwright.config.ts

@@ -6,7 +6,7 @@ const upstreamPort = Number(process.env.FAKE_HERMES_PORT || 19119)
 export default defineConfig({
   testDir: './tests/e2e',
   // These suites own different fixtures and run with their dedicated configurations.
-  testIgnore: ['**/workspace-chat.spec.ts', '**/team-tools.spec.ts', '**/avatarReference.spec.ts', '**/avatarRoundTrip.spec.ts', '**/serverIdentity.spec.ts', '**/bot-panels.spec.ts', '**/grok-auth.spec.ts', '**/execution-auth.spec.ts', '**/native-environment.spec.ts', '**/hermes-bridge.spec.ts', '**/desktop-hosts.spec.ts'],
+  testIgnore: ['**/workspace-chat.spec.ts', '**/team-tools.spec.ts', '**/avatarReference.spec.ts', '**/avatarRoundTrip.spec.ts', '**/serverIdentity.spec.ts', '**/bot-panels.spec.ts', '**/grok-auth.spec.ts', '**/execution-auth.spec.ts', '**/native-environment.spec.ts', '**/hermes-bridge.spec.ts', '**/credential-vault.spec.ts', '**/desktop-hosts.spec.ts'],
   timeout: 30_000,
   fullyParallel: false,
   workers: 1,

+ 9 - 0
playwright.credential-vault.config.ts

@@ -0,0 +1,9 @@
+import { defineConfig, devices } from '@playwright/test'
+process.env.NO_PROXY=[process.env.NO_PROXY,'127.0.0.1','localhost'].filter(Boolean).join(',')
+process.env.no_proxy=[process.env.no_proxy,'127.0.0.1','localhost'].filter(Boolean).join(',')
+export default defineConfig({
+  testDir:'./tests/e2e',testMatch:'credential-vault.spec.ts',workers:1,timeout:60000,reporter:'list',
+  use:{baseURL:'http://127.0.0.1:18836',trace:'retain-on-failure'},
+  projects:[{name:'chromium',use:{...devices['Desktop Chrome'],viewport:{width:1440,height:1000}}}],
+  webServer:{command:'WORKSPACE_FIXTURE_PORT=18836 WORKSPACE_FIXTURE_UPSTREAM_PORT=19136 node --import tsx tests/fixtures/workspace-server.ts',url:'http://127.0.0.1:18836/healthz',reuseExistingServer:false,timeout:60000},
+})

+ 14 - 9
src/client/components/app/CredentialVaultPanel.vue

@@ -17,20 +17,24 @@ const loginPath=ref('/login'),submitPath=ref('/login'),successPath=ref('/account
 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='';contents.value='';clearPassphrase.value=false}
+function failure(cause:unknown){
+  const code=cause&&typeof cause==='object'&&'code' in cause?String(cause.code):''
+  return ({vault_offline:'密码保险箱连接不可用,请刷新状态或检查数据目录权限。',vault_locked:'密码保险箱已锁定,请重新手动解锁后添加凭据。',vault_unlock_failed:'主密码错误或密码库已损坏,请核对主密码。',vault_invalid_request:'填写内容不符合要求:主密码至少 12 位;网站须为不含路径的 HTTPS 地址;SSH 须提供 SHA256 主机指纹。',vault_executor_not_enabled:'本机保险箱可以保存凭据;Bot 自动使用需要连接独立密码库服务。'} as Record<string,string>)[code]??'操作未完成,请检查连接和主密码;秘密不会回显。'
+}
 async function request<T>(path:string,method:'GET'|'POST'|'PUT'|'DELETE'='GET',body?:Record<string,JsonValue>){
   const c=new AbortController();inflight.add(c)
-  try{return await apiRequest<T>(root+path,{method,...(body?{body}:{}),signal:c.signal,timeoutMs:20000})}
+  try{return await apiRequest<T>(root+path,{method,...(body?{body}:{}),signal:c.signal,timeoutMs:20000,notifyUnauthorized:false})}
   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='操作未完成,请检查连接、主密码和本任务授权;秘密不会回显。'}
+  catch(cause){if(alive){notice.value='';error.value=failure(cause)}}
   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 unlock(){await action(async()=>{await request('/unlock','POST',{password:master.value,seconds:300})},state.value?.storageMode==='local'?'已解锁 5 分钟,可以添加和编辑凭据。':'已手动解锁 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'
@@ -89,11 +93,12 @@ onBeforeUnmount(()=>{
 
 <template>
   <section class="credential-vault" :aria-busy="busy">
-    <p>秘密只在专用界面录入,加密保存在 Hermes 执行节点;不发送给 Bot。密码库默认锁定,重启或到期需重新手动解锁。</p>
-    <p role="status">{{state?.execution==='protected-adapters'?'受控执行器已取得人工部署批准:仅支持配置的标准表单、固定 SSH 命令和指定 SFTP 文件;仍须逐任务授权。':'受控网站与 SSH/SFTP 执行尚未启用。隔离部署批准前,由你人工接管。'}}</p>
+    <p>秘密只在专用界面录入,由主密码加密保存;不发送给 Bot。密码保险箱默认锁定,重启或到期需重新手动解锁。</p>
+    <p v-if="state?.storageMode==='local'" role="status">本机保险箱:凭据保存在夭夭服务器的数据目录,只有当前账号解锁后可以管理。保存凭据无需配置独立服务;Bot 自动使用需要连接独立密码库服务。</p>
+    <p v-else role="status">{{state?.execution==='protected-adapters'?'受控执行器已取得人工部署批准:仅支持配置的标准表单、固定 SSH 命令和指定 SFTP 文件;仍须逐任务授权。':'受控网站与 SSH/SFTP 执行尚未启用。隔离部署批准前,由你人工接管。'}}</p>
     <p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p>
     <button type="button" :disabled="busy" @click="action(load,'状态已刷新')">刷新状态</button>
-    <p v-if="!state?.online">执行节点密码库离线或尚未配置。需要管理员先批准独立服务身份和私有连接;此处不会自动安装或启动服务。</p>
+    <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">
@@ -105,7 +110,7 @@ onBeforeUnmount(()=>{
       </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>
+        <p>先输入主密码解锁,即可添加和编辑凭据。</p><button :disabled="busy">手动解锁 5 分钟</button>
       </form>
       <template v-else>
         <div class="actions"><button type="button" :disabled="busy" @click="lock">立即锁定并撤销授权</button><button type="button" :disabled="busy" @click="edit()">添加凭据</button><button type="button" :disabled="busy" @click="backup">导出加密备份</button></div>
@@ -119,7 +124,7 @@ onBeforeUnmount(()=>{
           <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>
           <label v-if="kind==='ssh'&&editing"><input v-model="clearPassphrase" type="checkbox" :disabled="busy" />清除原私钥口令(留空输入默认保留)</label>
-          <label><input v-model="configureUse" type="checkbox" :disabled="busy||!!editing?.usage" />配置允许 Bot 执行的固定操作</label>
+          <label v-if="state.storageMode!=='local'"><input v-model="configureUse" type="checkbox" :disabled="busy||!!editing?.usage" />配置允许 Bot 执行的固定操作</label>
           <template v-if="configureUse&&kind==='website'">
             <p>仅支持主页面的标准 POST 表单。允许跳转仅限此 origin 的成功页;iframe、跨站 SSO、MFA 或验证码转人工。</p>
             <label>登录页路径<input v-model="loginPath" required :disabled="busy" /></label><label>表单提交路径<input v-model="submitPath" required :disabled="busy" /></label><label>登录成功页路径<input v-model="successPath" required :disabled="busy" /></label>
@@ -136,7 +141,7 @@ onBeforeUnmount(()=>{
           </template>
           <div class="actions"><button :disabled="busy">加密保存</button><button type="button" :disabled="busy" @click="showEditor=false;clearSecrets()">取消</button></div>
         </form>
-        <form @submit.prevent="grant">
+        <form v-if="state.storageMode!=='local'" @submit.prevent="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}}

+ 1 - 1
src/client/components/app/SettingsCenterDialog.vue

@@ -156,7 +156,7 @@ const showAgentSelector = computed(() => !props.botMode && activePage.value.star
 const activeScope = computed(() => {
   if (activePage.value.startsWith('agent-')) return `正在设置:${profileTitle(props.activeProfile)} / ${props.activeProfile?.name || '未选择'}`
   if (activePage.value === 'account-profile') return '管理你的账号头像与服务器名称。'
-  if (activePage.value.startsWith('account-')) return `当前账号:${accountName.value}${props.isAdmin ? ' · 管理员' : ''}`
+  if (activePage.value.startsWith('account-') || activePage.value === 'credential-vault') return `当前账号:${accountName.value}${props.isAdmin ? ' · 管理员' : ''}`
   if (activePage.value === 'chat-appearance') return '预设与颜色 · 自动保存到当前设备'
   if (activePage.value === 'appearance') return '选择当前浏览器的显示方式。'
   if (activePage.value === 'desktop-mode') return '选择这台电脑作为服务器或客户端。'

+ 2 - 2
src/server/app.ts

@@ -1,7 +1,7 @@
 import { unreadSnapshot } from './unreadCenter.js'
 import {ExecutionSettings} from './executionSettings.js'
 import {CredentialVaultCoordinator} from './credentialVault/coordinator.js'
-import {CredentialVaultClient} from './credentialVault/transport.js'
+import {createCredentialVaultClient} from './credentialVault/localClient.js'
 import {ManagedBrowsers} from './managedBrowsers.js'
 import { isWorkspaceAccountPath } from './subaccountAccess.js'
 import { chatTranscriptRouter } from './chatTranscriptApi.js'
@@ -264,7 +264,7 @@ 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 credentialVault=new CredentialVaultCoordinator(createCredentialVaultClient(config.credentialVault,config.home),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

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

@@ -122,6 +122,7 @@ export class CredentialVaultCoordinator {
         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 (state.storageMode === 'local') throw new HttpError(409, 'Bot 自动使用需要连接独立密码库服务', 'vault_executor_not_enabled')
       if (!entry) throw new HttpError(403, '请解锁并选择当前账号的凭据', 'vault_credential_forbidden')
       const summary = await this.call(s, 'grant', { ...body, ...binding, target: entry.target }) as CredentialLeaseSummary
       try {
@@ -203,5 +204,5 @@ export class CredentialVaultCoordinator {
       },
     }
   }
-  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() }
+  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(); this.client.close() }
 }

+ 59 - 0
src/server/credentialVault/localClient.ts

@@ -0,0 +1,59 @@
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { CredentialVaultClient } from './transport.js'
+import { CredentialVaultStore } from './store.js'
+import { CredentialVaultBroker } from './broker.js'
+import { HttpError } from '../errors.js'
+import { acquireServiceInstance } from '../serviceInstance.js'
+
+/** Default encrypted storage. It never supplies an executor or issues Bot leases.
+ * Automatic credential use still requires the explicitly configured isolated broker. */
+export class LocalCredentialVaultClient extends CredentialVaultClient {
+  private readonly controlEpoch = randomUUID()
+  private broker?: Promise<CredentialVaultBroker>
+  private instance?: ReturnType<typeof acquireServiceInstance>
+  private closed = false
+
+  constructor(private readonly home: string) { super() }
+
+  override async call(owner: string, session: string, command: string, value?: unknown): Promise<any> {
+    if (this.closed) throw new HttpError(503, '密码保险箱已关闭', 'vault_offline')
+    if (['grant', 'execute'].includes(command))
+      throw new HttpError(409, '本机保险箱只保存凭据;Bot 自动使用需要连接独立密码库服务', 'vault_executor_not_enabled')
+    this.broker ??= CredentialVaultStore.create(join(this.home, 'credential-vault')).then(store => {
+      if (this.closed) throw new Error('closed')
+      this.instance = acquireServiceInstance(store.root, 'credential-vault-v1')
+      const broker = new CredentialVaultBroker(store)
+      broker.hello(this.controlEpoch)
+      return broker
+    }).catch(() => {
+      this.instance?.release(); this.instance = undefined
+      this.broker = undefined
+      throw new HttpError(503, '无法打开本机密码保险箱,请检查数据目录权限或是否已被其他服务使用', 'vault_offline')
+    })
+    const broker = await this.broker
+    if (this.closed) throw new HttpError(503, '密码保险箱已关闭', 'vault_offline')
+    try {
+      const result = broker.dispatch(this.controlEpoch, owner, session, command, value)
+      return ['status', 'initialize', 'unlock', 'restore'].includes(command)
+        ? { ...(result as object), storageMode: 'local' }
+        : result
+    } catch (error) {
+      if (error instanceof HttpError) throw error
+      broker.disconnect()
+      throw new HttpError(409, '密码保险箱操作未完成,请刷新状态后重新解锁', 'vault_request_failed')
+    }
+  }
+
+  override close(): void {
+    this.closed = true
+    void this.broker?.then(broker => {
+      try { broker.close() } finally { this.instance?.release(); this.instance = undefined }
+    }).catch(() => {})
+  }
+}
+
+export function createCredentialVaultClient(config: ConstructorParameters<typeof CredentialVaultClient>[0], home: string) {
+  // An unavailable explicit deployment must never fall back to a different vault.
+  return config ? new CredentialVaultClient(config) : new LocalCredentialVaultClient(home)
+}

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

@@ -43,6 +43,7 @@ export class CredentialVaultClient {
   onDisconnect = () => {}
   constructor(private config?: { socket: string; tokenFile: string; hermesUid: number }) {}
   get configured() { return !!this.config }
+  close(): void {}
   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')

+ 45 - 0
src/server/dashboardCommand.ts

@@ -0,0 +1,45 @@
+import { basename, dirname, isAbsolute } from 'node:path'
+
+const profile = /^[a-z0-9][a-z0-9_-]{0,63}$/
+
+/** Recognize Hermes' executable, script and module entry points, including serve. */
+export function dashboardCommandIndex(args: readonly string[]): number | undefined {
+  let index: number
+  const executable = basename(args[0] ?? '')
+  if (executable === 'hermes') index = 1
+  else if (/^python(?:\d+(?:\.\d+)*)?$/i.test(executable)) {
+    const script = args[1] ?? ''
+    if (basename(script) === 'hermes' || (basename(script) === 'main.py' && basename(dirname(script)) === 'hermes_cli')) index = 2
+    else if (script === '-m' && args[2] === 'hermes_cli.main') index = 3
+    else return
+  } else return
+  if (args[index] === '--profile' || args[index] === '-p') {
+    if (!profile.test(args[index + 1] ?? '')) return
+    index += 2
+  } else if (args[index]?.startsWith('--profile=')) {
+    if (!profile.test(args[index]!.slice(10))) return
+    index++
+  } else if (profile.test(args[index] ?? '') && ['dashboard', 'serve'].includes(args[index + 1] ?? '')) index++
+  return ['dashboard', 'serve'].includes(args[index] ?? '') ? index : undefined
+}
+
+/** ps does not preserve argv boundaries. Only simple, bounded runtime flags can
+ * be replayed; shell wrappers, SSH backends and ambiguous paths are rejected. */
+export function standaloneDashboardCommand(command: string): { command: string; args: string[] } | undefined {
+  if (command.length > 8192 || /[\n\r'"\\]/.test(command)) return
+  const args = command.trim().split(/\s+/), index = dashboardCommandIndex(args)
+  if (index === undefined || !isAbsolute(args[0]!) || args.length > 32) return
+  if (index >= 2 && args[1] !== '-m' && !isAbsolute(args[1]!)) return
+  for (let i = index + 1; i < args.length; i++) {
+    const flag = args[i]!
+    if (['--no-open', '--skip-build', '--insecure', '--isolated'].includes(flag)) continue
+    const match = /^(--host|--port|--profile)(?:=(.*))?$/.exec(flag)
+    if (!match) return
+    const value = match[2] ?? args[++i]
+    if (!value) return
+    if (match[1] === '--host' && !['127.0.0.1', 'localhost', '0.0.0.0', '::', '::1'].includes(value)) return
+    if (match[1] === '--port' && value !== '9119') return
+    if (match[1] === '--profile' && !profile.test(value)) return
+  }
+  return { command: args[0]!, args: args.slice(1) }
+}

+ 100 - 24
src/server/dashboardController.ts

@@ -1,6 +1,8 @@
-import { execFile } from 'node:child_process'
-import { basename } from 'node:path'
+import { execFile, spawn } from 'node:child_process'
 import { promisify } from 'node:util'
+import { isAbsolute } from 'node:path'
+import { dashboardCommandIndex, standaloneDashboardCommand } from './dashboardCommand.js'
+import { LoopbackTransport } from './loopbackAuthorization.js'
 
 export interface DashboardController {
   readonly canRestart: boolean
@@ -10,6 +12,7 @@ export interface DashboardController {
 }
 
 type Run = (command: string, args: string[]) => Promise<string>
+type Launch = (command: string, args: string[], cwd: string) => Promise<number>
 interface Options {
   platform?: NodeJS.Platform
   uid?: number
@@ -17,8 +20,10 @@ interface Options {
   healthy?: (origin: string) => Promise<boolean>
   readyTimeoutMs?: number
   pollIntervalMs?: number
+  launch?: Launch
 }
-interface Service { target: string; pid: number }
+type Service = { kind: 'launchagent'; target: string; pid: number }
+  | { kind: 'standalone'; command: string; args: string[]; cwd: string; pid: number }
 interface OwnedDashboard extends DashboardController { suspendChecks?(): () => void }
 
 const execFileAsync = promisify(execFile)
@@ -26,13 +31,22 @@ const labels = ['com.samien.hermes.dashboard.local', 'ai.hermes.dashboard']
 const localOrigin = 'http://127.0.0.1:9119'
 
 async function healthy(origin: string): Promise<boolean> {
+  const transport = new LoopbackTransport()
   try {
-    const response = await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(1000), redirect: 'error' })
+    const response = await transport.fetch(new URL(`${origin}/api/health`), { signal: AbortSignal.timeout(1000) })
     await response.body?.cancel()
     return response.ok
-  } catch { return false }
+  } catch { return false } finally { transport.close() }
 }
 
+const launch: Launch = (command, args, cwd) => new Promise((resolve, reject) => {
+  const env = { ...process.env }
+  for (const key of Object.keys(env)) if (key.startsWith('HERMES_YAOYAO_') || key.startsWith('ELECTRON_')) delete env[key]
+  const child = spawn(command, args, { cwd, env, detached: true, stdio: 'ignore' })
+  child.once('error', reject)
+  child.once('spawn', () => { child.unref(); child.pid ? resolve(child.pid) : reject(new Error('Hermes Dashboard 启动失败。')) })
+})
+
 /** Runs only in the Yaoyao server. No browser, desktop IPC or client-supplied target is used. */
 export class ServerDashboardController implements DashboardController {
   private readonly platform: NodeJS.Platform
@@ -41,6 +55,8 @@ export class ServerDashboardController implements DashboardController {
   private readonly healthy: NonNullable<Options['healthy']>
   private readonly timeout: number
   private readonly interval: number
+  private readonly launch: Launch
+  private launchedPid?: number
   private service: Service | undefined
   private restarting = false
   private stopped = false
@@ -50,8 +66,9 @@ export class ServerDashboardController implements DashboardController {
     this.uid = options.uid ?? process.getuid?.()
     this.run = options.run ?? (async (command, args) => (await execFileAsync(command, args, { timeout: 3000, maxBuffer: 256 * 1024 })).stdout)
     this.healthy = options.healthy ?? healthy
-    this.timeout = options.readyTimeoutMs ?? 15000
+    this.timeout = options.readyTimeoutMs ?? 30000
     this.interval = options.pollIntervalMs ?? 250
+    this.launch = options.launch ?? launch
   }
 
   get canRestart(): boolean {
@@ -61,7 +78,7 @@ export class ServerDashboardController implements DashboardController {
   get unavailableReason(): string {
     if (this.upstream.origin !== localOrigin) return '此 Hermes 不在夭夭服务端可管理的本机地址,请在 Hermes 所在节点重启。'
     if (this.platform !== 'darwin') return '夭夭服务端尚未支持此系统的外部 Dashboard 服务管理,请在 Hermes 所在节点重启。'
-    return '夭夭服务端未识别到与 9119 监听进程匹配的 Hermes Dashboard 系统服务,请在 Hermes 所在节点重启。'
+    return '夭夭服务端未识别到当前账号可管理的 9119 Hermes Dashboard 进程;请确认由本机 Hermes 命令启动且与夭夭使用同一系统账号。'
   }
 
   async refresh(): Promise<void> {
@@ -74,22 +91,78 @@ export class ServerDashboardController implements DashboardController {
 
   private async discover(): Promise<Service | undefined> {
     if (this.stopped || this.upstream.origin !== localOrigin || this.platform !== 'darwin' || this.uid === undefined) return
+    const pids = await this.listeners().catch(() => [])
+    if (pids.length !== 1) return
+    const pid = pids[0]!
     for (const label of labels) {
-      const target = `gui/${this.uid}/${label}`
-      try {
-        const detail = await this.run('/bin/launchctl', ['print', target])
-        const pid = Number(detail.match(/^\s*pid = (\d+)\s*$/m)?.[1])
-        const args = detail.match(/^\s*arguments = \{\n([\s\S]*?)^\s*\}/m)?.[1]?.trim().split('\n').map(line => line.trim()) ?? []
-        const dashboard = args.indexOf('dashboard')
-        // Allow the Hermes executable or its Python entry point, never an arbitrary job with a familiar label.
-        const hermes = dashboard === 1 && basename(args[0]!) === 'hermes'
-          || dashboard === 2 && /^python(?:\d+(?:\.\d+)*)?$/.test(basename(args[0]!)) && basename(args[1]!) === 'hermes'
-        if (!pid || !hermes) continue
-        const listeners = await this.run('/usr/sbin/lsof', ['-nP', '-a', '-iTCP:9119', '-sTCP:LISTEN', '-Fp'])
-        const pids = new Set([...listeners.matchAll(/^p(\d+)$/gm)].map(match => Number(match[1])))
-        if (pids.size === 1 && pids.has(pid)) return { target, pid }
-      } catch { /* Missing job, permissions or an unknown listener: fail closed. */ }
+      const service = await this.launchAgent(label, pid)
+      if (service) return service
+    }
+    // Custom labels are common in manual installs. The listener PID, rather than
+    // a label alone, identifies the only candidate we may inspect and restart.
+    let jobs: { pid: number; label: string }[]
+    try {
+      const listing = await this.run('/bin/launchctl', ['list'])
+      jobs = [...listing.matchAll(/^\s*(\d+)\s+-?\d+\s+(\S+)\s*$/gm)].map(m => ({ pid: Number(m[1]), label: m[2]! }))
+    } catch { return }
+    const job = jobs.find(job => job.pid === pid)
+    if (job) return this.launchAgent(job.label, pid)
+    const process = await this.process(pid)
+    if (!process || process.uid !== this.uid || this.uid === 0 || (pid !== this.launchedPid && jobs.some(job => job.pid === process.parent))) return
+    const invocation = standaloneDashboardCommand(process.command)
+    if (!invocation) return
+    if (process.parent !== 1 && pid !== this.launchedPid) {
+      const parent = await this.process(process.parent)
+      if (!parent || parent.uid !== this.uid || !/^(?:\S*\/)?-?(?:zsh|bash|sh|fish)(?:\s|$)/.test(parent.command)) return
+    }
+    // Module entry points can depend on the original working directory.
+    try {
+      const detail = await this.run('/usr/sbin/lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'])
+      const paths = detail.split('\n').filter(line => line.startsWith('n')).map(line => line.slice(1))
+      const cwd = paths[0]
+      if (paths.length === 1 && cwd && isAbsolute(cwd) && !/[\r\0]/.test(cwd)) return { kind: 'standalone', pid, cwd, ...invocation }
+    } catch { /* Do not stop a process we cannot reliably relaunch. */ }
+  }
+
+  private async listeners(): Promise<number[]> {
+    try {
+      const listeners = await this.run('/usr/sbin/lsof', ['-nP', '-a', '-iTCP:9119', '-sTCP:LISTEN', '-Fp'])
+      return [...new Set([...listeners.matchAll(/^p(\d+)$/gm)].map(match => Number(match[1])))]
+    } catch (error) {
+      if ((error as { code?: number }).code === 1) return []
+      throw error
+    }
+  }
+
+  private async launchAgent(label: string, listener: number): Promise<Service | undefined> {
+    if (!/^[A-Za-z0-9_.-]+$/.test(label)) return
+    const target = `gui/${this.uid}/${label}`
+    try {
+      const detail = await this.run('/bin/launchctl', ['print', target])
+      const pid = Number(detail.match(/^\s*pid = (\d+)\s*$/m)?.[1])
+      const args = detail.match(/^\s*arguments = \{\n([\s\S]*?)^\s*\}/m)?.[1]?.trim().split('\n').map(line => line.trim()) ?? []
+      if (pid === listener && dashboardCommandIndex(args) !== undefined) return { kind: 'launchagent', target, pid }
+    } catch { /* Missing job or unknown command: fail closed. */ }
+  }
+
+  private async process(pid: number): Promise<{ uid: number; parent: number; command: string } | undefined> {
+    try {
+      const detail = await this.run('/bin/ps', ['-p', String(pid), '-o', 'uid=', '-o', 'ppid=', '-o', 'command='])
+      const match = /^\s*(\d+)\s+(\d+)\s+([^\r\n]+)\s*$/.exec(detail)
+      if (match) return { uid: Number(match[1]), parent: Number(match[2]), command: match[3]!.trim() }
+    } catch { /* Process has exited or is unreadable. */ }
+  }
+
+  private async restartStandalone(service: Extract<Service, { kind: 'standalone' }>): Promise<void> {
+    // discover() has just checked the owner, executable and sole listener.
+    await this.run('/bin/kill', ['-TERM', String(service.pid)])
+    const deadline = Date.now() + this.timeout
+    while (!this.stopped && await this.process(service.pid)) {
+      if (Date.now() >= deadline) throw new Error('Hermes Dashboard 未能停止,请在所在节点检查。')
+      await new Promise(resolve => setTimeout(resolve, this.interval))
     }
+    if (this.stopped || (await this.listeners()).length) throw new Error('9119 已被其他进程占用,未启动新的 Dashboard。')
+    this.launchedPid = await this.launch(service.command, service.args, service.cwd)
   }
 
   async restart(): Promise<void> {
@@ -107,13 +180,16 @@ export class ServerDashboardController implements DashboardController {
         // Refresh ownership at action time, even if the UI previously reported restart availability.
         previous = await this.discover()
         if (!previous || this.stopped) throw new Error(this.unavailableReason)
-        await this.run('/bin/launchctl', ['kickstart', '-k', previous.target])
+        if (previous.kind === 'launchagent') await this.run('/bin/launchctl', ['kickstart', '-k', previous.target])
+        else await this.restartStandalone(previous)
       }
       const deadline = Date.now() + this.timeout
       while (!this.stopped && Date.now() < deadline) {
         const replacement = owned ? undefined : await this.discover()
-        const replaced = owned ? this.owned?.canRestart
-          : replacement?.target === previous!.target && replacement.pid !== previous!.pid
+        const replaced = owned ? this.owned?.canRestart : replacement && previous && replacement.pid !== previous.pid
+          && (replacement.kind === 'launchagent' && previous.kind === 'launchagent' ? replacement.target === previous.target
+            : replacement.kind === 'standalone' && previous.kind === 'standalone' && replacement.pid === this.launchedPid
+              && replacement.command === previous.command && replacement.cwd === previous.cwd && JSON.stringify(replacement.args) === JSON.stringify(previous.args))
         if (replaced && await this.healthy(this.upstream.origin) && !this.stopped) {
           this.service = replacement
           return

+ 1 - 0
src/shared/credentialVault.ts

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

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

@@ -14,6 +14,27 @@ it('shows an offline state without offering secret entry and explicitly keeps re
   state.online=false;const w=await setup()
   expect(w.find('input[type=password]').exists()).toBe(false);expect(w.text()).toContain('尚未启用');expect(w.text()).toContain('不会自动安装或启动服务')
 })
+it('offers creation and credential entry in local storage mode without offering Bot leases',async()=>{
+  state.initialized=false;state.storageMode='local';const w=await setup()
+  expect(w.text()).toContain('保存凭据无需配置独立服务');expect(w.text()).toContain('创建密码库')
+  state.initialized=true;state.unlocked=true
+  await w.findAll('button').find(b=>b.text()==='刷新状态')!.trigger('click');await flushPromises()
+  await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
+  const editor=w.findAll('form')[0]!,label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
+  await label('名称').get('input').setValue('Dummy site');await label('用户名').get('input').setValue('dummy')
+  await label('准确网站').get('input').setValue('https://example.test');await label('密码').get('input').setValue('DUMMY-new-secret')
+  await editor.trigger('submit');await flushPromises()
+  expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:{name:'Dummy site',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret:'DUMMY-new-secret'}}))
+  expect(w.text()).not.toContain('授予本任务');expect(w.text()).not.toContain('DUMMY-new-secret');expect(w.find('input[type=password]').exists()).toBe(true)
+})
+it('reports a wrong vault master password without expiring the application login or echoing errors',async()=>{
+  const w=await setup();await w.get('input[type=password]').setValue('DUMMY-wrong-master')
+  api.mockImplementation(async(path)=>{if(path.endsWith('/unlock'))throw Object.assign(new Error('DUMMY-private-error-DUMMY-wrong-master'),{code:'vault_unlock_failed'});return structuredClone(state)})
+  await w.get('form').trigger('submit');await flushPromises()
+  expect(api).toHaveBeenCalledWith('/api/app/vault/unlock',expect.objectContaining({notifyUnauthorized:false}))
+  expect(w.get('[role=alert]').text()).toContain('主密码错误');expect(w.text()).not.toContain('DUMMY-private-error');expect(w.text()).not.toContain('DUMMY-wrong-master')
+  expect(w.get<HTMLInputElement>('input[type=password]').element.value).toBe('')
+})
 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')

+ 8 - 0
tests/client/settingsCenterDialog.test.ts

@@ -44,6 +44,7 @@ const childStubs = {
   AgentIdentityPanel: simpleStub('AgentIdentityPanel', 'agent-identity'),
   ModelServicesPanel: ModelServicesPanelStub,
   AccountSecurityPanel: simpleStub('AccountSecurityPanel', 'account-security'),
+  CredentialVaultPanel: simpleStub('CredentialVaultPanel', 'credential-vault'),
   NodePairingPanel: simpleStub('NodePairingPanel', 'node-pairing'),
   SystemOverviewPanel: simpleStub('SystemOverviewPanel', 'system-overview'),
   SystemManagementPanel: simpleStub('SystemManagementPanel', 'system-management'),
@@ -82,6 +83,13 @@ afterEach(() => {
 })
 
 describe('Settings center dialog', () => {
+  it('describes vault management as belonging to the signed-in account', () => {
+    const wrapper=mountSettings({botMode:true,initialPage:'credential-vault'})
+    expect(wrapper.get('.settings-content__heading').text()).toContain('当前账号:owner')
+    expect(wrapper.get('.settings-content__heading').text()).not.toContain('全局设置')
+    expect(wrapper.find('[data-testid=credential-vault]').exists()).toBe(true)
+    wrapper.unmount()
+  })
   it('keeps desktop runtime controls out of a browser', () => {
     const wrapper = mountSettings({ initialPage: 'desktop-mode' })
     expect(wrapper.findAll('.settings-sidebar nav button').map(button => button.text())).not.toContain('运行模式')

+ 46 - 0
tests/e2e/credential-vault.spec.ts

@@ -0,0 +1,46 @@
+import { test, expect } from '@playwright/test'
+import { mkdirSync } from 'node:fs'
+
+test('a default installation creates and saves encrypted passwords through the settings UI', async ({ page }) => {
+  const evidence='test-results/credential-vault-default';mkdirSync(evidence,{recursive:true})
+  const master='DUMMY-browser-vault-master',secret='DUMMY-browser-entry-secret'
+  await page.goto('/conversations')
+  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
+  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
+  await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
+  await page.getByRole('menuitem',{name:'我的设置',exact:true}).click()
+  await page.getByRole('button',{name:'密码管理',exact:true}).click()
+  const panel=page.locator('.credential-vault')
+  await expect(panel.getByText(/保存凭据无需配置独立服务/)).toBeVisible()
+  await panel.getByLabel('主密码',{exact:true}).fill(master)
+  await panel.getByLabel('确认主密码',{exact:true}).fill(master)
+  await panel.getByRole('button',{name:'创建密码库',exact:true}).click()
+  await expect(panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true})).toBeVisible()
+  await panel.getByLabel('主密码',{exact:true}).fill(master)
+  await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click()
+  await panel.getByRole('button',{name:'添加凭据',exact:true}).click()
+  await panel.getByLabel('名称',{exact:true}).fill('浏览器测试网站')
+  await panel.getByLabel('用户名',{exact:true}).fill('dummy-user')
+  await panel.getByLabel('准确网站 origin',{exact:true}).fill('https://example.test')
+  await panel.getByLabel('密码',{exact:true}).fill(secret)
+  await panel.getByRole('button',{name:'加密保存',exact:true}).click()
+  await expect(panel.getByText('浏览器测试网站',{exact:true})).toBeVisible()
+  await expect(panel.getByRole('status').filter({hasText:'凭据已加密保存'})).toBeVisible()
+  await expect(panel.getByRole('button',{name:'授予本任务 60 秒引用权限'})).toHaveCount(0)
+  expect(await panel.textContent()).not.toContain(secret)
+  await page.screenshot({path:evidence+'/desktop-saved.png'})
+  await page.setViewportSize({width:375,height:812})
+  await expect(panel.getByRole('button',{name:'添加凭据',exact:true})).toBeVisible()
+  expect(await panel.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
+  await page.screenshot({path:evidence+'/mobile-saved.png'})
+  await page.setViewportSize({width:1440,height:1000})
+  await panel.getByRole('button',{name:'立即锁定并撤销授权',exact:true}).click()
+  await expect(panel.getByText('浏览器测试网站',{exact:true})).toHaveCount(0)
+  await panel.getByLabel('主密码',{exact:true}).fill('DUMMY-wrong-master-password')
+  await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click()
+  await expect(panel.getByRole('alert')).toContainText('主密码错误')
+  await expect(page.getByRole('textbox',{name:'账号',exact:true})).toHaveCount(0)
+  await expect(panel.getByLabel('主密码',{exact:true})).toHaveValue('')
+  await page.screenshot({path:evidence+'/wrong-master-stays-signed-in.png'})
+})

+ 60 - 0
tests/server/credentialVaultLocalClient.test.ts

@@ -0,0 +1,60 @@
+// @vitest-environment node
+import { afterEach, expect, it } from 'vitest'
+import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { createCredentialVaultClient, LocalCredentialVaultClient } from '../../src/server/credentialVault/localClient'
+
+const homes: string[] = [], clients: LocalCredentialVaultClient[] = []
+const session = 'a'.repeat(64), password = 'DUMMY-local-master-only', secret = 'DUMMY-local-website-secret'
+function client(home = mkdtempSync(join(tmpdir(), 'yaoyao-local-vault-'))) {
+  if (!homes.includes(home)) homes.push(home)
+  const value = new LocalCredentialVaultClient(home); clients.push(value)
+  return { home, value, call: (command: string, body?: unknown, owner = 'dummy-owner', binding = session) => value.call(owner, binding, command, body) }
+}
+afterEach(async () => { clients.splice(0).forEach(c => c.close()); await Promise.resolve(); homes.splice(0).forEach(h => rmSync(h, { recursive: true, force: true })) })
+
+it('provides encrypted local CRUD by default without an external service or Bot executor', async () => {
+  const f = client()
+  expect(await f.call('status')).toMatchObject({ online: true, initialized: false, unlocked: false, execution: 'disabled', storageMode: 'local' })
+  await f.call('initialize', { password })
+  await expect(f.call('add', {})).rejects.toMatchObject({ code: 'vault_locked' })
+  await f.call('unlock', { password })
+  const entry = await f.call('add', { name: 'Dummy website', username: 'dummy', target: { kind: 'website', origin: 'https://example.test' }, secret })
+  expect(entry).not.toHaveProperty('secret')
+  const stored = readdirSync(join(f.home, 'credential-vault')).filter(name => name.endsWith('.vault')).map(name => readFileSync(join(f.home, 'credential-vault', name), 'utf8')).join('')
+  expect(stored).not.toContain(secret); expect(stored).not.toContain(password); expect(stored).not.toContain('Dummy website')
+  expect((await f.call('status')).entries).toHaveLength(1)
+  await expect(f.call('grant', {})).rejects.toMatchObject({ code: 'vault_executor_not_enabled' })
+  await expect(f.call('execute', {})).rejects.toMatchObject({ code: 'vault_executor_not_enabled' })
+  await f.call('update', { id: entry.id, entry: { name: 'Updated', username: 'dummy', target: entry.target, revision: entry.revision } })
+  expect((await f.call('status')).entries[0].name).toBe('Updated')
+  await f.call('remove', { id: entry.id }); expect((await f.call('status')).entries).toEqual([])
+})
+
+it('isolates accounts and login sessions and starts locked after a server restart', async () => {
+  const f = client(); await f.call('initialize', { password }); await f.call('unlock', { password })
+  await f.call('add', { name: 'Dummy', username: 'dummy', target: { kind: 'website', origin: 'https://example.test' }, secret })
+  expect(await f.call('status', undefined, 'another-owner')).toMatchObject({ initialized: false, entries: [] })
+  expect(await f.call('status', undefined, 'dummy-owner', 'b'.repeat(64))).toMatchObject({ unlocked: false, entries: [] })
+  await expect(f.call('add', {}, 'dummy-owner', 'b'.repeat(64))).rejects.toMatchObject({ code: 'vault_locked' })
+  f.value.close(); await Promise.resolve()
+  const restarted = client(f.home)
+  expect(await restarted.call('status')).toMatchObject({ initialized: true, unlocked: false, entries: [] })
+  await restarted.call('unlock', { password }); expect((await restarted.call('status')).entries).toHaveLength(1)
+})
+
+it('keeps an explicitly configured but unavailable private broker offline', async () => {
+  const c = createCredentialVaultClient({ socket: '/missing/socket', tokenFile: '/missing/token', hermesUid: process.getuid!() + 1 }, '/unused')
+  expect(c).not.toBeInstanceOf(LocalCredentialVaultClient)
+  await expect(c.call('owner', session, 'status')).rejects.toMatchObject({ code: 'vault_offline' })
+  c.close()
+})
+
+it('prevents concurrent brokers from writing the same local vault', async () => {
+  const first = client(); await first.call('initialize', { password })
+  const second = client(first.home)
+  await expect(second.call('status')).rejects.toMatchObject({ code: 'vault_offline' })
+  first.value.close(); await Promise.resolve()
+  expect(await second.call('status')).toMatchObject({ initialized: true, unlocked: false })
+})

+ 19 - 3
tests/server/credentialVaultRoutes.test.ts

@@ -11,10 +11,11 @@ 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(){
+async function fixture(external=false){
   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
+  if(external)config.credentialVault={socket:join(home,'missing.sock'),tokenFile:join(home,'missing.token'),hermesUid:process.getuid!()+1}
   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)
@@ -22,8 +23,8 @@ async function fixture(){
   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()
+it('vault routes enforce authentication, exact Origin and CSRF and keep an unavailable explicit broker offline',async()=>{
+  const f=await fixture(true)
   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')
@@ -32,6 +33,21 @@ it('vault routes enforce authentication, exact Origin and CSRF and stay offline
   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('lets a logged-in administrator create, unlock and add a password in the default installation',async()=>{
+  const f=await fixture(),password='DUMMY-default-vault-master',secret='DUMMY-default-entry-secret'
+  const status=()=>request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').set('Cookie',f.cookies)
+  expect((await status().expect(200)).body).toMatchObject({online:true,initialized:false,storageMode:'local'})
+  await request(f.callback).post('/api/app/vault/initialize').set('Host','127.0.0.1:15300').set('Cookie',f.cookies).set('Origin','http://127.0.0.1:15300').send({password}).expect(403)
+  await f.mutation('/api/app/vault/initialize').send({password}).expect(200)
+  expect((await status().expect(200)).body.unlocked).toBe(false)
+  await f.mutation('/api/app/vault/unlock').send({password}).expect(200)
+  const added=await f.mutation('/api/app/vault/entries').send({name:'Default site',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret}).expect(200)
+  expect(added.body).not.toHaveProperty('secret')
+  const unlocked=await status().expect(200);expect(unlocked.body.entries).toHaveLength(1);expect(JSON.stringify(unlocked.body)).not.toContain(secret)
+  await f.mutation('/api/app/vault/lock').send({}).expect(200)
+  expect((await status().expect(200)).body).toMatchObject({unlocked:false,entries:[]})
+  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()

+ 78 - 0
tests/server/dashboardController.test.ts

@@ -14,6 +14,7 @@ function fixture(options: { upstream?: URL; platform?: NodeJS.Platform; label?:
       return `${service} = {\n\targuments = {\n\t\t${options.command ?? '/server/.hermes/venv/bin/python\n\t\t/server/.hermes/hermes-agent/hermes\n\t\tdashboard'}\n\t\t--host\n\t\t127.0.0.1\n\t}\n\tpid = ${pid}\n}`
     }
     if (command === '/usr/sbin/lsof') return `p${listener}\n`
+    if (command === '/bin/launchctl' && args[0] === 'list') return available ? `${pid}\t0\t${service.split('/').at(-1)}\n` : ''
     if (command === '/bin/launchctl' && args[0] === 'kickstart') { pid = replacement; listener = replacement; return '' }
     throw new Error('unexpected command')
   })
@@ -48,6 +49,17 @@ it('supports the standard Hermes LaunchAgent only when it owns the configured li
   expect(f.mutations()[0]?.[1]).toEqual(['kickstart', '-k', 'gui/501/ai.hermes.dashboard'])
 })
 
+it.each([
+  '/server/.hermes/venv/bin/python3\n\t\t-m\n\t\thermes_cli.main\n\t\tdashboard',
+  '/server/.hermes/venv/bin/python\n\t\t/server/.hermes/hermes-agent/hermes_cli/main.py\n\t\tserve',
+  '/server/.local/bin/hermes\n\t\t--profile\n\t\tyaoyao\n\t\tdashboard',
+])('recognizes supported Hermes entry points under a custom LaunchAgent label: %s', async command => {
+  const f = fixture({ label: 'local.custom.hermes', command })
+  await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
+  await f.controller.restart()
+  expect(f.mutations()[0]?.[1]).toEqual(['kickstart', '-k', 'gui/501/local.custom.hermes'])
+})
+
 it.each([
   new URL('https://hermes.example'), new URL('http://192.168.1.5:9119'), new URL('http://127.0.0.1:9120'),
 ])('never runs local commands for an upstream on another server or port: %s', async upstream => {
@@ -134,3 +146,69 @@ it('serializes restarts and stops verification when the server shuts down', asyn
   expect(f.controller.canRestart).toBe(false)
   expect(f.run).not.toHaveBeenCalled()
 })
+
+function standaloneFixture() {
+  let pid = 1043, running = true, uid = 501, parent = 99
+  let command = '/server/venv/bin/python3 -m hermes_cli.main dashboard --host 0.0.0.0 --port 9119 --no-open'
+  let jobs = '', stopped = true, cwd = '/server/hermes-agent'
+  const run = vi.fn(async (program: string, args: string[]) => {
+    if (program === '/usr/sbin/lsof') return args.includes('cwd') ? `p${pid}\nfcwd\nn${cwd}\n` : running ? `p${pid}\n` : ''
+    if (program === '/bin/launchctl' && args[0] === 'list') return jobs
+    if (program === '/bin/launchctl') throw new Error('no managed job')
+    if (program === '/bin/ps') {
+      if (args[1] === '99') return '501 98 /bin/zsh -l\n'
+      if (running && args[1] === String(pid)) return `${uid} ${parent} ${command}\n`
+      throw new Error('process exited')
+    }
+    if (program === '/bin/kill' && args[1] === String(pid)) { if (stopped) running = false; return '' }
+    throw new Error('unexpected command')
+  })
+  const launch = vi.fn(async (program: string, args: string[], directory: string) => { pid = 2043; parent = 500; jobs = '500\t0\tcom.samien.hermes-yaoyao\n'; running = true; command = [program, ...args].join(' '); cwd = directory; return pid })
+  const healthy = vi.fn(async () => true)
+  const controller = new ServerDashboardController(origin, undefined, { platform: 'darwin', uid: 501, run, launch, healthy, readyTimeoutMs: 15, pollIntervalMs: 1 })
+  return { controller, run, launch, healthy, set: (value: { uid?: number; command?: string; jobs?: string; stopped?: boolean; parent?: number; cwd?: string }) => {
+    if (value.uid !== undefined) uid = value.uid
+    if (value.command !== undefined) command = value.command
+    if (value.jobs !== undefined) jobs = value.jobs
+    if (value.stopped !== undefined) stopped = value.stopped
+    if (value.parent !== undefined) parent = value.parent
+    if (value.cwd !== undefined) cwd = value.cwd
+  } }
+}
+
+it('restarts a verified terminal-launched Hermes listener while preserving its entry point and binding', async () => {
+  const f = standaloneFixture()
+  await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
+  expect(f.launch).not.toHaveBeenCalled()
+  await f.controller.restart()
+  expect(f.run).toHaveBeenCalledWith('/bin/kill', ['-TERM', '1043'])
+  expect(f.launch).toHaveBeenCalledWith('/server/venv/bin/python3', ['-m', 'hermes_cli.main', 'dashboard', '--host', '0.0.0.0', '--port', '9119', '--no-open'], '/server/hermes-agent')
+  expect(f.healthy).toHaveBeenCalledWith(origin.origin)
+  await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
+})
+
+it.each([
+  { uid: 502 }, { command: '/usr/bin/node /server/dashboard.js' },
+  { command: '/bin/sh -c /server/hermes dashboard' },
+  { command: '/server/hermes dashboard --ssh-session-token-file /private/token' },
+  { command: '/server/hermes dashboard --port 9120' },
+  { jobs: '1043\t0\tlocal.unknown.service\n' },
+  { jobs: '99\t0\tlocal.wrapper.service\n' }, { parent: 444 }, { cwd: '' },
+])('refuses to stop an unverified standalone listener: %j', async state => {
+  const f = standaloneFixture(); f.set(state)
+  await f.controller.refresh(); expect(f.controller.canRestart).toBe(false)
+  await expect(f.controller.restart()).rejects.toThrow('未识别到')
+  expect(f.launch).not.toHaveBeenCalled(); expect(f.run.mock.calls.some(([program]) => program === '/bin/kill')).toBe(false)
+})
+
+it('rechecks a standalone process owner immediately before the restart', async () => {
+  const f = standaloneFixture(); await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
+  f.set({ uid: 502 }); await expect(f.controller.restart()).rejects.toThrow('未识别到')
+  expect(f.launch).not.toHaveBeenCalled(); expect(f.run.mock.calls.some(([program]) => program === '/bin/kill')).toBe(false)
+})
+
+it('does not launch a competitor if the original standalone process ignores termination', async () => {
+  const f = standaloneFixture(); f.set({ stopped: false })
+  await expect(f.controller.restart()).rejects.toThrow('未能停止')
+  expect(f.launch).not.toHaveBeenCalled(); expect(f.controller.canRestart).toBe(false)
+})