소스 검색

完善机器人本机与独立浏览器环境及接管流程

Samien 1 개월 전
부모
커밋
657a94fe28

+ 54 - 0
desktop/browser-environment.mjs

@@ -0,0 +1,54 @@
+import {BrowserWindow,session} from 'electron'
+import {randomUUID} from 'node:crypto'
+import {join} from 'node:path'
+import {mkdirSync,chmodSync} from 'node:fs'
+
+export function browserURL(value){if(typeof value!=='string'||value.length>4096||/[\\\x00-\x20]/.test(value))throw new Error('请输入完整的 http 或 https 地址');const u=new URL(value);if(!['http:','https:'].includes(u.protocol)||u.username||u.password)throw new Error('浏览器只支持 http 和 https 页面');return u.href}
+const allowed=url=>url==='about:blank'||(()=>{try{browserURL(url);return true}catch{return false}})()
+/** A bot gets an isolated Chromium profile, never the app or personal browser's
+ * cookies. All commands are structured; no model-supplied JavaScript or CDP. */
+export class BrowserEnvironment {
+ constructor(root){mkdirSync(root,{recursive:true,mode:0o700});chmodSync(root,0o700);this.root=root;this.browsers=new Map();this.sessions=new Map()}
+ state(key){const b=this.browsers.get(key);return {open:!!b,tabs:b?[...b.tabs].map(([id,w])=>({id,title:w.webContents.getTitle()||'新标签页',url:w.webContents.getURL(),active:id===b.active})):[],profile:b?.profile??'persistent'}}
+ async open(key,profile){if(!/^[a-f0-9]{64}$/.test(key)||!['persistent','temporary'].includes(profile))throw new Error('浏览器身份无效');let b=this.browsers.get(key);if(!b){let s=this.sessions.get(key);if(!s){s=profile==='persistent'?session.fromPath(join(this.root,key)):session.fromPartition('yaoyao-temporary-'+key+'-'+randomUUID());s.setPermissionRequestHandler((_w,_p,callback)=>callback(false));s.setPermissionCheckHandler(()=>false);s.on('will-download',event=>event.preventDefault());this.sessions.set(key,s)}b={tabs:new Map(),active:'',session:s,profile};this.browsers.set(key,b);await this.newTab(b,'about:blank')}return b}
+ async newTab(b,url){if(b.tabs.size>=12)throw new Error('最多打开 12 个标签页,请先关闭不用的页面');const id=randomUUID(),w=new BrowserWindow({show:false,width:1280,height:800,useContentSize:true,webPreferences:{session:b.session,sandbox:true,nodeIntegration:false,contextIsolation:true,backgroundThrottling:false,webSecurity:true,allowRunningInsecureContent:false}});b.tabs.set(id,w);b.active=id
+  w.webContents.setWindowOpenHandler(({url})=>{if(allowed(url))void this.newTab(b,url).catch(()=>{});return {action:'deny'}})
+  w.webContents.on('will-navigate',(event,url)=>{if(!allowed(url))event.preventDefault()});w.webContents.on('will-redirect',(event,url)=>{if(!allowed(url))event.preventDefault()});w.webContents.on('will-attach-webview',event=>event.preventDefault());w.webContents.on('did-start-navigation',()=>{w.snapshotId=undefined})
+  w.on('closed',()=>{b.tabs.delete(id);if(b.active===id)b.active=b.tabs.keys().next().value??''})
+  try{await this.load(w,url)}catch(error){if(!w.isDestroyed()&&error.code!=='ERR_ABORTED')throw new Error('页面加载失败,请检查地址或网络')}
+  return id
+ }
+ async load(w,url){let timer;try{await Promise.race([w.loadURL(url),new Promise((_,reject)=>{timer=setTimeout(()=>{if(!w.isDestroyed())w.webContents.stop();reject(new Error('页面加载超时'))},18000)})])}finally{clearTimeout(timer)}}
+ current(b){const w=b.tabs.get(b.active);if(!w||w.isDestroyed())throw new Error('浏览器标签页已关闭,请新建标签页');return w}
+ async view(key){const b=this.browsers.get(key);if(!b)throw new Error('浏览器尚未打开,点击打开浏览器');const wc=this.current(b).webContents;await wc.executeJavaScript('new Promise(resolve=>{requestAnimationFrame(()=>requestAnimationFrame(resolve));setTimeout(resolve,200)})').catch(()=>{});const image=await wc.capturePage();if(image.isEmpty())throw new Error('浏览器画面尚未就绪');return {data:image.toPNG().toString('base64'),...image.getSize()}}
+ async action(key,profile,a){const b=await this.open(key,profile);if(a.kind==='new-tab'){await this.newTab(b,browserURL(a.url));return this.state(key)}
+  if(['select-tab','close-tab'].includes(a.kind)){if(!b.tabs.has(a.tabId))throw new Error('标签页已关闭');if(a.kind==='select-tab')b.active=a.tabId;else{b.tabs.get(a.tabId).destroy();if(!b.tabs.size)await this.newTab(b,'about:blank')}return this.state(key)}
+  const w=this.current(b),wc=w.webContents
+  if(a.kind==='navigate'){const url=browserURL(a.url);w.snapshotId=undefined;try{await this.load(w,url)}catch(error){if(error.code!=='ERR_ABORTED')throw new Error('页面加载失败,请检查地址或网络')}}
+  else if(a.kind==='back'){w.snapshotId=undefined;if(wc.navigationHistory.canGoBack())wc.navigationHistory.goBack()}
+  else if(a.kind==='forward'){w.snapshotId=undefined;if(wc.navigationHistory.canGoForward())wc.navigationHistory.goForward()}
+  else if(a.kind==='reload'){w.snapshotId=undefined;wc.reload()}
+  else if(a.kind==='snapshot'){
+   const id=randomUUID();w.snapshotId=id
+   // Ref metadata lives in an isolated world, out of reach of page scripts.
+   const value=await wc.executeJavaScriptInIsolatedWorld(999,[{code:`(()=>{const nodes=[...document.querySelectorAll('a,button,input,textarea,select,[role="button"],[contenteditable="true"]')].filter(e=>e.getClientRects().length).slice(0,500);globalThis.__yaoyaoRefs=new Map(nodes.map((e,i)=>['e'+i,e]));return {title:document.title,url:location.href,text:document.body?.innerText.slice(0,24000)||'',elements:nodes.map((e,i)=>({ref:'e'+i,tag:e.tagName.toLowerCase(),role:e.getAttribute('role'),label:e.getAttribute('aria-label')||e.getAttribute('placeholder')||e.innerText?.slice(0,160)||'',type:e.getAttribute('type')}))}})()`}]);return {...value,snapshotId:id}
+  }else if(a.kind==='click'||a.kind==='fill'){
+   if(!w.snapshotId||w.snapshotId!==a.snapshotId||!/^e[0-9]+$/.test(a.ref))throw new Error('页面已变化,请重新读取 snapshot')
+   if(a.kind==='fill'&&typeof a.text!=='string')throw new Error('填写内容缺失')
+   const code=`(()=>{const e=globalThis.__yaoyaoRefs?.get(${JSON.stringify(a.ref)});if(!e||!e.isConnected||!e.getClientRects().length)throw new Error('元素已变化,请重新读取 snapshot');if(e instanceof HTMLInputElement&&e.type==='file')throw new Error('请通过页面接管处理文件选择');e.scrollIntoView({block:'center'});${a.kind==='click'?'e.click();':`if(!(e instanceof HTMLInputElement||e instanceof HTMLTextAreaElement||e.isContentEditable))throw new Error('该元素不能填写');e.focus();if(e.isContentEditable)e.textContent=${JSON.stringify(a.text)};else{const setter=Object.getOwnPropertyDescriptor(e instanceof HTMLInputElement?HTMLInputElement.prototype:HTMLTextAreaElement.prototype,'value').set;setter.call(e,${JSON.stringify(a.text)})}e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));`}return {ok:true}})()`
+   await wc.executeJavaScriptInIsolatedWorld(999,[{code}]);w.snapshotId=undefined
+  }else throw new Error('浏览器操作无效')
+  return this.state(key)
+ }
+ async input(key,a,frame){const b=this.browsers.get(key);if(!b)throw new Error('浏览器未打开');const w=this.current(b),wc=w.webContents,size=w.getContentSize();w.snapshotId=undefined
+  const point=(x,y)=>{if(!Number.isFinite(x)||!Number.isFinite(y)||x<0||y<0||x>=frame.width||y>=frame.height)throw new Error('输入超出画面');return {x:Math.floor(x*size[0]/frame.width),y:Math.floor(y*size[1]/frame.height)}}
+  if(a.kind==='text')await wc.insertText(a.text)
+  else if(a.kind==='key'){const keyCode=({Return:'Enter',BackSpace:'Backspace',Up:'Up',Down:'Down',Left:'Left',Right:'Right',space:'Space'}[a.key]??a.key),modifiers=(a.modifiers??[]).map(m=>({ctrl:'control',alt:'alt',shift:'shift',super:'meta'}[m]));wc.sendInputEvent({type:'keyDown',keyCode,modifiers});wc.sendInputEvent({type:'keyUp',keyCode,modifiers})}
+  else if(a.kind==='click'){const p=point(a.x,a.y),button=a.button??'left',clickCount=a.count??1;wc.sendInputEvent({type:'mouseMove',...p});wc.sendInputEvent({type:'mouseDown',...p,button,clickCount});wc.sendInputEvent({type:'mouseUp',...p,button,clickCount})}
+  else if(a.kind==='drag'){const start=point(a.fromX,a.fromY),end=point(a.toX,a.toY);wc.sendInputEvent({type:'mouseDown',...start,button:'left',clickCount:1});wc.sendInputEvent({type:'mouseMove',...end,modifiers:['leftButtonDown']});wc.sendInputEvent({type:'mouseUp',...end,button:'left',clickCount:1})}
+  else if(a.kind==='scroll'){const amount=(a.amount??3)*80;wc.sendInputEvent({type:'mouseWheel',x:Math.floor(size[0]/2),y:Math.floor(size[1]/2),deltaX:a.direction==='left'?-amount:a.direction==='right'?amount:0,deltaY:a.direction==='up'?-amount:a.direction==='down'?amount:0,canScroll:true})}
+  else throw new Error('浏览器输入无效')
+  return {ok:true}
+ }
+ async close(){for(const b of this.browsers.values())for(const w of b.tabs.values())w.destroy();this.browsers.clear();for(const s of this.sessions.values()){s.flushStorageData();await s.cookies.flushStore();if(!s.isPersistent())await s.clearStorageData()}this.sessions.clear()}
+}

+ 41 - 0
desktop/browser-environment.test.mjs

@@ -0,0 +1,41 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,writeFile,rm,mkdir} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {pathToFileURL} from 'node:url'
+import {createServer} from 'node:http'
+import {randomUUID} from 'node:crypto'
+import {_electron} from '@playwright/test'
+
+test('real isolated Chromium supports refs, tabs, screenshots and separate persisted profiles',{timeout:60000},async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-browser-')),marker=randomUUID(),server=createServer((req,res)=>{res.setHeader('Content-Type','text/html; charset=utf-8');res.end(`<title>Browser ${marker}</title><body style="background:#edf4ff;font:24px sans-serif;padding:50px"><h1>独立浏览器验证</h1><label>随机内容 <input aria-label="验证输入"></label><button onclick="document.querySelector('output').textContent=document.querySelector('input').value">确认</button><output></output><p>${marker}</p><a href="/next">下一页</a></body>`)})
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const url=`http://127.0.0.1:${server.address().port}/`,moduleURL=pathToFileURL(resolve('desktop/browser-environment.mjs')).href
+ await writeFile(join(home,'main.mjs'),`import {app,BrowserWindow} from 'electron';app.setPath('userData',${JSON.stringify(join(home,'app'))});app.whenReady().then(async()=>{const hostWindow=new BrowserWindow({show:false});await hostWindow.loadURL('about:blank');const {BrowserEnvironment}=await import(${JSON.stringify(moduleURL)});globalThis.botBrowser=new BrowserEnvironment(${JSON.stringify(join(home,'profiles'))});app.on('window-all-closed',()=>{});});`)
+ let app
+ try{
+  app=await _electron.launch({args:[join(home,'main.mjs')],env:Object.fromEntries(Object.entries(process.env).filter(([key])=>key!=='ELECTRON_RUN_AS_NODE'))})
+  await app.evaluate(async()=>{for(let i=0;i<100&&!globalThis.botBrowser;i++)await new Promise(resolve=>setTimeout(resolve,20));if(!globalThis.botBrowser)throw new Error('browser host did not initialize')})
+  const call=async(method,...args)=>app.evaluate(async(_,{method,args})=>globalThis.botBrowser[method](...args),{method,args})
+  const a='a'.repeat(64),b='b'.repeat(64),t='c'.repeat(64)
+  assert.equal((await call('state',a)).open,false)
+  await call('action',a,'persistent',{kind:'navigate',url})
+  const snapshot=await call('action',a,'persistent',{kind:'snapshot'})
+  assert.ok(snapshot.text.includes(marker));const input=snapshot.elements.find(e=>e.label==='验证输入');assert.ok(input)
+  await call('action',a,'persistent',{kind:'fill',ref:input.ref,snapshotId:snapshot.snapshotId,text:marker})
+  await assert.rejects(call('action',a,'persistent',{kind:'click',ref:input.ref,snapshotId:snapshot.snapshotId}),/变化/)
+  const next=await call('action',a,'persistent',{kind:'snapshot'}),button=next.elements.find(e=>e.label==='确认')
+  await call('action',a,'persistent',{kind:'click',ref:button.ref,snapshotId:next.snapshotId})
+  assert.equal(await app.evaluate(()=>globalThis.botBrowser.current(globalThis.botBrowser.browsers.values().next().value).webContents.executeJavaScript('document.querySelector("output").textContent')),marker)
+  const frame=await call('view',a);assert.ok(frame.width>=1000);assert.ok(Buffer.from(frame.data,'base64').length>10000)
+  await mkdir(resolve('test-results/desktop'),{recursive:true});await writeFile(resolve('test-results/desktop/bot-browser.png'),Buffer.from(frame.data,'base64'))
+  await call('action',a,'persistent',{kind:'new-tab',url:url+'next'});const tabs=(await call('state',a)).tabs;assert.equal(tabs.length,2)
+  await call('action',a,'persistent',{kind:'close-tab',tabId:tabs.find(t=>t.active).id});assert.equal((await call('state',a)).tabs.length,1)
+  await assert.rejects(call('action',a,'persistent',{kind:'navigate',url:'file:///etc/passwd'}),/http/)
+  await app.evaluate(async(_,{a,url})=>globalThis.botBrowser.browsers.get(a).session.cookies.set({url,name:'owner',value:'a',expirationDate:Date.now()/1000+3600}),{a,url})
+  await call('action',b,'persistent',{kind:'navigate',url});assert.deepEqual(await app.evaluate(async(_,b)=>globalThis.botBrowser.browsers.get(b).session.cookies.get({name:'owner'}),b),[])
+  await call('action',t,'temporary',{kind:'navigate',url});await app.evaluate(async(_,{t,url})=>globalThis.botBrowser.browsers.get(t).session.cookies.set({url,name:'temporary',value:'yes',expirationDate:Date.now()/1000+3600}),{t,url})
+  await call('close');await app.close();app=await _electron.launch({args:[join(home,'main.mjs')],env:Object.fromEntries(Object.entries(process.env).filter(([key])=>key!=='ELECTRON_RUN_AS_NODE'))});await app.evaluate(async()=>{for(let i=0;i<100&&!globalThis.botBrowser;i++)await new Promise(resolve=>setTimeout(resolve,20))});await call('open',a,'persistent');assert.equal((await app.evaluate(async(_,a)=>globalThis.botBrowser.browsers.get(a).session.cookies.get({name:'owner'}),a))[0].value,'a')
+  await call('open',t,'temporary');assert.deepEqual(await app.evaluate(async(_,t)=>globalThis.botBrowser.browsers.get(t).session.cookies.get({name:'temporary'}),t),[])
+ }finally{await app?.close();await new Promise(resolve=>server.close(resolve));await rm(home,{recursive:true,force:true})}
+})

+ 95 - 0
desktop/computer-helper.swift

@@ -0,0 +1,95 @@
+import Foundation
+import Security
+import AppKit
+import ApplicationServices
+import Darwin
+
+// This helper's signed identity owns each Keychain item. Plaintext travels only
+// on private stdin/stdout, never in command arguments or logs.
+func fail(_ message: String) -> Never {
+    FileHandle.standardError.write(Data((message + "\n").utf8)); exit(1)
+}
+#if !DEVELOPMENT_HELPER
+// The distributed helper accepts only its containing, validly signed App with
+// the same signing team. Copying the helper or invoking it from a shell fails.
+@_silgen_name("proc_pidpath")
+func processPath(_ pid: Int32, _ buffer: UnsafeMutableRawPointer, _ size: UInt32) -> Int32
+var parentPath = [CChar](repeating: 0, count: 4096)
+let pathLength = parentPath.withUnsafeMutableBytes { processPath(getppid(), $0.baseAddress!, UInt32($0.count)) }
+guard pathLength > 0 else { fail("无法核对 App 身份") }
+let parentURL = URL(fileURLWithPath: String(cString: parentPath)).resolvingSymlinksInPath()
+let expected = parentURL.deletingLastPathComponent().appendingPathComponent("../Resources/runtime/computer-helper").standardizedFileURL.resolvingSymlinksInPath()
+guard expected.path == URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath().path else { fail("电脑助手只允许所属 App 调用") }
+var parentCode: SecCode?, ownCode: SecCode?
+guard SecCodeCopyGuestWithAttributes(nil, [kSecGuestAttributePid as String: getppid()] as CFDictionary, [], &parentCode) == errSecSuccess,
+      SecCodeCopySelf([], &ownCode) == errSecSuccess, let parentCode, let ownCode,
+      SecCodeCheckValidity(parentCode, [], nil) == errSecSuccess else { fail("App 签名无效") }
+func signingInfo(_ code: SecCode) -> [String: Any] {
+    var info: CFDictionary?
+    var staticCode: SecStaticCode?
+    guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode else { fail("无法读取签名身份") }
+    guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &info) == errSecSuccess else { fail("无法核对签名身份") }
+    return info as? [String: Any] ?? [:]
+}
+let parentInfo = signingInfo(parentCode), ownInfo = signingInfo(ownCode)
+guard let team = ownInfo[kSecCodeInfoTeamIdentifier as String] as? String,
+      parentInfo[kSecCodeInfoTeamIdentifier as String] as? String == team,
+      parentInfo[kSecCodeInfoIdentifier as String] as? String == "cn.samien.yaoyao.desktop" else { fail("App 签名身份不匹配") }
+#endif
+let bytes = FileHandle.standardInput.readDataToEndOfFile()
+guard bytes.count <= 131072,
+      let input = try? JSONSerialization.jsonObject(with: bytes) as? [String: Any],
+      let action = input["action"] as? [String: Any], let kind = action["kind"] as? String,
+      let frame = input["frame"] as? [String: Any], let bounds = frame["bounds"] as? [String: Any],
+      let width = frame["width"] as? Double, let height = frame["height"] as? Double,
+      let bx = bounds["x"] as? Double, let by = bounds["y"] as? Double,
+      let bw = bounds["width"] as? Double, let bh = bounds["height"] as? Double,
+      width > 0, height > 0, bw > 0, bh > 0 else { fail("电脑输入格式无效") }
+guard AXIsProcessTrusted() else { fail("请在系统设置中允许夭夭使用辅助功能") }
+func point(_ xKey: String, _ yKey: String) -> CGPoint {
+    guard let x = action[xKey] as? Double, let y = action[yKey] as? Double,
+          x.isFinite, y.isFinite, x >= 0, y >= 0, x < width, y < height else { fail("输入超出当前画面") }
+    return CGPoint(x: bx + x * bw / width, y: by + y * bh / height)
+}
+func mouse(_ type: CGEventType, _ point: CGPoint, _ button: CGMouseButton = .left, _ clicks: Int64 = 1) {
+    guard let event = CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: point, mouseButton: button) else { fail("无法创建鼠标输入") }
+    event.setIntegerValueField(.mouseEventClickState, value: clicks); event.post(tap: .cghidEventTap)
+}
+switch kind {
+case "click":
+    let p = point("x", "y"), name = action["button"] as? String ?? "left"
+    let button: CGMouseButton = name == "right" ? .right : name == "middle" ? .center : .left
+    let down: CGEventType = button == .left ? .leftMouseDown : button == .right ? .rightMouseDown : .otherMouseDown
+    let up: CGEventType = button == .left ? .leftMouseUp : button == .right ? .rightMouseUp : .otherMouseUp
+    let count = action["count"] as? Int ?? 1
+    guard (1...2).contains(count) else { fail("点击次数无效") }
+    mouse(.mouseMoved, p)
+    for i in 1...count { mouse(down,p,button,Int64(i)); mouse(up,p,button,Int64(i)); usleep(50000) }
+case "drag":
+    let start = point("fromX", "fromY"), end = point("toX", "toY")
+    mouse(.mouseMoved,start); mouse(.leftMouseDown,start)
+    for step in 1...12 { mouse(.leftMouseDragged,CGPoint(x:start.x+(end.x-start.x)*Double(step)/12,y:start.y+(end.y-start.y)*Double(step)/12)); usleep(12000) }
+    mouse(.leftMouseUp,end)
+case "text":
+    guard let text = action["text"] as? String, text.utf16.count <= 16000 else { fail("输入文本过长") }
+    let characters = Array(text.utf16)
+    for offset in stride(from: 0, to: characters.count, by: 200) {
+        let chunk = Array(characters[offset..<min(offset+200,characters.count)])
+        for down in [true,false] { guard let event = CGEvent(keyboardEventSource:nil,virtualKey:0,keyDown:down) else { fail("无法创建键盘输入") };chunk.withUnsafeBufferPointer { event.keyboardSetUnicodeString(stringLength:chunk.count,unicodeString:$0.baseAddress!) };event.post(tap:.cghidEventTap) }
+    }
+case "key":
+    let keys: [String:CGKeyCode] = ["a":0,"s":1,"d":2,"f":3,"h":4,"g":5,"z":6,"x":7,"c":8,"v":9,"b":11,"q":12,"w":13,"e":14,"r":15,"y":16,"t":17,"1":18,"2":19,"3":20,"4":21,"6":22,"5":23,"9":25,"7":26,"8":28,"0":29,"o":31,"u":32,"i":34,"p":35,"Return":36,"Enter":36,"l":37,"j":38,"k":40,"n":45,"m":46,"Tab":48,"Space":49,"space":49,"BackSpace":51,"Backspace":51,"Escape":53,"Delete":117,"Home":115,"End":119,"PageUp":116,"PageDown":121,"Left":123,"Right":124,"Down":125,"Up":126]
+    guard let name = action["key"] as? String, let key = keys[name] ?? keys[name.lowercased()] else { fail("暂不支持这个按键") }
+    var flags = CGEventFlags()
+    for modifier in action["modifiers"] as? [String] ?? [] { switch modifier { case "ctrl": flags.insert(.maskControl); case "alt": flags.insert(.maskAlternate); case "shift": flags.insert(.maskShift); case "super": flags.insert(.maskCommand); default: fail("按键修饰符无效") } }
+    for down in [true,false] { guard let event = CGEvent(keyboardEventSource:nil,virtualKey:key,keyDown:down) else { fail("无法创建键盘输入") };event.flags=flags;event.post(tap:.cghidEventTap) }
+case "scroll":
+    guard let direction = action["direction"] as? String, ["up","down","left","right"].contains(direction) else { fail("滚动方向无效") }
+    let amount = action["amount"] as? Int ?? 3
+    guard (1...50).contains(amount) else { fail("滚动距离无效") }
+    let vertical = direction == "up" ? amount : direction == "down" ? -amount : 0
+    let horizontal = direction == "left" ? amount : direction == "right" ? -amount : 0
+    guard let event = CGEvent(scrollWheelEvent2Source:nil,units:.line,wheelCount:2,wheel1:Int32(vertical),wheel2:Int32(horizontal),wheel3:0) else { fail("无法创建滚动输入") };event.post(tap:.cghidEventTap)
+default: fail("电脑操作无效")
+}
+FileHandle.standardOutput.write(Data("{\"ok\":true}".utf8))

+ 45 - 0
desktop/environment-host.mjs

@@ -0,0 +1,45 @@
+import {app,desktopCapturer,dialog,screen,shell,systemPreferences} from 'electron'
+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'
+
+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)})}
+export class DesktopEnvironmentHost {
+ constructor({manager,root,dataRoot}){this.manager=manager;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()}
+ save(){mkdirSync(this.dataRoot,{recursive:true,mode:0o700});writeFileSync(this.permissionsFile,JSON.stringify([...this.approved]),{mode:0o600})}
+ 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()
+   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(!['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('浏览器资源无效')
+   if(c.operation==='browser-state')return this.browser.state(c.resource)
+   if(c.operation==='open'){await this.browser.open(c.resource,c.profile);return {ok:true}}
+   if(c.operation==='browser')return this.browser.action(c.resource,c.profile,c.action)
+   if(c.operation==='view')return this.browser.view(c.resource)
+   return this.browser.input(c.resource,c.action,c.frame)
+  }
+  const info=this.info();if(!info.approved.includes(c.owner)||!info.screen||!info.accessibility)throw new Error('请在桌面端授权,并在系统设置开启屏幕录制和辅助功能')
+  if(c.operation==='open')return {ok:true}
+  if(c.operation==='view'){const d=screen.getPrimaryDisplay(),sources=await desktopCapturer.getSources({types:['screen'],thumbnailSize:{width:1600,height:1200}}),source=sources.find(s=>s.display_id===String(d.id));if(!source||source.thumbnail.isEmpty())throw new Error('系统尚未允许读取屏幕');const dimensions=source.thumbnail.getSize();this.frames.set(c.resource,{display:d.id,...dimensions,bounds:d.bounds});return {data:source.thumbnail.toPNG().toString('base64'),...dimensions}}
+  if(c.operation!=='input')throw new Error('本机操作无效')
+  const f=this.frames.get(c.resource),d=screen.getPrimaryDisplay();if(!f||f.display!==d.id||JSON.stringify(f.bounds)!==JSON.stringify(d.bounds)||f.width!==c.frame?.width||f.height!==c.frame?.height)throw new Error('显示器已变化,请刷新画面')
+  return new Promise((resolve,reject)=>{const child=execFile(join(this.root,app.isPackaged?'computer-helper':'computer-helper-dev'),[],{timeout:10000,maxBuffer:65536},(error,stdout,stderr)=>{if(error)reject(new Error(stderr.trim()||'系统未接受电脑输入'));else{try{resolve(JSON.parse(stdout))}catch{reject(new Error('电脑助手响应无效'))}}});child.stdin.end(JSON.stringify({action:c.action,frame:f}))})
+ }
+ async cycle(){if(this.closed)return;try{const record=await this.manager.readRecord();if(record){const verified=await this.manager.verify(record);record.url=verified.url;if(this.instance&&this.instance!==record.instanceId){this.results=[];this.id=randomUUID();await this.browser.close()}this.instance=record.instanceId;const sent=this.results.slice(),value=await exchange(record,{host:this.info(),results:sent});this.results.splice(0,sent.length);for(const c of value.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))}}}catch{}finally{if(!this.closed)this.timer=setTimeout(()=>void this.cycle(),400)}}
+ start(){this.closed=false;void this.cycle()}
+ async close(){this.closed=true;clearTimeout(this.timer);this.id=randomUUID();this.results=[];await this.browser.close()}
+}

+ 6 - 1
desktop/main.mjs

@@ -8,6 +8,7 @@ import { DesktopRunnerManager } from './runner-manager.mjs'
 import { DesktopPreferences } from './preferences.mjs'
 import { DesktopCredentials } from './credentials.mjs'
 import { synchronizeLocalService, stopLocalService } from './service-sync.mjs'
+import { DesktopEnvironmentHost } from './environment-host.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
@@ -18,7 +19,7 @@ app.setName('夭夭')
 if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
   let window, tray, manager, runnerManager, quitting = false, closing = false, timer
-  let updateWindow, updater
+  let updateWindow, updater, environmentHost
   const updateURL = pathToFileURL(join(desktopRoot, 'update.html')).href
   const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
   const root = app.isPackaged ? join(process.resourcesPath, 'runtime') : join(app.getAppPath(), '.desktop-build')
@@ -155,6 +156,7 @@ else {
     // Both cleanup and the final quit must run after that callback unwinds.
     setImmediate(() => { void (async()=>{
       try {
+        await environmentHost?.close()
         await runnerManager?.stop()
         if (stopBackground) await manager?.stopBackground()
         else await manager?.stop()
@@ -210,6 +212,7 @@ else {
         return utilityProcess.fork(join(root, 'server.mjs'), [], { cwd: root, env, stdio: 'pipe' })
       },
     })
+    environmentHost=new DesktopEnvironmentHost({manager,root,dataRoot:app.getPath('userData')})
     const { parseRunnerConfiguration } = await import(pathToFileURL(join(root,'runner-config.mjs')).href)
     mkdirSync(home,{recursive:true,mode:0o700})
     const credentials=new DesktopCredentials({home:realpathSync(home),helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result})
@@ -242,6 +245,7 @@ else {
         { type: 'separator' },
         { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
         { type: 'separator' },
+        {label:'撤销本机控制授权',click:()=>environmentHost.revoke()},
         { id:'desktop-quit',label:'退出夭夭(后台继续运行)',accelerator:'CommandOrControl+Q',click:()=>requestQuit() },
         { id:'desktop-stop-and-quit',label:'停止后台服务并退出',click:()=>requestQuit(true) }] },
       {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'},
@@ -260,6 +264,7 @@ else {
     powerMonitor.on('resume', () => { if(!closing&&!quitting)void manager.check() })
     timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
     await manager.start().catch(error => log(error.message))
+    if(!closing)environmentHost.start()
     if(!closing)await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
   }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
 }

+ 33 - 0
docs/verification/2026-09-10-bot-environments/README.md

@@ -0,0 +1,33 @@
+# Bot 本机与浏览器环境验收
+
+对照 OpenMausBot 的 `src/lib/local-computer.ts`、`server/local-computer.ts`、`server/browser-live.ts`、`src/components/BrowserPanel.tsx`:环境卡片显示连接电脑,本机显式申请系统权限;浏览器使用独立资料、标签页、地址栏、接管和交还,断线清除画面且不恢复输入权限。群聊仍只有 Inspector。
+
+实现:
+
+- 夭夭 Electron 提供本机截图与鼠标、键盘、拖动、滚轮输入。Web/手机通过当前账号连接的桌面端操作,展示实际主机名称。Mac 本机控制同时要求桌面账号授权、屏幕录制和辅助功能。App 菜单可以撤销授权。
+- 使用 Electron 自带 Chromium 的隔离浏览器,不读取个人浏览器资料。每个机器人独立保存 cookies;临时资料在退出桌面端时清除。支持标签新建/关闭/切换、导航/前进/后退/刷新、元素 snapshot/ref、填写与点击、截图与人工输入。浏览器页面没有 Node、preload 或桌面服务凭据。
+- 通过现有按任务授权的 Hermes 工具桥接入机器人;权限、来源、模式、浏览器资料、运行中断与桌面实例变化都会使旧请求失效。本机输入全局串行,浏览器按机器人资料串行;人工控制使用独立票据、代次、请求幂等、最新画面校验。过期或断线需要重新接管并交还,工具不自动重放。
+- 自动模式保留已有虚拟机选择;无虚拟机时优先使用已授权且系统权限就绪的本机,再按既有云端配置选择。查看面板不会创建浏览器或唤醒云端。浏览器在显式打开或机器人第一次工具调用时建立。
+
+验证:
+
+- Web/Server 类型检查与构建通过;全量 Vitest 891 项通过、7 项跳过。之后的控制就绪、画面归属和交还说明修改使用专项套件复核。
+- `tests/server/desktopEnvironments.test.ts` 8 项:私有传输、账号/来源、自动选择、全局本机控制、票据、旧画面、幂等、断开代次、人工接管、交还说明及运行中资料切换。与服务所有权测试共 14 项。
+- `desktop/browser-environment.test.mjs` 使用真正的 Electron/Chromium,随机输入、DOM 结果、截图与标签页实测;重启 Electron 后持久 cookies 保留,其他机器人与临时资料均隔离。
+- `playwright.native-environment.config.ts` 在临时 Web 数据目录启动真实 Electron 环境桥,Web 接管浏览器、导航到隔离页面、交还与重新接管通过。1280 像素桌面及 375 像素窄屏截图已检查。
+- iOS Simulator 签名构建、34 项 WorkspaceChatTests、3 项界面用例通过。界面用例使用 URLProtocol fixture,验证权限状态、浏览器工具栏、接管/交还、虚拟机回归及大字体群聊 Inspector。另追加“交还后再次接管”检查。未安装到 iPhone。
+
+边界:
+
+- 当前本机操作支持 macOS;需要运行更新后的夭夭桌面端并完成系统授权。本次未替用户授予权限,未在用户真实桌面执行鼠标/键盘输入。Swift 输入助手已编译,实际 TCC 授权和真实桌面输入仍需授权后验收。
+- 本轮验证了环境传输、浏览器真实动作与工具生命周期,未调用付费模型完成真实 Hermes 模型任务。浏览器暂不支持上传文件选择器、下载、麦克风/摄像头授权或个人 Chrome 配置接入。
+- 云端 Grok Bot 登录、共享虚拟机、定时任务和 Inspector 沿用现有机制。
+
+复现命令:
+
+```sh
+NODE_OPTIONS=--localstorage-file=/tmp/yaoyao-native-unit-localstorage npx vitest run tests/server/desktopEnvironments.test.ts tests/server/serviceInstance.test.ts --maxWorkers=2
+env -u http_proxy -u https_proxy -u all_proxy -u ELECTRON_RUN_AS_NODE NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost node --test desktop/browser-environment.test.mjs
+npm run desktop:build
+env -u http_proxy -u https_proxy -u all_proxy -u ELECTRON_RUN_AS_NODE NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost npx playwright test -c playwright.native-environment.config.ts
+```

BIN
docs/verification/2026-09-10-bot-environments/desktop-browser.png


BIN
docs/verification/2026-09-10-bot-environments/ios-permissions.png


BIN
docs/verification/2026-09-10-bot-environments/phone-browser.png


+ 1 - 0
electron-builder.yml

@@ -25,6 +25,7 @@ extraResources:
       - build-info.json
       - github-release.mjs
       - keychain-helper
+      - computer-helper
       - ui/**/*
       - web-service/**/*
 asar: true

+ 2 - 0
playwright.native-environment.config.ts

@@ -0,0 +1,2 @@
+import {defineConfig,devices} from '@playwright/test'
+export default defineConfig({testDir:'./tests/e2e',testMatch:'native-environment.spec.ts',workers:1,timeout:60000,reporter:'list',use:{baseURL:'http://127.0.0.1:18852',viewport:{width:1440,height:900},trace:'retain-on-failure',screenshot:'only-on-failure'},projects:[{name:'chromium',use:{...devices['Desktop Chrome']}}],webServer:{command:'WORKSPACE_FIXTURE_NATIVE=1 WORKSPACE_FIXTURE_PORT=18852 WORKSPACE_FIXTURE_UPSTREAM_PORT=19152 node --import tsx tests/fixtures/workspace-server.ts',url:'http://127.0.0.1:18852/healthz',reuseExistingServer:false,timeout:30000}})

+ 2 - 0
scripts/build-desktop.mjs

@@ -19,6 +19,7 @@ await build({ entryPoints: [resolve(root, 'src/server/githubReleases.ts')], outf
 const swiftTarget=process.arch==='arm64'?'arm64-apple-macosx12.0':'x86_64-apple-macosx12.0'
 execFileSync('xcrun',['swiftc','-O','-target',swiftTarget,resolve(root,'desktop/keychain-helper.swift'),'-o',resolve(out,'keychain-helper')],{stdio:'inherit'})
 execFileSync('xcrun',['swiftc','-O','-target',swiftTarget,'-D','DEVELOPMENT_HELPER',resolve(root,'desktop/keychain-helper.swift'),'-o',resolve(out,'keychain-helper-dev')],{stdio:'inherit'})
+for(const development of [false,true])execFileSync('xcrun',['swiftc','-O','-target',swiftTarget,...(development?['-D','DEVELOPMENT_HELPER']:[]),resolve(root,'desktop/computer-helper.swift'),'-o',resolve(out,development?'computer-helper-dev':'computer-helper')],{stdio:'inherit'})
 await cp(resolve(root, 'dist'), resolve(out, 'ui'), { recursive: true })
 await cp(resolve(root, 'release.json'), resolve(out, 'release.json'))
 await cp(resolve(root, 'public/icons/icon-512.png'), resolve(root, 'desktop/icon.png'))
@@ -26,6 +27,7 @@ const shell = resolve(out, 'shell')
 await mkdir(shell)
 for (const file of await readdir(resolve(root, 'desktop'))) {
   if (file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png)$/.test(file)) continue
+  if(/\.(js|mjs|cjs)$/.test(file))execFileSync(process.execPath,['--check',resolve(root,'desktop',file)],{stdio:'inherit'})
   await cp(resolve(root, 'desktop', file), resolve(shell, file))
 }
 for (const file of ['boot.html', 'boot.js', 'boot.css', 'preload.cjs']) await readFile(resolve(shell, file))

+ 29 - 0
src/client/components/workspace/BrowserToolbar.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+import {computed,ref,watch} from 'vue'
+import type {BotBrowserState} from '@shared/desktopEnvironment'
+const props=defineProps<{state?:BotBrowserState;enabled:boolean}>(),emit=defineEmits<{action:[action:Record<string,string>]}>()
+const address=ref(''),editing=ref(false),active=computed(()=>props.state?.tabs.find(t=>t.active))
+watch(()=>active.value?.url,url=>{if(!editing.value)address.value=url==='about:blank'?'':url??''},{immediate:true})
+function navigate(){let url=address.value.trim();if(!url)return;if(!/^https?:\/\//i.test(url))url='https://'+url;editing.value=false;emit('action',{kind:'navigate',url})}
+</script>
+<template>
+ <div class="browser-toolbar" aria-label="机器人浏览器">
+  <div class="browser-tabs" role="tablist" aria-label="浏览器标签页">
+   <div v-for="tab in state?.tabs" :key="tab.id" class="tab" :class="{active:tab.active}">
+    <button role="tab" :aria-selected="tab.active" :title="tab.title" :disabled="!enabled" @click="emit('action',{kind:'select-tab',tabId:tab.id})">{{tab.title}}</button>
+    <button class="close-tab" :aria-label="`关闭标签页 ${tab.title}`" :disabled="!enabled" @click="emit('action',{kind:'close-tab',tabId:tab.id})">×</button>
+   </div>
+   <button class="new-tab" aria-label="新建标签页" :disabled="!enabled" @click="emit('action',{kind:'new-tab',url:'https://www.google.com/'})">+</button>
+  </div>
+  <form class="navigation" @submit.prevent="navigate">
+   <button type="button" aria-label="后退" :disabled="!enabled" @click="emit('action',{kind:'back'})">←</button>
+   <button type="button" aria-label="前进" :disabled="!enabled" @click="emit('action',{kind:'forward'})">→</button>
+   <button type="button" aria-label="重新加载页面" :disabled="!enabled" @click="emit('action',{kind:'reload'})">↻</button>
+   <input v-model="address" type="text" inputmode="url" autocomplete="off" spellcheck="false" aria-label="浏览器地址" placeholder="输入网址" :disabled="!enabled" @focus="editing=true" @blur="editing=false">
+   <button type="submit" :disabled="!enabled||!address.trim()">前往</button>
+  </form>
+ </div>
+</template>
+<style scoped>
+.browser-toolbar{min-width:0;background:var(--surface-soft);border-bottom:1px solid var(--line)}.browser-tabs{display:flex;overflow-x:auto;gap:4px;padding:7px 10px 0}.tab{display:flex;align-items:center;min-width:90px;max-width:210px;border:1px solid transparent;border-radius:8px 8px 0 0}.tab.active{background:var(--surface);border-color:var(--line);border-bottom-color:var(--surface)}.tab button:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px}.browser-toolbar button{font:inherit;font-size:12px;min-width:36px;min-height:40px;background:transparent;border:0;color:var(--text-primary);cursor:pointer}.tab .close-tab{min-width:32px}.browser-toolbar button:disabled{opacity:.45;cursor:default}.navigation{display:flex;align-items:center;gap:3px;padding:5px 10px}.navigation input{flex:1;min-width:0;min-height:40px;border:1px solid var(--line);border-radius:8px;padding:6px 10px;background:var(--surface);color:var(--text-primary);font:inherit;font-size:13px}button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}@media(max-width:600px){.navigation{padding:4px}.browser-toolbar button{min-width:40px;min-height:44px}.tab button:first-child{max-width:130px}}
+</style>

+ 15 - 9
src/client/components/workspace/ComputerPanel.vue

@@ -1,6 +1,8 @@
 <script setup lang="ts">
 import {computed,onBeforeUnmount,onMounted,ref,watch} from 'vue'
 import {apiRequest} from '@/api/client'
+import BrowserToolbar from './BrowserToolbar.vue'
+import type {BotBrowserState} from '@shared/desktopEnvironment'
 import {createUuid} from '@/utils/id'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
@@ -8,12 +10,12 @@ import type {JsonValue} from '@shared/types'
 const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;autoTake?:boolean}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[]}>()
 const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
 const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
-const controlsOpen=ref(false)
+const controlsOpen=ref(false),browserState=ref<BotBrowserState>()
 const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
 const error=ref(''),busy=ref(false),text=ref(''),notes=ref(''),connectionOK=ref(false)
 let takeRequestId:string|undefined
 let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
-const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',idle:'虚拟机空闲 · 仅查看',agent:'机器人正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还机器人',error:'电脑需要检查'}
+const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',idle:'电脑空闲 · 仅查看',agent:'机器人正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还机器人',error:'电脑需要检查'}
 const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
 const canSend=computed(()=>connectionOK.value&&!!frame.value&&mine.value&&state.value.mode==='human'&&frame.value?.generation===state.value.generation)
 const controllable=computed(()=>canSend.value&&!busy.value)
@@ -29,11 +31,13 @@ async function refresh(){
     connectionOK.value=true
     if(ticket.value&&status.controlId!==ticket.value.controlId&&status.mode!=='pausing')ticket.value=undefined
     if(mine.value&&Date.now()-lastRenew>8000){await post('/renew',ticket.value);lastRenew=Date.now()}
-    if(status.mode!=='off'){
+    if(status.backend==='browser'){browserState.value=await apiRequest<BotBrowserState>(`/api/app/agents/${encodeURIComponent(selected.value)}/browser`)}else browserState.value=undefined
+    if(status.mode!=='off'&&(status.backend!=='browser'||browserState.value?.open)){
       const image=await apiRequest<ComputerFrame>(base+'/frame')
       if(current===generation&&!closed)frame.value=image
-    }
-  }catch(cause){if(current===generation&&!closed){connectionOK.value=false;error.value=cause instanceof Error?cause.message:'无法读取电脑状态';if([401,403,410].includes(Number((cause as {status?:number}).status))){ticket.value=undefined;frame.value=undefined;state.value={mode:'off'}}}}
+    }else frame.value=undefined
+    error.value=''
+  }catch(cause){if(current===generation&&!closed){connectionOK.value=false;frame.value=undefined;inputs.length=0;error.value=cause instanceof Error?cause.message:'无法读取电脑状态';if([401,403,410].includes(Number((cause as {status?:number}).status))){ticket.value=undefined;frame.value=undefined;state.value={mode:'off'}}}}
   finally{polling=false}
 }
 async function run(action:()=>Promise<void>){
@@ -59,6 +63,7 @@ async function flushInputs(){
   try{await run(async()=>{await post('/input',{...credentials,requestId:createUuid(),generation:image.generation,frameId:image.id,action})});if(error.value)inputs.length=0}
   finally{flushing=false;if(inputs.length)setTimeout(()=>void flushInputs(),0)}
 }
+function browserAction(action:Record<string,string>){if(!canSend.value||!frame.value||!ticket.value)return;const f=frame.value;void run(async()=>{await post('/browser',{...ticket.value,requestId:createUuid(),generation:f.generation,frameId:f.id,action})})}
 function point(event:MouseEvent|PointerEvent){const r=(event.currentTarget as HTMLImageElement).getBoundingClientRect(),f=frame.value!;return {x:Math.min(f.width-1,Math.max(0,Math.floor((event.clientX-r.left)*f.width/r.width))),y:Math.min(f.height-1,Math.max(0,Math.floor((event.clientY-r.top)*f.height/r.height)))}}
 let dragStart:{x:number;y:number;clientX:number;clientY:number}|undefined,dragged=false
 function down(event:PointerEvent){if(!canSend.value||event.button!==0)return;const p=point(event);dragStart={...p,clientX:event.clientX,clientY:event.clientY};(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)}
@@ -83,9 +88,9 @@ defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)
 </script>
 <template>
   <Teleport to="body" :disabled="embedded">
-    <component :is="embedded?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded}" aria-label="隔离电脑" @cancel.prevent="close">
+    <component :is="embedded?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded}" :aria-label="state.backend==='local'?'本机':state.backend==='browser'?'浏览器':'隔离电脑'" @cancel.prevent="close">
       <header>
-        <div><strong>{{state.backend==='grok'?'Grok Bot 云端电脑':'隔离电脑'}}</strong><small>{{ labels[state.mode] }}</small></div>
+        <div><strong>{{state.backend==='grok'?'Grok Bot 云端电脑':state.backend==='local'?'本机 · '+state.hostName:state.backend==='browser'?'浏览器 · '+state.hostName:'隔离电脑'}}</strong><small>{{ labels[state.mode] }}</small></div>
         <select v-if="agents.length>1" v-model="selected" aria-label="选择电脑" :disabled="!!ticket"><option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select>
         <span v-else-if="!embedded">{{ agents[0]?.name }}</span>
         <button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>
@@ -93,8 +98,9 @@ defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)
         <button v-if="embedded&&mine" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">交还</button>
         <button v-if="!embedded" type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
       </header>
+      <BrowserToolbar v-if="state.backend==='browser'" :state="browserState" :enabled="controllable" @action="browserAction"/>
       <div class="computer-screen" :class="{controlling:canSend}">
-        <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" alt="隔离电脑当前画面" draggable="false" tabindex="0" title="点按后可使用键盘、滚轮和拖动;按 Esc 退出键盘控制" @pointerdown="down" @pointerup="up" @pointercancel="dragStart=undefined" @wheel="wheel" @keydown="keydown" @click="click" @contextmenu.prevent="click">
+        <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="state.backend==='local'?'本机当前画面':state.backend==='browser'?'浏览器当前画面':'隔离电脑当前画面'" draggable="false" tabindex="0" title="点按后可使用键盘、滚轮和拖动;按 Esc 退出键盘控制" @pointerdown="down" @pointerup="up" @pointercancel="dragStart=undefined" @wheel="wheel" @keydown="keydown" @click="click" @contextmenu.prevent="click">
         <p v-else>{{ state.mode==='off' ? '打开电脑后可查看桌面。' : '正在读取电脑画面…' }}</p>
         <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
       </div>
@@ -108,7 +114,7 @@ defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)
           <button type="button" @click="controlsOpen=!controlsOpen">{{controlsOpen?'收起键盘':'键盘与操作'}}</button>
           <section v-if="controlsOpen" class="manual-input">
           <div class="key-row"><button v-for="key in ['Return','Tab','Escape','BackSpace','Up','Down','Left','Right']" :key="key" type="button" :disabled="!controllable" @click="send({kind:'key',key})">{{ ({Return:'回车',Escape:'Esc',BackSpace:'退格',Up:'↑',Down:'↓',Left:'←',Right:'→'} as Record<string,string>)[key]||key }}</button></div>
-          <div class="key-row"><button :disabled="!controllable" @click="send({kind:'key',key:'a',modifiers:['ctrl']})">全选</button><button :disabled="!controllable" @click="send({kind:'key',key:'c',modifiers:['ctrl']})">复制</button><button :disabled="!controllable" @click="send({kind:'key',key:'v',modifiers:['ctrl']})">粘贴</button></div>
+          <div class="key-row"><button :disabled="!controllable" @click="send({kind:'key',key:'a',modifiers:[state.backend==='local'||state.backend==='browser'?'super':'ctrl']})">全选</button><button :disabled="!controllable" @click="send({kind:'key',key:'c',modifiers:[state.backend==='local'||state.backend==='browser'?'super':'ctrl']})">复制</button><button :disabled="!controllable" @click="send({kind:'key',key:'v',modifiers:[state.backend==='local'||state.backend==='browser'?'super':'ctrl']})">粘贴</button></div>
           <label>输入到电脑<textarea v-model="text" rows="2" maxlength="16000" placeholder="输入文字后点击发送" :disabled="!controllable" /></label>
           <button type="button" :disabled="!controllable||!text" @click="send({kind:'text',text});text=''">输入文字</button>
           </section>

+ 31 - 8
src/client/components/workspace/LocalVmChatPanel.vue

@@ -2,6 +2,7 @@
 import {ref,computed,watch,onBeforeUnmount} from 'vue'
 import {apiRequest} from '@/api/client'
 import AppIcon from '@/components/common/AppIcon.vue'
+import type {DesktopEnvironmentState,BotBrowserState} from '@shared/desktopEnvironment'
 import GrokAuthPanel from './GrokAuthPanel.vue'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerFrame} from '@shared/computerControl'
@@ -10,15 +11,17 @@ const props=defineProps<{agents:WorkspaceAgent[];isAdmin:boolean;active?:boolean
 const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:WorkspaceAgent];workspace:[agent:WorkspaceAgent]}>()
 const selected=ref(''),state=ref<LocalVmInstance&{enabled:boolean;controlMode?:string}>(),frame=ref<ComputerFrame>(),error=ref(''),busy=ref(false),loading=ref(true)
 const agent=computed(()=>props.agents.find(a=>a.id===selected.value))
+const native=ref<DesktopEnvironmentState>()
+const nativeSelected=computed(()=>native.value?.selected)
 const computerTabs=ref<HTMLElement>(),cloud=ref<{configured:boolean;running:boolean;connected:boolean;mode?:string}>(),connectionSettings=ref(false)
-const cloudSelected=computed(()=>agent.value?.computer==='cloud'||(agent.value?.computer==='auto'&&agent.value.execution!=='computer'&&cloud.value?.configured))
+const cloudSelected=computed(()=>agent.value?.computer==='cloud'||(agent.value?.computer==='auto'&&!nativeSelected.value&&agent.value.execution!=='computer'&&cloud.value?.configured))
 const computerChoice=computed(()=>state.value?.fixedCapacity?(state.value.desktopId??'off'):agent.value?.computer??(state.value?.enabled?'vm':'off'))
 const computerChoices=computed(()=>[
  {value:'auto',label:'自动',description:'按可用环境自动选择',icon:'bolt' as const,disabled:false},
  {value:'cloud',label:'云端 · Grok Bot',description:'共享云端电脑,持续保存工作',icon:'globe' as const,disabled:false},
  ...(state.value?.fixedCapacity?(state.value.desktops??[]).map(desktop=>({value:desktop.id,label:desktop.name,description:desktop.ready?'已有共享桌面':'桌面尚未就绪',icon:'monitor' as const,disabled:desktop.available===false})):[{value:'vm',label:'本地虚拟机',description:'在隔离的本地桌面中工作',icon:'monitor' as const,disabled:false}]),
- {value:'local',label:'本机',description:'当前节点未提供本机控制',icon:'monitor' as const,disabled:true},
- {value:'browser',label:'仅浏览器',description:'尚未连接浏览器',icon:'globe' as const,disabled:true},
+ {value:'local',label:'本机',description:native.value?.host?.name??'请在电脑上打开夭夭桌面端',icon:'monitor' as const,disabled:!native.value?.local.supported},
+ {value:'browser',label:'仅浏览器',description:native.value?.browser.available?'独立浏览器,保存登录资料':'请在电脑上打开夭夭桌面端',icon:'globe' as const,disabled:!native.value?.browser.available},
  {value:'off',label:'不使用电脑',description:'使用基础机器人的其他工具',icon:'stop' as const,disabled:false},
 ])
 const computerChoiceDisabled=computed(()=>loading.value||!state.value||busy.value||state.value.inUse||!!agent.value?.remoteAgentId||!!agent.value?.temporaryGoalId)
@@ -40,6 +43,14 @@ async function refresh(){
  if(closed||props.active===false||!selected.value||document.hidden||busy.value)return
  const version=revision
  try{
+  native.value=await apiRequest<DesktopEnvironmentState>(`/api/app/agents/${selected.value}/desktop-environment`);if(version!==revision||closed)return
+  if(nativeSelected.value){
+   const n=native.value;let ready=n.local.ready
+   if(nativeSelected.value==='browser'){const b=await apiRequest<BotBrowserState>(`/api/app/agents/${selected.value}/browser`);ready=b.open}
+   state.value={enabled:true,container:ready?'running':'stopped',ready,image:true,mode:'shared',maxInstances:1,inUse:false}
+   if(ready){const f=await apiRequest<ComputerFrame>(`/api/app/agents/${selected.value}/computer/frame`);if(version===revision&&!closed)frame.value=f}else frame.value=undefined
+   error.value='';return
+  }
   const cloudState=await apiRequest<typeof cloud.value>(`/api/app/agents/${selected.value}/cloud-computer`);if(version!==revision||closed)return;cloud.value=cloudState
   if(cloudSelected.value){
    state.value={enabled:true,container:cloudState?.running?'running':'stopped',ready:!!cloudState?.connected&&!!cloudState?.running,image:!!cloudState?.configured,mode:'shared',maxInstances:1,inUse:cloudState?.mode==='agent'||cloudState?.mode==='human',controlMode:cloudState?.mode}
@@ -54,7 +65,7 @@ async function refresh(){
 }
 async function run(work:()=>Promise<unknown>){if(busy.value)return;busy.value=true;error.value='';try{await work();emit('changed')}catch(e){error.value=e instanceof Error?e.message:'电脑操作未完成'}finally{busy.value=false;await refresh()}}
 function choose(value:string){
- if(state.value?.fixedCapacity&&!['off','auto','cloud'].includes(value)){
+ if(state.value?.fixedCapacity&&!['off','auto','cloud','local','browser'].includes(value)){
    void run(()=>apiRequest(base(),{method:'PUT',body:{enabled:true,desktopId:value}}));return
  }
  void run(()=>apiRequest(`/api/app/agents/${selected.value}/computer-selection`,{method:'PUT',body:{computer:value}}))
@@ -66,7 +77,7 @@ function action(value:LocalVmAction){
 }
 async function cycle(){const version=revision;await refresh();if(!closed&&version===revision)timer=setTimeout(cycle,3000)}
 watch(()=>props.agents.map(a=>a.id).join(','),()=>{if(!props.agents.some(a=>a.id===selected.value))selected.value=props.agents[0]?.id??''},{immediate:true})
-watch(selected,()=>{revision++;state.value=undefined;cloud.value=undefined;frame.value=undefined;error.value='';loading.value=true;clearTimeout(timer);void cycle()},{immediate:true})
+watch(selected,()=>{revision++;state.value=undefined;native.value=undefined;cloud.value=undefined;frame.value=undefined;error.value='';loading.value=true;clearTimeout(timer);void cycle()},{immediate:true})
 watch(()=>props.active,value=>{if(value)void refresh()})
 onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
 </script>
@@ -77,10 +88,10 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
    <label v-if="agents.length>1" class="agent-select">机器人<select v-model="selected" :disabled="busy"><option v-for="item in agents" :key="item.id" :value="item.id">{{item.name}}</option></select></label>
    <p v-if="!agent">当前聊天没有可用的机器人。</p>
    <template v-else>
-    <div class="screen-caption"><span>{{agent.name}}的电脑</span><small v-if="state?.enabled">{{cloudSelected?'Grok Bot 云端':'本地虚拟机'}}</small></div>
+    <div class="screen-caption"><span>{{agent.name}}的电脑</span><small v-if="state?.enabled">{{nativeSelected?(native?.host?.name??'桌面端离线'):cloudSelected?'Grok Bot 云端':'本地虚拟机'}}</small></div>
     <button class="preview" :disabled="!frame||busy" :aria-label="`打开${agent.name}的桌面`" @click="openDesktop">
      <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="`${agent.name}的电脑画面`">
-     <span v-else class="empty"><AppIcon name="monitor" :size="26"/><span>{{loading?'正在检查电脑…':!state?.enabled?(computerChoice==='auto'?'暂无可用电脑':'未使用电脑'):!state.image?(cloudSelected?'尚未连接 Grok Bot 账号':'本地虚拟机尚未准备'):state.container==='missing'?'尚未创建虚拟机':state.container==='stopped'?'虚拟机已停止':cloudSelected?'打开云端电脑后可查看桌面':'正在连接桌面…'}}</span></span>
+     <span v-else class="empty"><AppIcon name="monitor" :size="26"/><span>{{loading?'正在检查电脑…':nativeSelected?(nativeSelected==='browser'?'点击打开机器人的独立浏览器':native?.local.ready?'正在读取本机画面…':'请完成本机控制授权'):!state?.enabled?(computerChoice==='auto'?'暂无可用电脑':'未使用电脑'):!state.image?(cloudSelected?'尚未连接 Grok Bot 账号':'本地虚拟机尚未准备'):state.container==='missing'?'尚未创建虚拟机':state.container==='stopped'?'虚拟机已停止':cloudSelected?'打开云端电脑后可查看桌面':'正在连接桌面…'}}</span></span>
      <span v-if="frame" class="open-badge"><AppIcon name="external" :size="12"/>打开桌面</span>
     </button>
     <p v-if="error||state?.problem" class="problem" role="alert">{{error||state?.problem}}</p>
@@ -92,7 +103,17 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
     </div>
     <div :id="`computer-options-${selected}`" class="computer-options" role="tabpanel" :aria-labelledby="`computer-tab-${selected}-${computerChoice}`">
     <p v-if="computerChoice==='auto'" class="hint">按已有可用环境自动选择。查看此面板不会创建或唤醒云端电脑。</p>
-    <template v-if="cloudSelected">
+    <template v-if="nativeSelected">
+     <p>{{nativeSelected==='local'?'机器人将查看并操作已连接电脑的真实桌面。':'浏览器在已连接的桌面端运行,机器人和你使用同一组标签页。'}}连接电脑:{{native?.host?.name??'未连接'}}。</p>
+     <template v-if="nativeSelected==='local'">
+      <div class="permission-list"><span>{{native?.local.authorized?'✓':'○'}} 本机控制授权</span><span>{{native?.local.screen?'✓':'○'}} 屏幕录制</span><span>{{native?.local.accessibility?'✓':'○'}} 辅助功能</span></div>
+      <button v-if="!native?.local.ready" class="primary" :disabled="busy||!native?.online||!isAdmin" @click="run(()=>apiRequest(`/api/app/agents/${selected}/desktop-environment/authorize`,{method:'POST',body:{},timeoutMs:125000}))">在桌面端授权</button>
+      <p v-if="!native?.local.ready" class="hint">在连接的 Mac 上确认授权,并按系统提示开启权限;系统可能要求重新打开 App。</p>
+     </template>
+     <label v-else>浏览器资料<select :value="native?.browser.profile" :disabled="busy" @change="run(()=>apiRequest(`/api/app/agents/${selected}/browser-profile`,{method:'PUT',body:{profile:($event.target as HTMLSelectElement).value}}))"><option value="persistent">此机器人的浏览器 · 保存登录</option><option value="temporary">临时浏览器 · 退出桌面端后清除</option></select></label>
+     <button class="primary" :disabled="busy||!native?.online||(nativeSelected==='local'&&!native?.local.ready)" @click="openDesktop">{{nativeSelected==='browser'?'打开浏览器':'接管本机'}}</button>
+    </template>
+    <template v-else-if="cloudSelected">
      <p>多个机器人共用同一台 Grok Bot 云端虚拟机、工作文件和浏览器登录。基础机器人和聊天记录保持独立。</p>
      <button v-if="cloud?.configured" class="primary" :disabled="busy" @click="openDesktop">{{state?.controlMode==='human'?'打开控制中的桌面':'接管云端电脑'}}</button>
      <button @click="connectionSettings=!connectionSettings">{{cloud?.configured?'云端连接设置':'连接 Grok Bot'}}</button>
@@ -134,3 +155,5 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
 <style scoped>.cloud-settings{display:grid;gap:10px;padding:14px;border:1px solid var(--line);border-radius:10px}.cloud-settings input{min-height:44px;font:inherit;color:var(--text-primary);background:var(--surface-raised);border:1px solid var(--line);border-radius:8px;padding:8px;min-width:0}.computer-tabs{display:grid;grid-template-columns:1fr 1fr;overflow:visible}.computer-tabs button{text-align:left;justify-content:flex-start;white-space:normal;min-height:48px}</style>
 
 <style scoped>.computer-tabs{gap:8px;border:0;padding:0;background:transparent}.computer-tabs button{display:flex;flex-direction:column;align-items:flex-start;gap:7px;padding:12px;border:1px solid var(--line);background:var(--surface-soft);min-height:82px}.computer-tabs button span{display:flex;align-items:center;gap:6px;font-weight:500}.computer-tabs button small{font-size:11px;line-height:1.5;color:var(--text-muted)}.computer-tabs button[aria-selected=true]{border-color:var(--accent);background:var(--surface-raised);box-shadow:none}.computer-tabs button:disabled{opacity:.55}</style>
+
+<style scoped>.permission-list{display:grid;gap:8px;font-size:13px;color:var(--text-secondary)}</style>

+ 9 - 1
src/server/app.ts

@@ -1,3 +1,4 @@
+import {DesktopEnvironments} from './desktopEnvironments.js'
 import type {GrokAuth} from './grokAuth.js'
 import {GrokCloud} from './grokCloud.js'
 import {WorkspaceInspector} from './workspaceInspector.js'
@@ -101,6 +102,7 @@ export interface ApplicationRuntime {
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
   chatCache?: ChatCacheCoordinator
+  desktopEnvironments:DesktopEnvironments
   grokAuth:GrokAuth
   workspaceRoutines:WorkspaceRoutines
   localVm: LocalVmService
@@ -229,6 +231,10 @@ export function createApplication(options: ApplicationOptions = {}): Application
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
   const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers,options.grokFetch)
+  const desktopEnvironments=new DesktopEnvironments(workspace,auth,workspaceNodes)
+  workspaceRuntime.desktopEnvironments=desktopEnvironments
+  grokCloud.preferDesktop=(owner,agent)=>!!desktopEnvironments.selected(owner,agent)
+  grokCloud.beforeSelection=(owner,id)=>desktopEnvironments.assertIdle(owner,id)
   workspaceRuntime.cloud=grokCloud
   const workspaceInspector=new WorkspaceInspector(workspace,auth)
   workspaceRuntime.inspector=workspaceInspector
@@ -405,7 +411,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     } else await next()
   })
 
-  for(const router of [grokCloud.authorization.router(),grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router()]){app.use(router.routes());app.use(router.allowedMethods())}
+  for(const router of [desktopEnvironments.router(),grokCloud.authorization.router(),grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router()]){app.use(router.routes());app.use(router.allowedMethods())}
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
   const localVmRouter=localVm.router();app.use(localVmRouter.routes());app.use(localVmRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
@@ -472,6 +478,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     workspace,
     workspaceRuntime,
     workspaceRoutines,
+    desktopEnvironments,
     grokAuth:grokCloud.authorization,
     realtime,
     app,
@@ -495,6 +502,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     close: () => {
       runners.close()
       workspaceAssets.close()
+      desktopEnvironments.close()
       grokCloud.authorization.close()
       workspaceRoutines.close()
       workspaceRuntime.close()

+ 149 - 0
src/server/desktopEnvironments.ts

@@ -0,0 +1,149 @@
+import Router from '@koa/router'
+import type Koa from 'koa'
+import {createHash,randomBytes,randomUUID,timingSafeEqual} from 'node:crypto'
+import {z} from 'zod'
+import {HttpError} from './errors.js'
+import {parse,type WorkspaceStore} from './workspaceStore.js'
+import type {LocalAuthStore} from './localAuth.js'
+import type {WorkspaceNodes,GatewayTarget} from './workspaceGateway.js'
+import type {WorkspaceAgent} from '../shared/workspace.js'
+import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
+import {requireTeamToolBridge} from './workspaceToolLease.js'
+
+const digest=(s:string)=>createHash('sha256').update(s).digest('hex')
+const inputSchema=z.discriminatedUnion('kind',[
+ z.object({kind:z.literal('click'),x:z.number().int().min(0).max(16384),y:z.number().int().min(0).max(16384),button:z.enum(['left','right','middle']).optional(),count:z.number().int().min(1).max(2).optional()}).strict(),
+ z.object({kind:z.literal('drag'),fromX:z.number().int().min(0).max(16384),fromY:z.number().int().min(0).max(16384),toX:z.number().int().min(0).max(16384),toY:z.number().int().min(0).max(16384)}).strict(),
+ z.object({kind:z.literal('text'),text:z.string().max(16000)}).strict(),
+ z.object({kind:z.literal('key'),key:z.string().regex(/^[A-Za-z0-9_+ -]{1,40}$/),modifiers:z.array(z.enum(['ctrl','alt','shift','super'])).max(4).optional()}).strict(),
+ z.object({kind:z.literal('scroll'),direction:z.enum(['up','down','left','right']),amount:z.number().int().min(1).max(50).optional()}).strict(),
+])
+const browserSchema=z.discriminatedUnion('kind',[
+ z.object({kind:z.enum(['navigate','new-tab']),url:z.string().max(4096).refine(value=>{try{const u=new URL(value);return ['http:','https:'].includes(u.protocol)&&!u.username&&!u.password&&!/[\\\x00-\x20]/.test(value)}catch{return false}},'请输入完整的 http 或 https 地址')}).strict(),
+ z.object({kind:z.enum(['select-tab','close-tab']),tabId:z.string().uuid()}).strict(),
+ z.object({kind:z.enum(['back','forward','reload','snapshot'])}).strict(),
+ z.object({kind:z.enum(['click','fill']),ref:z.string().regex(/^e[0-9]+$/),snapshotId:z.string().uuid(),text:z.string().max(16000).optional()}).strict(),
+])
+type Mode='local'|'browser'
+interface NativeHost {id:string;name:string;platform:string;screen:boolean;accessibility:boolean;approved: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 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>}>}
+const props={kind:{type:'string'},url:{type:'string'},tabId:{type:'string'},ref:{type:'string'},snapshotId:{type:'string'},text:{type:'string'}}
+export const DESKTOP_ENVIRONMENT_TOOLS=[
+ {id:'desktop_environment_view',name:'desktop_environment_view',description:'查看当前已选电脑的真实截图,返回截图尺寸、环境类型和连接的电脑名称。',inputSchema:{type:'object',properties:{},additionalProperties:false}},
+ {id:'desktop_environment_action',name:'desktop_environment_action',description:'操作当前已选电脑。必须先查看新截图;kind 为 click、drag、text、key 或 scroll,坐标使用截图像素。',inputSchema:{type:'object',properties:{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_browser',name:'desktop_browser',description:'操作机器人的独立浏览器。kind: navigate/new-tab(url)、select-tab/close-tab(tabId)、back/forward/reload、snapshot(读取页面元素与 ref)、click/fill(ref,snapshotId,text)。只在仅浏览器环境可用。',inputSchema:{type:'object',properties:props,required:['kind'],additionalProperties:false}},
+]
+export const DESKTOP_ENVIRONMENT_RULES='当前环境由用户连接的夭夭桌面端提供。使用 desktop_environment_view/action 查看和操作该环境;本机模式会影响这台真实电脑,遵循用户授权范围。仅浏览器模式使用独立浏览器,desktop_browser 可导航、查看页面及操作元素。先查看再操作,页面变化后重新获取截图或 snapshot;不得猜测旧坐标或 ref。人工接管时等待交还,按交还说明继续。电脑断开时报告原因,不得宣称操作成功。'
+
+/** The native transport is admitted only by serviceInstance's private loopback
+ * capability. Browser/desktop authority never crosses into the renderer. */
+export class DesktopEnvironments {
+ private host?:NativeHost
+ private seen=0
+ private pending=new Map<string,Pending>()
+ private queue=new Map<string,Promise<unknown>>()
+ private manual=new Map<string,Manual>()
+ private paused=new Set<string>()
+ private frames=new Map<string,ComputerFrame>()
+ private generation=new Map<string,number>()
+ private notes=new Map<string,string>()
+ private toolFrames=new WeakMap<AbortSignal,string>()
+ private activity=new Map<string,number>()
+ private authorizing=new Map<string,Promise<unknown>>()
+ private closed=false
+ constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes){}
+ get online(){return !this.closed&&!!this.host&&Date.now()-this.seen<8000}
+ get idleForUpdate(){for(const key of this.manual.keys())this.current(key);return !this.pending.size&&!this.queue.size&&!this.manual.size}
+ private ownerKey(owner:string){return digest((this.nodes.localNodeID??this.nodes.local.url.toString())+':'+owner)}
+ private ready(owner:string){return this.online&&this.host!.platform==='darwin'&&this.host!.screen&&this.host!.accessibility&&this.host!.approved.includes(this.ownerKey(owner))}
+ selected(owner:string,agent:WorkspaceAgent):Mode|undefined {if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)return undefined;if(agent.computer==='local'||agent.computer==='browser')return agent.computer;if(agent.computer==='auto'&&agent.execution!=='computer'&&this.ready(owner))return 'local';return undefined}
+ 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||a.temporaryGoalId)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'))}
+ state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent);return {online:this.online,host:this.online?{name:this.host!.name,platform:this.host!.platform}:null,local:{supported:this.online&&this.host!.platform==='darwin',authorized:this.online&&this.host!.approved.includes(this.ownerKey(owner)),screen:this.online&&this.host!.screen,accessibility:this.online&&this.host!.accessibility,ready:this.ready(owner)},browser:{available:this.online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent)}}
+ private check(owner:string,agentId:string,mode:Mode,epoch:string,version:number){const a=this.agent(owner,agentId);if(!this.online||this.host!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||this.selected(owner,a)!==mode)throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');if(mode==='local'&&!this.ready(owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
+ private invalidate(){for(const p of this.pending.values()){if(p.sent&&['input','browser'].includes(String(p.command.operation)))this.paused.add(String(p.command.resource));clearTimeout(p.timer);p.reject(new HttpError(409,'桌面连接已改变;执行结果未确认,不会重试。','desktop_disconnected'))}this.pending.clear();this.frames.clear();for(const [key,m] of this.manual){this.paused.add(key);this.store.remove('_system','computer-control',m.id)}this.manual.clear()}
+ close(){this.closed=true;this.invalidate()}
+ async bridge(ctx:Koa.Context){
+  if(ctx.method!=='POST'){ctx.status=405;return}
+  let bytes=0;const chunks:Buffer[]=[];for await(const chunk of ctx.req){bytes+=chunk.length;if(bytes>24*1024*1024)throw new HttpError(413,'桌面响应过大','desktop_limit');chunks.push(Buffer.from(chunk))}
+  let json:unknown;try{json=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'桌面请求格式无效','desktop_invalid')}
+  ctx.body=this.exchange(json)
+ }
+ exchange(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)}).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)
+  if(this.closed)throw new HttpError(503,'服务已停止','desktop_closed')
+  if(this.host&&(this.host.id!==value.host.id||!this.online))this.invalidate()
+  this.host=value.host;this.seen=Date.now()
+  for(const result of value.results){const p=this.pending.get(result.id);if(!p||p.epoch!==value.host.id)continue;this.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 this.pending.values()){if(p.sent)continue;try{p.check();p.sent=true;commands.push({id:p.id,deadline:p.deadline,...p.command})}catch(error){this.pending.delete(p.id);clearTimeout(p.timer);p.reject(error as Error)}if(commands.length>=8)break}
+  return {commands}
+ }
+ private command(command:Record<string,unknown>,check:()=>void,timeout=30000):Promise<any>{
+  check();if(!this.online)throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
+  if(this.pending.size>=64)throw new HttpError(429,'桌面操作过多,请稍后重试','desktop_busy')
+  return new Promise((resolve,reject)=>{const id=randomUUID(),deadline=Date.now()+timeout,timer=setTimeout(()=>{this.pending.delete(id);if(['input','browser'].includes(String(command.operation)))this.paused.add(String(command.resource));reject(new HttpError(504,'桌面操作未确认,不会自动重试。','desktop_timeout'))},timeout);timer.unref();this.pending.set(id,{id,epoch:this.host!.id,deadline,command,check,resolve,reject,timer,sent:false})})
+ }
+ private context(owner:string,agent:WorkspaceAgent,mode:Mode){const epoch=this.host?.id??'',version=this.auth.pushAuthorizationVersion(owner)??0,profile=agent.browserProfile??'persistent',resource=this.resource(owner,agent,mode);return {key:resource,check:()=>{this.check(owner,agent.id,mode,epoch,version);if((this.agent(owner,agent.id).browserProfile??'persistent')!==profile)throw new HttpError(410,'浏览器资料已切换','computer_control_expired')},command:{mode,owner:this.ownerKey(owner),resource,profile}}}
+ private current(key:string){const m=this.manual.get(key);if(m&&(!this.online||m.epoch!==this.host!.id||m.expiresAt<=Date.now()||this.auth.pushAuthorizationVersion(m.owner)!==m.version)){this.manual.delete(key);this.paused.add(key);this.frames.delete(key);this.store.remove('_system','computer-control',m.id);return undefined}return m}
+ private serial<T>(key:string,work:()=>Promise<T>){const next=(this.queue.get(key)??Promise.resolve()).catch(()=>{}).then(work);this.queue.set(key,next);void next.finally(()=>{if(this.queue.get(key)===next)this.queue.delete(key)}).catch(()=>{});return next}
+ private async capture(c:ReturnType<DesktopEnvironments['context']>){const cached=this.frames.get(c.key);c.check();if(cached&&cached.generation===(this.generation.get(c.key)??0)&&Date.now()-cached.capturedAt<700)return cached;const generation=this.generation.get(c.key)??0
+  const value=await this.command({...c.command,operation:'view'},c.check)
+  const image=parse(z.object({data:z.string().min(1).max(22*1024*1024),width:z.number().int().min(1).max(16384),height:z.number().int().min(1).max(16384)}).passthrough(),value)
+  if(generation!==(this.generation.get(c.key)??0))throw new HttpError(409,'控制状态改变,请刷新画面','computer_frame_stale')
+  const frame={...image,id:randomUUID(),generation,capturedAt:Date.now()};this.frames.set(c.key,frame);return frame
+ }
+ private status(owner:string,a:WorkspaceAgent,mode:Mode):ComputerControlStatus {const key=this.resource(owner,a,mode),m=this.current(key);return {backend:mode,hostName:this.online?this.host!.name:undefined,mode:!this.online?'off':m?(m.owner===owner&&m.ready?'human':'pausing'):this.paused.has(key)?'error':this.activity.get(key)?'agent':'idle',controlId:m?.owner===owner?m.id:undefined,generation:this.generation.get(key)??0,canResume:!!this.activity.get(key),error:!this.online?'请在电脑上打开夭夭桌面端':this.paused.has(key)?'接管已断开,请重新接管并交还后继续':undefined}}
+ assertIdle(owner:string,agentId:string){const a=this.agent(owner,agentId),mode=this.selected(owner,a);if(mode&&this.current(this.resource(owner,a,mode)))throw new HttpError(409,'请先交还电脑控制权','computer_busy')}
+ async requireAvailable(owner:string,a:WorkspaceAgent,target:GatewayTarget){const mode=this.selected(owner,a);if(!mode)throw new HttpError(409,'当前未选择桌面环境','desktop_unselected');this.context(owner,a,mode).check();await requireTeamToolBridge(target,a.profile)}
+ async call(owner:string,agentId:string,id:string,input:unknown,signal:AbortSignal,assertActive:()=>void,expected:Mode,epoch:string){
+  const a=this.agent(owner,agentId),c=this.context(owner,a,expected),check=()=>{signal.throwIfAborted();assertActive();c.check();if(this.host!.id!==epoch)throw new HttpError(410,'桌面连接已改变','desktop_disconnected')};check()
+  this.activity.set(c.key,(this.activity.get(c.key)??0)+1)
+  try{return await this.serial(c.key,async()=>{while(this.current(c.key)||this.paused.has(c.key)){check();await new Promise(resolve=>setTimeout(resolve,150))}check()
+   let result:any
+   if(id==='desktop_environment_view'){if(expected==='browser')await this.command({...c.command,operation:'open'},check);const frame=await this.capture({...c,check});this.toolFrames.set(signal,frame.id);result={content:[{type:'image',mimeType:'image/png',data:frame.data},{type:'text',text:JSON.stringify({width:frame.width,height:frame.height,host:this.host!.name,mode:expected})}]}}
+   else if(id==='desktop_browser'&&expected==='browser'){result=await this.command({...c.command,operation:'browser',action:parse(browserSchema,input)},check);this.frames.delete(c.key)}
+   else if(id==='desktop_environment_action'){const frame=this.frames.get(c.key);if(!frame||this.toolFrames.get(signal)!==frame.id||Date.now()-frame.capturedAt>15000)throw new HttpError(409,'请先查看最新电脑画面','computer_frame_stale');result=await this.command({...c.command,operation:'input',action:parse(inputSchema,input),frame:{width:frame.width,height:frame.height}},check);this.frames.delete(c.key)}
+   else throw new HttpError(404,'当前环境不支持这个工具','tool_not_found')
+   const note=this.notes.get(owner+':'+agentId);if(note){this.notes.delete(owner+':'+agentId);if(result.content)result.content.push({type:'text',text:'人工交还说明:'+note});else result={...result,handoffNote:note}}return result
+  })}finally{this.activity.set(c.key,Math.max(0,(this.activity.get(c.key)??1)-1))}
+ }
+ get epoch(){return this.host?.id??''}
+ private grant(owner:string,a:WorkspaceAgent,c:ReturnType<DesktopEnvironments['context']>,body:any){const m=this.current(c.key);const token=typeof body?.token==='string'?body.token:'';if(!m||!m.ready||m.owner!==owner||m.agentId!==a.id||m.id!==body?.controlId||!timingSafeEqual(Buffer.from(digest(token),'hex'),Buffer.from(digest(m.token),'hex')))throw new HttpError(410,'控制权已失效,请重新接管','computer_control_expired');c.check();return m}
+ router(){const router=new Router()
+  router.get('/api/app/agents/:id/desktop-environment',ctx=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);this.nodes.requireSource(owner,a);ctx.set('Cache-Control','no-store');ctx.body=this.state(owner,a)})
+  router.post('/api/app/agents/:id/desktop-environment/authorize',async ctx=>{const user=this.auth.require(ctx);this.auth.requireAdmin(ctx);this.agent(user.id,ctx.params.id);const owner=this.ownerKey(user.id),epoch=this.epoch;let pending=this.authorizing.get(user.id);if(!pending){pending=this.command({operation:'authorize',owner,account:user.username},()=>{this.auth.requireAdmin(ctx);if(!this.online||epoch!==this.epoch)throw new HttpError(409,'桌面端已断开','desktop_offline')},120000).finally(()=>this.authorizing.delete(user.id));this.authorizing.set(user.id,pending)}ctx.body=await pending})
+  router.put('/api/app/agents/:id/browser-profile',ctx=>{const owner=this.auth.require(ctx).id,a=this.agent(owner,ctx.params.id),body=parse(z.object({profile:z.enum(['persistent','temporary'])}).strict(),(ctx.request as any).body);this.assertIdle(owner,a.id);this.store.updateAgent(owner,a.id,{browserProfile:body.profile});ctx.body={ok:true}})
+  router.get('/api/app/agents/:id/browser',async ctx=>{const owner=this.auth.require(ctx).id,a=this.agent(owner,ctx.params.id);if(this.selected(owner,a)!=='browser')throw new HttpError(409,'请先选择仅浏览器环境','desktop_unselected');const c=this.context(owner,a,'browser');ctx.body=await this.command({...c.command,operation:'browser-state'},c.check)})
+  router.get('/api/app/agents/:id/computer',async(ctx,next)=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);ctx.body=this.status(owner,a,mode)})
+  router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);ctx.set('Cache-Control','no-store');ctx.body=await this.capture(this.context(owner,a,mode))})
+  router.post('/api/app/agents/:id/computer/:action',async(ctx,next)=>{
+   const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);const c=this.context(owner,a,mode),body=(ctx.request as any).body,action=ctx.params.action;c.check()
+   if(action==='take'){
+    const {requestId}=parse(z.object({requestId:z.string().uuid()}).strict(),body),old=this.current(c.key)
+    if(old){if(old.owner!==owner||old.agentId!==a.id||old.requestId!==requestId)throw new HttpError(409,'已有页面正在控制这台电脑','computer_busy');while(!old.ready){c.check();if(this.current(c.key)!==old)throw new HttpError(410,'接管已失效','computer_control_expired');await new Promise(resolve=>setTimeout(resolve,80))}ctx.body={...this.status(owner,a,mode),token:old.token};return}
+    const m:Manual={id:randomUUID(),token:randomBytes(32).toString('base64url'),requestId,owner,agentId:a.id,version:this.auth.pushAuthorizationVersion(owner)??0,expiresAt:Date.now()+90000,generation:(this.generation.get(c.key)??0)+1,epoch:this.epoch,actions:new Map()};this.manual.set(c.key,m);this.generation.set(c.key,m.generation);this.frames.delete(c.key)
+    this.store.put('_system','computer-control',m.id,{owner,agentId:a.id,environmentId:'desktop:'+c.key,expiresAt:m.expiresAt})
+    // The lane may contain agent calls waiting for this takeover. Only await
+    // commands already dispatched to the host, not the whole agent lane.
+    while([...this.pending.values()].some(p=>p.command.resource===c.key)){c.check();if(this.current(c.key)!==m)throw new HttpError(410,'接管已失效','computer_control_expired');await new Promise(resolve=>setTimeout(resolve,80))}
+    try{await this.command({...c.command,operation:'open'},c.check);m.ready=true;m.expiresAt=Date.now()+30000;ctx.body={...this.status(owner,a,mode),token:m.token}}catch(error){this.manual.delete(c.key);this.paused.add(c.key);this.store.remove('_system','computer-control',m.id);throw error}return
+   }
+   const m=this.grant(owner,a,c,body)
+   if(action==='renew'){m.expiresAt=Date.now()+30000;this.store.put('_system','computer-control',m.id,{owner,agentId:a.id,environmentId:'desktop:'+c.key,expiresAt:m.expiresAt});ctx.body={ok:true};return}
+   if(action==='giveback'){m.releasing=true;await m.pending?.catch(()=>{});if(typeof body.notes==='string')this.notes.set(owner+':'+a.id,body.notes.slice(0,4000));this.manual.delete(c.key);this.paused.delete(c.key);this.store.remove('_system','computer-control',m.id);this.generation.set(c.key,m.generation+1);this.frames.delete(c.key);ctx.body={ok:true};return}
+   if(action==='input'||action==='browser'){
+    const v=parse(z.object({controlId:z.string(),token:z.string(),requestId:z.string().uuid(),generation:z.number().int(),frameId:z.string().uuid(),action:z.unknown()}).strict(),body),fingerprint=JSON.stringify([action,v.generation,v.frameId,v.action]),old=m.actions.get(v.requestId)
+    if(old){if(old.fingerprint!==fingerprint)throw new HttpError(409,'请求编号冲突','idempotency_conflict');ctx.body=await old.value;return}
+    const f=this.frames.get(c.key);if(!f||f.id!==v.frameId||f.generation!==v.generation||m.generation!==v.generation||Date.now()-f.capturedAt>10000)throw new HttpError(409,'画面已过期,请刷新后操作','computer_frame_stale')
+    if(m.releasing||m.actions.size>=512)throw new HttpError(409,'请交还后重新接管','computer_control_expired')
+    if(action==='browser'&&mode!=='browser')throw new HttpError(409,'当前环境不是浏览器','desktop_unselected')
+    const input=(action==='browser'?parse(browserSchema,v.action):parse(inputSchema,v.action)),check=()=>{this.grant(owner,a,c,body);if(this.current(c.key)!==m)throw new HttpError(410,'控制权已失效','computer_control_expired')}
+    const result=(m.pending??Promise.resolve()).catch(()=>{}).then(()=>this.command({...c.command,operation:action==='browser'?'browser':'input',action:input,frame:{width:f.width,height:f.height}},check));m.pending=result;m.actions.set(v.requestId,{fingerprint,value:result});ctx.body=await result;this.frames.delete(c.key);return
+   }
+   throw new HttpError(404,'电脑操作不存在','not_found')
+  })
+  return router
+ }
+}

+ 5 - 3
src/server/grokCloud.ts

@@ -59,8 +59,10 @@ export class GrokCloud {
       },
     })
   }
+  preferDesktop?:(owner:string,agent:WorkspaceAgent)=>boolean
+  beforeSelection?:(owner:string,agentId:string)=>void
   configured(owner:string){return !!this.store.get(owner,'grok-cloud','connection')}
-  selected(owner:string,agent:WorkspaceAgent){return agent.computer==='cloud'||(agent.computer==='auto'&&agent.execution!=='computer'&&this.configured(owner))}
+  selected(owner:string,agent:WorkspaceAgent){return agent.computer==='cloud'||(agent.computer==='auto'&&agent.execution!=='computer'&&!this.preferDesktop?.(owner,agent)&&this.configured(owner))}
   private credential(owner:string):Credential {const row=this.store.get<{sealed:string}>(owner,'grok-cloud','connection');if(!row)throw new HttpError(409,'请先连接 Grok Bot 云端账号','grok_cloud_not_configured');return this.nodes.open<Credential>(row.sealed)}
   private agent(owner:string,id:string){const agent=this.store.require<WorkspaceAgent>(owner,'agent',id);if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'此机器人不能连接云端电脑','grok_agent_unavailable');this.nodes.requireSource(owner,agent);return agent}
   private authorize(owner:string,agentId:string){const agent=this.agent(owner,agentId);if(!this.selected(owner,agent))throw new HttpError(410,'机器人已切换电脑','computer_control_expired');return agent}
@@ -180,8 +182,8 @@ export class GrokCloud {
       if(!token)throw new HttpError(400,'请先通过浏览器完成 Grok Bot 授权。','grok_login_missing');ctx.body=await this.configure(owner,token,body.version,refreshToken)
     })
     router.put('/api/app/agents/:id/computer-selection',async ctx=>{
-      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),body=parse(z.object({computer:z.enum(['auto','cloud','vm','off'])}).strict(),(ctx.request as any).body)
-      this.shared.assertLocalVmIdle(owner,[agent.id]);if(this.current(owner))throw new HttpError(409,'请先交还云端电脑','computer_busy')
+      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),body=parse(z.object({computer:z.enum(['auto','cloud','vm','local','browser','off'])}).strict(),(ctx.request as any).body)
+      this.beforeSelection?.(owner,agent.id);this.shared.assertLocalVmIdle(owner,[agent.id]);if(this.current(owner))throw new HttpError(409,'请先交还云端电脑','computer_busy')
       if(body.computer==='cloud')await this.requireAvailable(owner,agent,this.nodes.target(owner,agent.nodeId))
       this.store.atomic(()=>{const execution=body.computer==='vm'?'computer':body.computer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId)this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{computer:body.computer,execution});const current=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,current);this.store.put(owner,'agent',agent.id,current)})
       if(body.computer==='cloud')await this.connect(owner)

+ 1 - 1
src/server/index.ts

@@ -26,7 +26,7 @@ let closeFrontend = async (): Promise<void> => undefined
 // Native update admission runs before routes (including streamed requests).
 // The capability is loopback-only and never shared with the Web renderer.
 runtime.app.middleware.unshift(instance.middleware(shutdown, () => runtime.realtime.broker.idleForUpdate
-  && runtime.runners.idleForUpdate && runtime.workspaceRuntime.idleForUpdate))
+  && runtime.runners.idleForUpdate && runtime.workspaceRuntime.idleForUpdate && runtime.desktopEnvironments.idleForUpdate, ctx=>runtime.desktopEnvironments.bridge(ctx)))
 
 if (runtime.config.production) {
   const dist = resolve(process.env.HERMES_YAOYAO_STATIC_DIR || resolve(process.cwd(), 'dist'))

+ 3 - 2
src/server/serviceInstance.ts

@@ -60,9 +60,9 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
       } catch { /* Never remove a lock that can no longer be identified. */ }
       finally { lock.close() }
     },
-    middleware(shutdown: () => Promise<void>, idle: () => boolean = () => true): Koa.Middleware {
+    middleware(shutdown: () => Promise<void>, idle: () => boolean = () => true, environment?: (ctx:Koa.Context)=>Promise<void>): Koa.Middleware {
       return async (ctx, next) => {
-        const control = ctx.path === '/desktop/service' || ctx.path === '/desktop/service/quiesce'
+        const control = ctx.path === '/desktop/environment' || ctx.path === '/desktop/service' || ctx.path === '/desktop/service/quiesce'
         if (!control) {
           if(stopping){ctx.status=503;ctx.body={error:'后台服务正在停止',code:'service_stopping'};return}
           if (quiesced() && !['/healthz', '/readyz', '/api/status'].includes(ctx.path)) {
@@ -79,6 +79,7 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
         const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(ctx.req.socket.remoteAddress ?? '')
         if (!local || ctx.get('origin') || !timingSafeEqual(received, expected)) { ctx.status = 403; ctx.body = { error: '桌面服务授权无效' }; return }
         ctx.set('Cache-Control', 'no-store')
+        if(ctx.path==='/desktop/environment'){if(environment){try{await environment(ctx)}catch(error){ctx.status=(error as any).status??500;ctx.body={error:ctx.status<500?(error as Error).message:'桌面连接请求失败',code:(error as any).code??'desktop_error'}}}else ctx.status=404;return}
         if (ctx.path.endsWith('/quiesce')) {
           if (ctx.method === 'POST') {
             if (pendingMutations || !idle()) { ctx.status = 409; ctx.body = { error: '正在等待任务完成', code: 'service_busy' }; return }

+ 8 - 3
src/server/workspaceRuntime.ts

@@ -1,3 +1,4 @@
+import {DESKTOP_ENVIRONMENT_TOOLS,DESKTOP_ENVIRONMENT_RULES,type DesktopEnvironments} from './desktopEnvironments.js'
 import {GROK_COMPUTER_TOOLS,GROK_COMPUTER_RULES,type GrokCloud} from './grokCloud.js'
 import { randomUUID } from 'node:crypto'
 import { readFileSync } from 'node:fs'
@@ -64,6 +65,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   get idleForUpdate(): boolean {
     return this.live.size === 0 && this.executing.size === 0 && !this.store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') LIMIT 1").get()
   }
+  desktopEnvironments?:DesktopEnvironments
   cloud?:GrokCloud
   inspector?:import('./workspaceInspector.js').WorkspaceInspector
   private live = new Map<string, LiveTurn>()
@@ -542,11 +544,13 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         this.nodes.requireSource(owner, agent)
         const team=agent.canManageTeam===true&&!this.store.require<Run>(owner,'run',run.runId).assignmentId
         const cloud=!!this.cloud?.selected(owner,agent)
-        if(team||cloud){
+        const desktop=this.desktopEnvironments?.selected(owner,agent),desktopEpoch=this.desktopEnvironments?.epoch??''
+        if(team||cloud||desktop){
           if(team){
             const granted=this.getWork(owner,run.id);granted.teamManagementRevision=agent.revision;this.saveWork(owner,granted)
             await this.teamTools.requireAvailable(owner,agent)
           }
+          if(desktop)await this.desktopEnvironments!.requireAvailable(owner,agent,target)
           if(cloud)await this.cloud!.requireAvailable(owner,agent,target)
           const assertActive=()=>{
             if(settled||this.closing||toolController.signal.aborted)throw new Error('运行已停止')
@@ -556,8 +560,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           toolLease=await createWorkspaceToolLease({
             target,profile:agent.profile,workId:run.id,signal:toolController.signal,
             session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
-            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(cloud?GROK_COMPUTER_TOOLS:[])],
-            call:async(toolId,args)=>{assertActive();return toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):this.teamTools.call(owner,run.id,toolId,args)},
+            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(cloud?GROK_COMPUTER_TOOLS:[]),...(desktop?DESKTOP_ENVIRONMENT_TOOLS.filter(t=>desktop==='browser'||t.id!=='desktop_browser'):[])],
+            call:async(toolId,args)=>{assertActive();return toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,desktop!,desktopEpoch):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):this.teamTools.call(owner,run.id,toolId,args)},
             onFailure:error=>{if(!settled)void gateway.rpc('session.interrupt',{session_id:runtimeId}).catch(()=>{}).finally(()=>finish(error))},
           })
           await toolLease.bind();if(settled)return await completion
@@ -603,6 +607,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           goal ? `当前团队目标 ID:${goal.id}。目标:${goal.objective.slice(0,8000)}\n验收要求:${goal.acceptanceCriteria.join(';')}\n${assignmentId ? `你在执行子任务 ${assignmentId},请完成分派并提交结果,不要扩大团队或再次委派。` : '优先使用 workspace_assign_task 进行有依赖和验收要求的结构化分派;成员结果需要通过 workspace_review_assignment 复核。最终使用 workspace_finish_team_task 记录完成、受阻或等待用户。不要将一次模型回复结束当作目标完成。'}` : '',
           agent.temporaryGoalId ? `你是当前任务的临时助手,任务 ID:${agent.temporaryGoalId}。仅处理分派工作,使用 computer_export 回传产物。任务结束后会退役;不要创建团队或改变自身权限。` : '',
           team ? TEAM_TOOL_RULES : '',
+          desktop ? DESKTOP_ENVIRONMENT_RULES : '',
           cloud ? GROK_COMPUTER_RULES : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
         ]

+ 5 - 3
src/server/workspaceStore.ts

@@ -39,7 +39,8 @@ export const agentInput = z
     avatar: avatar.default(''),
     instructions: z.string().max(24_000).default(''),
     execution:z.enum(['profile','computer']).default('profile'),
-    computer:z.enum(['auto','cloud','vm','off']).optional(),
+    computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
+    browserProfile:z.enum(['persistent','temporary']).optional(),
     canManageTeam: z.boolean().default(false),
     nodeId: z.string().default('local'),
     profile: z.string().min(1).max(256),
@@ -51,7 +52,8 @@ export const agentPatch = z
     avatar: avatar.optional(),
     instructions: z.string().max(24_000).optional(),
     execution:z.enum(['profile','computer']).optional(),
-    computer:z.enum(['auto','cloud','vm','off']).optional(),
+    computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
+    browserProfile:z.enum(['persistent','temporary']).optional(),
     nodeId:z.string().min(1).max(256).optional(),
     profile:z.string().min(1).max(256).optional(),
     canManageTeam: z.boolean().optional(),
@@ -353,7 +355,7 @@ export class WorkspaceStore {
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
       const sourceChanged=(patch.nodeId!==undefined&&patch.nodeId!==agent.nodeId)||(patch.profile!==undefined&&patch.profile!==agent.profile)
-      const destinationChanged=patch.computer!==undefined&&patch.computer!==agent.computer
+      const destinationChanged=(patch.computer!==undefined&&patch.computer!==agent.computer)||(patch.browserProfile!==undefined&&patch.browserProfile!==(agent.browserProfile??'persistent'))
       if(sourceChanged||destinationChanged){
         if(this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再修改机器人来源或电脑','agent_execution_busy')
         if(this.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(grant.agentId===id||grant.environmentId===(agent.computerEnvironmentId??id))))throw new HttpError(409,'请先交还电脑控制权','agent_execution_busy')

+ 1 - 1
src/shared/computerControl.ts

@@ -1,5 +1,5 @@
 export interface ComputerFrame {id:string;generation:number;data:string;width:number;height:number;capturedAt:number}
-export interface ComputerControlStatus {backend?:'grok';mode:'off'|'idle'|'agent'|'pausing'|'human'|'resuming'|'error';generation?:number;controlId?:string;canResume?:boolean;error?:string}
+export interface ComputerControlStatus {backend?:'grok'|'local'|'browser';hostName?:string;mode:'off'|'idle'|'agent'|'pausing'|'human'|'resuming'|'error';generation?:number;controlId?:string;canResume?:boolean;error?:string}
 export type ComputerInput =
   | {kind:'click';x:number;y:number;button?:'left'|'right'|'middle';count?:number}
   | {kind:'drag';fromX:number;fromY:number;toX:number;toY:number}

+ 10 - 0
src/shared/desktopEnvironment.ts

@@ -0,0 +1,10 @@
+import type {WorkspaceAgent} from './workspace.js'
+export interface DesktopEnvironmentState {
+ online:boolean
+ host:{name:string;platform:string}|null
+ local:{supported:boolean;authorized:boolean;screen:boolean;accessibility:boolean;ready:boolean}
+ browser:{available:boolean;profile:'persistent'|'temporary'}
+ selected:'local'|'browser'|null
+ agent:WorkspaceAgent
+}
+export interface BotBrowserState {open:boolean;profile:string;tabs:{id:string;title:string;url:string;active:boolean}[]}

+ 2 - 1
src/shared/workspace.ts

@@ -6,7 +6,8 @@ export interface WorkspaceAgent {
   instructions: string
   /** Explicit account-owner grant; absent on older records means disabled. */
   execution?: 'profile' | 'computer'
-  computer?: 'auto' | 'cloud' | 'vm' | 'off'
+  computer?: 'auto' | 'cloud' | 'vm' | 'local' | 'browser' | 'off'
+  browserProfile?: 'persistent' | 'temporary'
   computerEnvironmentId?:string
   computerEnvironmentName?:string
   canManageTeam?: boolean

+ 20 - 0
tests/e2e/native-environment.spec.ts

@@ -0,0 +1,20 @@
+import {test,expect} from '@playwright/test'
+import {mkdirSync} from 'node:fs'
+test('Web controls the real connected desktop browser and shows native permissions on a narrow viewport',async({page})=>{
+ const evidence='test-results/native-environment';mkdirSync(evidence,{recursive:true})
+ await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
+ await page.locator('.desktop-sidebar .sidebar-create-trigger').click();await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click();const editor=page.getByRole('dialog');await editor.getByRole('textbox',{name:'名称',exact:true}).fill('本机与浏览器验收');await editor.getByRole('button',{name:'保存',exact:true}).click()
+ await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click()
+ const panel=page.getByRole('complementary',{name:'机器人电脑面板'})
+ await expect(panel.getByRole('tab',{name:'本机',exact:true})).toBeEnabled({timeout:15000});await panel.getByRole('tab',{name:'本机',exact:true}).click()
+ await expect(panel.getByRole('button',{name:'在桌面端授权',exact:true})).toBeVisible();await expect(panel.getByText(/连接电脑:/)).toBeVisible();await page.screenshot({path:evidence+'/desktop-native-permissions.png'})
+ await panel.getByRole('tab',{name:'仅浏览器',exact:true}).click();await panel.getByRole('button',{name:'打开浏览器',exact:true}).click()
+ const viewer=page.locator('dialog.computer-panel');await expect(viewer.getByRole('textbox',{name:'浏览器地址',exact:true})).toBeEnabled({timeout:15000})
+ const url='http://127.0.0.1:18852/__test/browser-page';await viewer.getByRole('textbox',{name:'浏览器地址',exact:true}).fill(url);await viewer.getByRole('button',{name:'前往',exact:true}).click();await expect(viewer.getByRole('tab',{name:'浏览器操作验收',exact:true})).toBeVisible({timeout:15000})
+ await expect(viewer.locator('.computer-screen img')).toBeVisible();await expect.poll(async()=>(await viewer.locator('.computer-screen img').getAttribute('src'))?.length??0).toBeGreaterThan(14000);await page.screenshot({path:evidence+'/desktop-browser.png'})
+ await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
+ await page.setViewportSize({width:375,height:812});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'});await panel.getByRole('button',{name:'打开浏览器',exact:true}).scrollIntoViewIfNeeded();await page.screenshot({path:evidence+'/phone-browser-settings.png'})
+ await panel.getByRole('button',{name:'打开浏览器',exact:true}).click();await expect(viewer.getByRole('textbox',{name:'浏览器地址',exact:true})).toBeEnabled();await expect(viewer.getByRole('textbox',{name:'浏览器地址',exact:true})).toHaveValue(url);await page.screenshot({path:evidence+'/phone-browser.png'})
+ expect(await viewer.evaluate(el=>el.scrollWidth<=el.clientWidth+2)).toBe(true)
+ await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
+})

+ 15 - 0
tests/fixtures/native-environment.ts

@@ -0,0 +1,15 @@
+import {acquireServiceInstance} from '../../src/server/serviceInstance.js'
+import type {ApplicationRuntime} from '../../src/server/app.js'
+import {writeFileSync,realpathSync} from 'node:fs'
+import {join,resolve} from 'node:path'
+import {pathToFileURL} from 'node:url'
+import {spawn} from 'node:child_process'
+import electron from 'electron'
+export function nativeEnvironmentFixture(runtime:ApplicationRuntime,home:string,port:number){
+ const instance=acquireServiceInstance(home,'0.4.3');instance.publish(`http://127.0.0.1:${port}`)
+ runtime.app.middleware.unshift(instance.middleware(async()=>{},()=>true,ctx=>runtime.desktopEnvironments.bridge(ctx)))
+ const main=join(home,'native-main.mjs'),dataRoot=join(home,'native-data')
+ writeFileSync(main,`import {app} from 'electron';app.setPath('userData',${JSON.stringify(dataRoot)});app.whenReady().then(async()=>{const {DesktopEnvironmentHost}=await import(${JSON.stringify(pathToFileURL(resolve('desktop/environment-host.mjs')).href)});const {DesktopServiceManager}=await import(${JSON.stringify(pathToFileURL(resolve('desktop/service-manager.mjs')).href)});const manager=new DesktopServiceManager({home:${JSON.stringify(home)}});manager.root=${JSON.stringify(realpathSync(home))};globalThis.host=new DesktopEnvironmentHost({manager,root:${JSON.stringify(resolve('.desktop-build'))},dataRoot:${JSON.stringify(dataRoot)}});globalThis.host.start();app.on('window-all-closed',()=>{});});`)
+ const child=spawn(String(electron),[main],{env:Object.fromEntries(Object.entries(process.env).filter(([key])=>key!=='ELECTRON_RUN_AS_NODE')),stdio:'ignore'})
+ return ()=>{child.kill('SIGTERM');instance.release()}
+}

+ 4 - 0
tests/fixtures/workspace-server.ts

@@ -1,3 +1,4 @@
+import {nativeEnvironmentFixture} from './native-environment.js'
 import {FixtureGrokAuthProvider} from './grok-auth-provider.js'
 /** Isolated, deterministic Hermes fixture. Optional team-tool readiness is only for editor UI tests. */
 import { createServer } from 'node:http'
@@ -338,6 +339,8 @@ const auth = new LocalAuthStore(home, false)
 const grokAuth=process.env.WORKSPACE_FIXTURE_GROK_AUTH==='1'?new FixtureGrokAuthProvider():undefined
 const runtime = createApplication({ config, auth, grokFetch:grokAuth?.fetch }),
   node = createNodeServer(runtime)
+const closeNative=process.env.WORKSPACE_FIXTURE_NATIVE==='1'?nativeEnvironmentFixture(runtime,home,port):undefined
+runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/browser-page')return next();ctx.type='html';ctx.body='<!doctype html><meta charset="utf-8"><title>浏览器操作验收</title><body style="font:22px sans-serif;padding:48px;background:#edf4ff"><h1>机器人的独立浏览器</h1><p>这是隔离的真实浏览器验收页面。</p><input aria-label="输入内容"><button onclick="document.querySelector(\'output\').textContent=document.querySelector(\'input\').value">确认</button><output></output></body>'})
 runtime.app.use((ctx,next) => {
   if (ctx.path !== '/__test/task-plan' || ctx.method !== 'POST') return next()
   const owner=auth.require(ctx).id
@@ -420,6 +423,7 @@ await new Promise<void>((resolve) => node.server.listen(port, '127.0.0.1', resol
 process.stdout.write(`Workspace fixture http://127.0.0.1:${port}; home=${home}\n`)
 for (const signal of ['SIGTERM', 'SIGINT'])
   process.once(signal, () => {
+    closeNative?.()
     void node.close().then(() => {
       for (const socket of wss.clients) socket.terminate()
       wss.close()

+ 33 - 0
tests/server/desktopEnvironments.test.ts

@@ -0,0 +1,33 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID,createHash} from 'node:crypto'
+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 type {WorkspaceAgent} from '../../src/shared/workspace'
+import {HttpError} from '../../src/server/errors'
+import {acquireServiceInstance} from '../../src/server/serviceInstance'
+let home:string,store:WorkspaceStore,service:DesktopEnvironments,agent:WorkspaceAgent,version:number,host:any,poller:ReturnType<typeof setInterval>|undefined
+const auth={require:(ctx:any)=>({id:ctx.get('x-user')||'owner'}),requireAdmin:(ctx:any)=>{if(ctx.get('x-user'))throw new HttpError(403,'仅限管理员','admin_required')},pushAuthorizationVersion:()=>version} as any
+const nodes={localNodeID:'local-node',requireSource:()=>{}} as any
+const ownerKey=(owner:string)=>createHash('sha256').update('local-node:'+owner).digest('hex')
+beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'desktop-env-'));store=new WorkspaceStore(home);service=new DesktopEnvironments(store,auth,nodes);version=1;agent=store.createAgent('owner',{name:'Browser',nodeId:'local',profile:'default',computer:'browser'});host={id:randomUUID(),name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[ownerKey('owner')]}})
+afterEach(()=>{clearInterval(poller);service.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
+const app=()=>{const a=new Koa();a.use(async(ctx,next)=>{try{await next()}catch(e:any){ctx.status=e.status??500;ctx.body={error:e.message,code:e.code}}});a.use(bodyParser());a.use(service.router().routes());return a.callback()}
+const base=()=>'/api/app/agents/'+agent.id
+function drive(handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('fixture frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.exchange({host,results});poller=setInterval(()=>{const value=service.exchange({host,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5)}
+async function take(){return (await request(app()).post(base()+'/computer/take').send({requestId:randomUUID()}).expect(200)).body}
+async function frame(){return (await request(app()).get(base()+'/computer/frame').expect(200)).body}
+it('keeps capability private and refuses origins before parsing native commands',async()=>{const instance=acquireServiceInstance(home,'0.4.3'),a=new Koa().use(instance.middleware(async()=>{},()=>true,ctx=>service.bridge(ctx)));try{await request(a.callback()).post('/desktop/environment').send({host,results:[]}).expect(403);await request(a.callback()).post('/desktop/environment').set('x-yaoyao-desktop-token',instance.record.token).set('Origin','http://evil.test').send({host,results:[]}).expect(403);await request(a.callback()).post('/desktop/environment').set('x-yaoyao-desktop-token',instance.record.token).send({host,results:[]}).expect(200);expect(service.online).toBe(true)}finally{instance.release()}})
+it('auto selects only an approved and ready local host; observing never opens a browser',async()=>{agent=store.updateAgent('owner',agent.id,{computer:'auto'});expect(service.selected('owner',agent)).toBeUndefined();expect(service.exchange({host:{...host,approved:[]},results:[]}).commands).toEqual([]);expect(service.selected('owner',agent)).toBeUndefined();service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBe('local');host.screen=false;service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBeUndefined();await request(app()).get(base()+'/desktop-environment').set('x-user','other').expect(404)})
+it('shares the physical desktop across owners and robots but scopes manual tokens to their owner and robot',async()=>{agent=store.updateAgent('owner',agent.id,{computer:'local'});const other=store.createAgent('other',{name:'Other',nodeId:'local',profile:'default',computer:'local'});host.approved.push(ownerKey('other'));drive();const ticket=await take();await request(app()).post('/api/app/agents/'+other.id+'/computer/take').set('x-user','other').send({requestId:randomUUID()}).expect(409);await request(app()).post('/api/app/agents/'+other.id+'/computer/renew').set('x-user','other').send(ticket).expect(410);const state=(await request(app()).get('/api/app/agents/'+other.id+'/computer').set('x-user','other')).body;expect(state.controlId).toBeUndefined();await request(app()).post(base()+'/computer/giveback').send(ticket).expect(200)})
+it('rejects stale frames and replays an input result exactly once',async()=>{const calls:any[]=[];drive(c=>{calls.push(c);return c.operation==='view'?{data:'Zm9v',width:1280,height:800}:{ok:true}});const t=await take(),f=await frame(),body={controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'click',x:10,y:10}};await request(app()).post(base()+'/computer/input').send({...body,frameId:randomUUID()}).expect(409);await request(app()).post(base()+'/computer/input').send(body).expect(200);await request(app()).post(base()+'/computer/input').send(body).expect(200);expect(calls.filter(c=>c.operation==='input')).toHaveLength(1);await request(app()).post(base()+'/computer/input').send({...body,action:{kind:'text',text:'different'}}).expect(409);await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})
+it('expired control remains paused until a new takeover is explicitly returned',async()=>{drive();const t=await take();const original=Date.now;const time=vi.spyOn(Date,'now').mockImplementation(()=>original()+31000);service.exchange({host,results:[]});await request(app()).post(base()+'/computer/renew').send(t).expect(410);expect((await request(app()).get(base()+'/computer')).body.mode).toBe('error');time.mockRestore();const next=await take();await request(app()).post(base()+'/computer/giveback').send({...next,notes:'已完成登录'}).expect(200);expect((await request(app()).get(base()+'/computer')).body.mode).toBe('idle')})
+it('pins tool calls to the host epoch and does not dispatch after permissions change',async()=>{drive();const epoch=service.epoch,signal=new AbortController().signal;await service.call('owner',agent.id,'desktop_environment_view',{},signal,()=>{},'browser',epoch);host.id=randomUUID();service.exchange({host,results:[]});await expect(service.call('owner',agent.id,'desktop_environment_action',{kind:'text',text:'no'},signal,()=>{},'browser',epoch)).rejects.toThrow('连接已改变');version++;const t=await take();version++;await request(app()).post(base()+'/computer/renew').send(t).expect(410)})
+it('waits for human release before the robot runs and forwards handoff notes',async()=>{drive();const t=await take();let complete=false;const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'browser',service.epoch).then(value=>{complete=true;return value});await new Promise(resolve=>setTimeout(resolve,30));expect(complete).toBe(false);await request(app()).post(base()+'/computer/giveback').send({...t,notes:'已登录测试账号'}).expect(200);const value=await call;expect(JSON.stringify(value)).toContain('已登录测试账号')})
+it('refuses dangerous navigation and prevents changing profile during active work',async()=>{drive();const t=await take(),f=await frame();await request(app()).post(base()+'/computer/browser').send({controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'navigate',url:'file:///etc/passwd'}}).expect(400);await request(app()).post(base()+'/computer/giveback').send(t).expect(200);store.put('owner','turn','turn',{agentId:agent.id,status:'running'});await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})