Просмотр исходного кода

修复拆分部署后 Bot 的服务端环境识别

Samien 1 месяц назад
Родитель
Сommit
d88cf86794

+ 5 - 1
docs/desktop-hosts.md

@@ -6,6 +6,10 @@
 
 所有 Bot 的聊天、模型、配置、记忆提炼都运行于服务端 Hermes。客户端 App 只负责界面和已授权的电脑操作,无需安装 Hermes。虚拟机 Runner 只在调用 `computer_*` 时建立工具会话;虚拟机离线不会转移聊天,也不会让普通聊天启动虚拟机。
 
+Bot 的服务端状态独立于客户端:本轮 Hermes 会话连接成功即报告服务端已连接。工具桥 1.3.1 按 Profile 的完整原生目录上报终端、文件、桌面与浏览器能力,操作服务端运行环境时优先使用这些实际可用工具。目录存在不保证工具后端已连接,Hermes 进程的系统信息也不代表所有工具的执行目标;SSH、容器或远程桌面仍需按工具配置与结果确认。旧工具桥未上报快照时只将元数据标为未知,不判定服务端离线。
+
+服务器也可打开纯客户端,它提供的是另一条客户端电脑控制通道;关闭客户端不会影响 Hermes 已有能力。没有可信的目标关联时,不按名称或 IP 自动合并两种连接,也不将容器内路径当作宿主机路径。电脑清单中的「服务器桌面代理」离线只说明 `desktop_*` 通道不可用,不能据此判定 Hermes 原生能力离线。
+
 电脑开放范围与审批策略在 Bot 设置统一管理,默认开放服务器、电脑、Grok Bot 云电脑和虚拟机。旧 Bot 的 `computer`、`execution`、`envs`、`approvalPolicy` 不再决定实际设备权限或模型运行位置。旧虚拟机共享关系保留,任务助手仍受所属任务的生命周期约束。
 
 - **消息来源**:客户端每次发送时重新读取 `deviceHost`,服务端保存到消息和运行。省略 `host` 或使用「本机」/`local` 时,只解析为本轮来源电脑;切换客户端后新消息覆盖来源,已经运行的任务保留自己的来源快照。
@@ -13,7 +17,7 @@
 - **协作与定时任务**:Bot 转交、子任务和结果回传继承触发消息的来源。定时任务保存自己的「本机」目标,重启或换客户端查看不会改变它;旧任务未保存目标时须在内容中明确指定。
 - **并行环境**:同一 Bot 可在一轮中操作多个环境。连接、系统授权和人工接管状态按设备校验,不按 Bot 区分权限。文件与命令授权不依赖屏幕录制或辅助功能。
 - **审批**:所有 Bot 共用「询问我 / 自动允许 / 自动拒绝」,变更用于后续审批请求;已等待的请求单独答复。聊天中的允许只作用于本次,旧客户端的 `auto` 答复也只允许一次。
-- **Hermes 原生工具**:原生终端和文件工具始终操作服务器,与消息来源无关;访问来源客户端应使用 `desktop_*`。全局服务器电脑开关控制 desktop 适配器,旧 `denyServerTools` 字段停用,不再展示未接入的封禁开关。
+- **Hermes 原生工具**:原生工具按 Hermes 自身的后端配置执行,与消息来源无关;访问来源客户端应使用 `desktop_*`,不自动将两种连接视为同一目标。全局服务器电脑开关控制 desktop 适配器,旧 `denyServerTools` 字段停用,不再展示未接入的封禁开关。
 - **接管界面**:`/api/app/agents/:id/computer` 系列接受 `backend=desktop|cloud|vm`,桌面可附带 `host`。界面显式选择目标。此处的 `host=local` 是历史 API 的服务器编号,不等于 Bot 工具中随消息变化的别名。
 
 Windows 客户端的构建、未签名安装包和验收记录见 [Windows 客户端](windows-client.md)。Windows 首次启动直接连接服务器,仅支持客户端模式;以下双模式选择与本机服务器说明仅适用于 Mac。

+ 4 - 2
integrations/hermes-bots-bridge/README.md

@@ -1,6 +1,6 @@
 # 夭夭 Bot 的 Hermes 工具桥
 
-这是运行在 Hermes 内的用户插件,版本 1.2.5。它连接 Yaoyao 每轮授权的电脑、团队和应用工具。随附安装器还提供有备份的 Hermes Profile 模型兼容修复。
+这是运行在 Hermes 内的用户插件,版本 1.3.1。它连接 Yaoyao 每轮授权的电脑、团队和应用工具。随附安装器还提供有备份的 Hermes Profile 模型兼容修复。
 
 ## Hermes 托管虚拟机会话
 
@@ -44,7 +44,7 @@ Runner 包内可直接运行同目录的 `install-hermes-bridge.py`。安装器
 
 前缀:`/api/plugins/yaoyao-bot-bridge`。
 
-- `GET /capabilities?profile=...`:检查对应 Profile 及托管能力。
+- `GET /capabilities?profile=...`:检查对应 Profile 及托管能力。1.3.1 起可选的 `runtime_environment` 返回版本 1 的 Hermes 进程系统信息及对应 Profile 的原生终端、文件、桌面和浏览器工具名称;按完整目录采集,包含延迟发现的工具,不执行工具或探测其后端。进程系统信息不代表 SSH、容器或桌面工具的实际目标,不返回配置、凭据或客户端路径。
 - `GET /model-options?profile=...`:读取 Profile 的模型目录、默认值、思考能力与速度能力;只返回公开配置,不返回凭据。
 - `POST /model-settings/resolve`:接收 `profile` 与 `settings`,通过 Hermes 原生解析器校验完整组合,返回版本 1 的有效配置和模型确认提示;不写 Profile 配置。
 - 模型目录与配置解析均使用 Hermes 原生配置作用域,同时绑定 Profile 目录和凭据;多 Profile Dashboard 不会因缺少凭据作用域而报错,也不会借用其他 Profile 的环境变量。默认 Profile 保留 Hermes 自身的启动凭据规则。
@@ -99,4 +99,6 @@ CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确
 
 1.3.0 增加 Bot 个人资料的模型设置协议(`model_settings_version: 1`)。模型与 Provider 成对选择,思考等级和 `normal / fast / auto / cold` 速度可分别继承。Web 服务在每轮提交前解析配置,并通过原生 `config.set` 按模型、思考、速度的顺序应用到当前 Bot 会话;保存资料不修改 Profile 默认值,也不打断正在运行的回复。恢复中的原轮次只继续追踪,不重新设置或提交。模型目录未声明具体等级时返回 `reasoningKnown: false`;快速能力同时检查模型和 Provider 路由。
 
+1.3.1 增加上述 Hermes 环境快照。Bot 的服务端连接状态取自本轮 Hermes 会话,客户端电脑清单仅表示 `desktop_*` 控制通道。服务器未打开客户端或客户端退出时,Hermes 原生能力不因此被标记离线;非回环地址也不会隐藏 Hermes 运行环境。旧工具桥没有快照时保持聊天与原生工具兼容,系统与目录信息显示未知。更新工具桥后需要重启 Hermes 才能加载新快照协议。
+
 发布时先更新工具桥与 Web 服务,在空闲时重启 Hermes Dashboard,再发布 Web/桌面端、iOS 和 Android 客户端。旧桥不能编辑新增设置,会明确提示升级;未设置 `modelSettings` 的旧 Bot 继续按原有继承路径运行。原生会话配置回执不兼容、模型无效或应用失败会停止本轮,不使用旧配置继续运行。

+ 21 - 2
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -13,6 +13,7 @@ import json
 import math
 import mimetypes
 import os
+import platform
 import re
 import sys
 import threading
@@ -28,7 +29,7 @@ from pathlib import Path
 from typing import Any
 
 VERSION = 1
-PLUGIN_VERSION = "1.3.0"
+PLUGIN_VERSION = "1.3.1"
 def _plugin_fingerprint():
     root = Path(__file__).parent
     digest = hashlib.sha256()
@@ -351,6 +352,23 @@ def _transport_identity(transport) -> tuple[str, str]:
     return "local-session-token", "dashboard"
 
 
+def _runtime_environment(profile: str, definitions: list) -> dict:
+    # Only directory facts: never execute tools, probe desktops, or expose config
+    # credentials. Process metadata does not identify an SSH/container/UI backend.
+    names = {tool.get("function", {}).get("name") for tool in definitions if isinstance(tool, dict)}
+    names = {name for name in names if isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", name)}
+    return {
+        "version": 1, "profile": profile, "platform": sys.platform,
+        "osRelease": platform.release(), "arch": platform.machine(),
+        "nativeTools": {
+            "terminal": sorted(names & {"terminal", "process", "process_manage", "execute_code", "code_execution"}),
+            "files": sorted(names & {"read_file", "write_file", "patch", "search_files"}),
+            "desktop": sorted(names & {"computer_use", "desktop_project"}),
+            "browser": sorted(name for name in names if name.startswith("browser_")),
+        },
+    }
+
+
 def capabilities(profile: str = "default") -> dict:
     in_process = False
     try:
@@ -386,7 +404,8 @@ def capabilities(profile: str = "default") -> dict:
             return {"version": VERSION, "ready": True, "in_process": True, "profile": profile, "native_tools": True,
                     "computer_runtime_version": 2, "execution_policy_version": 1, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
                     "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
-                    "isolated_context_files": True, "skill_learning": True, "model_settings_version": 1, "file_transfer_version": 1}
+                    "isolated_context_files": True, "skill_learning": True, "model_settings_version": 1, "file_transfer_version": 1,
+                    "runtime_environment": _runtime_environment(profile, definitions)}
         finally:
             reset_hermes_home_override(context)
     except Exception as exc:

+ 1 - 1
integrations/hermes-bots-bridge/plugin.yaml

@@ -1,4 +1,4 @@
 name: yaoyao-bot-bridge
-version: "1.3.0"
+version: "1.3.1"
 description: "夭夭 Bot 每轮授权的电脑、浏览器、手机、团队与应用工具桥"
 author: "Samien"

+ 60 - 0
integrations/hermes-bots-bridge/tests/test_runtime_environment.py

@@ -0,0 +1,60 @@
+import importlib.util
+import sys
+import tempfile
+import unittest
+from contextvars import ContextVar
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+spec = importlib.util.spec_from_file_location('test_yaoyao_environment_bridge', Path(__file__).parents[1] / 'bridge_runtime.py')
+bridge = importlib.util.module_from_spec(spec)
+sys.modules[spec.name] = bridge
+spec.loader.exec_module(bridge)
+
+
+class RuntimeEnvironmentTests(unittest.TestCase):
+    def test_capabilities_reports_the_scoped_profile_catalog_without_operating_a_computer(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            scope = ContextVar('runtime_environment_profile', default=root)
+            configs = {
+                root / 'default': {'agent': {'disabled_toolsets': []}},
+                root / 'limited': {'agent': {'disabled_toolsets': ['terminal', 'computer_use']}},
+            }
+            observations = []
+
+            def definitions(enabled_toolsets, disabled_toolsets, **kwargs):
+                observations.append((scope.get(), kwargs))
+                names = ['yaoyao_tools', 'yaoyao_call', 'terminal', 'read_file', 'computer_use', 'browser_navigate', 'memory', 'yaoyao_desktop_fake']
+                return [{'function': {'name': name}} for name in names if name not in disabled_toolsets]
+
+            modules = {
+                'hermes_cli.profiles': SimpleNamespace(get_profile_dir=lambda profile: root / profile, profile_exists=lambda profile: profile in {'default', 'limited'}),
+                'hermes_constants': SimpleNamespace(set_hermes_home_override=scope.set, reset_hermes_home_override=scope.reset),
+                'hermes_cli.plugins': SimpleNamespace(discover_plugins=lambda: None),
+                'hermes_cli.config': SimpleNamespace(load_config=lambda: configs[scope.get()]),
+                'model_tools': SimpleNamespace(get_tool_definitions=definitions),
+            }
+            with patch.dict(sys.modules, modules), patch.object(bridge, '_web'), \
+                    patch.object(bridge, '_server', return_value=SimpleNamespace(_load_enabled_toolsets=lambda: ['all'])):
+                full = bridge.capabilities('default')
+                limited = bridge.capabilities('limited')
+
+            self.assertTrue(full['ready'])
+            facts = full['runtime_environment']
+            self.assertEqual(facts['profile'], 'default')
+            self.assertEqual(facts['nativeTools'], {'terminal': ['terminal'], 'files': ['read_file'], 'desktop': ['computer_use'], 'browser': ['browser_navigate']})
+            self.assertEqual(limited['runtime_environment']['nativeTools'], {'terminal': [], 'files': ['read_file'], 'desktop': [], 'browser': ['browser_navigate']})
+            self.assertEqual(set(facts), {'version', 'profile', 'platform', 'osRelease', 'arch', 'nativeTools'})
+            self.assertEqual(scope.get(), root)
+            self.assertEqual([entry[0] for entry in observations], [root / 'default', root / 'limited'])
+            self.assertTrue(all(entry[1]['skip_tool_search_assembly'] for entry in observations))
+
+    def test_runtime_directory_does_not_report_bridge_tools_as_native_computer_capabilities(self):
+        definitions = [{'function': {'name': name}} for name in ['yaoyao_tools', 'yaoyao_call', 'yaoyao_desktop_shell_123', 'tool_call', 'tool_describe', 'memory']]
+        self.assertEqual(bridge._runtime_environment('default', definitions)['nativeTools'], {'terminal': [], 'files': [], 'desktop': [], 'browser': []})
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 2 - 2
src/server/desktopEnvironments.ts

@@ -69,11 +69,11 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
  {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,当前 Bot 的虚拟机用 vm,设备名用设置里的名字,「本机」只指本轮消息来源电脑。例:把 mac1 桌面的 a.txt 放到 mac2 桌面,调用 {sourceHost:"mac1",sourcePath:"Desktop/a.txt",targetHost:"mac2",targetPath:"Desktop/a.txt"}。这是复制,保留原文件;只有用户明确要求覆盖时才传 overwrite:true。无需先截图,不要读取 base64 给模型、上传聊天附件或假设不同电脑共享文件路径。最大 25 MiB;工具返回已校验的成功结果后才报告完成。'
+export const DESKTOP_FILE_TRANSFER_RULES='电脑间传文件用 desktop_file_copy,一次指定 sourceHost、sourcePath、targetHost、targetPath;服务器桌面代理用 server,当前 Bot 的虚拟机用 vm,设备名用设置里的名字,「本机」只指本轮消息来源电脑。例:把 mac1 桌面的 a.txt 放到 mac2 桌面,调用 {sourceHost:"mac1",sourcePath:"Desktop/a.txt",targetHost:"mac2",targetPath:"Desktop/a.txt"}。这是复制,保留原文件;只有用户明确要求覆盖时才传 overwrite:true。无需先截图,不要读取 base64 给模型、上传聊天附件或假设不同电脑共享文件路径。最大 25 MiB;工具返回已校验的成功结果后才报告完成。'
 /** Macs connected to the server as computers. */
 export const DESKTOP_ENVIRONMENT_RULES='操作连接服务器的电脑时,使用本轮提供的 desktop_* 工具,并用 host 指定电脑名称或编号。Hermes 原生终端和文件工具不能代替该电脑上的操作。'
 /** Yaoyao host Mac — same look-then-act pattern as the cloud computer. */
-export const SERVER_COMPUTER_RULES='操作服务器桌面时,使用本轮提供的 desktop_* 工具并明确指定 host="server"。处理 Hermes 运行环境内的文件与命令时,使用实际提供的原生工具;处理服务器桌面用户主目录内的文件时,使用已授权的 desktop_file_* 或 desktop_shell,不假定两者共享路径。'
+export const SERVER_COMPUTER_RULES='操作客户端提供的服务器桌面代理时,使用本轮提供的 desktop_* 工具并明确指定 host="server"。处理 Hermes 原生工具实际控制的运行环境时,优先使用对应原生工具;处理服务器桌面代理用户主目录内的文件时,使用已授权的 desktop_file_* 或 desktop_shell,不假定两者共享路径或能力。'
 
 /** The loopback native transport is admitted only by serviceInstance's
  *  private capability; remote hosts are admitted by DesktopHostHub's bearer

+ 16 - 0
src/server/hermesRuntimeEnvironment.ts

@@ -0,0 +1,16 @@
+import { z } from 'zod'
+import type { HermesRuntimeMetadata } from '../shared/botEnvironment.js'
+
+const text = z.string().min(1).max(256).refine(value => !/[\u0000-\u001f]/.test(value))
+const tools = z.array(z.string().regex(/^[A-Za-z0-9_.:-]{1,128}$/)).max(256)
+const metadata = z.object({
+  version: z.literal(1), profile: text, platform: text, osRelease: text, arch: text,
+  nativeTools: z.object({ terminal: tools, files: tools, desktop: tools, browser: tools }),
+})
+
+/** Optional protocol extension: old or incompatible bridges leave facts unknown. */
+export function parseHermesRuntimeMetadata(value: unknown, profile: string): HermesRuntimeMetadata | undefined {
+  const parsed = metadata.safeParse(value)
+  if (!parsed.success || parsed.data.profile !== profile) return undefined
+  return parsed.data
+}

+ 3 - 1
src/server/workspaceEnvironment.ts

@@ -1,5 +1,5 @@
 import {MANAGED_BROWSER_TOOLS,MANAGED_BROWSER_VM_TOOLS} from './managedBrowserTools.js'
-import type { DesktopEnvironmentSnapshot, WorkspaceEnvironment } from '../shared/botEnvironment.js'
+import type { DesktopEnvironmentSnapshot, HermesRuntimeSnapshot, 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'
@@ -14,6 +14,7 @@ export function buildWorkspaceEnvironment(input: {
   bridge: boolean
   cloud: boolean
   plugins: boolean
+  runtime?: HermesRuntimeSnapshot
   execution?:import('../shared/executionEnvironment.js').ExecutionSelection
   serverDesktop?:boolean
   managedBrowser?: boolean
@@ -28,6 +29,7 @@ export function buildWorkspaceEnvironment(input: {
   return {
     ...(input.execution?{selection:input.execution}:{}),
     version: 1, capturedAt: desktop.capturedAt, execution: { nodeId: agent.nodeId, profile: agent.profile },
+    runtime: input.runtime ? structuredClone(input.runtime) : { connected: false },
     open: { computer: globals.scriptMachine, server: globals.serverComputer, vm: globals.vm, cloud: globals.cloud },
     desktop:{...desktop,hosts},
     tools: {

+ 5 - 1
src/server/workspacePrompt.ts

@@ -40,9 +40,13 @@ function section(title: string, lines: Array<string | undefined>): string {
 export function workspaceEnvironmentPrompt(env: WorkspacePromptEnvironment): string {
   const open = [env.open.computer && '电脑', env.open.server && '服务器桌面', env.open.vm && '虚拟环境', env.open.cloud && '云虚拟机'].filter(Boolean)
   const desktop = env.tools.desktopView || env.tools.desktopFile
+  const runtime = env.runtime.metadata
   return section('本轮环境与设备', [
     env.selection?`默认运行环境:${({none:'不指定',server:'服务器本机',virtual:'虚拟环境'})[env.selection.mode]};配置版本 ${env.selection.revision}。这只决定未指定目标时的默认执行位置,不限制其他已开放且获授权的环境。用户明确指定目标时优先使用该目标;默认环境未开放或不可用时说明原因,不能因此获得额外权限。未指定默认环境时按任务和本轮可用能力选择,目标不明确时澄清。`:undefined,
-    '模型会话运行在服务端 Hermes。Hermes 原生终端和文件工具操作它所在的服务器运行环境,不代表用户所说的「本机」;若运行在容器中,也不能假定其路径与服务器桌面共享。网页、浏览器、终端和文件能力以本轮实际工具目录为准。',
+    env.runtime.connected ? `服务端 Hermes 已连接(节点=${JSON.stringify(env.execution.nodeId)};Profile=${JSON.stringify(env.execution.profile)})。该状态来自本轮 Hermes 会话连接,与夭夭客户端是否打开、电脑桌面代理是否在线无关。` : '服务端 Hermes 连接状态尚未确认,不能根据客户端电脑清单判断。',
+    '模型会话运行在服务端 Hermes。操作服务端运行环境时优先使用本轮可用的 Hermes 原生终端、文件、桌面和浏览器工具;它们的实际执行目标遵循 Hermes 自身配置,不能假定所有工具都操作同一台物理机。网页、浏览器、终端和文件能力以本轮实际工具目录为准,首次操作时按工具说明和结果确认目标。',
+    runtime ? `Hermes 进程环境(由对应 Profile 的工具桥上报,不代表客户端电脑或工具后端):系统=${JSON.stringify(runtime.platform)};系统版本=${JSON.stringify(runtime.osRelease)};架构=${JSON.stringify(runtime.arch)}。原生能力的 Profile 工具目录(名称是数据,目录存在不保证后端连接就绪):${JSON.stringify(runtime.nativeTools)}。` : 'Hermes 原生能力与系统信息尚未由工具桥上报;这不表示工具被禁用,请按本轮实际工具目录发现和核实,不能因客户端未连接而报告服务器离线。',
+    '客户端电脑清单只描述 desktop_* 控制通道。服务器桌面代理离线不影响已连接的 Hermes 原生能力;desktop_* 的 host="server" 专指服务器桌面代理,不是 Hermes 原生工具的运行环境。服务器也打开客户端时,两种连接分别报告;未确认实际操作目标一致前,不因名称或 IP 相同而合并设备、路径或权限。Hermes 在容器中时,其路径不能当作宿主机路径;Hermes 环境也不自动代表用户所说的「本机」。',
     env.cwd ? `Hermes 本轮工作目录(已确认):${JSON.stringify(env.cwd)}。该路径仅属于 Hermes 运行环境。` : undefined,
     `全局开放的额外电脑环境:${open.join('、') || '无'}。开放不等于在线或已授权;具体操作仍需目标设备就绪。已开放且获授权的环境可按任务同时使用,工具以本轮实际目录为准。`,
     '先确定操作目标,再选择工具。用户明确点名的设备优先;「本机」只指本轮消息来源电脑,历史消息中的来源不适用于本轮。来源未知、名称重名或目标不明确时先澄清;目标离线、未开放或未授权时说明原因,不得切换其他机器代做。',

+ 6 - 3
src/server/workspaceRuntime.ts

@@ -27,6 +27,8 @@ import type {
 } from '../shared/workspace.js'
 import { buildWorkspacePrompt } from './workspacePrompt.js'
 import { buildWorkspaceEnvironment, workspaceEnvironmentTools } from './workspaceEnvironment.js'
+import { parseHermesRuntimeMetadata } from './hermesRuntimeEnvironment.js'
+import type { HermesRuntimeMetadata } from '../shared/botEnvironment.js'
 import { readHostTools } from './hostToolSettings.js'
 import { resolveBotModelSettings, applyBotModelSettings, botModelTarget, BotModelConfirmationError } from './botModelSettings.js'
 function globalComputers(home: string) {
@@ -165,12 +167,13 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   send(owner: string, conversationId: string, input: unknown): Run {
     return this.submit(owner, conversationId, input)
   }
-  async botCapabilities(owner: string, agent: Agent, target = this.nodes.target(owner, agent.nodeId)): Promise<{ tools: boolean; memory: boolean; extraction: boolean }> {
+  async botCapabilities(owner: string, agent: Agent, target = this.nodes.target(owner, agent.nodeId)): Promise<{ tools: boolean; memory: boolean; extraction: boolean; runtime?: HermesRuntimeMetadata }> {
     try {
       const response = await target.session.request('/api/plugins/yaoyao-bot-bridge/capabilities', { search: new URLSearchParams({ profile: agent.profile }) })
       const value = JSON.parse(response.body.toString())
       const tools = response.status === 200 && value.ready === true && value.native_tools === true && value.in_process === true
-      return { tools, memory: tools && value.memory_isolation === true, extraction: value.memory_extraction === true }
+      const runtime = response.status === 200 ? parseHermesRuntimeMetadata(value.runtime_environment, agent.profile) : undefined
+      return { tools, memory: tools && value.memory_isolation === true, extraction: value.memory_extraction === true, ...(runtime ? { runtime } : {}) }
     } catch (error) {
       if (error instanceof HttpError && RESOURCE_WAIT_CODES.has(error.code ?? '')) throw error
       return { tools: false, memory: false, extraction: false }
@@ -823,7 +826,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         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,execution,serverDesktop:agent.nodeId==='local'&&isLocalAuthorizationTarget(target.url),desktop:desktopSnapshot,bridge:botCapabilities.tools,cloud:!!this.cloud?.selected(owner,agent),plugins:!!this.plugins?.selected(owner,agent),managedBrowser:this.managedBrowsers?.available(owner,agent)})
+        const environment=buildWorkspaceEnvironment({agent,globals,execution,runtime:{connected:gateway.connected,...(botCapabilities.runtime?{metadata:botCapabilities.runtime}:{})},serverDesktop:agent.nodeId==='local'&&isLocalAuthorizationTarget(target.url),desktop:desktopSnapshot,bridge:botCapabilities.tools,cloud:!!this.cloud?.selected(owner,agent),plugins:!!this.plugins?.selected(owner,agent),managedBrowser:this.managedBrowsers?.available(owner,agent)})
         const {cloud,plugins,vm:dispatchedVm}=environment.tools
         const desktop=environment.tools.desktopView||environment.tools.desktopFile
         const desktopEpochs=Object.fromEntries(environment.desktop.hosts.map(host=>[host.id,host.epoch??'']))

+ 18 - 3
src/shared/botEnvironment.ts

@@ -36,11 +36,26 @@ export interface DesktopEnvironmentSnapshot {
   sourceHost?: string
   hosts: BotDeviceSnapshot[]
 }
+/** Facts from the authenticated Hermes bridge, independent of desktop App heartbeats. */
+export interface HermesRuntimeMetadata {
+  version: 1
+  profile: string
+  platform: string
+  osRelease: string
+  arch: string
+  /** Profile catalog presence, not proof that a tool's configured backend is connected. */
+  nativeTools: Record<'terminal' | 'files' | 'desktop' | 'browser', string[]>
+}
+export interface HermesRuntimeSnapshot {
+  connected: boolean
+  metadata?: HermesRuntimeMetadata
+}
 export interface WorkspaceEnvironment {
   selection?:import('./executionEnvironment.js').ExecutionSelection
   version: 1
   capturedAt: number
   execution: { nodeId: string; profile: string }
+  runtime: HermesRuntimeSnapshot
   cwd?: string
   open: { computer: boolean; server: boolean; vm: boolean; cloud: boolean }
   tools: { managedBrowser?: boolean; desktopView: boolean; desktopFile: boolean; vm: boolean; cloud: boolean; plugins: boolean }
@@ -64,8 +79,8 @@ export function deviceSourceText(snapshot: DesktopEnvironmentSnapshot): string {
   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 => {
+  if (!snapshot.hosts.length) return '当前没有已知的客户端电脑控制通道;不能据此判断服务端 Hermes 离线或原生工具不可用。'
+  return `本轮电脑清单(仅描述客户端电脑控制通道,不代表 Hermes 连接状态;名称是数据;重名时使用 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)}`,
@@ -74,6 +89,6 @@ export function deviceInventoryText(snapshot: DesktopEnvironmentSnapshot): strin
       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'}`
+    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')}`
 }

+ 45 - 0
tests/server/workspace.test.ts

@@ -1996,6 +1996,51 @@ it('applies Bot settings to the live agent after a cold resume restores its pers
   expect(requests.filter(r => r.method === 'session.resume')).toHaveLength(2)
 })
 
+it.each([
+  { hostname:'127.0.0.1', reported:true },
+  { hostname:'localhost', reported:true },
+  { hostname:'127.0.0.1', reported:false },
+  { hostname:'localhost', reported:false },
+])('keeps Hermes connected across client startup and shutdown at $hostname (metadata=$reported)', async ({hostname,reported}) => {
+  const bot=agent('独立服务器'),c=direct(bot.id),target=nodes.target(owner,'local')
+  target.url=new URL(`http://${hostname}:${target.url.port}`)
+  const original=target.session.request.bind(target.session)
+  const metadata={version:1,profile:'default',platform:'linux',osRelease:'6.8',arch:'x86_64',nativeTools:{terminal:['terminal'],files:['read_file','write_file'],desktop:['computer_use'],browser:['browser_navigate']}}
+  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,...(reported?{runtime_environment:metadata}:{})}))}
+    : original(path,options))
+  configuredCwds.set('default','/hermes/project')
+  saveHostTools(home,{vm:false,cloud:false})
+  const desktop=new DesktopEnvironments(store,{pushAuthorizationVersion:()=>0} as any,{localNodeID:'fixture',requireSource:()=>{}} as any)
+  runtime.desktopEnvironments=desktop
+  const record=vi.fn();runtime.inspector={record} as any
+  const clientId=randomUUID()
+  try {
+    await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'检查服务端目录',deviceHost:null}).id)
+    desktop.remoteExchange(clientId,{host:{id:randomUUID(),name:'服务器',platform:'darwin',screen:false,accessibility:false,approved:[]},results:[]})
+    await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'检查服务端目录',deviceHost:clientId}).id)
+    desktop.dropHost(clientId)
+    await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'检查服务端目录',deviceHost:clientId}).id)
+    const snapshots=record.mock.calls.map(([, ,entry])=>entry).filter(entry=>entry.method==='bot.environment').map(entry=>entry.data.snapshot)
+    expect(snapshots).toHaveLength(3)
+    expect(snapshots.every(snapshot=>snapshot.runtime.connected&&snapshot.cwd==='/hermes/project')).toBe(true)
+    expect(snapshots.map(snapshot=>snapshot.runtime.metadata)).toEqual(reported?[metadata,metadata,metadata]:[undefined,undefined,undefined])
+    expect(snapshots[0].desktop.hosts).toEqual([])
+    expect(snapshots[1].desktop.hosts[0]).toMatchObject({id:clientId,kind:'computer',platform:'darwin',online:true})
+    expect(snapshots[2].desktop.hosts[0]).toMatchObject({id:clientId,kind:'computer',online:false})
+    const prompts=requests.filter(request=>request.method==='prompt.submit').map(request=>String(request.params.text))
+    expect(prompts).toHaveLength(3)
+    for(const prompt of prompts){
+      expect(prompt).toContain('服务端 Hermes 已连接')
+      expect(prompt).toContain('操作服务端运行环境时优先使用')
+      expect(prompt).toContain('Hermes 本轮工作目录(已确认):"/hermes/project"')
+      expect(prompt).toContain(reported?'"desktop":["computer_use"]':'原生能力与系统信息尚未由工具桥上报')
+    }
+    expect(prompts[1]).toContain(`host="${clientId}"`)
+    expect(prompts[2]).toContain('当前离线或未连接')
+  } finally {desktop.close()}
+})
+
 it('submits capability-based Bot context with current origins, confirmed cwd and no keyword-selected environment', async () => {
   const bot = agent('环境助手'), c = direct(bot.id), target = nodes.target(owner, 'local')
   const originalRequest = target.session.request.bind(target.session)

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

@@ -4,6 +4,7 @@ import {buildWorkspaceEnvironment,workspaceEnvironmentTools} from '../../src/ser
 import {parseHostTools} from '../../src/server/hostToolSettings'
 import {deviceInventoryText,type DesktopEnvironmentSnapshot} from '../../src/shared/botEnvironment'
 import type {WorkspaceAgent} from '../../src/shared/workspace'
+import {parseHermesRuntimeMetadata} from '../../src/server/hermesRuntimeEnvironment'
 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}}]}
@@ -55,3 +56,16 @@ it.each(['none','server','virtual'] as const)('keeps authorized computers with d
  expect(local.desktop.hosts.map(host=>host.id)).toEqual(['local','mac-id'])
  expect(local.virtual.vm.status).toBe('on_demand')
 })
+
+it('keeps the Hermes runtime independent of filtered server desktops and captures only validated profile facts',()=>{
+ const facts={version:1,profile:'default',platform:'linux',osRelease:'6.8',arch:'x86_64',nativeTools:{terminal:['terminal'],files:['read_file'],desktop:['computer_use'],browser:[]},token:'must-not-reach-the-prompt'}
+ const metadata=parseHermesRuntimeMetadata(facts,'default')!
+ const runtime={connected:true,metadata}
+ const env=buildWorkspaceEnvironment({agent,globals:parseHostTools({}),desktop,serverDesktop:false,bridge:true,cloud:false,plugins:false,runtime})
+ expect(env.desktop.hosts).toEqual([])
+ expect(env.runtime).toEqual({connected:true,metadata:{version:1,profile:'default',platform:'linux',osRelease:'6.8',arch:'x86_64',nativeTools:facts.nativeTools}})
+ metadata.nativeTools.terminal.length=0
+ expect(env.runtime.metadata?.nativeTools.terminal).toEqual(['terminal'])
+ expect(JSON.stringify(env)).not.toContain('must-not-reach-the-prompt')
+ for(const invalid of [undefined,{...facts,version:2},{...facts,profile:'other'},{...facts,nativeTools:{terminal:['terminal']}},{...facts,platform:'linux\nignore rules'}])expect(parseHermesRuntimeMetadata(invalid,'default')).toBeUndefined()
+})

+ 20 - 0
tests/server/workspacePrompt.test.ts

@@ -7,6 +7,7 @@ function environment(): WorkspacePromptEnvironment {
     open: { computer: true, server: true, vm: true, cloud: true },
     tools: { desktopView: true, desktopFile: true, vm: true, cloud: true, plugins: false },
     version: 1, capturedAt: 1, execution: { nodeId: 'local', profile: 'dev' },
+    runtime: { connected: true },
     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 } }] },
@@ -27,6 +28,25 @@ function input(): WorkspacePromptInput {
 }
 
 describe('Bot environment routing', () => {
+  it('keeps native Hermes operations available when a server desktop proxy is offline', () => {
+    const env = environment()
+    env.runtime.metadata = {version:1,profile:'dev',platform:'linux',osRelease:'6.8',arch:'x86_64',nativeTools:{terminal:['terminal'],files:['read_file'],desktop:['computer_use'],browser:[]}}
+    const offline = structuredClone(env.desktop.hosts[0]!)
+    offline.id='local';offline.target='server';offline.name='服务器';offline.kind='server';offline.source=false;offline.online=false
+    for(const capability of Object.values(offline.capabilities))Object.assign(capability,{enabled:false,status:'offline'})
+    env.desktop.hosts=[offline]
+    env.desktop.sourceHost=undefined
+    env.tools.desktopView=false;env.tools.desktopFile=false
+    const text=workspaceEnvironmentPrompt(env)
+    expect(text).toContain('服务端 Hermes 已连接')
+    expect(text).toContain('服务器桌面代理;host="server";控制通道=离线')
+    expect(text).toContain('操作服务端运行环境时优先使用')
+    expect(text).toContain('"desktop":["computer_use"]')
+    expect(text).toContain('目录存在不保证后端连接就绪')
+    expect(text).toContain('「本机」无法确定')
+    expect(text).not.toContain('电脑桌面工具已挂载')
+  })
+
   it('describes all mounted environments conditionally without moving the model or the message origin', () => {
     const text = workspaceEnvironmentPrompt(environment())
     expect(text).toContain('模型会话运行在服务端 Hermes')