1
0

7 Sitoutukset 102ed078cf ... 242b2ccc8b

Tekijä SHA1 Viesti Päivämäärä
  Samien 242b2ccc8b 准备 v0.4.80 发布:未读铃铛、密码保险箱和聊天删除修复 1 kuukausi sitten
  Samien b2a0016162 修复 MCP 与应用板块供应商标识显示 1 kuukausi sitten
  Samien 1aa0b381ee 修复普通聊天删除失败并补齐取消确认 1 kuukausi sitten
  Samien daa393ae64 修复默认密码保险箱接入与本机 Dashboard 重启 1 kuukausi sitten
  Samien 60ec559674 修复月初用量统计测试的日期夹具 1 kuukausi sitten
  Samien 6b7b2daec6 更新源码版本号为 v0.4.79 1 kuukausi sitten
  Samien f4d18c64a0 将未读入口改为顶部铃铛并修复已读计数更新 1 kuukausi sitten
58 muutettua tiedostoa jossa 1485 lisäystä ja 119 poistoa
  1. 5 5
      README.md
  2. 4 0
      THIRD_PARTY_NOTICES.md
  3. 1 1
      docker.env.example
  4. 6 2
      docs/credential-vault.md
  5. 2 1
      docs/local-vm.md
  6. 48 0
      docs/releases/v0.4.80.md
  7. 2 2
      package-lock.json
  8. 1 1
      package.json
  9. 1 1
      playwright.config.ts
  10. 9 0
      playwright.credential-vault.config.ts
  11. 13 0
      public/provider-logos/airtable.svg
  12. 15 0
      public/provider-logos/discord.svg
  13. 10 0
      public/provider-logos/github.svg
  14. 1 0
      public/provider-logos/gmail.svg
  15. 89 0
      public/provider-logos/googlecalendar.svg
  16. 25 0
      public/provider-logos/googledrive.svg
  17. 5 0
      public/provider-logos/linear.svg
  18. 11 0
      public/provider-logos/notion.svg
  19. 76 0
      public/provider-logos/outlook.svg
  20. 22 0
      public/provider-logos/slack.svg
  21. 19 0
      public/provider-logos/trello.svg
  22. 128 0
      public/provider-logos/twitter.svg
  23. 3 3
      release.json
  24. 14 9
      src/client/components/app/CredentialVaultPanel.vue
  25. 1 1
      src/client/components/app/SettingsCenterDialog.vue
  26. 23 37
      src/client/components/app/WorkspaceShell.vue
  27. 27 0
      src/client/components/common/ProviderLogo.vue
  28. 4 2
      src/client/components/workspace/BotPluginsPanel.vue
  29. 10 6
      src/client/stores/unread.ts
  30. 52 5
      src/client/views/ChatView.vue
  31. 16 4
      src/client/views/ConversationsView.vue
  32. 2 2
      src/server/app.ts
  33. 3 2
      src/server/botPlugins/connectedApps.ts
  34. 2 1
      src/server/credentialVault/coordinator.ts
  35. 59 0
      src/server/credentialVault/localClient.ts
  36. 1 0
      src/server/credentialVault/transport.ts
  37. 45 0
      src/server/dashboardCommand.ts
  38. 100 24
      src/server/dashboardController.ts
  39. 4 0
      src/server/routes.ts
  40. 2 1
      src/server/security.ts
  41. 57 0
      src/shared/botProviderLogos.ts
  42. 1 0
      src/shared/credentialVault.ts
  43. 1 1
      src/shared/workspacePlugins.ts
  44. 20 0
      tests/client/botPluginsPanel.test.ts
  45. 61 3
      tests/client/conversationRecovery.test.ts
  46. 21 0
      tests/client/credentialVaultPanel.test.ts
  47. 37 0
      tests/client/providerLogo.test.ts
  48. 8 0
      tests/client/settingsCenterDialog.test.ts
  49. 78 0
      tests/client/unreadStore.test.ts
  50. 46 0
      tests/e2e/credential-vault.spec.ts
  51. 102 0
      tests/e2e/session-deletion.spec.ts
  52. 60 0
      tests/server/credentialVaultLocalClient.test.ts
  53. 19 3
      tests/server/credentialVaultRoutes.test.ts
  54. 78 0
      tests/server/dashboardController.test.ts
  55. 9 0
      tests/server/security.test.ts
  56. 1 0
      tests/server/sessionDeletion.test.ts
  57. 20 0
      tests/server/workspacePlugins.test.ts
  58. 5 2
      tests/server/workspaceRoutes.test.ts

+ 5 - 5
README.md

@@ -1,6 +1,6 @@
 # 夭夭 AI
 
-当前 Web 与 macOS 桌面发布版本:**v0.4.78**。桌面版提供 Apple Silicon(arm64)客户端正式 DMG;本次未发布完整版桌面安装包,Windows 与 Android 沿用旧版,详见 [发布说明](docs/releases/v0.4.78.md)。
+当前 Web 与 macOS 桌面发布版本:**v0.4.80**。桌面版提供 Apple Silicon(arm64)客户端正式 DMG;本次未发布完整版桌面安装包,Windows 与 Android 沿用旧版,详见 [发布说明](docs/releases/v0.4.80.md)。
 
 夭夭 AI 为网页、iOS 和安卓提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务。
 
@@ -68,11 +68,11 @@ yaoyao service install
 
 | 镜像 | 架构 |
 | --- | --- |
-| `samienluo/yaoyao:v0.4.78-amd` | AMD64 |
-| `samienluo/yaoyao:v0.4.78` | 自动匹配 AMD64 / ARM64 |
-| `samienluo/yaoyao:latest` | 最新稳定版通用镜像,当前与 `v0.4.78` 相同 |
+| `samienluo/yaoyao:v0.4.80-amd` | AMD64 |
+| `samienluo/yaoyao:v0.4.80` | 自动匹配 AMD64 / ARM64 |
+| `samienluo/yaoyao:latest` | 最新稳定版通用镜像,当前与 `v0.4.80` 相同 |
 
-准备好 Docker Engine/Compose 和容器可访问的 Hermes 9119 服务,在仓库目录将 `docker.env.example` 复制为 `docker.env`。配置上游地址,并设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.78`(或 `samienluo/yaoyao:latest`),即可使用远程镜像:
+准备好 Docker Engine/Compose 和容器可访问的 Hermes 9119 服务,在仓库目录将 `docker.env.example` 复制为 `docker.env`。配置上游地址,并设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.80`(或 `samienluo/yaoyao:latest`),即可使用远程镜像:
 
 ```sh
 docker compose --env-file docker.env pull web

+ 4 - 0
THIRD_PARTY_NOTICES.md

@@ -22,3 +22,7 @@ Avatar silhouette paths and original two-eye expressions from zhulin025/LaoA-Gro
 ## 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.
+
+## Application supplier logos
+
+`public/provider-logos/*.svg` contains the Gmail, Google Drive, Google Calendar, GitHub, Notion, Slack, Linear, Discord, Outlook, Trello, Airtable and X logos retrieved from `https://logos.composio.dev/api/{slug}` on 2026-10-02. These assets identify the corresponding integrations. Each brand's logo and trademark rights belong to its respective owner.

+ 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.78;仅 AMD64:samienluo/yaoyao:v0.4.78-amd。
+# 固定版本:samienluo/yaoyao:v0.4.80;仅 AMD64:samienluo/yaoyao:v0.4.80-amd。
 # 留空时使用本地构建名称 yaoyao:local。
 # YAOYAO_DESKTOP_IMAGE=hermes-yaoyao-desktop:0.4.0
 # Cursor Universal 兼容桌面的本地构建名称;基础镜像在 deploy/computer/Dockerfile.cursor 中固定。

+ 6 - 2
docs/credential-vault.md

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

+ 2 - 1
docs/local-vm.md

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

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

@@ -0,0 +1,48 @@
+# 夭夭 AI v0.4.80
+
+本次发布 Web、Docker 双架构镜像和 macOS Apple Silicon 纯客户端。full 桌面安装包不在本次发布范围;Windows 和 Android 沿用旧版附件。
+
+## 更新内容
+
+- 将未读入口调整为顶部铃铛,重新设计消息面板,改善跳转、标记已读及跨端计数更新。
+- 默认提供本机密码保险箱,可通过密码管理界面创建主密码、手动解锁并保存加密条目,无需预先配置独立密码库服务。
+- 明确本机保险箱与独立执行服务的能力边界;显式配置的独立服务连接失败时保留离线状态。
+- 修复普通聊天删除失败,增加取消和失败重试确认,避免重复删除请求。
+- 扩展服务端本机执行服务重启的识别与归属核验,兼容自定义服务和受支持的终端启动方式。
+- 修复 MCP 与应用板块的供应商标识显示,保留第三方图标许可与署名。
+
+## 升级说明
+
+- 升级前备份完整 `.yaoyao` 数据目录,建议先更新服务端,再更新客户端。
+- 独立部署的 Runner 建议随服务端一起更新;客户端安装成功不代表服务端或 Runner 已更新。
+- 本机密码保险箱默认锁定,数据加密保存在 `.yaoyao/credential-vault/`,只提供凭据存储与手动管理,不签发 Bot 任务授权或执行自动登录。
+- 本机保险箱使用服务端进程,不具备独立 OS 身份隔离保证。真实凭据的自动使用仍需按部署文档配置并验收独立密码库服务;已配置服务不可用时不会切换到本机保险箱。
+- macOS 安装包为纯客户端,需要连接已有服务器,不包含本机服务器或执行节点运行时。需要桌面内置本机服务的用户可继续使用历史 full 包。
+- macOS 客户端沿用历史应用标识,使用 Developer ID Application 正式签名和 Apple 公证,并提供完整自动更新附件。历史开发签名版请先手动安装正式 DMG。
+- Windows 和 Android 旧版附件继续保留。
+- 主项目采用 Apache-2.0,第三方组件保留各自许可与署名。
+
+## 下载
+
+- `Yaoyao-0.4.80-arm64.dmg`:macOS Apple Silicon 纯客户端安装包。
+- `Yaoyao-0.4.80-arm64.zip`、两种安装包对应的 `.blockmap` 和 `latest-mac.yml`:自动更新附件。
+- `Yaoyao-0.4.80-source.tar.gz`:Web/桌面源码和部署配置。
+- `SHA256SUMS.txt`:发布附件的 SHA-256 校验值。
+
+## Docker Hub
+
+| 镜像 | 架构 |
+| --- | --- |
+| `samienluo/yaoyao:v0.4.80-amd` | `linux/amd64` |
+| `samienluo/yaoyao:v0.4.80` | `linux/amd64`、`linux/arm64` |
+| `samienluo/yaoyao:latest` | 验证后更新为本次双架构镜像 |
+
+```sh
+docker pull samienluo/yaoyao:v0.4.80
+docker compose --env-file docker.env pull web
+docker compose --env-file docker.env up -d --no-build web
+```
+
+Compose 镜像配置设为 `samienluo/yaoyao:v0.4.80`。Docker 镜像上传到 Docker Hub;GitHub 提供客户端、源码和校验附件。旧版本标签与附件保留。
+
+附件摘要、镜像摘要和验证结果见 [GitHub 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.80)。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "@lsamien/yaoyao",
-  "version": "0.4.78",
+  "version": "0.4.80",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@lsamien/yaoyao",
-      "version": "0.4.78",
+      "version": "0.4.80",
       "license": "Apache-2.0",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@lsamien/yaoyao",
-  "version": "0.4.78",
+  "version": "0.4.80",
   "license": "Apache-2.0",
   "description": "夭夭 AI:聊天、多 Agent 团队、文件库与电脑工作空间",
   "type": "module",

+ 1 - 1
playwright.config.ts

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

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

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

+ 13 - 0
public/provider-logos/airtable.svg

@@ -0,0 +1,13 @@
+<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_0_309)">
+<path d="M57.1183 11.8136L9.51971 31.5412C6.88172 32.6882 6.88172 36.3584 9.51971 37.5054L57.3477 56.5448C61.5914 58.1505 66.1792 58.1505 70.4229 56.5448L118.366 37.5054C121.004 36.3584 121.118 32.6882 118.366 31.5412L70.767 11.8136C66.4086 9.97849 61.4767 9.97849 57.1183 11.8136Z" fill="#FCB400"/>
+<path d="M68.2437 66.8674V114.351C68.2437 116.645 70.5376 118.251 72.6022 117.333L126.05 96.6882C127.312 96.2294 128 95.0824 128 93.7061V46.2222C128 43.9283 125.706 42.3226 123.642 43.2401L70.1936 63.8853C68.9319 64.3441 68.2437 65.491 68.2437 66.8674Z" fill="#18BFFF"/>
+<path d="M0 46.3369V90.9534C0 93.362 2.75269 94.853 4.8172 93.8208L38.1936 77.7634L39.7993 76.9606L1.03226 44.1577C0.458781 44.7312 0 45.4193 0 46.3369Z" fill="#F82B60"/>
+<path d="M55.5125 63.5412L4.7025 43.3548C3.67024 43.0108 2.63799 43.1254 1.83512 43.5842C1.60573 43.6989 1.26164 43.9283 0.917557 44.1577L39.7993 76.9606L55.6272 69.3907C58.2652 68.129 58.0358 64.4588 55.5125 63.5412Z" fill="#BA1E45"/>
+</g>
+<defs>
+<clipPath id="clip0_0_309">
+<rect width="128" height="107.125" fill="white" transform="translate(0 10.4373)"/>
+</clipPath>
+</defs>
+</svg>

+ 15 - 0
public/provider-logos/discord.svg

@@ -0,0 +1,15 @@
+<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_0_445)">
+<mask id="mask0_0_445" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="14" width="128" height="100">
+<path d="M128 14.4225H0V113.577H128V14.4225Z" fill="white"/>
+</mask>
+<g mask="url(#mask0_0_445)">
+<path d="M108.357 23.2524C100.199 19.509 91.4506 16.7511 82.3035 15.1715C82.1369 15.141 81.9705 15.2172 81.8847 15.3695C80.7595 17.3707 79.5132 19.9813 78.6405 22.0333C68.8022 20.5604 59.0143 20.5604 49.3777 22.0333C48.5048 19.9357 47.2133 17.3707 46.0831 15.3695C45.9972 15.2223 45.8308 15.1461 45.6643 15.1715C36.5222 16.746 27.7737 19.504 19.6103 23.2524C19.5396 23.2828 19.4791 23.3337 19.4389 23.3997C2.84476 48.1909 -1.70107 72.3728 0.528961 96.2549C0.539051 96.3717 0.604639 96.4835 0.695456 96.5546C11.6438 104.595 22.2491 109.476 32.6575 112.711C32.8241 112.762 33.0006 112.701 33.1066 112.564C35.5687 109.202 37.7634 105.656 39.6452 101.928C39.7563 101.71 39.6503 101.451 39.4233 101.364C35.942 100.044 32.6272 98.4338 29.4386 96.6054C29.1863 96.4581 29.1662 96.0974 29.3982 95.9246C30.0692 95.4218 30.7404 94.8987 31.3811 94.3704C31.497 94.274 31.6585 94.2536 31.7948 94.3146C52.7429 103.879 75.4216 103.879 96.1224 94.3146C96.2587 94.2486 96.4202 94.2689 96.5412 94.3654C97.1821 94.8936 97.8531 95.4218 98.5292 95.9246C98.7612 96.0974 98.746 96.4581 98.4938 96.6054C95.3052 98.4693 91.9903 100.044 88.5041 101.359C88.2771 101.446 88.1761 101.71 88.2872 101.928C90.2094 105.651 92.4041 109.196 94.8208 112.559C94.9217 112.701 95.1033 112.762 95.2699 112.711C105.729 109.476 116.334 104.595 127.282 96.5546C127.378 96.4835 127.439 96.3768 127.449 96.26C130.118 68.6497 122.979 44.6661 108.524 23.4047C108.488 23.3337 108.428 23.2828 108.357 23.2524ZM42.7735 81.7132C36.4667 81.7132 31.27 75.9231 31.27 68.8123C31.27 61.7014 36.3659 55.9113 42.7735 55.9113C49.2313 55.9113 54.3776 61.7523 54.2767 68.8123C54.2767 75.9231 49.1808 81.7132 42.7735 81.7132ZM85.3053 81.7132C78.9987 81.7132 73.8021 75.9231 73.8021 68.8123C73.8021 61.7014 78.8978 55.9113 85.3053 55.9113C91.7634 55.9113 96.9095 61.7523 96.8087 68.8123C96.8087 75.9231 91.7634 81.7132 85.3053 81.7132Z" fill="#5865F2"/>
+</g>
+</g>
+<defs>
+<clipPath id="clip0_0_445">
+<rect width="128" height="99.1549" fill="white" transform="translate(0 14.4225)"/>
+</clipPath>
+</defs>
+</svg>

+ 10 - 0
public/provider-logos/github.svg

@@ -0,0 +1,10 @@
+<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_0_48)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M63.8093 1.30612C28.5244 1.30612 0 30.0408 0 65.5895C0 94.0055 18.2766 118.059 43.631 126.572C46.801 127.212 47.9621 125.189 47.9621 123.487C47.9621 121.997 47.8576 116.889 47.8576 111.566C30.1074 115.399 26.4111 103.903 26.4111 103.903C23.5585 96.4532 19.3319 94.5384 19.3319 94.5384C13.5223 90.6005 19.7551 90.6005 19.7551 90.6005C26.1995 91.0263 29.5811 97.199 29.5811 97.199C35.2849 106.99 44.4761 104.223 48.1737 102.52C48.7014 98.3693 50.3928 95.4958 52.1887 93.8997C38.0317 92.4095 23.1367 86.8754 23.1367 62.1832C23.1367 55.1589 25.6705 49.4119 29.6856 44.9424C29.0521 43.3463 26.833 36.7464 30.3203 27.9131C30.3203 27.9131 35.7081 26.21 47.8563 34.5117C53.0574 33.1045 58.4212 32.3887 63.8093 32.3827C69.1971 32.3827 74.6893 33.1285 79.761 34.5117C91.9105 26.21 97.2983 27.9131 97.2983 27.9131C100.786 36.7464 98.5652 43.3463 97.9318 44.9424C102.053 49.4119 104.482 55.1589 104.482 62.1832C104.482 86.8754 89.5869 92.3024 75.3241 93.8997C77.649 95.9216 79.6552 99.7525 79.6552 105.819C79.6552 114.44 79.5507 121.358 79.5507 123.486C79.5507 125.189 80.7132 127.212 83.8818 126.574C109.236 118.058 127.513 94.0055 127.513 65.5895C127.617 30.0408 98.9884 1.30612 63.8093 1.30612Z" fill="#24292F"/>
+</g>
+<defs>
+<clipPath id="clip0_0_48">
+<rect width="128" height="125.388" fill="white" transform="translate(0 1.30612)"/>
+</clipPath>
+</defs>
+</svg>

+ 1 - 0
public/provider-logos/gmail.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" fill="none" viewBox="0 0 192 192"><path fill="url(#a)" d="M146 44h38v110c0 6.627-5.373 12-12 12h-20a6 6 0 0 1-6-6z"/><path fill="#fc413d" d="M46 44H8v110c0 6.627 5.373 12 12 12h20a6 6 0 0 0 6-6z"/><path fill="url(#b)" d="M39.226 30.456c-8.033-6.752-20.018-5.714-26.77 2.319-6.752 8.032-5.714 20.017 2.319 26.77l76.078 63.949a8 8 0 0 0 10.295 0l76.078-63.95c8.032-6.752 9.07-18.737 2.318-26.77-6.752-8.032-18.737-9.07-26.769-2.318L96 78.18z"/><defs><linearGradient id="a" x1="165" x2="165" y1="44" y2="166" gradientUnits="userSpaceOnUse"><stop stop-color="#60d673"/><stop offset=".17" stop-color="#42c868"/><stop offset=".39" stop-color="#0ebc5f"/><stop offset=".62" stop-color="#00a9bb"/><stop offset=".86" stop-color="#3c90ff"/><stop offset="1" stop-color="#3186ff"/></linearGradient><linearGradient id="b" x1="8" x2="184" y1="46.13" y2="46.13" gradientUnits="userSpaceOnUse"><stop offset=".08" stop-color="#ff63a0"/><stop offset=".3" stop-color="#fc413d"/><stop offset=".5" stop-color="#fc413d"/><stop offset=".65" stop-color="#fc413d"/><stop offset=".72" stop-color="#fc5c30"/><stop offset=".86" stop-color="#feb10c"/><stop offset=".91" stop-color="#fec700"/><stop offset=".96" stop-color="#ffdb0f"/></linearGradient></defs></svg>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 89 - 0
public/provider-logos/googlecalendar.svg


+ 25 - 0
public/provider-logos/googledrive.svg

@@ -0,0 +1,25 @@
+<svg viewBox="0 0 800 741.3696" width="128" height="128" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
+  <mask id="a" width="168" height="154" x="12" y="18" maskUnits="userSpaceOnUse">
+    <path fill="#fff" d="M63.09 37c14.626-25.333 51.193-25.334 65.819 0l45.033 78c14.626 25.334-3.657 57.001-32.91 57.001H50.967c-29.253 0-47.536-31.667-32.91-57.001Z"/>
+  </mask>
+  <g mask="url(#a)" transform="matrix(4.8140532,0,0,4.8140532,-62.146701,-86.652356)">
+    <path fill="url(#b)" d="M206.905 172.02h-91.888l-19.015-32.934 45.944-79.578Z"/>
+    <path fill="url(#c)" d="M-14.919 172.006 50.04 59.494v.002L31.032 92.422h38.02L115 172.004l-129.918.001Z"/>
+    <path fill="url(#d)" d="M96.007-20.085 141.954 59.5l-19.011 32.928H31.048Z"/>
+  </g>
+  <defs>
+    <linearGradient id="b" x1="193.6" x2="103.09" y1="165.6" y2="111.21" gradientUnits="userSpaceOnUse">
+      <stop offset=".09" stop-color="#ffe921"/>
+      <stop offset="1" stop-color="#fec700"/>
+    </linearGradient>
+    <linearGradient id="c" x1="114.4" x2="15.53" y1="181.61" y2="121.8" gradientUnits="userSpaceOnUse">
+      <stop offset=".15" stop-color="#a9a8ff"/>
+      <stop offset=".33" stop-color="#6d97ff"/>
+      <stop offset=".48" stop-color="#3186ff"/>
+    </linearGradient>
+    <linearGradient id="d" x1="128.88" x2="28.7" y1="37.88" y2="84.64" gradientUnits="userSpaceOnUse">
+      <stop offset=".55" stop-color="#0ebc5f"/>
+      <stop offset=".85" stop-color="#78c9ff"/>
+    </linearGradient>
+  </defs>
+</svg>

+ 5 - 0
public/provider-logos/linear.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 16 16" fill="#8299FF">
+  <path
+    d="M2.147 9.383c-.027-.114.109-.186.192-.103L6.72 13.66c.083.083.011.219-.103.192a6.015 6.015 0 0 1-4.47-4.47ZM2 7.627a.119.119 0 0 0 .035.091l6.247 6.247a.119.119 0 0 0 .091.035c.285-.018.564-.055.836-.111a.117.117 0 0 0 .057-.198L2.31 6.734a.117.117 0 0 0-.198.057 6.007 6.007 0 0 0-.11.836ZM2.505 5.565a.119.119 0 0 0 .025.132l7.773 7.773a.118.118 0 0 0 .132.025c.215-.096.422-.203.623-.322a.118.118 0 0 0 .022-.185L3.012 4.92a.118.118 0 0 0-.185.022c-.119.2-.226.408-.322.623ZM3.519 4.169a.118.118 0 0 1-.005-.163 6.006 6.006 0 1 1 8.48 8.48.118.118 0 0 1-.163-.005L3.52 4.169Z"
+  />
+</svg>

+ 11 - 0
public/provider-logos/notion.svg

@@ -0,0 +1,11 @@
+<svg width="128" height="128" viewBox="0 0 335 350" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_913_11)">
+<path d="M21.056 15.0565L214.701 0.796296C238.481 -1.24326 244.599 0.122938 259.545 10.9802L321.359 54.4257C331.558 61.8966 334.958 63.9307 334.958 72.0749V310.358C334.958 325.292 329.518 334.124 310.498 335.475L85.6192 349.054C71.3416 349.735 64.5464 347.701 57.0693 338.19L11.5487 279.13C3.3919 268.259 0 260.126 0 250.61V38.8077C0 26.5954 5.44153 16.4088 21.056 15.0565Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M214.701 0.796296L21.056 15.0565C5.44153 16.4088 0 26.5954 0 38.8077V250.61C0 260.126 3.3919 268.259 11.5487 279.13L57.0693 338.19C64.5464 347.701 71.3416 349.735 85.6192 349.054L310.498 335.475C329.518 334.124 334.958 325.292 334.958 310.358V72.0749C334.958 64.3554 331.904 62.1254 322.899 55.5518C322.404 55.1901 321.891 54.8153 321.359 54.4257L259.545 10.9802C244.599 0.122938 238.481 -1.24326 214.701 0.796296ZM90.718 68.1664C72.3581 69.4062 68.1862 69.6879 57.7599 61.2177L31.2457 40.1737C28.5415 37.4525 29.8978 34.0579 36.6871 33.3847L222.855 19.8061C238.478 18.4455 246.629 23.8852 252.747 28.635L284.678 51.7183C286.038 52.3944 289.429 56.4624 285.351 56.4624L93.0882 68.0071C92.2729 68.0614 91.4837 68.1147 90.719 68.1664L90.718 68.1664ZM69.3031 308.318V106.024C69.3031 97.2006 72.021 93.1245 80.1694 92.4398L300.977 79.5458C308.466 78.867 311.857 83.6223 311.857 92.4398V293.376C311.857 302.208 310.495 309.686 298.261 310.358L86.9643 322.584C74.7361 323.257 69.3031 319.19 69.3031 308.318ZM277.894 116.876C279.249 122.992 277.894 129.102 271.768 129.789L261.586 131.817V281.164C252.747 285.915 244.596 288.631 237.804 288.631C226.929 288.631 224.206 285.234 216.06 275.056L149.467 170.513V271.662L170.539 276.417C170.539 276.417 170.539 288.631 153.538 288.631L106.67 291.349C105.308 288.631 106.67 281.848 111.424 280.488L123.654 277.099V143.362L106.672 142.001C105.311 135.886 108.703 127.068 118.221 126.383L168.501 122.994L237.804 228.898V135.212L220.135 133.184C218.778 125.707 224.206 120.278 231.001 119.605L277.894 116.876Z" fill="black"/>
+</g>
+<defs>
+<clipPath id="clip0_913_11">
+<rect width="335" height="349.948" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 76 - 0
public/provider-logos/outlook.svg

@@ -0,0 +1,76 @@
+<svg height="128" width="128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="60 90.4 570.02 539.67">
+<defs>
+<linearGradient id="linear0" gradientUnits="userSpaceOnUse" x1="9.98908" y1="22.364901" x2="30.932199" y2="9.37495" gradientTransform="matrix(15,0,0,15,0,0)">
+<stop offset="0" style="stop-color:rgb(12.54902%,65.490196%,98.039216%);stop-opacity:1;"/>
+<stop offset="0.4" style="stop-color:rgb(23.137255%,83.529412%,100%);stop-opacity:1;"/>
+<stop offset="1" style="stop-color:rgb(76.862745%,69.019608%,100%);stop-opacity:1;"/>
+</linearGradient>
+<linearGradient id="linear1" gradientUnits="userSpaceOnUse" x1="17.197201" y1="26.7945" x2="28.856199" y2="8.12575" gradientTransform="matrix(15,0,0,15,0,0)">
+<stop offset="0" style="stop-color:rgb(8.627451%,35.294118%,85.098039%);stop-opacity:1;"/>
+<stop offset="0.5008" style="stop-color:rgb(9.411765%,50.196078%,89.803922%);stop-opacity:1;"/>
+<stop offset="1" style="stop-color:rgb(52.156863%,52.941176%,100%);stop-opacity:1;"/>
+</linearGradient>
+<linearGradient id="linear2" gradientUnits="userSpaceOnUse" x1="25.7005" y1="27.048401" x2="12.7563" y2="16.501301" gradientTransform="matrix(15,0,0,15,0,0)">
+<stop offset="0.236946" style="stop-color:rgb(26.666667%,54.117647%,100%);stop-opacity:0;"/>
+<stop offset="0.792113" style="stop-color:rgb(0%,19.607843%,69.411765%);stop-opacity:0.2;"/>
+</linearGradient>
+<linearGradient id="linear3" gradientUnits="userSpaceOnUse" x1="24.0534" y1="31.1099" x2="44.509998" y2="18.0177" gradientTransform="matrix(15,0,0,15,0,0)">
+<stop offset="0" style="stop-color:rgb(10.196078%,26.27451%,65.098039%);stop-opacity:1;"/>
+<stop offset="0.492267" style="stop-color:rgb(12.54902%,32.156863%,79.607843%);stop-opacity:1;"/>
+<stop offset="1" style="stop-color:rgb(37.254902%,12.54902%,79.607843%);stop-opacity:1;"/>
+</linearGradient>
+<linearGradient id="linear4" gradientUnits="userSpaceOnUse" x1="29.8281" y1="30.327299" x2="17.397499" y2="19.570801" gradientTransform="matrix(15,0,0,15,0,0)">
+<stop offset="0" style="stop-color:rgb(0%,27.058824%,72.54902%);stop-opacity:0;"/>
+<stop offset="0.669859" style="stop-color:rgb(5.098039%,12.156863%,41.176471%);stop-opacity:0.2;"/>
+</linearGradient>
+<radialGradient id="radial0" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(0.000000000000024802,-405.040512,438.393002,0.000000000000026844,360.027008,102.268202)">
+<stop offset="0.568182" style="stop-color:rgb(15.294118%,37.254902%,94.117647%);stop-opacity:0;"/>
+<stop offset="0.992424" style="stop-color:rgb(0%,12.941176%,46.666667%);stop-opacity:1;"/>
+</radialGradient>
+<linearGradient id="linear5" gradientUnits="userSpaceOnUse" x1="41.998001" y1="29.9431" x2="23.8517" y2="29.9431" gradientTransform="matrix(15,0,0,15,0,0)">
+<stop offset="0" style="stop-color:rgb(30.196078%,76.862745%,100%);stop-opacity:1;"/>
+<stop offset="0.196145" style="stop-color:rgb(5.882353%,68.627451%,100%);stop-opacity:1;"/>
+</linearGradient>
+<radialGradient id="radial1" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(122.73959,-122.73959,122.73959,122.73959,421.392002,568.675518)">
+<stop offset="0.259477" style="stop-color:rgb(0%,37.647059%,81.960784%);stop-opacity:0.4;"/>
+<stop offset="0.908166" style="stop-color:rgb(1.176471%,51.372549%,94.509804%);stop-opacity:0;"/>
+</radialGradient>
+<radialGradient id="radial2" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(357.407022,-468.445926,423.594568,323.187085,159.471002,697.080002)">
+<stop offset="0.732317" style="stop-color:rgb(95.686275%,65.490196%,96.862745%);stop-opacity:0;"/>
+<stop offset="1" style="stop-color:rgb(95.686275%,65.490196%,96.862745%);stop-opacity:0.501961;"/>
+</radialGradient>
+<radialGradient id="radial3" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(-170.860868,259.725406,-674.018133,-443.404152,278.562012,412.978506)">
+<stop offset="0" style="stop-color:rgb(28.627451%,87.058824%,100%);stop-opacity:1;"/>
+<stop offset="0.724349" style="stop-color:rgb(16.078431%,76.470588%,100%);stop-opacity:1;"/>
+</radialGradient>
+<linearGradient id="linear6" gradientUnits="userSpaceOnUse" x1="3.45756" y1="37.872299" x2="20.9291" y2="37.859699" gradientTransform="matrix(15,0,0,15,0,0)">
+<stop offset="0.205882" style="stop-color:rgb(42.352941%,87.843137%,100%);stop-opacity:1;"/>
+<stop offset="0.535" style="stop-color:rgb(31.372549%,83.529412%,100%);stop-opacity:0;"/>
+</linearGradient>
+<radialGradient id="radial4" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(215.76719,230.769125,-230.769125,215.76719,59.143649,354.231005)">
+<stop offset="0.038877" style="stop-color:rgb(0%,56.862745%,100%);stop-opacity:1;"/>
+<stop offset="0.919119" style="stop-color:rgb(9.411765%,23.921569%,67.843137%);stop-opacity:1;"/>
+</radialGradient>
+<radialGradient id="radial5" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(0.000000000000010287,167.999997,-193.782005,0.000000000000011866,180,491.158504)">
+<stop offset="0.557796" style="stop-color:rgb(5.882353%,64.705882%,96.862745%);stop-opacity:0;"/>
+<stop offset="1" style="stop-color:rgb(45.490196%,77.647059%,100%);stop-opacity:0.501961;"/>
+</radialGradient>
+</defs>
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear0);" d="M 463.984375 140.144531 L 119.636719 358.414062 L 90.023438 311.695312 L 90.023438 271.4375 C 90.023438 256.78125 97.445312 243.121094 109.742188 235.144531 L 309.910156 105.257812 C 340.40625 85.46875 379.6875 85.464844 410.1875 105.25 Z M 463.984375 140.144531 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear1);" d="M 407.101562 103.339844 C 408.136719 103.953125 409.164062 104.59375 410.183594 105.253906 L 566.398438 206.585938 L 179.0625 452.105469 L 119.625 358.335938 L 403.894531 177.800781 C 430.820312 160.699219 432 122.230469 407.101562 103.339844 Z M 407.101562 103.339844 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear2);" d="M 407.101562 103.339844 C 408.136719 103.953125 409.164062 104.59375 410.183594 105.253906 L 566.398438 206.585938 L 179.0625 452.105469 L 119.625 358.335938 L 403.894531 177.800781 C 430.820312 160.699219 432 122.230469 407.101562 103.339844 Z M 407.101562 103.339844 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear3);" d="M 333.601562 498.988281 L 179.066406 452.109375 L 507.628906 243.835938 C 535.300781 226.296875 535.230469 185.898438 507.496094 168.457031 L 506.015625 167.527344 L 510.277344 170.175781 L 610.273438 235.042969 C 622.574219 243.019531 629.996094 256.683594 629.996094 271.34375 L 629.996094 310.304688 Z M 333.601562 498.988281 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear4);" d="M 333.601562 498.988281 L 179.066406 452.109375 L 507.628906 243.835938 C 535.300781 226.296875 535.230469 185.898438 507.496094 168.457031 L 506.015625 167.527344 L 510.277344 170.175781 L 610.273438 235.042969 C 622.574219 243.019531 629.996094 256.683594 629.996094 271.34375 L 629.996094 310.304688 Z M 333.601562 498.988281 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial0);" d="M 410.1875 105.25 C 379.6875 85.464844 340.40625 85.46875 309.90625 105.257812 L 109.742188 235.144531 C 97.445312 243.121094 90.023438 256.78125 90.023438 271.4375 L 90.023438 273.40625 C 90.507812 288.121094 98.25 301.679688 110.757812 309.566406 L 359.644531 466.476562 L 609.160156 309.804688 C 622.121094 301.667969 629.984375 287.441406 629.984375 272.140625 L 629.984375 310.308594 L 629.992188 271.34375 C 629.992188 256.683594 622.566406 243.023438 610.269531 235.042969 Z M 410.1875 105.25 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear5);" d="M 315.769531 630.050781 L 536.21875 630.050781 C 587.996094 630.050781 629.96875 588.078125 629.96875 536.300781 L 629.96875 272.140625 C 629.96875 287.441406 622.105469 301.667969 609.148438 309.804688 L 281.242188 515.695312 C 263.554688 526.804688 252.820312 546.222656 252.820312 567.109375 C 252.824219 601.871094 281.003906 630.050781 315.769531 630.050781 Z M 315.769531 630.050781 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial1);" d="M 315.769531 630.050781 L 536.21875 630.050781 C 587.996094 630.050781 629.96875 588.078125 629.96875 536.300781 L 629.96875 272.140625 C 629.96875 287.441406 622.105469 301.667969 609.148438 309.804688 L 281.242188 515.695312 C 263.554688 526.804688 252.820312 546.222656 252.820312 567.109375 C 252.824219 601.871094 281.003906 630.050781 315.769531 630.050781 Z M 315.769531 630.050781 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial2);" d="M 315.769531 630.050781 L 536.21875 630.050781 C 587.996094 630.050781 629.96875 588.078125 629.96875 536.300781 L 629.96875 272.140625 C 629.96875 287.441406 622.105469 301.667969 609.148438 309.804688 L 281.242188 515.695312 C 263.554688 526.804688 252.820312 546.222656 252.820312 567.109375 C 252.824219 601.871094 281.003906 630.050781 315.769531 630.050781 Z M 315.769531 630.050781 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial3);" d="M 405.402344 630.035156 L 183.738281 630.035156 C 131.960938 630.035156 89.988281 588.0625 89.988281 536.285156 L 89.988281 271.945312 C 89.988281 287.21875 97.824219 301.421875 110.742188 309.566406 L 438.324219 516.085938 C 456.257812 527.390625 467.132812 547.113281 467.132812 568.3125 C 467.128906 602.402344 439.492188 630.035156 405.402344 630.035156 Z M 405.402344 630.035156 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear6);" d="M 405.402344 630.035156 L 183.738281 630.035156 C 131.960938 630.035156 89.988281 588.0625 89.988281 536.285156 L 89.988281 271.945312 C 89.988281 287.21875 97.824219 301.421875 110.742188 309.566406 L 438.324219 516.085938 C 456.257812 527.390625 467.132812 547.113281 467.132812 568.3125 C 467.128906 602.402344 439.492188 630.035156 405.402344 630.035156 Z M 405.402344 630.035156 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial4);" d="M 108.75 345 L 251.25 345 C 278.175781 345 300 366.824219 300 393.75 L 300 536.25 C 300 563.175781 278.175781 585 251.25 585 L 108.75 585 C 81.824219 585 60 563.175781 60 536.25 L 60 393.75 C 60 366.824219 81.824219 345 108.75 345 Z M 108.75 345 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial5);" d="M 108.75 345 L 251.25 345 C 278.175781 345 300 366.824219 300 393.75 L 300 536.25 C 300 563.175781 278.175781 585 251.25 585 L 108.75 585 C 81.824219 585 60 563.175781 60 536.25 L 60 393.75 C 60 366.824219 81.824219 345 108.75 345 Z M 108.75 345 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 179.386719 534 C 159.539062 534 143.25 527.789062 130.511719 515.375 C 117.773438 502.960938 111.402344 486.757812 111.402344 466.769531 C 111.402344 445.660156 117.867188 428.589844 130.796875 415.550781 C 143.730469 402.515625 160.660156 396 181.59375 396 C 201.375 396 217.472656 402.238281 229.890625 414.714844 C 242.375 427.191406 248.617188 443.644531 248.617188 464.066406 C 248.617188 485.050781 242.148438 501.964844 229.21875 514.816406 C 216.351562 527.605469 199.742188 534 179.386719 534 Z M 179.960938 507.648438 C 190.777344 507.648438 199.484375 503.953125 206.078125 496.566406 C 212.671875 489.179688 215.96875 478.902344 215.96875 465.742188 C 215.96875 452.023438 212.765625 441.347656 206.367188 433.710938 C 199.964844 426.074219 191.417969 422.257812 180.730469 422.257812 C 169.71875 422.257812 160.851562 426.199219 154.132812 434.082031 C 147.410156 441.90625 144.050781 452.273438 144.050781 465.183594 C 144.050781 478.285156 147.410156 488.652344 154.132812 496.285156 C 160.851562 503.859375 169.460938 507.648438 179.960938 507.648438 Z M 179.960938 507.648438 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 179.332031 535.847656 C 159.5625 535.847656 143.332031 529.472656 130.640625 516.71875 C 117.953125 503.964844 111.605469 487.320312 111.605469 466.789062 C 111.605469 445.105469 118.046875 427.570312 130.929688 414.179688 C 143.8125 400.785156 160.679688 394.089844 181.53125 394.089844 C 201.234375 394.089844 217.273438 400.5 229.644531 413.316406 C 242.082031 426.136719 248.296875 443.035156 248.296875 464.015625 C 248.296875 485.566406 241.855469 502.945312 228.976562 516.144531 C 216.15625 529.28125 199.609375 535.847656 179.332031 535.847656 Z M 179.902344 508.78125 C 190.679688 508.78125 199.355469 504.984375 205.921875 497.398438 C 212.492188 489.808594 215.773438 479.253906 215.773438 465.734375 C 215.773438 451.640625 212.585938 440.675781 206.210938 432.832031 C 199.832031 424.988281 191.320312 421.066406 180.671875 421.066406 C 169.699219 421.066406 160.867188 425.113281 154.171875 433.214844 C 147.476562 441.246094 144.128906 451.898438 144.128906 465.160156 C 144.128906 478.617188 147.476562 489.265625 154.171875 497.109375 C 160.867188 504.890625 169.445312 508.78125 179.902344 508.78125 Z M 179.902344 508.78125 "/>
+</g>
+</svg>

+ 22 - 0
public/provider-logos/slack.svg

@@ -0,0 +1,22 @@
+<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_0_113)">
+<g clip-path="url(#clip1_0_113)">
+<path d="M26.8925 80.886C26.8925 88.2866 20.8469 94.3323 13.4463 94.3323C6.04561 94.3323 0 88.2866 0 80.886C0 73.4853 6.04561 67.4397 13.4463 67.4397H26.8925V80.886Z" fill="#E01E5A"/>
+<path d="M33.6675 80.886C33.6675 73.4853 39.7131 67.4397 47.1137 67.4397C54.5144 67.4397 60.56 73.4853 60.56 80.886V114.554C60.56 121.954 54.5144 128 47.1137 128C39.7131 128 33.6675 121.954 33.6675 114.554V80.886Z" fill="#E01E5A"/>
+<path d="M47.1137 26.8925C39.7131 26.8925 33.6675 20.8469 33.6675 13.4463C33.6675 6.0456 39.7131 -3.8147e-06 47.1137 -3.8147e-06C54.5144 -3.8147e-06 60.56 6.0456 60.56 13.4463V26.8925H47.1137Z" fill="#36C5F0"/>
+<path d="M47.114 33.6678C54.5147 33.6678 60.5603 39.7134 60.5603 47.114C60.5603 54.5147 54.5147 60.5603 47.114 60.5603H13.4463C6.0456 60.5603 0 54.5147 0 47.114C0 39.7134 6.0456 33.6678 13.4463 33.6678H47.114Z" fill="#36C5F0"/>
+<path d="M101.107 47.114C101.107 39.7134 107.153 33.6678 114.554 33.6678C121.954 33.6678 128 39.7134 128 47.114C128 54.5147 121.954 60.5603 114.554 60.5603H101.107V47.114Z" fill="#2EB67D"/>
+<path d="M94.3322 47.114C94.3322 54.5147 88.2866 60.5603 80.8859 60.5603C73.4853 60.5603 67.4397 54.5147 67.4397 47.114V13.4463C67.4397 6.0456 73.4853 -3.8147e-06 80.8859 -3.8147e-06C88.2866 -3.8147e-06 94.3322 6.0456 94.3322 13.4463V47.114Z" fill="#2EB67D"/>
+<path d="M80.8859 101.107C88.2866 101.107 94.3322 107.153 94.3322 114.554C94.3322 121.954 88.2866 128 80.8859 128C73.4853 128 67.4397 121.954 67.4397 114.554V101.107H80.8859Z" fill="#ECB22E"/>
+<path d="M80.8859 94.3323C73.4853 94.3323 67.4397 88.2866 67.4397 80.886C67.4397 73.4853 73.4853 67.4397 80.8859 67.4397H114.554C121.954 67.4397 128 73.4853 128 80.886C128 88.2866 121.954 94.3323 114.554 94.3323H80.8859Z" fill="#ECB22E"/>
+</g>
+</g>
+<defs>
+<clipPath id="clip0_0_113">
+<rect width="128" height="128" fill="white"/>
+</clipPath>
+<clipPath id="clip1_0_113">
+<rect width="128" height="128" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 19 - 0
public/provider-logos/trello.svg

@@ -0,0 +1,19 @@
+<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_240_7025)">
+<g clip-path="url(#clip1_240_7025)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.867 0H15.133C6.72578 0 0 6.72578 0 15.133V112.657C0 121.064 6.72578 127.79 15.133 127.79H112.657C121.064 127.79 127.79 121.064 127.79 112.657V15.3432C128 6.72578 121.274 0 112.867 0ZM55.0673 92.2693C55.0673 95.0016 52.7553 97.3136 50.023 97.3136H28.7947C26.0624 97.3136 23.7504 95.0016 23.7504 92.2693V28.7947C23.7504 26.0624 26.0624 23.7504 28.7947 23.7504H50.2332C52.9655 23.7504 55.2775 26.0624 55.2775 28.7947V92.2693H55.0673ZM104.46 63.0542C104.46 65.7865 102.358 68.0985 99.4154 68.3087C99.4154 68.3087 99.4154 68.3087 99.2053 68.3087H77.977C75.2447 68.3087 72.9327 65.9967 72.9327 63.2644V28.7947C72.9327 26.0624 75.2447 23.7504 77.977 23.7504H99.4154C102.148 23.7504 104.46 26.0624 104.46 28.7947V63.0542Z" fill="url(#paint0_linear_240_7025)"/>
+</g>
+</g>
+<defs>
+<linearGradient id="paint0_linear_240_7025" x1="64.042" y1="128.084" x2="64.042" y2="0" gradientUnits="userSpaceOnUse">
+<stop stop-color="#0052CC"/>
+<stop offset="1" stop-color="#2684FF"/>
+</linearGradient>
+<clipPath id="clip0_240_7025">
+<rect width="128" height="128" fill="white"/>
+</clipPath>
+<clipPath id="clip1_240_7025">
+<rect width="128" height="128" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 128 - 0
public/provider-logos/twitter.svg

@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0.00 0.00 1024.00 1024.00" width="128" height="128">
+<g stroke-width="2.00" fill="none" stroke-linecap="butt">
+<path stroke="#808080" vector-effect="non-scaling-stroke" d="
+  M 826.22 795.00
+  L 579.51 468.80
+  A 0.48 0.48 0.0 0 1 579.53 468.19
+  L 789.06 228.72
+  A 0.33 0.33 0.0 0 0 788.81 228.17
+  L 694.40 228.17
+  A 2.46 2.44 20.6 0 0 692.56 229.00
+  L 535.08 408.98
+  A 0.54 0.54 0.0 0 1 534.24 408.95
+  L 398.04 228.87
+  A 1.80 1.79 71.3 0 0 396.61 228.16
+  L 199.90 228.16
+  A 0.50 0.50 0.0 0 0 199.50 228.96
+  L 435.78 537.86
+  A 1.00 1.00 0.0 0 1 435.74 539.13
+  L 211.82 795.03
+  A 0.30 0.29 20.3 0 0 212.05 795.52
+  L 306.94 795.52
+  A 1.61 1.60 -69.1 0 0 308.14 794.98
+  L 480.70 597.76
+  A 0.59 0.59 0.0 0 1 481.62 597.79
+  L 632.49 795.03
+  A 1.27 1.27 0.0 0 0 633.49 795.52
+  L 825.96 795.52
+  A 0.33 0.32 -18.7 0 0 826.22 795.00"
+/>
+<path stroke="#808080" vector-effect="non-scaling-stroke" d="
+  M 712.09 737.88
+  A 0.33 0.33 0.0 0 0 712.35 737.35
+  L 368.64 282.92
+  A 0.33 0.33 0.0 0 0 368.38 282.79
+  L 312.01 282.79
+  A 0.33 0.33 0.0 0 0 311.75 283.32
+  L 659.34 737.75
+  A 0.33 0.33 0.0 0 0 659.60 737.88
+  L 712.09 737.88"
+/>
+</g>
+<path fill="#000000" d="
+  M 1024.00 0.00
+  L 1024.00 1024.00
+  L 0.00 1024.00
+  L 0.00 0.00
+  L 1024.00 0.00
+  Z
+  M 826.22 795.00
+  L 579.51 468.80
+  A 0.48 0.48 0.0 0 1 579.53 468.19
+  L 789.06 228.72
+  A 0.33 0.33 0.0 0 0 788.81 228.17
+  L 694.40 228.17
+  A 2.46 2.44 20.6 0 0 692.56 229.00
+  L 535.08 408.98
+  A 0.54 0.54 0.0 0 1 534.24 408.95
+  L 398.04 228.87
+  A 1.80 1.79 71.3 0 0 396.61 228.16
+  L 199.90 228.16
+  A 0.50 0.50 0.0 0 0 199.50 228.96
+  L 435.78 537.86
+  A 1.00 1.00 0.0 0 1 435.74 539.13
+  L 211.82 795.03
+  A 0.30 0.29 20.3 0 0 212.05 795.52
+  L 306.94 795.52
+  A 1.61 1.60 -69.1 0 0 308.14 794.98
+  L 480.70 597.76
+  A 0.59 0.59 0.0 0 1 481.62 597.79
+  L 632.49 795.03
+  A 1.27 1.27 0.0 0 0 633.49 795.52
+  L 825.96 795.52
+  A 0.33 0.32 -18.7 0 0 826.22 795.00
+  Z"
+/>
+<path fill="#ffffff" d="
+  M 826.22 795.00
+  A 0.33 0.32 -18.7 0 1 825.96 795.52
+  L 633.49 795.52
+  A 1.27 1.27 0.0 0 1 632.49 795.03
+  L 481.62 597.79
+  A 0.59 0.59 0.0 0 0 480.70 597.76
+  L 308.14 794.98
+  A 1.61 1.60 -69.1 0 1 306.94 795.52
+  L 212.05 795.52
+  A 0.30 0.29 20.3 0 1 211.82 795.03
+  L 435.74 539.13
+  A 1.00 1.00 0.0 0 0 435.78 537.86
+  L 199.50 228.96
+  A 0.50 0.50 0.0 0 1 199.90 228.16
+  L 396.61 228.16
+  A 1.80 1.79 71.3 0 1 398.04 228.87
+  L 534.24 408.95
+  A 0.54 0.54 0.0 0 0 535.08 408.98
+  L 692.56 229.00
+  A 2.46 2.44 20.6 0 1 694.40 228.17
+  L 788.81 228.17
+  A 0.33 0.33 0.0 0 1 789.06 228.72
+  L 579.53 468.19
+  A 0.48 0.48 0.0 0 0 579.51 468.80
+  L 826.22 795.00
+  Z
+  M 712.09 737.88
+  A 0.33 0.33 0.0 0 0 712.35 737.35
+  L 368.64 282.92
+  A 0.33 0.33 0.0 0 0 368.38 282.79
+  L 312.01 282.79
+  A 0.33 0.33 0.0 0 0 311.75 283.32
+  L 659.34 737.75
+  A 0.33 0.33 0.0 0 0 659.60 737.88
+  L 712.09 737.88
+  Z"
+/>
+<path fill="#000000" d="
+  M 712.09 737.88
+  L 659.60 737.88
+  A 0.33 0.33 0.0 0 1 659.34 737.75
+  L 311.75 283.32
+  A 0.33 0.33 0.0 0 1 312.01 282.79
+  L 368.38 282.79
+  A 0.33 0.33 0.0 0 1 368.64 282.92
+  L 712.35 737.35
+  A 0.33 0.33 0.0 0 1 712.09 737.88
+  Z"
+/>
+</svg>

+ 3 - 3
release.json

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

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

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

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

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

+ 23 - 37
src/client/components/app/WorkspaceShell.vue

@@ -488,6 +488,10 @@ defineExpose({openLocalVm:showLocalVmSettings})
       <div v-if="applicationWorkspace" class="bot-list-header">
         <button class="bot-logo-trigger sidebar-brand" type="button" aria-label="返回 Bot 列表" title="夭夭 AI" @click="navigate('/conversations')"><BrandMark :size="32" :label="false" compact bare /></button>
         <span class="bot-list-header__spacer" />
+        <button class="bot-list-toolbar-button unread-entry" type="button" :aria-label="`未读消息,${unread.total} 条`" aria-haspopup="dialog" aria-controls="unread-center" :aria-expanded="unreadOpen" title="未读消息" @click="openUnread">
+          <AppIcon name="bell" :size="20" />
+          <b v-if="unread.total" class="unread-entry__count" aria-hidden="true">{{ unread.total > 99 ? '99+' : unread.total }}</b>
+        </button>
         <button class="bot-list-toolbar-button sidebar-search-trigger" type="button" aria-label="搜索" :aria-expanded="sidebarSearchOpen" @click="openSidebarSearch(desktopSidebarContext)"><AppIcon name="search" :size="21" /></button>
         <button class="bot-list-toolbar-button sidebar-create-trigger" type="button" aria-label="新建" aria-haspopup="menu" :aria-expanded="createMenuOpen" @click="openCreateMenu"><AppIcon name="plus" :size="23" /></button>
       </div>
@@ -495,23 +499,11 @@ defineExpose({openLocalVm:showLocalVmSettings})
         <button class="rail__brand sidebar-brand" type="button" aria-label="返回聊天" title="夭夭 AI" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
           <BrandMark :size="sidebarCollapsed ? 26 : 32" :label="false" compact bare />
         </button>
+        <button class="unread-entry" type="button" :aria-label="`未读消息,${unread.total} 条`" aria-haspopup="dialog" aria-controls="unread-center" :aria-expanded="unreadOpen" title="未读消息" @click="openUnread">
+          <AppIcon name="bell" :size="20" />
+          <b v-if="unread.total" class="unread-entry__count" aria-hidden="true">{{ unread.total > 99 ? '99+' : unread.total }}</b>
+        </button>
       </div>
-      <button
-        class="unread-entry"
-        :class="{ 'unread-entry--pending': unread.total > 0 }"
-        type="button"
-        :aria-label="`未读消息,${unread.total} 条`"
-        aria-haspopup="dialog"
-        aria-controls="unread-center"
-        :aria-expanded="unreadOpen"
-        title="未读消息"
-        @click="openUnread"
-      >
-        <AppIcon name="bell" :size="18" />
-        <span class="unread-entry__label">未读消息</span>
-        <b v-if="unread.total" class="unread-entry__count" aria-hidden="true">{{ unread.total > 99 ? '99+' : unread.total }}</b>
-        <span v-else class="unread-entry__status" aria-hidden="true">全部已读</span>
-      </button>
       <button
         v-if="!applicationWorkspace"
         class="sidebar-collapse"
@@ -809,7 +801,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
 .mobile-header, .mobile-drawer, .drawer-scrim { display: none; }
 
 .desktop-sidebar {
-  --sidebar-search-top: 114px;
+  --sidebar-search-top: 58px;
   position: relative;
   z-index: 20;
   display: flex;
@@ -1038,33 +1030,27 @@ defineExpose({openLocalVm:showLocalVmSettings})
 
 <style scoped>
 .unread-entry {
-  display: flex;
-  min-height: 44px;
+  position: relative;
+  display: grid;
+  width: 36px;
+  height: 36px;
   flex: 0 0 auto;
-  align-items: center;
-  gap: 10px;
-  margin: 2px 12px 10px;
-  padding: 10px 12px;
+  place-items: center;
+  padding: 0;
   border: 1px solid transparent;
-  border-radius: 10px;
+  border-radius: 50%;
   background: transparent;
   color: var(--text-secondary);
   text-align: left;
   cursor: pointer;
   transition: background-color 140ms ease, border-color 140ms ease;
 }
-.unread-entry--pending { border-color: var(--line); background: var(--surface-soft); color: var(--text-primary); }
-.unread-entry:hover, .unread-entry[aria-expanded="true"] { background: var(--surface-hover); }
+.sidebar-brand-row .unread-entry { position: absolute; top: 9px; right: 49px; }
+.bot-list-header .unread-entry { position: relative; color: var(--text-secondary); }
+.unread-entry:hover, .unread-entry[aria-expanded="true"], .bot-list-header .unread-entry:hover, .bot-list-header .unread-entry[aria-expanded="true"] { background: var(--surface-hover); color: var(--text-primary); }
 .unread-entry:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
-.unread-entry__label { flex: 1; font-size: 14px; font-weight: 600; white-space: nowrap; }
-.unread-entry__status { color: var(--text-muted); font-size: 12px; white-space: nowrap; }
-.unread-entry__count { min-width: 24px; padding: 3px 7px; border-radius: 20px; background: var(--accent); color: var(--text-on-solid); font-size: 12px; line-height: 16px; text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; }
-.desktop-sidebar--collapsed .unread-entry { position: relative; justify-content: center; margin: 4px 8px 10px; padding: 10px; }
-.desktop-sidebar--collapsed .unread-entry__label, .desktop-sidebar--collapsed .unread-entry__status { display: none; }
-.desktop-sidebar--collapsed .unread-entry__count { position: absolute; top: -4px; right: -4px; min-width: 18px; padding: 1px 4px; font-size: 10px; }
-@media (max-width: 900px) {
-  .workspace-shell--conversations .unread-entry { order: 1; margin: 8px 12px; }
-  .workspace-shell--conversations .sidebar-footer { order: 2; }
-}
-@media (max-width: 767px) { .unread-entry { display: none; } }
+.unread-entry__count { position: absolute; top: -3px; right: -3px; display: grid; min-width: 18px; height: 18px; place-items: center; padding: 0 4px; border: 2px solid var(--surface); border-radius: 20px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 600; line-height: 14px; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.desktop-sidebar--collapsed .sidebar-brand-row { flex-direction: column; gap: 8px; padding: 9px 13px; }
+.desktop-sidebar--collapsed .unread-entry { position: relative; top: auto; right: auto; }
+@media (max-width: 767px) { .unread-entry, .bot-list-header .unread-entry { display: none; } }
 </style>

+ 27 - 0
src/client/components/common/ProviderLogo.vue

@@ -0,0 +1,27 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { safeProviderLogo } from '@shared/botProviderLogos'
+
+const props = defineProps<{ name: string; logo?: string; bundledLogo?: string }>()
+const failed = ref<string[]>([])
+const source = computed(() => [props.bundledLogo, props.logo].map(safeProviderLogo).find((url): url is string => !!url && !failed.value.includes(url)))
+const initial = computed(() => Array.from(props.name.trim())[0]?.toLocaleUpperCase() || '?')
+watch(() => [props.logo, props.bundledLogo], () => { failed.value = [] })
+function onError(event: Event) {
+  const url = (event.target as HTMLImageElement).getAttribute('src')
+  if (url && !failed.value.includes(url)) failed.value.push(url)
+}
+</script>
+
+<template>
+  <span class="provider-logo" :class="{ 'provider-logo--fallback': !source }" aria-hidden="true">
+    <img v-if="source" :key="source" :src="source" alt="" width="28" height="28" loading="lazy" decoding="async" referrerpolicy="no-referrer" :draggable="false" @error="onError" />
+    <span v-else>{{ initial }}</span>
+  </span>
+</template>
+
+<style scoped>
+.provider-logo{display:grid;place-items:center;box-sizing:border-box;width:40px;height:40px;flex:0 0 40px;overflow:hidden;border:1px solid var(--line);border-radius:10px;background:#fff}
+.provider-logo img{display:block;width:28px;height:28px;object-fit:contain}
+.provider-logo--fallback{background:var(--surface-soft);color:var(--text-primary);font-size:17px;font-weight:650}
+</style>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 4 - 2
src/client/components/workspace/BotPluginsPanel.vue


+ 10 - 6
src/client/stores/unread.ts

@@ -5,24 +5,28 @@ 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 }
+ function accept(value:UnreadSnapshot){
+  snapshot.value=value;error.value=''
+  void window.yaoyaoDesktop?.unreadState?.(scope.value).catch(()=>{})
+ }
  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:'未读同步失败'})
+   accept(value)
+  }).catch(e=>{if(generation===epoch&&request===revision)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}))}))})
+  const generation=epoch,account=scope.value,request=++revision;inflight=undefined
+  const value=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
+  if(request===revision){accept(value);return}
   // Concurrent reads can finish out of order; fetch the latest authority.
-  revision++;await refresh()
+  revision++;inflight=undefined;await refresh()
  }
  async function visible(mode:'bot'|'chat',id:string,profile:string|undefined,ids:string[]){
   if(!ids.length||!scope.value)return

+ 52 - 5
src/client/views/ChatView.vue

@@ -4,6 +4,8 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import AppIcon from '@/components/common/AppIcon.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
+import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
+import type { SessionSummary } from '@shared/types'
 import type { ChoiceOption } from '@/components/common/types'
 import ComposerShell from '@/components/composer/ComposerShell.vue'
 import ModelChoiceDialog from '@/components/composer/ModelChoiceDialog.vue'
@@ -46,6 +48,9 @@ const showThinking = ref(false)
 const queueMode = ref(false)
 const actionSessionId = ref('')
 const renaming = ref(false)
+const pendingDeleteSession = ref<Pick<SessionSummary, 'id' | 'profile' | 'title'> | null>(null)
+const deletingSession = ref(false)
+const deleteSessionError = ref('')
 const renameValue = ref('')
 const actionMenuPosition = ref({ x: 8, y: 8 })
 const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
@@ -337,12 +342,35 @@ function navigateOutline(target: { messageId: string; anchorId: string }) {
   if (window.matchMedia('(max-width: 900px)').matches) closeInspector()
 }
 
-async function deleteSession() {
-  const session = chat.sessions.find(item => item.id === actionSessionId.value)
+function confirmDeleteSession() {
+  const session = actionSession.value
   if (!session) return
-  await chat.removeSession(session.id, session.profile)
+  pendingDeleteSession.value = { id: session.id, profile: session.profile, title: session.title }
+  deleteSessionError.value = ''
   closeSessionActions()
-  if (route.params.sessionId === session.id) await router.replace('/chat')
+}
+
+function cancelDeleteSession() {
+  if (deletingSession.value) return
+  pendingDeleteSession.value = null
+  deleteSessionError.value = ''
+  void nextTick(() => headerSessionMenuButton.value?.focus())
+}
+
+async function deleteSession() {
+  const session = pendingDeleteSession.value
+  if (!session || deletingSession.value) return
+  deletingSession.value = true
+  deleteSessionError.value = ''
+  try {
+    await chat.removeSession(session.id, session.profile)
+    if (route.params.sessionId === session.id) await router.replace({ path: '/chat', query: { profile: session.profile } })
+    pendingDeleteSession.value = null
+  } catch (cause) {
+    deleteSessionError.value = cause instanceof Error ? cause.message : '删除失败,请重试'
+  } finally {
+    deletingSession.value = false
+  }
 }
 
 async function branch() {
@@ -517,6 +545,17 @@ watch(() => chat.activeSessionId, async id => {
 
   <ModelChoiceDialog :open="modelDialog" :options="chat.models" :selected-id="selectedModelId" :busy="modelSwitching" @close="modelDialog = false" @select="selectModel" />
 
+  <StandaloneDialog v-if="pendingDeleteSession" title="删除会话" compact :before-close="() => !deletingSession" @close="cancelDeleteSession">
+    <div class="session-delete" :aria-busy="deletingSession">
+      <p>确定删除「{{ pendingDeleteSession.title || '新会话' }}」吗?删除后无法恢复。</p>
+      <p v-if="deleteSessionError" class="session-delete-error" role="alert">{{ deleteSessionError }}</p>
+      <div class="session-delete-actions">
+        <button class="quiet-button" type="button" :disabled="deletingSession" @click="cancelDeleteSession">取消</button>
+        <button class="solid-button session-delete-confirm" type="button" :disabled="deletingSession" @click="deleteSession">{{ deletingSession ? '删除中…' : '删除' }}</button>
+      </div>
+    </div>
+  </StandaloneDialog>
+
   <Teleport to="body">
     <Transition name="session-menu">
       <section v-if="actionSessionId" class="session-actions" :style="actionMenuStyle" role="menu" aria-label="会话操作" @contextmenu.prevent>
@@ -529,7 +568,7 @@ watch(() => chat.activeSessionId, async id => {
           <button v-if="actionSessionId === chat.activeSessionId" class="action-row" role="menuitem" type="button" :disabled="chat.isStreaming || chat.isQueued || chat.isSending || chat.activeRouteState?.isLoadingHistory" @click="forceRefreshHistory"><AppIcon name="refresh" :size="14" />强制刷新历史</button>
           <button class="action-row" role="menuitem" type="button" @click="toggleSessionPinned"><AppIcon :name="actionSession?.pinned ? 'pin-off' : 'pin'" :size="14" />{{ actionSession?.pinned ? '取消置顶' : '置顶会话' }}</button>
           <button class="action-row" role="menuitem" type="button" @click="renaming = true"><AppIcon name="edit" :size="14" />重命名</button>
-          <button class="action-row danger" role="menuitem" type="button" @click="deleteSession"><AppIcon name="trash" :size="14" />删除会话</button>
+          <button class="action-row danger" role="menuitem" type="button" @click="confirmDeleteSession"><AppIcon name="trash" :size="14" />删除会话</button>
         </template>
       </section>
     </Transition>
@@ -538,6 +577,14 @@ watch(() => chat.activeSessionId, async id => {
 
 <style scoped>
 .chat-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
+.session-delete p { margin: 0; font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
+.session-delete .session-delete-error { margin-top: 12px; color: var(--danger); }
+.session-delete-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
+.session-delete-actions button { min-width: 80px; min-height: 44px; font-size: 14px; }
+.session-delete-confirm { border-color: var(--danger); background: var(--danger); color: var(--text-on-solid); }
+.session-delete-confirm:hover { transform: none; filter: brightness(.95); }
+.session-delete-actions button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.session-delete-actions button:disabled { opacity: .6; cursor: wait; }
 .sidebar-primary-action { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 10px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; font-size: 12px; font-weight: 610; text-align: left; transition: background-color 120ms ease; }
 .sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
 .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }

+ 16 - 4
src/client/views/ConversationsView.vue

@@ -398,13 +398,25 @@ async function load(id = selected.value, append = false, signal?: AbortSignal) {
     if (own === generation) loading.value = false
   }
 }
+let unreadNavigation=0
 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 own=++unreadNavigation,conversation=selected.value,task=selectedTask.value,path=route.path
+ const current=()=>!disposed&&own===unreadNavigation&&route.path===path&&active.value?.id===conversation&&selected.value===conversation
+  &&selectedTask.value===task&&route.query.unread===id
+ try{
+  for(let i=0;i<100&&current();i++){
+   await nextTick();if(!current()||timeline.value?.scrollToMessage(id))return
+   if(!older.value||loadingOlder.value)return
+   await loadOlder()
+  }
+ }finally{
+  // A removed/unavailable target must not keep all visible messages unread.
+  if(current()){const query={...route.query};delete query.unread;await router.replace({query})}
  }
 }
+watch(()=>route.query.unread,id=>{
+ if(typeof id==='string'&&!loading.value&&active.value?.id===selected.value)void locateUnread(id)
+},{flush:'post'})
 const unread=useUnreadStore()
 const pendingReads = new Set<string>()
 async function markVisible(ids:string[]) {

+ 2 - 2
src/server/app.ts

@@ -1,7 +1,7 @@
 import { unreadSnapshot } from './unreadCenter.js'
 import {ExecutionSettings} from './executionSettings.js'
 import {CredentialVaultCoordinator} from './credentialVault/coordinator.js'
-import {CredentialVaultClient} from './credentialVault/transport.js'
+import {createCredentialVaultClient} from './credentialVault/localClient.js'
 import {ManagedBrowsers} from './managedBrowsers.js'
 import { isWorkspaceAccountPath } from './subaccountAccess.js'
 import { chatTranscriptRouter } from './chatTranscriptApi.js'
@@ -264,7 +264,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   const executionSettings=new ExecutionSettings(workspace,workspaceNodes,auth)
-  const credentialVault=new CredentialVaultCoordinator(new CredentialVaultClient(config.credentialVault),workspace,auth,runners)
+  const credentialVault=new CredentialVaultCoordinator(createCredentialVaultClient(config.credentialVault,config.home),workspace,auth,runners)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0, openVikingService)
   workspaceRuntime.executionSettings=executionSettings
   workspaceRuntime.credentialVault=credentialVault

+ 3 - 2
src/server/botPlugins/connectedApps.ts

@@ -4,6 +4,7 @@ import type { WorkspaceStore } from '../workspaceStore.js'
 import type { WorkspaceNodes } from '../workspaceGateway.js'
 import { HttpError } from '../errors.js'
 import type { BotAppCard, BotAppConnection, BotPluginSettings } from '../../shared/workspacePlugins.js'
+import { bundledProviderLogo, safeProviderLogo } from '../../shared/botProviderLogos.js'
 
 const API = 'https://backend.composio.dev/api/v3.1'
 const CATALOG_API = 'https://backend.composio.dev/api/v3'
@@ -16,7 +17,7 @@ export const CURATED_APPS: BotAppCard[] = [
   ['github', 'GitHub', '代码仓库与议题'], ['notion', 'Notion', '文档与知识库'], ['slack', 'Slack', '团队沟通'],
   ['linear', 'Linear', '项目与任务'], ['discord', 'Discord', '社区消息'], ['outlook', 'Outlook', '邮件与日历'],
   ['trello', 'Trello', '看板与任务'], ['airtable', 'Airtable', '表格与业务数据'], ['twitter', 'X', '社交内容'],
-].map(([slug, name, description]) => ({ slug: slug!, name: name!, description: description! }))
+].map(([slug, name, description]) => ({ slug: slug!, name: name!, description: description!, logo: bundledProviderLogo(slug!) }))
 
 function trustedUrl(value: string): string {
   let url: URL
@@ -81,7 +82,7 @@ export class ConnectedApps {
     if (value?.key) try {
       const items = await this.pages(value.key, '/toolkits', { limit: '500', sort_by: 'usage' }, undefined, CATALOG_API)
       this.current(owner, value.revision)
-      const cards = items.filter(item => appSlug.safeParse(item.slug).success).map(item => ({ slug: item.slug, name: String(item.name ?? item.slug).slice(0, 100), description: String(item.meta?.description ?? item.description ?? '').slice(0, 180) }))
+      const cards = items.filter(item => appSlug.safeParse(item.slug).success).map(item => ({ slug: item.slug, name: String(item.name ?? item.slug).slice(0, 100), description: String(item.meta?.description ?? item.description ?? '').slice(0, 180), logo: safeProviderLogo(item.meta?.logo ?? item.logo) ?? bundledProviderLogo(item.slug) }))
       if (cards.length) { this.catalogCache.set(owner, { at: Date.now(), cards }); return { cards, source: 'api' } }
     } catch { /* Catalog fallback never claims that account inventory is empty. */ }
     return { cards: CURATED_APPS, source: 'curated' }

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

@@ -122,6 +122,7 @@ export class CredentialVaultCoordinator {
         operation: leaseInput.shape.operation, seconds: leaseInput.shape.seconds }).strict(), (ctx.request as any).body)
       this.task(s.owner, body.agentId, body.workId)
       const binding = this.binding(s.owner, body.agentId), state: CredentialVaultStatus = await this.call(s, 'status'), entry = state.entries.find(e => e.id === body.credentialRef)
+      if (state.storageMode === 'local') throw new HttpError(409, 'Bot 自动使用需要连接独立密码库服务', 'vault_executor_not_enabled')
       if (!entry) throw new HttpError(403, '请解锁并选择当前账号的凭据', 'vault_credential_forbidden')
       const summary = await this.call(s, 'grant', { ...body, ...binding, target: entry.target }) as CredentialLeaseSummary
       try {
@@ -203,5 +204,5 @@ export class CredentialVaultCoordinator {
       },
     }
   }
-  close() { this.closed=true; clearInterval(this.timer); for(const c of this.turnControllers)c.abort();this.turnControllers.clear();this.grants.clear(); this.pending.clear(); for (const s of this.sessions.values()) void this.endSession(s); this.sessions.clear() }
+  close() { this.closed=true; clearInterval(this.timer); for(const c of this.turnControllers)c.abort();this.turnControllers.clear();this.grants.clear(); this.pending.clear(); for (const s of this.sessions.values()) void this.endSession(s); this.sessions.clear(); this.client.close() }
 }

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

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

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

@@ -43,6 +43,7 @@ export class CredentialVaultClient {
   onDisconnect = () => {}
   constructor(private config?: { socket: string; tokenFile: string; hermesUid: number }) {}
   get configured() { return !!this.config }
+  close(): void {}
   private async send(owner: string, session: string, command: string, value?: unknown): Promise<any> {
     const c = this.config
     if (!c) throw new HttpError(503, '执行节点密码库尚未连接', 'vault_offline')

+ 45 - 0
src/server/dashboardCommand.ts

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

+ 100 - 24
src/server/dashboardController.ts

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

+ 4 - 0
src/server/routes.ts

@@ -356,6 +356,8 @@ function isMissingSessionResponse(response: UpstreamResponse): boolean {
     const payload = parseJson(response)
     if (payload.code === 'session_not_found') return true
     const detail = payload.detail
+    // Hermes also uses this detail for a stored session absent from its history.
+    if (typeof detail === 'string' && /^not found[.!]?$/i.test(detail.trim())) return true
     const message = [payload.error, payload.message, detail,
       detail && typeof detail === 'object' && 'message' in detail ? detail.message : undefined]
       .find(value => typeof value === 'string')
@@ -2305,6 +2307,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       if (isMissingSessionResponse(existing)) {
         // Only this account's cached copy remains; no upstream mutation is needed.
         dependencies.chatCache?.store.deleteSession(owner, profile, id)
+        dependencies.onChatListChanged?.(owner, profile, id)
         ctx.body = { ok: true }
         return
       }
@@ -2320,6 +2323,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
           profile,
           id,
         )
+        dependencies.onChatListChanged?.(owner, profile, id)
       }
       if (alreadyMissing) {
         ctx.body = { ok: true }

+ 2 - 1
src/server/security.ts

@@ -4,6 +4,7 @@ import type Koa from 'koa'
 import { parse, serialize } from 'cookie'
 import type { ServerConfig } from './config.js'
 import { isLoopbackHost, isPrivateHost } from './config.js'
+import { BOT_LOGO_ORIGINS } from '../shared/botProviderLogos.js'
 
 export const CSRF_COOKIE = 'hermes_yaoyao_csrf'
 
@@ -183,7 +184,7 @@ export function applySecurityHeaders(ctx: Koa.Context, tls: boolean, allowedHost
     "object-src 'none'",
     "script-src 'self'",
     "style-src 'self' 'unsafe-inline'",
-    "img-src 'self' data: blob:",
+    `img-src 'self' data: blob: ${BOT_LOGO_ORIGINS.join(' ')}`,
     "media-src 'self' blob:",
     `connect-src 'self' ${socketOrigin} ${proxySocketOrigins.join(' ')}`.trim(),
   ].join('; '))

+ 57 - 0
src/shared/botProviderLogos.ts

@@ -0,0 +1,57 @@
+import type { BotMcpPlugin } from './workspacePlugins.js'
+
+/** Bundled logos keep the built-in directory usable without a third-party request. */
+const providers: Record<string, { hosts: string[] }> = {
+  gmail: { hosts: ['gmail.com'] },
+  googledrive: { hosts: ['drive.google.com'] },
+  googlecalendar: { hosts: ['calendar.google.com'] },
+  github: { hosts: ['github.com', 'githubcopilot.com'] },
+  notion: { hosts: ['notion.so', 'notion.com'] },
+  slack: { hosts: ['slack.com'] },
+  linear: { hosts: ['linear.app'] },
+  discord: { hosts: ['discord.com'] },
+  outlook: { hosts: ['outlook.com', 'outlook.office.com'] },
+  trello: { hosts: ['trello.com'] },
+  airtable: { hosts: ['airtable.com'] },
+  twitter: { hosts: ['twitter.com', 'x.com'] },
+}
+const aliases: Record<string, string> = { gdrive: 'googledrive', x: 'twitter', microsoftoutlook: 'outlook' }
+
+export const BOT_LOGO_ORIGINS = ['https://logos.composio.dev', 'https://assets.composio.dev'] as const
+
+export function bundledProviderLogo(slug: string): string | undefined {
+  const key = slug.toLowerCase().replace(/[\s_-]/g, '')
+  const provider = aliases[key] ?? key
+  return Object.hasOwn(providers, provider) ? `/provider-logos/${provider}.svg` : undefined
+}
+
+/** Match the image CSP; never forward credentials or arbitrary image origins. */
+export function safeProviderLogo(value: unknown): string | undefined {
+  if (typeof value !== 'string' || value.length > 2048) return undefined
+  if (Object.keys(providers).some(slug => value === bundledProviderLogo(slug))) return value
+  try {
+    const url = new URL(value)
+    if (url.username || url.password || !BOT_LOGO_ORIGINS.some(origin => url.origin === origin)) return undefined
+    return url.href
+  } catch { return undefined }
+}
+
+export function mcpProviderLogo(plugin: BotMcpPlugin): string | undefined {
+  if (plugin.transport === 'http' && plugin.url) {
+    try {
+      const host = new URL(plugin.url).hostname.toLowerCase()
+      const provider = Object.entries(providers).find(([, { hosts }]) => hosts.some(domain => host === domain || host.endsWith(`.${domain}`)))
+      if (provider) return bundledProviderLogo(provider[0])
+    } catch { /* Older invalid definitions still get a readable placeholder. */ }
+  }
+  if (plugin.transport === 'stdio') {
+    for (const arg of [plugin.command ?? '', ...(plugin.args ?? [])]) {
+      const packageName = arg.toLowerCase().replace(/^--package=/, '').replace(/@[^/@]+$/, '')
+      if (packageName === '@notionhq/notion-mcp-server') return bundledProviderLogo('notion')
+      const slug = /^@modelcontextprotocol\/server-([a-z-]+)$/.exec(packageName)?.[1]
+      const logo = slug && bundledProviderLogo(slug)
+      if (logo) return logo
+    }
+  }
+  return bundledProviderLogo(plugin.name.replace(/\s*(?:mcp(?:\s*(?:server|服务))?|server|服务)$/i, '').trim())
+}

+ 1 - 0
src/shared/credentialVault.ts

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

+ 1 - 1
src/shared/workspacePlugins.ts

@@ -13,7 +13,7 @@ export interface BotMcpPlugin {
   testedAt?: number
   toolCount: number
 }
-export interface BotAppCard { slug: string; name: string; description: string }
+export interface BotAppCard { slug: string; name: string; description: string; logo?: string }
 export interface BotAppAccount { id: string; alias?: string; status: string }
 export interface BotAppConnection { slug: string; accounts: BotAppAccount[]; agentIds: string[]; revision: number }
 export interface BotPluginSettings { configured: boolean; revision: number }

+ 20 - 0
tests/client/botPluginsPanel.test.ts

@@ -36,6 +36,26 @@ it('retains connected accounts on failed refresh instead of presenting them as d
   expect(button(wrapper, '添加账号').exists()).toBe(true)
   wrapper.unmount()
 })
+it('shows the same supplier logo in the directory and connected list', async () => {
+  const wrapper = render(); await flushPromises()
+  expect(wrapper.get('.app-card .provider-logo img').attributes('src')).toBe('/provider-logos/gmail.svg')
+  await button(wrapper, '已连接 1').trigger('click')
+  expect(wrapper.get('.app-card .provider-logo img').attributes('src')).toBe('/provider-logos/gmail.svg')
+  wrapper.unmount()
+})
+it('shows a recognized MCP supplier logo and a placeholder for unknown services', async () => {
+  const request = vi.mocked(apiRequest).getMockImplementation()!
+  vi.mocked(apiRequest).mockImplementation(async (path, options) => path.endsWith('/mcp')
+    ? { plugins: [{ ...plugin, name: 'GitHub MCP', url: 'https://api.githubcopilot.com/mcp' }, { ...plugin, id: 'unknown' }], canManage } as never
+    : request(path, options))
+  const wrapper = render(); await flushPromises()
+  await button(wrapper, 'MCP 服务').trigger('click')
+  const logos = wrapper.findAll('.mcp-card .provider-logo')
+  expect(logos[0].get('img').attributes('src')).toBe('/provider-logos/github.svg')
+  expect(logos[1].find('img').exists()).toBe(false)
+  expect(logos[1].text()).toBe('现')
+  wrapper.unmount()
+})
 it('keeps secret placeholders and invalid form input without submitting a broken configuration', async () => {
   const wrapper = render(); await flushPromises()
   await button(wrapper, 'MCP 服务').trigger('click')

+ 61 - 3
tests/client/conversationRecovery.test.ts

@@ -1,18 +1,21 @@
 import { afterEach, beforeEach, expect, it, vi } from 'vitest'
 import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
-import { shallowRef } from 'vue'
+import { defineComponent, shallowRef } from 'vue'
 import { createMemoryHistory, createRouter, matchedRouteKey } from 'vue-router'
 import ConversationsView from '@/views/ConversationsView.vue'
 import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
 import { apiRequest } from '@/api/client'
+import { useUnreadStore } from '@/stores/unread'
 
 vi.mock('@/api/client', async importOriginal => ({ ...await importOriginal<typeof import('@/api/client')>(), apiRequest: vi.fn(), setApiCsrfToken: vi.fn() }))
 vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner' }, profiles: [], refreshProfileAvatars: async () => {} }) }))
 vi.mock('@/stores/theme', () => ({ useThemeStore: () => ({}) }))
 let wrapper: VueWrapper | undefined
 const sources: Array<{ url: string; close: ReturnType<typeof vi.fn> }> = []
+const scrollToMessage = vi.fn(() => true)
 beforeEach(() => {
   vi.useFakeTimers()
+  useUnreadStore().reset(); scrollToMessage.mockReset().mockReturnValue(true)
   sources.length = 0
   vi.stubGlobal('EventSource', class extends EventTarget {
     readonly close = vi.fn()
@@ -21,14 +24,20 @@ beforeEach(() => {
 })
 afterEach(() => {
   wrapper?.unmount(); wrapper = undefined
+  useUnreadStore().reset()
   vi.mocked(apiRequest).mockReset(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers()
 })
-async function mountView(path = '/conversations') {
+async function mountView(path = '/conversations', stubTimeline = false) {
   const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/conversations/:id?', component: { template: '<div />' } }] })
   await router.push(path); await router.isReady()
   wrapper = shallowMount(ConversationsView, { global: {
     provide: { [matchedRouteKey as symbol]: shallowRef(router.currentRoute.value.matched[0]) }, plugins: [router],
-    stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, ComposerShell: { template: '<div />', methods: { filesSnapshot: () => [] } }, Teleport: true },
+    stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, ComposerShell: { template: '<div />', methods: { filesSnapshot: () => [] } }, Teleport: true,
+      ...(stubTimeline ? { MessageTimeline: defineComponent({
+        name: 'WorkspaceMessageTimeline', props: ['messages', 'title'], emits: ['visibleMessages'], template: '<div />',
+        methods: { scrollToMessage, scrollToBottom() {}, isFollowingBottom: () => true },
+      }) } : {}),
+    },
   } })
   await flushPromises()
 }
@@ -111,3 +120,52 @@ it('aborts a pending detail on going offline and ignores its late response', asy
   expect(wrapper!.findComponent(WorkspaceMessageTimeline).props('title')).not.toBe('迟到会话')
   expect(sources).toHaveLength(0)
 })
+
+it.each([true, false])('resumes visible-message reads when entering the already-open conversation (target found: %s)', async found => {
+  const unread = useUnreadStore()
+  const pending = {
+    total: 2, bot: 2, chat: 0, conversations: [{ mode: 'bot' as const, id: 'c', name: '研发助手', preview: '回复', count: 2, updatedAt: 1,
+      messages: [{ id: 'first', seq: 1, version: 1 }, { id: 'second', seq: 2, version: 1 }],
+    }],
+  }
+  vi.mocked(apiRequest).mockImplementation(async path => {
+    if (path === '/api/app/unread') return pending as never
+    if (path === '/api/app/unread/read') return { ...pending, total: 1, bot: 1,
+      conversations: [{ ...pending.conversations[0], count: 1, messages: [pending.conversations[0]!.messages[1]] }],
+    } as never
+    if (path.startsWith('/api/app/conversations/c')) return {
+      conversation: { id: 'c', kind: 'direct', name: '研发助手', memberIds: [], readSeq: 0, lastSeq: 2 },
+      messages: ['first', 'second'].map(id => ({ id, role: 'assistant', status: 'complete', content: '回复', attachments: [], tools: [], createdAt: 1 })),
+      hasOlder: false, run: null, interactions: [], context: null,
+    } as never
+    return successfulResponse(path) as never
+  })
+  unread.start('owner'); await flushPromises()
+  await mountView('/conversations/c', true)
+  expect(wrapper!.findComponent(WorkspaceMessageTimeline).props('title')).toBe('研发助手')
+  scrollToMessage.mockReturnValue(found)
+  await wrapper!.vm.$router.push({ query: { unread: found ? 'first' : 'removed-message' } }); await flushPromises()
+  expect(scrollToMessage).toHaveBeenCalledWith(found ? 'first' : 'removed-message')
+  expect(wrapper!.vm.$router.currentRoute.value.query.unread).toBeUndefined()
+  wrapper!.findComponent(WorkspaceMessageTimeline).vm.$emit('visibleMessages', ['first'])
+  await flushPromises()
+  expect(unread.total).toBe(1)
+  expect(unread.snapshot.conversations[0]?.messages.map(message => message.id)).toEqual(['second'])
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/unread/read', expect.objectContaining({
+    body: { items: [{ mode: 'bot', id: 'c', messages: [{ id: 'first', seq: 1, version: 1 }] }] },
+  }))
+})
+
+it('locates an unread target after opening and loading a different conversation', async () => {
+  vi.mocked(apiRequest).mockImplementation(async path => {
+    if (path.startsWith('/api/app/conversations/c')) return {
+      conversation: { id: 'c', kind: 'direct', name: '研发助手', memberIds: [], readSeq: 0, lastSeq: 1 },
+      messages: [{ id: 'first', role: 'assistant', status: 'complete', content: '回复', attachments: [], tools: [], createdAt: 1 }],
+      hasOlder: false, run: null, interactions: [], context: null,
+    } as never
+    return successfulResponse(path) as never
+  })
+  await mountView('/conversations/c?unread=first', true)
+  expect(scrollToMessage).toHaveBeenCalledWith('first')
+  expect(wrapper!.vm.$router.currentRoute.value.query.unread).toBeUndefined()
+})

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

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

+ 37 - 0
tests/client/providerLogo.test.ts

@@ -0,0 +1,37 @@
+import { mount } from '@vue/test-utils'
+import { expect, it } from 'vitest'
+import ProviderLogo from '@/components/common/ProviderLogo.vue'
+import { mcpProviderLogo } from '@shared/botProviderLogos'
+import type { BotMcpPlugin } from '@shared/workspacePlugins'
+
+it('uses bundled logos offline and falls back through the catalog logo to an initial', async () => {
+  const remote = 'https://logos.composio.dev/api/github'
+  const wrapper = mount(ProviderLogo, { props: { name: 'GitHub', logo: remote, bundledLogo: '/provider-logos/github.svg' } })
+  expect(wrapper.get('img').attributes('src')).toBe('/provider-logos/github.svg')
+  await wrapper.get('img').trigger('error')
+  expect(wrapper.get('img').attributes('src')).toBe(remote)
+  expect(wrapper.get('img').attributes('referrerpolicy')).toBe('no-referrer')
+  await wrapper.get('img').trigger('error')
+  expect(wrapper.find('img').exists()).toBe(false)
+  expect(wrapper.text()).toBe('G')
+  await wrapper.setProps({ logo: 'https://logos.composio.dev/api/github?version=2' })
+  expect(wrapper.get('img').attributes('src')).toBe('/provider-logos/github.svg')
+  wrapper.unmount()
+})
+
+it.each(['javascript:alert(1)', 'https://unknown.example/logo.svg', 'https://user:secret@logos.composio.dev/api/github', '//logos.composio.dev/api/github'])('keeps a readable placeholder for an unsupported logo: %s', logo => {
+  const wrapper = mount(ProviderLogo, { props: { name: '供应商', logo } })
+  expect(wrapper.find('img').exists()).toBe(false)
+  expect(wrapper.text()).toBe('供')
+  wrapper.unmount()
+})
+
+it.each([
+  [{ name: '工作代码', transport: 'http', url: 'https://api.githubcopilot.com/mcp' }, 'github'],
+  [{ name: '工作文档', transport: 'http', url: 'https://mcp.notion.com/mcp' }, 'notion'],
+  [{ name: '团队沟通', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack@1.0.0'] }, 'slack'],
+  [{ name: 'GitHub MCP 服务', transport: 'stdio', command: 'local-mcp' }, 'github'],
+  [{ name: '未知工具', transport: 'http', url: 'https://github.com.example.test/mcp' }, undefined],
+] as const)('recognizes MCP providers without matching lookalike hosts: %o', (definition, provider) => {
+  expect(mcpProviderLogo(definition as unknown as BotMcpPlugin)).toBe(provider ? `/provider-logos/${provider}.svg` : undefined)
+})

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

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

+ 78 - 0
tests/client/unreadStore.test.ts

@@ -0,0 +1,78 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { flushPromises } from '@vue/test-utils'
+import { apiRequest } from '@/api/client'
+import { useUnreadStore } from '@/stores/unread'
+import { emptyUnread, type UnreadSnapshot } from '@shared/unread'
+
+vi.mock('@/api/client', () => ({ apiRequest: vi.fn() }))
+
+const unread = useUnreadStore()
+function snapshot(ids: string[]): UnreadSnapshot {
+  return { total: ids.length, bot: ids.length, chat: 0, conversations: ids.length ? [{
+    mode: 'bot', id: 'c', name: '研发助手', preview: '回复', count: ids.length, updatedAt: 1,
+    messages: ids.map((id, seq) => ({ id, seq, version: 1 })),
+  }] : [] }
+}
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>(done => { resolve = done })
+  return { promise, resolve }
+}
+beforeEach(() => { vi.useFakeTimers(); unread.reset(); vi.mocked(apiRequest).mockReset() })
+afterEach(() => { unread.reset(); vi.restoreAllMocks(); vi.useRealTimers() })
+
+it('updates the badge from the read acknowledgement without waiting for another GET', async () => {
+  const initial = snapshot(['first', 'second'])
+  vi.mocked(apiRequest).mockResolvedValueOnce(initial)
+  unread.start('owner'); await flushPromises()
+  vi.mocked(apiRequest).mockImplementation(async path => {
+    if (path === '/api/app/unread/read') return snapshot(['second']) as never
+    throw new Error('后续同步暂不可用')
+  })
+  await unread.visible('bot', 'c', undefined, ['first'])
+  expect(unread.total).toBe(1)
+  expect(unread.snapshot.conversations[0]?.messages.map(message => message.id)).toEqual(['second'])
+  expect(apiRequest).toHaveBeenLastCalledWith('/api/app/unread/read', expect.objectContaining({
+    body: { items: [{ mode: 'bot', id: 'c', messages: [{ id: 'first', seq: 0, version: 1 }] }] },
+  }))
+  await unread.visible('bot', 'c', undefined, ['first'])
+  expect(apiRequest).toHaveBeenCalledTimes(2)
+})
+
+it('does not let a poll started before reading restore already-read messages', async () => {
+  const initial = snapshot(['first', 'second']), poll = deferred<UnreadSnapshot>()
+  vi.mocked(apiRequest).mockResolvedValueOnce(initial)
+  unread.start('owner'); await flushPromises()
+  vi.mocked(apiRequest).mockImplementation(path => path === '/api/app/unread'
+    ? poll.promise as never : Promise.resolve(snapshot(['second'])) as never)
+  const polling = unread.refresh()
+  await unread.visible('bot', 'c', undefined, ['first'])
+  poll.resolve(initial); await polling
+  expect(unread.total).toBe(1)
+})
+
+it('reconciles concurrent read acknowledgements that arrive in reverse order', async () => {
+  const initial = snapshot(['first', 'second'])
+  const first = deferred<UnreadSnapshot>(), second = deferred<UnreadSnapshot>()
+  vi.mocked(apiRequest).mockResolvedValueOnce(initial)
+  unread.start('owner'); await flushPromises()
+  vi.mocked(apiRequest).mockReturnValueOnce(first.promise as never).mockReturnValueOnce(second.promise as never).mockResolvedValue(emptyUnread())
+  const readingFirst = unread.visible('bot', 'c', undefined, ['first'])
+  const readingSecond = unread.visible('bot', 'c', undefined, ['second'])
+  second.resolve(emptyUnread()); await readingSecond
+  first.resolve(snapshot(['second'])); await readingFirst
+  expect(unread.total).toBe(0)
+  expect(unread.snapshot.conversations).toEqual([])
+})
+
+it('ignores a read acknowledgement after the account changes', async () => {
+  const pending = deferred<UnreadSnapshot>()
+  vi.mocked(apiRequest).mockResolvedValueOnce(snapshot(['first']))
+  unread.start('owner'); await flushPromises()
+  vi.mocked(apiRequest).mockReturnValueOnce(pending.promise as never)
+  const reading = unread.visible('bot', 'c', undefined, ['first'])
+  unread.reset('another-owner'); unread.snapshot = snapshot(['new-message'])
+  pending.resolve(emptyUnread()); await reading
+  expect(unread.total).toBe(1)
+  expect(unread.snapshot.conversations[0]?.messages[0]?.id).toBe('new-message')
+})

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

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

+ 102 - 0
tests/e2e/session-deletion.spec.ts

@@ -0,0 +1,102 @@
+import { expect, test, type Page } from '@playwright/test'
+
+async function login(page: Page) {
+  await page.goto('/chat')
+  await page.getByLabel('账号').fill('admin')
+  await page.getByLabel('密码').fill('e2e-password')
+  await page.getByRole('button', { name: '登录', exact: true }).click()
+  await expect(page.getByRole('navigation').first()).toBeVisible()
+}
+
+async function openDeleteDialog(page: Page) {
+  await page.getByRole('button', { name: '会话操作', exact: true }).click()
+  await page.getByRole('menuitem', { name: '删除会话', exact: true }).click()
+  const dialog = page.getByRole('dialog', { name: '删除会话', exact: true })
+  await expect(dialog).toBeVisible()
+  return dialog
+}
+
+for (const mobile of [false, true]) {
+  test.describe(mobile ? 'mobile ordinary chat deletion' : 'desktop ordinary chat deletion', () => {
+    test.use({ hasTouch: mobile })
+
+    test('cancels safely and deletes a locally retained session missing from Hermes', async ({ page }, testInfo) => {
+      await login(page)
+      await page.getByRole('button', { name: '新建聊天', exact: true }).click()
+      await page.locator('.composer-textarea').fill('删除流程验收')
+      await page.getByRole('button', { name: '发送消息' }).click()
+      await expect(page).toHaveURL(/\/chat\/session-new/)
+      await expect(page.locator('.message__content').filter({ hasText: '这是来自假 Gateway 的流式回复。' })).toHaveCount(1)
+      if (mobile) await page.setViewportSize({ width: 375, height: 812 })
+      const originalURL = page.url()
+      const deletions: string[] = []
+      page.on('request', request => {
+        if (request.method() === 'DELETE') deletions.push(request.url())
+      })
+
+      let dialog = await openDeleteDialog(page)
+      await expect(dialog.getByRole('button', { name: '取消', exact: true })).toBeVisible()
+      await page.screenshot({ path: testInfo.outputPath('delete-confirmation.png') })
+      if (mobile) await dialog.getByRole('button', { name: '取消', exact: true }).tap()
+      else await dialog.getByRole('button', { name: '取消', exact: true }).click()
+      await expect(dialog).toBeHidden()
+      expect(page.url()).toBe(originalURL)
+      expect(deletions).toEqual([])
+      await expect(page.getByRole('button', { name: '会话操作', exact: true })).toBeFocused()
+
+      dialog = await openDeleteDialog(page)
+      await page.keyboard.press('Escape')
+      await expect(dialog).toBeHidden()
+      expect(deletions).toEqual([])
+
+      dialog = await openDeleteDialog(page)
+      const removed = page.waitForResponse(response => response.request().method() === 'DELETE')
+      if (mobile) await dialog.getByRole('button', { name: '删除', exact: true }).tap()
+      else await dialog.getByRole('button', { name: '删除', exact: true }).click()
+      expect((await removed).ok()).toBe(true)
+      await expect(dialog).toBeHidden()
+      await expect(page).toHaveURL(/\/chat\?profile=yaoyao$/)
+      expect(deletions).toHaveLength(1)
+      await page.reload()
+      const exists = await page.evaluate(async () => {
+        const response = await fetch('/api/app/sessions?view=chat&profile=yaoyao')
+        const body = await response.json()
+        return body.sessions.some((session: { id: string }) => session.id === 'session-new')
+      })
+      expect(exists).toBe(false)
+    })
+
+    test('shows a failure, blocks duplicate requests, and lets the user cancel or retry', async ({ page }) => {
+      await login(page)
+      await page.goto('/chat/session-demo?profile=yaoyao')
+      if (mobile) await page.setViewportSize({ width: 375, height: 812 })
+      const originalURL = page.url()
+      let finish!: () => void
+      let attempts = 0
+      await page.route('**/api/app/sessions/session-demo?profile=yaoyao', async route => {
+        if (route.request().method() !== 'DELETE') return route.continue()
+        attempts++
+        await new Promise<void>(resolve => { finish = resolve })
+        await route.fulfill({ status: 503, json: { error: '删除服务暂时不可用,请重试' } })
+      })
+
+      const dialog = await openDeleteDialog(page)
+      await dialog.getByRole('button', { name: '删除', exact: true }).click()
+      await expect(dialog.getByRole('button', { name: '删除中…', exact: true })).toBeDisabled()
+      await expect(dialog.getByRole('button', { name: '取消', exact: true })).toBeDisabled()
+      await page.keyboard.press('Escape')
+      await expect(dialog).toBeVisible()
+      await expect.poll(() => attempts).toBe(1)
+      finish()
+      await expect(dialog.getByRole('alert')).toHaveText('删除服务暂时不可用,请重试')
+      expect(page.url()).toBe(originalURL)
+      await dialog.getByRole('button', { name: '删除', exact: true }).click()
+      await expect.poll(() => attempts).toBe(2)
+      finish()
+      await expect(dialog.getByRole('alert')).toBeVisible()
+      await dialog.getByRole('button', { name: '取消', exact: true }).click()
+      await expect(dialog).toBeHidden()
+      expect(page.url()).toBe(originalURL)
+    })
+  })
+}

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

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

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

@@ -11,10 +11,11 @@ import type { ServerConfig } from '../../src/server/config'
 const runtimes:ApplicationRuntime[]=[],homes:string[]=[]
 function context():Koa.Context{const headers:Record<string,any>={};return {state:{},get:()=>'',set:(k:string,v:any)=>{headers[k.toLowerCase()]=v},response:{headers}} as any}
 function cookie(response:any){const values=response.headers['set-cookie'];return (Array.isArray(values)?values:[values]).filter(Boolean).map((v:string)=>v.split(';')[0]).join('; ')}
-async function fixture(){
+async function fixture(external=false){
   const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-routes-'));homes.push(home)
   const auth=new LocalAuthStore(home),login=context();auth.setupAdmin(login,'dummy-admin','DUMMY-only-login-password')
   const config={host:'127.0.0.1',port:15300,upstream:new URL('http://127.0.0.1:9119'),allowedHosts:new Set(),home,mediaRoot:join(home,'media'),attachmentsRoot:join(home,'attachments'),imagesRoot:join(home,'images'),mediaOwner:'dummy',allowInsecureLan:false,insecureLan:false,production:true} as ServerConfig
+  if(external)config.credentialVault={socket:join(home,'missing.sock'),tokenFile:join(home,'missing.token'),hermesUid:process.getuid!()+1}
   const fetchImpl=vi.fn(async()=>new Response(JSON.stringify({profiles:['default']}),{headers:{'content-type':'application/json'}})) as unknown as typeof fetch
   const runtime=createApplication({config,auth,fetchImpl});runtimes.push(runtime)
   const callback=runtime.app.callback(),bootstrap=await request(callback).get('/api/app/bootstrap?inspectOnly=1').set('Host','127.0.0.1:15300').set('Cookie',cookie(login.response)).expect(200)
@@ -22,8 +23,8 @@ async function fixture(){
   return {runtime,callback,fetchImpl,cookies,token,mutation:(path:string)=>request(callback).post(path).set('Host','127.0.0.1:15300').set('Cookie',cookies).set('Origin','http://127.0.0.1:15300').set('X-CSRF-Token',token)}
 }
 afterEach(()=>{runtimes.splice(0).forEach(r=>r.close());homes.splice(0).forEach(h=>rmSync(h,{recursive:true,force:true}));vi.restoreAllMocks()})
-it('vault routes enforce authentication, exact Origin and CSRF and stay offline without configuration',async()=>{
-  const f=await fixture()
+it('vault routes enforce authentication, exact Origin and CSRF and keep an unavailable explicit broker offline',async()=>{
+  const f=await fixture(true)
   await request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').expect(401)
   const status=await request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').set('Cookie',f.cookies).expect(200)
   expect(status.body).toMatchObject({online:false,unlocked:false,execution:'disabled',entries:[]});expect(status.headers['cache-control']).toBe('no-store')
@@ -32,6 +33,21 @@ it('vault routes enforce authentication, exact Origin and CSRF and stay offline
   const response=await f.mutation('/api/app/vault/unlock').send({password:'DUMMY-master'}).expect(503)
   expect(response.body.code).toBe('vault_offline');expect(JSON.stringify(response.body)).not.toContain('DUMMY-master');expect(f.fetchImpl).not.toHaveBeenCalled()
 })
+it('lets a logged-in administrator create, unlock and add a password in the default installation',async()=>{
+  const f=await fixture(),password='DUMMY-default-vault-master',secret='DUMMY-default-entry-secret'
+  const status=()=>request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').set('Cookie',f.cookies)
+  expect((await status().expect(200)).body).toMatchObject({online:true,initialized:false,storageMode:'local'})
+  await request(f.callback).post('/api/app/vault/initialize').set('Host','127.0.0.1:15300').set('Cookie',f.cookies).set('Origin','http://127.0.0.1:15300').send({password}).expect(403)
+  await f.mutation('/api/app/vault/initialize').send({password}).expect(200)
+  expect((await status().expect(200)).body.unlocked).toBe(false)
+  await f.mutation('/api/app/vault/unlock').send({password}).expect(200)
+  const added=await f.mutation('/api/app/vault/entries').send({name:'Default site',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret}).expect(200)
+  expect(added.body).not.toHaveProperty('secret')
+  const unlocked=await status().expect(200);expect(unlocked.body.entries).toHaveLength(1);expect(JSON.stringify(unlocked.body)).not.toContain(secret)
+  await f.mutation('/api/app/vault/lock').send({}).expect(200)
+  expect((await status().expect(200)).body).toMatchObject({unlocked:false,entries:[]})
+  expect(f.fetchImpl).not.toHaveBeenCalled()
+})
 it.each(['/api/app/logout','/auth/logout'])('logout path %s revokes vault before invalidating the authenticated session',async path=>{
   const f=await fixture(),logout=vi.spyOn(f.runtime.credentialVault,'logout').mockImplementation(async(owner,c)=>{expect(f.runtime.auth.currentFromCookieHeader(c)?.id).toBe(owner)})
   await f.mutation(path).send({}).expect(200);expect(logout).toHaveBeenCalledOnce()

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

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

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

@@ -78,6 +78,15 @@ describe('server security boundary', () => {
     expect(headers['Content-Security-Policy']).toContain('ws://yaoyao-lc.samien.cn')
     expect(headers['Content-Security-Policy']).toContain('wss://yaoyao-lc.samien.cn')
   })
+  it('allows the supplier logo CDNs only in the image policy', () => {
+    const headers: Record<string, string> = {}
+    const ctx = { host: '127.0.0.1:15300', set(name: string, value: string) { headers[name] = value } } as unknown as Koa.Context
+    applySecurityHeaders(ctx, false)
+    const directives = headers['Content-Security-Policy'].split('; ')
+    expect(directives.find(value => value.startsWith('img-src '))).toBe("img-src 'self' data: blob: https://logos.composio.dev https://assets.composio.dev")
+    expect(directives.find(value => value.startsWith('script-src '))).toBe("script-src 'self'")
+    expect(directives.find(value => value.startsWith('connect-src '))).not.toContain('composio.dev')
+  })
 
   it('accepts configured IPv6 addresses and brackets them in WebSocket CSP origins', () => {
     const allowed = new Set(['2001:db8::10'])

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

@@ -77,6 +77,7 @@ describe('deleting sessions retained only in cache', () => {
     { error: 'session 不存在' },
     { message: '会话不存在' },
     { detail: 'Session not found: cached-session' },
+    { detail: 'Not found' },
     { detail: { message: 'Session not found' } },
     { code: 'session_not_found' },
   ])('cleans a missing session and keeps deletion idempotent: %j', async payload => {

+ 20 - 0
tests/server/workspacePlugins.test.ts

@@ -202,6 +202,26 @@ require('node:readline').createInterface({ input: process.stdin }).on('line', li
 })
 
 describe('account-owned connected applications', () => {
+  it('includes bundled supplier logos before configuring the connection service', async () => {
+    const result = await api('get', '/apps/catalog').expect(200)
+    expect(result.body.source).toBe('curated')
+    expect(result.body.cards).toHaveLength(12)
+    expect(result.body.cards.every((card: any) => card.logo === `/provider-logos/${card.slug}.svg`)).toBe(true)
+  })
+  it('preserves catalog logos from the approved image origins and falls back for invalid metadata', async () => {
+    await configure()
+    rejectRequest = url => url.includes('/toolkits?') ? response({ items: [
+      { slug: 'github', name: 'GitHub', meta: { logo: 'https://logos.composio.dev/api/github' } },
+      { slug: 'customapp', name: 'Custom', logo: 'https://assets.composio.dev/logos/custom.png' },
+      { slug: 'gmail', name: 'Gmail', meta: { logo: 'javascript:alert(1)' } },
+      { slug: 'unsafeapp', name: 'Unsafe', meta: { logo: 'https://logos.composio.dev.example.test/logo.svg' } },
+      { slug: 'credentialapp', name: 'Credentials', meta: { logo: 'https://user:secret@logos.composio.dev/api/secret' } },
+    ] }) : undefined
+    const result = await api('get', '/apps/catalog').expect(200)
+    expect(result.body.cards.map((card: any) => card.logo)).toEqual([
+      'https://logos.composio.dev/api/github', 'https://assets.composio.dev/logos/custom.png', '/provider-logos/gmail.svg', undefined, undefined,
+    ])
+  })
   it('supports catalog, OAuth links, multiple accounts, Bot grants and scoped disconnect', async () => {
     await configure(); await configure('member')
     const ownerSettings = runtime.workspace.list('owner', 'bot-plugin-settings')

+ 5 - 2
tests/server/workspaceRoutes.test.ts

@@ -777,7 +777,8 @@ it('aggregates per-agent daily token usage for today, month and lifetime', async
     pad = (n: number) => String(n).padStart(2, '0'),
     today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
     month = today.slice(0, 7),
-    older = `${month}-01`
+    earlier = new Date(now.getFullYear(), now.getMonth(), now.getDate() === 1 ? 0 : 1),
+    older = `${earlier.getFullYear()}-${pad(earlier.getMonth() + 1)}-${pad(earlier.getDate())}`
   const seed = (agentId: string, date: string, input: number, output: number) =>
     store.put('first', 'agent-token-day', `${agentId}:${date}`, { agentId, date, input, output, total: input + output, updatedAt: 1 })
   seed(agent.id, today, 300, 100)
@@ -786,7 +787,9 @@ it('aggregates per-agent daily token usage for today, month and lifetime', async
   const usage = (await req('get', `/api/app/agents/${agent.id}/usage`).expect(200)).body
   expect(usage.today).toBe(today)
   expect(usage.todayUsage).toEqual({ input: 300, output: 100, total: 400 })
-  expect(usage.monthUsage).toEqual({ input: 2300, output: 1100, total: 3400 })
+  expect(usage.monthUsage).toEqual(older.startsWith(month)
+    ? { input: 2300, output: 1100, total: 3400 }
+    : { input: 300, output: 100, total: 400 })
   expect(usage.totalUsage).toEqual({ input: 2300, output: 1100, total: 3400 })
   expect(usage.daily.map((d: any) => d.date)).toEqual([today, older])
   await req('get', `/api/app/agents/${randomUUID()}/usage`).expect(404)

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä