|
@@ -32,6 +32,7 @@ const fileSchema=z.discriminatedUnion('op',[
|
|
|
z.object({op:z.literal('write'),path:z.string().min(1).max(2048),data:z.string().max(14*1024*1024).refine(value=>Buffer.from(value,'base64').toString('base64')===value,'文件内容必须是 base64')}).strict(),
|
|
z.object({op:z.literal('write'),path:z.string().min(1).max(2048),data:z.string().max(14*1024*1024).refine(value=>Buffer.from(value,'base64').toString('base64')===value,'文件内容必须是 base64')}).strict(),
|
|
|
])
|
|
])
|
|
|
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 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'
|
|
type Mode='local'|'browser'
|
|
|
interface NativeHost {id:string;name:string;platform:string;screen:boolean;accessibility:boolean;approved:string[];full?:string[]}
|
|
interface NativeHost {id:string;name:string;platform:string;screen:boolean;accessibility:boolean;approved:string[];full?: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 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>}
|
|
@@ -59,9 +60,11 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
|
|
|
{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}},
|
|
|
{id:'desktop_file_write',name:'desktop_file_write',description:'把 base64 内容写入当前电脑上的文件(覆盖已存在文件)。路径相对于用户主目录;超出允许范围会被拒绝。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},data:{type:'string'},host:{type:'string'}},required:['path','data'],additionalProperties:false}},
|
|
{id:'desktop_file_write',name:'desktop_file_write',description:'把 base64 内容写入当前电脑上的文件(覆盖已存在文件)。路径相对于用户主目录;超出允许范围会被拒绝。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},data:{type:'string'},host:{type:'string'}},required:['path','data'],additionalProperties:false}},
|
|
|
|
|
+ {id:'desktop_file_copy',name:'desktop_file_copy',description:'把文件从一台电脑直接复制到另一台电脑,支持脚本机之间、脚本机与服务器之间双向传输。填写来源和目标电脑的名称或编号(服务器用 server;本机指本轮消息来源电脑),以及各自用户主目录内的文件路径,例如 Desktop/a.txt。保留来源文件,默认拒绝覆盖目标同名文件;只有用户要求覆盖时才设 overwrite=true。最大 10 MiB,支持二进制文件,写入并校验后返回结果。文件数据由服务器中转,不经过模型或聊天附件。',inputSchema:{type:'object',properties:{sourceHost:{type:'string'},sourcePath:{type:'string'},targetHost:{type:'string'},targetPath:{type:'string'},overwrite:{type:'boolean',default:false}},required:['sourceHost','sourcePath','targetHost','targetPath'],additionalProperties:false}},
|
|
|
{id:'desktop_shell',name:'desktop_shell',description:'在这台电脑的用户主目录执行一条 shell 命令,返回 stdout、stderr 与退出码。长任务会超时终止;不要用它代替文件工具读写大文件。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{command:{type:'string'},cwd:{type:'string'},timeoutMs:{type:'integer'},host:{type:'string'}},required:['command'],additionalProperties:false}},
|
|
{id:'desktop_shell',name:'desktop_shell',description:'在这台电脑的用户主目录执行一条 shell 命令,返回 stdout、stderr 与退出码。长任务会超时终止;不要用它代替文件工具读写大文件。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{command:{type:'string'},cwd:{type:'string'},timeoutMs:{type:'integer'},host:{type:'string'}},required:['command'],additionalProperties:false}},
|
|
|
]
|
|
]
|
|
|
-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_file_copy','desktop_shell'])
|
|
|
|
|
+export const DESKTOP_FILE_TRANSFER_RULES='电脑间传文件用 desktop_file_copy,一次指定 sourceHost、sourcePath、targetHost、targetPath;服务器用 server,设备名用设置里的名字,「本机」只指本轮消息来源电脑。例:把 mac1 桌面的 a.txt 放到 mac2 桌面,调用 {sourceHost:"mac1",sourcePath:"Desktop/a.txt",targetHost:"mac2",targetPath:"Desktop/a.txt"}。这是复制,保留原文件;只有用户明确要求覆盖时才传 overwrite:true。无需先截图,不要读取 base64 给模型、上传聊天附件或假设不同电脑共享文件路径。最大 10 MiB;工具返回已校验的成功结果后才报告完成。'
|
|
|
/** Satellite Macs connected as script machines. */
|
|
/** Satellite Macs connected as script machines. */
|
|
|
export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机,也就是客户连接的 Mac。一律用 desktop_*,可用 host 写明设置里的电脑名称。省略 host 或写「本机」时:只使用本轮用户消息来源电脑;没有来源或来源离线时不得回退,必须明确目标。选工具:要列目录/读/写用 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. */
|
|
@@ -223,9 +226,45 @@ export class DesktopEnvironments {
|
|
|
const tools=this.envTools(owner,a)
|
|
const tools=this.envTools(owner,a)
|
|
|
if(tools.view||tools.file)await requireTeamToolBridge(target,a.profile)
|
|
if(tools.view||tools.file)await requireTeamToolBridge(target,a.profile)
|
|
|
}
|
|
}
|
|
|
|
|
+ private async copyFile(owner:string,agent:WorkspaceAgent,input:unknown,signal:AbortSignal,assertActive:()=>void,epoch:string|Record<string,string>|undefined,deviceHost?:string){
|
|
|
|
|
+ const args=parse(copySchema,input),source=this.namedHost(args.sourceHost,deviceHost),target=this.namedHost(args.targetHost,deviceHost)
|
|
|
|
|
+ const hosts=[...new Set([source,target])].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')}}
|
|
|
|
|
+ check()
|
|
|
|
|
+ const command=(host:HostState,action:Record<string,unknown>)=>this.command(host,{...contexts.find(item=>item.host===host)!.context.command,operation:'file',action},check,60000)
|
|
|
|
|
+ // A stable lock order prevents A→B and B→A transfers from deadlocking.
|
|
|
|
|
+ const locked=(index:number):Promise<unknown>=>index<contexts.length
|
|
|
|
|
+ ? this.serial(contexts[index]!.host,contexts[index]!.context.key,()=>locked(index+1))
|
|
|
|
|
+ : perform()
|
|
|
|
|
+ const available=async()=>{
|
|
|
|
|
+ while(contexts.some(({host,context})=>this.current(host,context.key)||host.paused.has(context.key))){check();await new Promise(resolve=>setTimeout(resolve,150))}
|
|
|
|
|
+ check()
|
|
|
|
|
+ }
|
|
|
|
|
+ const perform=async()=>{
|
|
|
|
|
+ await available()
|
|
|
|
|
+ const file=await command(source,{op:'read',path:args.sourcePath});check()
|
|
|
|
|
+ if(!Number.isSafeInteger(file?.size)||file.size<0||file.size>10*1024*1024||typeof file?.data!=='string'||file.data.length>16*1024*1024)
|
|
|
|
|
+ throw new HttpError(413,'电脑间传输目前支持不超过 10 MiB 的单个文件。','desktop_transfer_limit')
|
|
|
|
|
+ const bytes=Buffer.from(file.data,'base64')
|
|
|
|
|
+ if(bytes.length!==file.size||bytes.toString('base64')!==file.data)throw new HttpError(502,'来源电脑返回的文件不完整,未写入目标电脑。','desktop_transfer_invalid')
|
|
|
|
|
+ const sha256=createHash('sha256').update(bytes).digest('hex')
|
|
|
|
|
+ await available()
|
|
|
|
|
+ let result:any
|
|
|
|
|
+ try{result=await command(target,{op:'receive',path:args.targetPath,data:file.data,sha256,overwrite:args.overwrite})}
|
|
|
|
|
+ catch(error){if(error instanceof HttpError&&error.code==='desktop_command_failed'&&error.message==='文件操作无效')throw new HttpError(409,'目标电脑尚不支持直接接收文件,请更新该电脑的夭夭桌面端。','desktop_transfer_upgrade_required');throw error}
|
|
|
|
|
+ check()
|
|
|
|
|
+ if(result?.path!==args.targetPath||result?.size!==bytes.length||result?.sha256!==sha256)throw new HttpError(502,'目标电脑未确认文件校验成功,请检查目标文件;不会自动重传。','desktop_transfer_unconfirmed')
|
|
|
|
|
+ const handoffNotes=contexts.flatMap(({host})=>{const key=owner+':'+agent.id,note=host.notes.get(key);if(!note)return [];host.notes.delete(key);return [`${this.label(host)}:${note}`]})
|
|
|
|
|
+ return {copied:true,source:{host:source.key,name:this.label(source),path:args.sourcePath},target:{host:target.key,name:this.label(target),path:args.targetPath},size:bytes.length,sha256,...(handoffNotes.length?{handoffNotes}:{})}
|
|
|
|
|
+ }
|
|
|
|
|
+ for(const {host,context} of contexts)host.activity.set(context.key,(host.activity.get(context.key)??0)+1)
|
|
|
|
|
+ try{return await locked(0)}finally{for(const {host,context} of contexts)host.activity.set(context.key,Math.max(0,(host.activity.get(context.key)??1)-1))}
|
|
|
|
|
+ }
|
|
|
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)
|
|
|
|
|
+ if(id==='desktop_file_copy')return this.copyFile(owner,a,input,signal,assertActive,epoch,deviceHost)
|
|
|
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 requestedHost=host===undefined?undefined:parse(z.string().trim().min(1).max(128),host)
|
|
const requestedHost=host===undefined?undefined:parse(z.string().trim().min(1).max(128),host)
|
|
|
const s=this.targetFor(a,requestedHost,deviceHost)
|
|
const s=this.targetFor(a,requestedHost,deviceHost)
|