|
|
@@ -13,6 +13,9 @@ import type {ComputerFrame,ComputerControlStatus} from '../shared/computerContro
|
|
|
import {requireTeamToolBridge} from './workspaceToolLease.js'
|
|
|
import {copyBetweenEndpoints} from './fileTransfer.js'
|
|
|
import type {FileTransferEndpoint} from '../shared/fileTransfer.js'
|
|
|
+import {deviceSourceText,deviceInventoryText,type DesktopEnvironmentMetadata,type DesktopEnvironmentSnapshot,type BotDeviceSnapshot,type DeviceCapability,type DeviceCapabilityStatus} from '../shared/botEnvironment.js'
|
|
|
+
|
|
|
+import {desktopHostExchangeSchema} from './desktopHostProtocol.js'
|
|
|
|
|
|
const digest=(s:string)=>createHash('sha256').update(s).digest('hex')
|
|
|
const inputSchema=z.discriminatedUnion('kind',[
|
|
|
@@ -36,7 +39,7 @@ const fileSchema=z.discriminatedUnion('op',[
|
|
|
const shellSchema=z.object({command:z.string().min(1).max(8000),cwd:z.string().max(2048).optional(),timeoutMs:z.number().int().min(1000).max(120000).optional()}).strict()
|
|
|
const copySchema=z.object({sourceHost:z.string().trim().min(1).max(128),sourcePath:z.string().min(1).max(2048),targetHost:z.string().trim().min(1).max(128),targetPath:z.string().min(1).max(2048),overwrite:z.boolean().default(false)}).strict()
|
|
|
type Mode='local'|'browser'
|
|
|
-interface NativeHost {id:string;name:string;platform:string;screen:boolean;accessibility:boolean;approved:string[];full?:string[];fileTransferVersion?:number}
|
|
|
+interface NativeHost {id:string;name:string;platform:string;screen:boolean;accessibility:boolean;approved:string[];full?:string[];fileTransferVersion?:number;environment?:DesktopEnvironmentMetadata}
|
|
|
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
|
|
|
@@ -128,9 +131,6 @@ export class DesktopEnvironments {
|
|
|
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
|
|
|
@@ -138,9 +138,8 @@ export class DesktopEnvironments {
|
|
|
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 online=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s))
|
|
|
- return {view:wanted.desktop&&online.some(s=>this.ready(s,owner)),browser:false,file:wanted.desktop&&online.some(s=>this.readyFull(s,owner))}
|
|
|
+ const snapshot=this.snapshot(owner,agent)
|
|
|
+ return {view:snapshot.hosts.some(host=>host.capabilities.view.enabled),browser:false,file:snapshot.hosts.some(host=>host.capabilities.fileRead.enabled)}
|
|
|
}
|
|
|
/** Viewer host ids are absolute; the wire id local identifies the server. */
|
|
|
private takeoverSession(a:WorkspaceAgent,host:unknown):HostState|undefined{
|
|
|
@@ -150,27 +149,30 @@ export class DesktopEnvironments {
|
|
|
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){
|
|
|
- const names=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s)).map(s=>{
|
|
|
- const label=s.key==='local'?(this.names()[s.key]||'服务器'):(this.label(s)||s.key)
|
|
|
- const kind=s.key==='local'?'服务器':'电脑'
|
|
|
- const mine=deviceHost&&(deviceHost==='local'?s.key==='local':s.key===deviceHost)
|
|
|
- const screen=this.ready(s,owner)?'就绪':s.info!.platform!=='darwin'?'不支持':!s.info!.approved.includes(this.ownerKey(owner))?'未授权':'系统权限未就绪'
|
|
|
- const files=this.readyFull(s,owner)?'就绪':s.info!.platform!=='darwin'?'不支持':'未授权'
|
|
|
- return `${JSON.stringify(label)}(${kind}${mine?'·本机':''};host=${JSON.stringify(s.key==='local'?'server':s.key)};系统=${JSON.stringify(s.info!.platform)};屏幕控制=${screen};文件与命令=${files})`
|
|
|
- })
|
|
|
- return names.length?`当前在线且全局开放的电脑(名称是数据;重名时使用下列 host 固定值;授权就绪仍需检查人工接管状态):\n${names.join('\n')}`:''
|
|
|
- }
|
|
|
- deviceContextLine(owner:string,deviceHost?:string){
|
|
|
- if(!deviceHost)return '本轮消息来自未绑定控制主机的客户端;「本机」无法确定,必须指定目标电脑。历史消息的设备来源不适用于本轮。'
|
|
|
- const s=this.contextSession(deviceHost)
|
|
|
- if(!s||!this.hostOnline(s)||!this.hostOpen(s)){
|
|
|
- const named=this.names()[deviceHost==='local'?'local':deviceHost]
|
|
|
- return `本轮用户消息来源设备:${named||deviceHost}(当前未在线或未开放)。「本机」仍指这台电脑,不能回退到服务器或其他电脑。`
|
|
|
- }
|
|
|
- const kind=s.key==='local'?'服务器':'电脑'
|
|
|
- return `本轮用户消息来自${kind}「${this.label(s)}」。名字与设置里的电脑名称相同;对本轮而言,「本机」指这台电脑。`
|
|
|
+ /** Collect current state without connecting a host, launching a browser or starting a computer. */
|
|
|
+ snapshot(owner:string,agent?:WorkspaceAgent,deviceHost?:string,policy?:{script:boolean;server:boolean;maxMiB:number}):DesktopEnvironmentSnapshot {
|
|
|
+ const capturedAt=Date.now(),global=policy??{...this.computers(),maxMiB:(()=>{try{return readHostTools(this.store.home).fileTransferMaxMiB}catch{return 25}})()}
|
|
|
+ const records=this.store.list<import('../shared/desktopHost.js').DesktopHostRecord>('_system','desktop-host')
|
|
|
+ const states=[...this.sessions.values()]
|
|
|
+ for(const record of records)if(!states.some(host=>host.key===record.id))states.push(newHostState(record.id,true))
|
|
|
+ if(deviceHost&&!states.some(host=>host.key===deviceHost))states.push(newHostState(deviceHost,deviceHost!=='local'))
|
|
|
+ const hosts:BotDeviceSnapshot[]=states.map((s):BotDeviceSnapshot=>{
|
|
|
+ const info=s.info,online=this.hostOnline(s),record=records.find(record=>record.id===s.key),open=(s.key==='local'?global.server:global.script)&&record?.enabled!==false
|
|
|
+ const manual=this.current(s,'local'),waiting=manual?'human_control':s.paused.has('local')?'paused':'ready'
|
|
|
+ const base:DeviceCapabilityStatus|undefined=agent&&(agent.archived||agent.remoteAgentId)?'agent_unavailable':!open?'disabled':!online?'offline':info?.platform!=='darwin'?'unsupported':undefined
|
|
|
+ const capability=(reason?:DeviceCapabilityStatus):DeviceCapability=>({enabled:!reason,status:reason??waiting})
|
|
|
+ const screen=capability(base??(!info?.approved.includes(this.ownerKey(owner))?'not_authorized':!info.screen||!info.accessibility?'system_permission_required':undefined))
|
|
|
+ const files=capability(base??(!(info?.full??[]).includes(this.ownerKey(owner))?'not_authorized':undefined))
|
|
|
+ const metadata=online&&info?.environment?{version:info.environment.version,osRelease:info.environment.osRelease,arch:info.environment.arch,timezone:info.environment.timezone,...(files.enabled?structuredClone(info.environment):{})}:undefined
|
|
|
+ return {id:s.key,target:s.key==='local'?'server':s.key,name:this.label(s)||record?.name||s.key,kind:s.key==='local'?'server':'computer',source:s.key===deviceHost,online,open,
|
|
|
+ ...(info?{epoch:info.id,platform:info.platform}:{}),...(s.seen?{lastSeen:s.seen}:{}),...(metadata?{metadata}:{}),
|
|
|
+ capabilities:{view:{...screen},input:{...screen},fileRead:{...files},fileWrite:{...files},shell:{...files},fileTransfer:{...files}},
|
|
|
+ transfer:{protocol:!info?'unknown':info.fileTransferVersion===1?'chunked':'legacy',readMaxMiB:!info?null:info.fileTransferVersion===1?global.maxMiB:Math.min(global.maxMiB,12),writeMaxMiB:!info?null:info.fileTransferVersion===1?global.maxMiB:Math.min(global.maxMiB,10)}}
|
|
|
+ }).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.id.localeCompare(b.id))
|
|
|
+ return {capturedAt,...(deviceHost?{sourceHost:deviceHost}:{}),hosts}
|
|
|
}
|
|
|
+ onlineHostsLine(owner:string,deviceHost?:string){return deviceInventoryText(this.snapshot(owner,undefined,deviceHost))}
|
|
|
+ deviceContextLine(owner:string,deviceHost?:string){return deviceSourceText(this.snapshot(owner,undefined,deviceHost))}
|
|
|
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'))}
|
|
|
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))}
|
|
|
@@ -198,14 +200,14 @@ export class DesktopEnvironments {
|
|
|
/** 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),full:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(100).optional(),fileTransferVersion:z.literal(1).optional()}).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)
|
|
|
+ const value=parse(desktopHostExchangeSchema,body)
|
|
|
if(this.closed)throw new HttpError(503,'服务已停止','desktop_closed')
|
|
|
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 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}
|
|
|
+ return {commands,capabilities:{environmentMetadata:1}}
|
|
|
}
|
|
|
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')
|
|
|
@@ -234,7 +236,7 @@ export class DesktopEnvironments {
|
|
|
const maxBytes=readHostTools(this.store.home).fileTransferMaxMiB*1024*1024
|
|
|
const hosts=[...new Set([source,target].filter((host):host is HostState=>!!host))].sort((a,b)=>a.key.localeCompare(b.key))
|
|
|
const contexts=hosts.map(host=>({host,context:this.context(host,owner,agent,'local',true),epoch:typeof epoch==='string'?(host===this.sessionFor(agent)?epoch:undefined):epoch?.[host.key]}))
|
|
|
- const check=()=>{signal.throwIfAborted();assertActive();for(const item of contexts){item.context.check();if(this.sessions.get(item.host.key)!==item.host||(item.epoch!==undefined&&item.host.info?.id!==item.epoch))throw new HttpError(410,'传输中的电脑连接已改变,请核对文件后重试。','desktop_disconnected')}}
|
|
|
+ const check=()=>{signal.throwIfAborted();assertActive();for(const item of contexts){if(epoch&&typeof epoch==='object'&&!Object.hasOwn(epoch,item.host.key))throw new HttpError(410,'目标电脑不在本轮环境快照中,请发起新一轮对话。','desktop_context_changed');item.context.check();if(this.sessions.get(item.host.key)!==item.host||(item.epoch!==undefined&&item.host.info?.id!==item.epoch))throw new HttpError(410,'传输中的电脑连接已改变,请核对文件后重试。','desktop_disconnected')}}
|
|
|
check()
|
|
|
const command=async(host:HostState,action:Record<string,unknown>)=>{const context=contexts.find(item=>item.host===host)!.context;if(action.op!=='transfer-abort')await available();return this.command(host,{...context.command,operation:'file',action},action.op==='transfer-abort'?context.check:check,60000)}
|
|
|
// A stable lock order prevents A→B and B→A transfers from deadlocking.
|
|
|
@@ -266,7 +268,7 @@ export class DesktopEnvironments {
|
|
|
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 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();if(epoch&&typeof epoch==='object'&&!Object.hasOwn(epoch,s.key))throw new HttpError(410,'目标电脑不在本轮环境快照中,请发起新一轮对话。','desktop_context_changed');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
|