1
0

9 Коммиты 476e8f56d9 ... 050ccf085c

Автор SHA1 Сообщение Дата
  Samien 050ccf085c 修正桌面 E2E 凭据夹具并覆盖双构建源码入口 1 месяц назад
  Samien b633c94a37 修正断线恢复测试对异步状态的等待 1 месяц назад
  Samien 762fbd6659 发布 v0.4.77:更新客户端与服务端修复并排除完整版 1 месяц назад
  Samien cdad1cb528 完善停止兼容说明与 Node 26 回归测试环境 1 месяц назад
  Samien 26b6bd9815 修复 Bot 删除重试与原执行停止确认生命周期 1 месяц назад
  Samien 4cbb25c354 修复归档文件授权与登出后的 Bot 事件连接 1 месяц назад
  Samien d3f2524e67 修复记忆合成账号饥饿并限制并行读取 1 месяц назад
  Samien 39d4263dc3 修复实时命令过期去重与持久回执恢复 1 месяц назад
  Samien d88cf86794 修复拆分部署后 Bot 的服务端环境识别 1 месяц назад
62 измененных файлов с 1344 добавлено и 171 удалено
  1. 5 5
      README.md
  2. 6 2
      desktop/main.mjs
  3. 31 0
      desktop/source-entry.test.mjs
  4. 17 18
      desktop/test-support/onboarding.mjs
  5. 1 1
      docker.env.example
  6. 5 1
      docs/desktop-hosts.md
  7. 7 7
      docs/docker-install.md
  8. 3 1
      docs/http-sse-realtime.md
  9. 45 0
      docs/releases/v0.4.77.md
  10. 1 0
      docs/runner.md
  11. 4 2
      integrations/hermes-bots-bridge/README.md
  12. 21 2
      integrations/hermes-bots-bridge/bridge_runtime.py
  13. 1 1
      integrations/hermes-bots-bridge/plugin.yaml
  14. 60 0
      integrations/hermes-bots-bridge/tests/test_runtime_environment.py
  15. 2 2
      package-lock.json
  16. 4 4
      package.json
  17. 3 3
      release.json
  18. 10 2
      src/runner/agent.ts
  19. 30 16
      src/runner/worker/gateway.ts
  20. 2 10
      src/runner/worker/profileSession.ts
  21. 2 2
      src/server/desktopEnvironments.ts
  22. 1 1
      src/server/fileAccess.ts
  23. 16 0
      src/server/hermesRuntimeEnvironment.ts
  24. 21 8
      src/server/openVikingMemoryProvider.ts
  25. 32 14
      src/server/openVikingService.ts
  26. 1 1
      src/server/realtimeBroker.ts
  27. 37 8
      src/server/realtimeReceipts.ts
  28. 3 3
      src/server/runnerHub.ts
  29. 22 0
      src/server/sessionStop.ts
  30. 2 1
      src/server/taskCoordinator.ts
  31. 47 12
      src/server/workspaceAssets.ts
  32. 3 1
      src/server/workspaceEnvironment.ts
  33. 5 2
      src/server/workspaceMemorySynthesis.ts
  34. 5 1
      src/server/workspacePrompt.ts
  35. 19 13
      src/server/workspaceRoutes.ts
  36. 54 7
      src/server/workspaceRuntime.ts
  37. 4 1
      src/server/workspaceScheduler.ts
  38. 13 2
      src/server/workspaceStore.ts
  39. 4 1
      src/server/workspaceSync.ts
  40. 18 3
      src/shared/botEnvironment.ts
  41. 3 1
      tests/server/messageFiles.test.ts
  42. 40 0
      tests/server/openViking.test.ts
  43. 51 0
      tests/server/openVikingMemoryRead.test.ts
  44. 58 2
      tests/server/profileComputer.test.ts
  45. 92 0
      tests/server/realtimeReceipts.test.ts
  46. 3 1
      tests/server/runner.test.ts
  47. 21 0
      tests/server/sessionStop.test.ts
  48. 5 0
      tests/server/taskCoordinator.test.ts
  49. 89 1
      tests/server/workspace.test.ts
  50. 149 0
      tests/server/workspaceAssetsAuthorization.test.ts
  51. 14 0
      tests/server/workspaceEnvironment.test.ts
  52. 97 0
      tests/server/workspaceLifecycleCleanup.test.ts
  53. 21 0
      tests/server/workspaceMemorySynthesis.test.ts
  54. 9 2
      tests/server/workspaceMessagePatch.test.ts
  55. 2 2
      tests/server/workspacePanels.test.ts
  56. 20 0
      tests/server/workspacePrompt.test.ts
  57. 19 0
      tests/server/workspaceRoutes.test.ts
  58. 1 1
      tests/server/workspaceRoutineTools.test.ts
  59. 1 1
      tests/server/workspaceSync.test.ts
  60. 72 0
      tests/server/workspaceSyncAuthorization.test.ts
  61. 1 1
      tests/server/workspaceTeamTools.test.ts
  62. 9 1
      tests/setup.ts

+ 5 - 5
README.md

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

+ 6 - 2
desktop/main.mjs

@@ -25,14 +25,18 @@ const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
 // Packaged: the runtime sits in Resources/runtime; dev: staged build roots.
 const clientOnly = app.isPackaged
   ? clientOnlyRuntime(join(process.resourcesPath, 'runtime'))
-  : clientOnlyRuntime(join(app.getAppPath(), '.desktop-build-client'))
+  : process.env.HERMES_YAOYAO_DESKTOP_CLIENT_ONLY === '0'
+    ? false
+    : clientOnlyRuntime(join(app.getAppPath(), '.desktop-build-client'))
 const platform = desktopPlatform(process.platform, { clientOnly })
 const loginOptions = loginItemOptions()
 // Fixture identity also isolates Chromium cookies, window state and the app lock.
 // The shell package name distinguishes the variants: the client keeps the
 // historical 夭夭 identity (userData, logs, single-instance lock) so upgrades
 // stay in place, while the full dual-mode build uses its own 夭夭完整版 home.
-const productName = JSON.parse(readFileSync(join(desktopRoot, 'package.json'), 'utf8')).productName || '夭夭'
+const shellManifest = app.isPackaged ? join(desktopRoot, 'package.json')
+  : join(app.getAppPath(), clientOnly ? '.desktop-build-client' : '.desktop-build', 'shell', 'package.json')
+const productName = JSON.parse(readFileSync(shellManifest, 'utf8')).productName || '夭夭'
 if (fixtureHome) app.setPath('userData', join(fixtureHome, 'desktop'))
 app.setName(productName)
 if (platform.windows) app.setAppUserModelId('cn.samien.yaoyao.desktop')

+ 31 - 0
desktop/source-entry.test.mjs

@@ -0,0 +1,31 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { _electron as electron } from '@playwright/test'
+
+const root = resolve(import.meta.dirname, '..')
+// Both staged variants coexist after packaging. The source entry must use the
+// requested variant's manifest and policy without moving either build marker.
+for (const clientOnly of [false, true]) {
+  test(`source entry selects the ${clientOnly ? 'client' : 'full'} staged variant`, { timeout: 45000 }, async () => {
+    const home = await mkdtemp(join(tmpdir(), 'yaoyao-source-entry-'))
+    let app
+    try {
+      app = await electron.launch({ args: [root], cwd: root, env: { ...process.env,
+        HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_TEST_MODE: 'ask',
+        HERMES_YAOYAO_DESKTOP_CLIENT_ONLY: clientOnly ? '1' : '0', HERMES_YAOYAO_DESKTOP_PORT: '1',
+        HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0' }, timeout: 30000 })
+      const page = await app.firstWindow()
+      await page.waitForURL(/boot\.html$/)
+      assert.equal(await app.evaluate(({ app }) => app.getName()), clientOnly ? '夭夭' : '夭夭完整版')
+      const state = await page.evaluate(() => window.yaoyaoDesktop.status())
+      assert.deepEqual(state.supportedModes, !clientOnly && process.platform === 'darwin' ? ['client', 'server'] : ['client'])
+      assert.equal(state.mode, !clientOnly && process.platform === 'darwin' ? null : 'remote')
+    } finally {
+      await app?.close().catch(() => {})
+      await rm(home, { recursive: true, force: true })
+    }
+  })
+}

+ 17 - 18
desktop/test-support/onboarding.mjs

@@ -1,22 +1,21 @@
 /** Enter through the native local-admin flow, then set isolated fixture credentials. */
 export async function enterLocal(page, { username = 'desktop-fixture', password = 'desktop-fixture-password' } = {}) {
   await page.waitForURL(url => url.protocol === 'http:' || url.protocol === 'https:', { timeout: 90000 })
-  await page.evaluate(async ({username,password}) => {
-    const bootstrap = await (await fetch('/api/app/bootstrap')).json()
-    if (!bootstrap.user?.localDesktop) throw new Error('Native administrator session was not established')
-    const response = await fetch('/api/app/account/credentials', { method: 'PUT',
-      headers: {'Content-Type':'application/json','X-CSRF-Token':bootstrap.csrfToken},
-      body: JSON.stringify({username,currentPassword:'',newPassword:password}) })
-    if (!response.ok) throw new Error('Fixture account configuration failed')
-  }, {username,password})
-  // The native service may finish its own navigation after credentials rotate.
-  // Retry only interrupted navigation; all other browser failures remain errors.
-  const url = new URL('/conversations', page.url()).href
-  for (let attempt = 0; ; attempt++) {
-    try { await page.goto(url); break }
-    catch (error) {
-      if (attempt >= 2 || !String(error.message).includes('net::ERR_ABORTED')) throw error
-      await page.waitForLoadState('load')
-    }
-  }
+  // Rotate fixture credentials outside the SPA: its in-flight requests still
+  // carry the previous session and can otherwise trigger native login recovery.
+  const origin = new URL(page.url()).origin
+  const fixtureURL = `${origin}/__desktop_fixture_credentials__`
+  await page.route(fixtureURL, route => route.fulfill({ contentType: 'text/html', body: '<title>Fixture account setup</title>' }))
+  try {
+    await page.goto(fixtureURL)
+    await page.evaluate(async ({username,password}) => {
+      const bootstrap = await (await fetch('/api/app/bootstrap')).json()
+      if (!bootstrap.user?.localDesktop) throw new Error('Native administrator session was not established')
+      const response = await fetch('/api/app/account/credentials', { method: 'PUT',
+        headers: {'Content-Type':'application/json','X-CSRF-Token':bootstrap.csrfToken},
+        body: JSON.stringify({username,currentPassword:'',newPassword:password}) })
+      if (!response.ok) throw new Error('Fixture account configuration failed')
+    }, {username,password})
+  } finally { await page.unroute(fixtureURL) }
+  await page.goto(`${origin}/conversations`)
 }

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

+ 5 - 1
docs/desktop-hosts.md

@@ -6,6 +6,10 @@
 
 所有 Bot 的聊天、模型、配置、记忆提炼都运行于服务端 Hermes。客户端 App 只负责界面和已授权的电脑操作,无需安装 Hermes。虚拟机 Runner 只在调用 `computer_*` 时建立工具会话;虚拟机离线不会转移聊天,也不会让普通聊天启动虚拟机。
 
+Bot 的服务端状态独立于客户端:本轮 Hermes 会话连接成功即报告服务端已连接。工具桥 1.3.1 按 Profile 的完整原生目录上报终端、文件、桌面与浏览器能力,操作服务端运行环境时优先使用这些实际可用工具。目录存在不保证工具后端已连接,Hermes 进程的系统信息也不代表所有工具的执行目标;SSH、容器或远程桌面仍需按工具配置与结果确认。旧工具桥未上报快照时只将元数据标为未知,不判定服务端离线。
+
+服务器也可打开纯客户端,它提供的是另一条客户端电脑控制通道;关闭客户端不会影响 Hermes 已有能力。没有可信的目标关联时,不按名称或 IP 自动合并两种连接,也不将容器内路径当作宿主机路径。电脑清单中的「服务器桌面代理」离线只说明 `desktop_*` 通道不可用,不能据此判定 Hermes 原生能力离线。
+
 电脑开放范围与审批策略在 Bot 设置统一管理,默认开放服务器、电脑、Grok Bot 云电脑和虚拟机。旧 Bot 的 `computer`、`execution`、`envs`、`approvalPolicy` 不再决定实际设备权限或模型运行位置。旧虚拟机共享关系保留,任务助手仍受所属任务的生命周期约束。
 
 - **消息来源**:客户端每次发送时重新读取 `deviceHost`,服务端保存到消息和运行。省略 `host` 或使用「本机」/`local` 时,只解析为本轮来源电脑;切换客户端后新消息覆盖来源,已经运行的任务保留自己的来源快照。
@@ -13,7 +17,7 @@
 - **协作与定时任务**:Bot 转交、子任务和结果回传继承触发消息的来源。定时任务保存自己的「本机」目标,重启或换客户端查看不会改变它;旧任务未保存目标时须在内容中明确指定。
 - **并行环境**:同一 Bot 可在一轮中操作多个环境。连接、系统授权和人工接管状态按设备校验,不按 Bot 区分权限。文件与命令授权不依赖屏幕录制或辅助功能。
 - **审批**:所有 Bot 共用「询问我 / 自动允许 / 自动拒绝」,变更用于后续审批请求;已等待的请求单独答复。聊天中的允许只作用于本次,旧客户端的 `auto` 答复也只允许一次。
-- **Hermes 原生工具**:原生终端和文件工具始终操作服务器,与消息来源无关;访问来源客户端应使用 `desktop_*`。全局服务器电脑开关控制 desktop 适配器,旧 `denyServerTools` 字段停用,不再展示未接入的封禁开关。
+- **Hermes 原生工具**:原生工具按 Hermes 自身的后端配置执行,与消息来源无关;访问来源客户端应使用 `desktop_*`,不自动将两种连接视为同一目标。全局服务器电脑开关控制 desktop 适配器,旧 `denyServerTools` 字段停用,不再展示未接入的封禁开关。
 - **接管界面**:`/api/app/agents/:id/computer` 系列接受 `backend=desktop|cloud|vm`,桌面可附带 `host`。界面显式选择目标。此处的 `host=local` 是历史 API 的服务器编号,不等于 Bot 工具中随消息变化的别名。
 
 Windows 客户端的构建、未签名安装包和验收记录见 [Windows 客户端](windows-client.md)。Windows 首次启动直接连接服务器,仅支持客户端模式;以下双模式选择与本机服务器说明仅适用于 Mac。

+ 7 - 7
docs/docker-install.md

@@ -12,14 +12,14 @@
 
 | 标签 | 架构 |
 | --- | --- |
-| `v0.4.76-amd` | `linux/amd64` |
-| `v0.4.76` | `linux/amd64`、`linux/arm64`,拉取时自动匹配 |
-| `latest` | 最新稳定通用镜像,当前指向与 `v0.4.76` 相同的镜像 |
+| `v0.4.77-amd` | `linux/amd64` |
+| `v0.4.77` | `linux/amd64`、`linux/arm64`,拉取时自动匹配 |
+| `latest` | 最新稳定通用镜像,当前指向与 `v0.4.77` 相同的镜像 |
 
 ```sh
-docker pull samienluo/yaoyao:v0.4.76
+docker pull samienluo/yaoyao:v0.4.77
 # 需要固定 AMD64 时:
-docker pull samienluo/yaoyao:v0.4.76-amd
+docker pull samienluo/yaoyao:v0.4.77-amd
 ```
 
 每次新版本发布都会更新 `latest`,旧版本标签继续保留。需要固定部署版本时使用具体版本标签。
@@ -34,7 +34,7 @@ cp docker.env.example docker.env
 
 | 配置 | 用途 |
 | --- | --- |
-| `HERMES_YAOYAO_IMAGE` | 远程镜像可设为 `samienluo/yaoyao:v0.4.76` 或 `samienluo/yaoyao:latest`;未设置时使用本地构建名称 |
+| `HERMES_YAOYAO_IMAGE` | 远程镜像可设为 `samienluo/yaoyao:v0.4.77` 或 `samienluo/yaoyao:latest`;未设置时使用本地构建名称 |
 | `HERMES_YAOYAO_UPSTREAM` | Hermes 上游地址,默认 `http://host.docker.internal:9119` |
 | `HERMES_YAOYAO_BIND_ADDRESS` | Web 的宿主机发布地址,默认 `127.0.0.1`;局域网访问可设为 `0.0.0.0` |
 | `HERMES_YAOYAO_PUBLISHED_PORT` | 宿主机 Web 端口,默认 `15300` |
@@ -211,7 +211,7 @@ curl --fail http://127.0.0.1:15300/healthz
 使用 Docker Hub 发布镜像时,在 `docker.env` 设置:
 
 ```dotenv
-HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.76
+HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.77
 YAOYAO_CURSOR_DESKTOP_IMAGE=samienluo/yaoyao-desktop:v0.4.12-cursor-amd
 ```
 

+ 3 - 1
docs/http-sse-realtime.md

@@ -23,7 +23,9 @@ Chat frames retain their upstream seq separately from the opaque SSE `id`. Send
 
 `state` is `confirmed`, `rejected`, `pending`, or `unknown`. Only an actual upstream RPC acknowledgement is confirmed; receiving HTTP 200 alone is not a submission acknowledgement. Identical keys reuse the original operation; different payloads under one key conflict. Network retry must retain the key and original encoded command, not rerun attachment preparation. The iOS Outbox ID is the prompt key.
 
-`realtime-receipts.sqlite3` records write-ahead fingerprints and small control acknowledgements, not prompts, attachments, transcript content or full event streams. Terminal records retain 24 hours. Pending operations become unknown on process restart. Unknown receipts are never automatically re-executed or evicted to permit retries; capacity exhaustion rejects new admissions. Full RPC results have a separate bounded in-memory cache; after eviction/restart only the small retained control receipt remains, so clients must refresh resource data rather than assume a history snapshot was persisted.
+`realtime-receipts.sqlite3` records write-ahead fingerprints and bounded recovery metadata, not prompts, attachment bytes, transcript content or full event streams. Allowed attachment commands retain their file references; allowed configuration commands retain their applied value and model confirmation metadata. Terminal results retain 24 hours, then compact into permanent owner/request/fingerprint tombstones that return `unknown`. Compaction never makes an admitted request executable again, including after a restart; changed payloads still conflict. Tombstones contain no command bodies or RPC results and grow with unique retired admissions. Pending operations become unknown on process restart. Unknown receipts are never automatically re-executed or evicted to permit retries; capacity exhaustion rejects new admissions. Full RPC results have a separate bounded in-memory cache, so clients must refresh resource data rather than assume a history snapshot was persisted.
+
+Clients must persist each stable command ID as attempted **before** its POST. An absent receipt is permission to submit only when a durable local record proves that command was never attempted. For an attempted or legacy uncertain prompt, reconcile the exact `client_message_id` against the canonical transcript; absence from a recent page is not proof of non-execution. Keep unresolved sends uncertain, and never submit an attachment label without a recovered file reference.
 
 ## Resource limits and HTTP-only clients
 

+ 45 - 0
docs/releases/v0.4.77.md

@@ -0,0 +1,45 @@
+# 夭夭 AI v0.4.77
+
+本次发布 Web、Docker 双架构镜像和 macOS Apple Silicon 纯客户端。full 桌面安装包不在本次发布范围;Windows 和 Android 沿用旧版附件。
+
+## 更新内容
+
+- 修复 Bot 删除失败后的重试和原执行停止确认,减少残留任务与生命周期状态不一致的问题。
+- 修复拆分部署时 Bot 对服务端执行环境的识别。
+- 修复实时命令过期去重与持久回执恢复,提升断线恢复后的状态一致性。
+- 修复归档文件授权,并在登出后关闭 Bot 事件连接。
+- 改善记忆合成的账号调度,限制并行读取,避免部分账号长期得不到处理。
+- 修复桌面开发启动时纯客户端与完整版的模式及应用身份选择。
+
+## 升级说明
+
+- 升级前备份完整 `.yaoyao` 数据目录,建议先更新服务端,再更新客户端。
+- macOS 安装包为纯客户端,需要连接已有服务器,不包含本机服务器或执行节点运行时。需要桌面内置本机服务的用户可继续使用历史 full 包。
+- macOS 客户端沿用历史应用标识,使用 Developer ID Application 正式签名和 Apple 公证,并提供完整自动更新附件。历史开发签名版请先手动安装正式 DMG。
+- Windows 和 Android 旧版附件继续保留。
+- 主项目采用 Apache-2.0,第三方组件保留各自许可与署名。
+
+## 下载
+
+- `Yaoyao-0.4.77-arm64.dmg`:macOS Apple Silicon 纯客户端安装包。
+- `Yaoyao-0.4.77-arm64.zip`、两种安装包对应的 `.blockmap` 和 `latest-mac.yml`:自动更新附件。
+- `Yaoyao-0.4.77-source.tar.gz`:Web/桌面源码和部署配置。
+- `SHA256SUMS.txt`:发布附件的 SHA-256 校验值。
+
+## Docker Hub
+
+| 镜像 | 架构 |
+| --- | --- |
+| `samienluo/yaoyao:v0.4.77-amd` | `linux/amd64` |
+| `samienluo/yaoyao:v0.4.77` | `linux/amd64`、`linux/arm64` |
+| `samienluo/yaoyao:latest` | 验证后更新为本次双架构镜像 |
+
+```sh
+docker pull samienluo/yaoyao:v0.4.77
+docker compose --env-file docker.env pull web
+docker compose --env-file docker.env up -d --no-build web
+```
+
+Compose 镜像配置设为 `samienluo/yaoyao:v0.4.77`。Docker 镜像上传到 Docker Hub;GitHub 提供客户端、源码和校验附件。旧版本标签与附件保留。
+
+附件摘要、镜像摘要和验证结果见 [GitHub 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.77)。

+ 1 - 0
docs/runner.md

@@ -49,6 +49,7 @@ macOS App 可直接通过菜单栏“执行节点 → 导入节点配置…”
 - 本机 SQLite 保存命令指纹和执行回执。重复命令复用回执;未完成的历史命令视为结果不确定,禁止自动重放。大响应一小时后压缩为禁止重放的记录。
 - 组队工具只在 Hermes 本机建立回环租约,远端不开放原有回环接口。模型获得的是当轮不透明工具 ID;账号、来源、会话和调用身份由服务端绑定。
 - Gateway RPC 使用固定允许列表;会话操作必须属于自己的通道。HTTP 仅允许必要的只读查询。文件导出要求真实路径位于 `artifactRoots`,留空拒绝导出。
+- 停止任务须通过 `session.active_list` 确认原会话已空闲或退出;Runner 只返回当前通道拥有的会话 ID 与状态。请与控制面一起更新 Runner;旧 Runner 无法确认停止时,系统保留待清理状态并拒绝复用原执行。电脑执行停止超时也保留控制权和持久停止记录,确认停止后才释放。
 - 单个只读结果目前最多 8 MiB;大型产物的分块传输仍待实现。待传命令有数量和总字节限制。
 - 断线后的在途操作可能结果不确定。系统沿用原会话核对,不在另一个节点重放原任务。已完成会话切换节点后带入近期文字记录;历史附件路径需要重新核实。
 - 托管浏览器通过节点能力协商使用会话保留协议。普通 Bot 的持久上下文在正常任务结束或无任务的人工交还后暂停联网,下一轮使用或接管取得新授权后恢复;独立会话授权只保留资源,不继续授予已结束任务的操作权限。默认空闲回收为 30 分钟,状态和截图轮询不会续期。

+ 4 - 2
integrations/hermes-bots-bridge/README.md

@@ -1,6 +1,6 @@
 # 夭夭 Bot 的 Hermes 工具桥
 
-这是运行在 Hermes 内的用户插件,版本 1.2.5。它连接 Yaoyao 每轮授权的电脑、团队和应用工具。随附安装器还提供有备份的 Hermes Profile 模型兼容修复。
+这是运行在 Hermes 内的用户插件,版本 1.3.1。它连接 Yaoyao 每轮授权的电脑、团队和应用工具。随附安装器还提供有备份的 Hermes Profile 模型兼容修复。
 
 ## Hermes 托管虚拟机会话
 
@@ -44,7 +44,7 @@ Runner 包内可直接运行同目录的 `install-hermes-bridge.py`。安装器
 
 前缀:`/api/plugins/yaoyao-bot-bridge`。
 
-- `GET /capabilities?profile=...`:检查对应 Profile 及托管能力。
+- `GET /capabilities?profile=...`:检查对应 Profile 及托管能力。1.3.1 起可选的 `runtime_environment` 返回版本 1 的 Hermes 进程系统信息及对应 Profile 的原生终端、文件、桌面和浏览器工具名称;按完整目录采集,包含延迟发现的工具,不执行工具或探测其后端。进程系统信息不代表 SSH、容器或桌面工具的实际目标,不返回配置、凭据或客户端路径。
 - `GET /model-options?profile=...`:读取 Profile 的模型目录、默认值、思考能力与速度能力;只返回公开配置,不返回凭据。
 - `POST /model-settings/resolve`:接收 `profile` 与 `settings`,通过 Hermes 原生解析器校验完整组合,返回版本 1 的有效配置和模型确认提示;不写 Profile 配置。
 - 模型目录与配置解析均使用 Hermes 原生配置作用域,同时绑定 Profile 目录和凭据;多 Profile Dashboard 不会因缺少凭据作用域而报错,也不会借用其他 Profile 的环境变量。默认 Profile 保留 Hermes 自身的启动凭据规则。
@@ -99,4 +99,6 @@ CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确
 
 1.3.0 增加 Bot 个人资料的模型设置协议(`model_settings_version: 1`)。模型与 Provider 成对选择,思考等级和 `normal / fast / auto / cold` 速度可分别继承。Web 服务在每轮提交前解析配置,并通过原生 `config.set` 按模型、思考、速度的顺序应用到当前 Bot 会话;保存资料不修改 Profile 默认值,也不打断正在运行的回复。恢复中的原轮次只继续追踪,不重新设置或提交。模型目录未声明具体等级时返回 `reasoningKnown: false`;快速能力同时检查模型和 Provider 路由。
 
+1.3.1 增加上述 Hermes 环境快照。Bot 的服务端连接状态取自本轮 Hermes 会话,客户端电脑清单仅表示 `desktop_*` 控制通道。服务器未打开客户端或客户端退出时,Hermes 原生能力不因此被标记离线;非回环地址也不会隐藏 Hermes 运行环境。旧工具桥没有快照时保持聊天与原生工具兼容,系统与目录信息显示未知。更新工具桥后需要重启 Hermes 才能加载新快照协议。
+
 发布时先更新工具桥与 Web 服务,在空闲时重启 Hermes Dashboard,再发布 Web/桌面端、iOS 和 Android 客户端。旧桥不能编辑新增设置,会明确提示升级;未设置 `modelSettings` 的旧 Bot 继续按原有继承路径运行。原生会话配置回执不兼容、模型无效或应用失败会停止本轮,不使用旧配置继续运行。

+ 21 - 2
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -13,6 +13,7 @@ import json
 import math
 import mimetypes
 import os
+import platform
 import re
 import sys
 import threading
@@ -28,7 +29,7 @@ from pathlib import Path
 from typing import Any
 
 VERSION = 1
-PLUGIN_VERSION = "1.3.0"
+PLUGIN_VERSION = "1.3.1"
 def _plugin_fingerprint():
     root = Path(__file__).parent
     digest = hashlib.sha256()
@@ -351,6 +352,23 @@ def _transport_identity(transport) -> tuple[str, str]:
     return "local-session-token", "dashboard"
 
 
+def _runtime_environment(profile: str, definitions: list) -> dict:
+    # Only directory facts: never execute tools, probe desktops, or expose config
+    # credentials. Process metadata does not identify an SSH/container/UI backend.
+    names = {tool.get("function", {}).get("name") for tool in definitions if isinstance(tool, dict)}
+    names = {name for name in names if isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", name)}
+    return {
+        "version": 1, "profile": profile, "platform": sys.platform,
+        "osRelease": platform.release(), "arch": platform.machine(),
+        "nativeTools": {
+            "terminal": sorted(names & {"terminal", "process", "process_manage", "execute_code", "code_execution"}),
+            "files": sorted(names & {"read_file", "write_file", "patch", "search_files"}),
+            "desktop": sorted(names & {"computer_use", "desktop_project"}),
+            "browser": sorted(name for name in names if name.startswith("browser_")),
+        },
+    }
+
+
 def capabilities(profile: str = "default") -> dict:
     in_process = False
     try:
@@ -386,7 +404,8 @@ def capabilities(profile: str = "default") -> dict:
             return {"version": VERSION, "ready": True, "in_process": True, "profile": profile, "native_tools": True,
                     "computer_runtime_version": 2, "execution_policy_version": 1, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
                     "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
-                    "isolated_context_files": True, "skill_learning": True, "model_settings_version": 1, "file_transfer_version": 1}
+                    "isolated_context_files": True, "skill_learning": True, "model_settings_version": 1, "file_transfer_version": 1,
+                    "runtime_environment": _runtime_environment(profile, definitions)}
         finally:
             reset_hermes_home_override(context)
     except Exception as exc:

+ 1 - 1
integrations/hermes-bots-bridge/plugin.yaml

@@ -1,4 +1,4 @@
 name: yaoyao-bot-bridge
-version: "1.3.0"
+version: "1.3.1"
 description: "夭夭 Bot 每轮授权的电脑、浏览器、手机、团队与应用工具桥"
 author: "Samien"

+ 60 - 0
integrations/hermes-bots-bridge/tests/test_runtime_environment.py

@@ -0,0 +1,60 @@
+import importlib.util
+import sys
+import tempfile
+import unittest
+from contextvars import ContextVar
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+spec = importlib.util.spec_from_file_location('test_yaoyao_environment_bridge', Path(__file__).parents[1] / 'bridge_runtime.py')
+bridge = importlib.util.module_from_spec(spec)
+sys.modules[spec.name] = bridge
+spec.loader.exec_module(bridge)
+
+
+class RuntimeEnvironmentTests(unittest.TestCase):
+    def test_capabilities_reports_the_scoped_profile_catalog_without_operating_a_computer(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            scope = ContextVar('runtime_environment_profile', default=root)
+            configs = {
+                root / 'default': {'agent': {'disabled_toolsets': []}},
+                root / 'limited': {'agent': {'disabled_toolsets': ['terminal', 'computer_use']}},
+            }
+            observations = []
+
+            def definitions(enabled_toolsets, disabled_toolsets, **kwargs):
+                observations.append((scope.get(), kwargs))
+                names = ['yaoyao_tools', 'yaoyao_call', 'terminal', 'read_file', 'computer_use', 'browser_navigate', 'memory', 'yaoyao_desktop_fake']
+                return [{'function': {'name': name}} for name in names if name not in disabled_toolsets]
+
+            modules = {
+                'hermes_cli.profiles': SimpleNamespace(get_profile_dir=lambda profile: root / profile, profile_exists=lambda profile: profile in {'default', 'limited'}),
+                'hermes_constants': SimpleNamespace(set_hermes_home_override=scope.set, reset_hermes_home_override=scope.reset),
+                'hermes_cli.plugins': SimpleNamespace(discover_plugins=lambda: None),
+                'hermes_cli.config': SimpleNamespace(load_config=lambda: configs[scope.get()]),
+                'model_tools': SimpleNamespace(get_tool_definitions=definitions),
+            }
+            with patch.dict(sys.modules, modules), patch.object(bridge, '_web'), \
+                    patch.object(bridge, '_server', return_value=SimpleNamespace(_load_enabled_toolsets=lambda: ['all'])):
+                full = bridge.capabilities('default')
+                limited = bridge.capabilities('limited')
+
+            self.assertTrue(full['ready'])
+            facts = full['runtime_environment']
+            self.assertEqual(facts['profile'], 'default')
+            self.assertEqual(facts['nativeTools'], {'terminal': ['terminal'], 'files': ['read_file'], 'desktop': ['computer_use'], 'browser': ['browser_navigate']})
+            self.assertEqual(limited['runtime_environment']['nativeTools'], {'terminal': [], 'files': ['read_file'], 'desktop': [], 'browser': ['browser_navigate']})
+            self.assertEqual(set(facts), {'version', 'profile', 'platform', 'osRelease', 'arch', 'nativeTools'})
+            self.assertEqual(scope.get(), root)
+            self.assertEqual([entry[0] for entry in observations], [root / 'default', root / 'limited'])
+            self.assertTrue(all(entry[1]['skip_tool_search_assembly'] for entry in observations))
+
+    def test_runtime_directory_does_not_report_bridge_tools_as_native_computer_capabilities(self):
+        definitions = [{'function': {'name': name}} for name in ['yaoyao_tools', 'yaoyao_call', 'yaoyao_desktop_shell_123', 'tool_call', 'tool_describe', 'memory']]
+        self.assertEqual(bridge._runtime_environment('default', definitions)['nativeTools'], {'terminal': [], 'files': [], 'desktop': [], 'browser': []})
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 2 - 2
package-lock.json

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

+ 4 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@lsamien/yaoyao",
-  "version": "0.4.76",
+  "version": "0.4.77",
   "license": "Apache-2.0",
   "description": "夭夭 AI:聊天、多 Agent 团队、文件库与电脑工作空间",
   "type": "module",
@@ -30,12 +30,12 @@
     "test:e2e:team-tools": "npm run build && playwright test -c playwright.team-tools.config.ts",
     "desktop:build": "npm run build && node scripts/build-desktop.mjs",
     "desktop:build:client": "node scripts/write-build-info.mjs && node scripts/build-desktop-client.mjs",
-    "desktop:dev:client": "npm run desktop:build:client && electron .",
+    "desktop:dev:client": "npm run desktop:build:client && cross-env HERMES_YAOYAO_DESKTOP_CLIENT_ONLY=1 electron .",
     "desktop:pack:client": "npm run desktop:build:client && electron-builder --config electron-builder.client.yml --mac --arm64 --publish never",
-    "desktop:dev": "npm run desktop:build && electron .",
+    "desktop:dev": "npm run desktop:build && cross-env HERMES_YAOYAO_DESKTOP_CLIENT_ONLY=0 electron .",
     "desktop:pack": "npm run desktop:build && electron-builder --mac --arm64 --publish never",
     "desktop:release": "npm run desktop:build && node scripts/desktop-release.mjs",
-    "desktop:test": "node --test desktop/*.test.mjs",
+    "desktop:test": "cross-env HERMES_YAOYAO_DESKTOP_CLIENT_ONLY=0 node --test desktop/*.test.mjs",
     "runner": "node dist-server/runner/index.js",
     "runner:dev": "tsx src/runner/index.ts",
     "runner:build": "node scripts/build-runner.mjs",

+ 3 - 3
release.json

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

+ 10 - 2
src/runner/agent.ts

@@ -20,7 +20,7 @@ import {BrowserRuntimeError} from './browser/types.js'
 import type { RunnerCommand, RunnerConfiguration } from '../shared/runner.js'
 import { LoopbackTransport, isLocalAuthorizationTarget } from '../server/loopbackAuthorization.js'
 
-const commands=new Set(['session.create','session.resume','session.close','session.usage','session.interrupt','session.cwd.set','prompt.submit','session.steer','image.attach_bytes','file.attach','approval.respond','clarify.respond'])
+const commands=new Set(['session.create','session.resume','session.close','session.usage','session.interrupt','session.active_list','session.cwd.set','prompt.submit','session.steer','image.attach_bytes','file.attach','approval.respond','clarify.respond'])
 interface Connection {cleanupOnly?:boolean;gateway:WorkspaceGateway|ComputerGateway;sessions:Map<string,string>;running:Set<string>;events:Promise<void>}
 interface Lease {connectionId:string;profile:string;lease:WorkspaceToolLease;controller:AbortController;session:{runtimeId:string;storedId:string}}
 
@@ -254,7 +254,15 @@ export class RunnerAgent {
       const connection=this.connections.get(connectionId),method=String(p.method),params=p.params as Record<string,unknown>
       const computerCommand=method==='computer.invoke'||method==='computer.transfer'||method==='computer.complete'
       if(!connection||(!commands.has(method)&&!(computerCommand&&connection.gateway instanceof ComputerGateway))||!params||typeof params!=='object')throw new HttpError(403,'执行通道或命令无效','runner_command_forbidden')
-      if(connection.cleanupOnly&&!['session.resume','session.interrupt','session.close'].includes(method))throw new HttpError(403,'清理通道不允许执行新工作','runner_cleanup_forbidden')
+      if(connection.cleanupOnly&&!['session.resume','session.interrupt','session.close','session.active_list'].includes(method))throw new HttpError(403,'清理通道不允许执行新工作','runner_cleanup_forbidden')
+      if(method==='session.active_list'){
+        const id=String(params.current_session_id??''),profile=connection.sessions.get(id)
+        if(!profile||params.profile!==profile||Object.keys(params).some(key=>!['profile','current_session_id'].includes(key)))throw new HttpError(403,'停止状态查询不属于这个通道','runner_session_forbidden')
+        if(connection.gateway instanceof ComputerGateway)throw new HttpError(409,'电脑通道使用独立停止确认','computer_method_unavailable')
+        const state=await connection.gateway.rpc(method,{profile,current_session_id:id})
+        if(!Array.isArray(state?.sessions))throw new HttpError(502,'Hermes 停止状态无效','session_stop_unconfirmed')
+        return {sessions:state.sessions.filter((item:{id:string})=>item.id===id).map((item:{id:string;status:string})=>({id:item.id,status:item.status}))}
+      }
       if(method==='session.create'||method==='session.resume'){if(!connection.cleanupOnly)this.requireProfile(params.profile)}
       else if(!connection.sessions.has(String(params.session_id)))throw new HttpError(403,'会话不属于这个通道','runner_session_forbidden')
       if(method==='prompt.submit')connection.running.add(String(params.session_id))

+ 30 - 16
src/runner/worker/gateway.ts

@@ -29,8 +29,8 @@ import {FileTransferFiles} from '../../shared/fileTransferEndpoint.mjs'
 import {copyBetweenEndpoints} from '../../server/fileTransfer.js'
 
 export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string;hostAccess?:boolean;profileSession?:boolean;hermesRuntime?:boolean;fileTransferMaxBytes?:number}
-export interface WorkerSession {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain';vmExecution?:'profile';profileSessionId?:string;hermesRuntime?:boolean}
-interface Live {toolsOnly?:boolean;workMarker?:string;paused?:boolean;desktop?:{width:number;height:number};pauseJob?:Promise<void>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxyHandle?:ProxyHandle;session:WorkerSession;model:WorkerModel;contextConfig?:WorkerContextConfiguration;proxyEnv?:WorkerProxyEnvironment;lease?:ComputerLease;acquiring?:Promise<ComputerLease>;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
+export interface WorkerSession {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain';vmExecution?:'profile';profileSessionId?:string;hermesRuntime?:boolean;stopPending?:boolean}
+interface Live {toolsOnly?:boolean;workMarker?:string;paused?:boolean;desktop?:{width:number;height:number};pauseJob?:Promise<void>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxyHandle?:ProxyHandle;session:WorkerSession;model:WorkerModel;contextConfig?:WorkerContextConfiguration;proxyEnv?:WorkerProxyEnvironment;lease?:ComputerLease;acquiring?:Promise<ComputerLease>;leaseController?:AbortController;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
 const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
 const text={type:'string'}
 const TOOLS:WorkerTool[]=[
@@ -113,7 +113,11 @@ export class ComputerRuntime {
   }
   attachGateway(gateway:ComputerGateway){let gateways=this.gateways.get(gateway.meta.environmentId);if(!gateways){gateways=new Set();this.gateways.set(gateway.meta.environmentId,gateways)}gateways.add(gateway)}
   detachGateway(gateway:ComputerGateway){const gateways=this.gateways.get(gateway.meta.environmentId);if(gateways){gateways.delete(gateway);if(!gateways.size)this.gateways.delete(gateway.meta.environmentId)}}
-  activeGateways(environmentId:string):ComputerGateway[]{return [...(this.gateways.get(environmentId)??[])].filter(gateway=>gateway.active||gateway.paused)}
+  activeGateways(environmentId:string):ComputerGateway[]{
+    if(this.stopPending(environmentId))throw new HttpError(409,'原电脑执行尚未确认停止,不能接管或复用','computer_stopping')
+    return [...(this.gateways.get(environmentId)??[])].filter(gateway=>gateway.active||gateway.paused)
+  }
+  stopPending(environmentId:string):boolean{return (this.db.prepare('SELECT value FROM computer_sessions').all() as {value:string}[]).some(row=>{const session:WorkerSession=JSON.parse(row.value);return session.environmentId===environmentId&&session.stopPending===true})}
   /** One guest proxy per environment, shared by every holder; the guest
    * reserves port 3128, so concurrent holders must never start their own. */
   acquireProxy(spec:ComputerSpecification,authorize:()=>void):Promise<ProxyHandle>{
@@ -272,12 +276,13 @@ export class ComputerGateway {
   private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.imageFor(this.meta),cwd:session.cwd,network:this.runtime.network}}
   async rpc(method:string,params:Record<string,any>):Promise<any>{
     if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.live?.session.id){await this.interrupt();return {status:'interrupted'}}
-    if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.recoverySession?.id){await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {status:'interrupted'}}
+    if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.recoverySession?.id){await this.profileSession?.stop();await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);this.recoverySession!.stopPending=false;this.runtime.save(this.recoverySession!);return {status:'interrupted'}}
     this.guard()
     if(method==='session.create'||method==='session.resume'){
       if(this.live)throw new HttpError(409,'电脑通道已有会话','computer_session_busy')
       await this.authorized()
-      if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;if(session.profileSessionId){await this.openProfile(session,params);await this.profileSession!.stop()}const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderIds.includes(this.workId))await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
+      if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;session.stopPending=true;this.runtime.save(session);if(session.profileSessionId){await this.openProfile(session,params);await this.profileSession!.stop()}const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderIds.includes(this.workId))await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);session.stopPending=false;this.runtime.save(session);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
+      if(this.runtime.stopPending(this.meta.environmentId))throw new HttpError(409,'原电脑执行尚未确认停止,不能复用','computer_stopping')
       const profile=String(params.profile),resolved=this.hermesManaged?{cwd:this.runtime.pool.definition(this.meta.ownerKey,this.meta.environmentId)?.cwd??COMPUTER_WORKSPACE,configuredCwd:'.'}:await (this.meta.profileSession?this.runtime.resolveWorkspace(profile,this.controller.signal):this.runtime.resolve(profile,this.controller.signal))
       if(this.meta.hostAccess){const configured=String(resolved.configuredCwd??'.');this.hostCwd=['.','auto','cwd'].includes(configured)?homedir():hostPath(homedir(),configured)}
       await this.authorized();this.guard()
@@ -439,14 +444,17 @@ export class ComputerGateway {
       if(this.runtime.imageFor(this.meta)===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在应用设置的本地虚拟机页面完成准备','computer_image_required')
       await this.runtime.pool.configure(this.spec(live.session),()=>this.guard())
       await this.authorized()
-      const lease=await this.runtime.pool.acquire(this.spec(live.session),this.workId,()=>{this.guard();if(live.controller.signal.aborted)throw new Error('cancelled')},live.controller.signal)
+      // Tool cancellation must not discard the reservation before native Hermes
+      // termination is confirmed. Only this existing holder may renew for cleanup.
+      const leaseController=new AbortController();live.leaseController=leaseController
+      const lease=await this.runtime.pool.acquire(this.spec(live.session),this.workId,()=>{if(live.session.stopPending&&!live.releasing)return;this.guard()},leaseController.signal)
       if(this.closed){await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
       live.lease=lease
       if(this.runtime.network!=='none'){
         try{live.proxyHandle=await this.runtime.acquireProxy(this.spec(live.session),()=>{if(!this.runtime.pool.hasHolders(this.meta.ownerKey,this.meta.environmentId))throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked')})}
         catch(error){await this.runtime.pool.release(lease).catch(()=>{});live.lease=undefined;throw error}
       }
-      this.timer=setInterval(()=>{if(this.checking||!this.live?.lease)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(this.live!.lease!)).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).finally(()=>{this.checking=false})},5000);this.timer.unref()
+      this.timer=setInterval(()=>{if(this.checking||!this.live?.lease)return;this.checking=true;void Promise.resolve().then(async()=>{if(!this.live!.session.stopPending)await this.authorized();this.runtime.pool.renew(this.live!.lease!)}).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).finally(()=>{this.checking=false})},5000);this.timer.unref()
       return lease
     })().finally(()=>{live.acquiring=undefined})
     return live.acquiring
@@ -625,6 +633,7 @@ export class ComputerGateway {
    * records pending tool calls so the model can resume coherently. The pool
    * lease stays — the human acquires a holder of the same shared desktop. */
   async takeControl(controlId:string,authorize:()=>void):Promise<void>{
+    this.guard()
     const live=this.live
     if(!live||!this.active)throw new HttpError(409,'机器人已不在执行,请重新打开电脑','computer_not_active')
     if(live.pauseJob)await live.pauseJob
@@ -663,16 +672,21 @@ export class ComputerGateway {
     const live=this.live;if(!live)return
     if(live.stopping)return live.stopping
     live.stopping=(async()=>{
-      live.controller.abort();clearInterval(this.timer)
-      try{await this.profileSession?.stop()}
-      finally{
-        await this.profileSession?.close();await live.worker?.close()
-        try{await this.release(live)}catch(error){if((error as any).code!=='computer_lease_stale')throw error}
-        live.running=false
-      }
-    })()
+      live.session.stopPending=true;this.runtime.save(live.session)
+      live.controller.abort()
+      await this.profileSession?.stop()
+      await this.profileSession?.close();await live.worker?.close()
+      try{await this.release(live)}catch(error){if((error as any).code!=='computer_lease_stale')throw error}
+      live.running=false;live.session.stopPending=false;this.runtime.save(live.session)
+      if(this.closed)this.runtime.detachGateway(this)
+    })().finally(()=>{live.stopping=undefined})
     return live.stopping
   }
   async stopControl(){await this.interrupt();this.event('message.complete',{text:'电脑控制已结束,本轮已停止',status:'interrupted'})}
-  async close(){this.runtime.detachGateway(this);this.closed=true;this.controller.abort();this.team=undefined;clearInterval(this.timer);try{if(this.live?.running||this.live&&!this.live.releasing)await this.interrupt()}finally{await this.profileSession?.close()}}
+  async close(){
+    this.closed=true;this.controller.abort();this.team=undefined
+    if(this.live?.running||this.live&&!this.live.releasing)await this.interrupt()
+    if(this.recoverySession?.stopPending){await this.profileSession?.stop();await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);this.recoverySession.stopPending=false;this.runtime.save(this.recoverySession)}
+    await this.profileSession?.close();clearInterval(this.timer);this.runtime.detachGateway(this)
+  }
 }

+ 2 - 10
src/runner/worker/profileSession.ts

@@ -1,4 +1,4 @@
-import {setTimeout as delay} from 'node:timers/promises'
+import {waitForHermesSessionIdle} from '../../server/sessionStop.js'
 import {HttpError} from '../../server/errors.js'
 import {WorkspaceGateway,type GatewayFrame,type GatewayTarget} from '../../server/workspaceGateway.js'
 import {createWorkspaceToolLease,requireTeamToolBridge,type WorkspaceToolLease} from '../../server/workspaceToolLease.js'
@@ -95,15 +95,7 @@ export class ProfileComputerSession {
   async stop(){
     if(!this.runtimeId||this.closed)return
     await this.rpc('session.interrupt')
-    // An interrupt acknowledgement precedes actual termination of native tools.
-    const deadline=Date.now()+20000
-    for(;;){
-      const state=await this.rpc('session.active_list')
-      const session=state.sessions?.find((item:{id:string})=>item.id===this.runtimeId)
-      if(session?.status==='idle')return
-      if(Date.now()>=deadline)throw new HttpError(409,'本机任务尚未停止,暂不能交出电脑控制权','computer_profile_stopping')
-      await delay(100,undefined,{signal:this.controller.signal})
-    }
+    await waitForHermesSessionIdle((method,params)=>this.rpc(method,params),this.runtimeId,this.profile,{signal:this.controller.signal})
   }
   async close(){
     if(this.closed)return

+ 2 - 2
src/server/desktopEnvironments.ts

@@ -69,11 +69,11 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
  {id:'desktop_shell',name:'desktop_shell',description:'在这台电脑的用户主目录执行一条 shell 命令,返回 stdout、stderr 与退出码。长任务会超时终止;不要用它代替文件工具读写大文件。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{command:{type:'string'},cwd:{type:'string'},timeoutMs:{type:'integer'},host:{type:'string'}},required:['command'],additionalProperties:false}},
 ]
 export const DESKTOP_FILE_TOOL_IDS=new Set(['desktop_file_list','desktop_file_read','desktop_file_write','desktop_file_copy','desktop_shell'])
-export const DESKTOP_FILE_TRANSFER_RULES='电脑间传文件用 desktop_file_copy,一次指定 sourceHost、sourcePath、targetHost、targetPath;服务器用 server,当前 Bot 的虚拟机用 vm,设备名用设置里的名字,「本机」只指本轮消息来源电脑。例:把 mac1 桌面的 a.txt 放到 mac2 桌面,调用 {sourceHost:"mac1",sourcePath:"Desktop/a.txt",targetHost:"mac2",targetPath:"Desktop/a.txt"}。这是复制,保留原文件;只有用户明确要求覆盖时才传 overwrite:true。无需先截图,不要读取 base64 给模型、上传聊天附件或假设不同电脑共享文件路径。最大 25 MiB;工具返回已校验的成功结果后才报告完成。'
+export const DESKTOP_FILE_TRANSFER_RULES='电脑间传文件用 desktop_file_copy,一次指定 sourceHost、sourcePath、targetHost、targetPath;服务器桌面代理用 server,当前 Bot 的虚拟机用 vm,设备名用设置里的名字,「本机」只指本轮消息来源电脑。例:把 mac1 桌面的 a.txt 放到 mac2 桌面,调用 {sourceHost:"mac1",sourcePath:"Desktop/a.txt",targetHost:"mac2",targetPath:"Desktop/a.txt"}。这是复制,保留原文件;只有用户明确要求覆盖时才传 overwrite:true。无需先截图,不要读取 base64 给模型、上传聊天附件或假设不同电脑共享文件路径。最大 25 MiB;工具返回已校验的成功结果后才报告完成。'
 /** Macs connected to the server as computers. */
 export const DESKTOP_ENVIRONMENT_RULES='操作连接服务器的电脑时,使用本轮提供的 desktop_* 工具,并用 host 指定电脑名称或编号。Hermes 原生终端和文件工具不能代替该电脑上的操作。'
 /** Yaoyao host Mac — same look-then-act pattern as the cloud computer. */
-export const SERVER_COMPUTER_RULES='操作服务器桌面时,使用本轮提供的 desktop_* 工具并明确指定 host="server"。处理 Hermes 运行环境内的文件与命令时,使用实际提供的原生工具;处理服务器桌面用户主目录内的文件时,使用已授权的 desktop_file_* 或 desktop_shell,不假定两者共享路径。'
+export const SERVER_COMPUTER_RULES='操作客户端提供的服务器桌面代理时,使用本轮提供的 desktop_* 工具并明确指定 host="server"。处理 Hermes 原生工具实际控制的运行环境时,优先使用对应原生工具;处理服务器桌面代理用户主目录内的文件时,使用已授权的 desktop_file_* 或 desktop_shell,不假定两者共享路径或能力。'
 
 /** The loopback native transport is admitted only by serviceInstance's
  *  private capability; remote hosts are admitted by DesktopHostHub's bearer

+ 1 - 1
src/server/fileAccess.ts

@@ -50,7 +50,7 @@ export async function fileAccessWorkingDirectory(upstream: FileHost, profile: st
   return path
 }
 
-export async function authorizeFileRead(config: ServerConfig, upstream: FileHost, raw: string, profile = 'default', directoryRequest = false): Promise<string> {
+export async function authorizeFileRead(config: Pick<ServerConfig, 'home'>, upstream: FileHost, raw: string, profile = 'default', directoryRequest = false): Promise<string> {
   const path = serverFilePath(raw, false)
   if (!path) throw new HttpError(400, '文件路径无效', 'invalid_file_path')
   const policy = readFileAccess(config.home)

+ 16 - 0
src/server/hermesRuntimeEnvironment.ts

@@ -0,0 +1,16 @@
+import { z } from 'zod'
+import type { HermesRuntimeMetadata } from '../shared/botEnvironment.js'
+
+const text = z.string().min(1).max(256).refine(value => !/[\u0000-\u001f]/.test(value))
+const tools = z.array(z.string().regex(/^[A-Za-z0-9_.:-]{1,128}$/)).max(256)
+const metadata = z.object({
+  version: z.literal(1), profile: text, platform: text, osRelease: text, arch: text,
+  nativeTools: z.object({ terminal: tools, files: tools, desktop: tools, browser: tools }),
+})
+
+/** Optional protocol extension: old or incompatible bridges leave facts unknown. */
+export function parseHermesRuntimeMetadata(value: unknown, profile: string): HermesRuntimeMetadata | undefined {
+  const parsed = metadata.safeParse(value)
+  if (!parsed.success || parsed.data.profile !== profile) return undefined
+  return parsed.data
+}

+ 21 - 8
src/server/openVikingMemoryProvider.ts

@@ -21,6 +21,7 @@ const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`
 const isUuid = (value: string): boolean => /^[0-9a-f]{32}$/.test(value)
 const memoryId = (requestId: string): string => requestId.replaceAll('-', '')
 const MEMORY_TAG = 'yaoyao_memory=true'
+const MEMORY_READ_CONCURRENCY = 8
 const activeUri = (scope: MemoryScopeInput, id: string): string => `viking://~/memories/yaoyao/${scope.scope}/${id}.json`
 const revisionsUri = (scope: MemoryScopeInput, id: string, revision: number): string => `viking://resources/yaoyao/${scope.scope}/${id}/revisions/${revision}.json`
 const tombstoneUri = (scope: MemoryScopeInput, fingerprint: string): string => `viking://resources/yaoyao/${scope.scope}/tombstones/${fingerprint}.json`
@@ -84,16 +85,28 @@ export class OpenVikingMemoryProvider {
     const client = this.service.userClient(owner, scope.agentId)
     const directory = `viking://~/memories/yaoyao/${scope.scope}`
     const entries = await notFoundAsUndefined(() => client.list(directory, { recursive: true })) ?? []
-    const records: WorkspaceMemory[] = []
-    for (const entry of entries) {
+    const ids = entries.flatMap(entry => {
       const name = uriName(entry)
       const match = /([0-9a-f]{32})\.json$/.exec(name ?? '')
-      if (!match) continue
-      const record = await this.readRecord(owner, scope, match[1]!)
-      if (record && record.memory.scope === scope.scope && record.memory.agentId === scope.agentId
-        && record.memory.projectId === scope.projectId) records.push(record.memory)
-    }
-    return records
+      return match ? [match[1]!] : []
+    })
+    const records: Array<WorkspaceMemory | undefined> = new Array(ids.length)
+    let next = 0, failed = false
+    // Preserve directory order and per-record scope checks without N serial round trips.
+    const reads = await Promise.allSettled(Array.from({ length: Math.min(ids.length, MEMORY_READ_CONCURRENCY) }, async () => {
+      while (!failed) {
+        const index = next++
+        if (index >= ids.length) return
+        let record: StoredRecord | undefined
+        try { record = await this.readRecord(owner, scope, ids[index]!) }
+        catch (error) { failed = true; throw error }
+        if (record && record.memory.scope === scope.scope && record.memory.agentId === scope.agentId
+          && record.memory.projectId === scope.projectId) records[index] = record.memory
+      }
+    }))
+    const failure = reads.find(read => read.status === 'rejected')
+    if (failure?.status === 'rejected') throw failure.reason
+    return records.filter((memory): memory is WorkspaceMemory => memory !== undefined)
   }
   async list(owner: string, scopes: MemoryScopeInput[]): Promise<WorkspaceMemory[]> {
     const result: WorkspaceMemory[] = []

+ 32 - 14
src/server/openVikingService.ts

@@ -38,6 +38,8 @@ export class OpenVikingService {
   readonly #clients = new Map<string, OpenVikingClient>()
   readonly #adminClients = new Map<string, OpenVikingClient>()
   readonly #registrations = new Map<string, Promise<OpenVikingUserBinding>>()
+  readonly #removals = new Map<string, Promise<void>>()
+  onConfigurationChanged: () => void = () => {}
   #configuration: OpenVikingConfiguration | undefined
   readonly #clientFactory: OpenVikingClientFactory
   constructor(private readonly store: WorkspaceStore, private readonly configurationManager: OpenVikingConfigurationManager, clientFactory?: OpenVikingClientFactory) {
@@ -51,11 +53,13 @@ export class OpenVikingService {
   }
   configure(config: OpenVikingConfiguration | undefined): void {
     const signature = config ? `${config.url}\0${config.accountId}\0${config.adminKey}` : ''
-    if (signature !== this.#signature) {
+    const changed=signature!==this.#signature
+    if (changed) {
       this.#clients.clear()
       this.#adminClients.clear()
     }
     this.#configuration = config
+    if(changed)this.onConfigurationChanged()
   }
   get #signature(): string { return this.#configuration ? `${this.#configuration.url}\0${this.#configuration.accountId}\0${this.#configuration.adminKey}` : '' }
   binding(owner: string, agentId: string): OpenVikingUserBinding | undefined {
@@ -92,6 +96,7 @@ export class OpenVikingService {
     return client
   }
   userClient(owner: string, agentId: string): OpenVikingClient {
+    if(this.store.get(owner,'agent-deletion',agentId))throw new HttpError(410,'Bot 已删除,记忆用户不可使用','openviking_user_removed')
     const config = this.#configuration
     if (!config) throw new HttpError(409, 'OpenViking 记忆服务未启用', 'openviking_disabled')
     const binding = this.binding(owner, agentId)
@@ -107,6 +112,8 @@ export class OpenVikingService {
   }
   async ensureUser(owner: string, agent: Pick<WorkspaceAgent, 'id' | 'temporaryGoalId'>): Promise<OpenVikingUserBinding> {
     const key = `${this.namespace}:${owner}:${agent.id}`
+    if(this.#removals.has(key)||this.store.get(owner,'agent-deletion',agent.id)||this.binding(owner,agent.id)?.status==='removed')
+      throw new HttpError(410,'Bot 已删除或正在移除记忆用户,不能重新注册','openviking_user_removed')
     const existing = this.#registrations.get(key)
     if (existing) return existing
     const pending = this.registerUser(owner, agent)
@@ -144,19 +151,30 @@ export class OpenVikingService {
     }
   }
   async removeUser(owner: string, agent: Pick<WorkspaceAgent, 'id' | 'temporaryGoalId'>): Promise<void> {
-    const binding = this.binding(owner, agent.id)
-    if (!binding || binding.status !== 'active' && !binding.pendingRemoval) return
-    if (!this.#configuration) {
-      // Disabled mode preserves the remote data, but this local Bot can no longer reach it.
-      this.saveBinding({ ...binding, status: 'removed', pendingRemoval: true, updatedAt: Date.now() })
-      return
-    }
-    try {
-      await this.adminClient().adminRemoveUser(this.#configuration.accountId, binding.userId)
-    } catch (error) {
-      if (!(error instanceof OpenVikingError) || ![404, 'NOT_FOUND'].includes(error.statusCode ?? error.code)) mapError(error, 'user 删除')
-    }
-    this.saveBinding({ ...binding, status: 'removed', pendingRemoval: false, updatedAt: Date.now() })
+    const namespace=this.namespace,key=`${namespace}:${owner}:${agent.id}`
+    const existing=this.#removals.get(key)
+    if(existing)return existing
+    const registrations=[...this.#registrations.entries()].filter(([registrationKey])=>registrationKey.endsWith(`:${owner}:${agent.id}`)).map(([,registration])=>registration)
+    const config=this.#configuration,admin=config?this.adminClient():undefined
+    const pending=Promise.resolve().then(async()=>{
+      // Registration can already have reached the remote server. Finish it before
+      // removal so its late receipt cannot recreate the deleted user's binding.
+      await Promise.allSettled(registrations)
+      // Keep the deletion attached to the configuration captured at admission.
+      const legacy=this.store.get<OpenVikingUserBinding>(owner,'openviking-binding',agent.id)
+      const binding=config
+        ? this.store.get<OpenVikingUserBinding>(owner,'openviking-binding',`${namespace}:${agent.id}`)??(legacy&&(!legacy.namespace||legacy.namespace===namespace)?legacy:undefined)
+        : legacy
+      if(binding?.status==='removed'&&!binding.pendingRemoval)return
+      const removed:OpenVikingUserBinding={...(binding??{provider:'openviking',owner,agentId:agent.id,userId:openVikingUserId(agent.id),encryptedUserKey:'',namespace}),status:'removed',pendingRemoval:true,updatedAt:Date.now()}
+      this.saveBinding(removed)
+      if(!config||!admin)return // Keep a durable pending removal while disabled.
+      try{await admin.adminRemoveUser(config.accountId,removed.userId)}
+      catch(error){if(!(error instanceof OpenVikingError)||!(error.statusCode===404||error.code==='NOT_FOUND'))mapError(error,'user 删除')}
+      this.saveBinding({...removed,pendingRemoval:false,updatedAt:Date.now()})
+    })
+    this.#removals.set(key,pending)
+    try{await pending}finally{this.#removals.delete(key)}
   }
 }
 

+ 1 - 1
src/server/realtimeBroker.ts

@@ -161,7 +161,7 @@ export class RealtimeBroker {
     if (existing) return this.commands.get(key) ?? Promise.resolve(this.completed.get(key)?.receipt ?? existing)
     const work = this.perform(c, frame, requestId).then(response => {
       const state = response.error ? 'rejected' : 'confirmed'
-      this.receipts.finish(c.principal.key, requestId, state, response)
+      this.receipts.finish(c.principal.key, requestId, state, response, frame)
       return { requestId, state, response } as CommandReceipt
     }, (error: unknown) => {
       if (error instanceof HttpError) {

+ 37 - 8
src/server/realtimeReceipts.ts

@@ -10,6 +10,8 @@ export interface CommandReceipt {
   response?: Record<string, unknown>
 }
 
+interface ReceiptCommand { method?: unknown; params?: Record<string, unknown> }
+
 /** Write-ahead admission, not an event/transcript store. Never stores command bodies. */
 export class RealtimeReceipts {
   private db: DatabaseSync
@@ -24,36 +26,63 @@ export class RealtimeReceipts {
         state TEXT NOT NULL, response TEXT, updated INTEGER NOT NULL,
         PRIMARY KEY(owner,id));
       CREATE INDEX IF NOT EXISTS receipt_retention ON receipts(state,updated);
+      CREATE TABLE IF NOT EXISTS receipt_tombstones (
+        owner TEXT NOT NULL, id TEXT NOT NULL, fingerprint TEXT NOT NULL,
+        PRIMARY KEY(owner,id));
       UPDATE receipts SET state='unknown' WHERE state='pending';`)
   }
   lookup(owner: string, id: string): CommandReceipt | undefined {
     const row = this.db.prepare('SELECT state,response FROM receipts WHERE owner=? AND id=?').get(owner, id)
-    if (!row) return undefined
+    if (!row) return this.db.prepare('SELECT 1 FROM receipt_tombstones WHERE owner=? AND id=?').get(owner, id)
+      ? { requestId: id, state: 'unknown' } : undefined
     return { requestId: id, state: row.state as CommandReceipt['state'],
       ...(row.response ? { response: JSON.parse(String(row.response)) as Record<string, unknown> } : {}) }
   }
   reserve(owner: string, id: string, frame: string): CommandReceipt | undefined {
     if (!/^[A-Za-z0-9:_-]{1,200}$/.test(id)) throw new HttpError(400, 'Invalid request ID', 'invalid_request_id')
     const fingerprint = createHash('sha256').update(frame).digest('hex')
-    const row = this.db.prepare('SELECT fingerprint FROM receipts WHERE owner=? AND id=?').get(owner, id)
+    const row = this.db.prepare(`SELECT fingerprint FROM receipts WHERE owner=? AND id=?
+      UNION ALL SELECT fingerprint FROM receipt_tombstones WHERE owner=? AND id=?`).get(owner, id, owner, id)
     if (row) {
       if (row.fingerprint !== fingerprint) throw new HttpError(409, 'Request ID payload conflict', 'idempotency_conflict')
       return this.lookup(owner, id)
     }
-    this.db.prepare("DELETE FROM receipts WHERE updated<? AND state IN ('confirmed','rejected')").run(Date.now() - 86_400_000)
-    if (Number(this.db.prepare('SELECT count(*) AS n FROM receipts').get()!.n) >= 100_000) {
-      throw new HttpError(429, 'Command receipt capacity reached', 'receipt_capacity')
+    // Retire results, never admission identities. Old clients may retry years later.
+    // A compact fingerprint tombstone prevents those retries from executing again.
+    this.db.exec('BEGIN IMMEDIATE')
+    try {
+      const cutoff = Date.now() - 86_400_000
+      this.db.prepare(`INSERT OR IGNORE INTO receipt_tombstones
+        SELECT owner,id,fingerprint FROM receipts WHERE updated<? AND state IN ('confirmed','rejected')`).run(cutoff)
+      this.db.prepare("DELETE FROM receipts WHERE updated<? AND state IN ('confirmed','rejected')").run(cutoff)
+      if (Number(this.db.prepare('SELECT count(*) AS n FROM receipts').get()!.n) >= 100_000) {
+        throw new HttpError(429, 'Command receipt capacity reached', 'receipt_capacity')
+      }
+      this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?,NULL,?)').run(owner, id, fingerprint, 'pending', Date.now())
+      this.db.exec('COMMIT')
+    } catch (error) {
+      this.db.exec('ROLLBACK')
+      throw error
     }
-    this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?,NULL,?)').run(owner, id, fingerprint, 'pending', Date.now())
     return undefined
   }
-  finish(owner: string, id: string, state: CommandReceipt['state'], response?: Record<string, unknown>): void {
-    // Persist only control-plane acknowledgements, never prompt, attachment or history content.
+  finish(owner: string, id: string, state: CommandReceipt['state'], response?: Record<string, unknown>, command?: ReceiptCommand): void {
+    // Persist bounded recovery metadata, never command bodies, attachment bytes or history.
     const result = response?.result as Record<string, unknown> | undefined
     const safe: Record<string, unknown> = {}
     for (const key of ['status', 'session_id', 'stored_session_id', 'session_key', 'queued', 'running', 'ok']) {
       if (result && ['string', 'number', 'boolean'].includes(typeof result[key])) safe[key] = result[key]
     }
+    const strings = command?.method === 'config.set' && ['model', 'reasoning', 'fast'].includes(String(command.params?.key))
+      ? ['value', 'confirm_message', 'warning']
+      : ['file.attach', 'image.attach_bytes', 'pdf.attach'].includes(String(command?.method))
+        ? ['path', 'image_path', 'ref_path', 'ref_text'] : []
+    for (const key of strings) {
+      if (typeof result?.[key] === 'string' && result[key].length <= 4096) safe[key] = result[key]
+    }
+    if (command?.method === 'config.set') for (const key of ['deferred', 'confirm_required']) {
+      if (typeof result?.[key] === 'boolean') safe[key] = result[key]
+    }
     const saved = response?.error ? { error: { message: 'Upstream rejected command' } }
       : response ? { result: safe } : undefined
     this.db.prepare('UPDATE receipts SET state=?,response=?,updated=? WHERE owner=? AND id=?')

+ 3 - 3
src/server/runnerHub.ts

@@ -200,9 +200,9 @@ export class RunnerHub {
           let closed=false
           return {rpc:async(method,params)=>{
             if(closed)throw new Error('Runner gateway closed')
-            const cleanup=['session.interrupt','session.close'].includes(method)
+            const cleanup=['session.interrupt','session.close','session.active_list'].includes(method)
             if(method==='computer.transfer'&&!this.online.get(record.id)?.features.includes('file-transfer-v1'))throw new HttpError(409,'请更新 Runner 以使用虚拟机文件传输','computer_transfer_upgrade_required')
-            if(scope?.cleanupOnly&&(!['session.resume','session.interrupt','session.close'].includes(method)||(method==='session.resume'&&params.session_id!==scope.sessionId)))throw new HttpError(403,'清理通道只能操作原绑定会话','runner_cleanup_forbidden')
+            if(scope?.cleanupOnly&&(!['session.resume','session.interrupt','session.close','session.active_list'].includes(method)||(method==='session.resume'&&params.session_id!==scope.sessionId)))throw new HttpError(403,'清理通道只能操作原绑定会话','runner_cleanup_forbidden')
             if(!cleanup&&!valid())throw new HttpError(403,'本轮执行授权已失效','runner_command_not_admitted')
             if(!scope?.cleanupOnly&&(method==='session.create'||method==='session.resume'))requireProfile(String(params.profile??'default'))
             return this.request(record.id,'gateway.rpc',{connectionId:id,method,params})
@@ -380,7 +380,7 @@ export class RunnerHub {
       if(match[2]==='admit') {
         const pending=this.pending.get(record.id)?.get(body.id),command=pending?.command
         let allowed=!!command&&command.expiresAt>Date.now()&&pending!.valid()&&this.networkCommandAllowed(record.id,command.kind,command.payload)
-        if(command?.kind==='gateway.rpc'||command?.kind==='gateway.open'){const connection=this.connections.get(String(command.payload.connectionId));allowed&&=connection?.runnerId===record.id&&(command.kind==='gateway.rpc'&&['session.interrupt','session.close'].includes(String(command.payload.method))||connection.valid())}
+        if(command?.kind==='gateway.rpc'||command?.kind==='gateway.open'){const connection=this.connections.get(String(command.payload.connectionId));allowed&&=connection?.runnerId===record.id&&(command.kind==='gateway.rpc'&&['session.interrupt','session.close','session.active_list'].includes(String(command.payload.method))||connection.valid())}
         if(command?.kind==='lease.create'||command?.kind==='lease.bind') {
           const lease=this.leases.get(String(command.payload.leaseId))
           try {if(lease?.runnerId!==record.id)allowed=false;else lease.input.assertActive()}catch{allowed=false}

+ 22 - 0
src/server/sessionStop.ts

@@ -0,0 +1,22 @@
+import { setTimeout as delay } from 'node:timers/promises'
+import { HttpError } from './errors.js'
+
+/** Interrupt acknowledgement only requests cancellation; native tools may still be running. */
+export async function waitForHermesSessionIdle(
+  rpc: (method: string, params: Record<string, unknown>) => Promise<any>,
+  sessionId: string,
+  profile: string,
+  options: { signal?: AbortSignal; timeoutMs?: number; pollMs?: number } = {},
+): Promise<void> {
+  const deadline = Date.now() + (options.timeoutMs ?? 20_000)
+  for (;;) {
+    options.signal?.throwIfAborted()
+    const state = await rpc('session.active_list', { profile, current_session_id: sessionId })
+    if (!Array.isArray(state?.sessions)) throw new HttpError(502, 'Hermes 停止状态无效,原执行仍需核对', 'session_stop_unconfirmed')
+    // Finalized sessions disappear from Hermes' live list.
+    const session = state.sessions.find((item: { id?: string }) => item.id === sessionId)
+    if (!session || session.status === 'idle') return
+    if (Date.now() >= deadline) throw new HttpError(409, 'Hermes 原任务尚未停止,请等待执行结束后重试', 'session_still_stopping')
+    await delay(options.pollMs ?? 100, undefined, { signal: options.signal })
+  }
+}

+ 2 - 1
src/server/taskCoordinator.ts

@@ -233,6 +233,7 @@ export class WorkspaceTaskCoordinator {
       throw new HttpError(403,'恢复任务需要当前用户的新指令和有效管理员权限','goal_resume_forbidden')
     return this.store.command(owner,body.requestId,{actorId,operation:'goal.resume',goalId:goal.id,origin},()=>{
       if(goal.status==='cancelling')throw new HttpError(409,'旧执行尚未确认结束,请先核对状态','goal_still_running')
+      if(this.store.hasPendingCleanup(owner,{conversationTaskId:goal.id}))throw new HttpError(409,'旧执行尚未确认结束,请先核对状态','goal_still_running')
       if(!['blocked','waiting','cancelled'].includes(goal.status))throw new HttpError(409,'只能恢复受阻、等待或已停止的目标','goal_not_resumable')
       if(this.store.list<Run>(owner,'run').some(r=>r.conversationId===goal.conversationId&&r.conversationTaskId===goal.id&&r.id!==source.id&&!terminalRun(r.status)))
         throw new HttpError(409,'旧执行尚未确认结束,请先核对状态','goal_still_running')
@@ -291,7 +292,7 @@ export class WorkspaceTaskCoordinator {
       if(!helper.archived&&!terminalGoal(goal.status)&&helper.helperActivation===(goal.activation??1))continue
       if(!helper.archived){helper.archived=true;helper.revision++;helper.updatedAt=Date.now();helper.retiredAt=Date.now();helper.cleanupState='pending';this.store.put(owner,'agent',helper.id,helper);this.store.event(owner,'agent.changed',helper)}
       if(!helper.cleanupState){helper.cleanupState='pending';helper.retiredAt??=Date.now();this.store.put(owner,'agent',helper.id,helper)}
-      const works=this.store.list<{agentId:string;runId:string;status:string}>(owner,'turn').filter(work=>work.agentId===helper.id&&!terminalRun(work.status))
+      const works=this.store.list<{agentId:string;runId:string;status:string;cleanupPending?:boolean}>(owner,'turn').filter(work=>work.agentId===helper.id&&(!terminalRun(work.status)||work.cleanupPending))
       if(works.length){for(const runId of new Set(works.map(work=>work.runId)))void this.runtime.stop(owner,runId).catch(()=>{});continue}
       if(helper.cleanupState==='complete')continue
       const key=`${owner}:${helper.id}`

+ 47 - 12
src/server/workspaceAssets.ts

@@ -2,16 +2,19 @@ import { randomUUID, createHash } from 'node:crypto'
 import { mkdirSync, writeFileSync } from 'node:fs'
 import { join, basename } from 'node:path'
 import {HttpError} from './errors.js'
+import { authorizeFileRead } from './fileAccess.js'
 import { lookup } from 'mime-types'
 import { messageFileReferences, type MessageFileOrigin } from '../shared/messageFiles.js'
 import { WorkspaceStore } from './workspaceStore.js'
-import { WorkspaceNodes } from './workspaceGateway.js'
+import { WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
 import type { WorkspaceAgent, WorkspaceFile, WorkspaceMessage } from '../shared/workspace.js'
 export interface StoredWorkspaceFile extends WorkspaceFile, MessageFileOrigin {
   path: string
   digest?: string
   sourceNodeId?:string
 }
+const archiveKey = (nodeId: string, profile: string, messageId: string, path: string) =>
+  createHash('sha256').update(JSON.stringify([nodeId, profile, messageId, path])).digest('hex')
 export function publicFile(f: StoredWorkspaceFile): WorkspaceFile {
   const { path: _path, digest: _digest, messageFileSource: _source, ...result } = f
   return result
@@ -83,15 +86,39 @@ export class WorkspaceAssets {
     readonly nodes: WorkspaceNodes,
     readonly home: string,
   ) {}
+  private requireCapacity(owner: string, messageId: string, bytes: number): void {
+    const count = Number(this.store.db.prepare("SELECT count(*) AS n FROM workspace_entities WHERE owner=? AND kind='file' AND json_extract(data,'$.messageId')=?").get(owner, messageId)?.n ?? 0)
+    if (bytes > 25 * 1024 * 1024 || count >= 8)
+      throw new HttpError(413, '每轮最多 8 个产物,每个不超过 25 MiB', 'artifact_limit')
+    const used = Number(this.store.db.prepare("SELECT coalesce(sum(json_extract(data,'$.size')),0) AS n FROM workspace_entities WHERE kind='file'").get()?.n ?? 0)
+    if (used + bytes > 2 * 1024 * 1024 * 1024) throw new HttpError(413, '产物存储空间已达上限', 'artifact_quota')
+  }
+  /** Legacy archives lost nodeId, but their immutable archive key includes it.
+   * Restore only a unique authorized match; never assume a file came from local. */
+  recoverFileSource(owner: string, file: StoredWorkspaceFile): StoredWorkspaceFile {
+    if (file.sourceNodeId || !file.sourcePath || !file.profile || !file.messageId) return file
+    const records = Number(this.store.db.prepare("SELECT count(*) AS n FROM workspace_entities WHERE owner=? AND kind='file' AND json_extract(data,'$.sourcePath')=? AND json_extract(data,'$.profile')=? AND json_extract(data,'$.messageId')=?").get(owner, file.sourcePath, file.profile, file.messageId)?.n ?? 0)
+    if (records !== 1) return file
+    const candidates = ['local', ...this.store.list<WorkspaceNode>(owner, 'node').map(node => node.id)]
+    let revokedMatch = false
+    const matches = [...new Set(candidates)].filter(nodeId => {
+      if (!this.store.get(owner, 'archived-path', archiveKey(nodeId, file.profile!, file.messageId!, file.sourcePath!))) return false
+      try { this.nodes.requireSource(owner, { nodeId, profile: file.profile! }) }
+      catch { revokedMatch = true; return false }
+      return true
+    })
+    if (revokedMatch || matches.length !== 1) return file
+    const recovered = { ...file, sourceNodeId: matches[0]! }
+    this.store.put(owner, 'file', file.id, recovered)
+    return recovered
+  }
   publish(owner:string,message:WorkspaceMessage,agent:WorkspaceAgent,name:string,bytes:Buffer):WorkspaceFile {
     const digest=createHash('sha256').update(bytes).digest('hex')
     name=basename(name).replace(/[\\/\u0000-\u001f\u007f]/g,'_').slice(0,240)||'artifact'
     const files=this.store.list<StoredWorkspaceFile>(owner,'file')
     const existing=files.find(file=>file.messageId===message.id&&file.name===name&&file.digest===digest)
     if(existing)return publicFile(existing)
-    if(bytes.length>25*1024*1024||files.filter(file=>file.messageId===message.id).length>=8)throw new HttpError(413,'每轮最多 8 个产物,每个不超过 25 MiB','artifact_limit')
-    const used=Number(this.store.db.prepare("SELECT coalesce(sum(json_extract(data,'$.size')),0) AS n FROM workspace_entities WHERE kind='file'").get()?.n??0)
-    if(used+bytes.length>2*1024*1024*1024)throw new HttpError(413,'产物存储空间已达上限','artifact_quota')
+    this.requireCapacity(owner, message.id, bytes.length)
     const dir=join(this.home,'workspace-files',owner);mkdirSync(dir,{recursive:true,mode:0o700})
     const path=join(dir,digest)
     try{writeFileSync(path,bytes,{mode:0o600,flag:'wx'})}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error}
@@ -143,19 +170,26 @@ export class WorkspaceAssets {
       }
     }
     for (const path of [...paths].slice(0, 8)) {
-      const key = createHash('sha256')
-        .update(JSON.stringify([nodeId, profile, messageId, path]))
-        .digest('hex')
+      const key = archiveKey(nodeId, profile, messageId, path)
       if (this.store.get(owner, 'archived-path', key) || this.pending.has(`${owner}:${key}`))
         continue
       this.pending.add(`${owner}:${key}`)
       try {
-        const response = await (remoteAgentId ? this.nodes.targetForAgent(owner,{nodeId,remoteAgentId}) : this.nodes.target(owner,nodeId))
-          .session.request('/api/files/download', {
-            search: new URLSearchParams({ profile, path }),
-            maxResponseBytes: 25 * 1024 * 1024,
-          })
+        this.nodes.requireSource(owner, { nodeId, profile })
+        this.requireCapacity(owner, messageId, 0)
+        const target = remoteAgentId ? this.nodes.targetForAgent(owner,{nodeId,remoteAgentId}) : this.nodes.target(owner,nodeId)
+        const authorizedPath = await authorizeFileRead({ home: this.home }, target.session, path, profile)
+        this.nodes.requireSource(owner, { nodeId, profile })
+        const response = await target.session.request('/api/files/download', {
+          search: new URLSearchParams({ profile, path: authorizedPath }),
+          maxResponseBytes: 25 * 1024 * 1024,
+        })
         if (response.status !== 200 || this.stopped) continue
+        // A download may outlive an assignment or directory-policy change.
+        this.nodes.requireSource(owner, { nodeId, profile })
+        if (await authorizeFileRead({ home: this.home }, target.session, path, profile) !== authorizedPath || this.stopped) continue
+        this.nodes.requireSource(owner, { nodeId, profile })
+        this.requireCapacity(owner, messageId, response.body.length)
         const dir = join(this.home, 'workspace-files', owner)
         mkdirSync(dir, { recursive: true, mode: 0o700 })
         const digest = createHash('sha256').update(response.body).digest('hex')
@@ -177,6 +211,7 @@ export class WorkspaceAssets {
           size: response.body.length,
           sender,
           profile,
+          sourceNodeId: nodeId,
           conversationId,
           messageId,
           createdAt: Date.now(),

+ 3 - 1
src/server/workspaceEnvironment.ts

@@ -1,5 +1,5 @@
 import {MANAGED_BROWSER_TOOLS,MANAGED_BROWSER_VM_TOOLS} from './managedBrowserTools.js'
-import type { DesktopEnvironmentSnapshot, WorkspaceEnvironment } from '../shared/botEnvironment.js'
+import type { DesktopEnvironmentSnapshot, HermesRuntimeSnapshot, WorkspaceEnvironment } from '../shared/botEnvironment.js'
 import type { WorkspaceAgent } from '../shared/workspace.js'
 import type { HostToolSettings } from './hostToolSettings.js'
 import { DESKTOP_ENVIRONMENT_TOOLS, DESKTOP_FILE_TOOL_IDS } from './desktopEnvironments.js'
@@ -14,6 +14,7 @@ export function buildWorkspaceEnvironment(input: {
   bridge: boolean
   cloud: boolean
   plugins: boolean
+  runtime?: HermesRuntimeSnapshot
   execution?:import('../shared/executionEnvironment.js').ExecutionSelection
   serverDesktop?:boolean
   managedBrowser?: boolean
@@ -28,6 +29,7 @@ export function buildWorkspaceEnvironment(input: {
   return {
     ...(input.execution?{selection:input.execution}:{}),
     version: 1, capturedAt: desktop.capturedAt, execution: { nodeId: agent.nodeId, profile: agent.profile },
+    runtime: input.runtime ? structuredClone(input.runtime) : { connected: false },
     open: { computer: globals.scriptMachine, server: globals.serverComputer, vm: globals.vm, cloud: globals.cloud },
     desktop:{...desktop,hosts},
     tools: {

+ 5 - 2
src/server/workspaceMemorySynthesis.ts

@@ -39,6 +39,7 @@ export class WorkspaceMemorySynthesis {
   private timer?: ReturnType<typeof setInterval>
   private active = false
   private closed = false
+  private lastOwner?: string
   infer: (owner: string, agent: WorkspaceAgent, prompt: string) => Promise<string>
   constructor(readonly runtime: WorkspaceRuntime) {
     this.infer = async (owner, agent, prompt) => {
@@ -85,11 +86,13 @@ export class WorkspaceMemorySynthesis {
     if (this.active || this.closed) return
     this.active = true
     try {
-      for (const owner of this.runtime.store.owners()) {
+      const owners = this.runtime.store.owners()
+      const start = (owners.indexOf(this.lastOwner ?? '') + 1) % (owners.length || 1)
+      for (const owner of [...owners.slice(start), ...owners.slice(0, start)]) {
         if (this.closed || !this.runtime.userActive(owner) || owner.startsWith('_')) continue
         const jobs = this.runtime.knowledge.jobs(owner).filter(j => ['pending', 'running'].includes(j.status) && j.nextAt <= Date.now()).sort((a, b) => a.createdAt - b.createdAt)
         // One inference per tick keeps background work behind conversation work.
-        if (jobs[0]) { await this.run(owner, jobs[0]); break }
+        if (jobs[0]) { this.lastOwner = owner; await this.run(owner, jobs[0]); break }
       }
     } catch { /* Corrupt files remain untouched and are surfaced by the management API. */ }
     finally { this.active = false }

+ 5 - 1
src/server/workspacePrompt.ts

@@ -40,9 +40,13 @@ function section(title: string, lines: Array<string | undefined>): string {
 export function workspaceEnvironmentPrompt(env: WorkspacePromptEnvironment): string {
   const open = [env.open.computer && '电脑', env.open.server && '服务器桌面', env.open.vm && '虚拟环境', env.open.cloud && '云虚拟机'].filter(Boolean)
   const desktop = env.tools.desktopView || env.tools.desktopFile
+  const runtime = env.runtime.metadata
   return section('本轮环境与设备', [
     env.selection?`默认运行环境:${({none:'不指定',server:'服务器本机',virtual:'虚拟环境'})[env.selection.mode]};配置版本 ${env.selection.revision}。这只决定未指定目标时的默认执行位置,不限制其他已开放且获授权的环境。用户明确指定目标时优先使用该目标;默认环境未开放或不可用时说明原因,不能因此获得额外权限。未指定默认环境时按任务和本轮可用能力选择,目标不明确时澄清。`:undefined,
-    '模型会话运行在服务端 Hermes。Hermes 原生终端和文件工具操作它所在的服务器运行环境,不代表用户所说的「本机」;若运行在容器中,也不能假定其路径与服务器桌面共享。网页、浏览器、终端和文件能力以本轮实际工具目录为准。',
+    env.runtime.connected ? `服务端 Hermes 已连接(节点=${JSON.stringify(env.execution.nodeId)};Profile=${JSON.stringify(env.execution.profile)})。该状态来自本轮 Hermes 会话连接,与夭夭客户端是否打开、电脑桌面代理是否在线无关。` : '服务端 Hermes 连接状态尚未确认,不能根据客户端电脑清单判断。',
+    '模型会话运行在服务端 Hermes。操作服务端运行环境时优先使用本轮可用的 Hermes 原生终端、文件、桌面和浏览器工具;它们的实际执行目标遵循 Hermes 自身配置,不能假定所有工具都操作同一台物理机。网页、浏览器、终端和文件能力以本轮实际工具目录为准,首次操作时按工具说明和结果确认目标。',
+    runtime ? `Hermes 进程环境(由对应 Profile 的工具桥上报,不代表客户端电脑或工具后端):系统=${JSON.stringify(runtime.platform)};系统版本=${JSON.stringify(runtime.osRelease)};架构=${JSON.stringify(runtime.arch)}。原生能力的 Profile 工具目录(名称是数据,目录存在不保证后端连接就绪):${JSON.stringify(runtime.nativeTools)}。` : 'Hermes 原生能力与系统信息尚未由工具桥上报;这不表示工具被禁用,请按本轮实际工具目录发现和核实,不能因客户端未连接而报告服务器离线。',
+    '客户端电脑清单只描述 desktop_* 控制通道。服务器桌面代理离线不影响已连接的 Hermes 原生能力;desktop_* 的 host="server" 专指服务器桌面代理,不是 Hermes 原生工具的运行环境。服务器也打开客户端时,两种连接分别报告;未确认实际操作目标一致前,不因名称或 IP 相同而合并设备、路径或权限。Hermes 在容器中时,其路径不能当作宿主机路径;Hermes 环境也不自动代表用户所说的「本机」。',
     env.cwd ? `Hermes 本轮工作目录(已确认):${JSON.stringify(env.cwd)}。该路径仅属于 Hermes 运行环境。` : undefined,
     `全局开放的额外电脑环境:${open.join('、') || '无'}。开放不等于在线或已授权;具体操作仍需目标设备就绪。已开放且获授权的环境可按任务同时使用,工具以本轮实际目录为准。`,
     '先确定操作目标,再选择工具。用户明确点名的设备优先;「本机」只指本轮消息来源电脑,历史消息中的来源不适用于本轮。来源未知、名称重名或目标不明确时先澄清;目标离线、未开放或未授权时说明原因,不得切换其他机器代做。',

+ 19 - 13
src/server/workspaceRoutes.ts

@@ -56,15 +56,19 @@ export function workspaceRouter(
         .get(user, id)?.n ?? 0,
     )
   const fileRecord = (user: string, id: string): StoredWorkspaceFile => {
+    const authorize = (file: StoredWorkspaceFile) => {
+      file = assets.recoverFileSource(user, file)
+      if ((file.sourcePath || file.sourceNodeId) && (!file.sourceNodeId || !file.profile)) throw new HttpError(403, '归档文件来源无法确认,请重新交付文件', 'archived_file_source_unknown')
+      if (file.sourceNodeId && file.profile) nodes.requireSource(user, { nodeId: file.sourceNodeId, profile: file.profile })
+      return file
+    }
     if (/^\d+$/.test(id)) {
       const row = store.db
         .prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='file' AND rowid=?")
         .get(user, Number(id))
-      if (row) {const file:StoredWorkspaceFile=JSON.parse(String(row.data));if(file.sourceNodeId&&file.profile)nodes.requireSource(user,{nodeId:file.sourceNodeId,profile:file.profile});return file}
+      if (row) return authorize(JSON.parse(String(row.data)))
     }
-    const file=store.require<StoredWorkspaceFile>(user,'file',id)
-    if(file.sourceNodeId&&file.profile)nodes.requireSource(user,{nodeId:file.sourceNodeId,profile:file.profile})
-    return file
+    return authorize(store.require<StoredWorkspaceFile>(user,'file',id))
   }
   router.get('/api/app/capabilities', (ctx) => {
     owner(ctx)
@@ -182,8 +186,7 @@ export function workspaceRouter(
   })
   router.delete('/api/app/agents/:id', async (ctx) => {
     const user = owner(ctx)
-    await runtime.cleanupAgent(user, ctx.params.id)
-    store.deleteAgent(user, ctx.params.id)
+    await runtime.deleteAgent(user, ctx.params.id)
     ctx.body = { ok: true }
   })
   router.get('/api/app/agents/:id/usage', (ctx) => {
@@ -219,13 +222,16 @@ export function workspaceRouter(
   router.post('/api/app/conversations/:id/lifecycle', async ctx => {
     const input = parse(z.object({ action: z.enum(['archive', 'restore', 'delete']), confirmationToken: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(), body(ctx))
     if (input.action !== 'restore' && !input.confirmationToken) throw new HttpError(409, '请先确认聊天操作', 'lifecycle_confirmation_required')
-    const user = owner(ctx), conversation = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id)
-    if (runtime.openViking?.enabled && input.action === 'delete' && conversation.kind === 'direct') {
-      const agent = store.require<WorkspaceAgent>(user, 'agent', conversation.memberIds[0]!)
-      store.changeConversationLifecycle(user, conversation.id, 'archive', input.confirmationToken)
-      await runtime.cleanupAgent(user, agent.id)
-      store.deleteAgent(user, agent.id)
-    } else store.changeConversationLifecycle(user, conversation.id, input.action, input.confirmationToken)
+    const user = owner(ctx), conversation = store.get<WorkspaceConversation>(user, 'conversation', ctx.params.id)
+    if(!conversation&&input.action==='delete'){
+      const deletion=store.list<{id:string;conversationIds:string[]}>(user,'agent-deletion').find(item=>item.conversationIds.includes(ctx.params.id))
+      if(deletion)await runtime.deleteAgent(user,deletion.id)
+      else store.require(user,'conversation',ctx.params.id)
+    }else{
+      const current=conversation??store.require<WorkspaceConversation>(user,'conversation',ctx.params.id)
+      store.changeConversationLifecycle(user,current.id,input.action,input.confirmationToken)
+      if(input.action==='delete'&&current.kind==='direct')await runtime.cleanupAgent(user,current.memberIds[0]!)
+    }
     runtime.wake()
     ctx.body = { ok: true }
   })

+ 54 - 7
src/server/workspaceRuntime.ts

@@ -7,6 +7,7 @@ import type {BrowserUpload} from '../runner/browser/types.js'
 import {type DesktopEnvironments} from './desktopEnvironments.js'
 import {type GrokCloud} from './grokCloud.js'
 import {VmToolSession} from './vmComputer.js'
+import {waitForHermesSessionIdle} from './sessionStop.js'
 import { randomUUID } from 'node:crypto'
 import { setTimeout as delay } from 'node:timers/promises'
 import { readFileSync } from 'node:fs'
@@ -27,6 +28,8 @@ import type {
 } from '../shared/workspace.js'
 import { buildWorkspacePrompt } from './workspacePrompt.js'
 import { buildWorkspaceEnvironment, workspaceEnvironmentTools } from './workspaceEnvironment.js'
+import { parseHermesRuntimeMetadata } from './hermesRuntimeEnvironment.js'
+import type { HermesRuntimeMetadata } from '../shared/botEnvironment.js'
 import { readHostTools } from './hostToolSettings.js'
 import { resolveBotModelSettings, applyBotModelSettings, botModelTarget, BotModelConfirmationError } from './botModelSettings.js'
 function globalComputers(home: string) {
@@ -125,7 +128,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   private operationJournal:ToolOperationJournal
   assertCanSubmit:()=>void=()=>{}
   get idleForUpdate(): boolean {
-    return this.live.size === 0 && this.executing.size === 0 && !this.store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') LIMIT 1").get()
+    return this.live.size === 0 && this.executing.size === 0 && !this.store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND (COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') OR coalesce(json_extract(data,'$.cleanupPending'),0)=1) LIMIT 1").get()
   }
   desktopEnvironments?:DesktopEnvironments
   cloud?:GrokCloud
@@ -133,6 +136,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   computerAvailable?:(owner:string,agent:Agent)=>boolean
   inspector?:import('./workspaceInspector.js').WorkspaceInspector
   private live = new Map<string, LiveTurn>()
+  private memoryCleanup?: Promise<void>
+  private memoryCleanupRetries=new Map<string,{attempt:number;after:number}>()
   readonly teamTools: WorkspaceTeamTools
   routineTools?: WorkspaceRoutineTools
   readonly tasks: WorkspaceTaskCoordinator
@@ -144,6 +149,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     super(store, userActive)
     this.operationJournal=new ToolOperationJournal(store)
     this.openViking = openViking
+    if(openViking)openViking.onConfigurationChanged=()=>{if(!this.closing)this.wake()}
     this.teamTools = new WorkspaceTeamTools(store, nodes, this)
     this.tasks = new WorkspaceTaskCoordinator(store, this)
     this.knowledge = new WorkspaceKnowledge(store.home, store, openViking)
@@ -158,19 +164,54 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     if (!this.openViking?.enabled) return
     await this.openViking.ensureUser(owner, { id, temporaryGoalId: undefined })
   }
+  protected override maintenance(): boolean {
+    if(!this.openViking?.enabled)return false
+    const pending=new Map<string,{owner:string;id:string}>()
+    for(const owner of this.store.owners()){
+      for(const item of this.store.list<{id:string;memoryCleanupPending?:boolean}>(owner,'agent-deletion'))
+        if(item.memoryCleanupPending)pending.set(`${owner}:${item.id}`,{owner,id:item.id})
+      for(const binding of this.store.list<{agentId:string;namespace:string;status:string;pendingRemoval?:boolean}>(owner,'openviking-binding'))
+        if(binding.namespace===this.openViking.namespace&&binding.status==='removed'&&binding.pendingRemoval)
+          pending.set(`${owner}:${binding.agentId}`,{owner,id:binding.agentId})
+    }
+    if(!this.memoryCleanup){
+      const next=[...pending.entries()].find(([key])=>(this.memoryCleanupRetries.get(key)?.after??0)<=Date.now())
+      if(next){
+        const [key,{owner,id}]=next
+        this.memoryCleanup=this.cleanupAgent(owner,id).then(()=>{this.memoryCleanupRetries.delete(key)})
+          .catch(error=>{
+            if(this.closing)return
+            const attempt=(this.memoryCleanupRetries.get(key)?.attempt??0)+1
+            this.memoryCleanupRetries.set(key,{attempt,after:Date.now()+Math.min(30_000,500*2**Math.min(attempt-1,6))})
+            const deletion=this.store.get<{id:string;memoryCleanupPending:boolean;cleanupError?:string}>(owner,'agent-deletion',id)
+            if(deletion){deletion.cleanupError=error instanceof Error?error.message:'远端记忆清理失败';this.store.put(owner,'agent-deletion',id,deletion)}
+          }).finally(()=>{this.memoryCleanup=undefined;this.wake()})
+      }
+    }
+    return pending.size>0
+  }
   async cleanupAgent(owner: string, id: string): Promise<void> {
-    if (!this.openViking) return
-    await this.openViking.removeUser(owner, { id, temporaryGoalId: undefined })
+    await this.openViking?.removeUser(owner, { id, temporaryGoalId: undefined })
+    const deletion=this.store.get<{id:string;memoryCleanupPending:boolean;cleanupError?:string}>(owner,'agent-deletion',id)
+    if(deletion){deletion.memoryCleanupPending=this.openViking?.binding(owner,id)?.pendingRemoval===true;delete deletion.cleanupError;this.store.put(owner,'agent-deletion',id,deletion)}
+  }
+  async deleteAgent(owner: string, id: string): Promise<void> {
+    // Commit all local validation/deletion before any destructive remote action.
+    // The durable tombstone also makes a repeated DELETE retry remote cleanup.
+    if(this.store.get(owner,'agent',id))this.store.deleteAgent(owner,id)
+    else if(!this.store.get(owner,'agent-deletion',id))this.store.require(owner,'agent',id)
+    try{await this.cleanupAgent(owner,id)}finally{this.wake()}
   }
   send(owner: string, conversationId: string, input: unknown): Run {
     return this.submit(owner, conversationId, input)
   }
-  async botCapabilities(owner: string, agent: Agent, target = this.nodes.target(owner, agent.nodeId)): Promise<{ tools: boolean; memory: boolean; extraction: boolean }> {
+  async botCapabilities(owner: string, agent: Agent, target = this.nodes.target(owner, agent.nodeId)): Promise<{ tools: boolean; memory: boolean; extraction: boolean; runtime?: HermesRuntimeMetadata }> {
     try {
       const response = await target.session.request('/api/plugins/yaoyao-bot-bridge/capabilities', { search: new URLSearchParams({ profile: agent.profile }) })
       const value = JSON.parse(response.body.toString())
       const tools = response.status === 200 && value.ready === true && value.native_tools === true && value.in_process === true
-      return { tools, memory: tools && value.memory_isolation === true, extraction: value.memory_extraction === true }
+      const runtime = response.status === 200 ? parseHermesRuntimeMetadata(value.runtime_environment, agent.profile) : undefined
+      return { tools, memory: tools && value.memory_isolation === true, extraction: value.memory_extraction === true, ...(runtime ? { runtime } : {}) }
     } catch (error) {
       if (error instanceof HttpError && RESOURCE_WAIT_CODES.has(error.code ?? '')) throw error
       return { tools: false, memory: false, extraction: false }
@@ -211,6 +252,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       if (projectId) for (const id of source?.targetAgentId ? [source.targetAgentId] : c.memberIds) this.knowledge.requireProjectMember(owner, projectId, id)
       const conversationTask = this.store.resolveTask(owner, conversationId, body.taskId),
         conversationTaskId = conversationTask?.id
+      if(this.store.hasPendingCleanup(owner,{conversationId,conversationTaskId}))throw new HttpError(409,'原执行尚未确认停止,请等待停止完成后再发消息','conversation_stopping')
       if (conversationTaskId) {
         const alreadyActive = this.store.list<Run>(owner, 'run').some(run => run.conversationTaskId === conversationTaskId && !['complete', 'failed', 'interrupted'].includes(run.status))
         if (!alreadyActive && this.store.activeTaskCount(owner, conversationId) >= WORKSPACE_CONCURRENCY_LIMIT)
@@ -823,7 +865,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const globals=globalComputers(this.store.home)
         const desktopSnapshot=this.desktopEnvironments?.snapshot(owner,agent,root.deviceHost,{script:globals.scriptMachine,server:globals.serverComputer,maxMiB:globals.fileTransferMaxMiB})
           ??{capturedAt:Date.now(),sourceHost:root.deviceHost,hosts:[]}
-        const environment=buildWorkspaceEnvironment({agent,globals,execution,serverDesktop:agent.nodeId==='local'&&isLocalAuthorizationTarget(target.url),desktop:desktopSnapshot,bridge:botCapabilities.tools,cloud:!!this.cloud?.selected(owner,agent),plugins:!!this.plugins?.selected(owner,agent),managedBrowser:this.managedBrowsers?.available(owner,agent)})
+        const environment=buildWorkspaceEnvironment({agent,globals,execution,runtime:{connected:gateway.connected,...(botCapabilities.runtime?{metadata:botCapabilities.runtime}:{})},serverDesktop:agent.nodeId==='local'&&isLocalAuthorizationTarget(target.url),desktop:desktopSnapshot,bridge:botCapabilities.tools,cloud:!!this.cloud?.selected(owner,agent),plugins:!!this.plugins?.selected(owner,agent),managedBrowser:this.managedBrowsers?.available(owner,agent)})
         const {cloud,plugins,vm:dispatchedVm}=environment.tools
         const desktop=environment.tools.desktopView||environment.tools.desktopFile
         const desktopEpochs=Object.fromEntries(environment.desktop.hosts.map(host=>[host.id,host.epoch??'']))
@@ -1085,7 +1127,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     const key = this.bindingKey(work.conversationId, work.conversationTaskId, work.agentId)
     const live = this.live.get(key)
     if (live?.taskId === work.id) {
-      try { await live.gateway.rpc('session.interrupt', { session_id: live.runtimeId }) }
+      try {
+        await live.gateway.rpc('session.interrupt', { session_id: live.runtimeId })
+        const binding=this.store.require<WorkspaceBinding>(owner,'binding',key)
+        if(binding.execution!=='computer')await waitForHermesSessionIdle((method,params)=>live.gateway.rpc(method,params),live.runtimeId,binding.profile)
+      }
       catch (error) { if (!live.gateway.connected) live.done(new Error('已停止')); throw error }
       const current = this.getWork(owner, work.id)
       if (['complete', 'failed', 'interrupted'].includes(current.status)) {
@@ -1107,6 +1153,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           await gateway.connect()
           const opened = await gateway.rpc('session.resume', { profile: binding.profile, session_id: binding.storedId, omit_messages: true, close_on_disconnect: false,...(binding.execution==='computer'?{recoverOnly:true}:{}) })
           if (opened.running) await gateway.rpc('session.interrupt', { session_id: opened.session_id })
+          if(binding.execution!=='computer')await waitForHermesSessionIdle((method,params)=>gateway.rpc(method,params),String(opened.session_id),binding.profile)
         } finally { gateway.close() }
       }
       const current = this.getWork(owner, work.id)

+ 4 - 1
src/server/workspaceScheduler.ts

@@ -156,6 +156,8 @@ export abstract class WorkspaceScheduler {
     this.updateRoot(owner, root.id)
   }
   start(): void { this.wake() }
+  /** Maintenance uses the same bounded retry loop as durable turn cleanup. */
+  protected maintenance(): boolean { return false }
   wake(): void {
     if (this.closing || this.wakePending) return
     this.wakePending = true
@@ -307,6 +309,7 @@ export abstract class WorkspaceScheduler {
     if (this.pumping || this.closing) return
     this.pumping = true
     try {
+      if(this.maintenance())this.retrySoon()
       let all = this.store.owners().flatMap(owner => this.pendingWorks(owner).map(work => ({ owner, work })))
       for (const { owner, work } of all) {
         if (terminal(work.status) && work.cleanupPending) this.scheduleInterruptCleanup(owner, work)
@@ -360,7 +363,7 @@ export abstract class WorkspaceScheduler {
         if (work.status === 'queued' && (c.archived || root.stopRequested || !this.store.taskMemberIds(owner,c,work.conversationTaskId).includes(work.agentId))) {
           work.status = 'interrupted'; work.error = '执行前成员已移除或聊天已停止'; this.saveWork(owner, work); this.wake(); continue
         }
-        const occupied = all.map(entry => this.getWork(entry.owner, entry.work.id)).filter(t => (['running', 'waiting', 'uncertain'].includes(t.status) || this.executing.has(t.id)) && t.id !== work.id)
+        const occupied = all.map(entry => this.getWork(entry.owner, entry.work.id)).filter(t => (t.cleanupPending || ['running', 'waiting', 'uncertain'].includes(t.status) || this.executing.has(t.id)) && t.id !== work.id)
         // Only the conversation/task/Agent binding shares mutable session state.
         // Other sessions may run concurrently; computer leases and tool queues
         // continue to serialize access to their actual shared resources.

+ 13 - 2
src/server/workspaceStore.ts

@@ -421,6 +421,7 @@ export class WorkspaceStore {
     if(body.vmExecution==='profile')body.allowHostEnvironment=true
     if (origin?.createdByAgentId) this.require<Agent>(owner, 'agent', origin.createdByAgentId)
     return this.atomic(() => {
+      if (this.get(owner, 'agent-deletion', allocatedId)) throw new HttpError(409, '已删除的 Bot 编号不能复用', 'agent_deleted')
       if (
         this.list<Agent>(owner, 'agent').some(
           (a) => a.name.toLocaleLowerCase() === body.name.toLocaleLowerCase(),
@@ -517,13 +518,13 @@ export class WorkspaceStore {
       const hostPermissionChanged=patch.allowHostEnvironment!==undefined&&patch.allowHostEnvironment!==(agent.allowHostEnvironment===true)
       const destinationChanged=desktopHostChanged||hostPermissionChanged||(patch.vmExecution!==undefined&&patch.vmExecution!==(agent.vmExecution??'worker'))||(patch.computer!==undefined&&patch.computer!==agent.computer)||(patch.browserProfile!==undefined&&patch.browserProfile!==(agent.browserProfile??'persistent'))
       if(sourceChanged||destinationChanged){
-        if(this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再修改机器人来源或电脑','agent_execution_busy')
+        if(this.list<{agentId:string;status:string;cleanupPending?:boolean}>(owner,'turn').some(work=>work.agentId===id&&(work.cleanupPending||['queued','running','waiting','uncertain','cancelling'].includes(work.status))))throw new HttpError(409,'请先确认当前任务停止,再修改机器人来源或电脑','agent_execution_busy')
         if(this.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(grant.agentId===id||grant.environmentId===(agent.computerEnvironmentId??id))))throw new HttpError(409,'请先交还电脑控制权','agent_execution_busy')
         if(sourceChanged&&agent.computerEnvironmentId){const shared=this.require<{nodeId:string;profile:string;managedLocalVm?:boolean;managedCompose?:boolean}>(owner,'shared-computer',agent.computerEnvironmentId);if((patch.nodeId??agent.nodeId)!==shared.nodeId||(!shared.managedLocalVm&&!shared.managedCompose&&shared.profile!=='*'&&(patch.profile??agent.profile)!==shared.profile))throw new HttpError(409,'请先解除电脑共享,再切换到其他来源','shared_computer_active')}
       }
       if(agent.temporaryGoalId&&(Object.keys(patch).some(key=>key!=='archived')||patch.archived===false))throw new HttpError(409,'临时助手由所属任务管理,不能修改权限或恢复为持久成员','helper_task_bound')
       if(agent.computerEnvironmentId&&patch.execution==='profile'&&(patch.envs?.vm??agent.envs?.vm)!==true)throw new HttpError(409,'请先解除电脑共享,再切换执行环境','shared_computer_active')
-      if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['running','waiting','uncertain'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再切换执行环境','agent_execution_busy')
+      if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string;cleanupPending?:boolean}>(owner,'turn').some(work=>work.agentId===id&&(work.cleanupPending||['running','waiting','uncertain'].includes(work.status))))throw new HttpError(409,'请先确认当前任务停止,再切换执行环境','agent_execution_busy')
       if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived' && key !== 'approvalPolicy'))
         throw new HttpError(409, '引用机器人的配置由远端管理', 'remote_agent_read_only')
       if (
@@ -567,12 +568,17 @@ export class WorkspaceStore {
     }
   }
   private requireConversationIdle(owner: string, id: string): void {
+    if (this.hasPendingCleanup(owner, {conversationId:id})) throw new HttpError(409, '聊天原执行尚未确认停止,请稍后重试', 'conversation_stopping')
     if (['run', 'turn'].some(kind => this.list<{ conversationId: string; status: string }>(owner, kind).some(run => run.conversationId === id && !['complete', 'failed', 'interrupted', 'skipped'].includes(run.status))))
       throw new HttpError(409, '聊天仍有任务未结束,请停止任务后重试', 'conversation_running')
     if (this.list<{ conversationId: string; origin?: { conversationId: string }; status: string }>(owner, 'goal').some(goal =>
       (goal.conversationId === id || goal.origin?.conversationId === id) && !['complete', 'blocked', 'cancelled'].includes(goal.status)))
       throw new HttpError(409, '聊天仍有关联任务未结束,请停止任务后重试', 'conversation_running')
   }
+  hasPendingCleanup(owner: string, scope: {agentId?: string; conversationId?: string; conversationTaskId?: string}): boolean {
+    return this.list<{agentId:string;conversationId:string;conversationTaskId?:string;cleanupPending?:boolean}>(owner,'turn')
+      .some(work => work.cleanupPending && Object.entries(scope).every(([key,value]) => work[key as keyof typeof work] === value))
+  }
   changeConversationLifecycle(owner: string, id: string, action: WorkspaceLifecycleAction, confirmationToken?: string): void {
     this.atomic(() => {
       const conversation = this.require<Conversation>(owner, 'conversation', id)
@@ -590,6 +596,7 @@ export class WorkspaceStore {
       if (preview.groups.length && !confirmationToken)
         throw new HttpError(409, '此 Bot 仍是群聊成员,请确认退出群聊后再归档或删除', 'agent_in_group')
       if (agent?.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
+      if (agent && this.hasPendingCleanup(owner, {agentId:agent.id})) throw new HttpError(409, 'Bot 原执行尚未确认停止,请稍后重试', 'agent_stopping')
       if (agent && this.list<{ agentId: string; status: string }>(owner, 'turn').some(turn => turn.agentId === agent.id && !['complete', 'failed', 'interrupted', 'skipped'].includes(turn.status)))
         throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
       for (const conversationId of [id, ...preview.groups.map(group => group.id)]) this.requireConversationIdle(owner, conversationId)
@@ -607,6 +614,7 @@ export class WorkspaceStore {
     })
   }
   private detachAgentComputer(owner: string, agent: Agent): void {
+    if (this.hasPendingCleanup(owner, {agentId:agent.id})) throw new HttpError(409, 'Bot 原执行尚未确认停止,请稍后重试', 'agent_stopping')
     const environmentId = agent.computerEnvironmentId
     if (!environmentId) return
     if (this.list<{ agentId: string; status: string }>(owner, 'turn').some(work => work.agentId === agent.id && !['complete', 'failed', 'interrupted', 'skipped'].includes(work.status)))
@@ -629,6 +637,7 @@ export class WorkspaceStore {
       const agent = this.require<Agent>(owner, 'agent', id)
       if (!agent.archived) throw new HttpError(409, '请先归档 Bot,再删除', 'agent_not_archived')
       if (agent.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
+      if (this.hasPendingCleanup(owner, {agentId:id})) throw new HttpError(409, 'Bot 原执行尚未确认停止,请稍后重试', 'agent_stopping')
       const conversations = this.list<Conversation>(owner, 'conversation')
       if (conversations.some(c => c.kind === 'group' && c.memberIds.includes(id)))
         throw new HttpError(409, '此 Bot 仍是群聊成员,请先从群聊移除或删除相关群聊', 'agent_in_group')
@@ -643,6 +652,7 @@ export class WorkspaceStore {
         this.put(owner, 'bot-mcp-plugin', plugin.id, { ...plugin, agentIds: plugin.agentIds.filter(agentId => agentId !== id), revision: plugin.revision + 1 })
       }
       this.remove(owner, 'agent', id)
+      this.put(owner, 'agent-deletion', id, {id,conversationIds:conversations.filter(c=>c.kind==='direct'&&c.memberIds[0]===id).map(c=>c.id),deletedAt:Date.now(),memoryCleanupPending:true})
       this.event(owner, 'agent.deleted', { id })
     })
   }
@@ -925,6 +935,7 @@ export class WorkspaceStore {
   deleteTask(owner: string, conversationId: string, taskId: string): void {
     this.atomic(() => {
       const task = this.requireTask(owner, conversationId, taskId)
+      if (this.hasPendingCleanup(owner, {conversationTaskId:taskId})) throw new HttpError(409, '任务原执行尚未确认停止,暂时无法移除', 'workspace_task_stopping')
       if (this.list<Run>(owner, 'run').some(run => run.conversationTaskId === taskId && !['complete', 'failed', 'interrupted'].includes(run.status)))
         throw new HttpError(409, '任务仍在运行,暂时无法移除', 'workspace_task_running')
       const messageIds = new Set(this.list<Message>(owner, 'message').filter(message => message.conversationTaskId === taskId).map(message => message.id))

+ 4 - 1
src/server/workspaceSync.ts

@@ -29,6 +29,7 @@ export function workspaceDetail(store: WorkspaceStore, runtime: WorkspaceRuntime
 /** Durable replay followed by live updates. A slow reader reconnects from its last applied cursor. */
 export function streamWorkspace(ctx: Koa.Context, store: WorkspaceStore, auth: LocalAuthStore): void {
   const owner = auth.require(ctx).id, version = auth.pushAuthorizationVersion(owner)
+  const cookie = ctx.get('cookie')
   const patches = store.messagePatchesEnabled && ctx.query.format === 'patch-v1'
   const res = ctx.res
   const raw = ctx.get('last-event-id') || String(ctx.query.after ?? '0')
@@ -38,7 +39,9 @@ export function streamWorkspace(ctx: Koa.Context, store: WorkspaceStore, auth: L
   const pending = new Map<string, WorkspaceEvent>()
   const knownMessages = new Set<string>()
   const valid = () => {
-    try { return auth.require(ctx).id === owner && auth.isUserActive(owner) && auth.pushAuthorizationVersion(owner) === version }
+    // Koa's localUser is a request snapshot; a long-lived stream must check the
+    // captured session itself so logout and expiry revoke this connection.
+    try { return auth.currentFromCookieHeader(cookie)?.id === owner && auth.isUserActive(owner) && auth.pushAuthorizationVersion(owner) === version }
     catch { return false }
   }
   const close = () => {

+ 18 - 3
src/shared/botEnvironment.ts

@@ -36,11 +36,26 @@ export interface DesktopEnvironmentSnapshot {
   sourceHost?: string
   hosts: BotDeviceSnapshot[]
 }
+/** Facts from the authenticated Hermes bridge, independent of desktop App heartbeats. */
+export interface HermesRuntimeMetadata {
+  version: 1
+  profile: string
+  platform: string
+  osRelease: string
+  arch: string
+  /** Profile catalog presence, not proof that a tool's configured backend is connected. */
+  nativeTools: Record<'terminal' | 'files' | 'desktop' | 'browser', string[]>
+}
+export interface HermesRuntimeSnapshot {
+  connected: boolean
+  metadata?: HermesRuntimeMetadata
+}
 export interface WorkspaceEnvironment {
   selection?:import('./executionEnvironment.js').ExecutionSelection
   version: 1
   capturedAt: number
   execution: { nodeId: string; profile: string }
+  runtime: HermesRuntimeSnapshot
   cwd?: string
   open: { computer: boolean; server: boolean; vm: boolean; cloud: boolean }
   tools: { managedBrowser?: boolean; desktopView: boolean; desktopFile: boolean; vm: boolean; cloud: boolean; plugins: boolean }
@@ -64,8 +79,8 @@ export function deviceSourceText(snapshot: DesktopEnvironmentSnapshot): string {
   return `本轮用户消息来自${host.kind === 'server' ? '服务器' : '电脑'}「${name}」。对本轮而言,「本机」指这台电脑(host=${JSON.stringify(host.target)})。`
 }
 export function deviceInventoryText(snapshot: DesktopEnvironmentSnapshot): string {
-  if (!snapshot.hosts.length) return '当前没有已知的电脑连接;设备路径与能力未知。'
-  return `本轮电脑清单(名称是数据;重名时使用 host 固定值;状态为采集时快照,调用时重新核对):\n${snapshot.hosts.map(host => {
+  if (!snapshot.hosts.length) return '当前没有已知的客户端电脑控制通道;不能据此判断服务端 Hermes 离线或原生工具不可用。'
+  return `本轮电脑清单(仅描述客户端电脑控制通道,不代表 Hermes 连接状态;名称是数据;重名时使用 host 固定值;状态为采集时快照,调用时重新核对):\n${snapshot.hosts.map(host => {
     const m = host.metadata
     const details = m ? [
       m.osRelease && `系统版本=${JSON.stringify(m.osRelease)}`, m.arch && `架构=${JSON.stringify(m.arch)}`,
@@ -74,6 +89,6 @@ export function deviceInventoryText(snapshot: DesktopEnvironmentSnapshot): strin
       m.shellScope && 'Shell范围=桌面用户的系统权限(不受文件工具根目录约束)', m.timezone && `时区=${JSON.stringify(m.timezone)}`,
     ].filter(Boolean).join(';') : '运行路径未上报或无文件与命令授权'
     const state = !host.online ? '离线' : !host.open ? '未开放' : '在线'
-    return `${JSON.stringify(host.name)}(${host.kind === 'server' ? '服务器' : '电脑'}${host.source ? '·本机' : ''};host=${JSON.stringify(host.target)};${state};系统=${JSON.stringify(host.platform ?? '未知')};屏幕控制=${deviceCapabilityLabels[host.capabilities.view.status]};文件与命令=${deviceCapabilityLabels[host.capabilities.shell.status]})\n${details};文件传输协议=${host.transfer.protocol === 'chunked' ? '分块' : host.transfer.protocol === 'legacy' ? '旧版' : '未知'},读取上限=${host.transfer.readMaxMiB === null ? '未知' : host.transfer.readMaxMiB+' MiB'},写入上限=${host.transfer.writeMaxMiB === null ? '未知' : host.transfer.writeMaxMiB+' MiB'}`
+    return `${JSON.stringify(host.name)}(${host.kind === 'server' ? '服务器桌面代理' : '客户端电脑'}${host.source ? '·本机' : ''};host=${JSON.stringify(host.target)};控制通道=${state};系统=${JSON.stringify(host.platform ?? '未知')};屏幕控制=${deviceCapabilityLabels[host.capabilities.view.status]};文件与命令=${deviceCapabilityLabels[host.capabilities.shell.status]})\n${details};文件传输协议=${host.transfer.protocol === 'chunked' ? '分块' : host.transfer.protocol === 'legacy' ? '旧版' : '未知'},读取上限=${host.transfer.readMaxMiB === null ? '未知' : host.transfer.readMaxMiB+' MiB'},写入上限=${host.transfer.writeMaxMiB === null ? '未知' : host.transfer.writeMaxMiB+' MiB'}`
   }).join('\n')}`
 }

+ 3 - 1
tests/server/messageFiles.test.ts

@@ -8,12 +8,14 @@ import { WorkspaceAssets, type StoredWorkspaceFile } from '../../src/server/work
 import type { WorkspaceNodes } from '../../src/server/workspaceGateway'
 import type { WorkspaceMessage, WorkspaceConversation } from '../../src/shared/workspace'
 import { messageFileReferences, nativeMessageFileText } from '../../src/shared/messageFiles'
+import { saveFileAccess } from '../../src/server/fileAccess'
 
 it('archives only message references and reconciles legacy associations without deleting bytes or logs', async () => {
   const home = mkdtempSync(join(tmpdir(), 'yaoyao-message-files-'))
   const store = new WorkspaceStore(home)
   const request = vi.fn(async (_route: string, _options: { search: URLSearchParams }) => ({ status: 200, body: Buffer.from('report'), headers: new Headers({ 'content-type': 'text/plain' }) }))
-  const assets = new WorkspaceAssets(store, { target: () => ({ session: { request } }) } as unknown as WorkspaceNodes, home)
+  saveFileAccess(home, { mode: 'all', folders: [] })
+  const assets = new WorkspaceAssets(store, { requireSource: () => {}, target: () => ({ session: { request } }) } as unknown as WorkspaceNodes, home)
   try {
     const agent = store.createAgent('owner', { name: 'Agent', profile: 'default' })
     const conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!

+ 40 - 0
tests/server/openViking.test.ts

@@ -95,6 +95,46 @@ describe('OpenViking configuration', () => {
 })
 
 describe('OpenViking memory provider', () => {
+  it('serializes removal after in-flight registration and never resurrects a removed user',async()=>{
+    let release!:(value:{user_key:string})=>void
+    admin.adminRegisterUser.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
+    const registration=service.ensureUser(owner,a)
+    const removal=service.removeUser(owner,a)
+    await expect(service.ensureUser(owner,a)).rejects.toMatchObject({code:'openviking_user_removed'})
+    expect(admin.adminRemoveUser).not.toHaveBeenCalled()
+    release({user_key:'fixture-racing-key'})
+    await registration;await removal
+    expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:false})
+    await expect(service.ensureUser(owner,a)).rejects.toMatchObject({code:'openviking_user_removed'})
+    expect(admin.adminRegisterUser).toHaveBeenCalledTimes(1)
+    expect(admin.adminRemoveUser).toHaveBeenCalledTimes(1)
+  })
+  it('persists failed removal for an idempotent retry after restarting the service',async()=>{
+    await service.ensureUser(owner,a)
+    admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture offline'))
+    await expect(service.removeUser(owner,a)).rejects.toMatchObject({code:'openviking_unavailable'})
+    expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:true})
+    const restarted=new OpenVikingService(store,manager,()=>admin as unknown as OpenVikingClient)
+    await restarted.removeUser(owner,a);await restarted.removeUser(owner,a)
+    expect(restarted.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:false})
+    expect(admin.adminRemoveUser).toHaveBeenCalledTimes(2)
+    await expect(restarted.ensureUser(owner,a)).rejects.toMatchObject({code:'openviking_user_removed'})
+  })
+  it('does not lose removal when configuration is disabled during registration',async()=>{
+    let release!:(value:{user_key:string})=>void
+    admin.adminRegisterUser.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
+    const registration=service.ensureUser(owner,a)
+    service.configure(undefined)
+    const removal=service.removeUser(owner,a)
+    release({user_key:'fixture-register-before-disabled'})
+    await registration;await removal
+    expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:true})
+    expect(admin.adminRemoveUser).not.toHaveBeenCalled()
+    service.configure(manager.configuration())
+    await service.removeUser(owner,a)
+    expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:false})
+    expect(admin.adminRemoveUser).toHaveBeenCalledTimes(1)
+  })
   it('reports writes, duplicates and idempotent replays without changing the public memory shape', async () => {
     const input = { requestId: randomUUID(), scope: 'agent' as const, agentId: a.id, content: '开发环境为 studio', tier: 'log' as const }
     const first = await knowledge.writeMemoryWithResult(owner, input)

+ 51 - 0
tests/server/openVikingMemoryRead.test.ts

@@ -0,0 +1,51 @@
+// @vitest-environment node
+import { expect, it, vi } from 'vitest'
+import { OpenVikingMemoryProvider } from '../../src/server/openVikingMemoryProvider'
+import type { OpenVikingService } from '../../src/server/openVikingService'
+import type { WorkspaceKnowledge } from '../../src/server/workspaceKnowledge'
+
+function fixture() {
+  const records = Array.from({ length: 20 }, (_, index) => ({
+    schemaVersion: 1, revisions: [], memory: {
+      id: index.toString(16).padStart(32, '0'), scope: 'agent', agentId: 'agent', content: `fact-${index}`,
+    },
+  }))
+  const list = vi.fn(async () => records.map(record => `${record.memory.id}.json`))
+  const read = vi.fn(async (uri: string) => JSON.stringify(records.find(record => uri.endsWith(`${record.memory.id}.json`))))
+  const client = { list, read }
+  const service = { enabled: true, ensureUser: vi.fn(async () => undefined), userClient: vi.fn(() => client) }
+  const knowledge = { agent: vi.fn(() => ({ id: 'agent' })) }
+  return { provider: new OpenVikingMemoryProvider(service as unknown as OpenVikingService, knowledge as unknown as WorkspaceKnowledge),
+    records, read, list }
+}
+
+it('overlaps at most eight reads while preserving listing order and scope isolation', async () => {
+  const f = fixture()
+  let active = 0, peak = 0
+  const release: Array<() => void> = []
+  f.records[3]!.memory.agentId = 'another-agent'
+  f.read.mockImplementation(async uri => {
+    active++; peak = Math.max(peak, active)
+    await new Promise<void>(resolve => release.push(resolve))
+    active--
+    return JSON.stringify(f.records.find(record => uri.endsWith(`${record.memory.id}.json`)))
+  })
+  const pending = f.provider.list('owner', [{ scope: 'agent', agentId: 'agent' }])
+  await vi.waitFor(() => expect(release).toHaveLength(8))
+  // Release batches in reverse order, forcing responses to arrive out of listing order.
+  while (f.read.mock.calls.length < 20 || active) {
+    release.splice(0).reverse().forEach(resolve => resolve())
+    await new Promise(resolve => setTimeout(resolve, 0))
+  }
+  expect((await pending).map(memory => memory.content)).toEqual(f.records.filter(record => record.memory.agentId === 'agent').map(record => record.memory.content))
+  expect(peak).toBe(8)
+  expect(f.read).toHaveBeenCalledTimes(20)
+})
+
+it('keeps upstream failures visible and continues to reject corrupt records', async () => {
+  const f = fixture()
+  f.read.mockRejectedValueOnce(new Error('upstream unavailable'))
+  await expect(f.provider.list('owner', [{ scope: 'agent', agentId: 'agent' }])).rejects.toThrow('upstream unavailable')
+  f.read.mockResolvedValueOnce('invalid JSON')
+  await expect(f.provider.list('owner', [{ scope: 'agent', agentId: 'agent' }])).rejects.toMatchObject({ code: 'openviking_memory_corrupt' })
+})

+ 58 - 2
tests/server/profileComputer.test.ts

@@ -14,9 +14,11 @@ import {ProfileSkillSession} from '../../src/runner/worker/skills'
 import {UpstreamClient} from '../../src/server/upstream'
 import {UpstreamServiceSession} from '../../src/server/localAuth'
 import type {GatewayFrame} from '../../src/server/workspaceGateway'
+import * as sessionStop from '../../src/server/sessionStop'
+import {HttpError} from '../../src/server/errors'
 
 async function fixture(hermesManaged=false,bridgeVersion=2){
-  const home=await mkdtemp(join(tmpdir(),'yaoyao-profile-computer-')),db=new DatabaseSync(':memory:')
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-profile-computer-')),db=new DatabaseSync(join(home,'runner.sqlite'))
   const vmRoot=join(home,'vm'),hermesRoot=join(home,'hermes');await mkdir(vmRoot);await mkdir(hermesRoot);await writeFile(join(hermesRoot,'input.txt'),'hermes-authorized-file')
   const vmFiles=new FileTransferFiles(vmRoot),hermesFiles=new FileTransferFiles(hermesRoot)
   const calls:Array<{method:string;params:any}>=[],bindings:any[]=[],frames:GatewayFrame[]=[],gateways:ComputerGateway[]=[]
@@ -81,7 +83,7 @@ async function fixture(hermesManaged=false,bridgeVersion=2){
     expect(found,JSON.stringify(catalog)).toBeDefined()
     return post('/tools/call',{toolId:found.id,arguments:args,callId:randomUUID()})
   }
-  return {home,vmRoot,runtime,resolver,execute,meta,gateway,calls,frames,bindings,sessions,post,tool,emit,
+  return {home,vmRoot,runtime,resolver,execute,meta,gateway,calls,frames,bindings,sessions,post,tool,emit,target,
     setInterruptStops(value:boolean){interruptStops=value},revoke(){allowed=false},
     async close(){
       interruptStops=true;for(const session of sessions.values())session.running=false
@@ -173,6 +175,60 @@ it('waits for native termination and VM calls before human takeover, then resume
   }finally{await f.close()}
 })
 
+it('keeps the holder and durable stop barrier on timeout or close, and retries the same gateway safely',async()=>{
+  const f=await fixture()
+  try{
+    const g=f.gateway();await g.connect();const session=await g.rpc('session.create',{profile:'default'})
+    await g.rpc('prompt.submit',{session_id:session.session_id,text:'Work until stopped'})
+    await f.tool('computer_shell',{command:'pwd'})
+    const holder=g.lease!.holderId,stored=f.runtime.session(session.session_id,f.meta,'default').profileSessionId
+    f.setInterruptStops(false)
+    const timeout=vi.spyOn(sessionStop,'waitForHermesSessionIdle').mockRejectedValue(new HttpError(409,'native stop timeout','session_still_stopping'))
+    await expect(g.rpc('session.interrupt',{session_id:session.session_id})).rejects.toMatchObject({code:'session_still_stopping'})
+    expect(f.runtime.pool.status(f.meta.ownerKey)[0]?.holderIds).toContain(holder)
+    expect(f.runtime.session(session.session_id,f.meta,'default')).toMatchObject({stopPending:true,profileSessionId:stored})
+    expect(()=>f.runtime.pool.renew(g.lease!)).not.toThrow()
+    await expect(g.rpc('prompt.submit',{session_id:session.session_id,text:'forbidden retry'})).rejects.toMatchObject({code:'computer_cancelled'})
+    await expect(g.takeControl(randomUUID(),()=>{})).rejects.toMatchObject({code:'computer_cancelled'})
+    await expect(f.runtime.controls.take(f.meta,'default',randomUUID(),async()=>{})).rejects.toMatchObject({code:'computer_stopping'})
+    const next=f.gateway();await next.connect()
+    await expect(next.rpc('session.create',{profile:'default',toolsOnly:true})).rejects.toMatchObject({code:'computer_stopping'})
+    await expect(g.close()).rejects.toMatchObject({code:'session_still_stopping'})
+    expect(f.runtime.pool.status(f.meta.ownerKey)[0]?.holderIds).toContain(holder)
+    expect(f.runtime.gateways.get(f.meta.environmentId)?.has(g)).toBe(true)
+    timeout.mockRestore();f.setInterruptStops(true)
+    await g.interrupt();await g.close()
+    expect(f.runtime.session(session.session_id,f.meta,'default').stopPending).toBe(false)
+    expect(f.runtime.pool.status(f.meta.ownerKey)[0]?.holderIds).not.toContain(holder)
+    expect(f.calls.filter(call=>call.method==='prompt.submit')).toHaveLength(1)
+  }finally{await f.close()}
+})
+
+it('restores a persisted stop barrier and clears it only through cleanup resume without submitting again',async()=>{
+  const f=await fixture()
+  try{
+    const g=f.gateway();await g.connect();const opened=await g.rpc('session.create',{profile:'default'})
+    await g.rpc('prompt.submit',{session_id:opened.session_id,text:'original work'})
+    await f.tool('computer_shell',{command:'pwd'})
+    const persisted=f.runtime.session(opened.session_id,f.meta,'default')
+    await g.close()
+    // Crash checkpoint: native work stopped, but its durable cleanup receipt
+    // was not committed yet. Re-open the real temporary sqlite file.
+    persisted.stopPending=true;f.runtime.save(persisted)
+    const db=new DatabaseSync(join(f.home,'runner.sqlite'))
+    const restarted=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:f.target.url.href,hermesURL:f.target.url.href,token:'test',allowedProfiles:['default'],artifactRoots:[],computers:f.runtime.config},f.home)
+    const recovering=new ComputerGateway(restarted,f.meta,g.workId,async()=>{},async()=>({}),f.target)
+    try{
+      await restarted.ready;await recovering.connect()
+      await expect(recovering.rpc('session.resume',{profile:'default',session_id:opened.session_id})).rejects.toMatchObject({code:'computer_stopping'})
+      await recovering.rpc('session.resume',{profile:'default',session_id:opened.session_id,recoverOnly:true})
+      expect(restarted.session(opened.session_id,f.meta,'default').stopPending).toBe(false)
+      expect(f.calls.filter(call=>call.method==='prompt.submit')).toHaveLength(1)
+      expect(f.calls.findLast(call=>call.method==='session.resume')?.params.session_id).toBe(persisted.profileSessionId)
+    }finally{await recovering.close();await restarted.controls.close();await restarted.pool.close();db.close()}
+  }finally{await f.close()}
+})
+
 
 it('uses Hermes for an isolated Bot without reading Profile config or running a Worker; native skills and authorized files stay in Hermes',async()=>{
   const f=await fixture(true)

+ 92 - 0
tests/server/realtimeReceipts.test.ts

@@ -0,0 +1,92 @@
+// @vitest-environment node
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { RealtimeReceipts } from '../../src/server/realtimeReceipts'
+import { RealtimeBroker, type RealtimePrincipal } from '../../src/server/realtimeBroker'
+
+let home: string, receipts: RealtimeReceipts
+beforeEach(() => {
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-receipt-recovery-'))
+  receipts = new RealtimeReceipts(home)
+})
+afterEach(() => { receipts.close(); vi.restoreAllMocks(); rmSync(home, { recursive: true, force: true }) })
+
+it('retains owner-scoped deduplication after result expiry and database reopen', () => {
+  const now = vi.spyOn(Date, 'now').mockReturnValue(100_000)
+  receipts.reserve('alice', 'prompt-1', 'private prompt')
+  receipts.finish('alice', 'prompt-1', 'confirmed', { result: { status: 'submitted' } })
+  now.mockReturnValue(100_000 + 86_400_001)
+  receipts.reserve('alice', 'new-command', '{}')
+  receipts.close(); receipts = new RealtimeReceipts(home)
+  expect(receipts.lookup('alice', 'prompt-1')).toEqual({ requestId: 'prompt-1', state: 'unknown' })
+  expect(receipts.reserve('alice', 'prompt-1', 'private prompt')).toMatchObject({ state: 'unknown' })
+  expect(() => receipts.reserve('alice', 'prompt-1', 'changed payload')).toThrow('payload conflict')
+  expect(receipts.lookup('bob', 'prompt-1')).toBeUndefined()
+  expect(receipts.reserve('bob', 'prompt-1', 'private prompt')).toBeUndefined()
+  for (const file of ['realtime-receipts.sqlite3', 'realtime-receipts.sqlite3-wal']) {
+    expect(readFileSync(join(home, file)).includes(Buffer.from('private prompt'))).toBe(false)
+  }
+})
+
+it('does not call the executor a second time after expired results are compacted', async () => {
+  receipts.close()
+  const now = vi.spyOn(Date, 'now').mockReturnValue(100_000)
+  const broker = new RealtimeBroker(home)
+  const perform = vi.fn(async () => ({ result: { status: 'submitted' } }))
+  Object.assign(broker, { perform })
+  const principal: RealtimePrincipal = { key: 'alice', upstreamKey: 'mock', paired: false,
+    valid: () => true, url: async () => { throw new Error('No network allowed') } }
+  try {
+    const channel = { kind: 'chat', principal } as Parameters<RealtimeBroker['command']>[0]
+    const frame = { method: 'prompt.submit', params: { session_id: 'runtime', text: 'do work' } }
+    await broker.command(channel, 'prompt-1', frame)
+    now.mockReturnValue(100_000 + 86_400_001)
+    await broker.command(channel, 'new-prompt', frame)
+    // Simulate losing the process-local full response, as on a restart.
+    Object.assign(broker, { completed: new Map() })
+    expect(await broker.command(channel, 'prompt-1', frame)).toMatchObject({ state: 'unknown' })
+    expect(perform).toHaveBeenCalledTimes(2)
+  } finally {
+    broker.close()
+    await Promise.resolve()
+    receipts = new RealtimeReceipts(home)
+  }
+})
+
+it.each(['file.attach', 'image.attach_bytes', 'pdf.attach'])('restores %s references without persisting attachment contents', method => {
+  const id = method.replaceAll('.', '_')
+  receipts.reserve('alice', id, '{}')
+  receipts.finish('alice', id, 'confirmed', { result: {
+    path: '/files/upload', image_path: '/images/upload', ref_path: 'upload', ref_text: '@file:upload',
+    content: 'private bytes', content_base64: 'private bytes', text: 'private bytes',
+  } }, { method })
+  receipts.close(); receipts = new RealtimeReceipts(home)
+  expect(receipts.lookup('alice', id)?.response?.result).toEqual({
+    path: '/files/upload', image_path: '/images/upload', ref_path: 'upload', ref_text: '@file:upload',
+  })
+})
+
+it('restores model confirmation metadata only for allowed configuration keys', () => {
+  receipts.reserve('alice', 'model', '{}')
+  receipts.finish('alice', 'model', 'confirmed', { result: {
+    value: 'model --provider provider', deferred: false, confirm_required: true,
+    confirm_message: 'Please confirm', warning: 'Cost warning', api_key: 'private key',
+  } }, { method: 'config.set', params: { key: 'model' } })
+  receipts.close(); receipts = new RealtimeReceipts(home)
+  expect(receipts.lookup('alice', 'model')?.response?.result).toEqual({
+    value: 'model --provider provider', deferred: false, confirm_required: true,
+    confirm_message: 'Please confirm', warning: 'Cost warning',
+  })
+  receipts.reserve('alice', 'unrelated', '{}')
+  receipts.finish('alice', 'unrelated', 'confirmed', { result: { value: 'private key', path: '/private' } },
+    { method: 'config.set', params: { key: 'api_key' } })
+  expect(receipts.lookup('alice', 'unrelated')?.response?.result).toEqual({})
+})
+
+it('does not persist unbounded recovery strings', () => {
+  receipts.reserve('alice', 'large', '{}')
+  receipts.finish('alice', 'large', 'confirmed', { result: { path: 'x'.repeat(4097) } }, { method: 'file.attach' })
+  expect(receipts.lookup('alice', 'large')?.response?.result).toEqual({})
+})

+ 3 - 1
tests/server/runner.test.ts

@@ -47,7 +47,7 @@ beforeEach(async()=>{
     sockets.push(socket);socket.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
     socket.on('message',raw=>{
       const value=JSON.parse(String(raw));rpc.push(value)
-      const result=['session.create','session.resume'].includes(value.method)?{session_id:randomUUID(),stored_session_id:randomUUID(),info:{profile_name:value.params.profile}}:{ok:true}
+      const result=['session.create','session.resume'].includes(value.method)?{session_id:randomUUID(),stored_session_id:randomUUID(),info:{profile_name:value.params.profile}}:value.method==='session.active_list'?{sessions:[{id:value.params.current_session_id,status:'idle',title:'owned'},{id:'unowned',status:'working',title:'private'}]}:{ok:true}
       socket.send(JSON.stringify({id:value.id,result}))
     })
   })
@@ -363,6 +363,8 @@ it('keeps only stop operations available on an existing channel after task permi
   allowed=false
   await expect(gateway.rpc('prompt.submit',{session_id:session.session_id,text:'forbidden'})).rejects.toMatchObject({code:'runner_command_not_admitted'})
   expect(await gateway.rpc('session.interrupt',{session_id:session.session_id})).toEqual({ok:true})
+  expect(await gateway.rpc('session.active_list',{profile:'default',current_session_id:session.session_id})).toEqual({sessions:[{id:session.session_id,status:'idle'}]})
+  await expect(gateway.rpc('session.active_list',{profile:'default',current_session_id:'unowned'})).rejects.toMatchObject({code:'runner_session_forbidden'})
   expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(0)
 })
 

+ 21 - 0
tests/server/sessionStop.test.ts

@@ -0,0 +1,21 @@
+// @vitest-environment node
+import {afterEach,expect,it,vi} from 'vitest'
+import {waitForHermesSessionIdle} from '../../src/server/sessionStop'
+
+afterEach(()=>vi.useRealTimers())
+it('waits beyond the interrupt receipt until the owned native session is idle',async()=>{
+  const rpc=vi.fn().mockResolvedValueOnce({sessions:[{id:'other',status:'idle'},{id:'mine',status:'working'}]})
+    .mockResolvedValueOnce({sessions:[{id:'mine',status:'idle'}]})
+  await waitForHermesSessionIdle(rpc,'mine','default',{pollMs:1})
+  expect(rpc).toHaveBeenCalledTimes(2)
+  expect(rpc).toHaveBeenCalledWith('session.active_list',{profile:'default',current_session_id:'mine'})
+})
+it('accepts a finalized session disappearing, but refuses malformed stop evidence',async()=>{
+  await waitForHermesSessionIdle(async()=>({sessions:[]}),'mine','default')
+  await expect(waitForHermesSessionIdle(async()=>({ok:true}),'mine','default')).rejects.toMatchObject({code:'session_stop_unconfirmed'})
+})
+it('times out without treating an acknowledgement or a sibling idle session as termination',async()=>{
+  const pending=waitForHermesSessionIdle(async()=>({sessions:[{id:'mine',status:'working'},{id:'other',status:'idle'}]}),'mine','default',{timeoutMs:200,pollMs:100})
+  const rejected=expect(pending).rejects.toMatchObject({code:'session_still_stopping'})
+  await rejected
+})

+ 5 - 0
tests/server/taskCoordinator.test.ts

@@ -174,6 +174,11 @@ describe('durable team task coordination',()=>{
     expect(()=>runtime.tasks.resume(owner,lead.id,{requestId:randomUUID(),goalId:goal.id},origin)).toThrow('旧执行')
     settle(old,'interrupted','')
     await vi.waitFor(()=>expect(store.get<AgentGoal>(owner,'goal',goal.id)?.status).toBe('cancelled'))
+    const cleanupId=randomUUID()
+    store.put(owner,'turn',cleanupId,{id:cleanupId,agentId:worker.id,conversationId:team.id,conversationTaskId:goal.id,runId:old,status:'interrupted',cleanupPending:true})
+    expect(()=>runtime.tasks.resume(owner,lead.id,{requestId:randomUUID(),goalId:goal.id},origin)).toThrow('旧执行')
+    expect(()=>store.deleteTask(owner,team.id,goal.id)).toThrow('尚未确认停止')
+    store.remove(owner,'turn',cleanupId)
     const resumed=runtime.tasks.resume(owner,lead.id,{requestId:randomUUID(),goalId:goal.id},origin)
     expect(resumed.activation).toBe(2)
     expect(resumed.origin.runId).toBe(source.id)

+ 89 - 1
tests/server/workspace.test.ts

@@ -9,6 +9,7 @@ import { defaultAgentIdentity, encodeAgentAvatar, decodeAgentAvatar } from '../.
 import {readHostTools,saveHostTools} from '../../src/server/hostToolSettings'
 import {DesktopEnvironments} from '../../src/server/desktopEnvironments'
 import {HttpError} from '../../src/server/errors'
+import * as sessionStop from '../../src/server/sessionStop'
 import {ExecutionSettings} from '../../src/server/executionSettings'
 import {WorkspaceAssets} from '../../src/server/workspaceAssets'
 import { WorkspaceStore } from '../../src/server/workspaceStore'
@@ -38,6 +39,7 @@ let rejectPrompt: string | undefined
 let observeGatewayRequest: ((frame: { method: string; params: Record<string, any> }) => void) | undefined
 let rejectedModelConfig: Record<string, unknown> | undefined
 let rejectedInterrupts: number
+let nativeStillWorking:boolean
 let recoveryHistory: Array<{ role: string; content: string; tool_calls?: unknown }>
 let storedByRuntime: Map<string, string>, recoveryByStored: Map<string, Array<{ role: string; content: string }>>
 const owner = 'first-user',
@@ -54,6 +56,7 @@ beforeEach(async () => {
   observeGatewayRequest = undefined
   rejectedModelConfig = undefined
   rejectedInterrupts = 0
+  nativeStillWorking=false
   server = new WebSocketServer({ port: 0, host: '127.0.0.1' })
   await new Promise<void>((resolve) => server.once('listening', resolve))
   const address = server.address() as { port: number }
@@ -106,6 +109,7 @@ beforeEach(async () => {
         respond({ status: 'streaming' })
         reply(socket, frame.params)
       } else if (frame.method === 'session.usage') respond({ context_used: 400, context_max: 1000 })
+      else if (frame.method === 'session.active_list') respond({sessions:[{id:frame.params.current_session_id,status:nativeStillWorking?'working':'idle'}]})
       else if (frame.method === 'session.interrupt' && rejectedInterrupts-- > 0) socket.send(JSON.stringify({id:frame.id,error:{code:500,message:'暂时不能中断'}}))
       else respond({ ok: true, status: 'interrupted' })
     })
@@ -978,7 +982,7 @@ it('keeps an unconfirmed turn separate from a later reply and retries history wi
   }
   const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '只执行一次' })
   await vi.waitFor(() => expect(requests.filter(r => r.method === 'session.resume').length).toBeGreaterThanOrEqual(1), { timeout: 3000 })
-  expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain')
+  await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain'), { timeout: 3000 })
   expect(store.messages(owner, c.id).at(-1)?.content).not.toBe('另一轮回复')
   recoveryHistory = [{ role: 'user', content: prompt }, { role: 'assistant', content: '本轮结果' }]
   await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('complete'), { timeout: 4000 })
@@ -1600,6 +1604,45 @@ it('retries a failed stop of a live task without falsely completing it or resubm
   expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
 })
 
+it('retains the stop barrier and original binding after ACK, including a restart, until native work is idle',async()=>{
+  const a=agent('真正停止'),c=direct(a.id)
+  reply=()=>{};nativeStillWorking=true
+  const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'长任务'})
+  await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1))
+  await runtime.stop(owner,root.id)
+  await vi.waitFor(()=>expect(requests.some(r=>r.method==='session.active_list')).toBe(true))
+  const work=store.list<Work>(owner,'turn').find(item=>item.runId===root.id)!
+  expect(store.require<Work>(owner,'turn',work.id)).toMatchObject({status:'interrupted',cleanupPending:true})
+  const original=store.require<any>(owner,'binding',`${c.id}:${a.id}`)
+  expect(()=>runtime.send(owner,c.id,{requestId:randomUUID(),content:'下一轮'})).toThrow('尚未确认停止')
+  expect(()=>store.updateAgent(owner,a.id,{archived:true})).toThrow('尚未确认停止')
+  expect(runtime.idleForUpdate).toBe(false)
+  runtime.close()
+  runtime=new WorkspaceRuntime(store,nodes,uploads)
+  nativeStillWorking=false;runtime.start()
+  await vi.waitFor(()=>expect(store.require<Work>(owner,'turn',work.id).cleanupPending).toBe(false))
+  expect(store.require<any>(owner,'binding',`${c.id}:${a.id}`).storedId).toBe(original.storedId)
+  expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
+})
+it('retains cleanup and refuses reuse when native stop confirmation times out',async()=>{
+  const a=agent('停止超时'),c=direct(a.id)
+  reply=()=>{}
+  const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'超时长任务'})
+  await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1))
+  const confirmation=vi.spyOn(sessionStop,'waitForHermesSessionIdle').mockRejectedValueOnce(new HttpError(409,'Hermes 原任务尚未停止','session_still_stopping'))
+  try{
+    await runtime.stop(owner,root.id)
+    const work=store.list<Work>(owner,'turn').find(item=>item.runId===root.id)!
+    await vi.waitFor(()=>expect(store.require<Work>(owner,'turn',work.id).cleanupError).toContain('原任务尚未停止'))
+    expect(store.require<Work>(owner,'turn',work.id).cleanupPending).toBe(true)
+    expect(store.get(owner,'binding',`${c.id}:${a.id}`)).toBeTruthy()
+    expect(()=>runtime.send(owner,c.id,{requestId:randomUUID(),content:'不能复用'})).toThrow('尚未确认停止')
+    expect(()=>store.changeConversationLifecycle(owner,c.id,'delete',store.conversationLifecycle(owner,c.id).confirmationToken)).toThrow('尚未确认停止')
+    expect(runtime.idleForUpdate).toBe(false)
+  }finally{confirmation.mockRestore()}
+  await vi.waitFor(()=>expect(store.list<Work>(owner,'turn').find(item=>item.runId===root.id)?.cleanupPending).toBe(false),{timeout:3000})
+  expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
+})
 it('marks a stopped administrator as interrupted without sending a false completion notification', async () => {
   const a=agent('管理员停止'),b=agent('普通成员'),g=store.createGroup(owner,{name:'停止管理员',memberIds:[a.id,b.id],administratorId:a.id})
   reply=()=>{}
@@ -1996,6 +2039,51 @@ it('applies Bot settings to the live agent after a cold resume restores its pers
   expect(requests.filter(r => r.method === 'session.resume')).toHaveLength(2)
 })
 
+it.each([
+  { hostname:'127.0.0.1', reported:true },
+  { hostname:'localhost', reported:true },
+  { hostname:'127.0.0.1', reported:false },
+  { hostname:'localhost', reported:false },
+])('keeps Hermes connected across client startup and shutdown at $hostname (metadata=$reported)', async ({hostname,reported}) => {
+  const bot=agent('独立服务器'),c=direct(bot.id),target=nodes.target(owner,'local')
+  target.url=new URL(`http://${hostname}:${target.url.port}`)
+  const original=target.session.request.bind(target.session)
+  const metadata={version:1,profile:'default',platform:'linux',osRelease:'6.8',arch:'x86_64',nativeTools:{terminal:['terminal'],files:['read_file','write_file'],desktop:['computer_use'],browser:['browser_navigate']}}
+  vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>path.startsWith('/api/plugins/yaoyao-bot-bridge/')
+    ? {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({ok:true,version:1,ready:true,native_tools:true,in_process:true,...(reported?{runtime_environment:metadata}:{})}))}
+    : original(path,options))
+  configuredCwds.set('default','/hermes/project')
+  saveHostTools(home,{vm:false,cloud:false})
+  const desktop=new DesktopEnvironments(store,{pushAuthorizationVersion:()=>0} as any,{localNodeID:'fixture',requireSource:()=>{}} as any)
+  runtime.desktopEnvironments=desktop
+  const record=vi.fn();runtime.inspector={record} as any
+  const clientId=randomUUID()
+  try {
+    await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'检查服务端目录',deviceHost:null}).id)
+    desktop.remoteExchange(clientId,{host:{id:randomUUID(),name:'服务器',platform:'darwin',screen:false,accessibility:false,approved:[]},results:[]})
+    await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'检查服务端目录',deviceHost:clientId}).id)
+    desktop.dropHost(clientId)
+    await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'检查服务端目录',deviceHost:clientId}).id)
+    const snapshots=record.mock.calls.map(([, ,entry])=>entry).filter(entry=>entry.method==='bot.environment').map(entry=>entry.data.snapshot)
+    expect(snapshots).toHaveLength(3)
+    expect(snapshots.every(snapshot=>snapshot.runtime.connected&&snapshot.cwd==='/hermes/project')).toBe(true)
+    expect(snapshots.map(snapshot=>snapshot.runtime.metadata)).toEqual(reported?[metadata,metadata,metadata]:[undefined,undefined,undefined])
+    expect(snapshots[0].desktop.hosts).toEqual([])
+    expect(snapshots[1].desktop.hosts[0]).toMatchObject({id:clientId,kind:'computer',platform:'darwin',online:true})
+    expect(snapshots[2].desktop.hosts[0]).toMatchObject({id:clientId,kind:'computer',online:false})
+    const prompts=requests.filter(request=>request.method==='prompt.submit').map(request=>String(request.params.text))
+    expect(prompts).toHaveLength(3)
+    for(const prompt of prompts){
+      expect(prompt).toContain('服务端 Hermes 已连接')
+      expect(prompt).toContain('操作服务端运行环境时优先使用')
+      expect(prompt).toContain('Hermes 本轮工作目录(已确认):"/hermes/project"')
+      expect(prompt).toContain(reported?'"desktop":["computer_use"]':'原生能力与系统信息尚未由工具桥上报')
+    }
+    expect(prompts[1]).toContain(`host="${clientId}"`)
+    expect(prompts[2]).toContain('当前离线或未连接')
+  } finally {desktop.close()}
+})
+
 it('submits capability-based Bot context with current origins, confirmed cwd and no keyword-selected environment', async () => {
   const bot = agent('环境助手'), c = direct(bot.id), target = nodes.target(owner, 'local')
   const originalRequest = target.session.request.bind(target.session)

+ 149 - 0
tests/server/workspaceAssetsAuthorization.test.ts

@@ -0,0 +1,149 @@
+// @vitest-environment node
+import { createHash } from 'node:crypto'
+import { existsSync, mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type Koa from 'koa'
+import { afterEach, expect, it, vi } from 'vitest'
+import { WorkspaceAssets, type StoredWorkspaceFile } from '../../src/server/workspaceAssets'
+import { WorkspaceStore } from '../../src/server/workspaceStore'
+import { workspaceRouter } from '../../src/server/workspaceRoutes'
+import { saveFileAccess } from '../../src/server/fileAccess'
+import { HttpError } from '../../src/server/errors'
+import type { WorkspaceNodes } from '../../src/server/workspaceGateway'
+import type { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
+import type { UploadStore } from '../../src/server/uploads'
+import type { LocalAuthStore } from '../../src/server/localAuth'
+import type { PushCoordinator } from '../../src/server/pushCoordinator'
+
+const fixtures: Array<{ home: string; store: WorkspaceStore; assets: WorkspaceAssets }> = []
+afterEach(() => {
+  for (const { home, store, assets } of fixtures.splice(0)) {
+    assets.close(); store.close(); rmSync(home, { recursive: true, force: true })
+  }
+})
+const response = (value: unknown) => ({ status: 200, body: Buffer.from(JSON.stringify(value)), headers: new Headers() })
+const downloaded = () => ({ status: 200, body: Buffer.from('fixture-report'), headers: new Headers({ 'content-type': 'text/plain' }) })
+const keyFor = (nodeId: string) => createHash('sha256').update(JSON.stringify([nodeId, 'alpha', 'message', '/remote/work/report.txt'])).digest('hex')
+function fixture() {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-assets-auth-')), store = new WorkspaceStore(home)
+  let allowed = true
+  const request = vi.fn(async (route: string, options: { search?: URLSearchParams }) => {
+    if (route === '/api/config') return response({ terminal: { cwd: '/remote/work' } })
+    if (route === '/api/files') {
+      const path = options.search!.get('path')!
+      return response({ path, entries: [{ name: 'report.txt', path: path + '/report.txt' }, { name: 'escape.txt', path: '/private/escape.txt' }] })
+    }
+    if (route === '/api/files/download') return downloaded()
+    throw new Error('Unexpected mocked request: ' + route)
+  })
+  const requireSource = vi.fn((owner: string, _source: { nodeId: string; profile: string }) => {
+    if (owner !== 'owner' || !allowed) throw new HttpError(403, 'Fixture source revoked', 'agent_source_forbidden')
+  })
+  const nodes = { requireSource, target: () => ({ session: { request } }) } as unknown as WorkspaceNodes
+  const assets = new WorkspaceAssets(store, nodes, home)
+  const router = workspaceRouter(store, {} as WorkspaceRuntime, nodes, assets, {} as UploadStore,
+    { require: () => ({ id: 'owner' }) } as LocalAuthStore, {} as PushCoordinator)
+  const handler = router.stack.find(layer => layer.path === '/api/app/files/:id/download')!.stack[0]!
+  const download = async (id: string) => {
+    const ctx = { params: { id }, get: () => '', set() {} } as unknown as Koa.Context
+    await handler(ctx, async () => {})
+    const bytes: Buffer[] = []
+    for await (const chunk of ctx.body as NodeJS.ReadableStream) bytes.push(Buffer.from(chunk))
+    return Buffer.concat(bytes).toString()
+  }
+  fixtures.push({ home, store, assets })
+  return { home, store, assets, request, requireSource, download, revoke: () => { allowed = false },
+    files: () => store.list<StoredWorkspaceFile>('owner', 'file'),
+    archive: (path = '/remote/work/report.txt', messageId = 'message') =>
+      assets.archiveText('owner', `[report](${path})`, 'local', 'alpha', 'conversation', messageId) }
+}
+
+it('archives authorized references with their source and checks it for UUID and library-row downloads', async () => {
+  const f = fixture()
+  await f.archive()
+  const file = f.files()[0]!
+  expect(file).toMatchObject({ sourceNodeId: 'local', profile: 'alpha', sourcePath: '/remote/work/report.txt' })
+  expect(await f.download(file.id)).toBe('fixture-report')
+  const row = f.store.db.prepare("SELECT rowid AS n FROM workspace_entities WHERE owner='owner' AND kind='file' AND id=?").get(file.id)!
+  f.revoke()
+  for (const id of [file.id, String(row.n)]) await expect(f.download(id)).rejects.toMatchObject({ status: 403, code: 'agent_source_forbidden' })
+})
+
+it('refuses directory and symlink escapes before making a download request', async () => {
+  const f = fixture()
+  await f.archive('/outside/report.txt')
+  await f.archive('/remote/work/escape.txt')
+  expect(f.request.mock.calls.some(([route]) => route === '/api/files/download')).toBe(false)
+  expect(f.files()).toEqual([])
+  expect(existsSync(join(f.home, 'workspace-files'))).toBe(false)
+})
+
+it('refuses an unassigned source before any upstream request', async () => {
+  const f = fixture(); f.revoke()
+  await f.archive()
+  expect(f.request).not.toHaveBeenCalled()
+  expect(f.files()).toEqual([])
+})
+
+it.each(['source', 'directory', 'quota'] as const)('does not persist bytes if %s authorization changes during a download', async change => {
+  const f = fixture()
+  saveFileAccess(f.home, { mode: 'folders', folders: ['/extra'] })
+  let finish!: (value: ReturnType<typeof downloaded>) => void
+  let started!: () => void
+  const pending = new Promise<ReturnType<typeof downloaded>>(resolve => { finish = resolve })
+  const downloading = new Promise<void>(resolve => { started = resolve })
+  const original = f.request.getMockImplementation()!
+  f.request.mockImplementation((route, options) => {
+    if (route === '/api/files/download') { started(); return pending }
+    return original(route, options)
+  })
+  const archive = f.archive('/extra/report.txt')
+  await downloading
+  if (change === 'source') f.revoke()
+  else if (change === 'directory') saveFileAccess(f.home, { mode: 'folders', folders: [] })
+  else f.store.put('owner', 'file', 'full-quota', { id: 'full-quota', size: 2 * 1024 * 1024 * 1024 })
+  finish(downloaded()); await archive
+  expect(f.files()).toHaveLength(change === 'quota' ? 1 : 0)
+  expect(existsSync(join(f.home, 'workspace-files'))).toBe(false)
+  expect(f.store.list('owner', 'archived-path')).toEqual([])
+})
+
+it.each(['quota', 'message-files'] as const)('enforces the existing %s limit for automatic archives', async limit => {
+  const f = fixture()
+  for (let i = 0; i < (limit === 'quota' ? 1 : 8); i++)
+    f.store.put('owner', 'file', `existing-${i}`, { id: `existing-${i}`, messageId: limit === 'quota' ? 'older-message' : 'message', size: limit === 'quota' ? 2 * 1024 * 1024 * 1024 : 1 })
+  await f.archive()
+  expect(f.files()).toHaveLength(limit === 'quota' ? 1 : 8)
+  expect(existsSync(join(f.home, 'workspace-files'))).toBe(false)
+})
+
+it('recovers a uniquely authorized legacy source from its archive key without downloading again', async () => {
+  const f = fixture(); await f.archive()
+  const file = f.files()[0]!
+  delete file.sourceNodeId; f.store.put('owner', 'file', file.id, file)
+  f.request.mockClear()
+  expect(await f.download(file.id)).toBe('fixture-report')
+  expect(f.files()[0]!.sourceNodeId).toBe('local')
+  expect(f.request).not.toHaveBeenCalled()
+})
+
+it.each(['unknown', 'ambiguous', 'ambiguous-revoked', 'duplicate-record', 'revoked'] as const)('denies a %s legacy archive instead of guessing its source', async reason => {
+  const f = fixture(); await f.archive()
+  const file = f.files()[0]!
+  delete file.sourceNodeId; f.store.put('owner', 'file', file.id, file)
+  if (reason === 'unknown') f.store.remove('owner', 'archived-path', keyFor('local'))
+  if (reason === 'ambiguous' || reason === 'ambiguous-revoked') {
+    f.store.put('owner', 'node', 'other-node', { id: 'other-node' })
+    f.store.put('owner', 'archived-path', keyFor('other-node'), true)
+    if (reason === 'ambiguous-revoked') f.requireSource.mockImplementation((owner, source: { nodeId: string }) => {
+      if (owner !== 'owner' || source.nodeId !== 'local') throw new HttpError(403, 'Fixture source revoked', 'agent_source_forbidden')
+    })
+  }
+  if (reason === 'duplicate-record') f.store.put('owner', 'file', 'duplicate', { ...file, id: 'duplicate' })
+  if (reason === 'revoked') f.revoke()
+  f.request.mockClear()
+  await expect(f.download(file.id)).rejects.toMatchObject({ status: 403, code: 'archived_file_source_unknown' })
+  expect(f.files()[0]!.sourceNodeId).toBeUndefined()
+  expect(f.request).not.toHaveBeenCalled()
+})

+ 14 - 0
tests/server/workspaceEnvironment.test.ts

@@ -4,6 +4,7 @@ import {buildWorkspaceEnvironment,workspaceEnvironmentTools} from '../../src/ser
 import {parseHostTools} from '../../src/server/hostToolSettings'
 import {deviceInventoryText,type DesktopEnvironmentSnapshot} from '../../src/shared/botEnvironment'
 import type {WorkspaceAgent} from '../../src/shared/workspace'
+import {parseHermesRuntimeMetadata} from '../../src/server/hermesRuntimeEnvironment'
 const agent:WorkspaceAgent={id:'bot',name:'Bot',avatar:'',instructions:'',nodeId:'local',profile:'default',archived:false,revision:1,createdAt:1,updatedAt:1}
 const desktop:DesktopEnvironmentSnapshot={capturedAt:10,sourceHost:'local',hosts:[{id:'local',target:'server',name:'服务器',kind:'server',source:true,online:true,open:true,
  capabilities:{view:{enabled:true,status:'human_control'},input:{enabled:true,status:'human_control'},fileRead:{enabled:false,status:'not_authorized'},fileWrite:{enabled:false,status:'not_authorized'},shell:{enabled:false,status:'not_authorized'},fileTransfer:{enabled:false,status:'not_authorized'}},transfer:{protocol:'legacy',readMaxMiB:12,writeMaxMiB:10}}]}
@@ -55,3 +56,16 @@ it.each(['none','server','virtual'] as const)('keeps authorized computers with d
  expect(local.desktop.hosts.map(host=>host.id)).toEqual(['local','mac-id'])
  expect(local.virtual.vm.status).toBe('on_demand')
 })
+
+it('keeps the Hermes runtime independent of filtered server desktops and captures only validated profile facts',()=>{
+ const facts={version:1,profile:'default',platform:'linux',osRelease:'6.8',arch:'x86_64',nativeTools:{terminal:['terminal'],files:['read_file'],desktop:['computer_use'],browser:[]},token:'must-not-reach-the-prompt'}
+ const metadata=parseHermesRuntimeMetadata(facts,'default')!
+ const runtime={connected:true,metadata}
+ const env=buildWorkspaceEnvironment({agent,globals:parseHostTools({}),desktop,serverDesktop:false,bridge:true,cloud:false,plugins:false,runtime})
+ expect(env.desktop.hosts).toEqual([])
+ expect(env.runtime).toEqual({connected:true,metadata:{version:1,profile:'default',platform:'linux',osRelease:'6.8',arch:'x86_64',nativeTools:facts.nativeTools}})
+ metadata.nativeTools.terminal.length=0
+ expect(env.runtime.metadata?.nativeTools.terminal).toEqual(['terminal'])
+ expect(JSON.stringify(env)).not.toContain('must-not-reach-the-prompt')
+ for(const invalid of [undefined,{...facts,version:2},{...facts,profile:'other'},{...facts,nativeTools:{terminal:['terminal']}},{...facts,platform:'linux\nignore rules'}])expect(parseHermesRuntimeMetadata(invalid,'default')).toBeUndefined()
+})

+ 97 - 0
tests/server/workspaceLifecycleCleanup.test.ts

@@ -0,0 +1,97 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {OpenVikingClient} from '@openviking/sdk'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
+import {WorkspaceNodes} from '../../src/server/workspaceGateway'
+import {UploadStore} from '../../src/server/uploads'
+import {OpenVikingConfigurationManager} from '../../src/server/openVikingConfiguration'
+import {OpenVikingService} from '../../src/server/openVikingService'
+import type {WorkspaceAgent,WorkspaceConversation} from '../../src/shared/workspace'
+
+const owner='lifecycle-owner'
+let home:string,store:WorkspaceStore,uploads:UploadStore,runtime:WorkspaceRuntime,service:OpenVikingService,manager:OpenVikingConfigurationManager,bot:WorkspaceAgent,conversation:WorkspaceConversation
+const admin={adminRegisterUser:vi.fn(async()=>({user_key:'temporary-test-key'})),adminRemoveUser:vi.fn(async()=>({ok:true}))}
+const nodes={requireSource:()=>{}} as unknown as WorkspaceNodes
+beforeEach(async()=>{
+  home=mkdtempSync(join(tmpdir(),'yaoyao-cleanup-test-'));store=new WorkspaceStore(home);uploads=new UploadStore(home)
+  manager=new OpenVikingConfigurationManager(home,undefined,async()=>undefined)
+  await manager.update({enabled:true,url:'http://127.0.0.1:1933',accountId:'fixture',adminKey:'temporary-test-admin'})
+  service=new OpenVikingService(store,manager,()=>admin as unknown as OpenVikingClient)
+  runtime=new WorkspaceRuntime(store,nodes,uploads,undefined,undefined,service)
+  vi.spyOn(runtime,'wake').mockImplementation(()=>{})
+  bot=store.createAgent(owner,{name:'待删除 Bot',profile:'default'})
+  conversation=store.list<WorkspaceConversation>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)!
+})
+afterEach(()=>{runtime.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true});vi.clearAllMocks()})
+
+it('validates every local delete condition before removing any remote memory',async()=>{
+  await service.ensureUser(owner,bot)
+  await expect(runtime.deleteAgent(owner,bot.id)).rejects.toMatchObject({code:'agent_not_archived'})
+  const peer=store.createAgent(owner,{name:'群成员',profile:'default'})
+  const group=store.createGroup(owner,{name:'现有群',memberIds:[bot.id,peer.id],administratorId:peer.id})
+  store.updateAgent(owner,bot.id,{archived:true})
+  await expect(runtime.deleteAgent(owner,bot.id)).rejects.toMatchObject({code:'agent_in_group'})
+  expect(store.require<WorkspaceConversation>(owner,'conversation',group.id).memberIds).toContain(bot.id)
+  expect(admin.adminRemoveUser).not.toHaveBeenCalled()
+  expect(service.binding(owner,bot.id)?.status).toBe('active')
+  expect(store.get(owner,'agent-deletion',bot.id)).toBeUndefined()
+})
+
+it('preserves the stop ledger and session binding, and blocks deletion and source reuse until cleanup',()=>{
+  store.updateAgent(owner,bot.id,{archived:true})
+  const turnId=randomUUID(),bindingId=`${conversation.id}:${bot.id}`
+  store.put(owner,'turn',turnId,{id:turnId,agentId:bot.id,conversationId:conversation.id,status:'interrupted',cleanupPending:true})
+  store.put(owner,'binding',bindingId,{id:bindingId,conversationId:conversation.id,agentId:bot.id,storedId:'original-session'})
+  expect(()=>store.deleteAgent(owner,bot.id)).toThrow('尚未确认停止')
+  expect(()=>store.updateAgent(owner,bot.id,{profile:'changed'})).toThrow('确认当前任务停止')
+  expect(()=>store.deleteConversation(owner,conversation.id,true)).toThrow('尚未确认停止')
+  expect(store.require<any>(owner,'binding',bindingId).storedId).toBe('original-session')
+  const turn=store.require<any>(owner,'turn',turnId);turn.cleanupPending=false;store.put(owner,'turn',turnId,turn)
+  store.deleteAgent(owner,bot.id)
+  expect(store.get(owner,'turn',turnId)).toBeUndefined()
+  expect(store.require<any>(owner,'agent-deletion',bot.id).conversationIds).toEqual([conversation.id])
+  expect(()=>store.createAgent(owner,{name:'复用旧 ID',profile:'default'},undefined,bot.id)).toThrow('不能复用')
+})
+
+it('keeps a failed remote deletion retryable by the same Bot id after local deletion',async()=>{
+  await service.ensureUser(owner,bot);store.updateAgent(owner,bot.id,{archived:true})
+  admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture temporarily unavailable'))
+  await expect(runtime.deleteAgent(owner,bot.id)).rejects.toMatchObject({code:'openviking_unavailable'})
+  expect(store.get(owner,'agent',bot.id)).toBeUndefined()
+  expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(true)
+  await runtime.deleteAgent(owner,bot.id);await runtime.deleteAgent(owner,bot.id)
+  expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(false)
+  expect(admin.adminRemoveUser).toHaveBeenCalledTimes(2)
+})
+
+it('automatically recovers persisted remote cleanup after restart and retries transient failure',async()=>{
+  await service.ensureUser(owner,bot);store.updateAgent(owner,bot.id,{archived:true})
+  admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture initial failure'))
+  await expect(runtime.deleteAgent(owner,bot.id)).rejects.toThrow('fixture initial failure')
+  runtime.close()
+  service=new OpenVikingService(store,manager,()=>admin as unknown as OpenVikingClient)
+  runtime=new WorkspaceRuntime(store,nodes,uploads,undefined,undefined,service)
+  admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture retry failure'))
+  runtime.start()
+  await vi.waitFor(()=>expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(false),{timeout:3000})
+  expect(service.binding(owner,bot.id)).toMatchObject({status:'removed',pendingRemoval:false})
+  expect(admin.adminRemoveUser).toHaveBeenCalledTimes(3)
+  expect(admin.adminRegisterUser).toHaveBeenCalledTimes(1)
+})
+
+it('keeps cleanup durable while disabled and resumes it when the configured service returns',async()=>{
+  await service.ensureUser(owner,bot);store.updateAgent(owner,bot.id,{archived:true})
+  service.configure(undefined)
+  await runtime.deleteAgent(owner,bot.id)
+  expect(admin.adminRemoveUser).not.toHaveBeenCalled()
+  expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(true)
+  vi.mocked(runtime.wake).mockRestore()
+  service.configure(manager.configuration())
+  await vi.waitFor(()=>expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(false))
+  expect(admin.adminRemoveUser).toHaveBeenCalledTimes(1)
+})

+ 21 - 0
tests/server/workspaceMemorySynthesis.test.ts

@@ -30,6 +30,27 @@ function turn(content = '以后我喜欢简洁的中文回复') {
   synthesis.enqueue(owner, run)
   return { run, source: store.require<WorkspaceMessage>(owner, 'message', run.messageId), reply }
 }
+it('rotates accounts even when the first account always has eligible memory work', async () => {
+  vi.spyOn(store, 'owners').mockReturnValue(['first', 'second', 'inactive', '_internal'])
+  vi.spyOn(runtime, 'userActive').mockImplementation(account => account !== 'inactive')
+  vi.spyOn(runtime.knowledge, 'jobs').mockImplementation(account => [{
+    id: `${account}-job`, status: 'pending', nextAt: 0, createdAt: account === 'first' ? 100 : 0,
+  }] as any)
+  const run = vi.spyOn(synthesis, 'run').mockResolvedValue()
+  for (let index = 0; index < 6; index++) await synthesis.tick()
+  expect(run.mock.calls.map(([account]) => account)).toEqual(['first', 'second', 'first', 'second', 'first', 'second'])
+})
+it('does not reserve a fairness turn for accounts without due jobs and handles a removed account', async () => {
+  const owners = vi.spyOn(store, 'owners').mockReturnValue(['first', 'second'])
+  vi.spyOn(runtime.knowledge, 'jobs').mockImplementation(account => [{
+    id: `${account}-job`, status: 'pending', nextAt: account === 'first' ? Date.now() + 60_000 : 0, createdAt: 0,
+  }] as any)
+  const run = vi.spyOn(synthesis, 'run').mockResolvedValue()
+  await synthesis.tick()
+  owners.mockReturnValue(['third'])
+  await synthesis.tick()
+  expect(run.mock.calls.map(([account]) => account)).toEqual(['second', 'third'])
+})
 it('runs extraction on server Hermes even for a legacy computer Bot',async()=>{
   const request=vi.fn(async()=>({status:200,body:Buffer.from(JSON.stringify({text:'{"memories":[]}'}))}))
   const target={session:{request}}

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

@@ -13,7 +13,7 @@ import type { WorkspaceConversation, WorkspaceEvent, WorkspaceMessage } from '..
 import type { LocalAuthStore } from '../../src/server/localAuth'
 
 let home: string, store: WorkspaceStore, message: WorkspaceMessage, server: Server
-const auth = { require: () => ({ id: 'owner' }), isUserActive: () => true, pushAuthorizationVersion: () => 1 } as unknown as LocalAuthStore
+const auth = { require: () => ({ id: 'owner' }), currentFromCookieHeader: () => ({ id: 'owner' }), isUserActive: () => true, pushAuthorizationVersion: () => 1 } as unknown as LocalAuthStore
 beforeEach(async () => {
   home = mkdtempSync(join(tmpdir(), 'workspace-patches-'))
   store = new WorkspaceStore(home, { messagePatches: true })
@@ -87,7 +87,14 @@ it('sends a full live baseline then patches, with ordered legacy full frames and
     const response = await fetch(url(format), { headers: { 'Last-Event-ID': String(cursor) }, signal: controller.signal })
     const reader = response.body!.getReader(), decoder = new TextDecoder()
     let text = ''
-    const until = async (needle: string) => { while (!text.includes(needle)) text += decoder.decode((await reader.read()).value); return text }
+    const until = async (needle: string) => {
+      while (!text.includes(needle)) {
+        const chunk = await reader.read()
+        if (chunk.done) throw new Error(`Stream ended before ${needle}`)
+        text += decoder.decode(chunk.value)
+      }
+      return text
+    }
     await until('event: ready')
     return { controller, reader, until }
   }

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

@@ -23,7 +23,7 @@ it('changes the foundation only when idle and retires old session bindings witho
  const agent=store.createAgent('owner',{name:'甲',profile:'default'}),conversation=store.list<any>('owner','conversation')[0]
  store.put('owner','binding',`${conversation.id}:${agent.id}`,{id:`${conversation.id}:${agent.id}`,storedId:'old-session'})
  store.put('owner','turn','busy',{agentId:agent.id,status:'queued'})
- expect(()=>store.updateAgent('owner',agent.id,{profile:'other'})).toThrow('先停止')
+ expect(()=>store.updateAgent('owner',agent.id,{profile:'other'})).toThrowError(expect.objectContaining({code:'agent_execution_busy'}))
  store.remove('owner','turn','busy')
  expect(store.updateAgent('owner',agent.id,{profile:'other'}).profile).toBe('other')
  expect(store.list('owner','binding')).toEqual([])
@@ -140,7 +140,7 @@ it('persists the separate Profile execution mode, checks its Runner capability a
  expect(opened).not.toHaveProperty('hostAccess')
  store.put('owner','turn','active-profile',{agentId:agent.id,status:'running'})
  await request(app.callback()).put(path).send({computer:'vm',vmExecution:'worker'}).expect(409)
- expect(()=>store.updateAgent('owner',agent.id,{vmExecution:'worker'})).toThrow('先停止当前任务')
+ expect(()=>store.updateAgent('owner',agent.id,{vmExecution:'worker'})).toThrowError(expect.objectContaining({code:'agent_execution_busy'}))
  store.remove('owner','turn','active-profile')
  const member=store.createAgent('owner',{name:'不继承模式',profile:'default',computer:'vm',execution:'computer',vmExecution:'profile'},{createdByAgentId:agent.id,createdFromRunId:'run'})
  expect(member.vmExecution).toBe('worker');expect(member.allowHostEnvironment).toBe(false)

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

@@ -7,6 +7,7 @@ function environment(): WorkspacePromptEnvironment {
     open: { computer: true, server: true, vm: true, cloud: true },
     tools: { desktopView: true, desktopFile: true, vm: true, cloud: true, plugins: false },
     version: 1, capturedAt: 1, execution: { nodeId: 'local', profile: 'dev' },
+    runtime: { connected: true },
     desktop: { capturedAt: 1, sourceHost: 'mac-id', hosts: [{ id: 'mac-id', target: 'mac-id', name: '工作 Mac', kind: 'computer', source: true, online: true, open: true,
       capabilities: { view: { enabled: true, status: 'ready' }, input: { enabled: true, status: 'ready' }, fileRead: { enabled: true, status: 'ready' }, fileWrite: { enabled: true, status: 'ready' }, shell: { enabled: true, status: 'ready' }, fileTransfer: { enabled: true, status: 'ready' } },
       transfer: { protocol: 'chunked', readMaxMiB: 50, writeMaxMiB: 50 } }] },
@@ -27,6 +28,25 @@ function input(): WorkspacePromptInput {
 }
 
 describe('Bot environment routing', () => {
+  it('keeps native Hermes operations available when a server desktop proxy is offline', () => {
+    const env = environment()
+    env.runtime.metadata = {version:1,profile:'dev',platform:'linux',osRelease:'6.8',arch:'x86_64',nativeTools:{terminal:['terminal'],files:['read_file'],desktop:['computer_use'],browser:[]}}
+    const offline = structuredClone(env.desktop.hosts[0]!)
+    offline.id='local';offline.target='server';offline.name='服务器';offline.kind='server';offline.source=false;offline.online=false
+    for(const capability of Object.values(offline.capabilities))Object.assign(capability,{enabled:false,status:'offline'})
+    env.desktop.hosts=[offline]
+    env.desktop.sourceHost=undefined
+    env.tools.desktopView=false;env.tools.desktopFile=false
+    const text=workspaceEnvironmentPrompt(env)
+    expect(text).toContain('服务端 Hermes 已连接')
+    expect(text).toContain('服务器桌面代理;host="server";控制通道=离线')
+    expect(text).toContain('操作服务端运行环境时优先使用')
+    expect(text).toContain('"desktop":["computer_use"]')
+    expect(text).toContain('目录存在不保证后端连接就绪')
+    expect(text).toContain('「本机」无法确定')
+    expect(text).not.toContain('电脑桌面工具已挂载')
+  })
+
   it('describes all mounted environments conditionally without moving the model or the message origin', () => {
     const text = workspaceEnvironmentPrompt(environment())
     expect(text).toContain('模型会话运行在服务端 Hermes')

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

@@ -10,6 +10,7 @@ import { createApplication, type ApplicationRuntime } from '../../src/server/app
 import { loadServerConfig } from '../../src/server/config'
 import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
 import { WorkspaceAssets } from '../../src/server/workspaceAssets'
+import { saveFileAccess } from '../../src/server/fileAccess'
 import { WorkspaceTranscriptStore } from '../../src/client/components/workspace/transcriptStore'
 import { SharedComputers, type SharedComputer } from '../../src/server/sharedComputers'
 import type { WorkspaceAgent, WorkspaceConversation } from '../../src/shared/workspace'
@@ -353,6 +354,23 @@ describe('application workspace HTTP contract', () => {
     expect(store.require<WorkspaceAgent>('first', 'agent', bot.id).computerEnvironmentId).toBe(shared.id)
     expect(store.cursor('first')).toBe(cursor)
   })
+  it('retries remote cleanup through either delete endpoint after the local Bot and chat are gone',async()=>{
+    const store=runtime.workspace,bot=store.createAgent('first',{name:'远端清理重试',profile:'default'})
+    const direct=store.list<WorkspaceConversation>('first','conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)!
+    const path=`/api/app/conversations/${direct.id}/lifecycle`,preview=(await req('get',path).expect(200)).body
+    const cleanup=vi.spyOn(runtime.workspaceRuntime,'cleanupAgent').mockRejectedValueOnce(new Error('模拟远端清理失败')).mockResolvedValue(undefined)
+    await req('delete',`/api/app/agents/${bot.id}`).expect(409)
+    expect(cleanup).not.toHaveBeenCalled()
+    await req('post',path).send({action:'delete',confirmationToken:preview.confirmationToken}).expect(500)
+    expect(store.get('first','agent',bot.id)).toBeUndefined()
+    expect(store.get('first','conversation',direct.id)).toBeUndefined()
+    expect(store.get('first','agent-deletion',bot.id)).toBeTruthy()
+    await req('post',path).send({action:'delete',confirmationToken:preview.confirmationToken}).expect(200)
+    await req('delete',`/api/app/agents/${bot.id}`).expect(200)
+    expect(cleanup).toHaveBeenCalledTimes(3)
+    await req('delete',`/api/app/agents/${bot.id}`,'another').expect(404)
+    cleanup.mockRestore()
+  })
   it('deletes an unarchived group while preserving its member Bots', async () => {
     const store = runtime.workspace
     const a = store.createAgent('first', { name: '甲', profile: 'default' }), b = store.createAgent('first', { name: '乙', profile: 'default' })
@@ -500,6 +518,7 @@ describe('application workspace HTTP contract', () => {
   })
 
   it('archives native-chat uploads through the standard file API with user ownership', async () => {
+    saveFileAccess(home, { mode: 'all', folders: [] })
     const assets = new WorkspaceAssets(runtime.workspace, runtime.workspaceRuntime.nodes, home)
     await assets.archiveText(
       'first',

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

@@ -82,7 +82,7 @@ it('tells every Bot not to use Hermes cron, and explains its own routine tools w
     members: [{ id: 'bot', name: '助手' }],
     work: { depth: 0, requiredReply: false, replyMode: 'mentioned', messageId: 'message' },
     run: { mentionIds: [] },
-    environment: { open: { computer: false, server: false, vm: false, cloud: false }, tools: { desktopView: false, desktopFile: false, vm: false, cloud: false, plugins: false }, version: 1, capturedAt: 1, execution: { nodeId: 'local', profile: 'dev' }, desktop: { capturedAt: 1, hosts: [] }, virtual: { vm: { status: 'disabled' }, cloud: { status: 'disabled' } }, fileTransferMaxMiB: 25 },
+    environment: { open: { computer: false, server: false, vm: false, cloud: false }, tools: { desktopView: false, desktopFile: false, vm: false, cloud: false, plugins: false }, version: 1, capturedAt: 1, execution: { nodeId: 'local', profile: 'dev' }, runtime: { connected: false }, desktop: { capturedAt: 1, hosts: [] }, virtual: { vm: { status: 'disabled' }, cloud: { status: 'disabled' } }, fileTransferMaxMiB: 25 },
     memory: '', noReply: '[[YAOYAO_NO_REPLY_V1]]', marker: '[yaoyao-run:run:result]', content: '每天提醒我', contentKind: 'user', attachmentRefs: [],
   } as WorkspacePromptInput
   expect(buildWorkspacePrompt(fixture)).toContain('不要调用 Hermes 的 cronjob')

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

@@ -13,7 +13,7 @@ import type { Server } from 'node:http'
 import type { WorkspaceConversation, WorkspaceMessage } from '../../src/shared/workspace'
 
 let home: string, store: WorkspaceStore, server: Server, version: number
-const auth = { require: () => ({ id: 'owner' }), isUserActive: () => true, pushAuthorizationVersion: () => version } as unknown as LocalAuthStore
+const auth = { require: () => ({ id: 'owner' }), currentFromCookieHeader: () => ({ id: 'owner' }), isUserActive: () => true, pushAuthorizationVersion: () => version } as unknown as LocalAuthStore
 beforeEach(async () => {
   home = mkdtempSync(join(tmpdir(), 'workspace-sync-')); store = new WorkspaceStore(home); version = 1
   const app = new Koa(); app.use(ctx => streamWorkspace(ctx, store, auth))

+ 72 - 0
tests/server/workspaceSyncAuthorization.test.ts

@@ -0,0 +1,72 @@
+// @vitest-environment node
+import { EventEmitter } from 'node:events'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type Koa from 'koa'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { LocalAuthStore } from '../../src/server/localAuth'
+import { WorkspaceStore } from '../../src/server/workspaceStore'
+import { streamWorkspace } from '../../src/server/workspaceSync'
+
+class Response extends EventEmitter {
+  writableEnded = false
+  destroyed = false
+  writableLength = 0
+  writes: string[] = []
+  writeHead() {}
+  flushHeaders() {}
+  write(value: string) { this.writes.push(value); return true }
+  end() { this.writableEnded = true; this.emit('close') }
+}
+function context(cookie = ''): Koa.Context {
+  const headers: Record<string, string | string[]> = {}
+  return { state: {}, response: { headers }, get: (name: string) => name === 'cookie' ? cookie : '',
+    set: (name: string, value: string | string[]) => { headers[name.toLowerCase()] = value } } as unknown as Koa.Context
+}
+const issuedCookie = (ctx: Koa.Context) => (ctx.response.headers['set-cookie'] as string[])[0]!.split(';')[0]!
+let home: string, store: WorkspaceStore, auth: LocalAuthStore, cookie: string, owner: string, res: Response
+beforeEach(() => {
+  vi.useFakeTimers()
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-sse-auth-')); store = new WorkspaceStore(home); auth = new LocalAuthStore(home)
+  const setup = context(); owner = auth.setupAdmin(setup, 'fixture-user', 'fixture-password').id; cookie = issuedCookie(setup)
+  res = new Response()
+  const ctx = context(cookie)
+  // Mirror the authentication middleware's cached request identity.
+  ctx.state.localUser = auth.require(ctx); ctx.query = {}; ctx.res = res as unknown as Koa.Context['res']
+  streamWorkspace(ctx, store, auth)
+})
+afterEach(() => { res.end(); store.close(); rmSync(home, { recursive: true, force: true }); vi.useRealTimers() })
+
+it('closes the cached-user stream and sends no further event after logout', () => {
+  const version = auth.pushAuthorizationVersion(owner)
+  auth.logout(context(cookie))
+  expect(auth.pushAuthorizationVersion(owner)).toBe(version)
+  expect(auth.currentFromCookieHeader(cookie)).toBeUndefined()
+  store.event(owner, 'conversation.changed', { id: 'after-logout' })
+  expect(res.writableEnded).toBe(true)
+  expect(res.writes.join('')).not.toContain('after-logout')
+  expect(store.changes.listenerCount(owner)).toBe(0)
+})
+
+it('closes an idle expired-session stream on its next heartbeat', () => {
+  vi.setSystemTime(Date.now() + 31 * 24 * 60 * 60 * 1000)
+  vi.advanceTimersByTime(15_000)
+  expect(res.writableEnded).toBe(true)
+  expect(store.changes.listenerCount(owner)).toBe(0)
+})
+
+it('keeps a different valid login stream open when another session logs out', () => {
+  const other = context(); auth.login(other, 'fixture-user', 'fixture-password')
+  auth.logout(context(issuedCookie(other)))
+  store.event(owner, 'conversation.changed', { id: 'still-authorized' })
+  expect(res.writableEnded).toBe(false)
+  expect(res.writes.join('')).toContain('still-authorized')
+})
+
+it('still closes on account password revocation', () => {
+  auth.changeCredentials(context(cookie), 'fixture-password', 'fixture-password-changed')
+  store.event(owner, 'conversation.changed', { id: 'after-password-change' })
+  expect(res.writableEnded).toBe(true)
+  expect(res.writes.join('')).not.toContain('after-password-change')
+})

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

@@ -448,7 +448,7 @@ describe('loopback tool lease', () => {
 it('legacy VM managers create members on server Hermes and cannot rebind a running Agent',async()=>{
   store.put(owner,'agent',manager.id,{...manager,execution:'computer'})
   expect((await call('create_agent',{requestId:randomUUID(),name:'统一成员',profile:'default',execution:'profile'})).agent).toMatchObject({execution:'profile',canManageTeam:true})
-  expect(()=>store.updateAgent(owner,manager.id,{execution:'profile'})).toThrow('请先停止当前任务')
+  expect(()=>store.updateAgent(owner,manager.id,{execution:'profile'})).toThrowError(expect.objectContaining({code:'agent_execution_busy'}))
 })
 it('creates a task-scoped helper once and rejects promotion or use outside its goal',async()=>{
   const {team,task}=await buildTeam()

+ 9 - 1
tests/setup.ts

@@ -1,4 +1,12 @@
-import { afterEach } from 'vitest'
+import { afterEach, beforeEach } from 'vitest'
+
+beforeEach(() => {
+  // Node's native storage globals can shadow jsdom's browser storage.
+  const browser = (globalThis as typeof globalThis & { jsdom?: { window: Window } }).jsdom?.window
+  if (!browser) return
+  for (const key of ['localStorage', 'sessionStorage'] as const)
+    Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: browser[key] })
+})
 
 afterEach(() => {
   if (typeof document !== 'undefined') document.body.innerHTML = ''