فهرست منبع

新增服务器与脚本机之间的直接文件复制

Samien 1 ماه پیش
والد
کامیت
b6dd8a5

+ 15 - 0
desktop/environment-exchange.mjs

@@ -0,0 +1,15 @@
+import {request} from 'node:http'
+
+// A 10 MiB receive command expands to about 14 MiB as JSON/base64.
+export function exchangeDesktopEnvironment(record,body){return new Promise((resolve,reject)=>{
+ const url=new URL('/desktop/environment',record.url)
+ if(url.protocol!=='http:'||url.hostname!=='127.0.0.1')return reject(new Error('桌面服务地址无效'))
+ const bytes=Buffer.from(JSON.stringify(body))
+ const req=request(url,{method:'POST',headers:{'x-yaoyao-desktop-token':record.token,'content-type':'application/json','content-length':bytes.length}},res=>{
+  let size=0;const chunks=[]
+  res.on('data',chunk=>{size+=chunk.length;if(size>16*1024*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)})
+  res.on('error',reject)
+  res.on('end',()=>{try{if(res.statusCode!==200)throw new Error('桌面服务未连接');resolve(JSON.parse(Buffer.concat(chunks)))}catch(error){reject(error)}})
+ })
+ req.on('error',reject);req.setTimeout(5000,()=>req.destroy(new Error('桌面连接超时')));req.end(bytes)
+})}

+ 3 - 4
desktop/environment-host.mjs

@@ -3,12 +3,10 @@ import {hostname} from 'node:os'
 import {readFileSync,writeFileSync,mkdirSync} from 'node:fs'
 import {join} from 'node:path'
 import {randomUUID} from 'node:crypto'
-import {request} from 'node:http'
 import {execFile} from 'node:child_process'
 import {BrowserEnvironment} from './browser-environment.mjs'
-import {listHostFiles,readHostFile,writeHostFile,execHostShell} from './host-files.mjs'
-
-function exchange(record,body){return new Promise((resolve,reject)=>{const url=new URL('/desktop/environment',record.url);if(url.protocol!=='http:'||url.hostname!=='127.0.0.1')return reject(new Error('桌面服务地址无效'));const bytes=Buffer.from(JSON.stringify(body)),req=request(url,{method:'POST',headers:{'x-yaoyao-desktop-token':record.token,'content-type':'application/json','content-length':bytes.length}},res=>{let size=0;const chunks=[];res.on('data',chunk=>{size+=chunk.length;if(size>256*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)});res.on('error',reject);res.on('end',()=>{try{if(res.statusCode!==200)throw new Error('桌面服务未连接');resolve(JSON.parse(Buffer.concat(chunks)))}catch(error){reject(error)}})});req.on('error',reject);req.setTimeout(5000,()=>req.destroy(new Error('桌面连接超时')));req.end(bytes)})}
+import {listHostFiles,readHostFile,writeHostFile,receiveHostFile,execHostShell} from './host-files.mjs'
+import {exchangeDesktopEnvironment as exchange} from './environment-exchange.mjs'
 
 /** Command execution shared by the loopback host and remote desktop hosts. */
 export class DesktopHostCore {
@@ -42,6 +40,7 @@ export class DesktopHostCore {
    if(action.op==='list')return listHostFiles(this.home,action.path)
    if(action.op==='read')return readHostFile(this.home,action.path)
    if(action.op==='write')return writeHostFile(this.home,action)
+   if(action.op==='receive')return receiveHostFile(this.home,action)
    throw new Error('文件操作无效')
   }
   if(!['local','browser'].includes(c.mode)||!['persistent','temporary'].includes(c.profile)||!['open','view','input','browser','browser-state'].includes(c.operation))throw new Error('桌面命令无效')

+ 27 - 1
desktop/host-files.mjs

@@ -1,4 +1,5 @@
-import {readdir,readFile,writeFile,mkdir,stat,realpath} from 'node:fs/promises'
+import {readdir,readFile,writeFile,mkdir,stat,realpath,open,link,rename,unlink} from 'node:fs/promises'
+import {createHash,randomUUID} from 'node:crypto'
 import {join,resolve,sep} from 'node:path'
 import {homedir} from 'node:os'
 import {execFile} from 'node:child_process'
@@ -74,6 +75,31 @@ export async function writeHostFile(root,input){
   return {path:String(input?.path),size:bytes.length}
 }
 
+/** Receive a brokered copy: verify bytes, stage beside the destination, then
+ *  commit atomically. An older client rejects the new op instead of clobbering. */
+export async function receiveHostFile(root,input){
+  if(typeof input?.data!=='string'||input.data.length>14*1024*1024)throw new Error('传输文件内容无效或超过 10 MiB')
+  if(typeof input?.overwrite!=='boolean')throw new Error('传输覆盖选项无效')
+  const bytes=Buffer.from(input.data,'base64')
+  if(bytes.length>FILE_WRITE_LIMIT||bytes.toString('base64')!==input.data)throw new Error('传输文件内容无效或超过 10 MiB')
+  const sha256=createHash('sha256').update(bytes).digest('hex')
+  if(input.sha256!==sha256)throw new Error('传输文件校验失败,未写入目标文件')
+  const {target}=await resolveWithin(root,input.path,'write')
+  await mkdir(join(target,'..'),{recursive:true})
+  const temporary=join(target,'..','.yaoyao-transfer-'+randomUUID())
+  const handle=await open(temporary,'wx+',0o600)
+  try{
+    await handle.writeFile(bytes);await handle.sync()
+    const stored=Buffer.alloc(bytes.length);let offset=0
+    while(offset<stored.length){const {bytesRead}=await handle.read(stored,offset,stored.length-offset,offset);if(!bytesRead)break;offset+=bytesRead}
+    if(offset!==bytes.length||createHash('sha256').update(stored).digest('hex')!==sha256)throw new Error('目标文件写入校验失败')
+    await resolveWithin(root,input.path,'write')
+    if(input.overwrite)await rename(temporary,target)
+    else await link(temporary,target).catch(error=>{if(error.code==='EEXIST')throw new Error('目标文件已存在,未覆盖;只有用户要求覆盖时才设置 overwrite=true');throw error})
+    return {path:input.path,size:bytes.length,sha256}
+  }finally{await handle.close();await unlink(temporary).catch(error=>{if(error.code!=='ENOENT')throw error})}
+}
+
 export async function execHostShell(root,input){
   const command=String(input?.command??'')
   if(!command.trim())throw new Error('命令为空')

+ 29 - 2
desktop/host-files.test.mjs

@@ -1,9 +1,10 @@
 import {test} from 'node:test'
 import assert from 'node:assert/strict'
-import {mkdtemp,mkdir,writeFile,symlink,rm} from 'node:fs/promises'
+import {mkdtemp,mkdir,writeFile,readFile,readdir,symlink,rm} from 'node:fs/promises'
+import {createHash} from 'node:crypto'
 import {tmpdir} from 'node:os'
 import {join} from 'node:path'
-import {listHostFiles,readHostFile,writeHostFile,execHostShell,resolveWithin} from './host-files.mjs'
+import {listHostFiles,readHostFile,writeHostFile,receiveHostFile,execHostShell,resolveWithin} from './host-files.mjs'
 
 test('lists, reads and writes files inside the allowed root',async()=>{
  const root=await mkdtemp(join(tmpdir(),'yaoyao-host-files-'))
@@ -43,6 +44,32 @@ test('path escapes are refused for every operation',async()=>{
  }
 })
 
+test('receives verified binary and empty files atomically and preserves existing files unless overwrite was requested',async()=>{
+ const source=await mkdtemp(join(tmpdir(),'yaoyao-copy-source-')),target=await mkdtemp(join(tmpdir(),'yaoyao-copy-target-'))
+ try{
+  await mkdir(join(source,'Desktop'))
+  const bytes=Buffer.alloc(300_000);for(let i=0;i<bytes.length;i++)bytes[i]=i%256
+  await writeFile(join(source,'Desktop','a.bin'),bytes)
+  const file=await readHostFile(source,'Desktop/a.bin'),sha256=createHash('sha256').update(bytes).digest('hex')
+  const input={path:'Desktop/a.bin',data:file.data,sha256,overwrite:false}
+  assert.deepEqual(await receiveHostFile(target,input),{path:input.path,size:bytes.length,sha256})
+  assert.deepEqual(await readFile(join(source,'Desktop','a.bin')),bytes)
+  assert.deepEqual(await readFile(join(target,'Desktop','a.bin')),bytes)
+  await assert.rejects(()=>receiveHostFile(target,{...input,sha256:'wrong'}),/校验失败/)
+  const next=Buffer.from('replacement'),replacement={...input,data:next.toString('base64'),sha256:createHash('sha256').update(next).digest('hex')}
+  await assert.rejects(()=>receiveHostFile(target,replacement),/已存在/)
+  assert.deepEqual(await readFile(join(target,'Desktop','a.bin')),bytes)
+  await receiveHostFile(target,{...replacement,overwrite:true})
+  assert.deepEqual(await readFile(join(target,'Desktop','a.bin')),next)
+  const empty={...input,path:'Desktop/empty',data:'',sha256:createHash('sha256').update('').digest('hex')}
+  assert.equal((await receiveHostFile(target,empty)).size,0)
+  assert.equal((await readFile(join(target,'Desktop','empty'))).length,0)
+  await symlink(source,join(target,'escape'))
+  await assert.rejects(()=>receiveHostFile(target,{...input,path:'escape/evil.bin'}),/超出允许范围/)
+  assert.deepEqual((await readdir(join(target,'Desktop'))).sort(),['a.bin','empty'])
+ }finally{await rm(source,{recursive:true,force:true});await rm(target,{recursive:true,force:true})}
+})
+
 test('shell execution reports stdout, exit codes, timeouts and cwd stays in root',async()=>{
  const root=await mkdtemp(join(tmpdir(),'yaoyao-host-files-'))
  try{

+ 2 - 2
desktop/host-manager.mjs

@@ -7,13 +7,13 @@ import {parseDesktopHostConfiguration} from './host-config.mjs'
 
 const FATAL = Symbol('desktop-host-fatal')
 
-function remoteExchange(config,body){return new Promise((resolve,reject)=>{
+export function remoteExchange(config,body){return new Promise((resolve,reject)=>{
  const url=new URL(`/api/desktop-host/v1/${config.hostId}/exchange`,config.serverURL)
  if(!['http:','https:'].includes(url.protocol))return reject(new Error('夭夭服务地址无效'))
  const bytes=Buffer.from(JSON.stringify(body)),send=url.protocol==='https'?httpsRequest:httpRequest
  const req=send(url,{method:'POST',headers:{Authorization:`Bearer ${config.token}`,'x-desktop-host-protocol':'1','content-type':'application/json','content-length':bytes.length}},res=>{
   let size=0;const chunks=[]
-  res.on('data',chunk=>{size+=chunk.length;if(size>256*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)})
+  res.on('data',chunk=>{size+=chunk.length;if(size>16*1024*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)})
   res.on('error',reject)
   res.on('end',()=>{try{
    if(res.statusCode===401||res.statusCode===403){const error=new Error('桌面主机授权已失效,请在夭夭服务中重新注册并导入新配置');error[FATAL]=true;throw error}

+ 23 - 0
desktop/host-transfer-transport.test.mjs

@@ -0,0 +1,23 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {createServer} from 'node:http'
+import {randomUUID} from 'node:crypto'
+import {exchangeDesktopEnvironment} from './environment-exchange.mjs'
+import {remoteExchange} from './host-manager.mjs'
+
+for(const kind of ['server','client'])test(`${kind} transport accepts a complete 10 MiB file and still bounds oversized commands`,async()=>{
+ const data=Buffer.alloc(10*1024*1024,255).toString('base64'),hostId=randomUUID(),seen=[]
+ let response={commands:[{id:randomUUID(),operation:'file',action:{op:'receive',path:'Desktop/a.bin',data}}]}
+ const server=createServer(async(req,res)=>{let body='';for await(const chunk of req)body+=chunk;seen.push({path:req.url,headers:req.headers,body:JSON.parse(body)});res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify(response))})
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
+ const url=`http://127.0.0.1:${server.address().port}`
+ const exchange=()=>kind==='server'?exchangeDesktopEnvironment({url,token:'fixture'},{results:[]}):remoteExchange({serverURL:url,hostId,token:'fixture'},{results:[]})
+ try{
+  const result=await exchange()
+  assert.equal(result.commands[0].action.data,data)
+  assert.equal(seen[0].path,kind==='server'?'/desktop/environment':`/api/desktop-host/v1/${hostId}/exchange`)
+  assert.equal(kind==='server'?seen[0].headers['x-yaoyao-desktop-token']:seen[0].headers.authorization,kind==='server'?'fixture':'Bearer fixture')
+  response={commands:[],padding:'x'.repeat(16*1024*1024)}
+  await assert.rejects(exchange,/超过限制/)
+ }finally{server.closeAllConnections();await new Promise(resolve=>server.close(resolve))}
+})

+ 33 - 0
docs/verification/2026-09-19-host-file-copy/README.md

@@ -0,0 +1,33 @@
+# 电脑间直接复制文件
+
+用户说「把 mac1 桌面的 a.txt 放到 mac2(或服务器)桌面」时,Bot 使用 `desktop_file_copy`:
+
+```json
+{
+  "sourceHost": "mac1",
+  "sourcePath": "Desktop/a.txt",
+  "targetHost": "mac2",
+  "targetPath": "Desktop/a.txt"
+}
+```
+
+目标服务器写 `targetHost: "server"`。设备名解析为已连接的设备编号;`本机` 随本轮消息来源设备变化。名称重复、来源未知、设备离线或未授权时失败,不会切换到别的设备。
+
+服务器通过现有桌面连接读取来源文件,再转送给目标电脑。原始字节不进入模型上下文,不创建聊天附件或文件库条目。来源和目标都遵守全局电脑开关、当前账号文件权限、运行授权、连接代次和人工接管状态。两个设备按稳定顺序占用执行队列,避免相反方向同时复制发生死锁。
+
+目标先将文件写到同目录临时文件,落盘并读回校验 SHA-256,再原子地提交到目标路径。默认保留来源、拒绝覆盖已有文件;只有用户要求覆盖时才设置 `overwrite: true`。返回成功时包含两端设备、路径、字节数和 SHA-256。写入回执不确定时不自动重传。
+
+当前支持用户主目录内的单个文件,最大 **10 MiB**,包括图片、压缩包和空文件;目录、断点续传和 Grok/VM 与 Mac 之间的传输不在此次实现内。两端无需互相开放网络端口;脚本机继续连接服务器,客户端不运行 Hermes。
+
+服务器端和脚本机端的 HTTP 接收上限从 256 KiB 调整到 16 MiB,以容纳 10 MiB 文件编码后的命令;超过上限仍拒绝。目标桌面端新增 `file/receive` 操作,旧客户端拒绝该操作,服务端会提示更新,不降级成覆盖写入。
+
+## 验证
+
+- `tests/server/desktopEnvironments.test.ts`:37 项通过,覆盖 mac1→mac2、mac1→服务器、服务器→本机、相反方向并发、二进制完整性、双端授权、失联/撤权/取消/损坏/超限、回执校验和读取期间的人工接管。
+- `tests/server/desktopHosts.test.ts`:8 项通过。
+- `tests/server/workspace.test.ts` 新增实际 HTTP 工具桥调用测试通过,验证工具目录、自然语言请求上下文、来源/目标路由、重复调用去重,以及不会生成附件。Hermes 模型响应使用本地测试替身,不代表线上模型的执行证明。
+- `node --test desktop/host-files.test.mjs desktop/host-manager.test.mjs desktop/host-transfer-transport.test.mjs`:13 项通过。包含真实临时目录复制、内容校验、空文件、覆盖保护、清理临时文件,以及服务器和脚本机真实 HTTP 通道的 10 MiB/超限测试。
+- `npm run build`、服务端 TypeScript 检查、修改的桌面模块语法检查和 `git diff --check` 通过。
+- 扩展运行上述三个服务端测试文件:134 项通过,2 项失败。失败均为原有上下文用量投影断言(`workspace.test.ts:374` 和 `:528`),在未修改的 `5b4ae36` 临时完整源码副本上也复现。首次桌面测试中原有 HTTP 用例发生一次 `ECONNRESET`,单独复跑和随后整组运行均通过。
+
+本次未连接真实 mac1/mac2 传送用户文件,也未部署到 `10.10.1.200`。更新服务器及目标电脑桌面端后,新的 Bot 轮次会获得这条工具。

+ 40 - 1
src/server/desktopEnvironments.ts

@@ -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(),
 ])
 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[]}
 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_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_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}},
 ]
-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. */
 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. */
@@ -223,9 +226,45 @@ export class DesktopEnvironments {
   const tools=this.envTools(owner,a)
   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){
   if(id==='desktop_browser'||expected==='browser')throw new HttpError(404,'当前环境不支持这个工具','tool_not_found')
   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 requestedHost=host===undefined?undefined:parse(z.string().trim().min(1).max(128),host)
   const s=this.targetFor(a,requestedHost,deviceHost)

+ 2 - 1
src/server/workspaceRuntime.ts

@@ -1,4 +1,4 @@
-import {DESKTOP_ENVIRONMENT_TOOLS,DESKTOP_ENVIRONMENT_RULES,SERVER_COMPUTER_RULES,DESKTOP_FILE_TOOL_IDS,type DesktopEnvironments} from './desktopEnvironments.js'
+import {DESKTOP_ENVIRONMENT_TOOLS,DESKTOP_ENVIRONMENT_RULES,SERVER_COMPUTER_RULES,DESKTOP_FILE_TOOL_IDS,DESKTOP_FILE_TRANSFER_RULES,type DesktopEnvironments} from './desktopEnvironments.js'
 import {GROK_COMPUTER_TOOLS,grokComputerRules,type GrokCloud} from './grokCloud.js'
 import {VM_COMPUTER_TOOLS,VM_COMPUTER_RULES,VmToolSession} from './vmComputer.js'
 import { randomUUID } from 'node:crypto'
@@ -816,6 +816,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           desktop && hit.server && globals.serverComputer ? SERVER_COMPUTER_RULES+(this.desktopEnvironments?.onlineHostsLine(owner,root.deviceHost)??'') : '',
           desktop && hit.script && globals.scriptMachine ? DESKTOP_ENVIRONMENT_RULES+(this.desktopEnvironments?.onlineHostsLine(owner,root.deviceHost)??'') : '',
           this.desktopEnvironments?.deviceContextLine(owner,root.deviceHost) ?? '本轮未提供来源电脑;操作电脑时必须明确目标。',
+          envTools.file ? DESKTOP_FILE_TRANSFER_RULES : '',
           cloud && hit.cloud ? grokComputerRules() : '',
           dispatchedVm && hit.vm ? VM_COMPUTER_RULES : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,

+ 73 - 1
tests/server/desktopEnvironments.test.ts

@@ -8,7 +8,7 @@ import Koa from 'koa'
 import {bodyParser} from '@koa/bodyparser'
 import request from 'supertest'
 import {WorkspaceStore} from '../../src/server/workspaceStore'
-import {DesktopEnvironments} from '../../src/server/desktopEnvironments'
+import {DesktopEnvironments,DESKTOP_ENVIRONMENT_TOOLS,DESKTOP_FILE_TOOL_IDS} from '../../src/server/desktopEnvironments'
 import type {WorkspaceAgent} from '../../src/shared/workspace'
 import {saveHostTools} from '../../src/server/hostToolSettings'
 import {HttpError} from '../../src/server/errors'
@@ -132,6 +132,78 @@ it('passes the authorize scope through to the desktop host',async()=>{
  await request(app()).post(base()+'/desktop-environment/authorize').send({scope:'full'}).expect(200)
  expect(seen.find(c=>c.operation==='authorize')?.scope).toBe('full')
 })
+it('copies files between the explicitly named hosts and server without chat attachments or model payloads',async()=>{
+ const bytes=Buffer.alloc(300_000);for(let i=0;i<bytes.length;i++)bytes[i]=i%256
+ const seen:Array<{host:string;action:any}>=[]
+ const handle=(id:string)=>(c:any)=>{seen.push({host:id,action:c.action});return c.action.op==='read'?{path:c.action.path,size:bytes.length,data:bytes.toString('base64')}:{path:c.action.path,size:Buffer.from(c.action.data,'base64').length,sha256:createHash('sha256').update(Buffer.from(c.action.data,'base64')).digest('hex')}}
+ host.full=[ownerKey('owner')]
+ drive(handle('server'))
+ driveRemote(HOST_A,{...host,id:randomUUID(),name:'mac1'},handle('mac1'))
+ driveRemote(HOST_B,{...host,id:randomUUID(),name:'mac2'},handle('mac2'))
+ for(const [sourceHost,targetHost,targetId] of [['mac1','mac2',HOST_B],['mac1','server','local'],['server','本机',HOST_A]]){
+  const result=await service.call('owner',agent.id,'desktop_file_copy',{sourceHost,sourcePath:'Desktop/a.txt',targetHost,targetPath:'Desktop/a.txt'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
+  expect(result).toMatchObject({copied:true,target:{host:targetId,path:'Desktop/a.txt'},size:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')})
+  expect(JSON.stringify(result).length).toBeLessThan(700)
+ }
+ expect(seen.map(({host,action})=>[host,action.op])).toEqual([['mac1','read'],['mac2','receive'],['mac1','read'],['server','receive'],['server','read'],['mac1','receive']])
+ for(const {action} of seen.filter(item=>item.action.op==='receive')){expect(action.overwrite).toBe(false);expect(Buffer.from(action.data,'base64')).toEqual(bytes)}
+ expect(store.list('owner','file')).toEqual([])
+ expect(DESKTOP_FILE_TOOL_IDS.has('desktop_file_copy')).toBe(true)
+ expect(DESKTOP_ENVIRONMENT_TOOLS.some(tool=>tool.id==='desktop_file_copy')).toBe(true)
+})
+it('serializes opposing transfers without deadlocking or changing the target',async()=>{
+ host.full=[ownerKey('owner')]
+ const handle=(c:any)=>c.action.op==='read'?{size:1,data:'YQ=='}:{path:c.action.path,size:1,sha256:c.action.sha256}
+ driveRemote(HOST_A,{...host,name:'mac1'},handle);driveRemote(HOST_B,{...host,id:randomUUID(),name:'mac2'},handle)
+ const copy=(sourceHost:string,targetHost:string)=>service.call('owner',agent.id,'desktop_file_copy',{sourceHost,sourcePath:'Desktop/a',targetHost,targetPath:'Desktop/b'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
+ const values=await Promise.all([copy('mac1','mac2'),copy('mac2','mac1'),copy('mac1','mac1')])
+ expect(values.map(value=>value.target.host)).toEqual([HOST_B,HOST_A,HOST_A])
+})
+it('requires both host grants and never falls back for missing or ambiguous hosts',async()=>{
+ host.full=[ownerKey('owner')];drive()
+ driveRemote(HOST_A,{...host,id:randomUUID(),name:'mac1',full:[]})
+ const copy=(sourceHost:string,targetHost:string)=>service.call('owner',agent.id,'desktop_file_copy',{sourceHost,sourcePath:'Desktop/a',targetHost,targetPath:'Desktop/a'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
+ await expect(copy('server','mac1')).rejects.toMatchObject({code:'desktop_full_required'})
+ await expect(copy('mac1','server')).rejects.toMatchObject({code:'desktop_full_required'})
+ await expect(copy('server','本机')).rejects.toMatchObject({code:'desktop_source_required'})
+ await expect(copy('missing','server')).rejects.toMatchObject({code:'desktop_host_unknown'})
+ driveRemote(HOST_B,{...host,id:randomUUID(),name:'mac1'})
+ await expect(copy('mac1','server')).rejects.toMatchObject({code:'desktop_host_ambiguous'})
+})
+it.each(['disconnect','cancel','revoke','corrupt','oversized'])('does not write the target when the source transfer is %s',async failure=>{
+ host.full=[ownerKey('owner')];const controller=new AbortController(),writes:any[]=[]
+ driveRemote(HOST_A,{...host,name:'mac1'},()=>{
+  if(failure==='disconnect')service.dropHost(HOST_B)
+  if(failure==='cancel')controller.abort()
+  if(failure==='revoke')version++
+  return {size:failure==='oversized'?10*1024*1024+1:failure==='corrupt'?2:1,data:'YQ=='}
+ })
+ driveRemote(HOST_B,{...host,id:randomUUID(),name:'mac2'},c=>{writes.push(c);return {}})
+ await expect(service.call('owner',agent.id,'desktop_file_copy',{sourceHost:'mac1',sourcePath:'Desktop/a',targetHost:'mac2',targetPath:'Desktop/a'},controller.signal,()=>{},'local',service.hostEpochs('owner',agent))).rejects.toThrow()
+ expect(writes).toEqual([])
+})
+it('reports a failed destination receipt without claiming success or retrying',async()=>{
+ host.full=[ownerKey('owner')];const receives:any[]=[]
+ driveRemote(HOST_A,{...host,name:'mac1'},()=>({size:0,data:''}))
+ driveRemote(HOST_B,{...host,id:randomUUID(),name:'mac2'},c=>{receives.push(c);return {path:c.action.path,size:0,sha256:'wrong'}})
+ await expect(service.call('owner',agent.id,'desktop_file_copy',{sourceHost:'mac1',sourcePath:'Desktop/a',targetHost:'mac2',targetPath:'Desktop/a',overwrite:true},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))).rejects.toMatchObject({code:'desktop_transfer_unconfirmed'})
+ expect(receives).toHaveLength(1);expect(receives[0].action.overwrite).toBe(true)
+})
+it('waits for a target taken over during the source read and resumes after giveback',async()=>{
+ host.full=[ownerKey('owner')]
+ const source={...host,name:'mac1'},target={...host,id:randomUUID(),name:'mac2'},writes:any[]=[]
+ service.remoteExchange(HOST_A,{host:source,results:[]})
+ driveRemote(HOST_B,target,c=>{if(c.operation!=='file')return {ok:true};writes.push(c);return {path:c.action.path,size:1,sha256:c.action.sha256}})
+ const copying=service.call('owner',agent.id,'desktop_file_copy',{sourceHost:'mac1',sourcePath:'Desktop/a',targetHost:'mac2',targetPath:'Desktop/a'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
+ let read:any
+ await vi.waitFor(()=>{read=service.remoteExchange(HOST_A,{host:source,results:[]}).commands[0];expect(read).toBeDefined()})
+ const query='?backend=desktop&host='+HOST_B
+ const ticket=(await request(app()).post(base()+'/computer/take'+query).send({requestId:randomUUID()}).expect(200)).body
+ service.remoteExchange(HOST_A,{host:source,results:[{id:read.id,value:{size:1,data:'YQ=='}}]})
+ await new Promise(resolve=>setTimeout(resolve,180));expect(writes).toEqual([])
+ await request(app()).post(base()+'/computer/giveback'+query).send({controlId:ticket.controlId,token:ticket.token,notes:'已交还 mac2'}).expect(200)
+ expect(await copying).toMatchObject({copied:true,handoffNotes:['mac2:已交还 mac2']});expect(writes).toHaveLength(1)
+})
 it('routes parallel desktop tools to the host named in the call',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local',envs:{desktop:true,cloud:true}})
  const study={...host,name:'书房 iMac'},living={...host,id:randomUUID(),name:'客厅 Mac mini'}

+ 47 - 1
tests/server/workspace.test.ts

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { randomUUID } from 'node:crypto'
+import { randomUUID, createHash } from 'node:crypto'
 import { WebSocketServer, type WebSocket } from 'ws'
 import { defaultAgentIdentity, encodeAgentAvatar, decodeAgentAvatar } from '../../src/shared/agentIdentity'
 import {readHostTools,saveHostTools} from '../../src/server/hostToolSettings'
@@ -1679,3 +1679,49 @@ it('snapshots each message origin and uses inherited provenance for internal dis
  expect(prompts[3]).toContain('历史消息的设备来源不适用于本轮')
  expect(store.require<any>(owner,'message',delegated.messageId).deviceHost).toBe(a)
 })
+
+it('offers a direct host-to-host copy through the real Bot tool bridge without publishing chat files',async()=>{
+ const bot=agent('传文件'),c=direct(bot.id),target=nodes.target(owner,'local'),originalRequest=target.session.request.bind(target.session)
+ let binding:any,integrationError:unknown
+ vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>{
+  if(path.startsWith('/api/plugins/yaoyao-bot-bridge/')){
+   if(path.endsWith('/bind'))binding=options?.body
+   return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({ok:true,version:1,ready:true,native_tools:true,in_process:true}))}
+  }
+  return originalRequest(path,options)
+ })
+ const desktop=new DesktopEnvironments(store,{pushAuthorizationVersion:()=>0} as any,{localNodeID:'fixture',requireSource:()=>{}} as any)
+ runtime.desktopEnvironments=desktop
+ const payload=Buffer.alloc(300_000,123),sha256=createHash('sha256').update(payload).digest('hex'),clientId=randomUUID(),commands:Array<{host:string;op:string}>=[]
+ const info={platform:'darwin',screen:false,accessibility:false,approved:[],full:[createHash('sha256').update('fixture:'+owner).digest('hex')]}
+ const machines=[{key:'server',host:{...info,id:randomUUID(),name:'server'},exchange:(body:unknown)=>desktop.exchange(body)},
+  {key:'mac1',host:{...info,id:randomUUID(),name:'mac1'},exchange:(body:unknown)=>desktop.remoteExchange(clientId,body)}]
+ const timers=machines.map(machine=>{let results:any[]=[];machine.exchange({host:machine.host,results});return setInterval(()=>{
+  const response=machine.exchange({host:machine.host,results})
+  results=response.commands.map((command:any)=>{commands.push({host:machine.key,op:command.action.op});return {id:command.id,value:command.action.op==='read'
+   ? {size:payload.length,data:payload.toString('base64')}
+   : {path:command.action.path,size:Buffer.from(command.action.data,'base64').length,sha256:createHash('sha256').update(Buffer.from(command.action.data,'base64')).digest('hex')}}})
+ },5)})
+ const publish=vi.spyOn(runtime,'publishArtifact')
+ const http=async(path:string,body:unknown)=>{const response=await fetch(binding.bridge_url+path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding.token}`},body:JSON.stringify(body)});expect(response.status).toBe(200);return response.json()}
+ reply=(socket,p)=>{void(async()=>{
+  try{
+   expect(p.text).toContain('电脑间传文件用 desktop_file_copy')
+   const catalog=await http('/tools/list',{}),tool=catalog.tools.find((tool:any)=>tool.name==='desktop_file_copy')
+   expect(tool.inputSchema.required).toEqual(['sourceHost','sourcePath','targetHost','targetPath'])
+   const call={toolId:tool.id,callId:randomUUID(),arguments:{sourceHost:'mac1',sourcePath:'Desktop/a.txt',targetHost:'server',targetPath:'Desktop/a.txt'}}
+   const result=await http('/tools/call',call)
+   expect(result.isError).not.toBe(true)
+   expect(result.structuredContent).toMatchObject({copied:true,source:{host:clientId},target:{host:'local'},size:payload.length,sha256})
+   expect(JSON.stringify(result).length).toBeLessThan(1500)
+   expect(await http('/tools/call',call)).toEqual(result)
+  }catch(error){integrationError=error}
+  socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'文件复制完成',status:'complete'}}}))
+ })()}
+ try{
+  await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'帮我把 mac1 桌面的 a.txt 放到服务器桌面'}).id)
+  if(integrationError)throw integrationError
+  expect(commands).toEqual([{host:'mac1',op:'read'},{host:'server',op:'receive'}])
+  expect(publish).not.toHaveBeenCalled();expect(store.list(owner,'file')).toEqual([])
+ }finally{timers.forEach(clearInterval);desktop.close()}
+})