ソースを参照

修复电脑停用后的重新授权并统一术语

Samien 1 ヶ月 前
親
コミット
30c1125
45 ファイル変更、276 行追加、143 行削除
  1. 1 1
      README.md
  2. 1 1
      desktop/environment-host.mjs
  3. 6 6
      desktop/host-config.mjs
  4. 13 13
      desktop/host-manager.mjs
  5. 4 4
      desktop/host-manager.test.mjs
  6. 33 10
      desktop/main.mjs
  7. 3 2
      desktop/mode-switch.test.mjs
  8. 1 0
      desktop/preload.cjs
  9. 1 1
      desktop/remote-login.html
  10. 1 1
      desktop/remote-login.js
  11. 6 6
      desktop/remote-login.mjs
  12. 5 4
      desktop/remote-login.test.mjs
  13. 15 14
      docs/desktop-hosts.md
  14. 1 1
      docs/releases/v0.4.26.md
  15. 1 1
      docs/releases/v0.4.36.md
  16. 1 1
      docs/releases/v0.4.37.md
  17. 1 1
      docs/releases/v0.4.38.md
  18. 1 1
      docs/releases/v0.4.42.md
  19. 1 1
      docs/releases/v0.4.48.md
  20. 1 1
      docs/verification/2026-09-19-device-context/README.md
  21. 3 3
      docs/verification/2026-09-19-host-file-copy/README.md
  22. 12 12
      src/client/components/app/DesktopHostSettingsPanel.vue
  23. 2 2
      src/client/components/app/HostToolsPanel.vue
  24. 1 1
      src/client/components/app/SystemManagementPanel.vue
  25. 1 1
      src/client/components/workspace/BotSettingsDialog.vue
  26. 1 1
      src/client/components/workspace/ComputerPanel.vue
  27. 4 4
      src/client/components/workspace/LocalVmChatPanel.vue
  28. 1 0
      src/client/env.d.ts
  29. 16 2
      src/client/stores/auth.ts
  30. 1 1
      src/client/views/ComputerViewerView.vue
  31. 1 1
      src/runner/worker/gateway.ts
  32. 8 8
      src/server/desktopEnvironments.ts
  33. 12 7
      src/server/desktopHosts.ts
  34. 1 1
      src/server/grokCloud.ts
  35. 2 2
      src/server/vmComputer.ts
  36. 4 3
      src/server/workspaceRuntime.ts
  37. 2 2
      src/shared/desktopEnvironment.ts
  38. 1 1
      src/shared/desktopHost.ts
  39. 2 2
      src/shared/workspace.ts
  40. 67 0
      tests/client/authDesktopAuthorization.test.ts
  41. 1 1
      tests/client/computerTargetSelection.test.ts
  42. 10 10
      tests/e2e/desktop-hosts.spec.ts
  43. 4 3
      tests/e2e/native-environment.spec.ts
  44. 5 5
      tests/server/desktopEnvironments.test.ts
  45. 17 0
      tests/server/desktopHosts.test.ts

+ 1 - 1
README.md

@@ -16,7 +16,7 @@
 
 Bot 模式的“工具”菜单提供“自动化”和“已连接应用”两个独立入口。“已连接应用”统一管理应用连接与 MCP;“自动化”使用独立的日历、任务列表和运行日志页面。所有模式均可从底部菜单打开独立的“关于”对话框。这些页面不属于设置中心。
 
-远程桌面主机:其他 Mac 上的夭夭 App 启动时可选择「登录远程」或「进入本地」;登录远程用服务器地址加账号密码登录(与手机端一致的凭据方式),窗口直接使用远程服务器(15300)的夭夭界面,本机不运行 Web 服务;管理员账号登录同时把这台 Mac 注册为可远程控制的桌面主机,供服务器上的 Bot 操作。每个机器人可并列启用 本地虚拟机 / 云端 / 多台 Mac 的本机控制,工具按在线情况同时可用。详见 [远程桌面主机](docs/desktop-hosts.md)。
+远程电脑:其他 Mac 上的夭夭 App 启动时可选择「登录远程」或「进入本地」;登录远程用服务器地址加账号密码登录(与手机端一致的凭据方式),窗口直接使用远程服务器(15300)的夭夭界面,本机不运行 Web 服务;管理员账号登录同时把这台 Mac 注册为可远程控制的电脑,供服务器上的 Bot 操作。每个机器人可并列启用本地虚拟机、云端和多台 Mac 的电脑控制,工具按在线情况同时可用。详见 [服务器与电脑](docs/desktop-hosts.md)。
 
 ## 运行
 

+ 1 - 1
desktop/environment-host.mjs

@@ -8,7 +8,7 @@ import {BrowserEnvironment} from './browser-environment.mjs'
 import {listHostFiles,readHostFile,writeHostFile,receiveHostFile,execHostShell} from './host-files.mjs'
 import {exchangeDesktopEnvironment as exchange} from './environment-exchange.mjs'
 
-/** Command execution shared by the loopback host and remote desktop hosts. */
+/** Command execution shared by the loopback server and remote computers. */
 export class DesktopHostCore {
  constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;this.home=process.env.HOME||'/';this.id=randomUUID();this.results=[];this.running=new Set();this.closed=false;this.browser=new BrowserEnvironment(join(dataRoot,'bot-browsers'));this.approved=new Set();this.approvedFull=new Set();this.permissionsFile=join(dataRoot,'computer-permissions.json');try{const stored=JSON.parse(readFileSync(this.permissionsFile,'utf8'));const screen=Array.isArray(stored)?stored:stored.screen;const full=Array.isArray(stored)?[]:stored.full;if(Array.isArray(screen))for(const key of screen)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key);if(Array.isArray(full))for(const key of full)if(/^[a-f0-9]{64}$/.test(key))this.approvedFull.add(key)}catch{}this.frames=new Map()}
  info(){return {id:this.id,name:hostname(),platform:process.platform,screen:process.platform==='darwin'&&systemPreferences.getMediaAccessStatus('screen')==='granted',accessibility:process.platform==='darwin'&&systemPreferences.isTrustedAccessibilityClient(false),approved:[...this.approved],full:[...this.approvedFull]}}

+ 6 - 6
desktop/host-config.mjs

@@ -1,12 +1,12 @@
-/** desktop-host.json downloaded once from the server's host settings panel. */
+/** desktop-host.json downloaded once from the server's computer settings panel. */
 export function parseDesktopHostConfiguration(value){
- if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('桌面主机配置无效')
- if(value.protocol!==1)throw new Error('桌面主机配置协议不兼容,请更新夭夭')
+ if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('电脑配置无效')
+ if(value.protocol!==1)throw new Error('电脑配置协议不兼容,请更新夭夭')
  let web
  try{web=new URL(value.serverURL)}catch{throw new Error('夭夭服务地址无效')}
  if(!['http:','https:'].includes(web.protocol)||web.username||web.password||web.search||web.hash||web.pathname!=='/')throw new Error('夭夭服务地址无效')
- if(web.protocol==='http:'&&!['127.0.0.1','localhost','[::1]'].includes(web.hostname)&&value.allowInsecureLan!==true)throw new Error('远程主机需要 HTTPS,或使用注册时勾选可信局域网 HTTP 的配置')
- if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(String(value.hostId)))throw new Error('主机编号无效,请从夭夭重新下载配置')
- if(typeof value.token!=='string'||value.token.length<32||value.token.length>4096)throw new Error('主机凭据无效,请从夭夭重新下载配置')
+ if(web.protocol==='http:'&&!['127.0.0.1','localhost','[::1]'].includes(web.hostname)&&value.allowInsecureLan!==true)throw new Error('远程电脑需要 HTTPS,或使用注册时勾选可信局域网 HTTP 的配置')
+ if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(String(value.hostId)))throw new Error('电脑编号无效,请从夭夭重新下载配置')
+ if(typeof value.token!=='string'||value.token.length<32||value.token.length>4096)throw new Error('电脑凭据无效,请从夭夭重新下载配置')
  return {protocol:1,serverURL:web.origin,hostId:value.hostId,token:value.token,allowInsecureLan:value.allowInsecureLan===true}
 }

+ 13 - 13
desktop/host-manager.mjs

@@ -16,26 +16,26 @@ export function remoteExchange(config,body){return new Promise((resolve,reject)=
   res.on('data',chunk=>{size+=chunk.length;if(size>16*1024*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)})
   res.on('error',reject)
   res.on('end',()=>{try{
-   if(res.statusCode===401||res.statusCode===403){const error=new Error('桌面主机授权已失效,请在夭夭服务中重新注册并导入新配置');error[FATAL]=true;throw error}
-   if(res.statusCode===409){const error=new Error('桌面主机协议不兼容,请更新夭夭后重新导入');error[FATAL]=true;throw error}
-   if(res.statusCode!==200)throw new Error(`桌面主机服务未连接(HTTP ${res.statusCode})`)
+   if(res.statusCode===401||res.statusCode===403){const error=new Error('电脑授权已失效,请重新登录授权这台电脑');error[FATAL]=true;throw error}
+   if(res.statusCode===409){const error=new Error('电脑连接协议不兼容,请更新夭夭后重新授权');error[FATAL]=true;throw error}
+   if(res.statusCode!==200)throw new Error(`电脑未连接(HTTP ${res.statusCode})`)
    resolve(JSON.parse(Buffer.concat(chunks).toString()))
   }catch(error){reject(error)}})
  })
  req.on('error',reject)
- req.setTimeout(10000,()=>req.destroy(new Error('桌面主机连接超时')))
+ req.setTimeout(10000,()=>req.destroy(new Error('电脑连接超时')))
  req.end(bytes)
 })}
 
-/** Remote desktop host: imports an encrypted pairing config and exchanges
+/** Remote computer: imports an encrypted pairing config and exchanges
  *  commands with the paired server. Native-only; no browser IPC. */
 export class DesktopHostManager {
  constructor(options){
-  if(typeof options?.createCore!=='function')throw new Error('桌面主机需要命令执行核心')
+  if(typeof options?.createCore!=='function')throw new Error('电脑需要命令执行核心')
   this.options=options
   this.path=join(options.home,'desktop-host.enc')
   this.createCore=options.createCore
-  this.state='未配置桌面主机'
+  this.state='未配置电脑'
   this.core=undefined
   this.config=undefined
   this.timer=undefined
@@ -67,14 +67,14 @@ export class DesktopHostManager {
   let encrypted
   try{encrypted=await readFile(this.path)}catch(error){if(error.code==='ENOENT')return;throw error}
   try{return parseDesktopHostConfiguration(JSON.parse(await this.options.decrypt(encrypted)))}
-  catch{throw new Error('无法解锁桌面主机配置,请检查钥匙串或重新导入')}
+  catch{throw new Error('无法解锁电脑配置,请检查钥匙串或重新导入')}
  }
  importFile(path){
   const generation=++this.generation
   return this.serial(async()=>{
    let config
    try{config=parseDesktopHostConfiguration(JSON.parse(await readFile(path,'utf8')))}
-   catch(error){throw error instanceof Error?error:new Error('桌面主机配置无效,请从夭夭重新下载配置')}
+   catch(error){throw error instanceof Error?error:new Error('电脑配置无效,请从夭夭重新下载配置')}
    await this.store(config,generation)
   })
  }
@@ -106,7 +106,7 @@ export class DesktopHostManager {
   this.config=config
   this.core=this.createCore({root:this.options.root,dataRoot:this.options.dataRoot})
   this.backoff=400
-  this.publish(`桌面主机正在连接 ${new URL(config.serverURL).host}`)
+  this.publish(`电脑正在连接 ${new URL(config.serverURL).host}`)
   this.timer=setTimeout(()=>void this.cycle(),400)
   this.timer.unref?.()
  }
@@ -117,13 +117,13 @@ export class DesktopHostManager {
    const value=await remoteExchange(config,{host:core.info(),results:core.takeResults()})
    this.backoff=400
    if(this.config!==config||this.core!==core)return
-   this.publish(`桌面主机已连接 ${new URL(config.serverURL).host}`)
+   this.publish(`电脑已连接 ${new URL(config.serverURL).host}`)
    await core.handle(value.commands??[])
   }catch(error){
    if(this.config!==config||this.core!==core)return
    if(error&&error[FATAL]){this.publish(error.message);await this.stopLoop();return}
    this.backoff=Math.min(this.backoff*2,5000)
-   this.publish(`桌面主机连接中断,正在重试:${String(error.message).slice(0,120)}`)
+   this.publish(`电脑连接中断,正在重试:${String(error.message).slice(0,120)}`)
   }finally{
    if(this.core===core){this.timer=setTimeout(()=>void this.cycle(),this.backoff);this.timer.unref?.()}
   }
@@ -135,6 +135,6 @@ export class DesktopHostManager {
   if(core)await core.close().catch(()=>{})
  }
  stop(){this.generation++;return this.serial(()=>this.stopLoop())}
- forget(){this.generation++;return this.serial(async()=>{await this.stopLoop();await rm(this.path,{force:true});this.publish('未配置桌面主机')})}
+ forget(){this.generation++;return this.serial(async()=>{await this.stopLoop();await rm(this.path,{force:true});this.publish('未配置电脑')})}
  revoke(){return this.serial(async()=>{await this.core?.revoke()})}
 }

+ 4 - 4
desktop/host-manager.test.mjs

@@ -10,7 +10,7 @@ import {parseDesktopHostConfiguration} from './host-config.mjs'
 
 const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))
 
-test('desktop host configuration validation mirrors the runner rules',()=>{
+test('computer configuration validation mirrors the runner rules',()=>{
  const base={protocol:1,serverURL:'https://yaoyao.example.com',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
  assert.deepEqual(parseDesktopHostConfiguration(base),{...base,allowInsecureLan:false})
  assert.deepEqual(parseDesktopHostConfiguration({...base,serverURL:'http://127.0.0.1:15300'}).serverURL,'http://127.0.0.1:15300')
@@ -18,8 +18,8 @@ test('desktop host configuration validation mirrors the runner rules',()=>{
  assert.throws(()=>parseDesktopHostConfiguration({...base,serverURL:'http://192.168.1.4:15300'}),/HTTPS/)
  assert.equal(parseDesktopHostConfiguration({...base,serverURL:'http://192.168.1.4:15300',allowInsecureLan:true}).allowInsecureLan,true)
  assert.throws(()=>parseDesktopHostConfiguration({...base,serverURL:'https://example.com/path'}),/服务地址/)
- assert.throws(()=>parseDesktopHostConfiguration({...base,hostId:'not-a-uuid'}),/主机编号/)
- assert.throws(()=>parseDesktopHostConfiguration({...base,token:'short'}),/主机凭据/)
+ assert.throws(()=>parseDesktopHostConfiguration({...base,hostId:'not-a-uuid'}),/电脑编号/)
+ assert.throws(()=>parseDesktopHostConfiguration({...base,token:'short'}),/电脑凭据/)
 })
 
 async function fixture(config){
@@ -91,7 +91,7 @@ test('imports an encrypted config, exchanges with the paired server and stops on
   assert.match(manager.state,/已连接/)
   await manager.forget()
   assert.equal(cores[0].closed,true)
-  assert.equal(manager.state,'未配置桌面主机')
+  assert.equal(manager.state,'未配置电脑')
   await assert.rejects(()=>readFile(join(home,'desktop-host.enc')),{code:'ENOENT'})
  }finally{
   await manager.stop().catch(()=>{})

+ 33 - 10
desktop/main.mjs

@@ -265,7 +265,7 @@ else {
       createCore:({root,dataRoot})=>new DesktopHostCore({root,dataRoot}),
       encrypt:value=>credentials.encrypt(value),
       decrypt:bytes=>credentials.decrypt(bytes),
-      onState:message=>{log(`桌面主机: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('desktop-host-status');if(item)item.label=message}})
+      onState:message=>{log(`电脑: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('desktop-host-status');if(item)item.label=message}})
     ipcMain.handle('desktop:device-host', async event => {
       try {
         const trusted = event.sender === window?.webContents && event.senderFrame === event.sender.mainFrame
@@ -281,8 +281,15 @@ else {
       }
       return { deviceHost: 'local' }
     })
-    async function hostAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'桌面主机操作未完成',detail:error.message})}}
+    async function hostAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'电脑操作未完成',detail:error.message})}}
     const appDialog = options => window && !window.isDestroyed() ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
+    async function authorizeComputer(server,session){
+      const previous=await hostManager.read().catch(()=>undefined)
+      const previousHostId=previous&&new URL(previous.serverURL).origin===new URL(server).origin?previous.hostId:undefined
+      const registration=await enrollDesktopHost(session,{name:hostname(),installId:await hostManager.installIdentity(),previousHostId})
+      await hostManager.adopt({protocol:1,serverURL:server,hostId:registration.host.id,token:registration.token,...(server.startsWith('http://')?{allowInsecureLan:true}:{})})
+      return registration
+    }
     function requireModePage(event){
       let trusted=false
       try{trusted=event.sender===window?.webContents&&event.senderFrame===window.webContents.mainFrame
@@ -303,10 +310,26 @@ else {
       if(switchingMode)throw new Error('正在切换运行模式,请稍候')
       openRemoteLogin()
     })
+    ipcMain.handle('desktop:computer-authorize',async(event,csrfToken)=>{
+      requireModePage(event)
+      if(!remoteMode||!remoteURL)return {registered:false}
+      try{
+        const token=String(csrfToken??'').trim()
+        if(!token)throw new Error('登录状态缺少安全令牌,请重新登录')
+        const cookies=await electronSession.defaultSession.cookies.get({url:remoteURL})
+        const registration=await authorizeComputer(remoteURL,{origin:remoteURL,csrfToken:token,cookies:cookies.map(cookie=>[cookie.name,cookie.value])})
+        return {registered:true,hostId:registration.host.id}
+      }catch(error){
+        const message=error instanceof Error?error.message:'电脑授权恢复失败'
+        hostManager.publish(`电脑授权未恢复:${message}`)
+        await appDialog({type:'warning',message:'账号已登录,但电脑授权未恢复',detail:`${message}\n\n请在“电脑”菜单中选择“重新授权…”。`})
+        return {registered:false}
+      }
+    })
     async function importDesktopHost(){
       const selection=window&&!window.isDestroyed()
-        ?await dialog.showOpenDialog(window,{title:'导入桌面主机配置',properties:['openFile'],filters:[{name:'桌面主机配置',extensions:['json']}]})
-        :await dialog.showOpenDialog({title:'导入桌面主机配置',properties:['openFile'],filters:[{name:'桌面主机配置',extensions:['json']}]})
+        ?await dialog.showOpenDialog(window,{title:'导入电脑配置',properties:['openFile'],filters:[{name:'电脑配置',extensions:['json']}]})
+        :await dialog.showOpenDialog({title:'导入电脑配置',properties:['openFile'],filters:[{name:'电脑配置',extensions:['json']}]})
       if(!selection.canceled&&selection.filePaths[0]&&!closing)await hostAction(()=>hostManager.importFile(selection.filePaths[0]))
     }
     function openRemoteLogin(){
@@ -388,8 +411,7 @@ else {
         const auth=await remoteSession(server,{username:value?.username,password:value?.password})
         let registered=false
         if(auth.user?.role==='admin'){
-          const registration=await enrollDesktopHost(auth,{name:hostname(),installId:await hostManager.installIdentity()})
-          await hostManager.adopt({protocol:1,serverURL:server,hostId:registration.host.id,token:registration.token,...(server.startsWith('http://')?{allowInsecureLan:true}:{})})
+          await authorizeComputer(server,auth)
           registered=true
         }
         try{
@@ -401,7 +423,7 @@ else {
         if(!window||window.isDestroyed())createWindow()
         else void window.loadURL(server)
         loginWindow?.close()
-        if(!registered)await appDialog({type:'info',message:'已登录远程服务器',detail:'当前账号不是管理员:窗口将使用远程夭夭。要把这台 Mac 注册为可远程控制的桌面主机,请使用管理员账号重新登录。'})
+        if(!registered)await appDialog({type:'info',message:'已登录远程服务器',detail:'当前账号不是管理员:窗口将使用远程夭夭。要把这台 Mac 注册为可远程控制的电脑,请使用管理员账号重新登录。'})
         return {ok:true,hostRegistered:registered,user:{username:auth.user.username,role:auth.user.role}}
       }catch(error){return {ok:false,error:error instanceof Error?error.message:'登录失败'}}
     })
@@ -418,7 +440,7 @@ else {
       const stored=preferences.value.startupChoice
       if(stored==='local'||stored==='remote')return stored
       const choice=await dialog.showMessageBox({type:'question',title:'夭夭',message:'这台 Mac 如何使用夭夭?',
-        detail:'「登录远程」用夭夭服务器的地址和账号密码登录另一台电脑上的夭夭:本机不运行 Web 服务,窗口直接使用远程夭夭;管理员账号还会把这台 Mac 注册为可远程控制的桌面主机。只有「作为服务器运行」会启动这台 Mac 的服务,并使用服务器上的 Hermes。客户端不需要安装 Hermes。',
+        detail:'「登录远程」用夭夭服务器的地址和账号密码登录另一台电脑上的夭夭:本机不运行 Web 服务,窗口直接使用远程夭夭;管理员账号还会把这台 Mac 注册为可远程控制的电脑。只有「作为服务器运行」会启动这台 Mac 的服务,并使用服务器上的 Hermes。客户端不需要安装 Hermes。',
         buttons:['连接服务器(客户端)','作为服务器运行'],defaultId:0,cancelId:0,checkboxLabel:'记住选择,之后启动不再询问',checkboxChecked:false,noLink:true})
       const mode=choice.response===0?'remote':'local'
       if(choice.checkboxChecked)await preferences.setStartupChoice(mode).catch(error=>log(error.message))
@@ -461,8 +483,9 @@ else {
         {label:'更换服务器…',click:openRemoteLogin},{type:'separator'},
         {id:'desktop-host-ask',label:'启动时询问运行模式',type:'checkbox',checked:preferences.value.startupChoice==='ask',
           click:item=>{void preferences.setStartupChoice(item.checked?'ask':remoteMode?'remote':'local').catch(async error=>{item.checked=preferences.value.startupChoice==='ask';await appDialog({type:'error',message:'无法保存启动偏好',detail:error.message})})}}]},
-      {label:'桌面主机',submenu:[{id:'desktop-host-status',label:'未配置桌面主机',enabled:false},{type:'separator'},
-        {id:'desktop-host-import',label:'导入主机配置…',click:importDesktopHost},
+      {label:'电脑',submenu:[{id:'desktop-host-status',label:'未配置电脑',enabled:false},{type:'separator'},
+        {id:'desktop-host-authorize',label:'重新授权…',click:openRemoteLogin},
+        {id:'desktop-host-import',label:'导入电脑配置…',click:importDesktopHost},
         {id:'desktop-host-reconnect',label:'重新连接',click:()=>hostAction(()=>hostManager.start())},
         {id:'desktop-host-forget',label:'断开并忘记配置',click:()=>hostAction(()=>hostManager.forget())}]},
       { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },

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

@@ -28,6 +28,7 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
     app=await electron.launch({args:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(local.address().port),HERMES_YAOYAO_DESKTOP_TEST_SYNC:'0'}})
     const page=await app.firstWindow();releaseIdentity();await page.waitForURL(origin+'/**')
     assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.modeState()),{mode:'server',serverURL:origin,switching:false})
+    assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.authorizeComputer('csrf-test')),{registered:false})
     assert.equal(await page.evaluate(()=>window.yaoyaoDesktop.switchMode('invalid').then(()=>false,()=>true)),true)
     await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('client')})
     await page.waitForURL(remoteURL+'/**')
@@ -42,10 +43,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()].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")].map(p=>p.then(()=>false,()=>true)))')
       }finally{outsider.destroy()}
     },{origin:remoteURL,preload:join(root,'desktop/preload.cjs')})
-    assert.deepEqual(denied,[true,true,true])
+    assert.deepEqual(denied,[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})

+ 1 - 0
desktop/preload.cjs

@@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   modeState: () => ipcRenderer.invoke('desktop:mode-state'),
   switchMode: mode => ipcRenderer.invoke('desktop:mode-switch', mode),
   openRemoteLogin: () => ipcRenderer.invoke('desktop:remote-login'),
+  authorizeComputer: csrfToken => ipcRenderer.invoke('desktop:computer-authorize', csrfToken),
   /** '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),

+ 1 - 1
desktop/remote-login.html

@@ -4,7 +4,7 @@
 <title>登录远程 · 夭夭</title><link rel="stylesheet" href="remote-login.css"></head>
 <body><main>
   <header><img src="icon.png" alt="" width="56" height="56"><h1>登录远程夭夭</h1>
-    <p>连接另一台电脑上的夭夭服务器。登录后本机无需 Hermes,也不运行 Web 服务,窗口直接使用远程夭夭;管理员账号同时会把这台 Mac 注册为可远程控制的桌面主机。</p></header>
+    <p>连接另一台电脑上的夭夭服务器。登录后本机无需 Hermes,也不运行 Web 服务,窗口直接使用远程夭夭;管理员账号同时会把这台 Mac 注册为可远程控制的电脑。</p></header>
   <form id="login-form" aria-label="远程登录">
     <label>服务器地址<input id="server" name="server" required autocomplete="off" spellcheck="false" placeholder="192.168.1.10:15300" inputmode="url"></label>
     <label>用户名<input id="username" name="username" required autocomplete="username" autocapitalize="none" spellcheck="false"></label>

+ 1 - 1
desktop/remote-login.js

@@ -13,7 +13,7 @@ form.addEventListener('submit', async event => {
       password: byId('password').value,
     })
     if (!result.ok) throw new Error(result.error || '登录失败')
-    status.textContent = result.hostRegistered ? '登录成功,已注册本机为远程桌面主机…' : '登录成功…'
+    status.textContent = result.hostRegistered ? '登录成功,已注册本机为电脑…' : '登录成功…'
   } catch (cause) {
     status.hidden = true; error.hidden = false
     error.textContent = cause instanceof Error ? cause.message : '登录失败'

+ 6 - 6
desktop/remote-login.mjs

@@ -1,6 +1,6 @@
 /** Native remote login: username/password against a yaoyao Web server.
  *  Mirrors the browser's CSRF/Origin dance so no server changes are needed,
- *  and can enroll this Mac as a desktop host with the logged-in session. */
+ *  and can enroll this Mac as a computer with the logged-in session. */
 
 export function normalizeServerURL(input){
   const raw=String(input??'').trim()
@@ -47,16 +47,16 @@ export async function remoteSession(serverURL,{username,password},fetchImpl=glob
   return {origin:base,user:result.user,csrfToken:String(result.csrfToken??info.csrfToken),cookies:[...jar.cookies.entries()]}
 }
 
-export async function enrollDesktopHost(session,{name,installId},fetchImpl=globalThis.fetch){
+export async function enrollDesktopHost(session,{name,installId,previousHostId},fetchImpl=globalThis.fetch){
   const cookie=session.cookies.map(([key,value])=>`${key}=${value}`).join('; ')
   const response=await fetchImpl(new URL('/api/app/admin/desktop-hosts',session.origin),{
     method:'POST',redirect:'error',
     headers:{accept:'application/json','content-type':'application/json',cookie,'x-csrf-token':session.csrfToken,origin:session.origin},
-    body:JSON.stringify({name:String(name).slice(0,100),...(installId?{installId}:{})}),
+    body:JSON.stringify({name:String(name).slice(0,100),...(installId?{installId}:{}),...(previousHostId?{previousHostId}:{})}),
   })
-  if(response.status===403)throw new Error('需要管理员账号,才能把这台 Mac 注册为可远程控制的桌面主机')
-  if(!response.ok)throw new Error(`注册桌面主机失败(HTTP ${response.status})`)
+  if(response.status===403)throw new Error('需要管理员账号,才能把这台 Mac 注册为可远程控制的电脑')
+  if(!response.ok)throw new Error(`注册电脑失败(HTTP ${response.status})`)
   const result=await response.json()
-  if(typeof result?.token!=='string'||!result?.host?.id)throw new Error('注册桌面主机响应无效')
+  if(typeof result?.token!=='string'||!result?.host?.id)throw new Error('注册电脑响应无效')
   return result
 }

+ 5 - 4
desktop/remote-login.test.mjs

@@ -41,14 +41,15 @@ async function fakeServer(){
   return {server,url:`http://127.0.0.1:${server.address().port}`,requests,csrf,session,token,hostId}
 }
 
-test('logs in with username and password and enrolls this Mac as a desktop host',async()=>{
+test('logs in with username and password and enrolls this Mac as a computer',async()=>{
   const fake=await fakeServer()
   try{
     const auth=await remoteSession(fake.url,{username:'admin',password:'pass'})
     assert.equal(auth.user.role,'admin')
     assert.equal(auth.origin,fake.url)
     assert.ok(auth.cookies.some(([name])=>name==='session'))
-    const enrollment=await enrollDesktopHost(auth,{name:'书房 iMac'})
+    const installId='22222222-2222-4222-8222-222222222222',previousHostId='33333333-3333-4333-8333-333333333333'
+    const enrollment=await enrollDesktopHost(auth,{name:'书房 iMac',installId,previousHostId})
     assert.equal(enrollment.host.id,fake.hostId)
     assert.equal(enrollment.token,fake.token)
     const [bootstrap,login,enroll]=fake.requests
@@ -57,7 +58,7 @@ test('logs in with username and password and enrolls this Mac as a desktop host'
     assert.equal(login.csrf,fake.csrf)
     assert.equal(enroll.csrf,fake.csrf+'-rotated')
     assert.ok(enroll.cookie.includes(`session=${fake.session}`))
-    assert.equal(JSON.parse(enroll.body).name,'书房 iMac')
+    assert.deepEqual(JSON.parse(enroll.body),{name:'书房 iMac',installId,previousHostId})
   }finally{
     fake.server.close();fake.server.closeAllConnections?.()
   }
@@ -69,7 +70,7 @@ test('wrong credentials and non-admin accounts surface clear errors',async()=>{
     await assert.rejects(()=>remoteSession(fake.url,{username:'admin',password:'wrong'}),/用户名或密码不正确/)
     await assert.rejects(()=>remoteSession(fake.url,{username:'',password:'pass'}),/用户名和密码/)
     const auth={origin:fake.url,csrfToken:'stale',cookies:[['session','x']]}
-    await assert.rejects(()=>enrollDesktopHost(auth,{name:'x'}),/管理员账号|注册桌面主机失败/)
+    await assert.rejects(()=>enrollDesktopHost(auth,{name:'x'}),/管理员账号|注册电脑失败/)
   }finally{
     fake.server.close();fake.server.closeAllConnections?.()
   }

+ 15 - 14
docs/desktop-hosts.md

@@ -1,12 +1,12 @@
-# 远程桌面主机
+# 服务器与电脑
 
-一台服务器集中运行 Web(15300)与 Hermes(9119),其他 Mac 安装夭夭 App 后作为”桌面主机”出站连接服务器,向服务器上的 Bot 提供自己的真实桌面(截图、键鼠、独立浏览器)。服务器自己机器上的 App 继续走本机回环通道,等同一台编号为 `local` 的主机。
+一台服务器集中运行 Web(15300)与 Hermes(9119),其他 Mac 安装夭夭 App 后作为“电脑”出站连接服务器,向服务器上的 Bot 提供自己的真实桌面(截图、键鼠、独立浏览器)。产品中只用两种名称:运行服务的一端叫“服务器”,连接服务器的 Mac 叫“电脑”。服务器自己机器上的 App 继续走本机回环通道,内部编号为 `local`。
 
 ## 服务端执行与统一权限
 
 所有 Bot 的聊天、模型、配置、记忆提炼都运行于服务端 Hermes。客户端 App 只负责界面和已授权的电脑操作,无需安装 Hermes。虚拟机 Runner 只在调用 `computer_*` 时建立工具会话;虚拟机离线不会转移聊天,也不会让普通聊天启动虚拟机。
 
-电脑开放范围与审批策略在 Bot 设置统一管理,默认开放服务器、客户端电脑、Grok Bot 云电脑和虚拟机。旧 Bot 的 `computer`、`execution`、`envs`、`approvalPolicy` 不再决定实际设备权限或模型运行位置。旧虚拟机共享关系保留,任务助手仍受所属任务的生命周期约束。
+电脑开放范围与审批策略在 Bot 设置统一管理,默认开放服务器、电脑、Grok Bot 云电脑和虚拟机。旧 Bot 的 `computer`、`execution`、`envs`、`approvalPolicy` 不再决定实际设备权限或模型运行位置。旧虚拟机共享关系保留,任务助手仍受所属任务的生命周期约束。
 
 - **消息来源**:客户端每次发送时重新读取 `deviceHost`,服务端保存到消息和运行。省略 `host` 或使用「本机」/`local` 时,只解析为本轮来源电脑;切换客户端后新消息覆盖来源,已经运行的任务保留自己的来源快照。
 - **明确目标**:`desktop_*` 的 `host` 可指定电脑名称或编号;`server` /「服务器」固定指服务器。重复名称需要用编号。来源未知、离线或全局关闭时明确报错,不用旧消息、Bot 绑定或其他在线机器兜底。普通浏览器未提供可控设备身份时须点名目标。
@@ -19,13 +19,14 @@
 ## 启用流程
 
 1. 在那台 Mac 上启动夭夭 App。**首次启动会询问这台 Mac 的使用方式:「连接服务器(客户端)」或「作为服务器运行」,默认选择客户端**。
-   - 选「连接服务器」:弹出登录窗口,填写**夭夭服务器地址(如 `192.168.1.10:15300`)、用户名和密码**。登录成功后窗口直接使用远程服务器的夭夭界面(本机不运行 Web 服务);用**管理员账号**登录时,App 会自动把这台 Mac 注册为远程桌面主机(令牌经系统钥匙串加密保存在数据目录 `desktop-host.enc`,0600),无需任何配置文件。非管理员账号只登录界面,并提示需要管理员账号才能注册本机控制。
+   - 选「连接服务器」:弹出登录窗口,填写**夭夭服务器地址(如 `192.168.1.10:15300`)、用户名和密码**。登录成功后窗口直接使用远程服务器的夭夭界面(本机不运行 Web 服务);用**管理员账号**登录时,App 会自动把这台 Mac 注册为电脑(令牌经系统钥匙串加密保存在数据目录 `desktop-host.enc`,0600),无需任何配置文件。非管理员账号只登录界面,并提示需要管理员账号才能注册电脑控制。
    - 选「作为服务器运行」:在这台机器运行 Web,并连接这里配置的 Hermes。
-   - 勾选「记住选择,之后启动不再询问」会把选择写入偏好(`startupChoice`);也可在菜单「桌面主机 → 启动时询问使用方式」恢复每次询问。
-2. 后续启动直接进入远程模式;若登录会话已过期,App 会重新弹出登录窗口(服务器地址已预填)。会话 Cookie 保存在 App 的会话存储中,密码不落盘。使用中可随时通过菜单「桌面主机 → 登录远程服务器… / 作为服务器运行」切换;「忘记配置」会移除本机的桌面主机凭据,下次登录时重新注册。
-3. 服务器管理侧(可选):「设置 → Hermes 连接 → 桌面主机」列出各台已注册主机(在线状态、机器名、最后在线)并可停用;停用立即断开那台 Mac。手动注册并下载 `desktop-host.json` 仍可用于不便登录的机器(菜单「桌面主机 → 导入主机配置…」),但常规流程不再需要。
-4. 管理员在全局设置中开放电脑类型,在聊天右侧电脑面板查看各台电脑的连接与授权状态;无需为各 Bot 单独勾选环境或绑定默认执行机。
-5. 首次授权:管理员点「在桌面端授权」,那台 Mac 弹出原生授权对话框;确认后引导开启屏幕录制与辅助功能。授权按账号哈希(ownerKey)记录在各 Mac 的 `computer-permissions.json`,各机独立;菜单「撤销本机控制授权」同时作用于本机与远程通道。
+   - 勾选「记住选择,之后启动不再询问」会把选择写入偏好(`startupChoice`);也可在菜单「运行模式 → 启动时询问运行模式」恢复每次询问。
+2. 后续启动直接进入远程模式;若登录会话已过期,App 会重新弹出登录窗口(服务器地址已预填)。会话 Cookie 保存在 App 的会话存储中,密码不落盘。使用中可随时通过「运行模式」切换服务器与客户端模式;「电脑 → 断开并忘记配置」会移除本机的电脑凭据。
+3. 服务器管理侧(可选):「设置 → Hermes 连接 → 电脑」列出各台已注册电脑(在线状态、机器名、最后在线)并可停用;停用立即断开那台 Mac。被停用的电脑不会因启动或普通重连自行恢复;用户再次显式登录管理员账号,或选择「电脑 → 重新授权…」后,服务复用原记录、轮换令牌并恢复连接。旧版没有安装编号的电脑通过原记录编号完成迁移,避免产生重复记录。
+4. 手动注册并下载 `desktop-host.json` 仍可用于不便登录的机器(菜单「电脑 → 导入电脑配置…」),但常规流程不再需要。
+5. 管理员在全局设置中开放电脑类型,在聊天右侧电脑面板查看各台电脑的连接与授权状态;无需为各 Bot 单独勾选环境或绑定默认执行机。
+6. 首次授权:管理员点「在桌面端授权」,那台 Mac 弹出原生授权对话框;确认后引导开启屏幕录制与辅助功能。授权按账号哈希(ownerKey)记录在各 Mac 的 `computer-permissions.json`,各机独立;菜单「撤销本机控制授权」同时作用于本机与远程通道。
 
 ## 文件与命令(全面操作)
 
@@ -40,9 +41,9 @@
 
 - 机器端点 `POST /api/desktop-host/v1/<hostId>/exchange`:Bearer 令牌(sha256 后 timingSafeEqual)+ `x-desktop-host-protocol: 1`;拒绝任何携带 Origin 的请求,不进入用户会话、CSRF 或 CORS 体系;Host 白名单照常生效。
 - 交换协议沿用桌面端 `{host, results} → {commands}` 格式:每轮最多 8 条命令、20 条结果,请求体上限 24MB。App 成功后 400ms 轮询,网络错误指数退避至 5s;令牌失效(401/403)即停止并提示重新导入。
-- 每台主机独立持有命令队列、截图缓存、接管凭据与授权状态;宿主 epoch(App 每次启动/撤销时轮换的 host.id)变化即围栏在途命令。人工接管的 generation/epoch/30 秒续期语义与单主机时一致,接管记录以 `desktop:<hostKey>:<resource>` 区分。
-- 远程明文 HTTP(服务器地址以 `http://` 登录)时,App 保存的主机配置自动带 `allowInsecureLan: true`,与 Runner 规则一致;公网部署应使用 HTTPS 并把域名加入外网访问地址。登录凭据只走该服务器地址,密码不落盘。
-- 停用主机立即断开那台 Mac 并围栏其在途命令;配置丢失时停用并重新注册,旧令牌不能恢复。
+- 每台电脑独立持有命令队列、截图缓存、接管凭据与授权状态;连接 epoch(App 每次启动/撤销时轮换的 `host.id`)变化即围栏在途命令。人工接管的 generation/epoch/30 秒续期语义与单台电脑时一致,接管记录以 `desktop:<hostKey>:<resource>` 区分。
+- 远程明文 HTTP(服务器地址以 `http://` 登录)时,App 保存的电脑配置自动带 `allowInsecureLan: true`,与 Runner 规则一致;公网部署应使用 HTTPS 并把域名加入外网访问地址。登录凭据只走该服务器地址,密码不落盘。
+- 停用电脑立即断开那台 Mac 并围栏其在途命令;旧令牌不能恢复,必须通过显式管理员登录重新授权。
 
 ## 范围
 
@@ -53,5 +54,5 @@
 ## 验证
 
 - 服务端:`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`(注册/下载/停用;模拟远程主机连接与电脑面板状态)。真实多机链路需按上述流程手工验收。
+- 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`(注册/下载/停用;模拟远程电脑连接与电脑面板状态)。真实多机链路需按上述流程手工验收。

+ 1 - 1
docs/releases/v0.4.26.md

@@ -1,5 +1,5 @@
 # v0.4.26
 
-- 远程桌面主机:其他 Mac 用服务器地址与账号密码“登录远程”,窗口直接使用远程夭夭;管理员账号自动把本机注册为可远程控制的桌面主机(看屏/键鼠/文件/命令,分档授权)。
+- 远程电脑:其他 Mac 用服务器地址与账号密码“登录远程”,窗口直接使用远程夭夭;管理员账号自动把本机注册为可远程控制的电脑(看屏/键鼠/文件/命令,分档授权)。
 - 机器人环境改为并列多选(envs):本地虚拟机 / 云端 Grok / 本机控制(多台 Mac 按名寻址)/ 仅浏览器同时启用;虚拟机按执行节点可用性自动降级;旧单选选择自动迁移。
 - 修复:与 0.4.25 同版本安装时本机 Web 不会更新的问题——本版本号递增,安装后 App 会用配套 Web 覆盖旧版。

+ 1 - 1
docs/releases/v0.4.36.md

@@ -2,5 +2,5 @@
 
 - Bot 资料增加每轮带上的描述,记忆按基础、历史、短暂分层注入,服务端工具禁用改为全局开关(70ea7c6)。
 - 组队和协作改为默认能力,去掉单独开关,临时助手仍不能组队(953339c)。
-- 去掉仅浏览器环境线,脚本机浏览器和仅浏览器不再可选(1589880)。
+- 去掉仅浏览器环境线,电脑浏览器和仅浏览器不再可选(1589880)。
 - 修复可选的工作表情不再套上等待彩带(fd317fb)。

+ 1 - 1
docs/releases/v0.4.37.md

@@ -1,4 +1,4 @@
 # v0.4.37
 
-- 电脑开关改为全局设置,单个机器人不再勾选电脑;脚本机、服务器、虚拟环境和云虚拟机,以及文件访问和 Bot 记忆,都进 Bot 设置(997371b)。
+- 电脑开关改为全局设置,单个机器人不再勾选电脑;电脑、服务器、虚拟环境和云虚拟机,以及文件访问和 Bot 记忆,都进 Bot 设置(997371b)。
 - 虚拟环境操作对齐云虚拟机,先看桌面再操作;恢复会话时不再带 Hermes 不认的 disabled_tool_sets(997371b)。

+ 1 - 1
docs/releases/v0.4.38.md

@@ -1,3 +1,3 @@
 # v0.4.38
 
-- 在 Bot 设置里给本机和已配对的脚本机改显示名,机器人按新名字点名;写「本机」仍然指向夭夭所在的电脑(027d4dc)。
+- 在 Bot 设置里给服务器和已配对的电脑改显示名,机器人按新名字点名;写「本机」仍然指向当前消息来源电脑(027d4dc)。

+ 1 - 1
docs/releases/v0.4.42.md

@@ -1,4 +1,4 @@
 # v0.4.42
 
-- 服务器与脚本机分开,按云虚拟机方式先看真实桌面再操作,本机只碰夭夭所在电脑;Bot 可用 Hermes 全部工具,长期记忆仍用 Bot 自己的(159943e)。
+- 服务器与电脑分开,按云虚拟机方式先看真实桌面再操作,本机只指当前消息来源电脑;Bot 可用 Hermes 全部工具,长期记忆仍用 Bot 自己的(159943e)。
 - 去掉卫星执行节点和远程机器人:执行节点只服务虚拟环境,不再接管聊天;远程 Bot 不再导入或转发到别的服务器(e79d455)。

+ 1 - 1
docs/releases/v0.4.48.md

@@ -1,6 +1,6 @@
 # v0.4.48
 
-- 新增服务器与脚本机之间的直接文件复制(b6dd8a5)。
+- 新增服务器与电脑之间的直接文件复制(b6dd8a5)。
 - 修复电脑选择与桌面窗口目标不一致(5b4ae36)。
 - 修复命名 Profile 模型串用与工具桥初始化等待(a6a8eae)。
 - 修复设备编号并发重复,并区分同名电脑的配对记录(4a16d50)。

+ 1 - 1
docs/verification/2026-09-19-device-context/README.md

@@ -5,7 +5,7 @@
 ## 统一行为
 
 - 所有 Bot 的聊天与记忆整理使用服务端 Hermes;旧 Worker 会话迁移一次后继续复用服务端会话。VM 只执行按需调用的工具。
-- 服务器、客户端电脑、Grok Bot 云电脑与 VM 使用同一套全局开放设置;审批策略也统一管理,旧 Bot 的单独配置不覆盖全局权限。
+- 服务器、电脑、Grok Bot 云电脑与 VM 使用同一套全局开放设置;审批策略也统一管理,旧 Bot 的单独配置不覆盖全局权限。
 - “本机”由本条消息的 `deviceHost` 决定,保存到运行快照。设备未知、离线、关闭或名称有歧义时明确报错;不会切到服务器或旧绑定机器执行。
 - Bot 协作、团队启动、子任务及回传保留来源。用户换设备后新建或重试的子任务使用该次指令的来源,来源缺失会清空语义,不沿用旧目标的设备。
 - 定时任务单独保存“本机”目标。文件与命令授权独立于屏幕授权。接管面板在选定设备后获取控制权,切换设备时先交还原设备。

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

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

+ 12 - 12
src/client/components/app/DesktopHostSettingsPanel.vue

@@ -17,17 +17,17 @@ async function refresh() {
 async function action(work:()=>Promise<void>) {
   if(busy.value) return
   error.value = ''; notice.value = ''; busy.value = true
-  try { await work() } catch(cause) { error.value = cause instanceof Error ? cause.message : '桌面主机操作失败' }
+  try { await work() } catch(cause) { error.value = cause instanceof Error ? cause.message : '电脑操作失败' }
   finally { busy.value = false }
 }
 async function register() {
   await action(async()=>{
     const web = new URL(serverURL.value)
     if(!['http:','https:'].includes(web.protocol)||web.username||web.password||web.search||web.hash||web.pathname!=='/')throw new Error('请填写不带路径的夭夭服务地址')
-    if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程主机请使用 HTTPS,或勾选可信局域网 HTTP')
+    if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程电脑请使用 HTTPS,或勾选可信局域网 HTTP')
     const result = await apiRequest<{host:DesktopHostSummary;token:string}>('/api/app/admin/desktop-hosts', {method:'POST',body:{name:name.value.trim()}})
     configuration.value = {protocol:1,serverURL:web.origin,hostId:result.host.id,token:result.token,...(insecure.value?{allowInsecureLan:true}:{})}
-    notice.value = '主机已注册,请下载配置并在那台 Mac 的夭夭 App 中导入。'
+    notice.value = '电脑已注册,请下载配置并在那台 Mac 的夭夭 App 中导入。'
     await refresh()
   })
 }
@@ -48,28 +48,28 @@ onMounted(()=>{void action(refresh)})
 </script>
 
 <template>
-  <section class="desktop-host-panel" aria-label="桌面主机设置">
-    <p>在其他 Mac 上安装夭夭 App 并导入主机配置后,那台电脑会主动连接本服务,机器人即可操作它的真实桌面。每台主机单独授权,配置凭据仅注册时下发一次。</p>
-    <div class="host-toolbar"><strong>已注册主机</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
+  <section class="desktop-host-panel" aria-label="电脑设置">
+    <p>在其他 Mac 上安装夭夭 App 并导入电脑配置后,那台电脑会主动连接服务器,机器人即可操作它的真实桌面。每台电脑单独授权,配置凭据仅注册时下发一次。</p>
+    <div class="host-toolbar"><strong>已注册电脑</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
     <ul v-if="hosts.length" class="host-list">
       <li v-for="host in hosts" :key="host.id">
         <div><strong>{{ host.name }}</strong><span>{{ !host.enabled ? '已停用' : host.online ? `已连接 · ${host.hostName || host.platform || 'Mac'}` : '待连接' }}</span></div>
         <button v-if="host.enabled" type="button" :disabled="busy" :aria-label="`停用 ${host.name}`" @click="disable(host)">停用</button>
       </li>
     </ul>
-    <p v-else>尚未注册桌面主机。</p>
+    <p v-else>尚未注册电脑。</p>
     <form v-if="!configuration" @submit.prevent="register">
-      <label>主机名称<input v-model="name" required maxlength="100" autocomplete="off" placeholder="书房 iMac" :disabled="busy"></label>
+      <label>电脑名称<input v-model="name" required maxlength="100" autocomplete="off" placeholder="书房 iMac" :disabled="busy"></label>
       <small>用于在电脑面板中区分各台机器。</small>
       <label>夭夭服务地址<input v-model="serverURL" type="url" required :disabled="busy"></label>
       <small>必须能从那台 Mac 访问。</small>
       <label class="host-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
-      <button class="solid-button" :disabled="busy || !name.trim()">注册主机</button>
+      <button class="solid-button" :disabled="busy || !name.trim()">注册电脑</button>
     </form>
     <div v-else class="host-setup">
-      <button class="solid-button" type="button" @click="download">下载主机配置</button>
-      <p>配置包含这台主机的连接凭据,仅本次可下载。在那台 Mac 上打开夭夭 App,通过菜单「桌面主机 → 导入主机配置…」选择下载的 desktop-host.json。</p>
-      <small>配置丢失后可停用主机并重新注册;停用立即断开那台电脑。</small>
+      <button class="solid-button" type="button" @click="download">下载电脑配置</button>
+      <p>配置包含这台电脑的连接凭据,仅本次可下载。在那台 Mac 上打开夭夭 App,通过菜单「电脑 → 导入电脑配置…」选择下载的 desktop-host.json。</p>
+      <small>配置丢失后可停用电脑并重新注册;停用立即断开那台电脑。</small>
     </div>
     <p v-if="error" class="host-error" role="alert">{{ error }}</p>
     <p v-if="notice" class="host-notice" role="status">{{ notice }}</p>

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

@@ -101,7 +101,7 @@ onMounted(() => { void load(); void loadNames() })
   <section class="host-tools" aria-label="电脑与服务端工具">
     <p>这些开关对所有机器人生效,不再按单个机器人勾选。所有 Bot 共用电脑权限和审批策略。设备是否在线、是否获得系统授权会单独显示。</p>
     <form @submit.prevent="save" @change="changed">
-      <label class="toggle"><input v-model="scriptMachine" type="checkbox" :disabled="loading || saving" /><span><strong>脚本机</strong><small>客户连接的 Mac。机器人按名称点名:文件走文件工具,命令走 shell,只有看窗口或点按时才截图。</small></span></label>
+      <label class="toggle"><input v-model="scriptMachine" type="checkbox" :disabled="loading || saving" /><span><strong>电脑</strong><small>连接服务器的 Mac。机器人按名称点名:文件走文件工具,命令走 shell,只有看窗口或点按时才截图。</small></span></label>
       <label class="toggle"><input v-model="serverComputer" type="checkbox" :disabled="loading || saving" /><span><strong>服务器</strong><small>夭夭正在运行的这台电脑。文件走文件工具,命令走 shell;只有看窗口或点按时才截图操作。</small></span></label>
       <label class="toggle"><input v-model="vm" type="checkbox" :disabled="loading || saving" /><span><strong>虚拟环境</strong><small>服务端上的隔离桌面。聊天留在夭夭,操作派到虚拟机里。</small></span></label>
       <label class="toggle"><input v-model="cloud" type="checkbox" :disabled="loading || saving" /><span><strong>云虚拟机</strong><small>共享的 Grok Bot 电脑。先看真实桌面,再点击、输入、按键或滚动。</small></span></label>
@@ -112,7 +112,7 @@ onMounted(() => { void load(); void loadNames() })
     </form>
     <div class="names">
       <div class="names-heading"><strong>电脑名称与配对记录</strong><button type="button" :disabled="namesDisabled" @click="loadNames">{{ namesLoading ? '刷新中…' : '刷新电脑列表' }}</button></div>
-      <p>每台电脑可以改名,和机器人点名用的是同一套名字。服务器留空时显示「服务器」,脚本机留空时用配对时的主机名。「本机」跟用户当前发消息所在的电脑走,不是固定某一台。</p>
+      <p>每台电脑可以改名,和机器人点名用的是同一套名字。服务器留空时显示「服务器」,电脑留空时用配对时的主机名。「本机」跟用户当前发消息所在的电脑走,不是固定某一台。</p>
       <p v-if="hasSameNames" class="same-names">有同名的配对记录。请结合连接状态、编号和配对时间区分,确认不再使用后可停用旧记录。</p>
       <p v-if="nameError" class="error" role="alert">{{ nameError }}</p>
       <p v-if="nameStatus" role="status">{{ nameStatus }}</p>

+ 1 - 1
src/client/components/app/SystemManagementPanel.vue

@@ -517,7 +517,7 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
         <RunnerSettingsPanel v-if="runnerSettingsOpen" />
       </details>
       <details class="desktop-host-settings" @toggle="desktopHostSettingsOpen = ($event.target as HTMLDetailsElement).open">
-        <summary>桌面主机</summary>
+        <summary>电脑</summary>
         <DesktopHostSettingsPanel v-if="desktopHostSettingsOpen" />
       </details>
       <div class="block network-access-block">

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

@@ -55,7 +55,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><HostToolsPanel /></section>
+      <section v-else-if="page === 'computers' && isAdmin" class="bot-settings__content"><header><h3>电脑</h3><p>电脑、服务器、虚拟环境和云虚拟机对所有机器人一起开关。</p></header><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">

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

@@ -24,7 +24,7 @@ const computerSettings=ref({vm:true,cloud:true,desktop:true,browser:false})
 const targets=computed(()=>{
   const envs=computerSettings.value,info=envTargets.value
   const list:{key:string;label:string;backend:'desktop'|'cloud'|'vm';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.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.vm)list.push({key:'vm',label:'本地虚拟机'+(info?.vmEnabled?'':' · 未启用'),backend:'vm',disabled:!info?.vmEnabled})
   return list

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

@@ -20,12 +20,12 @@ const showVm=computed(()=>computers.value.vm&&!!state.value)
 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.serverComputer?'服务器':'',computers.value.scriptMachine?'电脑':'',
  computers.value.cloud?'云端':'',computers.value.vm?'虚拟机':'',
 ].filter(Boolean).join(' · '))
 const previewTargets=computed(()=>{
  const list:{key:string;label:string;backend:'desktop'|'cloud'|'vm';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.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'})
  return list
@@ -131,9 +131,9 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
     <p class="hint">电脑开关在 Bot 设置里,对所有机器人一起生效。</p>
     <div class="computer-options">
     <template v-if="showDesktop">
-      <p>服务器是夭夭所在的这台电脑;脚本机是另外连接的 Mac。两者都可改名。「本机」跟你当前发消息所在的电脑走。要文件用文件工具,要命令用 shell;只有需要看窗口或点按时才截图。</p>
+      <p>服务器是运行夭夭服务的电脑;电脑是连接这台服务器的 Mac。两者都可改名。「本机」跟你当前发消息所在的电脑走。要文件用文件工具,要命令用 shell;只有需要看窗口或点按时才截图。</p>
       <div v-for="host in desktopHosts" :key="host.id" class="host-permission" :class="{offline:!host.online}">
-       <div class="host-permission-head"><strong>{{host.id==='local'?('服务器 · '+(host.name||'服务器')):('脚本机 · '+(host.name||'未命名'))}}</strong><small>{{host.online?'在线':'离线'}}</small></div>
+       <div class="host-permission-head"><strong>{{host.id==='local'?('服务器 · '+(host.name||'服务器')):('电脑 · '+(host.name||'未命名'))}}</strong><small>{{host.online?'在线':'离线'}}</small></div>
        <div class="permission-list"><span>{{host.local.authorized?'✓':'○'}} 屏幕控制授权</span><span>{{host.local.screen?'✓':'○'}} 屏幕录制</span><span>{{host.local.accessibility?'✓':'○'}} 辅助功能</span><span>{{host.local.fullAuthorized?'✓':'○'}} 文件与命令</span></div>
        <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.id==='local'?'这台':host.name}} Mac 上确认授权,并按系统提示开启权限;系统可能要求重新打开 App。</p>

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

@@ -15,6 +15,7 @@ interface Window {
     modeState?(): Promise<{ mode: 'client' | 'server'; serverURL: string; switching: boolean }>
     switchMode?(mode: 'client' | 'server'): Promise<{ ok: boolean; error?: string; pendingLogin?: boolean }>
     openRemoteLogin?(): Promise<void>
+    authorizeComputer?(csrfToken: string): Promise<{ registered: boolean; hostId?: string }>
     deviceHost?(): Promise<{ deviceHost: string | null }>
     openComputer(id: string, target?: { backend?: 'desktop' | 'cloud' | 'vm'; host?: string }): Promise<boolean>
     computerTargetChanged?(target: { backend: 'desktop' | 'cloud' | 'vm'; host?: string }): Promise<void>

+ 16 - 2
src/client/stores/auth.ts

@@ -83,6 +83,12 @@ export const useAuthStore = defineStore('auth', () => {
 
   onApiUnauthorized(expire)
 
+  async function authorizeComputerAfterExplicitLogin(response: BootstrapResponse): Promise<void> {
+    if (response.user?.role !== 'admin' || !window.yaoyaoDesktop?.authorizeComputer) return
+    try { await window.yaoyaoDesktop.authorizeComputer(response.csrfToken) }
+    catch { /* Account login still succeeds; the desktop process presents the recovery error. */ }
+  }
+
   async function bootstrap(): Promise<void> {
     status.value = 'checking'
     error.value = undefined
@@ -99,7 +105,11 @@ export const useAuthStore = defineStore('auth', () => {
   async function login(input: authApi.LoginInput): Promise<void> {
     status.value = 'authenticating'
     error.value = undefined
-    try { publish(await authApi.login(input)) }
+    try {
+      const response = await authApi.login(input)
+      await authorizeComputerAfterExplicitLogin(response)
+      publish(response)
+    }
     catch (cause) {
       status.value = 'anonymous'
       error.value = message(cause)
@@ -110,7 +120,11 @@ export const useAuthStore = defineStore('auth', () => {
   async function setup(input: authApi.LoginInput): Promise<void> {
     status.value = 'authenticating'
     error.value = undefined
-    try { publish(await authApi.setup(input)) }
+    try {
+      const response = await authApi.setup(input)
+      await authorizeComputerAfterExplicitLogin(response)
+      publish(response)
+    }
     catch (cause) {
       status.value = 'anonymous'
       error.value = message(cause)

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

@@ -22,7 +22,7 @@ onMounted(async () => {
     if (requestedBackend !== undefined && requestedBackend !== 'desktop' && requestedBackend !== 'cloud' && requestedBackend !== 'vm')
       throw new Error('所选电脑无效,请关闭窗口后重新选择')
     if (requestedHost !== undefined && (requestedBackend !== 'desktop' || typeof requestedHost !== 'string' || !/^[\w-]{1,128}$/.test(requestedHost)))
-      throw new Error('所选电脑主机无效,请关闭窗口后重新选择')
+      throw new Error('所选电脑无效,请关闭窗口后重新选择')
     backend.value = requestedBackend
     host.value = typeof requestedHost === 'string' ? requestedHost : undefined
     const result = await apiRequest<{agents: WorkspaceAgent[]}>('/api/app/agents')

+ 1 - 1
src/runner/worker/gateway.ts

@@ -45,7 +45,7 @@ const desktopAction=z.discriminatedUnion('kind',[
 ])
 const PROFILE_COMPUTER_RULES='当前使用本机协作模式:沿用基础 Profile 的本机工具、配置和上下文,本地虚拟机是独立的 Linux 电脑。computer_shell/read_file/write_file/desktop_state/action/export 只操作虚拟机;本机文件、终端和程序使用 Profile 已授权的原生工具。computer_copy_file 在两个环境间显式复制文件。调用前明确目标环境,文件路径和浏览器登录互不通用;需要回传虚拟机文件时使用 computer_export。人工接管期间停止操作,交还后先检查当前状态。'
 const HERMES_COMPUTER_RULES='本轮由 Hermes 管理模型、配置、识图、技能和授权资源。使用 Hermes 原生 skills_list、skill_view 和 skill_manage 发现、读取和维护技能;遵循当前 Profile 的技能规则。技能依赖本机登录态、环境变量或授权文件时,使用 Hermes 原生工具完成相应步骤;适合 Linux 的脚本与输入文件可经 computer_copy_file 传入虚拟机,再用 computer_shell 执行。路径、软件和登录态在本机与虚拟机之间不通用。不要自行复制 Profile 配置或凭据到虚拟机。'
-const ISOLATED_COMPUTER_RULES='当前环境是虚拟环境,也就是服务端上的隔离虚拟机。先看 computer_desktop_state,再按截图像素用 computer_action 点击、输入、按键或滚动。命令和文件也通过 computer_* 在这台 Linux 电脑内部完成。不要使用服务端本机的终端、文件或桌面,也不要在节点离线时改去服务端或脚本机。脚本机和云虚拟机是另外勾选的环境,不从这台虚拟机里打开。人工接管期间停止操作,交还后先检查当前状态。'
+const ISOLATED_COMPUTER_RULES='当前环境是虚拟环境,也就是服务端上的隔离虚拟机。先看 computer_desktop_state,再按截图像素用 computer_action 点击、输入、按键或滚动。命令和文件也通过 computer_* 在这台 Linux 电脑内部完成。不要使用服务器或已连接电脑的终端、文件或桌面,也不要在节点离线时改去服务器或电脑。电脑和云虚拟机是另外勾选的环境,不从这台虚拟机里打开。人工接管期间停止操作,交还后先检查当前状态。'
 const ISOLATED_HERMES_RULES='本轮由 Hermes 管理模型、配置和这次会话。使用 Hermes 原生 skills_list、skill_view 和 skill_manage 发现、读取和维护技能。技能和 computer_* 都留在这台虚拟机里;不要把 Profile 的本机登录、文件或凭据复制进来。'
 export interface ProxyHandle {readonly proxy:ComputerPublicProxy;release():Promise<void>}
 export class ComputerRuntime {

+ 8 - 8
src/server/desktopEnvironments.ts

@@ -60,15 +60,15 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
  {id:'desktop_file_list',name:'desktop_file_list',description:'列出当前电脑允许范围内的目录内容,返回名称、类型、大小与修改时间。路径相对于用户主目录,例如 Desktop、Documents,或 ~/Desktop。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
  {id:'desktop_file_read',name:'desktop_file_read',description:'读取当前电脑上的一个文件,返回 base64 内容与大小。路径相对于用户主目录;超出允许范围会被拒绝。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
  {id:'desktop_file_write',name:'desktop_file_write',description:'把 base64 内容写入当前电脑上的文件(覆盖已存在文件)。路径相对于用户主目录;超出允许范围会被拒绝。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},data:{type:'string'},host:{type:'string'}},required:['path','data'],additionalProperties:false}},
- {id:'desktop_file_copy',name:'desktop_file_copy',description:'把文件从一台电脑直接复制到另一台电脑,支持脚本机之间、脚本机与服务器之间双向传输。填写来源和目标电脑的名称或编号(服务器用 server;本机指本轮消息来源电脑),以及各自用户主目录内的文件路径,例如 Desktop/a.txt。保留来源文件,默认拒绝覆盖目标同名文件;只有用户要求覆盖时才设 overwrite=true。最大 10 MiB,支持二进制文件,写入并校验后返回结果。文件数据由服务器中转,不经过模型或聊天附件。',inputSchema:{type:'object',properties:{sourceHost:{type:'string'},sourcePath:{type:'string'},targetHost:{type:'string'},targetPath:{type:'string'},overwrite:{type:'boolean',default:false}},required:['sourceHost','sourcePath','targetHost','targetPath'],additionalProperties:false}},
+ {id:'desktop_file_copy',name:'desktop_file_copy',description:'把文件从一台电脑直接复制到另一台电脑,支持电脑之间、电脑与服务器之间双向传输。填写来源和目标电脑的名称或编号(服务器用 server;本机指本轮消息来源电脑),以及各自用户主目录内的文件路径,例如 Desktop/a.txt。保留来源文件,默认拒绝覆盖目标同名文件;只有用户要求覆盖时才设 overwrite=true。最大 10 MiB,支持二进制文件,写入并校验后返回结果。文件数据由服务器中转,不经过模型或聊天附件。',inputSchema:{type:'object',properties:{sourceHost:{type:'string'},sourcePath:{type:'string'},targetHost:{type:'string'},targetPath:{type:'string'},overwrite:{type:'boolean',default:false}},required:['sourceHost','sourcePath','targetHost','targetPath'],additionalProperties:false}},
  {id:'desktop_shell',name:'desktop_shell',description:'在这台电脑的用户主目录执行一条 shell 命令,返回 stdout、stderr 与退出码。长任务会超时终止;不要用它代替文件工具读写大文件。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{command:{type:'string'},cwd:{type:'string'},timeoutMs:{type:'integer'},host:{type:'string'}},required:['command'],additionalProperties:false}},
 ]
 export const DESKTOP_FILE_TOOL_IDS=new Set(['desktop_file_list','desktop_file_read','desktop_file_write','desktop_file_copy','desktop_shell'])
 export const DESKTOP_FILE_TRANSFER_RULES='电脑间传文件用 desktop_file_copy,一次指定 sourceHost、sourcePath、targetHost、targetPath;服务器用 server,设备名用设置里的名字,「本机」只指本轮消息来源电脑。例:把 mac1 桌面的 a.txt 放到 mac2 桌面,调用 {sourceHost:"mac1",sourcePath:"Desktop/a.txt",targetHost:"mac2",targetPath:"Desktop/a.txt"}。这是复制,保留原文件;只有用户明确要求覆盖时才传 overwrite:true。无需先截图,不要读取 base64 给模型、上传聊天附件或假设不同电脑共享文件路径。最大 10 MiB;工具返回已校验的成功结果后才报告完成。'
-/** Satellite Macs connected as script machines. */
-export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机,也就是客户连接的 Mac。一律用 desktop_*,可用 host 写明设置里的电脑名称。省略 host 或写「本机」时:只使用本轮用户消息来源电脑;没有来源或来源离线时不得回退,必须明确目标。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。文件与 shell 在用户主目录范围内真实生效。服务器、虚拟环境和云虚拟机都不是脚本机。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
+/** Macs connected to the server as computers. */
+export const DESKTOP_ENVIRONMENT_RULES='当前环境是电脑,也就是连接服务器的 Mac。一律用 desktop_*,可用 host 写明设置里的电脑名称。省略 host 或写「本机」时:只使用本轮用户消息来源电脑;没有来源或来源离线时不得回退,必须明确目标。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。文件与 shell 在用户主目录范围内真实生效。服务器、虚拟环境和云虚拟机都不是这台电脑。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
 /** Yaoyao host Mac — same look-then-act pattern as the cloud computer. */
-export const SERVER_COMPUTER_RULES='当前环境是服务器,也就是夭夭正在运行的这台电脑。电脑名称与设置里一致,可改名;点名用这个名字。一律用 desktop_*。省略 host 或写「本机」时:用户消息来自服务器就用这台;来自脚本机则「本机」指那台脚本机;没有来源时必须明确目标。明确操作服务器时使用 host="server",不要省略 host。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。未授权文件与命令时如实说明,不要改用截图凑数。文件与 shell 在用户主目录范围内真实生效。脚本机、虚拟环境和云虚拟机都不是这台服务器本身。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
+export const SERVER_COMPUTER_RULES='当前环境是服务器,也就是夭夭正在运行的这台电脑。电脑名称与设置里一致,可改名;点名用这个名字。一律用 desktop_*。省略 host 或写「本机」时:用户消息来自服务器就用这台;来自电脑则「本机」指那台电脑;没有来源时必须明确目标。明确操作服务器时使用 host="server",不要省略 host。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。未授权文件与命令时如实说明,不要改用截图凑数。文件与 shell 在用户主目录范围内真实生效。已连接的电脑、虚拟环境和云虚拟机都不是这台服务器本身。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
 
 /** The loopback native transport is admitted only by serviceInstance's
  *  private capability; remote hosts are admitted by DesktopHostHub's bearer
@@ -151,7 +151,7 @@ export class DesktopEnvironments {
  onlineHostsLine(owner:string,deviceHost?:string){
   const names=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s)).map(s=>{
    const label=s.key==='local'?(this.names()[s.key]||'服务器'):(this.label(s)||s.key)
-   const kind=s.key==='local'?'服务器':'脚本机'
+   const kind=s.key==='local'?'服务器':'电脑'
    const mine=deviceHost&&(deviceHost==='local'?s.key==='local':s.key===deviceHost)
    return mine?`${label}(${kind}·本机)`:`${label}(${kind})`
   })
@@ -167,7 +167,7 @@ export class DesktopEnvironments {
    const named=this.names()[deviceHost==='local'?'local':deviceHost]
    return `本轮用户消息来源设备:${named||deviceHost}(当前未在线或未开放)。「本机」仍指这台电脑,不能回退到服务器或其他电脑。`
   }
-  const kind=s.key==='local'?'服务器':'脚本机'
+  const kind=s.key==='local'?'服务器':'电脑'
   return `本轮用户消息来自${kind}「${this.label(s)}」。名字与设置里的电脑名称相同;对本轮而言,「本机」指这台电脑。`
  }
  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}
@@ -187,9 +187,9 @@ export class DesktopEnvironments {
  }
  /** Loopback App exchange; kept for the local service-instance channel. */
  exchange(body:unknown){return this.exchangeFor(this.localSession(),body)}
- /** Remote desktop-host exchange, admitted upstream by DesktopHostHub. */
+ /** Remote computer exchange, admitted upstream by DesktopHostHub. */
  remoteExchange(id:string,body:unknown){
-  if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id))throw new HttpError(404,'桌面主机不存在','desktop_host_unknown')
+  if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id))throw new HttpError(404,'电脑不存在','desktop_host_unknown')
   let s=this.sessions.get(id)
   if(!s){s=newHostState(id,true);this.sessions.set(id,s)}
   return this.exchangeFor(s,body)

+ 12 - 7
src/server/desktopHosts.ts

@@ -9,18 +9,22 @@ import { DESKTOP_HOST_PROTOCOL, type DesktopHostExchange, type DesktopHostRecord
 import { readComputerNames, saveComputerName } from './computerNames.js'
 
 const hash = (value: string) => createHash('sha256').update(value).digest('hex')
-const enrollInput = z.object({ name: z.string().trim().min(1).max(100), installId: z.string().uuid().optional() }).strict()
+const enrollInput = z.object({
+  name: z.string().trim().min(1).max(100),
+  installId: z.string().uuid().optional(),
+  previousHostId: z.string().uuid().optional(),
+}).strict()
 const exchangeInput = 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), full: z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(100).optional() }).strict(),
   results: z.array(z.object({ id: z.string().uuid(), value: z.unknown().optional(), error: z.string().max(500).optional() }).strict()).max(20),
 }).strict()
 
-/** Outbound-only machine transport for remote desktop hosts. Browser auth and
+/** Outbound-only machine transport for remote computers. Browser auth and
  *  machine auth remain separate: these endpoints never enter the user session,
  *  CSRF or CORS pipeline, and reject any request carrying an Origin header. */
 export class DesktopHostHub {
   /** Bound to DesktopEnvironments.remoteExchange once the runtime exists. */
-  exchange: (id: string, value: DesktopHostExchange) => unknown = () => { throw new HttpError(503, '桌面主机尚未接入', 'desktop_host_offline') }
+  exchange: (id: string, value: DesktopHostExchange) => unknown = () => { throw new HttpError(503, '电脑尚未接入', 'desktop_host_offline') }
   /** Bound to DesktopEnvironments.dropHost so revocation fences live commands. */
   drop: (id: string) => void = () => {}
   private online = new Map<string, { seen: number; name: string; platform: string }>()
@@ -31,6 +35,7 @@ export class DesktopHostHub {
     const token = randomBytes(32).toString('base64url')
     const records = this.records()
     let record = body.installId ? records.find(item => item.installId === body.installId) : undefined
+    if (!record && body.previousHostId) record = records.find(item => item.id === body.previousHostId)
     if (!record && body.installId) {
       const unnamed = records.filter(item => item.enabled && item.name === body.name && !item.installId).sort((a, b) => a.createdAt - b.createdAt)
       record = unnamed[0]
@@ -81,14 +86,14 @@ export class DesktopHostHub {
       const record = this.store.get<DesktopHostRecord>('_system', 'desktop-host', match[1]!)
       const authorization = ctx.get('authorization')
       if (ctx.get('origin') || !record?.enabled || !authorization.startsWith('Bearer ') || !timingSafeEqual(Buffer.from(record.tokenHash), Buffer.from(hash(authorization.slice(7)))))
-        throw new HttpError(403, '桌面主机授权无效', 'desktop_host_unauthorized')
+        throw new HttpError(403, '电脑授权无效', 'desktop_host_unauthorized')
       if (ctx.method !== 'POST') throw new HttpError(405, '仅允许 POST', 'method_not_allowed')
-      if (ctx.get('x-desktop-host-protocol') !== String(DESKTOP_HOST_PROTOCOL)) throw new HttpError(409, '桌面主机协议不兼容', 'desktop_host_protocol_mismatch')
+      if (ctx.get('x-desktop-host-protocol') !== String(DESKTOP_HOST_PROTOCOL)) throw new HttpError(409, '电脑连接协议不兼容', 'desktop_host_protocol_mismatch')
       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)) }
+      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') }
+      try { json = JSON.parse(Buffer.concat(chunks).toString()) } catch { throw new HttpError(400, '电脑请求格式无效', 'desktop_invalid') }
       const value = parse(exchangeInput, json)
       this.online.set(record.id, { seen: Date.now(), name: value.host.name, platform: value.host.platform })
       ctx.set('Cache-Control', 'no-store')

+ 1 - 1
src/server/grokCloud.ts

@@ -28,7 +28,7 @@ export const GROK_COMPUTER_TOOLS=[
   {id:'cloud_computer_desktop',name:'cloud_computer_desktop',description:'查看 Grok Bot 云端电脑桌面,返回真实截图。',inputSchema:schema({},[])},
   {id:'cloud_computer_action',name:'cloud_computer_action',description:'操作 Grok Bot 云端电脑。先查看桌面,再使用 click、text、key 或 scroll。',inputSchema:schema({kind:{enum:['click','text','key','scroll']},x:{type:'integer'},y:{type:'integer'},text:field,key:field,direction:{enum:['up','down','left','right']}},['kind'])},
 ]
-export const GROK_COMPUTER_RULES='当前环境是云虚拟机,也就是 Grok Bot 虚拟机,工作目录 /workspace。电脑操作、文件读写和命令必须使用 cloud_computer_*。脚本机、服务端和虚拟环境都不属于这台云虚拟机。多个机器人共享工作文件和浏览器登录,避免覆盖其他机器人的工作;人工接管时等待交还。'
+export const GROK_COMPUTER_RULES='当前环境是云虚拟机,也就是 Grok Bot 虚拟机,工作目录 /workspace。电脑操作、文件读写和命令必须使用 cloud_computer_*。已连接的电脑、服务器和虚拟环境都不属于这台云虚拟机。多个机器人共享工作文件和浏览器登录,避免覆盖其他机器人的工作;人工接管时等待交还。'
 export function grokComputerRules(_allowHostEnvironment = false){
   return GROK_COMPUTER_RULES
 }

+ 2 - 2
src/server/vmComputer.ts

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

+ 4 - 3
src/server/workspaceRuntime.ts

@@ -36,7 +36,8 @@ function environmentMentions(text: string, hostNames: string[]) {
   const cloud = /云虚拟机|云电脑|云服务器|cloud_computer|Grok/i.test(text)
   const vm = /虚拟环境|虚拟机|\bVM\b/i.test(text) || /computer_(desktop|shell|action|read_file|write_file)/.test(text)
   const named = hostNames.some(name => name.length >= 2 && name !== '本机' && text.includes(name))
-  const script = /脚本机|客户端|本机/.test(text) || named
+  const physicalComputerText = text.replace(/云虚拟机|云电脑|云服务器/g, '')
+  const script = /电脑|客户端|本机/.test(physicalComputerText) || named
   const server = /服务器|本机/.test(text) || (/desktop_/.test(text) && !script)
   const desktop = server || script || /desktop_/.test(text)
   return { desktop, server, script, vm, cloud }
@@ -100,7 +101,7 @@ export const sendInput = z
     projectId: z.string().min(1).max(100).nullable().optional(),
     mentionIds: z.array(z.string().uuid()).max(8).default([]),
     fileIds: z.array(z.string().uuid()).max(8).default([]),
-    /** Desktop host the client is messaging from: 'local' or paired host uuid. */
+    /** Computer the client is messaging from: 'local' or paired computer uuid. */
     deviceHost: deviceHostId.nullish(),
   })
   .strict()
@@ -805,7 +806,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const assignmentId = this.store.require<Run>(owner, 'run', run.runId).assignmentId
         const globals = globalComputers(this.store.home)
         const eligible = !agent.remoteAgentId && !agent.archived
-        const open = [eligible && globals.scriptMachine ? '脚本机' : '', eligible && globals.serverComputer ? '服务器' : '', eligible && globals.vm ? '虚拟环境' : '', eligible && globals.cloud ? '云虚拟机' : ''].filter(Boolean)
+        const open = [eligible && globals.scriptMachine ? '电脑' : '', eligible && globals.serverComputer ? '服务器' : '', eligible && globals.vm ? '虚拟环境' : '', eligible && globals.cloud ? '云虚拟机' : ''].filter(Boolean)
         const hostNames = this.desktopEnvironments?.hostStates(owner).map(host => host.name).filter(Boolean) ?? []
         const prior = this.store.messages(owner, c.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true, run.conversationTaskId)
           .filter(message => message.id !== resultMessage.id && message.seq <= run.triggerSeq)

+ 2 - 2
src/shared/desktopEnvironment.ts

@@ -9,13 +9,13 @@ export interface DesktopHostStatus {
 }
 export interface DesktopEnvironmentState {
  online:boolean
- /** The host this agent resolves to; id is 'local' or a paired desktop-host id. */
+ /** The computer this agent resolves to; id is 'local' or a paired desktop-host id. */
  host:{id:string;name:string;platform:string}|null
  local:{supported:boolean;authorized:boolean;screen:boolean;accessibility:boolean;ready:boolean;fullAuthorized:boolean}
  browser:{available:boolean;profile:'persistent'|'temporary'}
  selected:'local'|'browser'|null
  agent:WorkspaceAgent
- /** Every connected desktop host, for the machine picker. */
+ /** Every connected computer, for the machine picker. */
  hosts:DesktopHostStatus[]
 }
 export interface BotBrowserState {open:boolean;profile:string;tabs:{id:string;title:string;url:string;active:boolean}[]}

+ 1 - 1
src/shared/desktopHost.ts

@@ -23,7 +23,7 @@ export interface DesktopHostConfiguration {
   token: string
   allowInsecureLan?: boolean
 }
-/** Wire format exchanged between a remote desktop host and the server. */
+/** Wire format exchanged between a remote computer and the server. */
 export interface DesktopHostExchange {
   host: { id: string; name: string; platform: string; screen: boolean; accessibility: boolean; approved: string[]; full?: string[] }
   results: { id: string; value?: unknown; error?: string }[]

+ 2 - 2
src/shared/workspace.ts

@@ -31,7 +31,7 @@ export interface WorkspaceAgent {
   computer?: 'auto' | 'cloud' | 'vm' | 'local' | 'browser' | 'off'
   /** Parallel environment toggles. Absent on older records: derive from computer. */
   envs?: { vm?: boolean; cloud?: boolean; desktop?: boolean; browser?: boolean }
-  /** Bound desktop host: 'local' (loopback App) or a paired desktop-host id. */
+  /** Bound computer: 'local' (loopback App) or a paired desktop-host id. */
   desktopHost?: string | null
   allowHostEnvironment?: boolean
   /** Local VM execution backend; older records retain the isolated Worker. */
@@ -183,7 +183,7 @@ export interface WorkspaceMessage {
   peerMessageId?: string
   revision?: number
   execution?:'profile'|'computer'
-  /** Desktop host the user was on when sending: 'local' (YaoYao server) or paired host uuid. */
+  /** Computer the user was on when sending: 'local' (YaoYao server) or paired computer uuid. */
   deviceHost?: string
   id: string
   conversationId: string

+ 67 - 0
tests/client/authDesktopAuthorization.test.ts

@@ -0,0 +1,67 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+  login: vi.fn(),
+  setup: vi.fn(),
+  authorizeComputer: vi.fn(),
+}))
+
+vi.mock('@/api/auth', () => ({
+  login: mocks.login,
+  setup: mocks.setup,
+  bootstrap: vi.fn(),
+  logout: vi.fn(),
+  fetchProfiles: vi.fn(),
+  fetchProfileIdentities: vi.fn(),
+  changeCredentials: vi.fn(),
+  updateAccountAvatar: vi.fn(),
+}))
+vi.mock('@/api/serverIdentity', () => ({ fetchServerIdentity: vi.fn(), onServerIdentity: () => () => {} }))
+vi.mock('@/api/client', () => ({
+  ApiError: class ApiError extends Error { status = 500 },
+  clearApiSecurityContext: vi.fn(),
+  onApiUnauthorized: () => () => {},
+}))
+
+import { useAuthStore } from '@/stores/auth'
+
+const response = (role: 'admin' | 'user') => ({
+  authRequired: true,
+  profiles: [],
+  csrfToken: 'fresh-csrf',
+  user: { id: 'user-1', username: '用户', role },
+})
+
+beforeEach(() => {
+  setActivePinia(createPinia())
+  mocks.login.mockReset()
+  mocks.setup.mockReset()
+  mocks.authorizeComputer.mockReset()
+  Object.defineProperty(window, 'yaoyaoDesktop', { configurable: true, value: { authorizeComputer: mocks.authorizeComputer } })
+})
+
+it('re-authorizes the computer only after an explicit administrator login', async () => {
+  mocks.login.mockResolvedValue(response('admin'))
+  mocks.authorizeComputer.mockResolvedValue({ registered: true, hostId: 'host-1' })
+  const auth = useAuthStore()
+  await auth.login({ username: 'admin', password: 'secret' })
+  expect(mocks.authorizeComputer).toHaveBeenCalledWith('fresh-csrf')
+  expect(auth.status).toBe('authenticated')
+})
+
+it('does not grant computer control to a non-administrator login', async () => {
+  mocks.login.mockResolvedValue(response('user'))
+  const auth = useAuthStore()
+  await auth.login({ username: 'member', password: 'secret' })
+  expect(mocks.authorizeComputer).not.toHaveBeenCalled()
+  expect(auth.status).toBe('authenticated')
+})
+
+it('keeps the administrator signed in when computer authorization fails', async () => {
+  mocks.login.mockResolvedValue(response('admin'))
+  mocks.authorizeComputer.mockRejectedValue(new Error('computer offline'))
+  const auth = useAuthStore()
+  await expect(auth.login({ username: 'admin', password: 'secret' })).resolves.toBeUndefined()
+  expect(auth.status).toBe('authenticated')
+})

+ 1 - 1
tests/client/computerTargetSelection.test.ts

@@ -129,7 +129,7 @@ it('passes the native window URL target into the viewer and rejects malformed ta
  route.query={backend:'vm',host:'client-mac'}
  const invalid=mount(Viewer,{global:{stubs:{ComputerPanel:true}}})
  try{
-  await flushPromises();expect(invalid.text()).toContain('所选电脑主机无效')
+  await flushPromises();expect(invalid.text()).toContain('所选电脑无效')
   expect(invalid.findComponent(ComputerPanel).exists()).toBe(false)
  }finally{invalid.unmount()}
 })

+ 10 - 10
tests/e2e/desktop-hosts.spec.ts

@@ -1,6 +1,6 @@
 import { test, expect } from '@playwright/test'
 
-test('registers a desktop host, downloads its private config and disables it', async ({ page }, testInfo) => {
+test('registers a computer, downloads its private config and disables it', 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')
@@ -10,14 +10,14 @@ test('registers a desktop host, downloads its private config and disables it', a
   const dialog = page.getByRole('dialog', { name: '我的设置' })
   await dialog.getByRole('button', { name: 'Hermes 连接', exact: true }).click()
   await dialog.locator('details.desktop-host-settings summary').click()
-  const panel = dialog.getByRole('region', { name: '桌面主机设置' })
-  await expect(panel).toContainText('尚未注册桌面主机')
-  await panel.getByRole('textbox', { name: '主机名称', exact: true }).fill('书房 iMac')
+  const panel = dialog.getByRole('region', { name: '电脑设置' })
+  await expect(panel).toContainText('尚未注册电脑')
+  await panel.getByRole('textbox', { name: '电脑名称', exact: true }).fill('书房 iMac')
   await page.screenshot({ path: testInfo.outputPath('desktop-host-registration.png') })
-  await panel.getByRole('button', { name: '注册主机', exact: true }).click()
+  await panel.getByRole('button', { name: '注册电脑', exact: true }).click()
   await expect(panel).toContainText('待连接')
   const downloaded = page.waitForEvent('download')
-  await panel.getByRole('button', { name: '下载主机配置', exact: true }).click()
+  await panel.getByRole('button', { name: '下载电脑配置', exact: true }).click()
   const download = await downloaded
   expect(download.suggestedFilename()).toBe('desktop-host.json')
   const stream = await download.createReadStream(); const chunks: Buffer[] = []
@@ -47,12 +47,12 @@ test('shows globally available hosts for a legacy Bot without per-Bot switches',
   const dialog = page.getByRole('dialog', { name: '我的设置' })
   await dialog.getByRole('button', { name: 'Hermes 连接', exact: true }).click()
   await dialog.locator('details.desktop-host-settings summary').click()
-  const panel = dialog.getByRole('region', { name: '桌面主机设置' })
-  await panel.getByRole('textbox', { name: '主机名称', exact: true }).fill('客厅 Mac mini')
-  await panel.getByRole('button', { name: '注册主机', exact: true }).click()
+  const panel = dialog.getByRole('region', { name: '电脑设置' })
+  await panel.getByRole('textbox', { name: '电脑名称', exact: true }).fill('客厅 Mac mini')
+  await panel.getByRole('button', { name: '注册电脑', exact: true }).click()
   await expect(panel).toContainText('待连接')
   const downloaded = page.waitForEvent('download')
-  await panel.getByRole('button', { name: '下载主机配置', exact: true }).click()
+  await panel.getByRole('button', { name: '下载电脑配置', exact: true }).click()
   const stream = await (await downloaded).createReadStream(); const chunks: Buffer[] = []
   for await (const chunk of stream!) chunks.push(Buffer.from(chunk))
   const configuration = JSON.parse(Buffer.concat(chunks).toString())

+ 4 - 3
tests/e2e/native-environment.spec.ts

@@ -6,8 +6,9 @@ test('Web shows native desktop permissions',async({page})=>{
  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'})
+ 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)
+ await expect(panel.getByRole('checkbox',{name:'电脑浏览器',exact:true})).toHaveCount(0)
 })

+ 5 - 5
tests/server/desktopEnvironments.test.ts

@@ -102,7 +102,7 @@ it('lets the manual viewer take over any connected host via the host parameter',
  const response=await request(app()).get(base()+'/computer?backend=desktop&host=nope').expect(409)
  expect(response.body.code).toBe('desktop_host_unknown')
 })
-it('revoking a desktop host fences only its dispatched commands',async()=>{
+it('revoking a computer fences only its dispatched commands',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  service.remoteExchange(HOST_A,{host,results:[]})
  const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.hostEpoch('owner',agent),HOST_A)
@@ -126,7 +126,7 @@ 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 desktop host',async()=>{
+it('passes the authorize scope through to the computer',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)
@@ -238,7 +238,7 @@ it('degrades envs desktop tools when no host is ready or online',async()=>{
  service.exchange({host,results:[]})
  expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:true})
 })
-it('keeps manual takeover scoped per desktop host',async()=>{
+it('keeps manual takeover scoped per computer',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  const second=store.createAgent('owner',{name:'Second',nodeId:'local',profile:'default',computer:'local',desktopHost:HOST_B})
  driveRemote(HOST_A,{...host,name:'书房'})
@@ -267,7 +267,7 @@ it('pins tool calls to the host epoch and does not dispatch after permissions ch
 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',{host:'server'},new AbortController().signal,()=>{},'local',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(409);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)})
 
-it('resolves 本机 to the messaging device when deviceHost is a script machine',async()=>{
+it('resolves 本机 to the messaging device when deviceHost is a computer',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local'})
  drive()
  driveRemote(HOST_A,{...host,name:'客厅 Mac'})
@@ -276,7 +276,7 @@ it('resolves 本机 to the messaging device when deviceHost is a script machine'
  const omitted=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
  expect(JSON.parse(omitted.content[1].text).host).toBe('客厅 Mac')
 })
-it('keeps 本机 on the server when deviceHost is local even if a script machine is bound',async()=>{
+it('keeps 本机 on the server when deviceHost is local even if a computer is bound',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  drive()
  driveRemote(HOST_A,{...host,name:'客厅 Mac'})

+ 17 - 0
tests/server/desktopHosts.test.ts

@@ -83,3 +83,20 @@ it('reuses the same computer when the installed Mac logs in again',async()=>{
   const list = (await request(app()).get('/api/app/admin/desktop-hosts').expect(200)).body
   expect(list.hosts.filter((host: { enabled: boolean }) => host.enabled)).toHaveLength(1)
 })
+
+it('re-enables the exact disabled legacy computer after an explicit administrator login',async()=>{
+  const first=await request(app()).post('/api/app/admin/desktop-hosts').send({name:'旧版 Mac'}).expect(201)
+  await request(app()).delete(`/api/app/admin/desktop-hosts/${first.body.host.id}`).expect(200)
+  const installId='33333333-3333-4333-8333-333333333333'
+  const restored=await request(app()).post('/api/app/admin/desktop-hosts').send({name:'旧版 Mac',installId,previousHostId:first.body.host.id}).expect(201)
+  expect(restored.body.host).toMatchObject({id:first.body.host.id,installId,enabled:true})
+  expect(restored.body.token).not.toBe(first.body.token)
+  const list=(await request(app()).get('/api/app/admin/desktop-hosts').expect(200)).body
+  expect(list.hosts).toHaveLength(1)
+  expect(list.hosts[0]).toMatchObject({id:first.body.host.id,installId,enabled:true})
+  await request(app()).post(`/api/desktop-host/v1/${first.body.host.id}/exchange`)
+    .set('Authorization','Bearer '+first.body.token)
+    .set('x-desktop-host-protocol','1')
+    .send(body())
+    .expect(403)
+})