소스 검색

feat(envs): 桌面工具支持多机并列与按名寻址

desktop_* 工具增加可选 host 参数(名称或编号),缺省沿用绑定解析;
调用按目标主机会话路由,epoch 钉住改为 map(hostEpochs),显式
主机无记录时按当次在线校验。注入条件改为 envTools 可用性判定:
任一主机就绪注入查看/操作,任一全授权注入文件/命令,浏览器按
在线降级;系统规则动态列出在线电脑名称。
Samien 1 개월 전
부모
커밋
39455e6d34
3개의 변경된 파일과 89개의 추가작업 그리고 26개의 파일을 삭제
  1. 58 22
      src/server/desktopEnvironments.ts
  2. 6 4
      src/server/workspaceRuntime.ts
  3. 25 0
      tests/server/desktopEnvironments.test.ts

+ 58 - 22
src/server/desktopEnvironments.ts

@@ -6,7 +6,7 @@ import {HttpError} from './errors.js'
 import {parse,type WorkspaceStore} from './workspaceStore.js'
 import {parse,type WorkspaceStore} from './workspaceStore.js'
 import type {LocalAuthStore} from './localAuth.js'
 import type {LocalAuthStore} from './localAuth.js'
 import type {WorkspaceNodes,GatewayTarget} from './workspaceGateway.js'
 import type {WorkspaceNodes,GatewayTarget} from './workspaceGateway.js'
-import {allowsHostEnvironment,type WorkspaceAgent} from '../shared/workspace.js'
+import {allowsHostEnvironment,agentEnvs,type WorkspaceAgent} from '../shared/workspace.js'
 import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
 import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
 import {requireTeamToolBridge} from './workspaceToolLease.js'
 import {requireTeamToolBridge} from './workspaceToolLease.js'
 
 
@@ -53,12 +53,12 @@ interface HostState {
 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 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'}}
 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=[
 export const DESKTOP_ENVIRONMENT_TOOLS=[
- {id:'desktop_environment_view',name:'desktop_environment_view',description:'查看当前已选电脑的真实截图,返回截图尺寸、环境类型和连接的电脑名称。',inputSchema:{type:'object',properties:{},additionalProperties:false}},
- {id:'desktop_environment_action',name:'desktop_environment_action',description:'操作当前已选电脑。必须先查看新截图;kind 为 click、drag、text、key 或 scroll,坐标使用截图像素。',inputSchema:{type:'object',properties:{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}},
- {id:'desktop_file_list',name:'desktop_file_list',description:'列出当前电脑允许范围内的目录内容,返回名称、类型、大小与修改时间。路径相对于用户主目录,可用 ~。',inputSchema:{type:'object',properties:{path:{type:'string'}},required:['path'],additionalProperties:false}},
- {id:'desktop_file_read',name:'desktop_file_read',description:'读取当前电脑上的一个文件,返回 base64 内容与大小。路径相对于用户主目录;超出允许范围会被拒绝。',inputSchema:{type:'object',properties:{path:{type:'string'}},required:['path'],additionalProperties:false}},
- {id:'desktop_file_write',name:'desktop_file_write',description:'把 base64 内容写入当前电脑上的文件(覆盖已存在文件)。路径相对于用户主目录;超出允许范围会被拒绝。',inputSchema:{type:'object',properties:{path:{type:'string'},data:{type:'string'}},required:['path','data'],additionalProperties:false}},
- {id:'desktop_shell',name:'desktop_shell',description:'在这台电脑的用户主目录执行一条 shell 命令,返回 stdout、stderr 与退出码。长任务会超时终止;不要用它代替文件工具读写大文件。',inputSchema:{type:'object',properties:{command:{type:'string'},cwd:{type:'string'},timeoutMs:{type:'integer'}},required:['command'],additionalProperties:false}},
+ {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}},
+ {id:'desktop_file_list',name:'desktop_file_list',description:'列出当前电脑允许范围内的目录内容,返回名称、类型、大小与修改时间。路径相对于用户主目录,可用 ~。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
+ {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}},
  {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_FILE_TOOL_IDS=new Set(['desktop_file_list','desktop_file_read','desktop_file_write','desktop_shell'])
@@ -90,8 +90,28 @@ export class DesktopEnvironments {
   const remote=[...this.sessions.keys()].filter(key=>key!=='local')
   const remote=[...this.sessions.keys()].filter(key=>key!=='local')
   return remote.length===1?this.sessions.get(remote[0]):undefined
   return remote.length===1?this.sessions.get(remote[0]):undefined
  }
  }
- 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'){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':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)}
  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'}}
+ /** 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)
+  const online=[...this.sessions.values()].filter(s=>this.hostOnline(s))
+  return {view:wanted.desktop&&online.some(s=>this.ready(s,owner)),browser:wanted.browser&&online.length>0,file:wanted.desktop&&online.some(s=>this.readyFull(s,owner))}
+ }
+ private readySession(owner:string){for(const s of this.sessions.values())if(this.hostOnline(s)&&this.ready(s,owner))return s;return undefined}
+ /** Resolve the host a tool call addresses: explicit host name/id, else the agent binding. */
+ private targetFor(agent:WorkspaceAgent,host:unknown):HostState{
+  if(typeof host==='string'&&host){
+   for(const s of this.sessions.values())if(s.key===host||(s.info&&s.info.name.toLowerCase()===host.toLowerCase()))return s
+   throw new HttpError(409,`指定的电脑不在线:${host.slice(0,64)}`,'desktop_host_unknown')
+  }
+  const s=this.sessionFor(agent)
+  if(!s)throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
+  return s
+ }
+ onlineHostsLine(owner:string){const names=[...this.sessions.values()].filter(s=>this.hostOnline(s)).map(s=>s.info?.name??s.key);return names.length?`当前在线电脑:${names.join('、')}。desktop_* 工具可用 host 参数(名称或编号)选择目标电脑;未指定时使用默认电脑。`:''}
  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 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'))}
  private resource(owner:string,agent:WorkspaceAgent,mode:Mode){return mode==='local'?'local':digest(this.ownerKey(owner)+':'+agent.id+':'+(agent.browserProfile??'persistent'))}
  hostStates(owner:string){return [...this.sessions.values()].map(s=>{const online=this.hostOnline(s),info=online?s.info!:undefined;return {id:s.key,name:info?.name??'',platform:info?.platform??'',online,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}
  hostStates(owner:string){return [...this.sessions.values()].map(s=>{const online=this.hostOnline(s),info=online?s.info!:undefined;return {id:s.key,name:info?.name??'',platform:info?.platform??'',online,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}
@@ -142,25 +162,41 @@ export class DesktopEnvironments {
  }
  }
  private status(s:HostState|undefined,owner:string,a:WorkspaceAgent,mode:Mode):ComputerControlStatus {const key=this.resource(owner,a,mode),m=s?this.current(s,key):undefined,online=!!s&&this.hostOnline(s);return {backend:mode,hostName:online?s!.info!.name:undefined,mode:!online?'off':m?(m.owner===owner&&m.ready?'human':'pausing'):s!.paused.has(key)?'error':s!.activity.get(key)?'agent':'idle',controlId:m?.owner===owner?m.id:undefined,generation:s?.generation.get(key)??0,canResume:!!s?.activity.get(key),error:!online?'请在电脑上打开夭夭桌面端':s!.paused.has(key)?'接管已断开,请重新接管并交还后继续':undefined}}
  private status(s:HostState|undefined,owner:string,a:WorkspaceAgent,mode:Mode):ComputerControlStatus {const key=this.resource(owner,a,mode),m=s?this.current(s,key):undefined,online=!!s&&this.hostOnline(s);return {backend:mode,hostName:online?s!.info!.name:undefined,mode:!online?'off':m?(m.owner===owner&&m.ready?'human':'pausing'):s!.paused.has(key)?'error':s!.activity.get(key)?'agent':'idle',controlId:m?.owner===owner?m.id:undefined,generation:s?.generation.get(key)??0,canResume:!!s?.activity.get(key),error:!online?'请在电脑上打开夭夭桌面端':s!.paused.has(key)?'接管已断开,请重新接管并交还后继续':undefined}}
  assertIdle(owner:string,agentId:string){const a=this.agent(owner,agentId),mode=this.selected(owner,a);if(mode){const s=this.sessionFor(a);if(s&&this.current(s,this.resource(owner,a,mode)))throw new HttpError(409,'请先交还电脑控制权','computer_busy')}}
  assertIdle(owner:string,agentId:string){const a=this.agent(owner,agentId),mode=this.selected(owner,a);if(mode){const s=this.sessionFor(a);if(s&&this.current(s,this.resource(owner,a,mode)))throw new HttpError(409,'请先交还电脑控制权','computer_busy')}}
- async requireAvailable(owner:string,a:WorkspaceAgent,target:GatewayTarget){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){
-  const a=this.agent(owner,agentId),s=this.sessionFor(a),c=this.context(s,owner,a,expected),check=()=>{signal.throwIfAborted();assertActive();c.check();if(s?.info?.id!==epoch)throw new HttpError(410,'桌面连接已改变','desktop_disconnected')};check()
-  s!.activity.set(c.key,(s!.activity.get(c.key)??0)+1)
-  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()
+ async requireAvailable(owner:string,a:WorkspaceAgent,target:GatewayTarget){
+  if(a.envs){
+   const tools=this.envTools(owner,a)
+   if(!tools.view&&!tools.browser)return
+   const mode:Mode=tools.view?'local':'browser'
+   const s=tools.view?this.readySession(owner)??this.sessionFor(a):this.sessionFor(a)
+   if(!s||!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
+   this.context(s,owner,a,mode).check();await requireTeamToolBridge(target,a.profile);return
+  }
+  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){
+  const a=this.agent(owner,agentId)
+  const {host,...arguments_}=typeof input==='object'&&input?input as Record<string,unknown>:{}
+  const s=this.targetFor(a,host)
+  const c=this.context(s,owner,a,expected)
+  const pinned=typeof epoch==='string'?(s===this.sessionFor(a)?epoch:undefined):epoch?.[s.key]
+  const check=()=>{signal.throwIfAborted();assertActive();c.check();if(pinned!==undefined&&s.info?.id!==pinned)throw new HttpError(410,'桌面连接已改变','desktop_disconnected')};check()
+  s.activity.set(c.key,(s.activity.get(c.key)??0)+1)
+  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
    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,input)},check);s!.frames.delete(c.key)}
+   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'){
    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,input):parse(fileSchema,{op:id==='desktop_file_list'?'list':id==='desktop_file_read'?'read':'write',...(typeof input==='object'&&input?input:{})})
-    result=await this.command(s!,{...c.command,operation:id==='desktop_shell'?'shell':'file',action},check,id==='desktop_shell'?150000:60000)
+    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_})
+    result=await this.command(s,{...c.command,operation:id==='desktop_shell'?'shell':'file',action},check,id==='desktop_shell'?150000:60000)
    }
    }
-   else if(id==='desktop_environment_action'){const frame=s!.frames.get(c.key);if(!frame||this.toolFrames.get(signal)!==frame.id||Date.now()-frame.capturedAt>15000)throw new HttpError(409,'请先查看最新电脑画面','computer_frame_stale');result=await this.command(s!,{...c.command,operation:'input',action:parse(inputSchema,input),frame:{width:frame.width,height:frame.height}},check);s!.frames.delete(c.key)}
+   else if(id==='desktop_environment_action'){const frame=s.frames.get(c.key);if(!frame||this.toolFrames.get(signal)!==frame.id||Date.now()-frame.capturedAt>15000)throw new HttpError(409,'请先查看最新电脑画面','computer_frame_stale');result=await this.command(s,{...c.command,operation:'input',action:parse(inputSchema,arguments_),frame:{width:frame.width,height:frame.height}},check);s.frames.delete(c.key)}
    else throw new HttpError(404,'当前环境不支持这个工具','tool_not_found')
    else throw new HttpError(404,'当前环境不支持这个工具','tool_not_found')
-   const note=s!.notes.get(owner+':'+agentId);if(note){s!.notes.delete(owner+':'+agentId);if(result.content)result.content.push({type:'text',text:'人工交还说明:'+note});else result={...result,handoffNote:note}}return result
-  })}finally{s!.activity.set(c.key,Math.max(0,(s!.activity.get(c.key)??1)-1))}
+   const note=s.notes.get(owner+':'+agentId);if(note){s.notes.delete(owner+':'+agentId);if(result.content)result.content.push({type:'text',text:'人工交还说明:'+note});else result={...result,handoffNote:note}}return result
+  })}finally{s.activity.set(c.key,Math.max(0,(s.activity.get(c.key)??1)-1))}
  }
  }
- /** Epoch of the host this agent is bound to; pins tool calls to one machine. */
+ /** Epochs of every connected host; pins multi-host tool calls per machine. */
+ hostEpochs(owner:string,agent:WorkspaceAgent):Record<string,string>{const epochs:Record<string,string>={};for(const s of this.sessions.values())if(s.info)epochs[s.key]=s.info.id;return epochs}
  hostEpoch(owner:string,agent:WorkspaceAgent){return this.sessionFor(agent)?.info?.id??''}
  hostEpoch(owner:string,agent:WorkspaceAgent){return this.sessionFor(agent)?.info?.id??''}
  get epoch(){return this.sessions.get('local')?.info?.id??''}
  get epoch(){return this.sessions.get('local')?.info?.id??''}
  private grant(s:HostState,owner:string,a:WorkspaceAgent,c:ReturnType<DesktopEnvironments['context']>,body:any){const m=this.current(s,c.key);const token=typeof body?.token==='string'?body.token:'';if(!m||!m.ready||m.owner!==owner||m.agentId!==a.id||m.id!==body?.controlId||!timingSafeEqual(Buffer.from(digest(token),'hex'),Buffer.from(digest(m.token),'hex')))throw new HttpError(410,'控制权已失效,请重新接管','computer_control_expired');c.check();return m}
  private grant(s:HostState,owner:string,a:WorkspaceAgent,c:ReturnType<DesktopEnvironments['context']>,body:any){const m=this.current(s,c.key);const token=typeof body?.token==='string'?body.token:'';if(!m||!m.ready||m.owner!==owner||m.agentId!==a.id||m.id!==body?.controlId||!timingSafeEqual(Buffer.from(digest(token),'hex'),Buffer.from(digest(m.token),'hex')))throw new HttpError(410,'控制权已失效,请重新接管','computer_control_expired');c.check();return m}

+ 6 - 4
src/server/workspaceRuntime.ts

@@ -692,7 +692,9 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const team=agent.canManageTeam===true&&!this.store.require<Run>(owner,'run',run.runId).assignmentId
         const team=agent.canManageTeam===true&&!this.store.require<Run>(owner,'run',run.runId).assignmentId
         const cloud=!!this.cloud?.selected(owner,agent)
         const cloud=!!this.cloud?.selected(owner,agent)
         const plugins=!!this.plugins?.selected(owner,agent)
         const plugins=!!this.plugins?.selected(owner,agent)
-        const desktop=this.desktopEnvironments?.toolMode(owner,agent),desktopEpoch=this.desktopEnvironments?.hostEpoch(owner,agent)??''
+        const envTools=this.desktopEnvironments?.envTools(owner,agent)??{view:false,browser:false,file:false}
+        const desktop=envTools.view||envTools.browser
+        const desktopEpochs=this.desktopEnvironments?.hostEpochs(owner,agent)??{}
         const knowledge = botCapabilities.tools && !agent.remoteAgentId
         const knowledge = botCapabilities.tools && !agent.remoteAgentId
         if(team||cloud||desktop||plugins||knowledge){
         if(team||cloud||desktop||plugins||knowledge){
           if(team){
           if(team){
@@ -712,8 +714,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             target,profile:agent.profile,workId:run.id,signal:toolController.signal,
             target,profile:agent.profile,workId:run.id,signal:toolController.signal,
             workspaceMemory:botCapabilities.memory,
             workspaceMemory:botCapabilities.memory,
             session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
             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=>(desktop==='browser'||t.id!=='desktop_browser')&&(desktop!=='local'||!DESKTOP_FILE_TOOL_IDS.has(t.id)||this.desktopEnvironments!.fileTools(owner,agent))):[]),...(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,desktop!,desktopEpoch):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:[]),...(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)},
             onFailure:error=>{if(!settled)void gateway.rpc('session.interrupt',{session_id:runtimeId}).catch(()=>{}).finally(()=>finish(error))},
             onFailure:error=>{if(!settled)void gateway.rpc('session.interrupt',{session_id:runtimeId}).catch(()=>{}).finally(()=>finish(error))},
           })
           })
           await toolLease.bind();if(settled)return await completion
           await toolLease.bind();if(settled)return await completion
@@ -767,7 +769,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           root.projectId ? `当前项目 ID:${root.projectId}。只能使用当前项目记忆。` : '',
           root.projectId ? `当前项目 ID:${root.projectId}。只能使用当前项目记忆。` : '',
           `本次来源消息 ID:${run.messageId};当前会话 ID:${c.id}。`,
           `本次来源消息 ID:${run.messageId};当前会话 ID:${c.id}。`,
           plugins ? '本轮已挂载用户为当前 Bot 授权的插件工具,工具名以 plugin_ 开头,说明中包含实际服务和操作。仅按用户当前任务使用;连接或重新授权应用请让用户打开 Bot 模式的工具 → 已连接应用。不要索取 API Key 或在回复中展示凭据。' : '',
           plugins ? '本轮已挂载用户为当前 Bot 授权的插件工具,工具名以 plugin_ 开头,说明中包含实际服务和操作。仅按用户当前任务使用;连接或重新授权应用请让用户打开 Bot 模式的工具 → 已连接应用。不要索取 API Key 或在回复中展示凭据。' : '',
-          desktop ? DESKTOP_ENVIRONMENT_RULES : '',
+          desktop ? DESKTOP_ENVIRONMENT_RULES+(this.desktopEnvironments?.onlineHostsLine(owner)??'') : '',
           cloud ? grokComputerRules(agent.computer==='cloud'&&agent.allowHostEnvironment===true) : '',
           cloud ? grokComputerRules(agent.computer==='cloud'&&agent.allowHostEnvironment===true) : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
         ]
         ]

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

@@ -92,6 +92,31 @@ it('passes the authorize scope through to the desktop host',async()=>{
  await request(app()).post(base()+'/desktop-environment/authorize').send({scope:'full'}).expect(200)
  await request(app()).post(base()+'/desktop-environment/authorize').send({scope:'full'}).expect(200)
  expect(seen.find(c=>c.operation==='authorize')?.scope).toBe('full')
  expect(seen.find(c=>c.operation==='authorize')?.scope).toBe('full')
 })
 })
+it('routes parallel desktop tools to the host named in the call',async()=>{
+ agent=store.updateAgent('owner',agent.id,{computer:'local',envs:{desktop:true,cloud:true}})
+ const study={...host,name:'书房 iMac'},living={...host,id:randomUUID(),name:'客厅 Mac mini'}
+ driveRemote(HOST_A,study)
+ let resultsB:any[]=[]
+ service.remoteExchange(HOST_B,{host:living,results:[]})
+ const pollerB=setInterval(()=>{const value=service.remoteExchange(HOST_B,{host:living,results:resultsB});resultsB=value.commands.map(c=>({id:c.id,value:c.operation==='view'?{data:Buffer.from('living-room').toString('base64'),width:1280,height:800}:{ok:true}}))},5)
+ try{
+  expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
+  const frame=await service.call('owner',agent.id,'desktop_environment_view',{host:'客厅 Mac mini'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
+  expect(Buffer.from(frame.content[0].data,'base64').toString()).toBe('living-room')
+  await expect(service.call('owner',agent.id,'desktop_environment_view',{host:'书房 iMac'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))).resolves.toMatchObject({content:[{type:'image'},{type:'text'}]})
+  await expect(service.call('owner',agent.id,'desktop_environment_view',{host:'地下室 MacBook'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))).rejects.toMatchObject({code:'desktop_host_unknown'})
+  expect(service.onlineHostsLine('owner')).toContain('书房 iMac')
+ }finally{clearInterval(pollerB)}
+})
+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})
+ host.full=[ownerKey('owner')]
+ service.exchange({host,results:[]})
+ expect(service.envTools('owner',agent)).toEqual({view:true,browser:true,file:true})
+})
 it('keeps manual takeover scoped per desktop host',async()=>{
 it('keeps manual takeover scoped per desktop host',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  const second=store.createAgent('owner',{name:'Second',nodeId:'local',profile:'default',computer:'local',desktopHost:HOST_B})
  const second=store.createAgent('owner',{name:'Second',nodeId:'local',profile:'default',computer:'local',desktopHost:HOST_B})