فهرست منبع

feat(bot): 去掉仅浏览器环境线

脚本机浏览器和仅浏览器不再可选,旧记录按未启用处理。
Samien 1 ماه پیش
والد
کامیت
1589880e7b

+ 1 - 1
src/client/components/app/HostToolsPanel.vue

@@ -30,7 +30,7 @@ onMounted(load)
 
 <template>
   <section class="host-tools" aria-label="服务端工具">
-    <p>控制 Bot 在服务端本机上能不能用终端和文件。默认禁用。已勾选的脚本机、脚本机浏览器、虚拟环境和云虚拟机不受这个开关影响。</p>
+    <p>控制 Bot 在服务端本机上能不能用终端和文件。默认禁用。已勾选的脚本机、虚拟环境和云虚拟机不受这个开关影响。</p>
     <form @submit.prevent="save" @change="changed">
       <label class="toggle"><input v-model="deny" type="checkbox" :disabled="loading || saving" /><span><strong>禁用服务端本机的终端和文件</strong><small>开启后,Bot 对话不会拿到服务端的 terminal 和 file 工具。关掉之后,新开的会话才会放开,已经在跑的会话要重开一次。</small></span></label>
       <p v-if="error" class="error" role="alert">{{ error }}</p>

+ 1 - 1
src/client/components/workspace/BotProfileDialog.vue

@@ -57,7 +57,7 @@ const allPages=computed(()=>[
 ])
 const pages=computed(()=>allPages.value.filter(item=>item.label.toLocaleLowerCase().includes(query.value.trim().toLocaleLowerCase())))
 const activeTitle=computed(()=>allPages.value.find(item=>item.id===page.value)?.label??'概览')
-const environment=computed(()=>props.agent.computer==='cloud'?'云端电脑':props.agent.execution==='computer'||props.agent.computer==='vm'?'本地虚拟机':props.agent.computer==='local'?'本机':props.agent.computer==='browser'?'浏览器':'基础机器人')
+const environment=computed(()=>props.agent.computer==='cloud'?'云端电脑':props.agent.execution==='computer'||props.agent.computer==='vm'?'本地虚拟机':props.agent.computer==='local'?'本机':'基础机器人')
 function selectPage(next:Page){
   if(props.busy||next===page.value)return
   if(modelDirty.value&&!window.confirm('放弃当前模型服务未保存的更改?'))return

+ 4 - 14
src/client/components/workspace/LocalVmChatPanel.vue

@@ -2,7 +2,7 @@
 import {ref,computed,watch,onBeforeUnmount} from 'vue'
 import {apiRequest} from '@/api/client'
 import AppIcon from '@/components/common/AppIcon.vue'
-import type {DesktopEnvironmentState,BotBrowserState} from '@shared/desktopEnvironment'
+import type {DesktopEnvironmentState} from '@shared/desktopEnvironment'
 import GrokAuthPanel from './GrokAuthPanel.vue'
 import {supportsHostEnvironment,agentEnvs,type WorkspaceAgent} from '@shared/workspace'
 import type {ComputerFrame} from '@shared/computerControl'
@@ -17,7 +17,6 @@ const modern=computed(()=>!state.value?.fixedCapacity)
 const envsOverride=ref<Record<'vm'|'cloud'|'desktop'|'browser',boolean>>()
 const envs=computed(()=>{const base=agent.value?agentEnvs(agent.value):{vm:false,cloud:false,desktop:false,browser:false};return envsOverride.value?{...base,...envsOverride.value}:base})
 const showDesktop=computed(()=>modern.value?envs.value.desktop:nativeSelected.value==='local')
-const showBrowser=computed(()=>modern.value?envs.value.browser:nativeSelected.value==='browser')
 watch(()=>agent.value?agentEnvs(agent.value):undefined,base=>{
  if(!envsOverride.value||!base)return
  const keys=Object.keys(envsOverride.value) as (keyof typeof base)[]
@@ -40,12 +39,11 @@ const activeEnvLine=computed(()=>{
  const parts:string[]=[]
  if(modern.value){
   if(envs.value.desktop)parts.push('本机控制')
-  if(envs.value.browser)parts.push('仅浏览器')
   if(envs.value.cloud)parts.push('云端')
   if(envs.value.vm)parts.push('本地虚拟机')
   return parts.join(' · ')
  }
- if(nativeSelected.value)parts.push(nativeSelected.value==='browser'?'仅浏览器':'本机控制')
+ if(nativeSelected.value)parts.push('本机控制')
  if(cloudSelected.value)parts.push('云端')
  if(showVm.value)parts.push('本地虚拟机')
  return parts.join(' · ')
@@ -82,7 +80,6 @@ const computerChoices=computed(()=>[
  {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)},
  {value:'off',label:'不使用电脑',description:'使用基础机器人的其他工具',icon:'stop' as const,disabled:false},
 ])
 const computerChoiceDisabled=computed(()=>loading.value||!state.value||busy.value||state.value.inUse||!!agent.value?.remoteAgentId||!!agent.value?.temporaryGoalId)
@@ -107,8 +104,7 @@ async function refresh(){
   native.value=await apiRequest<DesktopEnvironmentState>(`/api/app/agents/${selected.value}/desktop-environment`);if(version!==revision||closed)return
   let nativeNext:typeof state.value
   if(nativeSelected.value){
-   const n=native.value;let ready=n.local.ready
-   if(nativeSelected.value==='browser'){const b=await apiRequest<BotBrowserState>(`/api/app/agents/${selected.value}/browser`);ready=b.open}
+   const n=native.value;const ready=n.local.ready
    nativeNext={enabled:true,container:ready?'running':'stopped',ready,image:true,mode:'shared',maxInstances:1,inUse:false}
    if(!previewTarget.value){if(ready){const f=await apiRequest<ComputerFrame>(`/api/app/agents/${selected.value}/computer/frame`);if(version===revision&&!closed)frame.value=f}else frame.value=undefined}
   }
@@ -183,7 +179,7 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
     <div class="screen-caption"><span>{{agent.name}}的电脑</span><select v-if="previewTargets.length>1" v-model="previewKey" class="screen-select" aria-label="显示的桌面"><option value="">自动</option><option v-for="target in previewTargets" :key="target.key" :value="target.key">{{target.label}}</option></select><small v-else-if="activeEnvLine">{{activeEnvLine}}{{nativeSelected&&nativeSelected==='local'&&native?.host?.name?' · '+native.host.name:''}}</small></div>
     <button class="preview" :disabled="!frame||busy" :aria-label="`打开${agent.name}的桌面`" @click="openPreview()">
      <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="`${agent.name}的电脑画面`">
-     <span v-else class="empty"><AppIcon name="monitor" :size="26"/><span>{{loading?'正在检查电脑…':previewTarget?previewEmptyText:nativeSelected?(nativeSelected==='browser'?'点击打开机器人的独立浏览器':native?.local.ready?'正在读取本机画面…':'请完成本机控制授权'):!state?.enabled?(computerChoice==='auto'?'暂无可用电脑':'未使用电脑'):!state.image?(cloudSelected?'尚未连接 Grok Bot 账号':'本地虚拟机尚未准备'):state.container==='missing'?'尚未创建虚拟机':state.container==='stopped'?'虚拟机已停止':cloudSelected?'打开云端电脑后可查看桌面':'正在连接桌面…'}}</span></span>
+     <span v-else class="empty"><AppIcon name="monitor" :size="26"/><span>{{loading?'正在检查电脑…':previewTarget?previewEmptyText:nativeSelected?(native?.local.ready?'正在读取本机画面…':'请完成本机控制授权'):!state?.enabled?(computerChoice==='auto'?'暂无可用电脑':'未使用电脑'):!state.image?(cloudSelected?'尚未连接 Grok Bot 账号':'本地虚拟机尚未准备'):state.container==='missing'?'尚未创建虚拟机':state.container==='stopped'?'虚拟机已停止':cloudSelected?'打开云端电脑后可查看桌面':'正在连接桌面…'}}</span></span>
      <span v-if="frame" class="open-badge"><AppIcon name="external" :size="12"/>打开桌面</span>
     </button>
     <p v-if="error||state?.problem" class="problem" role="alert">{{error||state?.problem}}</p>
@@ -193,7 +189,6 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
       <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">
@@ -226,11 +221,6 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
       </div>
       <button class="primary" :disabled="busy||!native?.online||!native?.local.ready" @click="openDesktop()">接管本机</button>
     </template>
-    <template v-if="showBrowser">
-     <p>浏览器在已连接的桌面端运行,机器人和你使用同一组标签页。</p>
-     <label>浏览器资料<select :value="native?.browser.profile" :disabled="busy" @change="run(()=>apiRequest(`/api/app/agents/${selected}/browser-profile`,{method:'PUT',body:{profile:($event.target as HTMLSelectElement).value}}))"><option value="persistent">此机器人的浏览器 · 保存登录</option><option value="temporary">临时浏览器 · 退出桌面端后清除</option></select></label>
-     <button class="primary" :disabled="busy||!native?.online" @click="openDesktop()">打开浏览器</button>
-    </template>
     <template v-if="showCloud">
      <p>多个机器人共用同一台 Grok Bot 云端虚拟机、工作文件和浏览器登录。基础机器人和聊天记录保持独立。</p>
      <button v-if="cloud?.configured" class="primary" :disabled="busy" @click="openDesktop('cloud')">{{state?.controlMode==='human'?'打开控制中的桌面':'接管云端电脑'}}</button>

+ 4 - 6
src/server/desktopEnvironments.ts

@@ -51,7 +51,6 @@ interface HostState {
   readonly activity:Map<string,number>
 }
 const newHostState=(key:string,remote:boolean):HostState=>({key,remote,seen:0,pending:new Map(),queue:new Map(),manual:new Map(),paused:new Set(),frames:new Map(),generation:new Map(),notes:new Map(),activity:new Map()})
-const props={kind:{type:'string'},url:{type:'string'},tabId:{type:'string'},ref:{type:'string'},snapshotId:{type:'string'},text:{type:'string'}}
 export const DESKTOP_ENVIRONMENT_TOOLS=[
  {id:'desktop_environment_view',name:'desktop_environment_view',description:'查看当前已选电脑的真实截图,返回截图尺寸、环境类型和连接的电脑名称。',inputSchema:{type:'object',properties:{host:{type:'string',description:'可选,目标电脑的名称或编号;缺省用默认电脑'}},additionalProperties:false}},
  {id:'desktop_environment_action',name:'desktop_environment_action',description:'操作当前已选电脑。必须先查看新截图;kind 为 click、drag、text、key 或 scroll,坐标使用截图像素。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{host:{type:'string'},kind:{enum:['click','drag','text','key','scroll']},x:{type:'integer'},y:{type:'integer'},fromX:{type:'integer'},fromY:{type:'integer'},toX:{type:'integer'},toY:{type:'integer'},button:{enum:['left','right','middle']},count:{type:'integer'},text:{type:'string'},key:{type:'string'},modifiers:{type:'array',items:{enum:['ctrl','alt','shift','super']}},direction:{enum:['up','down','left','right']},amount:{type:'integer'}},required:['kind'],additionalProperties:false}},
@@ -59,10 +58,9 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
  {id:'desktop_file_read',name:'desktop_file_read',description:'读取当前电脑上的一个文件,返回 base64 内容与大小。路径相对于用户主目录;超出允许范围会被拒绝。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
  {id:'desktop_file_write',name:'desktop_file_write',description:'把 base64 内容写入当前电脑上的文件(覆盖已存在文件)。路径相对于用户主目录;超出允许范围会被拒绝。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},data:{type:'string'},host:{type:'string'}},required:['path','data'],additionalProperties:false}},
  {id:'desktop_shell',name:'desktop_shell',description:'在这台电脑的用户主目录执行一条 shell 命令,返回 stdout、stderr 与退出码。长任务会超时终止;不要用它代替文件工具读写大文件。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{command:{type:'string'},cwd:{type:'string'},timeoutMs:{type:'integer'},host:{type:'string'}},required:['command'],additionalProperties:false}},
- {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_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
@@ -95,10 +93,10 @@ export class DesktopEnvironments {
   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}
+ 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':undefined;if(agent.computer==='local')return 'local';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)}
  /** Intent for parallel desktop environments (screen control / browser). */
- modes(owner:string,agent:WorkspaceAgent):{desktop:boolean,browser:boolean} {const envs=agentEnvs(agent);if(agent.envs)return {desktop:envs.desktop,browser:envs.browser};const mode=this.toolMode(owner,agent);return {desktop:mode==='local',browser:mode==='browser'}}
+ modes(owner:string,agent:WorkspaceAgent):{desktop:boolean,browser:boolean} {const envs=agentEnvs(agent);if(agent.envs)return {desktop:envs.desktop,browser:false};const mode=this.toolMode(owner,agent);return {desktop:mode==='local',browser:false}}
  /** Availability for tool injection: degrade silently when hosts are offline. */
  envTools(owner:string,agent:WorkspaceAgent):{view:boolean,browser:boolean,file:boolean} {
   const wanted=this.modes(owner,agent)
@@ -189,6 +187,7 @@ export class DesktopEnvironments {
   const mode=this.toolMode(owner,a);if(!mode)throw new HttpError(409,'当前未选择桌面环境','desktop_unselected');this.context(this.sessionFor(a),owner,a,mode).check();await requireTeamToolBridge(target,a.profile)
  }
  async call(owner:string,agentId:string,id:string,input:unknown,signal:AbortSignal,assertActive:()=>void,expected:Mode,epoch:string|Record<string,string>|undefined){
+  if(id==='desktop_browser'||expected==='browser')throw new HttpError(404,'当前环境不支持这个工具','tool_not_found')
   const a=this.agent(owner,agentId)
   const {host,...arguments_}=typeof input==='object'&&input?input as Record<string,unknown>:{}
   const s=this.targetFor(a,host)
@@ -199,7 +198,6 @@ export class DesktopEnvironments {
   try{return await this.serial(s,c.key,async()=>{while(this.current(s,c.key)||s.paused.has(c.key)){check();await new Promise(resolve=>setTimeout(resolve,150))}check()
    let result:any
    if(id==='desktop_environment_view'){if(expected==='browser')await this.command(s,{...c.command,operation:'open'},check);const frame=await this.capture(s,{...c,check});this.toolFrames.set(signal,frame.id);result={content:[{type:'image',mimeType:'image/png',data:frame.data},{type:'text',text:JSON.stringify({width:frame.width,height:frame.height,host:s.info!.name,mode:expected})}]}}
-   else if(id==='desktop_browser'&&expected==='browser'){result=await this.command(s,{...c.command,operation:'browser',action:parse(browserSchema,arguments_)},check);s.frames.delete(c.key)}
    else if(DESKTOP_FILE_TOOL_IDS.has(id)&&expected==='local'){
     if(!this.readyFull(s,owner))throw new HttpError(403,'请在桌面端授权文件与命令访问后重试。','desktop_full_required')
     const action=id==='desktop_shell'?parse(shellSchema,arguments_):parse(fileSchema,{op:id==='desktop_file_list'?'list':id==='desktop_file_read'?'read':'write',...arguments_})

+ 3 - 3
src/server/workspaceRuntime.ts

@@ -752,8 +752,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:[]),...(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)},
+            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.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,'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
@@ -782,7 +782,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         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 open = [checked.desktop ? '脚本机' : '', checked.vm ? '虚拟环境' : '', checked.cloud ? '云虚拟机' : ''].filter(Boolean)
         const envDirective = `同一条对话里,只使用已勾选的环境:${open.join('、') || '无'}。脚本机是客户所在的机器,虚拟环境是服务端上的隔离虚拟机,云虚拟机是 Grok Bot 虚拟机。没勾选的不要用。${deniedToolsets.length ? '服务端本机的终端和文件已全局禁用,不要用服务端本机顶上。' : '全局设置已允许使用服务端本机的终端和文件。'}`
         const rules = [
           envDirective,

+ 3 - 1
src/server/workspaceStore.ts

@@ -471,6 +471,7 @@ export class WorkspaceStore {
     if (actBias !== undefined) patch.actBias = actBias ?? undefined
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     if (patch.desktopHost === null) patch.desktopHost = undefined
+    if (patch.computer === 'browser') patch.computer = 'off'
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
       // Parallel-environment agents keep their set; a raw computer write only
@@ -482,7 +483,8 @@ export class WorkspaceStore {
         patch.envs=envs
       }
       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.envs={...patch.envs,browser:false}
+        patch.computer=deriveComputer({vm:patch.envs.vm===true,cloud:patch.envs.cloud===true,desktop:patch.envs.desktop===true,browser:false})
         patch.execution='profile'
       }
       const desktopHostChanged = patch.desktopHost !== undefined && patch.desktopHost !== (agent.desktopHost ?? null)

+ 3 - 3
src/shared/workspace.ts

@@ -54,20 +54,20 @@ export interface WorkspaceAgent {
 export interface AgentEnvs { vm:boolean; cloud:boolean; desktop:boolean; browser:boolean }
 /** Normalized parallel environments; legacy single-choice agents derive here. */
 export function agentEnvs(agent: Pick<WorkspaceAgent,'computer'|'envs'> & {execution?:'profile'|'computer'}): AgentEnvs {
-  if(agent.envs)return {vm:agent.envs.vm===true,cloud:agent.envs.cloud===true,desktop:agent.envs.desktop===true,browser:agent.envs.browser===true}
+  if(agent.envs)return {vm:agent.envs.vm===true,cloud:agent.envs.cloud===true,desktop:agent.envs.desktop===true,browser:false}
   if(agent.computer===undefined&&agent.execution==='computer')return {vm:true,cloud:false,desktop:false,browser:false}
   switch(agent.computer){
     case 'vm':return {vm:true,cloud:false,desktop:false,browser:false}
     case 'cloud':return {vm:false,cloud:true,desktop:false,browser:false}
     case 'local':return {vm:false,cloud:false,desktop:true,browser:false}
-    case 'browser':return {vm:false,cloud:false,desktop:false,browser:true}
+    case 'browser':return {vm:false,cloud:false,desktop:false,browser:false}
     case 'auto':return {vm:true,cloud:true,desktop:true,browser:false}
     default:return {vm:false,cloud:false,desktop:false,browser:false}
   }
 }
 /** Single-choice projection kept for older clients reading agent.computer. */
 export function deriveComputer(envs: Pick<AgentEnvs,'vm'|'cloud'|'desktop'|'browser'>): 'vm'|'cloud'|'local'|'browser'|'off' {
-  return envs.vm?'vm':envs.cloud?'cloud':envs.desktop?'local':envs.browser?'browser':'off'
+  return envs.vm?'vm':envs.cloud?'cloud':envs.desktop?'local':'off'
 }
 const VOICE_TEXT:Record<WorkspaceVoice,string>={concise:'简洁专业:直击要点,不用客套与废话。',casual:'轻松随和:像朋友一样自然交流,可以适度幽默。',rigorous:'严谨细致:条理清晰,重要结论给出依据,主动指出风险与前提。',custom:''}
 const ACT_BIAS_TEXT:Record<WorkspaceActBias,string>={ask_first:'先问再做:任务不明确时先向用户澄清,确认后再执行。',ask_key_then_act:'先问关键问题即行动:最多问 2–3 个关键问题,职责清楚后立即开始,边做边汇报。',act_now:'直接行动:合理默认即可开工,只在重要分叉点询问。'}

+ 4 - 11
tests/e2e/native-environment.spec.ts

@@ -1,20 +1,13 @@
 import {test,expect} from '@playwright/test'
 import {mkdirSync} from 'node:fs'
-test('Web controls the real connected desktop browser and shows native permissions on a narrow viewport',async({page})=>{
+test('Web shows native desktop permissions',async({page})=>{
  const evidence='test-results/native-environment';mkdirSync(evidence,{recursive:true})
  await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
- await page.locator('.desktop-sidebar .sidebar-create-trigger').click();await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click();const editor=page.getByRole('dialog');await editor.getByRole('textbox',{name:'名称',exact:true}).fill('本机与浏览器验收');await editor.getByRole('button',{name:'保存',exact:true}).click()
+ await page.locator('.desktop-sidebar .sidebar-create-trigger').click();await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click();const editor=page.getByRole('dialog');await editor.getByRole('textbox',{name:'名称',exact:true}).fill('本机控制验收');await editor.getByRole('button',{name:'保存',exact:true}).click()
  await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click()
  const panel=page.getByRole('complementary',{name:'机器人电脑面板'})
  await expect(panel.getByRole('tab',{name:'本机',exact:true})).toBeEnabled({timeout:15000});await panel.getByRole('tab',{name:'本机',exact:true}).click()
  await expect(panel.getByRole('button',{name:'在桌面端授权',exact:true})).toBeVisible();await expect(panel.getByText(/连接电脑:/)).toBeVisible();await page.screenshot({path:evidence+'/desktop-native-permissions.png'})
- await panel.getByRole('tab',{name:'仅浏览器',exact:true}).click();await panel.getByRole('button',{name:'打开浏览器',exact:true}).click()
- const viewer=page.locator('dialog.computer-panel');await expect(viewer.getByRole('textbox',{name:'浏览器地址',exact:true})).toBeEnabled({timeout:15000})
- const url='http://127.0.0.1:18852/__test/browser-page';await viewer.getByRole('textbox',{name:'浏览器地址',exact:true}).fill(url);await viewer.getByRole('button',{name:'前往',exact:true}).click();await expect(viewer.getByRole('tab',{name:'浏览器操作验收',exact:true})).toBeVisible({timeout:15000})
- await expect(viewer.locator('.computer-screen img')).toBeVisible();await expect.poll(async()=>(await viewer.locator('.computer-screen img').getAttribute('src'))?.length??0).toBeGreaterThan(14000);await page.screenshot({path:evidence+'/desktop-browser.png'})
- await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
- await page.setViewportSize({width:375,height:812});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'});await panel.getByRole('button',{name:'打开浏览器',exact:true}).scrollIntoViewIfNeeded();await page.screenshot({path:evidence+'/phone-browser-settings.png'})
- await panel.getByRole('button',{name:'打开浏览器',exact:true}).click();await expect(viewer.getByRole('textbox',{name:'浏览器地址',exact:true})).toBeEnabled();await expect(viewer.getByRole('textbox',{name:'浏览器地址',exact:true})).toHaveValue(url);await page.screenshot({path:evidence+'/phone-browser.png'})
- expect(await viewer.evaluate(el=>el.scrollWidth<=el.clientWidth+2)).toBe(true)
- await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
+ await expect(panel.getByRole('tab',{name:'仅浏览器',exact:true})).toHaveCount(0)
+ await expect(panel.getByRole('checkbox',{name:'脚本机浏览器',exact:true})).toHaveCount(0)
 })

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

@@ -16,7 +16,7 @@ let home:string,store:WorkspaceStore,service:DesktopEnvironments,agent:Workspace
 const auth={require:(ctx:any)=>({id:ctx.get('x-user')||'owner'}),requireAdmin:(ctx:any)=>{if(ctx.get('x-user'))throw new HttpError(403,'仅限管理员','admin_required')},pushAuthorizationVersion:()=>version} as any
 const nodes={localNodeID:'local-node',requireSource:()=>{}} as any
 const ownerKey=(owner:string)=>createHash('sha256').update('local-node:'+owner).digest('hex')
-beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'desktop-env-'));store=new WorkspaceStore(home);service=new DesktopEnvironments(store,auth,nodes);version=1;agent=store.createAgent('owner',{name:'Browser',nodeId:'local',profile:'default',computer:'browser'});host={id:randomUUID(),name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[ownerKey('owner')]}})
+beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'desktop-env-'));store=new WorkspaceStore(home);service=new DesktopEnvironments(store,auth,nodes);version=1;agent=store.createAgent('owner',{name:'Desktop',nodeId:'local',profile:'default',computer:'local'});host={id:randomUUID(),name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[ownerKey('owner')]}})
 const pollers=new Set<ReturnType<typeof setInterval>>()
 afterEach(()=>{clearInterval(poller);for(const p of pollers)clearInterval(p);pollers.clear();service.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
 const app=()=>{const a=new Koa();a.use(async(ctx,next)=>{try{await next()}catch(e:any){ctx.status=e.status??500;ctx.body={error:e.message,code:e.code}}});a.use(bodyParser());a.use(service.router().routes());return a.callback()}
@@ -135,12 +135,12 @@ it('routes parallel desktop tools to the host named in the call',async()=>{
   expect(service.onlineHostsLine('owner')).toContain('书房 iMac')
  }finally{clearInterval(pollerB)}
 })
-it('keeps desktop and browser tools open together when both environments are on',async()=>{
+it('ignores a stored browser environment and keeps desktop tools',async()=>{
  agent=store.updateAgent('owner',agent.id,{envs:{vm:true,desktop:true,browser:true,cloud:false}})
  drive()
  expect(service.selected('owner',agent)).toBe('local')
- expect(service.toolMode('owner',agent)).toBe('local')
- await expect(service.call('owner',agent.id,'desktop_browser',{kind:'navigate',url:'https://example.test/both'},new AbortController().signal,()=>{},'browser',service.epoch)).resolves.toEqual({ok:true})
+ expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
+ await expect(service.call('owner',agent.id,'desktop_browser',{kind:'navigate',url:'https://example.test/both'},new AbortController().signal,()=>{},'browser',service.epoch)).rejects.toMatchObject({status:404,code:'tool_not_found'})
  const view=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch)
  expect(view.content[0].type).toBe('image')
 })
@@ -148,10 +148,10 @@ it('degrades envs desktop tools when no host is ready or online',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'off',envs:{desktop:true,browser:true}})
  expect(service.envTools('owner',agent)).toEqual({view:false,browser:false,file:false})
  service.exchange({host,results:[]})
- expect(service.envTools('owner',agent)).toEqual({view:true,browser:true,file:false})
+ expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
  host.full=[ownerKey('owner')]
  service.exchange({host,results:[]})
- expect(service.envTools('owner',agent)).toEqual({view:true,browser:true,file:true})
+ expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:true})
 })
 it('keeps manual takeover scoped per desktop host',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
@@ -173,33 +173,11 @@ it('keeps manual takeover scoped per desktop host',async()=>{
 })
 async function take(){return (await request(app()).post(base()+'/computer/take').send({requestId:randomUUID()}).expect(200)).body}
 async function frame(){return (await request(app()).get(base()+'/computer/frame').expect(200)).body}
-it('queues browser operations from concurrent sessions and cancels only the waiting session',async()=>{
- service.exchange({host,results:[]})
- const first=new AbortController(),cancelled=new AbortController(),third=new AbortController()
- const navigate=(signal:AbortSignal,path:string)=>service.call('owner',agent.id,'desktop_browser',{kind:'navigate',url:`https://example.test/${path}`},signal,()=>{},'browser',service.epoch)
- const one=navigate(first.signal,'first'),two=navigate(cancelled.signal,'cancelled').catch(error=>error),three=navigate(third.signal,'third')
- let commands:any[]=[]
- await vi.waitFor(()=>{commands=service.exchange({host,results:[]}).commands;expect(commands).toHaveLength(1)})
- const initial=commands[0]
- expect(initial.action.url).toBe('https://example.test/first')
- expect(service.exchange({host,results:[]}).commands).toEqual([])
- cancelled.abort()
- service.exchange({host,results:[{id:initial.id,value:{ok:true}}]})
- await one
- expect(await two).toMatchObject({name:'AbortError'})
- await vi.waitFor(()=>{commands=service.exchange({host,results:[]}).commands;expect(commands).toHaveLength(1)})
- expect(commands[0].action.url).toBe('https://example.test/third')
- expect(commands[0].resource).toBe(initial.resource)
- service.exchange({host,results:[{id:commands[0].id,value:{ok:true}}]})
- await expect(three).resolves.toEqual({ok:true})
- expect(first.signal.aborted).toBe(false)
- expect(third.signal.aborted).toBe(false)
-})
 it('keeps capability private and refuses origins before parsing native commands',async()=>{const instance=acquireServiceInstance(home,'0.4.3'),a=new Koa().use(instance.middleware(async()=>{},()=>true,ctx=>service.bridge(ctx)));try{await request(a.callback()).post('/desktop/environment').send({host,results:[]}).expect(403);await request(a.callback()).post('/desktop/environment').set('x-yaoyao-desktop-token',instance.record.token).set('Origin','http://evil.test').send({host,results:[]}).expect(403);await request(a.callback()).post('/desktop/environment').set('x-yaoyao-desktop-token',instance.record.token).send({host,results:[]}).expect(200);expect(service.online).toBe(true)}finally{instance.release()}})
 it('auto selects only an approved and ready local host; observing never opens a browser',async()=>{agent=store.updateAgent('owner',agent.id,{computer:'auto'});expect(service.selected('owner',agent)).toBeUndefined();expect(service.exchange({host:{...host,approved:[]},results:[]}).commands).toEqual([]);expect(service.selected('owner',agent)).toBeUndefined();service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBe('local');host.screen=false;service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBeUndefined();await request(app()).get(base()+'/desktop-environment').set('x-user','other').expect(404)})
 it('shares the physical desktop across owners and robots but scopes manual tokens to their owner and robot',async()=>{agent=store.updateAgent('owner',agent.id,{computer:'local'});const other=store.createAgent('other',{name:'Other',nodeId:'local',profile:'default',computer:'local'});host.approved.push(ownerKey('other'));drive();const ticket=await take();await request(app()).post('/api/app/agents/'+other.id+'/computer/take').set('x-user','other').send({requestId:randomUUID()}).expect(409);await request(app()).post('/api/app/agents/'+other.id+'/computer/renew').set('x-user','other').send(ticket).expect(410);const state=(await request(app()).get('/api/app/agents/'+other.id+'/computer').set('x-user','other')).body;expect(state.controlId).toBeUndefined();await request(app()).post(base()+'/computer/giveback').send(ticket).expect(200)})
 it('rejects stale frames and replays an input result exactly once',async()=>{const calls:any[]=[];drive(c=>{calls.push(c);return c.operation==='view'?{data:'Zm9v',width:1280,height:800}:{ok:true}});const t=await take(),f=await frame(),body={controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'click',x:10,y:10}};await request(app()).post(base()+'/computer/input').send({...body,frameId:randomUUID()}).expect(409);await request(app()).post(base()+'/computer/input').send(body).expect(200);await request(app()).post(base()+'/computer/input').send(body).expect(200);expect(calls.filter(c=>c.operation==='input')).toHaveLength(1);await request(app()).post(base()+'/computer/input').send({...body,action:{kind:'text',text:'different'}}).expect(409);await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})
 it('expired control remains paused until a new takeover is explicitly returned',async()=>{drive();const t=await take();const original=Date.now;const time=vi.spyOn(Date,'now').mockImplementation(()=>original()+31000);service.exchange({host,results:[]});await request(app()).post(base()+'/computer/renew').send(t).expect(410);expect((await request(app()).get(base()+'/computer')).body.mode).toBe('error');time.mockRestore();const next=await take();await request(app()).post(base()+'/computer/giveback').send({...next,notes:'已完成登录'}).expect(200);expect((await request(app()).get(base()+'/computer')).body.mode).toBe('idle')})
-it('pins tool calls to the host epoch and does not dispatch after permissions change',async()=>{drive();const epoch=service.epoch,signal=new AbortController().signal;await service.call('owner',agent.id,'desktop_environment_view',{},signal,()=>{},'browser',epoch);host.id=randomUUID();service.exchange({host,results:[]});await expect(service.call('owner',agent.id,'desktop_environment_action',{kind:'text',text:'no'},signal,()=>{},'browser',epoch)).rejects.toThrow('连接已改变');version++;const t=await take();version++;await request(app()).post(base()+'/computer/renew').send(t).expect(410)})
-it('waits for human release before the robot runs and forwards handoff notes',async()=>{drive();const t=await take();let complete=false;const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'browser',service.epoch).then(value=>{complete=true;return value});await new Promise(resolve=>setTimeout(resolve,30));expect(complete).toBe(false);await request(app()).post(base()+'/computer/giveback').send({...t,notes:'已登录测试账号'}).expect(200);const value=await call;expect(JSON.stringify(value)).toContain('已登录测试账号')})
-it('refuses dangerous navigation and prevents changing profile during active work',async()=>{drive();const t=await take(),f=await frame();await request(app()).post(base()+'/computer/browser').send({controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'navigate',url:'file:///etc/passwd'}}).expect(400);await request(app()).post(base()+'/computer/giveback').send(t).expect(200);store.put('owner','turn','turn',{agentId:agent.id,status:'running'});await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})
+it('pins tool calls to the host epoch and does not dispatch after permissions change',async()=>{drive();const epoch=service.epoch,signal=new AbortController().signal;await service.call('owner',agent.id,'desktop_environment_view',{},signal,()=>{},'local',epoch);host.id=randomUUID();service.exchange({host,results:[]});await expect(service.call('owner',agent.id,'desktop_environment_action',{kind:'text',text:'no'},signal,()=>{},'local',epoch)).rejects.toThrow('连接已改变');version++;const t=await take();version++;await request(app()).post(base()+'/computer/renew').send(t).expect(410)})
+it('waits for human release before the robot runs and forwards handoff notes',async()=>{drive();const t=await take();let complete=false;const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch).then(value=>{complete=true;return value});await new Promise(resolve=>setTimeout(resolve,30));expect(complete).toBe(false);await request(app()).post(base()+'/computer/giveback').send({...t,notes:'已登录测试账号'}).expect(200);const value=await call;expect(JSON.stringify(value)).toContain('已登录测试账号')})
+it('refuses dangerous navigation and prevents changing profile during active work',async()=>{drive();const t=await take(),f=await frame();await request(app()).post(base()+'/computer/browser').send({controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'navigate',url:'file:///etc/passwd'}}).expect(409);await request(app()).post(base()+'/computer/giveback').send(t).expect(200);store.put('owner','turn','turn',{agentId:agent.id,status:'running'});await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})