ソースを参照

支持配置虚拟机空闲停止及 Bot 同时使用本机和虚拟机

Samien 1 ヶ月 前
親
コミット
7dabcf054a

+ 16 - 1
docs/local-vm.md

@@ -11,7 +11,22 @@
 5. 右侧显示真实画面及运行状态,可停止或重建虚拟机;点击预览或 **打开桌面** 后接管交互。
 6. **打开双桌面** 在主工作区显示两台虚拟机,保留聊天侧栏。进入时仅查看,不会创建/启动机器;切换操作对象时先交还前一台的控制权。
 
-虚拟机实例移除或重建均保留工作目录和浏览器资料。正常完成任务和主动交还后桌面进入空闲状态;空闲 5 分钟后自动停止。取消、授权撤销、异常退出仍停止旧执行实例,以隔离迟到操作。基础 Profile 工作目录变化会在空闲时重建运行实例,数据保留。
+虚拟机实例移除或重建均保留工作目录和浏览器资料。正常完成任务和主动交还后桌面进入空闲状态;默认空闲 5 分钟后自动停止。应用设置 → **本地虚拟机 → 空闲自动停止** 可选择 1、5、10、15、30、60、120 分钟或 **永不自动停止**。设置保存在执行节点,适用于该节点的所有动态本地虚拟机。再次执行任务或接管时结束空闲计时,交还后重新计时;仅查看画面不会重置计时。
+
+“永不自动停止”仅关闭空闲计时。手动停止、取消、授权撤销、运行中的控制租约过期及执行节点关闭仍会停止旧实例,以隔离迟到操作。基础 Profile 工作目录变化会在空闲时重建运行实例,数据保留。旧版 Runner 需要更新后才能修改此设置;Compose 桌面的启停仍由 Compose 管理。
+
+## 同一 Bot 使用本机和虚拟机
+
+Web/桌面端的 Bot 电脑面板可选择 **本机 + 虚拟机**。此选项同时提供两套工具,现有“本地虚拟机”选项继续只提供隔离电脑工具。
+
+- `host_shell`、`host_read_file`、`host_write_file` 在 **Hermes 执行节点所在主机**运行,可操作该主机用户有权访问的文件和程序。默认本机工作目录采用 Profile 的显式目录;`.`、`auto`、`cwd` 使用执行节点用户的主目录。
+- `computer_shell`、`computer_read_file`、`computer_write_file`、桌面操作等继续在隔离 Linux 环境内执行。虚拟机不会因为启用双环境而挂载本机项目目录。
+- `computer_copy_file` 在两个环境之间显式复制文件,支持二进制文件,单文件最多 25 MiB。目标同名文件会被覆盖。
+- 已连接且已授权的 macOS 桌面可同时提供本机桌面工具。未授权屏幕控制时,本机命令与文件工具仍可用;电脑面板默认显示虚拟机画面。
+
+环境选择按 Bot 保存,任务执行或人工接管期间不能切换;下一轮采用当前授权,不会把本机工具带入已切回的仅虚拟机模式。Bot 创建的成员和临时助手不会自动获得本机工具。外部 Runner 必须支持 `host-computer-tools-v1`;“本机”指执行节点主机,可能与打开 Web 的电脑不同。Compose 模式先选择已有桌面,再勾选 **同时使用本机环境**,不改变固定桌面数量和生命周期。
+
+空闲策略接口为管理员专用 `PUT /api/app/admin/local-vm/idle-policy`,参数 `requestId` 和 `idleStopMinutes`(0 表示永不,1–10080 表示分钟)。Bot 双环境沿用 `PUT /api/app/agents/:id/computer-selection`,设置 `computer: hybrid`。
 
 切换共享方式不会修改普通 Profile 成员,也不会因其聊天任务而被阻止。已经交还控制权的空闲桌面可保持运行;虚拟机任务仍在执行、人工控制尚未交还或停止状态待核对时,继续保护原执行并拒绝切换。成员任务造成阻挡时,提示会显示具体成员名称。
 

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

@@ -5,7 +5,7 @@ import {createUuid} from '@/utils/id'
 import AppIcon from '@/components/common/AppIcon.vue'
 import RunnerSettingsPanel from './RunnerSettingsPanel.vue'
 import type {LocalVmStatus,LocalVmMode,LocalVmAction,LocalVmInstance} from '@shared/localVm'
-import {LOCAL_VM_IMAGES,type LocalVmImageKey} from '@shared/localVm'
+import {LOCAL_VM_IMAGES,VM_IDLE_STOP_OPTIONS,vmIdleStopLabel,type LocalVmImageKey} from '@shared/localVm'
 import type {WorkspaceAgent} from '@shared/workspace'
 const state=ref<LocalVmStatus>(),error=ref(''),loading=ref(false),acting=ref(false)
 const runnerSettingsOpen=ref(false)
@@ -51,6 +51,10 @@ onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
    <p class="sharing-explanation">共享指多个机器人操作同一台虚拟机,共用桌面和工作文件;与可同时运行的虚拟机数量无关。</p>
    <label class="row">虚拟机运行数量上限<select aria-label="虚拟机数量上限" :value="state?.maxInstances??2" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @change="policy(state!.mode,Number(($event.target as HTMLSelectElement).value))"><option v-for="n in [1,2,3,4]" :key="n" :value="n">{{n}}</option></select></label>
   </article>
+  <article v-if="state&&!state.fixedCapacity"><h3>空闲自动停止</h3><p>从任务结束或交还控制权后开始计时,适用于该执行节点上的所有本地虚拟机。</p>
+   <label class="row">停止时间<select aria-label="虚拟机空闲停止时间" :value="state.idleStopMinutes??5" :disabled="acting||!state.configured||state.idleStopMinutes===undefined" @change="action(()=>apiRequest('/api/app/admin/local-vm/idle-policy',{method:'PUT',body:{requestId:createUuid(),idleStopMinutes:Number(($event.target as HTMLSelectElement).value)}}))"><option v-for="minutes in VM_IDLE_STOP_OPTIONS" :key="minutes" :value="minutes">{{vmIdleStopLabel(minutes)}}</option><option v-if="state.idleStopMinutes!==undefined&&!VM_IDLE_STOP_OPTIONS.some(n=>n===state!.idleStopMinutes)" :value="state.idleStopMinutes">{{vmIdleStopLabel(state.idleStopMinutes)}}</option></select></label>
+   <p v-if="state.idleStopMinutes===undefined">连接并更新执行节点后可修改。</p><p v-else-if="state.idleStopMinutes===0">空闲时保持运行;仍可手动停止,执行节点关闭时也会停止。</p>
+  </article>
   <article v-if="state&&!state.fixedCapacity&&!state.setupRequired"><h3>准备运行环境</h3><p v-if="state?.executionHost==='runner'">以下检查和镜像准备均在执行节点所在电脑完成。</p><ol>
    <li><span class="step">{{state?.runtime?'✓':'1'}}</span><div><strong>安装 Docker 或 Podman</strong><p>{{state?.runtime?`已检测到 ${state.runtime}`:'请先安装 Docker Desktop 或 Podman。'}}</p></div></li>
    <li><span class="step">{{state?.daemonUp?'✓':'2'}}</span><div><strong>启动容器运行环境</strong><p>{{state?.daemonUp?'运行环境已启动':'打开 Docker / Podman,等待它完成启动后重新检查。'}}</p></div></li>

+ 10 - 3
src/client/components/workspace/LocalVmChatPanel.vue

@@ -6,7 +6,7 @@ import type {DesktopEnvironmentState,BotBrowserState} from '@shared/desktopEnvir
 import GrokAuthPanel from './GrokAuthPanel.vue'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerFrame} from '@shared/computerControl'
-import type {LocalVmInstance,LocalVmAction} from '@shared/localVm'
+import {vmIdleStopLabel,type LocalVmInstance,type LocalVmAction} from '@shared/localVm'
 const props=defineProps<{agents:WorkspaceAgent[];isAdmin:boolean;active?:boolean;embedded?:boolean}>()
 const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:WorkspaceAgent];workspace:[agent:WorkspaceAgent]}>()
 const selected=ref(''),state=ref<LocalVmInstance&{enabled:boolean;controlMode?:string}>(),frame=ref<ComputerFrame>(),error=ref(''),busy=ref(false),loading=ref(true)
@@ -21,6 +21,7 @@ const computerChoices=computed(()=>[
  {value:'cloud',label:'云端 · Grok Bot',description:'共享云端电脑,持续保存工作',icon:'globe' as const,disabled:false},
  ...(state.value?.fixedCapacity?(state.value.desktops??[]).map(desktop=>({value:desktop.id,label:desktop.name,description:(desktop.imageKey==='cursor'?'Cursor Universal · ':'标准桌面 · ')+(desktop.ready?'已就绪':'尚未就绪'),icon:'monitor' as const,disabled:desktop.available===false})):[{value:'vm',label:'本地虚拟机',description:'在隔离的本地桌面中工作',icon:'monitor' as const,disabled:false}]),
  {value:'local',label:'本机',description:native.value?.host?.name??'请在电脑上打开夭夭桌面端',icon:'monitor' as const,disabled:!native.value?.local.supported},
+ ...(!state.value?.fixedCapacity?[{value:'hybrid',label:'本机 + 虚拟机',description:'同时使用本机文件、命令和隔离桌面',icon:'monitor' as const,disabled:false}]:[]),
  {value:'browser',label:'仅浏览器',description:native.value?.browser.available?'独立浏览器,保存登录资料':'请在电脑上打开夭夭桌面端',icon:'globe' as const,disabled:!native.value?.browser.available},
  {value:'off',label:'不使用电脑',description:'使用基础机器人的其他工具',icon:'stop' as const,disabled:false},
 ])
@@ -65,7 +66,7 @@ async function 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){
- if(state.value?.fixedCapacity&&!['off','auto','cloud','local','browser'].includes(value)){
+ if(state.value?.fixedCapacity&&!['off','auto','cloud','local','browser','hybrid','vm'].includes(value)){
    void run(()=>apiRequest(base(),{method:'PUT',body:{enabled:true,desktopId:value}}));return
  }
  void run(()=>apiRequest(`/api/app/agents/${selected.value}/computer-selection`,{method:'PUT',body:{computer:value}}))
@@ -104,6 +105,12 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
     </div>
     <div :id="`computer-options-${selected}`" class="computer-options" role="tabpanel" :aria-labelledby="`computer-tab-${selected}-${computerChoice}`">
     <p v-if="computerChoice==='auto'" class="hint">按已有可用环境自动选择。查看此面板不会创建或唤醒云端电脑。</p>
+    <label v-if="state?.fixedCapacity&&agent.execution==='computer'" class="hint"><input type="checkbox" :checked="agent.computer==='hybrid'" :disabled="computerChoiceDisabled" @change="choose(($event.target as HTMLInputElement).checked?'hybrid':'vm')"> 同时使用本机环境</label>
+    <template v-if="agent.computer==='hybrid'">
+     <p class="hint">本机工具可读写 Hermes 执行节点上的文件并运行命令;虚拟机工具在隔离 Linux 桌面内执行。上方显示虚拟机画面。</p>
+     <p v-if="native?.local.ready" class="hint">已连接 {{native.host?.name}},Bot 也可操作本机桌面。</p>
+     <button v-else-if="native?.local.supported" :disabled="busy||!isAdmin" @click="run(()=>apiRequest(`/api/app/agents/${selected}/desktop-environment/authorize`,{method:'POST',body:{},timeoutMs:125000}))">授权本机桌面操作</button>
+    </template>
     <template v-if="nativeSelected">
      <p>{{nativeSelected==='local'?'机器人将查看并操作已连接电脑的真实桌面。':'浏览器在已连接的桌面端运行,机器人和你使用同一组标签页。'}}连接电脑:{{native?.host?.name??'未连接'}}。</p>
      <template v-if="nativeSelected==='local'">
@@ -135,7 +142,7 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
       <p class="control-state">{{state.controlMode==='human'?'你或其他操作者正在控制电脑':state.inUse?'机器人正在操作':'仅查看 · 虚拟机空闲'}}</p>
      </template>
      <div v-if="!state.fixedCapacity&&state.container!=='missing'&&!agent.temporaryGoalId" class="vm-actions"><button :disabled="busy||state.inUse" @click="action('stop')">停止虚拟机</button><button :disabled="busy||state.inUse" @click="action('recreate')">重建虚拟机</button><button :disabled="busy||state.inUse" @click="action('remove')">移除实例</button></div>
-     <p class="hint">{{state.fixedCapacity?'桌面由 Compose 创建,数量固定。这里只连接已有共享桌面;工作目录为 /home/cua/workspace。':'重建会保留工作目录和浏览器资料。虚拟机空闲 5 分钟后自动停止。'}}</p>
+     <p class="hint">{{state.fixedCapacity?'桌面由 Compose 创建,数量固定。这里只连接已有共享桌面;工作目录为 /home/cua/workspace。':`重建会保留工作目录和浏览器资料。${vmIdleStopLabel(state.idleStopMinutes)},可在设置中修改。`}}</p>
      <button v-if="isAdmin" class="settings-link" @click="emit('settings')"><AppIcon name="settings" :size="14"/>本地虚拟机设置</button>
     </template>
     </div>

+ 6 - 5
src/runner/agent.ts

@@ -3,7 +3,7 @@ import { DatabaseSync } from 'node:sqlite'
 import { realpath,access } from 'node:fs/promises'
 import { resolve, sep, join } from 'node:path'
 import {LocalVmImages} from './computers/localVm.js'
-import {LOCAL_VM_IMAGE_KEYS} from '../shared/localVm.js'
+import {LOCAL_VM_IMAGE_KEYS,MAX_VM_IDLE_STOP_MINUTES} from '../shared/localVm.js'
 import {ComputerError} from './computers/container.js'
 import {z} from 'zod'
 import {ComputerRuntime,ComputerGateway,type ComputerTarget} from './worker/gateway.js'
@@ -55,7 +55,7 @@ export class RunnerAgent {
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'host-computer-tools-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok){
@@ -96,7 +96,7 @@ export class RunnerAgent {
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
     if(command.kind==='local-vm.manage'){
       if(!this.localVm)throw new HttpError(409,'本地虚拟机尚未配置','computer_unavailable')
-      const input=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),op:z.enum(['status','prepare','policy','instance']),id:z.string().uuid().optional(),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional(),mode:z.enum(['shared','per-bot']).optional(),maxInstances:z.number().int().min(1).max(4).optional(),environmentId:z.string().uuid().optional(),action:z.enum(['stop','remove']).optional()}).strict().parse(p)
+      const input=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),op:z.enum(['status','prepare','policy','idle-policy','instance']),idleStopMinutes:z.number().int().min(0).max(MAX_VM_IDLE_STOP_MINUTES).optional(),id:z.string().uuid().optional(),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional(),mode:z.enum(['shared','per-bot']).optional(),maxInstances:z.number().int().min(1).max(4).optional(),environmentId:z.string().uuid().optional(),action:z.enum(['stop','remove']).optional()}).strict().parse(p)
       const check=async()=>{if(!this.connected||!this.active||(await this.api('local-vm-check',{id:input.id})).allowed!==true)throw new ComputerError('local_vm_authorization_revoked','本地虚拟机配置授权已失效')}
       if(input.op==='status')return this.localVm.status(input.owner)
       if(input.op==='instance'){
@@ -106,7 +106,8 @@ export class RunnerAgent {
         await check();return {ok:true}
       }
       if(input.op==='prepare')return this.localVm.prepare(input.id!,check,input.imageKey)
-      await check();return this.localVm.policy(input.mode!,input.maxInstances!)
+      await check();if(input.op==='idle-policy')return this.localVm.idlePolicy(input.idleStopMinutes!)
+      return this.localVm.policy(input.mode!,input.maxInstances!)
     }
     if(command.kind==='computer.control'){
       if(!this.computers)throw new HttpError(409,'电脑服务未配置','computer_unavailable')
@@ -124,7 +125,7 @@ export class RunnerAgent {
         const imageId=this.computers.imageFor(input.target),option=vm.images?.find(image=>image.imageId===imageId)
         let image=!!option?.ready
         if(!image&&!vm.fixedCapacity&&imageId!==UNCONFIGURED_COMPUTER_IMAGE)try{await this.localVm!.images.inspect(imageId);image=true}catch{}
-        return {...state,container:actual?.running?'running':spec?'stopped':'missing',ready,inUse:['agent','pausing','human','resuming'].includes(state.mode),mode:vm.mode,maxInstances:vm.maxInstances,image:vm.fixedCapacity?vm.image:image,imageId,images:vm.images,imageKey:option?.key,controlMode:state.mode,fixedCapacity:vm.fixedCapacity}
+        return {...state,container:actual?.running?'running':spec?'stopped':'missing',ready,inUse:['agent','pausing','human','resuming'].includes(state.mode),mode:vm.mode,maxInstances:vm.maxInstances,idleStopMinutes:vm.idleStopMinutes,image:vm.fixedCapacity?vm.image:image,imageId,images:vm.images,imageKey:option?.key,controlMode:state.mode,fixedCapacity:vm.fixedCapacity}
       }
       if(input.op==='image')return this.localVm!.select(input.target,z.enum(LOCAL_VM_IMAGE_KEYS).parse(input.imageKey),authorize)
       if(input.op==='lifecycle'){

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

@@ -5,12 +5,12 @@ import { ComputerImages } from './images.js'
 import { ComputerError } from './container.js'
 import { UNCONFIGURED_COMPUTER_IMAGE } from '../../shared/runner.js'
 import type { LocalVmMode, LocalVmStatus } from '../../shared/localVm.js'
-import {LOCAL_VM_IMAGES, type LocalVmImageKey, type LocalVmImageOption} from '../../shared/localVm.js'
+import {LOCAL_VM_IMAGES, DEFAULT_VM_IDLE_STOP_MINUTES, MAX_VM_IDLE_STOP_MINUTES, type LocalVmImageKey, type LocalVmImageOption} from '../../shared/localVm.js'
 import type { ComputerRuntime, ComputerTarget } from '../worker/gateway.js'
 import {ComposeComputerProvider} from './compose.js'
 import {COMPOSE_DESKTOP_IMAGE} from '../../shared/composeDesktops.js'
 
-type State = { imageId: string; images?: Partial<Record<LocalVmImageKey,string>>; mode: LocalVmMode; maxInstances: number; job?: LocalVmStatus['job'] }
+type State = { imageId: string; images?: Partial<Record<LocalVmImageKey,string>>; mode: LocalVmMode; maxInstances: number; idleStopMinutes?: number; job?: LocalVmStatus['job'] }
 
 /** Settings prepares trusted recipes; each desktop keeps one immutable image. */
 export class LocalVmImages {
@@ -34,6 +34,7 @@ export class LocalVmImages {
     runtime.config.imageId = this.state.imageId
     this.state.images??={standard:this.state.imageId}
     runtime.pool.limits.concurrent = this.state.maxInstances
+    runtime.pool.idleStopMinutes = this.state.idleStopMinutes ?? DEFAULT_VM_IDLE_STOP_MINUTES
     runtime.retainDesktops = true
     this.releaseMaintenance = runtime.pool.holdMaintenance()
     this.ready = runtime.ready.then(async () => {
@@ -93,9 +94,16 @@ export class LocalVmImages {
       try { await this.images.inspect(this.state.imageId);image=true } catch { problem='本地虚拟机镜像尚未就绪,请重新准备' }
     }
     return {configured:true,runtime:this.runtime.config.runtime,daemonUp,image,imageId:image?this.state.imageId:undefined,images:await this.options(),
-      mode:this.state.mode,maxInstances:this.state.maxInstances,busy:!!this.pending||!!this.releaseMaintenance,problem,job:this.state.job,
+      mode:this.state.mode,maxInstances:this.state.maxInstances,idleStopMinutes:this.runtime.pool.idleStopMinutes,busy:!!this.pending||!!this.releaseMaintenance,problem,job:this.state.job,
       instances:owner?this.runtime.pool.status(owner).map(row=>({id:row.environmentId,status:row.status})):[]}
   }
+  async idlePolicy(minutes:number) {
+    if(this.runtime.provider.fixedCapacity)throw new ComputerError('compose_desktop_managed','桌面的停止策略由 Compose 管理')
+    if(!Number.isSafeInteger(minutes)||minutes<0||minutes>MAX_VM_IDLE_STOP_MINUTES)throw new ComputerError('local_vm_idle_policy_invalid','请选择有效的空闲停止时间')
+    await this.ready
+    this.state.idleStopMinutes=minutes;this.runtime.pool.idleStopMinutes=minutes;this.save()
+    return this.status()
+  }
   async policy(mode:LocalVmMode,maxInstances:number) {
     if(this.runtime.provider.fixedCapacity)throw new ComputerError('compose_desktop_managed','桌面数量与共享方式由 Compose 固定,不能在界面修改')
     await this.ready

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

@@ -2,6 +2,7 @@ import {randomUUID} from 'node:crypto'
 import {DatabaseSync} from 'node:sqlite'
 import type {ComputerProvider,ComputerSpecification} from './container.js'
 import {ComputerError} from './container.js'
+import {DEFAULT_VM_IDLE_STOP_MINUTES} from '../../shared/localVm.js'
 
 export interface ComputerLease {id:string;environmentId:string;ownerKey:string;holderId:string;generation:number}
 type Status='free'|'idle'|'preparing'|'active'|'stopping'|'uncertain'
@@ -11,6 +12,7 @@ interface Active {lease:ComputerLease;authorize():void;controller:AbortControlle
 /** Durable exclusive use. A generation is invalidated before cancellation; no
  * successor is admitted until the old private environment is confirmed stopped. */
 export class ComputerPool {
+  idleStopMinutes = DEFAULT_VM_IDLE_STOP_MINUTES
   private active=new Map<string,Active>()
   private changing=new Map<string,Promise<unknown>>()
   private ready=false
@@ -193,7 +195,7 @@ export class ComputerPool {
   async expire():Promise<void>{
     for(const id of this.rows().map(entry=>entry.spec.id)){
       const entry=this.get(id)!
-      if(entry.status==='idle'&&this.now()-entry.updatedAt>=300000){this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{});continue}
+      if(entry.status==='idle'&&this.idleStopMinutes>0&&this.now()-entry.updatedAt>=this.idleStopMinutes*60000){this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{});continue}
       if((entry.status==='active'||entry.status==='preparing')&&entry.expiresAt<=this.now()){
         this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{})
       }

+ 27 - 2
src/runner/worker/gateway.ts

@@ -16,8 +16,10 @@ import {COMPOSE_DESKTOP_IMAGE,type DesktopRelay} from '../../shared/composeDeskt
 import {ComputerControls} from './control.js'
 import {ComputerPublicProxy} from '../network/computerProxy.js'
 import {HermesWorkerProcess,type WorkerModel,type WorkerProxyEnvironment,type WorkerTool,type WorkerFrame} from './process.js'
+import {HOST_TOOLS,hostTool,hostPath,readHostFile,writeHostFile} from './hostTools.js'
+import {homedir,platform} from 'node:os'
 
-export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string}
+export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string;hostAccess?:boolean}
 export interface WorkerSession {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
 interface Live {workMarker?:string;paused?:boolean;pauseJob?:Promise<ComputerLease>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxy?:ComputerPublicProxy;session:WorkerSession;model:WorkerModel;proxyEnv?:WorkerProxyEnvironment;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
 const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
@@ -124,6 +126,7 @@ export class ComputerGateway {
   private controller=new AbortController()
   private timer?:ReturnType<typeof setInterval>
   private checking=false
+  private hostCwd=homedir()
   private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>}
   constructor(readonly runtime:ComputerRuntime,readonly meta:ComputerTarget,readonly workId:string,readonly check:()=>Promise<void>,readonly publish:(body:Record<string,unknown>)=>Promise<any>){ }
   async connect(){this.runtime.assertTarget(this.meta);await this.runtime.ready;await this.authorized()}
@@ -141,6 +144,7 @@ export class ComputerGateway {
       if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderId===this.workId)await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
       if(this.runtime.imageFor(this.meta)===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在应用设置的本地虚拟机页面完成准备','computer_image_required')
       const profile=String(params.profile),resolved=await this.runtime.resolve(profile,this.controller.signal)
+      if(this.meta.hostAccess){const configured=String(resolved.configuredCwd??'.');this.hostCwd=['.','auto','cwd'].includes(configured)?homedir():hostPath(homedir(),configured)}
       await this.authorized();this.guard()
       const session:WorkerSession=method==='session.resume'?this.runtime.session(String(params.session_id),this.meta,profile):{id:randomUUID(),agentId:this.meta.agentId,ownerKey:this.meta.ownerKey,environmentId:this.meta.environmentId,profile,cwd:resolved.cwd,configuredCwd:resolved.configuredCwd,history:[]}
       session.cwd=this.meta.environmentId!==this.meta.agentId?(this.runtime.pool.definition(this.meta.ownerKey,this.meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd;session.configuredCwd=resolved.configuredCwd
@@ -195,7 +199,7 @@ export class ComputerGateway {
       live.received=false;live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
       const directory=join(this.runtime.home,'workers',live.session.id,this.workId,String(live.segment=(live.segment??0)+1))
       await mkdir(directory,{recursive:true,mode:0o700})
-      const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,proxyEnv:live.proxyEnv,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,network:this.runtime.config.network??'none',tools:[...TOOLS,...(this.team?.catalog??[])],prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
+      const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,proxyEnv:live.proxyEnv,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,...(this.meta.hostAccess?{host:{cwd:this.hostCwd,platform:platform()}}:{}),network:this.runtime.config.network??'none',tools:[...TOOLS,...(this.meta.hostAccess?HOST_TOOLS:[]),...(this.team?.catalog??[])],prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
       live.worker=worker
       this.runtime.workers.set(this.workId,{environmentId:this.meta.environmentId,ownerKey:this.meta.ownerKey,process:worker});void worker.exited.then(()=>{if(this.runtime.workers.get(this.workId)?.process===worker)this.runtime.workers.delete(this.workId)})
       worker.onTool=(name,args,id,callId)=>{
@@ -224,6 +228,27 @@ export class ComputerGateway {
       if(team)return await this.team!.call(team.id??name,z.record(z.string(),z.unknown()).parse(args),id)
       return await this.runtime.pool.use(live.lease,async context=>{
         const execute=(argv:string[],input?:Buffer)=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input})
+        if(this.meta.hostAccess&&name.startsWith('host_'))return hostTool(name,args,{...context,cwd:this.hostCwd})
+        if(this.meta.hostAccess&&name==='computer_copy_file'){
+          const body=z.object({from:z.enum(['host','vm']),source:pathField,destination:pathField}).strict().parse(args),host={...context,cwd:this.hostCwd}
+          if(body.from==='host'){
+            const bytes=await readHostFile(hostPath(this.hostCwd,body.source),25*1024*1024,host)
+            await execute(['python3','-c','import pathlib,sys; p=pathlib.Path(sys.argv[1]); p.parent.mkdir(parents=True,exist_ok=True); p.write_bytes(sys.stdin.buffer.read())',body.destination],bytes)
+          }else{
+            const snapshot=`/tmp/yaoyao-copy-${randomUUID()}`,chunks:Buffer[]=[]
+            try{
+              const size=Number((await execute(['python3','-c','import pathlib,sys,shutil; p=pathlib.Path(sys.argv[1]); assert p.is_file() and p.stat().st_size<=25*1024*1024; shutil.copyfile(p,sys.argv[2]); n=pathlib.Path(sys.argv[2]).stat().st_size; assert n<=25*1024*1024; print(n)',body.source,snapshot])).stdout.trim())
+              if(!Number.isSafeInteger(size)||size<0||size>25*1024*1024)throw new Error('文件大小无效')
+              for(let offset=0;offset<size;offset+=524288){
+                const data=(await execute(['python3','-c','import sys,base64; f=open(sys.argv[1],"rb"); f.seek(int(sys.argv[2])); print(base64.b64encode(f.read(524288)).decode())',snapshot,String(offset)])).stdout.trim()
+                chunks.push(Buffer.from(data,'base64'))
+              }
+              const bytes=Buffer.concat(chunks);if(bytes.length!==size)throw new Error('文件复制不完整')
+              await writeHostFile(hostPath(this.hostCwd,body.destination),bytes,host)
+            }finally{await execute(['rm','-f','--',snapshot]).catch(()=>{})}
+          }
+          return {ok:true}
+        }
         if(name==='computer_export'){
           const body=z.object({path:pathField,name:z.string().min(1).max(240).optional()}).strict().parse(args)
           const snapshot=`/tmp/yaoyao-export-${randomUUID()}`

+ 6 - 1
src/runner/worker/hermes_worker.py

@@ -194,8 +194,13 @@ def run_model():
                 slot["event"].set()
     threading.Thread(target=receive, daemon=True).start()
     emit("ready", tools=sorted(allowed))
+    environment = "Computer tools operate on your assigned isolated Linux computer, with working directory " + BOOT["cwd"] + ". Its network access follows the configured computer policy. "
+    if BOOT.get("host"):
+        environment += "The user also enabled host tools. host_* tools operate on the Runner host (" + BOOT["host"]["platform"] + "), with working directory " + BOOT["host"]["cwd"] + ". Use the appropriate OS syntax for each tool. computer_* tools operate in Linux; computer_copy_file explicitly copies between host and VM. These environments have separate files, processes and installed software. "
+    else:
+        environment += "Host files and commands are not available through computer tools. Never attempt to execute a host command through them. "
     result = agent.run_conversation(BOOT["prompt"], conversation_history=BOOT.get("history", []), task_id=BOOT["taskId"],
-        system_message="You are a controlled Hermes worker. All tools operate on your assigned isolated Linux computer. Use Linux paths and shell syntax. Network access follows the configured computer policy. Use only the supplied tools. The working directory is " + BOOT["cwd"] + ". Never claim host files or credentials are available. Follow the user's task and permission scope.")
+        system_message="You are a controlled Hermes worker. " + environment + "Other supplied application and team tools follow their descriptions. Use only supplied tools and follow the user's task and permission scope.")
     emit("complete", text=str(result.get("final_response") or ""), messages=result.get("messages", []), interrupted=stopped.is_set(), completed=result.get("completed", True))
 
 try:

+ 69 - 0
src/runner/worker/hostTools.ts

@@ -0,0 +1,69 @@
+import {spawn} from 'node:child_process'
+import {constants} from 'node:fs'
+import {homedir} from 'node:os'
+import {dirname,resolve} from 'node:path'
+import {mkdir,open,writeFile} from 'node:fs/promises'
+import {z} from 'zod'
+import type {WorkerTool} from './process.js'
+import {HttpError} from '../../server/errors.js'
+
+const path=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
+const text={type:'string'}
+const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
+export const HOST_TOOLS:WorkerTool[]=[
+  {name:'host_shell',description:'在 Hermes 执行节点本机运行命令,使用本机操作系统和软件。与 computer_shell 的隔离 Linux 环境不同。',inputSchema:object({command:text,cwd:text},['command'])},
+  {name:'host_read_file',description:'读取执行节点本机的 UTF-8 文件。相对路径基于本机 Profile 工作目录。',inputSchema:object({path:text},['path'])},
+  {name:'host_write_file',description:'写入执行节点本机文件。只按用户要求修改;虚拟机文件请用 computer_write_file。',inputSchema:object({path:text,content:text},['path','content'])},
+  {name:'computer_copy_file',description:'在本机和虚拟机之间复制一个文件,最多 25 MiB。from 指定来源环境,source 和 destination 分别使用各自环境的路径;目标同名文件会被覆盖。',inputSchema:object({from:{type:'string',enum:['host','vm']},source:text,destination:text},['from','source','destination'])},
+]
+export interface HostContext {cwd:string;signal:AbortSignal;authorize():void}
+export const hostPath=(cwd:string,value:string)=>resolve(cwd,value==='~'?homedir():value.startsWith('~/')?resolve(homedir(),value.slice(2)):value)
+export async function readHostFile(file:string,limit:number,context:HostContext){
+  context.authorize();context.signal.throwIfAborted()
+  const handle=await open(file,constants.O_RDONLY|constants.O_NONBLOCK)
+  try{
+    const stat=await handle.stat()
+    if(!stat.isFile()||stat.size>limit)throw new HttpError(400,'文件类型或大小不符合要求','host_file_invalid')
+    const buffer=Buffer.alloc(limit+1);let length=0
+    while(length<buffer.length){context.authorize();context.signal.throwIfAborted();const {bytesRead}=await handle.read(buffer,length,buffer.length-length,null);if(!bytesRead)break;length+=bytesRead}
+    if(length>limit)throw new HttpError(413,'文件超过大小限制','host_file_limit')
+    context.authorize();return buffer.subarray(0,length)
+  }finally{await handle.close()}
+}
+export async function writeHostFile(file:string,bytes:Buffer,context:HostContext){
+  context.authorize();context.signal.throwIfAborted();await mkdir(dirname(file),{recursive:true})
+  context.authorize();await writeFile(file,bytes,{signal:context.signal});context.authorize()
+}
+export async function hostTool(name:string,args:unknown,context:HostContext):Promise<unknown>{
+  context.authorize();context.signal.throwIfAborted()
+  if(name==='host_read_file'){
+    const body=z.object({path}).strict().parse(args)
+    return {stdout:(await readHostFile(hostPath(context.cwd,body.path),1000000,context)).toString('utf8')}
+  }
+  if(name==='host_write_file'){
+    const body=z.object({path,content:z.string().max(1000000)}).strict().parse(args)
+    await writeHostFile(hostPath(context.cwd,body.path),Buffer.from(body.content),context);return {ok:true}
+  }
+  if(name!=='host_shell')throw new HttpError(403,'未授权的本机工具','computer_tool_forbidden')
+  const body=z.object({command:z.string().min(1).max(65536),cwd:path.optional()}).strict().parse(args)
+  return new Promise((done,reject)=>{
+    const child=spawn(process.platform==='darwin'?'/bin/zsh':'/bin/bash',['-lc',body.command],{
+      cwd:body.cwd?hostPath(context.cwd,body.cwd):context.cwd,detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],
+      env:{PATH:process.env.PATH,HOME:homedir(),TMPDIR:process.env.TMPDIR,LANG:process.env.LANG??'en_US.UTF-8'},
+    })
+    const out:Buffer[]=[],err:Buffer[]=[];let size=0,failure:Error|undefined,killTimer:ReturnType<typeof setTimeout>|undefined
+    const kill=(signal:NodeJS.Signals)=>{if(!child.pid)return;try{if(process.platform!=='win32')process.kill(-child.pid,signal);else child.kill(signal)}catch{}}
+    const stop=(error:Error)=>{if(failure)return;failure=error;kill('SIGTERM');killTimer=setTimeout(()=>kill('SIGKILL'),1000)}
+    const abort=()=>stop(new Error('本机命令已中断'))
+    context.signal.addEventListener('abort',abort,{once:true});if(context.signal.aborted)abort()
+    const timer=setTimeout(()=>stop(new Error('本机命令超过 60 秒')),60000)
+    const collect=(target:Buffer[],chunk:Buffer)=>{size+=chunk.length;if(size>8*1024*1024)stop(new Error('本机命令输出超过限制'));else target.push(chunk)}
+    child.stdout.on('data',chunk=>collect(out,Buffer.from(chunk)));child.stderr.on('data',chunk=>collect(err,Buffer.from(chunk)))
+    child.once('error',error=>{failure=error})
+    child.once('close',code=>{
+      clearTimeout(timer);clearTimeout(killTimer);context.signal.removeEventListener('abort',abort)
+      if(failure){kill('SIGKILL');reject(failure);return}
+      try{context.authorize();done({exitCode:code,stdout:Buffer.concat(out).toString(),stderr:Buffer.concat(err).toString()})}catch(error){reject(error)}
+    })
+  })
+}

+ 3 - 2
src/server/desktopEnvironments.ts

@@ -58,10 +58,11 @@ export class DesktopEnvironments {
  private ownerKey(owner:string){return digest((this.nodes.localNodeID??this.nodes.local.url.toString())+':'+owner)}
  private ready(owner:string){return this.online&&this.host!.platform==='darwin'&&this.host!.screen&&this.host!.accessibility&&this.host!.approved.includes(this.ownerKey(owner))}
  selected(owner:string,agent:WorkspaceAgent):Mode|undefined {if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)return undefined;if(agent.computer==='local'||agent.computer==='browser')return agent.computer;if(agent.computer==='auto'&&agent.execution!=='computer'&&this.ready(owner))return 'local';return undefined}
+ toolMode(owner:string,agent:WorkspaceAgent):Mode|undefined {if(!agent.archived&&!agent.remoteAgentId&&!agent.temporaryGoalId&&agent.computer==='hybrid'&&this.ready(owner))return 'local';return this.selected(owner,agent)}
  private agent(owner:string,id:string){const a=this.store.require<WorkspaceAgent>(owner,'agent',id);this.nodes.requireSource(owner,a);if(a.archived||a.remoteAgentId||a.temporaryGoalId)throw new HttpError(409,'此机器人无法使用桌面环境','computer_unavailable');return a}
  private resource(owner:string,agent:WorkspaceAgent,mode:Mode){return mode==='local'?'local':digest(this.ownerKey(owner)+':'+agent.id+':'+(agent.browserProfile??'persistent'))}
  state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent);return {online:this.online,host:this.online?{name:this.host!.name,platform:this.host!.platform}:null,local:{supported:this.online&&this.host!.platform==='darwin',authorized:this.online&&this.host!.approved.includes(this.ownerKey(owner)),screen:this.online&&this.host!.screen,accessibility:this.online&&this.host!.accessibility,ready:this.ready(owner)},browser:{available:this.online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent)}}
- private check(owner:string,agentId:string,mode:Mode,epoch:string,version:number){const a=this.agent(owner,agentId);if(!this.online||this.host!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||this.selected(owner,a)!==mode)throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');if(mode==='local'&&!this.ready(owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
+ private check(owner:string,agentId:string,mode:Mode,epoch:string,version:number){const a=this.agent(owner,agentId);if(!this.online||this.host!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||this.toolMode(owner,a)!==mode)throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');if(mode==='local'&&!this.ready(owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
  private invalidate(){for(const p of this.pending.values()){if(p.sent&&['input','browser'].includes(String(p.command.operation)))this.paused.add(String(p.command.resource));clearTimeout(p.timer);p.reject(new HttpError(409,'桌面连接已改变;执行结果未确认,不会重试。','desktop_disconnected'))}this.pending.clear();this.frames.clear();for(const [key,m] of this.manual){this.paused.add(key);this.store.remove('_system','computer-control',m.id)}this.manual.clear()}
  close(){this.closed=true;this.invalidate()}
  async bridge(ctx:Koa.Context){
@@ -96,7 +97,7 @@ export class DesktopEnvironments {
  }
  private status(owner:string,a:WorkspaceAgent,mode:Mode):ComputerControlStatus {const key=this.resource(owner,a,mode),m=this.current(key);return {backend:mode,hostName:this.online?this.host!.name:undefined,mode:!this.online?'off':m?(m.owner===owner&&m.ready?'human':'pausing'):this.paused.has(key)?'error':this.activity.get(key)?'agent':'idle',controlId:m?.owner===owner?m.id:undefined,generation:this.generation.get(key)??0,canResume:!!this.activity.get(key),error:!this.online?'请在电脑上打开夭夭桌面端':this.paused.has(key)?'接管已断开,请重新接管并交还后继续':undefined}}
  assertIdle(owner:string,agentId:string){const a=this.agent(owner,agentId),mode=this.selected(owner,a);if(mode&&this.current(this.resource(owner,a,mode)))throw new HttpError(409,'请先交还电脑控制权','computer_busy')}
- async requireAvailable(owner:string,a:WorkspaceAgent,target:GatewayTarget){const mode=this.selected(owner,a);if(!mode)throw new HttpError(409,'当前未选择桌面环境','desktop_unselected');this.context(owner,a,mode).check();await requireTeamToolBridge(target,a.profile)}
+ async requireAvailable(owner:string,a:WorkspaceAgent,target:GatewayTarget){const mode=this.toolMode(owner,a);if(!mode)throw new HttpError(409,'当前未选择桌面环境','desktop_unselected');this.context(owner,a,mode).check();await requireTeamToolBridge(target,a.profile)}
  async call(owner:string,agentId:string,id:string,input:unknown,signal:AbortSignal,assertActive:()=>void,expected:Mode,epoch:string){
   const a=this.agent(owner,agentId),c=this.context(owner,a,expected),check=()=>{signal.throwIfAborted();assertActive();c.check();if(this.host!.id!==epoch)throw new HttpError(410,'桌面连接已改变','desktop_disconnected')};check()
   this.activity.set(c.key,(this.activity.get(c.key)??0)+1)

+ 3 - 2
src/server/grokCloud.ts

@@ -182,10 +182,11 @@ export class GrokCloud {
       if(!token)throw new HttpError(400,'请先通过浏览器完成 Grok Bot 授权。','grok_login_missing');ctx.body=await this.configure(owner,token,body.version,refreshToken)
     })
     router.put('/api/app/agents/:id/computer-selection',async ctx=>{
-      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),body=parse(z.object({computer:z.enum(['auto','cloud','vm','local','browser','off'])}).strict(),(ctx.request as any).body)
+      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),body=parse(z.object({computer:z.enum(['auto','cloud','vm','hybrid','local','browser','off'])}).strict(),(ctx.request as any).body)
       this.beforeSelection?.(owner,agent.id);this.shared.assertLocalVmIdle(owner,[agent.id]);if(this.current(owner))throw new HttpError(409,'请先交还云端电脑','computer_busy')
+      if(body.computer==='hybrid')this.nodes.targetForAgent(owner,{...agent,execution:'computer',computer:'hybrid'})
       if(body.computer==='cloud')await this.requireAvailable(owner,agent,this.nodes.target(owner,agent.nodeId))
-      this.store.atomic(()=>{const execution=body.computer==='vm'?'computer':body.computer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId)this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{computer:body.computer,execution});const current=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,current);this.store.put(owner,'agent',agent.id,current)})
+      this.store.atomic(()=>{const execution=['vm','hybrid'].includes(body.computer)?'computer':body.computer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId)this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{computer:body.computer,execution});const current=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,current);this.store.put(owner,'agent',agent.id,current)})
       if(body.computer==='cloud')await this.connect(owner)
       ctx.body={agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
     })

+ 7 - 1
src/server/localVm.ts

@@ -8,7 +8,7 @@ import { promisify } from 'node:util'
 import { RunnerAgent } from '../runner/agent.js'
 import { UNCONFIGURED_COMPUTER_IMAGE, type RunnerConfiguration } from '../shared/runner.js'
 import type { LocalVmMode, LocalVmStatus } from '../shared/localVm.js'
-import {LOCAL_VM_IMAGE_KEYS} from '../shared/localVm.js'
+import {LOCAL_VM_IMAGE_KEYS,MAX_VM_IDLE_STOP_MINUTES} from '../shared/localVm.js'
 import type { WorkspaceAgent } from '../shared/workspace.js'
 import { parse, type WorkspaceStore } from './workspaceStore.js'
 import type { LocalAuthStore } from './localAuth.js'
@@ -143,6 +143,12 @@ export class LocalVmService {
       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,...(body.imageKey?{imageKey:body.imageKey}:{})})
     })
+    router.put('/api/app/admin/local-vm/idle-policy',async ctx=>{
+      const owner=this.auth.requireAdmin(ctx).id;this.assertManaged()
+      const body=parse(z.object({requestId:z.string().uuid(),idleStopMinutes:z.number().int().min(0).max(MAX_VM_IDLE_STOP_MINUTES)}).strict(),(ctx.request as any).body)
+      const record=await this.ensure(owner);this.grant(owner,body.requestId,record.id)
+      ctx.body=await this.hub.localVm(owner,record.id,{op:'idle-policy',id:body.requestId,idleStopMinutes:body.idleStopMinutes})
+    })
     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)

+ 14 - 2
src/server/runnerHub.ts

@@ -57,6 +57,7 @@ export class RunnerHub {
     const record=this.store.require<RunnerRecord>('_system','runner',id),version=this.auth.pushAuthorizationVersion(owner)
     if(!record.enabled||!this.online.get(id)?.features.includes('local-vm-v1'))throw new HttpError(409,'本地虚拟机执行环境离线或版本过旧,请重新连接或更新','local_vm_unavailable')
     if(payload.imageKey&&!this.online.get(id)?.features.includes('image-options-v1'))throw new HttpError(409,'执行节点版本不支持镜像选择,请更新 Runner','computer_image_options_unavailable')
+    if(payload.op==='idle-policy'&&!this.online.get(id)?.features.includes('idle-stop-policy-v1'))throw new HttpError(409,'执行节点版本不支持空闲停止设置,请更新 Runner','local_vm_idle_policy_unavailable')
     const valid=()=>this.auth.isAdminActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version&&this.store.get<RunnerRecord>('_system','runner',id)?.enabled===true
     if(!valid())throw new HttpError(403,'本地虚拟机设置需要管理员权限','admin_required')
     const key = `${owner}:${id}`
@@ -116,6 +117,7 @@ export class RunnerHub {
       if(!this.auth.canUseSource(owner,sourceNodeId,profile)||!record.allowedProfiles.includes(profile))throw new HttpError(403,'执行节点未授权这个基础 Profile','runner_profile_forbidden')
     }
     const requireComputer=()=>{
+      if(computer?.hostAccess&&!this.online.get(record.id)?.features.includes('host-computer-tools-v1'))throw new HttpError(409,'执行节点版本不支持同时使用本机和虚拟机,请更新 Runner','computer_host_tools_unavailable')
       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)
@@ -124,6 +126,7 @@ export class RunnerHub {
       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?.hostAccess)requireComputer()
     const requestHTTP=async(path:string,options:UpstreamRequestOptions={}):Promise<UpstreamResponse>=>{
       requireComputer()
       const profile=options.search?.get('profile')??'default'
@@ -145,7 +148,16 @@ export class RunnerHub {
           requireComputer()
           if(computer&&!scope)throw new HttpError(403,'隔离执行缺少任务授权','computer_scope_required')
           const version=this.auth.pushAuthorizationVersion(owner)
-          const valid=()=>{try{scope?.authorize();return (scope?.cleanupOnly||(this.auth.isUserActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version))&&this.store.get<RunnerRecord>('_system','runner',record.id)?.enabled===true}catch{return false}}
+          const valid=()=>{
+            try{
+              scope?.authorize()
+              if(computer?.hostAccess){
+                const agent=this.store.get<import('../shared/workspace.js').WorkspaceAgent>(owner,'agent',computer.agentId)
+                if(!agent||agent.archived||agent.temporaryGoalId||agent.computer!=='hybrid'||agent.execution!=='computer')return false
+              }
+              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,computer,valid,onEvent,onDisconnect,publishArtifact:scope?.publishArtifact,cleanupOnly:scope?.cleanupOnly})
           try {await this.request(record.id,'gateway.open',{connectionId:id,computer,workId:scope?.workId,cleanupOnly:scope?.cleanupOnly})} catch(error){this.connections.delete(id);void this.request(record.id,'gateway.close',{connectionId:id}).catch(()=>{});throw error}
           let closed=false
@@ -229,7 +241,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['host-computer-tools-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')

+ 2 - 2
src/server/workspaceGateway.ts

@@ -107,10 +107,10 @@ export class WorkspaceNodes {
     }
     return target
   }
-  targetForAgent(owner: string, agent: { id?:string;nodeId: string; remoteAgentId?: string;execution?:string;helperRunnerId?:string;computerEnvironmentId?:string }): GatewayTarget {
+  targetForAgent(owner: string, agent: { id?:string;nodeId: string; remoteAgentId?: string;execution?:string;computer?:string;temporaryGoalId?:string;helperRunnerId?:string;computerEnvironmentId?:string }): GatewayTarget {
     if(agent.execution==='computer'){
       if(!agent.id||agent.remoteAgentId)throw new HttpError(409,'隔离电脑需要当前服务器管理的机器人','computer_agent_required')
-      const target=this.runnerTarget?.(owner,agent.nodeId,{environmentId:agent.computerEnvironmentId??agent.id,agentId:agent.id,ownerKey:createHash('sha256').update(owner).digest('hex')})
+      const target=this.runnerTarget?.(owner,agent.nodeId,{environmentId:agent.computerEnvironmentId??agent.id,agentId:agent.id,ownerKey:createHash('sha256').update(owner).digest('hex'),...(agent.computer==='hybrid'&&!agent.temporaryGoalId?{hostAccess:true}:{})})
       if(agent.computerEnvironmentId&&this.store.require<import('./sharedComputers.js').SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId).runnerId!==target?.runner?.id)throw new HttpError(409,'共享电脑的原执行节点已变化,请先恢复原节点','shared_runner_changed')
       if(agent.helperRunnerId&&target?.runner?.id!==agent.helperRunnerId)throw new HttpError(409,'临时助手的原执行节点已变化,请创建新助手','helper_runner_changed')
       if(!target)throw new HttpError(409,'隔离电脑需要连接执行节点','computer_runner_required')

+ 1 - 1
src/server/workspaceRuntime.ts

@@ -564,7 +564,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const team=agent.canManageTeam===true&&!this.store.require<Run>(owner,'run',run.runId).assignmentId
         const cloud=!!this.cloud?.selected(owner,agent)
         const plugins=!!this.plugins?.selected(owner,agent)
-        const desktop=this.desktopEnvironments?.selected(owner,agent),desktopEpoch=this.desktopEnvironments?.epoch??''
+        const desktop=this.desktopEnvironments?.toolMode(owner,agent),desktopEpoch=this.desktopEnvironments?.epoch??''
         if(team||cloud||desktop||plugins){
           if(team){
             const granted=this.getWork(owner,run.id);granted.teamManagementRevision=agent.revision;this.saveWork(owner,granted)

+ 4 - 2
src/server/workspaceStore.ts

@@ -39,7 +39,7 @@ export const agentInput = z
     avatar: avatar.default(''),
     instructions: z.string().max(24_000).default(''),
     execution:z.enum(['profile','computer']).default('profile'),
-    computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
+    computer:z.enum(['auto','cloud','vm','hybrid','local','browser','off']).optional(),
     browserProfile:z.enum(['persistent','temporary']).optional(),
     canManageTeam: z.boolean().default(false),
     nodeId: z.string().default('local'),
@@ -52,7 +52,7 @@ export const agentPatch = z
     avatar: avatar.optional(),
     instructions: z.string().max(24_000).optional(),
     execution:z.enum(['profile','computer']).optional(),
-    computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
+    computer:z.enum(['auto','cloud','vm','hybrid','local','browser','off']).optional(),
     browserProfile:z.enum(['persistent','temporary']).optional(),
     nodeId:z.string().min(1).max(256).optional(),
     profile:z.string().min(1).max(256).optional(),
@@ -327,6 +327,7 @@ export class WorkspaceStore {
   }
   createAgent(owner: string, input: unknown, origin?: { createdByAgentId: string; createdFromRunId: string;temporaryGoalId?:string;helperActivation?:number;helperRunnerId?:string }): Agent {
     const body = parse(agentInput, input)
+    if(body.computer==='hybrid')body.execution='computer'
     if (origin) this.require<Agent>(owner, 'agent', origin.createdByAgentId)
     return this.atomic(() => {
       if (
@@ -380,6 +381,7 @@ export class WorkspaceStore {
   }
   updateAgent(owner: string, id: string, input: unknown): Agent {
     const patch = parse(agentPatch, input)
+    if(patch.computer==='hybrid')patch.execution='computer'
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)

+ 1 - 1
src/server/workspaceTeamTools.ts

@@ -11,7 +11,7 @@ import { assignmentInput, assignmentReview, assignmentUpdate, assignmentCancel,
 
 const uuid = z.string().uuid()
 const empty = z.object({}).strict()
-const createAgent = agentInput.omit({ canManageTeam: true }).extend({ requestId: uuid,execution:z.enum(['profile','computer']).optional() }).strict()
+const createAgent = agentInput.omit({ canManageTeam: true }).extend({ computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),requestId: uuid,execution:z.enum(['profile','computer']).optional() }).strict()
 const createHelper=agentInput.pick({name:true,instructions:true}).extend({requestId:uuid,goalId:uuid,profile:z.string().min(1).max(256).optional(),title:z.string().trim().min(1).max(100),brief:z.string().trim().min(1).max(16000),acceptanceCriteria:z.array(z.string().trim().min(1).max(1000)).max(12).default([]),dependsOn:z.array(uuid).max(12).default([])}).strict()
 const createTeam = z.object({
   requestId: uuid,

+ 6 - 0
src/shared/localVm.ts

@@ -1,4 +1,8 @@
 export type LocalVmMode = 'shared' | 'per-bot'
+export const DEFAULT_VM_IDLE_STOP_MINUTES = 5
+export const MAX_VM_IDLE_STOP_MINUTES = 10080
+export const VM_IDLE_STOP_OPTIONS = [1, 5, 10, 15, 30, 60, 120, 0] as const
+export function vmIdleStopLabel(minutes = DEFAULT_VM_IDLE_STOP_MINUTES) { return minutes === 0 ? '永不自动停止' : `空闲 ${minutes} 分钟后自动停止` }
 export const LOCAL_VM_IMAGE_KEYS = ['standard', 'cursor'] as const
 export type LocalVmImageKey = typeof LOCAL_VM_IMAGE_KEYS[number]
 export const LOCAL_VM_IMAGES = [
@@ -21,12 +25,14 @@ export interface LocalVmStatus {
   images?: LocalVmImageOption[]
   mode: LocalVmMode
   maxInstances: number
+  idleStopMinutes?: number
   busy: boolean
   problem?: string
   job?: { id: string; state: 'running' | 'complete' | 'failed'; message: string; imageKey?: LocalVmImageKey }
   instances?: Array<{id:string;status:string;name?:string;orphaned?:boolean}>
 }
 export interface LocalVmInstance {
+  idleStopMinutes?: number
   desktopId?:string
   fixedCapacity?:boolean
   desktops?:Array<{id:string;name:string;online:boolean;ready:boolean;available?:boolean;imageKey?:LocalVmImageKey}>

+ 1 - 1
src/shared/workspace.ts

@@ -6,7 +6,7 @@ export interface WorkspaceAgent {
   instructions: string
   /** Explicit account-owner grant; absent on older records means disabled. */
   execution?: 'profile' | 'computer'
-  computer?: 'auto' | 'cloud' | 'vm' | 'local' | 'browser' | 'off'
+  computer?: 'auto' | 'cloud' | 'vm' | 'hybrid' | 'local' | 'browser' | 'off'
   browserProfile?: 'persistent' | 'temporary'
   computerEnvironmentId?:string
   computerEnvironmentName?:string

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

@@ -8,6 +8,50 @@ const images=[{key:'standard',name:'标准桌面',description:'XFCE',ready:true,
 const agent={id:'image-fixture',name:'镜像测试',computer:'vm',execution:'computer',nodeId:'local',profile:'default'} as any
 const vm={configured:true,runtime:'docker',daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false,images}
 afterEach(()=>{vi.clearAllMocks();vi.restoreAllMocks()})
+it('saves never-stop and restores the selected value after refresh',async()=>{
+ let idleStopMinutes=5
+ vi.mocked(apiRequest).mockImplementation(async(path,options:any)=>{
+  if(path.endsWith('/idle-policy'))idleStopMinutes=options.body.idleStopMinutes
+  return path==='/api/app/computers'?{computers:[]}:{...vm,idleStopMinutes} as any
+ })
+ const wrapper=mount(Settings,{global:{stubs:{AppIcon:true,RunnerSettingsPanel:true}}})
+ try{
+  await flushPromises();await wrapper.get('[aria-label="虚拟机空闲停止时间"]').setValue('0');await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/admin/local-vm/idle-policy',expect.objectContaining({body:expect.objectContaining({idleStopMinutes:0})}))
+  expect((wrapper.get('select[aria-label="虚拟机空闲停止时间"]').element as HTMLSelectElement).value).toBe('0')
+  expect(wrapper.text()).toContain('空闲时保持运行')
+ }finally{wrapper.unmount()}
+})
+it('offers hybrid mode without requiring native screen permissions for host file tools',async()=>{
+ vi.spyOn(document,'hidden','get').mockReturnValue(false)
+ vi.mocked(apiRequest).mockImplementation(async path=>{
+  if(path.endsWith('/desktop-environment'))return {selected:null,local:{supported:false},browser:{available:false}} as any
+  if(path.endsWith('/cloud-computer'))return {configured:false} as any
+  return {...vm,enabled:true,container:'missing',ready:false,inUse:false} as any
+ })
+ const wrapper=mount(Panel,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs:{AppIcon:true,GrokAuthPanel:true}}})
+ try{
+  await flushPromises();await wrapper.get('[role="tab"][aria-label="本机 + 虚拟机"]').trigger('click');await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{computer:'hybrid'}})
+ }finally{wrapper.unmount()}
+})
+it('keeps Compose lifecycle controls hidden while enabling host tools for an already assigned desktop',async()=>{
+ vi.spyOn(document,'hidden','get').mockReturnValue(false)
+ vi.mocked(apiRequest).mockImplementation(async path=>{
+  if(path==='/api/app/computers')return {computers:[]} as any
+  if(path.endsWith('/desktop-environment'))return {selected:null,local:{supported:false},browser:{available:false}} as any
+  if(path.endsWith('/cloud-computer'))return {configured:false} as any
+  return {...vm,fixedCapacity:true,desktopId:'desktop',desktops:[{id:'desktop',name:'已有桌面',ready:true}],enabled:true,container:'running',ready:false,inUse:false} as any
+ })
+ const settings=mount(Settings,{global:{stubs:{AppIcon:true,RunnerSettingsPanel:true}}})
+ const wrapper=mount(Panel,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs:{AppIcon:true,GrokAuthPanel:true}}})
+ try{
+  await flushPromises();expect(settings.find('[aria-label="虚拟机空闲停止时间"]').exists()).toBe(false)
+  expect(wrapper.findAll('button').some(b=>b.text()==='停止虚拟机')).toBe(false)
+  await wrapper.get('input[type="checkbox"]').setValue(true);await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{computer:'hybrid'}})
+ }finally{settings.unmount();wrapper.unmount()}
+})
 it('prepares the selected image in Settings and explains the shared image constraint',async()=>{
  vi.mocked(apiRequest).mockImplementation(async path=>path==='/api/app/computers'?{computers:[]}:vm as any)
  const wrapper=mount(Settings,{global:{stubs:{AppIcon:true,RunnerSettingsPanel:true}}})

+ 24 - 0
tests/e2e/team-tools.spec.ts

@@ -205,6 +205,30 @@ async function login(page:any,ready=false){
  await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
  await page.request.post('/__test/local-vm'+(ready?'?ready=1':''),{data:{}})
 }
+test('persists idle-stop settings and selects both host and VM on desktop and mobile',async({page},testInfo)=>{
+ await login(page,true)
+ await page.getByRole('button',{name:'设置与模式',exact:true}).first().click();await page.getByRole('menuitem',{name:'设置',exact:true}).click()
+ const settings=page.getByRole('dialog',{name:'设置中心'});await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
+ const timeout=settings.getByRole('combobox',{name:'虚拟机空闲停止时间',exact:true})
+ await timeout.selectOption('30');await expect(timeout).toHaveValue('30')
+ await timeout.selectOption('0');await expect(settings).toContainText('空闲时保持运行')
+ expect((await(await page.request.get('/api/app/admin/local-vm')).json()).idleStopMinutes).toBe(0)
+ await timeout.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('idle-stop-settings.png')})
+ await settings.getByRole('button',{name:'关闭设置中心',exact:true}).click()
+ await page.request.post('/__test/hybrid-runner',{data:{}})
+ const seed=await(await page.request.post('/__test/computer?off=1',{data:{}})).json()
+ await page.goto(`/conversations/${seed.conversationId}`);await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click()
+ const dock=page.getByRole('complementary',{name:'机器人电脑面板'}),hybrid=dock.getByRole('tab',{name:'本机 + 虚拟机',exact:true})
+ await hybrid.click();await expect(hybrid).toHaveAttribute('aria-selected','true')
+ expect((await(await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===seed.agentId)).toMatchObject({computer:'hybrid',execution:'computer'})
+ await expect(dock).toContainText('永不自动停止')
+ await hybrid.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('hybrid-desktop.png')})
+ await page.reload();await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click();await expect(hybrid).toHaveAttribute('aria-selected','true')
+ await page.setViewportSize({width:390,height:844});await hybrid.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('hybrid-mobile.png')})
+ expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
+ await dock.getByRole('tab',{name:'本地虚拟机',exact:true}).click();await expect(hybrid).toHaveAttribute('aria-selected','false')
+ expect((await(await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===seed.agentId)).toMatchObject({computer:'vm',execution:'computer'})
+})
 
 test('Local VM settings replace the image catalog and prepare the managed desktop',async({page},testInfo)=>{
  await login(page)

+ 4 - 2
tests/fixtures/workspace-server.ts

@@ -409,7 +409,7 @@ runtime.runners.computerRunner=(owner,agent)=>computerFixtures.has(agent.id)?{id
 runtime.runners.computer=async(owner,agent,op,data,authorize)=>{
   const state=computerFixtures.get(agent.id);if(!state)return originalComputer(owner,agent,op,data,authorize)
   authorize()
-  if(op==='detail')return {enabled:agent.execution==='computer',container:state.mode==='off'?'missing':state.mode==='stopped'?'stopped':'running',ready:!['off','stopped'].includes(state.mode),inUse:state.mode==='human',controlMode:state.mode,image:vmFixture.image,mode:agent.computerEnvironmentId?'shared':'per-bot',maxInstances:vmFixture.maxInstances}
+  if(op==='detail')return {enabled:agent.execution==='computer',container:state.mode==='off'?'missing':state.mode==='stopped'?'stopped':'running',ready:!['off','stopped'].includes(state.mode),inUse:state.mode==='human',controlMode:state.mode,image:vmFixture.image,mode:agent.computerEnvironmentId?'shared':'per-bot',maxInstances:vmFixture.maxInstances,idleStopMinutes:vmFixture.idleStopMinutes}
   if(op==='lifecycle'){state.mode=data.action==='remove'?'off':data.action==='stop'?'stopped':'idle';state.generation++;return {ok:true}}
   if(op==='frame'){const png=readFileSync(process.env.YAOYAO_VM_PREVIEW_PNG||resolve('public/icons/icon-512.png'));return {id:'22222222-2222-4222-8222-222222222222',generation:state.generation,width:png.readUInt32BE(16),height:png.readUInt32BE(20),capturedAt:Date.now(),data:png.toString('base64')}}
   if(op==='take'){state.controlId=String(data.controlId);state.mode='human';state.generation++}
@@ -433,7 +433,7 @@ runtime.app.use((ctx,next)=>{
   }
   ctx.body={actions:[...computerFixtures.values()].flatMap(state=>state.actions)}
 })
-const vmFixture={configured:true,runtime:'docker',daemonUp:true,image:false,mode:'per-bot',maxInstances:2,busy:false,job:undefined as any}
+const vmFixture={configured:true,runtime:'docker',daemonUp:true,image:false,mode:'per-bot',maxInstances:2,idleStopMinutes:5,busy:false,job:undefined as any}
 let vmRunnerId=''
 const originalSummary=runtime.runners.summary.bind(runtime.runners),originalLocalVm=runtime.runners.localVm.bind(runtime.runners)
 runtime.runners.summary=record=>record.id===vmRunnerId?{...originalSummary(record),online:true,features:['computer-worker-v1','computer-control-v1','local-vm-v1','image-ready-v1']}:originalSummary(record)
@@ -441,9 +441,11 @@ runtime.runners.localVm=async(owner,id,p)=>{
  if(id!==vmRunnerId)return originalLocalVm(owner,id,p)
  if(p.op==='prepare'){vmFixture.image=true;vmFixture.job={id:p.id,state:'complete',message:'本地虚拟机已就绪'}}
  if(p.op==='policy'){vmFixture.mode=String(p.mode);vmFixture.maxInstances=Number(p.maxInstances)}
+ if(p.op==='idle-policy')vmFixture.idleStopMinutes=Number(p.idleStopMinutes)
  return {...vmFixture}
 }
 runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/local-vm')return next();const owner=auth.requireAdmin(ctx).id;vmFixture.image=ctx.query.ready==='1';vmFixture.mode='per-bot';vmFixture.maxInstances=2;if(!vmRunnerId){for(const record of runtime.runners.records())if(record.sourceNodeId==='local'&&record.enabled)runtime.runners.remove(record.id);vmRunnerId=runtime.runners.enroll(owner,{name:'本机虚拟机',allowedProfiles:['default']}).runner.id;}ctx.body={runnerId:vmRunnerId}})
+runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/hybrid-runner')return next();auth.requireAdmin(ctx);(runtime.runners as any).online.set(vmRunnerId,{instance:randomUUID(),epoch:'fixture',seen:Date.now(),features:['computer-worker-v1','computer-control-v1','local-vm-v1','image-ready-v1','host-computer-tools-v1','idle-stop-policy-v1']});ctx.body={ok:true}})
 runtime.app.use(serve(resolve('dist')))
 runtime.app.use((ctx) => {
   if (!ctx.path.startsWith('/api/')) {

+ 52 - 0
tests/live/dualEnvironment.live.test.ts

@@ -0,0 +1,52 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {createServer} from 'node:http'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtemp,writeFile,readFile,rm} from 'node:fs/promises'
+import {join,resolve} from 'node:path'
+import {tmpdir} from 'node:os'
+import {randomUUID} from 'node:crypto'
+import {ComputerRuntime,ComputerGateway} from '../../src/runner/worker/gateway'
+
+it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)('uses host and VM tools in one real Worker turn and copies binary files in both directions',async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-dual-live-')),db=new DatabaseSync(':memory:'),proof=randomUUID()
+ const source=join(home,'host-source.bin'),returned=join(home,'returned.bin'),bytes=Buffer.alloc(9*1024*1024,0xa5)
+ await writeFile(source,bytes);await writeFile(join(home,'host-proof.txt'),proof)
+ const requests:any[]=[],toolResults:any[]=[]
+ const steps=[
+  ['host_read_file',{path:'host-proof.txt'}],
+  ['host_shell',{command:'uname -s; pwd'}],
+  ['computer_copy_file',{from:'host',source:'host-source.bin',destination:'import.bin'}],
+  ['computer_shell',{command:`uname -s; test ! -e '${source}'; wc -c < import.bin`}],
+  ['computer_copy_file',{from:'vm',source:'import.bin',destination:'returned.bin'}],
+ ] as const
+ const server=createServer(async(req,res)=>{
+  if(req.method==='GET'){res.setHeader('content-type','application/json');res.end(JSON.stringify({data:[{id:'fixture-model',context_length:65536}]}));return}
+  let body='';for await(const chunk of req)body+=chunk;const input=JSON.parse(body);requests.push(input)
+  const results=input.messages.filter((m:any)=>m.role==='tool');toolResults.push(...results)
+  const step=steps[results.length]
+  const message=step?{role:'assistant',content:null,tool_calls:[{id:'dual-'+results.length,type:'function',function:{name:step[0],arguments:JSON.stringify(step[1])}}]}:{role:'assistant',content:'双环境完成'}
+  if(input.stream){res.setHeader('content-type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((t:any,index:number)=>({...t,index}))}:{})},finish_reason:step?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')}
+  else{res.setHeader('content-type','application/json');res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:step?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))}
+ })
+ await new Promise<void>(done=>server.listen(0,'127.0.0.1',done))
+ const base=`http://127.0.0.1:${(server.address() as {port:number}).port}`
+ const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:base,hermesURL:base,token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python:process.env.YAOYAO_HERMES_PYTHON!,hermesSource:process.env.YAOYAO_HERMES_SOURCE!,hermesHome:home,network:'none'}},home,resolve('src/runner/worker/hermes_worker.py'))
+ const id=randomUUID(),gateway=new ComputerGateway(runtime,{agentId:id,environmentId:id,ownerKey:'dual-fixture',hostAccess:true},randomUUID(),async()=>{},async()=>({}))
+ const completed=new Promise<any>((done,reject)=>{gateway.onEvent=frame=>{if(frame.type==='message.complete')frame.payload.status==='complete'?done(frame.payload):reject(new Error(JSON.stringify(frame)))};gateway.onDisconnect=()=>reject(new Error('Worker disconnected'))})
+ try{
+  await runtime.ready
+  vi.spyOn(runtime,'resolve').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:home,model:{model:'fixture-model',provider:'custom',api_mode:'chat_completions',base_url:base+'/v1',api_key:'fixture'}})
+  await gateway.connect();const session=await gateway.rpc('session.create',{profile:'default'})
+  await gateway.rpc('prompt.submit',{session_id:session.session_id,text:'依次执行本机读取、本机命令、文件复制、虚拟机命令,并把结果复制回本机。'})
+  expect((await completed).text).toBe('双环境完成')
+  expect(await readFile(returned)).toEqual(bytes)
+  expect(toolResults.some(m=>String(m.content).includes(proof))).toBe(true)
+  expect(toolResults.some(m=>String(m.content).includes('Linux')&&String(m.content).includes(String(bytes.length)))).toBe(true)
+  expect(toolResults.some(m=>String(m.content).includes(process.platform==='darwin'?'Darwin':'Linux')&&String(m.content).includes(home))).toBe(true)
+  for(const req of requests){expect(req.tools.map((t:any)=>t.function.name)).toEqual(expect.arrayContaining(['host_shell','host_read_file','computer_shell','computer_copy_file']));expect(JSON.stringify(req.messages.filter((m:any)=>m.role==='system'))).toContain('separate files')}
+ }finally{
+  await gateway.close();await runtime.controls.close();await runtime.pool.close();db.close();vi.restoreAllMocks()
+  await new Promise<void>(done=>{server.close(()=>done());server.closeAllConnections()});await rm(home,{recursive:true,force:true})
+ }
+},120000)

+ 14 - 0
tests/server/computerPool.test.ts

@@ -7,6 +7,20 @@ import {ContainerComputerProvider,type ComputerProvider,type ComputerSpecificati
 const databases:DatabaseSync[]=[]
 afterEach(()=>{for(const db of databases.splice(0))db.close()})
 const spec=():ComputerSpecification=>({id:randomUUID(),ownerKey:'owner',imageId:`sha256:${'a'.repeat(64)}`})
+it('uses the configured idle deadline, resets it after reuse, and never exempts active leases from expiry',async()=>{
+  const f=fixture();await f.pool.recover();const resource=spec()
+  f.pool.idleStopMinutes=15
+  await f.pool.desktop(resource,'start',()=>{})
+  f.advance(5*60000);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('idle')
+  const lease=await f.pool.acquire(resource,'next',()=>{});await f.pool.release(lease,true)
+  f.advance(15*60000-1);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('idle')
+  f.advance(1);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('free')
+  f.pool.idleStopMinutes=0;await f.pool.desktop(resource,'start',()=>{})
+  f.advance(30*24*60*60000);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('idle')
+  await f.pool.acquire(resource,'expired-holder',()=>{});f.advance(30001);await f.pool.expire()
+  expect(f.pool.status('owner')[0]?.status).toBe('free')
+  await f.pool.desktop(resource,'start',()=>{});await f.pool.close();expect(f.pool.status('owner')[0]?.status).toBe('free')
+})
 function fixture(limits={concurrent:2,environments:32,ttlMs:30000}){
   let clock=100000
   const db=new DatabaseSync(':memory:');databases.push(db)

+ 9 - 0
tests/server/desktopEnvironments.test.ts

@@ -20,6 +20,15 @@ beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'desktop-env-'));store=new Worksp
 afterEach(()=>{clearInterval(poller);service.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
 const app=()=>{const a=new Koa();a.use(async(ctx,next)=>{try{await next()}catch(e:any){ctx.status=e.status??500;ctx.body={error:e.message,code:e.code}}});a.use(bodyParser());a.use(service.router().routes());return a.callback()}
 const base=()=>'/api/app/agents/'+agent.id
+it('adds an authorized native desktop to hybrid tools while leaving the default viewer on the VM',async()=>{
+ agent=store.updateAgent('owner',agent.id,{computer:'hybrid'})
+ expect(agent.execution).toBe('computer');expect(service.toolMode('owner',agent)).toBeUndefined()
+ drive();expect(service.selected('owner',agent)).toBeUndefined();expect(service.toolMode('owner',agent)).toBe('local')
+ const result=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch)
+ expect(result.content[0].type).toBe('image')
+ agent=store.updateAgent('owner',agent.id,{computer:'vm'});expect(service.toolMode('owner',agent)).toBeUndefined()
+ await expect(service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch)).rejects.toMatchObject({code:'computer_control_expired'})
+})
 function drive(handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('fixture frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.exchange({host,results});poller=setInterval(()=>{const value=service.exchange({host,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5)}
 async function take(){return (await request(app()).post(base()+'/computer/take').send({requestId:randomUUID()}).expect(200)).body}
 async function frame(){return (await request(app()).get(base()+'/computer/frame').expect(200)).body}

+ 64 - 0
tests/server/hostComputerTools.test.ts

@@ -0,0 +1,64 @@
+// @vitest-environment node
+import {expect,it,vi} from 'vitest'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtemp,writeFile,readFile,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
+import {hostTool} from '../../src/runner/worker/hostTools'
+
+it('runs host commands and files in the explicitly selected working directory and interrupts child processes',async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-host-tools-')),controller=new AbortController()
+ const context={cwd:home,signal:controller.signal,authorize:()=>{}}
+ try{
+  await hostTool('host_write_file',{path:'nested/source.txt',content:'host-proof'},context)
+  expect(await hostTool('host_read_file',{path:'nested/source.txt'},context)).toEqual({stdout:'host-proof'})
+  expect(await hostTool('host_shell',{command:'pwd; cat nested/source.txt'},context)).toMatchObject({exitCode:0,stdout:expect.stringContaining('host-proof')})
+  const running=hostTool('host_shell',{command:'sleep 1; printf escaped > late.txt'},context)
+  setTimeout(()=>controller.abort(),50);await expect(running).rejects.toThrow('中断')
+  await new Promise(resolve=>setTimeout(resolve,1100));await expect(readFile(join(home,'late.txt'))).rejects.toMatchObject({code:'ENOENT'})
+  await expect(hostTool('host_read_file',{path:'nested/source.txt'},context)).rejects.toThrow()
+ }finally{await rm(home,{recursive:true,force:true})}
+})
+
+it('exposes host tools only with an explicit hybrid grant and removes access on the next VM-only turn',async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-hybrid-gateway-')),db=new DatabaseSync(':memory:'),script=join(home,'probe.py')
+ await writeFile(script,`import json,sys
+boot=json.loads(sys.stdin.readline())
+print(json.dumps({"nonce":boot["nonce"],"type":"probe","boot":boot}),flush=True)
+for line in sys.stdin:
+    if json.loads(line).get("type")=="interrupt": break
+`)
+ const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'python3',hermesSource:home,hermesHome:home,network:'none'}},home,script)
+ const id=randomUUID(),gateways:ComputerGateway[]=[]
+ let allowed=true,sessionId:string|undefined
+ try{
+  await runtime.ready;await writeFile(join(home,'private.txt'),'explicit-host-proof')
+  vi.spyOn(runtime,'resolve').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:home,model:{provider:'custom',api_mode:'chat_completions',model:'fixture'}})
+  vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id,containerId:'fixture',running:true,workspace:home,isolation:'container'})
+  vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
+  const execute=vi.spyOn(runtime.provider,'execute').mockResolvedValue({stdout:'vm-proof',stderr:''})
+  for(const hostAccess of [false,true,false]){
+   const gateway=new ComputerGateway(runtime,{environmentId:id,agentId:id,ownerKey:'owner',...(hostAccess?{hostAccess:true}:{})},randomUUID(),async()=>{if(!allowed)throw new Error('revoked')},async()=>({}))
+   gateways.push(gateway);await gateway.connect()
+   const opened=await gateway.rpc(sessionId?'session.resume':'session.create',{profile:'default',...(sessionId?{session_id:sessionId}:{})});sessionId=opened.session_id
+   await gateway.rpc('prompt.submit',{session_id:sessionId,text:'exercise both environments'})
+   const worker=runtime.workers.get(gateway.workId)!.process,probe=await worker.wait('probe',5000),names=probe.boot.tools.map((tool:any)=>tool.name)
+   expect(names.includes('host_shell')).toBe(hostAccess);expect(names.includes('computer_copy_file')).toBe(hostAccess)
+   if(hostAccess){
+    expect(probe.boot.host.cwd).toBe(home)
+    expect(await worker.onTool('host_read_file',{path:'private.txt'},randomUUID())).toEqual({stdout:'explicit-host-proof'})
+    await worker.onTool('computer_copy_file',{from:'host',source:'private.txt',destination:'import.txt'},randomUUID())
+    expect(execute).toHaveBeenLastCalledWith(expect.anything(),expect.arrayContaining(['import.txt']),expect.objectContaining({input:Buffer.from('explicit-host-proof')}))
+   }else{
+    expect(probe.boot.host).toBeUndefined()
+    await expect(worker.onTool('host_read_file',{path:join(home,'private.txt')},randomUUID())).rejects.toMatchObject({code:'computer_tool_forbidden'})
+    await expect(worker.onTool('computer_copy_file',{from:'host',source:join(home,'private.txt'),destination:'bad.txt'},randomUUID())).rejects.toMatchObject({code:'computer_tool_forbidden'})
+   }
+   expect(await worker.onTool('computer_shell',{command:'pwd'},randomUUID())).toMatchObject({stdout:'vm-proof'})
+   if(!hostAccess&&gateways.length===3){allowed=false;await expect(worker.onTool('computer_shell',{command:'no'},randomUUID())).rejects.toThrow('revoked')}
+   await gateway.close()
+  }
+ }finally{await Promise.all(gateways.map(g=>g.close()));await runtime.controls.close();await runtime.pool.close();db.close();vi.restoreAllMocks();await rm(home,{recursive:true,force:true})}
+})

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

@@ -23,6 +23,19 @@ async function fixture(){
  vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
  return {db,runtime,running,async close(){await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}}
 }
+it('defaults older settings to five minutes and persists never-stop independently of sharing policy',async()=>{
+ const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db)
+ try{
+  await manager.ready;expect((await manager.status()).idleStopMinutes).toBe(5)
+  await manager.idlePolicy(0);await manager.policy('shared',3)
+  expect(f.runtime.pool.idleStopMinutes).toBe(0)
+  expect(JSON.parse(String(f.db.prepare('SELECT value FROM local_vm_runtime').get()?.value)).idleStopMinutes).toBe(0)
+  for(const minutes of [-1,1.5,10081,NaN])await expect(manager.idlePolicy(minutes)).rejects.toMatchObject({code:'local_vm_idle_policy_invalid'})
+  await manager.close();const restored=new LocalVmImages(f.runtime,f.db)
+  await restored.ready;expect((await restored.status()).idleStopMinutes).toBe(0)
+  await restored.idlePolicy(30);expect(f.runtime.pool.idleStopMinutes).toBe(30);await restored.close()
+ }finally{await manager.close();await f.close()}
+})
 it('prepares one managed image, verifies the desktop, applies it and persists it without a catalog step',async()=>{
  const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),imageId='sha256:'+'a'.repeat(64)
  try{

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

@@ -28,6 +28,10 @@ it('protects preparation, removes the old catalog API and scopes sharing to the
   store.put('alice','turn','ordinary-chat',{agentId:chatting.id,status:'running'})
   const other=store.createAgent('bob',{name:'其他账号',profile:'default',execution:'computer'})
   const id=randomUUID(),base='/api/app/admin/local-vm'
+  await request(app.callback()).put(base+'/idle-policy').send({requestId:randomUUID(),idleStopMinutes:0}).expect(403)
+  for(const minutes of [-1,0.5,10081,null])await request(app.callback()).put(base+'/idle-policy').set('x-admin','yes').send({requestId:randomUUID(),idleStopMinutes:minutes}).expect(400)
+  await request(app.callback()).put(base+'/idle-policy').set('x-admin','yes').send({requestId:randomUUID(),idleStopMinutes:0}).expect(200)
+  expect(hub.localVm).toHaveBeenLastCalledWith('alice',runner.id,expect.objectContaining({op:'idle-policy',idleStopMinutes:0}))
   await request(app.callback()).post(base+'/prepare').send({requestId:id}).expect(403)
   await request(app.callback()).post(base+'/prepare').set('x-admin','yes').send({requestId:id,imageId:'arbitrary'}).expect(400)
   await request(app.callback()).post(base+'/prepare').set('x-admin','yes').send({requestId:id}).expect(200)

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

@@ -72,6 +72,10 @@ async function channel(){
   await gateway.connect();const session=await gateway.rpc('session.create',{profile:'default'})
   return {gateway,session}
 }
+it('requires an upgraded Runner before granting mixed host and VM tools',()=>{
+ const id=randomUUID()
+ expect(()=>hub.target('owner','local',{environmentId:id,agentId:id,ownerKey:'owner',hostAccess:true})).toThrow('不支持同时使用本机和虚拟机')
+})
 async function machine(path:string,body?:unknown,overrides:Record<string,string>={}){
   return fetch(`${config.serverURL}/api/runner/v1/${config.runnerId}/${path}`,{
     method:body===undefined?'GET':'POST',headers:{Authorization:`Bearer ${config.token}`,'x-runner-instance':runner.instance,'x-runner-protocol':'1','x-runner-epoch':(runner as any).serverEpoch,'Content-Type':'application/json',...overrides},...(body===undefined?{}:{body:JSON.stringify(body)})

+ 16 - 0
tests/server/workspacePanels.test.ts

@@ -112,6 +112,22 @@ function fakeCloud(){
  const token='header.'+Buffer.from(JSON.stringify({sub:'fixture',exp:Math.floor(Date.now()/1000)+3600})).toString('base64url')+'.signature'
  return {cloud,token,fetcher,counts:()=>({ensure,exec})}
 }
+it('persists hybrid selection with an explicit Runner grant and refuses changes during active work',async()=>{
+ const {cloud}=fakeCloud(),agent=store.createAgent('owner',{name:'双环境',profile:'default',computer:'vm',execution:'computer'})
+ const target=vi.fn(()=>({runner:{id:'fixture'}} as any));nodes.runnerTarget=target
+ const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(error:any){ctx.status=error.status??500;ctx.body={error:error.message}}});app.use(bodyParser());app.use(cloud.router().routes())
+ const path=`/api/app/agents/${agent.id}/computer-selection`
+ const response=await request(app.callback()).put(path).send({computer:'hybrid'}).expect(200)
+ expect(response.body.agent).toMatchObject({computer:'hybrid',execution:'computer'})
+ expect(target).toHaveBeenCalledWith('owner','local',expect.objectContaining({agentId:agent.id,hostAccess:true}))
+ await request(app.callback()).put(path).set('x-user','other').send({computer:'hybrid'}).expect(404)
+ store.put('owner','turn','active-hybrid',{agentId:agent.id,status:'running'})
+ await request(app.callback()).put(path).send({computer:'vm'}).expect(409)
+ expect(store.require<any>('owner','agent',agent.id).computer).toBe('hybrid')
+ store.remove('owner','turn','active-hybrid');await request(app.callback()).put(path).send({computer:'vm'}).expect(200)
+ target.mockClear();nodes.targetForAgent('owner',store.require<any>('owner','agent',agent.id))
+ expect(target.mock.calls[0]?.[2]).not.toHaveProperty('hostAccess')
+})
 
 it('auto observation never provisions; multiple robots share one cloud connection with private credentials',async()=>{
  const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')