Просмотр исходного кода

修复 Docker 镜像构建并补齐虚拟桌面执行节点接入

LSamien 1 месяц назад
Родитель
Сommit
3ae168d325

+ 2 - 0
.dockerignore

@@ -17,6 +17,8 @@
 !third-party/**
 !scripts/
 !scripts/**
+!bin/
+!bin/**
 !src/
 !src/**
 

+ 6 - 1
Dockerfile

@@ -12,11 +12,14 @@ COPY tsconfig.json tsconfig.client.json tsconfig.client.build.json tsconfig.serv
 COPY index.html ./
 COPY public ./public
 COPY scripts ./scripts
+COPY bin ./bin
 COPY deploy ./deploy
 COPY third-party ./third-party
 COPY src ./src
 
 RUN npm run build \
+  && npm run runner:build \
+  && tar -C .runner-build -czf runner-bundle.tar.gz . \
   && npm prune --omit=dev
 
 FROM node:24-bookworm-slim AS runtime
@@ -26,7 +29,8 @@ ENV NODE_ENV=production \
     HERMES_YAOYAO_PORT=15300 \
     HERMES_YAOYAO_HOME=/var/lib/hermes-yaoyao \
     HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local \
-    HERMES_YAOYAO_SUPERVISE_DASHBOARD=0
+    HERMES_YAOYAO_SUPERVISE_DASHBOARD=0 \
+    HERMES_YAOYAO_LOCAL_VM_HOST=runner
 
 WORKDIR /app
 
@@ -36,6 +40,7 @@ COPY --chown=node:node package.json package-lock.json release.json ./
 COPY --chown=node:node --from=builder /app/node_modules ./node_modules
 COPY --chown=node:node --from=builder /app/dist ./dist
 COPY --chown=node:node --from=builder /app/dist-server ./dist-server
+COPY --chown=node:node --from=builder /app/build-info.json /app/runner-bundle.tar.gz ./
 
 USER node
 

+ 1 - 0
compose.yaml

@@ -27,6 +27,7 @@ services:
       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_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:-}"

+ 2 - 0
docker.env.example

@@ -8,6 +8,8 @@ HERMES_YAOYAO_PUBLISHED_PORT=15300
 HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:9119
 HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
 # 首次登录后在 Web 系统设置中保存 Hermes 连接凭据。
+# 虚拟桌面通过 Hermes 所在电脑的 Runner 提供。
+# 在“设置 → 本地虚拟机”下载配套程序、注册节点,连接后准备镜像;详见 docs/docker-install.md。
 
 # 使用域名访问时填写域名,多个值以英文逗号分隔;localhost 和私网 IP 无需填写。
 HERMES_YAOYAO_ALLOWED_HOSTS=

+ 31 - 0
docs/docker-install.md

@@ -46,6 +46,37 @@ docker compose --env-file docker.env logs --tail=100 web
 
 登录后创建会话、收发消息、上传并下载附件,验证实际使用流程。
 
+## 虚拟桌面与镜像准备
+
+Docker 镜像运行 Web 服务,虚拟桌面由运行 Hermes 的电脑上的 Runner 提供。Web 容器不需要挂载 Docker socket、Hermes 源码或模型凭据。不要在 Web 容器里安装 Docker Desktop,也不要把宿主机的 Python 虚拟环境直接挂载进容器。
+
+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
+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
+```
+
+整个程序目录须一起保留,包括 Python Worker、桌面构建资源和第三方许可证。Runner 凭据保存在配置文件内,不要发到聊天或提交到仓库。macOS 也可以使用配套 App 的“执行节点 → 导入节点配置…”,不需要另外安装 Node.js。
+
+5. 回到本地虚拟机设置,等待节点连接,再点击 **准备本地虚拟机**。镜像检测、下载/构建、桌面验证全部在执行电脑上完成。
+6. 在 Agent 聊天右上角打开 **电脑**,选择 **本地虚拟机**,创建桌面。可以预览、接管、启停,以及切换共享方式。
+
+页面会区分“尚未连接执行节点”“节点未启用虚拟桌面”和“镜像尚未准备”。节点离线时,请检查执行电脑的 Runner;此时不能通过在 Web 容器内安装软件来修复。`HERMES_YAOYAO_LOCAL_VM_HOST=runner` 已作为镜像和 Compose 的默认值。
+
+无法通过页面下载时,也可从已创建的 Web 容器取出同版本程序包:
+
+```sh
+docker compose --env-file docker.env cp web:/app/runner-bundle.tar.gz ./yaoyao-runner.tar.gz
+```
+
+验收截图:[Docker Web 的 Agent 桌面](screenshots/docker-vm/agent-desktop.png)。截图来自独立测试数据:真实容器 Web、下载的配套 Runner、Hermes Worker 和虚拟桌面;模型回复由测试服务提供。
+
 ## 数据与备份
 
 Compose 使用 `yaoyao-data` 命名卷,挂载到 `/var/lib/hermes-yaoyao`。账号、SQLite 数据库、聊天缓存、文件及密钥随数据卷持久保存。

+ 2 - 2
docs/runner.md

@@ -2,7 +2,7 @@
 
 Runner 在运行 Hermes 的电脑上主动连接夭夭服务。用于 Web 位于 Docker、另一台电脑或远程服务器时,将 Bot 会话和本轮组队工具交给 Hermes 所在电脑执行。Runner 源码、构建入口和管理页面均位于 hermes-yaoyao。
 
-普通执行方式继续使用原有 Hermes。配置电脑 Worker 后,Agent 可选择隔离电脑执行,模型工具不会退回宿主。具体链路与验证边界见 `docs/computer-worker.md`;公网代理已接入;屏幕接管与共享环境仍待完成。
+普通执行方式继续使用原有 Hermes。配置电脑 Worker 后,Agent 可选择隔离电脑执行,模型工具不会退回宿主。公网代理、屏幕接管和共享环境均已接入,使用流程见 `docs/local-vm.md`。Docker 部署与程序包下载见 `docs/docker-install.md`。
 
 ## 启用
 
@@ -12,7 +12,7 @@ Runner 在运行 Hermes 的电脑上主动连接夭夭服务。用于 Web 位于
 4. 将配置放到执行电脑,执行 `chmod 600 /完整路径/runner.json`。
 5. 在项目目录执行 `npm run build`,然后执行 `npm run runner -- --config /完整路径/runner.json`。
 
-也可执行 `npm run runner:build`,将 `.runner-build/runner.mjs` 复制到执行电脑,以 Node.js 24 或更高版本运行:
+也可执行 `npm run runner:build`,将整个 `.runner-build/` 目录复制到执行电脑,以 Node.js 24 或更高版本运行。不要只复制单个 `runner.mjs`,虚拟桌面还需要同目录的 Worker 和桌面资源:
 
 ```sh
 node runner.mjs --config /完整路径/runner.json

BIN
docs/screenshots/docker-vm/agent-desktop.png


+ 7 - 4
src/client/components/app/LocalVmSettingsPanel.vue

@@ -3,9 +3,11 @@ import {ref,onMounted,onBeforeUnmount} from 'vue'
 import {apiRequest} from '@/api/client'
 import {createUuid} from '@/utils/id'
 import AppIcon from '@/components/common/AppIcon.vue'
+import RunnerSettingsPanel from './RunnerSettingsPanel.vue'
 import type {LocalVmStatus,LocalVmMode,LocalVmAction,LocalVmInstance} from '@shared/localVm'
 import type {WorkspaceAgent} from '@shared/workspace'
 const state=ref<LocalVmStatus>(),error=ref(''),loading=ref(false),acting=ref(false)
+const runnerSettingsOpen=ref(false)
 const instances=ref<Array<{agent:WorkspaceAgent;vm:LocalVmInstance}>>([])
 let closed=false,timer:ReturnType<typeof setTimeout>|undefined
 async function refresh(){
@@ -35,16 +37,17 @@ onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
 </script>
 <template>
  <section class="local-vm-settings" aria-label="本地虚拟机设置">
-  <article><h3>本地虚拟机</h3><p>在这台电脑的隔离 Linux 桌面中运行 Agent,工作文件与浏览器资料持续保存。</p>
+  <article><h3>本地虚拟机</h3><p>在 Hermes 所在电脑的隔离 Linux 桌面中运行 Agent,工作文件与浏览器资料持续保存。</p>
    <div class="row"><span class="status"><AppIcon :name="state?.image?'check':'monitor'"/>{{loading&&!state?'正在检查…':state?.image?'虚拟机环境已就绪':'尚未就绪'}}</span><button :disabled="loading||acting" @click="error='';refresh()"><AppIcon name="refresh"/>重新检查</button></div>
    <p v-if="state?.problem">{{state.problem}}</p><p v-if="error" role="alert">{{error}}</p>
    <p v-if="state?.job" role="status">{{state.job.message}}</p>
   </article>
+  <article v-if="state?.executionHost==='runner'"><h3>虚拟桌面执行节点</h3><p>虚拟桌面由运行 Hermes 的执行节点提供,Docker 版 Web 通过这条连接使用桌面。{{state.runnerName?`当前节点:${state.runnerName}。`:''}}</p><button :aria-expanded="runnerSettingsOpen" @click="runnerSettingsOpen=!runnerSettingsOpen">{{runnerSettingsOpen?'收起执行节点设置':'打开执行节点设置'}}</button><RunnerSettingsPanel v-if="runnerSettingsOpen" computer-setup /></article>
   <article><h3>隔离方式</h3><p>按 Agent 创建独立桌面,或让可信的 Agent 共用一个桌面。</p>
-   <div class="segmented" role="group" aria-label="虚拟机隔离方式"><button :aria-pressed="state?.mode==='shared'" :disabled="!state?.configured||acting||state.busy" @click="policy('shared')">共享虚拟机</button><button :aria-pressed="state?.mode==='per-bot'" :disabled="!state?.configured||acting||state.busy" @click="policy('per-bot')">每个 Agent 独立</button></div>
-   <label class="row">最多同时运行的虚拟机<select aria-label="虚拟机数量上限" :value="state?.maxInstances??2" :disabled="!state?.configured||acting||state.busy" @change="policy(state!.mode,Number(($event.target as HTMLSelectElement).value))"><option v-for="n in [1,2,3,4]" :key="n" :value="n">{{n}}</option></select></label>
+   <div class="segmented" role="group" aria-label="虚拟机隔离方式"><button :aria-pressed="state?.mode==='shared'" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @click="policy('shared')">共享虚拟机</button><button :aria-pressed="state?.mode==='per-bot'" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @click="policy('per-bot')">每个 Agent 独立</button></div>
+   <label class="row">最多同时运行的虚拟机<select aria-label="虚拟机数量上限" :value="state?.maxInstances??2" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @change="policy(state!.mode,Number(($event.target as HTMLSelectElement).value))"><option v-for="n in [1,2,3,4]" :key="n" :value="n">{{n}}</option></select></label>
   </article>
-  <article><h3>准备运行环境</h3><ol>
+  <article v-if="!state?.setupRequired"><h3>准备运行环境</h3><p v-if="state?.executionHost==='runner'">以下检查和镜像准备均在执行节点所在电脑完成。</p><ol>
    <li><span class="step">{{state?.runtime?'✓':'1'}}</span><div><strong>安装 Docker 或 Podman</strong><p>{{state?.runtime?`已检测到 ${state.runtime}`:'请先安装 Docker Desktop 或 Podman。'}}</p></div></li>
    <li><span class="step">{{state?.daemonUp?'✓':'2'}}</span><div><strong>启动容器运行环境</strong><p>{{state?.daemonUp?'运行环境已启动':'打开 Docker / Podman,等待它完成启动后重新检查。'}}</p></div></li>
    <li><span class="step">{{state?.image?'✓':'3'}}</span><div><strong>准备本地虚拟机</strong><p>下载并检查托管桌面环境。后续创建虚拟机时会自动使用。</p><button class="primary" :disabled="acting||state?.busy||!state?.daemonUp" @click="prepare">{{state?.busy?'正在准备…':state?.image?'重新检查虚拟机镜像':'准备本地虚拟机'}}</button></div></li>

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

@@ -5,17 +5,19 @@ import { UNCONFIGURED_COMPUTER_IMAGE } from '@shared/runner'
 import type { RunnerConfiguration, RunnerRecord } from '@shared/runner'
 
 type Runner = Omit<RunnerRecord, 'tokenHash' | 'sourceOwner'> & {online: boolean}
+const props=defineProps<{computerSetup?:boolean}>()
+const bundleAvailable=ref(false)
 const runners = ref<Runner[]>([]), sources = ref<Array<{id:string;name:string}>>([])
 const busy = ref(false), error = ref(''), notice = ref('')
 const name = ref(''), source = ref('local'), profiles = ref('default')
 const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119')
 const computerNetwork=ref<'none'|'public-proxy'>('public-proxy')
-const computers=ref(false),computerRuntime=ref<'docker'|'podman'>('docker'),computerImage=ref(''),computerPython=ref(''),hermesSource=ref(''),hermesHome=ref('')
+const computers=ref(!!props.computerSetup),computerRuntime=ref<'docker'|'podman'>('docker'),computerImage=ref(''),computerPython=ref(''),hermesSource=ref(''),hermesHome=ref('')
 const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
 
 async function refresh() {
-  const data = await apiRequest<{runners:Runner[];sources:Array<{id:string;name:string}>}>('/api/app/admin/runners')
-  runners.value = data.runners; sources.value = data.sources
+  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
 }
 async function action(work:()=>Promise<void>) {
   if(busy.value) return
@@ -30,7 +32,7 @@ async function register() {
     if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程节点请使用 HTTPS,或勾选可信局域网 HTTP')
     if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
     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.startsWith('/'))))throw new Error('请填写镜像完整 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}})
     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}}:{})}
     notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
@@ -56,6 +58,7 @@ onMounted(()=>{void action(refresh)})
 <template>
   <section class="runner-panel" aria-label="执行节点设置">
     <p>在运行 Hermes 的电脑上启动 Runner,它会主动连接夭夭,接收 Bot 任务并提供本轮团队工具。注册后,该来源的 Bot 任务会交给 Runner;节点离线时无法启动新任务,恢复连接后可重试。</p>
+    <p v-if="bundleAvailable"><a href="/api/app/admin/runners/bundle" download>下载配套执行节点程序</a>(包含桌面资源,需要 Node.js 24 或更高版本)。</p>
     <div class="runner-toolbar"><strong>已注册节点</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
     <ul v-if="runners.length" class="runner-list">
       <li v-for="runner in runners" :key="runner.id">
@@ -79,9 +82,10 @@ onMounted(()=>{void action(refresh)})
         <label>联网范围<select v-model="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
         <small>公网模式通过受控代理访问,不开放宿主和内网地址。</small>
         <label>兼容镜像 ID<input v-model.trim="computerImage" placeholder="可在本地虚拟机设置中准备"></label>
-        <label>Hermes Python 路径<input v-model.trim="computerPython" required placeholder="/完整路径/hermes-agent/venv/bin/python"></label>
-        <label>Hermes 源码目录<input v-model.trim="hermesSource" required placeholder="/完整路径/hermes-agent"></label>
-        <label>Hermes 配置目录<input v-model.trim="hermesHome" required placeholder="/完整路径/.hermes"></label>
+        <label>Hermes Python 路径<input v-model.trim="computerPython" placeholder="留空使用默认安装"></label>
+        <label>Hermes 源码目录<input v-model.trim="hermesSource" placeholder="留空使用默认安装"></label>
+        <label>Hermes 配置目录<input v-model.trim="hermesHome" placeholder="留空使用默认安装"></label>
+        <small>留空时在执行电脑上查找 ~/.hermes/hermes-agent/venv/bin/python。自定义安装请填写绝对路径;镜像可在节点连接后于本页准备。</small>
         <small>这是外部执行节点的高级配置。本机虚拟机可直接在应用设置中准备,并从 Agent 聊天的电脑面板启用。</small>
       </fieldset>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
@@ -89,8 +93,9 @@ onMounted(()=>{void action(refresh)})
     <div v-else class="runner-setup">
       <button class="solid-button" type="button" @click="download">下载 Runner 配置</button>
       <p>配置包含本节点的连接凭据,仅本次可下载。将文件放在 Hermes 所在电脑,设置为仅自己可读:</p>
-      <code>chmod 600 runner.json<br>npm run runner -- --config /完整路径/runner.json</code>
-      <small>在 hermes-yaoyao 项目中先运行 npm run build。配置丢失后可停用节点并重新注册。</small>
+      <code v-if="bundleAvailable">mkdir -p yaoyao-runner<br>tar -xzf yaoyao-runner.tar.gz -C yaoyao-runner<br>chmod 600 runner.json<br>node yaoyao-runner/runner.mjs --config /完整路径/runner.json</code>
+      <code v-else>chmod 600 runner.json<br>npm run runner -- --config /完整路径/runner.json</code>
+      <small>{{bundleAvailable?'在运行 Hermes 和 Docker/Podman 的电脑上执行,节点连接后返回本地虚拟机设置准备桌面。':'在 hermes-yaoyao 项目中先运行 npm run build。'}}配置丢失后可停用节点并重新注册。</small>
     </div>
     <p v-if="error" class="runner-error" role="alert">{{ error }}</p>
     <p v-if="notice" role="status">{{ notice }}</p>

+ 7 - 2
src/runner/config.ts

@@ -1,13 +1,18 @@
-import { isAbsolute } from 'node:path'
+import { isAbsolute, join } from 'node:path'
+import { homedir } from 'node:os'
 import { z } from 'zod'
 import type { RunnerConfiguration } from '../shared/runner.js'
 
+const localPath=z.string().refine(value=>!value||isAbsolute(value),'路径必须为绝对路径').default('')
 const schema=z.object({
   protocol:z.literal(1),serverURL:z.string().url(),runnerId:z.string().uuid(),token:z.string().min(32).max(4096),
   hermesURL:z.string().url().default('http://127.0.0.1:9119'),
   allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),
   artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),
-  computers:z.object({network:z.enum(['none','public-proxy']).default('none'),runtime:z.enum(['docker','podman']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),python:z.string().refine(isAbsolute),hermesSource:z.string().refine(isAbsolute),hermesHome:z.string().refine(isAbsolute),maxConcurrent:z.number().int().min(1).max(8).optional()}).strict().optional(),
+  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=>{
+    const hermesHome=value.hermesHome||join(homedir(),'.hermes'),hermesSource=value.hermesSource||join(hermesHome,'hermes-agent')
+    return {...value,hermesHome,hermesSource,python:value.python||join(hermesSource,'venv','bin','python')}
+  }).optional(),
   allowInsecureLan:z.boolean().optional(),hermesCredentials:z.object({username:z.string().max(256),password:z.string().max(4096)}).strict().optional(),
 }).strict()
 export function parseRunnerConfiguration(value:unknown):RunnerConfiguration {

+ 4 - 0
src/server/config.ts

@@ -42,6 +42,7 @@ export interface ServerConfig {
   insecureLan: boolean
   production: boolean
   superviseDashboard?: boolean
+  localVmHost?: 'server' | 'runner'
   releaseSource?: string
   releaseRoot?: string
   allowRemoteUpdate?: boolean
@@ -162,6 +163,8 @@ export function loadServerConfig(
   const allowInsecureLan = flag(env.HERMES_YAOYAO_ALLOW_INSECURE_LAN)
   const production = env.NODE_ENV === 'production'
   const superviseDashboard = flag(env.HERMES_YAOYAO_SUPERVISE_DASHBOARD)
+  const localVmHost = env.HERMES_YAOYAO_LOCAL_VM_HOST?.trim() || 'server'
+  if(localVmHost!=='server'&&localVmHost!=='runner')throw new Error('HERMES_YAOYAO_LOCAL_VM_HOST must be server or runner')
   const chatCacheMode = parseChatCacheMode(env.HERMES_YAOYAO_CHAT_CACHE_MODE)
   const releaseSource = parseReleaseSource(env.HERMES_YAOYAO_RELEASE_SOURCE)
   const releaseRoot = resolve(env.HERMES_YAOYAO_RELEASE_ROOT?.trim() || `${homedir()}/.local/share/hermes-yaoyao`)
@@ -200,6 +203,7 @@ export function loadServerConfig(
     insecureLan,
     production,
     superviseDashboard,
+    localVmHost,
     chatCacheMode,
     releaseSource,
     releaseRoot,

+ 9 - 2
src/server/localVm.ts

@@ -38,6 +38,7 @@ export class LocalVmService {
   private record(){return this.hub.records().find(r=>r.enabled&&r.sourceNodeId==='local'&&r.sourceOwner==='_system')}
   async start(url:string) {
     this.url=url
+    if(this.config.localVmHost==='runner')return
     const stored=this.store.get<{sealed:string}>('_system','local-vm-managed','local')
     if(!stored||this.managed)return
     const config=this.nodes.open<RunnerConfiguration>(stored.sealed)
@@ -70,6 +71,7 @@ export class LocalVmService {
       if(this.url&&!this.managed)await this.start(this.url)
       return this.record()!
     }
+    if(this.config.localVmHost==='runner')throw new HttpError(409,'Docker 版需要先连接运行 Hermes 的执行节点,并启用虚拟桌面。请在本页打开执行节点设置。','local_vm_runner_required')
     this.starting=(async()=>{
       if(!this.url)throw new HttpError(503,'本机 Web 服务尚未启动','local_vm_unavailable')
       const detected=await this.discover()
@@ -98,19 +100,24 @@ export class LocalVmService {
   }
   async status(owner:string):Promise<LocalVmStatus> {
     const record=this.record()
+    const executionHost=this.config.localVmHost==='runner'||(record&&!this.store.get('_system','local-vm-managed','local'))?'runner':'server'
     if(record){
       if(this.url&&!this.managed)await this.start(this.url)
-      if(!this.hub.summary(record).online){
+      const summary=this.hub.summary(record)
+      if(!summary.online){
+        if(executionHost==='runner')return {configured:true,executionHost,runnerName:record.name,setupRequired:'runner',daemonUp:false,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:`执行节点「${record.name}」尚未连接,请在运行 Hermes 的电脑上启动 Runner。`}
         const detected=await this.discover()
         return {configured:true,...detected,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:this.problem??'本机执行环境正在连接,请稍后重新检查'}
       }
+      if(!summary.features?.includes('local-vm-v1'))return {configured:true,executionHost,runnerName:record.name,setupRequired:'worker',daemonUp:false,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:'执行节点已连接,但未启用虚拟桌面或版本过旧。请启用隔离电脑 Worker 并使用配套版本的 Runner。'}
       const state=await this.hub.localVm(owner,record.id,{op:'status'}) as LocalVmStatus
       const agents=this.store.list<WorkspaceAgent>(owner,'agent'),groups=this.store.list<{id:string;name:string}>(owner,'shared-computer')
-      return {...state,mode:this.mode(owner),instances:state.instances?.map(instance=>{
+      return {...state,executionHost,runnerName:record.name,mode:this.mode(owner),instances:state.instances?.map(instance=>{
         const agent=agents.find(a=>!a.archived&&(a.computerEnvironmentId??a.id)===instance.id&&a.execution==='computer')
         return {...instance,name:agent?.computerEnvironmentName??agent?.name??groups.find(g=>g.id===instance.id)?.name??'保留的虚拟机',orphaned:!agent}
       })}
     }
+    if(executionHost==='runner')return {configured:false,executionHost,setupRequired:'runner',daemonUp:false,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:'Docker 版通过执行节点提供虚拟桌面。请在运行 Hermes 的电脑上连接执行节点。'}
     const detected=await this.discover()
     return {configured:false,...detected,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:this.problem}
   }

+ 6 - 1
src/server/runnerHub.ts

@@ -1,6 +1,9 @@
 import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
 import Router from '@koa/router'
 import type Koa from 'koa'
+import {createReadStream,existsSync} from 'node:fs'
+import {dirname,resolve} from 'node:path'
+import {fileURLToPath} from 'node:url'
 import { z } from 'zod'
 import { HttpError } from './errors.js'
 import { parse, type WorkspaceStore } from './workspaceStore.js'
@@ -11,6 +14,7 @@ import type { LeaseInput, WorkspaceToolLease } from './workspaceToolLease.js'
 import { RUNNER_PROTOCOL, type RunnerCommand, type RunnerRecord } from '../shared/runner.js'
 
 const hash = (value: string) => createHash('sha256').update(value).digest('hex')
+const runnerBundle=()=>[resolve(dirname(fileURLToPath(import.meta.url)),'runner-bundle.tar.gz'),resolve(dirname(fileURLToPath(import.meta.url)),'../../runner-bundle.tar.gz')].find(existsSync)
 const input = z.object({ name:z.string().trim().min(1).max(100),sourceNodeId:z.string().min(1).max(100).default('local'),
   allowedProfiles:z.array(z.string().min(1).max(100)).min(1).max(256) }).strict()
 interface Pending { command:RunnerCommand; resolve(value:any):void; reject(error:Error):void; timer:ReturnType<typeof setTimeout>; nextDelivery:number; valid():boolean }
@@ -190,7 +194,8 @@ export class RunnerHub {
   }
   adminRouter():Router {
     const router=new Router()
-    router.get('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body={protocol:RUNNER_PROTOCOL,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,bundleAvailable:!!runnerBundle(),runners:this.records().map(r=>this.summary(r)),sources:[{id:'local',name:'本地来源'},...this.store.list<import('./workspaceGateway.js').WorkspaceNode>(actor.id,'node').filter(n=>n.transport!=='paired-web').map(n=>({id:n.id,name:n.name}))]}})
+    router.get('/api/app/admin/runners/bundle',ctx=>{this.auth.requireAdmin(ctx);const path=runnerBundle();if(!path)throw new HttpError(404,'当前服务未打包执行节点程序,请使用配套 App 或从源码构建 Runner','runner_bundle_missing');ctx.set('Cache-Control','no-store');ctx.attachment('yaoyao-runner.tar.gz');ctx.type='application/gzip';ctx.body=createReadStream(path)})
     router.post('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body=this.enroll(actor.id,(ctx.request as any).body);ctx.status=201})
     router.delete('/api/app/admin/runners/:id',ctx=>{this.auth.requireAdmin(ctx);this.remove(ctx.params.id);ctx.body={ok:true}})
     return router

+ 3 - 0
src/shared/localVm.ts

@@ -1,6 +1,9 @@
 export type LocalVmMode = 'shared' | 'per-bot'
 export type LocalVmAction = 'create' | 'start' | 'stop' | 'recreate' | 'remove'
 export interface LocalVmStatus {
+  executionHost?: 'server' | 'runner'
+  runnerName?: string
+  setupRequired?: 'runner' | 'worker'
   configured: boolean
   runtime?: 'docker' | 'podman'
   daemonUp: boolean

+ 50 - 4
tests/live/dockerPlatform.live.test.ts

@@ -8,6 +8,8 @@ import {createServer} from 'node:http'
 import {execFile,spawn} from 'node:child_process'
 import {promisify} from 'node:util'
 import request from 'supertest'
+import {chromium} from '@playwright/test'
+import {UNCONFIGURED_COMPUTER_IMAGE} from '../../src/shared/runner'
 const exec=promisify(execFile)
 it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE||!process.env.YAOYAO_HERMES_PYTHON)('executes an isolated task through the packaged Docker control plane and an external Runner',async()=>{
   const home=await mkdtemp(join(tmpdir(),'yaoyao-docker-platform-')),name=`yaoyao-platform-${randomUUID()}`,webHome=join(home,'web'),hermesHome=join(home,'hermes')
@@ -22,26 +24,53 @@ it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE
     else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}]}))
   })
   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
+  let child:ReturnType<typeof spawn>|undefined,exited:Promise<void>|undefined,browser:Awaited<ReturnType<typeof chromium.launch>>|undefined
   try{
     await exec('docker',['run','-d','--name',name,'--init','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:mode=1777','--add-host','host.docker.internal:host-gateway','-p','127.0.0.1::15300','--mount',`type=volume,source=${name}-data,target=/var/lib/hermes-yaoyao`,'-e','HERMES_YAOYAO_ALLOW_INSECURE_LAN=1','-e','HERMES_YAOYAO_ALLOWED_HOSTS=localhost,127.0.0.1','-e',`HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:${upstreamPort}`,process.env.YAOYAO_PLATFORM_IMAGE!])
     const detail=JSON.parse((await exec('docker',['inspect',name])).stdout)[0],port=detail.NetworkSettings.Ports['15300/tcp'][0].HostPort,address=`http://127.0.0.1:${port}`,client=request.agent(address)
     await expect.poll(async()=>{try{return (await client.get('/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
     expect(detail.Config.User).toBe('node');expect(detail.HostConfig.ReadonlyRootfs).toBe(true);expect(detail.HostConfig.CapDrop).toContain('ALL')
     expect((await exec('docker',['exec',name,'id','-u'])).stdout.trim()).toBe('1000')
+    expect(detail.Mounts.every((mount:any)=>!mount.Destination.includes('docker.sock')&&!mount.Destination.includes('.hermes'))).toBe(true)
+    await client.get('/api/app/admin/runners/bundle').expect(401)
     const bootstrap=await client.get('/api/app/bootstrap').expect(200)
     const setup=await client.post('/api/app/setup').set('Origin',address).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'docker-fixture',password:'docker-fixture-password'}).expect(200)
     expect(setup.body.user.id).toBeTruthy()
     const fresh=await client.get('/api/app/bootstrap').expect(200)
     const post=(path:string,body:unknown)=>client.post(path).set('Origin',address).set('X-CSRF-Token',fresh.body.csrfToken).send(body)
+    const localVm='/api/app/admin/local-vm'
+    expect((await client.get(localVm).expect(200)).body).toMatchObject({configured:false,executionHost:'runner',setupRequired:'runner'})
+    await post(localVm+'/prepare',{requestId:randomUUID()}).expect(409)
+    const bundle=await client.get('/api/app/admin/runners/bundle').buffer(true).parse((response,done)=>{const chunks:Buffer[]=[];response.on('data',chunk=>chunks.push(chunk));response.on('end',()=>done(null,Buffer.concat(chunks)))}).expect(200)
+    const bundlePath=join(home,'yaoyao-runner.tar.gz'),runnerHome=join(home,'runner')
+    await writeFile(bundlePath,bundle.body);await mkdir(runnerHome)
+    await exec('tar',['-xzf',bundlePath,'-C',runnerHome])
+    for(const file of ['runner.mjs','hermes_worker.py','guest_proxy.py','computer-image/Dockerfile','third-party/openmausbot/LICENSE'])expect((await readFile(join(runnerHome,file))).length).toBeGreaterThan(0)
+    browser=await chromium.launch({headless:true})
+    const page=await browser.newPage({viewport:{width:1440,height:960}})
+    await page.goto(address+'/conversations')
+    await page.getByRole('textbox',{name:'账号',exact:true}).fill('docker-fixture')
+    await page.getByRole('textbox',{name:'密码',exact:true}).fill('docker-fixture-password')
+    await page.getByRole('button',{name:'登录',exact:true}).click()
+    await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
+    await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
+    const settings=page.getByRole('dialog',{name:'设置中心'})
+    await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
+    await settings.getByRole('button',{name:'打开执行节点设置',exact:true}).click()
+    await expect.poll(()=>settings.getByRole('link',{name:'下载配套执行节点程序'}).count()).toBe(1)
+    expect(await settings.getByRole('checkbox',{name:'启用隔离电脑 Worker',exact:true}).isChecked()).toBe(true)
     const registration=await post('/api/app/admin/runners',{name:'容器外执行节点',allowedProfiles:['default']}).expect(201)
     const runnerID=registration.body.runner.id
     await writeFile(join(hermesHome,'config.yaml'),`model:\n  provider: custom\n  default: fixture-model\n  base_url: http://127.0.0.1:${upstreamPort}/v1\n  api_key: fixture-key\n  api_mode: chat_completions\nterminal:\n  cwd: /workspace/docker-proof\n`,{mode:0o600})
-    const config={protocol:1,serverURL:address,runnerId:runnerID,token:registration.body.token,hermesURL:`http://127.0.0.1:${upstreamPort}`,allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',network:'none',imageId:process.env.YAOYAO_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:{runtime:'docker',network:'none',imageId:UNCONFIGURED_COMPUTER_IMAGE,python:process.env.YAOYAO_HERMES_PYTHON,hermesSource:process.env.YAOYAO_HERMES_SOURCE,hermesHome}}
     const path=join(home,'runner.json');await writeFile(path,JSON.stringify(config),{mode:0o600})
-    child=spawn(process.execPath,[resolve('.runner-build/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()))
     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 expect.poll(async()=>(await client.get(localVm)).body.image,{timeout:90000}).toBe(true)
     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 sent=await post(`/api/app/conversations/${conversation.id}/messages`,{requestId:randomUUID(),content:'执行当前隔离工具并导出结果。'}).expect(202)
@@ -53,12 +82,29 @@ it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE
     expect(file?.id).toBeTruthy()
     expect((await client.get(`/api/app/files/${file.id}/download`).expect(200)).text).toBe('docker-control-plane-proof')
     expect(await readFile(join(home,'state',runnerID,'computer-workspaces',agentID,'docker-proof.txt'),'utf8')).toBe('docker-control-plane-proof')
+    const computerBase=`/api/app/agents/${agentID}/computer`
+    // Shell tasks can finish before the desktop capture service is ready.
+    await expect.poll(async()=>(await client.get(`/api/app/agents/${agentID}/local-vm`)).body.ready,{timeout:30000}).toBe(true)
+    const frame=(await client.get(computerBase+'/frame').expect(200)).body
+    expect(frame.width).toBeGreaterThan(1000);expect(frame.data.length).toBeGreaterThan(1000)
+    const taken=await post(computerBase+'/take',{requestId:randomUUID()}).expect(200)
+    await expect.poll(async()=>(await client.get(computerBase)).body.mode,{timeout:15000}).toBe('human')
+    await post(computerBase+'/giveback',{controlId:taken.body.controlId,token:taken.body.token}).expect(200)
+    const changeMode=(mode:string)=>client.put(localVm+'/policy').set('Origin',address).set('X-CSRF-Token',fresh.body.csrfToken).send({requestId:randomUUID(),mode,maxInstances:2})
+    await changeMode('shared').expect(200);await changeMode('per-bot').expect(200)
+    await settings.getByRole('button',{name:'关闭设置中心'}).click()
+    await page.goto(address+`/conversations/${conversation.id}`)
+    await page.getByRole('button',{name:'电脑',exact:true}).click()
+    await expect.poll(()=>page.getByRole('complementary',{name:'Agent 电脑面板'}).getByRole('img').count(),{timeout:20000}).toBe(1)
+    const evidence=resolve(process.env.YAOYAO_DOCKER_EVIDENCE??'test-results/docker-platform')
+    await mkdir(evidence,{recursive:true});await page.screenshot({path:join(evidence,'docker-agent-desktop.png')})
     const identity=await client.get('/api/status').expect(200);expect(identity.body.server_kind).toBe('yaoyao-web')
   }catch(error){const logs=await exec('docker',['logs','--tail','35',name]).catch(()=>({stdout:'',stderr:''}));console.error(logs.stdout,logs.stderr);throw error}finally{
     child?.kill('SIGTERM');await exited
+    await browser?.close()
     await exec('docker',['rm','-f',name]).catch(()=>{})
     await exec('docker',['volume','rm',name+'-data']).catch(()=>{})
     await new Promise<void>(done=>{upstream.close(()=>done());upstream.closeAllConnections()})
     await rm(home,{recursive:true,force:true})
   }
-},100000)
+},200000)

+ 27 - 1
tests/server/localVmRoutes.test.ts

@@ -11,6 +11,7 @@ import {WorkspaceStore} from '../../src/server/workspaceStore'
 import {LocalVmService} from '../../src/server/localVm'
 import {SharedComputers} from '../../src/server/sharedComputers'
 import {HttpError} from '../../src/server/errors'
+import {loadServerConfig} from '../../src/server/config'
 
 it('protects preparation, removes the old catalog API and scopes sharing to the account and profile',async()=>{
  const home=mkdtempSync(join(tmpdir(),'local-vm-routes-')),store=new WorkspaceStore(home)
@@ -18,7 +19,7 @@ it('protects preparation, removes the old catalog API and scopes sharing to the
  const auth:any={require:(ctx:any)=>({id:ctx.get('x-owner')||'alice'}),requireAdmin:(ctx:any)=>{if(ctx.get('x-admin')!=='yes'||!active)throw new HttpError(403,'需要管理员权限','admin_required');return {id:'alice'}},isAdminActive:()=>active,pushAuthorizationVersion:()=>version}
  const runner={id:randomUUID(),enabled:true,sourceNodeId:'local',sourceOwner:'_system'}
  const nodes:any={requireSource:vi.fn()}
- const hub:any={records:()=>[runner],summary:()=>({online:true}),localVm:vi.fn(async()=>({configured:true,daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false})),computer:vi.fn(async()=>({container:'running',ready:true}))}
+ const hub:any={records:()=>[runner],summary:()=>({online:true,features:['local-vm-v1']}),localVm:vi.fn(async()=>({configured:true,daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false})),computer:vi.fn(async()=>({container:'running',ready:true}))}
  const shared=new SharedComputers(store,auth,nodes,hub),service=new LocalVmService(store,auth,nodes,hub,shared,{home} as any),app=new Koa()
  app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status||500;ctx.body={error:String(e)}}});app.use(bodyParser());app.use(service.router().routes())
  try{
@@ -58,3 +59,28 @@ it('protects preparation, removes the old catalog API and scopes sharing to the
   active=false;expect(service.allowed(id,runner.id)).toBe(false)
  }finally{store.close();rmSync(home,{recursive:true,force:true})}
 })
+it('uses the external Runner in Docker and reports missing, offline and incompatible nodes without probing the Web container',async()=>{
+ const home=mkdtempSync(join(tmpdir(),'local-vm-docker-')),store=new WorkspaceStore(home)
+ let records:any[]=[],online=false,features:string[]=[]
+ const auth:any={requireAdmin:()=>({id:'alice'})},nodes:any={open:vi.fn(),requireSource:vi.fn()}
+ const hub:any={records:()=>records,summary:()=>({online,features}),localVm:vi.fn(async()=>({configured:true,runtime:'docker',daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false})),enroll:vi.fn()}
+ const config=loadServerConfig({HERMES_YAOYAO_HOME:home,HERMES_YAOYAO_LOCAL_VM_HOST:'runner'})
+ const shared=new SharedComputers(store,auth,nodes,hub),service=new LocalVmService(store,auth,nodes,hub,shared,config)
+ const discover=vi.spyOn(service as any,'discover')
+ const 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({configured:false,executionHost:'runner',setupRequired:'runner'})
+  await request(app.callback()).post('/api/app/admin/local-vm/prepare').send({requestId:randomUUID()}).expect(409,{code:'local_vm_runner_required'})
+  expect(hub.enroll).not.toHaveBeenCalled()
+  records=[{id:randomUUID(),name:'Hermes 所在电脑',enabled:true,sourceNodeId:'local',sourceOwner:'_system'}]
+  expect(await service.status('alice')).toMatchObject({setupRequired:'runner',runnerName:'Hermes 所在电脑'})
+  online=true
+  expect(await service.status('alice')).toMatchObject({setupRequired:'worker'})
+  features=['local-vm-v1']
+  expect(await service.status('alice')).toMatchObject({executionHost:'runner',image:true,daemonUp:true})
+  // Migrated desktop state must not launch a host-specific Python path in Docker.
+  store.put('_system','local-vm-managed','local',{sealed:'unusable-desktop-config'})
+  await service.start('http://127.0.0.1:15300')
+  expect(nodes.open).not.toHaveBeenCalled();expect(discover).not.toHaveBeenCalled()
+ }finally{await service.close();store.close();rmSync(home,{recursive:true,force:true})}
+})

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

@@ -1,6 +1,8 @@
 // @vitest-environment node
 import {expect,it} from 'vitest'
 import {randomUUID} from 'node:crypto'
+import {homedir} from 'node:os'
+import {join} from 'node:path'
 import {parseRunnerConfiguration} from '../../src/runner/config'
 const config={protocol:1,runnerId:randomUUID(),token:'secret-fixture-'.repeat(3),serverURL:'https://example.invalid',hermesURL:'http://127.0.0.1:9119',allowedProfiles:['default'],artifactRoots:[]}
 it.each([
@@ -27,3 +29,9 @@ it('keeps existing computer configurations offline unless public access is expli
   expect(parseRunnerConfiguration({...config,computers:{...computers,network:'public-proxy'}}).computers?.network).toBe('public-proxy')
   expect(()=>parseRunnerConfiguration({...config,computers:{...computers,network:'host'}})).toThrow()
 })
+it('resolves optional Hermes paths on the execution host while preserving explicit installation paths',()=>{
+ const computers={runtime:'docker',imageId:'sha256:'+'0'.repeat(64)}
+ expect(parseRunnerConfiguration({...config,computers}).computers).toMatchObject({hermesHome:join(homedir(),'.hermes'),hermesSource:join(homedir(),'.hermes','hermes-agent'),python:join(homedir(),'.hermes','hermes-agent','venv','bin','python')})
+ expect(parseRunnerConfiguration({...config,computers:{...computers,hermesHome:'/custom/hermes',hermesSource:'/custom/source',python:''}}).computers?.python).toBe('/custom/source/venv/bin/python')
+ expect(()=>parseRunnerConfiguration({...config,computers:{...computers,hermesHome:'relative'}})).toThrow()
+})