Преглед изворни кода

fix(desktop): 修复 ~/ 路径越权,并禁止文件操作先截图

~/Desktop 不再被解析成 /Desktop;执行节点透传真实工具错误;提示改为文件/命令直走工具,只有看窗口才截图。
Samien пре 1 месец
родитељ
комит
9aed519

+ 2 - 2
desktop/host-files.mjs

@@ -13,7 +13,7 @@ export const SHELL_TIMEOUT_MAX=120000
  *  through .., absolute outsiders and symlinks pointing out of the root. */
 export async function resolveWithin(root,input,kind='target'){
   const base=await realpath(root)
-  const raw=String(input??'').replace(/^~(?=\/|$)/,'')
+  const raw=String(input??'').replace(/^~\//,'').replace(/^~$/,'')
   if(/\u0000/.test(raw))throw new Error('路径无效')
   const target=resolve(base,raw)
   let real=target
@@ -79,7 +79,7 @@ export async function execHostShell(root,input){
   if(!command.trim())throw new Error('命令为空')
   const timeout=Math.min(Math.max(Number(input?.timeoutMs)||60000,1000),SHELL_TIMEOUT_MAX)
   let cwd
-  try{cwd=resolve(String(root),String(input?.cwd??'').replace(/^~(?=\/|$)/,''))}catch{cwd=String(root)}
+  try{cwd=resolve(String(root),String(input?.cwd??'').replace(/^~\//,'').replace(/^~$/,''))}catch{cwd=String(root)}
   return new Promise(done=>{
     execFile('/bin/zsh',['-c',command],{cwd,timeout,killSignal:'SIGKILL',maxBuffer:1024*1024,env:{...process.env,TERM:'dumb'}},(error,stdout,stderr)=>{
       const clip=value=>Buffer.from(value??'','utf8').subarray(0,SHELL_OUTPUT_LIMIT).toString('utf8')

+ 11 - 0
desktop/host-files.test.mjs

@@ -59,3 +59,14 @@ test('shell execution reports stdout, exit codes, timeouts and cwd stays in root
   await assert.rejects(()=>execHostShell(root,{command:'  '}),/命令为空/)
  }finally{await rm(root,{recursive:true,force:true})}
 })
+
+test('tilde home paths resolve inside the allowed root', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'yaoyao-host-files-'))
+ await mkdir(join(root, 'Desktop'), { recursive: true })
+ await writeFile(join(root, 'Desktop', 'a.txt'), 'x')
+ const listing = await listHostFiles(root, '~/Desktop')
+ assert.equal(listing.entries[0].name, 'a.txt')
+ const home = await listHostFiles(root, '~')
+ assert.ok(home.entries.some(e => e.name === 'Desktop'))
+ await assert.rejects(() => listHostFiles(root, '/Desktop'), /超出允许范围|不存在/)
+})

+ 2 - 2
src/client/components/app/HostToolsPanel.vue

@@ -68,8 +68,8 @@ onMounted(() => { void load(); void loadNames() })
   <section class="host-tools" aria-label="电脑与服务端工具">
     <p>这些开关对所有机器人生效,不再按单个机器人勾选。关掉之后,新开或恢复的会话才会按新设置执行。</p>
     <form @submit.prevent="save" @change="changed">
-      <label class="toggle"><input v-model="scriptMachine" type="checkbox" :disabled="loading || saving" /><span><strong>脚本机</strong><small>客户连接的 Mac。机器人按名称点名,可以查看画面、操作、读写文件和执行命令。</small></span></label>
-      <label class="toggle"><input v-model="serverComputer" type="checkbox" :disabled="loading || saving" /><span><strong>服务器</strong><small>夭夭正在运行的这台电脑。操作方式和云虚拟机一样:先看真实桌面,再点击、输入、按键或滚动。</small></span></label>
+      <label class="toggle"><input v-model="scriptMachine" type="checkbox" :disabled="loading || saving" /><span><strong>脚本机</strong><small>客户连接的 Mac。机器人按名称点名:文件走文件工具,命令走 shell,只有看窗口或点按时才截图。</small></span></label>
+      <label class="toggle"><input v-model="serverComputer" type="checkbox" :disabled="loading || saving" /><span><strong>服务器</strong><small>夭夭正在运行的这台电脑。文件走文件工具,命令走 shell;只有看窗口或点按时才截图操作。</small></span></label>
       <label class="toggle"><input v-model="vm" type="checkbox" :disabled="loading || saving" /><span><strong>虚拟环境</strong><small>服务端上的隔离桌面。聊天留在夭夭,操作派到虚拟机里。</small></span></label>
       <label class="toggle"><input v-model="cloud" type="checkbox" :disabled="loading || saving" /><span><strong>云虚拟机</strong><small>共享的 Grok Bot 电脑。先看真实桌面,再点击、输入、按键或滚动。</small></span></label>
       <label class="toggle"><input v-model="deny" type="checkbox" :disabled="loading || saving" /><span><strong>禁用服务端本机的终端和文件</strong><small>默认开启。开启后对话拿不到服务端的 terminal 和 file 工具。这和上面的电脑开关是分开的。</small></span></label>

+ 1 - 1
src/client/components/workspace/LocalVmChatPanel.vue

@@ -189,7 +189,7 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
     <p class="hint">电脑开关在 Bot 设置里,对所有机器人一起生效。</p>
     <div class="computer-options">
     <template v-if="showDesktop">
-      <p>服务器是夭夭所在的这台电脑;脚本机是另外连接的 Mac。两者都可改名。「本机」跟你当前发消息所在的电脑走:在服务器上聊就是服务器,在脚本机上聊就是那台脚本机。操作方式和云虚拟机一样:先看真实桌面,再点击、输入、按键或滚动。</p>
+      <p>服务器是夭夭所在的这台电脑;脚本机是另外连接的 Mac。两者都可改名。「本机」跟你当前发消息所在的电脑走。要文件用文件工具,要命令用 shell;只有需要看窗口或点按时才截图。</p>
       <div v-for="host in desktopHosts" :key="host.id" class="host-permission" :class="{offline:!host.online}">
        <div class="host-permission-head"><strong>{{host.id==='local'?('服务器 · '+(host.name||'服务器')):('脚本机 · '+(host.name||'未命名'))}}</strong><small>{{host.online?'在线':'离线'}}</small></div>
        <div class="permission-list"><span>{{host.local.authorized?'✓':'○'}} 本机控制授权</span><span>{{host.local.screen?'✓':'○'}} 屏幕录制</span><span>{{host.local.accessibility?'✓':'○'}} 辅助功能</span><span>{{host.local.fullAuthorized?'✓':'○'}} 文件与命令</span></div>

+ 4 - 2
src/runner/agent.ts

@@ -60,8 +60,10 @@ export class RunnerAgent {
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok){
-      if(path==='desktop'){const error=await response.json().catch(()=>({})) as {error?:string;code?:string};throw new HttpError(response.status,error.error?.slice(0,300)||'Compose 桌面连接失败',error.code||'compose_desktop_error')}
-      throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
+      const error=await response.json().catch(()=>({})) as {error?:string;code?:string}
+      if(path==='desktop')throw new HttpError(response.status,error.error?.slice(0,300)||'Compose 桌面连接失败',error.code||'compose_desktop_error')
+      // Surface the server's real tool/desktop error (e.g. path out of range) instead of a generic "connection refused".
+      throw new HttpError(response.status,error.error?.slice(0,300)||`执行节点请求失败(HTTP ${response.status})`,error.code||(response.status===403?'runner_unauthorized':'runner_request_failed'))
     }
     const reader=response.body?.getReader();const chunks:Uint8Array[]=[];let bytes=0
     if(!reader)throw new Error('执行节点响应为空')

+ 5 - 5
src/server/desktopEnvironments.ts

@@ -54,18 +54,18 @@ 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()})
 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_action',name:'desktop_environment_action',description:'操作电脑桌面。先查看真实桌面,再使用 click、text、key、scroll 或 drag;坐标用截图像素。可用 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:'列出当前电脑允许范围内的目录内容,返回名称、类型、大小与修改时间。路径相对于用户主目录,可用 ~。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
+ {id:'desktop_environment_view',name:'desktop_environment_view',description:'查看电脑真实桌面截图,返回截图尺寸、环境类型和电脑名称。仅在需要看窗口、图标布局或做点击输入时使用;列目录、读文件、写文件、跑命令不要先截图。可用 host 指定目标电脑;省略或写「本机」时优先用户当前发消息所在电脑。',inputSchema:{type:'object',properties:{host:{type:'string',description:'可选,目标电脑的名称或编号;缺省用默认电脑'}},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_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_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'])
 /** Satellite Macs connected as script machines. */
-export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机,也就是客户连接的 Mac。电脑操作、文件读写和命令必须使用 desktop_*;可用 host 写明电脑名称或编号。省略 host 或写「本机」时:若本轮用户消息带来源设备,就用那台电脑;否则用 Bot 的默认绑定。先查看真实桌面,再使用 click、text、key、scroll 或 drag;页面变化后重新截图,不得猜测旧坐标。文件与 shell 在用户主目录范围内真实生效。服务器、虚拟环境和云虚拟机都不是脚本机。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
+export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机,也就是客户连接的 Mac。一律用 desktop_*,可用 host 写明设置里的电脑名称。省略 host 或写「本机」时:有用户消息设备来源就用那台,否则用 Bot 默认绑定。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。文件与 shell 在用户主目录范围内真实生效。服务器、虚拟环境和云虚拟机都不是脚本机。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
 /** Yaoyao host Mac — same look-then-act pattern as the cloud computer. */
-export const SERVER_COMPUTER_RULES='当前环境是服务器,也就是夭夭正在运行的这台电脑。电脑名称与设置里一致,可改名;点名用这个名字。电脑操作、文件读写和命令必须使用 desktop_*。省略 host 或写「本机」时:若本轮用户消息来自服务器,就用这台电脑;若来自某台脚本机,则「本机」指那台脚本机;没有来源时默认仍是这台服务器。先查看真实桌面,再使用 click、text、key、scroll 或 drag;页面变化后重新截图,不得猜测旧坐标。文件与 shell 在用户主目录范围内真实生效。脚本机、虚拟环境和云虚拟机都不是这台服务器本身。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
+export const SERVER_COMPUTER_RULES='当前环境是服务器,也就是夭夭正在运行的这台电脑。电脑名称与设置里一致,可改名;点名用这个名字。一律用 desktop_*。省略 host 或写「本机」时:用户消息来自服务器就用这台;来自脚本机则「本机」指那台脚本机;没有来源时默认这台服务器。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。未授权文件与命令时如实说明,不要改用截图凑数。文件与 shell 在用户主目录范围内真实生效。脚本机、虚拟环境和云虚拟机都不是这台服务器本身。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
 
 /** The loopback native transport is admitted only by serviceInstance's
  *  private capability; remote hosts are admitted by DesktopHostHub's bearer

+ 2 - 2
src/server/vmComputer.ts

@@ -7,12 +7,12 @@ export const VM_COMPUTER_TOOLS=[
   {id:'computer_shell',name:'computer_shell',description:'在虚拟环境里执行命令。虚拟环境是服务端上的隔离 Linux 电脑,不会在服务端本机或脚本机上执行。',inputSchema:schema({command:field,user:{type:'string',enum:['cua','root']}},['command'])},
   {id:'computer_read_file',name:'computer_read_file',description:'读取虚拟环境里的 UTF-8 文件。',inputSchema:schema({path:field},['path'])},
   {id:'computer_write_file',name:'computer_write_file',description:'写入虚拟环境里的 UTF-8 文件。',inputSchema:schema({path:field,content:field},['path','content'])},
-  {id:'computer_desktop_state',name:'computer_desktop_state',description:'查看虚拟环境的真实桌面,返回截图和宽高。操作前必须先看。',inputSchema:schema({},[])},
+  {id:'computer_desktop_state',name:'computer_desktop_state',description:'查看虚拟环境的真实桌面,返回截图和宽高。仅在需要看窗口或做点击输入时使用;命令和文件不要先截图。',inputSchema:schema({},[])},
   {id:'computer_action',name:'computer_action',description:'操作虚拟环境桌面。必须先查看桌面。kind 为 click、text、key 或 scroll,坐标用截图像素。',inputSchema:schema({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:field,key:field,modifiers:{type:'array',items:{enum:['ctrl','alt','shift','super']}},direction:{enum:['up','down','left','right']},amount:{type:'integer'}},['kind'])},
   {id:'computer_export',name:'computer_export',description:'把虚拟环境里的文件作为附件回传到当前对话,最大 25 MiB。',inputSchema:schema({path:field,name:field},['path'])},
 ]
 
-export const VM_COMPUTER_RULES='已勾选虚拟环境。它是服务端上的隔离 Linux 电脑,不是这段对话本身。先用 computer_desktop_state 看真实桌面,再用 computer_action 点击、输入、按键或滚动,坐标用刚看到的截图像素。命令和文件用 computer_shell、computer_read_file、computer_write_file。不要用服务端本机的终端或文件代替。脚本机和云虚拟机是另外的工具,不要混用路径。'
+export const VM_COMPUTER_RULES='已勾选虚拟环境。它是服务端上的隔离 Linux 电脑,不是这段对话本身。要跑命令用 computer_shell,要读写文件用 computer_read_file / computer_write_file,不要先截图。只有需要看窗口或点按输入时才用 computer_desktop_state,再 computer_action;坐标用刚看到的截图像素,页面变化后重新截图。禁止用截图目测代替文件列表。不要用服务端本机的终端或文件代替。脚本机和云虚拟机是另外的工具,不要混用路径。'
 
 export class VmToolSession {
   private gateway?: WorkspaceGateway