17 Commits 40cce7413b ... 9121d09899

Author SHA1 Message Date
  Samien 9121d09899 修复子账号未读中心访问并验证账号隔离与 CSRF 1 month ago
  Samien 8f207e61ac 准备 v0.4.78 发布:密码管理、未读中心与同步修复 1 month ago
  Samien 283f8d746f 兼容聊天日期并避免流式增量反复扫描未读历史 1 month ago
  Samien a327a7f194 增加桌面未读中心与安全 Dock 权威角标 1 month ago
  Samien 5f41086417 修复 OpenViking 同步并发追加并核验不确定写入 1 month ago
  Samien 2c87f1622e 补充 OpenViking 跨客户端同步冲突与部分追加回归用例 1 month ago
  Samien e320aeb431 统一完整聊天未读计数和快照已读回执 1 month ago
  Samien 328d92e8f9 记录正式凭据适配器默认并行验收与部署边界 1 month ago
  Samien cf0c6f0c46 实现密码库受控表单和 SSH/SFTP 适配器,验证 Bot 授权执行与撤权链路 1 month ago
  Samien 5ebefde5a7 修复发送和排队时的聊天布局跳动 1 month ago
  Samien 1c72fc8a4b 记录密码库首期验收、实际适配范围和隔离部署前置 1 month ago
  Samien 7b98662cfe 修复中文附件名乱码并将待发送附件放入输入框 1 month ago
  Samien 771c72ce78 接入 Bot 密码库管理与短任务授权,打通 dummy 网站登录工具链 1 month ago
  Samien d1650eff7b 修复各客户端上下文统计口径不一致 1 month ago
  Samien 9092831048 新增手动解锁的独立加密密码库存储与恢复回归 1 month ago
  Samien 8253a27eb2 移除 hermes-yaoyao 命令别名,仅保留 yaoyao 1 month ago
  Samien 0fe4a41889 新增 npm 命令行服务重启和版本查询 1 month ago
93 changed files with 3802 additions and 270 deletions
  1. 7 5
      README.md
  2. 8 0
      THIRD_PARTY_NOTICES.md
  3. 15 2
      bin/hermes-yaoyao.mjs
  4. 19 0
      deploy/credential-vault/README.md
  5. 15 0
      deploy/credential-vault/isolation-approval.json.example
  6. 11 0
      deploy/credential-vault/vault.json.example
  7. 32 0
      deploy/credential-vault/yaoyao-vault.service.example
  8. 6 2
      desktop/main.mjs
  9. 1 0
      desktop/preload.cjs
  10. 35 0
      desktop/unread-badge.mjs
  11. 6 0
      desktop/unread-badge.test.mjs
  12. 1 1
      docker.env.example
  13. 75 0
      docs/credential-vault.md
  14. 8 3
      docs/npm-install.md
  15. 48 0
      docs/releases/v0.4.78.md
  16. 16 0
      licenses/libsodium.js/LICENSE
  17. 19 0
      licenses/ssh2/LICENSE
  18. 120 5
      package-lock.json
  19. 4 2
      package.json
  20. 3 3
      release.json
  21. 6 0
      src/client/App.vue
  22. 161 0
      src/client/components/app/CredentialVaultPanel.vue
  23. 5 0
      src/client/components/app/SettingsCenterDialog.vue
  24. 21 0
      src/client/components/app/UnreadPanel.vue
  25. 11 1
      src/client/components/app/WorkspaceShell.vue
  26. 46 33
      src/client/components/composer/ComposerShell.vue
  27. 4 0
      src/client/components/messages/MessageTimeline.vue
  28. 3 0
      src/client/components/workspace/WorkspaceMessageTimeline.vue
  29. 1 0
      src/client/env.d.ts
  30. 34 13
      src/client/stores/chat.ts
  31. 38 0
      src/client/stores/unread.ts
  32. 31 18
      src/client/utils/contextUsage.ts
  33. 8 18
      src/client/utils/messageReducer.ts
  34. 19 0
      src/client/utils/visibleMessages.ts
  35. 26 12
      src/client/views/ChatView.vue
  36. 24 44
      src/client/views/ConversationsView.vue
  37. 0 7
      src/client/views/HistoryView.vue
  38. 2 2
      src/runner/agent.ts
  39. 15 1
      src/server/app.ts
  40. 14 0
      src/server/chatTranscriptStore.ts
  41. 4 0
      src/server/config.ts
  42. 160 0
      src/server/credentialVault/broker.ts
  43. 207 0
      src/server/credentialVault/coordinator.ts
  44. 11 0
      src/server/credentialVault/executor.ts
  45. 40 0
      src/server/credentialVault/fixtureWebsiteExecutor.ts
  46. 37 0
      src/server/credentialVault/index.ts
  47. 23 0
      src/server/credentialVault/isolation.ts
  48. 12 0
      src/server/credentialVault/privateFiles.ts
  49. 17 0
      src/server/credentialVault/protectedExecutor.ts
  50. 41 0
      src/server/credentialVault/schema.ts
  51. 83 0
      src/server/credentialVault/sshExecutor.ts
  52. 215 0
      src/server/credentialVault/store.ts
  53. 88 0
      src/server/credentialVault/transport.ts
  54. 71 0
      src/server/credentialVault/websiteExecutor.ts
  55. 6 0
      src/server/localAuth.ts
  56. 126 25
      src/server/openVikingSessionSync.ts
  57. 7 4
      src/server/pushCoordinator.ts
  58. 15 0
      src/server/routes.ts
  59. 10 1
      src/server/runnerHub.ts
  60. 6 0
      src/server/subaccountAccess.ts
  61. 56 0
      src/server/unreadCenter.ts
  62. 4 1
      src/server/uploads.ts
  63. 2 1
      src/server/workspaceAssets.ts
  64. 1 1
      src/server/workspaceRoutes.ts
  65. 14 2
      src/server/workspaceRuntime.ts
  66. 88 40
      src/server/workspaceStore.ts
  67. 26 0
      src/shared/credentialVault.ts
  68. 13 0
      src/shared/unread.ts
  69. 12 0
      src/shared/uploadNames.ts
  70. 2 0
      src/shared/workspace.ts
  71. 53 0
      tests/client/chatCrossClientSync.test.ts
  72. 10 8
      tests/client/chatStore.test.ts
  73. 30 0
      tests/client/composer.test.ts
  74. 38 0
      tests/client/contextUsage.test.ts
  75. 69 0
      tests/client/credentialVaultPanel.test.ts
  76. 6 4
      tests/e2e/ordinary-local.spec.ts
  77. 148 0
      tests/e2e/workspace-chat.spec.ts
  78. 75 0
      tests/fixtures/credentialProtocols.ts
  79. 91 0
      tests/server/credentialVaultAdapters.test.ts
  80. 111 0
      tests/server/credentialVaultBotFlow.test.ts
  81. 93 0
      tests/server/credentialVaultBroker.test.ts
  82. 136 0
      tests/server/credentialVaultCoordinator.test.ts
  83. 39 0
      tests/server/credentialVaultRoutes.test.ts
  84. 83 0
      tests/server/credentialVaultStore.test.ts
  85. 248 2
      tests/server/openVikingSessionSync.test.ts
  86. 9 0
      tests/server/runner.test.ts
  87. 8 0
      tests/server/subaccountRegistration.test.ts
  88. 32 0
      tests/server/unreadCenter.test.ts
  89. 17 0
      tests/server/uploadNames.test.ts
  90. 2 2
      tests/server/workspaceMessagePatch.test.ts
  91. 45 0
      tests/server/workspaceRoutes.test.ts
  92. 28 5
      tests/server/workspaceUnread.test.ts
  93. 116 2
      tests/serviceCli.test.ts

+ 7 - 5
README.md

@@ -1,6 +1,6 @@
 # 夭夭 AI
 
-当前 Web 与 macOS 桌面发布版本:**v0.4.77**。桌面版提供 Apple Silicon(arm64)客户端正式 DMG;本次未发布完整版桌面安装包,Windows 与 Android 沿用旧版,详见 [发布说明](docs/releases/v0.4.77.md)。
+当前 Web 与 macOS 桌面发布版本:**v0.4.78**。桌面版提供 Apple Silicon(arm64)客户端正式 DMG;本次未发布完整版桌面安装包,Windows 与 Android 沿用旧版,详见 [发布说明](docs/releases/v0.4.78.md)。
 
 夭夭 AI 为网页、iOS 和安卓提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务。
 
@@ -60,17 +60,19 @@ yaoyao service install
 
 安装后访问 `http://127.0.0.1:15300` 创建管理员账号。macOS 通过 LaunchAgent 开机自启;Linux 的服务安装支持在路线图中。详见 [npm 安装说明](docs/npm-install.md)。
 
+执行 `yaoyao service restart` 重启服务,执行 `yaoyao --version`(或 `yaoyao version` / `yaoyao -v`)查询已安装的 npm 包版本。
+
 ## Docker 安装
 
 远程镜像发布在 [Docker Hub](https://hub.docker.com/r/samienluo/yaoyao):
 
 | 镜像 | 架构 |
 | --- | --- |
-| `samienluo/yaoyao:v0.4.77-amd` | AMD64 |
-| `samienluo/yaoyao:v0.4.77` | 自动匹配 AMD64 / ARM64 |
-| `samienluo/yaoyao:latest` | 最新稳定版通用镜像,当前与 `v0.4.77` 相同 |
+| `samienluo/yaoyao:v0.4.78-amd` | AMD64 |
+| `samienluo/yaoyao:v0.4.78` | 自动匹配 AMD64 / ARM64 |
+| `samienluo/yaoyao:latest` | 最新稳定版通用镜像,当前与 `v0.4.78` 相同 |
 
-准备好 Docker Engine/Compose 和容器可访问的 Hermes 9119 服务,在仓库目录将 `docker.env.example` 复制为 `docker.env`。配置上游地址,并设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.77`(或 `samienluo/yaoyao:latest`),即可使用远程镜像:
+准备好 Docker Engine/Compose 和容器可访问的 Hermes 9119 服务,在仓库目录将 `docker.env.example` 复制为 `docker.env`。配置上游地址,并设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.78`(或 `samienluo/yaoyao:latest`),即可使用远程镜像:
 
 ```sh
 docker compose --env-file docker.env pull web

+ 8 - 0
THIRD_PARTY_NOTICES.md

@@ -14,3 +14,11 @@ Avatar silhouette paths and original two-eye expressions from zhulin025/LaoA-Gro
 ## OpenMausBot macOS updater preparation
 
 `scripts/patch-mac-updater.mjs` is adapted from OpenMausBot commit 13c5005b (Apache-2.0), Copyright 2026 Milind Soni and OpenMausBot contributors. It waits for Squirrel.Mac's native readiness before allowing a restart. The packaged electron-updater bundle retains its dependencies' license comments. See `licenses/OpenMausBot/LICENSE` and `NOTICE`.
+
+## libsodium.js credential vault cryptography
+
+`libsodium-wrappers-sumo` 0.8.4 and its locked `libsodium-sumo` 0.8.4 dependency provide Argon2id and XChaCha20-Poly1305 for the manually unlocked credential vault. Copyright 2015-2026 Ahmad Ben Mrad, Frank Denis and Ryan Lester. ISC license: see `licenses/libsodium.js/LICENSE`. Source: https://github.com/jedisct1/libsodium.js.
+
+## ssh2 credential execution
+
+`ssh2` 1.17.0 provides SSH public-key authentication, host key verification and SFTP for the protected credential executor. Copyright Brian White. MIT license: see `licenses/ssh2/LICENSE`. Source: https://github.com/mscdex/ssh2. Installation lifecycle scripts are not required for the pure JavaScript fallback.

+ 15 - 2
bin/hermes-yaoyao.mjs

@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 
 import { execFileSync } from 'node:child_process'
-import { existsSync, realpathSync } from 'node:fs'
+import { existsSync, readFileSync, realpathSync } from 'node:fs'
 import { chmod, mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
 import { homedir } from 'node:os'
 import { dirname, join, resolve } from 'node:path'
@@ -174,6 +174,13 @@ async function start() {
   process.stdout.write(`已启动 ${label}\n`)
 }
 
+async function restart() {
+  if (!existsSync(plistPath)) throw new Error('服务尚未安装,请先运行 yaoyao service install')
+  if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
+  await bootstrapLaunchAgent()
+  process.stdout.write(`已重启 ${label}\n`)
+}
+
 function stop() {
   if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
   process.stdout.write(`已停止 ${label};Hermes 9119 未被操作\n`)
@@ -218,15 +225,21 @@ async function pruneUploads(args) {
 
 async function main() {
   const [, , group, command, ...args] = process.argv
+  if (group === 'version' || group === '--version' || group === '-v') {
+    const { version } = JSON.parse(readFileSync(join(sourceProjectRoot, 'package.json'), 'utf8'))
+    process.stdout.write(`${version}\n`)
+    return
+  }
   if (group === 'service') {
     if (command === 'install') return install()
     if (command === 'start') return start()
+    if (command === 'restart') return restart()
     if (command === 'stop') return stop()
     if (command === 'status') return status()
     if (command === 'uninstall') return uninstall()
   }
   if (group === 'uploads' && command === 'prune') return pruneUploads(args)
-  process.stdout.write(`夭夭 AI\n\n用法:\n  yaoyao service install|start|stop|status|uninstall\n  yaoyao uploads prune --older-than 30 --yes\n`)
+  process.stdout.write(`夭夭 AI\n\n用法:\n  yaoyao service install|start|restart|stop|status|uninstall\n  yaoyao version | --version | -v\n  yaoyao uploads prune --older-than 30 --yes\n`)
 }
 
 export function isMainModule(entry = process.argv[1]) {

+ 19 - 0
deploy/credential-vault/README.md

@@ -0,0 +1,19 @@
+# 密码库正式适配器:人工部署审阅材料
+
+这些文件仅是可审阅模板;本轮没有安装、执行或启用服务,也没有创建用户、控制 token、真实密码库或修改网络规则。JSON 中 UID/PID/时间均为无效示例,批准默认 false。不要直接复制成已批准配置。
+
+首先选择同一执行节点上的可信控制身份:Web/BFF 与 broker 使用同一非 root 控制 UID,Hermes、其任意 shell 和通用浏览器使用另一个非 root UID。当前 Unix socket 实现没有安全的跨节点控制通道,不能把 socket 改成公开 TCP 或通过 Runner 传输主密码。控制身份下不得提供给模型任意本机执行/调试/资料读取能力;若目前 Web 内运行这样的 host tools,须在实际部署前隔离它们,不能仅另起一个同 UID broker。服务器/Runner 的 `credential-ref-v1` 必须匹配;Runner 不持有秘密。
+
+需管理员另行批准和实施:
+
+1. 用实际 UID/PID 替换配置值,审核服务身份、组/ACL、父目录、备份目录、交换区/core dump/调试策略。Hermes 不能 root、sudo、调试控制进程或读取管理登录会话;必须验证其 shell 无法读取 vault/config/token/browser 资料或连接 socket。控制文件 0600、目录 0700;token 由管理员生成高熵随机值,仅用于 IPC,不是 DEK。不要放入仓库、环境日志或 Hermes 工作区。
+2. 安装 Node >=24、锁定的生产依赖和 Chromium(由管理员批准来源/版本),审核 Linux unit 的真实路径/浏览器 sandbox。不能以 `--no-sandbox` 或宽化私有权限解除启动问题。unit 不是 OS 隔离证明;实际平台需要等价的可信沙箱/防调试控制。模板不启动或修改 Hermes。
+3. 建立并验证网络策略:broker/browser 的出口只允许批准目标地址/端口与受信 DNS,禁止 loopback/内网管理面、云 metadata、通用 CDP/调试端口及非批准 SSH。网站外部资源/重定向须逐项审批,首次实现仅接受同 origin 的指定成功页。Chromium 请求路由不能控制所有后台/WebRTC 网络;网络过滤必须在 OS/容器层生效。DNS/CDN/IPv6 变化需明确审核,不把域名字符串当网络防火墙。
+4. 验证浏览器每次授权独立 pipe/context/profile,Hermes 无法访问其 PID、DOM、Cookie、请求、截图/CDP。Web 专用管理页必须经可信 HTTPS,不能让 Bot 控制的浏览器录入主密码。管理 Web、broker 和其依赖属于可信计算边界;批准文件只能由可信管理员维护。
+5. 完成上述实际攻击检查后,人工填写 `approved:true`、毫秒 `approvedAt/expiresAt`(最长31天)、broker/Hermes UID 和实际 Hermes PID;所有四项 controls 必须已经实测。代码会复查私有文件、时效和 `ps` 返回的实际 Hermes UID,失效会锁库/撤权;它不会证明四项控制本身。Hermes 重启 PID 变化后须重新审核,不自动恢复批准。
+6. BFF 配置 `HERMES_YAOYAO_VAULT_SOCKET`、`HERMES_YAOYAO_VAULT_TOKEN_FILE`、`HERMES_YAOYAO_VAULT_HERMES_UID`,前台启动命令为 `node dist-server/server/credentialVault/index.js --config /私有路径/vault.json`。这是后续部署操作,本轮未执行。只有独立部署批准才能启用 unit 或改线上配置。
+7. 用户亲自在「Bot模式 → 设置 → 密码管理」创建、解锁、录入凭据/使用计划,并逐任务授权。首次真实账号登录还需相应批准。先用专门的 dummy 测试账号验证锁定/登出/撤权/取消/断线/重连、host key 拒绝、Cookie 清理和备份恢复,不把同进程测试算作隔离验收。
+
+网站是标准表单认证验证,不支持任意网站登录后的通用浏览动作。正常完成可访问配置的 GET 退出页;取消或没有退出页时远端会话按网站规则处理,必要时人工撤销。SSH 仅固定无参数绝对程序,返回退出码;非零码表示程序已完成但失败。SFTP read 只返回有界字节数/摘要,write 创建固定正文的新文件,不覆盖。SHA256 摘要也属于目标数据的派生信息,只有授权任务可得到。中断后的远端命令/部分文件可能已生效;回执未知时需人工核对,不重试、删除或覆盖。
+
+支持证据:`tests/server/credentialVaultBotFlow.test.ts` 经 WorkspaceRuntime 工具 HTTP 目录、调用上下文、专用授权 API、私有 Unix IPC、正式 Playwright/ssh2 adapter 访问本地 dummy HTTPS/SSH/SFTP 服务。Hermes/模型是 stub,隔离 gate 是明确的测试替身;没有跨 OS 身份、真实外部账号、真实 Hermes 自主推理或部署验收。

+ 15 - 0
deploy/credential-vault/isolation-approval.json.example

@@ -0,0 +1,15 @@
+{
+  "version": 1,
+  "approved": false,
+  "brokerUid": 12346,
+  "hermesUid": 12345,
+  "hermesPid": 12345,
+  "approvedAt": 0,
+  "expiresAt": 0,
+  "controls": [
+    "separate-identity",
+    "hermes-no-control-access",
+    "browser-private-no-cdp",
+    "restricted-egress"
+  ]
+}

+ 11 - 0
deploy/credential-vault/vault.json.example

@@ -0,0 +1,11 @@
+{
+  "home": "/var/lib/yaoyao-control/vault",
+  "socket": "/run/yaoyao-control/vault.sock",
+  "tokenFile": "/etc/yaoyao-control/control.token",
+  "hermesUid": 12345,
+  "execution": {
+    "approvalFile": "/etc/yaoyao-control/isolation-approval.json",
+    "hermesPid": 12345,
+    "home": "/var/lib/yaoyao-control/executor"
+  }
+}

+ 32 - 0
deploy/credential-vault/yaoyao-vault.service.example

@@ -0,0 +1,32 @@
+# Review-only Linux template. No installer runs this unit. Adjust all paths,
+# identities, browser sandbox and network policy after an independent review.
+[Unit]
+Description=Yaoyao manually unlocked credential broker
+After=network.target
+
+[Service]
+Type=simple
+User=yaoyao-control
+Group=yaoyao-control
+WorkingDirectory=/opt/yaoyao
+ExecStart=/opt/node24/bin/node /opt/yaoyao/dist-server/server/credentialVault/index.js --config /etc/yaoyao-control/vault.json
+UMask=0077
+NoNewPrivileges=yes
+PrivateTmp=yes
+ProtectSystem=strict
+ProtectHome=yes
+CapabilityBoundingSet=
+AmbientCapabilities=
+Environment=PATH=/opt/node24/bin:/usr/bin:/bin
+Environment=PLAYWRIGHT_BROWSERS_PATH=/opt/yaoyao-browsers
+LimitCORE=0
+ReadWritePaths=/var/lib/yaoyao-control /run/yaoyao-control
+# The UID probe needs process-list metadata for Hermes. Standard different-UID
+# memory/ptrace denial must remain enforced; review host Yama/LSM settings.
+# Chromium and ps must work under the reviewed sandbox. Do not disable browser
+# sandbox or expose private IPC/public CDP just to make the unit start.
+# This unit alone does NOT restrict egress or isolate a same-UID Hermes process.
+Restart=no
+
+[Install]
+WantedBy=multi-user.target

+ 6 - 2
desktop/main.mjs

@@ -1,3 +1,4 @@
+import { installUnreadBadge } from './unread-badge.mjs'
 import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage, net, session as electronSession } from 'electron'
 import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
 import { join, dirname } from 'node:path'
@@ -56,6 +57,8 @@ else {
     try { return remoteMode && remoteURL ? [new URL(remoteURL).origin] : manager?.state.url ? [new URL(manager.state.url).origin] : [] }
     catch { return [] }
   }
+  const unreadBadge=installUnreadBadge({ipcMain,app,owner:()=>window,origins:serviceOrigins,url:serviceURL,quitting:()=>closing||quitting,fetch:(url,options)=>electronSession.defaultSession.fetch(url,options)})
+  app.on('will-quit',()=>unreadBadge.stop())
   const closeComputerViewer = installComputerViewer({ owner: () => window, origin: () => serviceURL(),
     preload: join(desktopRoot, 'preload.cjs'), quitting: () => quitting })
   app.on('will-quit', closeComputerViewer)
@@ -169,6 +172,7 @@ else {
       catch { /* Invalid navigation is denied. */ }
       event.preventDefault(); safeExternal(url)
     }
+    window.webContents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame)unreadBadge.clear()})
     window.webContents.on('will-navigate', guardNavigation)
     window.webContents.on('will-redirect', guardNavigation)
     window.webContents.on('will-attach-webview', event => event.preventDefault())
@@ -235,7 +239,7 @@ else {
   function requestQuit(stopBackground = false) {
     if (closing || quitting) return
     const resumeLocal = serverModeActive && manager?.activationRequested === true
-    closing = true; clearInterval(timer)
+    closing = true; unreadBadge.clear(); clearInterval(timer)
     updater?.stopChecking?.(); updater?.cancel(); updateWindow?.close()
     window?.hide()
     // Native Cmd+Q can enter before-quit from Cocoa's termination callback.
@@ -276,7 +280,7 @@ else {
   async function prepareUpdateRestart() {
     if (closing || quitting) throw new Error('App 正在退出,请稍后重试')
     resumeLocalAfterUpdate = serverModeActive && manager?.activationRequested === true
-    closing = true; clearInterval(timer)
+    closing = true; unreadBadge.clear(); clearInterval(timer)
     await environmentHost?.close()
     await hostManager?.stop()
     await runnerManager?.stop()

+ 1 - 0
desktop/preload.cjs

@@ -1,5 +1,6 @@
 const { contextBridge, ipcRenderer } = require('electron')
 contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
+  unreadState: account => ipcRenderer.invoke('desktop:unread-state', account),
   status: () => ipcRenderer.invoke('desktop:status'),
   selectServer: mode => ipcRenderer.invoke('desktop:onboarding-select', mode),
   prepareServer: input => ipcRenderer.invoke('desktop:onboarding-prepare', input),

+ 35 - 0
desktop/unread-badge.mjs

@@ -0,0 +1,35 @@
+/** Fetch the authority in the existing browser session; renderer count input is never trusted. */
+export function installUnreadBadge({ ipcMain, app, owner, origins, url, quitting, fetch, interval = 3000 }) {
+ let account=null,generation=0,poll,pending=false
+ const badge=count=>{if(app.dock)app.dock.setBadge(count ? String(count) : '')}
+ const clear=()=>{account=null;generation++;pending=false;clearInterval(poll);poll=undefined;badge(0)}
+ const trusted=event=>{
+  try{return !quitting()&&event.sender===owner()?.webContents&&event.senderFrame===owner().webContents.mainFrame&&origins().includes(new URL(event.senderFrame.url).origin)}catch{return false}
+ }
+ async function refresh(){
+  if(!account||pending||quitting())return
+  const current=generation,identity=account,origin=new URL(url()).origin;pending=true
+  try{
+   const options={credentials:'include',redirect:'error',cache:'no-store'}
+   const auth=await fetch(`${origin}/api/app/bootstrap?csrfOnly=1`,options)
+   if(!auth.ok)throw new Error('authentication required')
+   const user=await auth.json()
+   // auth-disabled local identity is represented as null by csrfOnly.
+   if((user.userId??'local')!==identity){if(current===generation)clear();return}
+   const response=await fetch(`${origin}/api/app/unread`,options)
+   if([401,403].includes(response.status)){if(current===generation)clear();return}
+   if(!response.ok)return
+   const value=await response.json()
+   if(current===generation&&account===identity&&origins().includes(origin)&&Number.isSafeInteger(value.total)&&value.total>=0)badge(value.total)
+  }catch{ /* Offline preserves the last authenticated count. Navigation/logout clears immediately. */ }
+  finally{if(current===generation)pending=false}
+ }
+ ipcMain.handle('desktop:unread-state',async(event,input)=>{
+  if(!trusted(event))throw new Error('不允许此页面更新未读状态')
+  if(input!==null&&(typeof input!=='string'||!input.trim()||input.length>256))throw new Error('无效账号范围')
+  if(input===null){clear();return}
+  if(account!==input){clear();account=input;poll=setInterval(()=>void refresh(),interval);poll.unref?.()}
+  await refresh()
+ })
+ return { clear,refresh,stop(){clear();ipcMain.removeHandler('desktop:unread-state')} }
+}

+ 6 - 0
desktop/unread-badge.test.mjs

@@ -0,0 +1,6 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {installUnreadBadge} from './unread-badge.mjs'
+function fixture(){let handler,badges=[],total=12,user='one',quit=false;const frame={url:'https://fixture.local/chat'},contents={mainFrame:frame},win={webContents:contents};const controller=installUnreadBadge({ipcMain:{handle(_name,fn){handler=fn},removeHandler(){}},app:{dock:{setBadge:v=>badges.push(v)}},owner:()=>win,origins:()=>['https://fixture.local'],url:()=>frame.url,quitting:()=>quit,fetch:async url=>({ok:true,status:200,json:async()=>url.includes('bootstrap')?{userId:user}:{total}}),interval:100000});return {controller,badges,event:{sender:contents,senderFrame:frame},invoke:(e,v)=>handler(e,v),setTotal:v=>total=v,setUser:v=>user=v,setQuit:v=>quit=v}}
+test('authority continues while owner is hidden/closed-to-tray and clears logout scope',async()=>{const f=fixture();try{await f.invoke(f.event,'one');assert.equal(f.badges.at(-1),'12');f.setTotal(103);await f.controller.refresh();assert.equal(f.badges.at(-1),'103');await f.invoke(f.event,null);assert.equal(f.badges.at(-1),'');await f.invoke(f.event,'one');f.setUser('two');await f.controller.refresh();assert.equal(f.badges.at(-1),'');f.setTotal(0);await f.invoke(f.event,'two');assert.equal(f.badges.at(-1),'')}finally{f.controller.stop()}})
+test('rejects subframe, foreign webContents, origin and malformed input without weakening frame checks',async()=>{const f=fixture();try{for(const e of [{...f.event,sender:{}},{...f.event,senderFrame:{url:'https://fixture.local/chat'}},{...f.event,senderFrame:{url:'https://bad.local'}}])await assert.rejects(f.invoke(e,'one'));await assert.rejects(f.invoke(f.event,{count:3}));f.setQuit(true);await assert.rejects(f.invoke(f.event,'one'))}finally{f.controller.stop()}})

+ 1 - 1
docker.env.example

@@ -3,7 +3,7 @@ HERMES_YAOYAO_BIND_ADDRESS=127.0.0.1
 HERMES_YAOYAO_PUBLISHED_PORT=15300
 # 远程通用镜像支持 AMD64 / ARM64;每次发布更新 latest 并保留旧版本标签。
 # HERMES_YAOYAO_IMAGE=samienluo/yaoyao:latest
-# 固定版本:samienluo/yaoyao:v0.4.77;仅 AMD64:samienluo/yaoyao:v0.4.77-amd。
+# 固定版本:samienluo/yaoyao:v0.4.78;仅 AMD64:samienluo/yaoyao:v0.4.78-amd。
 # 留空时使用本地构建名称 yaoyao:local。
 # YAOYAO_DESKTOP_IMAGE=hermes-yaoyao-desktop:0.4.0
 # Cursor Universal 兼容桌面的本地构建名称;基础镜像在 deploy/computer/Dockerfile.cursor 中固定。

+ 75 - 0
docs/credential-vault.md

@@ -0,0 +1,75 @@
+# Bot 内置密码库(首期)
+
+部署目标是 Hermes 执行节点,用户通过专用界面手动输入主密码。默认锁定,重启不自动解锁。密码库不是 Hermes 的环境变量或工具配置。
+
+## 密码学与存储
+
+固定依赖 libsodium-wrappers-sumo 0.8.4(官方 npm registry,禁用安装脚本),使用其 Argon2id13 和 XChaCha20-Poly1305。每个账号独立随机 256-bit DEK;主密码经随机 128-bit salt、3 次迭代、64 MiB 内存派生 KEK,包装 DEK。每次包装、写入都使用独立随机 192-bit nonce。AAD 绑定格式版本、账号、vault ID,以及 KDF 参数或文档修订;元数据与秘密一起加密,没有同目录静态解密密钥。
+
+格式 v1 的文件原子替换并 fsync;写入中断应留下旧或新的完整可解密版本。主密码轮换同时更换 salt 和 DEK。加密备份保留 owner 身份,只恢复到未初始化且账号身份相同的库;不能覆盖现有库。迁移必须保留账号 ID。备份可用其导出时的主密码解密,改主密码不会使旧备份失效;遗失主密码无法恢复。没有离线文件回滚检测或硬件密钥保护。
+
+解锁 DEK 只存在 broker 内存,固定到期、锁定、关闭时清除可控字节缓冲。JS 字符串、GC、WASM、交换区和进程转储不提供安全擦除保证,必须配合操作系统隔离;不能据此称“密码永不进入内存”。UI/API 不提供读取密码或私钥明文的接口;编辑秘密是单向替换。
+
+## 执行边界
+
+同 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)。
+
+网站仅支持用户在专用 UI 配置的主页面标准 HTML POST 表单:固定 HTTPS origin、登录/提交/成功页路径、form id、输入 name 和成功标记,允许成功跳转也必须预先指定。每个授权使用独立无持久资料的 Playwright Chromium/context,经私有 pipe 控制,无公开 CDP、截图、DOM/请求/Cookie 工具;受保护阶段不交给 Bot 通用浏览器。跨 origin、iframe、popup、SSO、MFA、验证码、XHR 登录或不匹配表单转人工接管,不会猜测或通用 fill/JS 降级。销毁本地 Cookie/context;可配置同 origin 固定 GET 退出路径,在正常完成时执行。未配置退出或中途取消时不能保证远端会话撤销,需站点自身过期/人工核对。登录成功回执只是认证证明,本期没有提供登录后任意网站浏览/业务操作;不能将受保护 Cookie 导出给通用工具。浏览器路由检查不能代替 OS 网络策略(例如 WebRTC/后台网络),也不能代替独立身份/禁止调试。
+
+SSH/SFTP 使用固定 `ssh2` 1.17.0,公钥认证及用户确认的 SHA256 host key,未知 host key 拒绝且不会尝试密码认证。SSH 只执行预先配置的绝对可执行文件,无参数/插值/PTY/环境/Agent forwarding/代理/端口转发,返回退出码,不返回 stdout/stderr。服务器协议内部执行方式由远端 SSH 服务决定,这不等于任意远端程序的沙箱。SFTP 只读取指定普通文件并返回有界字节数/校验,或把专用 UI 输入且加密的固定正文以 0600/exclusive-create 写到批准的绝对新路径;不覆盖已存在文件,不允许 Bot 提供正文/路径。限制为读至多 1 MiB、写至多 64 KiB,旧空正文编辑保留,不回显。没有将任意服务器上的实时文件/符号链接竞态转化为强隔离;远端目录仍必须由可信账号控制。执行断开/取消会关闭短连接,远端命令可能已执行、写入可能已部分落盘,不能自动重试或自动删除结果。明文 FTP 不支持。
+
+开发仅使用临时目录中的 dummy 凭据和本机测试夹具。真实秘密录入、真实登录、OS 用户/权限/守护进程安装及生产切换都不属于本轮本地实现操作。
+
+官方说明:[libsodium.js](https://github.com/jedisct1/libsodium.js)、[Argon2id](https://doc.libsodium.org/password_hashing/default_phf)、[XChaCha20-Poly1305](https://doc.libsodium.org/secret-key_cryptography/aead/chacha20-poly1305/xchacha20-poly1305_construction)。
+
+## 首期可操作范围与接入
+
+Web 的「Bot 模式 → 设置 → 密码管理」提供创建、手动解锁 5 分钟、加密条目 CRUD、立即锁定、60 秒单任务引用授权/撤销、加密备份/同账号恢复及主密码和 DEK 轮换。解锁前不展示条目元数据,编辑不读取原秘密。关闭/隐藏管理页会清除输入、取消待返回请求并尝试锁定;网络不可达时不能保证这条锁定请求送达,因此 broker 自身仍实行固定到期。不能保证清理浏览器/OS 的内存历史,录入只应在可信用户管理页面进行。
+
+私有协议为 `credential-vault/v1`(HTTP JSON,经 Unix socket,禁止 Origin),没有 read-secret 命令;`execute` 仅接受单次短授权与固定范围,正式 CLI 未注入执行器时明确拒绝。每次 Web 控制进程重新握手都会锁定全部库并撤销旧授权。BFF 重启不会恢复授权或重放操作。UI/API `GET /api/app/vault` 返回状态、元数据、活动任务和待授权请求;其下 POST `initialize/unlock/lock/rotate/backup/restore`,POST `entries/leases`,PUT/DELETE `entries/:id`,DELETE `leases/:id` 均沿用当前登录、精确 Origin、CSRF 和 owner 校验;管理入口仅接受 HTTPS 或本机回环 HTTP。不依赖全局「允许不安全局域网」选项。
+
+Runner 只声明 `credential-ref-v1`,**没有** `credential-executor-v1`。授权绑定 owner、本次登录、Bot、活动 work ID、Runner ID/instance/epoch、精确 origin 或 SSH 主机/端口/host key、操作、凭据版本和到期时间。停止/取消/删除/权限变更/重连/锁定/轮换/条目改动使旧授权失效,迟到的解锁或 grant 响应不能恢复权限。旧 Runner 明确拒绝;离线/锁定时 Bot 在任务的 waiting 状态等待,最长 120 秒后返回未执行的等待结果。任务取消会释放等待并撤销授权。
+
+Bot 工具只有 `credential_refs` 与 `credential_request`,输入严格为引用及操作,模型看不到用户名/密码/私钥/主密码/控制 token,也不能选择新的路径、正文、命令或表单。refs 返回当前任务的目标和公开的固定使用计划,不含 SFTP 正文。专用 UI 授权后,正式适配器在 broker 内实际使用秘密,返回严格白名单回执;空/额外字段/操作不匹配的正式回执均拒绝。重复工具 call ID 只收到原脱敏回执,不重复执行。缺使用计划或不支持的站点会明确返回人工接管,执行后未确认返回 `vault_operation_uncertain`,不能假称成功或转回等待后自动重试。私有 IPC 断连中止当前租约;Runner 在回执抵达前重连也返回未知结果。旧 `FixtureWebsiteLoginExecutor` 仍只是最初的专用测试夹具,生产入口不加载它。
+
+真实 WorkspaceRuntime 工具目录/调用上下文、专用用户授权 API、私有 IPC 和正式 adapter 已通过本地 HTTPS 标准表单、SSH 公钥固定命令、SFTP 读和写协议验收。Hermes 网关和模型是 stub,broker/工具 HTTP/浏览器/SSH 协议均执行实际代码。这不是不同 OS 身份攻击验收。
+
+## 部署前需人工完成的边界(本轮未执行)
+
+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 明确离线;锁定或离线不自动降级。
+5. 用户亲自在专用 UI 创建并解锁。上线前还需验证真实 OS/IPC 攻击边界、HTTPS 管理通道及备份恢复,不把本地同进程 dummy 测试作为隔离验收。本轮没有实际创建服务身份、控制凭据、生产密码库或录入真实秘密。
+
+受保护执行器的部署仍需要独立安全验收:限定操作及单次提交,执行前/后检查授权,撤销中止;结果只返回脱敏状态,执行断线或取消结果不明确时禁止自动重试。浏览器填充阶段必须独占并隔离 DOM、请求、Cookie、截图、CDP 与同 UID shell;登录后的站点操作也须走受限动作/脱敏返回,不能向模型开放可读 Cookie 的通用浏览器。SSH/SFTP 要独立短连接及已确认的 host key,不能允许 shell 自选命令、任意跳板/代理、端口转发、agent forwarding、密钥导出或明文 FTP。
+
+## 本地验证
+
+`tests/server/credentialVaultStore.test.ts`:密文与默认锁定、AAD 篡改、fresh nonce/修订、备份和轮换写入中断。`credentialVaultBroker.test.ts`:作用域、默认拒绝、单次消费、锁定/登出/撤权/取消/重连/修改/删除/到期的在途中止、脱敏审计及 symlink 拒绝。`credentialVaultCoordinator.test.ts`:dummy Unix IPC、迟到解锁/grant、会话与任务/Runner 失效、等待取消、HTTPS/旧 Runner拒绝。`credentialVaultRoutes.test.ts` 使用真实 Web/LocalAuth 但上游为 stub,检查认证/Origin/CSRF/登出联动。`tests/client/credentialVaultPanel.test.ts`:专用输入、无 localStorage、无明文回显、编辑空值保留、关闭/隐藏时取消并锁定。Runner 现有本机夹具验证能力声明。
+
+测试连接临时本地 HTTPS/SSH/SFTP 服务,只使用 dummy 账号;没有真实账号、跨 OS 身份穿透测试、移动端专用原生密码库 UI、端到端生产部署或真机验收。现有移动端与独立原生管理器不在首期范围。
+
+## 首期验收记录(2026-09-30)
+
+存储底座提交 `9092831`;UI、私有 broker/IPC、任务短授权、Runner 引用能力和工作的本地 dummy 网站登录工具链提交 `771c72c`。中间 `d1650ef` 是并行的客户端上下文统计工作,不属于密码库修改。版本保持 v0.4.77,私有密码库协议 v1,没有自行升版。
+
+最终非 live 回归 `vitest run --exclude 'tests/live/**' --maxWorkers=2` 为 **2144 passed / 7 skipped / 0 failed**,其中密码库 6 个测试文件共 41 项通过,包括真实 WorkspaceRuntime 的 Bot 工具目录/调用、用户任务授权、私有 IPC、HTTPS dummy 实际认证、受保护页面访问及脱敏回执/调用去重。另有真实 Runner 夹具 29 项、LocalAuth 7 项通过。类型检查、Web/服务端 build、Runner build 均通过。首次默认并行全量为 2130 passed / 2 failed / 7 skipped:Kanban 无效 board 返回状态和十活动任务调度;两项隔离复验通过,降低 worker 并行度后的最终全量也通过,未放宽断言/超时。首跑失败根因尚未完全确定,不能宣称已修复产品缺陷。
+
+日志/JSON 在本机 `/tmp/yaoyao-vault-final-vitest.log`、`/tmp/yaoyao-vault-final-vitest.json`、`/tmp/yaoyao-vault-final-typecheck.log`、`/tmp/yaoyao-vault-final-build.log`、`/tmp/yaoyao-vault-final-runner-build.log`;首次全量证据为 `/tmp/yaoyao-vault-full-vitest.json`,隔离复验为 `/tmp/yaoyao-vault-isolated-failures.log`。构建输出为 `dist/`、`dist-server/server/credentialVault/` 和 `.runner-build/runner.mjs`,只进行了构建检查,没有打新签名安装包、推送、部署、重启生产或安装到日常设备;工作区并行附件修改另行负责,未混入密码库提交。
+
+未验证的真实边界:不同 OS 身份间的隔离与攻击验证、部署后的 HTTPS 用户管理通道、复杂网站/登录后业务操作、真实外部 SSH/SFTP 目标、移动原生 UI、真实 Hermes/模型自主调用和生产数据恢复。上游 Hermes 模型/网关为测试夹具;Web/WorkspaceRuntime、工具 HTTP 路由、vault IPC、用户授权与本地 HTTPS 认证是实际代码执行。整个开发只接触 dummy 数据,未扫描 Keychain/.env/既有秘密库或录入真实秘密。正式 adapter 的本地协议实现已完成;实际 OS/IPC/CDP/网络部署尚未批准/实施/验收,当前环境不启用真实凭据登录。
+
+## 正式适配器本地验收(2026-09-30)
+
+实现提交 `cf0c6f0`,以并行聊天布局提交 `5ebefde` 为基准,未覆盖或混入其修改。版本仍为 v0.4.77。正式 adapter、Web/BFF/UI 与 broker 应使用此提交或其后兼容版本;Runner 仍使用 `credential-ref-v1`,不承担秘密执行,也没有增加虚假的 executor 能力声明。旧已交付签名安装包未更新,不能据其旧管理 UI 声称本次使用计划已经可操作;本轮只编译源码输出,不打包、安装或切换线上。
+
+默认并行全量 `vitest run --exclude 'tests/live/**'`:**2168 passed / 7 skipped / 0 failed**,其中密码库 7 文件 **65 项**全部通过。协议夹具实际运行 HTTPS 标准表单/成功页/退出页、SSH 公钥固定命令及 SFTP 读/写;Bot 流程 7 项从真实工具目录和任务授权到正式 adapter/脱敏回执,含网站/SSH/SFTP 执行中锁库撤权及 call ID 去重。另覆盖跨 origin/iframe/重定向拒绝、错误 host key 在认证前拒绝、非法密钥、有界读/指定路径、新文件排他写、无回执/泄露输出拒绝、失去私有连接/Runner 迟到回执、隔离批准撤销、专用 UI 计划编辑和固定正文密文保存。取消可能已有外部效果,测试确认未知结果和不重试,不能宣称远端回滚。
+
+类型检查、Web/服务端 build、Runner build 均通过。源码构建 `build-info.json` 为 `cf0c6f0`、build 675、dirty=true(用户原有未跟踪 `.zcode/` 保留,代码已提交);没有自主修改 package/release 版本。输出为 `dist/`、`dist-server/server/credentialVault/`、`.runner-build/runner.mjs`。Vite 仍报告现有大 chunk 警告;未调整警告阈值。没有 lint 脚本,不新增工具掩盖检查缺口。
+
+历史首跑不稳定仍记录:`kanbanRoutes.test.ts / requires an explicit canonical board on every board-scoped endpoint` 在无效 board 请求期望400、收到404;`workspace.test.ts / admits ten active conversation tasks for one Agent and rejects the eleventh without stopping them` 在默认 vi.waitFor 期望10个 prompt、收到7个。两完整文件本轮按默认并行复验126项通过,随后默认并行全量也通过;原始根因尚未定位,不能称已修复。没有简单降低 worker 掩盖本轮最终结果,也没有放宽原断言/超时。新增取消测试中发现夹具未分派 execute,以及使用轮询撞上工具等待间隔;已改为正确分派和等待协议明确提交事件。
+
+证据:`/tmp/yaoyao-vault-adapters-full.json`、`/tmp/yaoyao-vault-adapters-full.log`、`/tmp/yaoyao-vault-adapters-races.log`、`/tmp/yaoyao-vault-adapters-prior-failures.log`、`/tmp/yaoyao-vault-adapters-typecheck.log`、`/tmp/yaoyao-vault-adapters-build.log`、`/tmp/yaoyao-vault-adapters-runner-build.log`。只读/受限沙箱首跑不能绑定回环端口(EPERM),经授权放行本地测试后运行;没有真实网站/SSH 账号、OS 身份设置、守护进程安装、网络配置、推送或部署。

+ 8 - 3
docs/npm-install.md

@@ -30,10 +30,14 @@ yaoyao service install
 ```sh
 yaoyao service status     # 查看运行状态
 yaoyao service stop       # 停止服务
-yaoyao service start      # 重新启动
+yaoyao service start      # 启动服务
+yaoyao service restart    # 重启服务;已停止的服务会重新启动
 yaoyao service uninstall  # 卸载服务(数据保留)
+yaoyao --version          # 查看已安装的 npm 包版本
 ```
 
+版本查询也支持 `yaoyao version` 和 `yaoyao -v`,无需安装或启动服务。
+
 ## 配置
 
 | 环境变量 | 作用 | 默认 |
@@ -42,13 +46,14 @@ yaoyao service uninstall  # 卸载服务(数据保留)
 | `HERMES_YAOYAO_PORT` | Web 端口 | `15300` |
 | `HERMES_YAOYAO_UPSTREAM` | Hermes 上游地址 | `http://127.0.0.1:9119` |
 
-LaunchAgent 安装时的环境会写入 plist;修改配置后执行 `yaoyao service stop && yaoyao service start` 生效。
+LaunchAgent 安装时的环境会写入 plist;修改环境变量配置后,执行 `yaoyao service install` 重新写入配置并启动服务。`yaoyao service restart` 使用已保存的配置重启。
 
 ## 升级
 
 ```sh
 npm update -g @lsamien/yaoyao
-yaoyao service stop && yaoyao service start
+yaoyao service restart
+yaoyao --version
 ```
 
 Web 界面中的「系统更新」入口面向源码/发布包安装;npm 安装的服务请直接用上述 npm 命令升级,避免两套版本并存。

+ 48 - 0
docs/releases/v0.4.78.md

@@ -0,0 +1,48 @@
+# 夭夭 AI v0.4.78
+
+本次发布 Web、Docker 双架构镜像和 macOS Apple Silicon 纯客户端。full 桌面安装包不在本次发布范围;Windows 和 Android 沿用旧版附件。
+
+## 更新内容
+
+- 新增 Bot 密码管理界面,支持加密存储、手动解锁、短时任务授权、撤销、加密备份及主密码轮换。
+- 增加受控网站表单、SSH 与 SFTP 凭据适配器;仅执行用户预先配置并授权的操作,返回脱敏结果,结果不明确时不自动重试。
+- 新增统一未读中心,按聊天模式汇总未读消息,支持跳转与已读处理;macOS Dock 角标使用服务端确认的未读状态。
+- 修复子账号访问未读中心的权限遗漏,保留账号隔离与已读操作校验。
+- 改善跨端已读回执与聊天同步,处理并发追加、部分提交及写入结果不明确的情况。
+- 修复发送与排队时的聊天布局跳动,兼容不同聊天日期格式,减少流式输出时重复扫描未读历史。
+- 修正上下文用量统计、上传文件名称与命令行安装行为。
+
+## 升级说明
+
+- 升级前备份完整 `.yaoyao` 数据目录,建议先更新服务端,再更新客户端。
+- 独立部署的 Runner 应随服务端一起更新,以支持凭据引用及任务授权链路;客户端安装成功不代表服务端或 Runner 已更新。
+- 密码库及受保护执行适配器默认关闭,需按仓库部署文档配置独立服务身份、私有 IPC 和人工确认的隔离条件。当前版本不自动安装密码库服务或更改系统权限;网站首期仅支持预先配置的标准 HTTPS 表单,复杂登录转人工接管。
+- macOS 安装包为纯客户端,需要连接已有服务器,不包含本机服务器或执行节点运行时。需要桌面内置本机服务的用户可继续使用历史 full 包。
+- macOS 客户端沿用历史应用标识,使用 Developer ID Application 正式签名和 Apple 公证,并提供完整自动更新附件。历史开发签名版请先手动安装正式 DMG。
+- Windows 和 Android 旧版附件继续保留。
+- 主项目采用 Apache-2.0,第三方组件保留各自许可与署名。
+
+## 下载
+
+- `Yaoyao-0.4.78-arm64.dmg`:macOS Apple Silicon 纯客户端安装包。
+- `Yaoyao-0.4.78-arm64.zip`、两种安装包对应的 `.blockmap` 和 `latest-mac.yml`:自动更新附件。
+- `Yaoyao-0.4.78-source.tar.gz`:Web/桌面源码和部署配置。
+- `SHA256SUMS.txt`:发布附件的 SHA-256 校验值。
+
+## Docker Hub
+
+| 镜像 | 架构 |
+| --- | --- |
+| `samienluo/yaoyao:v0.4.78-amd` | `linux/amd64` |
+| `samienluo/yaoyao:v0.4.78` | `linux/amd64`、`linux/arm64` |
+| `samienluo/yaoyao:latest` | 验证后更新为本次双架构镜像 |
+
+```sh
+docker pull samienluo/yaoyao:v0.4.78
+docker compose --env-file docker.env pull web
+docker compose --env-file docker.env up -d --no-build web
+```
+
+Compose 镜像配置设为 `samienluo/yaoyao:v0.4.78`。Docker 镜像上传到 Docker Hub;GitHub 提供客户端、源码和校验附件。旧版本标签与附件保留。
+
+附件摘要、镜像摘要和验证结果见 [GitHub 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.78)。

+ 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.

+ 19 - 0
licenses/ssh2/LICENSE

@@ -0,0 +1,19 @@
+Copyright Brian White. All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.

+ 120 - 5
package-lock.json

@@ -1,12 +1,12 @@
 {
-  "name": "yaoyao",
-  "version": "0.4.77",
+  "name": "@lsamien/yaoyao",
+  "version": "0.4.78",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "yaoyao",
-      "version": "0.4.77",
+      "name": "@lsamien/yaoyao",
+      "version": "0.4.78",
       "license": "Apache-2.0",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",
@@ -34,6 +34,7 @@
         "koa": "^2.15.3",
         "koa-send": "^5.0.1",
         "koa-static": "^5.0.0",
+        "libsodium-wrappers-sumo": "0.8.4",
         "markdown-it": "^14.1.0",
         "mime-types": "^3.0.1",
         "naive-ui": "^2.44.1",
@@ -43,6 +44,7 @@
         "qrcode": "^1.5.4",
         "read-excel-file": "^7.0.3",
         "sass": "^1.99.0",
+        "ssh2": "1.17.0",
         "typescript": "~6.0.2",
         "vite": "^8.0.4",
         "vue": "^3.5.32",
@@ -52,12 +54,12 @@
         "zod": "^4.1.12"
       },
       "bin": {
-        "hermes-yaoyao": "bin/hermes-yaoyao.mjs",
         "yaoyao": "bin/hermes-yaoyao.mjs"
       },
       "devDependencies": {
         "@playwright/test": "^1.60.0",
         "@types/jsdom": "^21.1.7",
+        "@types/ssh2": "1.15.5",
         "@types/supertest": "^6.0.3",
         "@vitest/coverage-v8": "^3.2.4",
         "@vue/test-utils": "^2.4.6",
@@ -2979,6 +2981,33 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/ssh2": {
+      "version": "1.15.5",
+      "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
+      "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "^18.11.18"
+      }
+    },
+    "node_modules/@types/ssh2/node_modules/@types/node": {
+      "version": "18.19.130",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+      "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "node_modules/@types/ssh2/node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/superagent": {
       "version": "8.1.11",
       "resolved": "https://registry.npmmirror.com/@types/superagent/-/superagent-8.1.11.tgz",
@@ -3768,6 +3797,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/asn1": {
+      "version": "0.2.6",
+      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
+      "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": "~2.1.0"
+      }
+    },
     "node_modules/asn1js": {
       "version": "3.0.10",
       "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -3890,6 +3928,15 @@
       ],
       "license": "MIT"
     },
+    "node_modules/bcrypt-pbkdf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+      "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tweetnacl": "^0.14.3"
+      }
+    },
     "node_modules/bidi-js": {
       "version": "1.0.3",
       "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -3941,6 +3988,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/buildcheck": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
+      "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
+      "optional": true,
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
     "node_modules/builder-util": {
       "version": "26.15.3",
       "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz",
@@ -4529,6 +4585,20 @@
       "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
       "license": "MIT"
     },
+    "node_modules/cpu-features": {
+      "version": "0.0.10",
+      "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
+      "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
+      "hasInstallScript": true,
+      "optional": true,
+      "dependencies": {
+        "buildcheck": "~0.0.6",
+        "nan": "^2.19.0"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
     "node_modules/cross-dirname": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
@@ -6906,6 +6976,21 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/libsodium-sumo": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz",
+      "integrity": "sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==",
+      "license": "ISC"
+    },
+    "node_modules/libsodium-wrappers-sumo": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz",
+      "integrity": "sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==",
+      "license": "ISC",
+      "dependencies": {
+        "libsodium-sumo": "^0.8.0"
+      }
+    },
     "node_modules/lie": {
       "version": "3.3.0",
       "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
@@ -7559,6 +7644,13 @@
         "vue": "^3.0.0"
       }
     },
+    "node_modules/nan": {
+      "version": "2.29.0",
+      "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz",
+      "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/nanoid": {
       "version": "3.3.18",
       "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
@@ -9061,6 +9153,23 @@
       "license": "BSD-3-Clause",
       "optional": true
     },
+    "node_modules/ssh2": {
+      "version": "1.17.0",
+      "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
+      "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "asn1": "^0.2.6",
+        "bcrypt-pbkdf": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      },
+      "optionalDependencies": {
+        "cpu-features": "~0.0.10",
+        "nan": "^2.23.0"
+      }
+    },
     "node_modules/stackback": {
       "version": "0.0.2",
       "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
@@ -9661,6 +9770,12 @@
         "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
       }
     },
+    "node_modules/tweetnacl": {
+      "version": "0.14.5",
+      "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+      "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
+      "license": "Unlicense"
+    },
     "node_modules/type-fest": {
       "version": "0.13.1",
       "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",

+ 4 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@lsamien/yaoyao",
-  "version": "0.4.77",
+  "version": "0.4.78",
   "license": "Apache-2.0",
   "description": "夭夭 AI:聊天、多 Agent 团队、文件库与电脑工作空间",
   "type": "module",
@@ -9,7 +9,6 @@
     "node": ">=24.0.0"
   },
   "bin": {
-    "hermes-yaoyao": "./bin/hermes-yaoyao.mjs",
     "yaoyao": "./bin/hermes-yaoyao.mjs"
   },
   "scripts": {
@@ -70,6 +69,7 @@
     "koa": "^2.15.3",
     "koa-send": "^5.0.1",
     "koa-static": "^5.0.0",
+    "libsodium-wrappers-sumo": "0.8.4",
     "markdown-it": "^14.1.0",
     "mime-types": "^3.0.1",
     "naive-ui": "^2.44.1",
@@ -79,6 +79,7 @@
     "qrcode": "^1.5.4",
     "read-excel-file": "^7.0.3",
     "sass": "^1.99.0",
+    "ssh2": "1.17.0",
     "typescript": "~6.0.2",
     "vite": "^8.0.4",
     "vue": "^3.5.32",
@@ -90,6 +91,7 @@
   "devDependencies": {
     "@playwright/test": "^1.60.0",
     "@types/jsdom": "^21.1.7",
+    "@types/ssh2": "1.15.5",
     "@types/supertest": "^6.0.3",
     "@vitest/coverage-v8": "^3.2.4",
     "@vue/test-utils": "^2.4.6",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.4.77",
-  "webVersion": "0.4.77",
-  "gitTag": "v0.4.77"
+  "releaseVersion": "0.4.78",
+  "webVersion": "0.4.78",
+  "gitTag": "v0.4.78"
 }

+ 6 - 0
src/client/App.vue

@@ -4,13 +4,19 @@ import { useRoute, useRouter } from 'vue-router'
 import LoadingScreen from '@/components/app/LoadingScreen.vue'
 import LoginView from '@/views/LoginView.vue'
 import PasswordChangeView from '@/views/PasswordChangeView.vue'
+import { useUnreadStore } from '@/stores/unread'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { useKanbanStore } from '@/stores/kanban'
 import { useThemeStore } from '@/stores/theme'
 import AgentIdentityFixture from '@/components/app/AgentIdentityFixture.vue'
 
+const unread=useUnreadStore()
 const auth = useAuthStore()
+watch(()=>auth.isAuthenticated ? `${auth.serverIdentity?.serverId ?? location.origin}:${auth.user?.id ?? ''}` : '',()=>{
+ if(auth.isAuthenticated&&auth.user)unread.start(auth.user.id)
+ else { unread.reset();void window.yaoyaoDesktop?.unreadState?.(null).catch(()=>{}) }
+},{immediate:true})
 const chat = useChatStore()
 const kanban = useKanbanStore()
 const theme = useThemeStore()

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

@@ -0,0 +1,161 @@
+<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')
+const configureUse=ref(false),useKind=ref<'ssh.exec'|'sftp.read'|'sftp.write'>('ssh.exec'),command=ref('/usr/bin/uptime'),remotePath=ref(''),contents=ref(''),maxBytes=ref(65536),clearPassphrase=ref(false)
+const loginPath=ref('/login'),submitPath=ref('/login'),successPath=ref('/account'),logoutPath=ref(''),formId=ref('login'),usernameName=ref('username'),passwordName=ref('password'),successSelector=ref('#login-success')
+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}
+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:''
+  configureUse.value=!!entry?.usage;useKind.value=entry?.usage&&entry.usage.kind!=='website.form'?entry.usage.kind:'ssh.exec'
+  command.value=entry?.usage?.kind==='ssh.exec'?entry.usage.command:'/usr/bin/uptime';remotePath.value=entry?.usage&&(entry.usage.kind==='sftp.read'||entry.usage.kind==='sftp.write')?entry.usage.remotePath:''
+  maxBytes.value=entry?.usage?.kind==='sftp.read'?entry.usage.maxBytes:65536
+  const p=entry?.usage?.kind==='website.form'?entry.usage:undefined
+  loginPath.value=p?.loginPath??'/login';submitPath.value=p?.submitPath??'/login';successPath.value=p?.successPath??'/account';logoutPath.value=p?.logoutPath??'';formId.value=p?.formId??'login';usernameName.value=p?.usernameName??'username';passwordName.value=p?.passwordName??'password';successSelector.value=p?.successSelector??'#login-success'
+  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 usage:Record<string,JsonValue>|undefined=!configureUse.value?undefined:kind.value==='website'?{kind:'website.form',loginPath:loginPath.value,submitPath:submitPath.value,successPath:successPath.value,...(logoutPath.value?{logoutPath:logoutPath.value}:{}),formId:formId.value,usernameName:usernameName.value,passwordName:passwordName.value,successSelector:successSelector.value}:useKind.value==='ssh.exec'?{kind:'ssh.exec',command:command.value}:useKind.value==='sftp.read'?{kind:'sftp.read',remotePath:remotePath.value,maxBytes:maxBytes.value}:{kind:'sftp.write',remotePath:remotePath.value,...(contents.value||!editing.value||editing.value.usage?.kind!=='sftp.write'?{contents:contents.value}:{})}
+  const body={name:name.value,username:username.value,target,...(usage?{usage}:{}),...(secret.value?{secret:secret.value}:{}),...(clearPassphrase.value?{passphrase:''}:passphrase.value?{passphrase:passphrase.value}:{}),...(editing.value?{revision:editing.value.revision}:{})}
+  await action(async()=>{await request(editing.value?'/entries/'+editing.value.id:'/entries',editing.value?'PUT':'POST',body);showEditor.value=false},'凭据已加密保存,密码与私钥不回显')
+}
+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}
+function describeUse(entry?:CredentialSummary){const p=entry?.usage;if(!p)return '尚未配置,将转人工接管';if(p.kind==='website.form')return '网站表单 '+p.loginPath+' → '+p.submitPath+' → '+p.successPath+(p.logoutPath?' → 退出 '+p.logoutPath:'(远端会话按站点规则到期)');if(p.kind==='ssh.exec')return 'SSH '+p.command+',固定无参数';return p.kind==='sftp.read'?'SFTP 读取校验 '+p.remotePath+',最多 '+p.maxBytes+' 字节':'SFTP 创建新文件 '+p.remotePath+','+p.bytes+' 字节'}
+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">{{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>
+    <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>
+          <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>
+          <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>
+            <label>退出页路径(可选、仅批准的 GET)<input v-model="logoutPath" :disabled="busy" /></label><p>操作后销毁本地 Cookie;未配置退出页时,远端会话仍按站点规则到期。取消时不追加退出请求。</p>
+            <label>表单 HTML id<input v-model="formId" required :disabled="busy" /></label><label>用户名 input name<input v-model="usernameName" required :disabled="busy" /></label><label>密码 input name<input v-model="passwordName" required :disabled="busy" /></label><label>成功标记(#id 或 [data-…])<input v-model="successSelector" required :disabled="busy" /></label>
+          </template>
+          <template v-if="configureUse&&kind==='ssh'">
+            <label>允许操作<select v-model="useKind" :disabled="busy"><option value="ssh.exec">固定 SSH 可执行文件</option><option value="sftp.read">SFTP 指定文件读取校验</option><option value="sftp.write">SFTP 创建指定新文件</option></select></label>
+            <label v-if="useKind==='ssh.exec'">批准的绝对可执行路径(无参数、无 shell 拼接)<input v-model="command" required :disabled="busy" /></label>
+            <label v-else>批准的远端绝对路径<input v-model="remotePath" required :disabled="busy" /></label>
+            <label v-if="useKind==='sftp.read'">最大读取字节数<input v-model.number="maxBytes" type="number" min="1" max="1048576" required :disabled="busy" /></label>
+            <label v-if="useKind==='sftp.write'">固定写入正文(加密保存;最多 64 KiB)<textarea v-model="contents" maxlength="65536" autocomplete="off" :disabled="busy" /><small v-if="editing?.usage?.kind==='sftp.write'">留空保留原正文,不回显。写入仅创建新文件,不覆盖;中断需人工核对。</small></label>
+            <p>仅密钥认证,固定 host key;不转发 Agent/端口。Bot 只收到退出码或字节数和校验,收不到输出正文、密钥或文件正文。</p>
+          </template>
+          <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>
+          <p>批准的使用计划:{{describeUse(state.entries.find(e=>e.id===credentialId))}}</p>
+          <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"

File diff suppressed because it is too large
+ 21 - 0
src/client/components/app/UnreadPanel.vue


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

@@ -1,4 +1,6 @@
 <script setup lang="ts">
+import UnreadPanel from './UnreadPanel.vue'
+import { useUnreadStore } from '@/stores/unread'
 import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import type { Profile } from '@shared/types'
@@ -27,6 +29,7 @@ type SettingsPage =
   | 'agent-models'
   | 'account-profile'
   | 'account-security'
+  | 'credential-vault'
   | 'account-mobile'
   | 'appearance'
   | 'system-overview'
@@ -98,6 +101,7 @@ const emit = defineEmits<{
   botSettingsChanged: []
 }>()
 
+const unread=useUnreadStore(),unreadOpen=ref(false)
 const route = useRoute()
 const router = useRouter()
 const mobileDrawerOpen = ref(false)
@@ -443,7 +447,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
 </script>
 
 <template>
-  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="settingsOpen || !!standalone || (applicationWorkspace && sidebarSearchOpen)">
+  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="unreadOpen || settingsOpen || !!standalone || (applicationWorkspace && sidebarSearchOpen)">
     <header class="mobile-header" :inert="mobileDrawerOpen">
       <button ref="mobileNavigationTrigger" class="icon-button" type="button" aria-label="打开导航" @click="openMobileDrawer">
         <AppIcon name="menu" :size="20" />
@@ -539,6 +543,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
         </div>
       </section>
 
+      <button class="unread-entry" type="button" :aria-label="`未读消息,${unread.total} 条`" title="未读消息" @click="unreadOpen=true"><AppIcon name="bell" :size="19" /><span v-if="!sidebarCollapsed || applicationWorkspace">未读消息</span><b v-if="unread.total">{{ unread.total>99?'99+':unread.total }}</b></button>
       <div class="sidebar-footer">
         <button v-if="applicationWorkspace" class="sidebar-tools-trigger" type="button" aria-haspopup="menu" :aria-expanded="toolsMenuOpen" @click="openToolsMenu"><YaoYaoSidebarIcon name="tools" :size="18" /><span>工具</span><AppIcon name="chevron-down" :size="15" /></button>
         <div class="sidebar-account-switcher">
@@ -747,6 +752,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
         </div>
       </div>
     </Teleport>
+  <UnreadPanel v-if="unreadOpen" :mode="applicationWorkspace?'bot':'chat'" @close="unreadOpen=false" />
   </div>
 </template>
 
@@ -995,3 +1001,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
  .workspace-shell--conversation-open>.workspace-main{display:flex}
 }
 </style>
+
+<style scoped>
+.unread-entry{display:flex;align-items:center;gap:10px;margin:8px 12px;padding:10px 12px;border:0;border-radius:10px;background:transparent;color:inherit;text-align:left;cursor:pointer;min-height:44px}.unread-entry:hover{background:var(--surface-hover,rgba(128,128,128,.1))}.unread-entry span{flex:1}.unread-entry b{border-radius:20px;background:var(--accent,#796bcd);color:white;min-width:22px;padding:2px 6px;font-size:12px;white-space:nowrap}.desktop-sidebar--collapsed .unread-entry{position:relative;margin:8px;padding:10px}.desktop-sidebar--collapsed .unread-entry b{position:absolute;top:0;right:0;font-size:10px}@media(max-width:767px){.unread-entry{display:none}}
+</style>

+ 46 - 33
src/client/components/composer/ComposerShell.vue

@@ -21,7 +21,8 @@ const props = withDefaults(defineProps<{
   reasoningOptions?: ComposerOption[]
   contextUsed?: number
   contextLimit?: number
-  contextEstimated?: boolean
+  contextPercent?: number
+  showContext?: boolean
   queueMode?: boolean
   toolTraceVisible?: boolean
   reference?: ComposerReference | null
@@ -49,7 +50,7 @@ const props = withDefaults(defineProps<{
   reasoningOptions: () => [],
   contextUsed: 0,
   contextLimit: 0,
-  contextEstimated: false,
+  showContext: false,
   queueMode: false,
   toolTraceVisible: true,
   reference: null,
@@ -106,8 +107,10 @@ const canStop = computed(() => props.streaming && (props.stopWhileRunning || pro
 // holds a draft, so resource waits can always be cancelled by the user.
 const showStop = computed(() => canStop.value && !text.value.trim() && !attachments.value.length)
 const compactModel = computed(() => props.modelLabel.split('/').filter(Boolean).at(-1) || props.modelLabel)
-const contextPercent = computed(() => props.contextLimit > 0 ? Math.min(100, Math.round(props.contextUsed / props.contextLimit * 100)) : 0)
-const hasContext = computed(() => props.contextLimit > 0)
+const contextPercent = computed(() => Math.max(0, Math.min(100, props.contextPercent
+  ?? (props.contextLimit > 0 ? Math.round(props.contextUsed / props.contextLimit * 100) : 0))))
+const hasContextUsage = computed(() => props.contextUsed > 0)
+const hasContext = computed(() => props.showContext || props.contextLimit > 0 || hasContextUsage.value)
 const remainingContextTokens = computed(() => Math.max(0, props.contextLimit - props.contextUsed))
 const reasoningOptionIndex = computed(() => Math.max(0, props.reasoningOptions.findIndex(option => option.id === props.reasoningValue)))
 const reasoningLabel = computed(() => props.reasoningOptions[reasoningOptionIndex.value]?.label || props.reasoningEffort)
@@ -494,28 +497,23 @@ defineExpose({
 
 <template>
   <div class="composer-area">
-    <div v-if="attachments.length" class="composer-attachments" aria-label="附件">
-      <div v-for="attachment in attachments" :key="attachment.id" class="composer-attachment" :class="{ image: isImage(attachment.type) }">
-        <img v-if="attachment.previewUrl" :src="attachment.previewUrl" :alt="attachment.name" />
-        <AppIcon v-else name="file" :size="19" />
-        <span><strong>{{ attachment.name }}</strong><small>{{ formatSize(attachment.size) }}</small></span>
-        <button type="button" :aria-label="`移除 ${attachment.name}`" @click="removeAttachment(attachment.id)"><AppIcon name="close" :size="12" /></button>
-      </div>
-    </div>
-
     <div v-if="reference" class="composer-reference">
       <AppIcon name="quote" :size="14" />
       <span><small>{{ reference.author ? `回复 ${reference.author}` : '回复消息' }}</small><strong>{{ reference.content.replace(/\s+/g, ' ').trim() }}</strong></span>
       <button type="button" aria-label="取消引用" @click="emit('clearReference')"><AppIcon name="close" :size="13" /></button>
     </div>
 
-    <div v-if="mode === 'group'" class="composer-activity-slot">
-      <Transition name="composer-activity">
-        <div v-if="activityText" class="composer-activity" role="status" aria-label="机器人输入状态" aria-live="polite" aria-atomic="true">
-          <span class="composer-typing-dots" aria-hidden="true"><i /><i /><i /></span>
-          <strong>{{ activityText }}</strong>
-        </div>
-      </Transition>
+    <!-- Status changes share one row without resizing the transcript. -->
+    <div class="composer-activity-slot">
+      <slot name="activity">
+        <Transition name="composer-activity">
+          <div v-if="activityText" class="composer-activity" role="status" aria-label="机器人输入状态" aria-live="polite" aria-atomic="true">
+            <span class="composer-typing-dots" aria-hidden="true"><i /><i /><i /></span>
+            <strong>{{ activityText }}</strong>
+          </div>
+        </Transition>
+      </slot>
+      <span v-if="sending" class="composer-sending" role="status" aria-live="polite"><i />正在发送</span>
     </div>
 
     <slot name="before-input" />
@@ -532,9 +530,20 @@ defineExpose({
       <input ref="fileInput" class="composer-file-input" type="file" multiple @change="onFiles" />
       <div class="composer-resize" title="拖动调整高度;双击复位" @pointerdown="startResize" @dblclick="resetHeight" />
 
-      <div v-if="hasContext" class="composer-context" :class="{ warning: contextPercent > 80 }" :title="contextEstimated ? 'Hermes 未提供实时上下文用量;此数值按当前会话内容估算。' : undefined">
-        <span>{{ contextEstimated ? '约 ' : '' }}{{ formatTokens(contextUsed) }} / {{ formatTokens(contextLimit) }} · 剩余 {{ formatTokens(remainingContextTokens) }}</span>
-        <i><b :style="{ width: `${contextPercent}%` }" /></i>
+      <div v-if="hasContext" class="composer-context" :class="{ warning: hasContextUsage && contextPercent > 80 }" :title="hasContextUsage ? '最近一次模型请求的上下文用量' : '等待服务端提供最近一次模型请求的上下文用量'">
+        <span v-if="!hasContextUsage">等待本轮实际用量 · {{ contextLimit > 0 ? `窗口 ${formatTokens(contextLimit)}` : '窗口未确认' }}</span>
+        <span v-else-if="contextLimit > 0">{{ formatTokens(contextUsed) }} / {{ formatTokens(contextLimit) }} · 剩余 {{ formatTokens(remainingContextTokens) }}</span>
+        <span v-else>当前上下文 {{ formatTokens(contextUsed) }} · 窗口未确认</span>
+        <i v-if="hasContextUsage && contextLimit > 0"><b :style="{ width: `${contextPercent}%` }" /></i>
+      </div>
+
+      <div v-if="attachments.length" class="composer-attachments" aria-label="附件">
+        <div v-for="attachment in attachments" :key="attachment.id" class="composer-attachment" :class="{ image: isImage(attachment.type) }">
+          <img v-if="attachment.previewUrl" :src="attachment.previewUrl" :alt="attachment.name" />
+          <AppIcon v-else name="file" :size="19" />
+          <span><strong>{{ attachment.name }}</strong><small>{{ formatSize(attachment.size) }}</small></span>
+          <button type="button" :aria-label="`移除 ${attachment.name}`" @click="removeAttachment(attachment.id)"><AppIcon name="close" :size="12" /></button>
+        </div>
       </div>
 
       <textarea
@@ -628,12 +637,14 @@ defineExpose({
           <button v-else-if="mentionOptions.length" type="button" class="composer-tool composer-mention-hint" :disabled="disabled" aria-label="提及成员" @click="startMention"><At class="composer-action-icon" aria-hidden="true" /><span>提及成员</span></button>
         </div>
         <div class="composer-actions">
-          <button v-if="mode === 'chat' && streaming && canSubmit" class="queue-toggle" :class="{ active: queueMode }" type="button" :title="queueMode ? '消息将排队发送' : '消息将 Steer 当前会话'" @click="emit('queueToggle')">
-            {{ queueMode ? '排队' : 'Steer' }}
-          </button>
-          <span v-if="sending" class="composer-sending" role="status" aria-live="polite"><i />正在发送</span>
-          <span v-if="canStop && !showStop" class="composer-stop-hitbox">
-            <button class="composer-stop" type="button" aria-label="停止生成" title="停止生成" @click="emit('stop')">
+          <!-- Keep action widths stable when queue/send/stop controls switch. -->
+          <span v-if="mode === 'chat'" class="composer-queue-slot">
+            <button v-if="streaming && canSubmit" class="queue-toggle" :class="{ active: queueMode }" type="button" :title="queueMode ? '消息将排队发送' : '消息将 Steer 当前会话'" @click="emit('queueToggle')">
+              {{ queueMode ? '排队' : 'Steer' }}
+            </button>
+          </span>
+          <span v-if="stopWhileRunning || mode === 'chat'" class="composer-stop-hitbox">
+            <button v-if="canStop && !showStop" class="composer-stop" type="button" aria-label="停止生成" title="停止生成" @click="emit('stop')">
               <AppIcon name="stop" :size="15" />
             </button>
           </span>
@@ -681,7 +692,7 @@ defineExpose({
 
 <style scoped>
 .composer-area { position: relative; z-index: 12; flex: 0 0 auto; padding: 12px max(24px, calc((100% - 760px) / 2)) max(18px, env(safe-area-inset-bottom)); background: var(--conversation-canvas); }
-.composer-activity-slot { display: flex; width: 100%; max-width: 760px; min-height: 20px; margin: 0 auto 3px; align-items: center; }
+.composer-activity-slot { display: flex; width: 100%; max-width: 760px; height: 20px; margin: 0 auto 3px; align-items: center; gap: 8px; }
 .composer-activity { display: inline-flex; min-width: 0; align-items: center; gap: 7px; color: var(--text-muted); font-size: 10px; }
 .composer-activity strong { overflow: hidden; color: var(--text-secondary); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
 .composer-typing-dots { display: inline-flex; align-items: center; gap: 2px; }.composer-typing-dots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: composer-typing 1.05s ease-in-out infinite; }.composer-typing-dots i:nth-child(2) { animation-delay: 140ms; }.composer-typing-dots i:nth-child(3) { animation-delay: 280ms; }
@@ -750,6 +761,7 @@ defineExpose({
 .composer-setting-row:hover { background: var(--surface-hover); color: var(--text-primary); }
 .composer-setting-row > span { display: inline-flex; align-items: center; gap: 7px; }
 .queue-toggle { height: 26px; padding: 0 7px; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; }
+.composer-queue-slot { display: grid; width: 48px; height: 34px; place-items: center; }
 .queue-toggle:hover, .queue-toggle.active { background: var(--surface-hover); color: var(--text-primary); }
 .composer-send-hitbox { display: grid; place-items: center; width: 34px; height: 34px; }
 .composer-stop-hitbox { display: grid; place-items: center; width: 30px; height: 34px; }
@@ -759,10 +771,11 @@ defineExpose({
 .composer-send--stop { background: var(--accent); }
 .composer-stop { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; transition: color 150ms ease, border-color 150ms ease; }
 .composer-stop:hover { color: var(--danger); border-color: var(--danger); }
-.composer-sending { display: inline-flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; white-space: nowrap; }.composer-sending i { width: 5px; height: 5px; border-radius: 50%; background: var(--warning); animation: composer-pulse 1s ease-in-out infinite; }
+.composer-sending { display: inline-flex; flex-shrink: 0; margin-left: auto; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; white-space: nowrap; }.composer-sending i { width: 5px; height: 5px; border-radius: 50%; background: var(--warning); animation: composer-pulse 1s ease-in-out infinite; }
 @keyframes composer-pulse { 50% { opacity: .25; transform: scale(.7); } }
-.composer-attachments { display: flex; width: 100%; max-width: 760px; margin: 0 auto 9px; flex-wrap: wrap; gap: 7px; }
-.composer-attachment { position: relative; display: flex; max-width: 210px; min-height: 48px; align-items: center; gap: 8px; padding: 6px 28px 6px 9px; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-raised); color: var(--text-secondary); }
+.composer-attachments { display: flex; width: 100%; min-width: 0; flex-wrap: wrap; gap: 8px; }
+.composer-context + .composer-attachments { padding-top: 16px; }
+.composer-attachment { position: relative; display: flex; max-width: min(210px, 100%); min-height: 48px; align-items: center; gap: 8px; padding: 6px 28px 6px 9px; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-raised); color: var(--text-secondary); }
 .composer-attachment.image { width: 64px; height: 64px; padding: 0; }
 .composer-attachment img { width: 100%; height: 100%; object-fit: cover; }
 .composer-attachment > span { display: flex; min-width: 0; flex-direction: column; }

+ 4 - 0
src/client/components/messages/MessageTimeline.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import {useVisibleMessages} from '@/utils/visibleMessages'
 import { useChatAppearance } from '@/stores/chatAppearance'
 import { bubbleVariables } from '@/utils/chatAppearance'
 import '@/styles/chat-bubbles.css'
@@ -73,6 +74,7 @@ const props = withDefaults(defineProps<{
 })
 
 const emit = defineEmits<{
+  visibleMessages: [ids:string[]]
   loadOlder: []
   quote: [message: UiMessage]
   branch: [message: UiMessage]
@@ -83,6 +85,7 @@ const emit = defineEmits<{
 }>()
 
 const scroller = ref<HTMLElement | null>(null)
+useVisibleMessages(scroller,ids=>emit('visibleMessages',ids))
 const pinnedToBottom = ref(true)
 const showJump = ref(false)
 const copiedMessageId = ref('')
@@ -134,6 +137,7 @@ function scrollToMessage(id: string): boolean {
   if (!root) return false
   const message = root.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
   if (!message) return false
+  pinnedToBottom.value=false
   message.scrollIntoView({ block: 'center', behavior: 'smooth' })
   message.classList.remove('message--revealed')
   requestAnimationFrame(() => message.classList.add('message--revealed'))

+ 3 - 0
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import {useVisibleMessages} from '@/utils/visibleMessages'
 import { useChatAppearance } from '@/stores/chatAppearance'
 import { bubbleVariables } from '@/utils/chatAppearance'
 import '@/styles/chat-bubbles.css'
@@ -91,6 +92,7 @@ const props = withDefaults(defineProps<{
 })
 
 const emit = defineEmits<{
+  visibleMessages: [ids:string[]]
   loadOlder: []
   quote: [message: UiMessage]
   branch: [message: UiMessage]
@@ -102,6 +104,7 @@ const emit = defineEmits<{
 }>()
 
 const scroller = ref<HTMLElement | null>(null)
+useVisibleMessages(scroller,ids=>emit('visibleMessages',ids))
 const pinnedToBottom = ref(true)
 const showJump = ref(false)
 const copiedMessageId = ref('')

+ 1 - 0
src/client/env.d.ts

@@ -11,6 +11,7 @@ interface ImportMeta {
 
 interface Window {
   yaoyaoDesktop?: {
+    unreadState?(accountId: string | null): Promise<void>
     openLogin?(): Promise<unknown>
     forgetLogin?(): Promise<unknown>
     openUpdates?(): Promise<void>

+ 34 - 13
src/client/stores/chat.ts

@@ -20,6 +20,7 @@ import { bool, normalizeChatMessage, number, record, string, values } from '@/ut
 import { appendSessionPage, pinnedSessionsFirst } from '@/utils/sessionOrder'
 import { modelForSession, modelSelectionFromSessionInfo } from '@/utils/sessionModel'
 import { moveSessionFastMode, readSessionFastMode, writeSessionFastMode } from '@/utils/sessionPreferences'
+import { normalizeChatUsage } from '@/utils/contextUsage'
 import { useAuthStore } from './auth'
 
 interface CachedHistory { messages: ChatMessage[]; total: number; savedAt: number; transcript?: Omit<TranscriptSnapshot,'messages'> }
@@ -98,6 +99,7 @@ export const useChatStore = defineStore('chat', () => {
   const error = ref<string>()
   const models = ref<ModelOption[]>([])
   const unreadCounts = ref<Record<string, number>>({})
+  const contextUsageRequests = new Map<string, number>()
   const selectedModel = ref<ModelOption>()
   const reasoningEffort = ref<string>()
   const socket = new ChatRpcSocket()
@@ -158,6 +160,11 @@ export const useChatStore = defineStore('chat', () => {
         // Usage events replace the route while storage is pending. Commit to
         // the current route after both writes, preserving any newer local send.
         const latest=current();if(!latest)return
+        const usage=normalizeChatUsage(value.session)
+        if(usage && (['contextTokens','contextLimit','percentUsed'] as const)
+          .some(field => latest.usage?.[field] === undefined && usage[field] !== undefined)) {
+          latest.usage={...usage,...latest.usage}
+        }
         const latestPending=latest.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
         latest.messages=mergeChatMessages(canonical,latestPending);latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
         const approval=value.pendingApproval,clarification=value.pendingClarification
@@ -322,6 +329,7 @@ export const useChatStore = defineStore('chat', () => {
       if (active?.historySynced && (active.isStreaming || activeSession.value?.owned === true)
         && !active.route.sessionId.startsWith('draft-') && !active.runtimeSessionId && !reconnectResumePromise) {
         reconnectResumePromise = ensureRuntime(active)
+          .then(current => refreshContextUsage(current))
           .catch(cause => { active.error = errorMessage(cause) })
           .finally(() => { reconnectResumePromise = undefined })
       }
@@ -419,6 +427,8 @@ export const useChatStore = defineStore('chat', () => {
       if (liveSelection) {
         reconcileSessionModel(state.route.profile, state.route.sessionId, liveSelection.model, liveSelection.provider)
       }
+      const usage = normalizeChatUsage(payload)
+      if (usage) state.usage = { ...state.usage, ...usage }
     }
     const info = record(payload.info)
     const liveFastMode = optionalBoolean(payload.fast ?? info.fast)
@@ -644,10 +654,11 @@ export const useChatStore = defineStore('chat', () => {
     syncSelectedModel(refreshed?.model, refreshed?.provider)
     if (refreshed?.owned === true && !sessionId.startsWith('draft-')
       && activeSessionId.value === sessionId && activeProfileName.value === selectedProfile) {
-      await ensureRuntime(routes[routeKey(selectedProfile, sessionId)] ?? state).catch(cause => {
+      const current = await ensureRuntime(routes[routeKey(selectedProfile, sessionId)] ?? state).catch(cause => {
         const current = routes[routeKey(selectedProfile, sessionId)]
         if (current) current.error = errorMessage(cause)
       })
+      if (current) void refreshContextUsage(current).catch(() => undefined)
     }
   }
 
@@ -717,6 +728,8 @@ export const useChatStore = defineStore('chat', () => {
       if (!runtimeId || !storedId) throw new Error('Hermes 未返回新会话标识')
       const migrated = migrateRoute(initialKey, storedId, runtimeId)
       const info = record(result.info)
+      const usage = normalizeChatUsage(result)
+      if (usage) migrated.usage = { ...migrated.usage, ...usage }
       if (!desiredModelRoutes.has(routeKey(migrated.route.profile, migrated.route.sessionId))) {
         syncSelectedModel(string(result.model ?? info.model), string(result.provider ?? info.provider))
       }
@@ -739,6 +752,8 @@ export const useChatStore = defineStore('chat', () => {
     const migrated = migrateRoute(initialKey, storedId, runtimeId)
     migrated.error = undefined
     const info = record(result.info)
+    const usage = normalizeChatUsage(result)
+    if (usage) migrated.usage = { ...migrated.usage, ...usage }
     if (!desiredModelRoutes.has(routeKey(migrated.route.profile, migrated.route.sessionId))) {
       syncSelectedModel(string(result.model ?? info.model), string(result.provider ?? info.provider))
     }
@@ -1059,18 +1074,24 @@ export const useChatStore = defineStore('chat', () => {
     // Opening history is read-only. Resuming an old session merely to fetch
     // usage updates Hermes' last_active timestamp and incorrectly moves it
     // into today's sidebar group.
-    if (!initialState || initialState.route.sessionId.startsWith('draft-') || !initialState.runtimeSessionId) return
-    const current = initialState
-    const usage = resultRecord(await socket.request('session.usage', { session_id: current.runtimeSessionId! }))
-    current.usage = {
-      inputTokens: number(usage.input_tokens ?? usage.input) || undefined,
-      outputTokens: number(usage.output_tokens ?? usage.output) || undefined,
-      totalTokens: number(usage.total_tokens ?? usage.total) || undefined,
-      contextTokens: number(usage.context_tokens ?? usage.context_used ?? usage.used) || undefined,
-      contextLimit: number(usage.context_limit ?? usage.context_max ?? usage.limit) || undefined,
-      percentUsed: number(usage.percent_used ?? usage.context_percent) || undefined,
-      raw: usage as JsonValue,
-    }
+    if (!initialState || initialState.route.sessionId.startsWith('draft-') || !initialState.runtimeSessionId || initialState.isStreaming) return
+    const key = routeKey(initialState.route.profile, initialState.route.sessionId)
+    const runtimeId = initialState.runtimeSessionId
+    const generation = initialState.generation
+    const previousUsage = initialState.usage
+    const request = (contextUsageRequests.get(key) ?? 0) + 1
+    contextUsageRequests.set(key, request)
+    // Use the same source order as mobile. Breakdown can supply the gauge when
+    // session.usage only contains cumulative billing counters.
+    const results = await Promise.allSettled([
+      socket.request('session.usage', { session_id: runtimeId }, 15_000),
+      socket.request('session.context_breakdown', { session_id: runtimeId }, 15_000),
+    ])
+    const current = routes[key]
+    if (!current || current.generation !== generation || current.runtimeSessionId !== runtimeId
+      || current.usage !== previousUsage || contextUsageRequests.get(key) !== request) return
+    const usage = normalizeChatUsage(...results.flatMap(result => result.status === 'fulfilled' ? [result.value] : []))
+    if (usage) current.usage = { ...current.usage, ...usage }
   }
 
   return {

+ 38 - 0
src/client/stores/unread.ts

@@ -0,0 +1,38 @@
+import { ref, computed, reactive } from 'vue'
+import { apiRequest } from '@/api/client'
+import { emptyUnread, type UnreadConversation, type UnreadSnapshot } from '@shared/unread'
+const createUnreadStore=()=>{
+ const snapshot=ref<UnreadSnapshot>(emptyUnread()),error=ref(''),scope=ref('')
+ let epoch=0,revision=0,timer:ReturnType<typeof setInterval>|undefined,inflight:Promise<void>|undefined
+ function reset(account='') { epoch++;revision++;scope.value=account;snapshot.value=emptyUnread();error.value='';inflight=undefined;clearInterval(timer);timer=undefined }
+ async function refresh(){
+  if(!scope.value)return
+  if(inflight)return inflight
+  const generation=epoch,request=revision
+  const pending=apiRequest<UnreadSnapshot>('/api/app/unread').then(value=>{
+   if(generation!==epoch||request!==revision)return
+   snapshot.value=value;error.value=''
+   void window.yaoyaoDesktop?.unreadState?.(scope.value).catch(()=>{})
+  }).catch(e=>{if(generation===epoch)error.value=e instanceof Error?e.message:'未读同步失败'})
+  inflight=pending;try{await pending}finally{if(inflight===pending)inflight=undefined}
+ }
+ function start(account:string){reset(account);void window.yaoyaoDesktop?.unreadState?.(account).catch(()=>{});void refresh();timer=setInterval(()=>void refresh(),3000)}
+ async function read(items:UnreadConversation[]){
+  const generation=epoch,account=scope.value;revision++;inflight=undefined
+  await apiRequest<UnreadSnapshot>('/api/app/unread/read',{method:'POST',body:JSON.parse(JSON.stringify({items:items.map(({mode,id,profile,messages})=>({mode,id,profile,messages}))}))})
+  if(generation!==epoch||account!==scope.value)return
+  // Concurrent reads can finish out of order; fetch the latest authority.
+  revision++;await refresh()
+ }
+ async function visible(mode:'bot'|'chat',id:string,profile:string|undefined,ids:string[]){
+  if(!ids.length||!scope.value)return
+  const row=snapshot.value.conversations.find(c=>c.mode===mode&&c.id===id&&(mode==='bot'||c.profile===profile))
+  if(!row)return
+  const selected=new Set(ids),messages=row.messages.filter(m=>selected.has(m.id))
+  if(messages.length)await read([{...row,messages}])
+ }
+ const total=computed(()=>snapshot.value.total)
+ return reactive({snapshot,total,error,reset,start,refresh,read,visible})
+}
+let store:ReturnType<typeof createUnreadStore>|undefined
+export const useUnreadStore=()=>store??=createUnreadStore()

+ 31 - 18
src/client/utils/contextUsage.ts

@@ -1,20 +1,33 @@
-import type { ChatMessage } from '@shared/types'
+import type { ChatUsage, JsonValue } from '@shared/types'
+import { record } from './normalize'
 
-function estimateTextTokens(value: string): number {
-  const nonAscii = (value.match(/[^\x00-\x7F]/g) ?? []).length
-  return Math.ceil((value.length - nonAscii) / 4 + nonAscii * 0.9)
-}
-
-/**
- * A clearly-labelled fallback for gateways that do not persist a live context
- * gauge after a session has gone idle. It is intentionally conservative and
- * estimates only visible conversation payloads, never a model's hidden prompt.
- */
-export function estimateConversationTokens(messages: ChatMessage[]): number {
-  return messages.reduce((total, message) => {
-    const tools = message.toolCalls?.map(tool => JSON.stringify({
-      name: tool.name, arguments: tool.arguments, result: tool.result, error: tool.error,
-    })).join('\n') ?? ''
-    return total + estimateTextTokens([message.content, message.reasoning ?? '', tools].join('\n'))
-  }, 0)
+/** Match mobile: context is the latest prompt occupancy, never cumulative usage
+ * or the full (possibly compacted/paginated) visible transcript. */
+export function normalizeChatUsage(...sources: unknown[]): ChatUsage | undefined {
+  const fields: [Exclude<keyof ChatUsage, 'raw'>, string[]][] = [
+    ['inputTokens', ['input_tokens', 'inputTokens', 'input', 'prompt']],
+    ['outputTokens', ['output_tokens', 'outputTokens', 'output', 'completion']],
+    ['totalTokens', ['total_tokens', 'totalTokens', 'total']],
+    ['contextTokens', ['context_used', 'contextUsed', 'context_tokens', 'contextTokens']],
+    ['contextLimit', ['context_max', 'contextMax', 'context_limit', 'contextLimit', 'context_length', 'contextLength', 'effective_context_length']],
+    ['percentUsed', ['context_percent', 'contextPercent', 'percent_used', 'percentUsed']],
+  ]
+  const usage: ChatUsage = {}
+  for (const source of sources) {
+    const root = record(source)
+    for (const value of [record(root.usage), record(record(root.info).usage), root, record(root.info)]) {
+      for (const [field, aliases] of fields) {
+        if (usage[field] !== undefined) continue
+        for (const alias of aliases) {
+          const raw = value[alias]
+          if (typeof raw !== 'number' && (typeof raw !== 'string' || !raw.trim())) continue
+          const number = Number(raw)
+          if (!Number.isFinite(number) || number < 0 || (field === 'contextLimit' && number === 0)) continue
+          usage[field] = number
+          break
+        }
+      }
+    }
+  }
+  return Object.keys(usage).length ? { ...usage, raw: sources[0] as JsonValue } : undefined
 }

+ 8 - 18
src/client/utils/messageReducer.ts

@@ -1,9 +1,10 @@
 import type {
-  ApprovalRequest, ChatMessage, ChatRouteState, ChatUsage, ClarificationRequest, JsonValue, RpcEventFrame, ToolCall,
+  ApprovalRequest, ChatMessage, ChatRouteState, ClarificationRequest, JsonValue, RpcEventFrame, ToolCall,
 } from '@shared/types'
 import { createId } from './id'
 import { toolStatus } from '@shared/chatTools'
 import { bool, number, record, string } from './normalize'
+import { normalizeChatUsage } from './contextUsage'
 
 type MergePosition = 'append' | 'prepend' | 'snapshot'
 
@@ -173,20 +174,6 @@ function toolFromEvent(payload: Record<string, unknown>, status: ToolCall['statu
   }
 }
 
-function normalizeUsage(payload: Record<string, unknown>): ChatUsage | undefined {
-  const raw = record(payload.usage ?? payload)
-  if (!Object.keys(raw).length) return undefined
-  return {
-    inputTokens: number(raw.input_tokens ?? raw.input ?? raw.prompt) || undefined,
-    outputTokens: number(raw.output_tokens ?? raw.output ?? raw.completion) || undefined,
-    totalTokens: number(raw.total_tokens ?? raw.total) || undefined,
-    contextTokens: number(raw.context_tokens ?? raw.context_used ?? raw.current_tokens ?? raw.used) || undefined,
-    contextLimit: number(raw.context_limit ?? raw.context_max ?? raw.max_tokens ?? raw.limit) || undefined,
-    percentUsed: number(raw.percent_used ?? raw.context_percent ?? raw.percentage) || undefined,
-    raw: raw as JsonValue,
-  }
-}
-
 function updateStreamingMessage(
   state: ChatRouteState,
   payload: Record<string, unknown>,
@@ -302,7 +289,8 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
       next.isStreaming = waitingForOutput
       next.isQueued = false
       next.liveStatus = undefined
-      next.usage = normalizeUsage(payload) ?? next.usage
+      const usage = normalizeChatUsage(payload)
+      if (usage) next.usage = { ...next.usage, ...usage }
       if (failed) next.error = string(payload.error ?? payload.text ?? payload.message, '运行失败')
       break
     }
@@ -323,9 +311,11 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
       break
     case 'session.usage':
     case 'usage.update':
-    case 'context.update':
-      next.usage = normalizeUsage(payload)
+    case 'context.update': {
+      const usage = normalizeChatUsage(payload)
+      if (usage) next.usage = { ...next.usage, ...usage }
       break
+    }
     case 'tool.start':
     case 'tool.progress':
     case 'tool.started':

+ 19 - 0
src/client/utils/visibleMessages.ts

@@ -0,0 +1,19 @@
+import { onMounted,onBeforeUnmount,type Ref } from 'vue'
+export function useVisibleMessages(root:Ref<HTMLElement|null>,emit:(ids:string[])=>void){
+ let timer:ReturnType<typeof setInterval>|undefined,previous=new Set<string>()
+ const scan=()=>{
+  const el=root.value
+  if(!el||document.hidden||!document.hasFocus()){previous.clear();return}
+  const box=el.getBoundingClientRect(),current=new Set<string>()
+  for(const row of el.querySelectorAll<HTMLElement>('[data-message-id]')){
+   const r=row.getBoundingClientRect(),top=Math.max(r.top,box.top,0),bottom=Math.min(r.bottom,box.bottom,innerHeight),left=Math.max(r.left,box.left,0),right=Math.min(r.right,box.right,innerWidth)
+   if(bottom-top<Math.min(24,r.height)||right<=left)continue
+   const front=document.elementFromPoint((left+right)/2,(top+bottom)/2)
+   if(front&&row.contains(front)&&row.dataset.messageId)current.add(row.dataset.messageId)
+  }
+  const settled=[...current].filter(id=>previous.has(id));previous=current
+  if(settled.length)emit(settled)
+ }
+ onMounted(()=>{timer=setInterval(scan,350)})
+ onBeforeUnmount(()=>{clearInterval(timer);previous.clear()})
+}

+ 26 - 12
src/client/views/ChatView.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import {useUnreadStore} from '@/stores/unread'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import AppIcon from '@/components/common/AppIcon.vue'
@@ -24,12 +25,12 @@ import { consumeLibraryItemForComposer, loadComposerFile } from '@/components/wo
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
 import { modelChoiceId, modelForChoiceId } from '@/utils/sessionModel'
 import { MODEL_CATALOG_CHANGED_EVENT, modelCatalogChangedProfile } from '@/utils/modelCatalogEvents'
-import { estimateConversationTokens } from '@/utils/contextUsage'
 import { isOwnedChatSession } from '@/utils/sessionOwnership'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { getSession } from '@/api/sessions'
 
+const unread=useUnreadStore()
 const auth = useAuthStore()
 const chat = useChatStore()
 const route = useRoute()
@@ -68,16 +69,10 @@ const agentAvatars = computed(() => Object.fromEntries(auth.profiles.flatMap(pro
 )))
 const sidebarItems = computed(() => sessions.value.map(session => sessionSidebarItem(
   session,
-  chat.unreadCounts[session.id] ?? 0,
+  unread.snapshot.conversations.find(c=>c.mode==='chat'&&c.id===session.id&&c.profile===session.profile)?.count ?? 0,
   agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
 )))
 const messages = computed(() => chatMessagesToUi(chat.messages, profile => profile ? agentNames.value.get(profile) : undefined))
-const reportedContextTokens = computed(() => chat.contextUsage?.contextTokens
-  || chat.contextUsage?.totalTokens
-  || (activeSession.value?.inputTokens ?? 0) + (activeSession.value?.outputTokens ?? 0))
-const estimatedContextTokens = computed(() => estimateConversationTokens(chat.messages))
-const contextUsed = computed(() => reportedContextTokens.value || estimatedContextTokens.value)
-const contextIsEstimated = computed(() => !reportedContextTokens.value && contextUsed.value > 0)
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const activeSession = computed(() => chat.activeSession)
@@ -159,6 +154,23 @@ async function refreshSessions() {
   }
 }
 
+const readPending=new Set<string>()
+async function markVisible(ids:string[]){
+ if(preview.value||filePreview.value||outlineOpen.value||modelDialog.value||!chat.activeSessionId||typeof route.query.unread==='string')return
+ const session=chat.activeSessionId,profile=chat.activeProfileName,key=`${profile}:${session}:${ids.join(',')}`
+ if(readPending.has(key))return
+ readPending.add(key);try{await unread.visible('chat',session,profile,ids)}catch{}finally{readPending.delete(key)}
+}
+let unreadNavigation=0
+watch(()=>[route.params.id,route.query.unread,chat.messages.length],async()=>{
+ const id=route.query.unread;if(typeof id!=='string'||!chat.activeSessionId)return
+ const own=++unreadNavigation
+ for(let i=0;i<100&&own===unreadNavigation;i++){
+  await nextTick();if(timeline.value?.scrollToMessage(id)){const query={...route.query};delete query.unread;await router.replace({query});return}
+  if(!chat.hasMoreBefore)return
+  const before=chat.messages.length;await chat.loadOlder();if(chat.messages.length===before)return
+ }
+},{flush:'post'})
 async function chooseSession(id: string) {
   outlineOpen.value = false
   preview.value = null
@@ -168,7 +180,7 @@ async function chooseSession(id: string) {
   await ensureRouteProfile(profile)
     if (chat.activeProfileName !== profile) await chat.loadSessions(profile, 'chat')
   await chat.selectSession(id, profile)
-  await chat.markRead(id, profile).catch(() => undefined)
+
   quoted.value = null
 }
 
@@ -426,6 +438,7 @@ watch(() => chat.activeSessionId, async id => {
 
     <div class="chat-workspace">
       <MessageTimeline
+        @visible-messages="markVisible"
         ref="timeline"
         :messages="messages"
         :title="activeSession?.title || '新会话'"
@@ -469,9 +482,10 @@ watch(() => chat.activeSessionId, async id => {
         :reasoning-effort="reasoningLabel"
         :reasoning-value="chat.reasoningEffort || ''"
         :reasoning-options="reasoningComposerOptions"
-        :context-used="contextUsed"
-        :context-limit="chat.contextUsage?.contextLimit || 262144"
-        :context-estimated="contextIsEstimated"
+        show-context
+        :context-used="chat.contextUsage?.contextTokens"
+        :context-limit="chat.contextUsage?.contextLimit"
+        :context-percent="chat.contextUsage?.percentUsed"
         :queue-mode="queueMode || chat.isQueued"
         :tool-trace-visible="showThinking"
         :reference="reference"

+ 24 - 44
src/client/views/ConversationsView.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import { useUnreadStore } from '@/stores/unread'
 import type { ComputerBackend } from '@shared/managedBrowser'
 import { WORKSPACE_PATCH_CAPABILITY } from '@shared/workspaceMessagePatch'
 import { setApiCsrfToken } from '@/api/client'
@@ -376,8 +377,9 @@ async function load(id = selected.value, append = false, signal?: AbortSignal) {
     if (!append || atBottom) {
       await nextTick()
       if (!current()) return
-      if (!append) timeline.value?.scrollToBottom('auto')
-      void markRead().catch(e => { if (current()) error.value = e instanceof Error ? e.message : '同步已读失败' })
+      if (!append && typeof route.query.unread!=='string') timeline.value?.scrollToBottom('auto')
+      if(typeof route.query.unread==='string')void locateUnread(route.query.unread)
+
     }
   } catch (e) {
     if (current() && requestedTask && e instanceof ApiError && e.status === 404) {
@@ -396,44 +398,20 @@ async function load(id = selected.value, append = false, signal?: AbortSignal) {
     if (own === generation) loading.value = false
   }
 }
+async function locateUnread(id:string){
+ for(let i=0;i<100&&active.value?.id===selected.value;i++){
+  await nextTick();if(timeline.value?.scrollToMessage(id)){const query={...route.query};delete query.unread;await router.replace({query});return}
+  if(!older.value||loadingOlder.value)return
+  await loadOlder()
+ }
+}
+const unread=useUnreadStore()
 const pendingReads = new Set<string>()
-async function markRead() {
-  const c = active.value
-  const read = activeTask.value ?? c
-  if (!c || !read || (!workspaceHasUnread(read) && read.readSeq >= read.lastSeq)) return
-  const task = activeTask.value, seq = read.lastSeq, version = read.unreadVersion
-  const key = `${c.id}:${task?.id ?? ''}:${version}:${seq}`
-  if (pendingReads.has(key)) return
-  pendingReads.add(key)
-  const previous = { unread: read.unread, unreadCount: read.unreadCount, readSeq: read.readSeq }
-  const previousConversation = { unread: c.unread, unreadCount: c.unreadCount }
-  read.unread = false;read.unreadCount = 0;read.readSeq = seq
-  if (task) tasks.value = tasks.value.map(t => t.id === task.id ? { ...t, unread: false, unreadCount: 0, readSeq: seq } : t)
-  const publishUnread = () => {
-    c.unread = task ? tasks.value.some(workspaceHasUnread) : read.unread
-    c.unreadCount = c.unread ? 1 : 0
-    const row = conversations.value.find(row => row.id === c.id)
-    if (row && row.unreadVersion === c.unreadVersion) { row.unread = c.unread;row.unreadCount = c.unreadCount }
-  }
-  publishUnread()
-  try {
-    const result = await apiRequest<{conversation: Conversation}>(`/api/app/conversations/${c.id}/read${task ? `?taskId=${task.id}` : ''}`, {
-      method: 'PUT', body: { seq, ...(version === undefined ? {} : {unreadVersion:version}) },
-    })
-    const row = conversations.value.find(row => row.id === c.id)
-    if (row && result.conversation && (row.unreadVersion ?? 0) <= (result.conversation.unreadVersion ?? 0)) {
-      row.unread = result.conversation.unread;row.unreadCount = result.conversation.unreadCount;row.unreadVersion = result.conversation.unreadVersion
-    }
-  } catch (error) {
-    const row = conversations.value.find(row => row.id === c.id)
-    if (row && row.unreadVersion === c.unreadVersion) Object.assign(row, previousConversation)
-    if (active.value?.id === c.id && activeTask.value?.id === task?.id && (activeTask.value ?? active.value)?.unreadVersion === version) {
-      Object.assign(activeTask.value ?? active.value!, previous)
-      if (task) tasks.value = tasks.value.map(t => t.id === task.id ? { ...t, ...previous, unreadCount: previous.unreadCount ?? 0 } : t)
-      publishUnread()
-    }
-    throw error
-  } finally { pendingReads.delete(key) }
+async function markVisible(ids:string[]) {
+ const c=active.value;if(!c||loading.value||computerOpen.value||typeof route.query.unread==='string')return
+ const key=`${c.id}:${ids.join(',')}`;if(pendingReads.has(key))return
+ const complete=new Set(messages.value.filter(m=>m.role==='assistant'&&m.status==='complete'&&m.visible!==false).map(m=>m.id))
+ pendingReads.add(key);try{await unread.visible('bot',c.id,undefined,ids.filter(id=>complete.has(id)))}catch{}finally{pendingReads.delete(key)}
 }
 async function selectTask(event:Event){
   const select=event.currentTarget as HTMLSelectElement,value=select.value
@@ -503,7 +481,7 @@ function presentDetail(detail: WorkspaceDetail) {
   run.value = detail.run; interactions.value = detail.interactions
   context.value = detail.context; assignments.value = detail.assignments ?? []
   older.value = detail.hasOlder ?? false
-  if (atBottom) void markRead().catch(() => {})
+
 }
 function flushEvents() {
   eventFrame = undefined
@@ -1025,7 +1003,7 @@ onBeforeUnmount(() => {
     /></template>
     <div v-show="!twoDesktops" class="conversation-with-computer">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
-      <MessageTimeline ref="timeline" :identity="`${selected}:${activeTask?.id ?? ''}`" :loading-older="loadingOlder" :messages="uiMessages" :title="activeHeaderTitle"
+      <MessageTimeline @visible-messages="markVisible" ref="timeline" :identity="`${selected}:${activeTask?.id ?? ''}`" :loading-older="loadingOlder" :messages="uiMessages" :title="activeHeaderTitle"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
         :header-avatar-name="activeHeaderAvatarName" :header-avatar="activeHeaderAvatar" :header-avatar-kind="active?.kind === 'group' ? 'team' : 'agent'"
         :header-avatar-members="activeHeaderAvatarMembers" :header-avatar-state="activeHeaderAgent ? workspaceAvatarState(active, activeHeaderAgent.id) : 'idle'" :header-avatar-activity-key="active?.lastSeq"
@@ -1060,8 +1038,6 @@ onBeforeUnmount(() => {
           </div>
         </template>
       </MessageTimeline>
-      <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用机器人</p>
-      <p v-if="active?.collaborationWaiting" class="task-queue-status" role="status"><button @click="knowledgePanel?.open('collaboration', active.memberIds[0])">等待同伴回复 · 查看协作</button></p>
       <p v-if="error" class="error" role="alert">{{ error }}<button class="icon-button" @click="error = ''" aria-label="关闭错误"><AppIcon name="close" /></button></p>
       <ComposerShell v-if="active" :key="composerKey" ref="composer" mode="group" :draft-key="composerKey"
         v-model:delivery-mode="deliveryMode" :delivery-available="active.kind === 'group' && !activeTask?.goal"
@@ -1071,6 +1047,10 @@ onBeforeUnmount(() => {
         :tool-trace-visible="showThinking" :reference="reference" :context-used="Number(context?.usedTokens || 0)" :context-limit="Number(context?.limitTokens || 0)"
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event">
+        <template #activity>
+          <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用机器人</p>
+          <p v-else-if="active.collaborationWaiting" class="task-queue-status" role="status"><button @click="knowledgePanel?.open('collaboration', active.memberIds[0])">等待同伴回复 · 查看协作</button></p>
+        </template>
         <template #before-input>
           <div v-if="knowledgeEnabled" class="knowledge-shortcuts composer-knowledge">
             <label v-if="active.kind === 'direct' && projects.some(p => p.memberIds.includes(active!.memberIds[0]!))">当前项目<select v-model="directProjectId"><option value="">不关联项目</option><option v-for="project in projects.filter(p => !p.archived && p.memberIds.includes(active!.memberIds[0]!))" :key="project.id" :value="project.id">{{ project.name }}</option></select></label>
@@ -1285,7 +1265,7 @@ onBeforeUnmount(() => {
   font-size: 13px;
 }
 .task-picker-shell{position:relative;display:inline-flex;align-items:center;max-width:210px;min-width:0}.task-picker-shell>.app-icon{position:absolute;right:11px;pointer-events:none;color:var(--text-muted)}.task-picker{width:100%;min-width:0;max-width:210px;min-height:38px;appearance:none;border:1px solid var(--line);border-radius:999px;background:var(--surface);color:var(--text-primary);padding:6px 34px 6px 14px;font:inherit;font-size:12px;font-weight:520;text-overflow:ellipsis;cursor:pointer;box-shadow:0 1px 2px color-mix(in srgb,var(--text-primary) 5%,transparent);transition:border-color 140ms ease,background-color 140ms ease,box-shadow 140ms ease}.task-picker:hover{border-color:var(--line-strong);background:var(--surface-hover)}.task-picker:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.task-picker:disabled{cursor:wait;opacity:.55}
-.task-queue-status{margin:0 20px 8px;font-size:13px;color:var(--text-muted)}
+.task-queue-status{min-width:0;overflow:hidden;margin:0;font-size:12px;line-height:20px;color:var(--text-muted);text-overflow:ellipsis;white-space:nowrap}.task-queue-status button{padding:0;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer}.task-queue-status button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
 .group-options{margin:12px 0;border-top:1px solid var(--line)}.group-options summary{min-height:44px;align-content:center;cursor:pointer;font-size:13px;color:var(--text-secondary)}.group-options summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.group-help{font-size:13px;line-height:1.6;color:var(--text-secondary)}
 @media(max-width:720px){.task-picker-shell,.task-picker{max-width:118px}.task-picker{min-height:44px;padding-left:12px}}
 .team-management-permission > span {

+ 0 - 7
src/client/views/HistoryView.vue

@@ -24,7 +24,6 @@ import { consumeLibraryItemForComposer, loadComposerFile } from '@/components/wo
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
 import { modelChoiceId, modelForChoiceId } from '@/utils/sessionModel'
 import { MODEL_CATALOG_CHANGED_EVENT, modelCatalogChangedProfile } from '@/utils/modelCatalogEvents'
-import { estimateConversationTokens } from '@/utils/contextUsage'
 import { isOwnedChatSession } from '@/utils/sessionOwnership'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
@@ -74,12 +73,6 @@ const sidebarItems = computed(() => sessions.value.map(session => sessionSidebar
   agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
 )))
 const messages = computed(() => chatMessagesToUi(chat.messages, profile => profile ? agentNames.value.get(profile) : undefined))
-const reportedContextTokens = computed(() => chat.contextUsage?.contextTokens
-  || chat.contextUsage?.totalTokens
-  || (activeSession.value?.inputTokens ?? 0) + (activeSession.value?.outputTokens ?? 0))
-const estimatedContextTokens = computed(() => estimateConversationTokens(chat.messages))
-const contextUsed = computed(() => reportedContextTokens.value || estimatedContextTokens.value)
-const contextIsEstimated = computed(() => !reportedContextTokens.value && contextUsed.value > 0)
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const activeSession = computed(() => chat.activeSession)

+ 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){

+ 15 - 1
src/server/app.ts

@@ -1,4 +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 {ManagedBrowsers} from './managedBrowsers.js'
 import { isWorkspaceAccountPath } from './subaccountAccess.js'
 import { chatTranscriptRouter } from './chatTranscriptApi.js'
@@ -98,6 +101,7 @@ export interface ApplicationOptions {
 }
 
 export interface ApplicationRuntime {
+  credentialVault: CredentialVaultCoordinator
   hermesBridge: HermesBridgeManager
   runners: RunnerHub
   workspace: WorkspaceStore
@@ -246,6 +250,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
   // The application owns group events. No Dashboard plugin is queried.
   const workspace = new WorkspaceStore(config.home)
+  push.setUnreadCounter(owner => unreadSnapshot(workspace, chatCache!.store, owner,(node,profile)=>auth.canUseSource(owner,node,profile)).total)
   const openVikingService = new OpenVikingService(workspace, openVikingConfiguration)
   const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
@@ -259,8 +264,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 +498,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 +572,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
 
   return {
+    credentialVault,
     runners,
     hermesBridge,
     localVm,
@@ -594,6 +607,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     chatCache,
     startBackground,
     close: () => {
+      credentialVault.close()
       closed = true
       openVikingSessionSync.close()
       workspaceMemory.close()

+ 14 - 0
src/server/chatTranscriptStore.ts

@@ -577,6 +577,20 @@ export class ChatTranscriptStore {
           .prepare('INSERT OR IGNORE INTO chat_transcript_reads VALUES(?,?,?,?)')
           .run(owner, profile, sessionId, m.id)
   }
+  unreadMessages(...scope: Scope): TranscriptMessage[] {
+    this.unread(...scope)
+    return this.db.prepare(`SELECT m.data FROM chat_transcript_messages m LEFT JOIN chat_transcript_reads r USING(owner,profile,session_id,id)
+      WHERE m.owner=? AND m.profile=? AND m.session_id=? AND m.deleted=0 AND r.id IS NULL ORDER BY m.seq,m.id`).all(...scope)
+      .map(row=>JSON.parse(String(row.data)) as TranscriptMessage).filter(m=>m.final_result===true && isFinalChatResult(m))
+  }
+  markMessagesRead(owner:string,profile:string,sessionId:string,ids:string[]):void {
+    const selected=new Set(ids)
+    const head=this.head(owner,profile,sessionId);head.localReadEstablished=true
+    this.db.prepare('UPDATE chat_transcript_heads SET data=? WHERE owner=? AND profile=? AND session_id=?').run(JSON.stringify(head),owner,profile,sessionId)
+    for(const m of this.unreadMessages(owner,profile,sessionId))if(selected.has(m.id))
+      this.db.prepare('INSERT OR IGNORE INTO chat_transcript_reads VALUES(?,?,?,?)').run(owner,profile,sessionId,m.id)
+    this.notify()
+  }
   unread(...scope: Scope): number {
     // Legacy read cursors can finish importing after the initial local snapshot.
     // Map only persisted source rows, never consume a live streaming result.

+ 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,

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

@@ -0,0 +1,160 @@
+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 } from './schema.js'
+import type {CredentialExecutor,CredentialExecutionReceipt} from './executor.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
+}
+export type CredentialExecutorFixture = CredentialExecutor
+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 executor?: CredentialExecutor) {
+    store.onLock = owner => this.revokeOwner(owner)
+    this.timer = setInterval(() => {
+      try { this.executor?.assertAvailable?.() } catch { this.disconnect() }
+      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.execution().execution }
+  }
+  private execution():Pick<CredentialVaultStatus,'execution'|'reason'>{
+    if(!this.executor)return {execution:'disabled',reason:'executor_not_enabled'}
+    try{this.executor.assertAvailable?.();return {execution:this.executor.mode??'dummy-fixture',reason:this.executor.mode==='protected-adapters'?'operator_approved':'dummy_fixture_only'}}
+    catch{this.disconnect();return {execution:'disabled',reason:'isolation_required'}}
+  }
+  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 execution=this.execution(),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,
+      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(entry.usage&&(entry.usage.kind==='website.form'?'website.login':entry.usage.kind)!==b.operation)throw new HttpError(403,'操作不属于用户批准的凭据使用计划','vault_usage_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.executor) throw new HttpError(409, '受保护的浏览器与 SSH 执行器尚未启用,请人工接管', 'vault_executor_not_enabled')
+    this.executor.assertAvailable?.()
+    if (l!.consumed) throw new HttpError(409, '此授权已提交,不能重复执行', 'vault_operation_uncertain')
+    l!.consumed = true
+    this.audit(owner, 'execute-started', id)
+    try {
+      const result=await this.store.withSecret(owner, l!.credentialRef, entry => this.executor!.execute(entry, b, l!.controller.signal))
+      check()
+      if(result===undefined&&this.executor.mode==='protected-adapters')throw new Error('protected executor receipt missing')
+      const receipt:CredentialExecutionReceipt=result===undefined?{status:'complete',operation:b.operation}:parse(z.union([
+        z.object({status:z.literal('complete'),operation:leaseInput.shape.operation,exitCode:z.number().int().optional(),bytes:z.number().int().nonnegative().optional(),sha256:z.string().regex(/^[a-f0-9]{64}$/).optional()}).strict(),
+        z.object({status:z.literal('manual_takeover_required'),reason:z.enum(['unsupported_form_flow','usage_policy_required']),submitted:z.boolean()}).strict(),
+      ]),result)
+      if(receipt.status==='complete'&&receipt.operation!==b.operation)throw new Error('receipt scope changed')
+      this.audit(owner, receipt.status==='complete'?'execute-complete':'execute-manual', id)
+      return receipt // No raw executor output, URL, exception, Cookie or secret.
+    } catch(error) {
+      this.audit(owner, 'execute-ended', id, 'not-confirmed')
+      if(error instanceof HttpError&&['vault_host_key_rejected','vault_usage_forbidden','vault_isolation_required'].includes(error.code))throw error
+      throw new HttpError(409, '操作结果未确认,授权不再允许重试,请人工核对', 'vault_operation_uncertain')
+    } finally { this.revoke(id) }
+  }
+  connectionClosed(owner:string,session:string,id:string){const l=this.leases.get(id);if(l?.owner===owner&&l.session===session)this.revoke(id)}
+}

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

@@ -0,0 +1,207 @@
+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,CredentialUseSummary } 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; usage?:CredentialUseSummary; session: Session; binding: ReturnType<RunnerHub['credentialBinding']> }
+const toolSchema = { type: 'object', properties: { credentialRef: { type: 'string' }, operation: { enum: ['website.login', 'ssh.exec', 'sftp.read', 'sftp.write'] } }, required: ['credentialRef', 'operation'], additionalProperties: false }
+export const CREDENTIAL_TOOLS = [
+  { id: 'credential_refs', name: 'credential_refs', description: '列出用户授予当前 Bot/任务的凭据引用与允许目标,不包含账号密码或私钥。', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
+  { id: 'credential_request', name: 'credential_request', description: '请求并使用当前任务的短凭据授权,只执行用户配置的标准网站表单、固定 SSH 命令或指定 SFTP 操作。锁定/离线时等待用户解锁与授权,隔离未批准或复杂网站转人工接管。不得索取或读取密码,不得降级 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,usage:entry.usage, 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,...(g.usage?{allowedUse:g.usage}:{}) })
+      }
+      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'||state.execution==='protected-adapters') {
+                    try {
+                      const result = await this.call(g.session,'execute',{id:g.summary.id,input:{...b,agentId,workId,...binding,target:g.target,seconds:60}})
+                      check()
+                      if(JSON.stringify(this.binding(owner,agentId))!==JSON.stringify(binding))throw new HttpError(409,'执行端在回执确认前变化,结果未知;不能自动重试','vault_operation_uncertain')
+                      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() }
+}

+ 11 - 0
src/server/credentialVault/executor.ts

@@ -0,0 +1,11 @@
+import type { VaultEntry, LeaseInput } from './schema.js'
+export type CredentialExecutionReceipt =
+  | {status:'complete';operation:LeaseInput['operation'];exitCode?:number;bytes?:number;sha256?:string}
+  | {status:'manual_takeover_required';reason:'unsupported_form_flow'|'usage_policy_required';submitted:boolean}
+export interface CredentialExecutor {
+  readonly mode?:'dummy-fixture'|'protected-adapters'
+  assertAvailable?():void
+  execute(entry:Readonly<VaultEntry>,input:LeaseInput,signal:AbortSignal):Promise<CredentialExecutionReceipt|void>
+}
+/** Supplied by the trusted boot path, never by the model or an RPC body. */
+export interface IsolationGate { assert():void }

+ 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.
+  }
+}

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

@@ -0,0 +1,37 @@
+/** 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'
+import {OperatorIsolationGate} from './isolation.js'
+import {ProtectedCredentialExecutor} from './protectedExecutor.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(),execution:z.object({approvalFile:z.string().refine(isAbsolute),hermesPid:z.number().int().positive(),home:z.string().refine(isAbsolute)}).strict().optional() }).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 executor=config.execution?new ProtectedCredentialExecutor(config.execution.home,new OperatorIsolationGate(config.execution.approvalFile,config.hermesUid,config.execution.hermesPid)):undefined
+  const broker = new CredentialVaultBroker(store,Date.now,executor)
+  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
+}

+ 23 - 0
src/server/credentialVault/isolation.ts

@@ -0,0 +1,23 @@
+import {execFileSync} from 'node:child_process'
+import {z} from 'zod'
+import {readPrivateUtf8} from './privateFiles.js'
+import {parse} from './schema.js'
+import {HttpError} from '../errors.js'
+import type {IsolationGate} from './executor.js'
+const controls=['separate-identity','hermes-no-control-access','browser-private-no-cdp','restricted-egress'] as const
+const approval=z.object({version:z.literal(1),approved:z.literal(true),brokerUid:z.number().int().positive(),hermesUid:z.number().int().positive(),hermesPid:z.number().int().positive(),
+  approvedAt:z.number().int().positive(),expiresAt:z.number().int().positive(),controls:z.array(z.enum(controls)).length(4)}).strict()
+const processUid=(pid:number)=>Number(execFileSync('/bin/ps',['-o','uid=','-p',String(pid)],{encoding:'utf8',timeout:2000,env:{PATH:'/usr/bin:/bin'}}).trim())
+/** Manual operator approval is a deployment trust contract, NOT proof of OS,
+ * IPC/CDP/firewall isolation. Also checks the observable process identity.
+ * CLI uses the real probe; tests inject a dummy probe without inspecting users. */
+export class OperatorIsolationGate implements IsolationGate {
+  constructor(private path:string,private hermesUid:number,private hermesPid:number,private probe=processUid,private now=Date.now){}
+  assert(){
+    try{
+      const a=parse(approval,JSON.parse(readPrivateUtf8(this.path,8192))),uid=process.getuid?.()
+      if(!uid||uid!==a.brokerUid||uid===a.hermesUid||a.hermesUid!==this.hermesUid||a.hermesPid!==this.hermesPid||this.probe(a.hermesPid)!==a.hermesUid
+        ||a.approvedAt>this.now()||a.expiresAt<=this.now()||a.expiresAt-a.approvedAt>31*86400000||new Set(a.controls).size!==controls.length)throw new Error()
+    }catch{throw new HttpError(503,'执行隔离尚未获得有效人工批准或身份已变化','vault_isolation_required')}
+  }
+}

+ 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)}
+}

+ 17 - 0
src/server/credentialVault/protectedExecutor.ts

@@ -0,0 +1,17 @@
+import {mkdirSync,lstatSync} from 'node:fs'
+import type {CredentialExecutor,IsolationGate} from './executor.js'
+import type {VaultEntry,LeaseInput} from './schema.js'
+import {WebsiteFormExecutor} from './websiteExecutor.js'
+import {SshSftpExecutor} from './sshExecutor.js'
+export class ProtectedCredentialExecutor implements CredentialExecutor {
+  readonly mode='protected-adapters' as const
+  private website:WebsiteFormExecutor
+  private ssh:SshSftpExecutor
+  constructor(home:string,private gate:IsolationGate,testCA?:Buffer){
+    gate.assert();mkdirSync(home,{recursive:true,mode:0o700});const stat=lstatSync(home)
+    if(!stat.isDirectory()||stat.isSymbolicLink()||(stat.mode&0o077)||stat.uid!==process.getuid?.())throw new Error('private executor home required')
+    this.website=new WebsiteFormExecutor(home,gate,testCA);this.ssh=new SshSftpExecutor(gate)
+  }
+  assertAvailable(){this.gate.assert()}
+  execute(entry:Readonly<VaultEntry>,input:LeaseInput,signal:AbortSignal){this.gate.assert();return entry.target.kind==='website'?this.website.execute(entry,input,signal):this.ssh.execute(entry,input,signal)}
+}

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

@@ -0,0 +1,41 @@
+import { z } from 'zod'
+import { HttpError } from '../errors.js'
+export const website = z.object({ kind: z.literal('website'), origin: z.string().max(2000).transform((value, ctx) => {
+  try {
+    const u = new URL(value)
+    if (u.protocol !== 'https:' || u.username || u.password || u.search || u.hash || (u.pathname !== '/' && u.pathname !== '')) throw new Error()
+    return u.origin
+  } catch { ctx.addIssue({ code: 'custom', message: '网站需要准确的 HTTPS origin' }); return z.NEVER }
+}) }).strict()
+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])
+const path = z.string().min(2).max(1024).refine(v=>v.startsWith('/')&&!v.includes('\\')&&!/[\u0000-\u001f\u007f?#]/.test(v)&&v.split('/').every((s,i)=>i===0||!!s&&s!=='.'&&s!=='..'))
+const name = z.string().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_.:-]*$/)
+const form = z.object({kind:z.literal('website.form'),loginPath:path,submitPath:path,successPath:path,logoutPath:path.optional(),formId:name,usernameName:name,passwordName:name,
+  successSelector:z.string().min(1).max(256).regex(/^(#[A-Za-z][A-Za-z0-9_-]*|\[data-[a-z0-9-]+(?:="[A-Za-z0-9_-]+")?\])$/)}).strict()
+const command = z.object({kind:z.literal('ssh.exec'),command:z.string().max(512).regex(/^\/[A-Za-z0-9_/-]+$/).refine(v=>!v.includes('//')&&!v.endsWith('/'))}).strict()
+const read = z.object({kind:z.literal('sftp.read'),remotePath:path,maxBytes:z.number().int().min(1).max(1048576).default(65536)}).strict()
+const write = z.object({kind:z.literal('sftp.write'),remotePath:path,contents:z.string().max(65536)}).strict()
+export const usage = z.discriminatedUnion('kind',[form,command,read,write])
+const updatedUsage = z.discriminatedUnion('kind',[form,command,read,write.extend({contents:z.string().max(65536).optional()})])
+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, usage:usage.optional(), secret: z.string().min(1).max(32768), passphrase: z.string().max(4096).optional() }).strict()
+export const updateInput = entryInput.extend({ secret: entryInput.shape.secret.optional(), usage:updatedUsage.optional(), revision: z.number().int().positive() })
+export const storedEntry = entryInput.extend({ id: z.string().uuid(), revision: z.number().int().positive(), updatedAt: z.number().int().nonnegative() })
+export type VaultEntry = z.infer<typeof storedEntry>
+export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
+  const result = schema.safeParse(value)
+  if (!result.success) throw new HttpError(400, '密码库请求格式无效', 'vault_invalid_request')
+  return result.data
+}
+export function masterPassword(value: unknown): string {
+  return parse(z.string().min(12).max(1024), value)
+}

+ 83 - 0
src/server/credentialVault/sshExecutor.ts

@@ -0,0 +1,83 @@
+import {Client,type SFTPWrapper} from 'ssh2'
+import {createHash,timingSafeEqual} from 'node:crypto'
+import type {IsolationGate,CredentialExecutionReceipt} from './executor.js'
+import type {VaultEntry,LeaseInput} from './schema.js'
+import {HttpError} from '../errors.js'
+const call=<T>(start:(done:(error:Error|undefined|null,value:T)=>void)=>void)=>new Promise<T>((resolve,reject)=>start((error,value)=>error?reject(error):resolve(value)))
+/** Key-only, pinned-host SSH. No shell session, command interpolation, PTY,
+ * environment, agent forwarding, ProxyCommand, jump host or TCP forwarding. */
+export class SshSftpExecutor {
+  constructor(private gate:IsolationGate){}
+  async execute(entry:Readonly<VaultEntry>,input:LeaseInput,externalSignal:AbortSignal):Promise<CredentialExecutionReceipt>{
+    this.gate.assert();externalSignal.throwIfAborted()
+    const plan=entry.usage
+    if(entry.target.kind!=='ssh'||input.target.kind!=='ssh'||JSON.stringify(entry.target)!==JSON.stringify(input.target)||!plan||plan.kind==='website.form'||plan.kind!==input.operation)
+      return {status:'manual_takeover_required',reason:'usage_policy_required',submitted:false}
+    const host=entry.target,expected=Buffer.from(host.hostKey.slice(7),'base64')
+    if(expected.length!==32)throw new HttpError(403,'SSH host key 指纹无效','vault_host_key_rejected')
+    const signal=AbortSignal.any([externalSignal,AbortSignal.timeout(15000)]),conn=new Client()
+    let hostRejected=false,finished=false,fail!:(error:unknown)=>void,ready!:()=>void
+    const failure=new Promise<never>((_r,reject)=>{fail=reject}),connected=new Promise<void>(resolve=>{ready=resolve})
+    // connect() can synchronously reject a malformed key before work is created.
+    // Keep close/error rejection observed on that path as well.
+    void failure.catch(()=>{})
+    const invoke=<T>(start:(done:(error:Error|undefined|null,value:T)=>void)=>void)=>Promise.race([call(start),failure])
+    const abort=()=>{fail(new Error('operation cancelled'));conn.destroy()}
+    signal.addEventListener('abort',abort,{once:true})
+    conn.on('error',()=>fail(hostRejected?new HttpError(403,'SSH host key 与人工批准指纹不符','vault_host_key_rejected'):new Error('SSH connection unavailable')))
+    conn.on('close',()=>{if(!finished)fail(new Error('SSH receipt unavailable'))})
+    conn.once('ready',ready)
+    const check=()=>{signal.throwIfAborted();this.gate.assert()}
+    try{
+      conn.connect({host:host.host.replace(/^\[|\]$/g,''),port:host.port,username:entry.username,privateKey:entry.secret,...(entry.passphrase?{passphrase:entry.passphrase}:{}),
+        authHandler:['publickey'],tryKeyboard:false,agentForward:false,readyTimeout:5000,keepaliveInterval:0,
+        algorithms:{serverHostKey:['ssh-ed25519','rsa-sha2-512','rsa-sha2-256','ecdsa-sha2-nistp256']},
+        hostVerifier:(key:Buffer)=>{const digest=createHash('sha256').update(key).digest();const match=timingSafeEqual(digest,expected);hostRejected=!match;return match}})
+      const work=(async()=>{
+        await Promise.race([connected,failure]);check()
+        if(plan.kind==='ssh.exec'){
+          // Exactly the user-approved absolute executable; no arguments or
+          // strings assembled from model input are ever sent to the server.
+          const stream=await invoke<import('ssh2').ClientChannel>(done=>conn.exec(plan.command,{pty:false},done))
+          check();let output=0
+          const discard=(data:Buffer)=>{output+=data.length;if(output>65536)abort()}
+          stream.on('data',discard);stream.stderr.on('data',discard);stream.on('error',()=>fail(new Error('SSH command interrupted')))
+          const code=await new Promise<number>((resolve,reject)=>{stream.once('close',(value:number|undefined)=>Number.isInteger(value)?resolve(value!):reject(new Error('SSH exit receipt unavailable')));stream.end()})
+          check();return {status:'complete',operation:'ssh.exec',exitCode:code} as CredentialExecutionReceipt
+        }
+        const sftp=await invoke<SFTPWrapper>(done=>conn.sftp(done));sftp.on('error',()=>fail(new Error('SFTP interrupted')))
+        check()
+        if(plan.kind==='sftp.read'){
+          const canonical=await invoke<string>(done=>sftp.realpath(plan.remotePath,done));check()
+          if(canonical!==plan.remotePath)throw new HttpError(403,'SFTP 读取目标不是批准的准确路径','vault_usage_forbidden')
+          const handle=await invoke<Buffer>(done=>sftp.open(plan.remotePath,'r',done));check()
+          try{
+            const stat=await invoke<import('ssh2').Stats>(done=>sftp.fstat(handle,done));check()
+            if(!stat.isFile()||stat.size>plan.maxBytes)throw new HttpError(403,'SFTP 读取文件超出授权类型或大小','vault_usage_forbidden')
+            const digest=createHash('sha256'),buffer=Buffer.alloc(16384);let bytes=0
+            try{
+              while(true){check();const count=await invoke<number>(done=>sftp.read(handle,buffer,0,Math.min(buffer.length,plan.maxBytes+1-bytes),bytes,(error,count)=>done(error,count)));check();if(!count)break;bytes+=count;if(bytes>plan.maxBytes)throw new Error('SFTP size limit');digest.update(buffer.subarray(0,count))}
+              return {status:'complete',operation:'sftp.read',bytes,sha256:digest.digest('hex')} as CredentialExecutionReceipt
+            }finally{buffer.fill(0)}
+          }finally{await invoke<void>(done=>sftp.close(handle,error=>done(error,undefined))).catch(()=>{})}
+        }
+        const slash=plan.remotePath.lastIndexOf('/'),parent=plan.remotePath.slice(0,slash)||'/'
+        const canonical=await invoke<string>(done=>sftp.realpath(parent,done));check()
+        if(canonical!==parent)throw new HttpError(403,'SFTP 写入目录不是批准的准确路径','vault_usage_forbidden')
+        if(Buffer.byteLength(plan.contents,'utf8')>65536)throw new HttpError(403,'SFTP 写入超过授权大小','vault_usage_forbidden')
+        const data=Buffer.from(plan.contents,'utf8');let handle:Buffer|undefined
+        try{
+          // Exclusive creation: never overwrite or follow an existing final link.
+          handle=await invoke<Buffer>(done=>sftp.open(plan.remotePath,'wx',{mode:0o600},done));check()
+          for(let offset=0;offset<data.length;offset+=16384){check();await invoke<void>(done=>sftp.write(handle!,data,offset,Math.min(16384,data.length-offset),offset,error=>done(error,undefined)));check()}
+          await invoke<void>(done=>sftp.close(handle!,error=>done(error,undefined)));check()
+          handle=undefined
+          return {status:'complete',operation:'sftp.write',bytes:data.length,sha256:createHash('sha256').update(data).digest('hex')} as CredentialExecutionReceipt
+        }finally{data.fill(0);if(handle)await invoke<void>(done=>sftp.close(handle!,error=>done(error,undefined))).catch(()=>{})} // Interrupted writes may leave a partial file: never retry or overwrite automatically.
+      })()
+      const result=await Promise.race([work,failure]);finished=true;return result
+    }finally{
+      signal.removeEventListener('abort',abort);conn.destroy()
+    }
+  }
+}

+ 215 - 0
src/server/credentialVault/store.ts

@@ -0,0 +1,215 @@
+import sodium from 'libsodium-wrappers-sumo'
+import { randomUUID, createHash } from 'node:crypto'
+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}$/)
+const envelopeSchema = z.object({
+  format: z.literal('yaoyao-credential-vault'), version: z.literal(1),
+  owner: z.string().min(1).max(256), id: z.string().uuid(), revision: z.number().int().positive(),
+  kdf: z.object({ algorithm: z.literal('argon2id13'), salt: bytes, ops: z.literal(3), memory: z.literal(67108864) }).strict(),
+  wrapping: z.object({ nonce: bytes, ciphertext: bytes }).strict(),
+  payload: z.object({ nonce: bytes, ciphertext: bytes }).strict(),
+}).strict()
+type Envelope = z.infer<typeof envelopeSchema>
+const documentSchema = z.object({ entries: z.array(storedEntry).max(256) }).strict()
+const encode = (value: Uint8Array) => sodium.to_base64(value, sodium.base64_variants.ORIGINAL)
+const decode = (value: string, length?: number) => {
+  const data = sodium.from_base64(value, sodium.base64_variants.ORIGINAL)
+  if (length !== undefined && data.length !== length) throw new Error('invalid envelope')
+  return data
+}
+const wipe = (value?: Uint8Array) => { if (value) sodium.memzero(value) }
+function aad(e: Envelope, part: 'key' | 'entries') {
+  return JSON.stringify(['yaoyao-credential-vault', e.version, e.owner, e.id, part,
+    ...(part === 'key' ? [e.kdf] : [e.revision])])
+}
+function encrypt(value: Uint8Array, key: Uint8Array, associated: string) {
+  const nonce = sodium.randombytes_buf(24)
+  return { nonce: encode(nonce), ciphertext: encode(sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(value, associated, null, nonce, key)) }
+}
+function decrypt(value: Envelope['payload'], key: Uint8Array, associated: string) {
+  return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, decode(value.ciphertext), associated, decode(value.nonce, 24), key)
+}
+function derive(password: string, e: Envelope) {
+  const data = sodium.from_string(masterPassword(password))
+  try { return sodium.crypto_pwhash(32, data, decode(e.kdf.salt, 16), e.kdf.ops, e.kdf.memory, sodium.crypto_pwhash_ALG_ARGON2ID13) }
+  finally { wipe(data) }
+}
+const summary = ({ secret: _secret, passphrase: _passphrase, usage, ...entry }: VaultEntry): CredentialSummary => ({...entry,...(usage?{usage:usage.kind==='sftp.write'?{kind:usage.kind,remotePath:usage.remotePath,bytes:Buffer.byteLength(usage.contents)}:usage}:{})})
+
+/** This belongs in an independently isolated broker. No file/OS-account key is created.
+ * JS strings/GC and WASM are not a secure enclave; same-UID process access must be
+ * prevented by deployment. Restart discards the DEK and always starts locked. */
+export class CredentialVaultStore {
+  private unlocked = new Map<string, { envelope: Envelope; key: Uint8Array; entries: VaultEntry[]; until: number }>()
+  private failures = new Map<string, { count: number; next: number }>()
+  readonly root: string
+  onLock: (owner: string) => void = () => {}
+  constructor(root: string, private now = Date.now, private beforeRename?: () => void) {
+    this.root = resolve(root)
+    mkdirSync(this.root, { recursive: true, mode: 0o700 })
+    const stat = lstatSync(this.root)
+    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
+    return new CredentialVaultStore(root, now, beforeRename)
+  }
+  private path(owner: string) {
+    parse(z.string().min(1).max(256), owner)
+    return join(this.root, createHash('sha256').update(owner).digest('hex') + '.vault')
+  }
+  private load(owner: string): Envelope | undefined {
+    const path = this.path(owner)
+    try {
+      const e = parse(envelopeSchema, JSON.parse(readPrivateUtf8(path, MAX_BYTES)))
+      if (e.owner !== owner) throw new Error()
+      return e
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
+      throw new HttpError(409, '密码库文件无效或权限不安全', 'vault_corrupt')
+    }
+  }
+  private save(e: Envelope) {
+    const value = JSON.stringify(e)
+    if (Buffer.byteLength(value) > MAX_BYTES) throw new HttpError(413, '密码库已达到大小上限', 'vault_size_limit')
+    const path = this.path(e.owner), temp = path + '.' + randomUUID() + '.tmp'
+    try {
+      const fd = openSync(temp, 'wx', 0o600)
+      try { writeFileSync(fd, value); fsyncSync(fd) } finally { closeSync(fd) }
+      this.beforeRename?.()
+      renameSync(temp, path)
+      const directory = openSync(this.root, 'r')
+      try { fsyncSync(directory) } finally { closeSync(directory) }
+    } finally { rmSync(temp, { force: true }) }
+  }
+  private active(owner: string) {
+    const session = this.unlocked.get(owner)
+    if (session && session.until <= this.now()) this.lock(owner)
+    const current = this.unlocked.get(owner)
+    if (!current) throw new HttpError(423, '密码库已锁定,请在密码管理界面手动解锁', 'vault_locked')
+    return current
+  }
+  state(owner: string) {
+    const s = this.unlocked.get(owner)
+    if (s && s.until <= this.now()) this.lock(owner)
+    const current = this.unlocked.get(owner)
+    return { initialized: !!this.load(owner), unlocked: !!current, ...(current ? { unlockExpiresAt: current.until } : {}) }
+  }
+  private decodeDocument(e: Envelope, key: Uint8Array) {
+    const data = decrypt(e.payload, key, aad(e, 'entries'))
+    try { return parse(documentSchema, JSON.parse(sodium.to_string(data))).entries }
+    finally { wipe(data) }
+  }
+  private encodeDocument(e: Envelope, key: Uint8Array, entries: VaultEntry[]) {
+    const data = sodium.from_string(JSON.stringify({ entries }))
+    try { return { ...e, payload: encrypt(data, key, aad(e, 'entries')) } }
+    finally { wipe(data) }
+  }
+  initialize(owner: string, password: string) {
+    if (this.load(owner)) throw new HttpError(409, '密码库已存在', 'vault_exists')
+    const key = sodium.randombytes_buf(32)
+    const e: Envelope = { format: 'yaoyao-credential-vault', version: 1, owner, id: randomUUID(), revision: 1,
+      kdf: { algorithm: 'argon2id13', salt: encode(sodium.randombytes_buf(16)), ops: 3, memory: 67108864 },
+      wrapping: { nonce: '', ciphertext: '' }, payload: { nonce: '', ciphertext: '' } }
+    let kek: Uint8Array | undefined
+    try {
+      kek = derive(password, e); e.wrapping = encrypt(key, kek, aad(e, 'key'))
+      this.save(this.encodeDocument(e, key, []))
+    } finally { wipe(kek); wipe(key) }
+    return this.state(owner) // Creation does not silently unlock.
+  }
+  unlock(owner: string, password: string, seconds = 300) {
+    parse(z.number().int().min(30).max(900), seconds)
+    if ((this.failures.get(owner)?.next ?? 0) > this.now()) throw new HttpError(429, '解锁尝试过多,请稍后重试', 'vault_unlock_throttled')
+    const e = this.load(owner)
+    if (!e) throw new HttpError(404, '请先创建密码库', 'vault_missing')
+    this.lock(owner)
+    let kek: Uint8Array | undefined, key: Uint8Array | undefined
+    try {
+      kek = derive(password, e); key = decrypt(e.wrapping, kek, aad(e, 'key'))
+      if (key.length !== 32) throw new Error()
+      const entries = this.decodeDocument(e, key)
+      this.unlocked.set(owner, { envelope: e, key, entries, until: this.now() + seconds * 1000 })
+      key = undefined; this.failures.delete(owner)
+    } catch {
+      const count = (this.failures.get(owner)?.count ?? 0) + 1
+      this.failures.set(owner, { count, next: this.now() + Math.min(60000, 1000 * 2 ** Math.min(count - 1, 6)) })
+      throw new HttpError(401, '主密码错误或备份已损坏', 'vault_unlock_failed')
+    } finally { wipe(kek); wipe(key) }
+    return this.state(owner)
+  }
+  lock(owner: string) {
+    const session = this.unlocked.get(owner)
+    this.unlocked.delete(owner); wipe(session?.key)
+    if (session) { session.entries.length = 0; this.onLock(owner) }
+  }
+  lockAll() { for (const owner of [...this.unlocked.keys()]) this.lock(owner) }
+  list(owner: string) { return this.active(owner).entries.map(summary) }
+  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)
+    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)
+    if (s.entries.length >= 256) throw new HttpError(413, '凭据数量已达到上限', 'vault_entry_limit')
+    const entry = { ...input, id: randomUUID(), revision: 1, updatedAt: this.now() }
+    this.commit(owner, [...s.entries, entry]); return summary(entry)
+  }
+  update(owner: string, id: string, value: unknown) {
+    const s = this.active(owner), input = parse(updateInput, value), old = s.entries.find(v => v.id === id)
+    if (!old) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
+    if (old.revision !== input.revision) throw new HttpError(409, '凭据已变化,请刷新', 'vault_revision_conflict')
+    const changedUsage=input.usage?.kind==='sftp.write'?{...input.usage,contents:input.usage.contents??(old.usage?.kind==='sftp.write'?old.usage.contents:undefined)}:input.usage??old.usage
+    const next = parse(storedEntry,{ ...old, ...input,usage:changedUsage, secret: input.secret ?? old.secret, revision: old.revision + 1, updatedAt: this.now() })
+    this.commit(owner, s.entries.map(v => v.id === id ? next : v)); this.onLock(owner)
+    return summary(next)
+  }
+  remove(owner: string, id: string) {
+    const s = this.active(owner)
+    if (!s.entries.some(v => v.id === id)) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
+    this.commit(owner, s.entries.filter(v => v.id !== id)); this.onLock(owner)
+  }
+  /** Only the isolated executor callback may receive this. No read-secret RPC exists. */
+  withSecret<T>(owner: string, id: string, use: (entry: Readonly<VaultEntry>) => T): T {
+    const s = this.active(owner), entry = s.entries.find(v => v.id === id)
+    if (!entry) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
+    return use(entry)
+  }
+  backup(owner: string) {
+    this.active(owner)
+    return JSON.stringify(this.load(owner))
+  }
+  restore(owner: string, archive: string, password: string) {
+    if (this.load(owner)) throw new HttpError(409, '恢复需要尚未初始化的密码库,不能覆盖现有数据', 'vault_exists')
+    if (Buffer.byteLength(archive) > MAX_BYTES) throw new HttpError(413, '备份过大', 'vault_size_limit')
+    const e = parse(envelopeSchema, JSON.parse(archive))
+    if (e.owner !== owner) throw new HttpError(403, '备份不属于当前账号', 'vault_backup_owner')
+    let kek: Uint8Array | undefined, key: Uint8Array | undefined
+    try { kek = derive(password, e); key = decrypt(e.wrapping, kek, aad(e, 'key')); this.decodeDocument(e, key); this.save(e) }
+    catch { throw new HttpError(400, '备份无法验证,原密码库未改变', 'vault_backup_invalid') }
+    finally { wipe(kek); wipe(key) }
+    return this.state(owner)
+  }
+  rotate(owner: string, password: string) {
+    const s = this.active(owner), key = sodium.randombytes_buf(32)
+    const next = { ...s.envelope, revision: s.envelope.revision + 1,
+      kdf: { ...s.envelope.kdf, salt: encode(sodium.randombytes_buf(16)) } }
+    let kek: Uint8Array | undefined
+    try {
+      kek = derive(password, next); next.wrapping = encrypt(key, kek, aad(next, 'key'))
+      this.save(this.encodeDocument(next, key, s.entries))
+      this.lock(owner)
+    } catch (error) { this.lock(owner); throw error }
+    finally { wipe(kek); wipe(key) }
+  }
+}

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

@@ -0,0 +1,88 @@
+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); res.once('close',()=>{if(!res.writableEnded)broker.connectionClosed(body.owner,body.session,v.id)}); 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(command==='execute'?40000: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()
+        if(command==='execute')throw new HttpError(409,'执行连接中断,结果不确定,禁止自动重试','vault_operation_uncertain')
+      }
+      throw e
+    }
+  }
+}

+ 71 - 0
src/server/credentialVault/websiteExecutor.ts

@@ -0,0 +1,71 @@
+import {chromium,type Browser,type BrowserContext,type Page} from 'playwright'
+import {resolve} from 'node:path'
+import type {IsolationGate,CredentialExecutionReceipt} from './executor.js'
+import type {VaultEntry,LeaseInput} from './schema.js'
+/** Standard HTML POST form only. One private browser/context per lease; no
+ * persistent profile, public CDP port, fill/JS API, screenshots or page output. */
+export class WebsiteFormExecutor {
+  constructor(private home:string,private gate:IsolationGate,private testCA?:Buffer){}
+  async execute(entry:Readonly<VaultEntry>,input:LeaseInput,signal:AbortSignal):Promise<CredentialExecutionReceipt>{
+    this.gate.assert();signal.throwIfAborted()
+    const plan=entry.usage
+    if(entry.target.kind!=='website'||input.target.kind!=='website'||input.operation!=='website.login'||entry.target.origin!==input.target.origin||plan?.kind!=='website.form')
+      return {status:'manual_takeover_required',reason:'usage_policy_required',submitted:false}
+    // Test-only custom CA is not a TLS bypass: Chromium explicitly trusts this
+    // certificate's SPKI. It is restricted to loopback and never set by CLI.
+    const base=new URL(entry.target.origin)
+    if(this.testCA&&base.hostname!=='127.0.0.1')throw new Error('fixture CA is loopback only')
+    let browser:Browser|undefined,context:BrowserContext|undefined,page:Page|undefined,unsafe=false,submitted=false
+    const abort=()=>{void context?.close().catch(()=>{});void browser?.close().catch(()=>{})}
+    signal.addEventListener('abort',abort,{once:true})
+    try{
+      const args=['--force-webrtc-ip-handling-policy=disable_non_proxied_udp','--disable-background-networking']
+      if(this.testCA){const {X509Certificate,createHash}=await import('node:crypto');args.push('--ignore-certificate-errors-spki-list='+createHash('sha256').update(new X509Certificate(this.testCA).publicKey.export({type:'spki',format:'der'})).digest('base64'))}
+      browser=await chromium.launch({headless:true,chromiumSandbox:true,timeout:10000,args,env:{PATH:process.env.PATH??'/usr/bin:/bin',HOME:resolve(this.home),TMPDIR:resolve(this.home),LANG:'en_US.UTF-8'}})
+      signal.throwIfAborted();this.gate.assert()
+      context=await browser.newContext({acceptDownloads:false,serviceWorkers:'block',permissions:[]})
+      context.setDefaultTimeout(5000);context.setDefaultNavigationTimeout(5000)
+      await context.routeWebSocket('**/*',route=>route.close())
+      page=await context.newPage()
+      context.on('page',other=>{if(other!==page){unsafe=true;void other.close().catch(()=>{})}})
+      page.on('dialog',dialog=>{unsafe=true;void dialog.dismiss().catch(()=>{})})
+      page.on('frameattached',()=>{unsafe=true})
+      await context.route('**/*',async route=>{
+        const request=route.request(),url=new URL(request.url())
+        const main=request.isNavigationRequest()&&request.frame()===page!.mainFrame()
+        const approvedNavigation=main&&[plan.loginPath,plan.submitPath,plan.successPath,...(plan.logoutPath?[plan.logoutPath]:[])].includes(url.pathname)&&!url.search&&!url.hash
+        const resource=!request.isNavigationRequest()&&request.method()==='GET'&&['script','stylesheet','image','font'].includes(request.resourceType())
+        const allowed=url.origin===base.origin&&(approvedNavigation||resource)&&(!request.redirectedFrom()||request.method()==='GET'&&url.pathname===plan.successPath)
+          &&(request.method()==='GET'||main&&request.method()==='POST'&&url.pathname===plan.submitPath)&&!signal.aborted
+        if(!allowed){unsafe=true;await route.abort();return}
+        await route.continue()
+      })
+      await page.goto(new URL(plan.loginPath,base).href,{waitUntil:'domcontentloaded'})
+      const form=page.locator(`form[id="${plan.formId}"]`),user=form.locator(`input[name="${plan.usernameName}"]`),password=form.locator(`input[name="${plan.passwordName}"]`),submit=form.locator('button[type="submit"],input[type="submit"]')
+      if(unsafe||page.frames().length!==1||await form.count()!==1||await user.count()!==1||await password.count()!==1||await submit.count()!==1||plan.usernameName===plan.passwordName)
+        return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted:false}
+      const metadata=await form.evaluate(el=>{const f=el as HTMLFormElement;return {method:f.method,action:f.action,enctype:f.enctype}})
+      const action=new URL(metadata.action)
+      if(metadata.method.toLowerCase()!=='post'||metadata.enctype!=='application/x-www-form-urlencoded'||action.origin!==base.origin||action.pathname!==plan.submitPath||action.search||action.hash||await password.getAttribute('type')!=='password')
+        return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted:false}
+      this.gate.assert();signal.throwIfAborted()
+      await user.fill(entry.username);await password.fill(entry.secret)
+      if(unsafe) return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted:false}
+      submitted=true
+      await Promise.all([page.waitForURL(url=>url.origin===base.origin&&url.pathname===plan.successPath&&!url.search&&!url.hash,{waitUntil:'domcontentloaded'}),submit.click()])
+      signal.throwIfAborted();this.gate.assert()
+      if(unsafe||page.frames().length!==1||await page.locator(plan.successSelector).count()!==1||!await page.locator(plan.successSelector).isVisible())
+        return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted:true}
+      if(plan.logoutPath){const response=await page.goto(new URL(plan.logoutPath,base).href,{waitUntil:'domcontentloaded'});if(!response||response.status()>=300||unsafe)return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted:true}}
+      // The authenticated context is destroyed below. Cookies/DOM/network output
+      // are never handed to generic tools or included in the model receipt.
+      return {status:'complete',operation:'website.login'}
+    }catch{
+      signal.throwIfAborted()
+      return {status:'manual_takeover_required',reason:'unsupported_form_flow',submitted}
+    }finally{
+      signal.removeEventListener('abort',abort)
+      await context?.clearCookies().catch(()=>{});await context?.close().catch(()=>{});await browser?.close().catch(()=>{})
+    }
+  }
+}

+ 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]

+ 126 - 25
src/server/openVikingSessionSync.ts

@@ -1,4 +1,4 @@
-import { createHash } from 'node:crypto'
+import { createHash, randomUUID } from 'node:crypto'
 import { OpenVikingError } from '@openviking/sdk'
 import type { WorkspaceAgent, WorkspaceConversation, WorkspaceMessage, WorkspaceRun } from '../shared/workspace.js'
 import type { OpenVikingSyncStatus } from '../shared/openVikingSync.js'
@@ -7,10 +7,35 @@ import type { OpenVikingService } from './openVikingService.js'
 import { visibleMessageText } from '../shared/messageFiles.js'
 
 type Payload = { role: string; content: string; createdAt: string }
-type Job = { id: string; namespace: string; owner: string; agent: string; conversation: string; task: string; status: string; attempts: number; next_at: number; error: string | null; updated_at: number }
+type Job = { id: string; namespace: string; owner: string; agent: string; conversation: string; task: string; status: string; attempts: number; synced_messages: number; next_at: number; error: string | null; updated_at: number }
 const hash = (value: string) => createHash('sha256').update(value).digest('hex')
 const missing = (error: unknown) => error instanceof OpenVikingError && (error.statusCode === 404 || ['NOT_FOUND', 'ENOENT'].includes(error.code))
 class Paused extends Error {}
+const conflictMessage = 'OpenViking 会话记录与本地顺序不一致,已停止追加,请核对会话'
+class TranscriptConflict extends Error {
+  constructor(reason: string) {
+    super(`${conflictMessage}(${reason})。请备份并核对本地与远端会话后重试;仍有冲突时需人工恢复。`)
+  }
+}
+
+function transcript(raw: string): Payload[] {
+  try {
+    return raw.split('\n').filter(line => line.trim()).map(line => {
+      const row = JSON.parse(line)
+      if (!row || typeof row.role !== 'string' || typeof row.created_at !== 'string') throw new Error()
+      let content: string
+      if (Array.isArray(row.parts)) {
+        // A transcript mirror contains text only. Do not hide foreign parts when comparing.
+        if (row.parts.some((part: any) => !part || part.type !== 'text' || typeof part.text !== 'string')) throw new Error()
+        content = row.parts.map((part: { text: string }) => part.text).join('')
+      } else {
+        if (typeof row.content !== 'string') throw new Error()
+        content = row.content
+      }
+      return { role: row.role, content, createdAt: new Date(row.created_at).toISOString() }
+    })
+  } catch { throw new TranscriptConflict('远端记录格式无法安全核对') }
+}
 
 /** A durable transcript mirror. Memory synthesis remains owned by WorkspaceMemorySynthesis. */
 export class OpenVikingSessionSync {
@@ -23,6 +48,8 @@ export class OpenVikingSessionSync {
       CREATE TABLE IF NOT EXISTS openviking_sync_state(namespace TEXT PRIMARY KEY, scan_row INTEGER NOT NULL DEFAULT 0, scanned INTEGER NOT NULL DEFAULT 0, event_seq INTEGER NOT NULL);
       CREATE TABLE IF NOT EXISTS openviking_sync_jobs(id TEXT PRIMARY KEY, namespace TEXT NOT NULL, owner TEXT NOT NULL, agent TEXT NOT NULL, conversation TEXT NOT NULL, task TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, synced_messages INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, error TEXT, updated_at INTEGER NOT NULL);
       CREATE INDEX IF NOT EXISTS openviking_sync_pending ON openviking_sync_jobs(namespace,status,next_at);
+      CREATE TABLE IF NOT EXISTS openviking_sync_appends(session TEXT PRIMARY KEY, expected INTEGER NOT NULL, fingerprint TEXT NOT NULL);
+      CREATE TABLE IF NOT EXISTS openviking_sync_locks(session TEXT PRIMARY KEY, token TEXT NOT NULL, pid INTEGER NOT NULL);
       CREATE TABLE IF NOT EXISTS openviking_sync_messages(session TEXT NOT NULL, message TEXT NOT NULL, seq INTEGER NOT NULL, ready INTEGER NOT NULL, payload TEXT NOT NULL, fingerprint TEXT NOT NULL, PRIMARY KEY(session,message));
     `)
   }
@@ -129,6 +156,38 @@ export class OpenVikingSessionSync {
     return !!this.store.get(job.owner, 'agent', job.agent) && !!this.store.get(job.owner, 'conversation', job.conversation)
       && (!job.task || !!this.store.get(job.owner, 'conversation-task', job.task))
   }
+  private claim(session: string): string | undefined {
+    return this.store.atomic(() => {
+      const lock = this.store.db.prepare('SELECT pid FROM openviking_sync_locks WHERE session=?').get(session)
+      if (lock) {
+        // Never steal a lock from a live writer, even if a remote request is slow.
+        // After a process crash, reconcile the remote prefix before writing again.
+        try { process.kill(Number(lock.pid), 0); return undefined }
+        catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return undefined }
+        this.store.db.prepare('DELETE FROM openviking_sync_locks WHERE session=?').run(session)
+      }
+      const token = randomUUID()
+      this.store.db.prepare('INSERT INTO openviking_sync_locks VALUES(?,?,?)').run(session, token, process.pid)
+      return token
+    })
+  }
+  private projection(job: Job): { desired: Payload[]; unfinished: boolean } {
+    const rows = this.store.db.prepare('SELECT * FROM openviking_sync_messages WHERE session=? ORDER BY seq,message').all(job.id)
+    const desired: Payload[] = []
+    for (const row of rows) {
+      if (!row.ready) return { desired, unfinished: true }
+      if (row.payload) desired.push(JSON.parse(String(row.payload)))
+    }
+    return { desired, unfinished: false }
+  }
+  private verify(remote: Payload[], desired: Payload[], acknowledged: number): void {
+    if (remote.length < acknowledged) throw new TranscriptConflict('远端记录少于已确认的同步记录')
+    for (let i = 0; i < remote.length; i++) {
+      const local = desired[i], actual = remote[i]!
+      if (!local || actual.role !== local.role || actual.content !== local.content || actual.createdAt !== local.createdAt)
+        throw new TranscriptConflict(`第 ${i + 1} 条记录内容或顺序不同`)
+    }
+  }
   private async sync(job: Job): Promise<void> {
     const check = () => this.current(job.namespace)
     check()
@@ -143,6 +202,7 @@ export class OpenVikingSessionSync {
       check()
       this.store.db.prepare("UPDATE openviking_sync_jobs SET status='deleted',error=NULL WHERE id=?").run(job.id)
       this.store.db.prepare('DELETE FROM openviking_sync_messages WHERE session=?').run(job.id)
+      this.store.db.prepare('DELETE FROM openviking_sync_appends WHERE session=?').run(job.id)
       return
     }
     const agent = this.store.require<WorkspaceAgent>(job.owner, 'agent', job.agent)
@@ -152,6 +212,7 @@ export class OpenVikingSessionSync {
     let info: Record<string, unknown>
     try { info = await client.getSession(job.id) } catch (error) {
       if (!missing(error)) throw error
+      if (job.synced_messages > 0) throw new TranscriptConflict('已确认同步的远端会话缺失')
       check()
       if (!this.exists(job)) return
       await client.createSession({ sessionId: job.id, memoryPolicy: { self: { enabled: false }, peer: { enabled: false }, working_memory: { enabled: false } } })
@@ -161,30 +222,57 @@ export class OpenVikingSessionSync {
     // Older servers have no auto-commit policy; newer servers may inherit one.
     if (info.auto_commit_policy) { await this.service.disableSessionAutoCommit(job.owner, job.agent, job.id); check() }
     const uri = typeof info.uri === 'string' ? info.uri : `viking://session/${job.id}`
-    let raw = ''
-    try { raw = await client.read(`${uri}/messages.jsonl`) } catch (error) { if (!missing(error) || Number(info.message_count ?? 0) > 0) throw error }
-    check()
-    const remote: Payload[] = raw.split('\n').filter(line => line.trim()).map(line => {
-      const row = JSON.parse(line)
-      return { role: row.role, content: Array.isArray(row.parts) ? row.parts.filter((part: any) => part.type === 'text').map((part: any) => part.text).join('') : row.content ?? '', createdAt: new Date(row.created_at).toISOString() }
-    })
-    const rows = this.store.db.prepare('SELECT * FROM openviking_sync_messages WHERE session=? ORDER BY seq,message').all(job.id)
-    const desired: Payload[] = []
-    let unfinished = false
-    for (const row of rows) {
-      if (!row.ready) { unfinished = true; break }
-      if (row.payload) desired.push(JSON.parse(String(row.payload)))
+    const readRemote = async (minimum: number) => {
+      let raw = ''
+      try { raw = await client.read(`${uri}/messages.jsonl`) }
+      catch (error) { if (!missing(error) || minimum > 0 || Number(info.message_count ?? 0) > 0) throw error }
+      check()
+      return transcript(raw)
     }
-    for (let i = 0; i < remote.length; i++) {
-      if (!desired[i] || JSON.stringify(remote[i]) !== JSON.stringify(desired[i])) throw new Error('OpenViking 会话记录与本地顺序不一致,已停止追加,请核对会话')
+    const acknowledged = Number(this.store.db.prepare('SELECT synced_messages FROM openviking_sync_jobs WHERE id=?').get(job.id)?.synced_messages ?? 0)
+    let remote = await readRemote(acknowledged)
+    // Network awaits let other clients revise/finish messages. Drain durable events
+    // before comparing so we never append using a stale local projection.
+    if (!this.discover(job.namespace) || !this.exists(job)) return
+    let { desired, unfinished } = this.projection(job)
+    this.verify(remote, desired, acknowledged)
+    const intent = this.store.db.prepare('SELECT expected,fingerprint FROM openviking_sync_appends WHERE session=?').get(job.id)
+    if (intent) {
+      // A timed-out/crashed writer may still be executing at the remote service.
+      // A short prefix cannot prove it stopped. Never replay an uncertain suffix.
+      const expected = Number(intent.expected)
+      if (remote.length < expected || hash(JSON.stringify(remote.slice(0, expected))) !== intent.fingerprint)
+        throw new TranscriptConflict('上次追加结果尚未确认,请先确认原写入已结束')
+      this.store.db.prepare('DELETE FROM openviking_sync_appends WHERE session=?').run(job.id)
     }
-    if (!this.exists(job)) return
+    const verified = remote.length
+    this.store.db.prepare('UPDATE openviking_sync_jobs SET synced_messages=? WHERE id=?').run(verified, job.id)
     const batch = desired.slice(remote.length, remote.length + 100)
-    if (batch.length) { await client.batchAddMessages(job.id, batch); check() }
-    if (!this.exists(job)) return
-    const complete = !unfinished && remote.length + batch.length === desired.length
+    if (batch.length) {
+      this.store.db.prepare('INSERT INTO openviking_sync_appends VALUES(?,?,?)')
+        .run(job.id, verified + batch.length, hash(JSON.stringify(desired.slice(0, verified + batch.length))))
+      try {
+        await client.batchAddMessages(job.id, batch)
+        this.store.db.prepare('DELETE FROM openviking_sync_appends WHERE session=?').run(job.id)
+      } catch (error) {
+        // An application HTTP error completed the request; a timeout, broken
+        // connection or gateway timeout leaves the write result uncertain.
+        if (error instanceof OpenVikingError && error.statusCode && ![408, 502, 503, 504].includes(error.statusCode))
+          this.store.db.prepare('DELETE FROM openviking_sync_appends WHERE session=?').run(job.id)
+        throw error
+      }
+      check()
+      // A successful HTTP response is not proof that the whole batch was stored.
+      // Partial failures and lost responses resume from this verified prefix.
+      remote = await readRemote(Math.max(verified, 1))
+      if (!this.discover(job.namespace) || !this.exists(job)) return
+      ;({ desired, unfinished } = this.projection(job))
+      this.verify(remote, desired, verified)
+      if (remote.length === verified) throw new Error('OpenViking 尚未确认新增记录,请稍后重试')
+    }
+    const complete = !unfinished && remote.length === desired.length
     this.store.db.prepare('UPDATE openviking_sync_jobs SET status=?,synced_messages=?,attempts=0,next_at=?,error=NULL,updated_at=? WHERE id=?')
-      .run(complete ? 'complete' : 'pending', remote.length + batch.length, unfinished && !batch.length ? Date.now() + 5000 : 0, Date.now(), job.id)
+      .run(complete ? 'complete' : 'pending', remote.length, unfinished && !batch.length ? Date.now() + 5000 : 0, Date.now(), job.id)
   }
   async tick(): Promise<void> {
     if (this.active || this.closed) return
@@ -196,6 +284,10 @@ export class OpenVikingSessionSync {
       // consuming durable events, including databases whose events were pruned.
       if (this.scanningNamespace !== namespace) {
         this.store.db.prepare('UPDATE openviking_sync_state SET scan_row=0,scanned=0 WHERE namespace=?').run(namespace)
+        // Recheck legacy order failures once on startup. A genuine divergence
+        // still fails closed; no remote records are replaced or reordered.
+        this.store.db.prepare("UPDATE openviking_sync_jobs SET status='pending',attempts=0,next_at=0 WHERE namespace=? AND status='failed' AND error=?")
+          .run(namespace, conflictMessage)
         this.scanningNamespace = namespace
       }
       if (!this.discover(namespace)) return
@@ -203,13 +295,22 @@ export class OpenVikingSessionSync {
       this.store.db.prepare("UPDATE openviking_sync_jobs SET status='pending',next_at=0 WHERE namespace=? AND status='complete' AND (NOT EXISTS(SELECT 1 FROM workspace_entities e WHERE e.owner=openviking_sync_jobs.owner AND e.kind='agent' AND e.id=openviking_sync_jobs.agent) OR NOT EXISTS(SELECT 1 FROM workspace_entities e WHERE e.owner=openviking_sync_jobs.owner AND e.kind='conversation' AND e.id=openviking_sync_jobs.conversation) OR (task!='' AND NOT EXISTS(SELECT 1 FROM workspace_entities e WHERE e.owner=openviking_sync_jobs.owner AND e.kind='conversation-task' AND e.id=openviking_sync_jobs.task)))").run(namespace)
       const jobs = this.store.db.prepare("SELECT * FROM openviking_sync_jobs WHERE namespace=? AND status='pending' AND next_at<=? ORDER BY updated_at LIMIT 4").all(namespace, Date.now()) as unknown as Job[]
       for (const job of jobs) {
-        try { await this.sync(job) } catch (error) {
+        const token = this.claim(job.id)
+        if (!token) continue
+        let currentJob = job
+        try {
+          currentJob = this.store.db.prepare('SELECT * FROM openviking_sync_jobs WHERE id=?').get(job.id) as unknown as Job
+          if (currentJob.status !== 'pending' || currentJob.next_at > Date.now()) continue
+          await this.sync(currentJob)
+        } catch (error) {
           if (error instanceof Paused || this.closed) break
-          const attempts = job.attempts + 1
+          const attempts = currentJob.attempts + 1
           // Remote errors can contain request details; expose only a short error message.
           const message = error instanceof Error ? error.message.slice(0, 400) : 'OpenViking 会话同步失败'
           this.store.db.prepare('UPDATE openviking_sync_jobs SET status=?,attempts=?,next_at=?,error=?,updated_at=? WHERE id=?')
-            .run(attempts >= 3 ? 'failed' : 'pending', attempts, Date.now() + 2000 * 2 ** attempts, message, Date.now(), job.id)
+            .run(error instanceof TranscriptConflict || attempts >= 3 ? 'failed' : 'pending', attempts, Date.now() + 2000 * 2 ** attempts, message, Date.now(), job.id)
+        } finally {
+          this.store.db.prepare('DELETE FROM openviking_sync_locks WHERE session=? AND token=?').run(job.id, token)
         }
       }
     } catch (error) {

+ 7 - 4
src/server/pushCoordinator.ts

@@ -896,6 +896,8 @@ export class PushCoordinator {
     })
   }
 
+  private unreadCounter?: (owner: string) => number
+  setUnreadCounter(counter: (owner: string) => number): void { this.unreadCounter = counter }
   resetBadge(userIdValue: string, installationIdValue: string, clientAccountIdValue: string): number {
     const userId = requiredIdentifier(userIdValue, 'userId')
     const installationId = requiredIdentifier(installationIdValue, 'installationId')
@@ -904,8 +906,9 @@ export class PushCoordinator {
       const installation = state.installations.find(item => item.userId === userId
         && item.installationId === installationId
         && item.clientAccountId === clientAccountId)
-      if (installation) installation.badge = 0
-      return 0
+      const count = this.unreadCounter?.(userId) ?? 0
+      if (installation) installation.badge = count
+      return count
     })
   }
 
@@ -1258,7 +1261,7 @@ export class PushCoordinator {
       if (state.outbox.length + targets.length > MAX_OUTBOX_ITEMS) throw new Error('Push outbox limit reached')
       state.processedEvents[key] = now
       for (const target of targets) {
-        target.badge += 1
+        target.badge = this.unreadCounter ? this.unreadCounter(userId) : target.badge + 1
         state.outbox.push({
           id: randomUUID(),
           eventId,
@@ -1391,7 +1394,7 @@ export class PushCoordinator {
                 aps: {
                   alert: { title: item.title, body: item.body },
                   sound: 'default',
-                  badge: installation.badge,
+                  badge: this.unreadCounter ? this.unreadCounter(item.userId) : installation.badge,
                   ...(item.threadId ? { 'thread-id': item.threadId } : {}),
                 },
                 version: 1,

+ 15 - 0
src/server/routes.ts

@@ -1,3 +1,5 @@
+import { acknowledgeUnread, unreadSnapshot } from './unreadCenter.js'
+import { z } from 'zod'
 import { RegistrationLimiter } from './subaccountAccess.js'
 import { readServerIdentity, updateServerIdentity } from './serverIdentity.js'
 import { authorizeFileRead, fileAccessWorkingDirectory, readFileAccess, saveFileAccess } from './fileAccess.js'
@@ -2153,6 +2155,19 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     const owner=dependencies.auth.require(ctx).id
     ctx.body={session_ids:dependencies.chatCache!.store.pins(owner,String(ctx.query.profile??''))}
   })
+  const unreadInput=z.object({items:z.array(z.object({mode:z.enum(['bot','chat']),id:z.string().min(1).max(256),profile:z.string().max(256).optional(),messages:z.array(z.object({id:z.string().min(1).max(512),seq:z.number().int().nonnegative(),version:z.number().int().nonnegative().optional(),taskId:z.string().max(256).optional()}).strict()).max(20000)}).strict()).max(1000)}).strict()
+  router.get('/api/app/unread',ctx=>{
+    const owner=dependencies.auth.require(ctx).id
+    ctx.set('Cache-Control','no-store')
+    ctx.body=unreadSnapshot(dependencies.workspace,dependencies.chatCache!.store,owner,(node,profile)=>dependencies.auth.canUseSource(owner,node,profile))
+  })
+  router.post('/api/app/unread/read',ctx=>{
+    const owner=dependencies.auth.require(ctx).id,input=unreadInput.safeParse(body(ctx))
+    if(!input.success)throw new HttpError(400,'未读快照格式无效','invalid_unread_snapshot')
+    acknowledgeUnread(dependencies.workspace,dependencies.chatCache!.store,owner,input.data.items,(node,profile)=>dependencies.auth.canUseSource(owner,node,profile))
+    for(const item of input.data.items)if(item.mode==='chat')dependencies.onChatListChanged?.(owner,item.profile ?? 'default',item.id)
+    ctx.body=unreadSnapshot(dependencies.workspace,dependencies.chatCache!.store,owner,(node,profile)=>dependencies.auth.canUseSource(owner,node,profile))
+  })
   router.get('/api/app/sessions/unread', ctx => {
     const owner=dependencies.auth.require(ctx).id
     ctx.body=dependencies.chatCache!.store.unread(owner,String(ctx.query.profile??''))

+ 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')

+ 6 - 0
src/server/subaccountAccess.ts

@@ -1,6 +1,12 @@
 /** 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/unread') return true
+  if (method === 'POST' && path === '/api/app/unread/read') return true
+  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)

+ 56 - 0
src/server/unreadCenter.ts

@@ -0,0 +1,56 @@
+import type { WorkspaceStore } from './workspaceStore.js'
+import type { ChatCacheStore } from './chatCache.js'
+import type { WorkspaceAgent, WorkspaceConversation } from '../shared/workspace.js'
+import { emptyUnread, type UnreadSnapshot, type UnreadConversation } from '../shared/unread.js'
+import { notificationPlainText } from './notificationText.js'
+
+function messageTime(value:unknown):number {
+ const numeric=Number(value);if(Number.isFinite(numeric))return numeric>0&&numeric<100_000_000_000?numeric*1000:numeric
+ const parsed=typeof value==='string'?Date.parse(value):NaN;return Number.isFinite(parsed)?parsed:0
+}
+
+export function unreadSnapshot(workspace:WorkspaceStore, cache:ChatCacheStore, owner:string,
+  allowed:(node:string,profile:string)=>boolean = ()=>true):UnreadSnapshot {
+  const result=emptyUnread()
+  for(const c of workspace.list<WorkspaceConversation>(owner,'conversation')) {
+    if(c.archived || !c.memberIds.every(id=>{const a=workspace.get<WorkspaceAgent>(owner,'agent',id);return !a || allowed(a.nodeId,a.profile)}))continue
+    const messages=workspace.chatUnreadMessages(owner,c.id)
+    if(!messages.length)continue
+    result.conversations.push({mode:'bot',id:c.id,name:c.name,preview:notificationPlainText(messages.at(-1)!.content,{maximum:160,fallback:messages.at(-1)!.attachments[0]?.name ?? '附件'}),count:messages.length,
+      updatedAt:messages.at(-1)!.createdAt,messages:messages.map(m=>({id:m.id,seq:m.seq,version:m.chatUnreadVersion,taskId:m.conversationTaskId}))})
+  }
+  for(const row of cache.unread(owner,'').sessions) {
+    if(!allowed('local',row.profile))continue
+    const stored=cache.db.prepare('SELECT s.data,l.archived FROM chat_sessions s LEFT JOIN chat_local_state l USING(owner,profile,session_id) WHERE s.owner=? AND s.profile=? AND s.session_id=?').get(owner,row.profile,row.session_id)
+    if(!stored || stored.archived===1)continue
+    const summary=JSON.parse(String(stored.data))
+    if(summary.archived || summary.source==='yaoyao_workspace')continue
+    const messages=cache.transcripts.unreadMessages(owner,row.profile,row.session_id)
+    if(!messages.length)continue
+    const last=messages.at(-1)!
+    result.conversations.push({mode:'chat',id:row.session_id,profile:row.profile,name:summary.title || summary.name || '未命名聊天',preview:notificationPlainText(typeof last.content==='string'?last.content:'',{maximum:160,fallback:'附件'}),count:messages.length,
+      updatedAt:messageTime(last.timestamp ?? last.created_at ?? last.createdAt ?? summary.updated_at ?? 0),messages:messages.map(m=>({id:m.id,seq:m.seq}))})
+  }
+  result.conversations.sort((a,b)=>b.updatedAt-a.updatedAt || a.id.localeCompare(b.id))
+  for(const row of result.conversations)result[row.mode]+=row.count
+  result.total=result.bot+result.chat
+  return result
+}
+
+/** Only IDs in the user's click/render snapshot are acknowledged; later completions survive. */
+export function acknowledgeUnread(workspace:WorkspaceStore,cache:ChatCacheStore,owner:string,
+  items:Array<Pick<UnreadConversation,'mode'|'id'|'profile'|'messages'>>,allowed:(node:string,profile:string)=>boolean=()=>true):void {
+  for(const item of items) {
+    if(item.mode==='bot') {
+      const c=workspace.require<WorkspaceConversation>(owner,'conversation',item.id)
+      if(!c.memberIds.every(id=>{const a=workspace.get<WorkspaceAgent>(owner,'agent',id);return !a || allowed(a.nodeId,a.profile)}))continue
+      workspace.markChatMessagesRead(owner,c.id,item.messages)
+    } else {
+      const profile=item.profile ?? 'default'
+      if(!allowed('local',profile))continue
+      cache.requireOwned(owner,profile,item.id)
+      cache.transcripts.seed(owner,profile,item.id)
+      cache.transcripts.markMessagesRead(owner,profile,item.id,item.messages.map(m=>m.id))
+    }
+  }
+}

+ 4 - 1
src/server/uploads.ts

@@ -6,6 +6,7 @@ import { basename, extname, join, resolve, sep } from 'node:path'
 import { DatabaseSync } from 'node:sqlite'
 import Busboy from 'busboy'
 import { HttpError } from './errors.js'
+import { normalizeUploadName } from '../shared/uploadNames.js'
 
 export const MAX_UPLOAD_FILE_BYTES = 25 * 1_024 * 1_024
 export const MAX_UPLOAD_REQUEST_BYTES = 50 * 1_024 * 1_024
@@ -161,7 +162,7 @@ export class UploadStore {
       return {
         id: row.id,
         accountKey: row.account_key,
-        name: row.name,
+        name: normalizeUploadName(row.name),
         mimeType: row.mime_type,
         size: Number(row.size),
         path: row.path,
@@ -230,6 +231,8 @@ export async function receiveGroupUploads(
 
   const busboy = Busboy({
     headers: request.headers,
+    // Browser FormData sends ordinary filename parameters as UTF-8 bytes.
+    defParamCharset: 'utf8',
     limits: {
       files: MAX_UPLOAD_FILES,
       fileSize: MAX_UPLOAD_FILE_BYTES,

+ 2 - 1
src/server/workspaceAssets.ts

@@ -5,6 +5,7 @@ import {HttpError} from './errors.js'
 import { authorizeFileRead } from './fileAccess.js'
 import { lookup } from 'mime-types'
 import { messageFileReferences, type MessageFileOrigin } from '../shared/messageFiles.js'
+import { normalizeUploadName } from '../shared/uploadNames.js'
 import { WorkspaceStore } from './workspaceStore.js'
 import { WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
 import type { WorkspaceAgent, WorkspaceFile, WorkspaceMessage } from '../shared/workspace.js'
@@ -17,7 +18,7 @@ const archiveKey = (nodeId: string, profile: string, messageId: string, path: st
   createHash('sha256').update(JSON.stringify([nodeId, profile, messageId, path])).digest('hex')
 export function publicFile(f: StoredWorkspaceFile): WorkspaceFile {
   const { path: _path, digest: _digest, messageFileSource: _source, ...result } = f
-  return result
+  return result.sender === 'user' ? { ...result, name: normalizeUploadName(result.name) } : result
 }
 export function libraryFile(
   f: WorkspaceFile,

+ 1 - 1
src/server/workspaceRoutes.ts

@@ -527,7 +527,7 @@ export function workspaceRouter(
       ctx.type = activeContent ? 'application/octet-stream' : file.mimeType
       ctx.set(
         'Content-Disposition',
-        `${action === 'preview' && !activeContent ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(file.name)}`,
+        `${action === 'preview' && !activeContent ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(publicFile(file).name)}`,
       )
       ctx.set('Accept-Ranges', 'bytes')
       ctx.set('Cache-Control', 'private, no-store')

+ 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){

+ 88 - 40
src/server/workspaceStore.ts

@@ -7,8 +7,10 @@ import { messageAppendPatch } from '../shared/workspaceMessagePatch.js'
 import { z } from 'zod'
 import { HttpError } from './errors.js'
 import { isVisibleMessageFile } from '../shared/messageFiles.js'
+import { normalizeUploadName } from '../shared/uploadNames.js'
 import type { StoredWorkspaceFile } from './workspaceAssets.js'
 import type { SharedComputer } from './sharedComputers.js'
+import { isWorkspaceChatReply } from '../shared/unread.js'
 import { notificationPlainText } from './notificationText.js'
 import {readHostTools} from './hostToolSettings.js'
 import {supportsHostEnvironment,deriveComputer} from '../shared/workspace.js'
@@ -269,8 +271,7 @@ export class WorkspaceStore {
         for (const task of this.list<Task>(owner, 'conversation-task')) {
           task.unread ??= task.unreadCount > 0
           task.unreadVersion ??= task.unread ? 1 : 0
-          task.unreadCount = task.unread ? 1 : 0
-          this.put(owner, 'conversation-task', task.id, task)
+            this.put(owner, 'conversation-task', task.id, task)
         }
         for (const conversation of this.list<Conversation>(owner, 'conversation')) {
           conversation.unread ??= conversation.kind === 'group'
@@ -283,6 +284,25 @@ export class WorkspaceStore {
       }
       this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
     })
+    this.atomic(() => {
+      this.db.exec('CREATE TABLE IF NOT EXISTS workspace_chat_reads(owner TEXT NOT NULL,id TEXT NOT NULL,PRIMARY KEY(owner,id))')
+      const migration = 'workspace-chat-unread-v2'
+      if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get(migration)) return
+      for (const owner of this.owners()) for (const c of this.list<Conversation>(owner, 'conversation')) {
+        let version = c.unreadVersion ?? 0
+        for (const m of this.list<Message>(owner, 'message').filter(m => m.conversationId === c.id).sort((a,b) => a.seq-b.seq)) {
+          if (!isWorkspaceChatReply(m)) continue
+          m.chatUnreadVersion = ++version
+          const scope = m.conversationTaskId ? this.get<Task>(owner,'conversation-task',m.conversationTaskId) : c
+          if (!scope?.unread || m.seq < scope.readSeq || m.seq === scope.readSeq && m.seq !== scope.lastSeq)
+            this.db.prepare('INSERT OR IGNORE INTO workspace_chat_reads VALUES(?,?)').run(owner,m.id)
+          this.put(owner,'message',m.id,m)
+        }
+        c.unreadVersion = version
+        this.refreshChatUnread(owner,c)
+      }
+      this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
+    })
     this.atomic(() => {
       const migration='host-environment-option-v1'
       if(this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get(migration))return
@@ -293,6 +313,42 @@ export class WorkspaceStore {
       this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
     })
   }
+  chatUnreadMessages(owner: string, conversationId: string, taskId?: string): Message[] {
+    return this.db.prepare(`SELECT e.data FROM workspace_entities e LEFT JOIN workspace_chat_reads r ON r.owner=e.owner AND r.id=e.id
+      WHERE e.owner=? AND e.kind='message' AND json_extract(e.data,'$.conversationId')=? AND r.id IS NULL`).all(owner,conversationId)
+      .map(row => JSON.parse(String(row.data)) as Message).filter(m => (!taskId || m.conversationTaskId === taskId) && isWorkspaceChatReply(m))
+      .sort((a,b) => a.seq-b.seq || a.id.localeCompare(b.id))
+  }
+  private refreshChatUnread(owner: string, c: Conversation): void {
+    const messages = this.chatUnreadMessages(owner,c.id)
+    c.unreadCount = messages.length; c.unread = messages.length > 0
+    this.put(owner,'conversation',c.id,c)
+    if (c.kind === 'group') for (const task of this.tasks(owner,c.id)) {
+      const pending = messages.filter(m => m.conversationTaskId === task.id)
+      task.unreadCount = pending.length;task.unread = pending.length > 0
+      task.unreadVersion = Math.max(task.unreadVersion ?? 0,...pending.map(m => m.chatUnreadVersion ?? 0))
+      this.put(owner,'conversation-task',task.id,task)
+    }
+  }
+  markChatMessagesRead(owner: string, conversationId: string, ids: Array<{id:string;version?:number}>): Conversation {
+    return this.atomic(() => {
+      const c = this.require<Conversation>(owner,'conversation',conversationId)
+      const selected = new Map(ids.map(m => [m.id,m.version ?? Number.MAX_SAFE_INTEGER]))
+      for (const m of this.chatUnreadMessages(owner,c.id)) {
+        if (!selected.has(m.id) || (m.chatUnreadVersion ?? 0) > selected.get(m.id)!) continue
+        this.db.prepare('INSERT OR IGNORE INTO workspace_chat_reads VALUES(?,?)').run(owner,m.id)
+        c.readSeq = Math.max(c.readSeq,m.seq)
+        if (m.conversationTaskId) {
+          const task=this.requireTask(owner,c.id,m.conversationTaskId)
+          task.readSeq=Math.max(task.readSeq,m.seq);this.put(owner,'conversation-task',task.id,task)
+        }
+      }
+      this.refreshChatUnread(owner,c)
+      if(c.kind==='group')for(const task of this.tasks(owner,c.id))this.event(owner,'task.changed',task,c.id)
+      this.event(owner,'conversation.changed',c,c.id)
+      return this.conversationSummary(owner,c)
+    })
+  }
   private hasUnreadTask(owner: string, conversationId: string): boolean {
     return !!this.db.prepare("SELECT 1 FROM workspace_entities WHERE owner=? AND kind='conversation-task' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.unread')=1 LIMIT 1").get(owner, conversationId)
   }
@@ -906,31 +962,25 @@ export class WorkspaceStore {
     })
   }
   markTaskRead(owner: string, conversationId: string, taskId: string, seq: number, unreadVersion?: number): Task {
-    return this.atomic(() => {
-      const task = this.requireTask(owner, conversationId, taskId)
-      task.readSeq = Math.max(task.readSeq, Math.min(seq, task.lastSeq))
-      if (unreadVersion === undefined ? seq >= task.lastSeq : unreadVersion === (task.unreadVersion ?? 0)) task.unread = false
-      task.unreadCount = task.unread ? 1 : 0
-      this.put(owner, 'conversation-task', task.id, task)
-      const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
-      conversation.unread = this.hasUnreadTask(owner, conversationId)
-      conversation.unreadCount = conversation.unread ? 1 : 0
-      this.put(owner, 'conversation', conversationId, conversation)
-      this.event(owner, 'task.changed', task, conversationId)
-      this.event(owner, 'conversation.changed', conversation, conversationId)
-      return task
-    })
+    const task=this.requireTask(owner,conversationId,taskId)
+    this.markChatMessagesRead(owner,conversationId,this.chatUnreadMessages(owner,conversationId,taskId)
+      .filter(m => m.seq<=seq && (unreadVersion===undefined || (m.chatUnreadVersion ?? 0)<=unreadVersion))
+      .map(m=>({id:m.id,version:m.chatUnreadVersion})))
+    task.readSeq=Math.max(task.readSeq,Math.min(seq,task.lastSeq))
+    const updated=this.requireTask(owner,conversationId,taskId)
+    updated.readSeq=Math.max(updated.readSeq,task.readSeq);this.put(owner,'conversation-task',taskId,updated)
+    this.event(owner,'task.changed',updated,conversationId)
+    return updated
   }
   markConversationRead(owner: string, conversationId: string, seq: number, unreadVersion?: number): Conversation {
-    return this.atomic(() => {
-      const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
-      conversation.readSeq = Math.max(conversation.readSeq, Math.min(seq, conversation.lastSeq))
-      if (conversation.kind === 'direct' && (unreadVersion === undefined ? seq >= conversation.lastSeq : unreadVersion === (conversation.unreadVersion ?? 0))) conversation.unread = false
-      conversation.unreadCount = conversation.unread ? 1 : 0
-      this.put(owner, 'conversation', conversationId, conversation)
-      this.event(owner, 'conversation.changed', conversation, conversationId)
-      return this.conversationSummary(owner, conversation)
-    })
+    const c=this.require<Conversation>(owner,'conversation',conversationId)
+    if(c.kind==='group')return this.conversationSummary(owner,c)
+    const updated=this.markChatMessagesRead(owner,conversationId,this.chatUnreadMessages(owner,conversationId)
+      .filter(m=>m.seq<=seq && (unreadVersion===undefined || (m.chatUnreadVersion ?? 0)<=unreadVersion))
+      .map(m=>({id:m.id,version:m.chatUnreadVersion})))
+    updated.readSeq=Math.max(updated.readSeq,Math.min(seq,c.lastSeq));this.put(owner,'conversation',c.id,updated)
+    this.event(owner,'conversation.changed',updated,c.id)
+    return updated
   }
   deleteTask(owner: string, conversationId: string, taskId: string): void {
     this.atomic(() => {
@@ -957,8 +1007,7 @@ export class WorkspaceStore {
       conversation.previewAgentId = last?.role === 'assistant' ? last.agentId : undefined
       conversation.lastMessageAt = last?.createdAt
       conversation.updatedAt = Date.now()
-      conversation.unread = this.hasUnreadTask(owner, conversationId)
-      conversation.unreadCount = conversation.unread ? 1 : 0
+      this.refreshChatUnread(owner, conversation)
       this.projectConversationRuns(owner, conversation)
       this.put(owner, 'conversation', conversationId, conversation)
       this.event(owner, 'conversation.changed', conversation, conversationId)
@@ -1028,6 +1077,7 @@ export class WorkspaceStore {
     const messages = new Map(this.list<Message>(owner, 'message').map(message => [message.id, message]))
     return this.list<StoredWorkspaceFile>(owner, 'file').filter(file =>
       isVisibleMessageFile(file, messages.get(file.messageId ?? '') ?? this.nativeMessageForFile?.(owner, file)))
+      .map(file => file.sender === 'user' ? { ...file, name: normalizeUploadName(file.name) } : file)
   }
   messageForDisplay(owner: string, message: Message): Message {
     const peer = message.peerMessageId && message.role === 'system'
@@ -1042,7 +1092,7 @@ export class WorkspaceStore {
       const peerAvatar = entity?.avatar || (incoming ? peer.fromAvatar : peer.targetAvatar) || ''
       const files = peer.fileIds.flatMap(id => {
         const file = this.get<StoredWorkspaceFile>(owner, 'file', id) ?? message.attachments.find(file => file.id === id)
-        return file ? [{ id: file.id, name: file.name, mimeType: file.mimeType, size: file.size, sender: file.sender, createdAt: file.createdAt }] : []
+        return file ? [{ id: file.id, name: file.sender === 'user' ? normalizeUploadName(file.name) : file.name, mimeType: file.mimeType, size: file.size, sender: file.sender, createdAt: file.createdAt }] : []
       })
       return { ...message, reasoning: '', tools: [], attachments: message.visible === false ? [] : files,
         communication: { direction: incoming ? 'incoming' : 'outgoing', peerId, peerName, peerAvatar, peerKind, content: peer.content } }
@@ -1050,6 +1100,7 @@ export class WorkspaceStore {
     const attachments = message.role === 'user' || message.role === 'assistant'
       ? message.attachments.filter(file => isVisibleMessageFile(
         this.get<StoredWorkspaceFile>(owner, 'file', file.id) ?? file, message))
+        .map(file => file.sender === 'user' ? { ...file, name: normalizeUploadName(file.name) } : file)
       : []
     return { ...message, attachments }
   }
@@ -1076,15 +1127,12 @@ export class WorkspaceStore {
       if (previous?.visible === false && message.visible !== false) message.seq = 0
       if (!message.seq) message.seq = c.lastSeq + 1
       c.lastSeq = Math.max(c.lastSeq, message.seq)
-      const needsAttention = (value: Message | undefined) => !!value && value.role !== 'user' && value.visible !== false
-        && ['complete', 'failed', 'interrupted'].includes(value.status)
-        && !!(value.content.trim() || value.error || value.browserCard || value.serviceWarnings?.length || this.messageForDisplay(owner, value).attachments.length)
-      if (needsAttention(message) && !needsAttention(previous)) {
-        c.unread = true
-        c.unreadVersion = (c.unreadVersion ?? 0) + 1
-        if (task) { task.unread = true; task.unreadVersion = (task.unreadVersion ?? 0) + 1 }
-      }
-      c.unreadCount = c.unread ? 1 : 0
+      const eligible=isWorkspaceChatReply(message),previousEligible=!!previous&&isWorkspaceChatReply(previous)
+      if (eligible && !previous?.chatUnreadVersion) {
+        c.unreadVersion=(c.unreadVersion ?? 0)+1
+        message.chatUnreadVersion=c.unreadVersion
+        if(task)task.unreadVersion=c.unreadVersion
+      } else if (previous?.chatUnreadVersion) message.chatUnreadVersion=previous.chatUnreadVersion
       if (updateSummary && message.visible !== false && (message.content.trim() || message.attachments.length || message.browserCard && !previous?.browserCard || message.serviceWarnings?.length && !previous?.serviceWarnings?.length)) {
         c.updatedAt = Date.now()
         c.lastMessageAt = Math.max(c.lastMessageAt ?? 0, message.createdAt)
@@ -1099,7 +1147,6 @@ export class WorkspaceStore {
         if (becameVisible) task.messageCount += 1
         if (becameHidden) task.messageCount = Math.max(0, task.messageCount - 1)
         task.lastSeq = Math.max(task.lastSeq, message.seq)
-        task.unreadCount = task.unread ? 1 : 0
         if (message.visible !== false && (message.content.trim() || message.attachments.length || message.browserCard && !previous?.browserCard || message.serviceWarnings?.length && !previous?.serviceWarnings?.length)) {
           if (message.role === 'user' && task.titleSource === 'automatic' && task.messageCount <= 1) {
             const source = notificationPlainText(message.content, { maximum: 48, fallback: message.attachments[0]?.name ?? '' }).trim()
@@ -1109,8 +1156,9 @@ export class WorkspaceStore {
           task.updatedAt = Date.now()
         }
         this.put(owner, 'conversation-task', task.id, task)
-        this.event(owner, 'task.changed', task, c.id)
       }
+      if(eligible!==previousEligible || eligible&&!previous?.chatUnreadVersion)this.refreshChatUnread(owner,c)
+      if(task && updateSummary)this.event(owner,'task.changed',this.requireTask(owner,c.id,task.id),c.id)
       if (patch) this.event(owner, 'message.patch', patch, c.id, { type: 'message.changed', data: message })
       else this.event(owner, 'message.changed', message, c.id)
       if (updateSummary) {
@@ -1130,7 +1178,7 @@ export class WorkspaceStore {
       ...conversation,
       unread: conversation.unread ?? false,
       unreadVersion: conversation.unreadVersion ?? 0,
-      unreadCount: conversation.unread ? 1 : 0,
+      unreadCount: conversation.unreadCount ?? 0,
       ...(member ? { avatar: this.agentSummary(member).avatar } : {}),
       lastMessageAt: conversation.lastMessageAt
         ?? (conversation.lastSeq > 0 ? this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 1).at(-1)?.createdAt : undefined)

+ 26 - 0
src/shared/credentialVault.ts

@@ -0,0 +1,26 @@
+/** Public metadata only. Passwords, private keys and unlock material never belong here. */
+export const CREDENTIAL_VAULT_PROTOCOL = 1
+export type CredentialTarget =
+  | { kind: 'website'; origin: string }
+  | { kind: 'ssh'; host: string; port: number; hostKey: string }
+export type CredentialOperation = 'website.login' | 'ssh.exec' | 'sftp.read' | 'sftp.write'
+export type CredentialUseSummary =
+  | { kind:'website.form';loginPath:string;submitPath:string;successPath:string;logoutPath?:string;formId:string;usernameName:string;passwordName:string;successSelector:string }
+  | { kind:'ssh.exec';command:string }
+  | { kind:'sftp.read';remotePath:string;maxBytes:number }
+  | { kind:'sftp.write';remotePath:string;bytes:number }
+export interface CredentialSummary {
+  id: string; name: string; username: string; target: CredentialTarget
+  revision: number; updatedAt: number
+  usage?: CredentialUseSummary
+}
+export interface CredentialLeaseSummary {
+  id: string; credentialRef: string; agentId: string; workId: string
+  runnerId: string; operation: CredentialOperation; expiresAt: number
+}
+export interface CredentialVaultStatus {
+  protocol: 1; online: boolean; initialized: boolean; unlocked: boolean
+  unlockExpiresAt?: number
+  execution: 'disabled' | 'dummy-fixture' | 'protected-adapters'; reason: 'executor_not_enabled' | 'vault_offline' | 'dummy_fixture_only' | 'operator_approved' | 'isolation_required'
+  entries: CredentialSummary[]; leases: CredentialLeaseSummary[]
+}

+ 13 - 0
src/shared/unread.ts

@@ -0,0 +1,13 @@
+export type UnreadMode = 'bot' | 'chat'
+export interface UnreadMessage { id: string; seq: number; version?: number; taskId?: string }
+export interface UnreadConversation {
+  mode: UnreadMode; id: string; profile?: string; name: string; preview: string;
+  count: number; updatedAt: number; messages: UnreadMessage[];
+}
+export interface UnreadSnapshot { total: number; bot: number; chat: number; conversations: UnreadConversation[] }
+export const emptyUnread = (): UnreadSnapshot => ({ total: 0, bot: 0, chat: 0, conversations: [] })
+/** Tool traces may accompany a final answer; standalone process/system records never qualify. */
+export function isWorkspaceChatReply(m: { role: string; status: string; visible?: boolean; error?: string; content: string; attachments?: unknown[] }): boolean {
+  return m.role === 'assistant' && m.status === 'complete' && m.visible !== false && !m.error
+    && (!!m.content.replace(/<(think|thinking|reasoning)\b[^>]*>[\s\S]*?(?:<\/\1>|$)/gi, '').trim() || !!m.attachments?.length)
+}

+ 12 - 0
src/shared/uploadNames.ts

@@ -0,0 +1,12 @@
+/** Recover UTF-8 upload names that older multipart parsing decoded as Latin-1. */
+export function normalizeUploadName(name: string): string {
+  // Leave Unicode names and ordinary Latin-1 names alone unless every byte
+  // forms valid UTF-8. Fatal decoding avoids replacing data we cannot recover.
+  if (!/[\u0080-\u00ff]/.test(name) || /[^\u0000-\u00ff]/.test(name)) return name
+  try {
+    return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
+      .decode(Uint8Array.from(name, character => character.charCodeAt(0)))
+  } catch {
+    return name
+  }
+}

+ 2 - 0
src/shared/workspace.ts

@@ -179,6 +179,8 @@ export interface WorkspaceFile {
   sender: 'user' | 'agent'
 }
 export interface WorkspaceMessage {
+  /** Completion version assigned once to the stable user-facing reply ID. */
+  chatUnreadVersion?: number
   browserCard?: import('./managedBrowser.js').ManagedBrowserCard
   /** Server-owned, nonfatal availability notices; never model-authored markup. */
   serviceWarnings?: Array<{code:string;service:string;message:string}>

+ 53 - 0
tests/client/chatCrossClientSync.test.ts

@@ -137,6 +137,59 @@ describe('ordinary v2 cross-client store', () => {
     await vi.waitFor(() => expect(wire.clients.some((c) => c.id === id)).toBe(true))
     return wire.clients.filter((c) => c.id === id).at(-1)!
   }
+  it('refreshes an opened owned session with the same gauge source order as mobile', async () => {
+    wire.request.mockImplementation(async (method: string) => {
+      if (method === 'session.resume') return { session_id: 'runtime', stored_session_id: 's' }
+      if (method === 'session.usage') return { input_tokens: 1_800_000, output_tokens: 100_000, total_tokens: 1_900_000 }
+      if (method === 'session.context_breakdown') return { context_used: 12_500, context_max: 114_688, context_percent: 10.9 }
+      return {}
+    })
+    await open()
+    await vi.waitFor(() => expect(chat.contextUsage).toMatchObject({
+      totalTokens: 1_900_000, contextTokens: 12_500, contextLimit: 114_688, percentUsed: 10.9,
+    }))
+    expect(wire.request).toHaveBeenCalledWith('session.usage', { session_id: 'runtime' }, 15_000)
+    expect(wire.request).toHaveBeenCalledWith('session.context_breakdown', { session_id: 'runtime' }, 15_000)
+  })
+
+  it('keeps read-only history unattached when context statistics are unavailable', async () => {
+    chat.sessions = [session('s', false)]
+    await chat.selectSession('s', 'p')
+    expect(wire.request).not.toHaveBeenCalled()
+    expect(chat.contextUsage).toBeUndefined()
+  })
+
+  it('does not let a slow usage response replace a newer realtime gauge', async () => {
+    await open()
+    let finish!: (value: unknown) => void
+    wire.request.mockImplementation((method: string) => method === 'session.usage'
+      ? new Promise(resolve => { finish = resolve }) : Promise.resolve({}))
+    const refreshing = chat.refreshContextUsage()
+    emit('session.usage', { context_used: 4000, context_max: 8000, context_percent: 50 })
+    finish({ context_used: 1000, context_max: 8000, context_percent: 12.5 })
+    await refreshing
+    expect(chat.contextUsage).toMatchObject({ contextTokens: 4000, contextLimit: 8000, percentUsed: 50 })
+  })
+
+  it('refreshes context after a turn completed on another client', async () => {
+    const client = await open()
+    await client.changed(snapshot([answer('处理中', { status: 'streaming' })], 's', { running: true }))
+    wire.request.mockImplementation(async (method: string) => method === 'session.usage'
+      ? { context_used: 4000, context_max: 8000 } : {})
+    await client.changed(snapshot([answer()], 's', { running: false }))
+    await vi.waitFor(() => expect(chat.contextUsage).toMatchObject({ contextTokens: 4000, contextLimit: 8000 }))
+  })
+
+  it('restores a confirmed gauge from session metadata without inventing context from history', async () => {
+    wire.request.mockResolvedValue({ session_id: 'runtime', stored_session_id: 's',
+      info: { usage: { context_used: 2000, context_max: 8000 } } })
+    const client = await open()
+    expect(chat.contextUsage).toMatchObject({ contextTokens: 2000, contextLimit: 8000 })
+    await client.changed(snapshot([answer('很长的旧记录'.repeat(200))], 's', {
+      session: { input_tokens: 1_800_000, output_tokens: 100_000 },
+    }))
+    expect(chat.contextUsage).toMatchObject({ contextTokens: 2000, contextLimit: 8000 })
+  })
   it.each(['image_path', 'path', 'ref_path'])('preserves uploaded image %s in pending and canonical messages', async field => {
     const client = await open()
     const paths = ['/home/user/.hermes/images/照片 one.png', '/tmp/photo-two.png']

+ 10 - 8
tests/client/chatStore.test.ts

@@ -1,7 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import type { ChatMessage, ChatRouteState, RpcEventFrame } from '@shared/types'
 import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
-import { estimateConversationTokens } from '@/utils/contextUsage'
 
 function message(id: string, content = '相同内容', clientMessageId?: string): ChatMessage {
   return {
@@ -23,13 +22,6 @@ function event(type: string, payload: Record<string, unknown>, sessionId = 'sess
 }
 
 describe('chat message reducer', () => {
-  it('estimates visible conversation tokens when Hermes has no live gauge', () => {
-    expect(estimateConversationTokens([
-      message('one', '你好,Hermes!'),
-      { ...message('two', 'The quick brown fox'), role: 'assistant', reasoning: '检查上下文' },
-    ])).toBeGreaterThan(0)
-  })
-
   it('preserves repeated user text when server identities differ', () => {
     const merged = mergeChatMessages([message('one')], [message('two')])
     expect(merged).toHaveLength(2)
@@ -199,6 +191,16 @@ describe('chat message reducer', () => {
     expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, percentUsed: 10.9 })
   })
 
+  it('retains the context gauge when completion only updates cumulative counters', () => {
+    let current = applyChatEvent(state(), event('session.usage', { context_used: 12_500, context_max: 114_688 }))
+    current = applyChatEvent(current, event('message.complete', { text: '完成', usage: { total_tokens: 1_900_000 } }))
+    expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, totalTokens: 1_900_000 })
+    current = applyChatEvent(current, event('usage.update', { input_tokens: 10 }))
+    expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, inputTokens: 10 })
+    current = applyChatEvent(current, event('context.update', { context_used: 0, context_percent: 0 }))
+    expect(current.usage).toMatchObject({ contextTokens: 0, percentUsed: 0 })
+  })
+
   it('tracks approval and clarification independently', () => {
     let current = applyChatEvent(state(), event('approval.request', { request_id: 'approval-1', message: '允许吗?' }))
     current = applyChatEvent(current, event('clarify.request', { request_id: 'clarify-1', question: '选哪个?' }))

+ 30 - 0
tests/client/composer.test.ts

@@ -2,6 +2,36 @@ import { mount } from '@vue/test-utils'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import ComposerShell from '@/components/composer/ComposerShell.vue'
 
+describe('composer context gauge', () => {
+  it('shows unknown measurements instead of inventing a 256k window or remaining budget', async () => {
+    const wrapper = mount(ComposerShell, { props: { showContext: true } })
+    expect(wrapper.get('.composer-context').text()).toBe('等待本轮实际用量 · 窗口未确认')
+    expect(wrapper.find('.composer-context i').exists()).toBe(false)
+    await wrapper.setProps({ contextLimit: 262_144 })
+    expect(wrapper.get('.composer-context').text()).toBe('等待本轮实际用量 · 窗口 256.0k')
+    expect(wrapper.text()).not.toContain('剩余')
+    wrapper.unmount()
+  })
+
+  it('uses confirmed context and server percentage, and clears the gauge after reset', async () => {
+    const wrapper = mount(ComposerShell, { props: { showContext: true, contextUsed: 2000, contextLimit: 8000, contextPercent: 45.5 } })
+    expect(wrapper.get('.composer-context').text()).toBe('2.0k / 7.8k · 剩余 5.9k')
+    expect(wrapper.get('.composer-context b').attributes('style')).toContain('45.5%')
+    expect(wrapper.get('.composer-context').attributes('title')).toContain('最近一次模型请求')
+    await wrapper.setProps({ contextUsed: 0, contextPercent: 0 })
+    expect(wrapper.get('.composer-context').text()).toBe('等待本轮实际用量 · 窗口 7.8k')
+    expect(wrapper.find('.composer-context i').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
+  it('shows confirmed occupancy without pretending the model window is known', () => {
+    const wrapper = mount(ComposerShell, { props: { showContext: true, contextUsed: 12_500 } })
+    expect(wrapper.get('.composer-context').text()).toBe('当前上下文 12.2k · 窗口未确认')
+    expect(wrapper.find('.composer-context i').exists()).toBe(false)
+    wrapper.unmount()
+  })
+})
+
 beforeEach(() => {
   const values = new Map<string, string>()
   vi.stubGlobal('localStorage', {

+ 38 - 0
tests/client/contextUsage.test.ts

@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeChatUsage } from '@/utils/contextUsage'
+
+describe('context usage shared by web and desktop', () => {
+  it('never treats cumulative billing or an output limit as context occupancy', () => {
+    const usage = normalizeChatUsage({ input_tokens: 1_800_000, output_tokens: 100_000,
+      total_tokens: 1_900_000, max_tokens: 262_144, used: 1_900_000, limit: 262_144 })
+    expect(usage).toMatchObject({ inputTokens: 1_800_000, outputTokens: 100_000, totalTokens: 1_900_000 })
+    expect(usage?.contextTokens).toBeUndefined()
+    expect(usage?.contextLimit).toBeUndefined()
+  })
+
+  it('uses the same last-prompt source order and server percentage as mobile', () => {
+    const usage = normalizeChatUsage({ usage: { context_used: 2000, context_max: 8000, context_percent: 45.5 } },
+      { context_used: 3000, context_max: 16000 })
+    expect(usage).toMatchObject({ contextTokens: 2000, contextLimit: 8000, percentUsed: 45.5 })
+  })
+
+  it('fills missing gauges from context breakdown without using cumulative totals', () => {
+    expect(normalizeChatUsage({ total: 1_900_000 }, { context_used: 12_500, context_max: 114_688 }))
+      .toMatchObject({ totalTokens: 1_900_000, contextTokens: 12_500, contextLimit: 114_688 })
+  })
+
+  it('recognizes nested session metadata and camel-case context fields', () => {
+    expect(normalizeChatUsage({ info: { usage: { contextUsed: 2000, contextMax: 8000, contextPercent: 0 } } }))
+      .toMatchObject({ contextTokens: 2000, contextLimit: 8000, percentUsed: 0 })
+    expect(normalizeChatUsage({ contextTokens: 2000, contextLimit: 8000 }))
+      .toMatchObject({ contextTokens: 2000, contextLimit: 8000 })
+  })
+
+  it('preserves an explicit reset to zero and ignores missing or invalid measurements', () => {
+    expect(normalizeChatUsage({ context_used: 0, context_percent: 0, total: 1_900_000 }))
+      .toMatchObject({ contextTokens: 0, percentUsed: 0 })
+    expect(normalizeChatUsage({ text: '完成', context_used: -1, context_max: 0, context_percent: Number.NaN }))
+      .toBeUndefined()
+    expect(normalizeChatUsage(undefined, {})).toBeUndefined()
+  })
+})

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

@@ -0,0 +1,69 @@
+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()
+})
+it('configures an encrypted fixed SFTP body in the dedicated UI, clears it after save and never reads it back',async()=>{
+  state.unlocked=true;state.execution='protected-adapters';const w=await setup()
+  await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
+  const editor=w.findAll('form')[0]!
+  await editor.get('select').setValue('ssh');await editor.get('input[type=checkbox]').setValue(true)
+  await editor.findAll('select')[1]!.setValue('sftp.write')
+  const label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
+  await label('名称').get('input').setValue('dummy transfer');await label('用户名').get('input').setValue('dummy')
+  await label('SSH 主机').get('input').setValue('127.0.0.1');await label('已人工确认').get('input').setValue('SHA256:'+'A'.repeat(43))
+  await label('私钥').get('textarea').setValue('DUMMY-private-key');await label('批准的远端').get('input').setValue('/approved/new.txt')
+  await label('固定写入正文').get('textarea').setValue('DUMMY-private-write-body')
+  await editor.trigger('submit');await flushPromises()
+  expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:expect.objectContaining({usage:{kind:'sftp.write',remotePath:'/approved/new.txt',contents:'DUMMY-private-write-body'}})}))
+  expect(w.find('textarea').exists()).toBe(false);expect(w.text()).not.toContain('DUMMY-private-write-body')
+})

+ 6 - 4
tests/e2e/ordinary-local.spec.ts

@@ -2,16 +2,17 @@ import {test,expect} from '@playwright/test'
 
 test('ordinary SSE replies remain readable from Web without a second history source',async({page})=>{
   await page.goto('/chat')
-  if(await page.getByRole('heading',{name:'登录夭夭'}).isVisible()){
-    await page.getByLabel('账号').fill('admin');await page.getByLabel('密码').fill('e2e-password')
-    await page.getByRole('button',{name:'登录',exact:true}).click()
-  }
+  await expect(page.getByRole('heading',{name:'登录夭夭'})).toBeVisible()
+  await page.getByLabel('账号').fill('admin');await page.getByLabel('密码').fill('e2e-password')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
   await expect(page.getByRole('navigation')).toBeVisible()
   await page.getByRole('button',{name:'新建聊天',exact:true}).click()
+  await expect(page.locator('.composer-context')).toHaveText('等待本轮实际用量 · 窗口未确认')
   const prompt=`本地落盘验收 ${Date.now()}`
   await page.locator('.composer-textarea').fill(prompt)
   await page.getByRole('button',{name:'发送消息'}).click()
   await expect(page.locator('.message__content').filter({hasText:'这是来自假 Gateway 的流式回复。'})).toHaveCount(1,{timeout:15000})
+  await expect(page.locator('.composer-context')).toHaveText('12.2k / 112.0k · 剩余 99.8k')
   const result=await page.evaluate(async()=>{
     const response=await fetch('/api/app/sessions/session-new/messages?profile=yaoyao&limit=100')
     return{status:response.status,source:response.headers.get('X-Yaoyao-Data-Source'),body:await response.json()}
@@ -21,5 +22,6 @@ test('ordinary SSE replies remain readable from Web without a second history sou
   expect(result.body.messages.filter((m:any)=>m.role==='assistant').map((m:any)=>m.content)).toEqual(['这是来自假 Gateway 的流式回复。'])
   await page.reload()
   await expect(page.locator('.message__content').filter({hasText:'这是来自假 Gateway 的流式回复。'})).toHaveCount(1)
+  await expect(page.locator('.composer-context')).toHaveText('12.2k / 112.0k · 剩余 99.8k')
   await page.screenshot({path:'/tmp/yaoyao-ordinary-local-browser.png',fullPage:true})
 })

+ 148 - 0
tests/e2e/workspace-chat.spec.ts

@@ -1,4 +1,5 @@
 import { test, expect, type Page } from '@playwright/test'
+import { writeFile } from 'node:fs/promises'
 async function login(page: Page) {
   await page.goto('/conversations')
   await page.getByRole('textbox', { name: '账号', exact: true }).fill('fixture')
@@ -23,6 +24,153 @@ async function createAgent(page: Page, name: string) {
   await expect(page.getByRole('heading', { name, exact: true })).toBeVisible()
 }
 
+for (const { surface, kind } of [
+  { surface: 'Web', kind: 'direct' }, { surface: 'desktop bridge', kind: 'direct' },
+  { surface: 'Web', kind: 'group' }, { surface: 'desktop bridge', kind: 'group' },
+] as const) {
+test(`sending and queued Bot replies keep the ${kind} chat frame stable through ${surface}`, async ({ page }, testInfo) => {
+  await page.setViewportSize({ width: surface === 'Web' ? 1280 : 1024, height: 800 })
+  if (surface === 'desktop bridge') await page.addInitScript(() => {
+    Object.assign(window, { yaoyaoDesktop: { deviceHost: async () => ({ deviceHost: 'local' }) } })
+  })
+  await login(page)
+  const capabilities = await (await page.request.get('/api/realtime/capabilities')).json()
+  const headers = { 'X-CSRF-Token': capabilities.csrfToken, Origin: new URL(page.url()).origin }
+  const created = await page.request.post('/api/app/agents', { headers, data: { name: `发送布局验收 ${surface} ${kind}`, profile: 'default' } })
+  expect(created.ok(), await created.text()).toBe(true)
+  const agent = (await created.json()).agent
+  const conversations = (await (await page.request.get('/api/app/conversations')).json()).conversations
+  let conversation = conversations.find((c: any) => c.kind === 'direct' && c.memberIds[0] === agent.id)
+  if (kind === 'group') {
+    const peerResponse = await page.request.post('/api/app/agents', { headers, data: { name: `群聊布局成员 ${surface}`, profile: 'default' } })
+    expect(peerResponse.ok(), await peerResponse.text()).toBe(true)
+    const peer = (await peerResponse.json()).agent
+    const groupResponse = await page.request.post('/api/app/conversations', { headers, data: {
+      name: `发送布局群 ${surface}`, memberIds: [agent.id, peer.id], administratorId: agent.id, autoReplyIds: [agent.id],
+    } })
+    expect(groupResponse.ok(), await groupResponse.text()).toBe(true)
+    conversation = (await groupResponse.json()).conversation
+  }
+  await page.goto(`/conversations/${conversation.id}`)
+  const input = page.locator('.composer-textarea')
+  await input.fill('[hold-workspace] 先执行第一条消息')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  await expect(page.getByTestId('chat-run-thinking-dots')).toBeVisible()
+  await expect(page.locator('.message--assistant')).toBeVisible()
+  await expect(input).toHaveValue('')
+  await input.fill('排队执行第二条消息')
+  const messageIds = await page.locator('[data-message-id]').evaluateAll(rows => rows.map(row => row.getAttribute('data-message-id')))
+  await page.evaluate(() => {
+    const selectors = ['.timeline-header', '.timeline', '.composer-area', '.composer-shell', '.composer-textarea', '.composer-tools', '.composer-actions']
+    const nodes = selectors.map(selector => document.querySelector(selector)!)
+    const previousMessages = [...document.querySelectorAll('[data-message-id]')]
+    const samples: Array<{ connected: boolean; rects: number[][] }> = []
+    let frame: number
+    const record = () => {
+      samples.push({ connected: [...nodes, ...previousMessages].every(node => node.isConnected), rects: nodes.map(node => {
+        const box = node.getBoundingClientRect(); return [box.x, box.y, box.width, box.height]
+      }) })
+      frame = requestAnimationFrame(record)
+    }
+    record()
+    Object.assign(window, { stopSendLayoutRecording: () => { cancelAnimationFrame(frame); return { selectors, samples } } })
+  })
+  let release!: () => void
+  const gate = new Promise<void>(resolve => { release = resolve })
+  const pattern = `**/api/app/conversations/${conversation.id}/messages`
+  await page.route(pattern, async route => {
+    expect(route.request().postDataJSON().deviceHost).toBe(surface === 'Web' ? null : 'local')
+    await gate; await route.continue()
+  })
+  try {
+    await page.getByRole('button', { name: '发送消息', exact: true }).click()
+    await expect(page.locator('.composer-sending')).toBeVisible()
+    await page.evaluate(async () => { for (let count = 0; count < 3; count++) await new Promise(requestAnimationFrame) })
+    release()
+    await expect(page.getByText('正在等待可用机器人', { exact: true })).toBeVisible()
+    await expect(input).toHaveValue('')
+    await expect(page.locator('[data-message-id]')).toHaveCount(messageIds.length + 1)
+    await page.screenshot({ path: testInfo.outputPath('send-layout-queued.png') })
+    await page.request.post('http://127.0.0.1:19120/__release')
+    await expect(page.locator('.message--assistant')).toHaveCount(2)
+    await page.request.post('http://127.0.0.1:19120/__release')
+    await expect(page.getByTestId('chat-run-thinking-dots')).toHaveCount(0)
+    await input.fill('完成后再发送一条普通消息')
+    await page.getByRole('button', { name: '发送消息', exact: true }).click()
+    await expect(page.locator('.message--user')).toHaveCount(3)
+    await expect(page.locator('.message--assistant')).toHaveCount(3)
+    await page.request.post('http://127.0.0.1:19120/__release')
+    await expect(page.getByTestId('chat-run-thinking-dots')).toHaveCount(0)
+    await expect(input).toHaveValue('')
+  } finally {
+    release(); await page.unroute(pattern)
+  }
+  const recording = await page.evaluate(() => (window as unknown as { stopSendLayoutRecording(): { selectors: string[]; samples: { connected: boolean; rects: number[][] }[] } }).stopSendLayoutRecording())
+  const recordingPath = testInfo.outputPath('send-layout-frames.json')
+  await writeFile(recordingPath, JSON.stringify(recording, null, 2))
+  await testInfo.attach('send-layout-frames.json', { path: recordingPath, contentType: 'application/json' })
+  expect(recording.samples.every(sample => sample.connected)).toBe(true)
+  for (const [index, selector] of recording.selectors.entries()) {
+    for (let coordinate = 0; coordinate < 4; coordinate++) {
+      const values = recording.samples.map(sample => sample.rects[index]![coordinate]!)
+      expect(Math.max(...values) - Math.min(...values), `${selector} coordinate ${coordinate} shifted`).toBeLessThanOrEqual(1)
+    }
+  }
+})
+}
+
+test('UTF-8 attachments stay inside the composer and retain their names after sending and reload', async ({ page }, testInfo) => {
+  await login(page)
+  const capabilities = await (await page.request.get('/api/realtime/capabilities')).json()
+  const headers = { 'X-CSRF-Token': capabilities.csrfToken, Origin: new URL(page.url()).origin }
+  const created = await page.request.post('/api/app/agents', { headers, data: { name: '附件验收', profile: 'default' } })
+  expect(created.ok(), await created.text()).toBe(true)
+  const agent = (await created.json()).agent
+  const conversations = (await (await page.request.get('/api/app/conversations')).json()).conversations
+  const conversation = conversations.find((c: any) => c.kind === 'direct' && c.memberIds[0] === agent.id)
+  await page.goto(`/conversations/${conversation.id}`)
+  const name = '人工智能合同综合质量评估系统V2.0-信息采集表-待确认.docx'
+  const certificate = 'CertificateSigningRequest.certSigningRequest'
+  await page.locator('.composer-file-input').setInputFiles([
+    { name, mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', buffer: Buffer.from('附件内容保持不变') },
+    { name: certificate, mimeType: 'application/octet-stream', buffer: Buffer.from('certificate') },
+    { name: '图片.png', mimeType: 'image/png', buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') },
+  ])
+  await page.locator('.composer-textarea').fill('请按格式填写附件')
+  for (const width of [1280, 655, 375]) {
+    await page.setViewportSize({ width, height: 863 })
+    const attachments = page.locator('.composer-shell > .composer-attachments')
+    await expect(attachments.locator('.composer-attachment')).toHaveCount(3)
+    await expect(attachments).toContainText(name)
+    await expect(attachments.getByRole('img', { name: '图片.png' })).toBeVisible()
+    const shell = (await page.locator('.composer-shell').boundingBox())!
+    const tray = (await attachments.boundingBox())!
+    const input = (await page.locator('.composer-textarea').boundingBox())!
+    expect(tray.x).toBeGreaterThan(shell.x)
+    expect(tray.y).toBeGreaterThan(shell.y)
+    expect(tray.x + tray.width).toBeLessThan(shell.x + shell.width)
+    expect(input.y - (tray.y + tray.height)).toBeGreaterThanOrEqual(0)
+    expect(input.y - (tray.y + tray.height)).toBeLessThanOrEqual(12)
+    expect(input.y + input.height).toBeLessThan(shell.y + shell.height)
+    expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
+    await page.screenshot({ path: testInfo.outputPath(`composer-attachments-${width}.png`) })
+  }
+  await page.getByRole('button', { name: '移除 图片.png', exact: true }).click()
+  await page.getByRole('button', { name: `移除 ${certificate}`, exact: true }).click()
+  await expect(page.locator('.composer-attachment')).toHaveCount(1)
+  await expect(page.locator('.composer-textarea')).toHaveValue('请按格式填写附件')
+  const uploaded = page.waitForResponse(response => response.url().endsWith('/api/app/uploads') && response.request().method() === 'POST')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  expect((await (await uploaded).json()).files[0].name).toBe(name)
+  const file = page.locator('.message-file').filter({ hasText: name })
+  await expect(file).toBeVisible()
+  await expect(file).toHaveAccessibleName(new RegExp(`预览文件 ${name}`))
+  await expect(page.locator('.composer-attachment')).toHaveCount(0)
+  await page.reload()
+  await expect(file).toBeVisible()
+  await page.screenshot({ path: testInfo.outputPath('utf8-attachment-message.png') })
+})
+
 test('Bot unread dots sync across viewers and neither detail nor read requests delay navigation', async ({ page, context }, testInfo) => {
   await login(page)
   const capabilities = await (await page.request.get('/api/realtime/capabilities')).json()

+ 75 - 0
tests/fixtures/credentialProtocols.ts

@@ -0,0 +1,75 @@
+// Local, ephemeral dummy protocols only; never contact an external account.
+import {createServer} from 'node:https'
+import {once} from 'node:events'
+import {execFileSync} from 'node:child_process'
+import {readFileSync,writeFileSync} from 'node:fs'
+import {join} from 'node:path'
+import {createHash,randomBytes} from 'node:crypto'
+import {Server,utils,type Connection} from 'ssh2'
+export async function websiteFixture(home:string){
+  const key=join(home,'dummy-key.pem'),cert=join(home,'dummy-cert.pem'),cnf=join(home,'dummy-openssl.cnf')
+  writeFileSync(cnf,'[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=127.0.0.1\n[ext]\nsubjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:TRUE\n')
+  execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-days','1','-keyout',key,'-out',cert,'-config',cnf],{stdio:'ignore'})
+  const username='dummy-site-user',secret='DUMMY-local-only-password',cookie=randomBytes(32).toString('hex')
+  const submitted=Promise.withResolvers<void>()
+  const state={logins:0,proofs:0,logouts:0,loginViews:0,formAction:'/login',iframe:false,redirect:'/account',delay:false,requests:[] as string[]}
+  const server=createServer({key:readFileSync(key),cert:readFileSync(cert)},async(req,res)=>{
+    state.requests.push(req.method+' '+req.url)
+    if(req.url==='/login'&&req.method==='GET'){state.loginViews++;res.setHeader('Content-Type','text/html');res.end(`<form id="login" method="post" action="${state.formAction}"><input name="username"><input type="password" name="password"><button type="submit">Login</button></form>${state.iframe?'<iframe src="/frame"></iframe>':''}`);return}
+    if(req.url==='/login'&&req.method==='POST'){
+      let body='';for await(const c of req)body+=c
+      const form=new URLSearchParams(body)
+      if(form.get('username')!==username||form.get('password')!==secret){res.writeHead(401);res.end();return}
+      state.logins++;submitted.resolve();if(state.delay)return
+      res.setHeader('Set-Cookie',`fixture_auth=${cookie}; HttpOnly; Secure; SameSite=Strict`);res.writeHead(303,{Location:state.redirect});res.end();return
+    }
+    if(req.url==='/account'&&req.headers.cookie===`fixture_auth=${cookie}`){state.proofs++;res.setHeader('Content-Type','text/html');res.end('<div id="login-success">Dummy authenticated</div>');return}
+    if(req.url==='/logout'&&req.headers.cookie===`fixture_auth=${cookie}`){state.logouts++;res.setHeader('Set-Cookie','fixture_auth=; Max-Age=0; HttpOnly; Secure; SameSite=Strict');res.end('Logged out');return}
+    res.writeHead(401);res.end()
+  })
+  server.listen(0,'127.0.0.1');await once(server,'listening')
+  return {state,submitted:submitted.promise,username,secret,cookie,cert:readFileSync(cert),origin:'https://127.0.0.1:'+(server.address() as any).port,
+    usage:{kind:'website.form' as const,loginPath:'/login',submitPath:'/login',successPath:'/account',logoutPath:'/logout',formId:'login',usernameName:'username',passwordName:'password',successSelector:'#login-success'},
+    close:async()=>{server.closeAllConnections();await new Promise<void>(r=>server.close(()=>r()))}}
+}
+export async function sshFixture(){
+  const host=utils.generateKeyPairSync('ed25519'),key=utils.generateKeyPairSync('ed25519'),parsed=utils.parseKey(key.public) as utils.ParsedKey
+  const fingerprint='SHA256:'+createHash('sha256').update((utils.parseKey(host.private) as utils.ParsedKey).getPublicSSH()).digest('base64').replace(/=+$/,'')
+  const clients=new Set<Connection>(),files=new Map<string,Buffer>([['/approved/input.txt',Buffer.from('DUMMY-private-file-contents')]])
+  const executed=Promise.withResolvers<void>(),written=Promise.withResolvers<void>()
+  const state={auths:0,commands:[] as string[],opens:[] as {path:string,flags:number,mode:number}[],writes:0,delayWrite:false,delayExec:false,readRedirect:false}
+  const server=new Server({hostKeys:[host.private]},client=>{
+    clients.add(client);client.on('error',()=>{});client.on('close',()=>clients.delete(client))
+    client.on('authentication',ctx=>{
+      if(ctx.method!=='publickey'||ctx.username!=='dummy-ssh-user'||!ctx.key.data.equals(parsed.getPublicSSH())||ctx.signature&&parsed.verify(ctx.blob!,ctx.signature)!==true){ctx.reject();return}
+      state.auths++;ctx.accept()
+    }).on('ready',()=>client.on('session',accept=>{
+      const session=accept()
+      session.on('exec',(accept,_reject,info)=>{state.commands.push(info.command);const stream=accept();executed.resolve();if(state.delayExec)return;stream.write('DUMMY-private-command-output');stream.stderr.write('DUMMY-private-stderr');stream.exit(0);stream.end()})
+      session.on('sftp',accept=>{
+        const s=accept(),handles=new Map<string,string>();let id=0
+        s.on('error',()=>{})
+        s.on('REALPATH',(req,path)=>s.name(req,[{filename:state.readRedirect&&path==='/approved/input.txt'?'/outside/private.txt':path,longname:path,attrs:{}}]))
+        s.on('OPEN',(req,path,flags,attrs)=>{
+          state.opens.push({path,flags,mode:attrs.mode})
+          if(!path.startsWith('/approved/')||(flags&32)&&files.has(path)){s.status(req,4);return}
+          if(flags&8)files.set(path,Buffer.alloc(0))
+          if(!files.has(path)){s.status(req,2);return}
+          const handle=Buffer.from(String(++id));handles.set(handle.toString(),path);s.handle(req,handle)
+        })
+        s.on('FSTAT',(req,handle)=>{const data=files.get(handles.get(handle.toString())??'');if(!data){s.status(req,4);return}s.attrs(req,{mode:0o100600,size:data.length,uid:1,gid:1,atime:1,mtime:1})})
+        s.on('READ',(req,handle,offset,len)=>{const data=files.get(handles.get(handle.toString())??'');if(!data){s.status(req,4);return}if(offset>=data.length){s.status(req,1);return}s.data(req,data.subarray(offset,offset+len))})
+        s.on('WRITE',(req,handle,offset,data)=>{
+          const path=handles.get(handle.toString());if(!path){s.status(req,4);return}
+          state.writes++;const old=files.get(path)!,next=Buffer.alloc(Math.max(old.length,offset+data.length));old.copy(next);data.copy(next,offset);files.set(path,next)
+          written.resolve()
+          if(!state.delayWrite)s.status(req,0)
+        })
+        s.on('CLOSE',(req,handle)=>{handles.delete(handle.toString());s.status(req,0)})
+      })
+    }))
+  })
+  server.listen(0,'127.0.0.1');await once(server,'listening')
+  return {state,files,executed:executed.promise,written:written.promise,username:'dummy-ssh-user',secret:key.private,target:{kind:'ssh' as const,host:'127.0.0.1',port:(server.address() as any).port,hostKey:fingerprint},
+    close:async()=>{for(const client of clients)client.end();await new Promise<void>(r=>server.close(()=>r()))}}
+}

+ 91 - 0
tests/server/credentialVaultAdapters.test.ts

@@ -0,0 +1,91 @@
+// @vitest-environment node
+import {afterEach,expect,it,vi} from 'vitest'
+import {mkdtempSync,rmSync,writeFileSync,chmodSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID,createHash} from 'node:crypto'
+import {websiteFixture,sshFixture} from '../fixtures/credentialProtocols'
+import {ProtectedCredentialExecutor} from '../../src/server/credentialVault/protectedExecutor'
+import {OperatorIsolationGate} from '../../src/server/credentialVault/isolation'
+import {parse,entryInput,type VaultEntry,type LeaseInput} from '../../src/server/credentialVault/schema'
+const homes:string[]=[],cleanup:(()=>Promise<void>)[]=[]
+const gate={assert(){}} // Dummy wiring validates protocols, never OS isolation.
+function home(){const h=mkdtempSync(join(tmpdir(),'yaoyao-vault-adapters-'));homes.push(h);return h}
+function entry(value:unknown):VaultEntry{return {...parse(entryInput,value),id:randomUUID(),revision:1,updatedAt:Date.now()}}
+function input(e:VaultEntry,operation:LeaseInput['operation']):LeaseInput{return {credentialRef:e.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy',target:e.target,operation,seconds:60}}
+afterEach(async()=>{for(const close of cleanup.splice(0))await close();for(const h of homes.splice(0))rmSync(h,{recursive:true,force:true})})
+it('a protected standard form authenticates, verifies the marker, logs out and returns no secret/session/browser output',async()=>{
+  const h=home(),site=await websiteFixture(h);cleanup.push(site.close)
+  const e=entry({name:'dummy',username:site.username,secret:site.secret,target:{kind:'website',origin:site.origin},usage:site.usage})
+  const executor=new ProtectedCredentialExecutor(join(h,'browser'),gate,site.cert)
+  expect(await executor.execute(e,input(e,'website.login'),new AbortController().signal)).toEqual({status:'complete',operation:'website.login'})
+  expect(site.state).toMatchObject({logins:1,proofs:1,logouts:1})
+  // Fresh context cannot reuse the previous session.
+  expect(await executor.execute(e,input(e,'website.login'),new AbortController().signal)).toEqual({status:'complete',operation:'website.login'})
+  expect(site.state).toMatchObject({logins:2,proofs:2,logouts:2})
+},20000)
+it.each(['action','iframe','redirect'] as const)('rejects unapproved website %s and never exports a session',async cause=>{
+  const h=home(),site=await websiteFixture(h);cleanup.push(site.close)
+  if(cause==='action')site.state.formAction='https://127.0.0.1:1/steal'
+  if(cause==='iframe')site.state.iframe=true
+  if(cause==='redirect')site.state.redirect='https://127.0.0.1:1/steal'
+  const e=entry({name:'dummy',username:site.username,secret:site.secret,target:{kind:'website',origin:site.origin},usage:site.usage})
+  const result=await new ProtectedCredentialExecutor(join(h,'browser'),gate,site.cert).execute(e,input(e,'website.login'),new AbortController().signal)
+  expect(result).toMatchObject({status:'manual_takeover_required',reason:'unsupported_form_flow'})
+  expect(site.state.logins).toBe(cause==='redirect'?1:0);expect(site.state.proofs).toBe(0)
+  expect(JSON.stringify(result)).not.toContain(site.secret)
+},15000)
+it('cancelling a submitted website login closes its private context and leaves an unknown result without retry/logout',async()=>{
+  const h=home(),site=await websiteFixture(h);cleanup.push(site.close);site.state.delay=true
+  const e=entry({name:'dummy',username:site.username,secret:site.secret,target:{kind:'website',origin:site.origin},usage:site.usage}),controller=new AbortController()
+  const run=new ProtectedCredentialExecutor(join(h,'browser'),gate,site.cert).execute(e,input(e,'website.login'),controller.signal),result=expect(run).rejects.toThrow()
+  await vi.waitFor(()=>expect(site.state.logins).toBe(1));controller.abort();await result
+  expect(site.state.logins).toBe(1);expect(site.state.logouts).toBe(0)
+},15000)
+it('uses pinned public-key SSH, a fixed executable and bounded exact SFTP paths; receipts omit stdout and file contents',async()=>{
+  const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);const executor=new ProtectedCredentialExecutor(join(h,'executor'),gate)
+  const base={name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target}
+  const command=entry({...base,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
+  expect(await executor.execute(command,input(command,'ssh.exec'),new AbortController().signal)).toEqual({status:'complete',operation:'ssh.exec',exitCode:0})
+  expect(ssh.state.commands).toEqual(['/usr/bin/uptime'])
+  const read=entry({...base,usage:{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:64}}),original=ssh.files.get('/approved/input.txt')!
+  expect(await executor.execute(read,input(read,'sftp.read'),new AbortController().signal)).toEqual({status:'complete',operation:'sftp.read',bytes:original.length,sha256:createHash('sha256').update(original).digest('hex')})
+  const write=entry({...base,usage:{kind:'sftp.write',remotePath:'/approved/output.txt',contents:'DUMMY-fixed-encrypted-transfer-body'}})
+  const receipt=await executor.execute(write,input(write,'sftp.write'),new AbortController().signal)
+  expect(receipt).toMatchObject({status:'complete',operation:'sftp.write',bytes:Buffer.byteLength('DUMMY-fixed-encrypted-transfer-body')});expect(JSON.stringify(receipt)).not.toContain('DUMMY')
+  expect(ssh.files.get('/approved/output.txt')?.toString()).toBe('DUMMY-fixed-encrypted-transfer-body')
+  expect(ssh.state.opens.at(-1)).toMatchObject({flags:58,mode:0o600})
+  await expect(executor.execute(write,input(write,'sftp.write'),new AbortController().signal)).rejects.toThrow() // Never overwrite.
+},15000)
+it('rejects an unknown SSH host key before authentication, symlink targets and oversized files',async()=>{
+  const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);const executor=new ProtectedCredentialExecutor(join(h,'executor'),gate)
+  const base={name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target},e=entry({...base,target:{...ssh.target,hostKey:'SHA256:'+'A'.repeat(43)},usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
+  await expect(executor.execute(e,input(e,'ssh.exec'),new AbortController().signal)).rejects.toMatchObject({code:'vault_host_key_rejected'});expect(ssh.state.auths).toBe(0)
+  const read=entry({...base,usage:{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:1}})
+  await expect(executor.execute(read,input(read,'sftp.read'),new AbortController().signal)).rejects.toMatchObject({code:'vault_usage_forbidden'})
+  ssh.state.readRedirect=true
+  await expect(executor.execute(read,input(read,'sftp.read'),new AbortController().signal)).rejects.toMatchObject({code:'vault_usage_forbidden'})
+  expect(ssh.state.opens).toHaveLength(1)
+},15000)
+it('a malformed private key produces a rejected operation without unhandled connection errors',async()=>{
+  const h=home(),ssh=await sshFixture();cleanup.push(ssh.close)
+  const e=entry({name:'dummy',username:ssh.username,secret:'DUMMY-not-a-private-key',target:ssh.target,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
+  await expect(new ProtectedCredentialExecutor(join(h,'executor'),gate).execute(e,input(e,'ssh.exec'),new AbortController().signal)).rejects.toThrow()
+  expect(ssh.state.auths).toBe(0)
+})
+it('cancelling SFTP during a lost write acknowledgement terminates promptly and leaves the partial result for manual inspection',async()=>{
+  const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);ssh.state.delayWrite=true
+  const e=entry({name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target,usage:{kind:'sftp.write',remotePath:'/approved/partial.txt',contents:'DUMMY-only'}}),controller=new AbortController()
+  const run=new ProtectedCredentialExecutor(join(h,'executor'),gate).execute(e,input(e,'sftp.write'),controller.signal),result=expect(run).rejects.toThrow()
+  await vi.waitFor(()=>expect(ssh.state.writes).toBe(1));controller.abort();await result
+  expect(ssh.files.get('/approved/partial.txt')?.toString()).toBe('DUMMY-only');expect(ssh.state.writes).toBe(1)
+},10000)
+it('requires private, unexpired operator approval and the actual distinct Hermes process identity; revocation cannot downgrade',()=>{
+  const h=home(),path=join(h,'approval.json'),uid=process.getuid!(),now=Date.now()
+  const approval={version:1,approved:true,brokerUid:uid,hermesUid:uid+1,hermesPid:123,approvedAt:now,expiresAt:now+60000,controls:['separate-identity','hermes-no-control-access','browser-private-no-cdp','restricted-egress']}
+  const save=(v:unknown)=>writeFileSync(path,JSON.stringify(v),{mode:0o600}),check=()=>new OperatorIsolationGate(path,uid+1,123,()=>uid+1,()=>now).assert()
+  expect(check).toThrowError(expect.objectContaining({code:'vault_isolation_required'}));save(approval);expect(check).not.toThrow()
+  expect(()=>new OperatorIsolationGate(path,uid+1,123,()=>uid,()=>now).assert()).toThrow()
+  save({...approval,expiresAt:now});expect(check).toThrow();save(approval);chmodSync(path,0o644);expect(check).toThrow()
+  chmodSync(path,0o600);save({...approval,approved:false});expect(check).toThrow()
+})

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

@@ -0,0 +1,111 @@
+// @vitest-environment node
+import { expect, it, vi } from 'vitest'
+import Koa from 'koa'
+import request from 'supertest'
+import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { once } from 'node:events'
+import { randomUUID, randomBytes, createHash } 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 { ProtectedCredentialExecutor } from '../../src/server/credentialVault/protectedExecutor'
+import { websiteFixture, sshFixture } from '../fixtures/credentialProtocols'
+import type { RunnerHub } from '../../src/server/runnerHub'
+import type { WorkspaceRun } from '../../src/shared/workspace'
+
+it.each(['website.login','ssh.exec','sftp.read','sftp.write','website.login.cancel','ssh.exec.cancel','sftp.write.cancel'] as const)('a Bot turn uses its real tool catalog, task approval and protected adapter for local dummy %s, returning only a sanitized receipt',async scenario=>{
+  const cancelled=scenario.endsWith('.cancel'),operation=scenario.replace(/\.cancel$/,'') as 'website.login'|'ssh.exec'|'sftp.read'|'sftp.write'
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-bot-flow-'))
+  const website=await websiteFixture(home),ssh=await sshFixture()
+  if(cancelled){website.state.delay=true;ssh.state.delayExec=operation==='ssh.exec';ssh.state.delayWrite=true}
+  const isWebsite=operation==='website.login',master='DUMMY-master-password-only',secret=isWebsite?website.secret:ssh.secret,username=isWebsite?website.username:ssh.username,sessionCookie=website.cookie
+  const usage=operation==='website.login'?website.usage:operation==='ssh.exec'?{kind:'ssh.exec',command:'/usr/bin/uptime'}:operation==='sftp.read'?{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:64}:{kind:'sftp.write',remotePath:'/approved/output.txt',contents:'DUMMY-fixed-transfer-body'}
+  const credentialTarget=isWebsite?{kind:'website',origin:website.origin}:ssh.target
+  const expected=operation==='website.login'?{status:'complete',operation}:operation==='ssh.exec'?{status:'complete',operation,exitCode:0}:operation==='sftp.read'?{status:'complete',operation,bytes:ssh.files.get('/approved/input.txt')!.length,sha256:createHash('sha256').update(ssh.files.get('/approved/input.txt')!).digest('hex')}:{status:'complete',operation,bytes:Buffer.byteLength('DUMMY-fixed-transfer-body'),sha256:createHash('sha256').update('DUMMY-fixed-transfer-body').digest('hex')}
+  const effectCount=()=>isWebsite?website.state.logins:operation==='ssh.exec'?ssh.state.commands.length:ssh.state.opens.length
+  const store=new WorkspaceStore(home),uploads=new UploadStore(home),auth=new LocalAuthStore(home)
+  const loginHeaders:Record<string,any>={},login={state:{},response:{headers:loginHeaders},get:()=>'',set:(k:string,v:any)=>{loginHeaders[k.toLowerCase()]=v}} as any
+  const 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 ProtectedCredentialExecutor(join(home,'private-executor'),{assert(){}},website.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},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(effectCount()).toBe(0)
+            const waiting=store.require<any>(owner,'turn',view.requests[0].workId);expect(waiting.status).toBe('waiting')
+            await ui('/leases',{credentialRef,agentId:view.requests[0].agentId,workId:view.requests[0].workId,operation,seconds:60}).expect(200)
+            if(cancelled){await (isWebsite?website.submitted:operation==='ssh.exec'?ssh.executed:ssh.written);expect(effectCount()).toBe(1);await ui('/lock',{}).expect(200)}
+            const result=await pending;if(cancelled){expect(result.isError).toBe(true);expect(JSON.stringify(result)).toContain('vault_operation_uncertain')}else expect(result.structuredContent).toEqual(expected);results.push(result)
+            expect(await tool('/tools/call',call)).toEqual(result);expect(effectCount()).toBe(1) // Same call ID cannot log in twice.
+          }catch(e){integrationError=e}
+          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:credentialTarget,usage,secret}).expect(200);credentialRef=entry.body.id
+    const bot=store.createAgent(owner,{name:'dummy-login-Bot',profile:'default'}),conversation=store.list<any>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)!
+    const 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(effectCount()).toBe(1);if(isWebsite){expect(website.state.proofs).toBe(cancelled?0:1);expect(website.state.logouts).toBe(cancelled?0:1)};expect(results).toHaveLength(1);expect(catalogs).toHaveLength(1)
+    for(const blob of [JSON.stringify(results),JSON.stringify(catalogs),JSON.stringify(prompts),JSON.stringify(store.messages(owner,conversation.id)),readFileSync(join(home,'vault','audit.jsonl'),'utf8')]){
+      for(const privateValue of [secret,master,username,sessionCookie,token,'DUMMY-private-command-output','DUMMY-private-stderr','DUMMY-private-file-contents','DUMMY-fixed-transfer-body'])expect(blob).not.toContain(privateValue)
+    }
+    expect((await ui('')).body.leases).toHaveLength(0)
+  }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()));await website.close();await ssh.close()
+    nodes.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true})
+  }
+},15000)

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

@@ -0,0 +1,93 @@
+// @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')
+})
+it.each([undefined,{status:'complete',operation:'website.login',secret},{status:'complete',operation:'ssh.exec'}])('never treats missing, raw or scope-mismatched protected receipts as success',async receipt=>{
+  const f=await fixture({mode:'protected-adapters',execute:async()=>receipt as any}),lease=f.grant()
+  await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_operation_uncertain'})
+  expect(f.dispatch('status').leases).toHaveLength(0)
+  expect(readFileSync(join(f.home,'audit.jsonl'),'utf8')).not.toContain(secret)
+})
+it('revocation of deployment approval locks and cancels the current operation instead of downgrading its executor',async()=>{
+  let approved=true,signal:AbortSignal|undefined
+  const started=Promise.withResolvers<void>(),finish=Promise.withResolvers<void>()
+  const f=await fixture({mode:'protected-adapters',assertAvailable(){if(!approved)throw new Error('revoked')},execute:async(_e,_i,s)=>{signal=s;started.resolve();await finish.promise;return {status:'complete',operation:'website.login'}}}),lease=f.grant()
+  const pending=f.execute(lease.id),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'});await started.promise
+  approved=false;expect(f.dispatch('status')).toMatchObject({unlocked:false,execution:'disabled',reason:'isolation_required',leases:[]})
+  expect(signal?.aborted).toBe(true);finish.resolve();await assertion
+})

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

@@ -0,0 +1,136 @@
+// @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 type {CredentialExecutor} from '../../src/server/credentialVault/executor'
+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,executor?:CredentialExecutor){
+  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,Date.now,executor)
+  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)=>c==='execute'?broker.execute(epoch,o,s,(v as any).id,(v as any).input):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,server}
+}
+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)
+})
+it('a Runner change after execution but before its receipt reaches the Bot leaves an unknown result and cannot replay',async()=>{
+  const execute=vi.fn(async()=>({status:'complete' as const,operation:'website.login' as const})),f=await fixture(false,{mode:'protected-adapters',execute});await f.grant()
+  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==='execute'){reached.resolve();await release.promise}return result})
+  const t=f.turn(),pending=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'})
+  await reached.promise;f.changeBinding();release.resolve();await assertion
+  expect(execute).toHaveBeenCalledOnce();expect((await f.status()).leases).toHaveLength(0);t.close()
+})
+it('losing the private execution connection aborts the lease and returns unknown once instead of waiting/retrying',async()=>{
+  const reached=Promise.withResolvers<void>();let signal:AbortSignal|undefined
+  const execute=vi.fn(async(_e,_i,s:AbortSignal)=>{signal=s;reached.resolve();await new Promise<void>((_r,reject)=>s.addEventListener('abort',()=>reject(new Error('DUMMY-private-error')),{once:true}));return {status:'complete' as const,operation:'website.login' as const}})
+  const f=await fixture(true,{mode:'protected-adapters',execute});await f.grant()
+  const t=f.turn(),pending=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'})
+  await reached.promise;f.server!.closeAllConnections();await assertion
+  await vi.waitFor(()=>expect(signal?.aborted).toBe(true));expect(execute).toHaveBeenCalledOnce()
+  expect((await f.status()).leases).toHaveLength(0);t.close()
+})

+ 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)
+})

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

@@ -0,0 +1,83 @@
+// @vitest-environment node
+import { afterEach, expect, it } from 'vitest'
+import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { CredentialVaultStore } from '../../src/server/credentialVault/store'
+const homes: string[] = [], stores: CredentialVaultStore[] = []
+const owner = 'fixture-owner', password = 'dummy-master-password-only'
+const secret = 'DUMMY-DO-NOT-USE-website-password'
+const input = { name: 'fixture site', username: 'fixture-user', target: { kind: 'website' as const, origin: 'https://example.test' }, secret }
+async function fixture() {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-vault-store-')); homes.push(home)
+  const store = await CredentialVaultStore.create(home); stores.push(store); return { store, home }
+}
+afterEach(() => { stores.splice(0).forEach(s => s.lockAll()); homes.splice(0).forEach(p => rmSync(p, { recursive: true, force: true })) })
+it('starts locked, encrypts entries and exposes metadata only; restart requires manual unlock', async () => {
+  const { store, home } = await fixture()
+  expect(store.initialize(owner, password)).toMatchObject({ initialized: true, unlocked: false })
+  expect(() => store.add(owner, input)).toThrowError(expect.objectContaining({ code: 'vault_locked' }))
+  store.unlock(owner, password); const entry = store.add(owner, input)
+  expect(entry).not.toHaveProperty('secret')
+  const file = readdirSync(home)[0]!, bytes = readFileSync(join(home, file), 'utf8')
+  expect(bytes).not.toContain(secret); expect(bytes).not.toContain(input.username); expect(bytes).not.toContain(password)
+  expect(readdirSync(home)).toHaveLength(1) // No co-located key file.
+  const restarted = await CredentialVaultStore.create(home); stores.push(restarted)
+  expect(restarted.state(owner).unlocked).toBe(false)
+  expect(() => restarted.unlock(owner, 'wrong-dummy-password')).toThrowError(expect.objectContaining({ code: 'vault_unlock_failed' }))
+  expect(() => restarted.unlock(owner, password)).toThrowError(expect.objectContaining({ code: 'vault_unlock_throttled' }))
+  expect(store.withSecret(owner, entry.id, e => e.secret)).toBe(secret)
+})
+it('binds encrypted payload to owner, vault ID, revision and KDF metadata', async () => {
+  const { store, home } = await fixture(); store.initialize(owner, password)
+  const path = join(home, readdirSync(home)[0]!), original = JSON.parse(readFileSync(path, 'utf8'))
+  for (const mutate of [(e: any) => e.id = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', (e: any) => e.revision++, (e: any) => e.kdf.salt = Buffer.alloc(16).toString('base64')]) {
+    const e = structuredClone(original); mutate(e); writeFileSync(path, JSON.stringify(e), { mode: 0o600 })
+    const probe = await CredentialVaultStore.create(home); stores.push(probe)
+    expect(() => probe.unlock(owner, password)).toThrowError(expect.objectContaining({ code: 'vault_unlock_failed' }))
+  }
+})
+it('uses fresh nonces, enforces entry revisions and atomically rotates the DEK and wrapping password', async () => {
+  const { store, home } = await fixture(); store.initialize(owner, password); store.unlock(owner, password)
+  const first = store.add(owner, input), path = join(home, readdirSync(home)[0]!), before = JSON.parse(readFileSync(path, 'utf8'))
+  const next = store.update(owner, first.id, { ...input, secret: undefined, revision: 1 })
+  expect(next.revision).toBe(2)
+  expect(() => store.update(owner, first.id, { ...input, revision: 1 })).toThrowError(expect.objectContaining({ code: 'vault_revision_conflict' }))
+  expect(JSON.parse(readFileSync(path, 'utf8')).payload.nonce).not.toBe(before.payload.nonce)
+  store.rotate(owner, 'dummy-replacement-password')
+  expect(store.state(owner).unlocked).toBe(false)
+  store.unlock(owner, 'dummy-replacement-password')
+  expect(store.withSecret(owner, first.id, e => e.secret)).toBe(secret)
+  store.remove(owner, first.id); expect(store.list(owner)).toEqual([])
+})
+it('verifies encrypted backups, rejects owner changes and restores without retaining unlock', async () => {
+  const { store } = await fixture(); store.initialize(owner, password); store.unlock(owner, password)
+  const entry = store.add(owner, input), archive = store.backup(owner), { store: destination } = await fixture()
+  expect(archive).not.toContain(secret)
+  expect(() => destination.restore('another-owner', archive, password)).toThrowError(expect.objectContaining({ code: 'vault_backup_owner' }))
+  destination.restore(owner, archive, password); expect(destination.state(owner).unlocked).toBe(false)
+  destination.unlock(owner, password); expect(destination.withSecret(owner, entry.id, e => e.secret)).toBe(secret)
+})
+it('locks on a fixed deadline and preserves the old decryptable file if rotation is interrupted before rename', async () => {
+  const { store, home } = await fixture(); store.initialize(owner, password)
+  let now = 1000, fail = false
+  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)
+})
+it('encrypts fixed SFTP contents, never returns them, retains a blank edit and revokes policy changes',async()=>{
+  const {store,home}=await fixture();store.initialize(owner,password);store.unlock(owner,password)
+  const ssh={name:'dummy',username:'dummy',secret:'DUMMY-key',target:{kind:'ssh',host:'127.0.0.1',port:22,hostKey:'SHA256:'+'A'.repeat(43)}}
+  const body='DUMMY-fixed-private-file-body',e=store.add(owner,{...ssh,usage:{kind:'sftp.write',remotePath:'/approved/new.txt',contents:body}})
+  expect(e.usage).toEqual({kind:'sftp.write',remotePath:'/approved/new.txt',bytes:Buffer.byteLength(body)})
+  expect(JSON.stringify(store.list(owner))).not.toContain(body);expect(store.backup(owner)).not.toContain(body)
+  let revoked=0;store.onLock=()=>revoked++
+  const edit=store.update(owner,e.id,{...ssh,secret:undefined,revision:1,usage:{kind:'sftp.write',remotePath:'/approved/other.txt'}})
+  expect(edit.usage).toMatchObject({bytes:Buffer.byteLength(body)});expect(store.withSecret(owner,e.id,x=>x.usage)).toMatchObject({contents:body});expect(revoked).toBe(1)
+  for(const value of [{kind:'ssh.exec',command:'/bin/sh -c id'},{kind:'sftp.read',remotePath:'/approved/../outside',maxBytes:10}])expect(()=>store.update(owner,e.id,{...ssh,revision:2,usage:value})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
+  expect(readFileSync(join(home,readdirSync(home).find(v=>v.endsWith('.vault'))!),'utf8')).not.toContain(body)
+})

+ 248 - 2
tests/server/openVikingSessionSync.test.ts

@@ -14,6 +14,7 @@ import type { WorkspaceAgent, WorkspaceConversation, WorkspaceMessage } from '..
 let home: string, store: WorkspaceStore, service: OpenVikingService, sync: OpenVikingSessionSync, agent: WorkspaceAgent, conversation: WorkspaceConversation
 const remote = new Map<string, any[]>(), clients: any[] = []
 let failAfterWrite = false, offline = false, autoCommit = false
+let partialWrite: number | undefined, silentPartial = false
 const notFound = () => new OpenVikingError('not found', { code: 'NOT_FOUND', statusCode: 404 })
 const config = { enabled: true, url: 'http://localhost:1933', accountId: 'test', adminKey: 'admin-secret' }
 beforeEach(async () => {
@@ -28,7 +29,7 @@ beforeEach(async () => {
       createSession: vi.fn(async ({ sessionId }: any) => { if (!remote.has(prefix + sessionId)) remote.set(prefix + sessionId, []); return { session_id: sessionId } }),
       getSession: vi.fn(async (id: string) => { if (offline) throw new Error('offline'); if (!remote.has(prefix + id)) throw notFound(); return { uri: id, message_count: remote.get(prefix + id)!.length, auto_commit_policy: autoCommit ? { message_count_threshold: 1 } : null } }),
       read: vi.fn(async (uri: string) => remote.get(prefix + uri.replace('/messages.jsonl', ''))!.map(row => JSON.stringify({ role: row.role, parts: [{ type: 'text', text: row.content }], created_at: row.createdAt })).join('\n')),
-      batchAddMessages: vi.fn(async (id: string, messages: any[]) => { remote.get(prefix + id)!.push(...messages); if (failAfterWrite) { failAfterWrite = false; throw new Error('response lost') } return {} }),
+      batchAddMessages: vi.fn(async (id: string, messages: any[]) => { remote.get(prefix + id)!.push(...messages.slice(0, partialWrite ?? messages.length)); if (partialWrite !== undefined && !silentPartial) { partialWrite = undefined; throw new OpenVikingError('partial write', { code: 'INTERNAL', statusCode: 500 }) } partialWrite = undefined; if (failAfterWrite) { failAfterWrite = false; throw new Error('response lost') } return {} }),
       deleteSession: vi.fn(async (id: string) => { remote.delete(prefix + id) }),
       commitSession: vi.fn(),
     }
@@ -39,7 +40,7 @@ beforeEach(async () => {
   conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
   sync = new OpenVikingSessionSync(store, service)
 })
-afterEach(() => { sync.close(); store.close(); rmSync(home, { recursive: true, force: true }); remote.clear(); clients.length = 0; offline = false; failAfterWrite = false; autoCommit = false; vi.restoreAllMocks() })
+afterEach(() => { sync.close(); store.close(); rmSync(home, { recursive: true, force: true }); remote.clear(); clients.length = 0; offline = false; failAfterWrite = false; autoCommit = false; partialWrite = undefined; silentPartial = false; vi.restoreAllMocks() })
 
 function message(role: 'user' | 'assistant' | 'system', content: string, fields: Partial<WorkspaceMessage> = {}) {
   const value: WorkspaceMessage = { id: randomUUID(), conversationId: conversation.id, seq: 0, role, ...(role === 'assistant' ? { agentId: agent.id } : {}), content, reasoning: 'private thought', status: 'complete', attachments: [], tools: [{ secret: 'private tool' }], createdAt: Date.now(), ...fields }
@@ -185,3 +186,248 @@ it('mirrors Bot communication bodies with sender and receiver roles, without int
   await drain()
   expect([...remote.values()].flat().map(row => [row.role, row.content]).sort()).toEqual([['assistant', 'collaboration body'], ['user', 'collaboration body']])
 })
+
+
+it('catches messages from another client while an append is in flight', async () => {
+  message('user', 'client A')
+  await drain()
+  message('user', 'client B')
+  const client = clients.find(row => row.batchAddMessages.mock.calls.length)!
+  const append = client.batchAddMessages.getMockImplementation()!
+  client.batchAddMessages.mockImplementationOnce(async (id: string, batch: any[]) => {
+    const result = await append(id, batch)
+    message('user', 'client A while B appends')
+    return result
+  })
+  await drain()
+  expect(texts()).toEqual(['client A', 'client B', 'client A while B appends'])
+  expect(sync.status()).toMatchObject({ complete: 1, pending: 0, failed: 0 })
+})
+
+it('serializes overlapping ticks from different sync instances sharing the same database', async () => {
+  message('user', 'one copy')
+  const other = new OpenVikingSessionSync(store, service)
+  try {
+    await Promise.all([sync.tick(), other.tick()])
+    await drain()
+    expect(texts()).toEqual(['one copy'])
+    expect(sync.status().complete).toBe(1)
+  } finally { other.close() }
+})
+
+it('recovers a partial failed append after restart without repeating the accepted prefix', async () => {
+  message('user', 'first'); message('assistant', 'second'); message('user', 'third')
+  partialWrite = 1
+  await sync.tick()
+  expect(texts()).toEqual(['first'])
+  sync.close(); sync = new OpenVikingSessionSync(store, service)
+  await drain()
+  expect(texts()).toEqual(['first', 'second', 'third'])
+  expect(sync.status()).toMatchObject({ complete: 1, failed: 0 })
+})
+
+it('verifies writes even when a server acknowledges a partial batch as successful', async () => {
+  message('user', 'first'); message('assistant', 'second'); message('user', 'third')
+  partialWrite = 1; silentPartial = true
+  await drain()
+  expect(texts()).toEqual(['first', 'second', 'third'])
+  expect(sync.status()).toMatchObject({ complete: 1, failed: 0 })
+})
+
+it('detects a local revision received during the remote read before appending a suffix', async () => {
+  const original = message('user', 'original'); await drain()
+  message('assistant', 'must not append')
+  const client = clients.find(row => row.batchAddMessages.mock.calls.length)!
+  const read = client.read.getMockImplementation()!
+  client.read.mockImplementationOnce(async (uri: string) => {
+    const raw = await read(uri)
+    store.saveMessage('owner', { ...original, content: 'changed on another client' })
+    return raw
+  })
+  await drain()
+  expect(texts()).toEqual(['original'])
+  expect(sync.status()).toMatchObject({ failed: 1 })
+  expect(sync.status().lastError).toContain('已停止追加')
+})
+
+it('recovers a legacy failed job only after verifying the remote prefix on startup', async () => {
+  message('user', 'prefix'); await drain()
+  message('assistant', 'suffix')
+  store.db.prepare("UPDATE openviking_sync_jobs SET status='failed',attempts=3,error=?").run('OpenViking 会话记录与本地顺序不一致,已停止追加,请核对会话')
+  sync.close(); sync = new OpenVikingSessionSync(store, service)
+  await drain()
+  expect(texts()).toEqual(['prefix', 'suffix'])
+  expect(sync.status()).toMatchObject({ failed: 0, complete: 1 })
+})
+
+it.each(['reordered', 'changed', 'extra'])('keeps a real %s remote conflict closed across restart and explicit retry', async kind => {
+  message('user', 'first', { createdAt: 1000 }); message('assistant', 'second', { createdAt: 2000 })
+  await drain()
+  const rows = [...remote.values()][0]!
+  if (kind === 'reordered') rows.reverse()
+  if (kind === 'changed') rows[0] = { ...rows[0], content: 'foreign revision' }
+  if (kind === 'extra') rows.push({ role: 'user', content: 'foreign message', createdAt: new Date(3000).toISOString() })
+  const before = JSON.stringify(rows)
+  message('user', 'new suffix', { createdAt: 4000 })
+  await drain()
+  expect(sync.status().failed).toBe(1)
+  sync.close(); sync = new OpenVikingSessionSync(store, service); await drain()
+  sync.retry(); await drain()
+  expect(JSON.stringify(rows)).toBe(before)
+  expect(sync.status().failed).toBe(1)
+  expect(clients.every(client => client.deleteSession.mock.calls.length === 0)).toBe(true)
+})
+
+it('does not deduplicate distinct local messages with identical text and timestamps', async () => {
+  message('user', 'repeat', { createdAt: 1000 }); message('user', 'repeat', { createdAt: 1000 })
+  failAfterWrite = true; await drain()
+  expect(texts()).toEqual(['repeat', 'repeat'])
+  expect(sync.status().complete).toBe(1)
+})
+
+it('uses local sequence rather than client clocks and keeps it stable after reconnect', async () => {
+  const first = message('user', 'client A first', { createdAt: 2000 })
+  const second = message('assistant', 'client B second', { createdAt: 1000 })
+  await drain()
+  store.saveMessage('owner', { ...first, seq: 11 }); store.saveMessage('owner', { ...second, seq: 12 })
+  sync.close(); sync = new OpenVikingSessionSync(store, service); await drain()
+  expect(texts()).toEqual(['client A first', 'client B second'])
+  expect(sync.status().complete).toBe(1)
+})
+
+it('rejects remote history loss instead of appending an already acknowledged transcript again', async () => {
+  message('user', 'acknowledged'); await drain()
+  const rows = [...remote.values()][0]!
+  rows.length = 0
+  message('user', 'later'); await drain()
+  expect(rows).toEqual([])
+  expect(sync.status().failed).toBe(1)
+})
+
+
+it('serializes independent database connections without holding a transaction during network waits', async () => {
+  message('user', 'only once')
+  const otherStore = new WorkspaceStore(home)
+  const other = new OpenVikingSessionSync(otherStore, service)
+  try {
+    await Promise.all([sync.tick(), other.tick()])
+    await drain()
+    expect(texts()).toEqual(['only once'])
+    expect(store.db.prepare('SELECT COUNT(*) AS n FROM openviking_sync_locks').get()!.n).toBe(0)
+  } finally { other.close(); otherStore.close() }
+})
+
+it('does not steal a live writer lock and recovers a crashed writer by validating its remote prefix', async () => {
+  message('user', 'before crash'); await drain()
+  message('user', 'after crash')
+  const job = store.db.prepare('SELECT id FROM openviking_sync_jobs').get()!
+  store.db.prepare('INSERT INTO openviking_sync_locks VALUES(?,?,?)').run(job.id, 'synthetic-writer', process.pid)
+  await sync.tick()
+  expect(texts()).toEqual(['before crash'])
+  const kill = vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('synthetic dead process'), { code: 'ESRCH' }) })
+  await drain()
+  expect(texts()).toEqual(['before crash', 'after crash'])
+  expect(kill.mock.calls.every(([, signal]) => signal === 0)).toBe(true)
+  expect(store.db.prepare('SELECT COUNT(*) AS n FROM openviking_sync_locks').get()!.n).toBe(0)
+})
+
+it('releases the writer lock after a lost response and after a configuration pause', async () => {
+  message('user', 'prefix'); failAfterWrite = true
+  await sync.tick()
+  expect(store.db.prepare('SELECT COUNT(*) AS n FROM openviking_sync_locks').get()!.n).toBe(0)
+  await drain()
+  const client = clients.find(row => row.batchAddMessages.mock.calls.length)!
+  const append = client.batchAddMessages.getMockImplementation()!
+  client.batchAddMessages.mockImplementationOnce(async (id: string, batch: any[]) => {
+    const result = await append(id, batch)
+    service.configure(undefined)
+    return result
+  })
+  message('user', 'pause'); await sync.tick()
+  expect(store.db.prepare('SELECT COUNT(*) AS n FROM openviking_sync_locks').get()!.n).toBe(0)
+  service.configure(config); await drain()
+  expect(texts()).toEqual(['prefix', 'pause'])
+  expect(sync.status()).toMatchObject({ complete: 1, failed: 0 })
+})
+
+it('does not recreate a missing session containing previously confirmed messages', async () => {
+  message('user', 'confirmed'); await drain()
+  remote.clear()
+  message('user', 'later'); await drain()
+  expect(remote.size).toBe(0)
+  expect(sync.status().failed).toBe(1)
+  expect(clients.flatMap(client => client.createSession.mock.calls)).toHaveLength(1)
+})
+
+it.each(['foreign part', 'bad JSON', 'bad date'])('rejects %s without leaking transcript contents or writing a suffix', async kind => {
+  message('user', 'prefix'); await drain()
+  const client = clients.find(row => row.batchAddMessages.mock.calls.length)!
+  client.read.mockImplementation(async () => {
+    if (kind === 'bad JSON') return 'private contents: invalid JSON'
+    return JSON.stringify({ role: 'user', created_at: kind === 'bad date' ? 'bad date' : new Date(1000).toISOString(), parts: [{ type: kind === 'bad date' ? 'text' : 'tool', text: 'private contents' }] })
+  })
+  message('user', 'suffix'); await drain()
+  expect(texts()).toEqual(['prefix'])
+  expect(sync.status().failed).toBe(1)
+  expect(sync.status().lastError).not.toContain('private contents')
+})
+
+it('normalizes equivalent timestamp representations without ignoring timestamp differences', async () => {
+  message('user', 'prefix', { createdAt: 1000 }); await drain()
+  const client = clients.find(row => row.batchAddMessages.mock.calls.length)!
+  const read = client.read.getMockImplementation()!
+  client.read.mockImplementation(async (uri: string) => (await read(uri)).replace('1970-01-01T00:00:01.000Z', '1970-01-01T08:00:01+08:00'))
+  message('user', 'suffix', { createdAt: 2000 }); await drain()
+  expect(texts()).toEqual(['prefix', 'suffix'])
+  expect(sync.status().complete).toBe(1)
+  client.read.mockImplementation(async (uri: string) => (await read(uri)).replace('1970-01-01T00:00:01.000Z', '1970-01-01T00:00:02.000Z'))
+  message('user', 'must stop', { createdAt: 3000 }); await drain()
+  expect(texts()).toEqual(['prefix', 'suffix'])
+  expect(sync.status().failed).toBe(1)
+})
+
+it('backs off an acknowledged append that stores no messages instead of falsely completing', async () => {
+  message('user', 'pending')
+  partialWrite = 0; silentPartial = true
+  await sync.tick()
+  expect(texts()).toEqual([])
+  expect(sync.status()).toMatchObject({ pending: 1, complete: 0 })
+  expect(store.db.prepare('SELECT synced_messages FROM openviking_sync_jobs').get()!.synced_messages).toBe(0)
+  await drain()
+  expect(texts()).toEqual(['pending'])
+})
+
+
+it('does not replay a timed-out suffix while the original remote write may still finish', async () => {
+  message('user', 'prefix'); await drain()
+  const client = clients.find(row => row.batchAddMessages.mock.calls.length)!
+  const append = client.batchAddMessages.getMockImplementation()!
+  let finish: (() => Promise<unknown>) | undefined
+  client.batchAddMessages.mockImplementationOnce(async (id: string, batch: any[]) => {
+    finish = () => append(id, batch)
+    throw new OpenVikingError('timeout', { code: 'DEADLINE_EXCEEDED' })
+  })
+  message('user', 'uncertain suffix'); await drain()
+  expect(texts()).toEqual(['prefix'])
+  expect(sync.status().failed).toBe(1)
+  expect(sync.status().lastError).toContain('上次追加结果尚未确认')
+  expect(store.db.prepare('SELECT COUNT(*) AS n FROM openviking_sync_appends').get()!.n).toBe(1)
+  sync.close(); sync = new OpenVikingSessionSync(store, service); sync.retry(); await drain()
+  expect(texts()).toEqual(['prefix'])
+  await finish!()
+  sync.retry(); await drain()
+  expect(texts()).toEqual(['prefix', 'uncertain suffix'])
+  expect(sync.status().complete).toBe(1)
+  expect(store.db.prepare('SELECT COUNT(*) AS n FROM openviking_sync_appends').get()!.n).toBe(0)
+})
+
+it('preserves an uncertain append across a crash and refuses to steal its unfinished remote suffix', async () => {
+  message('user', 'prefix'); await drain()
+  const client = clients.find(row => row.batchAddMessages.mock.calls.length)!
+  client.batchAddMessages.mockImplementationOnce(async () => { throw new Error('connection lost before confirmation') })
+  message('user', 'suffix'); await sync.tick()
+  sync.close(); sync = new OpenVikingSessionSync(store, service); await drain()
+  expect(texts()).toEqual(['prefix'])
+  expect(sync.status().failed).toBe(1)
+  expect(client.batchAddMessages.mock.calls).toHaveLength(2)
+})

+ 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'}))
+})

+ 8 - 0
tests/server/subaccountRegistration.test.ts

@@ -69,6 +69,14 @@ describe('subaccount registration and approval through real middleware', () => {
     expect(snapshot.agents.map((a: any) => a.id)).toEqual([create.body.agent.id])
     expect(snapshot.conversations).toHaveLength(1)
     expect((await call(admin, 'get', '/api/app/workspace/snapshot')).body.agents).toEqual([])
+    // The shared unread centre is owner-scoped, including Bot-only accounts.
+    expect((await call(child, 'get', '/api/app/unread').expect(200)).body.total).toBe(0)
+    await call(child, 'head', '/api/app/unread').expect(200)
+    await call(child, 'post', '/api/app/unread/read').send({ items: [] }).expect(403)
+    await call(child, 'post', '/api/app/unread/read', login.body.csrfToken).send({ items: [] }).expect(200)
+    const adminAgent = (await call(admin, 'post', '/api/app/agents', csrf).send({ name: '管理员 Bot', profile: 'allowed' }).expect(201)).body.agent
+    const adminConversation = (await call(admin, 'get', '/api/app/conversations').expect(200)).body.conversations.find((c: any) => c.memberIds.includes(adminAgent.id))
+    await call(child, 'post', '/api/app/unread/read', login.body.csrfToken).send({ items: [{ mode: 'bot', id: adminConversation.id, messages: [] }] }).expect(404)
     await call(child, 'get', '/api/app/workspace/projects').expect(200)
     await call(child, 'get', '/api/app/workspace/collaboration').expect(200)
     await call(child, 'get', '/api/app/workspace/memory-jobs').expect(200)

+ 32 - 0
tests/server/unreadCenter.test.ts

@@ -0,0 +1,32 @@
+// @vitest-environment node
+import { afterEach, expect, it, vi } from 'vitest'
+import { mkdtempSync,rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { WorkspaceStore } from '../../src/server/workspaceStore'
+import { ChatCacheStore,ChatCacheCoordinator } from '../../src/server/chatCache'
+import { unreadSnapshot,acknowledgeUnread } from '../../src/server/unreadCenter'
+const homes:string[]=[];const stores:Array<{close():void}>=[]
+afterEach(()=>{stores.reverse().forEach(s=>s.close());stores.length=0;homes.splice(0).forEach(h=>rmSync(h,{recursive:true,force:true}))})
+it('shares exact snapshots between devices, isolates owners/profiles, and excludes task duplication and process rows',()=>{
+ const home=mkdtempSync(join(tmpdir(),'unread-integration-'));homes.push(home)
+ const workspace=new WorkspaceStore(home),cache=new ChatCacheStore(home),coordinator=new ChatCacheCoordinator(cache,{request:vi.fn()} as any);stores.push(workspace,cache,coordinator)
+ const a=workspace.createAgent('a',{name:'bot',profile:'p'}),b=workspace.createAgent('a',{name:'two',profile:'p'}),c=workspace.createGroup('a',{name:'group',memberIds:[a.id,b.id],administratorId:a.id}),task=workspace.tasks('a',c.id)[0]!
+ workspace.saveMessage('a',{id:'bot-final',conversationId:c.id,conversationTaskId:task.id,seq:0,role:'assistant',status:'complete',content:'附件答复',reasoning:'',attachments:[],tools:[],createdAt:1})
+ for(const profile of ['p','other']){
+  cache.recordCommand('a',profile,'s','session.create',{})
+  coordinator.observe('a',profile,'s',{type:'message.interim',payload:{text:'检查中'},delivery_id:'progress'})
+  coordinator.observe('a',profile,'s',{type:'message.complete',payload:{text:'答复'},delivery_id:'final'})
+  coordinator.observe('a',profile,'s',{type:'message.complete',payload:{text:'答复'},delivery_id:'final'})
+ }
+ const device1=unreadSnapshot(workspace,cache,'a'),device2=unreadSnapshot(workspace,cache,'a')
+ expect(device1).toMatchObject({total:3,bot:1,chat:2});expect(device2).toEqual(device1)
+ expect(unreadSnapshot(workspace,cache,'b').total).toBe(0)
+ expect(unreadSnapshot(workspace,cache,'a',(_,p)=>p==='p').total).toBe(2)
+ coordinator.observe('a','p','s',{type:'message.start',payload:{},delivery_id:'new-start'})
+ coordinator.observe('a','p','s',{type:'message.complete',payload:{text:'随后新消息'},delivery_id:'new-final'})
+ acknowledgeUnread(workspace,cache,'a',device1.conversations)
+ acknowledgeUnread(workspace,cache,'a',device2.conversations)
+ expect(unreadSnapshot(workspace,cache,'a')).toMatchObject({total:1,chat:1,bot:0})
+ expect(workspace.require<any>('a','conversation-task',task.id).unreadCount).toBe(0)
+})

+ 17 - 0
tests/server/uploadNames.test.ts

@@ -0,0 +1,17 @@
+// @vitest-environment node
+import { expect, it } from 'vitest'
+import { normalizeUploadName } from '../../src/shared/uploadNames'
+
+it.each([
+  '人工智能合同综合质量评估系统V2.0-信息采集表-待确认.docx',
+  'café-附件-📄.txt',
+])('recovers a legacy Latin-1 decoding of %s exactly once', name => {
+  const corrupted = Buffer.from(name, 'utf8').toString('latin1')
+  expect(normalizeUploadName(corrupted)).toBe(name)
+  expect(normalizeUploadName(normalizeUploadName(corrupted))).toBe(name)
+})
+
+it.each(['report.txt', '中文附件.docx', 'café.txt', 'ÿ.txt', '坏数据-äº.txt', 'äº.txt'])
+  ('preserves names that cannot be safely recovered: %s', name => {
+    expect(normalizeUploadName(name)).toBe(name)
+  })

+ 2 - 2
tests/server/workspaceMessagePatch.test.ts

@@ -154,7 +154,7 @@ it('persists card-only messages and replays installation updates as full frames
   store.saveMessage('owner',message)
   store.close();store=new WorkspaceStore(home,{messagePatches:true})
   expect(store.require<WorkspaceMessage>('owner','message',message.id)).toMatchObject({visible:true,status:'complete',content:'',browserCard:{status:'closed',title:'测试网页'}})
-  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(true)
+  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(false)
 })
 
 it('persists nonfatal service warnings independently of reply text and keeps terminal warning-only replies visible',()=>{
@@ -165,5 +165,5 @@ it('persists nonfatal service warnings independently of reply text and keeps ter
   expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).preview).toBe('部分服务暂时不可用')
   message.status='complete';store.saveMessage('owner',message)
   expect(store.require<WorkspaceMessage>('owner','message',message.id)).toMatchObject({visible:true,content:'',status:'complete',serviceWarnings:message.serviceWarnings})
-  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(true)
+  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(false)
 })

+ 45 - 0
tests/server/workspaceRoutes.test.ts

@@ -4,12 +4,14 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { randomUUID } from 'node:crypto'
+import { DatabaseSync } from 'node:sqlite'
 import request from 'supertest'
 import type Koa from 'koa'
 import { createApplication, type ApplicationRuntime } from '../../src/server/app'
 import { loadServerConfig } from '../../src/server/config'
 import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
 import { WorkspaceAssets } from '../../src/server/workspaceAssets'
+import type { StoredWorkspaceFile } from '../../src/server/workspaceAssets'
 import { saveFileAccess } from '../../src/server/fileAccess'
 import { WorkspaceTranscriptStore } from '../../src/client/components/workspace/transcriptStore'
 import { SharedComputers, type SharedComputer } from '../../src/server/sharedComputers'
@@ -657,6 +659,49 @@ describe('application workspace HTTP contract', () => {
     expect(response.headers['content-type']).toContain('application/octet-stream')
     expect(response.headers['content-disposition']).toContain('attachment')
   })
+  it.each([
+    '人工智能合同综合质量评估系统V2.0-信息采集表-待确认.docx',
+    'café-附件-📄.txt',
+  ])('preserves the UTF-8 upload name %s in metadata, library search and downloads', async name => {
+    const file = (await req('post', '/api/app/uploads')
+      .attach('files', Buffer.from('file contents'), { filename: name, contentType: 'application/octet-stream' })
+      .expect(201)).body.files[0]
+    expect(file.name).toBe(name)
+    expect(runtime.uploads.records([file.id], 'first')[0]?.name).toBe(name)
+    const library = await req('get', `/api/app/files?search=${encodeURIComponent(name)}`).expect(200)
+    expect(library.body.items[0]?.name).toBe(name)
+    const download = await req('get', `/api/app/files/${file.id}/download`).expect(200)
+    expect(download.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''${encodeURIComponent(name)}`)
+  })
+  it('recovers legacy upload names in history, events, search and downloads without changing stored files', async () => {
+    const name = '人工智能合同综合质量评估系统V2.0-信息采集表-待确认.docx'
+    const corrupted = Buffer.from(name, 'utf8').toString('latin1')
+    const uploaded = (await req('post', '/api/app/uploads')
+      .attach('files', Buffer.from('preserved file contents'), { filename: 'legacy.docx', contentType: 'application/octet-stream' })
+      .expect(201)).body.files[0]
+    const database = new DatabaseSync(join(home, 'uploads.sqlite3'))
+    try { database.prepare('UPDATE uploads SET name=? WHERE id=?').run(corrupted, uploaded.id) }
+    finally { database.close() }
+    const file = { ...runtime.workspace.require<StoredWorkspaceFile>('first', 'file', uploaded.id), name: corrupted }
+    runtime.workspace.put('first', 'file', file.id, file)
+    const agent = runtime.workspace.createAgent('first', { name: '附件测试', profile: 'default' })
+    const conversation = runtime.workspace.list<WorkspaceConversation>('first', 'conversation').find(c => c.memberIds[0] === agent.id)!
+    runtime.workspace.saveMessage('first', {
+      id: 'legacy-upload', conversationId: conversation.id, seq: 0, role: 'user', content: '请检查附件',
+      reasoning: '', tools: [], attachments: [file], status: 'complete', createdAt: 1,
+    })
+    expect(runtime.uploads.records([file.id], 'first')[0]?.name).toBe(name)
+    const history = (await req('get', `/api/app/conversations/${conversation.id}`).expect(200)).body
+    expect(history.messages[0]?.attachments[0]?.name).toBe(name)
+    const event = runtime.workspace.events('first', 0).find(event => event.type === 'message.changed' && (event.data as { id: string }).id === 'legacy-upload')!
+    expect((event.data as { attachments: { name: string }[] }).attachments[0]?.name).toBe(name)
+    const library = await req('get', `/api/app/files?search=${encodeURIComponent('信息采集表')}`).expect(200)
+    expect(library.body.items[0]?.name).toBe(name)
+    const download = await req('get', `/api/app/files/${file.id}/download`).expect(200)
+    expect(download.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''${encodeURIComponent(name)}`)
+    expect(readFileSync(file.path, 'utf8')).toBe('preserved file contents')
+    expect(runtime.workspace.require<StoredWorkspaceFile>('first', 'file', file.id).name).toBe(corrupted)
+  })
   it('persists encrypted voice credentials while giving each user their own selected voice', async () => {
     await req('put', '/api/app/admin/duplex-voice')
       .send({

+ 28 - 5
tests/server/workspaceUnread.test.ts

@@ -24,9 +24,9 @@ beforeEach(() => {
 })
 afterEach(() => { vi.restoreAllMocks(); store.close(); rmSync(home, { recursive: true, force: true }) })
 
-it('stores one unread flag for any number of completed replies and persists read state', () => {
+it('counts each completed reply and persists read state', () => {
   for (let i = 0; i < 12; i++) store.saveMessage(owner, message())
-  expect(current(direct.id)).toMatchObject({ unread: true, unreadCount: 1 })
+  expect(current(direct.id)).toMatchObject({ unread: true, unreadCount: 12 })
   const display = vi.spyOn(store, 'messageForDisplay')
   expect(store.conversationSummary(owner, current(direct.id)).unread).toBe(true)
   store.markConversationRead(owner, direct.id, current(direct.id).lastSeq, current(direct.id).unreadVersion)
@@ -71,7 +71,7 @@ it('does not let a stale task read clear another completion and recomputes flags
   const first = store.tasks(owner, group.id)[0]!
   store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
   const snapshot = task(first.id)
-  store.saveMessage(owner, message(group.id, { conversationTaskId: first.id, status: 'failed', error: '执行失败' }))
+  store.saveMessage(owner, message(group.id, { conversationTaskId: first.id, status: 'complete', content: '新消息' }))
   store.markTaskRead(owner, group.id, first.id, snapshot.lastSeq, snapshot.unreadVersion)
   expect(task(first.id).unread).toBe(true)
   expect(() => store.markTaskRead('another-owner', group.id, first.id, 100, 1)).toThrow()
@@ -86,11 +86,12 @@ it('migrates legacy counts once without loading message bodies and does not resu
   store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
   store.db.exec("UPDATE workspace_entities SET data=json_remove(data,'$.unread','$.unreadVersion') WHERE kind IN ('conversation','conversation-task')")
   store.db.prepare('DELETE FROM workspace_migrations WHERE id=?').run('workspace-unread-flag-v1')
+  store.db.prepare('DELETE FROM workspace_migrations WHERE id=?').run('workspace-chat-unread-v2')
   store.close(); store = new WorkspaceStore(home)
-  expect(current(direct.id)).toMatchObject({ unread: true, unreadVersion: 1, unreadCount: 1 })
+  expect(current(direct.id)).toMatchObject({ unread: true, unreadCount: 1 })
   expect(current(group.id).unread).toBe(true)
   expect(task(first.id).unread).toBe(true)
-  store.markConversationRead(owner, direct.id, current(direct.id).lastSeq, 1)
+  store.markConversationRead(owner, direct.id, current(direct.id).lastSeq, current(direct.id).unreadVersion)
   store.close(); store = new WorkspaceStore(home)
   expect(current(direct.id).unread).toBe(false)
 })
@@ -108,3 +109,25 @@ it('reads only hidden IDs while preserving owner and task boundaries', () => {
   const plan = store.db.prepare("EXPLAIN QUERY PLAN SELECT id FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.visible')=0 AND json_extract(data,'$.conversationTaskId')=?").all(owner, group.id, first.id)
   expect(plan.some(row => String(row.detail).includes('workspace_message_hidden'))).toBe(true)
 })
+
+it('snapshot IDs exclude future completions and keep gaps, retries and restart idempotent', () => {
+  const a=message(), b=message(), streaming=message(direct.id,{status:'streaming'})
+  for(const m of [a,b,streaming])store.saveMessage(owner,m)
+  const snapshot=store.chatUnreadMessages(owner,direct.id).map(m=>({id:m.id,version:m.chatUnreadVersion}))
+  streaming.status='complete';store.saveMessage(owner,streaming)
+  store.markChatMessagesRead(owner,direct.id,[snapshot[1]!])
+  expect(current(direct.id).unreadCount).toBe(2)
+  store.markChatMessagesRead(owner,direct.id,snapshot)
+  store.markChatMessagesRead(owner,direct.id,snapshot)
+  expect(store.chatUnreadMessages(owner,direct.id).map(m=>m.id)).toEqual([streaming.id])
+  store.close();store=new WorkspaceStore(home)
+  expect(current(direct.id).unreadCount).toBe(1)
+  store.saveMessage(owner,a)
+  expect(current(direct.id).unreadCount).toBe(1)
+})
+it('excludes failure, interruption, reasoning and system; counts attachments once',()=>{
+  for(const patch of [{status:'failed',error:'失败'},{status:'interrupted'},{role:'system'},{content:'<think>过程</think>'}])store.saveMessage(owner,message(direct.id,patch as Partial<WorkspaceMessage>))
+  const attachment=message(direct.id,{content:'',attachments:[{id:'f',name:'结果.pdf',size:1,mime:'application/pdf'} as any]})
+  store.saveMessage(owner,attachment);store.saveMessage(owner,attachment)
+  expect(current(direct.id).unreadCount).toBe(1)
+})

+ 116 - 2
tests/serviceCli.test.ts

@@ -1,9 +1,123 @@
-import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { homedir, tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { bootstrapLaunchAgent, isMainModule, launchAgentPlist } from '../bin/hermes-yaoyao.mjs'
 
+describe('npm-installed CLI commands', () => {
+  const label = 'com.samien.hermes-yaoyao'
+  const domain = `gui/${process.getuid?.() ?? 501}`
+  const entry = join(process.cwd(), 'bin', 'hermes-yaoyao.mjs')
+  const { version } = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
+  let root: string
+  let plist: string
+
+  beforeEach(() => {
+    root = mkdtempSync(join(tmpdir(), 'yaoyao-npm-cli-'))
+    const bin = join(root, 'bin')
+    mkdirSync(bin)
+    const launchAgents = join(root, 'Library', 'LaunchAgents')
+    mkdirSync(launchAgents, { recursive: true })
+    plist = join(launchAgents, `${label}.plist`)
+    symlinkSync(entry, join(bin, 'yaoyao'))
+    writeFileSync(join(bin, 'launchctl'), `#!${process.execPath}
+import { appendFileSync } from 'node:fs'
+const args = process.argv.slice(2)
+appendFileSync(process.env.YAOYAO_CLI_TEST_LOG, JSON.stringify(args) + '\\n')
+if (args[0] === process.env.YAOYAO_CLI_TEST_FAIL) {
+  process.stderr.write('fixture launchctl failure\\n')
+  process.exit(1)
+}
+if (args[0] === 'print' && process.env.YAOYAO_CLI_TEST_LOADED !== '1') process.exit(1)
+`, { mode: 0o755 })
+  })
+
+  afterEach(() => { rmSync(root, { recursive: true, force: true }) })
+
+  function runCli(args: string[], env: NodeJS.ProcessEnv = {}) {
+    return spawnSync(process.execPath, [join(root, 'bin', 'yaoyao'), ...args], {
+      cwd: root,
+      encoding: 'utf8',
+      timeout: 5_000,
+      env: {
+        ...process.env,
+        HOME: root,
+        PATH: `${join(root, 'bin')}:${process.env.PATH}`,
+        YAOYAO_HOME: join(root, 'data'),
+        HERMES_YAOYAO_SERVICE_ROOT: join(root, 'other-runtime'),
+        YAOYAO_CLI_TEST_LOG: join(root, 'launchctl.log'),
+        ...env,
+      },
+    })
+  }
+
+  function calls(): string[][] {
+    const log = join(root, 'launchctl.log')
+    return existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(line => JSON.parse(line)) : []
+  }
+
+  it.each(['version', '--version', '-v'])('queries the package version through yaoyao: %s', command => {
+    const result = runCli([command])
+    expect(result.status).toBe(0)
+    expect(result.stdout).toBe(`${version}\n`)
+    expect(result.stderr).toBe('')
+    expect(calls()).toEqual([])
+    expect(existsSync(join(root, 'data'))).toBe(false)
+  })
+
+  it('restarts the installed service while preserving its configuration and data', () => {
+    writeFileSync(plist, 'saved service configuration')
+    mkdirSync(join(root, 'data'))
+    const data = join(root, 'data', 'keep.txt')
+    writeFileSync(data, 'saved user data')
+
+    const result = runCli(['service', 'restart'], { YAOYAO_CLI_TEST_LOADED: '1' })
+
+    expect(result.status).toBe(0)
+    expect(result.stdout).toBe(`已重启 ${label}\n`)
+    expect(calls()).toEqual([
+      ['print', `${domain}/${label}`],
+      ['bootout', `${domain}/${label}`],
+      ['bootstrap', domain, plist],
+    ])
+    expect(readFileSync(plist, 'utf8')).toBe('saved service configuration')
+    expect(readFileSync(data, 'utf8')).toBe('saved user data')
+  })
+
+  it('starts an installed service that was stopped', () => {
+    writeFileSync(plist, 'saved service configuration')
+
+    const result = runCli(['service', 'restart'])
+
+    expect(result.status).toBe(0)
+    expect(calls()).toEqual([['print', `${domain}/${label}`], ['bootstrap', domain, plist]])
+  })
+
+  it('explains how to install a missing service without invoking launchctl', () => {
+    const result = runCli(['service', 'restart'])
+
+    expect(result.status).toBe(1)
+    expect(result.stderr).toContain('请先运行 yaoyao service install')
+    expect(result.stdout).toBe('')
+    expect(calls()).toEqual([])
+  })
+
+  it.each(['bootout', 'bootstrap'])('reports a failed %s without reporting restart success', command => {
+    writeFileSync(plist, 'saved service configuration')
+
+    const result = runCli(['service', 'restart'], {
+      YAOYAO_CLI_TEST_LOADED: '1',
+      YAOYAO_CLI_TEST_FAIL: command,
+    })
+
+    expect(result.status).toBe(1)
+    expect(result.stdout).toBe('')
+    expect(result.stderr).toContain('fixture launchctl failure')
+    expect(calls().at(-1)?.[0]).toBe(command)
+  })
+})
+
 describe('hermes-yaoyao LaunchAgent plist', () => {
   it('includes the local Hermes CLI directory in PATH', () => {
     const plist = launchAgentPlist()

Some files were not shown because too many files changed in this diff