Procházet zdrojové kódy

feat(desktop-host): Mac 端实现文件与命令通道及分档授权

新增 host-files:主目录范围内 列/读/写(12/12/10MB 上限,路径
与符号链接逃逸拒绝)与 zsh 执行(最长 120s、输出 512KB 截断)。
授权弹窗分两档:屏幕控制 与 文件+命令(approvedFull 独立保存,
撤销同时清两档);computer-permissions.json 迁移为 {screen,full},
兼容旧数组格式。
Samien před 1 měsícem
rodič
revize
06605d445b
3 změnil soubory, kde provedl 172 přidání a 7 odebrání
  1. 22 7
      desktop/environment-host.mjs
  2. 89 0
      desktop/host-files.mjs
  3. 61 0
      desktop/host-files.test.mjs

+ 22 - 7
desktop/environment-host.mjs

@@ -6,28 +6,43 @@ 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)})}
 
 /** Command execution shared by the loopback host and remote desktop hosts. */
 export class DesktopHostCore {
- constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;this.id=randomUUID();this.results=[];this.running=new Set();this.closed=false;this.browser=new BrowserEnvironment(join(dataRoot,'bot-browsers'));this.approved=new Set();this.permissionsFile=join(dataRoot,'computer-permissions.json');try{const list=JSON.parse(readFileSync(this.permissionsFile,'utf8'));if(Array.isArray(list))for(const key of list)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key)}catch{}this.frames=new Map()}
- info(){return {id:this.id,name:hostname(),platform:process.platform,screen:process.platform==='darwin'&&systemPreferences.getMediaAccessStatus('screen')==='granted',accessibility:process.platform==='darwin'&&systemPreferences.isTrustedAccessibilityClient(false),approved:[...this.approved]}}
- async revoke(){this.approved.clear();this.save();this.frames.clear();this.id=randomUUID()}
+ constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;this.home=process.env.HOME||'/';this.id=randomUUID();this.results=[];this.running=new Set();this.closed=false;this.browser=new BrowserEnvironment(join(dataRoot,'bot-browsers'));this.approved=new Set();this.approvedFull=new Set();this.permissionsFile=join(dataRoot,'computer-permissions.json');try{const stored=JSON.parse(readFileSync(this.permissionsFile,'utf8'));const screen=Array.isArray(stored)?stored:stored.screen;const full=Array.isArray(stored)?[]:stored.full;if(Array.isArray(screen))for(const key of screen)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key);if(Array.isArray(full))for(const key of full)if(/^[a-f0-9]{64}$/.test(key))this.approvedFull.add(key)}catch{}this.frames=new Map()}
+ info(){return {id:this.id,name:hostname(),platform:process.platform,screen:process.platform==='darwin'&&systemPreferences.getMediaAccessStatus('screen')==='granted',accessibility:process.platform==='darwin'&&systemPreferences.isTrustedAccessibilityClient(false),approved:[...this.approved],full:[...this.approvedFull]}}
+ async revoke(){this.approved.clear();this.approvedFull.clear();this.save();this.frames.clear();this.id=randomUUID()}
  async recycle(){this.results=[];this.id=randomUUID();await this.browser.close()}
- save(){mkdirSync(this.dataRoot,{recursive:true,mode:0o700});writeFileSync(this.permissionsFile,JSON.stringify([...this.approved]),{mode:0o600})}
+ save(){mkdirSync(this.dataRoot,{recursive:true,mode:0o700});writeFileSync(this.permissionsFile,JSON.stringify({screen:[...this.approved],full:[...this.approvedFull]}),{mode:0o600})}
  takeResults(){const sent=this.results.slice();this.results.splice(0,sent.length);return sent}
  async handle(commands){for(const c of commands??[]){if(this.running.has(c.id))continue;this.running.add(c.id);const epoch=this.id;void this.execute(c).then(value=>{if(epoch===this.id)this.results.push({id:c.id,value})},error=>{if(epoch===this.id)this.results.push({id:c.id,error:String(error.message).slice(0,500)})}).finally(()=>this.running.delete(c.id))}}
  async execute(c){const epoch=this.id;if(Date.now()>c.deadline||this.closed)throw new Error('桌面请求已过期');if(!/^[a-f0-9]{64}$/.test(c.owner))throw new Error('桌面请求身份无效')
   if(c.operation==='authorize'){
    if(process.platform!=='darwin')throw new Error('此电脑暂不支持本机控制')
-   const {response}=await dialog.showMessageBox({type:'question',title:'授权本机控制',message:`允许夭夭账号「${String(c.account??'当前账号').slice(0,100)}」的机器人查看和操作这台 Mac?`,detail:'授权后,机器人和该账号的 Web、手机端可以查看屏幕,并在接管时操作鼠标和键盘。你可以在夭夭菜单中撤销。',buttons:['取消','允许并检查系统权限'],defaultId:0,cancelId:0})
-   if(response!==1||Date.now()>c.deadline||this.closed||epoch!==this.id)throw new Error('本机控制授权已取消')
-   this.approved.add(c.owner);this.save()
+   const full=c.scope==='full'
+   const {response}=await dialog.showMessageBox({type:'question',title:full?'授权文件与命令':'授权本机控制',
+    message:`允许夭夭账号「${String(c.account??'当前账号').slice(0,100)}」的机器人${full?'使用这台 Mac 的文件并执行命令':'查看和操作这台 Mac'}?`,
+    detail:full?'机器人可以列出、读取和写入你用户主目录内的文件,并在这台 Mac 上执行 shell 命令。仅对信任的机器人开启;可随时在夭夭菜单中撤销。':'授权后,机器人和该账号的 Web、手机端可以查看屏幕,并在接管时操作鼠标和键盘。你可以在夭夭菜单中撤销。',
+    buttons:full?['取消','允许文件与命令']:['取消','允许并检查系统权限'],defaultId:0,cancelId:0})
+   if(response!==1||Date.now()>c.deadline||this.closed||epoch!==this.id)throw new Error(full?'文件与命令授权已取消':'本机控制授权已取消')
+   this.approved.add(c.owner);if(full)this.approvedFull.add(c.owner);this.save()
    if(!systemPreferences.isTrustedAccessibilityClient(false))systemPreferences.isTrustedAccessibilityClient(true)
    if(systemPreferences.getMediaAccessStatus('screen')!=='granted'){await desktopCapturer.getSources({types:['screen'],thumbnailSize:{width:1,height:1}}).catch(()=>{});if(systemPreferences.getMediaAccessStatus('screen')!=='granted')await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture')}
    return {ok:true}
   }
+  if(c.operation==='file'||c.operation==='shell'){
+   if(process.platform!=='darwin')throw new Error('此电脑暂不支持文件与命令')
+   if(!this.approvedFull.has(c.owner))throw new Error('请在桌面端授权文件与命令访问')
+   if(c.operation==='shell')return execHostShell(this.home,c.action)
+   const action=c.action??{}
+   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)
+   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('桌面命令无效')
   if(c.mode==='browser'){
    if(!/^[a-f0-9]{64}$/.test(c.resource))throw new Error('浏览器资源无效')

+ 89 - 0
desktop/host-files.mjs

@@ -0,0 +1,89 @@
+import {readdir,readFile,writeFile,mkdir,stat,realpath} from 'node:fs/promises'
+import {join,resolve,sep} from 'node:path'
+import {homedir} from 'node:os'
+import {execFile} from 'node:child_process'
+
+export const FILE_READ_LIMIT=12*1024*1024
+export const FILE_WRITE_LIMIT=10*1024*1024
+export const FILE_LIST_LIMIT=500
+export const SHELL_OUTPUT_LIMIT=512*1024
+export const SHELL_TIMEOUT_MAX=120000
+
+/** Resolve a user-supplied path inside an allowed root, refusing escapes
+ *  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(/^~(?=\/|$)/,'')
+  if(/\u0000/.test(raw))throw new Error('路径无效')
+  const target=resolve(base,raw)
+  let real=target
+  try{real=await realpath(target)}catch(error){
+    if(kind==='existing')throw new Error('文件或目录不存在')
+    if(kind==='write'){
+      let directory=join(target,'..')
+      for(;;){
+        try{
+          const parent=await realpath(directory)
+          if(parent!==base&&!parent.startsWith(base+sep))throw new Error('路径超出允许范围')
+          break
+        }catch(failure){
+          if(failure.message==='路径超出允许范围')throw failure
+          const next=join(directory,'..')
+          if(directory===next)throw error
+          directory=next
+        }
+      }
+      return {base,target}
+    }
+  }
+  if(real!==base&&!real.startsWith(base+sep))throw new Error('路径超出允许范围')
+  return {base,target}
+}
+
+export async function listHostFiles(root,input){
+  const {target}=await resolveWithin(root,input,'existing')
+  const info=await stat(target).catch(()=>{throw new Error('文件或目录不存在')})
+  if(!info.isDirectory())throw new Error('不是目录')
+  const entries=await readdir(target,{withFileTypes:true})
+  const limited=entries.slice(0,FILE_LIST_LIMIT)
+  const rows=await Promise.all(limited.map(async entry=>{
+    const full=join(target,entry.name)
+    const details=await stat(full).then(value=>({size:value.size,mtime:Math.round(value.mtimeMs)})).catch(()=>({size:0,mtime:0}))
+    return {name:entry.name,type:entry.isDirectory()?'directory':entry.isSymbolicLink()?'symlink':'file',...details}
+  }))
+  return {path:String(input),entries:rows,truncated:entries.length>FILE_LIST_LIMIT}
+}
+
+export async function readHostFile(root,input){
+  const {target}=await resolveWithin(root,input,'existing')
+  const info=await stat(target).catch(()=>{throw new Error('文件不存在')})
+  if(!info.isFile())throw new Error('不是文件')
+  if(info.size>FILE_READ_LIMIT)throw new Error(`文件超过读取上限(${Math.round(FILE_READ_LIMIT/1024/1024)} MB)`)
+  const bytes=await readFile(target)
+  return {path:String(input),size:bytes.length,data:bytes.toString('base64')}
+}
+
+export async function writeHostFile(root,input){
+  const bytes=Buffer.from(String(input?.data??''),'base64')
+  if(!bytes.length)throw new Error('文件内容为空')
+  if(bytes.length>FILE_WRITE_LIMIT)throw new Error(`内容超过写入上限(${Math.round(FILE_WRITE_LIMIT/1024/1024)} MB)`)
+  if(bytes.toString('base64')!==String(input?.data??''))throw new Error('文件内容必须是 base64')
+  const {target}=await resolveWithin(root,input?.path,'write')
+  await mkdir(join(target,'..'),{recursive:true})
+  await writeFile(target,bytes,{mode:0o600})
+  return {path:String(input?.path),size:bytes.length}
+}
+
+export async function execHostShell(root,input){
+  const command=String(input?.command??'')
+  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)}
+  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')
+      done({command,exitCode:error?typeof error.code==='number'?error.code:1:0,timedOut:error?.killed===true&&error?.signal==='SIGKILL',stdout:clip(stdout),stderr:clip(stderr)})
+    })
+  })
+}

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

@@ -0,0 +1,61 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,mkdir,writeFile,symlink,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {listHostFiles,readHostFile,writeHostFile,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-'))
+ try{
+  await mkdir(join(root,'docs'))
+  await writeFile(join(root,'docs','note.txt'),'你好,世界','utf8')
+  const listing=await listHostFiles(root,'docs')
+  assert.equal(listing.entries.length,1)
+  assert.deepEqual(listing.entries[0],{name:'note.txt',type:'file',size:Buffer.byteLength('你好,世界'),mtime:listing.entries[0].mtime})
+  const file=await readHostFile(root,'docs/note.txt')
+  assert.equal(Buffer.from(file.data,'base64').toString('utf8'),'你好,世界')
+  const written=await writeHostFile(root,{path:'docs/out/new.bin',data:Buffer.from([1,2,3]).toString('base64')})
+  assert.equal(written.size,3)
+  assert.deepEqual(Buffer.from(await readHostFile(root,'docs/out/new.bin').then(value=>Buffer.from(value.data,'base64'))),Buffer.from([1,2,3]))
+  await assert.rejects(()=>listHostFiles(root,'missing'),/不存在/)
+  await assert.rejects(()=>readHostFile(root,'docs'),/不是文件/)
+  await assert.rejects(()=>writeHostFile(root,{path:'x.txt',data:'not base64!!'}),/base64/)
+ }finally{await rm(root,{recursive:true,force:true})}
+})
+
+test('path escapes are refused for every operation',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'yaoyao-host-files-'))
+ const outside=await mkdtemp(join(tmpdir(),'yaoyao-host-files-out-'))
+ try{
+  await writeFile(join(outside,'secret.txt'),'secret','utf8')
+  await symlink(outside,join(root,'escape'))
+  await writeFile(join(root,'keep.txt'),'keep','utf8')
+  await assert.rejects(()=>resolveWithin(root,'../x'),/超出允许范围/)
+  await assert.rejects(()=>resolveWithin(root,'/etc/passwd'),/超出允许范围/)
+  await assert.rejects(()=>readHostFile(root,'escape/secret.txt'),/超出允许范围/)
+  await assert.rejects(()=>listHostFiles(root,'escape'),/超出允许范围/)
+  await assert.rejects(()=>writeHostFile(root,{path:'escape/evil.txt',data:Buffer.from('x').toString('base64')}),/超出允许范围/)
+  await assert.rejects(()=>readHostFile(root,'keep.txt\u0000'),/路径无效/)
+  assert.equal((await readHostFile(root,'keep.txt')).size,4)
+ }finally{
+  await rm(root,{recursive:true,force:true});await rm(outside,{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{
+  await mkdir(join(root,'work'))
+  const ok=await execHostShell(root,{command:'pwd',cwd:'work'})
+  assert.equal(ok.exitCode,0)
+  assert.ok(ok.stdout.includes(`${root}/work`),ok.stdout)
+  const failing=await execHostShell(root,{command:'echo boom >&2; exit 7'})
+  assert.equal(failing.exitCode,7);assert.equal(failing.stderr.trim(),'boom')
+  const timed=await execHostShell(root,{command:'sleep 5',timeoutMs:1000})
+  assert.equal(timed.timedOut,true);assert.notEqual(timed.exitCode,0)
+  const escaped=await execHostShell(root,{command:'cd / && pwd'})
+  assert.equal(escaped.exitCode,0)
+  await assert.rejects(()=>execHostShell(root,{command:'  '}),/命令为空/)
+ }finally{await rm(root,{recursive:true,force:true})}
+})