Bläddra i källkod

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

LSamien 1 månad sedan
förälder
incheckning
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:
     build:
       context: .
       context: .
       dockerfile: Dockerfile
       dockerfile: Dockerfile
-    image: hermes-yaoyao:local
+    image: "${HERMES_YAOYAO_IMAGE:-hermes-yaoyao:local}"
     restart: unless-stopped
     restart: unless-stopped
     init: true
     init: true
     read_only: 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
 COPY prepare-firefox.sh /usr/local/bin/prepare-yaoyao-firefox.sh
 RUN chmod 0755 /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
     && 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。
 # 宿主机仅在本机发布 15300;需要通过反向代理或可信局域网访问时再改为 0.0.0.0。
 HERMES_YAOYAO_BIND_ADDRESS=127.0.0.1
 HERMES_YAOYAO_BIND_ADDRESS=127.0.0.1
 HERMES_YAOYAO_PUBLISHED_PORT=15300
 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 启动。
 # 部署步骤见 docs/docker-install.md;使用 docker compose --env-file docker.env 启动。
 # 填写容器可访问的 Hermes Dashboard/Gateway 地址。
 # 填写容器可访问的 Hermes Dashboard/Gateway 地址。
@@ -8,8 +11,8 @@ HERMES_YAOYAO_PUBLISHED_PORT=15300
 HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:9119
 HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:9119
 HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
 HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
 # 首次登录后在 Web 系统设置中保存 Hermes 连接凭据。
 # 首次登录后在 Web 系统设置中保存 Hermes 连接凭据。
-# 虚拟桌面通过 Hermes 所在电脑的 Runner 提供。
-# 在“设置 → 本地虚拟机”下载配套程序、注册节点,连接后准备镜像;详见 docs/docker-install.md。
+# 使用 compose.desktops.yaml 可同时部署固定数量的共享桌面容器。
+# 桌面数量以 Compose 为准,Web 仅连接已有桌面;详见 docs/docker-install.md。
 
 
 # 使用域名访问时填写域名,多个值以英文逗号分隔;localhost 和私网 IP 无需填写。
 # 使用域名访问时填写域名,多个值以英文逗号分隔;localhost 和私网 IP 无需填写。
 HERMES_YAOYAO_ALLOWED_HOSTS=
 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
 ```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
 ```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,然后复制完整数据目录,再启动服务:
 备份时先停止 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、真实桌面键盘输入及快捷键、重启保留文件、双桌面控制切换的完整界面流程通过。
 本轮验收结果: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)。
 截图:[本地虚拟机设置](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>
 </script>
 <template>
 <template>
  <section class="local-vm-settings" aria-label="本地虚拟机设置">
  <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>
    <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?.problem">{{state.problem}}</p><p v-if="error" role="alert">{{error}}</p>
    <p v-if="state?.job" role="status">{{state.job.message}}</p>
    <p v-if="state?.job" role="status">{{state.job.message}}</p>
   </article>
   </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>
    <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>
    <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>
-  <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?.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?.daemonUp?'✓':'2'}}</span><div><strong>启动容器运行环境</strong><p>{{state?.daemonUp?'运行环境已启动':'打开 Docker / Podman,等待它完成启动后重新检查。'}}</p></div></li>
    <li><span class="step">{{state?.image?'✓':'3'}}</span><div><strong>准备本地虚拟机</strong><p>下载并检查托管桌面环境。后续创建虚拟机时会自动使用。</p><button class="primary" :disabled="acting||state?.busy||!state?.daemonUp" @click="prepare">{{state?.busy?'正在准备…':state?.image?'重新检查虚拟机镜像':'准备本地虚拟机'}}</button></div></li>
    <li><span class="step">{{state?.image?'✓':'3'}}</span><div><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>
    <li><span class="step">4</span><div><strong>从 Agent 聊天中创建桌面</strong><p>打开聊天右上角的“电脑”,选择“本地虚拟机”,然后创建该 Agent 的虚拟机。</p></div></li>
   </ol></article>
   </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">
    <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-if="row.vm.container==='running'" :disabled="acting||row.vm.inUse" @click="manage(row.agent,'stop')">停止</button>
     <button v-else :disabled="acting||!state?.image" @click="manage(row.agent,row.vm.container==='missing'?'create':'start')">{{row.vm.container==='missing'?'创建':'启动'}}</button>
     <button v-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}
 type Runner = Omit<RunnerRecord, 'tokenHash' | 'sourceOwner'> & {online: boolean}
 const props=defineProps<{computerSetup?: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 runners = ref<Runner[]>([]), sources = ref<Array<{id:string;name:string}>>([])
 const busy = ref(false), error = ref(''), notice = ref('')
 const busy = ref(false), error = ref(''), notice = ref('')
 const name = ref(''), source = ref('local'), profiles = ref('default')
 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>()
 const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
 
 
 async function refresh() {
 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>) {
 async function action(work:()=>Promise<void>) {
   if(busy.value) return
   if(busy.value) return
@@ -34,7 +34,7 @@ async function register() {
     const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
     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。')
     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}})
     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。'
     notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
     await refresh()
     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="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
       <label class="runner-check"><input v-model="computers" type="checkbox" :disabled="busy">启用隔离电脑 Worker</label>
       <label class="runner-check"><input v-model="computers" type="checkbox" :disabled="busy">启用隔离电脑 Worker</label>
       <fieldset v-if="computers" class="runner-computer">
       <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 Python 路径<input v-model.trim="computerPython" placeholder="留空使用默认安装"></label>
         <label>Hermes 源码目录<input v-model.trim="hermesSource" placeholder="留空使用默认安装"></label>
         <label>Hermes 源码目录<input v-model.trim="hermesSource" placeholder="留空使用默认安装"></label>
         <label>Hermes 配置目录<input v-model.trim="hermesHome" 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}
  }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()}}
 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){
 function action(value:LocalVmAction){
  if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件和浏览器资料会保留。'))return
  if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件和浏览器资料会保留。'))return
  void run(()=>apiRequest(base()+'/'+value,{method:'POST',body:{}}))
  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>
      <span v-if="frame" class="open-badge"><AppIcon name="external" :size="12"/>打开桌面</span>
     </button>
     </button>
     <p v-if="error||state?.problem" class="problem" role="alert">{{error||state?.problem}}</p>
     <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">
     <template v-if="state?.enabled">
      <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此 Agent 的独立虚拟机'}}</p>
      <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此 Agent 的独立虚拟机'}}</p>
      <button v-if="!state.image" class="primary" :disabled="!isAdmin" @click="emit('settings')">设置本地虚拟机</button>
      <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'">
      <template v-else-if="state.container==='running'">
       <button class="primary" :disabled="!state.ready||busy" @click="emit('desktop',agent)">打开桌面</button>
       <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>
       <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>
       <p class="control-state">{{state.controlMode==='human'?'你或其他操作者正在控制电脑':state.inUse?'Agent 正在操作':'仅查看 · 虚拟机空闲'}}</p>
      </template>
      </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>
      <button v-if="isAdmin" class="settings-link" @click="emit('settings')"><AppIcon name="settings" :size="14"/>本地虚拟机设置</button>
     </template>
     </template>
    </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.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
     this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
     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)')
     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()
     this.compactReceipts()
   }
   }
   private async api(path:string,body?:unknown):Promise<any> {
   private async api(path:string,body?:unknown):Promise<any> {
     const url=new URL(`/api/runner/v1/${this.config.runnerId}/${path}`,this.config.serverURL)
     const url=new URL(`/api/runner/v1/${this.config.runnerId}/${path}`,this.config.serverURL)
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     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,{
     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)}),
       ...(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
     const reader=response.body?.getReader();const chunks:Uint8Array[]=[];let bytes=0
     if(!reader)throw new Error('执行节点响应为空')
     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()}
     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')}
       const authorize=()=>{if(!this.connected||!this.active||Date.now()>command.expiresAt)throw new HttpError(403,'电脑请求已失效','computer_control_expired')}
       await this.computers.ready
       await this.computers.ready
       if(input.op==='detail'){
       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 state=await this.computers.controls.status(input.target)
         const actual=spec?await this.computers.provider.inspect(spec):undefined
         const actual=spec?await this.computers.provider.inspect(spec):undefined
         let ready=false
         let ready=false
         if(actual?.running)try{await this.computers.provider.health(spec!,authorize);ready=true}catch{}
         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'){
       if(input.op==='lifecycle'){
         const action=z.enum(['create','start','stop','recreate','remove']).parse(input.action)
         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>
 type Spec=z.output<typeof schema>
 export interface ComputerState {id:string;containerId:string;running:boolean;workspace:string;isolation:'container'|'vm'}
 export interface ComputerState {id:string;containerId:string;running:boolean;workspace:string;isolation:'container'|'vm'}
 export interface ComputerProvider {
 export interface ComputerProvider {
+  releaseFence?(spec:ComputerSpecification,keepRunning?:boolean):Promise<void>
+  renewFence?(spec:ComputerSpecification):Promise<void>
   validateSpecification(spec:ComputerSpecification):ComputerSpecification
   validateSpecification(spec:ComputerSpecification):ComputerSpecification
   readonly capabilities:{isolation:'container'|'vm';shell:boolean;desktop:boolean;persistentWorkspace:boolean;sharedDesktop:boolean;snapshots:boolean;network:'none'|'public-proxy'}
   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>
   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
 /** A private container per environment. No tools, filesystem operations or
  * process execution fall back to the host. Remote hosts run their own Runner. */
  * process execution fall back to the host. Remote hosts run their own Runner. */
 export class ContainerComputerProvider implements ComputerProvider {
 export class ContainerComputerProvider implements ComputerProvider {
+  readonly fixedCapacity:boolean=false
   private queue=new Map<string,Promise<unknown>>()
   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
   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) {
   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 { UNCONFIGURED_COMPUTER_IMAGE } from '../../shared/runner.js'
 import type { LocalVmMode, LocalVmStatus } from '../../shared/localVm.js'
 import type { LocalVmMode, LocalVmStatus } from '../../shared/localVm.js'
 import type { ComputerRuntime } from '../worker/gateway.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'] }
 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)')
     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
     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}
     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.
     // 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
       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
       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> {
   async status(owner?:string): Promise<LocalVmStatus> {
     await this.ready
     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') {
     if(this.releaseMaintenance&&!this.pending&&this.state.job?.state==='failed') {
       try{await this.cleanup(this.state.job.id);this.releaseMaintenance();this.releaseMaintenance=undefined}catch{}
       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})):[]}
       instances:owner?this.runtime.pool.status(owner).map(row=>({id:row.environmentId,status:row.status})):[]}
   }
   }
   async policy(mode:LocalVmMode,maxInstances:number) {
   async policy(mode:LocalVmMode,maxInstances:number) {
+    if(this.runtime.provider.fixedCapacity)throw new ComputerError('compose_desktop_managed','桌面数量与共享方式由 Compose 固定,不能在界面修改')
     await this.ready
     await this.ready
     // Sharing changes future Agent bindings; an idle desktop can keep running.
     // Sharing changes future Agent bindings; an idle desktop can keep running.
     // Image preparation still requires every desktop to be stopped.
     // Image preparation still requires every desktop to be stopped.
@@ -73,6 +82,7 @@ export class LocalVmImages {
     return this.status()
     return this.status()
   }
   }
   async prepare(id:string,check:()=>Promise<void>) {
   async prepare(id:string,check:()=>Promise<void>) {
+    if(this.runtime.provider.fixedCapacity)throw new ComputerError('compose_desktop_managed','桌面镜像由 Compose 配置,无需在界面准备')
     await this.ready;await check()
     await this.ready;await check()
     if(this.state.job?.id===id)return this.status()
     if(this.state.job?.id===id)return this.status()
     if(this.pending||this.closed)throw new ComputerError('local_vm_busy','本地虚拟机正在准备,请稍后查看')
     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)}
   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>{
   use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void})=>Promise<T>):Promise<T>{
     this.assert(lease)
     this.assert(lease)
     const active=this.active.get(lease.id)!
     const active=this.active.get(lease.id)!
@@ -120,6 +120,7 @@ export class ComputerPool {
       this.assert(lease)
       this.assert(lease)
       const entry=this.require(lease),active=this.active.get(lease.id)!
       const entry=this.require(lease),active=this.active.get(lease.id)!
       if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有未结束操作,暂不能进入空闲状态')
       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)
       active.detach();active.controller.abort();this.active.delete(lease.id)
       entry.status='idle';entry.generation++;entry.lease=undefined;entry.expiresAt=0;this.save(entry)
       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'),
   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),
   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([]),
   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')
     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')}
     return {...value,hermesHome,hermesSource,python:value.python||join(hermesSource,'venv','bin','python')}
   }).optional(),
   }).optional(),

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

@@ -37,11 +37,12 @@ export class ComputerControls {
     const state=this.state(meta)
     const state=this.state(meta)
     if(state)return {mode:state.mode,controlId:state.id,generation:state.lease?.generation,canResume:!!state.gateway,error:state.error}
     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)
     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}
     return {mode:resource?.status==='active'?'agent':resource?.status==='idle'?'idle':'off',generation:resource?.generation}
   }
   }
   async frame(meta:ComputerTarget,authorize:()=>void):Promise<ComputerFrame>{
   async frame(meta:ComputerTarget,authorize:()=>void):Promise<ComputerFrame>{
     this.runtime.assertTarget(meta);authorize()
     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')
     if(!spec)throw new HttpError(409,'电脑尚未启动','computer_not_running')
     const status=await this.status(meta),cached=this.frames.get(meta.environmentId)?.at(-1)
     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
     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 type {RunnerConfiguration} from '../../shared/runner.js'
 import {ContainerComputerProvider,COMPUTER_WORKSPACE,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
 import {ContainerComputerProvider,COMPUTER_WORKSPACE,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
 import {ComputerPool,type ComputerLease} from '../computers/pool.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 {ComputerControls} from './control.js'
 import {ComputerPublicProxy} from '../network/computerProxy.js'
 import {ComputerPublicProxy} from '../network/computerProxy.js'
 import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.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 workers=new Map<string,{environmentId:string;ownerKey:string;process:HermesWorkerProcess}>()
   readonly script:string
   readonly script:string
   readonly proxyScript: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')
     if(!config.computers)throw new HttpError(409,'执行节点尚未配置隔离电脑','computer_unavailable')
     this.config=config.computers
     this.config=config.computers
     this.script=script??join(dirname(fileURLToPath(import.meta.url)),'hermes_worker.py')
     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.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})
     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 retired_computers(id TEXT PRIMARY KEY,owner_key TEXT NOT NULL,spec TEXT)')
     db.exec('CREATE TABLE IF NOT EXISTS computer_sessions(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
     db.exec('CREATE TABLE IF NOT EXISTS computer_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){
   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 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()
     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){
   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()
     this.assertTarget(meta);authorize()
     // Stopping/removing an existing desktop must remain possible even when
     // Stopping/removing an existing desktop must remain possible even when
     // its Profile or model was removed or is temporarily misconfigured.
     // 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)
   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)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
+  runners.composeDesktops.desktops.push(...(config.composeDesktops??[]))
   const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
   const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
   const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
   const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
   const localVm=new LocalVmService(workspace,auth,workspaceNodes,runners,sharedComputers,config)
   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,
   type AllowedHostsConfigurationSnapshot,
 } from './allowedHostsConfiguration.js'
 } from './allowedHostsConfiguration.js'
 import type { ChatCacheMode } from './chatCache.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 { DEFAULT_APNS_TOPIC } from './apnsConfiguration.js'
 
 
 export interface APNsProviderConfig {
 export interface APNsProviderConfig {
@@ -43,6 +45,7 @@ export interface ServerConfig {
   production: boolean
   production: boolean
   superviseDashboard?: boolean
   superviseDashboard?: boolean
   localVmHost?: 'server' | 'runner'
   localVmHost?: 'server' | 'runner'
+  composeDesktops?: ComposeDesktop[]
   releaseSource?: string
   releaseSource?: string
   releaseRoot?: string
   releaseRoot?: string
   allowRemoteUpdate?: boolean
   allowRemoteUpdate?: boolean
@@ -204,6 +207,7 @@ export function loadServerConfig(
     production,
     production,
     superviseDashboard,
     superviseDashboard,
     localVmHost,
     localVmHost,
+    composeDesktops:parseComposeDesktops(env.HERMES_YAOYAO_COMPOSE_DESKTOPS),
     chatCacheMode,
     chatCacheMode,
     releaseSource,
     releaseSource,
     releaseRoot,
     releaseRoot,

+ 31 - 8
src/server/localVm.ts

@@ -28,17 +28,23 @@ export class LocalVmService {
   private problem?:string
   private problem?:string
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,
     readonly hub:RunnerHub,readonly shared:SharedComputers,readonly config:ServerConfig) {
     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){
   allowed(id:string,runnerId:string){
     const grant=this.store.get<Grant>('_system','local-vm-grant',id)
     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
     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')}
   private record(){return this.hub.records().find(r=>r.enabled&&r.sourceNodeId==='local'&&r.sourceOwner==='_system')}
   async start(url:string) {
   async start(url:string) {
     this.url=url
     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')
     const stored=this.store.get<{sealed:string}>('_system','local-vm-managed','local')
     if(!stored||this.managed)return
     if(!stored||this.managed)return
     const config=this.nodes.open<RunnerConfiguration>(stored.sealed)
     const config=this.nodes.open<RunnerConfiguration>(stored.sealed)
@@ -100,6 +106,11 @@ export class LocalVmService {
   }
   }
   async status(owner:string):Promise<LocalVmStatus> {
   async status(owner:string):Promise<LocalVmStatus> {
     const record=this.record()
     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'
     const executionHost=this.config.localVmHost==='runner'||(record&&!this.store.get('_system','local-vm-managed','local'))?'runner':'server'
     if(record){
     if(record){
       if(this.url&&!this.managed)await this.start(this.url)
       if(this.url&&!this.managed)await this.start(this.url)
@@ -125,11 +136,13 @@ export class LocalVmService {
     const router=new Router()
     const router=new Router()
     router.get('/api/app/admin/local-vm',async ctx=>{ctx.body=await this.status(this.auth.requireAdmin(ctx).id)})
     router.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=>{
     router.post('/api/app/admin/local-vm/prepare',async ctx=>{
+      this.auth.requireAdmin(ctx);this.assertManaged()
       const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
       const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
       const record=await this.ensure(actor.id);this.grant(actor.id,body.requestId,record.id)
       const record=await this.ensure(actor.id);this.grant(actor.id,body.requestId,record.id)
       ctx.body=await this.hub.localVm(actor.id,record.id,{op:'prepare',id:body.requestId})
       ctx.body=await this.hub.localVm(actor.id,record.id,{op:'prepare',id:body.requestId})
     })
     })
     router.put('/api/app/admin/local-vm/policy',async ctx=>{
     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 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)
       const record=await this.ensure(owner);this.shared.assertLocalVmIdle(owner)
       this.grant(owner,body.requestId,record.id)
       this.grant(owner,body.requestId,record.id)
@@ -139,6 +152,7 @@ export class LocalVmService {
       ctx.body=await this.status(owner)
       ctx.body=await this.status(owner)
     })
     })
     router.post('/api/app/admin/local-vm/instances/:id/:action',async ctx=>{
     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 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()
       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')
       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=>{
     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)
       const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
       this.nodes.requireSource(owner,agent)
       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=>{
     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)
       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')
       if(agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'请在该 Agent 所属的电脑上设置虚拟机','local_vm_remote')
       this.shared.assertLocalVmIdle(owner,[agent.id])
       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(()=>{
       ctx.body=this.store.atomic(()=>{
         if(!body.enabled)this.shared.detachLocalVm(owner,agent)
         if(!body.enabled)this.shared.detachLocalVm(owner,agent)
         this.store.updateAgent(owner,agent.id,{execution:body.enabled?'computer':'profile'})
         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))}
         return {agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
       })
       })
     })
     })
     router.post('/api/app/agents/:id/local-vm/:action',async ctx=>{
     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 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)
       const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
       if(agent.temporaryGoalId)throw new HttpError(409,'临时助手的虚拟机由当前任务管理','helper_task_bound')
       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 { UpstreamRequestOptions, UpstreamResponse } from './upstream.js'
 import type { LeaseInput, WorkspaceToolLease } from './workspaceToolLease.js'
 import type { LeaseInput, WorkspaceToolLease } from './workspaceToolLease.js'
 import { RUNNER_PROTOCOL, type RunnerCommand, type RunnerRecord } from '../shared/runner.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 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 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'),
 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()
   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 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>}> }
 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. */
 /** Outbound-only machine transport. Browser auth and machine auth remain separate. */
 export class RunnerHub {
 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) }
   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 localVmActivity = new Map<string, { owner: string; runnerId: string }>()
   private localVmChecks = new Set<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')
       if(!this.auth.canUseSource(owner,sourceNodeId,profile)||!record.allowedProfiles.includes(profile))throw new HttpError(403,'执行节点未授权这个基础 Profile','runner_profile_forbidden')
     }
     }
     const requireComputer=()=>{
     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&&!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')
       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')
           if(computer&&!scope)throw new HttpError(403,'隔离执行缺少任务授权','computer_scope_required')
           const version=this.auth.pushAuthorizationVersion(owner)
           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 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}
           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
           let closed=false
           return {rpc:async(method,params)=>{
           return {rpc:async(method,params)=>{
@@ -172,6 +179,7 @@ export class RunnerHub {
   computerRunner(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent){
   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))
     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(!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')
     if(!record.allowedProfiles.includes(agent.profile)||!this.auth.canUseSource(owner,agent.nodeId,agent.profile))throw new HttpError(403,'电脑 Profile 未授权','computer_profile_forbidden')
     return record
     return record
   }
   }
@@ -194,7 +202,7 @@ export class RunnerHub {
   }
   }
   adminRouter():Router {
   adminRouter():Router {
     const router=new 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.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.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}})
     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 {
   middleware():Koa.Middleware {
     return async(ctx,next)=>{
     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()
       if(!match)return next()
       const record=this.store.get<RunnerRecord>('_system','runner',match[1]!),token=ctx.get('authorization').replace(/^Bearer /,'')
       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))))
       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
         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')
       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')
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
         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')
       if(ctx.method!=='POST')throw new HttpError(405,'仅允许 POST','method_not_allowed')
       let size=0;const chunks:Buffer[]=[]
       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
       let body:any
       try {body=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'JSON 无效','invalid_json')}
       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(!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]==='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]==='control-check'){ctx.body={allowed:typeof body.controlId==='string'&&this.controlAllowed(body.controlId,record.id)};return}
       if(match[2]==='artifact'){
       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 {WorkspaceNodes} from './workspaceGateway.js'
 import type {RunnerHub} from './runnerHub.js'
 import type {RunnerHub} from './runnerHub.js'
 import type {LocalAuthStore} from './localAuth.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}){
 export function requireSharedComputer(store:WorkspaceStore,owner:string,agent:{id?:string;nodeId:string;profile:string;computerEnvironmentId?:string}){
   if(!agent.computerEnvironmentId)return
   if(!agent.computerEnvironmentId)return
   const shared=store.require<SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId)
   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 {
 export class SharedComputers {
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly hub:RunnerHub){}
   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))}
   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))}
   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){
   attachLocalVm(owner:string,agent:WorkspaceAgent,runnerId:string){
     if(agent.nodeId!=='local'||agent.execution!=='computer'||agent.temporaryGoalId||agent.remoteAgentId||agent.computerEnvironmentId)return
     if(agent.nodeId!=='local'||agent.execution!=='computer'||agent.temporaryGoalId||agent.remoteAgentId||agent.computerEnvironmentId)return
     this.nodes.requireSource(owner,agent)
     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 LocalVmMode = 'shared' | 'per-bot'
 export type LocalVmAction = 'create' | 'start' | 'stop' | 'recreate' | 'remove'
 export type LocalVmAction = 'create' | 'start' | 'stop' | 'recreate' | 'remove'
 export interface LocalVmStatus {
 export interface LocalVmStatus {
+  fixedCapacity?:boolean
+  desktops?:Array<{id:string;name:string;online:boolean;ready:boolean;available?:boolean}>
   executionHost?: 'server' | 'runner'
   executionHost?: 'server' | 'runner'
   runnerName?: string
   runnerName?: string
   setupRequired?: 'runner' | 'worker'
   setupRequired?: 'runner' | 'worker'
@@ -17,6 +19,9 @@ export interface LocalVmStatus {
   instances?: Array<{id:string;status:string;name?:string;orphaned?:boolean}>
   instances?: Array<{id:string;status:string;name?:string;orphaned?:boolean}>
 }
 }
 export interface LocalVmInstance {
 export interface LocalVmInstance {
+  desktopId?:string
+  fixedCapacity?:boolean
+  desktops?:Array<{id:string;name:string;online:boolean;ready:boolean;available?:boolean}>
   container: 'missing' | 'stopped' | 'running'
   container: 'missing' | 'stopped' | 'running'
   ready: boolean
   ready: boolean
   inUse: boolean
   inUse: boolean

+ 1 - 1
src/shared/runner.ts

@@ -24,7 +24,7 @@ export interface RunnerConfiguration {
   allowedProfiles: string[]
   allowedProfiles: string[]
   artifactRoots: string[]
   artifactRoots: string[]
   allowInsecureLan?: boolean
   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}
   hermesCredentials?: {username:string;password:string}
 }
 }
 export const UNCONFIGURED_COMPUTER_IMAGE='sha256:'+'0'.repeat(64)
 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()=>{
 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')
   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)
   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)=>{
   const upstream=createServer(async(req,res)=>{
     res.setHeader('Content-Type','application/json')
     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}
     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
   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
   let child:ReturnType<typeof spawn>|undefined,exited:Promise<void>|undefined,browser:Awaited<ReturnType<typeof chromium.launch>>|undefined
   try{
   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)
     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(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)
     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)
     await client.get('/api/app/admin/runners/bundle').expect(401)
     const bootstrap=await client.get('/api/app/bootstrap').expect(200)
     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)
     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 registration=await post('/api/app/admin/runners',{name:'容器外执行节点',allowedProfiles:['default']}).expect(201)
     const runnerID=registration.body.runner.id
     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})
     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})
     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'})
     child=spawn(process.execPath,[join(runnerHome,'runner.mjs'),'--config',path],{stdio:'ignore'})
     exited=new Promise(done=>child!.once('exit',()=>done()))
     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 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()
     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)
     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 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))
     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)
     const sent=await post(`/api/app/conversations/${conversation.id}/messages`,{requestId:randomUUID(),content:'执行当前隔离工具并导出结果。'}).expect(202)
     let result:any,message:any
     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/)
     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]
     const file=message.attachments?.[0]
     expect(file?.id).toBeTruthy()
     expect(file?.id).toBeTruthy()
     expect((await client.get(`/api/app/files/${file.id}/download`).expect(200)).text).toBe('docker-control-plane-proof')
     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`
     const computerBase=`/api/app/agents/${agentID}/computer`
     // Shell tasks can finish before the desktop capture service is ready.
     // 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)
     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 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)
     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})
     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 settings.getByRole('button',{name:'关闭设置中心'}).click()
     await page.goto(address+`/conversations/${conversation.id}`)
     await page.goto(address+`/conversations/${conversation.id}`)
     await page.getByRole('button',{name:'电脑',exact:true}).click()
     await page.getByRole('button',{name:'电脑',exact:true}).click()
     await expect.poll(()=>page.getByRole('complementary',{name:'Agent 电脑面板'}).getByRole('img').count(),{timeout:20000}).toBe(1)
     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')
     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')})
     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')
     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
     child?.kill('SIGTERM');await exited
     await browser?.close()
     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 new Promise<void>(done=>{upstream.close(()=>done());upstream.closeAllConnections()})
     await rm(home,{recursive:true,force:true})
     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()}
+})