|
@@ -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 {type WorkspaceAgent} from '../shared/workspace.js'
|
|
|
import {readHostTools} from './hostToolSettings.js'
|
|
import {readHostTools} from './hostToolSettings.js'
|
|
|
import {readComputerNames} from './computerNames.js'
|
|
import {readComputerNames} from './computerNames.js'
|
|
|
import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
|
|
import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
|
|
@@ -54,7 +54,7 @@ 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()})
|
|
|
export const DESKTOP_ENVIRONMENT_TOOLS=[
|
|
export const DESKTOP_ENVIRONMENT_TOOLS=[
|
|
|
- {id:'desktop_environment_view',name:'desktop_environment_view',description:'查看电脑真实桌面截图,返回截图尺寸、环境类型和电脑名称。仅在需要看窗口、图标布局或做点击输入时使用;列目录、读文件、写文件、跑命令不要先截图。可用 host 指定目标电脑;省略或写「本机」时优先用户当前发消息所在电脑。',inputSchema:{type:'object',properties:{host:{type:'string',description:'可选,目标电脑的名称或编号;缺省用默认电脑'}},additionalProperties:false}},
|
|
|
|
|
|
|
+ {id:'desktop_environment_view',name:'desktop_environment_view',description:'查看电脑真实桌面截图,返回截图尺寸、环境类型和电脑名称。仅在需要看窗口、图标布局或做点击输入时使用;列目录、读文件、写文件、跑命令不要先截图。可用 host 指定目标电脑;省略或写「本机」时只使用本轮消息来源电脑;来源未知或离线时必须明确目标,禁止回退。',inputSchema:{type:'object',properties:{host:{type:'string',description:'可选,目标电脑的名称或编号;缺省用本轮消息来源电脑;server 明确指服务器'}},additionalProperties:false}},
|
|
|
{id:'desktop_environment_action',name:'desktop_environment_action',description:'操作电脑桌面(click、text、key、scroll、drag)。做这些鼠标键盘操作前必须先截图,坐标用刚截到的像素;页面变化后重新截图。列目录和跑命令请用文件/shell 工具,不要用本工具。可用 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_environment_action',name:'desktop_environment_action',description:'操作电脑桌面(click、text、key、scroll、drag)。做这些鼠标键盘操作前必须先截图,坐标用刚截到的像素;页面变化后重新截图。列目录和跑命令请用文件/shell 工具,不要用本工具。可用 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:'列出当前电脑允许范围内的目录内容,返回名称、类型、大小与修改时间。路径相对于用户主目录,例如 Desktop、Documents,或 ~/Desktop。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
|
|
{id:'desktop_file_list',name:'desktop_file_list',description:'列出当前电脑允许范围内的目录内容,返回名称、类型、大小与修改时间。路径相对于用户主目录,例如 Desktop、Documents,或 ~/Desktop。可用 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_read',name:'desktop_file_read',description:'读取当前电脑上的一个文件,返回 base64 内容与大小。路径相对于用户主目录;超出允许范围会被拒绝。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
|
|
@@ -63,9 +63,9 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
|
|
|
]
|
|
]
|
|
|
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'])
|
|
|
/** Satellite Macs connected as script machines. */
|
|
/** Satellite Macs connected as script machines. */
|
|
|
-export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机,也就是客户连接的 Mac。一律用 desktop_*,可用 host 写明设置里的电脑名称。省略 host 或写「本机」时:有用户消息设备来源就用那台,否则用 Bot 默认绑定。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。文件与 shell 在用户主目录范围内真实生效。服务器、虚拟环境和云虚拟机都不是脚本机。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
|
|
|
|
|
|
|
+export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机,也就是客户连接的 Mac。一律用 desktop_*,可用 host 写明设置里的电脑名称。省略 host 或写「本机」时:只使用本轮用户消息来源电脑;没有来源或来源离线时不得回退,必须明确目标。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。文件与 shell 在用户主目录范围内真实生效。服务器、虚拟环境和云虚拟机都不是脚本机。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
|
|
|
/** Yaoyao host Mac — same look-then-act pattern as the cloud computer. */
|
|
/** Yaoyao host Mac — same look-then-act pattern as the cloud computer. */
|
|
|
-export const SERVER_COMPUTER_RULES='当前环境是服务器,也就是夭夭正在运行的这台电脑。电脑名称与设置里一致,可改名;点名用这个名字。一律用 desktop_*。省略 host 或写「本机」时:用户消息来自服务器就用这台;来自脚本机则「本机」指那台脚本机;没有来源时默认这台服务器。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。未授权文件与命令时如实说明,不要改用截图凑数。文件与 shell 在用户主目录范围内真实生效。脚本机、虚拟环境和云虚拟机都不是这台服务器本身。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
|
|
|
|
|
|
|
+export const SERVER_COMPUTER_RULES='当前环境是服务器,也就是夭夭正在运行的这台电脑。电脑名称与设置里一致,可改名;点名用这个名字。一律用 desktop_*。省略 host 或写「本机」时:用户消息来自服务器就用这台;来自脚本机则「本机」指那台脚本机;没有来源时必须明确目标。明确操作服务器时使用 host="server",不要省略 host。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。未授权文件与命令时如实说明,不要改用截图凑数。文件与 shell 在用户主目录范围内真实生效。脚本机、虚拟环境和云虚拟机都不是这台服务器本身。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
|
|
|
|
|
|
|
|
/** The loopback native transport is admitted only by serviceInstance's
|
|
/** The loopback native transport is admitted only by serviceInstance's
|
|
|
* private capability; remote hosts are admitted by DesktopHostHub's bearer
|
|
* private capability; remote hosts are admitted by DesktopHostHub's bearer
|
|
@@ -83,7 +83,7 @@ export class DesktopEnvironments {
|
|
|
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 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))}
|
|
|
/** File and shell grant: a separate approval tier held by the desktop app. */
|
|
/** File and shell grant: a separate approval tier held by the desktop app. */
|
|
|
private readyFull(s:HostState,owner:string){return this.hostOnline(s)&&s.info!.platform==='darwin'&&(s.info!.full??[]).includes(this.ownerKey(owner))}
|
|
private readyFull(s:HostState,owner:string){return this.hostOnline(s)&&s.info!.platform==='darwin'&&(s.info!.full??[]).includes(this.ownerKey(owner))}
|
|
|
- fileTools(owner:string,agent:WorkspaceAgent){const s=this.sessionFor(agent);return !!s&&this.readyFull(s,owner)}
|
|
|
|
|
|
|
+ fileTools(owner:string,agent:WorkspaceAgent){return this.envTools(owner,agent).file}
|
|
|
private localSession(){let s=this.sessions.get('local');if(!s){s=newHostState('local',false);this.sessions.set('local',s)}return s}
|
|
private localSession(){let s=this.sessions.get('local');if(!s){s=newHostState('local',false);this.sessions.set('local',s)}return s}
|
|
|
/** Default session: agent desktopHost binding, else server local.
|
|
/** Default session: agent desktopHost binding, else server local.
|
|
|
* 「本机」 at tool time prefers the user message deviceHost when provided. */
|
|
* 「本机」 at tool time prefers the user message deviceHost when provided. */
|
|
@@ -100,53 +100,50 @@ export class DesktopEnvironments {
|
|
|
if(!g.server)return undefined
|
|
if(!g.server)return undefined
|
|
|
return this.sessions.get('local')
|
|
return this.sessions.get('local')
|
|
|
}
|
|
}
|
|
|
- /** Resolve an explicit host name/id. When host is 本机/local, prefer contextDeviceHost. */
|
|
|
|
|
- private namedHost(host:string,contextDeviceHost?:string):HostState{
|
|
|
|
|
- const g=this.computers()
|
|
|
|
|
- if(host==='本机'||host==='local'){
|
|
|
|
|
- const contextual=this.contextSession(contextDeviceHost)
|
|
|
|
|
- if(contextual)return contextual
|
|
|
|
|
- if(!g.server)throw new HttpError(409,'全局设置未开放服务器电脑的操作。','desktop_offline')
|
|
|
|
|
- const local=this.sessions.get('local')
|
|
|
|
|
- if(local&&this.hostOnline(local))return local
|
|
|
|
|
- throw new HttpError(409,'服务器电脑未连接,请在这台电脑上打开夭夭。','desktop_offline')
|
|
|
|
|
- }
|
|
|
|
|
- if(!g.script)throw new HttpError(409,'全局设置未开放脚本机操作。','desktop_offline')
|
|
|
|
|
- const names=this.names();for(const s of this.sessions.values())if(s.key===host||(s.info&&s.info.name.toLowerCase()===host.toLowerCase())||(names[s.key]&&names[s.key].toLowerCase()===host.toLowerCase()))return s
|
|
|
|
|
- throw new HttpError(409,`指定的电脑不在线:${host.slice(0,64)}`,'desktop_host_unknown')
|
|
|
|
|
|
|
+ /** Tool aliases are relative to the triggering message, never to a Bot binding. */
|
|
|
|
|
+ private namedHost(host:string,deviceHost?:string):HostState{
|
|
|
|
|
+ if(host==='本机'||host==='local')return this.messageHost(deviceHost)
|
|
|
|
|
+ if(host==='server'||host==='服务器')return this.requireHost(this.localSession())
|
|
|
|
|
+ const matches=[...this.sessions.values()].filter(s=>
|
|
|
|
|
+ s.key===host||
|
|
|
|
|
+ this.label(s).toLowerCase()===host.toLowerCase()||s.info?.name.toLowerCase()===host.toLowerCase())
|
|
|
|
|
+ if(matches.length>1)throw new HttpError(409,'电脑名称重复,请使用电脑编号指定目标。','desktop_host_ambiguous')
|
|
|
|
|
+ const s=matches[0]
|
|
|
|
|
+ if(!s)throw new HttpError(409,`指定的电脑未连接:${host.slice(0,64)}`,'desktop_host_unknown')
|
|
|
|
|
+ return this.requireHost(s)
|
|
|
}
|
|
}
|
|
|
- /** Session for the user's messaging device when it is online and globally open. */
|
|
|
|
|
- private contextSession(deviceHost?:string):HostState|undefined{
|
|
|
|
|
- if(!deviceHost)return undefined
|
|
|
|
|
- const s=this.sessions.get(deviceHost==='local'?'local':deviceHost)
|
|
|
|
|
- if(!s||!this.hostOnline(s)||!this.hostOpen(s))return undefined
|
|
|
|
|
|
|
+ private requireHost(s:HostState):HostState{
|
|
|
|
|
+ if(!this.hostOpen(s))throw new HttpError(403,'全局设置未开放这台电脑。','desktop_host_disabled')
|
|
|
|
|
+ if(!this.hostOnline(s))throw new HttpError(409,'目标电脑已离线,请连接原电脑后重试。','desktop_offline')
|
|
|
return s
|
|
return s
|
|
|
}
|
|
}
|
|
|
- selected(owner:string,agent:WorkspaceAgent):Mode|undefined {if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)return undefined;const g=this.computers();if(!g.script&&!g.server)return undefined;if(agent.envs)return 'local';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 g=this.computers(),desktop=g.script||g.server;if(agent.envs)return {desktop,browser:false};const mode=this.toolMode(owner,agent);return {desktop: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} {
|
|
|
|
|
|
|
+ private messageHost(deviceHost?:string):HostState{
|
|
|
|
|
+ if(!deviceHost)throw new HttpError(409,'当前消息没有可控制的来源电脑,请指定电脑名称或编号。','desktop_source_required')
|
|
|
|
|
+ const s=this.sessions.get(deviceHost)
|
|
|
|
|
+ if(!s)throw new HttpError(409,'消息来源电脑未连接;不会改用其他电脑。','desktop_offline')
|
|
|
|
|
+ return this.requireHost(s)
|
|
|
|
|
+ }
|
|
|
|
|
+ private contextSession(deviceHost?:string):HostState|undefined{
|
|
|
|
|
+ return deviceHost?this.sessions.get(deviceHost):undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ selected(_owner:string,agent:WorkspaceAgent):Mode|undefined {
|
|
|
|
|
+ if(agent.archived||agent.remoteAgentId)return undefined
|
|
|
|
|
+ const g=this.computers();return g.script||g.server?'local':undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ toolMode(owner:string,agent:WorkspaceAgent):Mode|undefined{return this.selected(owner,agent)}
|
|
|
|
|
+ modes(owner:string,agent:WorkspaceAgent):{desktop:boolean,browser:boolean}{return {desktop:!!this.selected(owner,agent),browser:false}}
|
|
|
|
|
+ envTools(owner:string,agent:WorkspaceAgent):{view:boolean,browser:boolean,file:boolean}{
|
|
|
const wanted=this.modes(owner,agent)
|
|
const wanted=this.modes(owner,agent)
|
|
|
const online=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s))
|
|
const online=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(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))}
|
|
|
|
|
|
|
+ return {view:wanted.desktop&&online.some(s=>this.ready(s,owner)),browser:false,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. */
|
|
|
|
|
- /** Resolve the host a manual takeover request addresses: explicit host
|
|
|
|
|
- * id/name from the viewer switcher, else the agent binding. */
|
|
|
|
|
|
|
+ /** Viewer host ids are absolute; the wire id local identifies the server. */
|
|
|
private takeoverSession(a:WorkspaceAgent,host:unknown):HostState|undefined{
|
|
private takeoverSession(a:WorkspaceAgent,host:unknown):HostState|undefined{
|
|
|
- if(typeof host==='string'&&host)return this.namedHost(host)
|
|
|
|
|
|
|
+ if(typeof host==='string'&&host)return this.namedHost(host==='local'?'server':host)
|
|
|
return this.sessionFor(a)
|
|
return this.sessionFor(a)
|
|
|
}
|
|
}
|
|
|
- private targetFor(agent:WorkspaceAgent,host:unknown,contextDeviceHost?:string):HostState{
|
|
|
|
|
- if(typeof host==='string'&&host)return this.namedHost(host,contextDeviceHost)
|
|
|
|
|
- const contextual=this.contextSession(contextDeviceHost)
|
|
|
|
|
- if(contextual)return contextual
|
|
|
|
|
- const s=this.sessionFor(agent)
|
|
|
|
|
- if(!s)throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
|
|
|
|
|
- return s
|
|
|
|
|
|
|
+ private targetFor(_agent:WorkspaceAgent,host:unknown,deviceHost?:string):HostState{
|
|
|
|
|
+ return typeof host==='string'&&host?this.namedHost(host,deviceHost):this.messageHost(deviceHost)
|
|
|
}
|
|
}
|
|
|
onlineHostsLine(owner:string,deviceHost?:string){
|
|
onlineHostsLine(owner:string,deviceHost?:string){
|
|
|
const names=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s)).map(s=>{
|
|
const names=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s)).map(s=>{
|
|
@@ -156,26 +153,27 @@ export class DesktopEnvironments {
|
|
|
return mine?`${label}(${kind}·本机)`:`${label}(${kind})`
|
|
return mine?`${label}(${kind}·本机)`:`${label}(${kind})`
|
|
|
})
|
|
})
|
|
|
const tip=deviceHost
|
|
const tip=deviceHost
|
|
|
- ?'省略 host 或写「本机」时,优先使用用户当前发消息所在的那台电脑;也可用设置里的电脑名称指定其它电脑。'
|
|
|
|
|
- :'省略 host 或写「本机」时,没有用户设备来源则默认服务器,否则用 Bot 绑定;也可用设置里的电脑名称指定。'
|
|
|
|
|
|
|
+ ? '省略 host 或写「本机」只指本轮消息来源电脑;离线或未授权时不能改用其他电脑。指定服务器用 host="server"。'
|
|
|
|
|
+ : '本轮没有可控制的消息来源电脑,必须用 host 指定目标名称或编号;服务器用 host="server"。'
|
|
|
return names.length?`当前在线电脑:${names.join('、')}。desktop_* 工具可用 host 参数(名称或编号)选择目标电脑;${tip}`:''
|
|
return names.length?`当前在线电脑:${names.join('、')}。desktop_* 工具可用 host 参数(名称或编号)选择目标电脑;${tip}`:''
|
|
|
}
|
|
}
|
|
|
- deviceContextLine(owner:string,deviceHost:string){
|
|
|
|
|
|
|
+ deviceContextLine(owner:string,deviceHost?:string){
|
|
|
|
|
+ if(!deviceHost)return '本轮消息来自未绑定控制主机的客户端;「本机」无法确定,必须指定目标电脑。历史消息的设备来源不适用于本轮。'
|
|
|
const s=this.contextSession(deviceHost)
|
|
const s=this.contextSession(deviceHost)
|
|
|
- if(!s){
|
|
|
|
|
|
|
+ if(!s||!this.hostOnline(s)||!this.hostOpen(s)){
|
|
|
const named=this.names()[deviceHost==='local'?'local':deviceHost]
|
|
const named=this.names()[deviceHost==='local'?'local':deviceHost]
|
|
|
- return `本轮用户消息来源设备:${named||deviceHost}(当前未在线或未开放)。`
|
|
|
|
|
|
|
+ return `本轮用户消息来源设备:${named||deviceHost}(当前未在线或未开放)。「本机」仍指这台电脑,不能回退到服务器或其他电脑。`
|
|
|
}
|
|
}
|
|
|
const kind=s.key==='local'?'服务器':'脚本机'
|
|
const kind=s.key==='local'?'服务器':'脚本机'
|
|
|
return `本轮用户消息来自${kind}「${this.label(s)}」。名字与设置里的电脑名称相同;对本轮而言,「本机」指这台电脑。`
|
|
return `本轮用户消息来自${kind}「${this.label(s)}」。名字与设置里的电脑名称相同;对本轮而言,「本机」指这台电脑。`
|
|
|
}
|
|
}
|
|
|
- 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)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:this.label(s),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:this.label(s),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))}
|
|
|
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?{id:s!.key,name:this.label(s!),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),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
|
|
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?{id:s!.key,name:this.label(s!),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),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
|
|
|
- /** Parallel envs allow each requested leg; legacy agents still use the single toolMode. */
|
|
|
|
|
- private modeOpen(owner:string,agent:WorkspaceAgent,mode:Mode){if(agent.envs){const wanted=this.modes(owner,agent);return mode==='browser'?wanted.browser:wanted.desktop}return this.toolMode(owner,agent)===mode}
|
|
|
|
|
- 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.modeOpen(owner,a,mode))throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');if(mode==='local'&&!this.ready(s,owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
|
|
|
|
|
|
|
+ /** Every Bot follows the same global desktop policy. */
|
|
|
|
|
+ private modeOpen(owner:string,agent:WorkspaceAgent,mode:Mode){return this.selected(owner,agent)===mode}
|
|
|
|
|
+ private check(s:HostState|undefined,owner:string,agentId:string,mode:Mode,epoch:string,version:number,files=false){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.modeOpen(owner,a,mode))throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');this.requireHost(s);if(files&&!this.readyFull(s,owner))throw new HttpError(403,'请在桌面端授权文件与命令访问。','desktop_full_required');if(mode==='local'&&!files&&!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()}
|
|
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)}
|
|
close(){this.closed=true;for(const s of this.sessions.values())this.invalidate(s)}
|
|
|
async bridge(ctx:Koa.Context){
|
|
async bridge(ctx:Koa.Context){
|
|
@@ -210,7 +208,7 @@ export class DesktopEnvironments {
|
|
|
if(s.pending.size>=64)throw new HttpError(429,'桌面操作过多,请稍后重试','desktop_busy')
|
|
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})})
|
|
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(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 context(s:HostState|undefined,owner:string,agent:WorkspaceAgent,mode:Mode,files=false){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,files);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 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 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
|
|
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
|
|
@@ -222,21 +220,16 @@ 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){
|
|
async requireAvailable(owner:string,a:WorkspaceAgent,target:GatewayTarget){
|
|
|
- if(a.envs){
|
|
|
|
|
- const tools=this.envTools(owner,a)
|
|
|
|
|
- if(!tools.view&&!tools.browser)return
|
|
|
|
|
- if(tools.view){const s=this.readySession(owner)??this.sessionFor(a);if(!s||!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline');this.context(s,owner,a,'local').check()}
|
|
|
|
|
- if(tools.browser){const s=this.sessionFor(a);if(!s||!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline');this.context(s,owner,a,'browser').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)
|
|
|
|
|
|
|
+ const tools=this.envTools(owner,a)
|
|
|
|
|
+ if(tools.view||tools.file)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,deviceHost?:string){
|
|
async call(owner:string,agentId:string,id:string,input:unknown,signal:AbortSignal,assertActive:()=>void,expected:Mode,epoch:string|Record<string,string>|undefined,deviceHost?:string){
|
|
|
if(id==='desktop_browser'||expected==='browser')throw new HttpError(404,'当前环境不支持这个工具','tool_not_found')
|
|
if(id==='desktop_browser'||expected==='browser')throw new HttpError(404,'当前环境不支持这个工具','tool_not_found')
|
|
|
const a=this.agent(owner,agentId)
|
|
const a=this.agent(owner,agentId)
|
|
|
const {host,...arguments_}=typeof input==='object'&&input?input as Record<string,unknown>:{}
|
|
const {host,...arguments_}=typeof input==='object'&&input?input as Record<string,unknown>:{}
|
|
|
- const s=this.targetFor(a,host,deviceHost)
|
|
|
|
|
- const c=this.context(s,owner,a,expected)
|
|
|
|
|
|
|
+ const requestedHost=host===undefined?undefined:parse(z.string().trim().min(1).max(128),host)
|
|
|
|
|
+ const s=this.targetFor(a,requestedHost,deviceHost)
|
|
|
|
|
+ const c=this.context(s,owner,a,expected,DESKTOP_FILE_TOOL_IDS.has(id))
|
|
|
const pinned=typeof epoch==='string'?(s===this.sessionFor(a)?epoch:undefined):epoch?.[s.key]
|
|
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()
|
|
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)
|
|
s.activity.set(c.key,(s.activity.get(c.key)??0)+1)
|
|
@@ -265,7 +258,7 @@ export class DesktopEnvironments {
|
|
|
const body=parse(z.object({hostId:z.union([z.literal('local'),z.string().uuid()]).optional(),scope:z.enum(['screen','full']).default('screen')}).strict(),(ctx.request as any).body??{})
|
|
const body=parse(z.object({hostId:z.union([z.literal('local'),z.string().uuid()]).optional(),scope:z.enum(['screen','full']).default('screen')}).strict(),(ctx.request as any).body??{})
|
|
|
const s=body.hostId!==undefined?this.sessions.get(body.hostId):this.sessionFor(a)
|
|
const s=body.hostId!==undefined?this.sessions.get(body.hostId):this.sessionFor(a)
|
|
|
if(!s||!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
|
|
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
|
|
|
|
|
|
|
+ const owner=this.ownerKey(user.id),epoch=s.info!.id,authorizeKey=user.id+':'+s.key+':'+body.scope
|
|
|
let pending=this.authorizing.get(authorizeKey)
|
|
let pending=this.authorizing.get(authorizeKey)
|
|
|
if(!pending){pending=this.command(s,{operation:'authorize',owner,account:user.username,scope:body.scope},()=>{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)}
|
|
if(!pending){pending=this.command(s,{operation:'authorize',owner,account:user.username,scope:body.scope},()=>{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})
|
|
ctx.body=await pending})
|