소스 검색

将本机环境改为虚拟机下的可选开关并在切换时清除

Samien 1 개월 전
부모
커밋
5a464ca462

+ 6 - 5
docs/local-vm.md

@@ -17,16 +17,17 @@
 
 ## 同一 Bot 使用本机和虚拟机
 
-Web/桌面端的 Bot 电脑面板可选择 **本机 + 虚拟机**。此选项同时提供两套工具,现有“本地虚拟机”选项继续只提供隔离电脑工具。
+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_copy_file` 在两个环境之间显式复制文件,支持二进制文件,单文件最多 25 MiB。目标同名文件会被覆盖。
-- 已连接且已授权的 macOS 桌面可同时提供本机桌面工具。未授权屏幕控制时,本机命令与文件工具仍可用;电脑面板默认显示虚拟机画面。
+- 云虚拟机勾选后,本轮规则允许同时使用基础 Profile 已授权的本机文件和终端工具;云端操作继续使用 `cloud_computer_*`。取消勾选后,本轮规则要求电脑操作使用云端工具;Profile 本身的工具配置不被修改。
+- 勾选后,已连接且已授权的 macOS 桌面可同时提供本机桌面工具。未授权屏幕控制时,本机命令与文件工具仍可用;电脑面板默认显示虚拟机画面。
 
-环境选择按 Bot 保存,任务执行或人工接管期间不能切换;下一轮采用当前授权,不会把本机工具带入已切回的仅虚拟机模式。Bot 创建的成员和临时助手不会自动获得本机工具。外部 Runner 必须支持 `host-computer-tools-v1`;“本机”指执行节点主机,可能与打开 Web 的电脑不同。Compose 模式先选择已有桌面,再勾选 **同时使用本机环境**,不改变固定桌面数量和生命周期。
+环境选择按 Bot 保存,任务执行或人工接管期间不能切换;下一轮采用当前授权,不会把本机工具带入已切回的仅虚拟机模式。Bot 创建的成员和临时助手不会自动获得本机工具。外部 Runner 必须支持 `host-computer-tools-v1`;“本机”指执行节点主机,可能与打开 Web 的电脑不同。Compose 模式先选择已有桌面,再勾选 **允许本机环境**,不改变固定桌面数量和生命周期。
 
-空闲策略接口为管理员专用 `PUT /api/app/admin/local-vm/idle-policy`,参数 `requestId` 和 `idleStopMinutes`(0 表示永不,1–10080 表示分钟)。Bot 双环境沿用 `PUT /api/app/agents/:id/computer-selection`,设置 `computer: hybrid`。
+空闲策略接口为管理员专用 `PUT /api/app/admin/local-vm/idle-policy`,参数 `requestId` 和 `idleStopMinutes`(0 表示永不,1–10080 表示分钟)。Bot 双环境沿用 `PUT /api/app/agents/:id/computer-selection`,传入所选 `computer: vm | cloud` 与独立的 `allowHostEnvironment: true | false`。其他电脑类型强制清除此布尔值;旧 `hybrid` 记录在启动时一次性迁移为 `computer: vm` 和 `allowHostEnvironment: true`。
 
 切换共享方式不会修改普通 Profile 成员,也不会因其聊天任务而被阻止。已经交还控制权的空闲桌面可保持运行;虚拟机任务仍在执行、人工控制尚未交还或停止状态待核对时,继续保护原执行并拒绝切换。成员任务造成阻挡时,提示会显示具体成员名称。
 

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 13 - 7
src/client/components/workspace/LocalVmChatPanel.vue


+ 2 - 2
src/server/desktopEnvironments.ts

@@ -6,7 +6,7 @@ import {HttpError} from './errors.js'
 import {parse,type WorkspaceStore} from './workspaceStore.js'
 import type {LocalAuthStore} from './localAuth.js'
 import type {WorkspaceNodes,GatewayTarget} from './workspaceGateway.js'
-import type {WorkspaceAgent} from '../shared/workspace.js'
+import {allowsHostEnvironment,type WorkspaceAgent} from '../shared/workspace.js'
 import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
 import {requireTeamToolBridge} from './workspaceToolLease.js'
 
@@ -58,7 +58,7 @@ export class DesktopEnvironments {
  private ownerKey(owner:string){return digest((this.nodes.localNodeID??this.nodes.local.url.toString())+':'+owner)}
  private ready(owner:string){return this.online&&this.host!.platform==='darwin'&&this.host!.screen&&this.host!.accessibility&&this.host!.approved.includes(this.ownerKey(owner))}
  selected(owner:string,agent:WorkspaceAgent):Mode|undefined {if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)return undefined;if(agent.computer==='local'||agent.computer==='browser')return agent.computer;if(agent.computer==='auto'&&agent.execution!=='computer'&&this.ready(owner))return 'local';return undefined}
- toolMode(owner:string,agent:WorkspaceAgent):Mode|undefined {if(!agent.archived&&!agent.remoteAgentId&&!agent.temporaryGoalId&&agent.computer==='hybrid'&&this.ready(owner))return 'local';return this.selected(owner,agent)}
+ toolMode(owner:string,agent:WorkspaceAgent):Mode|undefined {if(allowsHostEnvironment(agent)&&this.ready(owner))return 'local';return this.selected(owner,agent)}
  private agent(owner:string,id:string){const a=this.store.require<WorkspaceAgent>(owner,'agent',id);this.nodes.requireSource(owner,a);if(a.archived||a.remoteAgentId||a.temporaryGoalId)throw new HttpError(409,'此机器人无法使用桌面环境','computer_unavailable');return a}
  private resource(owner:string,agent:WorkspaceAgent,mode:Mode){return mode==='local'?'local':digest(this.ownerKey(owner)+':'+agent.id+':'+(agent.browserProfile??'persistent'))}
  state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent);return {online:this.online,host:this.online?{name:this.host!.name,platform:this.host!.platform}:null,local:{supported:this.online&&this.host!.platform==='darwin',authorized:this.online&&this.host!.approved.includes(this.ownerKey(owner)),screen:this.online&&this.host!.screen,accessibility:this.online&&this.host!.accessibility,ready:this.ready(owner)},browser:{available:this.online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent)}}

+ 12 - 5
src/server/grokCloud.ts

@@ -28,6 +28,11 @@ export const GROK_COMPUTER_TOOLS=[
   {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 function grokComputerRules(allowHostEnvironment:boolean){
+  return allowHostEnvironment
+    ? '当前电脑为 Grok Bot 云端共享虚拟机,工作目录 /workspace;云端操作必须使用 cloud_computer_* 工具。用户同时勾选了“允许本机环境”,可按任务需要使用基础 Profile 已授权的本机文件和终端工具,以及另行授权的本机桌面工具。调用前明确目标环境,本机与云端的路径、程序、文件和浏览器资料互不通用。多个机器人共享云端工作文件和浏览器登录,避免覆盖其他机器人的工作;人工接管时等待交还。'
+    : GROK_COMPUTER_RULES+' 用户未勾选“允许本机环境”:本轮不要使用本机终端、本机文件或本机桌面完成电脑操作。'
+}
 
 /** Credentials and network tokens stay on the Web server. Every robot of an
  * account addresses the same existing cloud computer; robots keep their own
@@ -182,12 +187,14 @@ export class GrokCloud {
       if(!token)throw new HttpError(400,'请先通过浏览器完成 Grok Bot 授权。','grok_login_missing');ctx.body=await this.configure(owner,token,body.version,refreshToken)
     })
     router.put('/api/app/agents/:id/computer-selection',async ctx=>{
-      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),body=parse(z.object({computer:z.enum(['auto','cloud','vm','hybrid','local','browser','off'])}).strict(),(ctx.request as any).body)
+      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),body=parse(z.object({computer:z.enum(['auto','cloud','vm','local','browser','off']),allowHostEnvironment:z.boolean().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')
-      if(body.computer==='hybrid')this.nodes.targetForAgent(owner,{...agent,execution:'computer',computer:'hybrid'})
-      if(body.computer==='cloud')await this.requireAvailable(owner,agent,this.nodes.target(owner,agent.nodeId))
-      this.store.atomic(()=>{const execution=['vm','hybrid'].includes(body.computer)?'computer':body.computer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId)this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{computer:body.computer,execution});const current=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,current);this.store.put(owner,'agent',agent.id,current)})
-      if(body.computer==='cloud')await this.connect(owner)
+      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})
+      const enteringCloud=body.computer==='cloud'&&agent.computer!=='cloud'
+      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)})
+      if(enteringCloud)await this.connect(owner)
       ctx.body={agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
     })
     router.get('/api/app/agents/:id/cloud-computer',async ctx=>{const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id);ctx.body={...await this.state(owner),selected:this.selected(owner,agent),connected:!!this.descriptors.get(owner)&&this.descriptors.get(owner)!.expiresAt>Date.now(),shared:true,...await this.status(owner)}})

+ 1 - 1
src/server/runnerHub.ts

@@ -153,7 +153,7 @@ export class RunnerHub {
               scope?.authorize()
               if(computer?.hostAccess){
                 const agent=this.store.get<import('../shared/workspace.js').WorkspaceAgent>(owner,'agent',computer.agentId)
-                if(!agent||agent.archived||agent.temporaryGoalId||agent.computer!=='hybrid'||agent.execution!=='computer')return false
+                if(!agent||agent.archived||agent.temporaryGoalId||agent.allowHostEnvironment!==true||(agent.computer!=='vm'&&agent.computer!==undefined)||agent.execution!=='computer')return false
               }
               return (scope?.cleanupOnly||(this.auth.isUserActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version))&&this.store.get<RunnerRecord>('_system','runner',record.id)?.enabled===true
             }catch{return false}

+ 1 - 1
src/server/sharedComputers.ts

@@ -37,7 +37,7 @@ export class SharedComputers {
     group.archived=false;if(!group.memberIds.includes(agent.id))group.memberIds.push(agent.id)
     this.store.put('_system','compose-desktop-owner',desktop.id,{owner,runnerId})
     this.store.put(owner,'shared-computer',group.id,group)
-    agent.execution='computer';agent.computerEnvironmentId=desktop.id;agent.computerEnvironmentName=desktop.name
+    agent.execution='computer';agent.computer='vm';agent.computerEnvironmentId=desktop.id;agent.computerEnvironmentName=desktop.name
     if(persist){agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)}
   }
   attachLocalVm(owner:string,agent:WorkspaceAgent,runnerId:string){

+ 2 - 2
src/server/workspaceGateway.ts

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

+ 3 - 3
src/server/workspaceRuntime.ts

@@ -1,5 +1,5 @@
 import {DESKTOP_ENVIRONMENT_TOOLS,DESKTOP_ENVIRONMENT_RULES,type DesktopEnvironments} from './desktopEnvironments.js'
-import {GROK_COMPUTER_TOOLS,GROK_COMPUTER_RULES,type GrokCloud} from './grokCloud.js'
+import {GROK_COMPUTER_TOOLS,grokComputerRules,type GrokCloud} from './grokCloud.js'
 import { randomUUID } from 'node:crypto'
 import { readFileSync } from 'node:fs'
 import { z } from 'zod'
@@ -212,7 +212,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     },authorize:()=>{
       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||(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.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})
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
@@ -632,7 +632,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           team ? TEAM_TOOL_RULES : '',
           plugins ? '本轮已挂载用户为当前 Bot 授权的插件工具,工具名以 plugin_ 开头,说明中包含实际服务和操作。仅按用户当前任务使用;连接或重新授权应用请让用户打开 Bot 模式的工具 → 已连接应用。不要索取 API Key 或在回复中展示凭据。' : '',
           desktop ? DESKTOP_ENVIRONMENT_RULES : '',
-          cloud ? GROK_COMPUTER_RULES : '',
+          cloud ? grokComputerRules(agent.computer==='cloud'&&agent.allowHostEnvironment===true) : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
         ]
           .filter(Boolean)

+ 18 - 5
src/server/workspaceStore.ts

@@ -8,6 +8,7 @@ import { HttpError } from './errors.js'
 import { isVisibleMessageFile } from '../shared/messageFiles.js'
 import type { StoredWorkspaceFile } from './workspaceAssets.js'
 import { notificationPlainText } from './notificationText.js'
+import {supportsHostEnvironment} from '../shared/workspace.js'
 import type { WorkspaceLifecycleAction, WorkspaceLifecyclePreview } from '../shared/workspaceLifecycle.js'
 import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, encodeAgentAvatar, normalizeAvatar, randomAgentIdentity, MAX_AVATAR_DESCRIPTOR_LENGTH } from '../shared/agentIdentity.js'
 import type {
@@ -39,7 +40,8 @@ export const agentInput = z
     avatar: avatar.default(''),
     instructions: z.string().max(24_000).default(''),
     execution:z.enum(['profile','computer']).default('profile'),
-    computer:z.enum(['auto','cloud','vm','hybrid','local','browser','off']).optional(),
+    computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
+    allowHostEnvironment:z.boolean().default(false),
     browserProfile:z.enum(['persistent','temporary']).optional(),
     canManageTeam: z.boolean().default(false),
     nodeId: z.string().default('local'),
@@ -52,7 +54,8 @@ export const agentPatch = z
     avatar: avatar.optional(),
     instructions: z.string().max(24_000).optional(),
     execution:z.enum(['profile','computer']).optional(),
-    computer:z.enum(['auto','cloud','vm','hybrid','local','browser','off']).optional(),
+    computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
+    allowHostEnvironment:z.boolean().optional(),
     browserProfile:z.enum(['persistent','temporary']).optional(),
     nodeId:z.string().min(1).max(256).optional(),
     profile:z.string().min(1).max(256).optional(),
@@ -209,6 +212,15 @@ export class WorkspaceStore {
       }
       this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
     })
+    this.atomic(() => {
+      const migration='host-environment-option-v1'
+      if(this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get(migration))return
+      for(const row of this.db.prepare("SELECT owner,id,data FROM workspace_entities WHERE kind='agent' AND json_extract(data,'$.computer')='hybrid'").all()){
+        const agent=JSON.parse(String(row.data))
+        this.put(String(row.owner),'agent',String(row.id),{...agent,computer:'vm',execution:'computer',allowHostEnvironment:!agent.temporaryGoalId&&!agent.remoteAgentId,revision:(agent.revision??0)+1})
+      }
+      this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
+    })
   }
   private hasUnreadTask(owner: string, conversationId: string): boolean {
     return !!this.db.prepare("SELECT 1 FROM workspace_entities WHERE owner=? AND kind='conversation-task' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.unread')=1 LIMIT 1").get(owner, conversationId)
@@ -327,7 +339,7 @@ export class WorkspaceStore {
   }
   createAgent(owner: string, input: unknown, origin?: { createdByAgentId: string; createdFromRunId: string;temporaryGoalId?:string;helperActivation?:number;helperRunnerId?:string }): Agent {
     const body = parse(agentInput, input)
-    if(body.computer==='hybrid')body.execution='computer'
+    if(!supportsHostEnvironment(body)||origin)body.allowHostEnvironment=false
     if (origin) this.require<Agent>(owner, 'agent', origin.createdByAgentId)
     return this.atomic(() => {
       if (
@@ -381,12 +393,13 @@ export class WorkspaceStore {
   }
   updateAgent(owner: string, id: string, input: unknown): Agent {
     const patch = parse(agentPatch, input)
-    if(patch.computer==='hybrid')patch.execution='computer'
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
+      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 destinationChanged=(patch.computer!==undefined&&patch.computer!==agent.computer)||(patch.browserProfile!==undefined&&patch.browserProfile!==(agent.browserProfile??'persistent'))
+      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'))
       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<{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 empty = z.object({}).strict()
-const createAgent = agentInput.omit({ canManageTeam: true }).extend({ computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),requestId: uuid,execution:z.enum(['profile','computer']).optional() }).strict()
+const createAgent = agentInput.omit({ canManageTeam: true, allowHostEnvironment: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 createTeam = z.object({
   requestId: uuid,

+ 8 - 1
src/shared/workspace.ts

@@ -6,7 +6,8 @@ export interface WorkspaceAgent {
   instructions: string
   /** Explicit account-owner grant; absent on older records means disabled. */
   execution?: 'profile' | 'computer'
-  computer?: 'auto' | 'cloud' | 'vm' | 'hybrid' | 'local' | 'browser' | 'off'
+  computer?: 'auto' | 'cloud' | 'vm' | 'local' | 'browser' | 'off'
+  allowHostEnvironment?: boolean
   browserProfile?: 'persistent' | 'temporary'
   computerEnvironmentId?:string
   computerEnvironmentName?:string
@@ -27,6 +28,12 @@ export interface WorkspaceAgent {
   createdAt: number
   updatedAt: number
 }
+export function supportsHostEnvironment(agent: Pick<WorkspaceAgent,'computer'|'execution'>):boolean {
+  return agent.computer==='vm'||agent.computer==='cloud'||(!agent.computer&&agent.execution==='computer')
+}
+export function allowsHostEnvironment(agent: Pick<WorkspaceAgent,'computer'|'execution'|'allowHostEnvironment'|'archived'|'remoteAgentId'|'temporaryGoalId'>):boolean {
+  return agent.allowHostEnvironment===true&&supportsHostEnvironment(agent)&&!agent.archived&&!agent.remoteAgentId&&!agent.temporaryGoalId
+}
 export interface WorkspaceMemberRole {
   name: string
   description: string

+ 22 - 4
tests/client/localVmImages.test.ts

@@ -22,7 +22,7 @@ it('saves never-stop and restores the selected value after refresh',async()=>{
   expect(wrapper.text()).toContain('空闲时保持运行')
  }finally{wrapper.unmount()}
 })
-it('offers hybrid mode without requiring native screen permissions for host file tools',async()=>{
+it('offers a host environment checkbox without a combined environment tab',async()=>{
  vi.spyOn(document,'hidden','get').mockReturnValue(false)
  vi.mocked(apiRequest).mockImplementation(async path=>{
   if(path.endsWith('/desktop-environment'))return {selected:null,local:{supported:false},browser:{available:false}} as any
@@ -31,8 +31,26 @@ it('offers hybrid mode without requiring native screen permissions for host file
  })
  const wrapper=mount(Panel,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs:{AppIcon:true,GrokAuthPanel:true}}})
  try{
-  await flushPromises();await wrapper.get('[role="tab"][aria-label="本机 + 虚拟机"]').trigger('click');await flushPromises()
-  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{computer:'hybrid'}})
+  await flushPromises();expect(wrapper.find('[role="tab"][aria-label="本机 + 虚拟机"]').exists()).toBe(false);await wrapper.get('input[type="checkbox"]').setValue(true);await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{computer:'vm',allowHostEnvironment:true}})
+ }finally{wrapper.unmount()}
+})
+it('offers the checkbox for cloud selection and hides it for other environments',async()=>{
+ vi.spyOn(document,'hidden','get').mockReturnValue(false)
+ vi.mocked(apiRequest).mockImplementation(async path=>{
+  if(path.endsWith('/desktop-environment'))return {selected:null,local:{supported:false},browser:{available:false}} as any
+  if(path.endsWith('/cloud-computer'))return {configured:true,running:false} as any
+  return {...vm,enabled:false,container:'missing',ready:false,inUse:false} as any
+ })
+ const cloudAgent={...agent,computer:'cloud',execution:'profile'}
+ const wrapper=mount(Panel,{props:{agents:[cloudAgent],isAdmin:true,active:true},global:{stubs:{AppIcon:true,GrokAuthPanel:true}}})
+ try{
+  await flushPromises();await wrapper.get('input[type="checkbox"]').setValue(true);await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{computer:'cloud',allowHostEnvironment:true}})
+  for(const computer of ['auto','off','local','browser']){
+   await wrapper.setProps({agents:[{...cloudAgent,computer}]});await flushPromises()
+   expect(wrapper.find('.host-environment-option').exists()).toBe(false)
+  }
  }finally{wrapper.unmount()}
 })
 it('keeps Compose lifecycle controls hidden while enabling host tools for an already assigned desktop',async()=>{
@@ -49,7 +67,7 @@ it('keeps Compose lifecycle controls hidden while enabling host tools for an alr
   await flushPromises();expect(settings.find('[aria-label="虚拟机空闲停止时间"]').exists()).toBe(false)
   expect(wrapper.findAll('button').some(b=>b.text()==='停止虚拟机')).toBe(false)
   await wrapper.get('input[type="checkbox"]').setValue(true);await flushPromises()
-  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{computer:'hybrid'}})
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{computer:'vm',allowHostEnvironment:true}})
  }finally{settings.unmount();wrapper.unmount()}
 })
 it('prepares the selected image in Settings and explains the shared image constraint',async()=>{

+ 25 - 9
tests/e2e/team-tools.spec.ts

@@ -205,7 +205,7 @@ async function login(page:any,ready=false){
  await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
  await page.request.post('/__test/local-vm'+(ready?'?ready=1':''),{data:{}})
 }
-test('persists idle-stop settings and selects both host and VM on desktop and mobile',async({page},testInfo)=>{
+test('persists idle-stop settings and the host environment checkbox on desktop and mobile',async({page},testInfo)=>{
  await login(page,true)
  await page.getByRole('button',{name:'设置与模式',exact:true}).first().click();await page.getByRole('menuitem',{name:'设置',exact:true}).click()
  const settings=page.getByRole('dialog',{name:'设置中心'});await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
@@ -218,16 +218,32 @@ test('persists idle-stop settings and selects both host and VM on desktop and mo
  await page.request.post('/__test/hybrid-runner',{data:{}})
  const seed=await(await page.request.post('/__test/computer?off=1',{data:{}})).json()
  await page.goto(`/conversations/${seed.conversationId}`);await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click()
- const dock=page.getByRole('complementary',{name:'机器人电脑面板'}),hybrid=dock.getByRole('tab',{name:'本机 + 虚拟机',exact:true})
- await hybrid.click();await expect(hybrid).toHaveAttribute('aria-selected','true')
- expect((await(await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===seed.agentId)).toMatchObject({computer:'hybrid',execution:'computer'})
+ const dock=page.getByRole('complementary',{name:'机器人电脑面板'}),hostOption=dock.getByRole('checkbox',{name:'允许本机环境',exact:true})
+ await expect(dock.getByRole('tab',{name:'本机 + 虚拟机',exact:true})).toHaveCount(0)
+ await hostOption.check();await expect(hostOption).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:true})
  await expect(dock).toContainText('永不自动停止')
- await hybrid.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('hybrid-desktop.png')})
- await page.reload();await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click();await expect(hybrid).toHaveAttribute('aria-selected','true')
- await page.setViewportSize({width:390,height:844});await hybrid.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('hybrid-mobile.png')})
+ await hostOption.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('hybrid-desktop.png')})
+ await page.reload();await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click();await expect(hostOption).toBeChecked()
+ await page.setViewportSize({width:390,height:844});await hostOption.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('hybrid-mobile.png')})
  expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
- await dock.getByRole('tab',{name:'本地虚拟机',exact:true}).click();await expect(hybrid).toHaveAttribute('aria-selected','false')
- expect((await(await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===seed.agentId)).toMatchObject({computer:'vm',execution:'computer'})
+ await dock.getByRole('tab',{name:'不使用电脑',exact:true}).click();await expect(hostOption).toHaveCount(0)
+ 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})
+})
+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)
+ const seed=await(await page.request.post('/__test/cloud-host-option',{data:{}})).json()
+ await page.goto(`/conversations/${seed.conversationId}`);await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click()
+ const dock=page.getByRole('complementary',{name:'机器人电脑面板'}),option=dock.getByRole('checkbox',{name:'允许本机环境',exact:true}),cloud=dock.getByRole('tab',{name:'云端 · Grok Bot',exact:true})
+ await expect(cloud).toHaveAttribute('aria-selected','true');await expect(option).not.toBeChecked();await option.check()
+ await expect(option).toBeChecked();await expect(cloud).toHaveAttribute('aria-selected','true')
+ expect((await(await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===seed.agentId)).toMatchObject({computer:'cloud',execution:'profile',allowHostEnvironment:true})
+ await option.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('cloud-host-option.png')})
+ await page.reload();await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click();await expect(option).toBeChecked()
+ await dock.getByRole('tab',{name:'自动',exact:true}).click();await expect(option).toHaveCount(0)
+ expect((await(await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===seed.agentId).allowHostEnvironment).toBe(false)
+ await cloud.click();await expect(option).not.toBeChecked()
 })
 
 test('Local VM settings replace the image catalog and prepare the managed desktop',async({page},testInfo)=>{

+ 6 - 0
tests/fixtures/workspace-server.ts

@@ -446,6 +446,12 @@ runtime.runners.localVm=async(owner,id,p)=>{
 }
 runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/local-vm')return next();const owner=auth.requireAdmin(ctx).id;vmFixture.image=ctx.query.ready==='1';vmFixture.mode='per-bot';vmFixture.maxInstances=2;if(!vmRunnerId){for(const record of runtime.runners.records())if(record.sourceNodeId==='local'&&record.enabled)runtime.runners.remove(record.id);vmRunnerId=runtime.runners.enroll(owner,{name:'本机虚拟机',allowedProfiles:['default']}).runner.id;}ctx.body={runnerId:vmRunnerId}})
 runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/hybrid-runner')return next();auth.requireAdmin(ctx);(runtime.runners as any).online.set(vmRunnerId,{instance:randomUUID(),epoch:'fixture',seen:Date.now(),features:['computer-worker-v1','computer-control-v1','local-vm-v1','image-ready-v1','host-computer-tools-v1','idle-stop-policy-v1']});ctx.body={ok:true}})
+runtime.app.use((ctx,next)=>{
+ 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)
+ const agent=runtime.workspace.createAgent(owner,{name:'云虚拟机本机选项',computer:'cloud',profile:'default'})
+ ctx.body={agentId:agent.id,conversationId:runtime.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!.id}
+})
 runtime.app.use(serve(resolve('dist')))
 runtime.app.use((ctx) => {
   if (!ctx.path.startsWith('/api/')) {

+ 2 - 1
tests/server/composeDesktops.test.ts

@@ -39,10 +39,11 @@ it('locks Compose capacity and binds existing desktops without allowing cross-ac
  try{
   expect(await service.status('alice')).toMatchObject({fixedCapacity:true,maxInstances:2,mode:'shared'})
   for(const [verb,path,body] of [['post','/prepare',{requestId:randomUUID()}],['put','/policy',{requestId:randomUUID(),maxInstances:4,mode:'per-bot'}],['post',`/instances/${desktops[0]!.id}/remove`,{requestId:randomUUID()}]] as const){await request(app.callback())[verb]('/api/app/admin/local-vm'+path).set('x-admin','yes').send(body).expect(409,{code:'compose_desktop_managed'})}
-  const first=store.createAgent('alice',{name:'研究员',profile:'default'}),second=store.createAgent('alice',{name:'写作者',profile:'writer'}),other=store.createAgent('bob',{name:'其他账号',profile:'default'})
+  const first=store.createAgent('alice',{name:'研究员',profile:'default',computer:'cloud',allowHostEnvironment:true}),second=store.createAgent('alice',{name:'写作者',profile:'writer'}),other=store.createAgent('bob',{name:'其他账号',profile:'default'})
   const bind=(id:string,desktopId:string,owner='alice')=>request(app.callback()).put(`/api/app/agents/${id}/local-vm`).set('x-owner',owner).send({enabled:true,desktopId})
   await bind(first.id,randomUUID()).expect(400)
   await bind(first.id,desktops[0]!.id).expect(200);await bind(second.id,desktops[0]!.id).expect(200)
+  expect(store.require('alice','agent',first.id)).toMatchObject({computer:'vm',execution:'computer',allowHostEnvironment:true})
   await request(app.callback()).put(`/api/app/agents/${first.id}/local-vm/image`).send({imageKey:'cursor'}).expect(409,{code:'compose_desktop_managed'})
   for(const id of [first.id,second.id])expect(()=>nodes.requireSource('alice',store.require('alice','agent',id))).not.toThrow()
   await bind(other.id,desktops[0]!.id,'bob').expect(403)

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

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

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

@@ -12,7 +12,7 @@ import {WorkspaceNodes} from '../../src/server/workspaceGateway'
 import {loadServerConfig} from '../../src/server/config'
 import {WorkspaceRoutines,nextRoutineAt} from '../../src/server/workspaceRoutines'
 import {WorkspaceInspector,redactInspector} from '../../src/server/workspaceInspector'
-import {GrokCloud} from '../../src/server/grokCloud'
+import {GrokCloud,grokComputerRules} from '../../src/server/grokCloud'
 
 let home:string,store:WorkspaceStore,nodes:WorkspaceNodes
 const auth={require:(ctx:any)=>({id:ctx.get('x-user')||'owner',role:'admin'}),requireAdmin:(ctx:any)=>({id:ctx.get('x-user')||'owner',role:'admin'}),isUserActive:()=>true,pushAuthorizationVersion:()=>1} as any
@@ -112,22 +112,54 @@ function fakeCloud(){
  const token='header.'+Buffer.from(JSON.stringify({sub:'fixture',exp:Math.floor(Date.now()/1000)+3600})).toString('base64url')+'.signature'
  return {cloud,token,fetcher,counts:()=>({ensure,exec})}
 }
-it('persists hybrid selection with an explicit Runner grant and refuses changes during active work',async()=>{
+it('persists the separate host option, checks the Runner grant and refuses changes during active work',async()=>{
  const {cloud}=fakeCloud(),agent=store.createAgent('owner',{name:'双环境',profile:'default',computer:'vm',execution:'computer'})
  const target=vi.fn(()=>({runner:{id:'fixture'}} as any));nodes.runnerTarget=target
  const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(error:any){ctx.status=error.status??500;ctx.body={error:error.message}}});app.use(bodyParser());app.use(cloud.router().routes())
  const path=`/api/app/agents/${agent.id}/computer-selection`
- const response=await request(app.callback()).put(path).send({computer:'hybrid'}).expect(200)
- expect(response.body.agent).toMatchObject({computer:'hybrid',execution:'computer'})
+ const response=await request(app.callback()).put(path).send({computer:'vm',allowHostEnvironment:true}).expect(200)
+ expect(response.body.agent).toMatchObject({computer:'vm',execution:'computer',allowHostEnvironment:true})
  expect(target).toHaveBeenCalledWith('owner','local',expect.objectContaining({agentId:agent.id,hostAccess:true}))
- await request(app.callback()).put(path).set('x-user','other').send({computer:'hybrid'}).expect(404)
+ await request(app.callback()).put(path).set('x-user','other').send({computer:'vm',allowHostEnvironment:true}).expect(404)
  store.put('owner','turn','active-hybrid',{agentId:agent.id,status:'running'})
- await request(app.callback()).put(path).send({computer:'vm'}).expect(409)
- expect(store.require<any>('owner','agent',agent.id).computer).toBe('hybrid')
- store.remove('owner','turn','active-hybrid');await request(app.callback()).put(path).send({computer:'vm'}).expect(200)
+ await request(app.callback()).put(path).send({computer:'vm',allowHostEnvironment:false}).expect(409)
+ expect(store.require<any>('owner','agent',agent.id).allowHostEnvironment).toBe(true)
+ store.remove('owner','turn','active-hybrid');await request(app.callback()).put(path).send({computer:'vm',allowHostEnvironment:false}).expect(200)
  target.mockClear();nodes.targetForAgent('owner',store.require<any>('owner','agent',agent.id))
  expect(target.mock.calls[0]?.[2]).not.toHaveProperty('hostAccess')
 })
+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 available=vi.spyOn(cloud,'requireAvailable').mockResolvedValue(),connect=vi.spyOn(cloud,'connect').mockResolvedValue({} as any)
+ nodes.runnerTarget=()=>({runner:{id:'fixture'}} as any)
+ 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`
+ for(const computer of ['cloud','vm']){
+  const enabled=await request(app.callback()).put(path).send({computer,allowHostEnvironment:true}).expect(200)
+  expect(enabled.body.agent).toMatchObject({computer,allowHostEnvironment:true})
+ }
+ expect(connect).not.toHaveBeenCalled();expect(available).not.toHaveBeenCalled()
+ for(const computer of ['auto','off','local','browser']){
+  store.updateAgent('owner',agent.id,{computer:'vm',execution:'computer',allowHostEnvironment:true})
+  const switched=await request(app.callback()).put(path).send({computer,allowHostEnvironment:true}).expect(200)
+  expect(switched.body.agent.allowHostEnvironment).toBe(false)
+  const returned=await request(app.callback()).put(path).send({computer:'vm'}).expect(200)
+  expect(returned.body.agent.allowHostEnvironment).toBe(false)
+ }
+ await request(app.callback()).put(path).send({computer:'hybrid'}).expect(400)
+ expect(grokComputerRules(true)).toContain('基础 Profile 已授权的本机文件和终端工具')
+ expect(grokComputerRules(false)).toContain('本轮不要使用本机终端')
+})
+it('clears inapplicable host grants through generic updates and migrates the previous combined choice once',()=>{
+ const agent=store.createAgent('owner',{name:'权限迁移',profile:'default',computer:'vm',execution:'computer',allowHostEnvironment:true})
+ expect(store.updateAgent('owner',agent.id,{computer:'auto'}).allowHostEnvironment).toBe(false)
+ store.put('owner','agent',agent.id,{...agent,computer:'hybrid',allowHostEnvironment:undefined})
+ store.db.prepare('DELETE FROM workspace_migrations WHERE id=?').run('host-environment-option-v1')
+ store.close();store=new WorkspaceStore(home)
+ expect(store.require<any>('owner','agent',agent.id)).toMatchObject({computer:'vm',allowHostEnvironment:true})
+ store.updateAgent('owner',agent.id,{computer:'off'});store.close();store=new WorkspaceStore(home)
+ expect(store.require<any>('owner','agent',agent.id)).toMatchObject({computer:'off',allowHostEnvironment:false})
+})
 
 it('auto observation never provisions; multiple robots share one cloud connection with private credentials',async()=>{
  const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.