ソースを参照

feat(bot): 对话留在服务端,虚拟环境改为外派工具

Samien 1 ヶ月 前
親
コミット
9cfc4e4453

+ 5 - 5
src/client/components/workspace/LocalVmChatPanel.vue

@@ -79,7 +79,7 @@ function authorizeHost(hostId:string,scope?:'full'){
 }
 const computerChoices=computed(()=>[
  {value:'auto',label:'自动',description:'按可用环境自动选择',icon:'bolt' as const,disabled:false},
- {value:'cloud',label:'云端 · Grok Bot',description:'共享云端电脑,持续保存工作',icon:'globe' as const,disabled:false},
+ {value:'cloud',label:'云端 · Grok Bot',description:'Grok Bot 虚拟机,工作留在云端',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:!desktopHosts.value.some(host=>host.local.supported)},
  {value:'browser',label:'仅浏览器',description:native.value?.browser.available?'独立浏览器,保存登录资料':'请在电脑上打开夭夭桌面端',icon:'globe' as const,disabled:!desktopHosts.value.some(host=>host.browser.available)},
@@ -190,10 +190,10 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
     <div class="computer-select">
      <span>此机器人使用的电脑</span>
      <div v-if="modern" class="env-checks" role="group" aria-label="此机器人使用的电脑(可多选)">
-      <label class="env-check" :class="{selected:envs.vm}"><input type="checkbox" aria-label="本地虚拟机" :checked="envs.vm" :disabled="computerChoiceDisabled" @change="toggleEnv('vm',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="monitor" :size="14"/>本地虚拟机</b><small>隔离桌面,操作只在虚拟机内</small></span></label>
-      <label class="env-check" :class="{selected:envs.cloud}"><input type="checkbox" aria-label="云端 · Grok Bot" :checked="envs.cloud" :disabled="computerChoiceDisabled" @change="toggleEnv('cloud',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="globe" :size="14"/>云端 · Grok Bot</b><small>共享云端电脑,持续保存工作</small></span></label>
-      <label class="env-check" :class="{selected:envs.desktop}"><input type="checkbox" aria-label="本机控制" :checked="envs.desktop" :disabled="computerChoiceDisabled" @change="toggleEnv('desktop',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="monitor" :size="14"/>本机控制</b><small>{{native?.hosts?.length?`${native.hosts.filter((h:any)=>h.online).length} 台 Mac 在线,可按名选择`:'暂无已连接的 Mac'}}</small></span></label>
-      <label class="env-check" :class="{selected:envs.browser}"><input type="checkbox" aria-label="仅浏览器" :checked="envs.browser" :disabled="computerChoiceDisabled" @change="toggleEnv('browser',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="globe" :size="14"/>仅浏览器</b><small>连接 Mac 上的机器人独立浏览器</small></span></label>
+      <label class="env-check" :class="{selected:envs.vm}"><input type="checkbox" aria-label="虚拟环境" :checked="envs.vm" :disabled="computerChoiceDisabled" @change="toggleEnv('vm',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="monitor" :size="14"/>虚拟环境</b><small>服务端上的隔离桌面,操作只在虚拟机内</small></span></label>
+      <label class="env-check" :class="{selected:envs.cloud}"><input type="checkbox" aria-label="云虚拟机" :checked="envs.cloud" :disabled="computerChoiceDisabled" @change="toggleEnv('cloud',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="globe" :size="14"/>云虚拟机</b><small>共享云端电脑,持续保存工作</small></span></label>
+      <label class="env-check" :class="{selected:envs.desktop}"><input type="checkbox" aria-label="脚本机" :checked="envs.desktop" :disabled="computerChoiceDisabled" @change="toggleEnv('desktop',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="monitor" :size="14"/>脚本机</b><small>{{native?.hosts?.length?`${native.hosts.filter((h:any)=>h.online).length} 台 Mac 在线,可按名选择`:'暂无已连接的 Mac'}}</small></span></label>
+      <label class="env-check" :class="{selected:envs.browser}"><input type="checkbox" aria-label="脚本机浏览器" :checked="envs.browser" :disabled="computerChoiceDisabled" @change="toggleEnv('browser',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="globe" :size="14"/>脚本机浏览器</b><small>只使用客户那台机器上的浏览器</small></span></label>
       <p class="hint env-hint">勾选的环境并列可用;全部取消即不启用任何电脑环境。</p>
      </div>
      <div v-else ref="computerTabs" class="computer-tabs" role="tablist" aria-label="此机器人使用的电脑" @keydown="moveComputerTab">

+ 8 - 1
src/runner/worker/gateway.ts

@@ -37,7 +37,7 @@ const TOOLS:WorkerTool[]=[
 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。人工接管期间停止操作,交还后先检查当前状态。'
 const HERMES_COMPUTER_RULES='本轮由 Hermes 管理模型、配置、识图、技能和授权资源。使用 Hermes 原生 skills_list、skill_view 和 skill_manage 发现、读取和维护技能;遵循当前 Profile 的技能规则。技能依赖本机登录态、环境变量或授权文件时,使用 Hermes 原生工具完成相应步骤;适合 Linux 的脚本与输入文件可经 computer_copy_file 传入虚拟机,再用 computer_shell 执行。路径、软件和登录态在本机与虚拟机之间不通用。不要自行复制 Profile 配置或凭据到虚拟机。'
-const ISOLATED_COMPUTER_RULES='当前使用隔离虚拟机:命令、文件、浏览器和桌面操作都通过 computer_* 在这台 Linux 电脑内部完成。不要使用服务器本机的终端、文件或桌面,也不要在节点离线时改去本机。需要真实 Mac 时,那是单独勾选的本机环境,不从这台虚拟机里打开。人工接管期间停止操作,交还后先检查当前状态。'
+const ISOLATED_COMPUTER_RULES='当前环境是虚拟环境,也就是服务端上的隔离虚拟机。命令、文件、浏览器和桌面操作都通过 computer_* 在这台 Linux 电脑内部完成。不要使用服务端本机的终端、文件或桌面,也不要在节点离线时改去服务端或脚本机。脚本机和云虚拟机是另外勾选的环境,不从这台虚拟机里打开。人工接管期间停止操作,交还后先检查当前状态。'
 const ISOLATED_HERMES_RULES='本轮由 Hermes 管理模型、配置和这次会话。使用 Hermes 原生 skills_list、skill_view 和 skill_manage 发现、读取和维护技能。技能和 computer_* 都留在这台虚拟机里;不要把 Profile 的本机登录、文件或凭据复制进来。'
 export interface ProxyHandle {readonly proxy:ComputerPublicProxy;release():Promise<void>}
 export class ComputerRuntime {
@@ -228,6 +228,7 @@ export class ComputerGateway {
         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()
+      if(params.toolsOnly===true)return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile,tools_only:true}}
       if(this.meta.profileSession||this.hermesManaged){
         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}
@@ -235,6 +236,12 @@ export class ComputerGateway {
       return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
     }
     const live=this.live
+    if(method==='computer.invoke'){
+      if(!live||params.session_id!==live.session.id)throw new HttpError(403,'电脑会话不属于当前通道','computer_session_forbidden')
+      const name=String(params.name??'')
+      if(!TOOLS.some(tool=>tool.name===name))throw new HttpError(403,'本轮未授权该工具','computer_tool_forbidden')
+      return this.tool(live,name,params.arguments??{},typeof params.id==='string'&&params.id?params.id:randomUUID())
+    }
     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(this.profileSession){

+ 11 - 11
src/server/desktopEnvironments.ts

@@ -62,7 +62,7 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
  {id:'desktop_browser',name:'desktop_browser',description:'操作机器人的独立浏览器。kind: navigate/new-tab(url)、select-tab/close-tab(tabId)、back/forward/reload、snapshot(读取页面元素与 ref)、click/fill(ref,snapshotId,text)。只在仅浏览器环境可用。',inputSchema:{type:'object',properties:props,required:['kind'],additionalProperties:false}},
 ]
 export const DESKTOP_FILE_TOOL_IDS=new Set(['desktop_file_list','desktop_file_read','desktop_file_write','desktop_shell'])
-export const DESKTOP_ENVIRONMENT_RULES='当前环境由用户连接的夭夭桌面端提供。使用 desktop_environment_view/action 查看和操作该环境;本机模式会影响这台真实电脑,遵循用户授权范围。仅浏览器模式使用独立浏览器,desktop_browser 可导航、查看页面及操作元素。先查看再操作,页面变化后重新获取截图或 snapshot;不得猜测旧坐标或 ref。desktop_file_list/read/write 在用户主目录范围内读写文件,desktop_shell 在主目录执行命令;文件与命令是真实生效的操作,写文件和执行命令前确认目标路径,不要批量猜测写入。人工接管时等待交还,按交还说明继续。电脑断开时报告原因,不得宣称操作成功。'
+export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机,也就是客户所在的那台机器。使用 desktop_environment_view/action 查看和操作这台机器。脚本机浏览器使用 desktop_browser,可导航、查看页面及操作元素。先查看再操作,页面变化后重新获取截图或 snapshot;不得猜测旧坐标或 ref。desktop_file_list/read/write 在用户主目录范围内读写文件,desktop_shell 在主目录执行命令;这些是真实生效的操作。服务端本机、虚拟环境和云虚拟机都不是这台脚本机。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
 
 /** The loopback native transport is admitted only by serviceInstance's
  *  private capability; remote hosts are admitted by DesktopHostHub's bearer
@@ -82,18 +82,18 @@ export class DesktopEnvironments {
  private readyFull(s:HostState,owner:string){return this.hostOnline(s)&&s.info!.platform==='darwin'&&(s.info!.full??[]).includes(this.ownerKey(owner))}
  fileTools(owner:string,agent:WorkspaceAgent){const s=this.sessionFor(agent);return !!s&&this.readyFull(s,owner)}
  private localSession(){let s=this.sessions.get('local');if(!s){s=newHostState('local',false);this.sessions.set('local',s)}return s}
- /** Agent binding first; otherwise the loopback host, otherwise the single
-  *  connected remote host. Ambiguous remote sets require an explicit binding. */
- /** Default host for bot work: the server's own desktop (loopback App)
-  * while it is online — a legacy explicit binding only takes over when the
-  * server desktop is away, and a single online remote is the last resort. */
+ /** 脚本机 prefers the customer's machine. An explicit binding wins.
+ * One online remote host is the customer machine. Several remotes need a binding.
+ * The server loopback desktop is only used when no customer machine is online. */
  private sessionFor(agent:WorkspaceAgent):HostState|undefined{
-  const local=this.sessions.get('local')
-  if(local&&this.hostOnline(local))return local
-  if(agent.desktopHost)return this.sessions.get(agent.desktopHost)
-  if(local)return local
+  if(agent.desktopHost){
+   const pinned=this.sessions.get(agent.desktopHost)
+   if(pinned)return pinned
+  }
   const remote=[...this.sessions.values()].filter(h=>h.key!=='local'&&this.hostOnline(h))
-  return remote.length===1?remote[0]:undefined
+  if(remote.length===1)return remote[0]
+  if(remote.length>1)return undefined
+  return this.sessions.get('local')
  }
  selected(owner:string,agent:WorkspaceAgent):Mode|undefined {if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)return undefined;const envs=agentEnvs(agent);if(agent.envs)return envs.desktop?'local':envs.browser?'browser':undefined;if(agent.computer==='local'||agent.computer==='browser')return agent.computer;if(agent.computer==='auto'&&agent.execution!=='computer'){const s=this.sessionFor(agent);if(s&&this.ready(s,owner))return 'local'}return undefined}
  toolMode(owner:string,agent:WorkspaceAgent):Mode|undefined {if(allowsHostEnvironment(agent)){const s=this.sessionFor(agent);if(s&&this.ready(s,owner))return 'local'}return this.selected(owner,agent)}

+ 2 - 2
src/server/grokCloud.ts

@@ -27,7 +27,7 @@ export const GROK_COMPUTER_TOOLS=[
   {id:'cloud_computer_desktop',name:'cloud_computer_desktop',description:'查看 Grok Bot 云端电脑桌面,返回真实截图。',inputSchema:schema({},[])},
   {id:'cloud_computer_action',name:'cloud_computer_action',description:'操作 Grok Bot 云端电脑。先查看桌面,再使用 click、text、key 或 scroll。',inputSchema:schema({kind:{enum:['click','text','key','scroll']},x:{type:'integer'},y:{type:'integer'},text:field,key:field,direction:{enum:['up','down','left','right']}},['kind'])},
 ]
-export const GROK_COMPUTER_RULES='当前电脑为 Grok Bot 云端共享虚拟机,工作目录 /workspace。电脑操作、文件读写和命令必须使用 cloud_computer_* 工具。本机路径和本机终端不属于这台电脑。多个机器人共享工作文件和浏览器登录,避免覆盖其他机器人的工作;人工接管时等待交还。'
+export const GROK_COMPUTER_RULES='当前环境是云虚拟机,也就是 Grok Bot 虚拟机,工作目录 /workspace。电脑操作、文件读写和命令必须使用 cloud_computer_*。脚本机、服务端和虚拟环境都不属于这台云虚拟机。多个机器人共享工作文件和浏览器登录,避免覆盖其他机器人的工作;人工接管时等待交还。'
 export function grokComputerRules(_allowHostEnvironment = false){
   return GROK_COMPUTER_RULES
 }
@@ -202,7 +202,7 @@ export class GrokCloud {
       if(legacyBody&&legacyBody.computer==='vm'&&allowHostEnvironment)this.nodes.targetForAgent(owner,{...agent,execution:'computer',computer:'vm',allowHostEnvironment:true,vmExecution})
       const enteringCloud=(next?next.cloud:legacyBody!.computer==='cloud')&&!current.cloud
       if(enteringCloud)await this.requireAvailable(owner,agent,this.nodes.target(owner,agent.nodeId))
-      this.store.atomic(()=>{const execution=next?(next.vm?'computer':'profile'):targetComputer==='vm'?'computer':targetComputer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId)this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{...(next?{envs:next}:{computer:legacyBody!.computer}),...(body.desktopHost!==undefined?{desktopHost:body.desktopHost}:{}),execution,allowHostEnvironment,vmExecution});const updated=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,updated);this.store.put(owner,'agent',agent.id,updated)})
+      this.store.atomic(()=>{const execution=next?'profile':targetComputer==='vm'?'computer':targetComputer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId&&!(next&&next.vm))this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{...(next?{envs:next}:{computer:legacyBody!.computer}),...(body.desktopHost!==undefined?{desktopHost:body.desktopHost}:{}),execution,allowHostEnvironment,vmExecution});const updated=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,updated);this.store.put(owner,'agent',agent.id,updated)})
       if(enteringCloud)await this.connect(owner)
       ctx.body={agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
     })

+ 31 - 0
src/server/vmComputer.ts

@@ -0,0 +1,31 @@
+import {WorkspaceGateway,type GatewayTarget} from './workspaceGateway.js'
+
+const schema=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
+const field={type:'string'}
+
+export const VM_COMPUTER_TOOLS=[
+  {id:'computer_shell',name:'computer_shell',description:'在虚拟环境里执行命令。虚拟环境是服务端上的隔离 Linux 电脑,不会在服务端本机或脚本机上执行。',inputSchema:schema({command:field,user:{type:'string',enum:['cua','root']}},['command'])},
+  {id:'computer_read_file',name:'computer_read_file',description:'读取虚拟环境里的 UTF-8 文件。',inputSchema:schema({path:field},['path'])},
+  {id:'computer_write_file',name:'computer_write_file',description:'写入虚拟环境里的 UTF-8 文件。',inputSchema:schema({path:field,content:field},['path','content'])},
+  {id:'computer_desktop_state',name:'computer_desktop_state',description:'查看虚拟环境的桌面,返回真实截图。',inputSchema:schema({},[])},
+  {id:'computer_action',name:'computer_action',description:'操作虚拟环境桌面。先查看桌面,再调用动作。',inputSchema:schema({name:field,arguments:{type:'object'}},['name','arguments'])},
+  {id:'computer_export',name:'computer_export',description:'把虚拟环境里的文件作为附件回传到当前对话,最大 25 MiB。',inputSchema:schema({path:field,name:field},['path'])},
+]
+
+export const VM_COMPUTER_RULES='已勾选虚拟环境。它是服务端上的隔离 Linux 电脑,不是这段对话本身。查看、命令、文件和桌面都用 computer_*,不要用服务端本机的终端或文件代替。脚本机和云虚拟机是另外的工具,不要混用路径。'
+
+export class VmToolSession {
+  private gateway?: WorkspaceGateway
+  private sessionId=''
+  constructor(private readonly target:GatewayTarget,private readonly profile:string,private readonly workId:string,private readonly authorize:()=>void,private readonly publishArtifact?:(name:string,bytes:Buffer)=>Promise<unknown>){}
+  async call(name:string,args:unknown){
+    if(!this.gateway){
+      this.gateway=new WorkspaceGateway(this.target,{workId:this.workId,authorize:this.authorize,...(this.publishArtifact?{publishArtifact:this.publishArtifact}:{})})
+      const opened=await this.gateway.rpc('session.create',{profile:this.profile,toolsOnly:true,title:'Yaoyao vm tools',source:'yaoyao_workspace',close_on_disconnect:true})
+      this.sessionId=String(opened.session_id??'')
+      if(!this.sessionId)throw new Error('虚拟环境没有打开')
+    }
+    return this.gateway.rpc('computer.invoke',{session_id:this.sessionId,name,arguments:args??{}})
+  }
+  close(){const gateway=this.gateway;this.gateway=undefined;this.sessionId='';gateway?.close()}
+}

+ 37 - 9
src/server/workspaceRuntime.ts

@@ -1,5 +1,6 @@
 import {DESKTOP_ENVIRONMENT_TOOLS,DESKTOP_ENVIRONMENT_RULES,DESKTOP_FILE_TOOL_IDS,type DesktopEnvironments} from './desktopEnvironments.js'
 import {GROK_COMPUTER_TOOLS,grokComputerRules,type GrokCloud} from './grokCloud.js'
+import {VM_COMPUTER_TOOLS,VM_COMPUTER_RULES,VmToolSession} from './vmComputer.js'
 import { randomUUID } from 'node:crypto'
 import { readFileSync } from 'node:fs'
 import { z } from 'zod'
@@ -17,7 +18,7 @@ import type {
   WorkspaceInteraction,
   WorkspaceAgentUsageSummary,
 } from '../shared/workspace.js'
-import { personaSection } from '../shared/workspace.js'
+import { personaSection, agentEnvs } from '../shared/workspace.js'
 
 import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK, WORKSPACE_CONCURRENCY_LIMIT, RESOURCE_WAIT_CODES } from './workspaceScheduler.js'
 import type { WorkspacePlugins, OpenBotPlugins } from './botPlugins/workspacePlugins.js'
@@ -274,8 +275,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     this.nodes.requireSource(owner, agent)
     if (agent.remoteAgentId) agent = await this.nodes.refreshRemoteAgent(owner,agent)
     const key = this.bindingKey(c.id, run.conversationTaskId, agent.id),
-      computerExecution = !!(agent.remoteAgentId || (agent.execution==='computer' && (!agent.envs || this.computerAvailable?.(owner,agent) !== false))),
+      dispatchedVm = !!agent.envs && agentEnvs(agent).vm && !agent.remoteAgentId && !agent.temporaryGoalId,
+      computerExecution = !dispatchedVm && !!(agent.remoteAgentId || (agent.execution==='computer' && (!agent.envs || this.computerAvailable?.(owner,agent) !== false))),
+      chatExecution = dispatchedVm ? 'profile' : computerExecution ? 'computer' : (agent.execution ?? 'profile'),
       target = computerExecution ? this.nodes.targetForAgent(owner,agent) : this.nodes.target(owner, agent.nodeId)
+    let vmTools: VmToolSession | undefined
     const gateway = new WorkspaceGateway(target,{workId:run.id,publishArtifact:async(name,bytes)=>{
       this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
       const file=this.publishArtifact(owner,resultMessage,agent,name,bytes)
@@ -305,7 +309,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     }
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
     const memoryChanged = !recovering && !!binding?.memoryVersion && binding.memoryVersion !== memory.version
-    const movedRunner=memoryChanged||!!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')||!!binding.hermesComputer!==!!target.runner?.hermesComputer)
+    const movedRunner=memoryChanged||!!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')!==chatExecution||!!binding.hermesComputer!==!!target.runner?.hermesComputer)
     if(movedRunner) {
       if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
       binding=undefined
@@ -321,7 +325,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         conversationTaskId: run.conversationTaskId,
         seq: 0,
         role: 'assistant',
-        execution:computerExecution?'computer':(agent.execution??'profile'),
+        execution:chatExecution,
         agentId: agent.id,
         agentName: agent.name,
         content: '',
@@ -423,7 +427,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         if (error) rejectTurn(error)
         else resolveTurn(resultMessage)
       } catch (failure) { rejectTurn(failure instanceof Error ? failure : new Error('无法保存执行状态')) }
-      finally { if (usageCompletion) void usageCompletion.finally(() => gateway.close()); else gateway.close() }
+      finally { vmTools?.close(); if (usageCompletion) void usageCompletion.finally(() => gateway.close()); else gateway.close() }
     }
     gateway.onDisconnect = () =>
       finish(completedEvidence ? undefined : new Error('Hermes 连接断开'))
@@ -637,7 +641,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       binding = {
         memoryVersion: recovering ? binding?.memoryVersion : memory.version,
         runnerId: target.runner?.id,
-        execution:computerExecution?'computer':'profile',
+        execution:chatExecution,
         vmExecution:computerExecution?agent.vmExecution:undefined,
         hermesComputer:target.runner?.hermesComputer,
         computerEnvironmentId:agent.computerEnvironmentId,
@@ -710,7 +714,26 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const desktop=envTools.view||envTools.browser
         const desktopEpochs=this.desktopEnvironments?.hostEpochs(owner,agent)??{}
         const knowledge = botCapabilities.tools && !agent.remoteAgentId
-        if(team||cloud||desktop||plugins||knowledge){
+        const vmHolder:{session?:VmToolSession}={}
+        const callVm=async(name:string,args:unknown)=>{
+          if(!vmHolder.session){
+            const vmTarget=this.nodes.targetForAgent(owner,{...agent,execution:'computer'})
+            vmHolder.session=new VmToolSession(vmTarget,agent.profile,run.id,()=>{
+              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)
+              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)throw new HttpError(403,'本轮机器人或任务授权已结束','run_authorization_revoked')
+            },async(name,bytes)=>{
+              this.requireAuthorization(owner,run.runId)
+              const file=this.publishArtifact(owner,resultMessage,agent,name,bytes)
+              if(!resultMessage.attachments.some(item=>item.id===file.id))resultMessage.attachments.push(file)
+              this.store.saveMessage(owner,resultMessage)
+              return {...file,url:`/api/app/files/${file.id}/download`}
+            })
+            vmTools=vmHolder.session
+          }
+          return vmHolder.session.call(name,args)
+        }
+        if(team||cloud||desktop||plugins||knowledge||dispatchedVm){
           if(team){
             const granted=this.getWork(owner,run.id);granted.teamManagementRevision=agent.revision;this.saveWork(owner,granted)
             await this.teamTools.requireAvailable(owner,agent)
@@ -728,8 +751,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             target,profile:agent.profile,workId:run.id,signal:toolController.signal,
             workspaceMemory:botCapabilities.memory,
             session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
-            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...(cloud?GROK_COMPUTER_TOOLS:[]),...(desktop?DESKTOP_ENVIRONMENT_TOOLS.filter(t=>(envTools.browser&&t.id==='desktop_browser')||(envTools.view&&['desktop_environment_view','desktop_environment_action'].includes(t.id))||(envTools.file&&DESKTOP_FILE_TOOL_IDS.has(t.id))):[]),...(pluginLease?.catalog()??[])],
-            call:async(toolId,args)=>{assertActive();return knowledge&&this.knowledgeTools.handles(toolId)?this.knowledgeTools.call(owner,run.id,toolId,args,botCapabilities.memory):toolId.startsWith('plugin_')&&pluginLease?pluginLease.call(toolId,args):toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,toolId==='desktop_browser'?'browser':'local',desktopEpochs):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):this.teamTools.call(owner,run.id,toolId,args)},
+            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...(cloud?GROK_COMPUTER_TOOLS:[]),...(dispatchedVm?VM_COMPUTER_TOOLS:[]),...(desktop?DESKTOP_ENVIRONMENT_TOOLS.filter(t=>(envTools.browser&&t.id==='desktop_browser')||(envTools.view&&['desktop_environment_view','desktop_environment_action'].includes(t.id))||(envTools.file&&DESKTOP_FILE_TOOL_IDS.has(t.id))):[]),...(pluginLease?.catalog()??[])],
+            call:async(toolId,args)=>{assertActive();return knowledge&&this.knowledgeTools.handles(toolId)?this.knowledgeTools.call(owner,run.id,toolId,args,botCapabilities.memory):toolId.startsWith('plugin_')&&pluginLease?pluginLease.call(toolId,args):toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,toolId==='desktop_browser'?'browser':'local',desktopEpochs):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):toolId.startsWith('computer_')&&dispatchedVm?callVm(toolId,args):this.teamTools.call(owner,run.id,toolId,args)},
             onFailure:error=>{if(!settled)void gateway.rpc('session.interrupt',{session_id:runtimeId}).catch(()=>{}).finally(()=>finish(error))},
           })
           await toolLease.bind();if(settled)return await completion
@@ -757,7 +780,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const members = run.turnConfiguration!.members
         const goal = run.conversationTaskId ? this.store.get<import('../shared/agentTasks.js').AgentGoal>(owner, 'goal', run.conversationTaskId) : undefined
         const assignmentId = this.store.require<Run>(owner, 'run', run.runId).assignmentId
+        const checked = agentEnvs(agent)
+        const open = [checked.desktop ? '脚本机' : '', checked.browser ? '脚本机浏览器' : '', checked.vm ? '虚拟环境' : '', checked.cloud ? '云虚拟机' : ''].filter(Boolean)
+        const envDirective = `同一条对话里,只使用已勾选的环境:${open.join('、') || '无'}。脚本机是客户所在的机器,虚拟环境是服务端上的隔离虚拟机,云虚拟机是 Grok Bot 虚拟机。没勾选的不要用,也不要用服务端本机顶上。`
         const rules = [
+          envDirective,
           `你是 ${agent.name}。${personaSection(agent) ? `用户为它设定的工作规范:\n${personaSection(agent)}\n\n` : ''}以下是用户为这个独立机器人配置的角色与规则(版本 ${agent.revision}):\n${agent.remoteAgentId ? '配置由远端机器人管理。' : agent.instructions}`,
           c.kind === 'group' && Object.keys(c.memberRoles ?? {}).length
             ? `本群角色分工(仅在本群生效):\n${members.flatMap(member => {
@@ -785,6 +812,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           plugins ? '本轮已挂载用户为当前 Bot 授权的插件工具,工具名以 plugin_ 开头,说明中包含实际服务和操作。仅按用户当前任务使用;连接或重新授权应用请让用户打开 Bot 模式的工具 → 已连接应用。不要索取 API Key 或在回复中展示凭据。' : '',
           desktop ? DESKTOP_ENVIRONMENT_RULES+(this.desktopEnvironments?.onlineHostsLine(owner)??'') : '',
           cloud ? grokComputerRules() : '',
+          dispatchedVm ? VM_COMPUTER_RULES : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
         ]
           .filter(Boolean)

+ 2 - 2
src/server/workspaceStore.ts

@@ -481,7 +481,7 @@ export class WorkspaceStore {
       }
       if(patch.envs){
         patch.computer=deriveComputer({vm:patch.envs.vm===true,cloud:patch.envs.cloud===true,desktop:patch.envs.desktop===true,browser:patch.envs.browser===true})
-        patch.execution=patch.computer==='vm'?'computer':'profile'
+        patch.execution='profile'
       }
       const desktopHostChanged = patch.desktopHost !== undefined && patch.desktopHost !== (agent.desktopHost ?? null)
       if(patch.vmExecution==='profile'&&patch.allowHostEnvironment===false)throw new HttpError(400,'本机协作模式需要本机环境,请选择虚拟机模式','computer_profile_host_required')
@@ -501,7 +501,7 @@ export class WorkspaceStore {
         if(sourceChanged&&agent.computerEnvironmentId){const shared=this.require<{nodeId:string;profile:string;managedLocalVm?:boolean;managedCompose?:boolean}>(owner,'shared-computer',agent.computerEnvironmentId);if((patch.nodeId??agent.nodeId)!==shared.nodeId||(!shared.managedLocalVm&&!shared.managedCompose&&shared.profile!=='*'&&(patch.profile??agent.profile)!==shared.profile))throw new HttpError(409,'请先解除电脑共享,再切换到其他来源','shared_computer_active')}
       }
       if(agent.temporaryGoalId&&(Object.keys(patch).some(key=>key!=='archived')||patch.archived===false))throw new HttpError(409,'临时助手由所属任务管理,不能修改权限或恢复为持久成员','helper_task_bound')
-      if(agent.computerEnvironmentId&&patch.execution==='profile')throw new HttpError(409,'请先解除电脑共享,再切换执行环境','shared_computer_active')
+      if(agent.computerEnvironmentId&&patch.execution==='profile'&&(patch.envs?.vm??agent.envs?.vm)!==true)throw new HttpError(409,'请先解除电脑共享,再切换执行环境','shared_computer_active')
       if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['running','waiting','uncertain'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再切换执行环境','agent_execution_busy')
       if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived' && key !== 'approvalPolicy'))
         throw new HttpError(409, '引用机器人的配置由远端管理', 'remote_agent_read_only')

+ 4 - 4
tests/server/agentEnvs.test.ts

@@ -31,18 +31,18 @@ it('derives parallel environments from legacy single choices and auto',()=>{
   expect(deriveComputer({})).toBe('off')
 })
 
-it('writes envs through computer-selection and projects computer/execution for older clients',async()=>{
+it('writes envs through computer-selection and projects computer for older clients while the chat stays on the server',async()=>{
   const cloud=new GrokCloud(store,auth,nodes,{assertLocalVmIdle:()=>{}} as any,vi.fn(async()=>new Response('{}')))
   const agent=store.createAgent('owner',{name:'并行机器人',profile:'default',computer:'auto'})
   const path=`/api/app/agents/${agent.id}/computer-selection`
   const first=await request(app(cloud)).put(path).send({envs:{vm:true,cloud:true,desktop:true}}).expect(200)
-  expect(first.body.agent).toMatchObject({envs:{vm:true,cloud:true,desktop:true},computer:'vm',execution:'computer'})
+  expect(first.body.agent).toMatchObject({envs:{vm:true,cloud:true,desktop:true},computer:'vm',execution:'profile'})
   const cleared=await request(app(cloud)).put(path).send({envs:{}}).expect(200)
   expect(cleared.body.agent).toMatchObject({envs:{},computer:'off',execution:'profile'})
   const desktopOnly=await request(app(cloud)).put(path).send({envs:{desktop:true}}).expect(200)
   expect(desktopOnly.body.agent).toMatchObject({envs:{desktop:true},computer:'local',execution:'profile',allowHostEnvironment:false})
   await request(app(cloud)).put(path).send({envs:{vm:true},desktopHost:'local'}).expect(200)
-  expect(store.require<any>('owner','agent',agent.id)).toMatchObject({envs:{vm:true},desktopHost:'local',computer:'vm',execution:'computer'})
+  expect(store.require<any>('owner','agent',agent.id)).toMatchObject({envs:{vm:true},desktopHost:'local',computer:'vm',execution:'profile'})
 })
 
 it('raw computer writes on an envs agent only toggle the VM leg',async()=>{
@@ -51,7 +51,7 @@ it('raw computer writes on an envs agent only toggle the VM leg',async()=>{
   store.updateAgent('owner',agent.id,{computer:'off'})
   expect(store.require<any>('owner','agent',agent.id)).toMatchObject({envs:{vm:false,cloud:true,desktop:true},computer:'cloud',execution:'profile'})
   store.updateAgent('owner',agent.id,{computer:'vm'})
-  expect(store.require<any>('owner','agent',agent.id)).toMatchObject({envs:{vm:true,cloud:true,desktop:true},computer:'vm',execution:'computer'})
+  expect(store.require<any>('owner','agent',agent.id)).toMatchObject({envs:{vm:true,cloud:true,desktop:true},computer:'vm',execution:'profile'})
 })
 it('selects the cloud environment in parallel regardless of desktop readiness',async()=>{
   const cloud=new GrokCloud(store,auth,nodes,{assertLocalVmIdle:()=>{}} as any,vi.fn(async()=>new Response('{}')))

+ 2 - 2
tests/server/desktopEnvironments.test.ts

@@ -33,13 +33,13 @@ it('adds an authorized native desktop to hybrid tools while leaving the default
 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});const p=setInterval(()=>{const value=service.exchange({host,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5);pollers.add(p);poller=p}
 const HOST_A='11111111-aaaa-4bbb-8ccc-111111111111',HOST_B='22222222-aaaa-4bbb-8ccc-222222222222'
 function driveRemote(id:string,hostInfo:any,handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('remote frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.remoteExchange(id,{host:hostInfo,results});const p=setInterval(()=>{const value=service.remoteExchange(id,{host:hostInfo,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5);pollers.add(p);poller=p}
-it('defaults bot work to the server desktop and falls back to a legacy binding only when it is away',async()=>{
+it('defaults bot work to the customer machine and uses that binding while the server desktop is also online',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  const remoteHost={...host,name:'客厅 Mac Pro'}
  driveRemote(HOST_A,remoteHost)
  drive()
  const state=(await request(app()).get(base()+'/desktop-environment')).body
- expect(state.hostName??state.host?.name).toBe('测试 Mac')
+ expect(state.hostName??state.host?.name).toBe('客厅 Mac Pro')
  const named=(await request(app()).get(base()+'/computer?backend=desktop&host='+encodeURIComponent('客厅 Mac Pro'))).body
  expect(named.hostName).toBe('客厅 Mac Pro')
  vi.useFakeTimers()

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

@@ -228,3 +228,11 @@ it('keeps cloud tools paused after a lost human lease until a new explicit handb
  await request(app.callback()).post(base+'/giveback').send({controlId:renewed.body.controlId,token:renewed.body.token,notes:'已完成登录'})
  expect(await work).toMatchObject({handoffNote:'已完成登录'});expect(counts().exec).toBe(1)
 })
+
+it('keeps the chat on the server profile when the virtual environment is checked',async()=>{
+ const {cloud}=fakeCloud(),agent=store.createAgent('owner',{name:'虚拟环境外派',profile:'default',computer:'off',execution:'profile'})
+ 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 response=await request(app.callback()).put(`/api/app/agents/${agent.id}/computer-selection`).send({envs:{vm:true,cloud:false,desktop:true}}).expect(200)
+ expect(response.body.agent).toMatchObject({execution:'profile',computer:'vm'})
+ expect(response.body.agent.envs).toMatchObject({vm:true,desktop:true,cloud:false})
+})