Преглед на файлове

新增本地虚拟机的本机协作执行模式

Samien преди 1 месец
родител
ревизия
2411d98b14

+ 16 - 1
docs/local-vm.md

@@ -17,7 +17,22 @@
 
 
 ## 同一 Bot 使用本机和虚拟机
 ## 同一 Bot 使用本机和虚拟机
 
 
-Web/桌面端的 Bot 电脑面板在选择 **本地虚拟机** 或 **云端 · Grok Bot** 后显示独立的 **允许本机环境** 复选框。勾选不会改变所选虚拟机类型;切换到自动、本机、仅浏览器或不使用电脑时自动清除该选项,切回虚拟机也不会重新勾选。
+选择本地虚拟机后,电脑面板提供独立的 **执行方式**:
+
+- **虚拟机模式**:保留原有独立 Worker。可选的“允许本机环境”继续提供本机文件、命令与复制工具。
+- **本机协作模式**:复用云端电脑使用的原生 Profile 会话和工具桥,保留基础 Profile 的本机工具、配置和上下文,同时加入本地虚拟机工具。`computer_*` 在虚拟机中执行,本机操作使用 Profile 已授权的原生工具;`computer_copy_file` 显式复制两个环境的文件,`computer_export` 回传虚拟机产物。此模式本身包含本机环境能力,不再显示重复的权限复选框。
+
+执行方式按 Bot 保存,旧记录默认使用虚拟机模式,不自动迁移。切换执行方式后创建对应的新执行会话,并携带近期聊天记录;虚拟机工作目录、镜像和共享成员不变。任务或人工接管期间不能切换。Bot 创建的成员、临时助手不继承本机协作模式;切换到其他电脑类型或返回虚拟机模式时清除本机协作授权。
+
+本机协作模式需要支持 `profile-computer-v1` 的配套 Runner,以及当前 Profile 已启用的 `yaoyao-bot-bridge`。使用与云端模式相同的原生工具桥绑定,不启动隔离模型 Worker。人工接管会请求原生 Profile 停止,确认其空闲且在途虚拟机操作结束后才转交控制权;交还后在原 Profile 会话继续。会话压缩产生的新存储编号在 Runner 中保存,重连继续读取正确的 Profile 历史。
+
+Compose 部署同样可为已选择的桌面启用本机协作模式,仍由 Compose 管理桌面数量和生命周期。所有模式沿用原有虚拟机网络策略,不增加网站发布或端口转发。
+
+兼容旧客户端:取消“允许本机环境”会同时退出本机协作模式,恢复虚拟机模式;不会忽略撤销请求。`PUT /api/app/agents/:id/computer-selection` 新增可选 `vmExecution: worker | profile`,未传入时保留原选择。显式同时提交 `profile` 和 `allowHostEnvironment: false` 会拒绝冲突配置。
+
+2026-09-14 验证:1119 项服务端及客户端回归通过;新增模式、旧模式本机选项、云端本机选项共 3 项浏览器流程通过(含浅色桌面、深色 390px 页面及刷新恢复)。独立临时 Hermes 配置和真实 Docker 桌面验证了原生终端、虚拟机命令、虚拟机文件读取、Profile 项目上下文与完整回复。模型响应由确定性的本机测试服务提供,不依赖外部模型;测试不使用用户会话。协议测试另覆盖会话编号更新、跨模式会话拒绝、文件复制、团队工具、接管等待与交还继续。
+
+Web/桌面端的 Bot 电脑面板在选择 **本地虚拟机的虚拟机模式** 或 **云端 · Grok Bot** 后显示独立的 **允许本机环境** 复选框。勾选不会改变所选虚拟机类型;切换到自动、本机、仅浏览器或不使用电脑时自动清除该选项,切回虚拟机也不会重新勾选。
 
 
 - 本地虚拟机勾选后,`host_shell`、`host_read_file`、`host_write_file` 在 **Hermes 执行节点所在主机**运行,可操作该主机用户有权访问的文件和程序。默认本机工作目录采用 Profile 的显式目录;`.`、`auto`、`cwd` 使用执行节点用户的主目录。
 - 本地虚拟机勾选后,`host_shell`、`host_read_file`、`host_write_file` 在 **Hermes 执行节点所在主机**运行,可操作该主机用户有权访问的文件和程序。默认本机工作目录采用 Profile 的显式目录;`.`、`auto`、`cwd` 使用执行节点用户的主目录。
 - `computer_shell`、`computer_read_file`、`computer_write_file`、桌面操作等继续在隔离 Linux 环境内执行。虚拟机不会因为启用双环境而挂载本机项目目录。
 - `computer_shell`、`computer_read_file`、`computer_write_file`、桌面操作等继续在隔离 Linux 环境内执行。虚拟机不会因为启用双环境而挂载本机项目目录。

Файловите разлики са ограничени, защото са твърде много
+ 13 - 2
src/client/components/workspace/LocalVmChatPanel.vue


+ 8 - 3
src/runner/agent.ts

@@ -55,7 +55,7 @@ export class RunnerAgent {
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:25000)]),
       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?'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'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'profile-computer-v1,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)}),
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     })
     if(!response.ok){
     if(!response.ok){
@@ -146,7 +146,7 @@ export class RunnerAgent {
     }
     }
     if(command.kind==='http') {
     if(command.kind==='http') {
       const path=String(p.path),search=new URLSearchParams(String(p.search??''))
       const path=String(p.path),search=new URLSearchParams(String(p.search??''))
-      if(path==='/api/computer/capabilities'||(p.computer&&path==='/api/plugins/yaoyao-bot-bridge/capabilities')){
+      if(path==='/api/computer/capabilities'||(p.computer&&!(p.computer as ComputerTarget).profileSession&&path==='/api/plugins/yaoyao-bot-bridge/capabilities')){
         this.requireProfile(search.get('profile')??'default')
         this.requireProfile(search.get('profile')??'default')
         if(p.method!=='GET'||!this.computers)throw new HttpError(409,'执行节点未配置隔离电脑','computer_unavailable')
         if(p.method!=='GET'||!this.computers)throw new HttpError(409,'执行节点未配置隔离电脑','computer_unavailable')
         await Promise.all([access(this.computers.config.python),access(this.computers.config.hermesSource),access(this.computers.script)])
         await Promise.all([access(this.computers.config.python),access(this.computers.config.hermesSource),access(this.computers.script)])
@@ -156,6 +156,11 @@ export class RunnerAgent {
       if(p.computer&&/^\/api\/sessions\/[^/]+\/messages$/.test(path)){
       if(p.computer&&/^\/api\/sessions\/[^/]+\/messages$/.test(path)){
         if(p.method!=='GET'||!this.computers)throw new HttpError(403,'电脑历史不可用','computer_unavailable')
         if(p.method!=='GET'||!this.computers)throw new HttpError(403,'电脑历史不可用','computer_unavailable')
         const profile=this.requireProfile(search.get('profile')??'default'),session=this.computers.session(decodeURIComponent(path.split('/')[3]!),p.computer as ComputerTarget,profile)
         const profile=this.requireProfile(search.get('profile')??'default'),session=this.computers.session(decodeURIComponent(path.split('/')[3]!),p.computer as ComputerTarget,profile)
+        if(session.vmExecution==='profile'){
+          if(!session.profileSessionId)throw new HttpError(409,'本机 Profile 会话尚未建立','computer_profile_session_invalid')
+          const response=await this.target.session.request(`/api/sessions/${encodeURIComponent(session.profileSessionId)}/messages`,{search,maxResponseBytes:8*1024*1024})
+          return {status:response.status,headers:Object.fromEntries(response.headers.entries()),body:response.body.toString('base64')}
+        }
         const offset=Math.max(0,Number(search.get('offset')??0)),history=session.history
         const offset=Math.max(0,Number(search.get('offset')??0)),history=session.history
         return {status:200,headers:{},body:Buffer.from(JSON.stringify({messages:history.slice(Math.max(0,history.length-offset-500),history.length-offset)})).toString('base64')}
         return {status:200,headers:{},body:Buffer.from(JSON.stringify({messages:history.slice(Math.max(0,history.length-offset-500),history.length-offset)})).toString('base64')}
       }
       }
@@ -176,7 +181,7 @@ export class RunnerAgent {
       if(p.computer){
       if(p.computer){
         const meta=p.computer as ComputerTarget
         const meta=p.computer as ComputerTarget
         if(!this.computers||![meta.environmentId,meta.agentId,String(p.workId)].every(value=>/^[0-9a-f-]{36}$/.test(value))||!/^[a-f0-9]{64}$/.test(meta.ownerKey))throw new HttpError(409,'电脑任务配置或授权无效','computer_unavailable')
         if(!this.computers||![meta.environmentId,meta.agentId,String(p.workId)].every(value=>/^[0-9a-f-]{36}$/.test(value))||!/^[a-f0-9]{64}$/.test(meta.ownerKey))throw new HttpError(409,'电脑任务配置或授权无效','computer_unavailable')
-        gateway=new ComputerGateway(this.computers,meta,String(p.workId),async()=>{if(!this.active||!this.connected||(await this.api('check',{connectionId})).allowed!==true)throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked')},body=>this.api('artifact',{connectionId,...body}))
+        gateway=new ComputerGateway(this.computers,meta,String(p.workId),async()=>{if(!this.active||!this.connected||(await this.api('check',{connectionId})).allowed!==true)throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked')},body=>this.api('artifact',{connectionId,...body}),meta.profileSession?this.target:undefined)
       }else gateway=new WorkspaceGateway(this.target)
       }else gateway=new WorkspaceGateway(this.target)
       const connection:Connection={cleanupOnly:p.cleanupOnly===true,gateway,sessions:new Map(),running:new Set(),events:Promise.resolve()}
       const connection:Connection={cleanupOnly:p.cleanupOnly===true,gateway,sessions:new Map(),running:new Set(),events:Promise.resolve()}
       this.connections.set(connectionId,connection)
       this.connections.set(connectionId,connection)

+ 76 - 11
src/runner/worker/gateway.ts

@@ -6,7 +6,8 @@ import {existsSync} from 'node:fs'
 import type {DatabaseSync} from 'node:sqlite'
 import type {DatabaseSync} from 'node:sqlite'
 import {z} from 'zod'
 import {z} from 'zod'
 import {HttpError} from '../../server/errors.js'
 import {HttpError} from '../../server/errors.js'
-import type {GatewayFrame} from '../../server/workspaceGateway.js'
+import type {GatewayFrame,GatewayTarget} from '../../server/workspaceGateway.js'
+import {ProfileComputerSession} from './profileSession.js'
 import {UNCONFIGURED_COMPUTER_IMAGE} from '../../shared/runner.js'
 import {UNCONFIGURED_COMPUTER_IMAGE} from '../../shared/runner.js'
 import type {RunnerConfiguration} from '../../shared/runner.js'
 import type {RunnerConfiguration} from '../../shared/runner.js'
 import {ContainerComputerProvider,COMPUTER_WORKSPACE,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
 import {ContainerComputerProvider,COMPUTER_WORKSPACE,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
@@ -19,8 +20,8 @@ import {HermesWorkerProcess,type WorkerModel,type WorkerProxyEnvironment,type Wo
 import {HOST_TOOLS,hostTool,hostPath,readHostFile,writeHostFile} from './hostTools.js'
 import {HOST_TOOLS,hostTool,hostPath,readHostFile,writeHostFile} from './hostTools.js'
 import {homedir,platform} from 'node:os'
 import {homedir,platform} from 'node:os'
 
 
-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'}
+export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string;hostAccess?:boolean;profileSession?:boolean}
+export interface WorkerSession {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain';vmExecution?:'profile';profileSessionId?:string}
 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;contextConfig?:WorkerContextConfiguration;proxyEnv?:WorkerProxyEnvironment;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
 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;contextConfig?:WorkerContextConfiguration;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})
 const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
 const text={type:'string'}
 const text={type:'string'}
@@ -33,6 +34,7 @@ const TOOLS:WorkerTool[]=[
   {name:'computer_action',description:'在隔离电脑内调用 CUA 动作;先查看桌面并使用可用动作。',inputSchema:object({name:text,arguments:{type:'object'}},['name','arguments'])},
   {name:'computer_action',description:'在隔离电脑内调用 CUA 动作;先查看桌面并使用可用动作。',inputSchema:object({name:text,arguments:{type:'object'}},['name','arguments'])},
 ]
 ]
 const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
 const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
+const PROFILE_COMPUTER_RULES='当前使用本机协作模式:沿用基础 Profile 的本机工具、配置和上下文,本地虚拟机是独立的 Linux 电脑。computer_shell/read_file/write_file/desktop_state/action/export 只操作虚拟机;本机文件、终端和程序使用 Profile 已授权的原生工具。computer_copy_file 在两个环境间显式复制文件。调用前明确目标环境,文件路径和浏览器登录互不通用;需要回传虚拟机文件时使用 computer_export。人工接管期间停止操作,交还后先检查当前状态。'
 export class ComputerRuntime {
 export class ComputerRuntime {
   retainDesktops=false
   retainDesktops=false
   readonly controls:ComputerControls
   readonly controls:ComputerControls
@@ -90,6 +92,7 @@ export class ComputerRuntime {
     const row=this.db.prepare('SELECT value FROM computer_sessions WHERE id=?').get(id) as {value:string}|undefined
     const row=this.db.prepare('SELECT value FROM computer_sessions WHERE id=?').get(id) as {value:string}|undefined
     const session:WorkerSession|undefined=row?JSON.parse(row.value):undefined
     const session:WorkerSession|undefined=row?JSON.parse(row.value):undefined
     if(!session||(session.agentId??session.environmentId)!==meta.agentId||session.ownerKey!==meta.ownerKey||session.environmentId!==meta.environmentId||session.profile!==profile)throw new HttpError(404,'电脑会话不存在或不属于当前任务','computer_session_missing')
     if(!session||(session.agentId??session.environmentId)!==meta.agentId||session.ownerKey!==meta.ownerKey||session.environmentId!==meta.environmentId||session.profile!==profile)throw new HttpError(404,'电脑会话不存在或不属于当前任务','computer_session_missing')
+    if((session.vmExecution==='profile')!==(meta.profileSession===true))throw new HttpError(409,'虚拟机执行方式已改变,请使用新会话','computer_session_mode_changed')
     return session
     return session
   }
   }
   async resolve(profile:string,signal?:AbortSignal){
   async resolve(profile:string,signal?:AbortSignal){
@@ -127,10 +130,11 @@ export class ComputerGateway {
   private timer?:ReturnType<typeof setInterval>
   private timer?:ReturnType<typeof setInterval>
   private checking=false
   private checking=false
   private hostCwd=homedir()
   private hostCwd=homedir()
+  private profileSession?:ProfileComputerSession
   private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>}
   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>){ }
+  constructor(readonly runtime:ComputerRuntime,readonly meta:ComputerTarget,readonly workId:string,readonly check:()=>Promise<void>,readonly publish:(body:Record<string,unknown>)=>Promise<any>,readonly profileTarget?:GatewayTarget){ }
   async connect(){this.runtime.assertTarget(this.meta);await this.runtime.ready;await this.authorized()}
   async connect(){this.runtime.assertTarget(this.meta);await this.runtime.ready;await this.authorized()}
-  private async authorized(){try{await this.check();this.guard()}catch(error){await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect();throw error}}
+  private async authorized(){try{await this.check();this.guard()}catch(error){const stopping=this.interrupt().catch(()=>{});if(!this.profileSession)await stopping;if(!this.closed)this.onDisconnect();throw error}}
   private guard(){if(this.closed||this.live?.controller.signal.aborted)throw new HttpError(410,'电脑任务授权已结束','computer_cancelled')}
   private guard(){if(this.closed||this.live?.controller.signal.aborted)throw new HttpError(410,'电脑任务授权已结束','computer_cancelled')}
   private event(type:string,payload:Record<string,unknown>){if(!this.closed&&this.live)this.onEvent({type,session_id:this.live.session.id,payload})}
   private event(type:string,payload:Record<string,unknown>){if(!this.closed&&this.live)this.onEvent({type,session_id:this.live.session.id,payload})}
   private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.imageFor(this.meta),cwd:session.cwd,network:this.runtime.config.network??'none'}}
   private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.imageFor(this.meta),cwd:session.cwd,network:this.runtime.config.network??'none'}}
@@ -141,12 +145,13 @@ export class ComputerGateway {
     if(method==='session.create'||method==='session.resume'){
     if(method==='session.create'||method==='session.resume'){
       if(this.live)throw new HttpError(409,'电脑通道已有会话','computer_session_busy')
       if(this.live)throw new HttpError(409,'电脑通道已有会话','computer_session_busy')
       await this.authorized()
       await this.authorized()
-      if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderId===this.workId)await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
+      if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;if(this.meta.profileSession){await this.openProfile(session,params);await this.profileSession!.stop()}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')
       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)
+      const profile=String(params.profile),resolved=await (this.meta.profileSession?this.runtime.resolveWorkspace(profile,this.controller.signal):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)}
       if(this.meta.hostAccess){const configured=String(resolved.configuredCwd??'.');this.hostCwd=['.','auto','cwd'].includes(configured)?homedir():hostPath(homedir(),configured)}
       await this.authorized();this.guard()
       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:[]}
       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:[]}
+      if(this.meta.profileSession)session.vmExecution='profile'
       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
       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
       await this.runtime.pool.configure(this.spec(session),()=>this.guard())
       await this.runtime.pool.configure(this.spec(session),()=>this.guard())
       await this.authorized()
       await this.authorized()
@@ -160,11 +165,28 @@ export class ComputerGateway {
         catch(error){await this.interrupt();throw error}
         catch(error){await this.interrupt();throw error}
       }
       }
       this.timer=setInterval(()=>{if(this.checking)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(this.live!.lease)).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).finally(()=>{this.checking=false})},5000);this.timer.unref()
       this.timer=setInterval(()=>{if(this.checking)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(this.live!.lease)).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).finally(()=>{this.checking=false})},5000);this.timer.unref()
+      if(this.meta.profileSession){
+        try{const opened=await this.openProfile(session,params);return {...opened,session_id:session.id,stored_session_id:session.id}}
+        catch(error){await this.interrupt();throw error}
+      }
       return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
       return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
     }
     }
     const live=this.live
     const live=this.live
     if(!live||params.session_id!==live.session.id)throw new HttpError(403,'电脑会话不属于当前通道','computer_session_forbidden')
     if(!live||params.session_id!==live.session.id)throw new HttpError(403,'电脑会话不属于当前通道','computer_session_forbidden')
     if(method==='session.interrupt'||method==='session.close'){await this.interrupt();return {status:'interrupted'}}
     if(method==='session.interrupt'||method==='session.close'){await this.interrupt();return {status:'interrupted'}}
+    if(this.profileSession){
+      if(method==='session.usage')return this.profileSession.rpc(method,params)
+      if(live.paused)throw new HttpError(409,'电脑正在等待人工操作','computer_paused')
+      await this.authorized()
+      if(method==='prompt.submit'){
+        if(live.running||live.received)throw new HttpError(409,'电脑会话已经提交过本轮','computer_session_busy')
+        if(typeof params.workMarker==='string'&&/^\[yaoyao-run:[0-9a-f-]{36}:[0-9a-f-]{36}\]$/.test(params.workMarker))live.workMarker=params.workMarker
+        await this.profileSession.bind();this.guard()
+        live.running=true;live.session.outcome='uncertain';this.runtime.save(live.session)
+        return this.profileSession.rpc(method,{...params,text:`${PROFILE_COMPUTER_RULES}\n${String(params.text??'')}`})
+      }
+      return this.profileSession.rpc(method,params)
+    }
     if(method==='session.usage')return {}
     if(method==='session.usage')return {}
     if(method==='session.cwd.set'){
     if(method==='session.cwd.set'){
       if(params.cwd!==live.session.cwd&&params.cwd!==live.session.configuredCwd)throw new HttpError(403,'工作目录必须采用基础 Profile 的配置','computer_cwd_forbidden')
       if(params.cwd!==live.session.cwd&&params.cwd!==live.session.configuredCwd)throw new HttpError(403,'工作目录必须采用基础 Profile 的配置','computer_cwd_forbidden')
@@ -195,6 +217,40 @@ export class ComputerGateway {
     }
     }
     throw new HttpError(409,'当前隔离 Worker 不支持此交互','computer_method_unavailable')
     throw new HttpError(409,'当前隔离 Worker 不支持此交互','computer_method_unavailable')
   }
   }
+  private async openProfile(session:WorkerSession,params:Record<string,unknown>){
+    if(!this.profileTarget)throw new HttpError(409,'缺少本机 Profile 连接,请更新执行节点','computer_profile_unavailable')
+    if(params.session_id&&!session.profileSessionId)throw new HttpError(409,'本机 Profile 会话尚未建立,请新建会话','computer_profile_session_invalid')
+    const native=new ProfileComputerSession(this.profileTarget,session.profile,this.workId,()=>this.guard(),
+      ()=>[...TOOLS,...HOST_TOOLS.filter(tool=>tool.name==='computer_copy_file'),...(this.team?.catalog??[])],
+      async(name,args)=>{
+        const live=this.live
+        if(!live||!this.active||live.paused)throw new HttpError(410,'本轮电脑工具已结束或暂停','computer_cancelled')
+        const call=this.tool(live,name,args,randomUUID())
+        live.toolCalls.add(call);void call.finally(()=>live.toolCalls.delete(call)).catch(()=>{})
+        return call
+      })
+    this.profileSession=native
+    native.onStoredId=id=>{session.profileSessionId=id;this.runtime.save(session)}
+    native.onDisconnect=()=>{void this.interrupt().finally(()=>{if(!this.closed)this.onDisconnect()}).catch(()=>{})}
+    native.onEvent=frame=>{
+      const live=this.live
+      if(!live||this.closed||live.controller.signal.aborted||live.paused||live.received)return
+      if(['message.complete','run.completed','run.failed','message.error','error'].includes(frame.type)){
+        live.received=true
+        live.finishing=this.finishProfile(live,frame)
+        void live.finishing.catch(()=>this.lost(live))
+      }else this.event(frame.type,frame.type==='session.info'?{...frame.payload,stored_session_id:session.id,session_key:session.id}:frame.payload??{})
+    }
+    return native.open(session.profileSessionId,params)
+  }
+  private async finishProfile(live:Live,frame:GatewayFrame){
+    await Promise.allSettled([...live.toolCalls]);await this.authorized()
+    const payload=frame.payload??{},success=['message.complete','run.completed'].includes(frame.type)&&!payload.error&&!['interrupted','failed','error'].includes(String(payload.status))
+    live.session.outcome=success?'complete':'failed';this.runtime.save(live.session)
+    await this.release(live,this.runtime.retainDesktops&&success);live.running=false
+    if(this.closed||live.controller.signal.aborted)return
+    this.event(frame.type,{...payload,...(payload.status==='error'?{status:'failed'}:{})})
+  }
   private async startModel(live:Live,prompt:string,history:any[]){
   private async startModel(live:Live,prompt:string,history:any[]){
       live.received=false;live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
       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))
       const directory=join(this.runtime.home,'workers',live.session.id,this.workId,String(live.segment=(live.segment??0)+1))
@@ -339,6 +395,7 @@ export class ComputerGateway {
     if(live.pauseJob)return live.pauseJob
     if(live.pauseJob)return live.pauseJob
     live.paused=true;this.event('computer.paused',{stage:'pausing'})
     live.paused=true;this.event('computer.paused',{stage:'pausing'})
     live.pauseJob=(async()=>{
     live.pauseJob=(async()=>{
+      await this.profileSession?.stop()
       await live.worker?.close()
       await live.worker?.close()
       await Promise.allSettled([...live.toolCalls])
       await Promise.allSettled([...live.toolCalls])
       authorize();this.guard()
       authorize();this.guard()
@@ -362,6 +419,11 @@ export class ComputerGateway {
     live.lease=await this.runtime.pool.transfer(live.lease,this.workId,()=>this.guard())
     live.lease=await this.runtime.pool.transfer(live.lease,this.workId,()=>this.guard())
     live.paused=false;live.pauseJob=undefined;live.journal.clear()
     live.paused=false;live.pauseJob=undefined;live.journal.clear()
     this.event('computer.resumed',{generation:live.lease.generation,notes})
     this.event('computer.resumed',{generation:live.lease.generation,notes})
+    if(this.profileSession){
+      await this.profileSession.bind();this.guard()
+      await this.profileSession.rpc('prompt.submit',{text:`${PROFILE_COMPUTER_RULES}\n用户已交还电脑控制权。请先检查当前状态,避免重复已完成的操作。交还说明:${notes||'无额外说明'}\n${live.workMarker??''}`})
+      return
+    }
     await this.startModel(live,`用户已完成电脑接管并明确交还控制。请先检查当前电脑状态,再继续原任务;不要重复已完成的副作用。用户说明:${notes||'无额外说明'}\n${live.workMarker??''}`,structuredClone(live.session.history))
     await this.startModel(live,`用户已完成电脑接管并明确交还控制。请先检查当前电脑状态,再继续原任务;不要重复已完成的副作用。用户说明:${notes||'无额外说明'}\n${live.workMarker??''}`,structuredClone(live.session.history))
   }
   }
   async interrupt(){
   async interrupt(){
@@ -369,12 +431,15 @@ export class ComputerGateway {
     if(live.stopping)return live.stopping
     if(live.stopping)return live.stopping
     live.stopping=(async()=>{
     live.stopping=(async()=>{
       live.controller.abort();clearInterval(this.timer)
       live.controller.abort();clearInterval(this.timer)
-      await live.worker?.close()
-      try{await this.release(live)}catch(error){if((error as any).code!=='computer_lease_stale')throw error}
-      live.running=false
+      try{await this.profileSession?.stop()}
+      finally{
+        await this.profileSession?.close();await live.worker?.close()
+        try{await this.release(live)}catch(error){if((error as any).code!=='computer_lease_stale')throw error}
+        live.running=false
+      }
     })()
     })()
     return live.stopping
     return live.stopping
   }
   }
   async stopControl(){await this.interrupt();this.event('message.complete',{text:'电脑控制已结束,本轮已停止',status:'interrupted'})}
   async stopControl(){await this.interrupt();this.event('message.complete',{text:'电脑控制已结束,本轮已停止',status:'interrupted'})}
-  close(){if(this.runtime.gateways.get(this.meta.environmentId)===this)this.runtime.gateways.delete(this.meta.environmentId);this.closed=true;this.controller.abort();this.team=undefined;clearInterval(this.timer);return this.interrupt()}
+  async close(){if(this.runtime.gateways.get(this.meta.environmentId)===this)this.runtime.gateways.delete(this.meta.environmentId);this.closed=true;this.controller.abort();this.team=undefined;clearInterval(this.timer);try{if(this.live?.running||this.live&&!this.live.releasing)await this.interrupt()}finally{await this.profileSession?.close()}}
 }
 }

+ 92 - 0
src/runner/worker/profileSession.ts

@@ -0,0 +1,92 @@
+import {setTimeout as delay} from 'node:timers/promises'
+import {HttpError} from '../../server/errors.js'
+import {WorkspaceGateway,type GatewayFrame,type GatewayTarget} from '../../server/workspaceGateway.js'
+import {createWorkspaceToolLease,requireTeamToolBridge,type WorkspaceToolLease} from '../../server/workspaceToolLease.js'
+import type {WorkerTool} from './process.js'
+
+/** A normal Hermes Profile session, with the same native tool bridge as cloud computers. */
+export class ProfileComputerSession {
+  private readonly gateway:WorkspaceGateway
+  private toolLease?:WorkspaceToolLease
+  private runtimeId=''
+  storedId=''
+  private closed=false
+  private readonly controller=new AbortController()
+  onEvent:(frame:GatewayFrame)=>void=()=>{}
+  onDisconnect:()=>void=()=>{}
+  onStoredId:(id:string)=>void=()=>{}
+  constructor(readonly target:GatewayTarget,readonly profile:string,readonly workId:string,
+    readonly authorize:()=>void,readonly catalog:()=>WorkerTool[],
+    readonly call:(name:string,args:unknown,id:string)=>Promise<unknown>){
+    this.gateway=new WorkspaceGateway(target)
+    this.gateway.onDisconnect=()=>{if(!this.closed)this.onDisconnect()}
+    this.gateway.onEvent=frame=>{
+      if(this.closed||!this.runtimeId||frame.session_id!==this.runtimeId)return
+      if(frame.type==='session.info'){
+        const id=frame.payload?.stored_session_id??frame.payload?.session_key
+        if(typeof id==='string'&&id){this.storedId=id;this.onStoredId(id)}
+      }
+      this.onEvent(frame)
+    }
+  }
+  async open(storedId:string|undefined,params:Record<string,unknown>){
+    await this.gateway.connect()
+    const {session_id:_virtual,recoverOnly:_recover,...options}=params
+    const opened=await this.gateway.rpc(storedId?'session.resume':'session.create',{
+      ...options,profile:this.profile,close_on_disconnect:false,...(storedId?{session_id:storedId}:{}),
+    })
+    this.runtimeId=String(opened.session_id??'')
+    this.storedId=String(opened.stored_session_id??opened.session_key??storedId??'')
+    if(!this.runtimeId||!this.storedId)throw new HttpError(502,'本机 Profile 会话身份不完整','computer_profile_session_invalid')
+    this.onStoredId(this.storedId)
+    return opened
+  }
+  async bind(){
+    this.authorize()
+    if(!this.toolLease){
+      await requireTeamToolBridge(this.target,this.profile)
+      this.toolLease=await createWorkspaceToolLease({target:this.target,profile:this.profile,workId:this.workId,
+        session:()=>({runtimeId:this.runtimeId,storedId:this.storedId}),signal:this.controller.signal,
+        assertActive:()=>{this.controller.signal.throwIfAborted();this.authorize()},
+        catalog:()=>this.catalog().map(tool=>({...tool,id:tool.id??tool.name})),
+        call:async(name,args,id)=>{
+          const value=await this.call(name,args,id!)
+          // Hermes Worker uses OpenAI image blocks; the Profile bridge speaks MCP.
+          if(value&&typeof value==='object'&&(value as any)._multimodal){
+            const result=value as any
+            return {content:[{type:'text',text:result.text_summary??''},...result.content.map((item:any)=>{
+              const match=/^data:(image\/[a-z]+);base64,(.+)$/.exec(item.image_url?.url??'')
+              if(!match)throw new Error('虚拟机截图格式无效')
+              return {type:'image',mimeType:match[1],data:match[2]}
+            })]}
+          }
+          return value
+        },onFailure:()=>{void this.stop().finally(()=>this.onDisconnect()).catch(()=>{})},
+      })
+    }
+    await this.toolLease.bind()
+  }
+  rpc(method:string,params:Record<string,unknown>={}){
+    const {session_id:_virtual,workMarker:_marker,...options}=params
+    return this.gateway.rpc(method,{...options,session_id:this.runtimeId})
+  }
+  async stop(){
+    if(!this.runtimeId||this.closed)return
+    await this.rpc('session.interrupt')
+    // An interrupt acknowledgement precedes actual termination of native tools.
+    const deadline=Date.now()+20000
+    for(;;){
+      const state=await this.rpc('session.active_list')
+      const session=state.sessions?.find((item:{id:string})=>item.id===this.runtimeId)
+      if(session?.status==='idle')return
+      if(Date.now()>=deadline)throw new HttpError(409,'本机任务尚未停止,暂不能交出电脑控制权','computer_profile_stopping')
+      await delay(100,undefined,{signal:this.controller.signal})
+    }
+  }
+  async close(){
+    if(this.closed)return
+    this.closed=true;this.controller.abort()
+    await this.toolLease?.dispose()
+    this.gateway.close()
+  }
+}

+ 6 - 4
src/server/grokCloud.ts

@@ -187,13 +187,15 @@ export class GrokCloud {
       if(!token)throw new HttpError(400,'请先通过浏览器完成 Grok Bot 授权。','grok_login_missing');ctx.body=await this.configure(owner,token,body.version,refreshToken)
       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=>{
     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']),allowHostEnvironment:z.boolean().optional()}).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','local','browser','off']),allowHostEnvironment:z.boolean().optional(),vmExecution:z.enum(['worker','profile']).optional()}).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')
       this.beforeSelection?.(owner,agent.id);this.shared.assertLocalVmIdle(owner,[agent.id]);if(this.current(owner))throw new HttpError(409,'请先交还云端电脑','computer_busy')
-      const allowHostEnvironment=['vm','cloud'].includes(body.computer)&&(body.allowHostEnvironment??agent.allowHostEnvironment??false)
-      if(body.computer==='vm'&&allowHostEnvironment)this.nodes.targetForAgent(owner,{...agent,execution:'computer',computer:'vm',allowHostEnvironment:true})
+      if(body.vmExecution==='profile'&&body.allowHostEnvironment===false)throw new HttpError(400,'本机协作模式需要本机环境,请选择虚拟机模式','computer_profile_host_required')
+      const vmExecution=body.computer==='vm'?(body.vmExecution??(body.allowHostEnvironment===false?'worker':agent.vmExecution)??'worker'):'worker'
+      const allowHostEnvironment=(body.computer==='vm'&&vmExecution==='profile')||(['vm','cloud'].includes(body.computer)&&(body.allowHostEnvironment??(body.vmExecution==='worker'?false:agent.allowHostEnvironment)??false))
+      if(body.computer==='vm'&&allowHostEnvironment)this.nodes.targetForAgent(owner,{...agent,execution:'computer',computer:'vm',allowHostEnvironment:true,vmExecution})
       const enteringCloud=body.computer==='cloud'&&agent.computer!=='cloud'
       const enteringCloud=body.computer==='cloud'&&agent.computer!=='cloud'
       if(enteringCloud)await this.requireAvailable(owner,agent,this.nodes.target(owner,agent.nodeId))
       if(enteringCloud)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,allowHostEnvironment});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=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,allowHostEnvironment,vmExecution});const current=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,current);this.store.put(owner,'agent',agent.id,current)})
       if(enteringCloud)await this.connect(owner)
       if(enteringCloud)await this.connect(owner)
       ctx.body={agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
       ctx.body={agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
     })
     })

+ 5 - 0
src/server/runnerHub.ts

@@ -117,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')
       if(!this.auth.canUseSource(owner,sourceNodeId,profile)||!record.allowedProfiles.includes(profile))throw new HttpError(403,'执行节点未授权这个基础 Profile','runner_profile_forbidden')
     }
     }
     const requireComputer=()=>{
     const requireComputer=()=>{
+      if(computer?.profileSession&&!this.online.get(record.id)?.features.includes('profile-computer-v1'))throw new HttpError(409,'执行节点尚不支持本机协作模式,请更新配套 Runner','computer_profile_unavailable')
       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?.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(computer&&this.composeDesktops.desktops.length){
         if(!this.online.get(record.id)?.features.includes('compose-desktops-v1'))throw new HttpError(409,'当前部署需要 Compose 桌面执行节点','compose_runner_required')
         if(!this.online.get(record.id)?.features.includes('compose-desktops-v1'))throw new HttpError(409,'当前部署需要 Compose 桌面执行节点','compose_runner_required')
@@ -151,6 +152,10 @@ export class RunnerHub {
           const valid=()=>{
           const valid=()=>{
             try{
             try{
               scope?.authorize()
               scope?.authorize()
+              if(computer&&!scope?.cleanupOnly){
+                const agent=this.store.get<import('../shared/workspace.js').WorkspaceAgent>(owner,'agent',computer.agentId)
+                if(!agent||(agent.vmExecution==='profile')!==(computer.profileSession===true))return false
+              }
               if(computer?.hostAccess){
               if(computer?.hostAccess){
                 const agent=this.store.get<import('../shared/workspace.js').WorkspaceAgent>(owner,'agent',computer.agentId)
                 const agent=this.store.get<import('../shared/workspace.js').WorkspaceAgent>(owner,'agent',computer.agentId)
                 if(!agent||agent.archived||agent.temporaryGoalId||agent.allowHostEnvironment!==true||(agent.computer!=='vm'&&agent.computer!==undefined)||agent.execution!=='computer')return false
                 if(!agent||agent.archived||agent.temporaryGoalId||agent.allowHostEnvironment!==true||(agent.computer!=='vm'&&agent.computer!==undefined)||agent.execution!=='computer')return false

+ 2 - 2
src/server/workspaceGateway.ts

@@ -107,10 +107,10 @@ export class WorkspaceNodes {
     }
     }
     return target
     return target
   }
   }
-  targetForAgent(owner: string, agent: { id?:string;nodeId: string; remoteAgentId?: string;execution?:string;computer?:WorkspaceAgent['computer'];allowHostEnvironment?:boolean;temporaryGoalId?:string;helperRunnerId?:string;computerEnvironmentId?:string }): GatewayTarget {
+  targetForAgent(owner: string, agent: { id?:string;nodeId: string; remoteAgentId?: string;execution?:string;computer?:WorkspaceAgent['computer'];allowHostEnvironment?:boolean;vmExecution?:WorkspaceAgent['vmExecution'];temporaryGoalId?:string;helperRunnerId?:string;computerEnvironmentId?:string }): GatewayTarget {
     if(agent.execution==='computer'){
     if(agent.execution==='computer'){
       if(!agent.id||agent.remoteAgentId)throw new HttpError(409,'隔离电脑需要当前服务器管理的机器人','computer_agent_required')
       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'),...(agent.allowHostEnvironment===true&&(agent.computer==='vm'||!agent.computer)&&!agent.temporaryGoalId?{hostAccess:true}:{})})
+      const target=this.runnerTarget?.(owner,agent.nodeId,{environmentId:agent.computerEnvironmentId??agent.id,agentId:agent.id,ownerKey:createHash('sha256').update(owner).digest('hex'),...(agent.vmExecution==='profile'&&!agent.temporaryGoalId?{profileSession:true}:{}),...(agent.allowHostEnvironment===true&&(agent.computer==='vm'||!agent.computer)&&!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.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(agent.helperRunnerId&&target?.runner?.id!==agent.helperRunnerId)throw new HttpError(409,'临时助手的原执行节点已变化,请创建新助手','helper_runner_changed')
       if(!target)throw new HttpError(409,'隔离电脑需要连接执行节点','computer_runner_required')
       if(!target)throw new HttpError(409,'隔离电脑需要连接执行节点','computer_runner_required')

+ 4 - 2
src/server/workspaceRuntime.ts

@@ -29,6 +29,7 @@ type Run = WorkspaceRun
 export interface WorkspaceBinding {
 export interface WorkspaceBinding {
   runnerId?: string
   runnerId?: string
   execution?:string
   execution?:string
+  vmExecution?:Agent["vmExecution"]
   computerEnvironmentId?:string
   computerEnvironmentId?:string
   remoteAgentId?: string
   remoteAgentId?: string
   id: string
   id: string
@@ -212,11 +213,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     },authorize:()=>{
     },authorize:()=>{
       this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
       this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
       const current=this.getWork(owner,run.id),latest=this.store.require<Agent>(owner,'agent',agent.id),conversation=this.store.require<Conversation>(owner,'conversation',c.id)
       const current=this.getWork(owner,run.id),latest=this.store.require<Agent>(owner,'agent',agent.id),conversation=this.store.require<Conversation>(owner,'conversation',c.id)
-      if(this.closing||conversation.archived||!this.store.taskMemberIds(owner,conversation,run.conversationTaskId).includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||latest.computerEnvironmentId!==agent.computerEnvironmentId||(latest.allowHostEnvironment===true)!==(agent.allowHostEnvironment===true)||(latest.execution??'profile')!==(agent.execution??'profile')||latest.teamAuthorizationVersion!==agent.teamAuthorizationVersion)throw new HttpError(403,'本轮机器人或任务授权已结束','run_authorization_revoked')
+      if(this.closing||conversation.archived||!this.store.taskMemberIds(owner,conversation,run.conversationTaskId).includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||latest.computerEnvironmentId!==agent.computerEnvironmentId||(latest.vmExecution??'worker')!==(agent.vmExecution??'worker')||(latest.allowHostEnvironment===true)!==(agent.allowHostEnvironment===true)||(latest.execution??'profile')!==(agent.execution??'profile')||latest.teamAuthorizationVersion!==agent.teamAuthorizationVersion)throw new HttpError(403,'本轮机器人或任务授权已结束','run_authorization_revoked')
     }})
     }})
     gateway.onTrace=entry=>this.inspector?.record(owner,c.id,{...entry,agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId})
     gateway.onTrace=entry=>this.inspector?.record(owner,c.id,{...entry,agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId})
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
-    const movedRunner=!!this.store.get(owner,'binding-reset',key)||!!binding&&(binding.computerEnvironmentId!==agent.computerEnvironmentId||binding.runnerId!==target.runner?.id||(binding.execution??'profile')!==(agent.execution??'profile'))
+    const movedRunner=!!this.store.get(owner,'binding-reset',key)||!!binding&&((binding.vmExecution??'worker')!==(agent.vmExecution??'worker')||binding.computerEnvironmentId!==agent.computerEnvironmentId||binding.runnerId!==target.runner?.id||(binding.execution??'profile')!==(agent.execution??'profile'))
     if(movedRunner) {
     if(movedRunner) {
       if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
       if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
       binding=undefined
       binding=undefined
@@ -500,6 +501,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       binding = {
       binding = {
         runnerId: target.runner?.id,
         runnerId: target.runner?.id,
         execution:agent.execution,
         execution:agent.execution,
+        vmExecution:agent.vmExecution,
         computerEnvironmentId:agent.computerEnvironmentId,
         computerEnvironmentId:agent.computerEnvironmentId,
         id: key,
         id: key,
         nodeId: agent.nodeId,
         nodeId: agent.nodeId,

+ 12 - 1
src/server/workspaceStore.ts

@@ -43,6 +43,7 @@ export const agentInput = z
     execution:z.enum(['profile','computer']).default('profile'),
     execution:z.enum(['profile','computer']).default('profile'),
     computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
     computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
     allowHostEnvironment:z.boolean().default(false),
     allowHostEnvironment:z.boolean().default(false),
+    vmExecution:z.enum(['worker','profile']).optional(),
     browserProfile:z.enum(['persistent','temporary']).optional(),
     browserProfile:z.enum(['persistent','temporary']).optional(),
     canManageTeam: z.boolean().default(false),
     canManageTeam: z.boolean().default(false),
     nodeId: z.string().default('local'),
     nodeId: z.string().default('local'),
@@ -57,6 +58,7 @@ export const agentPatch = z
     execution:z.enum(['profile','computer']).optional(),
     execution:z.enum(['profile','computer']).optional(),
     computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
     computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
     allowHostEnvironment:z.boolean().optional(),
     allowHostEnvironment:z.boolean().optional(),
+    vmExecution:z.enum(['worker','profile']).optional(),
     browserProfile:z.enum(['persistent','temporary']).optional(),
     browserProfile:z.enum(['persistent','temporary']).optional(),
     nodeId:z.string().min(1).max(256).optional(),
     nodeId:z.string().min(1).max(256).optional(),
     profile:z.string().min(1).max(256).optional(),
     profile:z.string().min(1).max(256).optional(),
@@ -373,6 +375,8 @@ export class WorkspaceStore {
   createAgent(owner: string, input: unknown, origin?: { createdByAgentId: string; createdFromRunId: string;temporaryGoalId?:string;helperActivation?:number;helperRunnerId?:string }): Agent {
   createAgent(owner: string, input: unknown, origin?: { createdByAgentId: string; createdFromRunId: string;temporaryGoalId?:string;helperActivation?:number;helperRunnerId?:string }): Agent {
     const body = parse(agentInput, input)
     const body = parse(agentInput, input)
     if(!supportsHostEnvironment(body)||origin)body.allowHostEnvironment=false
     if(!supportsHostEnvironment(body)||origin)body.allowHostEnvironment=false
+    if(body.computer!=='vm'||origin)body.vmExecution='worker'
+    if(body.vmExecution==='profile')body.allowHostEnvironment=true
     if (origin) this.require<Agent>(owner, 'agent', origin.createdByAgentId)
     if (origin) this.require<Agent>(owner, 'agent', origin.createdByAgentId)
     return this.atomic(() => {
     return this.atomic(() => {
       if (
       if (
@@ -429,10 +433,17 @@ export class WorkspaceStore {
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     return this.atomic(() => {
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
       const agent = this.require<Agent>(owner, 'agent', id)
+      if(patch.vmExecution==='profile'&&patch.allowHostEnvironment===false)throw new HttpError(400,'本机协作模式需要本机环境,请选择虚拟机模式','computer_profile_host_required')
+      if(agent.vmExecution==='profile'){
+        if(patch.allowHostEnvironment===false&&patch.vmExecution===undefined)patch.vmExecution='worker'
+        if(patch.vmExecution==='worker'&&patch.allowHostEnvironment===undefined)patch.allowHostEnvironment=false
+      }
+      if((patch.computer??agent.computer)!=='vm'&&(patch.vmExecution!==undefined||agent.vmExecution==='profile'))patch.vmExecution='worker'
+      if((patch.vmExecution??agent.vmExecution)==='profile'&&(patch.computer??agent.computer)==='vm')patch.allowHostEnvironment=true
       if(!supportsHostEnvironment({...agent,...patch})&&(agent.allowHostEnvironment===true||patch.allowHostEnvironment!==undefined))patch.allowHostEnvironment=false
       if(!supportsHostEnvironment({...agent,...patch})&&(agent.allowHostEnvironment===true||patch.allowHostEnvironment!==undefined))patch.allowHostEnvironment=false
       const sourceChanged=(patch.nodeId!==undefined&&patch.nodeId!==agent.nodeId)||(patch.profile!==undefined&&patch.profile!==agent.profile)
       const sourceChanged=(patch.nodeId!==undefined&&patch.nodeId!==agent.nodeId)||(patch.profile!==undefined&&patch.profile!==agent.profile)
       const hostPermissionChanged=patch.allowHostEnvironment!==undefined&&patch.allowHostEnvironment!==(agent.allowHostEnvironment===true)
       const hostPermissionChanged=patch.allowHostEnvironment!==undefined&&patch.allowHostEnvironment!==(agent.allowHostEnvironment===true)
-      const destinationChanged=hostPermissionChanged||(patch.computer!==undefined&&patch.computer!==agent.computer)||(patch.browserProfile!==undefined&&patch.browserProfile!==(agent.browserProfile??'persistent'))
+      const destinationChanged=hostPermissionChanged||(patch.vmExecution!==undefined&&patch.vmExecution!==(agent.vmExecution??'worker'))||(patch.computer!==undefined&&patch.computer!==agent.computer)||(patch.browserProfile!==undefined&&patch.browserProfile!==(agent.browserProfile??'persistent'))
       if(sourceChanged||destinationChanged){
       if(sourceChanged||destinationChanged){
         if(this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再修改机器人来源或电脑','agent_execution_busy')
         if(this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再修改机器人来源或电脑','agent_execution_busy')
         if(this.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(grant.agentId===id||grant.environmentId===(agent.computerEnvironmentId??id))))throw new HttpError(409,'请先交还电脑控制权','agent_execution_busy')
         if(this.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(grant.agentId===id||grant.environmentId===(agent.computerEnvironmentId??id))))throw new HttpError(409,'请先交还电脑控制权','agent_execution_busy')

+ 1 - 1
src/server/workspaceTeamTools.ts

@@ -11,7 +11,7 @@ import { assignmentInput, assignmentReview, assignmentUpdate, assignmentCancel,
 
 
 const uuid = z.string().uuid()
 const uuid = z.string().uuid()
 const empty = z.object({}).strict()
 const empty = z.object({}).strict()
-const createAgent = agentInput.omit({ canManageTeam: true, allowHostEnvironment:true }).extend({ requestId: uuid,execution:z.enum(['profile','computer']).optional() }).strict()
+const createAgent = agentInput.omit({ canManageTeam: true, allowHostEnvironment:true, vmExecution:true }).extend({ 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 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({
 const createTeam = z.object({
   requestId: uuid,
   requestId: uuid,

+ 2 - 0
src/shared/workspace.ts

@@ -8,6 +8,8 @@ export interface WorkspaceAgent {
   execution?: 'profile' | 'computer'
   execution?: 'profile' | 'computer'
   computer?: 'auto' | 'cloud' | 'vm' | 'local' | 'browser' | 'off'
   computer?: 'auto' | 'cloud' | 'vm' | 'local' | 'browser' | 'off'
   allowHostEnvironment?: boolean
   allowHostEnvironment?: boolean
+  /** Local VM execution backend; older records retain the isolated Worker. */
+  vmExecution?: 'worker' | 'profile'
   browserProfile?: 'persistent' | 'temporary'
   browserProfile?: 'persistent' | 'temporary'
   computerEnvironmentId?:string
   computerEnvironmentId?:string
   computerEnvironmentName?:string
   computerEnvironmentName?:string

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

@@ -7,6 +7,25 @@ vi.mock('../../src/client/api/client',()=>({apiRequest:vi.fn()}))
 const images=[{key:'standard',name:'标准桌面',description:'XFCE',ready:true,imageId:'sha256:a'},{key:'cursor',name:'Cursor Universal',description:'Debian',ready:true,imageId:'sha256:b'}]
 const images=[{key:'standard',name:'标准桌面',description:'XFCE',ready:true,imageId:'sha256:a'},{key:'cursor',name:'Cursor Universal',description:'Debian',ready:true,imageId:'sha256:b'}]
 const agent={id:'image-fixture',name:'镜像测试',computer:'vm',execution:'computer',nodeId:'local',profile:'default'} as any
 const 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}
 const vm={configured:true,runtime:'docker',daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false,images}
+it('selects Profile collaboration without changing the VM and locks the mode while it is in use',async()=>{
+ vi.spyOn(document,'hidden','get').mockReturnValue(false)
+ let inUse=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} 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('[aria-label="本地虚拟机执行方式"]').setValue('profile');await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{computer:'vm',vmExecution:'profile',allowHostEnvironment:true}})
+  await wrapper.setProps({agents:[{...agent,vmExecution:'profile',allowHostEnvironment:true}]});await flushPromises()
+  expect(wrapper.text()).toContain('使用基础机器人的完整本机环境')
+  expect(wrapper.find('.host-environment-option').exists()).toBe(false)
+  inUse=true;await wrapper.setProps({active:false});await wrapper.setProps({active:true});await flushPromises()
+  expect((wrapper.get('[aria-label="本地虚拟机执行方式"]').element as HTMLSelectElement).disabled).toBe(true)
+ }finally{wrapper.unmount()}
+})
 afterEach(()=>{vi.clearAllMocks();vi.restoreAllMocks()})
 afterEach(()=>{vi.clearAllMocks();vi.restoreAllMocks()})
 it('saves never-stop and restores the selected value after refresh',async()=>{
 it('saves never-stop and restores the selected value after refresh',async()=>{
  let idleStopMinutes=5
  let idleStopMinutes=5

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

@@ -231,6 +231,24 @@ test('persists idle-stop settings and the host environment checkbox on desktop a
  await dock.getByRole('tab',{name:'本地虚拟机',exact:true}).click();await expect(hostOption).not.toBeChecked()
  await dock.getByRole('tab',{name:'本地虚拟机',exact:true}).click();await expect(hostOption).not.toBeChecked()
  expect((await(await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===seed.agentId)).toMatchObject({computer:'vm',execution:'computer',allowHostEnvironment:false})
  expect((await(await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===seed.agentId)).toMatchObject({computer:'vm',execution:'computer',allowHostEnvironment:false})
 })
 })
+test('selects local VM Profile collaboration and preserves it after reload on desktop and phone',async({page},testInfo)=>{
+ await login(page,true)
+ 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:'机器人电脑面板'}),mode=dock.getByRole('combobox',{name:'本地虚拟机执行方式',exact:true})
+ await mode.selectOption('profile');await expect(mode).toHaveValue('profile')
+ await expect(dock.getByRole('tab',{name:'本地虚拟机',exact:true})).toHaveAttribute('aria-selected','true')
+ await expect(dock).toContainText('使用基础机器人的完整本机环境')
+ await expect(dock.getByRole('checkbox',{name:'允许本机环境',exact:true})).toHaveCount(0)
+ expect((await(await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===seed.agentId)).toMatchObject({computer:'vm',execution:'computer',vmExecution:'profile',allowHostEnvironment:true})
+ await mode.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('profile-vm-desktop.png')})
+ await page.reload();await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click();await expect(mode).toHaveValue('profile')
+ await page.setViewportSize({width:390,height:844});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'})
+ await mode.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('profile-vm-phone.png')})
+ expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
+ await mode.selectOption('worker');await expect(dock.getByRole('checkbox',{name:'允许本机环境',exact:true})).not.toBeChecked()
+})
 test('cloud VM keeps its own selection while the host option can be checked and clears on auto',async({page},testInfo)=>{
 test('cloud VM keeps its own selection while the host option can be checked and clears on auto',async({page},testInfo)=>{
  await login(page,true)
  await login(page,true)
  const seed=await(await page.request.post('/__test/cloud-host-option',{data:{}})).json()
  const seed=await(await page.request.post('/__test/cloud-host-option',{data:{}})).json()

+ 1 - 1
tests/fixtures/workspace-server.ts

@@ -470,7 +470,7 @@ runtime.runners.localVm=async(owner,id,p)=>{
  return {...vmFixture}
  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;vmFixture.idleStopMinutes=5;vmFixture.job=undefined;(runtime.runners as any).online.delete(vmRunnerId);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/local-vm')return next();const owner=auth.requireAdmin(ctx).id;vmFixture.image=ctx.query.ready==='1';vmFixture.mode='per-bot';vmFixture.maxInstances=2;vmFixture.idleStopMinutes=5;vmFixture.job=undefined;(runtime.runners as any).online.delete(vmRunnerId);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((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','profile-computer-v1']});ctx.body={ok:true}})
 runtime.app.use((ctx,next)=>{
 runtime.app.use((ctx,next)=>{
  if(ctx.path!=='/__test/cloud-host-option')return next();const owner=auth.requireAdmin(ctx).id,cloud=runtime.workspaceRuntime.cloud!
  if(ctx.path!=='/__test/cloud-host-option')return next();const owner=auth.requireAdmin(ctx).id,cloud=runtime.workspaceRuntime.cloud!
  cloud.state=async()=>({configured:true,running:false});cloud.status=async()=>({backend:'grok',mode:'off'});cloud.requireAvailable=async()=>{};cloud.connect=async()=>({} as any)
  cloud.state=async()=>({configured:true,running:false});cloud.status=async()=>({backend:'grok',mode:'off'});cloud.requireAvailable=async()=>{};cloud.connect=async()=>({} as any)

+ 65 - 0
tests/live/profileComputer.live.test.ts

@@ -0,0 +1,65 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {createServer} from 'node:http'
+import {spawn} from 'node:child_process'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtemp,mkdir,cp,writeFile,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
+import {UpstreamClient} from '../../src/server/upstream'
+import {UpstreamServiceSession} from '../../src/server/localAuth'
+
+const available=['YAOYAO_HERMES_PYTHON','YAOYAO_HERMES_SOURCE','YAOYAO_PROFILE_BRIDGE','YAOYAO_COMPUTER_IMAGE'].every(name=>!!process.env[name])
+it.skipIf(!available)('runs a real Hermes Profile with native tools and a real Docker VM through the cloud-style bridge',async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-profile-live-')),workspace=join(home,'workspace'),db=new DatabaseSync(':memory:')
+ await mkdir(workspace);await mkdir(join(home,'plugins'))
+ await cp(process.env.YAOYAO_PROFILE_BRIDGE!,join(home,'plugins','yaoyao-bot-bridge'),{recursive:true})
+ await writeFile(join(workspace,'AGENTS.md'),'PROFILE_CONTEXT_MARKER: use only the fixture files and the assigned test virtual machine.\n')
+ const requests:any[]=[],results:any[]=[]
+ let step=0
+ const model=createServer(async(req,res)=>{
+  let body='';for await(const part of req)body+=part
+  res.setHeader('Content-Type','application/json')
+  if(req.method==='GET'){res.end(JSON.stringify({data:[{id:'fixture-profile-model',context_length:65536}]}));return}
+  const input=JSON.parse(body);requests.push(input)
+  const tools=input.tools??[];let tool:string|undefined,args:any
+  if(tools.length){
+   results.push(...input.messages.filter((m:any)=>m.role==='tool'))
+   const operation=[['terminal',{command:'printf native-profile-proof',timeout:10}],['yaoyao_computer_shell_',{command:'printf vm-profile-proof > proof.txt; uname -s'}],['yaoyao_computer_read_file_',{path:'proof.txt'}]][step]
+   if(operation){tool=tools.find((t:any)=>t.function.name===operation[0]||t.function.name.startsWith(operation[0]))?.function.name;args=operation[1];if(tool)step++}
+  }
+  const message=tool?{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:tool,arguments:JSON.stringify(args)}}]}:{role:'assistant',content:tools.length?'本机与虚拟机协作完成':'fixture'}
+  if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-profile-model',choices:[{index:0,delta:message,finish_reason:tool?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')}
+  else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-profile-model',choices:[{index:0,message,finish_reason:tool?'tool_calls':'stop'}],usage:{prompt_tokens:100,completion_tokens:30,total_tokens:130}}))
+ })
+ await new Promise<void>(done=>model.listen(0,'127.0.0.1',done))
+ const modelURL=`http://127.0.0.1:${(model.address() as {port:number}).port}/v1`
+ await writeFile(join(home,'config.yaml'),JSON.stringify({model:{default:'fixture-profile-model',provider:'custom',base_url:modelURL,api_key:'fixture',context_length:65536,max_tokens:4096},terminal:{cwd:workspace},plugins:{enabled:['yaoyao-bot-bridge']},toolsets:['all'],tools:{tool_search:{enabled:'off'}},dashboard:{turn_isolation:false},compression:{enabled:false},memory:{memory_enabled:false,user_profile_enabled:false}}))
+ const reservation=createServer();await new Promise<void>(done=>reservation.listen(0,'127.0.0.1',done))
+ const port=(reservation.address() as {port:number}).port;await new Promise<void>(done=>reservation.close(()=>done()))
+ const url=new URL(`http://127.0.0.1:${port}`),source=process.env.YAOYAO_HERMES_SOURCE!,python=process.env.YAOYAO_HERMES_PYTHON!
+ let logs=''
+ const child=spawn(python,['-m','hermes_cli.main','serve','--host','127.0.0.1','--port',String(port),'--skip-build','--isolated'],{cwd:workspace,env:{PATH:process.env.PATH,HOME:process.env.HOME,LANG:'en_US.UTF-8',HERMES_HOME:home,PYTHONPATH:source,HERMES_DASHBOARD_SESSION_TOKEN:'fixture-local-profile-token-123456789',HERMES_YOLO_MODE:'1',NO_PROXY:'127.0.0.1,localhost'},stdio:['ignore','pipe','pipe']})
+ child.stdout.on('data',c=>{logs=(logs+String(c)).slice(-16000)});child.stderr.on('data',c=>{logs=(logs+String(c)).slice(-16000)})
+ const client=new UpstreamClient(url),target={url,client,session:new UpstreamServiceSession(client,()=>undefined)}
+ const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:url.href,hermesURL:url.href,token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python,hermesSource:source,hermesHome:home,network:'none'}},home,resolve('src/runner/worker/hermes_worker.py'))
+ const id=randomUUID(),gateway=new ComputerGateway(runtime,{environmentId:id,agentId:id,ownerKey:'profile-live-fixture',hostAccess:true,profileSession:true},randomUUID(),async()=>{},async()=>({}),target)
+ try{
+  await expect.poll(async()=>{if(child.exitCode!==null)throw new Error(logs);try{return(await fetch(new URL('/api/status',url))).status}catch{return 0}},{timeout:45000}).toBe(200)
+  const complete=new Promise<any>((done,reject)=>{gateway.onEvent=frame=>{if(frame.type==='message.complete')done(frame.payload)};gateway.onDisconnect=()=>reject(new Error('Profile disconnected\n'+logs))})
+  void complete.catch(()=>{})
+  await gateway.connect();const opened=await gateway.rpc('session.create',{profile:'default'})
+  await gateway.rpc('prompt.submit',{session_id:opened.session_id,text:'依次执行本机终端、虚拟机命令、读取虚拟机文件,然后报告完成。'})
+  const result=await complete
+  expect(result.text,logs).toBe('本机与虚拟机协作完成');expect(step,JSON.stringify(requests.map(r=>r.tools?.map((t:any)=>t.function.name)))).toBe(3)
+  expect(JSON.stringify(results)).toContain('native-profile-proof');expect(JSON.stringify(results)).toContain('vm-profile-proof');expect(JSON.stringify(results)).toContain('Linux')
+  expect(JSON.stringify(requests.filter(r=>r.tools?.length).map(r=>r.messages))).toContain('PROFILE_CONTEXT_MARKER')
+  expect(runtime.workers.size).toBe(0)
+ }finally{
+  await gateway.close().catch(()=>{});await runtime.controls.close();await runtime.pool.close();db.close();client.close()
+  child.kill('SIGTERM');await new Promise<void>(done=>{if(child.exitCode!==null){done();return}const timer=setTimeout(()=>child.kill('SIGKILL'),5000);child.once('exit',()=>{clearTimeout(timer);done()})})
+  await new Promise<void>(done=>{model.close(()=>done());model.closeAllConnections()});await rm(home,{recursive:true,force:true})
+ }
+},120000)

+ 153 - 0
tests/server/profileComputer.test.ts

@@ -0,0 +1,153 @@
+// @vitest-environment node
+import {expect,it,vi} from 'vitest'
+import {createServer} from 'node:http'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtemp,rm,writeFile} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {WebSocketServer,type WebSocket} from 'ws'
+import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
+import {UpstreamClient} from '../../src/server/upstream'
+import {UpstreamServiceSession} from '../../src/server/localAuth'
+import type {GatewayFrame} from '../../src/server/workspaceGateway'
+
+async function fixture(){
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-profile-computer-')),db=new DatabaseSync(':memory:')
+  const calls:Array<{method:string;params:any}>=[],bindings:any[]=[],frames:GatewayFrame[]=[],gateways:ComputerGateway[]=[]
+  const sessions=new Map<string,{stored:string;running:boolean}>()
+  let socket:WebSocket,interruptStops=true,allowed=true
+  const server=createServer(async(req,res)=>{
+    let body='';for await(const chunk of req)body+=chunk
+    if(req.url?.endsWith('/bind'))bindings.push(JSON.parse(body))
+    res.setHeader('Content-Type','application/json')
+    res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'test-ticket'}:req.url?.split('?')[0]?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true}:{ok:true,native_tools:true}))
+  })
+  await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve))
+  const url=new URL(`http://127.0.0.1:${(server.address() as {port:number}).port}`)
+  const ws=new WebSocketServer({server})
+  const emit=(type:string,payload:Record<string,unknown>={},runtimeId=[...sessions.keys()].at(-1)!)=>socket.send(JSON.stringify({method:'event',params:{type,session_id:runtimeId,payload}}))
+  ws.on('connection',connected=>{
+    socket=connected;emit('gateway.ready')
+    connected.on('message',raw=>{
+      const request=JSON.parse(String(raw));calls.push(request)
+      let result:any={ok:true}
+      if(['session.create','session.resume'].includes(request.method)){
+        const id=randomUUID(),stored=request.method==='session.resume'?request.params.session_id:randomUUID()
+        sessions.set(id,{stored,running:false})
+        result={session_id:id,stored_session_id:stored,running:false,info:{profile_name:request.params.profile,cwd:home}}
+      }else if(request.method==='session.active_list')result={sessions:[...sessions].map(([id,s])=>({id,status:s.running?'working':'idle'}))}
+      else if(request.method==='session.usage')result={context_used:123,context_max:32000}
+      else if(request.method==='file.attach')result={ref_text:`[host attachment](${home}/input.txt)`}
+      else if(request.method==='prompt.submit')sessions.get(request.params.session_id)!.running=true
+      else if(request.method==='session.interrupt'&&interruptStops){sessions.get(request.params.session_id)!.running=false;emit('message.complete',{status:'interrupted'},request.params.session_id)}
+      connected.send(JSON.stringify({id:request.id,result}))
+    })
+  })
+  const client=new UpstreamClient(url),target={url,client,session:new UpstreamServiceSession(client,()=>undefined)}
+  const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:url.href,hermesURL:url.href,token:'test',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'/must-not-start-worker',hermesSource:home,hermesHome:home,network:'none'}},home)
+  await runtime.ready;runtime.retainDesktops=true
+  const resolver=vi.spyOn(runtime,'resolve').mockRejectedValue(new Error('isolated Worker must not be used'))
+  vi.spyOn(runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:home})
+  vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'vm',containerId:'vm',running:true,workspace:'/home/cua/workspace',isolation:'container'})
+  vi.spyOn(runtime.provider,'inspect').mockResolvedValue({id:'vm',containerId:'vm',running:true,workspace:'/home/cua/workspace',isolation:'container'})
+  vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
+  vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'image',width:100,height:80,capturedAt:Date.now()})
+  const execute=vi.spyOn(runtime.provider,'execute').mockResolvedValue({stdout:'vm result',stderr:''})
+  const id=randomUUID(),meta={environmentId:id,agentId:id,ownerKey:'owner',profileSession:true,hostAccess:true}
+  const gateway=()=>{
+    const instance=new ComputerGateway(runtime,meta,randomUUID(),async()=>{if(!allowed)throw new Error('revoked')},async()=>({}),target)
+    instance.onEvent=frame=>frames.push(frame);gateways.push(instance);return instance
+  }
+  const post=async(path:string,body:unknown)=>{
+    const binding=bindings.at(-1)
+    const response=await fetch(binding.bridge_url+path,{method:'POST',headers:{Authorization:`Bearer ${binding.token}`,'Content-Type':'application/json'},body:JSON.stringify(body)})
+    return {status:response.status,body:await response.json() as any}
+  }
+  const tool=async(name:string,args:unknown)=>{
+    const catalog=await post('/tools/list',{})
+    const found=catalog.body.tools.find((t:any)=>t.name===name)
+    expect(found,JSON.stringify(catalog)).toBeDefined()
+    return post('/tools/call',{toolId:found.id,arguments:args,callId:randomUUID()})
+  }
+  return {home,runtime,resolver,execute,meta,gateway,calls,frames,bindings,sessions,post,tool,emit,
+    setInterruptStops(value:boolean){interruptStops=value},revoke(){allowed=false},
+    async close(){
+      interruptStops=true;for(const session of sessions.values())session.running=false
+      await Promise.all(gateways.map(g=>g.close().catch(()=>{})))
+      await runtime.controls.close();await runtime.pool.close();db.close();client.close()
+      for(const c of ws.clients)c.terminate()
+      await new Promise<void>(resolve=>ws.close(()=>resolve()))
+      await new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
+      vi.restoreAllMocks();await rm(home,{recursive:true,force:true})
+    },
+  }
+}
+
+it('uses a native Profile session and bridge for VM tools, preserves rotated history and keeps Worker sessions separate',async()=>{
+  const f=await fixture()
+  try{
+    const g=f.gateway();await g.connect();const opened=await g.rpc('session.create',{profile:'default'})
+    g.installTeamLease('team',[{id:'team_test',name:'team_test',description:'team fixture',inputSchema:{}}],async()=>({proof:'team-result'}))
+    const attached=await g.rpc('file.attach',{session_id:opened.session_id,name:'input.txt',data_url:'data:text/plain;base64,aGk='})
+    expect(attached.ref_text).toContain(f.home)
+    await g.rpc('prompt.submit',{session_id:opened.session_id,text:'Use the Profile and its local VM'})
+    expect(f.resolver).not.toHaveBeenCalled();expect(f.runtime.workers.size).toBe(0)
+    expect(f.calls.find(c=>c.method==='session.create')?.params).toMatchObject({profile:'default',close_on_disconnect:false})
+    expect(f.calls.find(c=>c.method==='prompt.submit')?.params.text).toContain('本机协作模式')
+    expect(f.bindings.at(-1)).toMatchObject({native_tools:true,profile:'default'})
+    const catalog=(await f.post('/tools/list',{})).body.tools.map((t:any)=>t.name)
+    expect(catalog).toEqual(expect.arrayContaining(['computer_shell','computer_copy_file','team_test']))
+    expect(catalog).not.toContain('host_shell')
+    expect((await f.tool('computer_shell',{command:'pwd'})).body.structuredContent).toMatchObject({stdout:'vm result'})
+    expect(f.execute).toHaveBeenCalledWith(expect.objectContaining({cwd:'/home/cua/workspace'}),['/bin/bash','-lc','pwd'],expect.anything())
+    expect((await f.tool('team_test',{})).body.structuredContent).toEqual({proof:'team-result'})
+    await writeFile(join(f.home,'source.bin'),Buffer.from([0,1,255]))
+    await f.tool('computer_copy_file',{from:'host',source:'source.bin',destination:'target.bin'})
+    expect(f.execute.mock.calls.at(-1)?.[2]?.input).toEqual(Buffer.from([0,1,255]))
+    const rotated=randomUUID();f.emit('session.info',{stored_session_id:rotated})
+    await vi.waitFor(()=>expect(f.runtime.session(opened.session_id,f.meta,'default').profileSessionId).toBe(rotated))
+    expect(f.frames.at(-1)?.payload.stored_session_id).toBe(opened.session_id)
+    f.sessions.values().next().value!.running=false;f.emit('message.complete',{text:'Profile finished',status:'complete'})
+    await vi.waitFor(()=>expect(f.frames.at(-1)?.payload.text).toBe('Profile finished'))
+    expect((await f.runtime.controls.status(f.meta)).mode).toBe('idle')
+    expect(await g.rpc('session.usage',{session_id:opened.session_id})).toMatchObject({context_used:123})
+    await g.close()
+    const resumed=f.gateway();await resumed.connect();await resumed.rpc('session.resume',{profile:'default',session_id:opened.session_id})
+    expect(f.calls.findLast(c=>c.method==='session.resume')?.params.session_id).toBe(rotated)
+    expect(()=>f.runtime.session(opened.session_id,{...f.meta,profileSession:false},'default')).toThrow('执行方式已改变')
+  }finally{await f.close()}
+})
+
+it('waits for native termination and VM calls before human takeover, then resumes only after giveback',async()=>{
+  const f=await fixture()
+  try{
+    const g=f.gateway();await g.connect();const session=await g.rpc('session.create',{profile:'default'})
+    await g.rpc('prompt.submit',{session_id:session.session_id,text:'Work'})
+    let finishTool!:()=>void
+    f.execute.mockImplementationOnce(async()=>{await new Promise<void>(done=>{finishTool=done});return {stdout:'finished',stderr:''}})
+    const pending=f.tool('computer_shell',{command:'long command'})
+    await vi.waitFor(()=>expect(finishTool).toBeTypeOf('function'))
+    f.setInterruptStops(false)
+    const controlId=randomUUID();await f.runtime.controls.take(f.meta,'default',controlId,async()=>{})
+    await vi.waitFor(()=>expect(f.calls.some(c=>c.method==='session.interrupt')).toBe(true))
+    expect((await f.runtime.controls.status(f.meta)).mode).toBe('pausing')
+    for(const native of f.sessions.values())native.running=false
+    f.emit('message.complete',{status:'interrupted'})
+    await new Promise(done=>setTimeout(done,150))
+    expect((await f.runtime.controls.status(f.meta)).mode).toBe('pausing')
+    finishTool();await pending
+    await g.takeControl(controlId,()=>{})
+    await vi.waitFor(async()=>expect((await f.runtime.controls.status(f.meta)).mode).toBe('human'))
+    expect(f.frames.some(frame=>frame.type==='message.complete')).toBe(false)
+    expect((await f.tool('computer_shell',{command:'must not execute'})).body.isError).toBe(true)
+    expect(f.calls.filter(c=>c.method==='prompt.submit')).toHaveLength(1)
+    await f.runtime.controls.giveBack(f.meta,controlId,'已完成登录')
+    expect(f.calls.filter(c=>c.method==='prompt.submit')).toHaveLength(2)
+    expect(f.calls.at(-1)?.params.text).toContain('已完成登录')
+    expect((await f.runtime.controls.status(f.meta)).mode).toBe('agent')
+    f.setInterruptStops(true);await g.rpc('session.interrupt',{session_id:session.session_id})
+    expect((await f.runtime.controls.status(f.meta)).mode).toBe('off')
+    await expect(f.post('/tools/list',{})).rejects.toThrow()
+  }finally{await f.close()}
+})

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

@@ -76,6 +76,10 @@ it('requires an upgraded Runner before granting mixed host and VM tools',()=>{
  const id=randomUUID()
  const id=randomUUID()
  expect(()=>hub.target('owner','local',{environmentId:id,agentId:id,ownerKey:'owner',hostAccess:true})).toThrow('不支持同时使用本机和虚拟机')
  expect(()=>hub.target('owner','local',{environmentId:id,agentId:id,ownerKey:'owner',hostAccess:true})).toThrow('不支持同时使用本机和虚拟机')
 })
 })
+it('rejects Profile collaboration on a Runner that does not advertise the execution backend',()=>{
+ const id=randomUUID()
+ expect(()=>hub.target('owner','local',{environmentId:id,agentId:id,ownerKey:'owner',hostAccess:true,profileSession:true})).toThrow('不支持本机协作模式')
+})
 async function machine(path:string,body?:unknown,overrides:Record<string,string>={}){
 async function machine(path:string,body?:unknown,overrides:Record<string,string>={}){
   return fetch(`${config.serverURL}/api/runner/v1/${config.runnerId}/${path}`,{
   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)})
     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)})

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

@@ -128,6 +128,32 @@ it('persists the separate host option, checks the Runner grant and refuses chang
  target.mockClear();nodes.targetForAgent('owner',store.require<any>('owner','agent',agent.id))
  target.mockClear();nodes.targetForAgent('owner',store.require<any>('owner','agent',agent.id))
  expect(target.mock.calls[0]?.[2]).not.toHaveProperty('hostAccess')
  expect(target.mock.calls[0]?.[2]).not.toHaveProperty('hostAccess')
 })
 })
+it('persists the separate Profile execution mode, checks its Runner capability and prevents running or inherited changes',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 changed=await request(app.callback()).put(path).send({computer:'vm',vmExecution:'profile'}).expect(200)
+ expect(changed.body.agent).toMatchObject({computer:'vm',execution:'computer',vmExecution:'profile',allowHostEnvironment:true})
+ expect(target).toHaveBeenCalledWith('owner','local',expect.objectContaining({profileSession:true,hostAccess:true}))
+ store.put('owner','turn','active-profile',{agentId:agent.id,status:'running'})
+ await request(app.callback()).put(path).send({computer:'vm',vmExecution:'worker'}).expect(409)
+ expect(()=>store.updateAgent('owner',agent.id,{vmExecution:'worker'})).toThrow('先停止当前任务')
+ store.remove('owner','turn','active-profile')
+ const member=store.createAgent('owner',{name:'不继承模式',profile:'default',computer:'vm',execution:'computer',vmExecution:'profile'},{createdByAgentId:agent.id,createdFromRunId:'run'})
+ expect(member.vmExecution).toBe('worker');expect(member.allowHostEnvironment).toBe(false)
+ const worker=await request(app.callback()).put(path).send({computer:'vm',vmExecution:'worker'}).expect(200)
+ expect(worker.body.agent).toMatchObject({vmExecution:'worker',allowHostEnvironment:false})
+ await request(app.callback()).put(path).send({computer:'vm',vmExecution:'profile',allowHostEnvironment:false}).expect(400)
+ await request(app.callback()).put(path).send({computer:'vm',vmExecution:'profile'}).expect(200)
+ const legacy=await request(app.callback()).put(path).send({computer:'vm',allowHostEnvironment:false}).expect(200)
+ expect(legacy.body.agent).toMatchObject({vmExecution:'worker',allowHostEnvironment:false})
+ store.updateAgent('owner',agent.id,{vmExecution:'profile'})
+ expect(store.updateAgent('owner',agent.id,{allowHostEnvironment:false})).toMatchObject({vmExecution:'worker',allowHostEnvironment:false})
+ await request(app.callback()).put(path).send({computer:'vm',vmExecution:'profile'}).expect(200)
+ await request(app.callback()).put(path).send({computer:'off'}).expect(200)
+ expect(store.require<any>('owner','agent',agent.id)).toMatchObject({vmExecution:'worker',allowHostEnvironment:false})
+})
 it('allows the host option for cloud and VM only and clears it when selecting another environment',async()=>{
 it('allows the host option for cloud and VM only and clears it when selecting another environment',async()=>{
  const {cloud}=fakeCloud(),agent=store.createAgent('owner',{name:'云端本机选项',profile:'default',computer:'cloud'})
  const {cloud}=fakeCloud(),agent=store.createAgent('owner',{name:'云端本机选项',profile:'default',computer:'cloud'})
  const available=vi.spyOn(cloud,'requireAvailable').mockResolvedValue(),connect=vi.spyOn(cloud,'connect').mockResolvedValue({} as any)
  const available=vi.spyOn(cloud,'requireAvailable').mockResolvedValue(),connect=vi.spyOn(cloud,'connect').mockResolvedValue({} as any)

Някои файлове не бяха показани, защото твърде много файлове са промени