Parcourir la source

支持标准与 Cursor 桌面镜像选择并保留共享环境约束

Samien il y a 1 mois
Parent
commit
174002ba32

+ 3 - 1
compose.desktops.yaml

@@ -29,7 +29,7 @@ services:
       HERMES_YAOYAO_ALLOW_INSECURE_LAN: "1"
       HERMES_YAOYAO_SUPERVISE_DASHBOARD: "0"
       HERMES_YAOYAO_LOCAL_VM_HOST: "runner"
-      HERMES_YAOYAO_COMPOSE_DESKTOPS: "[{\"id\":\"160fe7cd-e530-4ac5-a97f-26c19a362b11\",\"name\":\"共享桌面 1\",\"socketPath\":\"/run/yaoyao-desktops/desktop-1/desktop.sock\"},{\"id\":\"eba5caaf-744e-4f27-bcdd-1f1f35d05a42\",\"name\":\"共享桌面 2\",\"socketPath\":\"/run/yaoyao-desktops/desktop-2/desktop.sock\"}]"
+      HERMES_YAOYAO_COMPOSE_DESKTOPS: "[{\"id\":\"160fe7cd-e530-4ac5-a97f-26c19a362b11\",\"name\":\"共享桌面 1\",\"socketPath\":\"/run/yaoyao-desktops/desktop-1/desktop.sock\",\"imageKey\":\"standard\"},{\"id\":\"eba5caaf-744e-4f27-bcdd-1f1f35d05a42\",\"name\":\"共享桌面 2\",\"socketPath\":\"/run/yaoyao-desktops/desktop-2/desktop.sock\",\"imageKey\":\"standard\"}]"
       HERMES_YAOYAO_CHAT_CACHE_MODE: "${HERMES_YAOYAO_CHAT_CACHE_MODE:-prefer-local}"
       HERMES_YAOYAO_RELEASE_SOURCE: "${HERMES_YAOYAO_RELEASE_SOURCE:-https://github.com/Lsamien/yaoyao.git}"
       HERMES_YAOYAO_HOME: /home/node/.yaoyao
@@ -48,6 +48,7 @@ services:
     image: "${YAOYAO_DESKTOP_IMAGE:-hermes-yaoyao-desktop:0.4.0}"
     build:
       context: ./deploy/computer
+      dockerfile: Dockerfile
     hostname: desktop-1
     extra_hosts:
       - "desktop-1:127.0.0.1"
@@ -76,6 +77,7 @@ services:
     image: "${YAOYAO_DESKTOP_IMAGE:-hermes-yaoyao-desktop:0.4.0}"
     build:
       context: ./deploy/computer
+      dockerfile: Dockerfile
     hostname: desktop-2
     extra_hosts:
       - "desktop-2:127.0.0.1"

+ 26 - 0
deploy/computer/Dockerfile.cursor

@@ -0,0 +1,26 @@
+# Cursor Universal with the same private desktop protocol as the standard image.
+# The upstream tag is mutable; pin the inspected amd64 digest for reproducible builds.
+ARG CURSOR_BASE=public.ecr.aws/k0i0n2g5/cursorenvironments/universal:sand-box-latest@sha256:858b6df5d0aeffd5db0e20c81553e98f84b07e9c700a13fcd1febac0e8c54830
+FROM ${CURSOR_BASE}
+USER root
+RUN set -eux; \
+    test "$(uname -m)" = x86_64; \
+    curl -fsSL 'https://files.pythonhosted.org/packages/fa/d7/a43008a328a40c85e7bc706fc20235b9abedc75e28b413817655153157ff/cua_driver-0.20.0-py3-none-manylinux_2_31_x86_64.whl' -o /tmp/cua-driver.whl; \
+    echo 'f60c35696a37f37ac954935e478ae4754f220856d022036625c9400d72185961  /tmp/cua-driver.whl' | sha256sum -c -; \
+    unzip -p /tmp/cua-driver.whl cua_driver/bin/cua-driver > /tmp/cua-driver; \
+    install -D -m 0755 /tmp/cua-driver /usr/local/libexec/openmausbot/cua-driver; \
+    rm /tmp/cua-driver.whl /tmp/cua-driver; \
+    test "$(/usr/local/libexec/openmausbot/cua-driver --version)" = 'cua-driver 0.20.0'; \
+    usermod -l cua -d /home/cua box; groupmod -n cua box; \
+    install -d -o cua -g cua -m 0700 /home/cua /home/cua/workspace /run/user/1000; \
+    install -d -m 0755 /run/yaoyao-private
+COPY compose_bridge.py /usr/local/libexec/yaoyao/compose_bridge.py
+COPY cursor-desktop.py /usr/local/libexec/yaoyao/cursor-desktop.py
+COPY cursor-browser.sh /usr/local/bin/yaoyao-browser
+RUN chmod 0755 /usr/local/bin/yaoyao-browser; \
+    sed -i 's|^Exec=.*|Exec=/usr/local/bin/yaoyao-browser %U|' /usr/share/applications/google-chrome.desktop /usr/share/applications/com.google.Chrome.desktop
+ENV HOME=/home/cua USER=cua DISPLAY=:1 XDG_RUNTIME_DIR=/run/user/1000 BROWSER=/usr/local/bin/yaoyao-browser CUA_DRIVER_INSTALL_CHANNEL=python_package CUA_DRIVER_RS_TELEMETRY_ENABLED=0
+LABEL com.openmausbot.local-vm="1" com.openmausbot.cua-driver="0.20.0" com.openmausbot.image-layer="5" \
+      cn.samien.yaoyao.computer-image="1" cn.samien.yaoyao.compose-desktop="1" cn.samien.yaoyao.image-key="cursor"
+# Start only the desktop and Yaoyao bridge; the upstream cloud services are not needed.
+ENTRYPOINT ["python3", "/usr/local/libexec/yaoyao/cursor-desktop.py"]

+ 6 - 0
deploy/computer/cursor-browser.sh

@@ -0,0 +1,6 @@
+#!/bin/sh
+set -eu
+if [ "${HTTP_PROXY:-}" = 'http://127.0.0.1:3128' ]; then
+    set -- "--proxy-server=$HTTP_PROXY" '--proxy-bypass-list=localhost;127.0.0.1;[::1]' --disable-quic "$@"
+fi
+exec /usr/bin/google-chrome --no-sandbox --no-first-run --no-default-browser-check --disable-dev-shm-usage --password-store=basic --user-data-dir=/home/cua/workspace/.browser-profiles/google-chrome "$@"

+ 86 - 0
deploy/computer/cursor-desktop.py

@@ -0,0 +1,86 @@
+"""Supervise the Cursor desktop using the existing uid, workspace and CUA socket contract."""
+import os
+from pathlib import Path
+import signal
+import subprocess
+import sys
+import time
+
+children = []
+critical = []
+stopping = False
+
+
+def stop(*_):
+    global stopping
+    stopping = True
+
+
+def spawn(argv, user=True, required=True):
+    child = subprocess.Popen(argv, user=1000 if user else None,
+                             group=1000 if user else None,
+                             extra_groups=[] if user else None)
+    children.append(child)
+    if required:
+        critical.append(child)
+    return child
+
+
+signal.signal(signal.SIGTERM, stop)
+signal.signal(signal.SIGINT, stop)
+try:
+    # Run workspace setup as its owner; no extra DAC or CHOWN capability is needed.
+    setup = """
+from pathlib import Path
+import os
+root = Path('/home/cua/workspace/.browser-profiles/google-chrome')
+root.mkdir(parents=True, exist_ok=True)
+for name in ['SingletonLock', 'SingletonSocket', 'SingletonCookie']:
+    (root / name).unlink(missing_ok=True)
+config = Path('/home/cua/.config')
+config.mkdir(exist_ok=True)
+link = config / 'google-chrome'
+if not link.is_symlink() and not link.exists():
+    link.symlink_to(root)
+launchers = config / 'plank/dock1/launchers'
+launchers.mkdir(parents=True, exist_ok=True)
+for name in ['google-chrome', 'xfce4-terminal', 'thunar']:
+    (launchers / (name + '.dockitem')).write_text('[PlankDockItemPreferences]\\nLauncher=file:///usr/share/applications/' + name + '.desktop\\n')
+"""
+    subprocess.run(['python3', '-c', setup], user=1000, group=1000, extra_groups=[], check=True)
+    for path in ['/tmp/.X1-lock', '/tmp/.X11-unix/X1', '/run/user/1000/openmausbot-cua.sock', '/run/user/1000/yaoyao-dbus']:
+        subprocess.run(['rm', '-f', path], user=1000, group=1000, extra_groups=[], check=True)
+    os.environ['DBUS_SESSION_BUS_ADDRESS'] = 'unix:path=/run/user/1000/yaoyao-dbus'
+    spawn(['dbus-daemon', '--session', '--nofork', '--address=' + os.environ['DBUS_SESSION_BUS_ADDRESS']])
+    spawn(['Xvfb', ':1', '-screen', '0', '1280x800x24', '-ac', '-nolisten', 'tcp', '-noreset'])
+    for _ in range(100):
+        if stopping or any(p.poll() is not None for p in children):
+            raise RuntimeError('桌面启动已中断')
+        if subprocess.run(['xset', 'q'], user=1000, group=1000, extra_groups=[], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0:
+            break
+        time.sleep(0.2)
+    else:
+        raise RuntimeError('X display :1 did not become ready')
+    spawn(['xfwm4', '--compositor=off'])
+    subprocess.run(['xsetroot', '-solid', '#202734'], user=1000, group=1000, extra_groups=[])
+    spawn(['plank'], required=False)
+    spawn(['/usr/local/libexec/openmausbot/cua-driver', 'serve', '--socket', '/run/user/1000/openmausbot-cua.sock', '--permission-mode', 'standard'])
+    # A visible browser gives this minimal desktop a usable initial surface.
+    spawn(['/usr/local/bin/yaoyao-browser', 'about:blank'], required=False)
+    if os.environ.get('YAOYAO_COMPOSE_DESKTOP_ID'):
+        spawn(['python3', '/usr/local/libexec/yaoyao/compose_bridge.py'], user=False)
+    while not stopping:
+        if any(p.poll() is not None for p in critical):
+            raise RuntimeError('桌面进程已退出,等待重新启动')
+        time.sleep(0.5)
+except Exception as error:
+    print(error, file=sys.stderr, flush=True)
+    sys.exit(1)
+finally:
+    # Stop the guest session without giving root CAP_KILL in managed containers.
+    subprocess.run(['python3', '-c', 'import os,signal; os.kill(-1,signal.SIGTERM)'], user=1000, group=1000, extra_groups=[])
+    for child in children:
+        try:
+            child.wait(timeout=3)
+        except subprocess.TimeoutExpired:
+            pass

+ 13 - 1
docs/docker-install.md

@@ -95,7 +95,7 @@ docker compose --env-file docker.env -f compose.desktops.yaml ps
 - 每台桌面的工作文件放在独立的 `desktop-*-workspace` 命名卷内。Compose 桌面统一使用 `/home/cua/workspace`,不会更改基础 Profile 的工作目录配置。
 - 示例桌面使用 `network_mode: none`,默认没有桌面外网。网络、资源额度、镜像版本和服务数量均由部署者在 Compose 中管理;本机 App 的动态虚拟机模式保持原有方式。
 
-如部署前要调整数量或名称,编辑 `deploy/compose-desktops.json`,保留已有桌面 ID,重新生成配套文件:
+如部署前要调整数量、名称或镜像,编辑 `deploy/compose-desktops.json`,保留已有桌面 ID,重新生成配套文件:
 
 ```sh
 node scripts/generate-desktop-compose.mjs
@@ -105,6 +105,18 @@ docker compose --env-file docker.env -f compose.desktops.yaml up -d --build
 
 生成器会把 Web 连接清单、桌面服务和数据卷同步写入同一个 Compose 文件。运行中不能通过 Web API 扩容。不要用 `down --volumes` 更新部署,否则会删除工作文件。
 
+### 同时部署两种镜像
+
+在 `deploy/compose-desktops.json` 的每个桌面条目增加 `imageKey`:`standard` 为现有标准桌面(不填写时的默认值),`cursor` 为 Cursor Universal 兼容桌面。例如保留已有 ID 和 service,将第二台配置为:
+
+```json
+{"id":"eba5caaf-744e-4f27-bcdd-1f1f35d05a42","service":"desktop-2","name":"Cursor 开发桌面","imageKey":"cursor"}
+```
+
+然后运行上述生成和部署命令。生成器为 Cursor 桌面指定 `Dockerfile.cursor` 和 `platform: linux/amd64`;镜像名可通过 `YAOYAO_CURSOR_DESKTOP_IMAGE` 设置,标准桌面继续使用 `YAOYAO_DESKTOP_IMAGE`。两种变量应指向对应的夭夭兼容镜像,不能将上游原始 Cursor 镜像直接作为运行镜像。
+
+聊天电脑面板显示每台桌面的镜像名称,机器人通过选择已有桌面来选择环境。不同桌面各自使用独立数据卷,可同时使用不同镜像;共享同一桌面的成员只能使用该桌面的一个镜像。改变现有条目的镜像会在下次 Compose 部署时重建该实例,请先结束使用它的任务,保留原有 ID、service 和数据卷。
+
 ### Hermes 执行连接
 
 Compose 管理桌面,Hermes 执行连接负责模型运行和工具调用。沿用已有执行节点;新部署可在 **设置 → 本地虚拟机 → 打开执行节点设置** 下载程序和注册配置。此部署生成的配置自动带 `managedBy: compose`,执行节点不再创建桌面,也不需要安装 Docker。

+ 12 - 4
docs/local-vm.md

@@ -5,9 +5,9 @@
 ## 使用流程
 
 1. 进入应用设置 → **本地虚拟机**。macOS App 的应用菜单也提供“本地虚拟机设置…”。
-2. 检查 Docker/Podman 已安装并启动,点击 **准备本地虚拟机**。本机执行环境自动连接,不需要手工填写镜像 ID、下载 Runner 配置再导入。准备会复用兼容镜像或构建固定版本,并验证实际桌面后自动使用。
-3. 选择 **共享虚拟机** 或 **每个 Agent 独立**,最多同时运行 1–4 台,默认 2 台。同一账号、同一基础 Profile 的持久 Agent 共用桌面、工作文件和浏览器资料,新建成员会继承当前共享策略。临时任务助手继续使用任务隔离环境,完成后单独清理,不会删除持久共享工作区。
-4. 打开 Agent 聊天,点击右上角 **电脑**。右侧电脑面板中选择 **本地虚拟机**,从这里创建或启动该 Agent 的桌面。
+2. 检查 Docker/Podman 已安装并启动,在 **准备虚拟机镜像** 中选择 **标准桌面** 或 **Cursor Universal**,点击 **准备所选镜像**。两个镜像可分别准备;会复用对应的兼容镜像或构建固定版本,验证驱动和实际截图后标记就绪。准备前先停止所有虚拟机任务和实例。外部执行节点需更新配套 Runner。
+3. 选择 **共享虚拟机** 或 **每个 Agent 独立**,最多同时运行 1–4 台,默认 2 台。同一账号、同一执行节点的持久 Agent 共用桌面、工作文件和浏览器资料,新建成员会继承当前共享策略。临时任务助手继续使用任务隔离环境,完成后单独清理,不会删除持久共享工作区。
+4. 打开 Agent 聊天,点击右上角 **电脑**。右侧电脑面板中选择 **本地虚拟机**,在 **虚拟机镜像** 中选择已准备的镜像,再创建桌面。独立桌面可分别选择不同镜像并同时运行;同一个共享桌面的所有成员只能使用这台桌面的一个镜像。
 5. 右侧显示真实画面及运行状态,可停止或重建虚拟机;点击预览或 **打开桌面** 后接管交互。
 6. **打开双桌面** 在主工作区显示两台虚拟机,保留聊天侧栏。进入时仅查看,不会创建/启动机器;切换操作对象时先交还前一台的控制权。
 
@@ -17,6 +17,14 @@
 
 `terminal.cwd` 默认的 `.`(以及 `auto`、`cwd`、未设置)指向虚拟机的持久工作区 `/home/cua/workspace`;相对路径以该工作区为起点,显式绝对路径继续沿用配置。创建、启动和人工接管只读取工作目录,不要求模型可用;停止和移除已有实例也不依赖 Profile 配置。模型仅在运行 Agent 时解析,Profile、目录和模型错误分别提示。
 
+## 镜像与持久状态
+
+- 标准桌面沿用现有 XFCE 镜像;Cursor Universal 基于 `public.ecr.aws/k0i0n2g5/cursorenvironments/universal:sand-box-latest` 的固定 amd64 摘要,并添加相同的 CUA 0.20.0 驱动和私有桥接协议。原始镜像不能直接填入现有桌面镜像配置。
+- Cursor 镜像目前只有 Linux amd64;Apple 芯片上的 Docker/Podman 需要支持 amd64 模拟。macOS arm64 App 安装包不代表有 Linux arm64 桌面镜像。
+- 每台独立桌面拥有自己的工作目录和浏览器资料。同一共享桌面只有一个持久镜像选择,所有成员看到同一个选择。
+- 准备第二个镜像不会替换已有实例的镜像。切换前先停止任务、交还控制权,在电脑面板点击“移除实例”,再选择新镜像并创建。实例移除保留持久数据,后续启动和重启沿用已选镜像。
+- `POST /api/app/admin/local-vm/prepare` 接受可选的 `imageKey: standard | cursor`;`PUT /api/app/agents/:id/local-vm/image` 保存该桌面的镜像选择。客户端只能选择已准备的配套镜像,不能提交任意镜像 ID。
+
 ## 手机端
 
 iOS/Android 没有全局虚拟桌面清单、镜像设置或执行环境选择。入口在 **当前 Agent 聊天右上角的电脑图标**。单个 Agent 直接打开画面,群聊有多台电脑时选择当前聊天成员的电脑,同一共享环境只显示一次。
@@ -32,7 +40,7 @@ iOS/Android 没有全局虚拟桌面清单、镜像设置或执行环境选择
 - `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 或其他人控制的虚拟机。
+- 应用设置只向管理员开放。共享不会跨账号或执行节点;实例操作会拒绝正在由 Agent 或其他人控制的虚拟机。
 
 ## 验收
 

+ 64 - 19
scripts/generate-desktop-compose.mjs

@@ -1,22 +1,67 @@
 import {readFileSync,writeFileSync} from 'node:fs'
 import {resolve} from 'node:path'
+import {fileURLToPath} from 'node:url'
 const root=resolve(import.meta.dirname,'..')
-const entries=JSON.parse(readFileSync(resolve(root,'deploy/compose-desktops.json'),'utf8'))
-if(!Array.isArray(entries)||entries.length<1||entries.length>32)throw new Error('Compose 桌面数量须为 1–32')
-for(const d of entries)if(!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(d.id)||!/^desktop-[a-z0-9-]+$/.test(d.service)||typeof d.name!=='string'||!d.name.trim())throw new Error('Compose 桌面定义无效')
-if(new Set(entries.map(d=>d.id)).size!==entries.length||new Set(entries.map(d=>d.service)).size!==entries.length)throw new Error('Compose 桌面定义重复')
-const q=JSON.stringify
-const targets=entries.map(d=>({id:d.id,name:d.name,socketPath:`/run/yaoyao-desktops/${d.service}/desktop.sock`}))
-const base=readFileSync(resolve(root,'compose.yaml'),'utf8')
-  .replace('      HERMES_YAOYAO_LOCAL_VM_HOST: "runner"','      HERMES_YAOYAO_LOCAL_VM_HOST: "runner"\n      HERMES_YAOYAO_COMPOSE_DESKTOPS: '+q(JSON.stringify(targets)))
-  .replace('      - yaoyao-data:/home/node/.yaoyao','      - yaoyao-data:/home/node/.yaoyao\n'+entries.map(d=>`      - ${d.service}-ipc:/run/yaoyao-desktops/${d.service}:ro`).join('\n'))
-const volumesAt=base.indexOf('\nvolumes:')
-if(volumesAt<0||!base.includes('HERMES_YAOYAO_COMPOSE_DESKTOPS'))throw new Error('compose.yaml 模板结构已变化')
-let yaml='# Generated from compose.yaml and deploy/compose-desktops.json.\n'+base.slice(0,volumesAt)+'\n'
-for(const d of entries)yaml+=`  ${d.service}:\n    image: "\${YAOYAO_DESKTOP_IMAGE:-hermes-yaoyao-desktop:0.4.0}"\n    build:\n      context: ./deploy/computer\n    hostname: ${d.service}\n    extra_hosts:\n      - "${d.service}:127.0.0.1"\n    init: true\n    restart: unless-stopped\n    network_mode: none\n    shm_size: 512m\n    mem_limit: 4g\n    cpus: 2\n    pids_limit: 512\n    security_opt:\n      - no-new-privileges:true\n    cap_drop:\n      - ALL\n    cap_add:\n      - SETUID\n      - SETGID\n      - CHOWN\n      - KILL\n    environment:\n      YAOYAO_COMPOSE_DESKTOP_ID: ${q(d.id)}\n    volumes:\n      - ${d.service}-workspace:/home/cua/workspace\n      - ${d.service}-ipc:/run/yaoyao-private/bridge\n`
-yaml+=base.slice(volumesAt)+'\n'
-for(const d of entries)yaml+=`  ${d.service}-workspace:\n  ${d.service}-ipc:\n`
-const output=resolve(root,'compose.desktops.yaml')
-if(process.argv.includes('--check')){if(readFileSync(output,'utf8')!==yaml)throw new Error('compose.desktops.yaml 与桌面定义不同步,请运行 node scripts/generate-desktop-compose.mjs')}
-else writeFileSync(output,yaml)
-console.log(`Compose 共享桌面:${entries.length} 台`)
+const recipes={
+  standard:{image:'${YAOYAO_DESKTOP_IMAGE:-hermes-yaoyao-desktop:0.4.0}',dockerfile:'Dockerfile'},
+  cursor:{image:'${YAOYAO_CURSOR_DESKTOP_IMAGE:-yaoyao-desktop:cursor}',dockerfile:'Dockerfile.cursor',platform:'linux/amd64'},
+}
+export function generateDesktopCompose(base,entries){
+  if(!Array.isArray(entries)||entries.length<1||entries.length>32)throw new Error('Compose 桌面数量须为 1–32')
+  for(const d of entries){
+    if(!d||!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(d.id)||!/^desktop-[a-z0-9-]+$/.test(d.service)||typeof d.name!=='string'||!d.name.trim())throw new Error('Compose 桌面定义无效')
+    if(d.imageKey!==undefined&&!Object.hasOwn(recipes,d.imageKey))throw new Error('桌面镜像须为 standard 或 cursor')
+  }
+  if(new Set(entries.map(d=>d.id)).size!==entries.length||new Set(entries.map(d=>d.service)).size!==entries.length)throw new Error('Compose 桌面定义重复')
+  const q=JSON.stringify
+  const targets=entries.map(d=>({id:d.id,name:d.name,socketPath:`/run/yaoyao-desktops/${d.service}/desktop.sock`,imageKey:d.imageKey??'standard'}))
+  base=base.replace('      HERMES_YAOYAO_LOCAL_VM_HOST: "runner"','      HERMES_YAOYAO_LOCAL_VM_HOST: "runner"\n      HERMES_YAOYAO_COMPOSE_DESKTOPS: '+q(JSON.stringify(targets)))
+    .replace('      - yaoyao-data:/home/node/.yaoyao','      - yaoyao-data:/home/node/.yaoyao\n'+entries.map(d=>`      - ${d.service}-ipc:/run/yaoyao-desktops/${d.service}:ro`).join('\n'))
+  const volumesAt=base.indexOf('\nvolumes:')
+  if(volumesAt<0||!base.includes('HERMES_YAOYAO_COMPOSE_DESKTOPS'))throw new Error('compose.yaml 模板结构已变化')
+  let yaml='# Generated from compose.yaml and deploy/compose-desktops.json.\n'+base.slice(0,volumesAt)+'\n'
+  for(const d of entries){
+    const recipe=recipes[d.imageKey??'standard']
+    yaml+=`  ${d.service}:
+    image: "${recipe.image}"
+${recipe.platform?`    platform: ${recipe.platform}\n`:''}    build:
+      context: ./deploy/computer
+      dockerfile: ${recipe.dockerfile}
+    hostname: ${d.service}
+    extra_hosts:
+      - "${d.service}:127.0.0.1"
+    init: true
+    restart: unless-stopped
+    network_mode: none
+    shm_size: 512m
+    mem_limit: 4g
+    cpus: 2
+    pids_limit: 512
+    security_opt:
+      - no-new-privileges:true
+    cap_drop:
+      - ALL
+    cap_add:
+      - SETUID
+      - SETGID
+      - CHOWN
+      - KILL
+    environment:
+      YAOYAO_COMPOSE_DESKTOP_ID: ${q(d.id)}
+    volumes:
+      - ${d.service}-workspace:/home/cua/workspace
+      - ${d.service}-ipc:/run/yaoyao-private/bridge
+`
+  }
+  yaml+=base.slice(volumesAt)+'\n'
+  for(const d of entries)yaml+=`  ${d.service}-workspace:\n  ${d.service}-ipc:\n`
+  return yaml
+}
+if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){
+  const entries=JSON.parse(readFileSync(resolve(root,'deploy/compose-desktops.json'),'utf8'))
+  const yaml=generateDesktopCompose(readFileSync(resolve(root,'compose.yaml'),'utf8'),entries)
+  const output=resolve(root,'compose.desktops.yaml')
+  if(process.argv.includes('--check')){if(readFileSync(output,'utf8')!==yaml)throw new Error('compose.desktops.yaml 与桌面定义不同步,请运行 node scripts/generate-desktop-compose.mjs')}
+  else writeFileSync(output,yaml)
+  console.log(`Compose 共享桌面:${entries.length} 台`)
+}

+ 6 - 3
src/client/components/app/LocalVmSettingsPanel.vue

@@ -5,9 +5,11 @@ import {createUuid} from '@/utils/id'
 import AppIcon from '@/components/common/AppIcon.vue'
 import RunnerSettingsPanel from './RunnerSettingsPanel.vue'
 import type {LocalVmStatus,LocalVmMode,LocalVmAction,LocalVmInstance} from '@shared/localVm'
+import {LOCAL_VM_IMAGES,type LocalVmImageKey} from '@shared/localVm'
 import type {WorkspaceAgent} from '@shared/workspace'
 const state=ref<LocalVmStatus>(),error=ref(''),loading=ref(false),acting=ref(false)
 const runnerSettingsOpen=ref(false)
+const prepareImage=ref<LocalVmImageKey>('standard')
 const instances=ref<Array<{agent:WorkspaceAgent;vm:LocalVmInstance}>>([])
 let closed=false,timer:ReturnType<typeof setTimeout>|undefined
 async function refresh(){
@@ -23,7 +25,7 @@ async function refresh(){
  }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 prepare(){void action(()=>apiRequest('/api/app/admin/local-vm/prepare',{method:'POST',body:{requestId:createUuid(),imageKey:prepareImage.value}}))}
 function policy(mode:LocalVmMode,maxInstances=state.value?.maxInstances??2){
  if(mode==='shared'&&state.value?.mode!=='shared'&&!confirm('当前账号的多个机器人将共用桌面、工作文件和浏览器登录。确认使用共享虚拟机?'))return
  void action(()=>apiRequest('/api/app/admin/local-vm/policy',{method:'PUT',body:{requestId:createUuid(),mode,maxInstances}}))
@@ -43,7 +45,7 @@ onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
    <p v-if="state?.job" role="status">{{state.job.message}}</p>
   </article>
   <article v-if="state?.executionHost==='runner'"><h3>Hermes 执行连接</h3><p>{{state.fixedCapacity?'执行连接负责 Hermes 模型与工具调用,桌面容器完全由这套 Compose 管理。':'虚拟桌面由运行 Hermes 的执行节点提供,Docker 版 Web 通过这条连接使用桌面。'}}{{state.runnerName?`当前节点:${state.runnerName}。`:''}}</p><button :aria-expanded="runnerSettingsOpen" @click="runnerSettingsOpen=!runnerSettingsOpen">{{runnerSettingsOpen?'收起执行节点设置':'打开执行节点设置'}}</button><RunnerSettingsPanel v-if="runnerSettingsOpen" computer-setup /></article>
-  <article v-if="state?.fixedCapacity"><h3>Compose 共享桌面</h3><p>已配置 {{state.maxInstances}} 台桌面,数量由 Compose 固定。请在机器人聊天的电脑面板选择已有桌面。</p><div v-for="desktop in state.desktops" :key="desktop.id" class="instance"><strong>{{desktop.name}}</strong><span>{{desktop.ready?'已就绪':desktop.online?'正在启动':'未连接'}}</span></div></article>
+  <article v-if="state?.fixedCapacity"><h3>Compose 共享桌面</h3><p>已配置 {{state.maxInstances}} 台桌面,数量由 Compose 固定。请在机器人聊天的电脑面板选择已有桌面。</p><div v-for="desktop in state.desktops" :key="desktop.id" class="instance"><strong>{{desktop.name}} · {{desktop.imageKey==='cursor'?'Cursor Universal':'标准桌面'}}</strong><span>{{desktop.ready?'已就绪':desktop.online?'正在启动':'未连接'}}</span></div></article>
   <article v-else-if="state"><h3>隔离方式</h3><p>按机器人创建独立桌面,或让可信的机器人共用一个桌面。</p>
    <div class="segmented" role="group" aria-label="虚拟机隔离方式"><button :aria-pressed="state?.mode==='shared'" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @click="policy('shared')">共享虚拟机</button><button :aria-pressed="state?.mode==='per-bot'" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @click="policy('per-bot')">每个机器人独立</button></div>
    <p class="sharing-explanation">共享指多个机器人操作同一台虚拟机,共用桌面和工作文件;与可同时运行的虚拟机数量无关。</p>
@@ -52,7 +54,7 @@ onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
   <article v-if="state&&!state.fixedCapacity&&!state.setupRequired"><h3>准备运行环境</h3><p v-if="state?.executionHost==='runner'">以下检查和镜像准备均在执行节点所在电脑完成。</p><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">{{state?.image?'✓':'3'}}</span><div class="image-preparation"><strong>准备虚拟机镜像</strong><p>两个镜像可分别准备。独立桌面可各自选择;同一共享桌面的所有机器人使用一个镜像。</p><label>镜像<select v-model="prepareImage" :disabled="acting||state.busy"><option v-for="image in LOCAL_VM_IMAGES" :key="image.key" :value="image.key">{{image.name}} · {{state.images?.find(i=>i.key===image.key)?.ready?'已就绪':'未准备'}}</option></select></label><p>{{LOCAL_VM_IMAGES.find(i=>i.key===prepareImage)?.description}}{{prepareImage==='cursor'?';Apple 芯片电脑需要运行环境支持 amd64 模拟。':''}}</p><button class="primary" :disabled="acting||state?.busy||!state?.daemonUp" @click="prepare">{{state?.busy?'正在准备…':state.images?.find(i=>i.key===prepareImage)?.ready?'重新检查所选镜像':'准备所选镜像'}}</button></div></li>
    <li><span class="step">4</span><div><strong>从机器人聊天中创建桌面</strong><p>打开聊天右上角的“电脑”,选择“本地虚拟机”,然后创建该机器人的虚拟机。</p></div></li>
   </ol></article>
   <article v-if="state&&!state.fixedCapacity"><h3>{{state?.mode==='shared'?'共享虚拟机':'机器人虚拟机'}}</h3><p v-if="!instances.length">还没有机器人使用本地虚拟机。</p>
@@ -68,3 +70,4 @@ onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
 <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>
+<style scoped>.image-preparation{display:grid;gap:10px;min-width:0}.image-preparation label{display:grid;gap:6px}.image-preparation select{width:100%;min-height:44px}.image-preparation button{justify-self:start}</style>

+ 7 - 2
src/client/components/workspace/LocalVmChatPanel.vue

@@ -19,7 +19,7 @@ const computerChoice=computed(()=>state.value?.fixedCapacity?(state.value.deskto
 const computerChoices=computed(()=>[
  {value:'auto',label:'自动',description:'按可用环境自动选择',icon:'bolt' as const,disabled:false},
  {value:'cloud',label:'云端 · Grok Bot',description:'共享云端电脑,持续保存工作',icon:'globe' as const,disabled:false},
- ...(state.value?.fixedCapacity?(state.value.desktops??[]).map(desktop=>({value:desktop.id,label:desktop.name,description:desktop.ready?'已有共享桌面':'桌面尚未就绪',icon:'monitor' as const,disabled:desktop.available===false})):[{value:'vm',label:'本地虚拟机',description:'在隔离的本地桌面中工作',icon:'monitor' as const,disabled:false}]),
+ ...(state.value?.fixedCapacity?(state.value.desktops??[]).map(desktop=>({value:desktop.id,label:desktop.name,description:(desktop.imageKey==='cursor'?'Cursor Universal · ':'标准桌面 · ')+(desktop.ready?'已就绪':'尚未就绪'),icon:'monitor' as const,disabled:desktop.available===false})):[{value:'vm',label:'本地虚拟机',description:'在隔离的本地桌面中工作',icon:'monitor' as const,disabled:false}]),
  {value:'local',label:'本机',description:native.value?.host?.name??'请在电脑上打开夭夭桌面端',icon:'monitor' as const,disabled:!native.value?.local.supported},
  {value:'browser',label:'仅浏览器',description:native.value?.browser.available?'独立浏览器,保存登录资料':'请在电脑上打开夭夭桌面端',icon:'globe' as const,disabled:!native.value?.browser.available},
  {value:'off',label:'不使用电脑',description:'使用基础机器人的其他工具',icon:'stop' as const,disabled:false},
@@ -73,6 +73,7 @@ function choose(value:string){
 async function openDesktop(){if(!agent.value)return;if(cloudSelected.value){await run(()=>apiRequest(`/api/app/agents/${selected.value}/cloud-computer/open`,{method:'POST',body:{}}));if(error.value)return}emit('desktop',agent.value)}
 function action(value:LocalVmAction){
  if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件和浏览器资料会保留。'))return
+ if(value==='remove'&&!confirm('移除这台虚拟机实例以切换镜像?工作文件和浏览器资料会保留。'))return
  void run(()=>apiRequest(base()+'/'+value,{method:'POST',body:{}}))
 }
 async function cycle(){const version=revision;await refresh();if(!closed&&version===revision)timer=setTimeout(cycle,3000)}
@@ -121,6 +122,10 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
     </template>
     <template v-else-if="state?.enabled">
      <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此机器人的独立虚拟机'}}</p>
+     <template v-if="!state.fixedCapacity&&state.images?.length&&!agent.temporaryGoalId">
+      <label>虚拟机镜像<select :value="state.imageKey??''" :disabled="busy||state.inUse||state.container!=='missing'" @change="run(()=>apiRequest(base()+'/image',{method:'PUT',body:{imageKey:($event.target as HTMLSelectElement).value}}))"><option v-if="!state.imageKey" value="" disabled>{{state.image?'当前保留的镜像':'请选择已准备的镜像'}}</option><option v-for="image in state.images" :key="image.key" :value="image.key" :disabled="!image.ready">{{image.name}}{{image.ready?'':' · 未准备'}}</option></select></label>
+      <p class="hint">{{state.mode==='shared'?'同一共享桌面只能使用一个镜像,选择会对所有共享成员生效。':'此桌面的镜像、文件和浏览器资料与其他独立桌面分开。'}}{{state.container!=='missing'?'切换镜像前请先移除实例。':''}}</p>
+     </template>
      <button v-if="!state.image" class="primary" :disabled="!isAdmin" @click="emit('settings')">设置本地虚拟机</button>
      <button v-else-if="!state.fixedCapacity&&state.container==='missing'&&!agent.temporaryGoalId" class="primary" :disabled="busy" @click="action('create')">创建 {{agent.name}} 的虚拟机</button>
      <button v-else-if="!state.fixedCapacity&&state.container==='stopped'&&!agent.temporaryGoalId" class="primary" :disabled="busy" @click="action('start')">启动虚拟机</button>
@@ -129,7 +134,7 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
       <button v-if="state.mode==='per-bot'" :disabled="busy" @click="emit('workspace',agent)"><AppIcon name="panel"/>打开双桌面</button>
       <p class="control-state">{{state.controlMode==='human'?'你或其他操作者正在控制电脑':state.inUse?'机器人正在操作':'仅查看 · 虚拟机空闲'}}</p>
      </template>
-     <div v-if="!state.fixedCapacity&&state.container!=='missing'&&!agent.temporaryGoalId" class="vm-actions"><button :disabled="busy||state.inUse" @click="action('stop')">停止虚拟机</button><button :disabled="busy||state.inUse" @click="action('recreate')">重建虚拟机</button></div>
+     <div v-if="!state.fixedCapacity&&state.container!=='missing'&&!agent.temporaryGoalId" class="vm-actions"><button :disabled="busy||state.inUse" @click="action('stop')">停止虚拟机</button><button :disabled="busy||state.inUse" @click="action('recreate')">重建虚拟机</button><button :disabled="busy||state.inUse" @click="action('remove')">移除实例</button></div>
      <p class="hint">{{state.fixedCapacity?'桌面由 Compose 创建,数量固定。这里只连接已有共享桌面;工作目录为 /home/cua/workspace。':'重建会保留工作目录和浏览器资料。虚拟机空闲 5 分钟后自动停止。'}}</p>
      <button v-if="isAdmin" class="settings-link" @click="emit('settings')"><AppIcon name="settings" :size="14"/>本地虚拟机设置</button>
     </template>

+ 10 - 5
src/runner/agent.ts

@@ -3,6 +3,7 @@ import { DatabaseSync } from 'node:sqlite'
 import { realpath,access } from 'node:fs/promises'
 import { resolve, sep, join } from 'node:path'
 import {LocalVmImages} from './computers/localVm.js'
+import {LOCAL_VM_IMAGE_KEYS} from '../shared/localVm.js'
 import {ComputerError} from './computers/container.js'
 import {z} from 'zod'
 import {ComputerRuntime,ComputerGateway,type ComputerTarget} from './worker/gateway.js'
@@ -54,7 +55,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(path==='desktop'?75000: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,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-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,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'',...(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){
@@ -95,7 +96,7 @@ export class RunnerAgent {
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
     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 input=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),op:z.enum(['status','prepare','policy','instance']),id:z.string().uuid().optional(),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).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'){
@@ -104,12 +105,12 @@ export class RunnerAgent {
         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)
+      if(input.op==='prepare')return this.localVm.prepare(input.id!,check,input.imageKey)
       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','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)
+      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','image','lifecycle','frame','take','input','giveback']),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional(),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')}
@@ -120,8 +121,12 @@ export class RunnerAgent {
         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,fixedCapacity:vm.fixedCapacity}
+        const imageId=this.computers.imageFor(input.target),option=vm.images?.find(image=>image.imageId===imageId)
+        let image=!!option?.ready
+        if(!image&&!vm.fixedCapacity&&imageId!==UNCONFIGURED_COMPUTER_IMAGE)try{await this.localVm!.images.inspect(imageId);image=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.fixedCapacity?vm.image:image,imageId,images:vm.images,imageKey:option?.key,controlMode:state.mode,fixedCapacity:vm.fixedCapacity}
       }
+      if(input.op==='image')return this.localVm!.select(input.target,z.enum(LOCAL_VM_IMAGE_KEYS).parse(input.imageKey),authorize)
       if(input.op==='lifecycle'){
         const action=z.enum(['create','start','stop','recreate','remove']).parse(input.action)
         await this.computers.desktop(input.target,input.profile,action,authorize)

+ 6 - 4
src/runner/computers/images.ts

@@ -6,9 +6,11 @@ import {tmpdir} from 'node:os'
 import {spawn} from 'node:child_process'
 import {z} from 'zod'
 import {ContainerComputerProvider} from './container.js'
+import type {LocalVmImageKey} from '../../shared/localVm.js'
+import {LOCAL_VM_IMAGE_KEYS} from '../../shared/localVm.js'
 
 const imageId=z.string().regex(/^sha256:[a-f0-9]{64}$/)
-export const imageManifest=z.object({protocol:z.literal(1),imageId,architecture:z.enum(['amd64','arm64']),driver:z.literal('0.20.0'),layer:z.literal('5'),createdAt:z.number().int().nonnegative(),archiveSha256:z.string().regex(/^[a-f0-9]{64}$/).optional()}).strict()
+export const imageManifest=z.object({protocol:z.literal(1),imageId,architecture:z.enum(['amd64','arm64']),driver:z.literal('0.20.0'),layer:z.literal('5'),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional(),createdAt:z.number().int().nonnegative(),archiveSha256:z.string().regex(/^[a-f0-9]{64}$/).optional()}).strict()
 export type ComputerImageManifest=z.infer<typeof imageManifest>
 export async function fileSHA256(path:string){const hash=createHash('sha256');for await(const bytes of createReadStream(path))hash.update(bytes);return hash.digest('hex')}
 export async function writeManifest(path:string,manifest:ComputerImageManifest){
@@ -32,11 +34,11 @@ export class ComputerImages {
     imageId.parse(id)
     const image=JSON.parse(await this.run(this.runtime,['image','inspect',id]))[0]
     if(image?.Id!==id||image.Os!=='linux'||image.Config?.Labels?.['com.openmausbot.cua-driver']!=='0.20.0'||image.Config?.Labels?.['com.openmausbot.image-layer']!=='5')throw new Error('镜像身份或电脑驱动不兼容')
-    return imageManifest.parse({protocol:1,imageId:id,architecture:image.Architecture,driver:'0.20.0',layer:'5',createdAt:Date.now()})
+    return imageManifest.parse({protocol:1,imageId:id,architecture:image.Architecture,driver:'0.20.0',layer:'5',imageKey:image.Config.Labels['cn.samien.yaoyao.image-key']??'standard',createdAt:Date.now()})
   }
-  async prepare(recipe:string):Promise<ComputerImageManifest>{
+  async prepare(recipe:string,key:LocalVmImageKey='standard'):Promise<ComputerImageManifest>{
     const tag=`localhost/yaoyao/computer:prepare-${randomUUID()}`
-    await this.run(this.runtime,['build','--pull=false','--tag',tag,resolve(recipe)])
+    await this.run(this.runtime,['build','--pull=false',...(key==='cursor'?['--platform','linux/amd64','--file',join(resolve(recipe),'Dockerfile.cursor')]:[]),'--tag',tag,resolve(recipe)])
     const id=JSON.parse(await this.run(this.runtime,['image','inspect',tag]))[0]?.Id
     const manifest=await this.inspect(id)
     await this.verify(manifest)

+ 39 - 12
src/runner/computers/localVm.ts

@@ -5,13 +5,14 @@ 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'
+import {LOCAL_VM_IMAGES, type LocalVmImageKey, type LocalVmImageOption} from '../../shared/localVm.js'
+import type { ComputerRuntime, ComputerTarget } from '../worker/gateway.js'
 import {ComposeComputerProvider} from './compose.js'
 import {COMPOSE_DESKTOP_IMAGE} from '../../shared/composeDesktops.js'
 
-type State = { imageId: string; mode: LocalVmMode; maxInstances: number; job?: LocalVmStatus['job'] }
+type State = { imageId: string; images?: Partial<Record<LocalVmImageKey,string>>; mode: LocalVmMode; maxInstances: number; job?: LocalVmStatus['job'] }
 
-/** One managed Local VM image. It is prepared in Settings, never selected from an image catalog. */
+/** Settings prepares trusted recipes; each desktop keeps one immutable image. */
 export class LocalVmImages {
   readonly images: ComputerImages
   readonly ready: Promise<void>
@@ -31,6 +32,7 @@ export class LocalVmImages {
       if (old) this.state.imageId = JSON.parse(old.value).active ?? this.state.imageId
     }
     runtime.config.imageId = this.state.imageId
+    this.state.images??={standard:this.state.imageId}
     runtime.pool.limits.concurrent = this.state.maxInstances
     runtime.retainDesktops = true
     this.releaseMaintenance = runtime.pool.holdMaintenance()
@@ -51,6 +53,29 @@ export class LocalVmImages {
     await this.runtime.provider.remove(spec)
     await this.runtime.provider.deleteWorkspace(spec)
   }
+  async options():Promise<LocalVmImageOption[]> {
+    await this.ready
+    return Promise.all(LOCAL_VM_IMAGES.map(async option=>{
+      const imageId=this.state.images?.[option.key]
+      let ready=false
+      if(imageId&&imageId!==UNCONFIGURED_COMPUTER_IMAGE)try{const image=await this.images.inspect(imageId);ready=(image.imageKey??'standard')===option.key}catch{}
+      return {...option,ready,...(ready?{imageId}:{})}
+    }))
+  }
+  async select(meta:ComputerTarget,key:LocalVmImageKey,authorize:()=>void) {
+    if(this.runtime.provider.fixedCapacity)throw new ComputerError('compose_desktop_managed','桌面镜像由 Compose 配置')
+    await this.ready;authorize();this.runtime.assertTarget(meta)
+    const release=this.runtime.pool.beginMaintenance({allowIdle:true})
+    try {
+      this.runtime.imageFor(meta) // Validate ownership, including bindings without an instance.
+      if(this.runtime.pool.definition(meta.ownerKey,meta.environmentId))throw new ComputerError('computer_image_in_use','请先移除虚拟机实例再切换镜像,工作文件和浏览器资料会保留')
+      const image=(await this.options()).find(option=>option.key===key&&option.ready)
+      if(!image?.imageId)throw new ComputerError('computer_image_required','请先在本地虚拟机设置中准备这个镜像')
+      authorize()
+      this.db.prepare('INSERT INTO computer_image_bindings VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET image_id=excluded.image_id').run(meta.environmentId,meta.ownerKey,image.imageId)
+      return {ok:true}
+    } finally {release()}
+  }
   async status(owner?:string): Promise<LocalVmStatus> {
     await this.ready
     if(this.runtime.provider instanceof ComposeComputerProvider){
@@ -67,7 +92,7 @@ export class LocalVmImages {
     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,
+    return {configured:true,runtime:this.runtime.config.runtime,daemonUp,image,imageId:image?this.state.imageId:undefined,images:await this.options(),
       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})):[]}
   }
@@ -81,13 +106,13 @@ export class LocalVmImages {
     finally { release() }
     return this.status()
   }
-  async prepare(id:string,check:()=>Promise<void>) {
+  async prepare(id:string,check:()=>Promise<void>,key:LocalVmImageKey='standard') {
     if(this.runtime.provider.fixedCapacity)throw new ComputerError('compose_desktop_managed','桌面镜像由 Compose 配置,无需在界面准备')
     await this.ready;await check()
-    if(this.state.job?.id===id)return this.status()
+    if(this.state.job?.id===id){if((this.state.job.imageKey??'standard')!==key)throw new ComputerError('idempotency_conflict','请求编号已用于其他镜像');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()
+    this.state.job={id,state:'running',imageKey:key,message:'正在准备本地虚拟机'};this.save()
     const progress=(message:string)=>{this.state.job!.message=message;this.save()}
     this.pending=(async()=>{
       try {
@@ -104,18 +129,20 @@ export class LocalVmImages {
         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{} }
+        for(const value of [this.state.images?.[key],...ids].filter((id):id is string=>!!id&&id!==UNCONFIGURED_COMPUTER_IMAGE)) { try{const candidate=await this.images.inspect(value);if((candidate.imageKey??'standard')===key){image=candidate;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)
+          image=await this.images.prepare(recipe,key)
         }
-        await check();this.state.imageId=image.imageId;this.runtime.config.imageId=image.imageId
-        this.state.job={id,state:'complete',message:'本地虚拟机已就绪,可在机器人的电脑面板中创建桌面'};this.save()
+        await check();const previous=this.state.images![key];this.state.images![key]=image.imageId
+        // Preparing a second image must not change an existing desktop's choice.
+        if(this.state.imageId===UNCONFIGURED_COMPUTER_IMAGE||this.state.imageId===previous){this.state.imageId=image.imageId;this.runtime.config.imageId=image.imageId}
+        this.state.job={id,state:'complete',imageKey:key,message:'镜像已就绪,可在机器人的电脑面板中选择并创建桌面'};this.save()
       } catch(error) {
-        this.state.job={id,state:'failed',message:error instanceof Error?error.message.slice(0,240):'本地虚拟机准备失败'};this.save()
+        this.state.job={id,state:'failed',imageKey:key,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() }

+ 1 - 1
src/runner/worker/control.ts

@@ -65,7 +65,7 @@ export class ComputerControls {
       if(state.gateway){state.lease=await state.gateway.takeControl(id,()=>this.guard(state));state.spec=state.gateway.specification}
       else{
         const resolved=await this.runtime.resolveWorkspace(profile);await check();this.guard(state)
-        state.spec={id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.config.imageId,cwd:resolved.cwd,network:this.runtime.config.network??'none'}
+        state.spec={id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.imageFor(meta),cwd:meta.environmentId!==meta.agentId?(this.runtime.pool.definition(meta.ownerKey,meta.environmentId)?.cwd??'/home/cua/workspace'):resolved.cwd,network:this.runtime.config.network??'none'}
         await this.runtime.pool.configure(state.spec,()=>this.guard(state))
         state.lease=await this.runtime.pool.acquire(state.spec,`human:${id}`,()=>this.guard(state))
         if(this.runtime.config.network==='public-proxy')state.proxy=await ComputerPublicProxy.start(this.runtime.provider,state.spec,this.runtime.proxyScript,()=>this.runtime.pool.authorize(state.lease!),check,()=>{void this.stop(state)})

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

@@ -55,10 +55,17 @@ export class ComputerRuntime {
     this.pool=new ComputerPool(db,this.provider,{concurrent:this.config.maxConcurrent??2,environments:32,ttlMs:30000})
     db.exec('CREATE TABLE IF NOT EXISTS retired_computers(id TEXT PRIMARY KEY,owner_key TEXT NOT NULL,spec TEXT)')
     db.exec('CREATE TABLE IF NOT EXISTS computer_sessions(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
+    db.exec('CREATE TABLE IF NOT EXISTS computer_image_bindings(id TEXT PRIMARY KEY,owner_key TEXT NOT NULL,image_id TEXT NOT NULL)')
     this.controls=new ComputerControls(this)
     this.ready=this.pool.recover();void this.ready.catch(()=>{})
   }
   assertTarget(meta:ComputerTarget){if(this.db.prepare('SELECT id FROM retired_computers WHERE id=?').get(meta.environmentId))throw new HttpError(410,'临时电脑已退役','computer_retired')}
+  imageFor(meta:ComputerTarget):string {
+    const spec=this.pool.definition(meta.ownerKey,meta.environmentId)
+    const binding=this.db.prepare('SELECT owner_key,image_id FROM computer_image_bindings WHERE id=?').get(meta.environmentId) as {owner_key:string;image_id:string}|undefined
+    if(binding&&binding.owner_key!==meta.ownerKey)throw new HttpError(403,'电脑归属不匹配','computer_owner_mismatch')
+    return spec?.imageId??binding?.image_id??this.config.imageId
+  }
   async retire(meta:ComputerTarget){
     const old=this.db.prepare('SELECT owner_key,spec FROM retired_computers WHERE id=?').get(meta.environmentId) as {owner_key:string;spec?:string}|undefined
     if(old&&old.owner_key!==meta.ownerKey)throw new HttpError(403,'电脑归属不匹配','computer_owner_mismatch')
@@ -105,7 +112,7 @@ export class ComputerRuntime {
       return
     }
     const resolved=await this.resolveWorkspace(profile);authorize()
-    await this.pool.desktop({id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.config.imageId,cwd:meta.environmentId!==meta.agentId?(this.pool.definition(meta.ownerKey,meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd,network:this.config.network??'none'},action,authorize)
+    await this.pool.desktop({id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.imageFor(meta),cwd:meta.environmentId!==meta.agentId?(this.pool.definition(meta.ownerKey,meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd,network:this.config.network??'none'},action,authorize)
   }
 }
 export class ComputerGateway {
@@ -123,7 +130,7 @@ export class ComputerGateway {
   private async authorized(){try{await this.check();this.guard()}catch(error){await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect();throw error}}
   private guard(){if(this.closed||this.live?.controller.signal.aborted)throw new HttpError(410,'电脑任务授权已结束','computer_cancelled')}
   private event(type:string,payload:Record<string,unknown>){if(!this.closed&&this.live)this.onEvent({type,session_id:this.live.session.id,payload})}
-  private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.config.imageId,cwd:session.cwd,network:this.runtime.config.network??'none'}}
+  private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.imageFor(this.meta),cwd:session.cwd,network:this.runtime.config.network??'none'}}
   async rpc(method:string,params:Record<string,any>):Promise<any>{
     if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.live?.session.id){await this.interrupt();return {status:'interrupted'}}
     if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.recoverySession?.id){await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {status:'interrupted'}}
@@ -132,7 +139,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.imageFor(this.meta)===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:[]}

+ 3 - 2
src/server/composeDesktops.ts

@@ -2,12 +2,13 @@ import {request} from 'node:http'
 import {z} from 'zod'
 import {HttpError} from './errors.js'
 import type {ComposeDesktop,ComposeDesktopState} from '../shared/composeDesktops.js'
+import {LOCAL_VM_IMAGE_KEYS} from '../shared/localVm.js'
 
 export function parseComposeDesktops(value:string|undefined):ComposeDesktop[]{
   if(!value?.trim())return []
   let input:unknown
   try{input=JSON.parse(value)}catch{throw new Error('HERMES_YAOYAO_COMPOSE_DESKTOPS 必须为有效 JSON')}
-  const parsed=z.array(z.object({id:z.string().uuid(),name:z.string().trim().min(1).max(100),socketPath:z.string().regex(/^\/run\/yaoyao-desktops\/[a-z0-9_-]+\/desktop\.sock$/)}).strict()).min(1).max(32).parse(input)
+  const parsed=z.array(z.object({id:z.string().uuid(),name:z.string().trim().min(1).max(100),socketPath:z.string().regex(/^\/run\/yaoyao-desktops\/[a-z0-9_-]+\/desktop\.sock$/),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional()}).strict()).min(1).max(32).parse(input)
   if(new Set(parsed.map(x=>x.id)).size!==parsed.length||new Set(parsed.map(x=>x.socketPath)).size!==parsed.length)throw new Error('Compose 桌面 ID 和连接路径不能重复')
   return parsed
 }
@@ -30,5 +31,5 @@ export class ComposeDesktops {
       req.end(data)
     })
   }
-  async status():Promise<ComposeDesktopState[]>{return Promise.all(this.desktops.map(async d=>{try{const state=await this.call(d.id,'health');if(state.id!==d.id||state.protocol!==1)throw new Error('身份不匹配');return {id:d.id,name:d.name,online:true,ready:state.ready===true}}catch{return {id:d.id,name:d.name,online:false,ready:false}}}))}
+  async status():Promise<ComposeDesktopState[]>{return Promise.all(this.desktops.map(async d=>{try{const state=await this.call(d.id,'health');if(state.id!==d.id||state.protocol!==1)throw new Error('身份不匹配');return {id:d.id,name:d.name,imageKey:d.imageKey,online:true,ready:state.ready===true}}catch{return {id:d.id,name:d.name,imageKey:d.imageKey,online:false,ready:false}}}))}
 }

+ 12 - 2
src/server/localVm.ts

@@ -8,6 +8,7 @@ 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 {LOCAL_VM_IMAGE_KEYS} from '../shared/localVm.js'
 import type { WorkspaceAgent } from '../shared/workspace.js'
 import { parse, type WorkspaceStore } from './workspaceStore.js'
 import type { LocalAuthStore } from './localAuth.js'
@@ -138,9 +139,9 @@ export class LocalVmService {
     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=>{
       this.auth.requireAdmin(ctx);this.assertManaged()
-      const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
+      const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid(),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional()}).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})
+      ctx.body=await this.hub.localVm(actor.id,record.id,{op:'prepare',id:body.requestId,...(body.imageKey?{imageKey:body.imageKey}:{})})
     })
     router.put('/api/app/admin/local-vm/policy',async ctx=>{
       this.auth.requireAdmin(ctx);this.assertManaged()
@@ -187,6 +188,15 @@ export class LocalVmService {
         return {agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
       })
     })
+    router.put('/api/app/agents/:id/local-vm/image',async ctx=>{
+      const owner=this.auth.require(ctx).id;this.assertManaged()
+      const body=parse(z.object({imageKey:z.enum(LOCAL_VM_IMAGE_KEYS)}).strict(),(ctx.request as any).body)
+      const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
+      this.nodes.requireSource(owner,agent)
+      if(agent.execution!=='computer'||agent.archived||agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'此机器人不能更改虚拟机镜像','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,'image',{imageKey:body.imageKey},()=>this.nodes.requireSource(owner,agent))
+    })
     router.post('/api/app/agents/:id/local-vm/:action',async ctx=>{
       this.auth.require(ctx);this.assertManaged()
       const owner=this.auth.require(ctx).id,action=parse(z.enum(['create','start','stop','recreate','remove']),ctx.params.action)

+ 3 - 1
src/server/runnerHub.ts

@@ -56,6 +56,7 @@ export class RunnerHub {
   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('local-vm-v1'))throw new HttpError(409,'本地虚拟机执行环境离线或版本过旧,请重新连接或更新','local_vm_unavailable')
+    if(payload.imageKey&&!this.online.get(id)?.features.includes('image-options-v1'))throw new HttpError(409,'执行节点版本不支持镜像选择,请更新 Runner','computer_image_options_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')
     const key = `${owner}:${id}`
@@ -186,6 +187,7 @@ export class RunnerHub {
   sharedComputerRunner(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent){const record=this.computerRunner(owner,agent);if(agent.computerEnvironmentId&&this.store.require<import('./sharedComputers.js').SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId).runnerId!==record.id)throw new HttpError(409,'共享电脑的原执行节点已变化','shared_runner_changed');if(!this.online.get(record.id)?.features.includes('shared-computer-v1'))throw new HttpError(409,'执行节点不支持共享电脑,请更新 Runner','shared_computer_unavailable');return record}
   async computer(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent,op:string,data:Record<string,unknown>,authorize:()=>void):Promise<any>{
     const record=agent.computerEnvironmentId?this.sharedComputerRunner(owner,agent):this.computerRunner(owner,agent),version=this.auth.pushAuthorizationVersion(owner)
+    if(op==='image'&&!this.online.get(record.id)?.features.includes('image-options-v1'))throw new HttpError(409,'执行节点版本不支持镜像选择,请更新 Runner','computer_image_options_unavailable')
     const valid=()=>{try{authorize();return this.auth.isUserActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version}catch{return false}}
     const result=await this.request(record.id,'computer.control',{target:{environmentId:agent.computerEnvironmentId??agent.id,agentId:agent.id,ownerKey:hash(owner)},profile:agent.profile,op,...data},valid)
     if(!valid())throw new HttpError(403,'电脑请求授权已失效','computer_control_expired')
@@ -227,7 +229,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','local-vm-v1','image-ready-v1','compose-desktops-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-options-v1','image-ready-v1','compose-desktops-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')

+ 3 - 2
src/shared/composeDesktops.ts

@@ -1,5 +1,6 @@
-export interface ComposeDesktop {id:string;name:string;socketPath:string}
-export interface ComposeDesktopState {id:string;name:string;online:boolean;ready:boolean}
+import type {LocalVmImageKey} from './localVm.js'
+export interface ComposeDesktop {id:string;name:string;socketPath:string;imageKey?:LocalVmImageKey}
+export interface ComposeDesktopState {id:string;name:string;online:boolean;ready:boolean;imageKey?:LocalVmImageKey}
 export interface DesktopRelay {
   (id:string,operation:string,body:Record<string,unknown>):Promise<any>
 }

+ 14 - 3
src/shared/localVm.ts

@@ -1,8 +1,15 @@
 export type LocalVmMode = 'shared' | 'per-bot'
+export const LOCAL_VM_IMAGE_KEYS = ['standard', 'cursor'] as const
+export type LocalVmImageKey = typeof LOCAL_VM_IMAGE_KEYS[number]
+export const LOCAL_VM_IMAGES = [
+  {key:'standard',name:'标准桌面',description:'XFCE 桌面环境'},
+  {key:'cursor',name:'Cursor Universal',description:'Debian 开发桌面 · amd64'},
+] as const
+export interface LocalVmImageOption {key:LocalVmImageKey;name:string;description:string;ready:boolean;imageId?:string}
 export type LocalVmAction = 'create' | 'start' | 'stop' | 'recreate' | 'remove'
 export interface LocalVmStatus {
   fixedCapacity?:boolean
-  desktops?:Array<{id:string;name:string;online:boolean;ready:boolean;available?:boolean}>
+  desktops?:Array<{id:string;name:string;online:boolean;ready:boolean;available?:boolean;imageKey?:LocalVmImageKey}>
   executionHost?: 'server' | 'runner'
   runnerName?: string
   setupRequired?: 'runner' | 'worker'
@@ -11,22 +18,26 @@ export interface LocalVmStatus {
   daemonUp: boolean
   image: boolean
   imageId?: string
+  images?: LocalVmImageOption[]
   mode: LocalVmMode
   maxInstances: number
   busy: boolean
   problem?: string
-  job?: { id: string; state: 'running' | 'complete' | 'failed'; message: string }
+  job?: { id: string; state: 'running' | 'complete' | 'failed'; message: string; imageKey?: LocalVmImageKey }
   instances?: Array<{id:string;status:string;name?:string;orphaned?:boolean}>
 }
 export interface LocalVmInstance {
   desktopId?:string
   fixedCapacity?:boolean
-  desktops?:Array<{id:string;name:string;online:boolean;ready:boolean;available?:boolean}>
+  desktops?:Array<{id:string;name:string;online:boolean;ready:boolean;available?:boolean;imageKey?:LocalVmImageKey}>
   container: 'missing' | 'stopped' | 'running'
   ready: boolean
   inUse: boolean
   mode: LocalVmMode
   maxInstances: number
   image: boolean
+  imageId?: string
+  images?: LocalVmImageOption[]
+  imageKey?: LocalVmImageKey
   problem?: string
 }

+ 43 - 0
tests/client/localVmImages.test.ts

@@ -0,0 +1,43 @@
+import {it,expect,vi,afterEach} from 'vitest'
+import {mount,flushPromises} from '@vue/test-utils'
+import Settings from '../../src/client/components/app/LocalVmSettingsPanel.vue'
+import Panel from '../../src/client/components/workspace/LocalVmChatPanel.vue'
+import {apiRequest} from '../../src/client/api/client'
+vi.mock('../../src/client/api/client',()=>({apiRequest:vi.fn()}))
+const images=[{key:'standard',name:'标准桌面',description:'XFCE',ready:true,imageId:'sha256:a'},{key:'cursor',name:'Cursor Universal',description:'Debian',ready:true,imageId:'sha256:b'}]
+const agent={id:'image-fixture',name:'镜像测试',computer:'vm',execution:'computer',nodeId:'local',profile:'default'} as any
+const vm={configured:true,runtime:'docker',daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false,images}
+afterEach(()=>{vi.clearAllMocks();vi.restoreAllMocks()})
+it('prepares the selected image in Settings and explains the shared image constraint',async()=>{
+ vi.mocked(apiRequest).mockImplementation(async path=>path==='/api/app/computers'?{computers:[]}:vm as any)
+ const wrapper=mount(Settings,{global:{stubs:{AppIcon:true,RunnerSettingsPanel:true}}})
+ try{
+  await flushPromises()
+  const select=wrapper.findAll('select').find(select=>select.text().includes('Cursor Universal'))!
+  await select.setValue('cursor')
+  await wrapper.findAll('button').find(b=>b.text()==='重新检查所选镜像')!.trigger('click')
+  await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/admin/local-vm/prepare',expect.objectContaining({body:expect.objectContaining({imageKey:'cursor'})}))
+  expect(wrapper.text()).toContain('同一共享桌面的所有机器人使用一个镜像')
+ }finally{wrapper.unmount()}
+})
+it('selects a prepared desktop image and locks the selection for an existing shared instance',async()=>{
+ vi.spyOn(document,'hidden','get').mockReturnValue(false)
+ let detail={...vm,enabled:true,container:'missing',ready:false,inUse:false,imageKey:'standard'}
+ vi.mocked(apiRequest).mockImplementation(async path=>{
+  if(path.endsWith('/desktop-environment'))return {selected:null,local:{supported:false},browser:{available:false}} as any
+  if(path.endsWith('/cloud-computer'))return {configured:false} as any
+  return detail as any
+ })
+ const wrapper=mount(Panel,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs:{AppIcon:true,GrokAuthPanel:true}}})
+ try{
+  await flushPromises()
+  const select=wrapper.findAll('select').find(s=>s.text().includes('Cursor Universal'))!
+  await select.setValue('cursor');await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/local-vm/image',{method:'PUT',body:{imageKey:'cursor'}})
+  detail={...detail,mode:'shared',container:'stopped'}
+  await wrapper.setProps({active:false});await wrapper.setProps({active:true});await flushPromises()
+  expect(select.attributes('disabled')).toBeDefined()
+  expect(wrapper.text()).toContain('同一共享桌面只能使用一个镜像')
+ }finally{wrapper.unmount()}
+})

+ 97 - 0
tests/live/desktopImages.live.test.ts

@@ -0,0 +1,97 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtemp,mkdir,readFile,writeFile,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {execFile} from 'node:child_process'
+import {promisify} from 'node:util'
+import {ComputerRuntime} from '../../src/runner/worker/gateway'
+import {LocalVmImages} from '../../src/runner/computers/localVm'
+import {CUA_DRIVER,CUA_SOCKET} from '../../src/runner/computers/container'
+// @ts-expect-error Standalone deployment script.
+import {generateDesktopCompose} from '../../scripts/generate-desktop-compose.mjs'
+const exec=promisify(execFile)
+const enabled=!!process.env.YAOYAO_TEST_STANDARD_IMAGE&&!!process.env.YAOYAO_TEST_CURSOR_IMAGE
+const docker=(args:string[])=>exec('docker',args,{timeout:90000,maxBuffer:8*1024*1024})
+const evidence=resolve('test-results/desktop-image-options')
+
+it.skipIf(!enabled)('runs both images independently, preserves a selection and workspace across recreation, and shares one choice',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'yaoyao-images-live-')),db=new DatabaseSync(':memory:')
+ const standard=(await docker(['image','inspect',process.env.YAOYAO_TEST_STANDARD_IMAGE!,'--format','{{.Id}}'])).stdout.trim()
+ const cursor=(await docker(['image','inspect',process.env.YAOYAO_TEST_CURSOR_IMAGE!,'--format','{{.Id}}'])).stdout.trim()
+ 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:standard,python:'/unused',hermesSource:'/unused',hermesHome:'/unused',network:'public-proxy'}},root)
+ const manager=new LocalVmImages(runtime,db)
+ const ids=[randomUUID(),randomUUID()],targets=ids.map(id=>({environmentId:id,agentId:id,ownerKey:'fixture'}))
+ const ready=async(meta:typeof targets[number])=>{const spec=runtime.pool.definition('fixture',meta.environmentId)!;await vi.waitFor(async()=>{await runtime.provider.health(spec,()=>{});await runtime.provider.capture(spec,()=>{})},{timeout:65000,interval:1000});return spec}
+ try{
+  await manager.ready
+  // Bind the exact two local images under test; normal preparation verification still runs.
+  const command=manager.images.run.bind(manager.images)
+  vi.spyOn(manager.images,'run').mockImplementation((engine,args)=>args[0]==='image'&&args[1]==='ls'?Promise.resolve(cursor):command(engine,args))
+  await manager.prepare(randomUUID(),async()=>{},'cursor')
+  await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('complete'),{timeout:80000,interval:1000})
+  vi.spyOn(runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:'.'})
+  for(const [index,key] of (['standard','cursor'] as const).entries()){
+   await manager.select(targets[index]!,key,()=>{})
+   await runtime.desktop(targets[index]!,'default','create',()=>{})
+  }
+  await mkdir(evidence,{recursive:true})
+  for(const [index,meta] of targets.entries()){
+   const spec=await ready(meta)
+   if(index===1){
+    await runtime.provider.execute(spec,['/usr/local/bin/yaoyao-browser','--new-window','data:text/html,<title>Yaoyao Cursor verification</title><h1>Cursor Universal</h1><p>Desktop, browser and private workspace are ready.</p>'],{authorize:()=>{}})
+    await vi.waitFor(()=>runtime.provider.execute(spec,['xdotool','search','--name','Yaoyao Cursor verification'],{authorize:()=>{}}),{timeout:30000,interval:1000})
+    expect((await runtime.provider.execute(spec,['ps','-eo','args='],{authorize:()=>{}})).stdout).toContain('--proxy-server=http://127.0.0.1:3128')
+   }
+   const result=await runtime.provider.execute(spec,['sh','-c',`printf image-${index} > proof-${index}.txt; mkdir -p .browser-profiles; printf profile-${index} > .browser-profiles/proof-${index}.txt; test ! -e proof-${1-index}.txt`],{authorize:()=>{}})
+   expect(result.stderr).toBe('')
+   const frame=await runtime.provider.capture(spec,()=>{})
+   await writeFile(join(evidence,index?'cursor.png':'standard.png'),Buffer.from(frame.data,'base64'))
+  }
+  expect(runtime.pool.status('fixture').filter(s=>s.status==='idle')).toHaveLength(2)
+  expect(runtime.imageFor({...targets[1]!,agentId:randomUUID()})).toBe(cursor)
+  await expect(manager.select(targets[1]!,'standard',()=>{})).rejects.toMatchObject({code:'computer_image_in_use'})
+  await runtime.desktop(targets[1]!,'default','remove',()=>{})
+  await runtime.desktop(targets[1]!,'default','create',()=>{})
+  const restored=await ready(targets[1]!)
+  expect(restored.imageId).toBe(cursor)
+  expect((await runtime.provider.execute(restored,['cat','proof-1.txt','.browser-profiles/proof-1.txt'],{authorize:()=>{}})).stdout).toBe('image-1profile-1')
+  await runtime.provider.execute(restored,[CUA_DRIVER,'call','press_key',JSON.stringify({key:'escape'}),'--socket',CUA_SOCKET],{authorize:()=>{}})
+  expect(await readFile(join(root,'computer-workspaces',ids[0]!,'proof-0.txt'),'utf8')).toBe('image-0')
+ }finally{
+  await manager.close()
+  for(const meta of targets)await runtime.desktop(meta,'default','remove',()=>{})
+  await runtime.controls.close();await runtime.pool.close();db.close();await rm(root,{recursive:true,force:true})
+ }
+},240000)
+
+it.skipIf(!enabled)('runs mixed Compose desktops with isolated volumes and the same private bridge protocol',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'yaoyao-compose-images-')),project='yaoyao-image-test-'+randomUUID().slice(0,8)
+ const entries=[{id:randomUUID(),service:'desktop-1',name:'Standard'},{id:randomUUID(),service:'desktop-2',name:'Cursor',imageKey:'cursor'}]
+ let yaml=generateDesktopCompose(await readFile('compose.yaml','utf8'),entries)
+ yaml=yaml.replace('${YAOYAO_DESKTOP_IMAGE:-hermes-yaoyao-desktop:0.4.0}',process.env.YAOYAO_TEST_STANDARD_IMAGE!).replace('${YAOYAO_CURSOR_DESKTOP_IMAGE:-yaoyao-desktop:cursor}',process.env.YAOYAO_TEST_CURSOR_IMAGE!)
+ const config=join(root,'compose.yaml');await writeFile(config,yaml)
+ const compose=['compose','--project-name',project,'--project-directory',process.cwd(),'-f',config]
+ try{
+  await docker([...compose,'up','-d','--no-build','desktop-1','desktop-2'])
+  for(const [index,entry] of entries.entries()){
+   const id=(await docker([...compose,'ps','-q',entry.service])).stdout.trim()
+   const call=async(op:string,body={})=>{
+    const script="import http.client,socket,sys; c=http.client.HTTPConnection('localhost'); c.sock=socket.socket(socket.AF_UNIX); c.sock.connect('/run/desktop/desktop.sock'); c.request('POST',sys.argv[1],sys.argv[2]); r=c.getresponse(); print(r.read().decode())"
+    // Match Web's uid and read-only IPC mount, outside the desktop's private parent.
+    return JSON.parse((await docker(['run','--rm','--network','none','--cap-drop','ALL','--user','1000:1000','--mount',`type=volume,src=${project}_${entry.service}-ipc,dst=/run/desktop,readonly`,'--entrypoint','python3',process.env.YAOYAO_TEST_CURSOR_IMAGE!,'-c',script,'/'+op,JSON.stringify({...body,desktopId:entry.id})])).stdout)
+   }
+   await vi.waitFor(async()=>expect((await call('health')).ready).toBe(true),{timeout:65000,interval:1000})
+   const lease=await call('acquire',{requestId:randomUUID(),owner:'fixture'})
+   const state=JSON.parse((await docker(['inspect',id])).stdout)[0]
+   expect(state.HostConfig.NetworkMode).toBe('none')
+   expect(Object.keys(state.HostConfig.PortBindings??{})).toHaveLength(0)
+   const output=await docker(['exec','--user','1000:1000',id,'sh','-c',`printf compose-${index} > /home/cua/workspace/proof-${index}; test ! -e /home/cua/workspace/proof-${1-index}`])
+   expect(output.stderr).toBe('')
+   expect((await call('frame',lease)).data).toBeTruthy()
+   await call('release',{...lease,cancel:false})
+  }
+ }finally{await docker([...compose,'down','--volumes']);await rm(root,{recursive:true,force:true})}
+},180000)

+ 1 - 1
tests/live/localVm.live.test.ts

@@ -55,7 +55,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
   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 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('本机虚拟机')

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

@@ -43,6 +43,7 @@ it('locks Compose capacity and binds existing desktops without allowing cross-ac
   const bind=(id:string,desktopId:string,owner='alice')=>request(app.callback()).put(`/api/app/agents/${id}/local-vm`).set('x-owner',owner).send({enabled:true,desktopId})
   await bind(first.id,randomUUID()).expect(400)
   await bind(first.id,desktops[0]!.id).expect(200);await bind(second.id,desktops[0]!.id).expect(200)
+  await request(app.callback()).put(`/api/app/agents/${first.id}/local-vm/image`).send({imageKey:'cursor'}).expect(409,{code:'compose_desktop_managed'})
   for(const id of [first.id,second.id])expect(()=>nodes.requireSource('alice',store.require('alice','agent',id))).not.toThrow()
   await bind(other.id,desktops[0]!.id,'bob').expect(403)
   expect((await service.status('bob')).desktops?.[0]?.available).toBe(false)

+ 23 - 0
tests/server/desktopComposeImages.test.ts

@@ -0,0 +1,23 @@
+// @vitest-environment node
+import {it,expect} from 'vitest'
+import {readFileSync} from 'node:fs'
+// @ts-expect-error Deployment generator is a standalone Node script.
+import {generateDesktopCompose} from '../../scripts/generate-desktop-compose.mjs'
+import {parseComposeDesktops} from '../../src/server/composeDesktops'
+const base=readFileSync(new URL('../../compose.yaml',import.meta.url),'utf8')
+const entries=JSON.parse(readFileSync(new URL('../../deploy/compose-desktops.json',import.meta.url),'utf8'))
+it('generates different recipes and isolated persistent volumes for two fixed desktops',()=>{
+ const yaml=generateDesktopCompose(base,[entries[0],{...entries[1],imageKey:'cursor'}])
+ expect(yaml).toContain('dockerfile: Dockerfile\n')
+ expect(yaml).toContain('dockerfile: Dockerfile.cursor\n')
+ expect(yaml).toContain('platform: linux/amd64')
+ expect(yaml).toContain('YAOYAO_CURSOR_DESKTOP_IMAGE:-yaoyao-desktop:cursor')
+ expect(yaml).toContain('desktop-1-workspace:/home/cua/workspace')
+ expect(yaml).toContain('desktop-2-workspace:/home/cua/workspace')
+ expect(yaml).not.toContain('/var/run/docker.sock')
+ const line=yaml.split('\n').find((s:string)=>s.includes('HERMES_YAOYAO_COMPOSE_DESKTOPS:'))!
+ const targets=parseComposeDesktops(JSON.parse(line.slice(line.indexOf(':')+1).trim()))
+ expect(targets.map(d=>d.imageKey)).toEqual(['standard','cursor'])
+ expect(targets[0]?.socketPath).not.toBe(targets[1]?.socketPath)
+ for(const key of ['arbitrary','__proto__','constructor','cursor\n    privileged: true'])expect(()=>generateDesktopCompose(base,[{...entries[0],imageKey:key}])).toThrow()
+})

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

@@ -128,3 +128,73 @@ it('keeps uncertain preparation cleanup fenced and retries cleanup when runtime
   const release=f.runtime.pool.beginMaintenance();release()
  }finally{canStop=true;await manager.close();await f.close()}
 })
+
+it('keeps two prepared images and binds one immutable image per independent or shared desktop',async()=>{
+ const f=await fixture(),standard='sha256:'+'a'.repeat(64),cursor='sha256:'+'b'.repeat(64)
+ f.runtime.config.imageId=standard
+ const manager=new LocalVmImages(f.runtime,f.db)
+ const manifest=(id:string)=>({protocol:1 as const,imageId:id,architecture:'amd64' as const,driver:'0.20.0' as const,layer:'5' as const,imageKey:id===cursor?'cursor' as const:'standard' as const,createdAt:1})
+ try{
+  vi.spyOn(manager.images,'inspect').mockImplementation(async id=>manifest(id))
+  vi.spyOn(manager.images,'run').mockResolvedValue(standard+'\n'+cursor)
+  await manager.prepare(randomUUID(),async()=>{},'cursor')
+  await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('complete'))
+  expect((await manager.status()).images?.filter(i=>i.ready).map(i=>i.key)).toEqual(['standard','cursor'])
+  expect(f.runtime.config.imageId).toBe(standard)
+  const a={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'alice'},b={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'alice'}
+  await manager.select(a,'standard',()=>{})
+  await manager.select(b,'cursor',()=>{})
+  expect(f.runtime.imageFor(a)).toBe(standard);expect(f.runtime.imageFor(b)).toBe(cursor)
+  expect(f.runtime.imageFor({...b,agentId:randomUUID()})).toBe(cursor)
+  await expect(manager.select({...b,ownerKey:'bob'},'standard',()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
+  vi.spyOn(f.runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:'.'})
+  await f.runtime.desktop(a,'default','create',()=>{})
+  await f.runtime.desktop(b,'default','create',()=>{})
+  expect(f.running.size).toBe(2)
+  expect(f.runtime.pool.definition('alice',a.environmentId)?.imageId).toBe(standard)
+  expect(f.runtime.pool.definition('alice',b.environmentId)?.imageId).toBe(cursor)
+  await expect(manager.select(b,'standard',()=>{})).rejects.toMatchObject({code:'computer_image_in_use'})
+  await f.runtime.desktop(b,'default','stop',()=>{})
+  await expect(manager.select(b,'standard',()=>{})).rejects.toMatchObject({code:'computer_image_in_use'})
+  await f.runtime.desktop(b,'default','remove',()=>{})
+  expect(f.runtime.imageFor(b)).toBe(cursor)
+  await manager.select(b,'standard',()=>{})
+  expect(f.runtime.imageFor({...b,agentId:randomUUID()})).toBe(standard)
+  expect(f.runtime.provider.deleteWorkspace).toHaveBeenCalledTimes(2) // Only preparation scratch space.
+  await manager.close()
+  const resumed=new LocalVmImages(f.runtime,f.db);await resumed.ready
+  expect(f.runtime.imageFor(b)).toBe(standard)
+  vi.spyOn(resumed.images,'inspect').mockImplementation(async id=>manifest(id))
+  expect((await resumed.options()).filter(i=>i.ready)).toHaveLength(2)
+  await resumed.close()
+ }finally{await manager.close();await f.close()}
+})
+
+it('does not select an unprepared image or mutate a choice after authorization expires',async()=>{
+ const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),meta={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'alice'}
+ try{
+  await expect(manager.select(meta,'cursor',()=>{})).rejects.toMatchObject({code:'computer_image_required'})
+  expect(f.runtime.imageFor(meta)).toBe(UNCONFIGURED_COMPUTER_IMAGE)
+  const denied=()=>{throw new Error('expired')}
+  await expect(manager.select(meta,'standard',denied)).rejects.toThrow('expired')
+  expect(f.db.prepare('SELECT * FROM computer_image_bindings').all()).toEqual([])
+ }finally{await manager.close();await f.close()}
+})
+
+it('does not fall back to the standard recipe when Cursor preparation fails',async()=>{
+ const f=await fixture(),standard='sha256:'+'a'.repeat(64)
+ f.runtime.config.imageId=standard
+ const manager=new LocalVmImages(f.runtime,f.db)
+ try{
+  vi.spyOn(manager.images,'run').mockResolvedValue(standard)
+  vi.spyOn(manager.images,'inspect').mockResolvedValue({protocol:1,imageId:standard,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1})
+  const prepare=vi.spyOn(manager.images,'prepare').mockRejectedValue(new Error('cursor build failed'))
+  const id=randomUUID()
+  await manager.prepare(id,async()=>{},'cursor')
+  await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('failed'))
+  expect(prepare).toHaveBeenCalledWith(expect.any(String),'cursor')
+  expect(f.runtime.config.imageId).toBe(standard)
+  expect((await manager.options()).find(i=>i.key==='cursor')?.ready).toBe(false)
+  await expect(manager.prepare(id,async()=>{},'standard')).rejects.toMatchObject({code:'idempotency_conflict'})
+ }finally{await manager.close();await f.close()}
+})

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

@@ -31,6 +31,12 @@ it('protects preparation, removes the old catalog API and scopes sharing to the
   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)
+  await request(app.callback()).post(base+'/prepare').set('x-admin','yes').send({requestId:randomUUID(),imageKey:'cursor'}).expect(200)
+  expect(hub.localVm).toHaveBeenLastCalledWith('alice',runner.id,expect.objectContaining({op:'prepare',imageKey:'cursor'}))
+  await request(app.callback()).put(`/api/app/agents/${first.id}/local-vm/image`).send({imageKey:'cursor'}).expect(200)
+  expect(hub.computer).toHaveBeenLastCalledWith('alice',expect.objectContaining({id:first.id}),'image',{imageKey:'cursor'},expect.any(Function))
+  await request(app.callback()).put(`/api/app/agents/${first.id}/local-vm/image`).send({imageKey:'arbitrary'}).expect(400)
+  await request(app.callback()).put(`/api/app/agents/${first.id}/local-vm/image`).set('x-owner','bob').send({imageKey:'cursor'}).expect(404)
   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)
@@ -38,6 +44,8 @@ it('protects preparation, removes the old catalog API and scopes sharing to the
   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)
+  await request(app.callback()).put(`/api/app/agents/${second.id}/local-vm/image`).send({imageKey:'standard'}).expect(200)
+  expect(hub.computer).toHaveBeenLastCalledWith('alice',expect.objectContaining({computerEnvironmentId:a.computerEnvironmentId}),'image',{imageKey:'standard'},expect.any(Function))
   expect(store.require('alice','agent',chatting.id)).toEqual(chatting)
   const later=store.createAgent('alice',{name:'后加入成员',profile:'default',execution:'computer'})
   expect(later.computerEnvironmentId).toBe(a.computerEnvironmentId)