Selaa lähdekoodia

按 OpenMausBot 重构本地虚拟机与聊天电脑面板并移除旧镜像设计

LSamien 1 kuukausi sitten
vanhempi
sitoutus
d81a928b5b
59 muutettua tiedostoa jossa 1160 lisäystä ja 734 poistoa
  1. 3 0
      desktop/main.mjs
  2. 1 1
      docs/agent-platform-design.md
  3. 1 1
      docs/agent-platform-implementation.md
  4. 1 1
      docs/computer-maintenance.md
  5. 0 60
      docs/computer-management-ui.md
  6. 40 0
      docs/local-vm.md
  7. BIN
      docs/screenshots/local-vm/agent-panel.png
  8. BIN
      docs/screenshots/local-vm/interactive-desktop.png
  9. BIN
      docs/screenshots/local-vm/phone-chat-entry.png
  10. BIN
      docs/screenshots/local-vm/phone-desktop.png
  11. BIN
      docs/screenshots/local-vm/settings.png
  12. BIN
      docs/screenshots/local-vm/two-desktops.png
  13. BIN
      docs/screenshots/openmaus-layout/android-desktop.png
  14. BIN
      docs/screenshots/openmaus-layout/bot-header-computer-entry.png
  15. BIN
      docs/screenshots/openmaus-layout/computer-immersive-control.png
  16. BIN
      docs/screenshots/openmaus-layout/computer-readonly.png
  17. BIN
      docs/screenshots/openmaus-layout/image-overview.png
  18. BIN
      docs/screenshots/openmaus-layout/mobile-immersive-large-dark.png
  19. BIN
      docs/screenshots/openmaus-layout/mobile-shared-screen.png
  20. 1 1
      docs/shared-computers.md
  21. 1 1
      scripts/build-desktop.mjs
  22. 65 0
      src/client/components/app/LocalVmSettingsPanel.vue
  23. 2 2
      src/client/components/app/RunnerSettingsPanel.vue
  24. 5 0
      src/client/components/app/SettingsCenterDialog.vue
  25. 5 0
      src/client/components/app/WorkspaceShell.vue
  26. 0 44
      src/client/components/workspace/ComputerEnvironmentHub.vue
  27. 61 46
      src/client/components/workspace/ComputerPanel.vue
  28. 0 63
      src/client/components/workspace/ImageManagementPanel.vue
  29. 68 0
      src/client/components/workspace/LocalVmChatPanel.vue
  30. 32 0
      src/client/components/workspace/LocalVmWorkspace.vue
  31. 21 19
      src/client/views/ConversationsView.vue
  32. 36 29
      src/runner/agent.ts
  33. 0 142
      src/runner/computers/imageManagement.ts
  34. 116 0
      src/runner/computers/localVm.ts
  35. 37 6
      src/runner/computers/pool.ts
  36. 16 6
      src/runner/worker/control.ts
  37. 4 3
      src/runner/worker/gateway.ts
  38. 8 4
      src/server/app.ts
  39. 2 2
      src/server/computerControls.ts
  40. 0 37
      src/server/imageManagement.ts
  41. 170 0
      src/server/localVm.ts
  42. 24 24
      src/server/runnerHub.ts
  43. 37 1
      src/server/sharedComputers.ts
  44. 2 0
      src/server/workspaceStore.ts
  45. 2 1
      src/shared/computerControl.ts
  46. 0 3
      src/shared/imageManagement.ts
  47. 24 0
      src/shared/localVm.ts
  48. 1 1
      src/shared/runner.ts
  49. 86 96
      tests/e2e/team-tools.spec.ts
  50. 15 21
      tests/fixtures/workspace-server.ts
  51. 117 0
      tests/live/localVm.live.test.ts
  52. 9 30
      tests/live/worker.live.test.ts
  53. 6 0
      tests/server/computerControl.test.ts
  54. 5 5
      tests/server/desktopSync.test.ts
  55. 0 48
      tests/server/imageManagement.test.ts
  56. 0 24
      tests/server/imageManagementRoutes.test.ts
  57. 88 0
      tests/server/localVm.test.ts
  58. 48 0
      tests/server/localVmRoutes.test.ts
  59. 0 12
      tests/server/runner.test.ts

+ 3 - 0
desktop/main.mjs

@@ -90,6 +90,8 @@ else {
   }
   function stateChanged(state) {
     log(`${state.phase}: ${state.message}`)
+    const localVmMenu=Menu.getApplicationMenu()?.getMenuItemById('desktop-local-vm')
+    if(localVmMenu)localVmMenu.enabled=state.phase==='ready'
     const notice = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-notice')
     if (notice) notice.visible = Boolean(state.updateNotice)
     const retrySync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-retry')
@@ -158,6 +160,7 @@ else {
     Menu.setApplicationMenu(Menu.buildFromTemplate([
       { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
         { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
+        { id:'desktop-local-vm',label:'本地虚拟机设置…',enabled:false,click:()=>{if(manager.state.phase==='ready'){show();void window.webContents.executeJavaScript("window.dispatchEvent(new CustomEvent('yaoyao:local-vm-settings'))")}} },
         { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) },
         { id:'desktop-sync-notice', label:'Web 同步待完成:服务忙碌', enabled:false, visible:false },
         { id:'desktop-sync-retry', label:'重试同步本机 Web…', enabled:false,

+ 1 - 1
docs/agent-platform-design.md

@@ -88,4 +88,4 @@ Runner 默认最多同时运行 2 台电脑,可配置 1–8 台;默认最多
 配套说明:`bot-agent-teams.md`、`runner.md`、`computer-provider.md`、`computer-worker.md`、`computer-network.md`、`computer-control.md`、`task-helpers.md`、`shared-computers.md`、`computer-maintenance.md`、`macos-app.md`。
 
 
-2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 1 - 1
docs/agent-platform-implementation.md

@@ -117,4 +117,4 @@ Docker 已做真实运行验收;Podman 有接口与离线一致性测试,未
 模型循环使用真实 Hermes SDK,模型输出由本地确定性测试服务提供,未调用付费模型。正式 15300 服务、实体手机、参考仓库没有被本轮替换或部署;验收容器、临时数据卷和测试服务均已清理。App 使用 Apple Development 开发签名,未公证、未对外发布。旧 safeStorage 配置若需要钥匙串授权,仍由用户完成正常系统授权。
 
 
-2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 1 - 1
docs/computer-maintenance.md

@@ -47,4 +47,4 @@ CLI 默认数据目录为配置旁的 `state/<runnerId>`。App 托管节点的
 真实电脑备份/恢复后重新启动并读取原文件通过,恢复前文件保留在旧数据副本。新镜像的打包 Runner / Hermes Worker 验收 2 项通过,包含公网访问、临时助手、产物回传、人工接管和原任务续跑。类型检查及 767 项全量测试通过。
 
 
-2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 0 - 60
docs/computer-management-ui.md

@@ -1,60 +0,0 @@
-# 电脑、镜像与屏幕的界面入口
-
-## 平台边界
-
-- Web/macOS:Bot 模式聊天列表中常驻“电脑与环境”,包含电脑、共享环境、镜像、节点配置。
-- iOS/Android:常驻“桌面与屏幕”,只列出虚拟桌面和共享屏幕,支持查看、接管、输入和交还。没有镜像管理、共享成员变更或执行环境切换入口。
-- 没有电脑、节点未连接、账号无管理权限或服务端版本较旧时,保留入口并显示原因。手机安装新版客户端不会自动更新它连接的服务端。
-
-## Web/macOS 操作
-
-1. 管理员在“节点配置”注册并连接 Runner,勾选隔离电脑 Worker。镜像 ID 可留空,之后在镜像页准备。
-2. 在“镜像”选择执行节点,点击“准备兼容镜像”,或选择 `.tar` 和配套 `.tar.json` 上传导入。界面显示传输进度及节点上的操作记录。
-3. 准备或导入完成后,在镜像列表选择“应用”。界面显示当前镜像及可回退版本。更新和回退保留工作区。
-4. “导出”会生成可分别下载的镜像与校验清单;导出副本可以清理,原镜像保留。未完成上传可以取消清理。
-5. “电脑”列出所有自己的 Agent 及配置状态,可以启用隔离电脑、进入查看/接管面板。共享环境在独立的“共享环境”页创建、查看成员或解除。
-
-镜像管理仅对服务器管理员开放。镜像任务开始前要求节点的电脑资源空闲,期间新电脑任务等待;未确认测试电脑已停止时保留维护锁,刷新状态可重试清理。后台任务不会因关闭页面自动重复执行,Runner 重启后原未完成任务显示待核对。
-
-## 接口与数据
-
-- `GET /api/app/computers`:当前账号的电脑列表、可用性与原因。
-- `/api/app/computers/shared`:沿用已有账号范围的共享授权。
-- `/api/app/admin/runners/:id/images`:镜像目录、当前/上一个镜像、操作记录。
-- `/images/jobs`:准备、校验、应用、回退、导入、导出;请求编号持久去重。
-- `/images/uploads`:512 KiB 分块传输,上限 20 GiB,校验归档 SHA-256 后导入;不接受宿主路径。
-- `/images/jobs/:id/archive`、`manifest`:下载导出结果;文件在 Runner 侧,控制面流式转发。
-
-Runner 使用 `image-management-v1` 能力协商,镜像选择与任务记录保存在 Runner 的 SQLite 中。应用的镜像覆盖节点配置中的默认镜像,重新连接后保留;重新导入不同默认镜像配置时清除旧覆盖。管理请求经过管理员身份和授权版本检查,模型工具没有这些管理接口或凭据。
-
-## 验证
-
-Web 流程覆盖常驻入口、未配置提示、应用/回退、导出下载及旧服务提示,并检查窄屏深色截图。手机 UI 流程确认没有管理按钮,分别显示独立/共享屏幕并完成接管交还。已有 iOS 28 项 Workspace 测试通过,Android 编译与原生 UI 流程通过。
-
-真实 Docker + 打包 Runner + 控制面已完成镜像准备、校验、应用、回退,并导出、分块下载/上传及重新导入约 500 MB 的兼容镜像,核对 SHA-256。镜像完成后继续执行原有 Worker、共享电脑和临时助手验收。
-
-本分支最终单元/集成测试为 775 项通过、6 项按环境跳过;浏览器流程 7 项通过,真实 Worker 流程 2 项通过。下载块只保存命令指纹,每次重试重新鉴权读取,避免在回执数据库中重复存储整个归档;上传块在网络中断或服务器暂时错误时最多重试两次。
-
-手机端源码为 iOS 1.3(189),已签名并安装至 iPhone 13 Pro Max;安装后设备离线,启动核验尚未完成。Android 已通过编译和模拟器界面验证。新版客户端连接旧服务时显示升级提示;本次未替换正式运行中的 Web 服务。
-
-
-## OpenMausBot 展示方式对齐
-
-这轮以 `/Users/samien/git/OpenMausBot` 当前源码为参考,重点是 Bot 上下文、镜像状态和手机画面的摆放。
-
-| 参考实现 | 本分支对应行为 |
-| --- | --- |
-| `ios/App/ChatView.swift` 的右上角电脑入口 | iOS/Android 聊天右上角显示电脑图标。一个桌面直接全屏打开,多个桌面先选择;共享成员指向同一环境时只显示一次。 |
-| `ios/App/ComputerView.swift`、Android `ComputerScreen.kt` | 黑色全屏背景,画面保持原比例并居中,顶部显示 Bot 名称、共享环境和预览/控制状态。 |
-| 手机端主画面底部的桌面操作 | 底部保留接管/交还主按钮;接管后点“键盘与操作”打开面板,提供文字、按键、左右键、滚动和交还说明。 |
-| 按查看生命周期订阅屏幕 | 当前传输沿用已授权的 Runner 帧接口,仅查看时刷新,退出或后台停止轮询;连接失败显示画面已暂停并禁止输入旧帧。 |
-| `src/components/LocalComputerSection.tsx` 的镜像就绪状态 | Web/macOS 镜像页突出当前应用状态,展示“准备镜像 → 应用到节点 → 打开 Bot 电脑”的顺序。 |
-
-聊天列表的“桌面与屏幕”改为顶部图标,不占用聊天内容和输入区。桌面列表使用紧凑的图标行;没有桌面时才展示配置指引。手机依然不管理镜像、共享成员或执行环境。
-
-本轮原生界面使用从兼容镜像中实际截取的 1280×900 XFCE 桌面进行展示验收;交互 API 由隔离测试服务提供。iOS 覆盖聊天右上角入口、独立/共享选择、全屏预览、接管、键盘输入、交还、大字体深色布局。Android 另外验证等比留黑区域不会产生点击,桌面中心正确映射到远端坐标。Web 7 项浏览器流程通过。真实 Runner 的端到端验收记录见上节;本轮未更换屏幕传输协议。
-
-新版手机界面构建号为 iOS 1.3(190)。189 的既有安装记录不代表本轮布局已经覆盖安装,交付时需再次核对设备状态。
-
-
-本轮截图:[聊天右上角入口](screenshots/openmaus-layout/bot-header-computer-entry.png)、[iOS 全屏画面](screenshots/openmaus-layout/computer-readonly.png)、[接管后的操作栏](screenshots/openmaus-layout/computer-immersive-control.png)、[Android 全屏画面](screenshots/openmaus-layout/android-desktop.png)、[镜像状态](screenshots/openmaus-layout/image-overview.png)。

+ 40 - 0
docs/local-vm.md

@@ -0,0 +1,40 @@
+# 本地虚拟机
+
+本实现参照 OpenMausBot 的 `LocalComputerSection.tsx`、`ComputerPanel.tsx`、`LocalVmWorkspace.tsx` 及手机 `ComputerView.swift` / `ComputerScreen.kt`。
+
+## 使用流程
+
+1. 进入应用设置 → **本地虚拟机**。macOS App 的应用菜单也提供“本地虚拟机设置…”。
+2. 检查 Docker/Podman 已安装并启动,点击 **准备本地虚拟机**。本机执行环境自动连接,不需要手工填写镜像 ID、下载 Runner 配置再导入。准备会复用兼容镜像或构建固定版本,并验证实际桌面后自动使用。
+3. 选择 **共享虚拟机** 或 **每个 Agent 独立**,最多同时运行 1–4 台,默认 2 台。同一账号、同一基础 Profile 的持久 Agent 共用桌面、工作文件和浏览器资料,新建成员会继承当前共享策略。临时任务助手继续使用任务隔离环境,完成后单独清理,不会删除持久共享工作区。
+4. 打开 Agent 聊天,点击右上角 **电脑**。右侧电脑面板中选择 **本地虚拟机**,从这里创建或启动该 Agent 的桌面。
+5. 右侧显示真实画面及运行状态,可停止或重建虚拟机;点击预览或 **打开桌面** 后接管交互。
+6. **打开双桌面** 在主工作区显示两台虚拟机,保留聊天侧栏。进入时仅查看,不会创建/启动机器;切换操作对象时先交还前一台的控制权。
+
+虚拟机实例移除或重建均保留工作目录和浏览器资料。正常完成任务和主动交还后桌面进入空闲状态;空闲 5 分钟后自动停止。取消、授权撤销、异常退出仍停止旧执行实例,以隔离迟到操作。基础 Profile 工作目录变化会在空闲时重建运行实例,数据保留。
+
+## 手机端
+
+iOS/Android 没有全局虚拟桌面清单、镜像设置或执行环境选择。入口在 **当前 Agent 聊天右上角的电脑图标**。单个 Agent 直接打开画面,群聊有多台电脑时选择当前聊天成员的电脑,同一共享环境只显示一次。
+
+黑底全屏、按原比例显示画面,顶部是 Agent 名称、共享标识和状态,底部是接管/交还操作。键盘、文字、滚动及交还说明单独展开。未启用电脑的 Agent 显示桌面端配置提示,不由手机自动创建环境。
+
+## 范围与接口
+
+- 已删除此前的“电脑与环境”聊天列表入口、独立镜像目录、应用/回退/导入/导出界面及相应管理 HTTP API。
+- 旧工作区、旧共享数据、已下载归档和 Docker 镜像不会因删除界面而被删除。无人使用的保留实例可在本地虚拟机设置中停止或移除,工作文件继续保留。
+- 托管的本机 Runner 凭据只在服务端加密保存,重启 Web 后自动连接;高级外部 Runner 仍可沿用现有连接配置。
+- 本地虚拟机运行在当前连接的服务所在电脑,不跨机器合并容量。
+- `GET /api/app/admin/local-vm` 提供准备状态、隔离策略和实例清单;`POST /prepare` 准备托管镜像,`PUT /policy` 更新策略。
+- `/api/app/agents/:id/local-vm` 提供该 Agent 的配置和状态,`POST /:action` 提供 create/start/stop/recreate/remove。
+- 屏幕与输入沿用受控的 `/api/app/agents/:id/computer` 通道。输入绑定账号、Agent、当前控制令牌、租约版本和截图;旧租约不能继续操作。
+- 应用设置只向管理员开放。共享不会跨账号或 Profile;实例操作会拒绝正在由 Agent 或其他人控制的虚拟机。
+
+## 验收
+
+测试覆盖准备与权限、持久化、实例生命周期、空闲配额、控制权失效、共享范围、聊天右侧面板、双桌面移交及手机聊天入口。另有真实 Docker + Hermes Worker 和从设置自动准备到聊天内操作真实桌面的测试,界面截图来自隔离验收环境。
+
+
+本轮验收结果:787 项单元/集成测试通过;7 项环境限定测试跳过。7 项浏览器流程、iOS 3 项界面流程、Android 2 项界面流程通过。真实 Hermes Worker + Docker 流程 2 项通过;自动准备本机 Runner、真实桌面键盘输入及快捷键、重启保留文件、双桌面控制切换的完整界面流程通过。
+
+截图:[本地虚拟机设置](screenshots/local-vm/settings.png)、[Agent 聊天右侧电脑面板](screenshots/local-vm/agent-panel.png)、[交互桌面](screenshots/local-vm/interactive-desktop.png)、[双桌面工作区](screenshots/local-vm/two-desktops.png)、[手机聊天入口](screenshots/local-vm/phone-chat-entry.png)。

BIN
docs/screenshots/local-vm/agent-panel.png


BIN
docs/screenshots/local-vm/interactive-desktop.png


BIN
docs/screenshots/local-vm/phone-chat-entry.png


BIN
docs/screenshots/local-vm/phone-desktop.png


BIN
docs/screenshots/local-vm/settings.png


BIN
docs/screenshots/local-vm/two-desktops.png


BIN
docs/screenshots/openmaus-layout/android-desktop.png


BIN
docs/screenshots/openmaus-layout/bot-header-computer-entry.png


BIN
docs/screenshots/openmaus-layout/computer-immersive-control.png


BIN
docs/screenshots/openmaus-layout/computer-readonly.png


BIN
docs/screenshots/openmaus-layout/image-overview.png


BIN
docs/screenshots/openmaus-layout/mobile-immersive-large-dark.png


BIN
docs/screenshots/openmaus-layout/mobile-shared-screen.png


+ 1 - 1
docs/shared-computers.md

@@ -33,4 +33,4 @@ python3 tests/fixtures/native-computer.py --port 18841 --image public/icons/icon
 ```
 
 
-2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 1 - 1
scripts/build-desktop.mjs

@@ -31,7 +31,7 @@ await cp(resolve(root,'build-info.json'),resolve(out,'build-info.json'))
 const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
 const service = resolve(out, 'web-service')
 await mkdir(service)
-for (const file of ['server.mjs', 'ui', 'release.json', 'build-info.json']) await cp(resolve(out, file), resolve(service, file), { recursive: true })
+for (const file of ['server.mjs', 'ui', 'release.json', 'build-info.json', 'hermes_worker.py', 'guest_proxy.py', 'computer-image', 'third-party']) await cp(resolve(out, file), resolve(service, file), { recursive: true })
 await cp(resolve(root, 'bin'), resolve(service, 'bin'), { recursive: true })
 await cp(await realpath(process.execPath), resolve(service, 'node'))
 await cp(resolve(await realpath(process.execPath), '../../LICENSE'), resolve(service, 'NODE-LICENSE.txt'))

+ 65 - 0
src/client/components/app/LocalVmSettingsPanel.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+import {ref,onMounted,onBeforeUnmount} from 'vue'
+import {apiRequest} from '@/api/client'
+import {createUuid} from '@/utils/id'
+import AppIcon from '@/components/common/AppIcon.vue'
+import type {LocalVmStatus,LocalVmMode,LocalVmAction,LocalVmInstance} from '@shared/localVm'
+import type {WorkspaceAgent} from '@shared/workspace'
+const state=ref<LocalVmStatus>(),error=ref(''),loading=ref(false),acting=ref(false)
+const instances=ref<Array<{agent:WorkspaceAgent;vm:LocalVmInstance}>>([])
+let closed=false,timer:ReturnType<typeof setTimeout>|undefined
+async function refresh(){
+ if(loading.value||acting.value||closed)return
+ loading.value=true
+ try{
+  const next=await apiRequest<LocalVmStatus>('/api/app/admin/local-vm');if(closed)return;state.value=next
+  const list=await apiRequest<{computers:Array<{agent:WorkspaceAgent}>}>('/api/app/computers')
+  const seen=new Set<string>()
+  const agents=list.computers.map(x=>x.agent).filter(a=>a.execution==='computer'&&!a.remoteAgentId&&a.nodeId==='local'&&!seen.has(a.computerEnvironmentId??a.id)&&!!seen.add(a.computerEnvironmentId??a.id))
+  const rows=await Promise.all(agents.map(async agent=>({agent,vm:await apiRequest<LocalVmInstance>(`/api/app/agents/${agent.id}/local-vm`)})))
+  if(!closed)instances.value=rows
+ }catch(e){if(!closed)error.value=e instanceof Error?e.message:'无法读取本地虚拟机状态'}finally{loading.value=false}
+}
+async function action(work:()=>Promise<unknown>){acting.value=true;error.value='';try{await work()}catch(e){error.value=e instanceof Error?e.message:'操作未完成'}finally{acting.value=false;await refresh()}}
+function prepare(){void action(()=>apiRequest('/api/app/admin/local-vm/prepare',{method:'POST',body:{requestId:createUuid()}}))}
+function policy(mode:LocalVmMode,maxInstances=state.value?.maxInstances??2){
+ if(mode==='shared'&&state.value?.mode!=='shared'&&!confirm('同一账号、同一基础 Agent 的机器人将共用桌面、工作文件和浏览器登录。确认使用共享虚拟机?'))return
+ void action(()=>apiRequest('/api/app/admin/local-vm/policy',{method:'PUT',body:{requestId:createUuid(),mode,maxInstances}}))
+}
+function manage(agent:WorkspaceAgent,operation:LocalVmAction){
+ if(operation==='remove'&&!confirm(`移除“${agent.name}”的虚拟机实例?工作文件和浏览器资料将保留。`))return
+ void action(()=>apiRequest(`/api/app/agents/${agent.id}/local-vm/${operation}`,{method:'POST',body:{}}))
+}
+async function cycle(){await refresh();if(!closed)timer=setTimeout(cycle,state.value?.busy?2000:10000)}
+onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
+</script>
+<template>
+ <section class="local-vm-settings" aria-label="本地虚拟机设置">
+  <article><h3>本地虚拟机</h3><p>在这台电脑的隔离 Linux 桌面中运行 Agent,工作文件与浏览器资料持续保存。</p>
+   <div class="row"><span class="status"><AppIcon :name="state?.image?'check':'monitor'"/>{{loading&&!state?'正在检查…':state?.image?'虚拟机环境已就绪':'尚未就绪'}}</span><button :disabled="loading||acting" @click="error='';refresh()"><AppIcon name="refresh"/>重新检查</button></div>
+   <p v-if="state?.problem">{{state.problem}}</p><p v-if="error" role="alert">{{error}}</p>
+   <p v-if="state?.job" role="status">{{state.job.message}}</p>
+  </article>
+  <article><h3>隔离方式</h3><p>按 Agent 创建独立桌面,或让可信的 Agent 共用一个桌面。</p>
+   <div class="segmented" role="group" aria-label="虚拟机隔离方式"><button :aria-pressed="state?.mode==='shared'" :disabled="!state?.configured||acting||state.busy" @click="policy('shared')">共享虚拟机</button><button :aria-pressed="state?.mode==='per-bot'" :disabled="!state?.configured||acting||state.busy" @click="policy('per-bot')">每个 Agent 独立</button></div>
+   <label class="row">最多同时运行的虚拟机<select aria-label="虚拟机数量上限" :value="state?.maxInstances??2" :disabled="!state?.configured||acting||state.busy" @change="policy(state!.mode,Number(($event.target as HTMLSelectElement).value))"><option v-for="n in [1,2,3,4]" :key="n" :value="n">{{n}}</option></select></label>
+  </article>
+  <article><h3>准备运行环境</h3><ol>
+   <li><span class="step">{{state?.runtime?'✓':'1'}}</span><div><strong>安装 Docker 或 Podman</strong><p>{{state?.runtime?`已检测到 ${state.runtime}`:'请先安装 Docker Desktop 或 Podman。'}}</p></div></li>
+   <li><span class="step">{{state?.daemonUp?'✓':'2'}}</span><div><strong>启动容器运行环境</strong><p>{{state?.daemonUp?'运行环境已启动':'打开 Docker / Podman,等待它完成启动后重新检查。'}}</p></div></li>
+   <li><span class="step">{{state?.image?'✓':'3'}}</span><div><strong>准备本地虚拟机</strong><p>下载并检查托管桌面环境。后续创建虚拟机时会自动使用。</p><button class="primary" :disabled="acting||state?.busy||!state?.daemonUp" @click="prepare">{{state?.busy?'正在准备…':state?.image?'重新检查虚拟机镜像':'准备本地虚拟机'}}</button></div></li>
+   <li><span class="step">4</span><div><strong>从 Agent 聊天中创建桌面</strong><p>打开聊天右上角的“电脑”,选择“本地虚拟机”,然后创建该 Agent 的虚拟机。</p></div></li>
+  </ol></article>
+  <article><h3>{{state?.mode==='shared'?'共享虚拟机':'Agent 虚拟机'}}</h3><p v-if="!instances.length">还没有 Agent 使用本地虚拟机。</p>
+   <div v-for="row in instances" :key="row.agent.id" class="instance"><div><strong>{{row.agent.computerEnvironmentName??row.agent.name}}</strong><p>{{row.vm.inUse?'正在使用':({running:'运行中',stopped:'已停止',missing:'尚未创建'})[row.vm.container]}}</p></div><div class="row">
+    <button v-if="row.vm.container==='running'" :disabled="acting||row.vm.inUse" @click="manage(row.agent,'stop')">停止</button>
+    <button v-else :disabled="acting||!state?.image" @click="manage(row.agent,row.vm.container==='missing'?'create':'start')">{{row.vm.container==='missing'?'创建':'启动'}}</button>
+    <button v-if="row.vm.container!=='missing'" :disabled="acting||row.vm.inUse" @click="manage(row.agent,'remove')">移除实例</button>
+   </div></div>
+   <div v-for="item in state?.instances?.filter(i=>i.orphaned)" :key="item.id" class="instance"><div><strong>{{item.name}}</strong><p>暂无 Agent 使用 · {{item.status==='free'?'已停止':'运行中或待核对'}}</p></div><div class="row"><button :disabled="acting" @click="action(()=>apiRequest(`/api/app/admin/local-vm/instances/${item.id}/stop`,{method:'POST',body:{requestId:createUuid()}}))">停止</button><button :disabled="acting" @click="action(()=>apiRequest(`/api/app/admin/local-vm/instances/${item.id}/remove`,{method:'POST',body:{requestId:createUuid()}}))">移除实例</button></div></div>
+  </article>
+ </section>
+</template>
+<style scoped>
+.local-vm-settings{display:grid;gap:16px;max-width:820px}article{padding:20px;border:1px solid var(--line);border-radius:14px;display:grid;gap:12px;background:var(--surface)}h3,p{margin:0}h3{font-size:15px}p{font-size:13px;line-height:1.7;color:var(--text-secondary)}.row{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.status{display:inline-flex;align-items:center;gap:7px;font-size:13px}.segmented{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}.segmented button{flex:1;border:0;border-radius:0}.segmented button[aria-pressed=true]{background:var(--surface-soft);font-weight:600}.instance{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:12px;border-top:1px solid var(--line)}button,select{font:inherit;color:var(--text-primary);background:var(--surface-raised);border:1px solid var(--line);border-radius:8px;min-height:40px;padding:7px 12px}button{display:inline-flex;align-items:center;justify-content:center;gap:6px;cursor:pointer}button:disabled{opacity:.45;cursor:default}.primary{background:var(--accent);color:var(--text-on-solid);margin-top:8px}button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}ol{list-style:none;margin:0;padding:0;display:grid;gap:20px}li{display:flex;gap:12px}.step{display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;height:26px;width:26px;flex:none;font-size:12px}li strong{font-size:13px}[role=alert]{color:var(--danger)}
+</style>

+ 2 - 2
src/client/components/app/RunnerSettingsPanel.vue

@@ -78,11 +78,11 @@ onMounted(()=>{void action(refresh)})
         <label>容器运行时<select v-model="computerRuntime"><option value="docker">Docker</option><option value="podman">Podman</option></select></label>
         <label>联网范围<select v-model="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
         <small>公网模式通过受控代理访问,不开放宿主和内网地址。</small>
-        <label>兼容镜像 ID<input v-model.trim="computerImage" placeholder="可稍后在镜像页面准备和应用"></label>
+        <label>兼容镜像 ID<input v-model.trim="computerImage" placeholder="可在本地虚拟机设置中准备"></label>
         <label>Hermes Python 路径<input v-model.trim="computerPython" required placeholder="/完整路径/hermes-agent/venv/bin/python"></label>
         <label>Hermes 源码目录<input v-model.trim="hermesSource" required placeholder="/完整路径/hermes-agent"></label>
         <label>Hermes 配置目录<input v-model.trim="hermesHome" required placeholder="/完整路径/.hermes"></label>
-        <small>在 Agent 设置中选择“隔离电脑”后使用。当前使用 CUA 0.20.0 layer 5 兼容镜像,注册并连接后,可在“电脑与环境 → 镜像”中准备、导入和应用镜像。</small>
+        <small>这是外部执行节点的高级配置。本机虚拟机可直接在应用设置中准备,并从 Agent 聊天的电脑面板启用。</small>
       </fieldset>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
     </form>

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

@@ -10,6 +10,7 @@ import WorkspaceNodesPanel from '@/components/workspace/WorkspaceNodesPanel.vue'
 import WorkspaceVoiceProviders from '@/components/workspace/WorkspaceVoiceProviders.vue'
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
+import LocalVmSettingsPanel from '@/components/app/LocalVmSettingsPanel.vue'
 import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
@@ -28,6 +29,7 @@ type SettingsPage =
   | 'system-push'
   | 'system-nodes'
   | 'system-voice'
+  | 'system-local-vm'
   | 'system-update'
 
 type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh'
@@ -104,6 +106,7 @@ const accountItems = computed<NavigationItem[]>(() => [
   { key: 'appearance', label: '外观', icon: 'sun' },
 ])
 const systemItems: NavigationItem[] = [
+  { key: 'system-local-vm', label: '本地虚拟机', icon: 'panel' },
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
   { key: 'system-users', label: '用户与权限', icon: 'users' },
   { key: 'system-connection', label: 'Hermes 连接', icon: 'link' },
@@ -126,6 +129,7 @@ const activeTitle = computed(() => ({
   'account-security': '登录与安全',
   'account-mobile': '手机登录',
   appearance: '外观',
+  'system-local-vm': '本地虚拟机',
   'system-overview': '系统概览',
   'system-users': '用户与权限',
   'system-connection': 'Hermes 连接',
@@ -356,6 +360,7 @@ function requestModeSwitch() {
                   </div>
                 </section>
                 <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
+                <LocalVmSettingsPanel v-else-if="activePage === 'system-local-vm' && isAdmin" />
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />

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

@@ -30,6 +30,7 @@ type SettingsPage =
   | 'system-push'
   | 'system-nodes'
   | 'system-voice'
+  | 'system-local-vm'
   | 'system-update'
 
 const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
@@ -352,17 +353,21 @@ watch(() => activeNav.value.key, () => {
   profileMenuOpen.value = false
 })
 
+function showLocalVmSettings(){if(props.isAdmin)openSettings('system-local-vm')}
 onMounted(() => {
+  window.addEventListener('yaoyao:local-vm-settings',showLocalVmSettings)
   try { sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1' } catch { /* optional persistence */ }
   document.addEventListener('mousedown', closeMenus)
   document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
   document.addEventListener('keydown', workspaceKeydown)
 })
 onBeforeUnmount(() => {
+  window.removeEventListener('yaoyao:local-vm-settings',showLocalVmSettings)
   document.removeEventListener('mousedown', closeMenus)
   document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
   document.removeEventListener('keydown', workspaceKeydown)
 })
+defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}})
 </script>
 
 <template>

+ 0 - 44
src/client/components/workspace/ComputerEnvironmentHub.vue

@@ -1,44 +0,0 @@
-<script setup lang="ts">
-import {ref,computed,onMounted,onBeforeUnmount} from 'vue'
-import {apiRequest} from '@/api/client'
-import {createUuid} from '@/utils/id'
-import type {WorkspaceAgent} from '@shared/workspace'
-import ComputerPanel from './ComputerPanel.vue'
-import ImageManagementPanel from './ImageManagementPanel.vue'
-import RunnerSettingsPanel from '@/components/app/RunnerSettingsPanel.vue'
-const emit=defineEmits<{close:[];changed:[]}>(),dialog=ref<HTMLDialogElement>(),tab=ref('computers'),loading=ref(false),busy=ref(false),error=ref(''),unsupported=ref(false),canManageImages=ref(false)
-const computers=ref<Array<{agent:WorkspaceAgent;available:boolean;reason:string}>>([]),shared=ref<Array<{id:string;name:string;memberIds:string[]}>>([]),viewing=ref<WorkspaceAgent>(),name=ref('团队共享电脑'),members=ref<string[]>([]),trusted=ref(false)
-let closed=false
-const candidates=computed(()=>computers.value.filter(row=>row.agent.execution==='computer'&&!row.agent.temporaryGoalId&&!row.agent.computerEnvironmentId))
-async function refresh(){loading.value=true;error.value='';try{const data=await apiRequest<{computers:typeof computers.value;canManageImages:boolean}>('/api/app/computers');if(closed)return;computers.value=data.computers;canManageImages.value=data.canManageImages;shared.value=(await apiRequest<{computers:typeof shared.value}>('/api/app/computers/shared')).computers;unsupported.value=false}catch(e){unsupported.value=Number((e as any).status)===404;error.value=unsupported.value?'当前连接的服务尚未提供电脑管理,请切换到配套分支服务或更新服务端。':e instanceof Error?e.message:'无法读取电脑环境'}finally{loading.value=false}}
-async function action(work:()=>Promise<unknown>){if(busy.value)return;busy.value=true;error.value='';try{await work();emit('changed');await refresh()}catch(e){error.value=e instanceof Error?e.message:'操作失败'}finally{busy.value=false}}
-function enable(agent:WorkspaceAgent){void action(()=>apiRequest(`/api/app/agents/${agent.id}`,{method:'PATCH',body:{execution:'computer'}}))}
-function share(){void action(async()=>{await apiRequest('/api/app/computers/shared',{method:'POST',body:{requestId:createUuid(),name:name.value,memberIds:members.value,trusted:trusted.value}});members.value=[];trusted.value=false})}
-onMounted(()=>{dialog.value?.showModal();void refresh()});onBeforeUnmount(()=>{closed=true})
-</script>
-<template>
- <Teleport to="body"><dialog ref="dialog" class="environment-hub" aria-label="电脑与环境" @cancel.prevent="!viewing&&!busy&&emit('close')">
-  <header><div><h2>电脑与环境</h2><p>管理电脑、镜像与共享成员</p></div><button :disabled="busy||!!viewing" @click="emit('close')">关闭</button></header>
-  <nav aria-label="环境管理分类"><button v-for="item in [{id:'computers',name:'电脑'},{id:'shared',name:'共享环境'},{id:'images',name:'镜像'},{id:'nodes',name:'节点配置'}]" :key="item.id" :aria-pressed="tab===item.id" @click="tab=item.id">{{item.name}}</button></nav>
-  <main>
-   <div class="actions"><button :disabled="loading||busy" @click="refresh">刷新状态</button><span v-if="loading" role="status">正在读取…</span></div>
-   <p v-if="error" role="alert">{{error}}</p>
-   <p v-if="unsupported">安装新版手机 App 不会自动更新服务端。请核对当前连接的服务地址;本页面会保留,更新后点击刷新。</p>
-   <template v-else-if="tab==='computers'">
-    <p v-if="!computers.length&&!loading">还没有 Agent。先在 Bot 模式新建 Agent,再在这里启用隔离电脑。</p>
-    <article v-for="row in computers" :key="row.agent.id"><h3>{{row.agent.name}}</h3><p>{{row.agent.computerEnvironmentName?'共享电脑 · '+row.agent.computerEnvironmentName:row.agent.execution==='computer'?'独立电脑':'尚未配置电脑'}}</p><p>{{row.reason}}</p><div class="actions"><button v-if="row.agent.execution==='computer'" :disabled="!row.available||busy" @click="viewing=row.agent">查看与接管</button><button v-else :disabled="busy||!!row.agent.remoteAgentId" @click="enable(row.agent)">启用隔离电脑</button><button v-if="!row.available" @click="tab=canManageImages?'nodes':'images'">查看配置指引</button></div></article>
-   </template>
-   <template v-else-if="tab==='shared'">
-    <p>共享成员可以访问同一工作区与浏览器登录资料;电脑由一名操作者使用,其他任务排队。</p>
-    <p v-if="!shared.length">还没有共享环境。默认每个 Agent 使用独立电脑。</p>
-    <article v-for="item in shared" :key="item.id"><h3>{{item.name}}</h3><p>{{item.memberIds.map(id=>computers.find(row=>row.agent.id===id)?.agent.name||id).join('、')}}</p><button :disabled="busy" @click="action(()=>apiRequest(`/api/app/computers/shared/${item.id}`,{method:'DELETE'}))">解除共享并保留数据</button></article>
-    <fieldset><legend>创建共享环境</legend><label>名称<input v-model="name" maxlength="100"></label><p v-if="candidates.length<2">至少需要两名使用同一来源和 Profile 的持久隔离 Agent。请先在电脑页完成配置。</p><label v-for="row in candidates" :key="row.agent.id" class="check"><input v-model="members" type="checkbox" :value="row.agent.id">{{row.agent.name}} · {{row.agent.profile}}</label><label class="check"><input v-model="trusted" type="checkbox">我确认这些成员可以共享工作文件和浏览器登录资料</label><button :disabled="busy||members.length<2||members.length>8||!name.trim()||!trusted" @click="share">创建共享环境</button></fieldset>
-   </template>
-   <template v-else-if="tab==='images'"><ImageManagementPanel v-if="canManageImages"/><p v-else>镜像管理需要服务器管理员权限。你仍可在电脑页查看和接管自己的电脑,在共享环境页管理自己的成员。</p></template>
-   <template v-else><RunnerSettingsPanel v-if="canManageImages"/><p v-else>请让管理员注册执行节点、启用隔离电脑并分配 Profile,然后刷新此页面。</p></template>
-  </main>
- </dialog><ComputerPanel v-if="viewing" :agents="[viewing]" @close="viewing=undefined" @changed="refresh"/></Teleport>
-</template>
-<style scoped>
-.environment-hub{width:min(900px,calc(100vw - 24px));max-height:calc(100dvh - 24px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary)}.environment-hub::backdrop{background:#0007}header{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:18px;border-bottom:1px solid var(--line)}h2,h3,p{margin:0}h2{font-size:18px}h3{font-size:15px}p{font-size:13px;line-height:1.7;color:var(--text-secondary)}nav,.actions{display:flex;gap:8px;flex-wrap:wrap}nav{padding:12px 18px;border-bottom:1px solid var(--line)}main{display:grid;gap:16px;padding:18px;min-width:0}article,fieldset{display:grid;gap:10px;padding:16px;border:1px solid var(--line);border-radius:12px;min-width:0}label{display:grid;gap:8px}.check{display:flex;align-items:center;min-height:44px}.check input{width:18px;height:18px}button,input{font:inherit;color:inherit}button,input:not([type=checkbox]){min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised)}button{cursor:pointer}button[aria-pressed=true]{border-color:var(--accent);background:var(--surface-soft)}button:disabled{opacity:.5}button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[role=alert]{color:var(--danger)}@media(max-width:600px){.environment-hub{width:100vw;max-height:100dvh;border-radius:0}main,header,nav{padding:12px}}
-</style>

+ 61 - 46
src/client/components/workspace/ComputerPanel.vue

@@ -5,18 +5,18 @@ import {createUuid} from '@/utils/id'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
 import type {JsonValue} from '@shared/types'
-const props=defineProps<{agents:WorkspaceAgent[]}>(),emit=defineEmits<{close:[];changed:[]}>()
+const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;autoTake?:boolean}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[]}>()
 const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
 const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
-const managing=ref(false),shareName=ref('团队共享电脑'),shareMembers=ref<string[]>([]),trusted=ref(false)
+const controlsOpen=ref(false)
 const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
-const candidates=computed(()=>props.agents.filter(agent=>!agent.temporaryGoalId&&!agent.computerEnvironmentId&&!agent.archived))
-const error=ref(''),busy=ref(false),text=ref(''),notes=ref('')
+const error=ref(''),busy=ref(false),text=ref(''),notes=ref(''),connectionOK=ref(false)
 let takeRequestId:string|undefined
 let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
-const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',agent:'Agent 正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还 Agent',error:'电脑需要检查'}
+const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',idle:'虚拟机空闲 · 仅查看',agent:'Agent 正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还 Agent',error:'电脑需要检查'}
 const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
-const controllable=computed(()=>mine.value&&state.value.mode==='human'&&frame.value?.generation===state.value.generation&&!busy.value)
+const canSend=computed(()=>connectionOK.value&&!!frame.value&&mine.value&&state.value.mode==='human'&&frame.value?.generation===state.value.generation)
+const controllable=computed(()=>canSend.value&&!busy.value)
 const endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/computer`
 async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path,{method:'POST',body:body as JsonValue})}
 async function refresh(){
@@ -26,90 +26,105 @@ async function refresh(){
     const status=await apiRequest<ComputerControlStatus>(base)
     if(current!==generation||closed)return
     state.value=status
+    connectionOK.value=true
     if(ticket.value&&status.controlId!==ticket.value.controlId&&status.mode!=='pausing')ticket.value=undefined
     if(mine.value&&Date.now()-lastRenew>8000){await post('/renew',ticket.value);lastRenew=Date.now()}
     if(status.mode!=='off'){
       const image=await apiRequest<ComputerFrame>(base+'/frame')
       if(current===generation&&!closed)frame.value=image
     }
-  }catch(cause){if(current===generation&&!closed){error.value=cause instanceof Error?cause.message:'无法读取电脑状态';if([401,403,410].includes(Number((cause as {status?:number}).status))){ticket.value=undefined;frame.value=undefined;state.value={mode:'off'}}}}
+  }catch(cause){if(current===generation&&!closed){connectionOK.value=false;error.value=cause instanceof Error?cause.message:'无法读取电脑状态';if([401,403,410].includes(Number((cause as {status?:number}).status))){ticket.value=undefined;frame.value=undefined;state.value={mode:'off'}}}}
   finally{polling=false}
 }
 async function run(action:()=>Promise<void>){
   if(busy.value)return;generation++;busy.value=true;error.value=''
-  try{await action()}catch(cause){error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;await refresh()}
+  try{await action()}catch(cause){error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;await refresh();void flushInputs()}
 }
-function take(){void run(async()=>{takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
+function take(){return run(async()=>{takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
+const inputs:ComputerInput[]=[];let flushing=false,pendingClick:ComputerInput|undefined,clickTimer:ReturnType<typeof setTimeout>|undefined
 function send(action:ComputerInput){
-  if(!controllable.value||!frame.value||!ticket.value)return
-  const image=frame.value,credentials=ticket.value
-  void run(async()=>{await post('/input',{...credentials,requestId:createUuid(),generation:image.generation,frameId:image.id,action})})
+  if(!canSend.value||closed)return
+  if(pendingClick){inputs.push(pendingClick);pendingClick=undefined;clearTimeout(clickTimer)}
+  const last=inputs.at(-1)
+  if(action.kind==='text'&&last?.kind==='text'&&last.text.length+action.text.length<=16000)last.text+=action.text
+  else if(inputs.length<64)inputs.push(action)
+  else {error.value='输入过快,请稍后再试';return}
+  void flushInputs()
 }
+async function flushInputs(){
+  if(flushing||busy.value||!inputs.length||closed)return
+  if(!canSend.value||!frame.value||!ticket.value){inputs.length=0;return}
+  flushing=true
+  const action=inputs.shift()!,image=frame.value,credentials=ticket.value
+  try{await run(async()=>{await post('/input',{...credentials,requestId:createUuid(),generation:image.generation,frameId:image.id,action})});if(error.value)inputs.length=0}
+  finally{flushing=false;if(inputs.length)setTimeout(()=>void flushInputs(),0)}
+}
+function point(event:MouseEvent|PointerEvent){const r=(event.currentTarget as HTMLImageElement).getBoundingClientRect(),f=frame.value!;return {x:Math.min(f.width-1,Math.max(0,Math.floor((event.clientX-r.left)*f.width/r.width))),y:Math.min(f.height-1,Math.max(0,Math.floor((event.clientY-r.top)*f.height/r.height)))}}
+let dragStart:{x:number;y:number;clientX:number;clientY:number}|undefined,dragged=false
+function down(event:PointerEvent){if(!canSend.value||event.button!==0)return;const p=point(event);dragStart={...p,clientX:event.clientX,clientY:event.clientY};(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)}
+function up(event:PointerEvent){if(!dragStart)return;const start=dragStart;dragStart=undefined;if(!canSend.value)return;if(Math.hypot(event.clientX-start.clientX,event.clientY-start.clientY)>5){const end=point(event);dragged=true;send({kind:'drag',fromX:start.x,fromY:start.y,toX:end.x,toY:end.y})}}
+function wheel(event:WheelEvent){if(!canSend.value)return;event.preventDefault();send({kind:'scroll',direction:Math.abs(event.deltaX)>Math.abs(event.deltaY)?event.deltaX>0?'right':'left':event.deltaY>0?'down':'up',amount:Math.min(20,Math.max(1,Math.ceil(Math.abs(event.deltaY||event.deltaX)/80)))})}
+function keydown(event:KeyboardEvent){if(!canSend.value)return;event.preventDefault();event.stopPropagation();if(event.key==='Escape'){(event.currentTarget as HTMLElement).blur();return}const modifiers=[event.ctrlKey?'ctrl':'',event.altKey?'alt':'',event.shiftKey?'shift':'',event.metaKey?'super':''].filter(Boolean);if(event.key.length===1&&!event.ctrlKey&&!event.altKey&&!event.metaKey)send({kind:'text',text:event.key});else if(!['Control','Alt','Shift','Meta'].includes(event.key))send({kind:'key',key:({Enter:'Return',ArrowUp:'Up',ArrowDown:'Down',ArrowLeft:'Left',ArrowRight:'Right',Backspace:'BackSpace'} as Record<string,string>)[event.key]??event.key,modifiers})}
 function click(event:MouseEvent){
-  if(!controllable.value||!frame.value)return
+  if(dragged){dragged=false;return}
+  if(!canSend.value||!frame.value)return
   const rect=(event.currentTarget as HTMLImageElement).getBoundingClientRect()
-  send({kind:'click',x:Math.min(frame.value.width-1,Math.max(0,Math.floor((event.clientX-rect.left)*frame.value.width/rect.width))),y:Math.min(frame.value.height-1,Math.max(0,Math.floor((event.clientY-rect.top)*frame.value.height/rect.height))),button:event.button===2?'right':'left',count:event.detail>1?2:1})
+  const action:ComputerInput={kind:'click',x:Math.min(frame.value.width-1,Math.max(0,Math.floor((event.clientX-rect.left)*frame.value.width/rect.width))),y:Math.min(frame.value.height-1,Math.max(0,Math.floor((event.clientY-rect.top)*frame.value.height/rect.height))),button:event.button===2?'right':'left',count:event.detail>1?2:1}
+  if(event.button===2||event.detail>1){clearTimeout(clickTimer);pendingClick=undefined;send(action)}
+  else {if(pendingClick){const old=pendingClick;pendingClick=undefined;clearTimeout(clickTimer);send(old)}pendingClick=action;clickTimer=setTimeout(()=>{const next=pendingClick;pendingClick=undefined;if(next)send(next)},180)}
 }
-function manage(){managing.value=!managing.value;shareMembers.value=candidates.value.map(agent=>agent.id);trusted.value=false}
-function share(){void run(async()=>{await apiRequest('/api/app/computers/shared',{method:'POST',body:{requestId:createUuid(),name:shareName.value,memberIds:shareMembers.value,trusted:trusted.value}});managing.value=false;frame.value=undefined;state.value={mode:'off'};takeRequestId=undefined;emit('changed')})}
-function unshare(){const id=selectedAgent.value?.computerEnvironmentId;if(!id)return;void run(async()=>{await apiRequest(`/api/app/computers/shared/${encodeURIComponent(id)}`,{method:'DELETE'});managing.value=false;frame.value=undefined;state.value={mode:'off'};takeRequestId=undefined;emit('changed')})}
 async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
-async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;dialog.value?.close();emit('close')}}
+async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;if(!props.embedded)dialog.value?.close();emit('close')}}
 async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
-watch(selected,()=>{generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value='';void refresh()})
-onMounted(()=>{dialog.value?.showModal();void cycle()})
-onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
+watch(selected,()=>{inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value='';void refresh()})
+onMounted(()=>{if(!props.embedded)dialog.value?.showModal();void cycle();if(props.autoTake)void take()})
+onBeforeUnmount(()=>{inputs.length=0;pendingClick=undefined;clearTimeout(clickTimer);closed=true;generation++;clearTimeout(timer)})
+defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)throw new Error(error.value||'请先交还控制权')},hasControl:()=>!!ticket.value})
 </script>
 <template>
-  <Teleport to="body">
-    <dialog ref="dialog" class="computer-panel" aria-label="隔离电脑" @cancel.prevent="close">
+  <Teleport to="body" :disabled="embedded">
+    <component :is="embedded?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded}" aria-label="隔离电脑" @cancel.prevent="close">
       <header>
         <div><strong>隔离电脑</strong><small>{{ labels[state.mode] }}</small></div>
         <select v-if="agents.length>1" v-model="selected" aria-label="选择电脑" :disabled="!!ticket"><option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select>
-        <span v-else>{{ agents[0]?.name }}</span>
-        <button type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
+        <span v-else-if="!embedded">{{ agents[0]?.name }}</span>
+        <button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>
+        <button v-if="embedded&&mine" @click="controlsOpen=!controlsOpen">键盘与操作</button>
+        <button v-if="embedded&&mine" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">交还</button>
+        <button v-if="!embedded" type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
       </header>
-      <div class="computer-screen" :class="{controlling:controllable}">
-        <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" alt="隔离电脑当前画面" draggable="false" @click="click" @contextmenu.prevent="click">
+      <div class="computer-screen" :class="{controlling:canSend}">
+        <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" alt="隔离电脑当前画面" draggable="false" tabindex="0" title="点按后可使用键盘、滚轮和拖动;按 Esc 退出键盘控制" @pointerdown="down" @pointerup="up" @pointercancel="dragStart=undefined" @wheel="wheel" @keydown="keydown" @click="click" @contextmenu.prevent="click">
         <p v-else>{{ state.mode==='off' ? '打开电脑后可查看桌面。' : '正在读取电脑画面…' }}</p>
-        <span v-if="frame&&!controllable" class="view-label">{{ state.mode==='off'?'上次画面':'仅查看' }}</span>
+        <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
       </div>
       <p v-if="error" class="computer-error" role="alert">{{ error }}</p>
-      <footer>
+      <footer v-if="!embedded||controlsOpen">
         <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
-        <button v-if="!ticket&&(candidates.length>1||selectedAgent?.computerEnvironmentId)" type="button" :disabled="busy" @click="manage">{{ managing?'返回电脑':'电脑共享设置' }}</button>
-        <section v-if="managing" class="sharing-settings" aria-label="电脑共享设置">
-          <template v-if="selectedAgent?.computerEnvironmentId">
-            <p>解除共享后,成员回到各自原有的独立电脑;共享工作区保留,可由执行节点备份或恢复。</p>
-            <button type="button" :disabled="busy" @click="unshare">解除这台电脑的共享</button>
-          </template>
-          <template v-else>
-            <label>共享电脑名称<input v-model="shareName" maxlength="100"></label>
-            <fieldset><legend>选择可信成员(同一来源和 Profile)</legend><label v-for="agent in candidates" :key="agent.id" class="share-option"><input v-model="shareMembers" type="checkbox" :value="agent.id">{{ agent.name }} · {{ agent.profile }}</label></fieldset>
-            <p>新建共享工作区,原独立电脑的文件仍然保留。请先停止所选成员的任务并交还电脑控制权。</p>
-            <label class="share-option"><input v-model="trusted" type="checkbox">我确认这些成员可以访问同一工作区和浏览器登录资料</label>
-            <button type="button" :disabled="busy||!trusted||shareMembers.length<2||shareMembers.length>8||!shareName.trim()" @click="share">创建可信共享电脑</button>
-          </template>
-        </section>
         <p v-if="!ticket">查看不会输入任何内容。接管后 Agent 会暂停,已有操作结束后才允许人工操作。</p>
-        <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="take">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
+        <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
         <template v-if="mine">
+          <button type="button" @click="controlsOpen=!controlsOpen">{{controlsOpen?'收起键盘':'键盘与操作'}}</button>
+          <section v-if="controlsOpen" class="manual-input">
           <div class="key-row"><button v-for="key in ['Return','Tab','Escape','BackSpace','Up','Down','Left','Right']" :key="key" type="button" :disabled="!controllable" @click="send({kind:'key',key})">{{ ({Return:'回车',Escape:'Esc',BackSpace:'退格',Up:'↑',Down:'↓',Left:'←',Right:'→'} as Record<string,string>)[key]||key }}</button></div>
+          <div class="key-row"><button :disabled="!controllable" @click="send({kind:'key',key:'a',modifiers:['ctrl']})">全选</button><button :disabled="!controllable" @click="send({kind:'key',key:'c',modifiers:['ctrl']})">复制</button><button :disabled="!controllable" @click="send({kind:'key',key:'v',modifiers:['ctrl']})">粘贴</button></div>
           <label>输入到电脑<textarea v-model="text" rows="2" maxlength="16000" placeholder="输入文字后点击发送" :disabled="!controllable" /></label>
           <button type="button" :disabled="!controllable||!text" @click="send({kind:'text',text});text=''">输入文字</button>
+          </section>
           <label>交还说明<textarea v-model="notes" rows="2" maxlength="4000" placeholder="告诉 Agent 你完成了什么,以及接下来需要做什么" /></label>
           <button type="button" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">{{ state.canResume ? '交还 Agent 并继续' : '结束控制' }}</button>
           <small>控制只属于当前页面。页面断开后控制权会到期,任务不会自行恢复。</small>
         </template>
       </footer>
-    </dialog>
+    </component>
   </Teleport>
 </template>
 <style scoped>
+.computer-panel.embedded{position:static;width:100%;max-height:none;height:100%;margin:0;border:0;border-radius:0;overflow:auto}.computer-panel.embedded{display:flex;flex-direction:column}.embedded .computer-screen{flex:1;min-height:150px}.embedded .computer-screen img{max-height:calc(100dvh - 250px);object-fit:contain}.embedded footer{max-height:45vh;overflow:auto}.manual-input{display:grid;gap:10px}
 .computer-panel{width:min(1120px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary);overflow:auto}
 .computer-panel::backdrop{background:rgba(0,0,0,.45)}header{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--line)}header div{display:grid;gap:4px;margin-right:auto}small{font-size:12px;color:var(--text-muted);line-height:1.5}
 button,select,textarea,input{font:inherit;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised);color:var(--text-primary)}button,select{min-height:44px;padding:8px 12px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
-.computer-screen{position:relative;display:flex;align-items:center;justify-content:center;min-height:220px;background:#14181e}.computer-screen img{max-width:100%;max-height:58vh;width:auto;height:auto;user-select:none}.controlling img{cursor:crosshair}.computer-screen p{color:#d8dee7}.view-label{position:absolute;top:10px;left:12px;padding:4px 8px;border-radius:6px;background:rgba(0,0,0,.65);color:white;font-size:12px;pointer-events:none}
+.computer-screen{position:relative;display:flex;align-items:center;justify-content:center;min-height:220px;background:#14181e}.computer-screen img{max-width:100%;max-height:58vh;width:auto;height:auto;user-select:none}.controlling img{cursor:crosshair}.computer-screen img:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.computer-screen p{color:#d8dee7}.view-label{position:absolute;top:10px;left:12px;padding:4px 8px;border-radius:6px;background:rgba(0,0,0,.65);color:white;font-size:12px;pointer-events:none}
 footer{padding:16px 18px;display:grid;gap:12px}footer p{margin:0;font-size:13px;color:var(--text-secondary);line-height:1.6}label{display:grid;gap:6px;font-size:13px}textarea{padding:10px;min-width:0;resize:vertical}.key-row{display:flex;gap:6px;flex-wrap:wrap}.computer-error{color:var(--danger);padding:0 18px;font-size:13px}
 .sharing-settings{display:grid;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px}.sharing-settings input:not([type=checkbox]){min-height:44px;padding:8px 10px}.share-option{display:flex;align-items:center;gap:8px;min-height:44px}.share-option input{width:18px;height:18px;flex:none}fieldset{border:1px solid var(--line);border-radius:8px}
 @media(max-width:600px){.computer-panel{width:100vw;max-height:100dvh;border-radius:0}header{flex-wrap:wrap;padding:12px}footer{padding:12px}.computer-screen img{max-height:48vh}}

+ 0 - 63
src/client/components/workspace/ImageManagementPanel.vue

@@ -1,63 +0,0 @@
-<script setup lang="ts">
-import {ref,computed,onMounted,onBeforeUnmount} from 'vue'
-import AppIcon from '@/components/common/AppIcon.vue'
-import {apiRequest} from '@/api/client'
-import {createUuid} from '@/utils/id'
-import type {ImageInventory,ImageOperation} from '@shared/imageManagement'
-const runners=ref<Array<{id:string;name:string;online:boolean;enabled:boolean}>>([]),selected=ref(''),inventory=ref<ImageInventory>(),error=ref(''),busy=ref(false),loading=ref(false),progress=ref('')
-const archive=ref<File>(),manifest=ref<File>(),uploadId=ref('');let closed=false,timer:ReturnType<typeof setTimeout>,generation=0
-const base=()=>`/api/app/admin/runners/${encodeURIComponent(selected.value)}/images`
-const names:Record<ImageOperation,string>={prepare:'准备兼容镜像',verify:'校验',activate:'应用',rollback:'回退',import:'导入',export:'导出'}
-const activeImage=computed(()=>inventory.value?.images.find(image=>image.imageId===inventory.value?.activeImageId))
-const readiness=computed(()=>error.value?'状态读取失败':inventory.value?.busy?'正在维护镜像':activeImage.value?'桌面镜像已应用':inventory.value?.images.length?'镜像已准备,等待应用':'尚未准备桌面镜像')
-async function refresh(){if(!selected.value||busy.value||closed)return;const current=++generation;loading.value=true;try{const data=await apiRequest<ImageInventory>(base());if(current===generation)inventory.value=data}catch(e){if(current===generation)error.value=e instanceof Error?e.message:'无法读取镜像'}finally{loading.value=false}}
-async function action(operation:ImageOperation,imageId?:string){if(busy.value)return;busy.value=true;error.value='';try{await apiRequest(base()+'/jobs',{method:'POST',body:{requestId:createUuid(),operation,...(imageId?{imageId}:{}),...(operation==='import'?{uploadId:uploadId.value}:{})}})}catch(e){error.value=e instanceof Error?e.message:'镜像操作失败'}finally{busy.value=false;await refresh()}}
-async function sendChunk(endpoint:string,body:{offset:number;data:string}){
- for(let attempt=0;;attempt++){if(closed)throw new Error('上传已取消');try{return await apiRequest<{offset:number}>(endpoint,{method:'POST',body})}catch(error){const status=Number((error as any).status);if(attempt>=2||(status>=400&&status<500))throw error;await new Promise(resolve=>setTimeout(resolve,300*(attempt+1)))}}
-}
-async function upload(){
- if(!archive.value||!manifest.value||busy.value)return;busy.value=true;error.value='';const file=archive.value;const endpoint=base();let success=false
- try{
-  if(file.size>20*1024**3||manifest.value.size>16000)throw new Error('镜像上限 20 GiB;请选择配套的 JSON 清单')
-  const metadata=JSON.parse(await manifest.value.text());uploadId.value=createUuid()
-  await apiRequest(endpoint+'/uploads',{method:'POST',body:{id:uploadId.value,size:file.size,manifest:metadata}})
-  for(let offset=0;offset<file.size;){if(closed)throw new Error('上传已取消');const bytes=new Uint8Array(await file.slice(offset,offset+524288).arrayBuffer());let binary='';for(const byte of bytes)binary+=String.fromCharCode(byte)
-   const next=await sendChunk(endpoint+'/uploads/'+uploadId.value,{offset,data:btoa(binary)});if(next.offset<=offset)throw new Error('上传未能继续');offset=next.offset;progress.value=`正在上传 ${Math.round(offset/file.size*100)}%`
-  }success=true;progress.value='上传完成,正在提交导入'
- }catch(e){error.value=e instanceof Error?e.message:'上传失败';if(uploadId.value)await apiRequest(endpoint+'/uploads/'+uploadId.value,{method:'DELETE'}).catch(()=>{})}
- finally{busy.value=false}
- if(success){await action('import');archive.value=undefined;manifest.value=undefined;progress.value=''}
-}
-function choose(event:Event,kind:'archive'|'manifest'){const file=(event.target as HTMLInputElement).files?.[0];if(kind==='archive')archive.value=file;else manifest.value=file}
-async function poll(){await refresh();if(!closed)timer=setTimeout(poll,2000)}
-onMounted(async()=>{try{runners.value=(await apiRequest<{runners:typeof runners.value}>('/api/app/admin/runners')).runners.filter(r=>r.enabled);selected.value=runners.value[0]?.id??'';void poll()}catch(e){error.value=e instanceof Error?e.message:'无法读取节点'}})
-onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
-</script>
-<template>
- <section class="image-manager" aria-label="镜像管理">
-  <div class="image-intro"><span class="desktop-icon"><AppIcon name="monitor" :size="24"/></span><div><h3>虚拟桌面镜像</h3><p>为 Bot 准备独立或共享的桌面环境,手机端可查看画面与接管操作。</p></div></div>
-  <p v-if="!runners.length&&!error">尚未注册执行节点。请先在“节点配置”中注册并连接 Runner,镜像 ID 可以稍后设置。</p>
-  <label v-else>执行节点<select v-model="selected" :disabled="busy||inventory?.busy" @change="inventory=undefined;error='';refresh()"><option v-for="runner in runners" :key="runner.id" :value="runner.id">{{runner.name}}{{runner.online?'':'(离线)'}}</option></select></label>
-  <p v-if="error" role="alert">{{error}}</p>
-  <template v-if="selected">
-   <section v-if="inventory" class="image-summary" aria-label="桌面镜像状态">
-    <strong>{{readiness}}</strong>
-    <p v-if="activeImage">Linux · {{activeImage.architecture}} · 当前镜像 {{activeImage.imageId.slice(0,19)}}…</p>
-    <p v-else>准备或导入镜像后,点击“应用”供这个节点上的 Bot 使用。</p>
-    <ol class="setup-steps" aria-label="镜像准备流程"><li :class="{done:inventory.images.length}">1 · 准备镜像</li><li :class="{done:activeImage}">2 · 应用到节点</li><li>3 · 打开 Bot 电脑</li></ol>
-   </section>
-   <div class="actions"><button :disabled="busy||inventory?.busy" @click="action('prepare')">准备兼容镜像</button><button :disabled="busy||loading" @click="error='';refresh()">刷新</button><button v-if="inventory?.previousImageId" :disabled="busy||inventory?.busy" @click="action('rollback')">回退到上一镜像</button></div>
-   <p>操作在节点后台继续。应用或回退保留工作区;维护前请停止电脑任务并交还控制权。</p>
-   <p v-if="loading&&!inventory" role="status">正在读取节点镜像…</p>
-   <p v-if="inventory&&!inventory.images.length">节点还没有兼容镜像。可以在线准备,或导入已有镜像和校验清单。</p>
-   <article v-for="image in inventory?.images" :key="image.imageId" class="image-card"><strong>{{image.architecture}} · CUA {{image.driver}}</strong><code>{{image.imageId}}</code><span v-if="image.imageId===inventory?.activeImageId">当前使用</span><div class="actions"><button :disabled="busy||inventory?.busy" @click="action('verify',image.imageId)">校验</button><button :disabled="busy||inventory?.busy||image.imageId===inventory?.activeImageId" @click="action('activate',image.imageId)">应用</button><button :disabled="busy||inventory?.busy" @click="action('export',image.imageId)">导出</button></div></article>
-   <article v-for="item in inventory?.uploads" :key="item.id" class="image-card"><p>未完成上传:{{Math.round(item.offset/item.size*100)}}%</p><button :disabled="busy||inventory?.busy" @click="apiRequest(`${base()}/uploads/${item.id}`,{method:'DELETE'}).then(refresh).catch(e=>error=String(e))">取消并清理上传</button></article>
-   <fieldset><legend>导入离线镜像</legend><label>镜像文件(.tar)<input type="file" accept=".tar" :disabled="busy||inventory?.busy" @change="choose($event,'archive')"></label><label>校验清单(.tar.json)<input type="file" accept=".json" :disabled="busy||inventory?.busy" @change="choose($event,'manifest')"></label><button :disabled="busy||inventory?.busy||!archive||!manifest" @click="upload">上传并导入</button><p v-if="progress" role="status">{{progress}}</p></fieldset>
-   <section v-if="inventory?.jobs.length" aria-label="镜像操作记录"><h3>操作记录</h3><article v-for="job in inventory.jobs" :key="job.id" class="image-card"><strong>{{names[job.operation]}} · {{({running:'进行中',complete:'完成',failed:'失败',uncertain:'待核对'})[job.state]}}</strong><p role="status">{{job.message}}</p><div v-if="job.operation==='export'&&job.state==='complete'&&job.archiveSize" class="actions"><a :href="`${base()}/jobs/${job.id}/archive`" download>下载镜像</a><a :href="`${base()}/jobs/${job.id}/manifest`" download>下载校验清单</a><button @click="apiRequest(`${base()}/jobs/${job.id}/archive`,{method:'DELETE'}).then(refresh).catch(e=>error=String(e))">清理导出文件</button></div></article></section>
-  </template>
- </section>
-</template>
-<style scoped>
-.image-intro{display:flex;gap:14px;align-items:center}.image-intro h3{margin:0 0 6px;font-size:17px}.desktop-icon{display:grid;place-items:center;width:48px;height:48px;flex:none;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft);color:var(--accent)}.image-summary{display:grid;gap:10px;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft)}.setup-steps{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:4px 0 0;padding:0;font-size:12px;color:var(--text-muted)}.setup-steps li{padding:8px 10px;border:1px solid var(--line);border-radius:8px}.setup-steps .done{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 40%,var(--line))}
-.image-manager,.image-card,fieldset,label{display:grid;gap:12px}.image-card,fieldset{padding:16px;border:1px solid var(--line);border-radius:12px;min-width:0}.actions{display:flex;gap:8px;flex-wrap:wrap}p{font-size:13px;line-height:1.6;margin:0;color:var(--text-secondary)}code{overflow-wrap:anywhere;font-size:12px}button,select,input,a{font:inherit;min-height:44px;color:var(--text-primary)}button,select,a{border:1px solid var(--line);border-radius:8px;padding:8px 12px;background:var(--surface-raised)}a{display:inline-flex;align-items:center;text-decoration:none}button:disabled{opacity:.5}button{cursor:pointer}:is(button,select,input,a):focus-visible{outline:2px solid var(--accent);outline-offset:2px}[role=alert]{color:var(--danger)}
-</style>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 68 - 0
src/client/components/workspace/LocalVmChatPanel.vue


+ 32 - 0
src/client/components/workspace/LocalVmWorkspace.vue

@@ -0,0 +1,32 @@
+<script setup lang="ts">
+import {ref,computed} from 'vue'
+import type {WorkspaceAgent} from '@shared/workspace'
+import ComputerPanel from './ComputerPanel.vue'
+const props=defineProps<{agents:WorkspaceAgent[];primary:string}>(),emit=defineEmits<{close:[]}>()
+const slots=ref([props.primary,props.agents.find(a=>a.id!==props.primary)?.id??''])
+const panels:Array<InstanceType<typeof ComputerPanel>|null>=[]
+const busy=ref(false),error=ref(''),controlled=ref<number>()
+const choices=computed(()=>props.agents.filter(a=>!a.computerEnvironmentId))
+async function release(){for(const panel of panels)if(panel?.hasControl())await panel.releaseControl();controlled.value=undefined}
+async function take(index:number){
+ if(busy.value)return;busy.value=true;error.value=''
+ try{await release();await panels[index]?.take();if(panels[index]?.hasControl())controlled.value=index}catch(e){error.value=e instanceof Error?e.message:'切换控制失败'}finally{busy.value=false}
+}
+async function select(index:number,value:string){if(busy.value)return;busy.value=true;error.value='';try{await release();slots.value[index]=value}catch(e){error.value=String(e)}finally{busy.value=false}}
+async function close(){if(busy.value)return false;busy.value=true;try{await release();emit('close');return true}catch(e){error.value=String(e);return false}finally{busy.value=false}}
+defineExpose({close})
+</script>
+<template>
+ <main class="local-vm-workspace" aria-label="双桌面工作区">
+  <header><div><strong>本地虚拟机 · 双桌面</strong><p>同时查看两个 Agent 的桌面,每次只控制其中一台。</p></div><button :disabled="busy" @click="close">交还并关闭</button></header>
+  <p v-if="error" role="alert">{{error}}</p>
+  <div class="panes"><section v-for="(_,index) in slots" :key="index" class="pane" :class="{controlled:controlled===index}">
+   <label>桌面 {{index+1}}<select :value="slots[index]" :disabled="busy" :aria-label="`选择桌面 ${index+1}`" @change="select(index,($event.target as HTMLSelectElement).value)"><option value="">选择 Agent</option><option v-for="agent in choices" :key="agent.id" :value="agent.id" :disabled="slots[1-index]===agent.id">{{agent.name}}</option></select></label>
+   <ComputerPanel v-if="slots[index]&&choices.some(a=>a.id===slots[index])" :key="slots[index]" :ref="el=>panels[index]=el as InstanceType<typeof ComputerPanel>|null" embedded :agents="choices.filter(a=>a.id===slots[index])" @control-request="take(index)"/>
+   <p v-else class="empty">选择另一位 Agent。打开工作区不会创建或启动虚拟机。</p>
+  </section></div>
+ </main>
+</template>
+<style scoped>
+.local-vm-workspace{display:flex;flex-direction:column;width:100%;height:100%;min-height:0;max-height:none;margin:0;padding:0;border:0;background:var(--surface);color:var(--text-primary)}.local-vm-workspace::backdrop{background:#0009}header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid var(--line)}p{font-size:12px;color:var(--text-secondary);line-height:1.6;margin:6px 0 0}.panes{display:grid;grid-template-columns:1fr 1fr;flex:1;min-height:0;gap:12px;padding:12px;background:var(--surface-soft)}.pane{display:flex;min-width:0;min-height:0;flex-direction:column;background:var(--surface);border:1px solid var(--line);border-radius:10px;overflow:hidden}.pane>label{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 16px;font-size:13px;border-bottom:1px solid var(--line)}.controlled{outline:2px solid var(--accent);outline-offset:-2px}button,select{min-height:40px;padding:7px 12px;font:inherit;border:1px solid var(--line);border-radius:8px;color:inherit;background:var(--surface-raised)}button:disabled,select:disabled{opacity:.5}button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.empty{padding:30px}.panes:has(+[role=alert]){height:auto}[role=alert]{color:var(--danger);padding:0 20px}@media(max-width:800px){.panes{grid-template-columns:1fr;height:auto}.local-vm-workspace{overflow:auto}.pane{min-height:70vh}}
+</style>

+ 21 - 19
src/client/views/ConversationsView.vue

@@ -2,11 +2,12 @@
 import { publishServerIdentity } from '@/api/serverIdentity'
 import type { ServerIdentity } from '@shared/serverIdentity'
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
-import { useRoute, useRouter } from 'vue-router'
+import { useRoute, useRouter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
-import ComputerEnvironmentHub from '@/components/workspace/ComputerEnvironmentHub.vue'
+import LocalVmChatPanel from '@/components/workspace/LocalVmChatPanel.vue'
+import LocalVmWorkspace from '@/components/workspace/LocalVmWorkspace.vue'
 import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
 import TaskPlan from '@/components/workspace/TaskPlan.vue'
 import type { AgentAssignment } from '@shared/agentTasks'
@@ -186,8 +187,14 @@ let cursor = 0,
   pendingFingerprint = ''
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
 const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
-const environmentOpen=ref(false)
-const computerOpen=ref(false)
+const shell=ref<InstanceType<typeof WorkspaceShell>>(),vmWorkspace=ref<InstanceType<typeof LocalVmWorkspace>>(),desktopViewer=ref<InstanceType<typeof ComputerPanel>>()
+const computerOpen=ref(false),computerDockOpen=ref(false),twoDesktops=ref(false),desktopAgent=ref<Agent>()
+async function leaveComputer(){
+  if(twoDesktops.value&&vmWorkspace.value&&!await vmWorkspace.value.close())return false
+  if(computerOpen.value&&desktopViewer.value){try{await desktopViewer.value.releaseControl();computerOpen.value=false}catch{return false}}
+  return true
+}
+onBeforeRouteUpdate(leaveComputer);onBeforeRouteLeave(leaveComputer)
 const computerAgents=computed(()=>agents.value.filter(agent=>agent.execution==='computer'&&!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===selectedTask.value))))
 const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
@@ -624,7 +631,7 @@ onBeforeUnmount(() => {
 })
 </script>
 <template>
-  <WorkspaceShell :server-name="auth.serverIdentity?.displayName"
+  <WorkspaceShell ref="shell" :server-name="auth.serverIdentity?.displayName"
     :user-name="auth.user?.username"
     :user-avatar="auth.user?.avatar"
     :profiles="auth.profiles"
@@ -649,11 +656,12 @@ onBeforeUnmount(() => {
     @create-remote-agent="remotePickerOpen = true"
   >
     <template #sidebar
-      ><div class="environment-entry"><button type="button" @click="environmentOpen=true">电脑与环境</button></div><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
     <template #mobile-sidebar
-      ><div class="environment-entry"><button type="button" @click="environmentOpen=true">电脑与环境</button></div><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
+    <div v-show="!twoDesktops" class="conversation-with-computer">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
       <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
@@ -671,7 +679,7 @@ onBeforeUnmount(() => {
         <template #header-leading><button v-if="active" class="workspace-list-back icon-button" aria-label="返回 Bot 列表" @click="router.push('/conversations')"><AppIcon name="chevron-left" /></button></template>
         <template #header-actions>
           <div v-if="active" class="header-actions">
-            <button v-if="computerAgents.length" type="button" @click="computerOpen=true">电脑</button>
+            <button type="button" class="icon-button" aria-label="电脑" title="电脑" @click="computerDockOpen=!computerDockOpen"><AppIcon name="monitor"/></button>
             <select v-if="active.kind === 'group' && tasks.length" class="task-picker" aria-label="当前任务" :value="activeTask?.id" @change="router.push({query:{...route.query,taskId:($event.target as HTMLSelectElement).value}})">
               <option v-for="task in tasks" :key="task.id" :value="task.id">{{ task.title }}</option>
             </select>
@@ -690,8 +698,10 @@ onBeforeUnmount(() => {
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
     </section>
-    <ComputerEnvironmentHub v-if="environmentOpen" @close="environmentOpen=false" @changed="refresh()" />
-    <ComputerPanel v-if="computerOpen" :agents="computerAgents" @changed="load()" @close="computerOpen=false" />
+    <LocalVmChatPanel v-if="computerDockOpen" :active="!twoDesktops" :agents="members" :is-admin="auth.user?.role === 'admin'" @close="computerDockOpen=false" @changed="refresh()" @settings="shell?.openLocalVm()" @desktop="desktopAgent=$event;computerOpen=true" @workspace="desktopAgent=$event;twoDesktops=true" />
+    </div>
+    <ComputerPanel ref="desktopViewer" v-if="computerOpen&&desktopAgent" :agents="[desktopAgent]" auto-take @changed="load()" @close="computerOpen=false" />
+    <LocalVmWorkspace ref="vmWorkspace" v-if="twoDesktops&&desktopAgent" :agents="agents.filter(a=>a.execution==='computer'&&!a.archived&&!a.computerEnvironmentId)" :primary="desktopAgent.id" @close="twoDesktops=false" />
     <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @select="select" />
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
@@ -740,14 +750,6 @@ onBeforeUnmount(() => {
             "
           />
         </label>
-        <label v-if="isAgentDialog && !isRemoteAgent">
-          执行环境
-          <select v-model="form.execution" aria-label="执行环境">
-            <option value="profile">基础 Profile 环境</option>
-            <option value="computer">隔离电脑</option>
-          </select>
-          <small v-if="form.execution === 'computer'">文件、终端和桌面操作在独立电脑中执行。需先配置支持电脑的 Runner,并为基础 Profile 设置绝对工作目录。联网范围由执行节点的配置决定。</small>
-        </label>
         <label v-if="isAgentDialog && !isRemoteAgent" class="team-management-permission">
           <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>
           <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础 Agent,新成员默认不获得此权限。发起者需使用已启用工具桥的同机 Hermes,或支持团队工具的 Runner。隔离发起者新建的成员也使用隔离电脑。</small>
@@ -832,7 +834,7 @@ onBeforeUnmount(() => {
   </WorkspaceShell>
 </template>
 <style scoped>
-.environment-entry{padding:8px 12px}.environment-entry button{width:100%;min-height:44px;text-align:left;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.environment-entry button:focus-visible{outline:2px solid var(--accent)}
+.conversation-with-computer{display:flex;height:100%;min-width:0;min-height:0}.conversation-with-computer>.workspace-chat{flex:1;min-width:0}
 .workspace-chat{display:flex;min-width:0;min-height:0;flex:1;flex-direction:column}.new-actions{display:grid;gap:2px}.new-actions button{display:flex;align-items:center;min-height:40px;padding:0 11px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);text-align:left;font-size:12px;font-weight:610;cursor:pointer}.new-actions button:hover{background:var(--surface-hover)}.header-actions{display:flex;gap:4px;margin-left:auto}.error{display:flex;align-items:center;justify-content:space-between;margin:0 18px 9px;color:var(--danger);font-size:12px}.team-avatar-settings{display:grid;gap:12px}.team-avatar-options{display:flex;gap:10px}.team-avatar-options button{padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);cursor:pointer}.team-avatar-options button[aria-pressed=true]{border-color:var(--accent)}.nodes-overlay{position:fixed;inset:0;z-index:1000;background:var(--scrim);display:grid;place-items:center}.nodes-overlay>div{padding:20px;max-height:85vh;overflow:auto;width:min(520px,90vw);background:var(--surface-raised);border:1px solid var(--line);border-radius:16px}
 .editor {
   border: 1px solid var(--line);

+ 36 - 29
src/runner/agent.ts

@@ -2,7 +2,7 @@ import { randomUUID, createHash } from 'node:crypto'
 import { DatabaseSync } from 'node:sqlite'
 import { realpath,access } from 'node:fs/promises'
 import { resolve, sep, join } from 'node:path'
-import {ImageManagement} from './computers/imageManagement.js'
+import {LocalVmImages} from './computers/localVm.js'
 import {ComputerError} from './computers/container.js'
 import {z} from 'zod'
 import {ComputerRuntime,ComputerGateway,type ComputerTarget} from './worker/gateway.js'
@@ -32,7 +32,7 @@ export class RunnerAgent {
   private inflight=new Map<string,Promise<unknown>>()
   private db:DatabaseSync
   private computers?:ComputerRuntime
-  private imageManagement?:ImageManagement
+  private localVm?:LocalVmImages
   private target:GatewayTarget
   private controlTransport=new LoopbackTransport()
   private serverEpoch?:string
@@ -46,7 +46,7 @@ export class RunnerAgent {
     this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
     this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
     this.db.exec('CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT,created INTEGER NOT NULL)')
-    if(config.computers){this.computers=new ComputerRuntime(this.db,config,home);this.imageManagement=new ImageManagement(this.computers,this.db,home)}
+    if(config.computers){this.computers=new ComputerRuntime(this.db,config,home);this.localVm=new LocalVmImages(this.computers,this.db)}
     this.compactReceipts()
   }
   private async api(path:string,body?:unknown):Promise<any> {
@@ -54,7 +54,7 @@ export class RunnerAgent {
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,helper-retirement-v1,computer-control-v1,shared-computer-v1,image-management-v1'+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,helper-retirement-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok)throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
@@ -90,25 +90,42 @@ export class RunnerAgent {
   private async execute(command:RunnerCommand):Promise<any> {
     const p=command.payload
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
-    if(command.kind==='images.manage'){
-      if(!this.imageManagement)throw new HttpError(409,'执行节点尚未启用隔离电脑,请更新节点配置','computer_unavailable')
-      const input=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),op:z.enum(['status','start','upload','append','cancel-upload','download','remove-export']),id:z.string().uuid().optional(),operation:z.enum(['prepare','verify','activate','rollback','import','export']).optional(),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),uploadId:z.string().uuid().optional(),manifest:z.unknown().optional(),size:z.number().optional(),offset:z.number().optional(),data:z.string().max(750000).optional()}).strict().parse(p)
-      const check=async()=>{if(!this.connected||!this.active||(await this.api('image-check',{id:input.id})).allowed!==true)throw new ComputerError('image_authorization_revoked','镜像管理授权已失效')}
-      if(input.op==='status')return this.imageManagement.inventory(input.owner)
-      if(input.op==='start')return this.imageManagement.start(input.owner,input.id!,input.operation!,{...(input.imageId?{imageId:input.imageId}:{}),...(input.uploadId?{uploadId:input.uploadId}:{})},check)
-      if(input.op==='upload')return this.imageManagement.beginUpload(input.owner,input.id!,input.size!,input.manifest)
-      if(input.op==='append')return this.imageManagement.appendUpload(input.owner,input.id!,input.offset!,input.data!)
-      if(input.op==='cancel-upload')return this.imageManagement.cancelUpload(input.owner,input.id!)
-      if(input.op==='remove-export')return this.imageManagement.removeExport(input.owner,input.id!)
-      return this.imageManagement.download(input.owner,input.id!,input.offset!)
+    if(command.kind==='local-vm.manage'){
+      if(!this.localVm)throw new HttpError(409,'本地虚拟机尚未配置','computer_unavailable')
+      const input=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),op:z.enum(['status','prepare','policy','instance']),id:z.string().uuid().optional(),mode:z.enum(['shared','per-bot']).optional(),maxInstances:z.number().int().min(1).max(4).optional(),environmentId:z.string().uuid().optional(),action:z.enum(['stop','remove']).optional()}).strict().parse(p)
+      const check=async()=>{if(!this.connected||!this.active||(await this.api('local-vm-check',{id:input.id})).allowed!==true)throw new ComputerError('local_vm_authorization_revoked','本地虚拟机配置授权已失效')}
+      if(input.op==='status')return this.localVm.status(input.owner)
+      if(input.op==='instance'){
+        await check()
+        const spec=this.computers!.pool.definition(input.owner,input.environmentId!)
+        if(spec)await this.computers!.pool.desktop(spec,input.action!,()=>{if(!this.active||!this.connected)throw new Error('节点已断开')})
+        await check();return {ok:true}
+      }
+      if(input.op==='prepare')return this.localVm.prepare(input.id!,check)
+      await check();return this.localVm.policy(input.mode!,input.maxInstances!)
     }
     if(command.kind==='computer.control'){
       if(!this.computers)throw new HttpError(409,'电脑服务未配置','computer_unavailable')
-      const input=z.object({target:z.object({environmentId:z.string().uuid(),agentId:z.string().uuid(),ownerKey:z.string().regex(/^[a-f0-9]{64}$/)}).strict(),profile:z.string(),op:z.enum(['status','frame','take','input','giveback']),controlId:z.string().uuid().optional(),requestId:z.string().uuid().optional(),generation:z.number().int().optional(),frameId:z.string().uuid().optional(),action:z.unknown().optional(),notes:z.string().max(4000).optional()}).strict().parse(p)
+      const input=z.object({target:z.object({environmentId:z.string().uuid(),agentId:z.string().uuid(),ownerKey:z.string().regex(/^[a-f0-9]{64}$/)}).strict(),profile:z.string(),op:z.enum(['status','detail','lifecycle','frame','take','input','giveback']),controlId:z.string().uuid().optional(),requestId:z.string().uuid().optional(),generation:z.number().int().optional(),frameId:z.string().uuid().optional(),action:z.unknown().optional(),notes:z.string().max(4000).optional()}).strict().parse(p)
       this.requireProfile(input.profile)
       const check=async()=>{if(!this.connected||!input.controlId||(await this.api('control-check',{controlId:input.controlId})).allowed!==true)throw new HttpError(403,'电脑控制授权已失效','computer_control_expired')}
       const authorize=()=>{if(!this.connected||!this.active||Date.now()>command.expiresAt)throw new HttpError(403,'电脑请求已失效','computer_control_expired')}
       await this.computers.ready
+      if(input.op==='detail'){
+        const vm=await this.localVm!.status(),spec=this.computers.pool.definition(input.target.ownerKey,input.target.environmentId)
+        const state=await this.computers.controls.status(input.target)
+        const actual=spec?await this.computers.provider.inspect(spec):undefined
+        let ready=false
+        if(actual?.running)try{await this.computers.provider.health(spec!,authorize);ready=true}catch{}
+        return {...state,container:actual?.running?'running':spec?'stopped':'missing',ready,inUse:['agent','pausing','human','resuming'].includes(state.mode),mode:vm.mode,maxInstances:vm.maxInstances,image:vm.image,controlMode:state.mode}
+      }
+      if(input.op==='lifecycle'){
+        const action=z.enum(['create','start','stop','recreate','remove']).parse(input.action)
+        this.computers.assertTarget(input.target)
+        const resolved=await this.computers.resolve(input.profile);authorize()
+        await this.computers.pool.desktop({id:input.target.environmentId,ownerKey:input.target.ownerKey,imageId:this.computers.config.imageId,cwd:resolved.cwd,network:this.computers.config.network??'none'},action,authorize)
+        return {ok:true}
+      }
       if(input.op==='status')return this.computers.controls.status(input.target)
       if(input.op==='frame')return this.computers.controls.frame(input.target,authorize)
       if(input.op==='take')return this.computers.controls.take(input.target,input.profile,input.controlId!,check)
@@ -229,18 +246,6 @@ export class RunnerAgent {
     const existing=this.inflight.get(command.id)
     const fingerprint=createHash('sha256').update(JSON.stringify(command)).digest('hex')
     const previous=this.db.prepare('SELECT * FROM commands WHERE id=?').get(command.id)
-    // Export chunks are immutable reads. Retain the fingerprint, not a second
-    // copy of a multi-gigabyte archive in the receipt database.
-    if(command.kind==='images.manage'&&command.payload.op==='download'){
-      if(previous&&previous.fingerprint!==fingerprint)throw new HttpError(409,'命令编号已用于其他内容','idempotency_conflict')
-      if(existing)return existing
-      if(!previous)this.db.prepare("INSERT INTO commands VALUES(?,?,'read',NULL,?)").run(command.id,fingerprint,Date.now())
-      const pending=Promise.resolve().then(async()=>{
-        if(command.expiresAt<=Date.now()||!(await this.api('admit',{id:command.id})).allowed||command.expiresAt<=Date.now())throw new HttpError(403,'读取授权已失效','runner_command_not_admitted')
-        return {result:await this.execute(command)}
-      }).catch(error=>({error:{message:error instanceof ComputerError||error instanceof HttpError?error.message:'镜像读取失败',code:error instanceof ComputerError||error instanceof HttpError?error.code:'runner_error'}})).finally(()=>this.inflight.delete(command.id))
-      this.inflight.set(command.id,pending);return pending
-    }
     if(previous){
       if(previous.fingerprint!==fingerprint)throw new HttpError(409,'命令编号已用于其他内容','idempotency_conflict')
       if(existing)return existing
@@ -260,6 +265,7 @@ export class RunnerAgent {
   private status(message:string){if(message!==this.lastStatus){this.lastStatus=message;this.onStatus(message)}}
   async run(signal:AbortSignal):Promise<void> {
     const deliveries=new Map<string,Promise<void>>()
+    const cleanupTimer=setInterval(()=>{void this.computers?.pool.expire().catch(()=>{})},5000);cleanupTimer.unref()
     const abort=()=>{this.active=false;this.controlAbort.abort();void this.disconnect()};signal.addEventListener('abort',abort,{once:true})
     try {
       while(this.active&&!signal.aborted) {
@@ -287,11 +293,12 @@ export class RunnerAgent {
         }
       }
     }finally{
+      clearInterval(cleanupTimer)
       signal.removeEventListener('abort',abort)
       try{
         await this.disconnect().catch(()=>{})
         await Promise.allSettled(deliveries.values());await Promise.allSettled(this.inflight.values())
-        await this.imageManagement?.close();await this.computers?.controls.close();await this.computers?.pool.close()
+        await this.localVm?.close();await this.computers?.controls.close();await this.computers?.pool.close()
       }finally{this.target.client.close();this.controlTransport.close();this.db.close();this.status('执行节点已停止')}
     }
   }

+ 0 - 142
src/runner/computers/imageManagement.ts

@@ -1,142 +0,0 @@
-import {mkdir,open,rm,stat,writeFile} from 'node:fs/promises'
-import {join,dirname,resolve} from 'node:path'
-import {existsSync} from 'node:fs'
-import {createHash} from 'node:crypto'
-import type {DatabaseSync} from 'node:sqlite'
-import {z} from 'zod'
-import {ComputerImages,imageManifest} from './images.js'
-import {ComputerError} from './container.js'
-import type {ComputerRuntime} from '../worker/gateway.js'
-import type {ImageJob,ImageInventory,ImageOperation} from '../../shared/imageManagement.js'
-const chunkSize=512*1024,maxSize=20*1024**3
-const digest=(v:unknown)=>createHash('sha256').update(JSON.stringify(v)).digest('hex')
-interface Upload {id:string;owner:string;size:number;offset:number;manifest:Record<string,unknown>;createdAt:number}
-interface StoredJob extends ImageJob {owner:string;fingerprint:string}
-/** Only administrator RPCs reach this manager; no host paths come from a model or browser. */
-export class ImageManagement {
-  private pending?:Promise<void>
-  private closed=false
-  private blocked?:{ids:string[];release:()=>void}
-  private repair?:Promise<void>
-  private directory:string
-  readonly images:ComputerImages
-  readonly ready:Promise<void>
-  constructor(readonly runtime:ComputerRuntime,readonly db:DatabaseSync,readonly home:string){
-    this.directory=join(home,'image-management');this.images=new ComputerImages(runtime.config.runtime)
-    db.exec('CREATE TABLE IF NOT EXISTS managed_image_jobs(id TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS managed_image_uploads(id TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS managed_image_selection(id INTEGER PRIMARY KEY,value TEXT NOT NULL)')
-    const selected=this.selection();if(selected?.base===runtime.config.imageId)runtime.config.imageId=selected.active;else if(selected)db.prepare('DELETE FROM managed_image_selection').run()
-    const release=runtime.pool.holdMaintenance();this.ready=runtime.ready.then(()=>this.recover()).then(()=>{if(this.blocked)this.blocked.release=release;else release()});void this.ready.catch(()=>{})
-  }
-  private selection():{base:string;active:string;previous?:string}|undefined{const row=this.db.prepare('SELECT value FROM managed_image_selection WHERE id=1').get() as any;return row?JSON.parse(row.value):undefined}
-  private job(id:string):StoredJob|undefined {const row=this.db.prepare('SELECT value FROM managed_image_jobs WHERE id=?').get(id) as any;return row?JSON.parse(row.value):undefined}
-  private jobs():StoredJob[]{return (this.db.prepare('SELECT value FROM managed_image_jobs ORDER BY rowid DESC LIMIT 40').all() as any[]).map(row=>JSON.parse(row.value))}
-  private save(job:StoredJob){job.updatedAt=Date.now();this.db.prepare('INSERT OR REPLACE INTO managed_image_jobs VALUES(?,?)').run(job.id,JSON.stringify(job))}
-  private async recover(){
-    await mkdir(this.directory,{recursive:true,mode:0o700})
-    for(const job of this.jobs())if(job.state==='running'||job.state==='uncertain'){
-      job.state='uncertain';job.message='节点曾重启,原操作不会重复执行。请刷新列表或重新校验镜像。';this.save(job)
-      try{await this.cleanupCheck(job.id)}catch{this.blocked??={ids:[],release:()=>{}};this.blocked.ids.push(job.id);job.message='测试电脑停止尚未确认,刷新状态重试清理';this.save(job)}
-    }
-    for(const row of this.db.prepare('SELECT value FROM managed_image_uploads').all() as any[]){const u:Upload=JSON.parse(row.value);if(Date.now()-u.createdAt>2*3600000){await rm(this.uploadPath(u.id),{force:true});await rm(this.uploadPath(u.id)+'.json',{force:true});this.db.prepare('DELETE FROM managed_image_uploads WHERE id=?').run(u.id)}}
-  }
-  private checkSpec(id:string,imageId=this.runtime.config.imageId){return {id,ownerKey:'image-management',imageId}}
-  private async cleanupCheck(id:string){const spec=this.checkSpec(id);await this.runtime.provider.remove(spec);await this.runtime.provider.deleteWorkspace(spec)}
-  private async retryCleanup(){
-    if(this.repair)return this.repair
-    const blocked=this.blocked;if(!blocked)return
-    this.repair=(async()=>{for(const id of blocked.ids)await this.cleanupCheck(id);if(this.blocked===blocked){this.blocked=undefined;blocked.release();for(const id of blocked.ids){const job=this.job(id);if(job){job.state='failed';job.message='测试电脑已清理,可重新校验镜像';this.save(job)}}}})().finally(()=>{this.repair=undefined})
-    return this.repair
-  }
-  async inventory(owner:string):Promise<ImageInventory>{
-    await this.ready;await this.retryCleanup().catch(()=>{})
-    let output:string
-    try{output=await this.images.run(this.images.runtime,['image','ls','--no-trunc','--quiet','--filter','label=com.openmausbot.cua-driver=0.20.0'])}catch{throw new ComputerError('image_runtime_unavailable','无法读取镜像,请确认执行节点上的 Docker/Podman 服务已启动')}
-    const images=[]
-    for(const id of [...new Set(output.trim().split(/\s+/).filter(Boolean))].slice(0,100))try{images.push(await this.images.inspect(id))}catch{}
-    return {configured:true,activeImageId:this.runtime.config.imageId,previousImageId:this.selection()?.previous,images,jobs:this.jobs().filter(j=>j.owner===owner).map(({owner:_o,fingerprint:_f,...j})=>j),busy:!!this.pending||!!this.blocked,uploads:(this.db.prepare('SELECT value FROM managed_image_uploads').all() as any[]).map(row=>JSON.parse(row.value) as Upload).filter(u=>u.owner===owner).map(u=>({id:u.id,size:u.size,offset:u.offset}))}
-  }
-  private uploadPath(id:string){return join(this.directory,id+'.tar')}
-  async beginUpload(owner:string,id:string,size:number,manifest:unknown){
-    z.string().uuid().parse(id);z.number().int().positive().max(maxSize).parse(size);const value=imageManifest.parse(manifest)
-    if(!value.archiveSha256)throw new ComputerError('image_manifest_invalid','请选择包含 SHA-256 的镜像校验清单')
-    await this.ready
-    const old=this.db.prepare('SELECT value FROM managed_image_uploads WHERE id=?').get(id) as any
-    if(old){const entry:Upload=JSON.parse(old.value);if(entry.owner!==owner||entry.size!==size||digest(entry.manifest)!==digest(value))throw new ComputerError('image_upload_conflict','上传编号已用于其他文件');return {id,offset:entry.offset}}
-    if(Number(this.db.prepare('SELECT count(*) AS n FROM managed_image_uploads').get()!.n)>=2)throw new ComputerError('image_upload_limit','请先完成或取消已有上传')
-    await writeFile(this.uploadPath(id),'',{flag:'wx',mode:0o600})
-    const entry:Upload={id,owner,size,manifest:value,offset:0,createdAt:Date.now()};this.db.prepare('INSERT INTO managed_image_uploads VALUES(?,?)').run(id,JSON.stringify(entry));return {id,offset:0}
-  }
-  private upload(owner:string,id:string):Upload{z.string().uuid().parse(id);const row=this.db.prepare('SELECT value FROM managed_image_uploads WHERE id=?').get(id) as any;const u:Upload|undefined=row?JSON.parse(row.value):undefined;if(!u||u.owner!==owner)throw new ComputerError('image_upload_missing','上传不存在或不属于当前账号');return u}
-  private uploading=new Set<string>()
-  async appendUpload(owner:string,id:string,offset:number,data:string){
-    const u=this.upload(owner,id);if(this.uploading.has(id))throw new ComputerError('image_upload_busy','上一块仍在写入')
-    const bytes=Buffer.from(z.string().max(750000).parse(data),'base64');z.number().int().nonnegative().parse(offset)
-    if(!bytes.length||bytes.length>chunkSize||offset+bytes.length>u.size)throw new ComputerError('image_upload_invalid','镜像数据块无效')
-    this.uploading.add(id)
-    try{
-      const file=await open(this.uploadPath(id),'r+')
-      try{
-        if(offset<u.offset){const old=Buffer.alloc(bytes.length);await file.read(old,0,old.length,offset);if(offset+bytes.length>u.offset||!old.equals(bytes))throw new Error('上传内容冲突');return {offset:u.offset}}
-        if(offset!==u.offset)throw new ComputerError('image_upload_offset','请从已接收的位置继续上传')
-        let written=0;while(written<bytes.length){const result=await file.write(bytes,written,bytes.length-written,offset+written);if(!result.bytesWritten)throw new Error('写入未完成');written+=result.bytesWritten}await file.sync();u.offset+=bytes.length
-        this.db.prepare('UPDATE managed_image_uploads SET value=? WHERE id=?').run(JSON.stringify(u),id);return {offset:u.offset}
-      }finally{await file.close()}
-    }finally{this.uploading.delete(id)}
-  }
-  async cancelUpload(owner:string,id:string){this.upload(owner,id);if(this.uploading.has(id)||this.pending)throw new ComputerError('image_upload_busy','请等待当前操作结束');await rm(this.uploadPath(id),{force:true});this.db.prepare('DELETE FROM managed_image_uploads WHERE id=?').run(id);return {ok:true}}
-  async start(owner:string,id:string,operation:ImageOperation,args:{imageId?:string;uploadId?:string},check:()=>Promise<void>){
-    await this.ready;await check();z.string().uuid().parse(id)
-    const fingerprint=digest({owner,operation,args}),old=this.job(id)
-    if(old){if(old.fingerprint!==fingerprint)throw new ComputerError('idempotency_conflict','操作编号与内容不一致');return {job:old}}
-    if(this.closed||this.pending||this.blocked)throw new ComputerError('image_job_busy','已有镜像操作正在执行')
-    z.enum(['prepare','verify','activate','rollback','import','export']).parse(operation)
-    if(operation==='export'&&this.jobs().filter(j=>j.archiveSize).length>=3)throw new ComputerError('image_export_limit','请先清理之前的导出文件,最多保留三份')
-    const release=this.runtime.pool.beginMaintenance()
-    const job:StoredJob={id,owner,operation,fingerprint,state:'running',message:'正在准备',createdAt:Date.now(),updatedAt:Date.now()};this.save(job)
-    const progress=(message:string)=>{job.message=message;this.save(job)}
-    this.pending=(async()=>{
-      try{
-        await this.runtime.provider.verifyRuntime();await check()
-        const manager=this.images
-        const verify=async(manifest:any)=>{
-          progress('正在启动测试电脑并校验桌面');await check()
-          const spec=this.checkSpec(id,manifest.imageId)
-          try{await this.runtime.provider.ensure(spec,()=>{if(this.closed)throw new Error('节点正在关闭')});const end=Date.now()+60000;for(;;){await check();try{await this.runtime.provider.health(spec,()=>{});await this.runtime.provider.capture(spec,()=>{});break}catch(e){if(Date.now()>end)throw e;await new Promise(r=>setTimeout(r,1000))}}}
-          finally{try{await this.runtime.provider.remove(spec);await this.runtime.provider.deleteWorkspace(spec)}catch{this.blocked={ids:[id],release};throw new ComputerError('computer_stop_uncertain','测试电脑停止尚未确认,请刷新状态重试清理')}}
-        }
-        const previousVerify=manager.verify.bind(manager);manager.verify=verify
-        try{
-          let manifest:any
-          if(operation==='prepare'){progress('正在构建兼容镜像,首次准备可能需要数分钟');manifest=await manager.prepare([join(dirname(this.runtime.script),'computer-image'),join(dirname(this.runtime.script),'../computers/computer-image'),resolve('deploy/computer')].find(path=>existsSync(join(path,'Dockerfile')))??'missing-computer-recipe')}
-          else if(operation==='import'){
-            const u=this.upload(owner,args.uploadId!);if(u.offset!==u.size)throw new ComputerError('image_upload_incomplete','镜像尚未上传完成')
-            progress('正在校验归档并导入镜像');await writeFile(this.uploadPath(u.id)+'.json',JSON.stringify(u.manifest),{mode:0o600})
-            manifest=await manager.import(this.uploadPath(u.id));await rm(this.uploadPath(u.id),{force:true});await rm(this.uploadPath(u.id)+'.json',{force:true});this.db.prepare('DELETE FROM managed_image_uploads WHERE id=?').run(u.id)
-          }else{
-            const selected=operation==='rollback'?this.selection()?.previous:args.imageId
-            if(!selected)throw new ComputerError('image_missing','没有可用的镜像或回退版本')
-            manifest=await manager.inspect(selected)
-            if(operation==='export'){progress('正在导出镜像');manifest=await manager.export(selected,join(this.directory,id+'.export.tar'));job.archiveSize=(await stat(join(this.directory,id+'.export.tar'))).size;job.manifest=manifest}
-            else await verify(manifest)
-          }
-          await check();job.imageId=manifest.imageId
-          if((operation==='activate'||operation==='rollback')&&manifest.imageId!==this.runtime.config.imageId){
-            const old=this.selection();this.db.prepare('INSERT OR REPLACE INTO managed_image_selection VALUES(1,?)').run(JSON.stringify({base:old?.base??this.runtime.config.imageId,active:manifest.imageId,previous:this.runtime.config.imageId}))
-            this.runtime.config.imageId=manifest.imageId
-          }
-          job.state='complete';progress(operation==='activate'||operation==='rollback'?'已应用,下次启动电脑使用此镜像':'操作完成')
-        }finally{manager.verify=previousVerify}
-      }catch(error){job.state=this.blocked?'uncertain':'failed';progress(error instanceof Error&&/^(镜像|电脑|上传|请|没有)/.test(error.message)?error.message.slice(0,240):'镜像操作失败,请检查运行时、网络和镜像清单后重试')}
-      finally{if(!this.blocked)release();this.pending=undefined}
-    })()
-    return {job:{...job}}
-  }
-  async download(owner:string,id:string,offset:number){
-    z.string().uuid().parse(id);z.number().int().nonnegative().parse(offset)
-    const job=this.job(id)
-    if(!job||job.owner!==owner||job.state!=='complete'||job.operation!=='export'||!job.archiveSize||offset>job.archiveSize)throw new ComputerError('image_export_missing','导出文件不存在')
-    const file=await open(join(this.directory,id+'.export.tar'),'r');try{const bytes=Buffer.alloc(Math.min(chunkSize,job.archiveSize-offset));let received=0;while(received<bytes.length){const result=await file.read(bytes,received,bytes.length-received,offset+received);if(!result.bytesRead)throw new ComputerError('image_export_incomplete','镜像导出不完整');received+=result.bytesRead}return {data:bytes.toString('base64'),size:job.archiveSize,offset,manifest:job.manifest}}finally{await file.close()}
-  }
-  async removeExport(owner:string,id:string){const job=this.job(id);if(!job||job.owner!==owner||job.state!=='complete'||job.operation!=='export')throw new ComputerError('image_export_missing','导出不存在');await rm(join(this.directory,id+'.export.tar'),{force:true});await rm(join(this.directory,id+'.export.tar.json'),{force:true});delete job.archiveSize;job.message='导出文件已清理,可重新导出';this.save(job);return {ok:true}}
-  async close(){this.closed=true;await this.ready;await this.pending;await this.retryCleanup()}
-}

+ 116 - 0
src/runner/computers/localVm.ts

@@ -0,0 +1,116 @@
+import { existsSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import type { DatabaseSync } from 'node:sqlite'
+import { ComputerImages } from './images.js'
+import { ComputerError } from './container.js'
+import { UNCONFIGURED_COMPUTER_IMAGE } from '../../shared/runner.js'
+import type { LocalVmMode, LocalVmStatus } from '../../shared/localVm.js'
+import type { ComputerRuntime } from '../worker/gateway.js'
+
+type State = { imageId: string; mode: LocalVmMode; maxInstances: number; job?: LocalVmStatus['job'] }
+
+/** One managed Local VM image. It is prepared in Settings, never selected from an image catalog. */
+export class LocalVmImages {
+  readonly images: ComputerImages
+  readonly ready: Promise<void>
+  private state: State
+  private pending?: Promise<void>
+  private closed = false
+  private releaseMaintenance?: () => void
+  constructor(readonly runtime: ComputerRuntime, readonly db: DatabaseSync) {
+    this.images = new ComputerImages(runtime.config.runtime)
+    db.exec('CREATE TABLE IF NOT EXISTS local_vm_runtime(id INTEGER PRIMARY KEY CHECK(id=1),value TEXT NOT NULL)')
+    const row = db.prepare('SELECT value FROM local_vm_runtime WHERE id=1').get() as {value:string}|undefined
+    this.state = row ? JSON.parse(row.value) : {imageId:runtime.config.imageId,mode:'per-bot',maxInstances:runtime.config.maxConcurrent ?? 2}
+    // Preserve an already selected image when migrating; old archives and workspaces are untouched.
+    if (!row && db.prepare("SELECT name FROM sqlite_master WHERE name='managed_image_selection'").get()) {
+      const old = db.prepare('SELECT value FROM managed_image_selection WHERE id=1').get() as {value:string}|undefined
+      if (old) this.state.imageId = JSON.parse(old.value).active ?? this.state.imageId
+    }
+    runtime.config.imageId = this.state.imageId
+    runtime.pool.limits.concurrent = this.state.maxInstances
+    runtime.retainDesktops = true
+    this.releaseMaintenance = runtime.pool.holdMaintenance()
+    this.ready = runtime.ready.then(async () => {
+      if (this.state.job?.state === 'running') {
+        try { await this.cleanup(this.state.job.id) }
+        catch {this.state.job={...this.state.job,state:'failed',message:'上次准备的测试虚拟机停止状态待核对,请启动容器运行环境后重新检查'};this.save();return}
+        this.state.job={...this.state.job,state:'failed',message:'上次准备已中断,可重新准备本地虚拟机'};this.save()
+      }
+      this.releaseMaintenance?.(); this.releaseMaintenance = undefined
+    })
+    void this.ready.catch(() => {})
+  }
+  private save() { this.db.prepare('INSERT OR REPLACE INTO local_vm_runtime VALUES(1,?)').run(JSON.stringify(this.state)) }
+  private spec(id:string,imageId=this.runtime.config.imageId) { return {id,ownerKey:'local-vm-prepare',imageId} }
+  private async cleanup(id:string) {
+    const spec = this.spec(id)
+    await this.runtime.provider.remove(spec)
+    await this.runtime.provider.deleteWorkspace(spec)
+  }
+  async status(owner?:string): Promise<LocalVmStatus> {
+    await this.ready
+    if(this.releaseMaintenance&&!this.pending&&this.state.job?.state==='failed') {
+      try{await this.cleanup(this.state.job.id);this.releaseMaintenance();this.releaseMaintenance=undefined}catch{}
+    }
+    let daemonUp=false,image=false,problem:string|undefined
+    try { await this.runtime.provider.verifyRuntime();daemonUp=true }
+    catch { problem='请先安装并启动 Docker 或 Podman' }
+    if(daemonUp && this.state.imageId!==UNCONFIGURED_COMPUTER_IMAGE) {
+      try { await this.images.inspect(this.state.imageId);image=true } catch { problem='本地虚拟机镜像尚未就绪,请重新准备' }
+    }
+    return {configured:true,runtime:this.runtime.config.runtime,daemonUp,image,imageId:image?this.state.imageId:undefined,
+      mode:this.state.mode,maxInstances:this.state.maxInstances,busy:!!this.pending||!!this.releaseMaintenance,problem,job:this.state.job,
+      instances:owner?this.runtime.pool.status(owner).map(row=>({id:row.environmentId,status:row.status})):[]}
+  }
+  async policy(mode:LocalVmMode,maxInstances:number) {
+    await this.ready
+    const release=this.runtime.pool.beginMaintenance()
+    try { this.state.mode=mode;this.state.maxInstances=maxInstances;this.runtime.pool.limits.concurrent=maxInstances;this.save() }
+    finally { release() }
+    return this.status()
+  }
+  async prepare(id:string,check:()=>Promise<void>) {
+    await this.ready;await check()
+    if(this.state.job?.id===id)return this.status()
+    if(this.pending||this.closed)throw new ComputerError('local_vm_busy','本地虚拟机正在准备,请稍后查看')
+    this.releaseMaintenance=this.runtime.pool.beginMaintenance()
+    this.state.job={id,state:'running',message:'正在准备本地虚拟机'};this.save()
+    const progress=(message:string)=>{this.state.job!.message=message;this.save()}
+    this.pending=(async()=>{
+      try {
+        await this.runtime.provider.verifyRuntime();await check()
+        const verify=async(manifest:{imageId:string})=>{
+          progress('正在检查虚拟机桌面与电脑驱动')
+          const spec=this.spec(id,manifest.imageId)
+          try {
+            await this.runtime.provider.ensure(spec,()=>{if(this.closed)throw new Error('服务已停止')})
+            const deadline=Date.now()+60000
+            for(;;) { await check();try{await this.runtime.provider.health(spec,()=>{});await this.runtime.provider.capture(spec,()=>{});break}catch(error){if(Date.now()>deadline)throw error;await new Promise(r=>setTimeout(r,1000))} }
+          } finally { await this.runtime.provider.remove(spec);await this.runtime.provider.deleteWorkspace(spec) }
+        }
+        this.images.verify=verify
+        const ids=(await this.images.run(this.images.runtime,['image','ls','--no-trunc','--quiet','--filter','label=cn.samien.yaoyao.computer-image=1'])).trim().split(/\s+/).filter(Boolean)
+        let image
+        for(const value of [this.state.imageId,...ids].filter(id=>id!==UNCONFIGURED_COMPUTER_IMAGE)) { try{image=await this.images.inspect(value);break}catch{} }
+        if(image)await verify(image)
+        else {
+          progress('正在准备托管桌面镜像,首次下载可能需要数分钟')
+          const recipe=[join(dirname(this.runtime.script),'computer-image'),join(dirname(this.runtime.script),'../computers/computer-image'),resolve('deploy/computer')].find(p=>existsSync(join(p,'Dockerfile')))
+          if(!recipe)throw new Error('缺少本地虚拟机资源,请重新安装 App')
+          image=await this.images.prepare(recipe)
+        }
+        await check();this.state.imageId=image.imageId;this.runtime.config.imageId=image.imageId
+        this.state.job={id,state:'complete',message:'本地虚拟机已就绪,可在 Agent 的电脑面板中创建桌面'};this.save()
+      } catch(error) {
+        this.state.job={id,state:'failed',message:error instanceof Error?error.message.slice(0,240):'本地虚拟机准备失败'};this.save()
+      } finally {
+        // Do not release maintenance when an interrupted verification VM may still be alive.
+        try { await this.cleanup(id);this.releaseMaintenance?.();this.releaseMaintenance=undefined } catch { this.state.job={id,state:'failed',message:'测试虚拟机停止状态待核对,请重启执行环境后重试'};this.save() }
+        this.pending=undefined
+      }
+    })()
+    return this.status()
+  }
+  async close() { this.closed=true;await this.ready;await this.pending }
+}

+ 37 - 6
src/runner/computers/pool.ts

@@ -4,7 +4,7 @@ import type {ComputerProvider,ComputerSpecification} from './container.js'
 import {ComputerError} from './container.js'
 
 export interface ComputerLease {id:string;environmentId:string;ownerKey:string;holderId:string;generation:number}
-type Status='free'|'preparing'|'active'|'stopping'|'uncertain'
+type Status='free'|'idle'|'preparing'|'active'|'stopping'|'uncertain'
 interface Entry {spec:ComputerSpecification;generation:number;status:Status;lease?:ComputerLease;expiresAt:number;updatedAt:number}
 interface Active {lease:ComputerLease;authorize():void;controller:AbortController;operations:Set<Promise<unknown>>;detach():void}
 
@@ -60,9 +60,9 @@ export class ComputerPool {
       let entry=this.get(spec.id)
       if(entry&&(entry.spec.ownerKey!==spec.ownerKey||JSON.stringify(entry.spec)!==JSON.stringify(spec)))throw new ComputerError('computer_owner_mismatch','电脑环境归属或配置不匹配')
       if(entry?.status==='active'&&entry.lease?.holderId===holderId&&this.active.has(entry.lease.id)){this.assert(entry.lease);return entry.lease}
-      if(entry&&entry.status!=='free')throw new ComputerError('computer_busy','电脑正在使用或需要核对停止状态')
+      if(entry&&entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑正在使用或需要核对停止状态')
       const rows=this.rows()
-      if((!entry&&rows.length>=this.limits.environments)||rows.filter(row=>row.status!=='free').length>=this.limits.concurrent)throw new ComputerError('computer_quota','电脑资源已达上限,请等待其他任务释放')
+      if((!entry&&rows.length>=this.limits.environments)||rows.filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
       const lease:ComputerLease={id:randomUUID(),environmentId:spec.id,ownerKey:spec.ownerKey,holderId,generation:(entry?.generation??0)+1}
       entry={spec:structuredClone(spec),lease,generation:lease.generation,status:'preparing',expiresAt:this.now()+this.limits.ttlMs,updatedAt:this.now()}
       // This reservation is synchronous, before any provider await or next acquire.
@@ -85,7 +85,7 @@ export class ComputerPool {
       const entry=this.get(spec.id);if(!entry)return
       if(entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
       if(JSON.stringify(entry.spec)===JSON.stringify(spec))return
-      if(entry.status!=='free')throw new ComputerError('computer_busy','请先释放当前电脑,再应用新的配置')
+      if(entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','请先释放当前电脑,再应用新的配置')
       entry.status='stopping';entry.generation++;this.save(entry)
       try{await this.provider.remove(entry.spec);authorize();entry.spec=spec;entry.status='free';this.save(entry)}
       catch(error){entry.status='uncertain';this.save(entry);throw error}
@@ -115,18 +115,48 @@ export class ComputerPool {
     active.operations.add(pending);void pending.finally(()=>active.operations.delete(pending)).catch(()=>{})
     return pending
   }
-  release(lease:ComputerLease):Promise<void>{
+  release(lease:ComputerLease,keepRunning=false):Promise<void>{
+    if(keepRunning)return this.serial(lease.environmentId,async()=>{
+      this.assert(lease)
+      const entry=this.require(lease),active=this.active.get(lease.id)!
+      if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有未结束操作,暂不能进入空闲状态')
+      active.detach();active.controller.abort();this.active.delete(lease.id)
+      entry.status='idle';entry.generation++;entry.lease=undefined;entry.expiresAt=0;this.save(entry)
+    })
     const existing=this.get(lease.environmentId)
     const entry=existing&&existing.lease?.id===lease.id&&existing.spec.ownerKey===lease.ownerKey&&existing.lease.holderId===lease.holderId&&['stopping','uncertain'].includes(existing.status)?existing:this.require(lease,false)
     this.invalidate(entry)
     return this.serial(entry.spec.id,async()=>{const current=this.get(entry.spec.id)!;if(current.status==='free')return;await this.stopEntry(current)})
   }
   definition(ownerKey:string,id:string){const entry=this.get(id);if(entry&&entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配');return entry?.spec}
+  async desktop(spec:ComputerSpecification,action:'create'|'start'|'stop'|'recreate'|'remove',authorize:()=>void){
+    spec=this.provider.validateSpecification(spec)
+    await this.serial(spec.id,async()=>{
+      authorize()
+      if(!this.ready||this.closing||this.maintenance)throw new ComputerError('computer_busy','本地虚拟机正在维护')
+      let entry=this.get(spec.id)
+      if(entry?.spec.ownerKey!==undefined&&entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','虚拟机归属不匹配')
+      if(entry&&!['free','idle'].includes(entry.status))throw new ComputerError('computer_busy','请先停止 Agent 任务并交还控制权')
+      const starts=['create','start','recreate'].includes(action)
+      if(starts&&this.rows().filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
+      if(!entry&&!starts)return
+      entry??={spec,generation:0,status:'free',expiresAt:0,updatedAt:this.now()}
+      entry.status='preparing';entry.generation++;this.save(entry)
+      try {
+        if(['stop','recreate','remove'].includes(action)) {await this.provider.stop(entry.spec);await this.provider.remove(entry.spec)}
+        authorize()
+        if(starts){entry.spec=spec;await this.provider.ensure(spec,authorize);entry.status='idle'}else entry.status='free'
+        if(action==='remove')this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(spec.id)
+        else this.save(entry)
+      }catch(error){entry.status='uncertain';this.save(entry);throw error}
+    })
+  }
   async retire(ownerKey:string,id:string):Promise<ComputerSpecification|undefined>{
     return this.serial(id,async()=>{
       const entry=this.get(id);if(!entry)return
       if(entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
-      if(entry.status!=='free')throw new ComputerError('computer_busy','电脑停止状态尚未确认')
+      if(entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑停止状态尚未确认')
+      if(entry.status==='idle')await this.provider.stop(entry.spec)
       await this.provider.remove(entry.spec)
       this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(id)
       return entry.spec
@@ -162,6 +192,7 @@ export class ComputerPool {
   async expire():Promise<void>{
     for(const id of this.rows().map(entry=>entry.spec.id)){
       const entry=this.get(id)!
+      if(entry.status==='idle'&&this.now()-entry.updatedAt>=300000){this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{});continue}
       if((entry.status==='active'||entry.status==='preparing')&&entry.expiresAt<=this.now()){
         this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{})
       }

+ 16 - 6
src/runner/worker/control.ts

@@ -9,6 +9,7 @@ import type {ComputerControlStatus,ComputerFrame} from '../../shared/computerCon
 
 const actionSchema=z.discriminatedUnion('kind',[
   z.object({kind:z.literal('click'),x:z.number().int().nonnegative(),y:z.number().int().nonnegative(),button:z.enum(['left','right','middle']).default('left'),count:z.number().int().min(1).max(2).default(1)}).strict(),
+  z.object({kind:z.literal('drag'),fromX:z.number().int().nonnegative(),fromY:z.number().int().nonnegative(),toX:z.number().int().nonnegative(),toY:z.number().int().nonnegative()}).strict(),
   z.object({kind:z.literal('text'),text:z.string().max(16000)}).strict(),
   z.object({kind:z.literal('key'),key:z.string().min(1).max(40).regex(/^[A-Za-z0-9_+ -]+$/),modifiers:z.array(z.enum(['ctrl','alt','shift','super'])).max(4).default([])}).strict(),
   z.object({kind:z.literal('scroll'),direction:z.enum(['up','down','left','right']),amount:z.number().int().min(1).max(50).default(3)}).strict(),
@@ -36,7 +37,7 @@ export class ComputerControls {
     const state=this.state(meta)
     if(state)return {mode:state.mode,controlId:state.id,generation:state.lease?.generation,canResume:!!state.gateway,error:state.error}
     const resource=this.runtime.pool.status(meta.ownerKey).find(item=>item.environmentId===meta.environmentId)
-    return {mode:resource?.status==='active'?'agent':'off',generation:resource?.generation}
+    return {mode:resource?.status==='active'?'agent':resource?.status==='idle'?'idle':'off',generation:resource?.generation}
   }
   async frame(meta:ComputerTarget,authorize:()=>void):Promise<ComputerFrame>{
     this.runtime.assertTarget(meta);authorize()
@@ -84,16 +85,22 @@ export class ComputerControls {
     if(old){if(old.fingerprint!==fingerprint)throw new HttpError(409,'操作编号与内容不一致','idempotency_conflict');return old.result}
     if(state.actions.size>=1000)throw new HttpError(429,'本次接管操作已达上限,请重新接管','computer_control_limit')
     const common={scope:'desktop',session:`yaoyao-control-${state.id}`}
-    let tool:string,args:Record<string,unknown>
+    let tool:string,args:Record<string,unknown>,argv:string[]|undefined
     if(action.kind==='click'){
       if(action.x>=frame.width||action.y>=frame.height)throw new HttpError(400,'点击位置超出画面','computer_point_invalid')
       tool='click';args={...common,x:action.x,y:action.y,button:action.button,count:action.count}
+    }else if(action.kind==='drag'){
+      if(action.fromX>=frame.width||action.toX>=frame.width||action.fromY>=frame.height||action.toY>=frame.height)throw new HttpError(400,'拖动位置超出画面','computer_point_invalid')
+      tool='drag';args={}
+      argv=['env','DISPLAY=:1','xdotool','mousemove','--sync',String(action.fromX),String(action.fromY),'mousedown','1','mousemove','--sync',String(action.toX),String(action.toY),'mouseup','1']
     }else if(action.kind==='text'){tool='type_text';args={...common,text:action.text}}
-    else if(action.kind==='key'){tool='press_key';args={...common,key:action.key,modifiers:action.modifiers}}
-    else {tool='scroll';args={...common,direction:action.direction,amount:action.amount}}
+    else if(action.kind==='key'){
+      const key=({Return:'enter',BackSpace:'backspace',Escape:'esc'} as Record<string,string>)[action.key]??action.key.toLowerCase()
+      tool=action.modifiers.length?'hotkey':'press_key';args=action.modifiers.length?{...common,keys:[...action.modifiers,key]}:{...common,key}
+    }else {tool='scroll';args={...common,x:Math.floor(frame.width/2),y:Math.floor(frame.height/2),by:'line',direction:action.direction,amount:action.amount}}
     const result=this.runtime.pool.use(state.lease,async context=>{
       this.guard(state);await state.check()
-      const response=await this.runtime.provider.execute(state.spec!,[CUA_DRIVER,'call',tool,JSON.stringify(args),'--socket',CUA_SOCKET],context)
+      const response=await this.runtime.provider.execute(state.spec!,argv??[CUA_DRIVER,'call',tool,JSON.stringify(args),'--socket',CUA_SOCKET],context)
       this.guard(state);let parsed:any;try{parsed=JSON.parse(response.stdout)}catch{}
       if(parsed?.code||parsed?.error||parsed?.isError)throw new HttpError(502,'电脑未接受这次输入,请刷新画面后重试','computer_action_failed')
       return {ok:true,result:response.stdout}
@@ -104,7 +111,10 @@ export class ComputerControls {
     const state=this.state(meta);if(!state||state.id!==id)throw new HttpError(410,'电脑控制权已结束','computer_control_expired')
     await state.pending;this.guard(state);await state.check()
     if(state.gateway){state.mode='resuming';await state.gateway.giveBack(notes);state.valid=false;this.manual.delete(meta.environmentId)}
-    else await this.stop(state)
+    else if(this.runtime.retainDesktops&&state.lease){
+      await state.proxy?.close();await this.runtime.pool.release(state.lease,true)
+      state.valid=false;this.manual.delete(meta.environmentId)
+    }else await this.stop(state)
     return {ok:true}
   }
   private async stop(state:Manual){

+ 4 - 3
src/runner/worker/gateway.ts

@@ -30,6 +30,7 @@ const TOOLS:WorkerTool[]=[
 ]
 const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
 export class ComputerRuntime {
+  retainDesktops=false
   readonly controls:ComputerControls
   readonly provider:ContainerComputerProvider
   readonly pool:ComputerPool
@@ -106,7 +107,7 @@ export class ComputerGateway {
       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;const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderId===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(this.runtime.config.imageId===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在电脑与环境的镜像页面准备并应用镜像','computer_image_required')
+      if(this.runtime.config.imageId===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在应用设置的本地虚拟机页面完成准备','computer_image_required')
       const profile=String(params.profile),resolved=await this.runtime.resolve(profile,this.controller.signal)
       await this.authorized();this.guard()
       const session:WorkerSession=method==='session.resume'?this.runtime.session(String(params.session_id),this.meta,profile):{id:randomUUID(),agentId:this.meta.agentId,ownerKey:this.meta.ownerKey,environmentId:this.meta.environmentId,profile,cwd:resolved.cwd,configuredCwd:resolved.configuredCwd,history:[]}
@@ -240,12 +241,12 @@ export class ComputerGateway {
     const success=frame.completed!==false&&!frame.interrupted
     if(success&&Array.isArray(frame.messages)){live.session.history=frame.messages;live.session.outcome='complete'}else live.session.outcome='failed'
     this.runtime.save(live.session)
-    await this.release(live)
+    await this.release(live,success&&this.runtime.retainDesktops)
     live.running=false
     if(this.closed||live.controller.signal.aborted)return
     this.event('message.complete',{text:String(frame.text??''),status:success?'complete':'failed',...(!success?{error:'隔离 Worker 未完成本轮'}:{})})
   }
-  private release(live:Live){clearInterval(this.timer);live.releasing??=(async()=>{await live.proxy?.close();await this.runtime.pool.release(live.lease)})();return live.releasing}
+  private release(live:Live,keepRunning=false){clearInterval(this.timer);live.releasing??=(async()=>{await live.proxy?.close();await this.runtime.pool.release(live.lease,keepRunning)})();return live.releasing}
   private async lost(live:Live){
     if(!live.running||live.paused||this.closed||live.controller.signal.aborted)return
     live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()

+ 8 - 4
src/server/app.ts

@@ -49,7 +49,7 @@ import {
   HermesChatNotificationResolver,
 } from './pushEvents.js'
 import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
-import {ImageManagementService} from './imageManagement.js'
+import {LocalVmService} from './localVm.js'
 import {SharedComputers} from './sharedComputers.js'
 import {ComputerControlService} from './computerControls.js'
 import { RunnerHub } from './runnerHub.js'
@@ -95,6 +95,7 @@ export interface ApplicationRuntime {
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
   chatCache?: ChatCacheCoordinator
+  localVm: LocalVmService
   close(): void
 }
 
@@ -213,8 +214,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
   const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
   const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
-  const imageManagement=new ImageManagementService(workspace,auth,runners)
-  runners.imageAllowed=(id,runnerId)=>imageManagement.allowed(id,runnerId)
+  const localVm=new LocalVmService(workspace,auth,workspaceNodes,runners,sharedComputers,config)
+  runners.localVmAllowed=(id,runnerId)=>localVm.allowed(id,runnerId)
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
@@ -381,7 +382,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
 
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
-  const imageRouter=imageManagement.router();app.use(imageRouter.routes());app.use(imageRouter.allowedMethods())
+  const localVmRouter=localVm.router();app.use(localVmRouter.routes());app.use(localVmRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
   const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
   app.use(runners.adminRouter().routes())
@@ -442,6 +443,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
 
   return {
     runners,
+    localVm,
     workspace,
     workspaceRuntime,
     realtime,
@@ -499,6 +501,7 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
       runtime.chatPushJobs.stop()
     }
   }
+  server.once('listening',()=>{const address=server.address();if(address&&typeof address!=='string')void runtime.localVm.start(`${config.tlsCert?'https':'http'}://127.0.0.1:${address.port}`).catch(error=>console.error('本地虚拟机服务启动失败',error.message))})
   runtime.workspaceRuntime.start()
   synchronizePushObservers()
   const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
@@ -506,6 +509,7 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
   return {
     server,
     close: async (closeConnections = false) => {
+      await runtime.localVm.close()
       removePushConfigurationListener()
       removeWebSockets()
       runtime.realtime.close()

+ 2 - 2
src/server/computerControls.ts

@@ -32,9 +32,9 @@ export class ComputerControlService {
     const router=new Router()
     router.get('/api/app/computers',ctx=>{
       const actor=this.auth.require(ctx),agents=this.store.list<WorkspaceAgent>(actor.id,'agent').filter(agent=>!agent.archived)
-      ctx.body={canManageImages:actor.role==='admin',computers:agents.map(agent=>{
+      ctx.body={canManageLocalVm:actor.role==='admin',computers:agents.map(agent=>{
         let available=false,reason='在 Agent 设置中将执行环境设为隔离电脑'
-        if(agent.execution==='computer')try{this.nodes.requireSource(actor.id,agent);const record=this.hub.computerRunner(actor.id,agent),features=this.hub.summary(record).features;if(features.includes('image-management-v1')&&!features.includes('image-ready-v1'))throw new Error('请先在镜像页面准备并应用镜像');available=true;reason='可以打开电脑面板'}catch(error){reason=error instanceof Error?error.message:'电脑不可用'}
+        if(agent.execution==='computer')try{this.nodes.requireSource(actor.id,agent);const record=this.hub.computerRunner(actor.id,agent),features=this.hub.summary(record).features;if(features.includes('local-vm-v1')&&!features.includes('image-ready-v1'))throw new Error('请先在应用设置的本地虚拟机页面完成准备');available=true;reason='可以打开电脑面板'}catch(error){reason=error instanceof Error?error.message:'电脑不可用'}
         return {agent,available,reason}
       })}
     })

+ 0 - 37
src/server/imageManagement.ts

@@ -1,37 +0,0 @@
-import Router from '@koa/router'
-import {Readable} from 'node:stream'
-import {z} from 'zod'
-import type {WorkspaceStore} from './workspaceStore.js'
-import {parse} from './workspaceStore.js'
-import type {LocalAuthStore} from './localAuth.js'
-import type {RunnerHub} from './runnerHub.js'
-import {HttpError} from './errors.js'
-interface Grant {id:string;owner:string;runnerId:string;version:number;expiresAt:number}
-export class ImageManagementService {
-  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly hub:RunnerHub){}
-  allowed(id:string,runnerId:string){const grant=this.store.get<Grant>('_system','image-management-grant',id);return !!grant&&grant.runnerId===runnerId&&grant.expiresAt>Date.now()&&this.auth.isAdminActive(grant.owner)&&this.auth.pushAuthorizationVersion(grant.owner)===grant.version}
-  router(){
-    const router=new Router(),base='/api/app/admin/runners/:id/images'
-    const call=async(ctx:any,payload:Record<string,unknown>)=>{const owner=this.auth.requireAdmin(ctx).id;ctx.set('Cache-Control','no-store');return this.hub.images(owner,ctx.params.id,payload)}
-    router.get(base,async ctx=>{ctx.body=await call(ctx,{op:'status'})})
-    router.post(base+'/jobs',async ctx=>{
-      const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid(),operation:z.enum(['prepare','verify','activate','rollback','import','export']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),uploadId:z.string().uuid().optional()}).strict(),(ctx.request as any).body)
-      const old=this.store.get<Grant>('_system','image-management-grant',body.requestId)
-      if(old&&(old.owner!==actor.id||old.runnerId!==ctx.params.id))throw new HttpError(409,'操作编号已使用','idempotency_conflict')
-      if(!old)this.store.put('_system','image-management-grant',body.requestId,{id:body.requestId,owner:actor.id,runnerId:ctx.params.id,version:this.auth.pushAuthorizationVersion(actor.id)??0,expiresAt:Date.now()+3600000})
-      ctx.body=await call(ctx,{op:'start',id:body.requestId,operation:body.operation,...(body.imageId?{imageId:body.imageId}:{}),...(body.uploadId?{uploadId:body.uploadId}:{})});ctx.status=202
-    })
-    router.post(base+'/uploads',async ctx=>{const body=parse(z.object({id:z.string().uuid(),size:z.number().int().positive().max(20*1024**3),manifest:z.record(z.string(),z.unknown())}).strict(),(ctx.request as any).body);ctx.body=await call(ctx,{op:'upload',...body})})
-    router.post(base+'/uploads/:uploadId',async ctx=>{const body=parse(z.object({offset:z.number().int().nonnegative(),data:z.string().max(750000)}).strict(),(ctx.request as any).body);ctx.body=await call(ctx,{op:'append',id:ctx.params.uploadId,...body})})
-    router.delete(base+'/uploads/:uploadId',async ctx=>{ctx.body=await call(ctx,{op:'cancel-upload',id:ctx.params.uploadId})})
-    router.delete(base+'/jobs/:jobId/archive',async ctx=>{ctx.body=await call(ctx,{op:'remove-export',id:ctx.params.jobId})})
-    router.get(base+'/jobs/:jobId/chunk',async ctx=>{ctx.body=await call(ctx,{op:'download',id:ctx.params.jobId,offset:Number(ctx.query.offset??0)})})
-    router.get(base+'/jobs/:jobId/manifest',async ctx=>{const data=await call(ctx,{op:'download',id:ctx.params.jobId,offset:0});ctx.attachment('computer-image.tar.json');ctx.body=data.manifest})
-    router.get(base+'/jobs/:jobId/archive',async ctx=>{
-      const first=await call(ctx,{op:'download',id:ctx.params.jobId,offset:0});ctx.attachment('computer-image.tar');ctx.type='application/octet-stream';ctx.length=first.size
-      const stream=Readable.from((async function*(){let part=first,offset=0;while(offset<first.size&&!ctx.res.destroyed){const bytes=Buffer.from(part.data,'base64');if(!bytes.length||part.offset!==offset)throw new Error('镜像导出数据不完整');yield bytes;offset+=bytes.length;if(offset<first.size)part=await call(ctx,{op:'download',id:ctx.params.jobId,offset})}})())
-      ctx.res.once('close',()=>stream.destroy());ctx.body=stream
-    })
-    return router
-  }
-}

+ 170 - 0
src/server/localVm.ts

@@ -0,0 +1,170 @@
+import Router from '@koa/router'
+import { z } from 'zod'
+import { existsSync, mkdirSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { RunnerAgent } from '../runner/agent.js'
+import { UNCONFIGURED_COMPUTER_IMAGE, type RunnerConfiguration } from '../shared/runner.js'
+import type { LocalVmMode, LocalVmStatus } from '../shared/localVm.js'
+import type { WorkspaceAgent } from '../shared/workspace.js'
+import { parse, type WorkspaceStore } from './workspaceStore.js'
+import type { LocalAuthStore } from './localAuth.js'
+import type { WorkspaceNodes } from './workspaceGateway.js'
+import type { RunnerHub } from './runnerHub.js'
+import type { SharedComputers } from './sharedComputers.js'
+import type { ServerConfig } from './config.js'
+import { HttpError } from './errors.js'
+
+interface Grant {id:string;owner:string;runnerId:string;version:number;expiresAt:number}
+const exec=promisify(execFile)
+
+/** Settings owns preparation. Chats own each Agent's desktop and its lifecycle. */
+export class LocalVmService {
+  private url?:string
+  private managed?:{agent:RunnerAgent;abort:AbortController;done:Promise<void>}
+  private starting?:Promise<void>
+  private problem?:string
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,
+    readonly hub:RunnerHub,readonly shared:SharedComputers,readonly config:ServerConfig) {
+    store.prepareAgent=(owner,agent)=>{const record=this.record();if(record&&this.mode(owner)==='shared')this.shared.attachLocalVm(owner,agent,record.id)}
+  }
+  allowed(id:string,runnerId:string){
+    const grant=this.store.get<Grant>('_system','local-vm-grant',id)
+    return !!grant&&grant.runnerId===runnerId&&grant.expiresAt>Date.now()&&this.auth.isAdminActive(grant.owner)&&this.auth.pushAuthorizationVersion(grant.owner)===grant.version
+  }
+  private mode(owner:string):LocalVmMode { return this.store.get<{mode:LocalVmMode}>(owner,'local-vm-preferences','local')?.mode??'per-bot' }
+  private record(){return this.hub.records().find(r=>r.enabled&&r.sourceNodeId==='local'&&r.sourceOwner==='_system')}
+  async start(url:string) {
+    this.url=url
+    const stored=this.store.get<{sealed:string}>('_system','local-vm-managed','local')
+    if(!stored||this.managed)return
+    const config=this.nodes.open<RunnerConfiguration>(stored.sealed)
+    if(!this.hub.records().some(r=>r.id===config.runnerId&&r.enabled))return
+    config.serverURL=url
+    const home=join(this.config.home,'runner-state','local-vm',config.runnerId)
+    mkdirSync(home,{recursive:true,mode:0o700})
+    const abort=new AbortController(),agent=new RunnerAgent(config,home)
+    const state={agent,abort,done:Promise.resolve()}
+    this.managed=state
+    state.done=agent.run(abort.signal).catch(error=>{this.problem=error instanceof Error?error.message:'本地虚拟机服务已断开'}).finally(()=>{if(this.managed===state)this.managed=undefined})
+  }
+  private async discover(){
+    for(const runtime of ['docker','podman'] as const) {
+      try {
+        await exec(runtime,['--version'],{timeout:5000})
+        try { await exec(runtime,['info'],{timeout:8000,maxBuffer:1024*1024});return {runtime,daemonUp:true} }
+        catch{return {runtime,daemonUp:false}}
+      }catch{}
+    }
+    return {runtime:undefined,daemonUp:false}
+  }
+  private installation(){
+    const hermesHome=join(homedir(),'.hermes'),hermesSource=join(hermesHome,'hermes-agent')
+    return {hermesHome,hermesSource,python:join(hermesSource,'venv','bin','python')}
+  }
+  private async ensure(owner:string) {
+    if(this.starting)await this.starting
+    if(this.record()){
+      if(this.url&&!this.managed)await this.start(this.url)
+      return this.record()!
+    }
+    this.starting=(async()=>{
+      if(!this.url)throw new HttpError(503,'本机 Web 服务尚未启动','local_vm_unavailable')
+      const detected=await this.discover()
+      if(!detected.runtime||!detected.daemonUp)throw new HttpError(409,'请先安装并启动 Docker 或 Podman','local_vm_runtime_required')
+      const {hermesHome,hermesSource,python}=this.installation()
+      if(!existsSync(python)||!existsSync(join(hermesSource,'run_agent.py')))throw new HttpError(409,'没有找到本机 Hermes 运行环境。请先安装 Hermes,或在高级连接设置中配置执行节点','local_vm_hermes_required')
+      const sources=await this.nodes.sources(owner)
+      const allowedProfiles=[...new Set(sources.sources.filter(s=>s.nodeId==='local').map(s=>s.profile))]
+      const registered=this.hub.enroll(owner,{name:'本机虚拟机',sourceNodeId:'local',allowedProfiles})
+      const config:RunnerConfiguration={protocol:1,serverURL:this.url,runnerId:registered.runner.id,token:registered.token,
+        hermesURL:this.config.upstream.origin,allowedProfiles,artifactRoots:[],computers:{runtime:detected.runtime,
+          imageId:UNCONFIGURED_COMPUTER_IMAGE,python,hermesSource,hermesHome,maxConcurrent:2,network:'public-proxy'}}
+      this.store.put('_system','local-vm-managed','local',{sealed:this.nodes.seal(config)})
+      await this.start(this.url)
+    })().finally(()=>{this.starting=undefined})
+    await this.starting
+    const record=this.record()!
+    const deadline=Date.now()+8000
+    while(!this.hub.summary(record).online&&Date.now()<deadline)await new Promise(r=>setTimeout(r,100))
+    return record
+  }
+  private grant(owner:string,id:string,runnerId:string){
+    const old=this.store.get<Grant>('_system','local-vm-grant',id)
+    if(old&&(old.owner!==owner||old.runnerId!==runnerId))throw new HttpError(409,'请求编号已用于其他操作','idempotency_conflict')
+    this.store.put('_system','local-vm-grant',id,{id,owner,runnerId,version:this.auth.pushAuthorizationVersion(owner)??0,expiresAt:Date.now()+3600000})
+  }
+  async status(owner:string):Promise<LocalVmStatus> {
+    const record=this.record()
+    if(record){
+      if(this.url&&!this.managed)await this.start(this.url)
+      if(!this.hub.summary(record).online){
+        const detected=await this.discover()
+        return {configured:true,...detected,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:this.problem??'本机执行环境正在连接,请稍后重新检查'}
+      }
+      const state=await this.hub.localVm(owner,record.id,{op:'status'}) as LocalVmStatus
+      const agents=this.store.list<WorkspaceAgent>(owner,'agent'),groups=this.store.list<{id:string;name:string}>(owner,'shared-computer')
+      return {...state,mode:this.mode(owner),instances:state.instances?.map(instance=>{
+        const agent=agents.find(a=>!a.archived&&(a.computerEnvironmentId??a.id)===instance.id&&a.execution==='computer')
+        return {...instance,name:agent?.computerEnvironmentName??agent?.name??groups.find(g=>g.id===instance.id)?.name??'保留的虚拟机',orphaned:!agent}
+      })}
+    }
+    const detected=await this.discover()
+    return {configured:false,...detected,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:this.problem}
+  }
+  router(){
+    const router=new Router()
+    router.get('/api/app/admin/local-vm',async ctx=>{ctx.body=await this.status(this.auth.requireAdmin(ctx).id)})
+    router.post('/api/app/admin/local-vm/prepare',async ctx=>{
+      const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
+      const record=await this.ensure(actor.id);this.grant(actor.id,body.requestId,record.id)
+      ctx.body=await this.hub.localVm(actor.id,record.id,{op:'prepare',id:body.requestId})
+    })
+    router.put('/api/app/admin/local-vm/policy',async ctx=>{
+      const owner=this.auth.requireAdmin(ctx).id,body=parse(z.object({requestId:z.string().uuid(),mode:z.enum(['shared','per-bot']),maxInstances:z.number().int().min(1).max(4)}).strict(),(ctx.request as any).body)
+      const record=await this.ensure(owner);this.shared.assertLocalVmIdle(owner)
+      this.grant(owner,body.requestId,record.id)
+      await this.hub.localVm(owner,record.id,{op:'policy',id:body.requestId,mode:body.mode,maxInstances:body.maxInstances})
+      this.shared.setLocalVmMode(owner,body.mode,record.id)
+      this.store.put(owner,'local-vm-preferences','local',{mode:body.mode})
+      ctx.body=await this.status(owner)
+    })
+    router.post('/api/app/admin/local-vm/instances/:id/:action',async ctx=>{
+      const owner=this.auth.requireAdmin(ctx).id,id=parse(z.string().uuid(),ctx.params.id),action=parse(z.enum(['stop','remove']),ctx.params.action)
+      const body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body),record=this.record()
+      if(!record)throw new HttpError(409,'本地虚拟机尚未配置','local_vm_unavailable')
+      this.grant(owner,body.requestId,record.id)
+      ctx.body=await this.hub.localVm(owner,record.id,{op:'instance',id:body.requestId,environmentId:id,action})
+    })
+    router.get('/api/app/agents/:id/local-vm',async ctx=>{
+      const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
+      this.nodes.requireSource(owner,agent)
+      if(agent.execution!=='computer'){ctx.body={enabled:false,agent};return}
+      try { ctx.body={enabled:true,agent,...await this.hub.computer(owner,agent,'detail',{},()=>this.nodes.requireSource(owner,agent)),mode:agent.computerEnvironmentId?'shared':'per-bot'} }
+      catch(error){ctx.body={enabled:true,agent,container:'missing',ready:false,image:false,problem:error instanceof Error?error.message:'本地虚拟机不可用'}}
+    })
+    router.put('/api/app/agents/:id/local-vm',ctx=>{
+      const owner=this.auth.require(ctx).id,body=parse(z.object({enabled:z.boolean()}).strict(),(ctx.request as any).body)
+      const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);this.nodes.requireSource(owner,agent)
+      if(agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'请在该 Agent 所属的电脑上设置虚拟机','local_vm_remote')
+      this.shared.assertLocalVmIdle(owner,[agent.id])
+      ctx.body=this.store.atomic(()=>{
+        if(!body.enabled)this.shared.detachLocalVm(owner,agent)
+        this.store.updateAgent(owner,agent.id,{execution:body.enabled?'computer':'profile'})
+        if(body.enabled&&this.mode(owner)==='shared'){const record=this.record();if(record)this.shared.setLocalVmMode(owner,'shared',record.id)}
+        return {agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
+      })
+    })
+    router.post('/api/app/agents/:id/local-vm/:action',async ctx=>{
+      const owner=this.auth.require(ctx).id,action=parse(z.enum(['create','start','stop','recreate','remove']),ctx.params.action)
+      const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
+      if(agent.execution!=='computer'||agent.archived||agent.remoteAgentId)throw new HttpError(409,'此 Agent 未使用本地虚拟机','local_vm_disabled')
+      this.shared.assertLocalVmIdle(owner,agent.computerEnvironmentId?this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.computerEnvironmentId===agent.computerEnvironmentId).map(a=>a.id):[agent.id])
+      ctx.body=await this.hub.computer(owner,agent,'lifecycle',{action},()=>this.nodes.requireSource(owner,agent))
+    })
+    return router
+  }
+  async close(){this.managed?.abort.abort();await this.managed?.done}
+}

+ 24 - 24
src/server/runnerHub.ts

@@ -19,9 +19,9 @@ interface Lease { runnerId:string; input:LeaseInput; calls:Map<string,{fingerpri
 
 /** Outbound-only machine transport. Browser auth and machine auth remain separate. */
 export class RunnerHub {
-  get idleForUpdate(): boolean { return this.imageActivity.size === 0 && [...this.pending.values()].every(commands => commands.size === 0) && [...this.artifacts.values()].every(upload => upload.result !== undefined) }
-  private imageActivity = new Map<string, { owner: string; runnerId: string }>()
-  private imageChecks = new Set<string>()
+  get idleForUpdate(): boolean { return this.localVmActivity.size === 0 && [...this.pending.values()].every(commands => commands.size === 0) && [...this.artifacts.values()].every(upload => upload.result !== undefined) }
+  private localVmActivity = new Map<string, { owner: string; runnerId: string }>()
+  private localVmChecks = new Set<string>()
   controlAllowed:(id:string,runnerId:string)=>boolean=()=>false
   readonly epoch=randomUUID()
   private online = new Map<string,{instance:string;seen:number;epoch:string;features:string[];wake?:()=>void}>()
@@ -32,33 +32,33 @@ export class RunnerHub {
   private leases = new Map<string,Lease>()
   private watchdog: ReturnType<typeof setInterval>
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly local:GatewayTarget) {
-    for (const grant of store.list<{ owner: string; runnerId: string; expiresAt: number }>('_system', 'image-management-grant'))
-      if (grant.expiresAt > Date.now()) this.imageActivity.set(`${grant.owner}:${grant.runnerId}`, { owner: grant.owner, runnerId: grant.runnerId })
+    for (const grant of store.list<{ owner: string; runnerId: string; expiresAt: number }>('_system', 'local-vm-grant'))
+      if (grant.expiresAt > Date.now()) this.localVmActivity.set(`${grant.owner}:${grant.runnerId}`, { owner: grant.owner, runnerId: grant.runnerId })
     this.watchdog=setInterval(()=>{
       for(const [id,state] of this.online) if(Date.now()-state.seen>35_000)this.disconnect(id)
       for(const [id,upload] of this.artifacts)if(!this.connections.has(upload.connectionId))this.artifacts.delete(id)
-      for (const [key, activity] of this.imageActivity) {
-        if (this.imageChecks.has(key) || !this.online.has(activity.runnerId)) continue
-        this.imageChecks.add(key)
-        void this.request(activity.runnerId, 'images.manage', { op: 'status', owner: hash(activity.owner) }, () => this.store.get<RunnerRecord>('_system', 'runner', activity.runnerId)?.enabled === true)
-          .then(result => { if (result.busy === false && this.imageActivity.get(key) === activity) this.imageActivity.delete(key) })
-          .catch(() => {}).finally(() => this.imageChecks.delete(key))
+      for (const [key, activity] of this.localVmActivity) {
+        if (this.localVmChecks.has(key) || !this.online.has(activity.runnerId)) continue
+        this.localVmChecks.add(key)
+        void this.request(activity.runnerId, 'local-vm.manage', { op: 'status', owner: hash(activity.owner) }, () => this.store.get<RunnerRecord>('_system', 'runner', activity.runnerId)?.enabled === true)
+          .then(result => { if (result.busy === false && this.localVmActivity.get(key) === activity) this.localVmActivity.delete(key) })
+          .catch(() => {}).finally(() => this.localVmChecks.delete(key))
       }
     },5000);this.watchdog.unref()
   }
-  imageAllowed:(id:string,runnerId:string)=>boolean=()=>false
-  async images(owner:string,id:string,payload:Record<string,unknown>){
+  localVmAllowed:(id:string,runnerId:string)=>boolean=()=>false
+  async localVm(owner:string,id:string,payload:Record<string,unknown>){
     const record=this.store.require<RunnerRecord>('_system','runner',id),version=this.auth.pushAuthorizationVersion(owner)
-    if(!record.enabled||!this.online.get(id)?.features.includes('image-management-v1'))throw new HttpError(409,'节点离线或尚不支持镜像管理,请更新并连接 Runner','image_management_unavailable')
+    if(!record.enabled||!this.online.get(id)?.features.includes('local-vm-v1'))throw new HttpError(409,'本地虚拟机执行环境离线或版本过旧,请重新连接或更新','local_vm_unavailable')
     const valid=()=>this.auth.isAdminActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version&&this.store.get<RunnerRecord>('_system','runner',id)?.enabled===true
-    if(!valid())throw new HttpError(403,'镜像管理需要管理员权限','admin_required')
+    if(!valid())throw new HttpError(403,'本地虚拟机设置需要管理员权限','admin_required')
     const key = `${owner}:${id}`
-    if (payload.op === 'start') this.imageActivity.set(key, { owner, runnerId: id })
-    const activity = this.imageActivity.get(key)
-    const result=await this.request(id,'images.manage',{...payload,owner:hash(owner)},valid)
-    if(!valid())throw new HttpError(403,'镜像管理授权已失效','admin_required')
-    if (payload.op === 'status' && result.busy === false && this.imageActivity.get(key) === activity) this.imageActivity.delete(key)
-    else if (payload.op === 'status' && result.busy === true && !activity) this.imageActivity.set(key, { owner, runnerId: id })
+    if (payload.op === 'prepare') this.localVmActivity.set(key, { owner, runnerId: id })
+    const activity = this.localVmActivity.get(key)
+    const result=await this.request(id,'local-vm.manage',{...payload,owner:hash(owner)},valid)
+    if(!valid())throw new HttpError(403,'本地虚拟机设置授权已失效','admin_required')
+    if (payload.op === 'status' && result.busy === false && this.localVmActivity.get(key) === activity) this.localVmActivity.delete(key)
+    else if (payload.op === 'status' && result.busy === true && !activity) this.localVmActivity.set(key, { owner, runnerId: id })
     return result
   }
   records():RunnerRecord[] {return this.store.list('_system','runner')}
@@ -197,7 +197,7 @@ export class RunnerHub {
   }
   middleware():Koa.Middleware {
     return async(ctx,next)=>{
-      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool|check|artifact|control-check|image-check)$/.exec(ctx.path)
+      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool|check|artifact|control-check|local-vm-check)$/.exec(ctx.path)
       if(!match)return next()
       const record=this.store.get<RunnerRecord>('_system','runner',match[1]!),token=ctx.get('authorization').replace(/^Bearer /,'')
       if(ctx.get('origin')||!record?.enabled||!ctx.get('authorization').startsWith('Bearer ')||!timingSafeEqual(Buffer.from(record.tokenHash),Buffer.from(hash(token))))
@@ -214,7 +214,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','image-management-v1','image-ready-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-ready-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
@@ -235,7 +235,7 @@ export class RunnerHub {
       let body:any
       try {body=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'JSON 无效','invalid_json')}
       if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
-      if(match[2]==='image-check'){ctx.body={allowed:typeof body.id==='string'&&this.imageAllowed(body.id,record.id)};return}
+      if(match[2]==='local-vm-check'){ctx.body={allowed:typeof body.id==='string'&&this.localVmAllowed(body.id,record.id)};return}
       if(match[2]==='control-check'){ctx.body={allowed:typeof body.controlId==='string'&&this.controlAllowed(body.controlId,record.id)};return}
       if(match[2]==='artifact'){
         const value=parse(z.object({connectionId:z.string().uuid(),id:z.string().uuid(),phase:z.enum(['begin','append','finish']),name:z.string().min(1).max(240).optional(),size:z.number().int().min(0).max(25*1024*1024).optional(),digest:z.string().regex(/^[a-f0-9]{64}$/).optional(),index:z.number().int().min(0).optional(),data:z.string().max(1024*1024).optional()}).strict(),body)

+ 37 - 1
src/server/sharedComputers.ts

@@ -8,7 +8,7 @@ import {HttpError} from './errors.js'
 import type {WorkspaceNodes} from './workspaceGateway.js'
 import type {RunnerHub} from './runnerHub.js'
 import type {LocalAuthStore} from './localAuth.js'
-export interface SharedComputer {id:string;name:string;memberIds:string[];nodeId:string;profile:string;runnerId:string;archived:boolean;createdAt:number}
+export interface SharedComputer {managedLocalVm?:boolean;id:string;name:string;memberIds:string[];nodeId:string;profile:string;runnerId:string;archived:boolean;createdAt:number}
 export function requireSharedComputer(store:WorkspaceStore,owner:string,agent:{id?:string;nodeId:string;profile:string;computerEnvironmentId?:string}){
   if(!agent.computerEnvironmentId)return
   const shared=store.require<SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId)
@@ -20,6 +20,42 @@ export class SharedComputers {
     if(this.store.list<{agentId:string;status:string}>(owner,'turn').some(work=>ids.includes(work.agentId)&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止这些成员的当前任务,再修改电脑共享','shared_computer_busy')
     if(this.store.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(ids.includes(grant.agentId)||(environmentId&&grant.environmentId===environmentId))))throw new HttpError(409,'请先交还电脑控制权,再修改共享','shared_computer_busy')
   }
+  assertLocalVmIdle(owner:string,ids?:string[]){this.idle(owner,ids??this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.nodeId==='local').map(a=>a.id))}
+  attachLocalVm(owner:string,agent:WorkspaceAgent,runnerId:string){
+    if(agent.nodeId!=='local'||agent.execution!=='computer'||agent.temporaryGoalId||agent.remoteAgentId||agent.computerEnvironmentId)return
+    this.nodes.requireSource(owner,agent)
+    const group=this.store.list<SharedComputer>(owner,'shared-computer').find(g=>!g.archived&&g.managedLocalVm&&g.runnerId===runnerId&&g.profile===agent.profile)
+      ??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile:agent.profile,runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
+    if(!group.memberIds.includes(agent.id))group.memberIds.push(agent.id)
+    this.store.put(owner,'shared-computer',group.id,group)
+    agent.computerEnvironmentId=group.id;agent.computerEnvironmentName=group.name
+  }
+  detachLocalVm(owner:string,agent:WorkspaceAgent){
+    if(!agent.computerEnvironmentId)return
+    const group=this.store.require<SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId)
+    group.memberIds=group.memberIds.filter(id=>id!==agent.id);group.archived=group.memberIds.length===0
+    this.store.put(owner,'shared-computer',group.id,group)
+    delete agent.computerEnvironmentId;delete agent.computerEnvironmentName
+    this.store.put(owner,'agent',agent.id,agent)
+  }
+  setLocalVmMode(owner:string,mode:'shared'|'per-bot',runnerId:string){
+    this.assertLocalVmIdle(owner)
+    this.store.atomic(()=>{
+      const agents=this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.nodeId==='local'&&!a.archived&&!a.remoteAgentId&&!a.temporaryGoalId)
+      const groups=this.store.list<SharedComputer>(owner,'shared-computer').filter(g=>g.nodeId==='local'&&g.runnerId===runnerId)
+      for(const group of groups){group.archived=true;this.store.put(owner,'shared-computer',group.id,group)}
+      for(const agent of agents){
+        if(agent.computerEnvironmentId&&groups.some(g=>g.id===agent.computerEnvironmentId)){delete agent.computerEnvironmentId;delete agent.computerEnvironmentName}
+      }
+      if(mode==='shared')for(const profile of new Set(agents.filter(a=>a.execution==='computer'&&!a.computerEnvironmentId).map(a=>a.profile))){
+        const members=agents.filter(a=>a.execution==='computer'&&!a.computerEnvironmentId&&a.profile===profile)
+        const group=groups.find(g=>g.profile===profile&&g.runnerId===runnerId)??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile,runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
+        group.archived=false;group.managedLocalVm=true;group.memberIds=members.map(a=>a.id);this.store.put(owner,'shared-computer',group.id,group)
+        for(const agent of members){agent.computerEnvironmentId=group.id;agent.computerEnvironmentName=group.name}
+      }
+      for(const agent of agents){agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)}
+    })
+  }
   create(owner:string,input:unknown){
     const body=parse(z.object({requestId:z.string().uuid(),name:z.string().trim().min(1).max(100),memberIds:z.array(z.string().uuid()).min(2).max(8),trusted:z.literal(true)}).strict(),input)
     return this.store.command(owner,body.requestId,{operation:'computer.share',...body},()=>this.store.atomic(()=>{

+ 2 - 0
src/server/workspaceStore.ts

@@ -100,6 +100,7 @@ export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
   return parsed.data
 }
 export class WorkspaceStore {
+  prepareAgent?: (owner:string,agent:Agent)=>void
   private observers = new Set<(owner: string, event: WorkspaceEvent) => void>()
   observe(listener: (owner: string, event: WorkspaceEvent) => void): () => void {
     this.observers.add(listener)
@@ -311,6 +312,7 @@ export class WorkspaceStore {
         createdAt: now,
         updatedAt: now,
       }
+      this.prepareAgent?.(owner,agent)
       this.put(owner, 'agent', id, agent)
       if(origin?.temporaryGoalId){this.event(owner,'agent.changed',agent);return agent}
       const conversation: Conversation = {

+ 2 - 1
src/shared/computerControl.ts

@@ -1,7 +1,8 @@
 export interface ComputerFrame {id:string;generation:number;data:string;width:number;height:number;capturedAt:number}
-export interface ComputerControlStatus {mode:'off'|'agent'|'pausing'|'human'|'resuming'|'error';generation?:number;controlId?:string;canResume?:boolean;error?:string}
+export interface ComputerControlStatus {mode:'off'|'idle'|'agent'|'pausing'|'human'|'resuming'|'error';generation?:number;controlId?:string;canResume?:boolean;error?:string}
 export type ComputerInput =
   | {kind:'click';x:number;y:number;button?:'left'|'right'|'middle';count?:number}
+  | {kind:'drag';fromX:number;fromY:number;toX:number;toY:number}
   | {kind:'text';text:string}
   | {kind:'key';key:string;modifiers?:string[]}
   | {kind:'scroll';direction:'up'|'down'|'left'|'right';amount?:number}

+ 0 - 3
src/shared/imageManagement.ts

@@ -1,3 +0,0 @@
-export type ImageOperation='prepare'|'verify'|'activate'|'rollback'|'import'|'export'
-export interface ImageJob {id:string;operation:ImageOperation;state:'running'|'complete'|'failed'|'uncertain';message:string;imageId?:string;createdAt:number;updatedAt:number;archiveSize?:number;manifest?:Record<string,unknown>}
-export interface ImageInventory {configured:boolean;activeImageId?:string;previousImageId?:string;images:Array<{imageId:string;architecture:string;driver:string}>;jobs:ImageJob[];busy:boolean;uploads?:Array<{id:string;size:number;offset:number}>}

+ 24 - 0
src/shared/localVm.ts

@@ -0,0 +1,24 @@
+export type LocalVmMode = 'shared' | 'per-bot'
+export type LocalVmAction = 'create' | 'start' | 'stop' | 'recreate' | 'remove'
+export interface LocalVmStatus {
+  configured: boolean
+  runtime?: 'docker' | 'podman'
+  daemonUp: boolean
+  image: boolean
+  imageId?: string
+  mode: LocalVmMode
+  maxInstances: number
+  busy: boolean
+  problem?: string
+  job?: { id: string; state: 'running' | 'complete' | 'failed'; message: string }
+  instances?: Array<{id:string;status:string;name?:string;orphaned?:boolean}>
+}
+export interface LocalVmInstance {
+  container: 'missing' | 'stopped' | 'running'
+  ready: boolean
+  inUse: boolean
+  mode: LocalVmMode
+  maxInstances: number
+  image: boolean
+  problem?: string
+}

+ 1 - 1
src/shared/runner.ts

@@ -11,7 +11,7 @@ export interface RunnerRecord {
 }
 export interface RunnerCommand {
   id: string
-  kind: 'images.manage' | 'computer.control' | 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
+  kind: 'local-vm.manage' | 'computer.control' | 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
   payload: Record<string, unknown>
   expiresAt: number
 }

+ 86 - 96
tests/e2e/team-tools.spec.ts

@@ -113,106 +113,96 @@ test('registers an execution node, downloads its private config and disables it'
   expect((await (await page.request.get('/api/app/admin/runners')).json()).runners.find((r:any)=>r.id===configuration.runnerId).enabled).toBe(false)
 })
 
-test('explains isolated execution and keeps the edit form intact when a computer Runner is missing',async({page},testInfo)=>{
-  await page.goto('/conversations')
-  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
-  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
-  await page.getByRole('button',{name:'登录',exact:true}).click()
-  await page.locator('.desktop-sidebar .sidebar-create-trigger').click()
-  await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click()
-  const dialog=page.getByRole('dialog')
-  await dialog.getByRole('textbox',{name:'名称',exact:true}).fill('浏览器隔离成员')
-  await dialog.getByRole('combobox',{name:'执行环境',exact:true}).selectOption('computer')
-  await expect(dialog).toContainText('联网范围由执行节点的配置决定')
-  await dialog.getByRole('button',{name:'保存',exact:true}).click()
-  await expect(dialog.getByText(/隔离电脑需要连接执行节点/)).toBeVisible()
-  await expect(dialog).toBeVisible()
-  await expect(dialog.getByRole('combobox',{name:'执行环境',exact:true})).toHaveValue('computer')
-  await page.setViewportSize({width:390,height:844})
-  await dialog.getByRole('combobox',{name:'执行环境',exact:true}).scrollIntoViewIfNeeded()
-  await page.screenshot({path:testInfo.outputPath('computer-execution-mobile.png')})
-})
-
-test('computer viewing is read-only until takeover and returns control with a user note',async({page},testInfo)=>{
-  await page.goto('/conversations')
-  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
-  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
-  await page.getByRole('button',{name:'登录',exact:true}).click()
-  await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
-  const seeded=await page.request.post('/__test/computer',{data:{}});expect(seeded.ok(),await seeded.text()).toBe(true)
-  const fixture=await seeded.json();expect(fixture.conversationId).toBeTruthy()
-  await page.goto(`/conversations/${fixture.conversationId}`)
-  await page.getByRole('button',{name:'电脑',exact:true}).click()
-  const panel=page.getByRole('dialog',{name:'隔离电脑',exact:true}),screen=panel.getByRole('img',{name:'隔离电脑当前画面'})
-  await expect(screen).toBeVisible()
-  await screen.click()
-  expect((await (await page.request.get('/__test/computer')).json()).actions).toEqual([])
-  await panel.getByRole('button',{name:'接管电脑',exact:true}).click()
-  await expect(panel).toContainText('人工控制中')
-  await panel.getByRole('textbox',{name:'输入到电脑',exact:true}).fill('人工输入验收')
-  await panel.getByRole('button',{name:'输入文字',exact:true}).click()
-  await expect.poll(async()=>(await (await page.request.get('/__test/computer')).json()).actions).toEqual([{kind:'text',text:'人工输入验收'}])
-  await panel.getByRole('textbox',{name:'交还说明',exact:true}).fill('已完成设置,请继续任务')
-  await page.setViewportSize({width:390,height:844})
-  await panel.getByRole('button',{name:'交还 Agent 并继续',exact:true}).scrollIntoViewIfNeeded()
-  await page.screenshot({path:testInfo.outputPath('computer-panel-mobile.png')})
-  await panel.getByRole('button',{name:'交还 Agent 并继续',exact:true}).click()
-  await expect(panel.getByRole('button',{name:'接管电脑',exact:true})).toBeVisible()
-  await panel.getByRole('button',{name:'关闭',exact:true}).click()
-  await expect(panel).not.toBeVisible()
+test('configures the Agent computer in its chat panel and keeps preparation in app settings',async({page},testInfo)=>{
+ await page.goto('/conversations')
+ await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
+ await page.locator('.desktop-sidebar .sidebar-create-trigger').click();await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click()
+ const editor=page.getByRole('dialog');await editor.getByRole('textbox',{name:'名称',exact:true}).fill('尚未配置的本地电脑')
+ await expect(editor.getByRole('combobox',{name:'执行环境'})).toHaveCount(0)
+ await editor.getByRole('button',{name:'保存',exact:true}).click()
+ await page.getByRole('button',{name:'电脑',exact:true}).click()
+ const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
+ await dock.getByRole('combobox',{name:'此 Agent 使用的电脑'}).selectOption('vm')
+ await expect(dock.getByRole('button',{name:'设置本地虚拟机',exact:true})).toBeVisible()
+ await page.screenshot({path:testInfo.outputPath('local-vm-unconfigured-agent.png')})
 })
 
+async function login(page:any,ready=false){
+ await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
+ await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
+ await page.request.post('/__test/local-vm'+(ready?'?ready=1':''),{data:{}})
+}
 
-test('trusted shared computers require an explicit data-sharing grant and can be detached',async({page},testInfo)=>{
-  await page.goto('/conversations')
-  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
-  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
-  await page.getByRole('button',{name:'登录',exact:true}).click()
-  await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
-  const seeded=await page.request.post('/__test/computer?shared=1',{data:{}});expect(seeded.ok(),await seeded.text()).toBe(true)
-  const fixture=await seeded.json();await page.goto(`/conversations/${fixture.conversationId}`)
-  await page.getByRole('button',{name:'电脑',exact:true}).click()
-  const panel=page.getByRole('dialog',{name:'隔离电脑',exact:true})
-  await panel.getByRole('button',{name:'电脑共享设置',exact:true}).click()
-  const create=panel.getByRole('button',{name:'创建可信共享电脑',exact:true})
-  await expect(create).toBeDisabled()
-  await panel.getByRole('checkbox',{name:'我确认这些成员可以访问同一工作区和浏览器登录资料'}).check()
-  await create.click()
-  await expect(panel).toContainText('共享电脑:团队共享电脑')
-  await page.setViewportSize({width:390,height:844})
-  await panel.getByRole('button',{name:'电脑共享设置',exact:true}).click()
-  await panel.getByRole('button',{name:'解除这台电脑的共享',exact:true}).scrollIntoViewIfNeeded()
-  await page.screenshot({path:testInfo.outputPath('shared-computer-mobile.png')})
-  await panel.getByRole('button',{name:'解除这台电脑的共享',exact:true}).click()
-  await expect(panel).not.toContainText('共享电脑:团队共享电脑')
-  const agents=(await (await page.request.get('/api/app/agents')).json()).agents
-  expect(agents.find((agent:any)=>agent.id===fixture.agentId).computerEnvironmentId).toBeUndefined()
+test('Local VM settings replace the image catalog and prepare the managed desktop',async({page},testInfo)=>{
+ await login(page)
+ await expect(page.getByRole('button',{name:'电脑与环境',exact:true})).toHaveCount(0)
+ await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
+ await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
+ const settings=page.getByRole('dialog',{name:'设置中心'})
+ await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
+ const panel=settings.getByRole('region',{name:'本地虚拟机设置'})
+ await expect(panel).toContainText('隔离方式')
+ await expect(panel.getByRole('button',{name:'上传并导入'})).toHaveCount(0)
+ await panel.getByRole('button',{name:'准备本地虚拟机',exact:true}).click()
+ await expect(panel).toContainText('虚拟机环境已就绪')
+ await panel.getByRole('combobox',{name:'虚拟机数量上限'}).selectOption('3')
+ await expect(panel.getByRole('combobox',{name:'虚拟机数量上限'})).toHaveValue('3')
+ await panel.getByRole('heading',{name:'本地虚拟机',exact:true}).scrollIntoViewIfNeeded()
+ await page.screenshot({path:testInfo.outputPath('local-vm-settings.png')})
+ expect((await page.request.get('/api/app/admin/runners/11111111-1111-4111-8111-111111111111/images')).status()).toBe(404)
 })
 
+test('Agent computer appears as a right panel and opens an interactive desktop',async({page},testInfo)=>{
+ await login(page)
+ const seed=await(await page.request.post('/__test/computer?off=1',{data:{}})).json()
+ await page.goto(`/conversations/${seed.conversationId}`)
+ await page.getByRole('button',{name:'电脑',exact:true}).click()
+ const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
+ await expect(dock).toBeVisible();await expect(dock).toContainText('本地虚拟机')
+ await dock.getByRole('button',{name:'设置本地虚拟机',exact:true}).click()
+ const settings=page.getByRole('dialog',{name:'设置中心'})
+ await settings.getByRole('button',{name:'准备本地虚拟机',exact:true}).click()
+ await settings.getByRole('button',{name:'关闭设置中心',exact:true}).click()
+ await expect(dock.getByRole('button',{name:/创建.*的虚拟机/})).toBeVisible({timeout:12000})
+ await dock.getByRole('button',{name:/创建.*的虚拟机/}).click()
+ await expect(dock.getByRole('img')).toBeVisible()
+ const bounds=await dock.boundingBox();expect(bounds!.x).toBeGreaterThan(500)
+ await page.screenshot({path:testInfo.outputPath('agent-local-vm-panel.png')})
+ expect((await(await page.request.get('/__test/computer')).json()).actions).toEqual([])
+ await dock.getByRole('button',{name:'打开桌面',exact:true}).click()
+ const viewer=page.getByRole('dialog',{name:'隔离电脑',exact:true})
+ await expect(viewer).toContainText('人工控制中')
+ await viewer.getByRole('button',{name:'键盘与操作',exact:true}).click()
+ await viewer.getByRole('textbox',{name:'输入到电脑',exact:true}).fill('本地虚拟机交互验收')
+ await viewer.getByRole('button',{name:'输入文字',exact:true}).click()
+ await expect.poll(async()=>(await(await page.request.get('/__test/computer')).json()).actions).toEqual([{kind:'text',text:'本地虚拟机交互验收'}])
+ await viewer.getByRole('img').dblclick()
+ await expect.poll(async()=>(await(await page.request.get('/__test/computer')).json()).actions.filter((a:any)=>a.kind==='click')).toMatchObject([{kind:'click',button:'left',count:2}])
+ await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
+ await dock.getByRole('button',{name:'停止虚拟机',exact:true}).click()
+ await expect(dock.getByRole('button',{name:'启动虚拟机',exact:true})).toBeVisible()
+})
 
-test('always exposes computer management and performs image and sharing actions through the UI',async({page},testInfo)=>{
- await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
- await expect(page.locator('.desktop-sidebar .environment-entry')).toBeVisible()
- await page.request.post('/__test/image-management',{data:{}})
- await page.locator('.desktop-sidebar .environment-entry button').click()
- const hub=page.getByRole('dialog',{name:'电脑与环境',exact:true})
- await expect(hub).toBeVisible();await expect(hub.getByText('尚未配置电脑',{exact:true}).first()).toBeVisible()
- await hub.getByRole('button',{name:'镜像',exact:true}).click()
- await expect(hub.getByRole('button',{name:'准备兼容镜像'})).toBeVisible()
- await expect(hub.getByRole('region',{name:'桌面镜像状态'})).toContainText('桌面镜像已应用')
- await page.screenshot({path:testInfo.outputPath('image-desktop-overview.png')})
- const rows=hub.locator('.image-card');await expect(rows.first()).toContainText('当前使用')
- await rows.nth(1).getByRole('button',{name:'应用',exact:true}).click()
- await expect(rows.nth(1)).toContainText('当前使用')
- await hub.getByRole('button',{name:'回退到上一镜像'}).click();await expect(rows.first()).toContainText('当前使用')
- await rows.first().getByRole('button',{name:'导出',exact:true}).click()
- await expect(hub.getByRole('link',{name:'下载镜像',exact:true})).toBeVisible()
- const download=page.waitForEvent('download');await hub.getByRole('link',{name:'下载镜像',exact:true}).click();expect((await download).suggestedFilename()).toBe('computer-image.tar')
- await page.setViewportSize({width:375,height:844});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'});await hub.getByRole('button',{name:'准备兼容镜像'}).scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('environment-images-mobile.png')})
- await hub.getByRole('button',{name:'共享环境',exact:true}).click();await expect(hub.getByText('创建共享环境',{exact:true}).first()).toBeVisible()
- await hub.getByRole('button',{name:'关闭',exact:true}).click()
- await page.route('**/api/app/computers',route=>route.fulfill({status:404,contentType:'application/json',body:'{"error":"old server"}'}))
- await page.setViewportSize({width:1280,height:900});await page.locator('.desktop-sidebar .environment-entry button').click()
- await expect(page.getByRole('dialog',{name:'电脑与环境'})).toContainText('当前连接的服务尚未提供电脑管理')
- await page.screenshot({path:testInfo.outputPath('environment-old-server.png')})
+test('two desktops are watch-only on opening and release before changing the controlled pane',async({page},testInfo)=>{
+ await login(page,true)
+ const first=await(await page.request.post('/__test/computer',{data:{}})).json()
+ const second=await(await page.request.post('/__test/computer',{data:{}})).json()
+ await page.goto(`/conversations/${first.conversationId}`)
+ await page.getByRole('button',{name:'电脑',exact:true}).click()
+ const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
+ await expect(dock.getByRole('button',{name:'打开双桌面'})).toBeVisible()
+ await dock.getByRole('button',{name:'打开双桌面'}).click()
+ const workspace=page.getByRole('main',{name:'双桌面工作区'})
+ await expect(workspace.locator('.pane')).toHaveCount(2)
+ await workspace.getByRole('combobox',{name:'选择桌面 2'}).selectOption(second.agentId)
+ await expect(workspace.locator('.pane').last().getByRole('img')).toBeVisible()
+ await expect(workspace.getByRole('button',{name:'接管电脑',exact:true})).toHaveCount(2)
+ await workspace.locator('.pane').first().getByRole('button',{name:'接管电脑',exact:true}).click()
+ await expect(workspace.locator('.pane').first()).toContainText('人工控制中')
+ await workspace.locator('.pane').last().getByRole('button',{name:'接管电脑',exact:true}).click()
+ await expect(workspace.locator('.pane').first().getByRole('button',{name:'接管电脑',exact:true})).toBeVisible()
+ await expect(workspace.locator('.pane').last()).toContainText('人工控制中')
+ await page.screenshot({path:testInfo.outputPath('local-vm-two-desktops.png')})
+ await workspace.getByRole('button',{name:'交还并关闭',exact:true}).click()
+ await expect(workspace).toHaveCount(0)
 })

+ 15 - 21
tests/fixtures/workspace-server.ts

@@ -356,7 +356,9 @@ runtime.runners.computerRunner=(owner,agent)=>computerFixtures.has(agent.id)?{id
 runtime.runners.computer=async(owner,agent,op,data,authorize)=>{
   const state=computerFixtures.get(agent.id);if(!state)return originalComputer(owner,agent,op,data,authorize)
   authorize()
-  if(op==='frame')return {id:'22222222-2222-4222-8222-222222222222',generation:state.generation,width:512,height:512,capturedAt:Date.now(),data:readFileSync(resolve('public/icons/icon-512.png')).toString('base64')}
+  if(op==='detail')return {enabled:agent.execution==='computer',container:state.mode==='off'?'missing':state.mode==='stopped'?'stopped':'running',ready:!['off','stopped'].includes(state.mode),inUse:state.mode==='human',controlMode:state.mode,image:vmFixture.image,mode:agent.computerEnvironmentId?'shared':'per-bot',maxInstances:vmFixture.maxInstances}
+  if(op==='lifecycle'){state.mode=data.action==='remove'?'off':data.action==='stop'?'stopped':'idle';state.generation++;return {ok:true}}
+  if(op==='frame'){const png=readFileSync(process.env.YAOYAO_VM_PREVIEW_PNG||resolve('public/icons/icon-512.png'));return {id:'22222222-2222-4222-8222-222222222222',generation:state.generation,width:png.readUInt32BE(16),height:png.readUInt32BE(20),capturedAt:Date.now(),data:png.toString('base64')}}
   if(op==='take'){state.controlId=String(data.controlId);state.mode='human';state.generation++}
   if(op==='input')state.actions.push(data.action)
   if(op==='giveback'){state.mode='agent';state.controlId=undefined;state.generation++}
@@ -366,8 +368,8 @@ runtime.app.use((ctx,next)=>{
   if(ctx.path!=='/__test/computer')return next()
   const owner=auth.require(ctx).id
   if(ctx.method==='POST'){
-    const agent=runtime.workspace.createAgent(owner,{name:ctx.query.shared==='1'?'共享电脑主成员':'电脑面板验收',profile:'default',execution:'computer'})
-    computerFixtures.set(agent.id,{mode:'agent',generation:1,actions:[]})
+    const agent=runtime.workspace.createAgent(owner,{name:(ctx.query.shared==='1'?'共享电脑主成员':'电脑面板验收')+' '+(computerFixtures.size+1),profile:'default',execution:'computer'})
+    computerFixtures.set(agent.id,{mode:ctx.query.off==='1'?'off':'idle',generation:1,actions:[]})
     if(ctx.query.shared==='1'){
       const second=runtime.workspace.createAgent(owner,{name:'可信共享成员',profile:'default',execution:'computer'})
       computerFixtures.set(second.id,{mode:'off',generation:1,actions:[]})
@@ -378,25 +380,17 @@ runtime.app.use((ctx,next)=>{
   }
   ctx.body={actions:[...computerFixtures.values()].flatMap(state=>state.actions)}
 })
-const imageFixture={activeImageId:'sha256:'+'a'.repeat(64),previousImageId:undefined as string|undefined,images:[{imageId:'sha256:'+'a'.repeat(64),architecture:'arm64',driver:'0.20.0'},{imageId:'sha256:'+'b'.repeat(64),architecture:'arm64',driver:'0.20.0'}],jobs:[] as any[],uploads:[] as any[]}
-let imageRunnerId=''
-const originalSummary=runtime.runners.summary.bind(runtime.runners),originalImages=runtime.runners.images.bind(runtime.runners)
-runtime.runners.summary=record=>record.id===imageRunnerId?{...originalSummary(record),online:true,features:['computer-worker-v1','computer-control-v1','image-management-v1','image-ready-v1']}:originalSummary(record)
-runtime.runners.images=async(owner,id,p)=>{
- if(id!==imageRunnerId)return originalImages(owner,id,p)
- if(p.op==='status')return {configured:true,busy:false,...imageFixture}
- if(p.op==='start'){
-  if(p.operation==='activate'||p.operation==='rollback'){const old=imageFixture.activeImageId;imageFixture.activeImageId=String(p.operation==='rollback'?imageFixture.previousImageId:p.imageId);imageFixture.previousImageId=old}
-  const job={id:p.id,operation:p.operation,state:'complete',message:'操作完成',createdAt:Date.now(),updatedAt:Date.now(),...(p.operation==='export'?{archiveSize:7,manifest:{protocol:1}}:{})};imageFixture.jobs.unshift(job);return {job}
- }
- if(p.op==='download')return {data:Buffer.from('fixture').toString('base64'),offset:0,size:7,manifest:{protocol:1}}
- if(p.op==='upload'){imageFixture.uploads.push({id:p.id,size:p.size,offset:0});return {id:p.id,offset:0}}
- if(p.op==='append'){const upload=imageFixture.uploads.find(u=>u.id===p.id);upload.offset+=Buffer.from(String(p.data),'base64').length;return {offset:upload.offset}}
- if(p.op==='remove-export'){delete imageFixture.jobs.find(j=>j.id===p.id).archiveSize;return {ok:true}}
- if(p.op==='cancel-upload'){imageFixture.uploads=imageFixture.uploads.filter(u=>u.id!==p.id);return {ok:true}}
- return {ok:true}
+const vmFixture={configured:true,runtime:'docker',daemonUp:true,image:false,mode:'per-bot',maxInstances:2,busy:false,job:undefined as any}
+let vmRunnerId=''
+const originalSummary=runtime.runners.summary.bind(runtime.runners),originalLocalVm=runtime.runners.localVm.bind(runtime.runners)
+runtime.runners.summary=record=>record.id===vmRunnerId?{...originalSummary(record),online:true,features:['computer-worker-v1','computer-control-v1','local-vm-v1','image-ready-v1']}:originalSummary(record)
+runtime.runners.localVm=async(owner,id,p)=>{
+ if(id!==vmRunnerId)return originalLocalVm(owner,id,p)
+ if(p.op==='prepare'){vmFixture.image=true;vmFixture.job={id:p.id,state:'complete',message:'本地虚拟机已就绪'}}
+ if(p.op==='policy'){vmFixture.mode=String(p.mode);vmFixture.maxInstances=Number(p.maxInstances)}
+ return {...vmFixture}
 }
-runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/image-management')return next();const owner=auth.requireAdmin(ctx).id;imageRunnerId=runtime.runners.enroll(owner,{name:'镜像管理验收节点',allowedProfiles:['default']}).runner.id;ctx.body={runnerId:imageRunnerId}})
+runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/local-vm')return next();const owner=auth.requireAdmin(ctx).id;vmFixture.image=ctx.query.ready==='1';vmFixture.mode='per-bot';vmFixture.maxInstances=2;if(!vmRunnerId){for(const record of runtime.runners.records())if(record.sourceNodeId==='local'&&record.enabled)runtime.runners.remove(record.id);vmRunnerId=runtime.runners.enroll(owner,{name:'本机虚拟机',allowedProfiles:['default']}).runner.id;}ctx.body={runnerId:vmRunnerId}})
 runtime.app.use(serve(resolve('dist')))
 runtime.app.use((ctx) => {
   if (!ctx.path.startsWith('/api/')) {

+ 117 - 0
tests/live/localVm.live.test.ts

@@ -0,0 +1,117 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'
+import {readFileSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {createServer} from 'node:http'
+import {chromium} from '@playwright/test'
+import serve from 'koa-static'
+import request from 'supertest'
+import {createApplication,createNodeServer} from '../../src/server/app'
+import {loadServerConfig} from '../../src/server/config'
+
+it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)('prepares the local runner automatically and operates a real desktop from the Agent chat panel',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'yaoyao-local-vm-ui-')),hermes=join(root,'hermes'),evidence=resolve(process.env.YAOYAO_VM_EVIDENCE??'test-results/local-vm-live')
+ await mkdir(hermes)
+ const upstream=createServer((req,res)=>{
+  res.setHeader('Content-Type','application/json')
+  const path=new URL(req.url!,'http://fixture').pathname
+  res.end(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'}]}:path==='/api/config'?{terminal:{cwd:'/projects/local-vm'}}:{ok:true}))
+ })
+ await new Promise<void>(done=>upstream.listen(0,'127.0.0.1',done))
+ const upstreamURL=`http://127.0.0.1:${(upstream.address() as any).port}`
+ await writeFile(join(hermes,'config.yaml'),`model:\n  provider: custom\n  default: fixture-model\n  base_url: ${upstreamURL}/v1\n  api_key: fixture\nterminal:\n  cwd: /projects/local-vm\n`,{mode:0o600})
+ const app=createApplication({config:loadServerConfig({HERMES_YAOYAO_HOME:join(root,'web'),HERMES_YAOYAO_UPSTREAM:upstreamURL})})
+ vi.spyOn(app.localVm as any,'installation').mockReturnValue({hermesHome:hermes,hermesSource:process.env.YAOYAO_HERMES_SOURCE,python:process.env.YAOYAO_HERMES_PYTHON})
+ app.app.use(serve(resolve('dist')))
+ app.app.use(ctx=>{if(!ctx.path.startsWith('/api/')){ctx.type='html';ctx.body=readFileSync(resolve('dist/index.html'))}})
+ const node=createNodeServer(app)
+ await new Promise<void>(done=>node.server.listen(0,'127.0.0.1',done))
+ app.config.port=(node.server.address() as any).port
+ const address=`http://127.0.0.1:${app.config.port}`
+ let browser:Awaited<ReturnType<typeof chromium.launch>>|undefined
+ try{
+  const bootstrap=await request(address).get('/api/app/bootstrap').expect(200)
+  const setup=await request(address).post('/api/app/setup').set('Origin',address).set('Cookie',bootstrap.headers['set-cookie'].map((s:string)=>s.split(';')[0]).join('; ')).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'vm-ui-fixture',password:'vm-ui-fixture-password'}).expect(200)
+  const owner=setup.body.user.id
+  const agent=app.workspace.createAgent(owner,{name:'本地虚拟机验收',profile:'default'})
+  const conversation=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!
+  browser=await chromium.launch({headless:true})
+  const context=await browser.newContext({viewport:{width:1440,height:960},locale:'zh-CN'})
+  const page=await context.newPage()
+  const inputs:unknown[]=[],failures:unknown[]=[]
+  page.on('request',r=>{if(r.url().endsWith('/computer/input'))inputs.push(r.postDataJSON()?.action)})
+  page.on('response',r=>{if(r.url().includes('/computer')&&r.status()>=400)void r.json().then(value=>failures.push(value))})
+  await page.goto(address+'/conversations')
+  await page.getByRole('textbox',{name:'账号',exact:true}).fill('vm-ui-fixture')
+  await page.getByRole('textbox',{name:'密码',exact:true}).fill('vm-ui-fixture-password')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
+  await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
+  await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
+  const settings=page.getByRole('dialog',{name:'设置中心'})
+  await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
+  await settings.getByRole('button',{name:'准备本地虚拟机',exact:true}).click()
+  await expect.poll(async()=>await settings.innerText(),{timeout:120000}).toContain('虚拟机环境已就绪')
+  expect(app.runners.records()).toHaveLength(1)
+  expect(app.runners.records()[0]?.name).toBe('本机虚拟机')
+  await mkdir(evidence,{recursive:true})
+  await page.screenshot({path:join(evidence,'settings.png')})
+  await settings.getByRole('button',{name:'关闭设置中心'}).click()
+  await page.goto(address+`/conversations/${conversation.id}`)
+  await page.getByRole('button',{name:'电脑',exact:true}).click()
+  const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
+  await dock.getByRole('combobox',{name:'此 Agent 使用的电脑'}).selectOption('vm')
+  await dock.getByRole('button',{name:/创建.*的虚拟机/}).click()
+  await expect.poll(async()=>await dock.getByRole('img').count(),{timeout:60000}).toBe(1)
+  await page.screenshot({path:join(evidence,'agent-panel.png')})
+  await dock.getByRole('button',{name:'打开桌面',exact:true}).click()
+  const viewer=page.getByRole('dialog',{name:'隔离电脑',exact:true}),screen=viewer.getByRole('img')
+  await expect.poll(async()=>await viewer.innerText(),{timeout:30000}).toContain('人工控制中')
+  await expect.poll(()=>viewer.locator('.computer-screen.controlling').count(),{timeout:15000}).toBe(1)
+  await expect.poll(()=>screen.evaluate(e=>(e as HTMLImageElement).naturalWidth),{timeout:10000}).toBeGreaterThan(1000)
+  const box=(await screen.boundingBox())!
+  await screen.click({position:{x:box.width*568/1280,y:box.height*875/900}})
+  await page.waitForTimeout(1500)
+  await screen.focus()
+  await page.keyboard.type('printf vm-ui-proof > /projects/local-vm/proof.txt',{delay:25})
+  await page.keyboard.press('Enter')
+  const workspace=join(root,'web','runner-state','local-vm',app.runners.records()[0]!.id,'computer-workspaces',agent.id)
+  try {await expect.poll(()=>readFile(join(workspace,'proof.txt'),'utf8').catch(()=>''),{timeout:30000}).toBe('vm-ui-proof')}
+  catch(error){await page.screenshot({path:join(evidence,'input-failed.png')});throw new Error(`${String(error)}\n${JSON.stringify({inputs,failures,text:await viewer.innerText()})}`)}
+  await screen.focus()
+  await page.keyboard.type('erase-this',{delay:20});await page.keyboard.press('Control+u')
+  await page.keyboard.type('printf hotkey-proof > /projects/local-vm/hotkey.txt',{delay:20});await page.keyboard.press('Enter')
+  await expect.poll(()=>readFile(join(workspace,'hotkey.txt'),'utf8').catch(()=>''),{timeout:30000}).toBe('hotkey-proof')
+  await page.screenshot({path:join(evidence,'interactive-desktop.png')})
+  await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
+  await dock.getByRole('button',{name:'停止虚拟机',exact:true}).click()
+  await expect.poll(async()=>await dock.innerText(),{timeout:20000}).toContain('虚拟机已停止')
+  await dock.getByRole('button',{name:'启动虚拟机',exact:true}).click()
+  await expect.poll(async()=>await dock.getByRole('img').count(),{timeout:60000}).toBe(1)
+  expect(await readFile(join(workspace,'proof.txt'),'utf8')).toBe('vm-ui-proof')
+  const second=app.workspace.createAgent(owner,{name:'第二台本地虚拟机',profile:'default',execution:'computer'})
+  const cookies=setup.headers['set-cookie'].map((s:string)=>s.split(';')[0]).join('; ')
+  const fresh=await request(address).get('/api/app/bootstrap').set('Cookie',cookies).expect(200)
+  await request(address).post(`/api/app/agents/${second.id}/local-vm/create`).set('Origin',address).set('Cookie',cookies).set('X-CSRF-Token',fresh.body.csrfToken).send({}).expect(200)
+  await expect.poll(async()=>(await request(address).get(`/api/app/agents/${second.id}/local-vm`).set('Cookie',cookies)).body.ready,{timeout:30000}).toBe(true)
+  await page.reload();await page.getByRole('button',{name:'电脑',exact:true}).click()
+  await dock.getByRole('button',{name:'打开双桌面',exact:true}).click()
+  const desktops=page.getByRole('main',{name:'双桌面工作区'})
+  await desktops.getByRole('combobox',{name:'选择桌面 2'}).selectOption(second.id)
+  await expect.poll(()=>desktops.getByRole('img').count(),{timeout:30000}).toBe(2)
+  await desktops.locator('.pane').first().getByRole('button',{name:'接管电脑',exact:true}).click()
+  await expect.poll(async()=>await desktops.locator('.pane').first().innerText(),{timeout:15000}).toContain('人工控制中')
+  await desktops.locator('.pane').last().getByRole('button',{name:'接管电脑',exact:true}).click()
+  await expect.poll(async()=>await desktops.locator('.pane').last().innerText(),{timeout:15000}).toContain('人工控制中')
+  await expect.poll(async()=>await desktops.locator('.pane').first().innerText(),{timeout:15000}).toContain('仅查看')
+  await expect.poll(()=>desktops.getByRole('img').count(),{timeout:15000}).toBe(2)
+  await expect.poll(()=>desktops.locator('.pane').last().locator('.computer-screen.controlling').count(),{timeout:15000}).toBe(1)
+  await page.screenshot({path:join(evidence,'two-desktops.png')})
+  await desktops.getByRole('button',{name:'交还并关闭',exact:true}).click()
+ }finally{
+  await browser?.close();await node.close(true)
+  await new Promise<void>(done=>{upstream.close(()=>done());upstream.closeAllConnections()})
+  await rm(root,{recursive:true,force:true})
+ }
+},240000)

+ 9 - 30
tests/live/worker.live.test.ts

@@ -89,36 +89,14 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     }else{const runner=new RunnerAgent(runnerConfig as any,runnerHome);running=runner.run(abort.signal)}
     void running.catch(()=>{})
     await expect.poll(()=>app.runners.summary(app.runners.records()[0]!).online).toBe(true)
-    const imageCookies=setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')
-    const imageBootstrap=await request(address).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)
-    const imageBase=`/api/app/admin/runners/${registered.runner.id}/images`
-    const imagesGet=async()=>(await request(address).get(imageBase).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)).body
-    const imagesPost=(body:unknown)=>request(address).post(imageBase+'/jobs').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',imageCookies).set('X-CSRF-Token',imageBootstrap.body.csrfToken).send(body)
-    const prepareId=randomUUID();await imagesPost({requestId:prepareId,operation:'prepare'}).expect(202)
-    let prepared:any
-    await expect.poll(async()=>{prepared=(await imagesGet()).jobs.find((j:any)=>j.id===prepareId);return prepared?.state},{timeout:90000}).toMatch(/complete|failed/)
-    expect(prepared.state,JSON.stringify(prepared)).toBe('complete')
-    const applyId=randomUUID();await imagesPost({requestId:applyId,operation:'activate',imageId:prepared.imageId}).expect(202)
-    await expect.poll(async()=>(await imagesGet()).jobs.find((j:any)=>j.id===applyId)?.state,{timeout:60000}).toBe('complete')
-    expect((await imagesGet()).activeImageId).toBe(prepared.imageId)
-    const rollbackId=randomUUID();await imagesPost({requestId:rollbackId,operation:'rollback'}).expect(202)
-    await expect.poll(async()=>(await imagesGet()).jobs.find((j:any)=>j.id===rollbackId)?.state,{timeout:60000}).toBe('complete')
-    expect((await imagesGet()).activeImageId).toBe(process.env.YAOYAO_COMPUTER_IMAGE)
-    const exportId=randomUUID();await imagesPost({requestId:exportId,operation:'export',imageId:process.env.YAOYAO_COMPUTER_IMAGE}).expect(202)
-    let exportedImage:any
-    await expect.poll(async()=>{exportedImage=(await imagesGet()).jobs.find((j:any)=>j.id===exportId);return exportedImage?.state},{timeout:60000}).toBe('complete')
-    const uploadId=randomUUID()
-    const uploadAPI=(path:string,body:unknown)=>request(address).post(imageBase+path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',imageCookies).set('X-CSRF-Token',imageBootstrap.body.csrfToken).send(body)
-    await uploadAPI('/uploads',{id:uploadId,size:exportedImage.archiveSize,manifest:exportedImage.manifest}).expect(200)
-    const {createHash}=await import('node:crypto');const archiveHash=createHash('sha256')
-    for(let offset=0;offset<exportedImage.archiveSize;){
-      const part=(await request(address).get(imageBase+`/jobs/${exportId}/chunk?offset=${offset}`).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)).body
-      const bytes=Buffer.from(part.data,'base64');archiveHash.update(bytes)
-      const accepted=await uploadAPI(`/uploads/${uploadId}`,{offset,data:part.data}).expect(200);offset+=bytes.length;expect(accepted.body.offset).toBe(offset)
-    }
-    expect(archiveHash.digest('hex')).toBe(exportedImage.manifest.archiveSha256)
-    const importId=randomUUID();await imagesPost({requestId:importId,operation:'import',uploadId}).expect(202)
-    await expect.poll(async()=>(await imagesGet()).jobs.find((j:any)=>j.id===importId)?.state,{timeout:60000}).toBe('complete')
+    const vmCookies=setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')
+    const vmBootstrap=await request(address).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',vmCookies).expect(200)
+    const vmGet=async()=>(await request(address).get('/api/app/admin/local-vm').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',vmCookies).expect(200)).body
+    const prepareId=randomUUID()
+    await request(address).post('/api/app/admin/local-vm/prepare').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',vmCookies).set('X-CSRF-Token',vmBootstrap.body.csrfToken).send({requestId:prepareId}).expect(200)
+    await expect.poll(async()=>(await vmGet()).job?.state,{timeout:90000}).toBe('complete')
+    expect((await vmGet()).image).toBe(true)
+
 
     const worker=app.workspace.createAgent(owner,{name:'隔离执行成员',profile:'default',execution:'computer'})
     const direct=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===worker.id)!
@@ -195,6 +173,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     await api('/giveback',{controlId:sharedTake.body.controlId,token:sharedTake.body.token,notes:''}).expect(200)
     await sharedAPI('delete',`/api/app/computers/shared/${shared.body.computer.id}`,{}).expect(200)
     expect(app.workspace.require<any>(owner,'agent',worker.id).computerEnvironmentId).toBeUndefined()
+    await sharedAPI('post',`/api/app/admin/local-vm/instances/${shared.body.computer.id}/stop`,{requestId:randomUUID()}).expect(200)
     const manager=app.workspace.createAgent(owner,{name:'隔离组队者',profile:'default',execution:'computer',canManageTeam:true})
     const managerChat=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===manager.id)!
     const managed=app.workspaceRuntime.send(owner,managerChat.id,{requestId:randomUUID(),content:'fixture:team'})

+ 6 - 0
tests/server/computerControl.test.ts

@@ -30,6 +30,12 @@ it('separates view from control and fences stale, duplicate and out-of-frame inp
     expect(await runtime.controls.input(meta,id,requestId,frame.generation,frame.id,action)).toEqual(first)
     expect(execute).toHaveBeenCalledTimes(1)
     await expect(runtime.controls.input(meta,id,requestId,frame.generation,frame.id,{kind:'click',x:30,y:30})).rejects.toMatchObject({code:'idempotency_conflict'})
+    await expect(runtime.controls.input(meta,id,randomUUID(),frame.generation,frame.id,{kind:'drag',fromX:2,fromY:3,toX:101,toY:4})).rejects.toMatchObject({code:'computer_point_invalid'})
+    await runtime.controls.input(meta,id,randomUUID(),frame.generation,frame.id,{kind:'drag',fromX:2,fromY:3,toX:20,toY:30})
+    expect(execute.mock.calls.at(-1)?.[1]).toContain('xdotool')
+    await runtime.controls.input(meta,id,randomUUID(),frame.generation,frame.id,{kind:'key',key:'a',modifiers:['ctrl']})
+    expect(execute.mock.calls.at(-1)?.[1]).toContain('hotkey')
+    expect(JSON.parse(execute.mock.calls.at(-1)![1][3]!)).toMatchObject({keys:['ctrl','a']})
     permitted=false
     await expect(runtime.controls.input(meta,id,randomUUID(),frame.generation,frame.id,action)).rejects.toThrow('revoked')
     await vi.waitFor(async()=>expect((await runtime.controls.status(meta)).mode).toBe('off'),{timeout:6500,interval:100})

+ 5 - 5
tests/server/desktopSync.test.ts

@@ -50,17 +50,17 @@ describe('desktop release selection and integrity', () => {
     release(); unlock()
     const next = updateMutex(home); next()
   })
-  it('waits for asynchronous image jobs even after their start command has returned', async () => {
+  it('waits for Local VM preparation even after its command has returned', async () => {
     const store = { list: () => [], require: () => ({ enabled: true }), get: () => ({ enabled: true }) }
     const auth = { pushAuthorizationVersion: () => 1, isAdminActive: () => true }
     const hub = new RunnerHub(store as any, auth as any, {} as any)
     try {
-      ;(hub as any).online.set('runner', { features: ['image-management-v1'], seen: Date.now() })
-      ;(hub as any).request = async (_id: string, _kind: string, body: any) => body.op === 'start' ? { job: { state: 'running' } } : { busy: false }
+      ;(hub as any).online.set('runner', { features: ['local-vm-v1'], seen: Date.now() })
+      ;(hub as any).request = async (_id: string, _kind: string, body: any) => body.op === 'prepare' ? { job: { state: 'running' } } : { busy: false }
       expect(hub.idleForUpdate).toBe(true)
-      await hub.images('owner', 'runner', { op: 'start' })
+      await hub.localVm('owner', 'runner', { op: 'prepare' })
       expect(hub.idleForUpdate).toBe(false)
-      await hub.images('owner', 'runner', { op: 'status' })
+      await hub.localVm('owner', 'runner', { op: 'status' })
       expect(hub.idleForUpdate).toBe(true)
     } finally { hub.close() }
   })

+ 0 - 48
tests/server/imageManagement.test.ts

@@ -1,48 +0,0 @@
-// @vitest-environment node
-import {it,expect,vi} from 'vitest'
-import {DatabaseSync} from 'node:sqlite'
-import {mkdtemp,rm,writeFile} from 'node:fs/promises'
-import {tmpdir} from 'node:os'
-import {join} from 'node:path'
-import {randomUUID} from 'node:crypto'
-import {ComputerRuntime} from '../../src/runner/worker/gateway'
-import {ImageManagement} from '../../src/runner/computers/imageManagement'
-import {fileSHA256} from '../../src/runner/computers/images'
-it('persists image selection, rejects in-use switching and recovers interrupted jobs without replay',async()=>{
- const home=await mkdtemp(join(tmpdir(),'yaoyao-images-ui-')),db=new DatabaseSync(':memory:'),initial='sha256:'+'a'.repeat(64),next='sha256:'+'b'.repeat(64)
- const config:any={protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:initial,python:'/fixture/python',hermesSource:'/fixture/source',hermesHome:'/fixture/home'}}
- const runtime=new ComputerRuntime(db,config,home);let manager:ImageManagement|undefined
- try{await runtime.ready;manager=new ImageManagement(runtime,db,home);await manager.ready
-  const manifest={protocol:1 as const,imageId:next,architecture:'arm64' as const,driver:'0.20.0' as const,layer:'5' as const,createdAt:1}
-  vi.spyOn(manager.images,'inspect').mockResolvedValue(manifest)
-  vi.spyOn(runtime.provider,'verifyRuntime').mockResolvedValue();vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'fixture',containerId:'fixture',workspace:'/fixture',running:true,isolation:'container'});vi.spyOn(runtime.provider,'health').mockResolvedValue({});vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'fixture',width:1,height:1,capturedAt:1});vi.spyOn(runtime.provider,'remove').mockResolvedValue();vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
-  const release=runtime.pool.beginMaintenance();await expect(manager.start('owner',randomUUID(),'activate',{imageId:next},async()=>{})).rejects.toMatchObject({code:'computer_busy'});release()
-  const id=randomUUID();await manager.start('owner',id,'activate',{imageId:next},async()=>{});await vi.waitFor(()=>expect(runtime.config.imageId).toBe(next));await manager.close()
-  expect(JSON.parse(String(db.prepare('SELECT value FROM managed_image_selection').get()!.value))).toMatchObject({active:next,previous:initial,base:initial})
-  config.computers.imageId=initial
-  const resumed=new ImageManagement(runtime,db,home);await resumed.ready;expect(runtime.config.imageId).toBe(next);await resumed.close()
- }finally{await manager?.close();await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}
-})
-it('uploads ordered bounded chunks, detects conflicting retries and isolates the uploader',async()=>{
- const home=await mkdtemp(join(tmpdir(),'yaoyao-images-upload-')),db=new DatabaseSync(':memory:'),runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'/fixture',hermesHome:'/fixture',hermesSource:'/fixture'}},home)
- const manager=new ImageManagement(runtime,db,home)
- try{await manager.ready;const id=randomUUID(),data=Buffer.from('image-fixture');const path=join(home,'hash');await writeFile(path,data)
-  await manager.beginUpload('owner',id,data.length,{protocol:1,imageId:'sha256:'+'a'.repeat(64),architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1,archiveSha256:await fileSHA256(path)})
-  const bytes=data.toString('base64');expect(await manager.appendUpload('owner',id,0,bytes)).toEqual({offset:data.length});expect(await manager.appendUpload('owner',id,0,bytes)).toEqual({offset:data.length})
-  await expect(manager.appendUpload('other',id,0,bytes)).rejects.toMatchObject({code:'image_upload_missing'})
-  await expect(manager.appendUpload('owner',id,0,Buffer.from('wrong').toString('base64'))).rejects.toThrow()
-  await manager.cancelUpload('owner',id)
- }finally{await manager.close();await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}
-})
-it('keeps the node in maintenance until uncertain validation cleanup is confirmed',async()=>{
- const home=await mkdtemp(join(tmpdir(),'yaoyao-images-cleanup-')),db=new DatabaseSync(':memory:'),initial='sha256:'+'a'.repeat(64)
- const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:initial,python:'/fixture',hermesHome:'/fixture',hermesSource:'/fixture'}},home),manager=new ImageManagement(runtime,db,home)
- let stopped=false
- try{await manager.ready;vi.spyOn(manager.images,'inspect').mockResolvedValue({protocol:1,imageId:'sha256:'+'b'.repeat(64),architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1});vi.spyOn(manager.images,'run').mockResolvedValue('');vi.spyOn(runtime.provider,'verifyRuntime').mockResolvedValue();vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'fixture',containerId:'fixture',running:true,workspace:'/fixture',isolation:'container'});vi.spyOn(runtime.provider,'health').mockResolvedValue({});vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'fixture',width:1,height:1,capturedAt:1});vi.spyOn(runtime.provider,'remove').mockImplementation(async()=>{if(!stopped)throw new Error('stop uncertain')});vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
-  await manager.start('owner',randomUUID(),'activate',{imageId:'sha256:'+'b'.repeat(64)},async()=>{})
-  await vi.waitFor(async()=>expect((await manager.inventory('owner')).jobs[0]?.state).toBe('uncertain'))
-  expect(()=>runtime.pool.beginMaintenance()).toThrow('请先停止');expect(runtime.config.imageId).toBe(initial)
-  stopped=true;expect((await manager.inventory('owner')).busy).toBe(false)
-  const release=runtime.pool.beginMaintenance();release()
- }finally{stopped=true;await manager.close();await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}
-})

+ 0 - 24
tests/server/imageManagementRoutes.test.ts

@@ -1,24 +0,0 @@
-// @vitest-environment node
-import {it,expect,vi} from 'vitest'
-import Koa from 'koa'
-import {bodyParser} from '@koa/bodyparser'
-import request from 'supertest'
-import {mkdtempSync,rmSync} from 'node:fs'
-import {tmpdir} from 'node:os'
-import {join} from 'node:path'
-import {randomUUID} from 'node:crypto'
-import {WorkspaceStore} from '../../src/server/workspaceStore'
-import {ImageManagementService} from '../../src/server/imageManagement'
-import {HttpError} from '../../src/server/errors'
-it('restricts image operations to admins and fences job grants to user version and Runner',async()=>{
- const home=mkdtempSync(join(tmpdir(),'yaoyao-image-route-')),store=new WorkspaceStore(home);let version=1,active=true
- const auth:any={requireAdmin:(ctx:any)=>{if(ctx.get('x-admin')!=='yes')throw new HttpError(403,'管理员权限','admin_required');return {id:'owner'}},isAdminActive:()=>active,pushAuthorizationVersion:()=>version}
- const images=vi.fn(async(owner:string,runnerId:string,p:any)=>({owner,runnerId,job:{id:p.id}})),service=new ImageManagementService(store,auth,{images} as any),app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status||500;ctx.body={error:String(e)}}});app.use(bodyParser());app.use(service.router().routes())
- try{const runner=randomUUID(),id=randomUUID(),base=`/api/app/admin/runners/${runner}/images`
-  await request(app.callback()).post(base+'/jobs').send({requestId:id,operation:'prepare'}).expect(403);expect(images).not.toHaveBeenCalled()
-  await request(app.callback()).post(base+'/jobs').set('x-admin','yes').send({requestId:id,operation:'prepare',path:'/etc/passwd'}).expect(400)
-  await request(app.callback()).post(base+'/jobs').set('x-admin','yes').send({requestId:id,operation:'prepare'}).expect(202)
-  expect(service.allowed(id,runner)).toBe(true);expect(service.allowed(id,randomUUID())).toBe(false)
-  version++;expect(service.allowed(id,runner)).toBe(false);version--;active=false;expect(service.allowed(id,runner)).toBe(false)
- }finally{store.close();rmSync(home,{recursive:true,force:true})}
-})

+ 88 - 0
tests/server/localVm.test.ts

@@ -0,0 +1,88 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtemp,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {ComputerRuntime} from '../../src/runner/worker/gateway'
+import {LocalVmImages} from '../../src/runner/computers/localVm'
+import {UNCONFIGURED_COMPUTER_IMAGE} from '../../src/shared/runner'
+
+async function fixture(){
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-local-vm-')),db=new DatabaseSync(':memory:')
+ const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:UNCONFIGURED_COMPUTER_IMAGE,python:'/fixture/python',hermesSource:'/fixture/source',hermesHome:'/fixture/home'}},home)
+ await runtime.ready
+ const running=new Set<string>()
+ vi.spyOn(runtime.provider,'verifyRuntime').mockResolvedValue()
+ vi.spyOn(runtime.provider,'ensure').mockImplementation(async s=>{running.add(s.id);return {id:s.id,containerId:s.id,workspace:'/fixture',running:true,isolation:'container'}})
+ vi.spyOn(runtime.provider,'stop').mockImplementation(async s=>{running.delete(s.id)})
+ vi.spyOn(runtime.provider,'remove').mockImplementation(async s=>{running.delete(s.id)})
+ vi.spyOn(runtime.provider,'health').mockResolvedValue({})
+ vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'fixture',width:1280,height:900,capturedAt:Date.now()})
+ vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
+ return {db,runtime,running,async close(){await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}}
+}
+it('prepares one managed image, verifies the desktop, applies it and persists it without a catalog step',async()=>{
+ const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),imageId='sha256:'+'a'.repeat(64)
+ try{
+  vi.spyOn(manager.images,'run').mockResolvedValue(imageId)
+  vi.spyOn(manager.images,'inspect').mockImplementation(async id=>{if(id!==imageId)throw new Error('not ready');return {protocol:1,imageId,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1}})
+  await manager.ready
+  expect((await manager.status()).image).toBe(false)
+  const id=randomUUID(),check=vi.fn(async()=>{})
+  await manager.prepare(id,check)
+  await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('complete'))
+  expect(f.runtime.config.imageId).toBe(imageId)
+  expect(f.runtime.provider.capture).toHaveBeenCalledOnce()
+  expect(f.running.size).toBe(0)
+  await manager.prepare(id,check)
+  expect(f.runtime.provider.capture).toHaveBeenCalledOnce()
+  await manager.close()
+  const resumed=new LocalVmImages(f.runtime,f.db);await resumed.ready
+  expect(f.runtime.config.imageId).toBe(imageId);await resumed.close()
+ }finally{await manager.close();await f.close()}
+})
+it('retains completed desktop state but fences the old lease and counts idle desktops against capacity',async()=>{
+ const f=await fixture(),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
+ try{
+  await f.runtime.pool.desktop(spec,'create',()=>{})
+  expect(f.running.has(spec.id)).toBe(true)
+  expect((await f.runtime.controls.status({agentId:spec.id,environmentId:spec.id,ownerKey:'owner'})).mode).toBe('idle')
+  const lease=await f.runtime.pool.acquire(spec,'turn-1',()=>{})
+  await f.runtime.pool.release(lease,true)
+  expect(()=>f.runtime.pool.use(lease,async()=>{})).toThrow()
+  const second={...spec,id:randomUUID()};await f.runtime.pool.desktop(second,'create',()=>{})
+  await expect(f.runtime.pool.desktop({...spec,id:randomUUID()},'create',()=>{})).rejects.toMatchObject({code:'computer_quota'})
+  await expect(f.runtime.pool.desktop({...spec,ownerKey:'other'},'remove',()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
+  await f.runtime.pool.desktop(spec,'stop',()=>{});expect(f.running.has(spec.id)).toBe(false)
+  await f.runtime.pool.desktop(spec,'start',()=>{});expect(f.running.has(spec.id)).toBe(true)
+  await f.runtime.pool.desktop(spec,'recreate',()=>{});expect(f.running.has(spec.id)).toBe(true)
+  await f.runtime.pool.desktop(spec,'remove',()=>{});expect(f.runtime.pool.definition('owner',spec.id)).toBeUndefined()
+  expect(f.runtime.provider.deleteWorkspace).not.toHaveBeenCalled()
+ }finally{await f.close()}
+})
+it('refuses lifecycle changes while a human or Agent holds the VM',async()=>{
+ const f=await fixture(),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
+ try{
+  const lease=await f.runtime.pool.acquire(spec,'human:test',()=>{})
+  for(const action of ['stop','remove','recreate'] as const)await expect(f.runtime.pool.desktop(spec,action,()=>{})).rejects.toMatchObject({code:'computer_busy'})
+  await f.runtime.pool.release(lease)
+ }finally{await f.close()}
+})
+it('keeps uncertain preparation cleanup fenced and retries cleanup when runtime recovers',async()=>{
+ const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),imageId='sha256:'+'a'.repeat(64)
+ let canStop=false
+ try{
+  await manager.ready
+  vi.spyOn(manager.images,'run').mockResolvedValue(imageId)
+  vi.spyOn(manager.images,'inspect').mockResolvedValue({protocol:1,imageId,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1})
+  vi.mocked(f.runtime.provider.remove).mockImplementation(async()=>{if(!canStop)throw new Error('stop uncertain')})
+  await manager.prepare(randomUUID(),async()=>{})
+  await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('failed'))
+  expect(()=>f.runtime.pool.beginMaintenance()).toThrow()
+  expect(f.runtime.config.imageId).toBe(UNCONFIGURED_COMPUTER_IMAGE)
+  canStop=true;expect((await manager.status()).busy).toBe(false)
+  const release=f.runtime.pool.beginMaintenance();release()
+ }finally{canStop=true;await manager.close();await f.close()}
+})

+ 48 - 0
tests/server/localVmRoutes.test.ts

@@ -0,0 +1,48 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import Koa from 'koa'
+import {bodyParser} from '@koa/bodyparser'
+import request from 'supertest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {LocalVmService} from '../../src/server/localVm'
+import {SharedComputers} from '../../src/server/sharedComputers'
+import {HttpError} from '../../src/server/errors'
+
+it('protects preparation, removes the old catalog API and scopes sharing to the account and profile',async()=>{
+ const home=mkdtempSync(join(tmpdir(),'local-vm-routes-')),store=new WorkspaceStore(home)
+ let version=1,active=true
+ const auth:any={require:(ctx:any)=>({id:ctx.get('x-owner')||'alice'}),requireAdmin:(ctx:any)=>{if(ctx.get('x-admin')!=='yes'||!active)throw new HttpError(403,'需要管理员权限','admin_required');return {id:'alice'}},isAdminActive:()=>active,pushAuthorizationVersion:()=>version}
+ const runner={id:randomUUID(),enabled:true,sourceNodeId:'local',sourceOwner:'_system'}
+ const nodes:any={requireSource:vi.fn()}
+ const hub:any={records:()=>[runner],summary:()=>({online:true}),localVm:vi.fn(async()=>({configured:true,daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false})),computer:vi.fn(async()=>({container:'running',ready:true}))}
+ const shared=new SharedComputers(store,auth,nodes,hub),service=new LocalVmService(store,auth,nodes,hub,shared,{home} as any),app=new Koa()
+ app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status||500;ctx.body={error:String(e)}}});app.use(bodyParser());app.use(service.router().routes())
+ try{
+  const first=store.createAgent('alice',{name:'甲',profile:'default',execution:'computer'}),second=store.createAgent('alice',{name:'乙',profile:'default',execution:'computer'})
+  const other=store.createAgent('bob',{name:'其他账号',profile:'default',execution:'computer'})
+  const id=randomUUID(),base='/api/app/admin/local-vm'
+  await request(app.callback()).post(base+'/prepare').send({requestId:id}).expect(403)
+  await request(app.callback()).post(base+'/prepare').set('x-admin','yes').send({requestId:id,imageId:'arbitrary'}).expect(400)
+  await request(app.callback()).post(base+'/prepare').set('x-admin','yes').send({requestId:id}).expect(200)
+  expect(service.allowed(id,runner.id)).toBe(true);expect(service.allowed(id,randomUUID())).toBe(false)
+  version++;expect(service.allowed(id,runner.id)).toBe(false);version--
+  await request(app.callback()).get(`/api/app/admin/runners/${runner.id}/images`).set('x-admin','yes').expect(404)
+  await request(app.callback()).get(`/api/app/agents/${first.id}/local-vm`).set('x-owner','bob').expect(404)
+  await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'shared',maxInstances:2}).expect(200)
+  const a=store.require<any>('alice','agent',first.id),b=store.require<any>('alice','agent',second.id)
+  expect(a.computerEnvironmentId).toBeTruthy();expect(b.computerEnvironmentId).toBe(a.computerEnvironmentId)
+  const later=store.createAgent('alice',{name:'后加入成员',profile:'default',execution:'computer'})
+  expect(later.computerEnvironmentId).toBe(a.computerEnvironmentId)
+  const disabled=await request(app.callback()).put(`/api/app/agents/${later.id}/local-vm`).send({enabled:false}).expect(200)
+  expect(disabled.body.agent.execution).toBe('profile');expect(disabled.body.agent.computerEnvironmentId).toBeUndefined()
+  expect(store.require<any>('alice','agent',first.id).computerEnvironmentId).toBe(a.computerEnvironmentId)
+  expect(store.require<any>('bob','agent',other.id).computerEnvironmentId).toBeUndefined()
+  await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'per-bot',maxInstances:2}).expect(200)
+  expect(store.require<any>('alice','agent',first.id).computerEnvironmentId).toBeUndefined()
+  active=false;expect(service.allowed(id,runner.id)).toBe(false)
+ }finally{store.close();rmSync(home,{recursive:true,force:true})}
+})

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

@@ -264,15 +264,3 @@ it('keeps only stop operations available on an existing channel after task permi
   expect(await gateway.rpc('session.interrupt',{session_id:session.session_id})).toEqual({ok:true})
   expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(0)
 })
-it('keeps image download receipts small and reauthorizes immutable chunk retries',async()=>{
- const original=(runner as any).api.bind(runner)
- const api=vi.spyOn(runner as any,'api').mockImplementation(async(...args:any[])=>args[0]==='admit'?{allowed:true}:original(...args))
- const execute=vi.spyOn(runner as any,'execute').mockResolvedValue({data:Buffer.alloc(524288).toString('base64'),offset:0,size:524288})
- try{
-  const command:RunnerCommand={id:randomUUID(),kind:'images.manage',payload:{op:'download',owner:'fixture',id:randomUUID(),offset:0},expiresAt:Date.now()+30000}
-  expect(await runner.handle(command)).toHaveProperty('result.data')
-  expect((runner as any).db.prepare('SELECT state,result FROM commands WHERE id=?').get(command.id)).toMatchObject({state:'read',result:null})
-  expect(await runner.handle(command)).toHaveProperty('result.data');expect(execute).toHaveBeenCalledTimes(2)
-  await expect(runner.handle({...command,payload:{...command.payload,offset:1}})).rejects.toMatchObject({code:'idempotency_conflict'})
- }finally{api.mockRestore();execute.mockRestore()}
-})

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