Explorar o código

统一 Bot 环境快照与设备能力上报

Samien hai 1 mes
pai
achega
3f832405ef

+ 6 - 4
desktop/environment-host.mjs

@@ -7,16 +7,18 @@ import {execFile} from 'node:child_process'
 import {BrowserEnvironment} from './browser-environment.mjs'
 import {listHostFiles,readHostFile,writeHostFile,receiveHostFile,execHostShell} from './host-files.mjs'
 import {exchangeDesktopEnvironment as exchange} from './environment-exchange.mjs'
+import {HostEnvironmentReporter} from './host-environment.mjs'
 import {FileTransferFiles} from './file-transfer.mjs'
 
 /** 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()}
+ constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;this.home=process.env.HOME||'/';this.environmentReporter=new HostEnvironmentReporter(this.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()}
  transfers=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],fileTransferVersion:1}}
+ 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],...this.environmentReporter.fields()}}
+ acceptCapabilities(capabilities){this.environmentReporter.accept(capabilities)}
  async clearTransfers(){const transfers=[...this.transfers.values()];this.transfers.clear();await Promise.allSettled(transfers.map(transfer=>transfer.close()))}
  async revoke(){this.approved.clear();this.approvedFull.clear();this.save();this.frames.clear();this.id=randomUUID();await this.clearTransfers()}
- async recycle(){this.results=[];this.id=randomUUID();await this.clearTransfers();await this.browser.close()}
+ async recycle(){this.environmentReporter.reset();this.results=[];this.id=randomUUID();await this.clearTransfers();await this.browser.close()}
  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))}}
@@ -74,7 +76,7 @@ export class DesktopHostCore {
 export class DesktopEnvironmentHost {
  constructor({manager,root,dataRoot}){this.manager=manager;this.core=new DesktopHostCore({root,dataRoot});this.closed=false;this.timer=undefined;this.instance=undefined}
  revoke(){return this.core.revoke()}
- async cycle(){if(this.closed)return;try{const record=await this.manager.readRecord();if(record){const verified=await this.manager.verify(record);record.url=verified.url;if(this.instance&&this.instance!==record.instanceId)await this.core.recycle();this.instance=record.instanceId;const value=await exchange(record,{host:this.core.info(),results:this.core.takeResults()});await this.core.handle(value.commands??[])}}catch{}finally{if(!this.closed)this.timer=setTimeout(()=>void this.cycle(),400)}}
+ async cycle(){if(this.closed)return;try{const record=await this.manager.readRecord();if(record){const verified=await this.manager.verify(record);record.url=verified.url;if(this.instance&&this.instance!==record.instanceId)await this.core.recycle();this.instance=record.instanceId;const value=await exchange(record,{host:this.core.info(),results:this.core.takeResults()});this.core.acceptCapabilities(value.capabilities);await this.core.handle(value.commands??[])}}catch{this.core.acceptCapabilities(undefined)}finally{if(!this.closed)this.timer=setTimeout(()=>void this.cycle(),400)}}
  start(){this.closed=false;this.core.closed=false;void this.cycle()}
  async close(){this.closed=true;clearTimeout(this.timer);await this.core.close()}
 }

+ 10 - 0
desktop/host-environment.mjs

@@ -0,0 +1,10 @@
+import {arch,release} from 'node:os'
+import {HOST_SHELL} from './host-files.mjs'
+
+/** Optional metadata stays off the wire until the server advertises support. */
+export class HostEnvironmentReporter {
+ constructor(home){this.home=home;this.supported=false}
+ accept(capabilities){this.supported=capabilities?.environmentMetadata===1}
+ reset(){this.supported=false}
+ fields(){return this.supported?{fileTransferVersion:1,environment:{version:1,osRelease:release(),arch:arch(),shell:HOST_SHELL,homeDirectory:this.home,defaultCwd:this.home,fileRoots:[this.home],shellScope:'user',timezone:Intl.DateTimeFormat().resolvedOptions().timeZone}}:{}}
+}

+ 21 - 0
desktop/host-environment.test.mjs

@@ -0,0 +1,21 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {arch,release} from 'node:os'
+import {HostEnvironmentReporter} from './host-environment.mjs'
+import {HOST_SHELL} from './host-files.mjs'
+
+test('only sends optional host facts after explicit server negotiation and resets for old servers',()=>{
+ const reporter=new HostEnvironmentReporter('/Users/fixture')
+ assert.deepEqual(reporter.fields(),{})
+ reporter.accept({environmentMetadata:1})
+ const {fileTransferVersion,environment}=reporter.fields()
+ assert.equal(fileTransferVersion,1)
+ assert.deepEqual(environment,{version:1,osRelease:release(),arch:arch(),shell:HOST_SHELL,homeDirectory:'/Users/fixture',defaultCwd:'/Users/fixture',fileRoots:['/Users/fixture'],shellScope:'user',timezone:Intl.DateTimeFormat().resolvedOptions().timeZone})
+ assert.equal(Object.keys(environment).some(key=>/token|password|env|secret/i.test(key)),false)
+ reporter.accept(undefined)
+ assert.deepEqual(reporter.fields(),{})
+ reporter.accept({environmentMetadata:2})
+ assert.deepEqual(reporter.fields(),{})
+ reporter.accept({environmentMetadata:1});reporter.reset()
+ assert.deepEqual(reporter.fields(),{})
+})

+ 2 - 1
desktop/host-files.mjs

@@ -4,6 +4,7 @@ import {join,resolve,sep} from 'node:path'
 import {homedir} from 'node:os'
 import {execFile} from 'node:child_process'
 
+export const HOST_SHELL='/bin/zsh'
 export const FILE_READ_LIMIT=12*1024*1024
 export const FILE_WRITE_LIMIT=10*1024*1024
 export const FILE_LIST_LIMIT=500
@@ -107,7 +108,7 @@ export async function execHostShell(root,input){
   let cwd
   try{cwd=resolve(String(root),String(input?.cwd??'').replace(/^~\//,'').replace(/^~$/,''))}catch{cwd=String(root)}
   return new Promise(done=>{
-    execFile('/bin/zsh',['-c',command],{cwd,timeout,killSignal:'SIGKILL',maxBuffer:1024*1024,env:{...process.env,TERM:'dumb'}},(error,stdout,stderr)=>{
+    execFile(HOST_SHELL,['-c',command],{cwd,timeout,killSignal:'SIGKILL',maxBuffer:1024*1024,env:{...process.env,TERM:'dumb'}},(error,stdout,stderr)=>{
       const clip=value=>Buffer.from(value??'','utf8').subarray(0,SHELL_OUTPUT_LIMIT).toString('utf8')
       done({command,exitCode:error?typeof error.code==='number'?error.code:1:0,timedOut:error?.killed===true&&error?.signal==='SIGKILL',stdout:clip(stdout),stderr:clip(stderr)})
     })

+ 2 - 0
desktop/host-manager.mjs

@@ -118,9 +118,11 @@ export class DesktopHostManager {
    this.backoff=400
    if(this.config!==config||this.core!==core)return
    this.publish(`电脑已连接 ${new URL(config.serverURL).host}`)
+   core.acceptCapabilities?.(value.capabilities)
    await core.handle(value.commands??[])
   }catch(error){
    if(this.config!==config||this.core!==core)return
+   core.acceptCapabilities?.(undefined)
    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)}`)

+ 26 - 2
desktop/host-manager.test.mjs

@@ -5,6 +5,7 @@ import {tmpdir} from 'node:os'
 import {join} from 'node:path'
 import {createServer} from 'node:http'
 import {randomUUID,randomBytes,createCipheriv,createDecipheriv} from 'node:crypto'
+import {HostEnvironmentReporter} from './host-environment.mjs'
 import {DesktopHostManager} from './host-manager.mjs'
 import {parseDesktopHostConfiguration} from './host-config.mjs'
 
@@ -29,8 +30,9 @@ async function fixture(config){
  const cores=[],handled=[]
  const manager=new DesktopHostManager({home,root:'/app',dataRoot:home,
   encrypt,decrypt,
-  createCore:()=>{const core={id:randomUUID(),closed:false,results:[],exchanges:0,
-    info(){core.exchanges++;return {id:core.id,name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[]}},
+  createCore:()=>{const reporter=new HostEnvironmentReporter('/Users/fixture');const core={id:randomUUID(),closed:false,results:[],exchanges:0,
+    info(){core.exchanges++;return {id:core.id,name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[],...reporter.fields()}},
+    acceptCapabilities(capabilities){reporter.accept(capabilities)},
     takeResults(){return core.results.splice(0,core.results.length)},
     async handle(commands){handled.push(...commands??[])},
     async revoke(){core.id=randomUUID()},
@@ -133,3 +135,25 @@ test('network failures back off but keep retrying',async()=>{
   await rm(home,{recursive:true,force:true})
  }
 })
+
+test('negotiates metadata and retries in legacy format when the server stops supporting it',async()=>{
+ const exchanges=[]
+ const server=createServer((req,res)=>{let body='';req.on('data',chunk=>body+=chunk);req.on('end',()=>{
+  exchanges.push(JSON.parse(body));res.setHeader('content-type','application/json')
+  if(exchanges.length===2){res.statusCode=400;res.end(JSON.stringify({error:'older server'}));return}
+  res.end(JSON.stringify(exchanges.length===1?{commands:[],capabilities:{environmentMetadata:1}}:{commands:[]}))
+ })})
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
+ const f=await fixture({protocol:1,serverURL:`http://127.0.0.1:${server.address().port}`,hostId:randomUUID(),token:randomBytes(32).toString('base64url')})
+ try{
+  await f.manager.importFile(f.configPath)
+  clearTimeout(f.manager.timer)
+  for(let i=0;i<3;i++){await f.manager.cycle();clearTimeout(f.manager.timer)}
+  assert.equal(exchanges[0].host.environment,undefined)
+  assert.equal(exchanges[0].host.fileTransferVersion,undefined)
+  assert.equal(exchanges[1].host.environment.homeDirectory,'/Users/fixture')
+  assert.equal(exchanges[1].host.fileTransferVersion,1)
+  assert.equal(exchanges[2].host.environment,undefined)
+  assert.equal(exchanges[2].host.fileTransferVersion,undefined)
+ }finally{await f.manager.stop();await new Promise(resolve=>server.close(resolve));await rm(f.home,{recursive:true,force:true})}
+})

+ 37 - 2
docs/bot-prompt-context.md

@@ -1,6 +1,6 @@
 # Bot 提示词与环境上下文
 
-2026-09-21,第一批优化。适用于应用的 Bot 模式,由服务端统一生效。
+2026-09-21,第一、二批优化。适用于应用的 Bot 模式;设备扩展信息需要桌面端与服务端均升级。
 
 ## 提示词组织
 
@@ -20,6 +20,30 @@
 - 设备清单每轮只出现一次,包含名称、稳定的工具 `host` 值、平台及该账号的屏幕控制/文件与命令状态。服务器工具目标固定为 `server`;同名电脑可按 ID 区分。
 - “本机”继续取本轮来源。用户明确指定目标时使用指定目标;未知来源、离线、未开放或未授权不得回退到其他设备。协作、定时任务、并发来源和执行时权限校验沿用原机制。
 
+## 第二批:统一环境快照
+
+`src/shared/botEnvironment.ts` 定义本轮结构化环境数据;`desktopEnvironments.snapshot()` 采集设备状态,`workspaceEnvironment.ts` 汇总 Hermes、桌面、VM 和云电脑能力。提示词、电脑工具目录和 Inspector 的 `bot.environment` 记录共用这一份快照。
+
+- 设备按稳定 ID 列出,包含已登记但离线、已关闭的电脑,以及本轮来源。分别记录查看、输入、文件读写、命令和文件传输能力;区分离线、未开放、不支持、未授权、系统权限缺失、人工接管和暂停等原因。
+- 桌面端上报白名单信息:系统内核版本、架构、Shell、主目录、默认工作目录、文件工具允许根目录、Shell 权限范围和时区。没有上报的信息保持未知,不推测路径,也不采集环境变量、令牌或凭据。
+- 服务端按当前账号裁剪设备信息。没有文件与命令授权时,提示词和诊断快照均不包含设备运行路径;离线设备也不提供缓存路径。文件工具受允许根目录约束,Shell 使用桌面用户的系统权限,不宣称它受同一根目录限制。
+- 每台设备单独标记分块/旧版传输协议与读写上限。旧版读取最多 12 MiB、写入最多 10 MiB,且仍受全局传输上限约束;没有连接信息时标记未知。
+- 人工接管期间保留已经可用的工具,调用等待交还。执行时仍实时校验授权、全局开关和连接代次;撤权立即生效,设备重连或本轮快照之外的新设备不能沿用旧上下文执行。
+- VM 和云电脑仅记录配置及按需可用状态;采集快照不启动电脑、不探测连接。运行状态和 VM 工作目录由首次调用确认。Hermes 工作目录仍只使用成功确认的结果。
+- Inspector 同时记录结构化快照及实际挂载的电脑工具 ID,便于比对模型收到的环境说明与工具目录。
+
+### 客户端兼容
+
+本机和远程设备入口共用严格的协议校验。新版桌面端先发送旧格式,服务器通过 `capabilities.environmentMetadata=1` 声明支持后,客户端才发送扩展信息和分块传输版本。连接失败或服务器不再声明支持时清除协商结果,下一次按旧格式重试。
+
+| 服务端 | 桌面端 | 行为 |
+| --- | --- | --- |
+| 新版 | 新版 | 协商后上报完整元数据与分块能力 |
+| 新版 | 旧版 | 接收原有字段,缺失元数据明确显示未知 |
+| 旧版 | 新版 | 使用旧格式,按旧版传输上限工作 |
+
+服务端原有的远程设备入口未接受 `fileTransferVersion`,现在与本机入口统一,避免桌面端发送该字段时被严格校验拒绝。
+
 ## 验证
 
 修改前的五组相关测试 163 项通过。修改后的十二组测试 267 项通过,包括新增的 9 项提示词、设备清单和真实 Bot WebSocket 提交链路测试。
@@ -44,4 +68,15 @@ npm run build
 
 类型检查和构建通过,构建保留大包体积提示。验证使用模拟 Hermes 与设备传输,没有调用外部模型或操作生产设备,不能据此认定真实模型的目标选择准确率或 Token 开销已改善。
 
-完整设备元数据协议、上下文预算、按任务检索记忆和 Hermes 独立上下文角色留待后续批次;本批不部署或重启运行中的服务。
+第二批在以上测试组基础上增加 `workspaceEnvironment.test.ts`、`desktopHosts.test.ts`,合计 14 组、285 项通过。另有 13 项桌面端测试通过:
+
+```sh
+node --test desktop/host-environment.test.mjs \
+  desktop/host-manager.test.mjs desktop/host-files.test.mjs
+npm run typecheck
+npm run desktop:build
+```
+
+覆盖新旧协议协商、服务器回退、按账号裁剪路径、离线与关闭设备、人工接管与撤权、连接变更,以及真实 Bot 提交链路中提示词/工具目录/Inspector 的一致性。类型检查和桌面完整构建通过,构建仍有大包体积提示。未进行多台真实 Mac 联调或外部模型效果评测。
+
+上下文预算、按任务检索记忆和 Hermes 独立上下文角色留待第三批;前两批均未部署或重启运行中的服务。

+ 33 - 31
src/server/desktopEnvironments.ts

@@ -13,6 +13,9 @@ import type {ComputerFrame,ComputerControlStatus} from '../shared/computerContro
 import {requireTeamToolBridge} from './workspaceToolLease.js'
 import {copyBetweenEndpoints} from './fileTransfer.js'
 import type {FileTransferEndpoint} from '../shared/fileTransfer.js'
+import {deviceSourceText,deviceInventoryText,type DesktopEnvironmentMetadata,type DesktopEnvironmentSnapshot,type BotDeviceSnapshot,type DeviceCapability,type DeviceCapabilityStatus} from '../shared/botEnvironment.js'
+
+import {desktopHostExchangeSchema} from './desktopHostProtocol.js'
 
 const digest=(s:string)=>createHash('sha256').update(s).digest('hex')
 const inputSchema=z.discriminatedUnion('kind',[
@@ -36,7 +39,7 @@ const fileSchema=z.discriminatedUnion('op',[
 const shellSchema=z.object({command:z.string().min(1).max(8000),cwd:z.string().max(2048).optional(),timeoutMs:z.number().int().min(1000).max(120000).optional()}).strict()
 const copySchema=z.object({sourceHost:z.string().trim().min(1).max(128),sourcePath:z.string().min(1).max(2048),targetHost:z.string().trim().min(1).max(128),targetPath:z.string().min(1).max(2048),overwrite:z.boolean().default(false)}).strict()
 type Mode='local'|'browser'
-interface NativeHost {id:string;name:string;platform:string;screen:boolean;accessibility:boolean;approved:string[];full?:string[];fileTransferVersion?:number}
+interface NativeHost {id:string;name:string;platform:string;screen:boolean;accessibility:boolean;approved:string[];full?:string[];fileTransferVersion?:number;environment?:DesktopEnvironmentMetadata}
 interface Pending {id:string;epoch:string;deadline:number;command:Record<string,unknown>;check:()=>void;resolve:(v:any)=>void;reject:(e:Error)=>void;sent:boolean;timer:ReturnType<typeof setTimeout>}
 interface Manual {ready?:boolean;id:string;token:string;requestId:string;owner:string;agentId:string;version:number;expiresAt:number;generation:number;epoch:string;pending?:Promise<unknown>;releasing?:boolean;actions:Map<string,{fingerprint:string;value:Promise<unknown>}>}
 /** All per-host state. The loopback App owns key 'local'; remote Macs each
@@ -128,9 +131,6 @@ export class DesktopEnvironments {
   if(!s)throw new HttpError(409,'消息来源电脑未连接;不会改用其他电脑。','desktop_offline')
   return this.requireHost(s)
  }
- private contextSession(deviceHost?:string):HostState|undefined{
-  return deviceHost?this.sessions.get(deviceHost):undefined
- }
  selected(_owner:string,agent:WorkspaceAgent):Mode|undefined {
   if(agent.archived||agent.remoteAgentId)return undefined
   const g=this.computers();return g.script||g.server?'local':undefined
@@ -138,9 +138,8 @@ export class DesktopEnvironments {
  toolMode(owner:string,agent:WorkspaceAgent):Mode|undefined{return this.selected(owner,agent)}
  modes(owner:string,agent:WorkspaceAgent):{desktop:boolean,browser:boolean}{return {desktop:!!this.selected(owner,agent),browser:false}}
  envTools(owner:string,agent:WorkspaceAgent):{view:boolean,browser:boolean,file:boolean}{
-  const wanted=this.modes(owner,agent)
-  const online=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s))
-  return {view:wanted.desktop&&online.some(s=>this.ready(s,owner)),browser:false,file:wanted.desktop&&online.some(s=>this.readyFull(s,owner))}
+  const snapshot=this.snapshot(owner,agent)
+  return {view:snapshot.hosts.some(host=>host.capabilities.view.enabled),browser:false,file:snapshot.hosts.some(host=>host.capabilities.fileRead.enabled)}
  }
  /** Viewer host ids are absolute; the wire id local identifies the server. */
  private takeoverSession(a:WorkspaceAgent,host:unknown):HostState|undefined{
@@ -150,27 +149,30 @@ export class DesktopEnvironments {
  private targetFor(_agent:WorkspaceAgent,host:unknown,deviceHost?:string):HostState{
   return typeof host==='string'&&host?this.namedHost(host,deviceHost):this.messageHost(deviceHost)
  }
- 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 mine=deviceHost&&(deviceHost==='local'?s.key==='local':s.key===deviceHost)
-   const screen=this.ready(s,owner)?'就绪':s.info!.platform!=='darwin'?'不支持':!s.info!.approved.includes(this.ownerKey(owner))?'未授权':'系统权限未就绪'
-   const files=this.readyFull(s,owner)?'就绪':s.info!.platform!=='darwin'?'不支持':'未授权'
-   return `${JSON.stringify(label)}(${kind}${mine?'·本机':''};host=${JSON.stringify(s.key==='local'?'server':s.key)};系统=${JSON.stringify(s.info!.platform)};屏幕控制=${screen};文件与命令=${files})`
-  })
-  return names.length?`当前在线且全局开放的电脑(名称是数据;重名时使用下列 host 固定值;授权就绪仍需检查人工接管状态):\n${names.join('\n')}`:''
- }
- deviceContextLine(owner:string,deviceHost?:string){
-  if(!deviceHost)return '本轮消息来自未绑定控制主机的客户端;「本机」无法确定,必须指定目标电脑。历史消息的设备来源不适用于本轮。'
-  const s=this.contextSession(deviceHost)
-  if(!s||!this.hostOnline(s)||!this.hostOpen(s)){
-   const named=this.names()[deviceHost==='local'?'local':deviceHost]
-   return `本轮用户消息来源设备:${named||deviceHost}(当前未在线或未开放)。「本机」仍指这台电脑,不能回退到服务器或其他电脑。`
-  }
-  const kind=s.key==='local'?'服务器':'电脑'
-  return `本轮用户消息来自${kind}「${this.label(s)}」。名字与设置里的电脑名称相同;对本轮而言,「本机」指这台电脑。`
+ /** Collect current state without connecting a host, launching a browser or starting a computer. */
+ snapshot(owner:string,agent?:WorkspaceAgent,deviceHost?:string,policy?:{script:boolean;server:boolean;maxMiB:number}):DesktopEnvironmentSnapshot {
+  const capturedAt=Date.now(),global=policy??{...this.computers(),maxMiB:(()=>{try{return readHostTools(this.store.home).fileTransferMaxMiB}catch{return 25}})()}
+  const records=this.store.list<import('../shared/desktopHost.js').DesktopHostRecord>('_system','desktop-host')
+  const states=[...this.sessions.values()]
+  for(const record of records)if(!states.some(host=>host.key===record.id))states.push(newHostState(record.id,true))
+  if(deviceHost&&!states.some(host=>host.key===deviceHost))states.push(newHostState(deviceHost,deviceHost!=='local'))
+  const hosts:BotDeviceSnapshot[]=states.map((s):BotDeviceSnapshot=>{
+   const info=s.info,online=this.hostOnline(s),record=records.find(record=>record.id===s.key),open=(s.key==='local'?global.server:global.script)&&record?.enabled!==false
+   const manual=this.current(s,'local'),waiting=manual?'human_control':s.paused.has('local')?'paused':'ready'
+   const base:DeviceCapabilityStatus|undefined=agent&&(agent.archived||agent.remoteAgentId)?'agent_unavailable':!open?'disabled':!online?'offline':info?.platform!=='darwin'?'unsupported':undefined
+   const capability=(reason?:DeviceCapabilityStatus):DeviceCapability=>({enabled:!reason,status:reason??waiting})
+   const screen=capability(base??(!info?.approved.includes(this.ownerKey(owner))?'not_authorized':!info.screen||!info.accessibility?'system_permission_required':undefined))
+   const files=capability(base??(!(info?.full??[]).includes(this.ownerKey(owner))?'not_authorized':undefined))
+   const metadata=online&&info?.environment?{version:info.environment.version,osRelease:info.environment.osRelease,arch:info.environment.arch,timezone:info.environment.timezone,...(files.enabled?structuredClone(info.environment):{})}:undefined
+   return {id:s.key,target:s.key==='local'?'server':s.key,name:this.label(s)||record?.name||s.key,kind:s.key==='local'?'server':'computer',source:s.key===deviceHost,online,open,
+    ...(info?{epoch:info.id,platform:info.platform}:{}),...(s.seen?{lastSeen:s.seen}:{}),...(metadata?{metadata}:{}),
+    capabilities:{view:{...screen},input:{...screen},fileRead:{...files},fileWrite:{...files},shell:{...files},fileTransfer:{...files}},
+    transfer:{protocol:!info?'unknown':info.fileTransferVersion===1?'chunked':'legacy',readMaxMiB:!info?null:info.fileTransferVersion===1?global.maxMiB:Math.min(global.maxMiB,12),writeMaxMiB:!info?null:info.fileTransferVersion===1?global.maxMiB:Math.min(global.maxMiB,10)}}
+  }).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.id.localeCompare(b.id))
+  return {capturedAt,...(deviceHost?{sourceHost:deviceHost}:{}),hosts}
  }
+ onlineHostsLine(owner:string,deviceHost?:string){return deviceInventoryText(this.snapshot(owner,undefined,deviceHost))}
+ 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:info?.platform==='darwin',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))}
@@ -198,14 +200,14 @@ export class DesktopEnvironments {
  /** Revocation or replacement of a paired host fences its live commands. */
  dropHost(id:string){const s=this.sessions.get(id);if(s){this.invalidate(s);this.sessions.delete(id)}}
  private exchangeFor(s:HostState,body:unknown){
-  const value=parse(z.object({host:z.object({id:z.string().uuid(),name:z.string().max(128),platform:z.string().max(16),screen:z.boolean(),accessibility:z.boolean(),approved:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(100),full:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(100).optional(),fileTransferVersion:z.literal(1).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(),body)
+  const value=parse(desktopHostExchangeSchema,body)
   if(this.closed)throw new HttpError(503,'服务已停止','desktop_closed')
   if(s.info&&(s.info.id!==value.host.id||!this.hostOnline(s)))this.invalidate(s)
   s.info=value.host;s.seen=Date.now()
   for(const result of value.results){const p=s.pending.get(result.id);if(!p||p.epoch!==value.host.id)continue;s.pending.delete(p.id);clearTimeout(p.timer);try{p.check();if(result.error)throw new HttpError(409,result.error,'desktop_command_failed');p.resolve(result.value)}catch(error){p.reject(error as Error)}}
   const commands:Record<string,unknown>[]=[]
   for(const p of s.pending.values()){if(p.sent)continue;try{p.check();p.sent=true;commands.push({id:p.id,deadline:p.deadline,...p.command})}catch(error){s.pending.delete(p.id);clearTimeout(p.timer);p.reject(error as Error)}if(commands.length>=8)break}
-  return {commands}
+  return {commands,capabilities:{environmentMetadata:1}}
  }
  private command(s:HostState,command:Record<string,unknown>,check:()=>void,timeout=30000):Promise<any>{
   check();if(!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
@@ -234,7 +236,7 @@ export class DesktopEnvironments {
   const maxBytes=readHostTools(this.store.home).fileTransferMaxMiB*1024*1024
   const hosts=[...new Set([source,target].filter((host):host is HostState=>!!host))].sort((a,b)=>a.key.localeCompare(b.key))
   const contexts=hosts.map(host=>({host,context:this.context(host,owner,agent,'local',true),epoch:typeof epoch==='string'?(host===this.sessionFor(agent)?epoch:undefined):epoch?.[host.key]}))
-  const check=()=>{signal.throwIfAborted();assertActive();for(const item of contexts){item.context.check();if(this.sessions.get(item.host.key)!==item.host||(item.epoch!==undefined&&item.host.info?.id!==item.epoch))throw new HttpError(410,'传输中的电脑连接已改变,请核对文件后重试。','desktop_disconnected')}}
+  const check=()=>{signal.throwIfAborted();assertActive();for(const item of contexts){if(epoch&&typeof epoch==='object'&&!Object.hasOwn(epoch,item.host.key))throw new HttpError(410,'目标电脑不在本轮环境快照中,请发起新一轮对话。','desktop_context_changed');item.context.check();if(this.sessions.get(item.host.key)!==item.host||(item.epoch!==undefined&&item.host.info?.id!==item.epoch))throw new HttpError(410,'传输中的电脑连接已改变,请核对文件后重试。','desktop_disconnected')}}
   check()
   const command=async(host:HostState,action:Record<string,unknown>)=>{const context=contexts.find(item=>item.host===host)!.context;if(action.op!=='transfer-abort')await available();return this.command(host,{...context.command,operation:'file',action},action.op==='transfer-abort'?context.check:check,60000)}
   // A stable lock order prevents A→B and B→A transfers from deadlocking.
@@ -266,7 +268,7 @@ export class DesktopEnvironments {
   const s=this.targetFor(a,requestedHost,deviceHost)
   const c=this.context(s,owner,a,expected,DESKTOP_FILE_TOOL_IDS.has(id))
   const pinned=typeof epoch==='string'?(s===this.sessionFor(a)?epoch:undefined):epoch?.[s.key]
-  const check=()=>{signal.throwIfAborted();assertActive();c.check();if(pinned!==undefined&&s.info?.id!==pinned)throw new HttpError(410,'桌面连接已改变','desktop_disconnected')};check()
+  const check=()=>{signal.throwIfAborted();assertActive();if(epoch&&typeof epoch==='object'&&!Object.hasOwn(epoch,s.key))throw new HttpError(410,'目标电脑不在本轮环境快照中,请发起新一轮对话。','desktop_context_changed');c.check();if(pinned!==undefined&&s.info?.id!==pinned)throw new HttpError(410,'桌面连接已改变','desktop_disconnected')};check()
   s.activity.set(c.key,(s.activity.get(c.key)??0)+1)
   try{return await this.serial(s,c.key,async()=>{while(this.current(s,c.key)||s.paused.has(c.key)){check();await new Promise(resolve=>setTimeout(resolve,150))}check()
    let result:any

+ 18 - 0
src/server/desktopHostProtocol.ts

@@ -0,0 +1,18 @@
+import { z } from 'zod'
+
+const text = z.string().min(1).max(4096).regex(/^[^\u0000-\u001f\u007f]*$/)
+export const desktopHostEnvironmentSchema = z.object({
+  version: z.literal(1), osRelease: text, arch: text, shell: text,
+  homeDirectory: text, defaultCwd: text, fileRoots: z.array(text).min(1).max(8),
+  shellScope: z.literal('user'), timezone: text,
+}).strict()
+export const desktopHostInfoSchema = 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(),
+  fileTransferVersion: z.literal(1).optional(), environment: desktopHostEnvironmentSchema.optional(),
+}).strict()
+export const desktopHostExchangeSchema = z.object({
+  host: desktopHostInfoSchema,
+  results: z.array(z.object({ id: z.string().uuid(), value: z.unknown().optional(), error: z.string().max(500).optional() }).strict()).max(20),
+}).strict()

+ 2 - 5
src/server/desktopHosts.ts

@@ -6,6 +6,7 @@ import { HttpError } from './errors.js'
 import { parse, type WorkspaceStore } from './workspaceStore.js'
 import type { LocalAuthStore } from './localAuth.js'
 import { DESKTOP_HOST_PROTOCOL, type DesktopHostExchange, type DesktopHostRecord, type DesktopHostSummary } from '../shared/desktopHost.js'
+import { desktopHostExchangeSchema } from './desktopHostProtocol.js'
 import { readComputerNames, saveComputerName } from './computerNames.js'
 
 const hash = (value: string) => createHash('sha256').update(value).digest('hex')
@@ -14,10 +15,6 @@ const enrollInput = z.object({
   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 computers. Browser auth and
  *  machine auth remain separate: these endpoints never enter the user session,
@@ -94,7 +91,7 @@ export class DesktopHostHub {
       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') }
-      const value = parse(exchangeInput, json)
+      const value = parse(desktopHostExchangeSchema, json)
       this.online.set(record.id, { seen: Date.now(), name: value.host.name, platform: value.host.platform })
       ctx.set('Cache-Control', 'no-store')
       ctx.body = this.exchange(record.id, value)

+ 48 - 0
src/server/workspaceEnvironment.ts

@@ -0,0 +1,48 @@
+import type { DesktopEnvironmentSnapshot, WorkspaceEnvironment } from '../shared/botEnvironment.js'
+import type { WorkspaceAgent } from '../shared/workspace.js'
+import type { HostToolSettings } from './hostToolSettings.js'
+import { DESKTOP_ENVIRONMENT_TOOLS, DESKTOP_FILE_TOOL_IDS } from './desktopEnvironments.js'
+import { GROK_COMPUTER_TOOLS } from './grokCloud.js'
+import { VM_COMPUTER_TOOLS } from './vmComputer.js'
+
+/** One snapshot drives model instructions, mounted computer tools and inspection. */
+export function buildWorkspaceEnvironment(input: {
+  agent: WorkspaceAgent
+  globals: HostToolSettings
+  desktop: DesktopEnvironmentSnapshot
+  bridge: boolean
+  cloud: boolean
+  plugins: boolean
+}): WorkspaceEnvironment {
+  const { agent, globals, desktop } = input
+  const eligible = !agent.archived && !agent.remoteAgentId
+  const vm = !!eligible && globals.vm && input.bridge
+  const cloud = !!eligible && globals.cloud && input.cloud
+  return {
+    version: 1, capturedAt: desktop.capturedAt, execution: { nodeId: agent.nodeId, profile: agent.profile },
+    open: { computer: globals.scriptMachine, server: globals.serverComputer, vm: globals.vm, cloud: globals.cloud },
+    desktop,
+    tools: {
+      desktopView: !!eligible && desktop.hosts.some(host => host.capabilities.view.enabled),
+      desktopFile: !!eligible && desktop.hosts.some(host => host.capabilities.fileRead.enabled),
+      vm, cloud, plugins: input.plugins,
+    },
+    virtual: {
+      vm: { status: !globals.vm ? 'disabled' : vm ? 'on_demand' : 'bridge_unavailable', environmentId: agent.computerEnvironmentId ?? agent.id },
+      cloud: { status: !globals.cloud ? 'disabled' : cloud ? 'on_demand' : 'not_configured', ...(cloud ? { cwd: '/workspace' } : {}) },
+    },
+    fileTransferMaxMiB: globals.fileTransferMaxMiB,
+  }
+}
+
+export function workspaceEnvironmentTools(environment: WorkspaceEnvironment) {
+  const { tools, fileTransferMaxMiB } = environment
+  return [
+    ...(tools.cloud ? GROK_COMPUTER_TOOLS : []),
+    ...(tools.vm ? VM_COMPUTER_TOOLS : []),
+    ...DESKTOP_ENVIRONMENT_TOOLS.filter(tool =>
+      (tools.desktopView && ['desktop_environment_view', 'desktop_environment_action'].includes(tool.id)) ||
+      (tools.desktopFile && DESKTOP_FILE_TOOL_IDS.has(tool.id)))
+      .map(tool => tool.id === 'desktop_file_copy' ? { ...tool, description: tool.description.replace('25 MiB', `${fileTransferMaxMiB} MiB`) } : tool),
+  ]
+}

+ 6 - 13
src/server/workspacePrompt.ts

@@ -5,15 +5,8 @@ import { DESKTOP_ENVIRONMENT_RULES, SERVER_COMPUTER_RULES, DESKTOP_FILE_TRANSFER
 import { grokComputerRules } from './grokCloud.js'
 import { VM_COMPUTER_RULES } from './vmComputer.js'
 
-/** Prompt facts only: collecting these must not start a computer or grant access. */
-export interface WorkspacePromptEnvironment {
-  open: { computer: boolean; server: boolean; vm: boolean; cloud: boolean }
-  tools: { desktopView: boolean; desktopFile: boolean; vm: boolean; cloud: boolean; plugins: boolean }
-  deviceContext: string
-  onlineHosts: string
-  cwd?: string
-  fileTransferMaxMiB: number
-}
+import {deviceSourceText,deviceInventoryText,type WorkspaceEnvironment} from '../shared/botEnvironment.js'
+export type WorkspacePromptEnvironment = WorkspaceEnvironment
 
 export interface WorkspacePromptInput {
   agent: WorkspaceAgent
@@ -47,15 +40,15 @@ export function workspaceEnvironmentPrompt(env: WorkspacePromptEnvironment): str
     env.cwd ? `Hermes 本轮工作目录(已确认):${JSON.stringify(env.cwd)}。该路径仅属于 Hermes 运行环境。` : undefined,
     `全局开放的额外电脑环境:${open.join('、') || '无'}。开放不等于在线或已授权;具体操作仍需目标设备就绪。全局电脑开关不代表 Hermes 原生工具权限,环境可按任务同时使用。`,
     '先确定操作目标,再选择工具。用户明确点名的设备优先;「本机」只指本轮消息来源电脑,历史消息中的来源不适用于本轮。来源未知、名称重名或目标不明确时先澄清;目标离线、未开放或未授权时说明原因,不得切换其他机器代做。',
-    env.deviceContext,
-    env.onlineHosts,
+    deviceSourceText(env.desktop),
+    deviceInventoryText(env.desktop),
     desktop && env.open.computer ? DESKTOP_ENVIRONMENT_RULES : undefined,
     desktop && env.open.server ? SERVER_COMPUTER_RULES : undefined,
     env.tools.desktopFile ? '电脑文件与命令工具已挂载,按目标设备分别检查授权。列目录、读写文件用 desktop_file_*,运行命令用 desktop_shell;路径相对于目标电脑用户主目录。文件与命令操作无需先截图。' : undefined,
     env.tools.desktopView ? '电脑桌面工具已挂载,按目标设备分别检查授权。需要看窗口或点击输入时,先用 desktop_environment_view,再用 desktop_environment_action;页面变化后重新截图,不猜测旧坐标。人工接管时等待交还。不能用截图目测代替文件列表或目录统计。' : undefined,
     env.tools.desktopFile ? DESKTOP_FILE_TRANSFER_RULES.replace('25 MiB', `${env.fileTransferMaxMiB} MiB`) : undefined,
-    env.tools.vm ? VM_COMPUTER_RULES : undefined,
-    env.tools.cloud ? grokComputerRules() : undefined,
+    env.tools.vm ? VM_COMPUTER_RULES+' 虚拟机连接、工作目录与运行状态尚未探测,首次调用时确认。' : `虚拟环境不可用:${env.virtual.vm.status==='disabled'?'全局未开放':'工具桥不支持'}。`,
+    env.tools.cloud ? grokComputerRules()+' 云电脑连接与运行状态尚未探测,首次调用时确认。' : `云电脑不可用:${env.virtual.cloud.status==='disabled'?'全局未开放':'未连接云端账号'}。`,
     '不同设备的同名路径不是同一文件。工具挂载不保证每台设备都支持该操作;只使用本轮实际提供的工具,执行结果未确认时不要宣称成功。',
   ])
 }

+ 17 - 19
src/server/workspaceRuntime.ts

@@ -1,6 +1,6 @@
-import {DESKTOP_ENVIRONMENT_TOOLS,DESKTOP_FILE_TOOL_IDS,type DesktopEnvironments} from './desktopEnvironments.js'
-import {GROK_COMPUTER_TOOLS,type GrokCloud} from './grokCloud.js'
-import {VM_COMPUTER_TOOLS,VmToolSession} from './vmComputer.js'
+import {type DesktopEnvironments} from './desktopEnvironments.js'
+import {type GrokCloud} from './grokCloud.js'
+import {VmToolSession} from './vmComputer.js'
 import { randomUUID } from 'node:crypto'
 import { readFileSync } from 'node:fs'
 import { z } from 'zod'
@@ -19,6 +19,7 @@ import type {
   WorkspaceAgentUsageSummary,
 } from '../shared/workspace.js'
 import { buildWorkspacePrompt } from './workspacePrompt.js'
+import { buildWorkspaceEnvironment, workspaceEnvironmentTools } from './workspaceEnvironment.js'
 import { readHostTools } from './hostToolSettings.js'
 import { resolveBotModelSettings, applyBotModelSettings, botModelTarget, BotModelConfirmationError } from './botModelSettings.js'
 function globalComputers(home: string) {
@@ -313,7 +314,6 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     }})
     gateway.onTrace=entry=>this.inspector?.record(owner,c.id,{...entry,agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId})
     const botCapabilities = await this.botCapabilities(owner, agent, target)
-    const dispatchedVm = globalComputers(this.store.home).vm && botCapabilities.tools && !agent.remoteAgentId
     const root = this.store.require<Run>(owner, 'run', run.runId)
     if (root.peerMessageId && !recovering) {
       const peer = this.store.require<import('../shared/workspaceKnowledge.js').WorkspacePeerMessage>(owner, 'peer-message', root.peerMessageId)
@@ -698,11 +698,14 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         if (opened.running) throw new HttpError(409, '上游会话仍在运行', 'session_busy')
         this.nodes.requireSource(owner, agent)
         const team=botCapabilities.tools&&!agent.temporaryGoalId&&!agent.remoteAgentId&&!this.store.require<Run>(owner,'run',run.runId).assignmentId
-        const cloud=!!this.cloud?.selected(owner,agent)
-        const plugins=!!this.plugins?.selected(owner,agent)
-        const envTools=this.desktopEnvironments?.envTools(owner,agent)??{view:false,browser:false,file:false}
-        const desktop=envTools.view||envTools.file
-        const desktopEpochs=this.desktopEnvironments?.hostEpochs(owner,agent)??{}
+        const globals=globalComputers(this.store.home)
+        const desktopSnapshot=this.desktopEnvironments?.snapshot(owner,agent,root.deviceHost,{script:globals.scriptMachine,server:globals.serverComputer,maxMiB:globals.fileTransferMaxMiB})
+          ??{capturedAt:Date.now(),sourceHost:root.deviceHost,hosts:[]}
+        const environment=buildWorkspaceEnvironment({agent,globals,desktop:desktopSnapshot,bridge:botCapabilities.tools,cloud:!!this.cloud?.selected(owner,agent),plugins:!!this.plugins?.selected(owner,agent)})
+        const {cloud,plugins,vm:dispatchedVm}=environment.tools
+        const desktop=environment.tools.desktopView||environment.tools.desktopFile
+        const desktopEpochs=Object.fromEntries(desktopSnapshot.hosts.map(host=>[host.id,host.epoch??'']))
+        const environmentCatalog=workspaceEnvironmentTools(environment)
         const knowledge = botCapabilities.tools && !agent.remoteAgentId
         const vmHolder:{session?:VmToolSession}={}
         const requireVm=()=>{if(!globalComputers(this.store.home).vm)throw new HttpError(403,'全局设置未开放虚拟机。','computer_disabled')}
@@ -740,13 +743,14 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             target,profile:agent.profile,workId:run.id,signal:toolController.signal,
             workspaceMemory:botCapabilities.memory,
             session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
-            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...(cloud?GROK_COMPUTER_TOOLS:[]),...(dispatchedVm?VM_COMPUTER_TOOLS:[]),...(desktop?DESKTOP_ENVIRONMENT_TOOLS.map(t=>t.id==='desktop_file_copy'?{...t,description:t.description.replace('25 MiB',`${globalComputers(this.store.home).fileTransferMaxMiB} MiB`)}:t).filter(t=>(envTools.view&&['desktop_environment_view','desktop_environment_action'].includes(t.id))||(envTools.file&&DESKTOP_FILE_TOOL_IDS.has(t.id))):[]),...(pluginLease?.catalog()??[])],
+            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...environmentCatalog,...(pluginLease?.catalog()??[])],
             call:async(toolId,args)=>{assertActive();return knowledge&&this.knowledgeTools.handles(toolId)?this.knowledgeTools.call(owner,run.id,toolId,args,botCapabilities.memory):toolId.startsWith('plugin_')&&pluginLease?pluginLease.call(toolId,args):toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,'local',desktopEpochs,root.deviceHost,dispatchedVm?action=>callVm('__file_transfer',action):undefined):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):toolId.startsWith('computer_')&&dispatchedVm?callVm(toolId,args):this.teamTools.call(owner,run.id,toolId,args)},
             onFailure:error=>{if(!settled)void gateway.rpc('session.interrupt',{session_id:runtimeId}).catch(()=>{}).finally(()=>finish(error))},
           })
           await toolLease.bind();if(settled)return await completion
         }
         const workingDirectory = await applyWorkingDirectory((method, params) => gateway.rpc(method, params), runtimeId, cwd, undefined, opened.info?.cwd)
+        environment.cwd=workingDirectory?.resolved
         // Binding initializes cold Hermes sessions; its persisted runtime can overwrite
         // pre-build config acknowledgements. Apply Bot settings only after that boundary.
         if (agent.modelSettings !== undefined) {
@@ -792,7 +796,6 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         }
         const members = run.turnConfiguration!.members
         const goal = run.conversationTaskId ? this.store.get<import('../shared/agentTasks.js').AgentGoal>(owner, 'goal', run.conversationTaskId) : undefined
-        const globals = globalComputers(this.store.home)
         const text = c.kind === 'group' ? this.contextText(owner, c, agent, run, binding.contextSeq ?? 0, movedRunner)
           : movedRunner ? `执行节点已切换。以下是原会话的近期记录,旧路径和执行状态需要在当前节点重新核实;不要重新执行已完成的操作。\n\n${this.contextText(owner,c,agent,run,0,true)}` : trigger.content
         const admission = this.getWork(owner, run.id)
@@ -811,20 +814,15 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           submittedText = buildWorkspacePrompt({
             agent, conversation: c, members, work: run,
             run: this.store.require<Run>(owner, 'run', run.runId), goal,
-            environment: {
-              open: { computer: globals.scriptMachine, server: globals.serverComputer, vm: globals.vm, cloud: globals.cloud },
-              tools: { desktopView: envTools.view, desktopFile: envTools.file, vm: dispatchedVm, cloud, plugins },
-              deviceContext: this.desktopEnvironments?.deviceContextLine(owner, root.deviceHost) ?? '本轮未提供来源电脑;操作电脑时必须明确目标。',
-              onlineHosts: this.desktopEnvironments?.onlineHostsLine(owner, root.deviceHost) ?? '',
-              cwd: workingDirectory?.resolved,
-              fileTransferMaxMiB: globals.fileTransferMaxMiB,
-            },
+            environment,
             teamRules: team ? TEAM_TOOL_RULES : undefined,
             knowledgeRules: knowledge ? BOT_KNOWLEDGE_RULES : undefined,
             memory: memory.text, noReply: NO_REPLY,
             marker: `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
             content: text, contentKind: c.kind === 'group' || movedRunner ? 'history' : 'user', attachmentRefs,
           })
+          this.inspector?.record(owner,c.id,{method:'bot.environment',direction:'request',agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId,
+            data:{snapshot:environment,computerToolIds:environmentCatalog.map(tool=>tool.id)}})
           await gateway.rpc('prompt.submit', {
             session_id: runtimeId,
             ...(target.runner?.computer?{workMarker:`[yaoyao-run:${run.runId}:${resultMessage.id}]`}:{}),

+ 78 - 0
src/shared/botEnvironment.ts

@@ -0,0 +1,78 @@
+/** Non-secret execution facts reported by a desktop after capability negotiation. */
+export interface DesktopEnvironmentMetadata {
+  version: 1
+  osRelease: string
+  arch: string
+  shell: string
+  homeDirectory: string
+  defaultCwd: string
+  fileRoots: string[]
+  shellScope: 'user'
+  timezone: string
+}
+export type DeviceCapabilityStatus = 'ready' | 'offline' | 'disabled' | 'unsupported' | 'not_authorized' | 'system_permission_required' | 'human_control' | 'paused' | 'agent_unavailable'
+export interface DeviceCapability {
+  /** May be mounted while waiting for human control; execution always rechecks live state. */
+  enabled: boolean
+  status: DeviceCapabilityStatus
+}
+export interface BotDeviceSnapshot {
+  id: string
+  target: string
+  name: string
+  kind: 'server' | 'computer'
+  source: boolean
+  online: boolean
+  open: boolean
+  epoch?: string
+  lastSeen?: number
+  platform?: string
+  metadata?: Partial<DesktopEnvironmentMetadata>
+  capabilities: Record<'view' | 'input' | 'fileRead' | 'fileWrite' | 'shell' | 'fileTransfer', DeviceCapability>
+  transfer: { protocol: 'chunked' | 'legacy' | 'unknown'; readMaxMiB: number | null; writeMaxMiB: number | null }
+}
+export interface DesktopEnvironmentSnapshot {
+  capturedAt: number
+  sourceHost?: string
+  hosts: BotDeviceSnapshot[]
+}
+export interface WorkspaceEnvironment {
+  version: 1
+  capturedAt: number
+  execution: { nodeId: string; profile: string }
+  cwd?: string
+  open: { computer: boolean; server: boolean; vm: boolean; cloud: boolean }
+  tools: { desktopView: boolean; desktopFile: boolean; vm: boolean; cloud: boolean; plugins: boolean }
+  desktop: DesktopEnvironmentSnapshot
+  virtual: {
+    vm: { status: 'on_demand' | 'disabled' | 'bridge_unavailable'; environmentId: string }
+    cloud: { status: 'on_demand' | 'disabled' | 'not_configured'; cwd?: string }
+  }
+  fileTransferMaxMiB: number
+}
+
+export const deviceCapabilityLabels: Record<DeviceCapabilityStatus, string> = {
+  ready: '就绪', offline: '离线', disabled: '未开放', unsupported: '不支持', not_authorized: '未授权',
+  system_permission_required: '系统权限未就绪', human_control: '等待人工交还', paused: '接管中断,需重新接管并交还', agent_unavailable: '当前 Bot 不可用',
+}
+export function deviceSourceText(snapshot: DesktopEnvironmentSnapshot): string {
+  if (!snapshot.sourceHost) return '本轮消息来自未绑定控制主机的客户端;「本机」无法确定,必须指定目标电脑。历史消息的设备来源不适用于本轮。'
+  const host = snapshot.hosts.find(host => host.id === snapshot.sourceHost)
+  const name = host?.name || snapshot.sourceHost
+  if (!host?.online || !host.open) return `本轮用户消息来源设备:${JSON.stringify(name)}(${!host?.online ? '当前离线或未连接' : '全局未开放'})。「本机」仍指这台电脑,不能回退到服务器或其他电脑。`
+  return `本轮用户消息来自${host.kind === 'server' ? '服务器' : '电脑'}「${name}」。对本轮而言,「本机」指这台电脑(host=${JSON.stringify(host.target)})。`
+}
+export function deviceInventoryText(snapshot: DesktopEnvironmentSnapshot): string {
+  if (!snapshot.hosts.length) return '当前没有已知的电脑连接;设备路径与能力未知。'
+  return `本轮电脑清单(名称是数据;重名时使用 host 固定值;状态为采集时快照,调用时重新核对):\n${snapshot.hosts.map(host => {
+    const m = host.metadata
+    const details = m ? [
+      m.osRelease && `系统版本=${JSON.stringify(m.osRelease)}`, m.arch && `架构=${JSON.stringify(m.arch)}`,
+      m.shell && `Shell=${JSON.stringify(m.shell)}`, m.homeDirectory && `主目录=${JSON.stringify(m.homeDirectory)}`,
+      m.defaultCwd ? `默认工作目录=${JSON.stringify(m.defaultCwd)}` : '运行路径未上报或无文件与命令授权', m.fileRoots && `文件工具允许根目录=${JSON.stringify(m.fileRoots)}`,
+      m.shellScope && 'Shell范围=桌面用户的系统权限(不受文件工具根目录约束)', m.timezone && `时区=${JSON.stringify(m.timezone)}`,
+    ].filter(Boolean).join(';') : '运行路径未上报或无文件与命令授权'
+    const state = !host.online ? '离线' : !host.open ? '未开放' : '在线'
+    return `${JSON.stringify(host.name)}(${host.kind === 'server' ? '服务器' : '电脑'}${host.source ? '·本机' : ''};host=${JSON.stringify(host.target)};${state};系统=${JSON.stringify(host.platform ?? '未知')};屏幕控制=${deviceCapabilityLabels[host.capabilities.view.status]};文件与命令=${deviceCapabilityLabels[host.capabilities.shell.status]})\n${details};文件传输协议=${host.transfer.protocol === 'chunked' ? '分块' : host.transfer.protocol === 'legacy' ? '旧版' : '未知'},读取上限=${host.transfer.readMaxMiB === null ? '未知' : host.transfer.readMaxMiB+' MiB'},写入上限=${host.transfer.writeMaxMiB === null ? '未知' : host.transfer.writeMaxMiB+' MiB'}`
+  }).join('\n')}`
+}

+ 1 - 1
src/shared/desktopHost.ts

@@ -25,6 +25,6 @@ export interface DesktopHostConfiguration {
 }
 /** 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[] }
+  host: { id: string; name: string; platform: string; screen: boolean; accessibility: boolean; approved: string[]; full?: string[]; fileTransferVersion?: 1; environment?: import('./botEnvironment.js').DesktopEnvironmentMetadata }
   results: { id: string; value?: unknown; error?: string }[]
 }

+ 60 - 1
tests/server/desktopEnvironments.test.ts

@@ -372,6 +372,65 @@ it('lists stable tool targets and separate per-host capabilities without claimin
  const ungranted = service.onlineHostsLine('other', HOST_A)
  expect(ungranted).not.toContain('=就绪')
  saveHostTools(home, { scriptMachine: false })
- expect(service.onlineHostsLine('owner', HOST_A)).not.toContain(HOST_A)
+ expect(service.snapshot('owner', agent, HOST_A).hosts.find(host => host.id === HOST_A)?.capabilities.shell.status).toBe('disabled')
  expect(service.onlineHostsLine('owner', HOST_A)).toContain('host="server"')
 })
+
+const environmentMetadata={version:1 as const,osRelease:'25.0.0',arch:'arm64',shell:'/bin/zsh',homeDirectory:'/Users/fixture',defaultCwd:'/Users/fixture',fileRoots:['/Users/fixture'],shellScope:'user' as const,timezone:'Asia/Shanghai'}
+it('snapshots negotiated host facts per owner without inventing paths for old clients',()=>{
+ const result=service.exchange({host:{...host,full:[ownerKey('owner')],fileTransferVersion:1,environment:environmentMetadata},results:[]})
+ expect(result.capabilities).toEqual({environmentMetadata:1})
+ service.remoteExchange(HOST_A,{host:{...host,id:randomUUID(),name:'旧客户端'},results:[]})
+ const snapshot=service.snapshot('owner',agent,'local'),server=snapshot.hosts.find(host=>host.id==='local')!
+ expect(server.metadata).toEqual(environmentMetadata)
+ expect(server.capabilities.shell).toEqual({enabled:true,status:'ready'})
+ expect(server.transfer).toEqual({protocol:'chunked',readMaxMiB:25,writeMaxMiB:25})
+ expect(snapshot.hosts.find(host=>host.id===HOST_A)?.metadata).toBeUndefined()
+ expect(JSON.stringify(service.snapshot('other',agent,'local'))).not.toContain('/Users/fixture')
+ expect(JSON.stringify(snapshot)).not.toContain(ownerKey('owner'))
+ server.metadata!.fileRoots!.push('/changed')
+ expect(service.snapshot('owner',agent).hosts.find(host=>host.id==='local')?.metadata?.fileRoots).toEqual(['/Users/fixture'])
+})
+
+it('keeps offline and disabled registered computers visible with actionable reasons',()=>{
+ store.put('_system','desktop-host',HOST_B,{id:HOST_B,name:'尚未连接',enabled:false,tokenHash:'private',createdAt:1})
+ service.remoteExchange(HOST_A,{host:{...host,id:randomUUID(),name:'连接电脑',full:[ownerKey('owner')],environment:environmentMetadata},results:[]})
+ const saved=service.snapshot('owner',agent,HOST_A)
+ vi.useFakeTimers();vi.advanceTimersByTime(16000)
+ const offline=service.snapshot('owner',agent,HOST_A)
+ expect(offline.hosts.find(host=>host.id===HOST_A)).toMatchObject({source:true,online:false,capabilities:{shell:{enabled:false,status:'offline'}}})
+ expect(offline.hosts.find(host=>host.id===HOST_A)?.metadata).toBeUndefined()
+ expect(offline.hosts.find(host=>host.id===HOST_B)).toMatchObject({name:'尚未连接',open:false,capabilities:{view:{enabled:false,status:'disabled'}}})
+ expect(saved.hosts.find(host=>host.id===HOST_A)?.capabilities.shell.status).toBe('ready')
+ expect(service.deviceContextLine('owner',HOST_A)).toContain('不能回退到服务器')
+ expect(JSON.stringify(offline)).not.toContain('private')
+})
+
+it('distinguishes human takeover from grants and rechecks revocation after collecting a snapshot',async()=>{
+ host.full=[ownerKey('owner')];drive()
+ const ticket=await take(),snapshot=service.snapshot('owner',agent,'local'),server=snapshot.hosts[0]!
+ expect(server.capabilities.shell).toEqual({enabled:true,status:'human_control'})
+ await request(app()).post(base()+'/computer/giveback').send({controlId:ticket.controlId,token:ticket.token}).expect(200)
+ expect(service.snapshot('owner',agent).hosts[0]?.capabilities.shell.status).toBe('ready')
+ host.full=[];service.exchange({host,results:[]})
+ const epochs=Object.fromEntries(snapshot.hosts.map(host=>[host.id,host.epoch!]))
+ await expect(service.call('owner',agent.id,'desktop_file_list',{host:'server',path:'.'},new AbortController().signal,()=>{},'local',epochs,'local')).rejects.toMatchObject({code:'desktop_full_required'})
+ expect(snapshot.hosts[0]?.capabilities.shell.enabled).toBe(true)
+ expect(service.snapshot('owner',agent).hosts[0]?.capabilities.shell).toEqual({enabled:false,status:'not_authorized'})
+})
+
+it('rejects malformed metadata without replacing the last admitted host state',()=>{
+ service.exchange({host,results:[]})
+ expect(()=>service.exchange({host:{...host,environment:{...environmentMetadata,homeDirectory:'bad\npath'}},results:[]})).toThrow()
+ expect(service.snapshot('owner',agent).hosts[0]?.epoch).toBe(host.id)
+ expect(service.snapshot('owner',agent).hosts[0]?.metadata).toBeUndefined()
+})
+
+it('does not dispatch to a newly connected target outside the captured host set',async()=>{
+ drive()
+ const snapshot=service.snapshot('owner',agent,'local'),epochs=Object.fromEntries(snapshot.hosts.map(host=>[host.id,host.epoch!]))
+ const commands:unknown[]=[]
+ driveRemote(HOST_A,{...host,id:randomUUID()},command=>{commands.push(command);return {ok:true}})
+ await expect(service.call('owner',agent.id,'desktop_environment_view',{host:HOST_A},new AbortController().signal,()=>{},'local',epochs,'local')).rejects.toMatchObject({code:'desktop_context_changed'})
+ expect(commands).toEqual([])
+})

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

@@ -100,3 +100,14 @@ it('re-enables the exact disabled legacy computer after an explicit administrato
     .send(body())
     .expect(403)
 })
+
+it('admits optional negotiated environment facts through the same remote transport schema',async()=>{
+ await enroll()
+ const input=body()
+ input.host.fileTransferVersion=1
+ input.host.environment={version:1,osRelease:'25.0.0',arch:'arm64',shell:'/bin/zsh',homeDirectory:'/Users/fixture',defaultCwd:'/Users/fixture',fileRoots:['/Users/fixture'],shellScope:'user',timezone:'Asia/Shanghai'}
+ await post(`/api/desktop-host/v1/${hostId}/exchange`).send(input).expect(200)
+ expect(exchanged[0]?.host.environment).toEqual(input.host.environment)
+ await post(`/api/desktop-host/v1/${hostId}/exchange`).send({...input,host:{...input.host,environment:{...input.host.environment,token:'must-not-be-metadata'}}}).expect(400)
+ expect(exchanged).toHaveLength(1)
+})

+ 19 - 2
tests/server/workspace.test.ts

@@ -1898,20 +1898,35 @@ it('submits capability-based Bot context with current origins, confirmed cwd and
   vi.spyOn(target.session, 'request').mockImplementation(async (path, options) => path.startsWith('/api/plugins/yaoyao-bot-bridge/')
     ? { status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({ ok: true, version: 1, ready: true, native_tools: true, in_process: true })) }
     : originalRequest(path, options))
+  const record=vi.fn();runtime.inspector={record} as any
+  let binding:any,integrationError:unknown
+  const catalogs:string[][]=[]
+  const bridgeRequest=target.session.request.bind(target.session)
+  target.session.request=async(path,options)=>{if(path.endsWith('/bind'))binding=options?.body;return bridgeRequest(path,options)}
+  const originalReply=reply
+  reply=(socket,params)=>{void(async()=>{try{const response=await fetch(binding.bridge_url+'/tools/list',{method:'POST',headers:{Authorization:`Bearer ${binding.token}`,'Content-Type':'application/json'},body:'{}'});const result=await response.json() as any;catalogs.push(result.tools.filter((tool:any)=>/^(desktop_|computer_|cloud_computer_)/.test(tool.name)).map((tool:any)=>tool.name).sort())}catch(error){integrationError=error}originalReply(socket,params)})()}
   configuredCwds.set('default', '/fixture/server-workspace')
   saveHostTools(home, { vm: false, cloud: false })
   const desktop = new DesktopEnvironments(store, { pushAuthorizationVersion: () => 0 } as any, { localNodeID: 'fixture', requireSource: () => {} } as any)
   runtime.desktopEnvironments = desktop
   const clientId = randomUUID(), grant = createHash('sha256').update('fixture:' + owner).digest('hex')
   desktop.exchange({ host: { id: randomUUID(), name: '服务器', platform: 'darwin', screen: true, accessibility: true, approved: [grant] }, results: [] })
-  desktop.remoteExchange(clientId, { host: { id: randomUUID(), name: '工作 Mac', platform: 'darwin', screen: false, accessibility: false, approved: [], full: [grant] }, results: [] })
+  desktop.remoteExchange(clientId, { host: { id: randomUUID(), name: '工作 Mac', platform: 'darwin', screen: false, accessibility: false, approved: [], full: [grant], environment: {version:1,osRelease:'25.0.0',arch:'arm64',shell:'/bin/zsh',homeDirectory:'/Users/work',defaultCwd:'/Users/work',fileRoots:['/Users/work'],shellScope:'user',timezone:'Asia/Shanghai'} }, results: [] })
   try {
     await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '读取本机目录,再解释 Grok 模型与虚拟机的区别', deviceHost: clientId }).id)
     await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '继续', deviceHost: clientId }).id)
     await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '读取本机目录', deviceHost: 'local' }).id)
     await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '读取本机目录', deviceHost: null }).id)
     const prompts = requests.filter(r => r.method === 'prompt.submit').map(r => String(r.params.text))
+    if(integrationError)throw integrationError
     expect(prompts).toHaveLength(4)
+    const inspections=record.mock.calls.map(([, , entry])=>entry).filter(entry=>entry.method==='bot.environment')
+    expect(inspections).toHaveLength(4)
+    expect(inspections.map(entry=>entry.data.computerToolIds.slice().sort())).toEqual(catalogs)
+    expect(inspections.map(entry=>entry.data.snapshot.desktop.sourceHost)).toEqual([clientId,clientId,'local',undefined])
+    expect(inspections.every(entry=>entry.data.snapshot.cwd==='/fixture/server-workspace')).toBe(true)
+    expect(inspections[0].data.snapshot.desktop.hosts.find((host:any)=>host.id===clientId).metadata.fileRoots).toEqual(['/Users/work'])
+    expect(JSON.stringify(inspections)).not.toContain(grant)
     const environments = prompts.map(prompt => prompt.split('【本轮环境与设备】')[1]!.split('【Bot 记忆与相关事实】')[0])
     expect(environments[0]).toBe(environments[1])
     expect(environments[0]).toContain('本轮用户消息来自电脑「工作 Mac」')
@@ -1922,7 +1937,9 @@ it('submits capability-based Bot context with current origins, confirmed cwd and
       expect(prompt).not.toContain('当前环境是')
       expect(prompt).not.toContain('cloud_computer_*')
       expect(prompt).not.toContain('computer_shell')
-      expect(prompt.match(/当前在线且全局开放的电脑/g)).toHaveLength(1)
+      expect(prompt.match(/本轮电脑清单/g)).toHaveLength(1)
+      expect(prompt).toContain('Shell="/bin/zsh"')
+      expect(prompt).toContain('文件工具允许根目录=["/Users/work"]')
       expect(prompt).toContain(`host="${clientId}"`)
       expect(prompt).toContain('屏幕控制=未授权;文件与命令=就绪')
       expect(prompt).toContain('屏幕控制=就绪;文件与命令=未授权')

+ 38 - 0
tests/server/workspaceEnvironment.test.ts

@@ -0,0 +1,38 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {buildWorkspaceEnvironment,workspaceEnvironmentTools} from '../../src/server/workspaceEnvironment'
+import {parseHostTools} from '../../src/server/hostToolSettings'
+import {deviceInventoryText,type DesktopEnvironmentSnapshot} from '../../src/shared/botEnvironment'
+import type {WorkspaceAgent} from '../../src/shared/workspace'
+const agent:WorkspaceAgent={id:'bot',name:'Bot',avatar:'',instructions:'',nodeId:'local',profile:'default',archived:false,revision:1,createdAt:1,updatedAt:1}
+const desktop:DesktopEnvironmentSnapshot={capturedAt:10,sourceHost:'local',hosts:[{id:'local',target:'server',name:'服务器',kind:'server',source:true,online:true,open:true,
+ capabilities:{view:{enabled:true,status:'human_control'},input:{enabled:true,status:'human_control'},fileRead:{enabled:false,status:'not_authorized'},fileWrite:{enabled:false,status:'not_authorized'},shell:{enabled:false,status:'not_authorized'},fileTransfer:{enabled:false,status:'not_authorized'}},transfer:{protocol:'legacy',readMaxMiB:12,writeMaxMiB:10}}]}
+it('mounts only granted desktop capabilities while distinguishing human control and deferred virtual environments',()=>{
+ const env=buildWorkspaceEnvironment({agent,globals:parseHostTools({}),desktop,bridge:true,cloud:false,plugins:false})
+ const names=workspaceEnvironmentTools(env).map(tool=>tool.id)
+ expect(names).toContain('desktop_environment_view')
+ expect(names).not.toContain('desktop_shell')
+ expect(names).toContain('computer_shell')
+ expect(names).not.toContain('cloud_computer_shell')
+ expect(env.virtual.vm.status).toBe('on_demand')
+ expect(env.virtual.cloud.status).toBe('not_configured')
+ expect(deviceInventoryText(env.desktop)).toContain('等待人工交还')
+ expect(env.cwd).toBeUndefined()
+})
+it('captures policy and file limits rather than observing later settings mutations',()=>{
+ const globals=parseHostTools({fileTransferMaxMiB:60,cloud:false,vm:false})
+ const facts=structuredClone(desktop)
+ for(const cap of Object.values(facts.hosts[0]!.capabilities))Object.assign(cap,{enabled:true,status:'ready'})
+ const env=buildWorkspaceEnvironment({agent,globals,desktop:facts,bridge:true,cloud:true,plugins:false})
+ globals.fileTransferMaxMiB=1;globals.vm=true
+ const names=workspaceEnvironmentTools(env)
+ expect(names.find(tool=>tool.id==='desktop_file_copy')?.description).toContain('60 MiB')
+ expect(names.some(tool=>tool.id==='computer_shell')).toBe(false)
+ expect(env.virtual.vm.status).toBe('disabled')
+ expect(env.virtual.cloud.status).toBe('disabled')
+})
+it('does not expose computer tools for an unavailable Bot or missing bridge',()=>{
+ const env=buildWorkspaceEnvironment({agent:{...agent,archived:true},globals:parseHostTools({}),desktop,bridge:false,cloud:true,plugins:false})
+ expect(workspaceEnvironmentTools(env)).toEqual([])
+ expect(env.virtual.vm.status).toBe('bridge_unavailable')
+})

+ 6 - 2
tests/server/workspacePrompt.test.ts

@@ -6,8 +6,12 @@ function environment(): WorkspacePromptEnvironment {
   return {
     open: { computer: true, server: true, vm: true, cloud: true },
     tools: { desktopView: true, desktopFile: true, vm: true, cloud: true, plugins: false },
-    deviceContext: '本轮用户消息来自电脑「工作 Mac」。「本机」指这台电脑。',
-    onlineHosts: '工作 Mac(host=mac-id)', cwd: '/srv/hermes/project', fileTransferMaxMiB: 50,
+    version: 1, capturedAt: 1, execution: { nodeId: 'local', profile: 'dev' },
+    desktop: { capturedAt: 1, sourceHost: 'mac-id', hosts: [{ id: 'mac-id', target: 'mac-id', name: '工作 Mac', kind: 'computer', source: true, online: true, open: true,
+      capabilities: { view: { enabled: true, status: 'ready' }, input: { enabled: true, status: 'ready' }, fileRead: { enabled: true, status: 'ready' }, fileWrite: { enabled: true, status: 'ready' }, shell: { enabled: true, status: 'ready' }, fileTransfer: { enabled: true, status: 'ready' } },
+      transfer: { protocol: 'chunked', readMaxMiB: 50, writeMaxMiB: 50 } }] },
+    virtual: { vm: { status: 'on_demand', environmentId: 'bot' }, cloud: { status: 'on_demand', cwd: '/workspace' } },
+    cwd: '/srv/hermes/project', fileTransferMaxMiB: 50,
   }
 }
 function input(): WorkspacePromptInput {