Browse Source

为 Docker Web 接入 Compose 固定共享桌面并锁定数量

LSamien 1 month ago
parent
commit
70cdd9c532

+ 109 - 0
compose.desktops.yaml

@@ -0,0 +1,109 @@
+# Generated from compose.yaml and deploy/compose-desktops.json.
+name: hermes-yaoyao
+
+services:
+  web:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    image: "${HERMES_YAOYAO_IMAGE:-hermes-yaoyao:local}"
+    restart: unless-stopped
+    init: true
+    read_only: true
+    security_opt:
+      - no-new-privileges:true
+    cap_drop:
+      - ALL
+    ports:
+      - "${HERMES_YAOYAO_BIND_ADDRESS:-127.0.0.1}:${HERMES_YAOYAO_PUBLISHED_PORT:-15300}:15300"
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+    environment:
+      NODE_ENV: production
+      HERMES_YAOYAO_HOST: 0.0.0.0
+      HERMES_YAOYAO_PORT: 15300
+      HERMES_YAOYAO_UPSTREAM: "${HERMES_YAOYAO_UPSTREAM:-http://host.docker.internal:9119}"
+      # Permit localhost access when the published host port differs from the
+      # container's fixed 15300 port; configured public host names remain opt-in.
+      HERMES_YAOYAO_ALLOWED_HOSTS: "localhost,127.0.0.1,${HERMES_YAOYAO_ALLOWED_HOSTS:-}"
+      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_CHAT_CACHE_MODE: "${HERMES_YAOYAO_CHAT_CACHE_MODE:-prefer-local}"
+      HERMES_YAOYAO_HOME: /var/lib/hermes-yaoyao
+      HERMES_YAOYAO_APNS_KEY_FILE: "${HERMES_YAOYAO_APNS_KEY_FILE:-}"
+      HERMES_YAOYAO_APNS_KEY_ID: "${HERMES_YAOYAO_APNS_KEY_ID:-}"
+      HERMES_YAOYAO_APNS_TEAM_ID: "${HERMES_YAOYAO_APNS_TEAM_ID:-}"
+      HERMES_YAOYAO_APNS_TOPIC: "${HERMES_YAOYAO_APNS_TOPIC:-}"
+    volumes:
+      - yaoyao-data:/var/lib/hermes-yaoyao
+      - desktop-1-ipc:/run/yaoyao-desktops/desktop-1:ro
+      - desktop-2-ipc:/run/yaoyao-desktops/desktop-2:ro
+    tmpfs:
+      - /tmp:mode=1777
+
+  desktop-1:
+    image: "${YAOYAO_DESKTOP_IMAGE:-hermes-yaoyao-desktop:0.4.0}"
+    build:
+      context: ./deploy/computer
+    hostname: desktop-1
+    extra_hosts:
+      - "desktop-1: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: "160fe7cd-e530-4ac5-a97f-26c19a362b11"
+    volumes:
+      - desktop-1-workspace:/home/cua/workspace
+      - desktop-1-ipc:/run/yaoyao-private/bridge
+  desktop-2:
+    image: "${YAOYAO_DESKTOP_IMAGE:-hermes-yaoyao-desktop:0.4.0}"
+    build:
+      context: ./deploy/computer
+    hostname: desktop-2
+    extra_hosts:
+      - "desktop-2: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: "eba5caaf-744e-4f27-bcdd-1f1f35d05a42"
+    volumes:
+      - desktop-2-workspace:/home/cua/workspace
+      - desktop-2-ipc:/run/yaoyao-private/bridge
+
+volumes:
+  yaoyao-data:
+
+  desktop-1-workspace:
+  desktop-1-ipc:
+  desktop-2-workspace:
+  desktop-2-ipc:

+ 1 - 1
compose.yaml

@@ -5,7 +5,7 @@ services:
     build:
       context: .
       dockerfile: Dockerfile
-    image: hermes-yaoyao:local
+    image: "${HERMES_YAOYAO_IMAGE:-hermes-yaoyao:local}"
     restart: unless-stopped
     init: true
     read_only: true

+ 4 - 0
deploy/compose-desktops.json

@@ -0,0 +1,4 @@
+[
+  {"id":"160fe7cd-e530-4ac5-a97f-26c19a362b11","service":"desktop-1","name":"共享桌面 1"},
+  {"id":"eba5caaf-744e-4f27-bcdd-1f1f35d05a42","service":"desktop-2","name":"共享桌面 2"}
+]

+ 4 - 0
deploy/computer/Dockerfile

@@ -94,3 +94,7 @@ LABEL cn.samien.yaoyao.computer-image="1"
 COPY prepare-firefox.sh /usr/local/bin/prepare-yaoyao-firefox.sh
 RUN chmod 0755 /usr/local/bin/prepare-yaoyao-firefox.sh \
     && sed -i '2i /usr/local/bin/prepare-yaoyao-firefox.sh || exit 1' /usr/local/bin/start-openmausbot-cua-driver.sh
+
+COPY compose_bridge.py /usr/local/libexec/yaoyao/compose_bridge.py
+RUN printf '\n[program:yaoyao-compose-desktop]\ncommand=python3 /usr/local/libexec/yaoyao/compose_bridge.py\nuser=root\nautorestart=unexpected\nexitcodes=0\nstartsecs=0\nstdout_logfile=/var/log/supervisor/yaoyao-compose.log\nstderr_logfile=/var/log/supervisor/yaoyao-compose.error.log\npriority=40\n' >> /etc/supervisor/supervisord.conf
+LABEL cn.samien.yaoyao.compose-desktop="1"

+ 193 - 0
deploy/computer/compose_bridge.py

@@ -0,0 +1,193 @@
+"""Private Compose desktop transport. No network port or Docker socket."""
+import base64
+import http.server
+import json
+import os
+from pathlib import Path
+import secrets
+import signal
+import socketserver
+import subprocess
+import threading
+import time
+
+DESKTOP_ID = os.environ.get('YAOYAO_COMPOSE_DESKTOP_ID')
+if not DESKTOP_ID:
+    raise SystemExit(0)
+ROOT = Path('/run/yaoyao-private')
+ROOT.mkdir(exist_ok=True)
+ROOT.chmod(0o700)
+BRIDGE = ROOT / 'bridge'
+BRIDGE.mkdir(exist_ok=True)
+BRIDGE.chmod(0o755)
+SOCKET = BRIDGE / 'desktop.sock'
+INSTANCE = secrets.token_hex(24)
+PID1 = Path('/proc/1/stat').read_text().split()[21]
+RESET = BRIDGE / 'reset.json'
+if RESET.exists() and json.loads(RESET.read_text()).get('pid1') == PID1:
+    raise SystemExit(1)  # A new bridge process alone cannot fence old programs.
+RESET.unlink(missing_ok=True)
+SOCKET.unlink(missing_ok=True)
+LOCK = threading.RLock()
+LEASE = None
+RESTARTING = False
+DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
+CUA_SOCKET = '/run/user/1000/openmausbot-cua.sock'
+ENV = {'PATH': '/opt/venv/bin:/usr/local/bin:/usr/bin:/bin', 'HOME': '/home/cua', 'USER': 'cua', 'DISPLAY': ':1'}
+
+def reset_desktop():
+    global RESTARTING, LEASE
+    with LOCK:
+        if RESTARTING:
+            return
+        RESTARTING = True
+        LEASE = None
+        RESET.write_text(json.dumps({'pid1': PID1}))
+    # Compose restarts the same service and retains its workspace volume.
+    threading.Timer(0.1, lambda: os.kill(1, signal.SIGTERM)).start()
+
+def current(body):
+    if RESTARTING or not LEASE or body.get('token') != LEASE['token'] or body.get('instance') != INSTANCE or LEASE['expires'] <= time.monotonic():
+        raise ValueError('桌面控制授权已失效')
+    return LEASE
+
+def start(argv, cwd):
+    # Change directory after dropping privileges: the persistent workspace is
+    # owned by cua and the bridge does not need DAC_OVERRIDE.
+    wrapper = 'import os,sys;os.chdir(sys.argv[1]);os.execvpe(sys.argv[2],sys.argv[2:],os.environ)'
+    return subprocess.Popen(['python3','-c',wrapper,cwd,*argv], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=ENV, user=1000, group=1000, extra_groups=[], start_new_session=True)
+
+def run(argv, cwd='/home/cua/workspace', data=None, timeout=30):
+    process = start(argv, cwd)
+    try:
+        stdout, stderr = process.communicate(data, timeout=timeout)
+    except subprocess.TimeoutExpired:
+        os.killpg(process.pid, signal.SIGKILL)
+        process.communicate()
+        raise ValueError('桌面画面服务尚未就绪')
+    if len(stdout) + len(stderr) > 8 * 1024 * 1024:
+        raise ValueError('桌面命令输出超过限制')
+    return {'stdout': stdout.decode('utf-8', 'replace'), 'stderr': stderr.decode('utf-8', 'replace'), 'exitCode': process.returncode}
+
+class Handler(http.server.BaseHTTPRequestHandler):
+    def log_message(self, *args):
+        pass
+
+    def do_POST(self):
+        global LEASE
+        try:
+            length = int(self.headers.get('Content-Length', '0'))
+            if length > 36 * 1024 * 1024:
+                raise ValueError('请求过大')
+            body = json.loads(self.rfile.read(length) or b'{}')
+            if body.get('desktopId') != DESKTOP_ID:
+                raise ValueError('桌面身份不匹配')
+            op = self.path
+            if op == '/health':
+                ready = False
+                if not RESTARTING:
+                    try:
+                        result = run([DRIVER, 'call', 'health_report', '{}', '--socket', CUA_SOCKET], timeout=5)
+                        ready = result['exitCode'] == 0
+                    except Exception:
+                        pass
+                result = {'id': DESKTOP_ID, 'protocol': 1, 'instance': INSTANCE, 'ready': ready}
+            elif op == '/acquire':
+                with LOCK:
+                    if RESTARTING:
+                        raise ValueError('桌面正在重置')
+                    if LEASE:
+                        if body.get('requestId') != LEASE['requestId'] or body.get('owner') != LEASE['owner']:
+                            raise ValueError('共享桌面正在使用')
+                        current({'token': LEASE['token'], 'instance': INSTANCE})
+                    else:
+                        if not isinstance(body.get('requestId'), str) or not isinstance(body.get('owner'), str):
+                            raise ValueError('缺少桌面授权身份')
+                        LEASE = {'requestId': body['requestId'], 'owner': body['owner'], 'token': secrets.token_hex(32), 'expires': time.monotonic() + 90, 'active': 0}
+                    result = {'token': LEASE['token'], 'instance': INSTANCE}
+            elif op == '/renew':
+                with LOCK:
+                    current(body)['expires'] = time.monotonic() + 90
+                    result = {'ok': True}
+            elif op == '/release':
+                with LOCK:
+                    current(body)
+                    if LEASE['active'] and not body.get('cancel'):
+                        raise ValueError('共享桌面仍有未结束操作')
+                    LEASE = None
+                    result = {'ok': True}
+                    if body.get('cancel'):
+                        reset_desktop()
+            elif op == '/frame':
+                path = '/tmp/yaoyao-compose-' + secrets.token_hex(12) + '.png'
+                try:
+                    frame = run([DRIVER, 'call', 'get_desktop_state', '{}', '--socket', CUA_SOCKET, '--screenshot-out-file', path], timeout=10)
+                    if frame['exitCode'] != 0:
+                        raise ValueError('桌面画面尚未就绪')
+                    data = Path(path).read_bytes()
+                    if data[1:4] != b'PNG':
+                        raise ValueError('桌面画面无效')
+                    result = {'data': base64.b64encode(data).decode(), 'width': int.from_bytes(data[16:20], 'big'), 'height': int.from_bytes(data[20:24], 'big'), 'capturedAt': int(time.time()*1000)}
+                finally:
+                    Path(path).unlink(missing_ok=True)
+            elif op == '/execute':
+                argv = body.get('argv')
+                if not isinstance(argv, list) or not argv or len(argv) > 64 or any(not isinstance(x, str) or '\0' in x for x in argv) or sum(map(len, argv)) > 65536:
+                    raise ValueError('桌面命令无效')
+                cwd = body.get('cwd', '/home/cua/workspace')
+                if not isinstance(cwd, str) or (cwd != '/home/cua/workspace' and not cwd.startswith('/home/cua/workspace/')) or '..' in Path(cwd).parts:
+                    raise ValueError('Compose 桌面工作目录必须位于 /home/cua/workspace')
+                with LOCK:
+                    lease = current(body)
+                    # Spawn while holding the lease lock, then release the lock
+                    # before waiting so revocation can stop the container.
+                    process = start(argv, cwd)
+                    lease['active'] += 1
+                try:
+                    data = base64.b64decode(body['input']) if body.get('input') else None
+                    stdout, stderr = process.communicate(data, timeout=min(60, max(1, body.get('timeout', 30000)/1000)))
+                    if len(stdout) + len(stderr) > 8*1024*1024:
+                        raise ValueError('桌面命令输出超过限制')
+                    with LOCK:
+                        current(body)
+                    result = {'stdout': stdout.decode('utf-8','replace'), 'stderr': stderr.decode('utf-8','replace'), 'exitCode': process.returncode}
+                except Exception:
+                    reset_desktop()
+                    raise
+                finally:
+                    with LOCK:
+                        if LEASE is lease:
+                            lease['active'] -= 1
+            else:
+                raise ValueError('桌面数量和创建删除由 Compose 管理')
+            payload = json.dumps(result).encode()
+            self.send_response(200)
+        except Exception as exc:
+            code = 'computer_busy' if str(exc) in ('共享桌面正在使用','共享桌面仍有未结束操作') else 'compose_desktop_error'
+            payload = json.dumps({'error': str(exc), 'code': code}, ensure_ascii=False).encode()
+            self.send_response(409)
+        self.send_header('Content-Type','application/json')
+        self.send_header('X-Yaoyao-Desktop-Id',DESKTOP_ID)
+        self.send_header('Content-Length',str(len(payload)))
+        self.end_headers()
+        try:
+            self.wfile.write(payload)
+        except BrokenPipeError:
+            pass
+
+class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
+    daemon_threads = True
+
+def expire():
+    while True:
+        time.sleep(5)
+        with LOCK:
+            expired = LEASE and LEASE['expires'] <= time.monotonic()
+        if expired:
+            reset_desktop()
+
+server = Server(str(SOCKET), Handler)
+SOCKET.chmod(0o600)
+os.chown(SOCKET,1000,1000)
+threading.Thread(target=expire,daemon=True).start()
+server.serve_forever()

+ 5 - 2
docker.env.example

@@ -1,6 +1,9 @@
 # 宿主机仅在本机发布 15300;需要通过反向代理或可信局域网访问时再改为 0.0.0.0。
 HERMES_YAOYAO_BIND_ADDRESS=127.0.0.1
 HERMES_YAOYAO_PUBLISHED_PORT=15300
+# 可选:使用已经导入或发布的镜像;留空时使用本地构建名称。
+# HERMES_YAOYAO_IMAGE=hermes-yaoyao:local
+# YAOYAO_DESKTOP_IMAGE=hermes-yaoyao-desktop:0.4.0
 
 # 部署步骤见 docs/docker-install.md;使用 docker compose --env-file docker.env 启动。
 # 填写容器可访问的 Hermes Dashboard/Gateway 地址。
@@ -8,8 +11,8 @@ HERMES_YAOYAO_PUBLISHED_PORT=15300
 HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:9119
 HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
 # 首次登录后在 Web 系统设置中保存 Hermes 连接凭据。
-# 虚拟桌面通过 Hermes 所在电脑的 Runner 提供。
-# 在“设置 → 本地虚拟机”下载配套程序、注册节点,连接后准备镜像;详见 docs/docker-install.md。
+# 使用 compose.desktops.yaml 可同时部署固定数量的共享桌面容器。
+# 桌面数量以 Compose 为准,Web 仅连接已有桌面;详见 docs/docker-install.md。
 
 # 使用域名访问时填写域名,多个值以英文逗号分隔;localhost 和私网 IP 无需填写。
 HERMES_YAOYAO_ALLOWED_HOSTS=

+ 33 - 18
docs/docker-install.md

@@ -46,40 +46,55 @@ docker compose --env-file docker.env logs --tail=100 web
 
 登录后创建会话、收发消息、上传并下载附件,验证实际使用流程。
 
-## 虚拟桌面与镜像准备
+## 随 Web 一起部署固定共享桌面
 
-Docker 镜像运行 Web 服务,虚拟桌面由运行 Hermes 的电脑上的 Runner 提供。Web 容器不需要挂载 Docker socket、Hermes 源码或模型凭据。不要在 Web 容器里安装 Docker Desktop,也不要把宿主机的 Python 虚拟环境直接挂载进容器。
+使用配套的 **单个 `compose.desktops.yaml`** 部署文件,同时创建 Web 和共享桌面容器。默认示例为两台桌面;不是在 Web 页面里临时创建虚拟机。
 
-1. 在执行电脑安装 Hermes、Node.js 24 或更高版本,并启动 Docker/Podman。Hermes Dashboard 使用该电脑的回环地址。
-2. 在 Web **设置 → 本地虚拟机 → 打开执行节点设置**,下载配套执行节点程序。填写节点名称、允许的 Profile、该电脑能够访问的 Web 地址;保持“启用隔离电脑 Worker”勾选。可信局域网 HTTP 需要明确勾选,其他场景使用 HTTPS。
-3. Hermes 默认安装的三个路径可以留空,由 Runner 在执行电脑上查找 `~/.hermes/hermes-agent/venv/bin/python`。自定义安装再填写绝对路径。镜像 ID 留空,连接后由页面准备。
-4. 注册并下载 `runner.json`,把程序包和配置放到执行电脑,执行:
+```sh
+cp docker.env.example docker.env
+# 配置 Web 发布地址、端口和已有 Hermes 的 9119 地址。
+docker compose --env-file docker.env -f compose.desktops.yaml up -d --build
+docker compose --env-file docker.env -f compose.desktops.yaml ps
+```
+
+- Compose 创建几台,页面就显示几台;没有数量修改、镜像准备、创建、删除或重建按钮。
+- Agent 聊天右上角 **电脑 → 此 Agent 使用的电脑**,选择已有的“共享桌面 1 / 共享桌面 2”等目标。多个 Agent 可以连接同一台共享桌面,复用文件和浏览器资料。
+- 桌面连接使用专用 Unix socket 数据卷。Web 容器不挂载宿主 Docker socket,桌面也不发布 VNC、HTTP 或控制端口。
+- 每台桌面的工作文件放在独立的 `desktop-*-workspace` 命名卷内。Compose 桌面统一使用 `/home/cua/workspace`,不会更改基础 Profile 的工作目录配置。
+- 示例桌面使用 `network_mode: none`,默认没有桌面外网。网络、资源额度、镜像版本和服务数量均由部署者在 Compose 中管理;本机 App 的动态虚拟机模式保持原有方式。
+
+如部署前要调整数量或名称,编辑 `deploy/compose-desktops.json`,保留已有桌面 ID,重新生成配套文件:
 
 ```sh
-mkdir -p yaoyao-runner
-tar -xzf yaoyao-runner.tar.gz -C yaoyao-runner
-chmod 600 runner.json
-node yaoyao-runner/runner.mjs --config /完整路径/runner.json
+node scripts/generate-desktop-compose.mjs
+node scripts/generate-desktop-compose.mjs --check
+docker compose --env-file docker.env -f compose.desktops.yaml up -d --build
 ```
 
-整个程序目录须一起保留,包括 Python Worker、桌面构建资源和第三方许可证。Runner 凭据保存在配置文件内,不要发到聊天或提交到仓库。macOS 也可以使用配套 App 的“执行节点 → 导入节点配置…”,不需要另外安装 Node.js。
+生成器会把 Web 连接清单、桌面服务和数据卷同步写入同一个 Compose 文件。运行中不能通过 Web API 扩容。不要用 `down --volumes` 更新部署,否则会删除工作文件。
 
-5. 回到本地虚拟机设置,等待节点连接,再点击 **准备本地虚拟机**。镜像检测、下载/构建、桌面验证全部在执行电脑上完成。
-6. 在 Agent 聊天右上角打开 **电脑**,选择 **本地虚拟机**,创建桌面。可以预览、接管、启停,以及切换共享方式。
+### Hermes 执行连接
 
-页面会区分“尚未连接执行节点”“节点未启用虚拟桌面”和“镜像尚未准备”。节点离线时,请检查执行电脑的 Runner;此时不能通过在 Web 容器内安装软件来修复。`HERMES_YAOYAO_LOCAL_VM_HOST=runner` 已作为镜像和 Compose 的默认值。
+Compose 管理桌面,Hermes 执行连接负责模型运行和工具调用。沿用已有执行节点;新部署可在 **设置 → 本地虚拟机 → 打开执行节点设置** 下载程序和注册配置。此部署生成的配置自动带 `managedBy: compose`,执行节点不再创建桌面,也不需要安装 Docker。
 
-无法通过页面下载时,也可从已创建的 Web 容器取出同版本程序包:
+执行节点只需已有 Hermes 和 Node.js 24+。默认 Hermes 路径可留空;自定义安装填写绝对路径。把下载的程序和 `runner.json` 放在运行 Hermes 的电脑:
 
 ```sh
-docker compose --env-file docker.env cp web:/app/runner-bundle.tar.gz ./yaoyao-runner.tar.gz
+mkdir -p yaoyao-runner
+tar -xzf yaoyao-runner.tar.gz -C yaoyao-runner
+chmod 600 runner.json
+node yaoyao-runner/runner.mjs --config /完整路径/runner.json
 ```
 
-验收截图:[Docker Web 的 Agent 桌面](screenshots/docker-vm/agent-desktop.png)。截图来自独立测试数据:真实容器 Web、下载的配套 Runner、Hermes Worker 和虚拟桌面;模型回复由测试服务提供。
+整份程序目录须保留。节点配置包含私有凭据,不要提交到仓库。运行中的任务或接管操作仍有独占授权;交还后其他成员才能操作。取消无法确认结束的命令时,会重置对应桌面服务来终止旧程序,数量与数据卷保持不变。
+
+只需要 Web 服务、继续使用既有外部动态桌面的部署仍可使用 `compose.yaml`;两种部署文件任选其一,不要让两套 Web 同时写同一数据卷。
+
+验收截图:[Docker Web 的 Agent 桌面](screenshots/compose-desktops/agent-desktop.png)。验收使用独立数据和测试模型服务,桌面与容器连接是真实运行。
 
 ## 数据与备份
 
-Compose 使用 `yaoyao-data` 命名卷,挂载到 `/var/lib/hermes-yaoyao`。账号、SQLite 数据库、聊天缓存、文件及密钥随数据卷持久保存。
+Compose 使用 `yaoyao-data` 命名卷,挂载到 `/var/lib/hermes-yaoyao`。账号、SQLite 数据库、聊天缓存、文件及密钥随数据卷持久保存。固定桌面部署还必须备份每台桌面的 `desktop-*-workspace` 卷;`desktop-*-ipc` 卷只是连接通道。
 
 备份时先停止 Web,然后复制完整数据目录,再启动服务:
 

+ 4 - 0
docs/local-vm.md

@@ -42,3 +42,7 @@ iOS/Android 没有全局虚拟桌面清单、镜像设置或执行环境选择
 本轮验收结果: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)。
+
+## Docker Web 的固定桌面
+
+Docker Web 使用配套 `compose.desktops.yaml` 时,桌面由 Compose 预先创建。数量固定为部署清单中的数量,全部作为已有共享桌面供 Agent 选择。页面和 API 都不提供数量修改、镜像准备或桌面增删;每个桌面独立保留工作数据。此模式不会改变 macOS 本机的动态虚拟机流程。部署步骤见 [Docker 部署](docker-install.md)。

BIN
docs/screenshots/compose-desktops/agent-desktop.png


+ 22 - 0
scripts/generate-desktop-compose.mjs

@@ -0,0 +1,22 @@
+import {readFileSync,writeFileSync} from 'node:fs'
+import {resolve} from 'node:path'
+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:/var/lib/hermes-yaoyao','      - yaoyao-data:/var/lib/hermes-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} 台`)

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

@@ -37,23 +37,24 @@ onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
 </script>
 <template>
  <section class="local-vm-settings" aria-label="本地虚拟机设置">
-  <article><h3>本地虚拟机</h3><p>在 Hermes 所在电脑的隔离 Linux 桌面中运行 Agent,工作文件与浏览器资料持续保存。</p>
+  <article><h3>{{state?.fixedCapacity?'Compose 共享桌面':'本地虚拟机'}}</h3><p>{{state?.fixedCapacity?'桌面容器随 Web 的 Compose 文件部署,工作文件与浏览器资料保存在各桌面的数据卷中。':'在 Hermes 所在电脑的隔离 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 v-if="state?.executionHost==='runner'"><h3>虚拟桌面执行节点</h3><p>虚拟桌面由运行 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><h3>隔离方式</h3><p>按 Agent 创建独立桌面,或让可信的 Agent 共用一个桌面。</p>
+  <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 固定。请在 Agent 聊天的电脑面板选择已有桌面。</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-else-if="state"><h3>隔离方式</h3><p>按 Agent 创建独立桌面,或让可信的 Agent 共用一个桌面。</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')">每个 Agent 独立</button></div>
    <label class="row">最多同时运行的虚拟机<select aria-label="虚拟机数量上限" :value="state?.maxInstances??2" :disabled="!state?.configured||!!state?.setupRequired||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 v-if="!state?.setupRequired"><h3>准备运行环境</h3><p v-if="state?.executionHost==='runner'">以下检查和镜像准备均在执行节点所在电脑完成。</p><ol>
+  <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">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>
+  <article v-if="state&&!state.fixedCapacity"><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>

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

@@ -6,7 +6,7 @@ import type { RunnerConfiguration, RunnerRecord } from '@shared/runner'
 
 type Runner = Omit<RunnerRecord, 'tokenHash' | 'sourceOwner'> & {online: boolean}
 const props=defineProps<{computerSetup?:boolean}>()
-const bundleAvailable=ref(false)
+const bundleAvailable=ref(false),fixedDesktops=ref(false)
 const runners = ref<Runner[]>([]), sources = ref<Array<{id:string;name:string}>>([])
 const busy = ref(false), error = ref(''), notice = ref('')
 const name = ref(''), source = ref('local'), profiles = ref('default')
@@ -16,8 +16,8 @@ const computers=ref(!!props.computerSetup),computerRuntime=ref<'docker'|'podman'
 const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
 
 async function refresh() {
-  const data = await apiRequest<{runners:Runner[];sources:Array<{id:string;name:string}>;bundleAvailable?:boolean}>('/api/app/admin/runners')
-  runners.value = data.runners; sources.value = data.sources;bundleAvailable.value=!!data.bundleAvailable
+  const data = await apiRequest<{runners:Runner[];sources:Array<{id:string;name:string}>;bundleAvailable?:boolean;fixedDesktops?:boolean}>('/api/app/admin/runners')
+  runners.value = data.runners; sources.value = data.sources;bundleAvailable.value=!!data.bundleAvailable;fixedDesktops.value=!!data.fixedDesktops
 }
 async function action(work:()=>Promise<void>) {
   if(busy.value) return
@@ -34,7 +34,7 @@ async function register() {
     const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
     if(computers.value&&((!!computerImage.value&&!/^sha256:[a-f0-9]{64}$/.test(computerImage.value))||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>path&&!path.startsWith('/'))))throw new Error('自定义路径必须为执行电脑上的绝对路径;默认安装可留空。镜像 ID 可留空或填写完整 sha256 ID。')
     const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
-    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{}),...(computers.value?{computers:{network:computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value||UNCONFIGURED_COMPUTER_IMAGE,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
+    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{}),...(computers.value?{computers:{...(fixedDesktops.value?{managedBy:'compose' as const}:{}),network:fixedDesktops.value?'none':computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value||UNCONFIGURED_COMPUTER_IMAGE,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
     notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
     await refresh()
   })
@@ -77,11 +77,11 @@ onMounted(()=>{void action(refresh)})
       <label class="runner-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
       <label class="runner-check"><input v-model="computers" type="checkbox" :disabled="busy">启用隔离电脑 Worker</label>
       <fieldset v-if="computers" class="runner-computer">
-        <legend>执行电脑配置</legend>
-        <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>
+        <legend>执行电脑配置</legend><p v-if="fixedDesktops">桌面已由 Compose 创建。Runner 只转发执行和控制,不安装桌面镜像、不创建容器;联网方式由 Compose 指定。</p>
+        <label v-if="!fixedDesktops">容器运行时<select v-model="computerRuntime"><option value="docker">Docker</option><option value="podman">Podman</option></select></label>
+        <label v-if="!fixedDesktops">联网范围<select v-model="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
+        <small v-if="!fixedDesktops">公网模式通过受控代理访问,不开放宿主和内网地址。</small>
+        <label v-if="!fixedDesktops">兼容镜像 ID<input v-model.trim="computerImage" placeholder="可在本地虚拟机设置中准备"></label>
         <label>Hermes Python 路径<input v-model.trim="computerPython" placeholder="留空使用默认安装"></label>
         <label>Hermes 源码目录<input v-model.trim="hermesSource" placeholder="留空使用默认安装"></label>
         <label>Hermes 配置目录<input v-model.trim="hermesHome" placeholder="留空使用默认安装"></label>

+ 9 - 6
src/client/components/workspace/LocalVmChatPanel.vue

@@ -21,7 +21,10 @@ async function refresh(){
  }catch(e){if(version===revision)error.value=e instanceof Error?e.message:'无法读取电脑画面'}finally{if(version===revision)loading.value=false}
 }
 async function run(work:()=>Promise<unknown>){if(busy.value)return;busy.value=true;error.value='';try{await work();emit('changed')}catch(e){error.value=e instanceof Error?e.message:'电脑操作未完成'}finally{busy.value=false;await refresh()}}
-function choose(value:string){void run(()=>apiRequest(base(),{method:'PUT',body:{enabled:value==='vm'}}))}
+function choose(value:string){
+ if(state.value?.fixedCapacity&&value!=='off'&&value!==state.value.desktopId&&!confirm('连接这台共享桌面?同一桌面的成员会共用文件和浏览器登录。'))return
+ void run(()=>apiRequest(base(),{method:'PUT',body:state.value?.fixedCapacity?{enabled:value!=='off',...(value!=='off'?{desktopId:value}:{})}:{enabled:value==='vm'}}))
+}
 function action(value:LocalVmAction){
  if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件和浏览器资料会保留。'))return
  void run(()=>apiRequest(base()+'/'+value,{method:'POST',body:{}}))
@@ -46,19 +49,19 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
      <span v-if="frame" class="open-badge"><AppIcon name="external" :size="12"/>打开桌面</span>
     </button>
     <p v-if="error||state?.problem" class="problem" role="alert">{{error||state?.problem}}</p>
-    <label class="computer-select">此 Agent 使用的电脑<select aria-label="此 Agent 使用的电脑" :value="state?.enabled?'vm':'off'" :disabled="busy||state?.inUse||!!agent.remoteAgentId||!!agent.temporaryGoalId" @change="choose(($event.target as HTMLSelectElement).value)"><option value="off">不使用电脑</option><option value="vm">本地虚拟机</option></select></label>
+    <label class="computer-select">此 Agent 使用的电脑<select aria-label="此 Agent 使用的电脑" :value="state?.fixedCapacity?(state.desktopId??'off'):state?.enabled?'vm':'off'" :disabled="loading||!state||busy||state.inUse||!!agent.remoteAgentId||!!agent.temporaryGoalId" @change="choose(($event.target as HTMLSelectElement).value)"><option value="off">不使用电脑</option><template v-if="state?.fixedCapacity"><option v-for="desktop in state.desktops" :key="desktop.id" :value="desktop.id" :disabled="desktop.available===false">{{desktop.name}}{{desktop.ready?'':' · 未就绪'}}</option></template><option v-else value="vm">本地虚拟机</option></select></label>
     <template v-if="state?.enabled">
      <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此 Agent 的独立虚拟机'}}</p>
      <button v-if="!state.image" class="primary" :disabled="!isAdmin" @click="emit('settings')">设置本地虚拟机</button>
-     <button v-else-if="state.container==='missing'&&!agent.temporaryGoalId" class="primary" :disabled="busy" @click="action('create')">创建 {{agent.name}} 的虚拟机</button>
-     <button v-else-if="state.container==='stopped'&&!agent.temporaryGoalId" class="primary" :disabled="busy" @click="action('start')">启动虚拟机</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>
      <template v-else-if="state.container==='running'">
       <button class="primary" :disabled="!state.ready||busy" @click="emit('desktop',agent)">打开桌面</button>
       <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?'Agent 正在操作':'仅查看 · 虚拟机空闲'}}</p>
      </template>
-     <div v-if="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>
-     <p class="hint">重建会保留工作目录和浏览器资料。虚拟机空闲 5 分钟后自动停止。</p>
+     <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>
+     <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>
    </template>

+ 9 - 6
src/runner/agent.ts

@@ -46,18 +46,21 @@ 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.localVm=new LocalVmImages(this.computers,this.db)}
+    if(config.computers){this.computers=new ComputerRuntime(this.db,config,home,undefined,(id,operation,body)=>this.api('desktop',{id,operation,...body}));this.localVm=new LocalVmImages(this.computers,this.db)}
     this.compactReceipts()
   }
   private async api(path:string,body?:unknown):Promise<any> {
     const url=new URL(`/api/runner/v1/${this.config.runnerId}/${path}`,this.config.serverURL)
     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,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'}:{})},
+      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'}:{})},
       ...(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')
+    if(!response.ok){
+      if(path==='desktop'){const error=await response.json().catch(()=>({})) as {error?:string;code?:string};throw new HttpError(response.status,error.error?.slice(0,300)||'Compose 桌面连接失败',error.code||'compose_desktop_error')}
+      throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
+    }
     const reader=response.body?.getReader();const chunks:Uint8Array[]=[];let bytes=0
     if(!reader)throw new Error('执行节点响应为空')
     try{for(;;){const part=await reader.read();if(part.done)break;bytes+=part.value.length;if(bytes>40*1024*1024)throw new Error('执行节点响应超过大小限制');chunks.push(part.value)}}finally{await reader.cancel()}
@@ -112,12 +115,12 @@ export class RunnerAgent {
       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 vm=await this.localVm!.status(),spec=this.computers.pool.definition(input.target.ownerKey,input.target.environmentId)??(this.computers.provider.fixedCapacity?{id:input.target.environmentId,ownerKey:input.target.ownerKey,imageId:this.computers.config.imageId}:undefined)
         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}
+        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}
       }
       if(input.op==='lifecycle'){
         const action=z.enum(['create','start','stop','recreate','remove']).parse(input.action)

+ 47 - 0
src/runner/computers/compose.ts

@@ -0,0 +1,47 @@
+import {randomUUID} from 'node:crypto'
+import {ContainerComputerProvider,ComputerError,type ComputerSpecification,type ComputerState,type CommandResult} from './container.js'
+import type {DesktopRelay} from '../../shared/composeDesktops.js'
+
+/** Uses only the fixed desktops declared by Compose; never calls a container engine. */
+export class ComposeComputerProvider extends ContainerComputerProvider {
+  readonly fixedCapacity=true
+  private leases=new Map<string,{token:string;instance:string}>()
+  constructor(runnerId:string,home:string,readonly relay:DesktopRelay){super('docker',runnerId,home)}
+  async inventory(){return this.relay('','list',{})}
+  async verifyRuntime(){await this.inventory()}
+  async inspect(spec:ComputerSpecification):Promise<ComputerState|undefined>{
+    this.validateSpecification(spec)
+    const state=await this.relay(spec.id,'health',{ownerKey:spec.ownerKey})
+    return {id:spec.id,containerId:state.instance,workspace:'/home/cua/workspace',running:state.ready===true,isolation:'container'}
+  }
+  async ensure(spec:ComputerSpecification,authorize:()=>void):Promise<ComputerState>{
+    authorize();const state=await this.inspect(spec);authorize()
+    if(!state?.running)throw new ComputerError('compose_desktop_offline','Compose 桌面尚未就绪,请检查对应桌面容器')
+    if(!this.leases.has(spec.id)){
+      const lease=await this.relay(spec.id,'acquire',{ownerKey:spec.ownerKey,requestId:randomUUID()})
+      this.leases.set(spec.id,lease)
+      try{authorize()}catch(error){await this.stop(spec);throw error}
+    }
+    return state
+  }
+  async releaseFence(spec:ComputerSpecification,keepRunning=false){
+    const lease=this.leases.get(spec.id);if(!lease)return
+    try{await this.relay(spec.id,'release',{ownerKey:spec.ownerKey,...lease,cancel:!keepRunning})}
+    catch(error){const state=await this.relay(spec.id,'health',{ownerKey:spec.ownerKey}).catch(()=>undefined);if(!state||state.instance===lease.instance)throw error}
+    this.leases.delete(spec.id)
+  }
+  async renewFence(spec:ComputerSpecification){const lease=this.leases.get(spec.id);if(lease)await this.relay(spec.id,'renew',{ownerKey:spec.ownerKey,...lease})}
+  async stop(spec:ComputerSpecification){await this.releaseFence(spec)}
+  async remove(spec:ComputerSpecification){await this.stop(spec)}
+  async deleteWorkspace(_spec:ComputerSpecification){} // Compose owns the named volume.
+  async execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer}):Promise<CommandResult>{
+    options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止')
+    const lease=this.leases.get(spec.id);if(!lease)throw new ComputerError('computer_lease_stale','需要当前共享桌面的控制权')
+    const result=await this.relay(spec.id,'execute',{ownerKey:spec.ownerKey,...lease,argv,cwd:spec.cwd??'/home/cua/workspace',timeout:options.timeout??30000,...(options.input?{input:options.input.toString('base64')}:{})})
+    options.authorize();if(result.exitCode)throw Object.assign(new ComputerError('computer_command_failed','桌面命令执行失败'),{code:result.exitCode,stdout:result.stdout,stderr:result.stderr})
+    return result
+  }
+  async capture(spec:ComputerSpecification,authorize:()=>void){authorize();const result=await this.relay(spec.id,'frame',{ownerKey:spec.ownerKey});authorize();return result}
+  async health(spec:ComputerSpecification,authorize:()=>void){authorize();const result=await this.relay(spec.id,'health',{ownerKey:spec.ownerKey});if(!result.ready)throw new ComputerError('compose_desktop_offline','Compose 桌面尚未就绪');return result}
+  async configureNetwork(_spec:ComputerSpecification,authorize:()=>void){authorize()}
+}

+ 3 - 0
src/runner/computers/container.ts

@@ -34,6 +34,8 @@ export type ComputerSpecification=z.input<typeof schema>
 type Spec=z.output<typeof schema>
 export interface ComputerState {id:string;containerId:string;running:boolean;workspace:string;isolation:'container'|'vm'}
 export interface ComputerProvider {
+  releaseFence?(spec:ComputerSpecification,keepRunning?:boolean):Promise<void>
+  renewFence?(spec:ComputerSpecification):Promise<void>
   validateSpecification(spec:ComputerSpecification):ComputerSpecification
   readonly capabilities:{isolation:'container'|'vm';shell:boolean;desktop:boolean;persistentWorkspace:boolean;sharedDesktop:boolean;snapshots:boolean;network:'none'|'public-proxy'}
   inspect(spec:ComputerSpecification):Promise<ComputerState|undefined>
@@ -50,6 +52,7 @@ const capabilities=(values:unknown)=>Array.isArray(values)?values.map(value=>Str
 /** A private container per environment. No tools, filesystem operations or
  * process execution fall back to the host. Remote hosts run their own Runner. */
 export class ContainerComputerProvider implements ComputerProvider {
+  readonly fixedCapacity:boolean=false
   private queue=new Map<string,Promise<unknown>>()
   readonly capabilities={isolation:'container',shell:true,desktop:true,persistentWorkspace:true,sharedDesktop:false,snapshots:false,network:'none'} as const
   constructor(readonly runtime:'docker'|'podman',readonly runnerId:string,readonly home:string,readonly run:ContainerCommand=command,readonly environment:NodeJS.ProcessEnv=process.env) {

+ 11 - 1
src/runner/computers/localVm.ts

@@ -6,6 +6,8 @@ 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 {ComposeComputerProvider} from './compose.js'
+import {COMPOSE_DESKTOP_IMAGE} from '../../shared/composeDesktops.js'
 
 type State = { imageId: string; mode: LocalVmMode; maxInstances: number; job?: LocalVmStatus['job'] }
 
@@ -22,8 +24,9 @@ export class LocalVmImages {
     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}
+    if(runtime.provider.fixedCapacity)this.state={imageId:COMPOSE_DESKTOP_IMAGE,mode:'shared',maxInstances: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()) {
+    if (!runtime.provider.fixedCapacity && !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
     }
@@ -50,6 +53,11 @@ export class LocalVmImages {
   }
   async status(owner?:string): Promise<LocalVmStatus> {
     await this.ready
+    if(this.runtime.provider instanceof ComposeComputerProvider){
+      const desktops=await this.runtime.provider.inventory()
+      this.runtime.pool.limits.concurrent=Math.max(1,desktops.length)
+      return {configured:true,executionHost:'runner',fixedCapacity:true,desktops,daemonUp:true,image:desktops.some((d:any)=>d.ready),imageId:COMPOSE_DESKTOP_IMAGE,mode:'shared',maxInstances:desktops.length,busy:false}
+    }
     if(this.releaseMaintenance&&!this.pending&&this.state.job?.state==='failed') {
       try{await this.cleanup(this.state.job.id);this.releaseMaintenance();this.releaseMaintenance=undefined}catch{}
     }
@@ -64,6 +72,7 @@ export class LocalVmImages {
       instances:owner?this.runtime.pool.status(owner).map(row=>({id:row.environmentId,status:row.status})):[]}
   }
   async policy(mode:LocalVmMode,maxInstances:number) {
+    if(this.runtime.provider.fixedCapacity)throw new ComputerError('compose_desktop_managed','桌面数量与共享方式由 Compose 固定,不能在界面修改')
     await this.ready
     // Sharing changes future Agent bindings; an idle desktop can keep running.
     // Image preparation still requires every desktop to be stopped.
@@ -73,6 +82,7 @@ export class LocalVmImages {
     return this.status()
   }
   async prepare(id:string,check:()=>Promise<void>) {
+    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.pending||this.closed)throw new ComputerError('local_vm_busy','本地虚拟机正在准备,请稍后查看')

+ 2 - 1
src/runner/computers/pool.ts

@@ -107,7 +107,7 @@ export class ComputerPool {
     })
   }
   authorize(lease:ComputerLease){this.assert(lease)}
-  renew(lease:ComputerLease):void{this.assert(lease);const entry=this.require(lease);entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)}
+  renew(lease:ComputerLease):void{this.assert(lease);const entry=this.require(lease);entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry);void this.provider.renewFence?.(entry.spec).catch(()=>{try{this.invalidate(this.require(lease))}catch{}})}
   use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void})=>Promise<T>):Promise<T>{
     this.assert(lease)
     const active=this.active.get(lease.id)!
@@ -120,6 +120,7 @@ export class ComputerPool {
       this.assert(lease)
       const entry=this.require(lease),active=this.active.get(lease.id)!
       if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有未结束操作,暂不能进入空闲状态')
+      if(this.provider.releaseFence)await this.provider.releaseFence(entry.spec,true)
       active.detach();active.controller.abort();this.active.delete(lease.id)
       entry.status='idle';entry.generation++;entry.lease=undefined;entry.expiresAt=0;this.save(entry)
     })

+ 1 - 1
src/runner/config.ts

@@ -9,7 +9,7 @@ const schema=z.object({
   hermesURL:z.string().url().default('http://127.0.0.1:9119'),
   allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),
   artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),
-  computers:z.object({network:z.enum(['none','public-proxy']).default('none'),runtime:z.enum(['docker','podman']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),python:localPath,hermesSource:localPath,hermesHome:localPath,maxConcurrent:z.number().int().min(1).max(8).optional()}).strict().transform(value=>{
+  computers:z.object({managedBy:z.literal('compose').optional(),network:z.enum(['none','public-proxy']).default('none'),runtime:z.enum(['docker','podman']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),python:localPath,hermesSource:localPath,hermesHome:localPath,maxConcurrent:z.number().int().min(1).max(8).optional()}).strict().transform(value=>{
     const hermesHome=value.hermesHome||join(homedir(),'.hermes'),hermesSource=value.hermesSource||join(hermesHome,'hermes-agent')
     return {...value,hermesHome,hermesSource,python:value.python||join(hermesSource,'venv','bin','python')}
   }).optional(),

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

@@ -37,11 +37,12 @@ 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)
+    if(this.runtime.provider.fixedCapacity&&(!resource||['free','idle'].includes(resource.status))){const state=await this.runtime.provider.inspect({id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.config.imageId});return {mode:state?.running?'idle':'off',generation:resource?.generation??0}}
     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()
-    const spec=this.runtime.pool.definition(meta.ownerKey,meta.environmentId)
+    const spec=this.runtime.pool.definition(meta.ownerKey,meta.environmentId)??(this.runtime.provider.fixedCapacity?{id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.config.imageId}:undefined)
     if(!spec)throw new HttpError(409,'电脑尚未启动','computer_not_running')
     const status=await this.status(meta),cached=this.frames.get(meta.environmentId)?.at(-1)
     if(cached&&cached.generation===status.generation&&Date.now()-cached.capturedAt<700)return cached

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

@@ -11,6 +11,8 @@ import {UNCONFIGURED_COMPUTER_IMAGE} from '../../shared/runner.js'
 import type {RunnerConfiguration} from '../../shared/runner.js'
 import {ContainerComputerProvider,COMPUTER_WORKSPACE,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
 import {ComputerPool,type ComputerLease} from '../computers/pool.js'
+import {ComposeComputerProvider} from '../computers/compose.js'
+import {COMPOSE_DESKTOP_IMAGE,type DesktopRelay} from '../../shared/composeDesktops.js'
 import {ComputerControls} from './control.js'
 import {ComputerPublicProxy} from '../network/computerProxy.js'
 import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.js'
@@ -40,12 +42,16 @@ export class ComputerRuntime {
   readonly workers=new Map<string,{environmentId:string;ownerKey:string;process:HermesWorkerProcess}>()
   readonly script:string
   readonly proxyScript:string
-  constructor(readonly db:DatabaseSync,config:RunnerConfiguration,readonly home:string,script?:string){
+  constructor(readonly db:DatabaseSync,config:RunnerConfiguration,readonly home:string,script?:string,relay?:DesktopRelay){
     if(!config.computers)throw new HttpError(409,'执行节点尚未配置隔离电脑','computer_unavailable')
     this.config=config.computers
     this.script=script??join(dirname(fileURLToPath(import.meta.url)),'hermes_worker.py')
     this.proxyScript=existsSync(join(dirname(this.script),'guest_proxy.py'))?join(dirname(this.script),'guest_proxy.py'):join(dirname(this.script),'../network/guest_proxy.py')
-    this.provider=new ContainerComputerProvider(this.config.runtime,config.runnerId,home)
+    if(this.config.managedBy==='compose'){
+      if(!relay)throw new HttpError(409,'缺少 Compose 桌面连接','compose_desktop_unavailable')
+      this.config.imageId=COMPOSE_DESKTOP_IMAGE;this.config.network='none'
+      this.provider=new ComposeComputerProvider(config.runnerId,home,relay)
+    }else this.provider=new ContainerComputerProvider(this.config.runtime,config.runnerId,home)
     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)')
@@ -86,9 +92,10 @@ export class ComputerRuntime {
   private async resolveProfile(profile:string,mode:'resolve'|'resolve-workspace',signal?:AbortSignal){
     const resolver=new HermesWorkerProcess(this.config.python,this.script,{mode,profile,defaultCwd:COMPUTER_WORKSPACE,hermesSource:this.config.hermesSource,hermesHome:this.config.hermesHome})
     const abort=()=>{void resolver.close()};signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort()
-    try{return await resolver.wait('resolved')}finally{signal?.removeEventListener('abort',abort);await resolver.close()}
+    try{const result=await resolver.wait('resolved');return this.provider.fixedCapacity?{...result,cwd:COMPUTER_WORKSPACE}:result}finally{signal?.removeEventListener('abort',abort);await resolver.close()}
   }
   async desktop(meta:ComputerTarget,profile:string,action:'create'|'start'|'stop'|'recreate'|'remove',authorize:()=>void){
+    if(this.provider.fixedCapacity)throw new HttpError(409,'桌面由 Compose 创建和管理,不能在界面增删或重建','compose_desktop_managed')
     this.assertTarget(meta);authorize()
     // Stopping/removing an existing desktop must remain possible even when
     // its Profile or model was removed or is temporarily misconfigured.

+ 1 - 0
src/server/app.ts

@@ -212,6 +212,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
+  runners.composeDesktops.desktops.push(...(config.composeDesktops??[]))
   const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
   const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
   const localVm=new LocalVmService(workspace,auth,workspaceNodes,runners,sharedComputers,config)

+ 34 - 0
src/server/composeDesktops.ts

@@ -0,0 +1,34 @@
+import {request} from 'node:http'
+import {z} from 'zod'
+import {HttpError} from './errors.js'
+import type {ComposeDesktop,ComposeDesktopState} from '../shared/composeDesktops.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)
+  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
+}
+
+/** Only deployment-owned Unix sockets are addressable. No Docker daemon access. */
+export class ComposeDesktops {
+  constructor(readonly desktops:ComposeDesktop[]){}
+  async call(id:string,operation:string,body:Record<string,unknown>={}):Promise<any>{
+    const desktop=this.desktops.find(x=>x.id===id)
+    if(!desktop)throw new HttpError(404,'该桌面不在 Compose 配置中','compose_desktop_missing')
+    if(!['health','frame','acquire','renew','release','execute'].includes(operation))throw new HttpError(409,'Compose 管理桌面数量和生命周期','compose_desktop_managed')
+    return new Promise((resolve,reject)=>{
+      const data=JSON.stringify({...body,desktopId:id}),req=request({socketPath:desktop.socketPath,path:`/${operation}`,method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)},timeout:70000},res=>{
+        const chunks:Buffer[]=[];let size=0
+        res.on('data',chunk=>{size+=chunk.length;if(size>40*1024*1024)req.destroy(new Error('桌面回应过大'));else chunks.push(chunk)})
+        res.on('end',()=>{try{if(res.headers['x-yaoyao-desktop-id']!==id)throw new HttpError(502,'Compose 桌面连接身份不匹配','compose_desktop_mismatch');const value=JSON.parse(Buffer.concat(chunks).toString());if(res.statusCode!==200)throw new HttpError(res.statusCode??502,value.error||'桌面操作失败',value.code||'compose_desktop_error');resolve(value)}catch(error){reject(error)}})
+      })
+      req.on('timeout',()=>req.destroy(new Error('桌面回应超时')))
+      req.on('error',()=>reject(new HttpError(503,`无法连接「${desktop.name}」,请检查对应 Compose 桌面容器`,'compose_desktop_offline')))
+      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}}}))}
+}

+ 4 - 0
src/server/config.ts

@@ -10,6 +10,8 @@ import {
   type AllowedHostsConfigurationSnapshot,
 } from './allowedHostsConfiguration.js'
 import type { ChatCacheMode } from './chatCache.js'
+import {parseComposeDesktops} from './composeDesktops.js'
+import type {ComposeDesktop} from '../shared/composeDesktops.js'
 export { DEFAULT_APNS_TOPIC } from './apnsConfiguration.js'
 
 export interface APNsProviderConfig {
@@ -43,6 +45,7 @@ export interface ServerConfig {
   production: boolean
   superviseDashboard?: boolean
   localVmHost?: 'server' | 'runner'
+  composeDesktops?: ComposeDesktop[]
   releaseSource?: string
   releaseRoot?: string
   allowRemoteUpdate?: boolean
@@ -204,6 +207,7 @@ export function loadServerConfig(
     production,
     superviseDashboard,
     localVmHost,
+    composeDesktops:parseComposeDesktops(env.HERMES_YAOYAO_COMPOSE_DESKTOPS),
     chatCacheMode,
     releaseSource,
     releaseRoot,

+ 31 - 8
src/server/localVm.ts

@@ -28,17 +28,23 @@ export class LocalVmService {
   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)}
+    store.prepareAgent=(owner,agent)=>{
+      const record=this.record()
+      if(this.fixed){const parent=agent.createdByAgentId?store.get<WorkspaceAgent>(owner,'agent',agent.createdByAgentId):undefined,desktop=this.config.composeDesktops?.find(d=>d.id===parent?.computerEnvironmentId);if(record&&desktop&&agent.execution==='computer'){if(agent.nodeId!=='local')throw new HttpError(409,'Compose 桌面成员须使用当前 Web 的 Hermes 来源','compose_source_required');this.shared.bindCompose(owner,agent,desktop,record.id,false)}return}
+      if(record&&this.mode(owner)==='shared')this.shared.attachLocalVm(owner,agent,record.id)
+    }
   }
+  private get fixed(){return !!this.config.composeDesktops?.length}
+  private assertManaged(){if(this.fixed)throw new HttpError(409,'桌面由 Compose 创建和管理,数量和镜像不能在界面修改','compose_desktop_managed')}
   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 mode(owner:string):LocalVmMode { return this.fixed?'shared':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
-    if(this.config.localVmHost==='runner')return
+    if(this.fixed||this.config.localVmHost==='runner')return
     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)
@@ -100,6 +106,11 @@ export class LocalVmService {
   }
   async status(owner:string):Promise<LocalVmStatus> {
     const record=this.record()
+    if(this.fixed){
+      const desktops=(await this.hub.composeDesktops.status()).map(d=>{const claim=this.store.get<{owner:string}>('_system','compose-desktop-owner',d.id);return {...d,available:!claim||claim.owner===owner}})
+      const runner=record?this.hub.summary(record):undefined,connected=runner?.online&&runner.features.includes('compose-desktops-v1')
+      return {configured:!!connected,executionHost:'runner',runnerName:record?.name,fixedCapacity:true,desktops,daemonUp:true,image:desktops.some(d=>d.ready),mode:'shared',maxInstances:desktops.length,busy:false,...(!connected?{setupRequired:'runner' as const,problem:'Compose 桌面数量已固定。请连接启用 Compose 桌面模式的 Hermes 执行节点。'}:{})}
+    }
     const executionHost=this.config.localVmHost==='runner'||(record&&!this.store.get('_system','local-vm-managed','local'))?'runner':'server'
     if(record){
       if(this.url&&!this.managed)await this.start(this.url)
@@ -125,11 +136,13 @@ export class LocalVmService {
     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=>{
+      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 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=>{
+      this.auth.requireAdmin(ctx);this.assertManaged()
       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)
@@ -139,6 +152,7 @@ export class LocalVmService {
       ctx.body=await this.status(owner)
     })
     router.post('/api/app/admin/local-vm/instances/:id/:action',async ctx=>{
+      this.auth.requireAdmin(ctx);this.assertManaged()
       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')
@@ -148,23 +162,32 @@ export class LocalVmService {
     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:'本地虚拟机不可用'}}
+      const fixed=this.fixed?{fixedCapacity:true,desktopId:agent.computerEnvironmentId,desktops:(await this.status(owner)).desktops}:{}
+      if(agent.execution!=='computer'||(this.fixed&&!agent.computerEnvironmentId)){ctx.body={enabled:false,agent,...fixed};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',...fixed} }
+      catch(error){ctx.body={enabled:true,agent,container:'missing',ready:false,image:false,problem:error instanceof Error?error.message:'本地虚拟机不可用',...fixed}}
     })
     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 owner=this.auth.require(ctx).id,body=parse(z.object({enabled:z.boolean(),desktopId:z.string().uuid().optional()}).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])
+      if(this.fixed&&body.enabled){
+        if(agent.nodeId!=='local')throw new HttpError(409,'Compose 桌面成员须使用当前 Web 的 Hermes 来源','compose_source_required')
+        const desktop=this.config.composeDesktops!.find(d=>d.id===body.desktopId),record=this.record()
+        if(!desktop)throw new HttpError(400,'请选择 Compose 中已有的共享桌面','compose_desktop_required')
+        if(!record||!this.hub.summary(record).features.includes('compose-desktops-v1'))throw new HttpError(409,'请连接启用 Compose 桌面模式的执行节点','compose_runner_required')
+        ctx.body=this.store.atomic(()=>{this.shared.bindCompose(owner,agent,desktop,record.id);return {agent:this.store.agentSummary(agent)}});return
+      }
       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)}
+        if(body.enabled&&!this.fixed&&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=>{
+      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)
       const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
       if(agent.temporaryGoalId)throw new HttpError(409,'临时助手的虚拟机由当前任务管理','helper_task_bound')

+ 27 - 6
src/server/runnerHub.ts

@@ -12,17 +12,19 @@ import type { GatewayFrame, GatewayTarget } from './workspaceGateway.js'
 import type { UpstreamRequestOptions, UpstreamResponse } from './upstream.js'
 import type { LeaseInput, WorkspaceToolLease } from './workspaceToolLease.js'
 import { RUNNER_PROTOCOL, type RunnerCommand, type RunnerRecord } from '../shared/runner.js'
+import {ComposeDesktops} from './composeDesktops.js'
 
 const hash = (value: string) => createHash('sha256').update(value).digest('hex')
 const runnerBundle=()=>[resolve(dirname(fileURLToPath(import.meta.url)),'runner-bundle.tar.gz'),resolve(dirname(fileURLToPath(import.meta.url)),'../../runner-bundle.tar.gz')].find(existsSync)
 const input = z.object({ name:z.string().trim().min(1).max(100),sourceNodeId:z.string().min(1).max(100).default('local'),
   allowedProfiles:z.array(z.string().min(1).max(100)).min(1).max(256) }).strict()
 interface Pending { command:RunnerCommand; resolve(value:any):void; reject(error:Error):void; timer:ReturnType<typeof setTimeout>; nextDelivery:number; valid():boolean }
-interface GatewayConnection { runnerId:string;cleanupOnly?:boolean;publishArtifact?:(name:string,bytes:Buffer)=>Promise<unknown>; valid():boolean; onEvent(frame:GatewayFrame):void; onDisconnect():void }
+interface GatewayConnection { runnerId:string;computer?:{environmentId:string;ownerKey:string;agentId:string};cleanupOnly?:boolean;publishArtifact?:(name:string,bytes:Buffer)=>Promise<unknown>; valid():boolean; onEvent(frame:GatewayFrame):void; onDisconnect():void }
 interface Lease { runnerId:string; input:LeaseInput; calls:Map<string,{fingerprint:string;result:Promise<unknown>}> }
 
 /** Outbound-only machine transport. Browser auth and machine auth remain separate. */
 export class RunnerHub {
+  composeDesktops=new ComposeDesktops([])
   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>()
@@ -113,6 +115,11 @@ export class RunnerHub {
       if(!this.auth.canUseSource(owner,sourceNodeId,profile)||!record.allowedProfiles.includes(profile))throw new HttpError(403,'执行节点未授权这个基础 Profile','runner_profile_forbidden')
     }
     const requireComputer=()=>{
+      if(computer&&this.composeDesktops.desktops.length){
+        if(!this.online.get(record.id)?.features.includes('compose-desktops-v1'))throw new HttpError(409,'当前部署需要 Compose 桌面执行节点','compose_runner_required')
+        const claim=this.store.get<{owner:string;runnerId:string}>('_system','compose-desktop-owner',computer.environmentId)
+        if(!this.composeDesktops.desktops.some(d=>d.id===computer.environmentId)||claim?.owner!==owner||claim.runnerId!==record.id)throw new HttpError(409,'请先为 Agent 选择已有 Compose 共享桌面','compose_desktop_required')
+      }
       if(computer&&!this.online.get(record.id)?.features.includes('computer-worker-v1'))throw new HttpError(409,'执行节点未提供隔离 Worker 能力,请检查配置或更新 Runner','computer_unavailable')
       if(computer&&computer.environmentId!==computer.agentId&&!this.online.get(record.id)?.features.includes('shared-computer-v1'))throw new HttpError(409,'执行节点不支持共享电脑,请更新 Runner','shared_computer_unavailable')
     }
@@ -138,7 +145,7 @@ export class RunnerHub {
           if(computer&&!scope)throw new HttpError(403,'隔离执行缺少任务授权','computer_scope_required')
           const version=this.auth.pushAuthorizationVersion(owner)
           const valid=()=>{try{scope?.authorize();return (scope?.cleanupOnly||(this.auth.isUserActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version))&&this.store.get<RunnerRecord>('_system','runner',record.id)?.enabled===true}catch{return false}}
-          const id=randomUUID();this.connections.set(id,{runnerId:record.id,valid,onEvent,onDisconnect,publishArtifact:scope?.publishArtifact,cleanupOnly:scope?.cleanupOnly})
+          const id=randomUUID();this.connections.set(id,{runnerId:record.id,computer,valid,onEvent,onDisconnect,publishArtifact:scope?.publishArtifact,cleanupOnly:scope?.cleanupOnly})
           try {await this.request(record.id,'gateway.open',{connectionId:id,computer,workId:scope?.workId,cleanupOnly:scope?.cleanupOnly})} catch(error){this.connections.delete(id);void this.request(record.id,'gateway.close',{connectionId:id}).catch(()=>{});throw error}
           let closed=false
           return {rpc:async(method,params)=>{
@@ -172,6 +179,7 @@ export class RunnerHub {
   computerRunner(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent){
     const record=this.records().find(record=>record.enabled&&record.sourceNodeId===agent.nodeId&&(record.sourceOwner==='_system'||record.sourceOwner===owner))
     if(!record||!this.online.get(record.id)?.features.includes('computer-control-v1'))throw new HttpError(409,'执行节点不支持电脑查看与控制,请更新 Runner','computer_control_unavailable')
+    if(this.composeDesktops.desktops.length&&!this.online.get(record.id)?.features.includes('compose-desktops-v1'))throw new HttpError(409,'当前部署需要 Compose 桌面执行节点','compose_runner_required')
     if(!record.allowedProfiles.includes(agent.profile)||!this.auth.canUseSource(owner,agent.nodeId,agent.profile))throw new HttpError(403,'电脑 Profile 未授权','computer_profile_forbidden')
     return record
   }
@@ -194,7 +202,7 @@ export class RunnerHub {
   }
   adminRouter():Router {
     const router=new Router()
-    router.get('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body={protocol:RUNNER_PROTOCOL,bundleAvailable:!!runnerBundle(),runners:this.records().map(r=>this.summary(r)),sources:[{id:'local',name:'本地来源'},...this.store.list<import('./workspaceGateway.js').WorkspaceNode>(actor.id,'node').filter(n=>n.transport!=='paired-web').map(n=>({id:n.id,name:n.name}))]}})
+    router.get('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body={protocol:RUNNER_PROTOCOL,fixedDesktops:this.composeDesktops.desktops.length>0,bundleAvailable:!!runnerBundle(),runners:this.records().map(r=>this.summary(r)),sources:[{id:'local',name:'本地来源'},...this.store.list<import('./workspaceGateway.js').WorkspaceNode>(actor.id,'node').filter(n=>n.transport!=='paired-web').map(n=>({id:n.id,name:n.name}))]}})
     router.get('/api/app/admin/runners/bundle',ctx=>{this.auth.requireAdmin(ctx);const path=runnerBundle();if(!path)throw new HttpError(404,'当前服务未打包执行节点程序,请使用配套 App 或从源码构建 Runner','runner_bundle_missing');ctx.set('Cache-Control','no-store');ctx.attachment('yaoyao-runner.tar.gz');ctx.type='application/gzip';ctx.body=createReadStream(path)})
     router.post('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body=this.enroll(actor.id,(ctx.request as any).body);ctx.status=201})
     router.delete('/api/app/admin/runners/:id',ctx=>{this.auth.requireAdmin(ctx);this.remove(ctx.params.id);ctx.body={ok:true}})
@@ -202,7 +210,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|local-vm-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|desktop)$/.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))))
@@ -219,7 +227,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'].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','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')
@@ -236,10 +244,23 @@ export class RunnerHub {
       }
       if(ctx.method!=='POST')throw new HttpError(405,'仅允许 POST','method_not_allowed')
       let size=0;const chunks:Buffer[]=[]
-      for await(const chunk of ctx.req){size+=chunk.length;if(size>16*1024*1024)throw new HttpError(413,'执行节点响应过大','runner_payload_limit');chunks.push(Buffer.from(chunk))}
+      for await(const chunk of ctx.req){size+=chunk.length;if(size>(match[2]==='desktop'?40:16)*1024*1024)throw new HttpError(413,'执行节点响应过大','runner_payload_limit');chunks.push(Buffer.from(chunk))}
       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]==='desktop'){
+        if(record.sourceNodeId!=='local'||record.sourceOwner!=='_system'||!state.features.includes('compose-desktops-v1'))throw new HttpError(403,'该节点不能访问 Compose 桌面','compose_desktop_forbidden')
+        const {id,operation,ownerKey,...payload}=parse(z.object({id:z.string(),operation:z.enum(['list','health','frame','acquire','renew','release','execute']),ownerKey:z.string().optional()}).passthrough(),body)
+        if(operation==='list'){ctx.body=await this.composeDesktops.status();return}
+        const claim=this.store.get<{owner:string;runnerId:string}>('_system','compose-desktop-owner',id)
+        if(!claim||claim.runnerId!==record.id||hash(claim.owner)!==ownerKey)throw new HttpError(403,'没有该共享桌面的使用权限','compose_desktop_forbidden')
+        const valid=()=>this.auth.isUserActive(claim.owner)&&this.store.list<import('../shared/workspace.js').WorkspaceAgent>(claim.owner,'agent').some(a=>!a.archived&&a.computerEnvironmentId===id&&this.auth.canUseSource(claim.owner,a.nodeId,a.profile))
+        const active=()=>[...this.connections.values()].some(c=>c.runnerId===record.id&&c.computer?.environmentId===id&&c.computer.ownerKey===ownerKey&&c.valid())||this.store.list<any>('_system','computer-control').some(g=>g.owner===claim.owner&&g.runnerId===record.id&&g.environmentId===id&&this.controlAllowed(g.id,record.id))
+        if(operation!=='release'&&(!valid()||(!['health','frame'].includes(operation)&&!active())))throw new HttpError(403,'桌面操作缺少当前任务或接管授权','compose_desktop_forbidden')
+        const result=await this.composeDesktops.call(id,operation,{...payload,owner:ownerKey})
+        if(operation==='acquire'&&(!valid()||!active())){await this.composeDesktops.call(id,'release',{...result,cancel:true}).catch(()=>{});throw new HttpError(410,'桌面操作已取消','computer_control_expired')}
+        ctx.body=result;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'){

+ 14 - 2
src/server/sharedComputers.ts

@@ -8,11 +8,11 @@ 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 {managedLocalVm?:boolean;id:string;name:string;memberIds:string[];nodeId:string;profile:string;runnerId:string;archived:boolean;createdAt:number}
+export interface SharedComputer {managedCompose?:boolean;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)
-  if(shared.archived||!agent.id||!shared.memberIds.includes(agent.id)||shared.nodeId!==agent.nodeId||shared.profile!==agent.profile)throw new HttpError(403,'当前成员没有这台共享电脑的授权','shared_computer_forbidden')
+  if(shared.archived||!agent.id||!shared.memberIds.includes(agent.id)||shared.nodeId!==agent.nodeId||(!shared.managedCompose&&shared.profile!==agent.profile))throw new HttpError(403,'当前成员没有这台共享电脑的授权','shared_computer_forbidden')
 }
 export class SharedComputers {
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly hub:RunnerHub){}
@@ -28,6 +28,18 @@ export class SharedComputers {
   }
   private localVmAgents(owner:string){return this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.nodeId==='local'&&!a.archived&&!a.remoteAgentId&&!a.temporaryGoalId&&(a.execution==='computer'||!!a.computerEnvironmentId))}
   assertLocalVmIdle(owner:string,ids?:string[]){this.idle(owner,ids??this.localVmAgents(owner).map(a=>a.id))}
+  bindCompose(owner:string,agent:WorkspaceAgent,desktop:{id:string;name:string},runnerId:string,persist=true){
+    this.idle(owner,[agent.id])
+    const claim=this.store.get<{owner:string;runnerId:string}>('_system','compose-desktop-owner',desktop.id)
+    if(claim&&(claim.owner!==owner||claim.runnerId!==runnerId))throw new HttpError(403,'这台共享桌面已分配给其他账号或执行节点','compose_desktop_forbidden')
+    if(agent.computerEnvironmentId&&agent.computerEnvironmentId!==desktop.id)this.detachLocalVm(owner,agent)
+    const group=this.store.get<SharedComputer>(owner,'shared-computer',desktop.id)??{id:desktop.id,name:desktop.name,nodeId:'local',profile:'*',runnerId,memberIds:[],archived:false,createdAt:Date.now(),managedCompose:true}
+    group.archived=false;if(!group.memberIds.includes(agent.id))group.memberIds.push(agent.id)
+    this.store.put('_system','compose-desktop-owner',desktop.id,{owner,runnerId})
+    this.store.put(owner,'shared-computer',group.id,group)
+    agent.execution='computer';agent.computerEnvironmentId=desktop.id;agent.computerEnvironmentName=desktop.name
+    if(persist){agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)}
+  }
   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)

+ 6 - 0
src/shared/composeDesktops.ts

@@ -0,0 +1,6 @@
+export interface ComposeDesktop {id:string;name:string;socketPath:string}
+export interface ComposeDesktopState {id:string;name:string;online:boolean;ready:boolean}
+export interface DesktopRelay {
+  (id:string,operation:string,body:Record<string,unknown>):Promise<any>
+}
+export const COMPOSE_DESKTOP_IMAGE='sha256:'+'c'.repeat(64)

+ 5 - 0
src/shared/localVm.ts

@@ -1,6 +1,8 @@
 export type LocalVmMode = 'shared' | 'per-bot'
 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}>
   executionHost?: 'server' | 'runner'
   runnerName?: string
   setupRequired?: 'runner' | 'worker'
@@ -17,6 +19,9 @@ export interface LocalVmStatus {
   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}>
   container: 'missing' | 'stopped' | 'running'
   ready: boolean
   inUse: boolean

+ 1 - 1
src/shared/runner.ts

@@ -24,7 +24,7 @@ export interface RunnerConfiguration {
   allowedProfiles: string[]
   artifactRoots: string[]
   allowInsecureLan?: boolean
-  computers?: {network?:'none'|'public-proxy';runtime:'docker'|'podman';imageId:string;python:string;hermesSource:string;hermesHome:string;maxConcurrent?:number}
+  computers?: {managedBy?:'compose';network?:'none'|'public-proxy';runtime:'docker'|'podman';imageId:string;python:string;hermesSource:string;hermesHome:string;maxConcurrent?:number}
   hermesCredentials?: {username:string;password:string}
 }
 export const UNCONFIGURED_COMPUTER_IMAGE='sha256:'+'0'.repeat(64)

+ 60 - 10
tests/live/dockerPlatform.live.test.ts

@@ -14,6 +14,10 @@ const exec=promisify(execFile)
 it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE||!process.env.YAOYAO_HERMES_PYTHON)('executes an isolated task through the packaged Docker control plane and an external Runner',async()=>{
   const home=await mkdtemp(join(tmpdir(),'yaoyao-docker-platform-')),name=`yaoyao-platform-${randomUUID()}`,webHome=join(home,'web'),hermesHome=join(home,'hermes')
   await mkdir(webHome);await mkdir(hermesHome)
+  const fixed=!!process.env.YAOYAO_COMPOSE_DESKTOP_IMAGE
+  let webContainer=name,composeIds:string[]=[],desktopId=''
+  const override=join(home,'compose.override.json')
+  const compose=(args:string[])=>exec('docker',['compose','--project-name',name,'--project-directory',resolve('.'),'--env-file',resolve('docker.env.example'),'-f',resolve('compose.desktops.yaml'),'-f',override,...args],{env:{...process.env,HERMES_YAOYAO_PUBLISHED_PORT:'0',HERMES_YAOYAO_BIND_ADDRESS:'127.0.0.1',HERMES_YAOYAO_UPSTREAM:`http://host.docker.internal:${upstreamPort}`}})
   const upstream=createServer(async(req,res)=>{
     res.setHeader('Content-Type','application/json')
     if(req.method==='GET'){res.end(JSON.stringify(req.url?.startsWith('/api/profiles')?{profiles:[{name:'default'}]}:req.url?.startsWith('/api/config')?{terminal:{cwd:'/workspace/docker-proof'}}:{ok:true}));return}
@@ -26,12 +30,25 @@ it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE
   await new Promise<void>(done=>upstream.listen(0,'0.0.0.0',done));const upstreamPort=(upstream.address() as {port:number}).port
   let child:ReturnType<typeof spawn>|undefined,exited:Promise<void>|undefined,browser:Awaited<ReturnType<typeof chromium.launch>>|undefined
   try{
-    await exec('docker',['run','-d','--name',name,'--init','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:mode=1777','--add-host','host.docker.internal:host-gateway','-p','127.0.0.1::15300','--mount',`type=volume,source=${name}-data,target=/var/lib/hermes-yaoyao`,'-e','HERMES_YAOYAO_ALLOW_INSECURE_LAN=1','-e','HERMES_YAOYAO_ALLOWED_HOSTS=localhost,127.0.0.1','-e',`HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:${upstreamPort}`,process.env.YAOYAO_PLATFORM_IMAGE!])
-    const detail=JSON.parse((await exec('docker',['inspect',name])).stdout)[0],port=detail.NetworkSettings.Ports['15300/tcp'][0].HostPort,address=`http://127.0.0.1:${port}`,client=request.agent(address)
+    if(fixed){
+      await writeFile(override,JSON.stringify({services:{web:{image:process.env.YAOYAO_PLATFORM_IMAGE},'desktop-1':{image:process.env.YAOYAO_COMPOSE_DESKTOP_IMAGE},'desktop-2':{image:process.env.YAOYAO_COMPOSE_DESKTOP_IMAGE}}}))
+      await compose(['up','-d','--no-build'])
+      webContainer=(await compose(['ps','-q','web'])).stdout.trim()
+      composeIds=(await compose(['ps','-q','desktop-1','desktop-2'])).stdout.trim().split(/\s+/)
+      expect(composeIds).toHaveLength(2)
+    }else await exec('docker',['run','-d','--name',name,'--init','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:mode=1777','--add-host','host.docker.internal:host-gateway','-p','127.0.0.1::15300','--mount',`type=volume,source=${name}-data,target=/var/lib/hermes-yaoyao`,'-e','HERMES_YAOYAO_ALLOW_INSECURE_LAN=1','-e','HERMES_YAOYAO_ALLOWED_HOSTS=localhost,127.0.0.1','-e',`HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:${upstreamPort}`,process.env.YAOYAO_PLATFORM_IMAGE!])
+    const detail=JSON.parse((await exec('docker',['inspect',webContainer])).stdout)[0],port=detail.NetworkSettings.Ports['15300/tcp'][0].HostPort,address=`http://127.0.0.1:${port}`,client=request.agent(address)
     await expect.poll(async()=>{try{return (await client.get('/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
     expect(detail.Config.User).toBe('node');expect(detail.HostConfig.ReadonlyRootfs).toBe(true);expect(detail.HostConfig.CapDrop).toContain('ALL')
-    expect((await exec('docker',['exec',name,'id','-u'])).stdout.trim()).toBe('1000')
+    expect((await exec('docker',['exec',webContainer,'id','-u'])).stdout.trim()).toBe('1000')
     expect(detail.Mounts.every((mount:any)=>!mount.Destination.includes('docker.sock')&&!mount.Destination.includes('.hermes'))).toBe(true)
+    if(fixed){
+      for(const id of composeIds){
+        const desktop=JSON.parse((await exec('docker',['inspect',id])).stdout)[0]
+        expect(Object.values(desktop.NetworkSettings.Ports??{}).every(value=>value===null)).toBe(true)
+
+      }
+    }
     await client.get('/api/app/admin/runners/bundle').expect(401)
     const bootstrap=await client.get('/api/app/bootstrap').expect(200)
     const setup=await client.post('/api/app/setup').set('Origin',address).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'docker-fixture',password:'docker-fixture-password'}).expect(200)
@@ -62,17 +79,29 @@ it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE
     const registration=await post('/api/app/admin/runners',{name:'容器外执行节点',allowedProfiles:['default']}).expect(201)
     const runnerID=registration.body.runner.id
     await writeFile(join(hermesHome,'config.yaml'),`model:\n  provider: custom\n  default: fixture-model\n  base_url: http://127.0.0.1:${upstreamPort}/v1\n  api_key: fixture-key\n  api_mode: chat_completions\nterminal:\n  cwd: /workspace/docker-proof\n`,{mode:0o600})
-    const config={protocol:1,serverURL:address,runnerId:runnerID,token:registration.body.token,hermesURL:`http://127.0.0.1:${upstreamPort}`,allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',network:'none',imageId:UNCONFIGURED_COMPUTER_IMAGE,python:process.env.YAOYAO_HERMES_PYTHON,hermesSource:process.env.YAOYAO_HERMES_SOURCE,hermesHome}}
+    const config={protocol:1,serverURL:address,runnerId:runnerID,token:registration.body.token,hermesURL:`http://127.0.0.1:${upstreamPort}`,allowedProfiles:['default'],artifactRoots:[],computers:{...(fixed?{managedBy:'compose'}:{}),runtime:'docker',network:'none',imageId:UNCONFIGURED_COMPUTER_IMAGE,python:process.env.YAOYAO_HERMES_PYTHON,hermesSource:process.env.YAOYAO_HERMES_SOURCE,hermesHome}}
     const path=join(home,'runner.json');await writeFile(path,JSON.stringify(config),{mode:0o600})
     child=spawn(process.execPath,[join(runnerHome,'runner.mjs'),'--config',path],{stdio:'ignore'})
     exited=new Promise(done=>child!.once('exit',()=>done()))
     await expect.poll(async()=>(await client.get('/api/app/admin/runners')).body.runners?.some((runner:any)=>runner.id===runnerID&&runner.online),{timeout:15000}).toBe(true)
     await settings.getByRole('button',{name:'收起执行节点设置',exact:true}).click()
     await settings.getByRole('button',{name:'重新检查',exact:true}).click()
-    await settings.getByRole('button',{name:'准备本地虚拟机',exact:true}).click()
+    if(fixed){
+      expect(await settings.getByRole('combobox',{name:'虚拟机数量上限'}).count()).toBe(0)
+      expect(await settings.getByRole('button',{name:'准备本地虚拟机',exact:true}).count()).toBe(0)
+      const inventory=(await client.get(localVm)).body;expect(inventory.fixedCapacity).toBe(true);expect(inventory.maxInstances).toBe(2);desktopId=inventory.desktops[0].id
+    }else await settings.getByRole('button',{name:'准备本地虚拟机',exact:true}).click()
     await expect.poll(async()=>(await client.get(localVm)).body.image,{timeout:90000}).toBe(true)
+    if(fixed){
+      await expect.poll(async()=>(await client.get(localVm)).body.desktops.every((d:any)=>d.ready),{timeout:90000}).toBe(true)
+      for(const id of composeIds){
+        const denied=await exec('docker',['exec','--user','1000:1000',id,'python3','-c',"import socket; s=socket.socket(socket.AF_UNIX);\ntry:s.connect('/run/yaoyao-private/bridge/desktop.sock');print('accessible')\nexcept PermissionError:print('blocked')"])
+        expect(denied.stdout.trim()).toBe('blocked')
+      }
+    }
     const created=await post('/api/app/agents',{name:'Docker 隔离成员',profile:'default',execution:'computer'}).expect(201)
     const agentID=created.body.agent.id,conversations=await client.get('/api/app/conversations').expect(200),conversation=conversations.body.conversations.find((item:any)=>item.memberIds.includes(agentID))
+    if(fixed)await client.put(`/api/app/agents/${agentID}/local-vm`).set('Origin',address).set('X-CSRF-Token',fresh.body.csrfToken).send({enabled:true,desktopId}).expect(200)
     const sent=await post(`/api/app/conversations/${conversation.id}/messages`,{requestId:randomUUID(),content:'执行当前隔离工具并导出结果。'}).expect(202)
     let result:any,message:any
     await expect.poll(async()=>{result=(await client.get(`/api/app/conversations/${conversation.id}`)).body;message=result.messages?.find((item:any)=>item.role==='assistant'&&item.runId===sent.body.run.id);return message?.status??'pending'},{timeout:45000}).toMatch(/complete|failed|uncertain/)
@@ -81,7 +110,8 @@ it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE
     const file=message.attachments?.[0]
     expect(file?.id).toBeTruthy()
     expect((await client.get(`/api/app/files/${file.id}/download`).expect(200)).text).toBe('docker-control-plane-proof')
-    expect(await readFile(join(home,'state',runnerID,'computer-workspaces',agentID,'docker-proof.txt'),'utf8')).toBe('docker-control-plane-proof')
+    if(fixed)expect((await exec('docker',['exec','--user','1000:1000',composeIds[0]!,'cat','/home/cua/workspace/docker-proof.txt'])).stdout).toBe('docker-control-plane-proof')
+    else expect(await readFile(join(home,'state',runnerID,'computer-workspaces',agentID,'docker-proof.txt'),'utf8')).toBe('docker-control-plane-proof')
     const computerBase=`/api/app/agents/${agentID}/computer`
     // Shell tasks can finish before the desktop capture service is ready.
     await expect.poll(async()=>(await client.get(`/api/app/agents/${agentID}/local-vm`)).body.ready,{timeout:30000}).toBe(true)
@@ -91,19 +121,39 @@ it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE
     await expect.poll(async()=>(await client.get(computerBase)).body.mode,{timeout:15000}).toBe('human')
     await post(computerBase+'/giveback',{controlId:taken.body.controlId,token:taken.body.token}).expect(200)
     const changeMode=(mode:string)=>client.put(localVm+'/policy').set('Origin',address).set('X-CSRF-Token',fresh.body.csrfToken).send({requestId:randomUUID(),mode,maxInstances:2})
-    await changeMode('shared').expect(200);await changeMode('per-bot').expect(200)
+    await changeMode('shared').expect(fixed?409:200);await changeMode('per-bot').expect(fixed?409:200)
+    if(fixed){
+      for(const action of ['create','recreate','remove','start','stop'])await post(`/api/app/agents/${agentID}/local-vm/${action}`,{}).expect(409)
+      const second=(await post('/api/app/agents',{name:'共用桌面的第二个成员',profile:'default'}).expect(201)).body.agent
+      await client.put(`/api/app/agents/${second.id}/local-vm`).set('Origin',address).set('X-CSRF-Token',fresh.body.csrfToken).send({enabled:true,desktopId}).expect(200)
+      expect((await client.get(`/api/app/agents/${second.id}/local-vm`)).body.desktopId).toBe(desktopId)
+      const secondChat=(await client.get('/api/app/conversations')).body.conversations.find((c:any)=>c.memberIds.includes(second.id))
+      const task=await post(`/api/app/conversations/${secondChat.id}/messages`,{requestId:randomUUID(),content:'在同一共享桌面执行并导出结果。'}).expect(202)
+      await expect.poll(async()=>(await client.get(`/api/app/conversations/${secondChat.id}`)).body.messages.find((m:any)=>m.role==='assistant'&&m.runId===task.body.run.id)?.status,{timeout:45000}).toBe('complete')
+      expect((await compose(['ps','-q','desktop-1','desktop-2'])).stdout.trim().split(/\s+/).sort()).toEqual([...composeIds].sort())
+    }
     await settings.getByRole('button',{name:'关闭设置中心'}).click()
     await page.goto(address+`/conversations/${conversation.id}`)
     await page.getByRole('button',{name:'电脑',exact:true}).click()
     await expect.poll(()=>page.getByRole('complementary',{name:'Agent 电脑面板'}).getByRole('img').count(),{timeout:20000}).toBe(1)
+    if(fixed){
+      const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
+      expect(await dock.getByRole('button',{name:'停止虚拟机',exact:true}).count()).toBe(0)
+      const otherDesktop=(await client.get(localVm)).body.desktops[1].id
+      page.once('dialog',dialog=>{void dialog.accept()})
+      await dock.getByRole('combobox',{name:'此 Agent 使用的电脑'}).selectOption(otherDesktop)
+      await expect.poll(async()=>(await client.get(`/api/app/agents/${agentID}/local-vm`)).body.desktopId).toBe(otherDesktop)
+      await expect.poll(()=>dock.getByRole('img').count(),{timeout:15000}).toBe(1)
+      expect((await compose(['ps','-q','desktop-1','desktop-2'])).stdout.trim().split(/\s+/).sort()).toEqual([...composeIds].sort())
+    }
     const evidence=resolve(process.env.YAOYAO_DOCKER_EVIDENCE??'test-results/docker-platform')
     await mkdir(evidence,{recursive:true});await page.screenshot({path:join(evidence,'docker-agent-desktop.png')})
     const identity=await client.get('/api/status').expect(200);expect(identity.body.server_kind).toBe('yaoyao-web')
-  }catch(error){const logs=await exec('docker',['logs','--tail','35',name]).catch(()=>({stdout:'',stderr:''}));console.error(logs.stdout,logs.stderr);throw error}finally{
+  }catch(error){if(fixed){for(const id of composeIds){const log=await exec('docker',['exec',id,'sh','-c','tail -n 25 /var/log/supervisor/yaoyao-compose.error.log; ls -ld /run/yaoyao-private /run/yaoyao-private/bridge /run/yaoyao-private/bridge/desktop.sock']).catch(()=>({stdout:'desktop unavailable',stderr:''}));console.error(log.stdout,log.stderr)}}const logs=await exec('docker',['logs','--tail','35',webContainer]).catch(()=>({stdout:'',stderr:''}));console.error(logs.stdout,logs.stderr);throw error}finally{
     child?.kill('SIGTERM');await exited
     await browser?.close()
-    await exec('docker',['rm','-f',name]).catch(()=>{})
-    await exec('docker',['volume','rm',name+'-data']).catch(()=>{})
+    if(fixed)await compose(['down','--volumes','--remove-orphans']).catch(()=>{})
+    else{await exec('docker',['rm','-f',name]).catch(()=>{});await exec('docker',['volume','rm',name+'-data']).catch(()=>{})}
     await new Promise<void>(done=>{upstream.close(()=>done());upstream.closeAllConnections()})
     await rm(home,{recursive:true,force:true})
   }

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

@@ -0,0 +1,73 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {randomUUID} from 'node:crypto'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {DatabaseSync} from 'node:sqlite'
+import Koa from 'koa'
+import {bodyParser} from '@koa/bodyparser'
+import request from 'supertest'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceNodes} from '../../src/server/workspaceGateway'
+import {SharedComputers} from '../../src/server/sharedComputers'
+import {LocalVmService} from '../../src/server/localVm'
+import {loadServerConfig} from '../../src/server/config'
+import {ComposeDesktops,parseComposeDesktops} from '../../src/server/composeDesktops'
+import {ComposeComputerProvider} from '../../src/runner/computers/compose'
+import {ComputerPool} from '../../src/runner/computers/pool'
+import {COMPOSE_DESKTOP_IMAGE} from '../../src/shared/composeDesktops'
+
+it('accepts only fixed deployment socket paths and rejects undeclared desktop targets',async()=>{
+ const desktop={id:randomUUID(),name:'共享桌面',socketPath:'/run/yaoyao-desktops/desktop-1/desktop.sock'}
+ expect(parseComposeDesktops(JSON.stringify([desktop]))).toEqual([desktop])
+ for(const path of ['/var/run/docker.sock','http://host:1234','/run/yaoyao-desktops/../../docker.sock'])expect(()=>parseComposeDesktops(JSON.stringify([{...desktop,socketPath:path}]))).toThrow()
+ expect(()=>parseComposeDesktops(JSON.stringify([desktop,desktop]))).toThrow()
+ await expect(new ComposeDesktops([desktop]).call(randomUUID(),'health')).rejects.toMatchObject({code:'compose_desktop_missing'})
+ await expect(new ComposeDesktops([desktop]).call(desktop.id,'remove')).rejects.toMatchObject({code:'compose_desktop_managed'})
+})
+
+it('locks Compose capacity and binds existing desktops without allowing cross-account reuse',async()=>{
+ const home=mkdtempSync(join(tmpdir(),'yaoyao-compose-routes-')),store=new WorkspaceStore(home)
+ const desktops=[1,2].map(n=>({id:randomUUID(),name:`共享桌面 ${n}`,socketPath:`/run/yaoyao-desktops/desktop-${n}/desktop.sock`}))
+ const config=loadServerConfig({HERMES_YAOYAO_HOME:home,HERMES_YAOYAO_COMPOSE_DESKTOPS:JSON.stringify(desktops)})
+ const nodes=new WorkspaceNodes(store,config,{} as any),runner={id:randomUUID(),name:'执行端',enabled:true,sourceNodeId:'local',sourceOwner:'_system'}
+ const auth:any={require:(ctx:any)=>({id:ctx.get('x-owner')||'alice'}),requireAdmin:(ctx:any)=>{if(ctx.get('x-admin')!=='yes')throw Object.assign(new Error('管理员权限'),{status:403});return {id:'alice'}}}
+ const hub:any={records:()=>[runner],summary:()=>({online:true,features:['compose-desktops-v1']}),composeDesktops:{status:async()=>desktops.map(d=>({...d,online:true,ready:true}))},localVm:vi.fn()}
+ const shared=new SharedComputers(store,auth,nodes,hub),service=new LocalVmService(store,auth,nodes,hub,shared,config),app=new Koa()
+ app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status||500;ctx.body={code:(e as any).code}}});app.use(bodyParser());app.use(service.router().routes())
+ try{
+  expect(await service.status('alice')).toMatchObject({fixedCapacity:true,maxInstances:2,mode:'shared'})
+  for(const [verb,path,body] of [['post','/prepare',{requestId:randomUUID()}],['put','/policy',{requestId:randomUUID(),maxInstances:4,mode:'per-bot'}],['post',`/instances/${desktops[0]!.id}/remove`,{requestId:randomUUID()}]] as const){await request(app.callback())[verb]('/api/app/admin/local-vm'+path).set('x-admin','yes').send(body).expect(409,{code:'compose_desktop_managed'})}
+  const first=store.createAgent('alice',{name:'研究员',profile:'default'}),second=store.createAgent('alice',{name:'写作者',profile:'writer'}),other=store.createAgent('bob',{name:'其他账号',profile:'default'})
+  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)
+  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)
+  for(const op of ['create','start','stop','recreate','remove'])await request(app.callback()).post(`/api/app/agents/${first.id}/local-vm/${op}`).send({}).expect(409)
+  const child=store.createAgent('alice',{name:'团队新成员',profile:'default',execution:'computer'},{createdByAgentId:first.id,createdFromRunId:randomUUID()})
+  expect(child.computerEnvironmentId).toBe(desktops[0]!.id)
+  expect(hub.localVm).not.toHaveBeenCalled()
+  expect((await service.status('alice')).maxInstances).toBe(2)
+ }finally{nodes.close();await service.close();store.close();rmSync(home,{recursive:true,force:true})}
+})
+
+it('reuses a fixed desktop, rotates authorization and never creates or deletes its container',async()=>{
+ const id=randomUUID(),db=new DatabaseSync(':memory:'),operations:any[]=[],tokens:string[]=[]
+ const relay=vi.fn(async(target:string,op:string,body:any)=>{operations.push({target,op,body});if(op==='list')return [{id,name:'固定桌面',ready:true}];if(target!==id)throw new Error('unknown desktop');if(op==='health')return {instance:'boot-1',ready:true};if(op==='acquire'){const token=randomUUID();tokens.push(token);return {token,instance:'boot-1'}};if(op==='execute')return {stdout:'ok',stderr:'',exitCode:0};return {ok:true}})
+ const provider=new ComposeComputerProvider(randomUUID(),'/fixture',relay),engine=vi.spyOn(provider,'run'),pool=new ComputerPool(db,provider),spec={id,ownerKey:'owner',imageId:COMPOSE_DESKTOP_IMAGE}
+ try{
+  await pool.recover()
+  const first=await pool.acquire(spec,'agent-a',()=>{})
+  await pool.use(first,c=>provider.execute(spec,['pwd'],c))
+  await pool.release(first,true)
+  expect(()=>pool.use(first,async()=>{})).toThrow()
+  const second=await pool.acquire(spec,'agent-b',()=>{})
+  expect(tokens[0]).not.toBe(tokens[1])
+  await pool.release(second)
+  expect(operations.filter(x=>x.op==='release').map(x=>x.body.cancel)).toEqual([false,true])
+  expect(engine).not.toHaveBeenCalled()
+ }finally{await pool.close();db.close()}
+})