Sfoglia il codice sorgente

feat(desktop-host): DesktopEnvironments 支持多台桌面主机会话

每台主机(本机回环 + 各配对远程 Mac)独立持有命令队列、截图、
接管凭据与授权状态;机器人通过 desktopHost 绑定目标机器,
未绑定时回退本机、唯一远程可用。撤销主机即围栏其在途命令。
接管记录 environmentId 加主机前缀,多机互不串扰。
Samien 1 mese fa
parent
commit
63157aa1e1

+ 2 - 0
src/server/app.ts

@@ -254,6 +254,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers,options.grokFetch)
   const desktopEnvironments=new DesktopEnvironments(workspace,auth,workspaceNodes)
   const desktopHosts=new DesktopHostHub(workspace,auth)
+  desktopHosts.exchange=(id,value)=>desktopEnvironments.remoteExchange(id,value)
+  desktopHosts.drop=id=>desktopEnvironments.dropHost(id)
   workspaceRuntime.desktopEnvironments=desktopEnvironments
   grokCloud.preferDesktop=(owner,agent)=>!!desktopEnvironments.selected(owner,agent)
   grokCloud.beforeSelection=(owner,id)=>desktopEnvironments.assertIdle(owner,id)

+ 109 - 68
src/server/desktopEnvironments.ts

@@ -28,6 +28,23 @@ type Mode='local'|'browser'
 interface NativeHost {id:string;name:string;platform:string;screen:boolean;accessibility:boolean;approved:string[]}
 interface Pending {id:string;epoch:string;deadline:number;command:Record<string,unknown>;check:()=>void;resolve:(v:any)=>void;reject:(e:Error)=>void;sent:boolean;timer:ReturnType<typeof setTimeout>}
 interface Manual {ready?:boolean;id:string;token:string;requestId:string;owner:string;agentId:string;version:number;expiresAt:number;generation:number;epoch:string;pending?:Promise<unknown>;releasing?:boolean;actions:Map<string,{fingerprint:string;value:Promise<unknown>}>}
+/** All per-host state. The loopback App owns key 'local'; remote Macs each
+ *  own the desktop-host id they paired with. */
+interface HostState {
+  readonly key:string
+  readonly remote:boolean
+  info?:NativeHost
+  seen:number
+  readonly pending:Map<string,Pending>
+  readonly queue:Map<string,Promise<unknown>>
+  readonly manual:Map<string,Manual>
+  readonly paused:Set<string>
+  readonly frames:Map<string,ComputerFrame>
+  readonly generation:Map<string,number>
+  readonly notes:Map<string,string>
+  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:{},additionalProperties:false}},
@@ -36,112 +53,136 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
 ]
 export const DESKTOP_ENVIRONMENT_RULES='当前环境由用户连接的夭夭桌面端提供。使用 desktop_environment_view/action 查看和操作该环境;本机模式会影响这台真实电脑,遵循用户授权范围。仅浏览器模式使用独立浏览器,desktop_browser 可导航、查看页面及操作元素。先查看再操作,页面变化后重新获取截图或 snapshot;不得猜测旧坐标或 ref。人工接管时等待交还,按交还说明继续。电脑断开时报告原因,不得宣称操作成功。'
 
-/** The native transport is admitted only by serviceInstance's private loopback
- * capability. Browser/desktop authority never crosses into the renderer. */
+/** The loopback native transport is admitted only by serviceInstance's
+ *  private capability; remote hosts are admitted by DesktopHostHub's bearer
+ *  pairing. Browser/desktop authority never crosses into the renderer. */
 export class DesktopEnvironments {
- private host?:NativeHost
- private seen=0
- private pending=new Map<string,Pending>()
- private queue=new Map<string,Promise<unknown>>()
- private manual=new Map<string,Manual>()
- private paused=new Set<string>()
- private frames=new Map<string,ComputerFrame>()
- private generation=new Map<string,number>()
- private notes=new Map<string,string>()
- private toolFrames=new WeakMap<AbortSignal,string>()
- private activity=new Map<string,number>()
+ private readonly sessions=new Map<string,HostState>()
  private authorizing=new Map<string,Promise<unknown>>()
+ private toolFrames=new WeakMap<AbortSignal,string>()
  private closed=false
  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes){}
- get online(){return !this.closed&&!!this.host&&Date.now()-this.seen<8000}
- get idleForUpdate(){for(const key of this.manual.keys())this.current(key);return !this.pending.size&&!this.queue.size&&!this.manual.size}
+ private hostOnline(s:HostState){return !this.closed&&!!s.info&&Date.now()-s.seen<(s.remote?15000:8000)}
+ get online(){const s=this.sessions.get('local');return !!s&&this.hostOnline(s)}
+ get idleForUpdate(){for(const s of this.sessions.values()){for(const key of s.manual.keys())this.current(s,key);if(s.pending.size||s.queue.size||s.manual.size)return false}return true}
  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(allowsHostEnvironment(agent)&&this.ready(owner))return 'local';return this.selected(owner,agent)}
+ private ready(s:HostState,owner:string){return this.hostOnline(s)&&s.info!.platform==='darwin'&&s.info!.screen&&s.info!.accessibility&&s.info!.approved.includes(this.ownerKey(owner))}
+ private localSession(){let s=this.sessions.get('local');if(!s){s=newHostState('local',false);this.sessions.set('local',s)}return s}
+ /** Agent binding first; otherwise the loopback host, otherwise the single
+  *  connected remote host. Ambiguous remote sets require an explicit binding. */
+ private sessionFor(agent:WorkspaceAgent):HostState|undefined{
+  if(agent.desktopHost)return this.sessions.get(agent.desktopHost)
+  if(this.sessions.has('local'))return this.sessions.get('local')
+  const remote=[...this.sessions.keys()].filter(key=>key!=='local')
+  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}
+ 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)}
  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)}}
- private check(owner:string,agentId:string,mode:Mode,epoch:string,version:number){const a=this.agent(owner,agentId);if(!this.online||this.host!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||this.toolMode(owner,a)!==mode)throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');if(mode==='local'&&!this.ready(owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
- private invalidate(){for(const p of this.pending.values()){if(p.sent&&['input','browser'].includes(String(p.command.operation)))this.paused.add(String(p.command.resource));clearTimeout(p.timer);p.reject(new HttpError(409,'桌面连接已改变;执行结果未确认,不会重试。','desktop_disconnected'))}this.pending.clear();this.frames.clear();for(const [key,m] of this.manual){this.paused.add(key);this.store.remove('_system','computer-control',m.id)}this.manual.clear()}
- close(){this.closed=true;this.invalidate()}
+ 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)},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}
+ state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent),s=this.sessionFor(agent),online=!!s&&this.hostOnline(s),info=online?s!.info:undefined;return {online,host:info?{name:info.name,platform:info.platform}:null,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)},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
+ private check(s:HostState|undefined,owner:string,agentId:string,mode:Mode,epoch:string,version:number){const a=this.agent(owner,agentId);if(!s||!this.hostOnline(s)||s.info!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||this.toolMode(owner,a)!==mode)throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');if(mode==='local'&&!this.ready(s,owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
+ private invalidate(s:HostState){for(const p of s.pending.values()){if(p.sent&&['input','browser'].includes(String(p.command.operation)))s.paused.add(String(p.command.resource));clearTimeout(p.timer);p.reject(new HttpError(409,'桌面连接已改变;执行结果未确认,不会重试。','desktop_disconnected'))}s.pending.clear();s.frames.clear();for(const [key,m] of s.manual){s.paused.add(key);this.store.remove('_system','computer-control',m.id)}s.manual.clear()}
+ close(){this.closed=true;for(const s of this.sessions.values())this.invalidate(s)}
  async bridge(ctx:Koa.Context){
   if(ctx.method!=='POST'){ctx.status=405;return}
   let bytes=0;const chunks:Buffer[]=[];for await(const chunk of ctx.req){bytes+=chunk.length;if(bytes>24*1024*1024)throw new HttpError(413,'桌面响应过大','desktop_limit');chunks.push(Buffer.from(chunk))}
   let json:unknown;try{json=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'桌面请求格式无效','desktop_invalid')}
   ctx.body=this.exchange(json)
  }
- exchange(body:unknown){
+ /** Loopback App exchange; kept for the local service-instance channel. */
+ exchange(body:unknown){return this.exchangeFor(this.localSession(),body)}
+ /** Remote desktop-host exchange, admitted upstream by DesktopHostHub. */
+ remoteExchange(id:string,body:unknown){
+  if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id))throw new HttpError(404,'桌面主机不存在','desktop_host_unknown')
+  let s=this.sessions.get(id)
+  if(!s){s=newHostState(id,true);this.sessions.set(id,s)}
+  return this.exchangeFor(s,body)
+ }
+ /** Revocation or replacement of a paired host fences its live commands. */
+ dropHost(id:string){const s=this.sessions.get(id);if(s){this.invalidate(s);this.sessions.delete(id)}}
+ private exchangeFor(s:HostState,body:unknown){
   const value=parse(z.object({host:z.object({id:z.string().uuid(),name:z.string().max(128),platform:z.string().max(16),screen:z.boolean(),accessibility:z.boolean(),approved:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(100)}).strict(),results:z.array(z.object({id:z.string().uuid(),value:z.unknown().optional(),error:z.string().max(500).optional()}).strict()).max(20)}).strict(),body)
   if(this.closed)throw new HttpError(503,'服务已停止','desktop_closed')
-  if(this.host&&(this.host.id!==value.host.id||!this.online))this.invalidate()
-  this.host=value.host;this.seen=Date.now()
-  for(const result of value.results){const p=this.pending.get(result.id);if(!p||p.epoch!==value.host.id)continue;this.pending.delete(p.id);clearTimeout(p.timer);try{p.check();if(result.error)throw new HttpError(409,result.error,'desktop_command_failed');p.resolve(result.value)}catch(error){p.reject(error as Error)}}
+  if(s.info&&(s.info.id!==value.host.id||!this.hostOnline(s)))this.invalidate(s)
+  s.info=value.host;s.seen=Date.now()
+  for(const result of value.results){const p=s.pending.get(result.id);if(!p||p.epoch!==value.host.id)continue;s.pending.delete(p.id);clearTimeout(p.timer);try{p.check();if(result.error)throw new HttpError(409,result.error,'desktop_command_failed');p.resolve(result.value)}catch(error){p.reject(error as Error)}}
   const commands:Record<string,unknown>[]=[]
-  for(const p of this.pending.values()){if(p.sent)continue;try{p.check();p.sent=true;commands.push({id:p.id,deadline:p.deadline,...p.command})}catch(error){this.pending.delete(p.id);clearTimeout(p.timer);p.reject(error as Error)}if(commands.length>=8)break}
+  for(const p of s.pending.values()){if(p.sent)continue;try{p.check();p.sent=true;commands.push({id:p.id,deadline:p.deadline,...p.command})}catch(error){s.pending.delete(p.id);clearTimeout(p.timer);p.reject(error as Error)}if(commands.length>=8)break}
   return {commands}
  }
- private command(command:Record<string,unknown>,check:()=>void,timeout=30000):Promise<any>{
-  check();if(!this.online)throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
-  if(this.pending.size>=64)throw new HttpError(429,'桌面操作过多,请稍后重试','desktop_busy')
-  return new Promise((resolve,reject)=>{const id=randomUUID(),deadline=Date.now()+timeout,timer=setTimeout(()=>{this.pending.delete(id);if(['input','browser'].includes(String(command.operation)))this.paused.add(String(command.resource));reject(new HttpError(504,'桌面操作未确认,不会自动重试。','desktop_timeout'))},timeout);timer.unref();this.pending.set(id,{id,epoch:this.host!.id,deadline,command,check,resolve,reject,timer,sent:false})})
+ private command(s:HostState,command:Record<string,unknown>,check:()=>void,timeout=30000):Promise<any>{
+  check();if(!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
+  if(s.pending.size>=64)throw new HttpError(429,'桌面操作过多,请稍后重试','desktop_busy')
+  return new Promise((resolve,reject)=>{const id=randomUUID(),deadline=Date.now()+timeout,timer=setTimeout(()=>{s.pending.delete(id);if(['input','browser'].includes(String(command.operation)))s.paused.add(String(command.resource));reject(new HttpError(504,'桌面操作未确认,不会自动重试。','desktop_timeout'))},timeout);timer.unref();s.pending.set(id,{id,epoch:s.info!.id,deadline,command,check,resolve,reject,timer,sent:false})})
  }
- private context(owner:string,agent:WorkspaceAgent,mode:Mode){const epoch=this.host?.id??'',version=this.auth.pushAuthorizationVersion(owner)??0,profile=agent.browserProfile??'persistent',resource=this.resource(owner,agent,mode);return {key:resource,check:()=>{this.check(owner,agent.id,mode,epoch,version);if((this.agent(owner,agent.id).browserProfile??'persistent')!==profile)throw new HttpError(410,'浏览器资料已切换','computer_control_expired')},command:{mode,owner:this.ownerKey(owner),resource,profile}}}
- private current(key:string){const m=this.manual.get(key);if(m&&(!this.online||m.epoch!==this.host!.id||m.expiresAt<=Date.now()||this.auth.pushAuthorizationVersion(m.owner)!==m.version)){this.manual.delete(key);this.paused.add(key);this.frames.delete(key);this.store.remove('_system','computer-control',m.id);return undefined}return m}
- private serial<T>(key:string,work:()=>Promise<T>){const next=(this.queue.get(key)??Promise.resolve()).catch(()=>{}).then(work);this.queue.set(key,next);void next.finally(()=>{if(this.queue.get(key)===next)this.queue.delete(key)}).catch(()=>{});return next}
- private async capture(c:ReturnType<DesktopEnvironments['context']>){const cached=this.frames.get(c.key);c.check();if(cached&&cached.generation===(this.generation.get(c.key)??0)&&Date.now()-cached.capturedAt<700)return cached;const generation=this.generation.get(c.key)??0
-  const value=await this.command({...c.command,operation:'view'},c.check)
+ private context(s:HostState|undefined,owner:string,agent:WorkspaceAgent,mode:Mode){const epoch=s?.info?.id??'',version=this.auth.pushAuthorizationVersion(owner)??0,profile=agent.browserProfile??'persistent',resource=this.resource(owner,agent,mode);return {key:resource,check:()=>{this.check(s,owner,agent.id,mode,epoch,version);if((this.agent(owner,agent.id).browserProfile??'persistent')!==profile)throw new HttpError(410,'浏览器资料已切换','computer_control_expired')},command:{mode,owner:this.ownerKey(owner),resource,profile}}}
+ private current(s:HostState,key:string){const m=s.manual.get(key);if(m&&(!this.hostOnline(s)||m.epoch!==s.info!.id||m.expiresAt<=Date.now()||this.auth.pushAuthorizationVersion(m.owner)!==m.version)){s.manual.delete(key);s.paused.add(key);s.frames.delete(key);this.store.remove('_system','computer-control',m.id);return undefined}return m}
+ private serial<T>(s:HostState,key:string,work:()=>Promise<T>){const next=(s.queue.get(key)??Promise.resolve()).catch(()=>{}).then(work);s.queue.set(key,next);void next.finally(()=>{if(s.queue.get(key)===next)s.queue.delete(key)}).catch(()=>{});return next}
+ private async capture(s:HostState,c:ReturnType<DesktopEnvironments['context']>){const cached=s.frames.get(c.key);c.check();if(cached&&cached.generation===(s.generation.get(c.key)??0)&&Date.now()-cached.capturedAt<700)return cached;const generation=s.generation.get(c.key)??0
+  const value=await this.command(s,{...c.command,operation:'view'},c.check)
   const image=parse(z.object({data:z.string().min(1).max(22*1024*1024),width:z.number().int().min(1).max(16384),height:z.number().int().min(1).max(16384)}).passthrough(),value)
-  if(generation!==(this.generation.get(c.key)??0))throw new HttpError(409,'控制状态改变,请刷新画面','computer_frame_stale')
-  const frame={...image,id:randomUUID(),generation,capturedAt:Date.now()};this.frames.set(c.key,frame);return frame
+  if(generation!==(s.generation.get(c.key)??0))throw new HttpError(409,'控制状态改变,请刷新画面','computer_frame_stale')
+  const frame={...image,id:randomUUID(),generation,capturedAt:Date.now()};s.frames.set(c.key,frame);return frame
  }
- private status(owner:string,a:WorkspaceAgent,mode:Mode):ComputerControlStatus {const key=this.resource(owner,a,mode),m=this.current(key);return {backend:mode,hostName:this.online?this.host!.name:undefined,mode:!this.online?'off':m?(m.owner===owner&&m.ready?'human':'pausing'):this.paused.has(key)?'error':this.activity.get(key)?'agent':'idle',controlId:m?.owner===owner?m.id:undefined,generation:this.generation.get(key)??0,canResume:!!this.activity.get(key),error:!this.online?'请在电脑上打开夭夭桌面端':this.paused.has(key)?'接管已断开,请重新接管并交还后继续':undefined}}
- assertIdle(owner:string,agentId:string){const a=this.agent(owner,agentId),mode=this.selected(owner,a);if(mode&&this.current(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(owner,a,mode).check();await requireTeamToolBridge(target,a.profile)}
+ 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')}}
+ 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),c=this.context(owner,a,expected),check=()=>{signal.throwIfAborted();assertActive();c.check();if(this.host!.id!==epoch)throw new HttpError(410,'桌面连接已改变','desktop_disconnected')};check()
-  this.activity.set(c.key,(this.activity.get(c.key)??0)+1)
-  try{return await this.serial(c.key,async()=>{while(this.current(c.key)||this.paused.has(c.key)){check();await new Promise(resolve=>setTimeout(resolve,150))}check()
+  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()
    let result:any
-   if(id==='desktop_environment_view'){if(expected==='browser')await this.command({...c.command,operation:'open'},check);const frame=await this.capture({...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:this.host!.name,mode:expected})}]}}
-   else if(id==='desktop_browser'&&expected==='browser'){result=await this.command({...c.command,operation:'browser',action:parse(browserSchema,input)},check);this.frames.delete(c.key)}
-   else if(id==='desktop_environment_action'){const frame=this.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({...c.command,operation:'input',action:parse(inputSchema,input),frame:{width:frame.width,height:frame.height}},check);this.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,input)},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,input),frame:{width:frame.width,height:frame.height}},check);s!.frames.delete(c.key)}
    else throw new HttpError(404,'当前环境不支持这个工具','tool_not_found')
-   const note=this.notes.get(owner+':'+agentId);if(note){this.notes.delete(owner+':'+agentId);if(result.content)result.content.push({type:'text',text:'人工交还说明:'+note});else result={...result,handoffNote:note}}return result
-  })}finally{this.activity.set(c.key,Math.max(0,(this.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))}
  }
- get epoch(){return this.host?.id??''}
- private grant(owner:string,a:WorkspaceAgent,c:ReturnType<DesktopEnvironments['context']>,body:any){const m=this.current(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}
+ /** Epoch of the host this agent is bound to; pins tool calls to one machine. */
+ hostEpoch(owner:string,agent:WorkspaceAgent){return this.sessionFor(agent)?.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}
  router(){const router=new Router()
   router.get('/api/app/agents/:id/desktop-environment',ctx=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);this.nodes.requireSource(owner,a);ctx.set('Cache-Control','no-store');ctx.body=this.state(owner,a)})
-  router.post('/api/app/agents/:id/desktop-environment/authorize',async ctx=>{const user=this.auth.require(ctx);this.auth.requireAdmin(ctx);this.agent(user.id,ctx.params.id);const owner=this.ownerKey(user.id),epoch=this.epoch;let pending=this.authorizing.get(user.id);if(!pending){pending=this.command({operation:'authorize',owner,account:user.username},()=>{this.auth.requireAdmin(ctx);if(!this.online||epoch!==this.epoch)throw new HttpError(409,'桌面端已断开','desktop_offline')},120000).finally(()=>this.authorizing.delete(user.id));this.authorizing.set(user.id,pending)}ctx.body=await pending})
+  router.post('/api/app/agents/:id/desktop-environment/authorize',async ctx=>{
+   const user=this.auth.require(ctx);this.auth.requireAdmin(ctx);const a=this.agent(user.id,ctx.params.id)
+   const body=parse(z.object({hostId:z.union([z.literal('local'),z.string().uuid()]).optional()}).strict(),(ctx.request as any).body??{})
+   const s=body.hostId!==undefined?this.sessions.get(body.hostId):this.sessionFor(a)
+   if(!s||!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
+   const owner=this.ownerKey(user.id),epoch=s.info!.id,authorizeKey=user.id+':'+s.key
+   let pending=this.authorizing.get(authorizeKey)
+   if(!pending){pending=this.command(s,{operation:'authorize',owner,account:user.username},()=>{this.auth.requireAdmin(ctx);if(!this.hostOnline(s)||epoch!==s.info!.id)throw new HttpError(409,'桌面端已断开','desktop_offline')},120000).finally(()=>this.authorizing.delete(authorizeKey));this.authorizing.set(authorizeKey,pending)}
+   ctx.body=await pending})
   router.put('/api/app/agents/:id/browser-profile',ctx=>{const owner=this.auth.require(ctx).id,a=this.agent(owner,ctx.params.id),body=parse(z.object({profile:z.enum(['persistent','temporary'])}).strict(),(ctx.request as any).body);this.assertIdle(owner,a.id);this.store.updateAgent(owner,a.id,{browserProfile:body.profile});ctx.body={ok:true}})
-  router.get('/api/app/agents/:id/browser',async ctx=>{const owner=this.auth.require(ctx).id,a=this.agent(owner,ctx.params.id);if(this.selected(owner,a)!=='browser')throw new HttpError(409,'请先选择仅浏览器环境','desktop_unselected');const c=this.context(owner,a,'browser');ctx.body=await this.command({...c.command,operation:'browser-state'},c.check)})
-  router.get('/api/app/agents/:id/computer',async(ctx,next)=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);ctx.body=this.status(owner,a,mode)})
-  router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);ctx.set('Cache-Control','no-store');ctx.body=await this.capture(this.context(owner,a,mode))})
+  router.get('/api/app/agents/:id/browser',async ctx=>{const owner=this.auth.require(ctx).id,a=this.agent(owner,ctx.params.id);if(this.selected(owner,a)!=='browser')throw new HttpError(409,'请先选择仅浏览器环境','desktop_unselected');const s=this.sessionFor(a);ctx.body=await this.command(s!,{...this.context(s,owner,a,'browser').command,operation:'browser-state'},this.context(s,owner,a,'browser').check)})
+  router.get('/api/app/agents/:id/computer',async(ctx,next)=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);ctx.body=this.status(this.sessionFor(a),owner,a,mode)})
+  router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);const s=this.sessionFor(a),c=this.context(s,owner,a,mode);ctx.set('Cache-Control','no-store');c.check();ctx.body=await this.capture(s!,c)})
   router.post('/api/app/agents/:id/computer/:action',async(ctx,next)=>{
-   const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);const c=this.context(owner,a,mode),body=(ctx.request as any).body,action=ctx.params.action;c.check()
+   const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);const s=this.sessionFor(a),c=this.context(s,owner,a,mode),body=(ctx.request as any).body,action=ctx.params.action;c.check()
    if(action==='take'){
-    const {requestId}=parse(z.object({requestId:z.string().uuid()}).strict(),body),old=this.current(c.key)
-    if(old){if(old.owner!==owner||old.agentId!==a.id||old.requestId!==requestId)throw new HttpError(409,'已有页面正在控制这台电脑','computer_busy');while(!old.ready){c.check();if(this.current(c.key)!==old)throw new HttpError(410,'接管已失效','computer_control_expired');await new Promise(resolve=>setTimeout(resolve,80))}ctx.body={...this.status(owner,a,mode),token:old.token};return}
-    const m:Manual={id:randomUUID(),token:randomBytes(32).toString('base64url'),requestId,owner,agentId:a.id,version:this.auth.pushAuthorizationVersion(owner)??0,expiresAt:Date.now()+90000,generation:(this.generation.get(c.key)??0)+1,epoch:this.epoch,actions:new Map()};this.manual.set(c.key,m);this.generation.set(c.key,m.generation);this.frames.delete(c.key)
-    this.store.put('_system','computer-control',m.id,{owner,agentId:a.id,environmentId:'desktop:'+c.key,expiresAt:m.expiresAt})
+    const {requestId}=parse(z.object({requestId:z.string().uuid()}).strict(),body),old=this.current(s!,c.key)
+    if(old){if(old.owner!==owner||old.agentId!==a.id||old.requestId!==requestId)throw new HttpError(409,'已有页面正在控制这台电脑','computer_busy');while(!old.ready){c.check();if(this.current(s!,c.key)!==old)throw new HttpError(410,'接管已失效','computer_control_expired');await new Promise(resolve=>setTimeout(resolve,80))}ctx.body={...this.status(s,owner,a,mode),token:old.token};return}
+    const m:Manual={id:randomUUID(),token:randomBytes(32).toString('base64url'),requestId,owner,agentId:a.id,version:this.auth.pushAuthorizationVersion(owner)??0,expiresAt:Date.now()+90000,generation:(s!.generation.get(c.key)??0)+1,epoch:s!.info!.id,actions:new Map()};s!.manual.set(c.key,m);s!.generation.set(c.key,m.generation);s!.frames.delete(c.key)
+    this.store.put('_system','computer-control',m.id,{owner,agentId:a.id,environmentId:'desktop:'+s!.key+':'+c.key,expiresAt:m.expiresAt})
     // The lane may contain agent calls waiting for this takeover. Only await
     // commands already dispatched to the host, not the whole agent lane.
-    while([...this.pending.values()].some(p=>p.command.resource===c.key)){c.check();if(this.current(c.key)!==m)throw new HttpError(410,'接管已失效','computer_control_expired');await new Promise(resolve=>setTimeout(resolve,80))}
-    try{await this.command({...c.command,operation:'open'},c.check);m.ready=true;m.expiresAt=Date.now()+30000;ctx.body={...this.status(owner,a,mode),token:m.token}}catch(error){this.manual.delete(c.key);this.paused.add(c.key);this.store.remove('_system','computer-control',m.id);throw error}return
+    while([...s!.pending.values()].some(p=>p.command.resource===c.key)){c.check();if(this.current(s!,c.key)!==m)throw new HttpError(410,'接管已失效','computer_control_expired');await new Promise(resolve=>setTimeout(resolve,80))}
+    try{await this.command(s!,{...c.command,operation:'open'},c.check);m.ready=true;m.expiresAt=Date.now()+30000;ctx.body={...this.status(s,owner,a,mode),token:m.token}}catch(error){s!.manual.delete(c.key);s!.paused.add(c.key);this.store.remove('_system','computer-control',m.id);throw error}return
    }
-   const m=this.grant(owner,a,c,body)
-   if(action==='renew'){m.expiresAt=Date.now()+30000;this.store.put('_system','computer-control',m.id,{owner,agentId:a.id,environmentId:'desktop:'+c.key,expiresAt:m.expiresAt});ctx.body={ok:true};return}
-   if(action==='giveback'){m.releasing=true;await m.pending?.catch(()=>{});if(typeof body.notes==='string')this.notes.set(owner+':'+a.id,body.notes.slice(0,4000));this.manual.delete(c.key);this.paused.delete(c.key);this.store.remove('_system','computer-control',m.id);this.generation.set(c.key,m.generation+1);this.frames.delete(c.key);ctx.body={ok:true};return}
+   const m=this.grant(s!,owner,a,c,body)
+   if(action==='renew'){m.expiresAt=Date.now()+30000;this.store.put('_system','computer-control',m.id,{owner,agentId:a.id,environmentId:'desktop:'+s!.key+':'+c.key,expiresAt:m.expiresAt});ctx.body={ok:true};return}
+   if(action==='giveback'){m.releasing=true;await m.pending?.catch(()=>{});if(typeof body.notes==='string')s!.notes.set(owner+':'+a.id,body.notes.slice(0,4000));s!.manual.delete(c.key);s!.paused.delete(c.key);this.store.remove('_system','computer-control',m.id);s!.generation.set(c.key,m.generation+1);s!.frames.delete(c.key);ctx.body={ok:true};return}
    if(action==='input'||action==='browser'){
     const v=parse(z.object({controlId:z.string(),token:z.string(),requestId:z.string().uuid(),generation:z.number().int(),frameId:z.string().uuid(),action:z.unknown()}).strict(),body),fingerprint=JSON.stringify([action,v.generation,v.frameId,v.action]),old=m.actions.get(v.requestId)
     if(old){if(old.fingerprint!==fingerprint)throw new HttpError(409,'请求编号冲突','idempotency_conflict');ctx.body=await old.value;return}
-    const f=this.frames.get(c.key);if(!f||f.id!==v.frameId||f.generation!==v.generation||m.generation!==v.generation||Date.now()-f.capturedAt>10000)throw new HttpError(409,'画面已过期,请刷新后操作','computer_frame_stale')
+    const f=s!.frames.get(c.key);if(!f||f.id!==v.frameId||f.generation!==v.generation||m.generation!==v.generation||Date.now()-f.capturedAt>10000)throw new HttpError(409,'画面已过期,请刷新后操作','computer_frame_stale')
     if(m.releasing||m.actions.size>=512)throw new HttpError(409,'请交还后重新接管','computer_control_expired')
     if(action==='browser'&&mode!=='browser')throw new HttpError(409,'当前环境不是浏览器','desktop_unselected')
-    const input=(action==='browser'?parse(browserSchema,v.action):parse(inputSchema,v.action)),check=()=>{this.grant(owner,a,c,body);if(this.current(c.key)!==m)throw new HttpError(410,'控制权已失效','computer_control_expired')}
-    const result=(m.pending??Promise.resolve()).catch(()=>{}).then(()=>this.command({...c.command,operation:action==='browser'?'browser':'input',action:input,frame:{width:f.width,height:f.height}},check));m.pending=result;m.actions.set(v.requestId,{fingerprint,value:result});ctx.body=await result;this.frames.delete(c.key);return
+    const input=(action==='browser'?parse(browserSchema,v.action):parse(inputSchema,v.action)),check=()=>{this.grant(s!,owner,a,c,body);if(this.current(s!,c.key)!==m)throw new HttpError(410,'控制权已失效','computer_control_expired')}
+    const result=(m.pending??Promise.resolve()).catch(()=>{}).then(()=>this.command(s!,{...c.command,operation:action==='browser'?'browser':'input',action:input,frame:{width:f.width,height:f.height}},check));m.pending=result;m.actions.set(v.requestId,{fingerprint,value:result});ctx.body=await result;s!.frames.delete(c.key);return
    }
    throw new HttpError(404,'电脑操作不存在','not_found')
   })

+ 1 - 1
src/server/workspaceRuntime.ts

@@ -692,7 +692,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const team=agent.canManageTeam===true&&!this.store.require<Run>(owner,'run',run.runId).assignmentId
         const cloud=!!this.cloud?.selected(owner,agent)
         const plugins=!!this.plugins?.selected(owner,agent)
-        const desktop=this.desktopEnvironments?.toolMode(owner,agent),desktopEpoch=this.desktopEnvironments?.epoch??''
+        const desktop=this.desktopEnvironments?.toolMode(owner,agent),desktopEpoch=this.desktopEnvironments?.hostEpoch(owner,agent)??''
         const knowledge = botCapabilities.tools && !agent.remoteAgentId
         if(team||cloud||desktop||plugins||knowledge){
           if(team){

+ 5 - 1
src/server/workspaceStore.ts

@@ -42,6 +42,7 @@ export const agentInput = z
     instructions: z.string().max(24_000).default(''),
     execution:z.enum(['profile','computer']).default('profile'),
     computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
+    desktopHost:z.union([z.literal('local'),z.string().uuid(),z.null()]).optional(),
     allowHostEnvironment:z.boolean().default(false),
     vmExecution:z.enum(['worker','profile']).optional(),
     browserProfile:z.enum(['persistent','temporary']).optional(),
@@ -60,6 +61,7 @@ export const agentPatch = z
     instructions: z.string().max(24_000).optional(),
     execution:z.enum(['profile','computer']).optional(),
     computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
+    desktopHost:z.union([z.literal('local'),z.string().uuid(),z.null()]).optional(),
     allowHostEnvironment:z.boolean().optional(),
     vmExecution:z.enum(['worker','profile']).optional(),
     browserProfile:z.enum(['persistent','temporary']).optional(),
@@ -441,8 +443,10 @@ export class WorkspaceStore {
   updateAgent(owner: string, id: string, input: unknown): Agent {
     const patch = parse(agentPatch, input)
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
+    if (patch.desktopHost === null) patch.desktopHost = undefined
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
+      const desktopHostChanged = patch.desktopHost !== undefined && patch.desktopHost !== (agent.desktopHost ?? null)
       if(patch.vmExecution==='profile'&&patch.allowHostEnvironment===false)throw new HttpError(400,'本机协作模式需要本机环境,请选择虚拟机模式','computer_profile_host_required')
       if(agent.vmExecution==='profile'){
         if(patch.allowHostEnvironment===false&&patch.vmExecution===undefined)patch.vmExecution='worker'
@@ -453,7 +457,7 @@ export class WorkspaceStore {
       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 hostPermissionChanged=patch.allowHostEnvironment!==undefined&&patch.allowHostEnvironment!==(agent.allowHostEnvironment===true)
-      const destinationChanged=hostPermissionChanged||(patch.vmExecution!==undefined&&patch.vmExecution!==(agent.vmExecution??'worker'))||(patch.computer!==undefined&&patch.computer!==agent.computer)||(patch.browserProfile!==undefined&&patch.browserProfile!==(agent.browserProfile??'persistent'))
+      const destinationChanged=desktopHostChanged||hostPermissionChanged||(patch.vmExecution!==undefined&&patch.vmExecution!==(agent.vmExecution??'worker'))||(patch.computer!==undefined&&patch.computer!==agent.computer)||(patch.browserProfile!==undefined&&patch.browserProfile!==(agent.browserProfile??'persistent'))
       if(sourceChanged||destinationChanged){
         if(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')

+ 2 - 0
src/shared/workspace.ts

@@ -13,6 +13,8 @@ export interface WorkspaceAgent {
   /** Explicit account-owner grant; absent on older records means disabled. */
   execution?: 'profile' | 'computer'
   computer?: 'auto' | 'cloud' | 'vm' | 'local' | 'browser' | 'off'
+  /** Bound desktop host: 'local' (loopback App) or a paired desktop-host id. */
+  desktopHost?: string | null
   allowHostEnvironment?: boolean
   /** Local VM execution backend; older records retain the isolated Worker. */
   vmExecution?: 'worker' | 'profile'

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

@@ -30,6 +30,64 @@ it('adds an authorized native desktop to hybrid tools while leaving the default
  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)}
+const HOST_A='11111111-aaaa-4bbb-8ccc-111111111111',HOST_B='22222222-aaaa-4bbb-8ccc-222222222222'
+function driveRemote(id:string,hostInfo:any,handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('remote frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.remoteExchange(id,{host:hostInfo,results});poller=setInterval(()=>{const value=service.remoteExchange(id,{host:hostInfo,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5)}
+it('routes a bound agent to its remote desktop host and keeps hosts isolated',async()=>{
+ agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
+ const remoteHost={...host,name:'客厅 Mac Pro'}
+ driveRemote(HOST_A,remoteHost)
+ service.exchange({host,results:[]})
+ const epoch=service.hostEpoch('owner',agent)
+ expect(epoch).toBe(remoteHost.id)
+ const result=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',epoch)
+ expect(result.content[0].type).toBe('image')
+ expect(JSON.parse(result.content[1].text).host).toBe('客厅 Mac Pro')
+ const state=(await request(app()).get(base()+'/desktop-environment')).body
+ expect(state.hostName??state.host?.name).toBe('客厅 Mac Pro')
+ expect(state.hosts.map((h:any)=>h.id)).toEqual(['local',HOST_A])
+ expect(state.hosts.find((h:any)=>h.id===HOST_A)).toMatchObject({name:'客厅 Mac Pro',online:true})
+})
+it('requires an explicit binding when several remote hosts are connected',async()=>{
+ agent=store.updateAgent('owner',agent.id,{computer:'local'})
+ service.remoteExchange(HOST_A,{host:{...host,name:'书房'},results:[]})
+ service.remoteExchange(HOST_B,{host:{...host,name:'客厅'},results:[]})
+ const state=(await request(app()).get(base()+'/desktop-environment')).body
+ expect(state.online).toBe(false)
+ expect(state.hosts).toHaveLength(2)
+ await request(app()).get(base()+'/computer/frame').expect(409)
+ agent=store.updateAgent('owner',agent.id,{desktopHost:HOST_B})
+ driveRemote(HOST_B,{...host,name:'客厅'})
+ const status=(await request(app()).get(base()+'/computer')).body
+ expect(status.hostName).toBe('客厅')
+ const ticket=await take()
+ await request(app()).post(base()+'/computer/giveback').send(ticket).expect(200)
+})
+it('revoking a desktop host fences only its dispatched commands',async()=>{
+ agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
+ service.remoteExchange(HOST_A,{host,results:[]})
+ const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))
+ await vi.waitFor(()=>{expect(service.remoteExchange(HOST_A,{host,results:[]}).commands).toHaveLength(1)})
+ service.dropHost(HOST_A)
+ await expect(call).rejects.toMatchObject({code:'desktop_disconnected'})
+})
+it('keeps manual takeover scoped per desktop host',async()=>{
+ 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})
+ driveRemote(HOST_A,{...host,name:'书房'})
+ let resultsB:any[]=[]
+ const pollerB=setInterval(()=>{const value=service.remoteExchange(HOST_B,{host:{...host,name:'客厅'},results:resultsB});resultsB=value.commands.map(c=>({id:c.id,value:{ok:true}}))},5)
+ try{
+  const ticketA=await take()
+  const ticketB=(await request(app()).post('/api/app/agents/'+second.id+'/computer/take').send({requestId:randomUUID()}).expect(200)).body
+  expect(ticketB.hostName).toBe('客厅')
+  const records=store.list<any>('_system','computer-control')
+  expect(records).toHaveLength(2)
+  expect(new Set(records.map(r=>r.environmentId.split(':')[1])).size).toBe(2)
+  await request(app()).post(base()+'/computer/renew').send(ticketA).expect(200)
+  await request(app()).post(base()+'/computer/giveback').send(ticketA).expect(200)
+  await request(app()).post('/api/app/agents/'+second.id+'/computer/giveback').send(ticketB).expect(200)
+ }finally{clearInterval(pollerB)}
+})
 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()=>{