Forráskód Böngészése

限制设备授权为本机并收拢电脑面板入口

Samien 3 hete
szülő
commit
afa4e3e067

+ 14 - 0
desktop/computer-authorization.mjs

@@ -0,0 +1,14 @@
+/** The native caller chooses its own core; no host identifier comes from the page. */
+export async function authorizeCurrentComputer({ scope, core, origin, cookies, fetchImpl, isCurrent }) {
+  if (!['screen', 'full'].includes(scope)) throw new Error('授权范围无效')
+  if (!core || core.closed) throw new Error('当前设备尚未连接,请重新登录客户端')
+  const response = await fetchImpl(new URL('/api/app/desktop-environment/authorization', origin), {
+    method: 'GET', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(10000),
+    headers: { accept: 'application/json', cookie: cookies.map(cookie => `${cookie.name}=${cookie.value}`).join('; ') },
+  })
+  if (!response.ok) throw new Error(response.status === 403 ? '请使用管理员账号授权当前设备' : '无法读取当前账号,请检查服务连接并重试')
+  const context = await response.json()
+  if (!/^[a-f0-9]{64}$/.test(context?.owner) || typeof context?.account !== 'string') throw new Error('设备授权响应无效,请更新服务器后重试')
+  if (!isCurrent()) throw new Error('当前连接已改变,请重新打开 Bot 设置')
+  return core.authorize({ owner: context.owner, account: context.account, scope, deadline: Date.now() + 120000 }, isCurrent)
+}

+ 32 - 13
desktop/computer-targets.test.mjs

@@ -16,8 +16,10 @@ test('selected previews reach the matching native desktop and switch only after
  try{
   await expect.poll(async()=>{try{return(await fetch(origin+'/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
   const harness=join(home,'main.mjs')
-  await writeFile(harness,`import {app,BrowserWindow} from 'electron';
+  await writeFile(harness,`import {app,BrowserWindow,ipcMain} from 'electron';
 import {installComputerViewer} from '${pathToFileURL(join(root,'desktop/computer-viewer.mjs')).href}';
+ipcMain.handle('desktop:device-host',()=>({deviceHost:'client-mac'}));
+globalThis.grantedDeviceScopes=[];ipcMain.handle('desktop:device-authorize',(_event,scope)=>{globalThis.grantedDeviceScopes.push(scope);return {ok:true}});
 app.setPath('userData',${JSON.stringify(join(home,'chromium'))});
 app.whenReady().then(async()=>{let quitting=false;app.on('before-quit',()=>quitting=true);
 const owner=new BrowserWindow({width:1440,height:900,webPreferences:{preload:${JSON.stringify(join(root,'desktop/preload.cjs'))},sandbox:true,contextIsolation:true}});
@@ -31,8 +33,6 @@ await owner.loadURL(${JSON.stringify(origin+'/conversations')});});`)
   await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
   const seed=await(await page.request.post(origin+'/__test/computer',{data:{}})).json()
   const targets=[
-   {key:'desktop:local',backend:'desktop',host:'local',name:'服务器',color:'#263f59'},
-   {key:'desktop:client-mac',backend:'desktop',host:'client-mac',name:'Mac Studio 客户端',color:'#245b49'},
    {key:'cloud',backend:'cloud',name:'Grok Bot 云端',color:'#6a405e'},
    {key:'managed-browser',backend:'managed-browser',name:'托管浏览器',color:'#264e60'},
    {key:'vm',backend:'vm',name:'独立虚拟机',color:'#65502c'},
@@ -45,9 +45,10 @@ await owner.loadURL(${JSON.stringify(origin+'/conversations')});});`)
    return [target.key,canvas.toDataURL('image/png').split(',')[1]]
   })),targets)
   const permission={supported:true,authorized:true,screen:true,accessibility:true,ready:true,fullAuthorized:true}
-  const hosts=targets.filter(t=>t.backend==='desktop').map(t=>({id:t.host,name:t.name,online:true,local:permission,browser:{available:false}}))
+  const hosts=[{id:'local',name:'服务器'},{id:'client-mac',name:'Mac Studio 客户端'},{id:'other-mac',name:'另一台 Mac'}].map(h=>({...h,platform:'darwin',online:true,local:{...permission,authorized:false,ready:false,fullAuthorized:false},browser:{available:false}}))
   const calls=[],leases=new Set()
   let refuseGiveback=false
+  await app.context().route('**/api/app/desktop-environment',route=>route.fulfill({json:{hosts}}))
   await app.context().route('**/api/app/settings/host-tools',route=>route.fulfill({json:{scriptMachine:true,serverComputer:true,vm:true,cloud:true,managedBrowser:true}}))
   await app.context().route('**/api/app/agents/*/**',async route=>{
    const url=new URL(route.request().url()),path=url.pathname
@@ -108,22 +109,21 @@ await owner.loadURL(${JSON.stringify(origin+'/conversations')});});`)
     assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false)
     await page.setViewportSize({width:1440,height:900})
    }
-   if(target.host==='client-mac'){
-    const output=join(root,'test-results/computer-targets');await mkdir(output,{recursive:true})
-    await viewer.screenshot({path:join(output,'client-desktop.png')})
-    await page.setViewportSize({width:390,height:844});await page.screenshot({path:join(output,'selected-client-mobile.png')})
-    await page.setViewportSize({width:1440,height:900})
-   }
+
   }
   // Reopening an unchanged target focuses its existing window.
   await page.evaluate(({id,target})=>window.yaoyaoDesktop.openComputer(id,target),{id:seed.agentId,target:{backend:'vm'}})
   assert.equal(viewer.isClosed(),false);assert.equal(app.windows().length,2)
   const rejected=await page.evaluate(async id=>{try{await window.yaoyaoDesktop.openComputer(id,{backend:'vm',host:'client-mac'});return false}catch{return true}},seed.agentId)
   assert.equal(rejected,true);assert.equal(viewer.isClosed(),false)
+  const physicalRejected=await page.evaluate(async id=>{try{await window.yaoyaoDesktop.openComputer(id,{backend:'desktop',host:'local'});return false}catch{return true}},seed.agentId)
+  assert.equal(physicalRejected,true)
+  assert.equal(await dock.locator('option[value^="desktop:"]').count(),0)
+  assert.equal(await dock.locator('.host-permission').count(),0)
   refuseGiveback=true
   const beforeFailedSwitch=calls.length
   const failedSwitch=await page.evaluate(async id=>{
-   try{await window.yaoyaoDesktop.openComputer(id,{backend:'desktop',host:'local'});return ''}
+   try{await window.yaoyaoDesktop.openComputer(id,{backend:'cloud'});return ''}
    catch(error){return error.message}
   },seed.agentId)
   assert.match(failedSwitch,/尚未交还/)
@@ -133,8 +133,8 @@ await owner.loadURL(${JSON.stringify(origin+'/conversations')});});`)
   // A manual change inside the viewer must update the identity used for reuse.
   await viewer.getByRole('button',{name:'键盘与操作',exact:true}).click()
   await viewer.getByRole('button',{name:'结束控制',exact:true}).click()
-  await viewer.getByRole('combobox',{name:'切换电脑环境'}).selectOption('desktop:local')
-  await expect(viewer.locator('.computer-screen img')).toHaveAttribute('src','data:image/png;base64,'+images['desktop:local'])
+  await viewer.getByRole('combobox',{name:'切换电脑环境'}).selectOption('cloud')
+  await expect(viewer.locator('.computer-screen img')).toHaveAttribute('src','data:image/png;base64,'+images.cloud)
   const reopened=app.waitForEvent('window'),oldViewer=viewer
   await page.evaluate(id=>window.yaoyaoDesktop.openComputer(id,{backend:'vm'}),seed.agentId)
   viewer=await reopened
@@ -144,6 +144,25 @@ await owner.loadURL(${JSON.stringify(origin+'/conversations')});});`)
   await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
   await expect.poll(()=>viewer.isClosed()).toBe(true)
   assert.equal(leases.size,0)
+  await page.locator('.desktop-sidebar').getByRole('button',{name:'工具',exact:true}).click()
+  await page.getByRole('menuitem',{name:'Bot 设置',exact:true}).click()
+  const settings=page.getByRole('dialog',{name:'Bot 设置',exact:true})
+  await settings.getByRole('button',{name:'环境与授权',exact:true}).click()
+  const devices=settings.locator('.device-authorization')
+  await expect(devices.locator('[data-host-id="client-mac"]')).toContainText('当前设备')
+  await expect(devices.locator('[data-host-id="local"] button')).toHaveCount(0)
+  await expect(devices.locator('[data-host-id="other-mac"] button')).toHaveCount(0)
+  await devices.getByRole('button',{name:'授权本机文件与命令',exact:true}).click()
+  await expect(devices.getByRole('status')).toContainText('本机授权已保存')
+  assert.deepEqual(await app.evaluate(()=>globalThis.grantedDeviceScopes),['full'])
+  await expect(devices.getByRole('button',{name:'授权本机文件与命令',exact:true})).toBeEnabled()
+  const output=join(root,'test-results/device-authorization');await mkdir(output,{recursive:true})
+  await page.screenshot({path:join(output,'desktop-settings.png')})
+  await page.setViewportSize({width:375,height:812})
+  await devices.scrollIntoViewIfNeeded()
+  assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false)
+  await page.screenshot({path:join(output,'mobile-settings.png')})
+
  }finally{
   await app?.close().catch(()=>{})
   server.kill('SIGTERM');if(server.exitCode===null)await new Promise(done=>server.once('exit',done))

+ 2 - 3
desktop/computer-viewer.mjs

@@ -9,12 +9,11 @@ export function installComputerViewer({ owner, origin, preload, quitting = () =>
     if (typeof id !== 'string' || !/^[\w-]{1,128}$/.test(id)) throw new Error('电脑窗口请求无效')
     if (!target || typeof target !== 'object' || Array.isArray(target)
       || Object.keys(target).some(key => !['backend', 'host'].includes(key))
-      || (target.backend !== undefined && !['desktop', 'cloud', 'vm', 'managed-browser'].includes(target.backend))
-      || (target.host !== undefined && (target.backend !== 'desktop' || typeof target.host !== 'string' || !/^[\w-]{1,128}$/.test(target.host))))
+      || (target.backend !== undefined && !['cloud', 'vm', 'managed-browser'].includes(target.backend))
+      || target.host !== undefined)
       throw new Error('所选电脑无效,请重新选择')
     const url = new URL(`/conversations/computer/${encodeURIComponent(id)}`, origin())
     if (target.backend) url.searchParams.set('backend', target.backend)
-    if (target.backend === 'desktop') url.searchParams.set('host', target.host || 'local')
     return url.href
   }
   ipcMain.handle('desktop-computer:open', (event, id, target) => {

+ 7 - 5
desktop/environment-host.mjs

@@ -36,22 +36,24 @@ export class DesktopHostCore {
  save(){mkdirSync(this.dataRoot,{recursive:true,mode:0o700});writeFileSync(this.permissionsFile,JSON.stringify({screen:[...this.approved],full:[...this.approvedFull]}),{mode:0o600})}
  takeResults(){const sent=this.results.slice();this.results.splice(0,sent.length);return sent}
  async handle(commands){for(const c of commands??[]){if(this.running.has(c.id))continue;this.running.add(c.id);const epoch=this.id;void this.execute(c).then(value=>{if(epoch===this.id)this.results.push({id:c.id,value})},error=>{if(epoch===this.id)this.results.push({id:c.id,error:String(error.message).slice(0,500)})}).finally(()=>this.running.delete(c.id))}}
- async execute(c){const epoch=this.id;if(Date.now()>c.deadline||this.closed)throw new Error('桌面请求已过期');if(!/^[a-f0-9]{64}$/.test(c.owner))throw new Error('桌面请求身份无效')
-  if(c.operation==='authorize'){
+ async authorize(c,isCurrent=()=>true){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(!['screen','full'].includes(c.scope))throw new Error('授权范围无效')
    if(!this.platform.computer)throw new Error('此电脑暂不支持本机控制')
    const full=c.scope==='full'
    const {response}=await dialog.showMessageBox({type:'question',title:full?'授权文件与命令':'授权本机控制',
     message:`允许夭夭账号「${String(c.account??'当前账号').slice(0,100)}」的机器人${full?'使用这台电脑的文件并执行命令':'查看和操作这台电脑'}?`,
-    detail:full?'机器人可以列出、读取和写入你用户主目录内的文件,并在这台电脑上执行 shell 命令。仅对信任的机器人开启;可随时在夭夭菜单中撤销。':'授权后,机器人和该账号的 Web、手机端可以查看屏幕,并在接管时操作鼠标和键盘。你可以在夭夭菜单中撤销。',
+    detail:full?'机器人可以列出、读取和写入你用户主目录内的文件,并在这台电脑上执行 shell 命令。仅对信任的机器人开启;可随时在夭夭菜单中撤销。':'授权后,机器人可以查看屏幕并操作鼠标和键盘。你可以在夭夭菜单中撤销。',
     buttons:full?['取消','允许文件与命令']:['取消','允许并检查系统权限'],defaultId:0,cancelId:0})
-   if(response!==1||Date.now()>c.deadline||this.closed||epoch!==this.id)throw new Error(full?'文件与命令授权已取消':'本机控制授权已取消')
+   if(response!==1||Date.now()>c.deadline||this.closed||epoch!==this.id||!isCurrent())throw new Error(full?'文件与命令授权已取消':'本机控制授权已取消')
    if(full)this.approvedFull.add(c.owner);else this.approved.add(c.owner);this.save()
    if(full)return {ok:true}
    if(this.native){await this.prepareInfo(true);return {ok:true}}
    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}
-  }
+ }
+ 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')throw new Error('请在当前设备的 Bot 设置中授权')
   if(c.operation==='file'||c.operation==='shell'){
    if(!this.platform.computer)throw new Error('此电脑暂不支持文件与命令')
    if(!this.approvedFull.has(c.owner))throw new Error('请在桌面端授权文件与命令访问')

+ 13 - 0
desktop/main.mjs

@@ -13,6 +13,7 @@ import { resolveDataHome } from './data-home.mjs'
 import { DesktopEnvironmentHost, DesktopHostCore } from './environment-host.mjs'
 import { DesktopHostManager } from './host-manager.mjs'
 import { remoteRegistration, remoteSession, enrollDesktopHost, inspectServer, persistRemoteSession, sessionCookieDetails } from './remote-login.mjs'
+import { authorizeCurrentComputer } from './computer-authorization.mjs'
 import { DesktopLoginSessions } from './login-sessions.mjs'
 import { DesktopOnboarding } from './onboarding.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
@@ -459,6 +460,18 @@ else {
       if(switchingMode)throw new Error('正在切换运行模式,请稍候')
       return openRemoteLogin()
     })
+    ipcMain.handle('desktop:device-authorize', async (event, scope) => {
+      requireModePage(event)
+      if (switchingMode) throw new Error('正在切换运行模式,请稍候')
+      const origin = new URL(event.senderFrame.url).origin
+      const core = remoteMode ? hostManager?.core : environmentHost?.core
+      const isCurrent = () => !closing && !quitting && !switchingMode && core === (remoteMode ? hostManager?.core : environmentHost?.core)
+        && new URL(event.senderFrame.url).origin === origin
+        && (!remoteMode || hostManager?.config?.serverURL === remoteURL)
+      const cookies = await electronSession.defaultSession.cookies.get({ url: origin })
+      if (!isCurrent()) throw new Error('当前连接已改变,请重新打开 Bot 设置')
+      return authorizeCurrentComputer({ scope, core, origin, cookies, fetchImpl: authFetch, isCurrent })
+    })
     ipcMain.handle('desktop:computer-authorize',async(event,csrfToken)=>{
       requireModePage(event)
       if(!remoteMode||!remoteURL)return {registered:false}

+ 2 - 2
desktop/mode-switch.test.mjs

@@ -72,10 +72,10 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
       const outsider=new BrowserWindow({show:false,webPreferences:{sandbox:true,contextIsolation:true,preload}})
       try{
         await outsider.loadURL(origin)
-        return await outsider.webContents.executeJavaScript('Promise.all([window.yaoyaoDesktop.modeState(),window.yaoyaoDesktop.switchMode("server"),window.yaoyaoDesktop.openRemoteLogin(),window.yaoyaoDesktop.authorizeComputer("csrf-test")].map(p=>p.then(()=>false,()=>true)))')
+        return await outsider.webContents.executeJavaScript('Promise.all([window.yaoyaoDesktop.modeState(),window.yaoyaoDesktop.switchMode("server"),window.yaoyaoDesktop.openRemoteLogin(),window.yaoyaoDesktop.authorizeComputer("csrf-test"),window.yaoyaoDesktop.authorizeCurrentDevice("full")].map(p=>p.then(()=>false,()=>true)))')
       }finally{outsider.destroy()}
     },{origin:remoteURL,preload:join(root,'desktop/preload.cjs')})
-    assert.deepEqual(denied,[true,true,true,true])
+    assert.deepEqual(denied,[true,true,true,true,true])
     await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('server')})
     await page.waitForURL(origin+'/**')
     await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false,platform:'darwin',supportedModes:['client','server']})

+ 1 - 0
desktop/preload.cjs

@@ -22,6 +22,7 @@ contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   switchMode: mode => ipcRenderer.invoke('desktop:mode-switch', mode),
   openRemoteLogin: () => ipcRenderer.invoke('desktop:remote-login'),
   authorizeComputer: csrfToken => ipcRenderer.invoke('desktop:computer-authorize', csrfToken),
+  authorizeCurrentDevice: scope => ipcRenderer.invoke('desktop:device-authorize', scope),
   /** 'local' on the YaoYao server Mac; paired host uuid in remote mode; null if unknown. */
   deviceHost: () => ipcRenderer.invoke('desktop:device-host'),
   openComputer: (id, target) => ipcRenderer.invoke('desktop-computer:open', id, target),

+ 8 - 4
docs/desktop-hosts.md

@@ -31,12 +31,16 @@ Windows 客户端的构建、未签名安装包和验收记录见 [Windows 客
 2. 后续启动直接进入远程模式;若登录会话已过期,App 会重新弹出登录窗口(服务器地址已预填)。会话 Cookie 保存在 App 的会话存储中,密码不落盘。使用中可随时通过「运行模式」切换服务器与客户端模式;「电脑 → 断开并忘记配置」会移除本机的电脑凭据。
 3. 服务器管理侧(可选):「设置 → Hermes 连接 → 电脑」列出各台已注册电脑(在线状态、机器名、最后在线)并可停用;停用立即断开那台 Mac。被停用的电脑不会因启动或普通重连自行恢复;用户再次显式登录管理员账号,或选择「电脑 → 重新授权…」后,服务复用原记录、轮换令牌并恢复连接。旧版没有安装编号的电脑通过原记录编号完成迁移,避免产生重复记录。
 4. 手动注册并下载 `desktop-host.json` 仍可用于不便登录的机器(菜单「电脑 → 导入电脑配置…」),但常规流程不再需要。
-5. 管理员在全局设置中开放电脑类型,在聊天右侧电脑面板查看各台电脑的连接与授权状态;无需为各 Bot 单独勾选环境或绑定默认执行机。
-6. 首次授权:管理员点「在桌面端授权」,那台 Mac 弹出原生授权对话框;确认后引导开启屏幕录制与辅助功能。授权按账号哈希(ownerKey)记录在各 Mac 的 `computer-permissions.json`,各机独立;菜单「撤销本机控制授权」同时作用于本机与远程通道。
+5. 管理员在 Bot 设置中开放电脑类型,并在「环境与授权 → 设备授权」查看各台电脑的连接与授权状态;无需为各 Bot 单独勾选环境或绑定默认执行机。
+6. 首次授权:管理员在当前客户端的 Bot 设置点「授权本机屏幕控制」,本机弹出原生授权对话框;确认后引导开启屏幕录制与辅助功能。授权按账号哈希(ownerKey)记录在各 Mac 的 `computer-permissions.json`,各机独立;菜单「撤销本机控制授权」同时作用于本机与远程通道。
+
+设备授权只能由当前电脑客户端发起,其他设备只显示状态,不能从本机发起授权。浏览器和手机 Web 仅可查看。客户端从已登录的服务器读取当前账号身份,再由本机原生授权窗口确认;不接收目标设备编号。旧远程授权接口及下发的授权命令都会被拒绝,客户端和服务器需同时升级。
+
+聊天电脑面板及独立电脑窗口只提供「本地虚拟机」「云虚拟机」「托管浏览器」。真实电脑和服务器的授权状态集中在 Bot 设置;Bot 在已授权设备上使用桌面、文件及命令工具的能力保持独立。
 
 ## 文件与命令(全面操作)
 
-屏幕授权之外是独立的第二档授权「文件与命令」:电脑面板直接提供「授权文件与命令」按钮,无需先完成屏幕授权,桌面端弹窗单独确认。授权后该账号下所有机器人获得四个工具:
+屏幕授权之外是独立的第二档授权「文件与命令」:Bot 设置中的当前设备提供「授权本机文件与命令」按钮,无需先完成屏幕授权,桌面端弹窗单独确认。授权后该账号下所有机器人获得四个工具:
 
 - `desktop_file_list` / `desktop_file_read` / `desktop_file_write`:在这台电脑的**用户主目录**范围内列目录、读文件(≤12MB)和写文件(≤10MB);`..`、绝对路径越界和符号链接逃逸都会被拒绝,TCC 保护目录仍按系统权限处理。
 - `desktop_shell`:在主目录执行一条命令(Mac 使用 zsh,Windows 使用系统 Windows PowerShell)(最长 120 秒,stdout/stderr 各截断 512KB,超时终止并如实报告)。命令按当前系统用户权限执行,不受文件工具主目录边界限制。
@@ -61,4 +65,4 @@ Windows 客户端的构建、未签名安装包和验收记录见 [Windows 客
 
 - 服务端:`tests/server/desktopHosts.test.ts`(注册令牌一次性、准入与协议头、撤销围栏)、`tests/server/desktopEnvironments.test.ts`(消息来源解析、离线不回退、全局权限、文件与屏幕授权独立、并发来源隔离、按机围栏)。
 - App:`desktop/remote-login.test.mjs`(地址归一化、账号密码登录的 CSRF/Origin 往返、管理员注册电脑、错误路径)、`desktop/host-files.test.mjs`(目录内列/读/写、路径与符号链接逃逸拒绝、命令退出码/超时/工作目录)、`desktop/host-manager.test.mjs`(配置校验、加密导入、交换往返、403 停止、退避重试)、`desktop/preferences.test.mjs`(启动选择与远程地址偏好持久化)。启动选择/登录窗口需按上述流程手工验收(自动化桌面测试通过 fixture 环境固定走本地路径)。
-- 浏览器:`tests/e2e/desktop-hosts.spec.ts`(注册/下载/停用;模拟远程电脑连接与电脑面板状态)。真实多机链路需按上述流程手工验收。
+- 浏览器:`tests/e2e/desktop-hosts.spec.ts`(注册/下载/停用;模拟远程电脑连接与 Bot 设置设备状态)。真实多机链路需按上述流程手工验收。

+ 74 - 0
src/client/components/app/DeviceAuthorizationPanel.vue

@@ -0,0 +1,74 @@
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref } from 'vue'
+import { apiRequest } from '@/api/client'
+import type { DesktopHostStatus } from '@shared/desktopEnvironment'
+
+const nativeAuthorization = !!window.yaoyaoDesktop?.authorizeCurrentDevice
+const hosts = ref<DesktopHostStatus[]>([])
+const currentHost = ref<string | null>(null)
+const loading = ref(false), busy = ref(false), error = ref(''), readError = ref(''), notice = ref('')
+let closed = false, timer: ReturnType<typeof setTimeout> | undefined
+const canAuthorize = (host: DesktopHostStatus) => host.id === currentHost.value && host.online && !!window.yaoyaoDesktop?.authorizeCurrentDevice
+async function refresh() {
+  if (closed || loading.value) return
+  loading.value = true
+  try {
+    const [state, current] = await Promise.all([
+      apiRequest<{ hosts: DesktopHostStatus[] }>('/api/app/desktop-environment'),
+      window.yaoyaoDesktop?.deviceHost?.(),
+    ])
+    if (closed) return
+    currentHost.value = current?.deviceHost ?? null
+    hosts.value = state.hosts.sort((a, b) => Number(b.id === currentHost.value) - Number(a.id === currentHost.value))
+    readError.value = ''
+  } catch (cause) { if (!closed) readError.value = cause instanceof Error ? cause.message : '无法读取设备状态' }
+  finally { if (!closed) loading.value = false }
+}
+async function authorize(scope: 'screen' | 'full') {
+  if (busy.value || !hosts.value.some(canAuthorize)) return
+  busy.value = true; error.value = ''; notice.value = ''
+  try {
+    await window.yaoyaoDesktop!.authorizeCurrentDevice!(scope)
+    if (!closed) { notice.value = '本机授权已保存,设备状态将在连接同步后更新。'; await refresh() }
+  } catch (cause) { if (!closed) error.value = cause instanceof Error ? cause.message : '本机授权未完成' }
+  finally { if (!closed) busy.value = false }
+}
+async function cycle() {
+  if (!busy.value && !document.hidden) await refresh()
+  if (!closed) timer = setTimeout(cycle, 3000)
+}
+onMounted(() => { void cycle() })
+onBeforeUnmount(() => { closed = true; clearTimeout(timer) })
+</script>
+
+<template>
+  <section class="device-authorization" aria-label="设备授权">
+    <header><h4>设备授权</h4><button type="button" :disabled="loading || busy" @click="refresh">{{ loading ? '刷新中…' : '刷新状态' }}</button></header>
+    <p>只能授权当前客户端所在的设备。其他设备的连接和权限状态可在这里查看;如需授权,请在那台设备上打开 Bot 设置。</p>
+    <p v-if="!currentHost" class="hint">请在电脑客户端中授权本机;浏览器和手机端仅可查看设备状态。</p>
+    <p v-else-if="!nativeAuthorization" class="hint">请更新当前电脑客户端后授权本机。</p>
+    <article v-for="host in hosts" :key="host.id" :data-host-id="host.id">
+      <div class="device-heading"><strong>{{ host.name || '未命名电脑' }}<span v-if="host.id === currentHost"> · 当前设备</span></strong><span>{{ host.online ? '在线' : '离线' }}</span></div>
+      <small>{{ host.id === 'local' ? '服务器' : `编号 ${host.id.slice(0, 8)}` }}</small>
+      <dl v-if="host.online">
+        <div><dt>屏幕控制</dt><dd>{{ host.local.authorized ? '已授权' : '未授权' }}</dd></div>
+        <div><dt>{{ host.platform === 'win32' ? '屏幕可用' : '屏幕录制' }}</dt><dd>{{ host.local.screen ? '已开启' : '未开启' }}</dd></div>
+        <div><dt>{{ host.platform === 'win32' ? '键鼠可用' : '辅助功能' }}</dt><dd>{{ host.local.accessibility ? '已开启' : '未开启' }}</dd></div>
+        <div><dt>文件与命令</dt><dd>{{ host.local.fullAuthorized ? '已授权' : '未授权' }}</dd></div>
+      </dl>
+      <p v-else class="hint">设备离线,连接后更新授权状态。</p>
+      <div v-if="canAuthorize(host)" class="actions">
+        <button v-if="!host.local.ready" type="button" :disabled="busy" @click="authorize('screen')">{{ host.local.authorized ? '检查本机系统权限' : '授权本机屏幕控制' }}</button>
+        <button v-if="!host.local.fullAuthorized" type="button" :disabled="busy" @click="authorize('full')">授权本机文件与命令</button>
+      </div>
+      <p v-else-if="host.id !== currentHost" class="hint">仅查看,请在该设备上授权。</p>
+    </article>
+    <p v-if="!hosts.length && !loading && !readError">尚无已连接或配对的设备。</p>
+    <p v-if="error || readError" class="error" role="alert">{{ error || readError }}</p>
+    <p v-if="notice" role="status">{{ notice }}</p>
+  </section>
+</template>
+
+<style scoped>
+.device-authorization{display:grid;gap:14px;max-width:680px;margin-bottom:28px;font-size:13px;line-height:1.6;color:var(--text-primary)}header,.device-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}h4,p,dl,dd{margin:0}h4{font-size:16px}p,small,dt,.device-heading>span{color:var(--text-secondary)}article{display:grid;gap:10px;padding:16px;border:1px solid var(--line);border-radius:10px}.device-heading strong{min-width:0;overflow-wrap:anywhere}.device-heading>span{flex-shrink:0}dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 16px}dl>div{display:flex;flex-wrap:wrap;gap:4px 12px;justify-content:space-between}.actions{display:flex;flex-wrap:wrap;gap:8px}button{min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}button:hover:not(:disabled){background:var(--surface-hover)}button:active:not(:disabled){background:var(--settings-selected)}button:disabled{opacity:.5;cursor:not-allowed}button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.hint{font-size:12px}.error{color:var(--danger)}@media(max-width:480px){dl{grid-template-columns:1fr}.actions button{flex:1 1 100%}}
+</style>

+ 2 - 1
src/client/components/workspace/BotSettingsDialog.vue

@@ -5,6 +5,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
 import WorkspaceKnowledgePanel from './WorkspaceKnowledgePanel.vue'
 import FileAccessPanel from '@/components/app/FileAccessPanel.vue'
+import DeviceAuthorizationPanel from '@/components/app/DeviceAuthorizationPanel.vue'
 import HostToolsPanel from '@/components/app/HostToolsPanel.vue'
 import ExecutionSettingsPanel from '@/components/app/ExecutionSettingsPanel.vue'
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
@@ -56,7 +57,7 @@ onBeforeUnmount(() => { closed = true })
         <button v-for="item in pages" :key="item.id" type="button" :aria-pressed="page === item.id" @click="page = item.id"><AppIcon :name="item.icon" :size="18" /><span>{{item.label}}</span></button>
       </nav>
       <section v-if="page === 'vm' && isAdmin" class="bot-settings__content bot-settings__vm"><header><h3>本地虚拟机</h3><p>为 Bot 配置隔离的工作环境。</p></header><LocalVmSettingsPanel /></section>
-      <section v-else-if="page === 'computers' && isAdmin" class="bot-settings__content"><header><h3>运行环境与授权</h3><p>管理系统执行位置、共享服务授权和虚拟机网络。</p></header><ExecutionSettingsPanel /><HostToolsPanel /></section>
+      <section v-else-if="page === 'computers' && isAdmin" class="bot-settings__content"><header><h3>运行环境与授权</h3><p>管理系统执行位置、共享服务授权和虚拟机网络。</p></header><DeviceAuthorizationPanel /><ExecutionSettingsPanel /><HostToolsPanel /></section>
       <section v-else-if="page === 'files' && isAdmin" class="bot-settings__content"><header><h3>文件访问</h3><p>控制聊天里服务器文件的预览和下载。</p></header><FileAccessPanel /></section>
       <section v-else-if="page === 'memory' && isAdmin" class="bot-settings__content"><header><h3>Bot 记忆</h3><p>管理 Bot 使用的记忆服务。</p></header><SystemManagementPanel section="memory" :active="page === 'memory'" /></section>
       <section v-else class="bot-settings__content bot-settings__knowledge" :aria-label="page === 'projects' ? '项目设置' : '用户记忆设置'" :aria-busy="loading">

+ 8 - 10
src/client/components/workspace/ComputerPanel.vue

@@ -5,14 +5,14 @@ import {apiRequest} from '@/api/client'
 import {browserCanPrepare,prepareManagedBrowser} from '@/utils/managedBrowser'
 import {BrowserDragSession} from '@/utils/browserDrag'
 import BrowserToolbar from './BrowserToolbar.vue'
-import type {BotBrowserState,DesktopEnvironmentState} from '@shared/desktopEnvironment'
+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'
 import type {JsonValue} from '@shared/types'
 const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;standalone?:boolean;autoTake?:boolean;backend?:ComputerBackend,host?:string}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[];targetChanged:[backend:ComputerBackend,host?:string]}>()
 const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
-const backend=ref(props.backend),host=ref(props.host??(props.backend==='desktop'?'local':'')),envTargets=ref<{hosts:DesktopEnvironmentState['hosts'];cloudConfigured:boolean;vmEnabled:boolean;managedBrowser?:ManagedBrowserState}>()
+const backend=ref(props.backend),host=ref(props.host??(props.backend==='desktop'?'local':'')),envTargets=ref<{cloudConfigured:boolean;vmEnabled:boolean;managedBrowser?:ManagedBrowserState}>()
 const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
 const controlsOpen=ref(false),actualSize=ref(false),browserState=ref<BotBrowserState>()
 const dragging=ref(false)
@@ -31,12 +31,11 @@ const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.con
 const canCloseBrowser=computed(()=>backend.value==='managed-browser'&&mine.value&&state.value.mode==='human'&&!state.value.canResume)
 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&&!dragging.value)
-const computerSettings=ref({vm:true,cloud:true,desktop:true,managedBrowser:false})
+const computerSettings=ref({vm:true,cloud:true,managedBrowser:false})
 const targets=computed(()=>{
   const envs=computerSettings.value,info=envTargets.value
   const list:{key:string;label:string;backend:ComputerBackend;host?:string;disabled?:boolean}[]=[]
-  if(envs.desktop)for(const h of info?.hosts??[])list.push({key:'desktop:'+h.id,label:(h.id==='local'?'服务器':'电脑')+' · '+(h.name||'未命名'),backend:'desktop',host:h.id,disabled:!h.online})
-  if(envs.cloud)list.push({key:'cloud',label:'云端 · Grok Bot'+(info?.cloudConfigured?'':' · 未连接'),backend:'cloud',disabled:!info?.cloudConfigured})
+  if(envs.cloud)list.push({key:'cloud',label:'云虚拟机'+(info?.cloudConfigured?'':' · 未连接'),backend:'cloud',disabled:!info?.cloudConfigured})
   if(envs.vm)list.push({key:'vm',label:'本地虚拟机'+(info?.vmEnabled?'':' · 未启用'),backend:'vm',disabled:!info?.vmEnabled})
   if(envs.managedBrowser){const browser=info?.managedBrowser;list.push({key:'managed-browser',label:'托管浏览器'+(browser?.available?'':browserCanPrepare(browser)?' · '+(browser?.installation?.status==='failed'?'准备失败':'待准备'):' · 不可用'),backend:'managed-browser',disabled:!browserCanPrepare(browser)})}
   return list
@@ -49,15 +48,14 @@ async function loadTargets(){
   const selectedId=selected.value,id=encodeURIComponent(selectedId)
   const settings=await apiRequest<{scriptMachine:boolean;serverComputer:boolean;vm:boolean;cloud:boolean;managedBrowser?:boolean}>('/api/app/settings/host-tools')
   if(closed||selectedId!==selected.value)return
-  computerSettings.value={vm:settings.vm,cloud:settings.cloud,desktop:settings.scriptMachine||settings.serverComputer,managedBrowser:settings.managedBrowser===true}
-  const [desk,cloud,vm,browser]=await Promise.all([
-    computerSettings.value.desktop?apiRequest<DesktopEnvironmentState>(`/api/app/agents/${id}/desktop-environment`).catch(()=>undefined):Promise.resolve(undefined),
+  computerSettings.value={vm:settings.vm,cloud:settings.cloud,managedBrowser:settings.managedBrowser===true}
+  const [cloud,vm,browser]=await Promise.all([
     computerSettings.value.cloud?apiRequest<{configured:boolean}>(`/api/app/agents/${id}/cloud-computer`).catch(()=>undefined):Promise.resolve(undefined),
     computerSettings.value.vm?apiRequest<{enabled:boolean}>(`/api/app/agents/${id}/local-vm`).catch(()=>undefined):Promise.resolve(undefined),
     computerSettings.value.managedBrowser?apiRequest<ManagedBrowserState>(`/api/app/agents/${id}/managed-browser`).catch(()=>undefined):Promise.resolve(undefined),
   ])
   if(closed||selectedId!==selected.value)return
-  envTargets.value={hosts:((desk as DesktopEnvironmentState|undefined)?.hosts??[]).filter(h=>h.id==='local'?settings.serverComputer:settings.scriptMachine),cloudConfigured:!!(cloud as {configured?:boolean}|undefined)?.configured,vmEnabled:!!(vm as {enabled?:boolean}|undefined)?.enabled,managedBrowser:browser}
+  envTargets.value={cloudConfigured:!!(cloud as {configured?:boolean}|undefined)?.configured,vmEnabled:!!(vm as {enabled?:boolean}|undefined)?.enabled,managedBrowser:browser}
   if(backend.value==='managed-browser'){managedBrowser.value=browser;browserState.value=browser}
 }
 async function switchTarget(key:string){
@@ -269,7 +267,7 @@ defineExpose({take,close,releaseControl:async()=>{if(preparing.value){preparatio
   <Teleport to="body" :disabled="embedded||standalone">
     <component :is="embedded||standalone?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded,standalone}" :aria-label="targets.find(t=>t.key===currentKey)?.label||'电脑桌面'" @cancel.prevent="close">
       <header>
-        <div><strong>{{backend==='managed-browser'?'托管浏览器':state.backend==='grok'?'Grok Bot 云端电脑':state.backend==='local'?(state.hostName??'服务器桌面'):state.backend==='browser'?'浏览器 · '+state.hostName:'本地虚拟机'}}</strong><small>{{ preparing?'正在准备浏览器':labels[state.mode] }}</small></div>
+        <div><strong>{{backend==='managed-browser'?'托管浏览器':backend==='cloud'?'云虚拟机':'本地虚拟机'}}</strong><small>{{ preparing?'正在准备浏览器':labels[state.mode] }}</small></div>
         <select v-if="targets.length>1" :value="currentKey" aria-label="切换电脑环境" :disabled="!!ticket||busy" @change="switchTarget(($event.target as HTMLSelectElement).value)"><option v-for="target in targets" :key="target.key" :value="target.key" :disabled="target.disabled">{{ target.label }}</option></select>
         <select v-if="agents.length>1" v-model="selected" aria-label="选择电脑" :disabled="!!ticket||busy"><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>

+ 15 - 40
src/client/components/workspace/LocalVmChatPanel.vue

@@ -4,7 +4,6 @@ import {ref,computed,watch,onBeforeUnmount} from 'vue'
 import {apiRequest} from '@/api/client'
 import {browserCanPrepare} from '@/utils/managedBrowser'
 import AppIcon from '@/components/common/AppIcon.vue'
-import type {DesktopEnvironmentState} from '@shared/desktopEnvironment'
 import GrokAuthPanel from './GrokAuthPanel.vue'
 import {type WorkspaceAgent} from '@shared/workspace'
 import type {ComputerFrame} from '@shared/computerControl'
@@ -13,11 +12,9 @@ const props=defineProps<{agents:WorkspaceAgent[];isAdmin:boolean;active?:boolean
 const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:WorkspaceAgent,backend?:ComputerBackend,host?:string];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 computers=ref({scriptMachine:true,serverComputer:true,vm:true,cloud:true,managedBrowser:false})
+const computers=ref({vm:true,cloud:true,managedBrowser:false})
 const managedBrowser=ref<ManagedBrowserState>()
 const recheckingBrowser=ref(false),browserError=ref('')
-const showDesktop=computed(()=>computers.value.scriptMachine||computers.value.serverComputer)
 const cloud=ref<{configured:boolean;running:boolean;connected:boolean;mode?:string}>(),connectionSettings=ref(false)
 const showCloud=computed(()=>computers.value.cloud)
 const showVm=computed(()=>computers.value.vm&&!!state.value)
@@ -31,18 +28,14 @@ watch(()=>state.value,value=>{
  const available=value.desktops?.filter(d=>d.online&&d.ready&&d.available!==false)??[]
  composeDesktopId.value=value.desktopId??(available.length===1?available[0]!.id:'')
 })
-const desktopHosts=computed(()=>(native.value?.hosts??[]).filter(host=>host.id==='local'?computers.value.serverComputer:computers.value.scriptMachine))
-const serverHost=computed(()=>desktopHosts.value.find(host=>host.id==='local'))
 const activeEnvLine=computed(()=>[
- computers.value.serverComputer?'服务器':'',computers.value.scriptMachine?'电脑':'',
- computers.value.cloud?'云端':'',computers.value.vm?'虚拟机':'',
+ computers.value.cloud?'云虚拟机':'',computers.value.vm?'本地虚拟机':'',
  computers.value.managedBrowser?'托管浏览器':'',
 ].filter(Boolean).join(' · '))
 const previewTargets=computed(()=>{
  const list:{key:string;label:string;backend:ComputerBackend;host?:string}[]=[]
- if(computers.value.scriptMachine||computers.value.serverComputer)for(const h of desktopHosts.value){list.push({key:'desktop:'+h.id,label:(h.id==='local'?'服务器 · '+(h.name||'服务器'):'电脑 · '+(h.name||'未命名'))+(h.online?'':' · 离线'),backend:'desktop',host:h.id})}
- if(computers.value.cloud&&cloud.value?.configured)list.push({key:'cloud',label:'云端 · Grok Bot',backend:'cloud'})
- if(computers.value.vm)list.push({key:'vm',label:'虚拟环境',backend:'vm'})
+ if(computers.value.cloud&&cloud.value?.configured)list.push({key:'cloud',label:'云虚拟机',backend:'cloud'})
+ if(computers.value.vm)list.push({key:'vm',label:'本地虚拟机',backend:'vm'})
  if(computers.value.managedBrowser)list.push({key:'managed-browser',label:'托管浏览器',backend:'managed-browser'})
  return list
 })
@@ -50,14 +43,13 @@ const previewKey=ref('')
 const automaticPreviewTarget=computed(()=>{
  const vm=previewTargets.value.find(target=>target.backend==='vm')
  if(vm&&state.value?.enabled&&state.value.container==='running'&&state.value.ready&&!state.value.problem)return vm
- return previewTargets.value.find(target=>target.backend!=='desktop'||desktopHosts.value.some(host=>host.id===target.host&&host.online))
+ return previewTargets.value.find(target=>target.backend==='cloud'&&cloud.value?.running&&cloud.value.connected)||previewTargets.value[0]
 })
 const previewTarget=computed(()=>previewKey.value?previewTargets.value.find(t=>t.key===previewKey.value):automaticPreviewTarget.value)
 const previewLoading=ref(false)
 const previewEmptyText=computed(()=>{
  const target=previewTarget.value
  if(!target)return previewKey.value?'所选电脑已不可用,请重新选择':'暂无已开放的电脑'
- if(target.backend==='desktop'){const host=native.value?.hosts?.find(h=>h.id===target.host);return !host?.online?'这台电脑已离线':host.local.ready?'正在读取电脑画面…':'请完成这台电脑的屏幕控制授权'}
  if(target.backend==='cloud')return cloud.value?.configured?(cloud.value?.running?'正在连接桌面…':'打开云端电脑后可查看桌面'):'尚未连接 Grok Bot 账号'
  if(target.backend==='managed-browser')return !managedBrowser.value?.available?(managedBrowser.value?.installation?.message||managedBrowser.value?.reason||'托管浏览器执行节点不可用'):managedBrowser.value.open?'正在读取浏览器画面…':'浏览器尚未打开,接管后可打开网页'
  if(!state.value?.enabled)return state.value?.fixedCapacity?'请先连接下方的共享桌面':'未使用电脑'
@@ -67,12 +59,6 @@ const previewEmptyText=computed(()=>{
  if(state.value.container==='stopped')return '虚拟机已停止'
  return '正在连接桌面…'
 })
-function authorizeHost(hostId:string,scope?:'full'){
- const body:{hostId?:string;scope?:'full'}={}
- if(hostId)body.hostId=hostId
- if(scope)body.scope=scope
- void run(()=>apiRequest(`/api/app/agents/${selected.value}/desktop-environment/authorize`,{method:'POST',body,timeoutMs:125000}))
-}
 let timer:ReturnType<typeof setTimeout>|undefined,closed=false,revision=0,previewRevision=0,browserRevision=0
 const base=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/local-vm`
 async function readBrowser(){
@@ -89,7 +75,7 @@ async function refreshFrame(){
  const usable=target?.backend==='vm'?state.value?.container==='running'&&state.value?.ready
   :target?.backend==='cloud'?!!cloud.value?.running&&!!cloud.value?.connected
   :target?.backend==='managed-browser'?!!managedBrowser.value?.available&&!!managedBrowser.value?.open
-  :target?.backend==='desktop'&&desktopHosts.value.some(h=>h.id===target.host&&h.online&&h.local.ready)
+  :false
  if(!target||!usable){frame.value=undefined;previewLoading.value=false;return}
  previewLoading.value=true
  try{
@@ -104,16 +90,15 @@ async function refresh(){
  if(closed||props.active===false||!selected.value||document.hidden||busy.value)return
  const version=revision
  try{
-  try{const tools=await apiRequest<{scriptMachine?:boolean;serverComputer?:boolean;vm?:boolean;cloud?:boolean;managedBrowser?:boolean}>('/api/app/settings/host-tools');if(version===revision&&!closed)computers.value={scriptMachine:tools.scriptMachine!==false,serverComputer:tools.serverComputer!==false,vm:tools.vm!==false,cloud:tools.cloud!==false,managedBrowser:tools.managedBrowser===true}}catch{}
+  try{const tools=await apiRequest<{vm?:boolean;cloud?:boolean;managedBrowser?:boolean}>('/api/app/settings/host-tools');if(version===revision&&!closed)computers.value={vm:tools.vm!==false,cloud:tools.cloud!==false,managedBrowser:tools.managedBrowser===true}}catch{}
   const id=selected.value
-  const [desktopResult,cloudResult,vmResult]=await Promise.all([
-   apiRequest<DesktopEnvironmentState>(`/api/app/agents/${id}/desktop-environment`),
+  const [cloudResult,vmResult]=await Promise.all([
    apiRequest<typeof cloud.value>(`/api/app/agents/${id}/cloud-computer`),
    computers.value.vm?apiRequest<typeof state.value>(base()):Promise.resolve(undefined),
    computers.value.managedBrowser?readBrowser():Promise.resolve(undefined),
   ])
   if(version!==revision||closed)return
-  native.value=desktopResult;cloud.value=cloudResult;state.value=vmResult
+  cloud.value=cloudResult;state.value=vmResult
   await refreshFrame()
   if(version!==revision||closed)return
   error.value=''
@@ -127,7 +112,7 @@ async function connectCompose(){
 }
 async function openDesktop(backend:ComputerBackend,host?:string){
  const selectedAgent=agent.value
- if(!selectedAgent||busy.value)return
+ if(!selectedAgent||busy.value||!['vm','cloud','managed-browser'].includes(backend))return
  if(backend==='cloud'&&!await run(()=>apiRequest(`/api/app/agents/${encodeURIComponent(selectedAgent.id)}/cloud-computer/open`,{method:'POST',body:{}})))return
  if(!closed&&agent.value?.id===selectedAgent.id)emit('desktop',selectedAgent,backend,host)
 }
@@ -142,7 +127,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++;previewRevision++;browserRevision++;state.value=undefined;composeDesktopId.value='';native.value=undefined;cloud.value=undefined;managedBrowser.value=undefined;browserError.value='';frame.value=undefined;error.value='';loading.value=true;previewKey.value='';clearTimeout(timer);void cycle()},{immediate:true})
+watch(selected,()=>{revision++;previewRevision++;browserRevision++;state.value=undefined;composeDesktopId.value='';cloud.value=undefined;managedBrowser.value=undefined;browserError.value='';frame.value=undefined;error.value='';loading.value=true;previewKey.value='';clearTimeout(timer);void cycle()},{immediate:true})
 watch(()=>props.active,value=>{if(value)void refresh()})
 onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer)})
 </script>
@@ -160,7 +145,7 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
      <span v-if="frame" class="open-badge"><AppIcon name="external" :size="12"/>打开桌面</span>
     </button>
     <p v-if="error" class="problem" role="alert">{{error}}</p>
-    <p class="hint">电脑开关在 Bot 设置里,对所有机器人一起生效。</p>
+    <p class="hint">可查看和接管本地虚拟机、云虚拟机和托管浏览器。设备授权状态在 Bot 设置中查看。</p>
     <div class="computer-options">
     <section v-if="computers.managedBrowser" class="managed-browser-option" aria-label="托管浏览器">
       <strong>托管浏览器</strong>
@@ -170,25 +155,15 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
       <button v-if="!managedBrowser?.available" type="button" :disabled="busy||recheckingBrowser" :aria-busy="recheckingBrowser" @click="recheckBrowser">{{recheckingBrowser?'正在检测…':'重新检测'}}</button>
       <button class="primary" :disabled="busy||recheckingBrowser||!browserCanPrepare(managedBrowser)" @click="openDesktop('managed-browser')">{{managedBrowser?.installation?.status==='failed'?'重试并接管浏览器':managedBrowser?.open?'接管浏览器':'打开并接管浏览器'}}</button>
     </section>
-    <template v-if="showDesktop">
-      <p>服务器是运行夭夭服务的电脑;电脑是连接这台服务器的 Mac 或 Windows 电脑。两者都可改名。「本机」跟你当前发消息所在的电脑走。要文件用文件工具,要命令用 shell;只有需要看窗口或点按时才截图。</p>
-      <div v-for="host in desktopHosts" :key="host.id" class="host-permission" :class="{offline:!host.online}">
-       <div class="host-permission-head"><strong>{{host.id==='local'?('服务器 · '+(host.name||'服务器')):('电脑 · '+(host.name||'未命名'))}}</strong><small>{{host.online?'在线':'离线'}}</small></div>
-       <div class="permission-list"><span>{{host.local.authorized?'✓':'○'}} 屏幕控制授权</span><span>{{host.local.screen?'✓':'○'}} {{host.platform==='win32'?'屏幕可用':'屏幕录制'}}</span><span>{{host.local.accessibility?'✓':'○'}} {{host.platform==='win32'?'键鼠可用':'辅助功能'}}</span><span>{{host.local.fullAuthorized?'✓':'○'}} 文件与命令</span></div>
-       <button v-if="host.online&&!host.local.ready" class="primary" :disabled="busy||!isAdmin" @click="authorizeHost(host.id)">在桌面端授权</button>
-       <p v-if="host.online&&!host.local.ready" class="hint">{{host.platform==='win32'?'在 Windows 客户端确认授权,并解锁电脑、保持桌面会话连接。':'在目标 Mac 上确认授权,并按系统提示开启屏幕录制和辅助功能;可能需要重新打开 App。'}}</p>
-       <button v-if="host.online&&!host.local.fullAuthorized" :disabled="busy||!isAdmin" @click="authorizeHost(host.id,'full')">授权文件与命令</button>
-       <p v-if="host.online&&!host.local.fullAuthorized" class="hint">授权后,机器人可以在这台电脑的用户主目录内读写文件,并以当前用户权限执行命令;每台电脑单独授权,可随时撤销。</p>
-      </div>
-      <button v-if="serverHost" class="primary" :disabled="busy||!serverHost.online||!serverHost.local.ready" @click="openDesktop('desktop','local')">接管服务器</button>
-    </template>
     <template v-if="showCloud">
+     <strong>云虚拟机</strong>
      <p>多个机器人共用同一台 Grok Bot 云端虚拟机、工作文件和浏览器登录。基础机器人和聊天记录保持独立。</p>
-     <button v-if="cloud?.configured" class="primary" :disabled="busy" @click="openDesktop('cloud')">{{cloud?.mode==='human'?'打开控制中的桌面':'接管云端电脑'}}</button>
+     <button v-if="cloud?.configured" class="primary" :disabled="busy" @click="openDesktop('cloud')">{{cloud?.mode==='human'?'打开控制中的桌面':'接管云虚拟机'}}</button>
      <button @click="connectionSettings=!connectionSettings">{{cloud?.configured?'云端连接设置':'连接 Grok Bot'}}</button>
      <p v-if="!isAdmin&&!cloud?.configured" class="hint">请由管理员为当前账号配置 Grok Bot 连接。</p>
     </template>
     <template v-if="showVm&&state">
+     <strong>本地虚拟机</strong>
      <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.fixedCapacity||state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此机器人的独立虚拟机'}}</p>
      <p v-if="state.problem" class="problem" role="alert" aria-label="本地虚拟机状态">{{state.problem}}</p>
      <template v-if="state.fixedCapacity&&!agent.temporaryGoalId">

+ 1 - 0
src/client/env.d.ts

@@ -22,6 +22,7 @@ interface Window {
     switchMode?(mode: 'client' | 'server'): Promise<{ ok: boolean; error?: string; pendingLogin?: boolean }>
     openRemoteLogin?(): Promise<void>
     authorizeComputer?(csrfToken: string): Promise<{ registered: boolean; hostId?: string }>
+    authorizeCurrentDevice?(scope: 'screen' | 'full'): Promise<{ ok: boolean }>
     deviceHost?(): Promise<{ deviceHost: string | null }>
     openComputer(id: string, target?: { backend?: import('@shared/managedBrowser').ComputerBackend; host?: string }): Promise<boolean>
     computerTargetChanged?(target: { backend: import('@shared/managedBrowser').ComputerBackend; host?: string }): Promise<void>

+ 2 - 2
src/client/views/ComputerViewerView.vue

@@ -20,9 +20,9 @@ onMounted(async () => {
   detach = window.yaoyaoDesktop?.onComputerClose(() => { void close() })
   try {
     const requestedBackend = route.query.backend, requestedHost = route.query.host
-    if (requestedBackend !== undefined && requestedBackend !== 'desktop' && requestedBackend !== 'cloud' && requestedBackend !== 'vm' && requestedBackend !== 'managed-browser')
+    if (requestedBackend !== undefined && requestedBackend !== 'cloud' && requestedBackend !== 'vm' && requestedBackend !== 'managed-browser')
       throw new Error('所选电脑无效,请关闭窗口后重新选择')
-    if (requestedHost !== undefined && (requestedBackend !== 'desktop' || typeof requestedHost !== 'string' || !/^[\w-]{1,128}$/.test(requestedHost)))
+    if (requestedHost !== undefined)
       throw new Error('所选电脑无效,请关闭窗口后重新选择')
     backend.value = requestedBackend
     host.value = typeof requestedHost === 'string' ? requestedHost : undefined

+ 17 - 11
src/server/desktopEnvironments.ts

@@ -80,7 +80,6 @@ export const SERVER_COMPUTER_RULES='操作客户端提供的服务器桌面代
  *  pairing. Browser/desktop authority never crosses into the renderer. */
 export class DesktopEnvironments {
  private readonly sessions=new Map<string,HostState>()
- private authorizing=new Map<string,Promise<unknown>>()
  private toolFrames=new WeakMap<AbortSignal,string>()
  private closed=false
  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes){}
@@ -178,7 +177,16 @@ export class DesktopEnvironments {
  deviceContextLine(owner:string,deviceHost?:string){return deviceSourceText(this.snapshot(owner,undefined,deviceHost))}
  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)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'))}
- hostStates(owner:string){return [...this.sessions.values()].map(s=>{const online=this.hostOnline(s),info=online?s.info!:undefined;return {id:s.key,name:this.label(s),platform:info?.platform??'',online,local:{supported:['darwin','win32'].includes(info?.platform??''),authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}
+ hostStates(owner:string){
+  const records=this.store.list<import('../shared/desktopHost.js').DesktopHostRecord>('_system','desktop-host')
+  const states=[...this.sessions.values()]
+  for(const record of records)if(record.enabled&&!states.some(s=>s.key===record.id))states.push(newHostState(record.id,true))
+  return states.filter(s=>!records.some(record=>record.id===s.key&&!record.enabled)).map(s=>{
+   const online=this.hostOnline(s),info=online?s.info!:undefined
+   return {id:s.key,name:this.label(s)||records.find(record=>record.id===s.key)?.name||s.key,platform:info?.platform??'',online,
+    local:{supported:['darwin','win32'].includes(info?.platform??''),authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online}}
+  }).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))
+ }
  state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent),s=this.sessionFor(agent),online=!!s&&this.hostOnline(s),info=online?s!.info:undefined;return {online,host:info?{id:s!.key,name:this.label(s!),platform:info.platform}:null,local:{supported:['darwin','win32'].includes(info?.platform??''),authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s!,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
  /** Every Bot follows the same global desktop policy. */
  private modeOpen(owner:string,agent:WorkspaceAgent,mode:Mode){return this.selected(owner,agent)===mode}
@@ -293,15 +301,13 @@ export class DesktopEnvironments {
  private grant(s:HostState,owner:string,a:WorkspaceAgent,c:ReturnType<DesktopEnvironments['context']>,body:any){const m=this.current(s,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);const a=this.agent(user.id,ctx.params.id)
-   const body=parse(z.object({hostId:z.union([z.literal('local'),z.string().uuid()]).optional(),scope:z.enum(['screen','full']).default('screen')}).strict(),(ctx.request as any).body??{})
-   const s=body.hostId!==undefined?this.sessions.get(body.hostId):this.sessionFor(a)
-   if(!s||!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
-   const owner=this.ownerKey(user.id),epoch=s.info!.id,authorizeKey=user.id+':'+s.key+':'+body.scope
-   let pending=this.authorizing.get(authorizeKey)
-   if(!pending){pending=this.command(s,{operation:'authorize',owner,account:user.username,scope:body.scope},()=>{this.auth.requireAdmin(ctx);if(!this.hostOnline(s)||epoch!==s.info!.id)throw new HttpError(409,'桌面端已断开','desktop_offline')},120000).finally(()=>this.authorizing.delete(authorizeKey));this.authorizing.set(authorizeKey,pending)}
-   ctx.body=await pending})
+  router.get('/api/app/desktop-environment',ctx=>{const user=this.auth.require(ctx);this.auth.requireAdmin(ctx);ctx.set('Cache-Control','no-store');ctx.body={hosts:this.hostStates(user.id)}})
+  router.get('/api/app/desktop-environment/authorization',ctx=>{const user=this.auth.require(ctx);this.auth.requireAdmin(ctx);ctx.set('Cache-Control','no-store');ctx.body={owner:this.ownerKey(user.id),account:user.username}})
+  // Authorization is granted by the current native client, never dispatched to a selected host.
+  router.post('/api/app/agents/:id/desktop-environment/authorize',ctx=>{
+   this.auth.requireAdmin(ctx)
+   throw new HttpError(403,'请在需要授权的那台电脑上打开客户端,在 Bot 设置中授权当前设备。','desktop_local_authorization_required')
+  })
   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 s=this.sessionFor(a);ctx.body=await this.command(s!,{...this.context(s,owner,a,'browser').command,operation:'browser-state'},this.context(s,owner,a,'browser').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(typeof ctx.query.backend==='string'&&ctx.query.backend!=='desktop')return next();if(!mode)return next();this.agent(owner,a.id);ctx.body=this.status(this.takeoverSession(a,ctx.query.host),owner,a,mode)})

+ 24 - 36
tests/client/computerTargetSelection.test.ts

@@ -33,17 +33,17 @@ beforeEach(()=>{
 })
 afterEach(()=>{vi.restoreAllMocks();vi.clearAllMocks()})
 
-it('scopes an unavailable VM to its own settings while the server preview remains usable',async()=>{
+it('scopes an unavailable VM to its own settings while the cloud preview remains usable',async()=>{
  vmProblem='本地虚拟机执行节点尚未启用,请在本地虚拟机设置中完成配置'
  const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  try{
   await flushPromises()
-  expect(wrapper.get('[aria-label="显示的桌面"] option:checked').text()).toBe('自动 · 服务器 · 服务器')
-  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,local')
+  expect(wrapper.get('[aria-label="显示的桌面"] option:checked').text()).toBe('自动 · 云虚拟机')
+  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,cloud')
   expect(wrapper.find('.panel-body > .problem').exists()).toBe(false)
   expect(wrapper.get('.computer-options [aria-label="本地虚拟机状态"]').text()).toBe(vmProblem)
-  await wrapper.get('.preview').trigger('click')
-  expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'desktop','local'])
+  await wrapper.get('.preview').trigger('click');await flushPromises()
+  expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'cloud',undefined])
   await wrapper.get('[aria-label="显示的桌面"]').setValue('vm');await flushPromises()
   expect(wrapper.get('.preview').text()).toContain(vmProblem)
   expect(wrapper.find('.preview img').exists()).toBe(false)
@@ -55,48 +55,36 @@ it('refreshes the selected preview immediately and never replaces it with a late
  const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  try{
   await flushPromises()
-  expect(wrapper.get('[aria-label="显示的桌面"] option:checked').text()).toBe('自动 · 虚拟环境')
+  expect(wrapper.get('[aria-label="显示的桌面"] option:checked').text()).toBe('自动 · 本地虚拟机')
   expect(wrapper.get('.preview img').attributes('src')).toContain('base64,vm')
   let resolveClient!:(value:unknown)=>void
-  readFrame=key=>key==='client-mac'?new Promise(resolve=>{resolveClient=resolve}):screenshot(key)
-  await wrapper.get('[aria-label="显示的桌面"]').setValue('desktop:client-mac')
+  readFrame=key=>key==='cloud'?new Promise(resolve=>{resolveClient=resolve}):screenshot(key)
+  await wrapper.get('[aria-label="显示的桌面"]').setValue('cloud')
   await flushPromises()
   expect(wrapper.find('.preview img').exists()).toBe(false)
   expect(wrapper.get('.preview').attributes('disabled')).toBeDefined()
-  await wrapper.get('[aria-label="显示的桌面"]').setValue('cloud');await flushPromises()
-  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,cloud')
-  resolveClient(screenshot('stale-client'));await flushPromises()
-  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,cloud')
+  await wrapper.get('[aria-label="显示的桌面"]').setValue('vm');await flushPromises()
+  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,vm')
+  resolveClient(screenshot('stale-cloud'));await flushPromises()
+  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,vm')
   await wrapper.get('.preview').trigger('click');await flushPromises()
-  expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'cloud',undefined])
-  readFrame=screenshot
-  for(const [key,backend,host] of [['desktop:local','desktop','local'],['desktop:client-mac','desktop','client-mac'],['vm','vm',undefined]]){
-   await wrapper.get('[aria-label="显示的桌面"]').setValue(key);await flushPromises()
-   expect(wrapper.get('.preview img').attributes('src')).toContain('base64,'+(host||backend))
-   await wrapper.get('.preview').trigger('click');await flushPromises()
-   expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,backend,host])
-  }
+  expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'vm',undefined])
  }finally{wrapper.unmount()}
 })
 
-it('keeps an offline or removed explicit selection instead of opening the server',async()=>{
+it('never exposes physical desktops or authorization in the computer panel',async()=>{
  const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  try{
-  await flushPromises();await wrapper.get('[aria-label="显示的桌面"]').setValue('desktop:client-mac');await flushPromises()
-  hosts[1].online=false
-  await wrapper.setProps({active:false});await wrapper.setProps({active:true});await flushPromises()
-  expect(wrapper.text()).toContain('这台电脑已离线')
-  expect(wrapper.find('.preview img').exists()).toBe(false)
-  hosts=hosts.slice(0,1)
-  await wrapper.setProps({active:false});await wrapper.setProps({active:true});await flushPromises()
-  expect(wrapper.text()).toContain('所选电脑已不可用')
-  expect(wrapper.get('.preview').attributes('disabled')).toBeDefined()
-  expect(wrapper.emitted('desktop')).toBeUndefined()
+  await flushPromises()
+  expect(wrapper.findAll('option').some(option=>option.attributes('value')?.startsWith('desktop:'))).toBe(false)
+  expect(wrapper.find('.host-permission').exists()).toBe(false)
+  expect(wrapper.findAll('button').some(button=>button.text().includes('授权'))).toBe(false)
+  expect(vi.mocked(apiRequest).mock.calls.some(([path])=>path.endsWith('/desktop-environment'))).toBe(false)
  }finally{wrapper.unmount()}
 })
 
 it.each([
- ['desktop','local'],['desktop','client-mac'],['cloud',undefined],['vm',undefined],
+ ['cloud',undefined],['vm',undefined],
 ] as const)('opens and controls exactly the requested %s/%s target',async(backend,host)=>{
  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend,host,standalone:true,autoTake:true},global:{stubs}})
  try{
@@ -109,8 +97,8 @@ it.each([
  }finally{wrapper.unmount()}
 })
 
-it('does not auto-take a different computer when the requested host is missing',async()=>{
- const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'desktop',host:'missing-host',standalone:true,autoTake:true},global:{stubs}})
+it.each(['local','client-mac','missing-host'])('rejects the physical desktop %s without falling back',async(host)=>{
+ const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'desktop',host,standalone:true,autoTake:true},global:{stubs}})
  try{
   await flushPromises()
   expect(wrapper.get('[role="alert"]').text()).toContain('所选电脑不可用')
@@ -120,11 +108,11 @@ it('does not auto-take a different computer when the requested host is missing',
 })
 
 it('passes the native window URL target into the viewer and rejects malformed targets',async()=>{
- route.query={backend:'desktop',host:'client-mac'}
+ route.query={backend:'cloud'}
  const wrapper=mount(Viewer,{global:{stubs:{ComputerPanel:true}}})
  try{
   await flushPromises()
-  expect(wrapper.getComponent(ComputerPanel).props()).toMatchObject({backend:'desktop',host:'client-mac',autoTake:true})
+  expect(wrapper.getComponent(ComputerPanel).props()).toMatchObject({backend:'cloud',host:undefined,autoTake:true})
  }finally{wrapper.unmount()}
  route.query={backend:'vm',host:'client-mac'}
  const invalid=mount(Viewer,{global:{stubs:{ComputerPanel:true}}})

+ 52 - 0
tests/client/deviceAuthorizationPanel.test.ts

@@ -0,0 +1,52 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import DeviceAuthorizationPanel from '../../src/client/components/app/DeviceAuthorizationPanel.vue'
+import { apiRequest } from '../../src/client/api/client'
+
+vi.mock('../../src/client/api/client', () => ({ apiRequest: vi.fn() }))
+const permissions = { supported: true, authorized: false, screen: false, accessibility: false, ready: false, fullAuthorized: false }
+let wrapper: ReturnType<typeof mount> | undefined
+beforeEach(() => {
+  vi.spyOn(document, 'hidden', 'get').mockReturnValue(false)
+  vi.mocked(apiRequest).mockResolvedValue({ hosts: [
+    { id: 'local', name: '服务器', online: true, platform: 'darwin', local: permissions },
+    { id: 'current', name: '我的电脑', online: true, platform: 'darwin', local: permissions },
+    { id: 'other', name: '另一台电脑', online: true, platform: 'win32', local: { ...permissions, fullAuthorized: true } },
+    { id: 'offline', name: '离线电脑', online: false, platform: '', local: permissions },
+  ] })
+})
+afterEach(() => { wrapper?.unmount(); wrapper = undefined; delete window.yaoyaoDesktop; vi.restoreAllMocks(); vi.clearAllMocks() })
+
+it.each(['current', 'local'])('authorizes only the native current device %s while showing other devices read-only', async current => {
+  const authorize = vi.fn().mockResolvedValue({ ok: true })
+  window.yaoyaoDesktop = { deviceHost: async () => ({ deviceHost: current }), authorizeCurrentDevice: authorize } as any
+  wrapper = mount(DeviceAuthorizationPanel)
+  await flushPromises()
+  const active = wrapper.get(`[data-host-id="${current}"]`)
+  expect(active.text()).toContain('当前设备')
+  expect(active.findAll('button')).toHaveLength(2)
+  for (const id of ['local', 'current', 'other', 'offline'].filter(id => id !== current)) {
+    expect(wrapper.get(`[data-host-id="${id}"]`).findAll('button')).toHaveLength(0)
+  }
+  expect(wrapper.get('[data-host-id="other"]').text()).toContain('已授权')
+  expect(wrapper.get('[data-host-id="offline"]').text()).toContain('连接后更新授权状态')
+  await active.findAll('button')[1].trigger('click'); await flushPromises()
+  expect(authorize).toHaveBeenCalledExactlyOnceWith('full')
+  expect(vi.mocked(apiRequest).mock.calls.every(([, options]) => !options?.method || options.method === 'GET')).toBe(true)
+})
+
+it.each([undefined, { deviceHost: async () => ({ deviceHost: 'current' }) }])('keeps browser and old client devices read-only', async desktop => {
+  window.yaoyaoDesktop = desktop as any
+  wrapper = mount(DeviceAuthorizationPanel); await flushPromises()
+  expect(wrapper.findAll('article button')).toHaveLength(0)
+  expect(wrapper.findAll('article')).toHaveLength(4)
+})
+
+it('keeps authorization failure visible for retry without claiming success', async () => {
+  window.yaoyaoDesktop = { deviceHost: async () => ({ deviceHost: 'current' }), authorizeCurrentDevice: vi.fn().mockRejectedValue(new Error('本机控制授权已取消')) } as any
+  wrapper = mount(DeviceAuthorizationPanel); await flushPromises()
+  await wrapper.get('[data-host-id="current"] button').trigger('click'); await flushPromises()
+  expect(wrapper.get('[role="alert"]').text()).toContain('已取消')
+  expect(wrapper.find('[role="status"]').exists()).toBe(false)
+  expect(wrapper.get('[data-host-id="current"] button').attributes('disabled')).toBeUndefined()
+})

+ 3 - 3
tests/client/managedBrowser.test.ts

@@ -58,7 +58,7 @@ it('shows the independent browser beside existing environments without opening i
   const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
   try{
     await flushPromises()
-    for(const key of ['desktop:local','cloud','vm','managed-browser'])expect(wrapper.find(`option[value="${key}"]`).exists()).toBe(true)
+    for(const key of ['cloud','vm','managed-browser'])expect(wrapper.find(`option[value="${key}"]`).exists()).toBe(true)
     await wrapper.get('[aria-label="显示的桌面"]').setValue('managed-browser');await flushPromises()
     expect(wrapper.get('.preview').text()).toContain('浏览器尚未打开')
     expect(wrapper.find('.preview img').exists()).toBe(false)
@@ -161,7 +161,7 @@ it.each([
   }finally{wrapper.unmount()}
 })
 
-it.each(['desktop','cloud','vm'] as const)('does not expose managed browser close for a %s target',async(backend)=>{
+it.each(['cloud','vm'] as const)('does not expose managed browser close for a %s target',async(backend)=>{
   const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend,standalone:true,autoTake:true},global:{stubs}})
   try{
     await flushPromises()
@@ -208,7 +208,7 @@ it('ignores browser metadata that arrives after a target switch',async()=>{
   const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true},global:{stubs}})
   try{
     await flushPromises()
-    await wrapper.get('[aria-label="切换电脑环境"]').setValue('desktop:local');await flushPromises()
+    await wrapper.get('[aria-label="切换电脑环境"]').setValue('vm');await flushPromises()
     release({...state,tabs:[{id:'late',title:'延迟旧页面',url:'https://old.example',active:true}]});await flushPromises()
     expect(wrapper.findComponent(BrowserToolbar).exists()).toBe(false)
     expect(wrapper.text()).not.toContain('延迟旧页面')

+ 9 - 3
tests/e2e/desktop-hosts.spec.ts

@@ -37,7 +37,7 @@ test('registers a computer, downloads its private config and disables it', async
   expect((await (await page.request.get('/api/app/admin/desktop-hosts')).json()).hosts.find((h: any) => h.id === configuration.hostId).enabled).toBe(false)
 })
 
-test('shows globally available hosts for a legacy Bot without per-Bot switches', async ({ page }, testInfo) => {
+test('shows other device status only in Bot settings', async ({ page }, testInfo) => {
   await page.goto('/conversations')
   await page.getByRole('textbox', { name: '账号', exact: true }).fill('fixture')
   await page.getByRole('textbox', { name: '密码', exact: true }).fill('fixture-pass')
@@ -68,11 +68,17 @@ test('shows globally available hosts for a legacy Bot without per-Bot switches',
   const dock = page.getByRole('complementary', { name: '机器人电脑面板' })
   await expect(dock.locator('.env-check')).toHaveCount(0)
   await expect(dock.getByRole('combobox', { name: '操作的电脑', exact: true })).toHaveCount(0)
-  const hostCard = dock.locator('.host-permission', { hasText: '客厅 Mac mini' })
+  await expect(dock.locator('.host-permission')).toHaveCount(0)
+  await page.locator('.desktop-sidebar').getByRole('button',{name:'工具',exact:true}).click()
+  await page.getByRole('menuitem',{name:'Bot 设置',exact:true}).click()
+  const settings=page.getByRole('dialog',{name:'Bot 设置',exact:true})
+  await settings.getByRole('button',{name:'环境与授权',exact:true}).click()
+  const hostCard=settings.locator('.device-authorization article',{hasText:'客厅 Mac mini'})
   await expect(hostCard).toBeVisible()
   await expect(hostCard).toContainText('在线')
+  await expect(hostCard.locator('button')).toHaveCount(0)
   await hostCard.scrollIntoViewIfNeeded()
-  await page.screenshot({ path: testInfo.outputPath('desktop-host-global-permissions.png') })
+  await page.screenshot({path:testInfo.outputPath('desktop-host-settings-status.png')})
   const agents = (await (await page.request.get('/api/app/agents')).json()).agents
   const updated = agents.find((a: any) => a.id === seed.agentId)
   expect(updated).toMatchObject({ envs: { vm: true, desktop: true } })

+ 37 - 13
tests/e2e/native-environment.spec.ts

@@ -1,14 +1,38 @@
-import {test,expect} from '@playwright/test'
-import {mkdirSync} from 'node:fs'
-test('Web shows native desktop permissions',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:'机器人电脑面板'})
- const desktop=panel.getByRole('combobox',{name:'显示的桌面'})
- await expect(desktop).toBeEnabled({timeout:15000});await desktop.selectOption('desktop:local')
- await expect(panel.getByRole('button',{name:'在桌面端授权',exact:true})).toBeVisible();await expect(panel.getByText(/服务器是运行夭夭服务的电脑;电脑是连接这台服务器的 Mac/)).toBeVisible();await page.screenshot({path:evidence+'/desktop-native-permissions.png'})
- await expect(panel.getByRole('tab',{name:'仅浏览器',exact:true})).toHaveCount(0)
- await expect(panel.getByRole('checkbox',{name:'电脑浏览器',exact:true})).toHaveCount(0)
+import { test, expect } from '@playwright/test'
+
+test('shows device permissions only in Bot settings and keeps Web authorization read-only', async ({ page }, testInfo) => {
+  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 expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
+  const seed = await (await page.request.post('/__test/computer', { data: {} })).json()
+  await page.goto(`/conversations/${seed.conversationId}`)
+  await page.getByRole('button', { name: '电脑与定时任务', exact: true }).click()
+  const panel = page.getByRole('complementary', { name: '机器人电脑面板' })
+  await expect(panel).toBeVisible()
+  await expect(panel.locator('.host-permission')).toHaveCount(0)
+  await expect(panel.locator('option[value^="desktop:"]')).toHaveCount(0)
+  await expect(panel.getByRole('button', { name: /授权/ })).toHaveCount(0)
+  await page.screenshot({ path: testInfo.outputPath('computer-panel.png') })
+  await page.locator('.desktop-sidebar').getByRole('button', { name: '工具', exact: true }).click()
+  await page.getByRole('menuitem', { name: 'Bot 设置', exact: true }).click()
+  const settings = page.getByRole('dialog', { name: 'Bot 设置', exact: true })
+  await settings.getByRole('button', { name: '环境与授权', exact: true }).click()
+  const devices = settings.locator('.device-authorization')
+  await expect(devices.locator('article').first()).toBeVisible()
+  await expect(devices).toContainText('屏幕控制')
+  await expect(devices).toContainText('浏览器和手机端仅可查看设备状态')
+  await expect(devices.locator('article button')).toHaveCount(0)
+  for (const theme of ['light', 'dark'] as const) {
+    await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' })
+    await page.screenshot({ path: testInfo.outputPath(`device-status-${theme}.png`) })
+  }
+  for (const viewport of [{ width: 375, height: 812 }, { width: 812, height: 375 }]) {
+    await page.setViewportSize(viewport)
+    await devices.locator('article').first().scrollIntoViewIfNeeded()
+    expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false)
+    expect(await devices.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(false)
+    await page.screenshot({ path: testInfo.outputPath(`device-status-${viewport.width}.png`) })
+  }
 })

+ 61 - 0
tests/server/currentComputerAuthorization.test.ts

@@ -0,0 +1,61 @@
+// @vitest-environment node
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { DesktopHostCore } from '../../desktop/environment-host.mjs'
+import { authorizeCurrentComputer } from '../../desktop/computer-authorization.mjs'
+import { dialog } from 'electron'
+
+vi.mock('electron', () => ({
+  app: { isPackaged: false }, BrowserWindow: class {}, session: {},
+  desktopCapturer: {}, shell: {},
+  dialog: { showMessageBox: vi.fn() },
+  screen: { on: vi.fn(), removeListener: vi.fn() },
+  powerMonitor: { on: vi.fn(), removeListener: vi.fn() },
+  systemPreferences: { isTrustedAccessibilityClient: () => true, getMediaAccessStatus: () => 'granted' },
+}))
+let home: string, core: any
+const owner = 'a'.repeat(64)
+beforeEach(() => {
+  home = mkdtempSync(join(tmpdir(), 'native-authorization-'))
+  core = new DesktopHostCore({ root: home, dataRoot: home })
+  core.platform = { computer: true }
+  vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false })
+})
+afterEach(async () => { await core.close(); rmSync(home, { recursive: true, force: true }); vi.clearAllMocks() })
+const request = (scope = 'full', isCurrent = () => true, context = { owner, account: '当前账号' }) => {
+  const fetchImpl = vi.fn().mockResolvedValue({ ok: true, json: async () => context })
+  return { fetchImpl, promise: authorizeCurrentComputer({ scope, core, origin: 'https://example.test', cookies: [{ name: 'session', value: 'test' }], fetchImpl, isCurrent }) }
+}
+it('grants only the current native core after reading the authenticated account context', async () => {
+  const { promise, fetchImpl } = request()
+  await expect(promise).resolves.toEqual({ ok: true })
+  expect(core.approvedFull.has(owner)).toBe(true)
+  expect(core.approved.size).toBe(0)
+  expect(JSON.parse(readFileSync(join(home, 'computer-permissions.json'), 'utf8'))).toEqual({ screen: [], full: [owner] })
+  expect(fetchImpl.mock.calls[0][0].pathname).toBe('/api/app/desktop-environment/authorization')
+  expect(fetchImpl.mock.calls[0][1]).toMatchObject({ method: 'GET', redirect: 'error' })
+})
+it('rejects server-dispatched authorization without showing a dialog', async () => {
+  await expect(core.execute({ operation: 'authorize', scope: 'full', owner, deadline: Date.now() + 10000 })).rejects.toThrow('当前设备')
+  expect(dialog.showMessageBox).not.toHaveBeenCalled()
+  expect(core.approvedFull.size).toBe(0)
+})
+it('does not grant if the client connection changes while confirmation is open', async () => {
+  let current = true
+  vi.mocked(dialog.showMessageBox).mockImplementation(async () => { current = false; return { response: 1, checkboxChecked: false } })
+  await expect(request('full', () => current).promise).rejects.toThrow('已取消')
+  expect(core.approvedFull.size).toBe(0)
+})
+it('rejects an all-devices scope before fetching or confirming', async () => {
+  const { promise, fetchImpl } = request('all')
+  await expect(promise).rejects.toThrow('授权范围无效')
+  expect(fetchImpl).not.toHaveBeenCalled()
+  expect(dialog.showMessageBox).not.toHaveBeenCalled()
+})
+it('leaves grants unchanged when confirmation is cancelled', async () => {
+  vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false })
+  await expect(request().promise).rejects.toThrow('已取消')
+  expect(core.approvedFull.size).toBe(0)
+})

+ 18 - 4
tests/server/desktopEnvironments.test.ts

@@ -14,7 +14,7 @@ import {saveHostTools} from '../../src/server/hostToolSettings'
 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 auth={require:(ctx:any)=>({id:ctx.get('x-user')||'owner',username:'测试账号'}),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:'Desktop',nodeId:'local',profile:'default',computer:'local'});host={id:randomUUID(),name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[ownerKey('owner')]}})
@@ -147,11 +147,25 @@ it('gates file and shell tools behind the separate full grant',async()=>{
  expect(seen.find(c=>c.operation==='file')?.action).toMatchObject({op:'list',path:'Documents'})
  await expect(service.call('owner',agent.id,'desktop_file_write',{host:'server',path:'a.txt',data:'!!!not base64!!!'},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent))).rejects.toMatchObject({status:400})
 })
-it('passes the authorize scope through to the computer',async()=>{
+it('rejects remote authorization for every scope and target without dispatching a command',async()=>{
  const seen:any[]=[]
  drive(c=>{seen.push(c);return {ok:true}})
- await request(app()).post(base()+'/desktop-environment/authorize').send({scope:'full'}).expect(200)
- expect(seen.find(c=>c.operation==='authorize')?.scope).toBe('full')
+ for(const hostId of [undefined,'local',randomUUID()])for(const scope of ['screen','full']){
+  const result=await request(app()).post(base()+'/desktop-environment/authorize').send({hostId,scope}).expect(403)
+  expect(result.body.code).toBe('desktop_local_authorization_required')
+ }
+ expect(seen).toEqual([])
+})
+it('reads devices and the native authorization identity without needing a Bot',async()=>{
+ service.exchange({host,results:[]})
+ const offlineId=randomUUID(),revokedId=randomUUID()
+ for(const [id,enabled] of [[offlineId,true],[revokedId,false]] as const)store.put('_system','desktop-host',id,{id,enabled,name:'其他电脑',createdAt:1,tokenHash:'secret'})
+ const state=(await request(app()).get('/api/app/desktop-environment').expect(200)).body
+ expect(state.hosts).toEqual(expect.arrayContaining([expect.objectContaining({id:'local',online:true}),expect.objectContaining({id:offlineId,name:'其他电脑',online:false})]))
+ expect(state.hosts.some((h:any)=>h.id===revokedId)).toBe(false)
+ expect(JSON.stringify(state)).not.toContain('secret')
+ expect((await request(app()).get('/api/app/desktop-environment/authorization').expect(200)).body).toEqual({owner:ownerKey('owner'),account:'测试账号'})
+ for(const path of ['/api/app/desktop-environment','/api/app/desktop-environment/authorization'])await request(app()).get(path).set('x-user','other').expect(403)
 })
 it('copies files between the explicitly named hosts and server without chat attachments or model payloads',async()=>{
  const bytes=Buffer.alloc(300_000);for(let i=0;i<bytes.length;i++)bytes[i]=i%256