Browse Source

增加工具桥插件检查与安装入口

在 Hermes 连接设置中按 Profile 检查安装文件和实际加载版本,提供安装、启用、更新与备份结果。管理接口限制管理员及本机环境,拦截重复安装和运行中任务,并检查默认后台入口依赖。通过代码指纹区分同版本更新后的待重启状态,补充真实 Hermes 安装及响应式界面验证。
Samien 1 month ago
parent
commit
15a9f3b696

+ 10 - 0
docs/local-vm.md

@@ -37,6 +37,16 @@
 
 ### 更新配套组件
 
+管理员可在 **设置 → Hermes 连接 → 工具桥插件** 检查、安装、更新或重新安装。
+
+- 每个 Profile 单独显示安装版本、实际加载版本和状态。缺少后台入口时,先安装或更新默认 Profile,再安装命名 Profile。
+- “安装并启用”只启用选中的工具桥及其工具集,保留其他插件、禁用项和模型配置。每次安装保留原插件与配置备份,界面可查看备份位置。
+- 检查会比较插件文件指纹与 Hermes 启动时加载的指纹;同版本修复后未重启也会显示“待重启”,不会仅凭安装文件判断就绪。
+- 安装不会自动重启 Hermes。请在空闲时重启,再点击“重新检查”。正在执行任务时暂不可安装。
+- 本机安装只用于当前服务可访问的本机 Hermes;远程节点支持状态检查,需在对应节点安装。
+
+管理员 API:`GET /api/app/admin/hermes-bridge` 与 `POST /api/app/admin/hermes-bridge/install`,安装参数仅为 `profile` 和可选的 `enable`;不接受客户端传入目录、命令或 Python 路径。
+
 需要 Runner 的 `hermes-computer-v2` 能力,以及每个使用中的 Profile 安装工具桥 **1.2.0**,能力响应包含 `computer_runtime_version: 2`。旧插件会明确提示更新,不回退启动 Worker。
 
 插件源码位于 `integrations/hermes-bots-bridge`,并随 Runner 和桌面服务打包。使用 Hermes 的 Python 安装,例如:

+ 30 - 0
docs/verification/2026-09-15-hermes-bridge/README.md

@@ -0,0 +1,30 @@
+# 工具桥检查与安装验证
+
+入口:设置 → Hermes 连接 → 工具桥插件,仅管理员可见。
+
+## 覆盖内容
+
+- 按默认及命名 Profile 检查安装版本、已加载版本和代码指纹。
+- 安装、明确启用、更新、重新安装与备份位置展示。
+- 默认 Profile 后台入口缺失时,阻止无效的命名 Profile 安装并给出顺序提示。
+- 未安装、未启用、需更新、待重启、已就绪和无法确认状态。
+- 管理员权限、CSRF、非法 Profile/路径输入、安装互斥与运行中任务检查。
+- 同版本代码变化、损坏插件修复、显式启用只调整目标插件、失败恢复原配置。
+
+## 验证方式
+
+- `tests/server/hermesBridge.test.ts`:管理器状态、安装互斥、接口授权及错误边界。
+- `tests/client/hermesBridgePanel.test.ts`:进度、目标 Profile、失败反馈、远程安装限制。
+- `integrations/hermes-bots-bridge/tests/test_installer.py`:临时目录中的实际安装、只读检查、备份及回滚。
+- `tests/live/profileComputer.live.test.ts`:临时 Hermes + 真实 Python 安装器;运行期间替换同版本插件包后,检查结果正确为“待重启”。同时保留原有真实 Docker、Skill 与审批链路验证。模型响应和授权文件采用测试数据。
+- `playwright.hermes-bridge.config.ts`:真实设置页面 + 确定性的管理接口响应,检查安装请求、按钮禁用、状态切换、375px 窄屏、812px 横屏和暗色主题。
+
+## 界面截图
+
+- [桌面检查](desktop-before.png)
+- [安装完成待重启](desktop-installed.png)
+- [手机宽度](mobile-ready.png)
+- [横屏](landscape-ready.png)
+- [暗色主题](dark-ready.png)
+
+验证使用临时环境,没有安装到用户当前运行的 Hermes,也没有重启现有服务。

BIN
docs/verification/2026-09-15-hermes-bridge/dark-ready.png


BIN
docs/verification/2026-09-15-hermes-bridge/desktop-before.png


BIN
docs/verification/2026-09-15-hermes-bridge/desktop-installed.png


BIN
docs/verification/2026-09-15-hermes-bridge/landscape-ready.png


BIN
docs/verification/2026-09-15-hermes-bridge/mobile-ready.png


+ 5 - 0
integrations/hermes-bots-bridge/README.md

@@ -14,6 +14,9 @@ Yaoyao 的虚拟机模式和本机协作模式都使用真实 Hermes Profile 会
 
 ## 安装
 
+Yaoyao 管理员可从“设置 → Hermes 连接 → 工具桥插件”执行检查和安装。默认 Profile 提供共享后台入口,应先安装默认入口,再处理命名 Profile。界面依据磁盘与已加载代码的指纹区分待重启与已就绪。
+
+
 使用 Hermes 的 Python,从 Yaoyao 仓库运行:
 
 ```sh
@@ -52,3 +55,5 @@ HTTP 使用 Hermes 自身认证。绑定身份必须与 WebSocket 的真实身
 `tests/live/profileComputer.live.test.ts` 使用临时 Hermes 目录、真实 Hermes、真实 Docker 和确定性模型响应,验证两种模式的 Skill、Profile 环境变量、授权文件双向传输、原图输入及无工具记忆提炼。Worker 路径故意配置为不存在,证明新链路不依赖 Worker 配置读取。
 
 真实测试使用已安装的 Tirith 检查器,默认查找 Hermes 主目录的 `bin/tirith`,也可通过 `YAOYAO_TIRITH_BIN` 指定,避免临时环境下载依赖造成超时。隔离模式关闭 YOLO,实际接收审批 RPC 并逐次回复 `once`;模型响应与授权文件使用测试数据。
+
+CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确启用所选插件及工具集,仅移除这个工具桥的显式禁用项。默认安装行为继续保留禁用设置。

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

@@ -26,6 +26,16 @@ from pathlib import Path
 from typing import Any
 
 VERSION = 1
+def _plugin_fingerprint():
+    root = Path(__file__).parent
+    digest = hashlib.sha256()
+    for path in sorted(p for p in root.rglob('*') if p.is_file() and '__pycache__' not in p.parts
+                       and 'tests' not in p.relative_to(root).parts and (p.suffix == '.py' or p.name in ('plugin.yaml', 'manifest.json'))):
+        digest.update(path.relative_to(root).as_posix().encode() + b'\0' + path.read_bytes() + b'\0')
+    return digest.hexdigest()
+
+# Freeze at module load: files copied later must not masquerade as loaded code.
+PLUGIN_FINGERPRINT = _plugin_fingerprint()
 MAX_LEASE_MS = 30 * 60 * 1000
 MAX_RESPONSE_BYTES = 64 * 1024 * 1024
 registered = False
@@ -319,12 +329,14 @@ def capabilities(profile: str = "default") -> dict:
             if not {"yaoyao_tools", "yaoyao_call"} <= names:
                 raise BridgeError(f"Profile {profile} 尚未安装或启用夭夭工具桥,请检查该 Profile 的插件与工具集设置", 503, "profile_tools_unavailable")
             return {"version": VERSION, "ready": True, "in_process": True, "profile": profile, "native_tools": True,
-                    "computer_runtime_version": 2, "memory_isolation": True, "memory_extraction": True}
+                    "computer_runtime_version": 2, "plugin_version": "1.2.0", "plugin_fingerprint": PLUGIN_FINGERPRINT,
+                    "memory_isolation": True, "memory_extraction": True}
         finally:
             reset_hermes_home_override(context)
     except Exception as exc:
         reason = str(exc) if isinstance(exc, BridgeError) else "无法确认 Hermes 插件接口兼容性"
-        return {"version": VERSION, "ready": False, "in_process": in_process, "profile": profile, "reason": reason}
+        return {"version": VERSION, "ready": False, "in_process": in_process, "profile": profile, "reason": reason,
+                "plugin_version": "1.2.0", "plugin_fingerprint": PLUGIN_FINGERPRINT}
 
 
 def _string(body: dict, key: str, maximum: int = 512) -> str:

+ 25 - 0
integrations/hermes-bots-bridge/tests/test_installer.py

@@ -51,6 +51,31 @@ class InstallerTests(unittest.TestCase):
                 self.assertEqual(self.config.read_bytes(), original)
                 self.assertEqual((self.target / 'old').read_text(), 'previous-plugin')
 
+    def test_check_is_read_only_and_enable_changes_only_the_selected_profile_plugin(self):
+        named = self.home / 'profiles' / 'writer'; named.mkdir(parents=True)
+        config = named / 'config.yaml'
+        config.write_text('plugins:\n  disabled: [yaoyao-bot-bridge, other]\nagent:\n  disabled_toolsets: [yaoyao_bot_bridge, terminal]\nplatform_toolsets:\n  cli: [web]\n')
+        before = self.config.read_bytes()
+        self.assertFalse(installer.check(self.home, 'writer', self.source)['enabled'])
+        self.assertFalse((named / 'backups').exists())
+        installer.install(self.home, 'writer', self.source, enable=True)
+        checked = installer.check(self.home, 'writer', self.source)
+        self.assertTrue(checked['filesCurrent']); self.assertTrue(checked['enabled']); self.assertFalse(checked['disabled'])
+        saved = yaml.safe_load(config.read_text())
+        self.assertEqual(saved['plugins']['disabled'], ['other'])
+        self.assertEqual(saved['agent']['disabled_toolsets'], ['terminal'])
+        self.assertEqual(saved['platform_toolsets']['cli'], ['web', 'yaoyao_bot_bridge'])
+        self.assertEqual(self.config.read_bytes(), before)
+        (named / 'plugins' / 'yaoyao-bot-bridge' / 'plugin.yaml').write_text('invalid: [')
+        broken = installer.check(self.home, 'writer', self.source)
+        self.assertTrue(broken['valid']); self.assertFalse(broken['filesCurrent'])
+
+    def test_check_detects_same_version_code_changes(self):
+        installer.install(self.home, 'default', self.source)
+        self.assertTrue(installer.check(self.home, 'default', self.source)['filesCurrent'])
+        (self.target / 'bridge_runtime.py').write_text('old implementation')
+        self.assertFalse(installer.check(self.home, 'default', self.source)['filesCurrent'])
+
 
 if __name__ == '__main__':
     unittest.main()

+ 9 - 0
playwright.hermes-bridge.config.ts

@@ -0,0 +1,9 @@
+import {defineConfig,devices} from '@playwright/test'
+process.env.NO_PROXY=[process.env.NO_PROXY,'127.0.0.1','localhost'].filter(Boolean).join(',')
+process.env.no_proxy=[process.env.no_proxy,'127.0.0.1','localhost'].filter(Boolean).join(',')
+export default defineConfig({
+  testDir:'./tests/e2e',testMatch:'hermes-bridge.spec.ts',workers:1,timeout:60000,reporter:'list',
+  use:{baseURL:'http://127.0.0.1:18835',viewport:{width:1440,height:1000},trace:'retain-on-failure'},
+  projects:[{name:'chromium',use:{...devices['Desktop Chrome'],viewport:{width:1440,height:1000}}}],
+  webServer:{command:'WORKSPACE_FIXTURE_PORT=18835 WORKSPACE_FIXTURE_UPSTREAM_PORT=19135 node --import tsx tests/fixtures/workspace-server.ts',url:'http://127.0.0.1:18835/healthz',reuseExistingServer:false,timeout:60000},
+})

+ 56 - 4
scripts/install-hermes-bridge.py

@@ -4,6 +4,7 @@ Run with the Hermes virtualenv Python. This command does not restart Hermes.
 """
 import argparse
 import json
+import hashlib
 import os
 import re
 import shutil
@@ -15,12 +16,44 @@ from pathlib import Path
 import yaml
 
 
-def install(root, profile, source):
+def fingerprint(directory):
+    files = sorted(p for p in directory.rglob('*') if p.is_file() and '__pycache__' not in p.parts
+                   and 'tests' not in p.relative_to(directory).parts and (p.suffix == '.py' or p.name in ('plugin.yaml', 'manifest.json')))
+    digest = hashlib.sha256()
+    for path in files:
+        digest.update(path.relative_to(directory).as_posix().encode() + b'\0' + path.read_bytes() + b'\0')
+    return digest.hexdigest()
+
+
+def check(root, profile, source):
+    home = root if profile == 'default' else root / 'profiles' / profile
+    result = {'profile': profile, 'exists': (home / 'config.yaml').is_file(), 'valid': False}
+    if not result['exists'] or home.is_symlink() or (home / 'config.yaml').is_symlink():
+        return result
+    try:
+        config = yaml.safe_load((home / 'config.yaml').read_bytes()) or {}
+        plugins = config.get('plugins') or {}
+        result.update(valid=True, enabled='yaoyao-bot-bridge' in plugins.get('enabled', []),
+                      disabled='yaoyao-bot-bridge' in plugins.get('disabled', []))
+        target = home / 'plugins' / 'yaoyao-bot-bridge'
+        try:
+            manifest = yaml.safe_load((target / 'plugin.yaml').read_bytes()) if (target / 'plugin.yaml').is_file() else {}
+            result.update(installedVersion=str(manifest.get('version', '')) or None,
+                          fingerprint=fingerprint(target) if manifest else None,
+                          filesCurrent=bool(manifest) and fingerprint(target) == fingerprint(source))
+        except Exception:
+            result.update(installedVersion='未知', filesCurrent=False, message='插件文件损坏,可以重新安装修复')
+    except Exception:
+        result['message'] = '无法读取此 Profile 的插件配置'
+    return result
+
+
+def install(root, profile, source, enable=False):
     if not re.fullmatch(r'[a-z0-9][a-z0-9_-]{0,63}', profile):
         raise ValueError('Profile 名称无效')
     home = root if profile == 'default' else root / 'profiles' / profile
     config_path = home / 'config.yaml'
-    if not config_path.is_file() or config_path.is_symlink():
+    if not config_path.is_file() or config_path.is_symlink() or home.is_symlink():
         raise ValueError('Profile 配置不存在或是符号链接')
     original = config_path.read_bytes()
     config = yaml.safe_load(original) or {}
@@ -33,6 +66,16 @@ def install(root, profile, source):
         if key in plugins and not isinstance(plugins[key], list):
             raise ValueError('Profile 插件列表格式无效')
     name = 'yaoyao-bot-bridge'
+    if enable:
+        plugins['disabled'] = [item for item in plugins.get('disabled', []) if item != name]
+        # A dedicated Enable action changes this plugin's toolset only.
+        agent = config.get('agent') or {}
+        if isinstance(agent.get('disabled_toolsets'), list):
+            agent['disabled_toolsets'] = [item for item in agent['disabled_toolsets'] if item != 'yaoyao_bot_bridge']
+        platforms = config.get('platform_toolsets') or {}
+        for platform in ('cli', 'tui'):
+            if isinstance(platforms.get(platform), list) and 'all' not in platforms[platform] and 'yaoyao_bot_bridge' not in platforms[platform]:
+                platforms[platform].append('yaoyao_bot_bridge')
     disabled = name in plugins.get('disabled', [])
     if not disabled and name not in plugins.setdefault('enabled', []):
         plugins['enabled'].append(name)
@@ -82,14 +125,23 @@ def main():
     parser = argparse.ArgumentParser(description=__doc__)
     parser.add_argument('--hermes-home', required=True, type=Path)
     parser.add_argument('--profile', action='append', default=[])
+    parser.add_argument('--check', action='store_true')
+    parser.add_argument('--enable', action='store_true', help='明确启用此插件及其工具集,保留其他禁用项')
     args = parser.parse_args()
     source = Path(__file__).resolve().parent / 'hermes-bots-bridge'
     if not source.is_dir():
         source = Path(__file__).resolve().parents[1] / 'integrations' / 'hermes-bots-bridge'
     if not (source / 'plugin.yaml').is_file():
         raise ValueError('缺少随附的工具桥插件')
-    for profile in dict.fromkeys(args.profile or ['default']):
-        print(json.dumps(install(args.hermes_home.expanduser().resolve(), profile, source), ensure_ascii=False))
+    root = args.hermes_home.expanduser().resolve()
+    profiles = args.profile or (['default'] + sorted(p.name for p in (root / 'profiles').iterdir()
+                 if p.is_dir() and not p.is_symlink() and re.fullmatch(r'[a-z0-9][a-z0-9_-]{0,63}', p.name))
+                 if args.check and (root / 'profiles').is_dir() else ['default'])
+    for profile in dict.fromkeys(profiles):
+        if not re.fullmatch(r'[a-z0-9][a-z0-9_-]{0,63}', profile):
+            raise ValueError('Profile 名称无效')
+        result = check(root, profile, source) if args.check else install(root, profile, source, enable=args.enable)
+        print(json.dumps(result, ensure_ascii=False))
 
 
 if __name__ == '__main__':

+ 7 - 0
src/client/api/hermesBridge.ts

@@ -0,0 +1,7 @@
+import {apiRequest} from './client'
+import type {HermesBridgeStatus,HermesBridgeInstallResult} from '@shared/hermesBridge'
+
+export const getHermesBridgeStatus=()=>apiRequest<HermesBridgeStatus>('/api/app/admin/hermes-bridge',{timeoutMs:45000})
+export const installHermesBridge=(profile:string,enable=false)=>apiRequest<HermesBridgeInstallResult>('/api/app/admin/hermes-bridge/install',{
+  method:'POST',body:{profile,enable},timeoutMs:60000,
+})

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

@@ -0,0 +1,74 @@
+<script setup lang="ts">
+import {onBeforeUnmount,onMounted,ref} from 'vue'
+import {getHermesBridgeStatus,installHermesBridge} from '@/api/hermesBridge'
+import type {HermesBridgeProfileStatus,HermesBridgeState,HermesBridgeStatus} from '@shared/hermesBridge'
+import type {Profile} from '@shared/types'
+
+const props=withDefaults(defineProps<{profiles?:Profile[]}>(),{profiles:()=>[]})
+const status=ref<HermesBridgeStatus>(),checking=ref(false),installing=ref(''),error=ref(''),notice=ref(''),backup=ref('')
+const lastInstalled=ref('')
+const labels:Record<HermesBridgeState,string>={ready:'已就绪',missing:'未安装',outdated:'需要更新','restart-required':'待重启',disabled:'未启用',unavailable:'无法确认'}
+let generation=0,disposed=false,timer:ReturnType<typeof setTimeout>|undefined
+function profileLabel(name:string){const profile=props.profiles.find(p=>p.name===name);return profile?.agentName||profile?.displayName||name}
+function actionLabel(profile:HermesBridgeProfileStatus){return profile.state==='missing'||profile.state==='disabled'?'安装并启用':profile.state==='outdated'?'更新工具桥':'重新安装'}
+async function load(){
+  if(installing.value)return
+  clearTimeout(timer)
+  const current=++generation;checking.value=true;error.value=''
+  try{
+    const result=await getHermesBridgeStatus()
+    if(disposed||current!==generation)return
+    status.value=result
+    if(lastInstalled.value&&result.profiles.find(p=>p.profile===lastInstalled.value)?.state==='ready')notice.value='检查完成,工具桥已就绪。'
+    if(result.installing)timer=setTimeout(()=>void load(),1500)
+  }catch(cause){if(!disposed&&current===generation)error.value=cause instanceof Error?cause.message:'无法检查工具桥状态'}
+  finally{if(!disposed&&current===generation)checking.value=false}
+}
+async function install(profile:HermesBridgeProfileStatus){
+  if(installing.value||checking.value||!profile.canInstall)return
+  ++generation;installing.value=profile.profile;error.value='';notice.value='';backup.value=''
+  try{
+    const result=await installHermesBridge(profile.profile,profile.state==='disabled'||profile.state==='missing')
+    if(disposed)return
+    status.value=result.status;notice.value=result.message;backup.value=result.backup;lastInstalled.value=result.profile
+  }catch(cause){if(!disposed)error.value=cause instanceof Error?cause.message:'工具桥安装失败'}
+  finally{if(!disposed)installing.value=''}
+}
+onMounted(()=>void load())
+onBeforeUnmount(()=>{disposed=true;++generation;clearTimeout(timer)})
+</script>
+
+<template>
+  <section class="hermes-bridge" aria-label="工具桥插件" :aria-busy="checking || !!installing || !!status?.installing">
+    <header>
+      <div><h4>工具桥插件</h4><p>连接 Bot 的电脑、技能和应用工具。默认及其他 Profile 需分别安装。</p></div>
+      <button type="button" :disabled="checking || !!installing" @click="load">{{ checking ? '检查中…' : '重新检查' }}</button>
+    </header>
+    <p v-if="error" class="error" role="alert">{{ error }}</p>
+    <p v-if="!status && checking" class="loading" role="status">正在检查安装文件和 Hermes 加载状态…</p>
+    <template v-if="status">
+      <p v-if="status.message" class="hint">{{ status.message }}</p>
+      <div class="profiles">
+        <article v-for="profile in status.profiles" :key="profile.profile" class="profile">
+          <div class="profile-detail">
+            <div class="profile-heading"><strong>{{ profileLabel(profile.profile) }}</strong><span class="badge" :class="profile.state">{{ labels[profile.state] }}</span></div>
+            <small v-if="profileLabel(profile.profile)!==profile.profile">{{ profile.profile }}</small>
+            <p>{{ profile.message }}</p>
+            <small>随附版本 {{ status.bundledVersion }}<template v-if="profile.installedVersion"> · 已安装 {{ profile.installedVersion }}</template><template v-if="profile.loadedVersion"> · 已加载 {{ profile.loadedVersion }}</template></small>
+          </div>
+          <button v-if="status.local" type="button" class="install" :disabled="checking || !!installing || !profile.canInstall" :aria-label="`${actionLabel(profile)}:${profileLabel(profile.profile)}`" @click="install(profile)">
+            {{ installing===profile.profile || status.installing===profile.profile ? '安装中…' : actionLabel(profile) }}
+          </button>
+        </article>
+      </div>
+    </template>
+    <div v-if="notice" class="result" role="status"><p>{{ notice }}</p><details v-if="backup"><summary>查看备份位置</summary><code>{{ backup }}</code></details></div>
+    <p class="footnote">安装前会备份原插件与配置。安装完成后,请在空闲时重启 Hermes,再点击重新检查。</p>
+  </section>
+</template>
+
+<style scoped>
+.hermes-bridge{display:grid;gap:14px;margin-top:22px;padding-top:22px;border-top:1px solid var(--line);color:var(--text-primary);font-size:13px;line-height:1.65}
+header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}h4{margin:0 0 5px;font-size:15px}p{margin:0;color:var(--text-secondary)}button{flex-shrink:0;min-height:44px;padding:8px 14px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}button:hover:not(:disabled){background:var(--surface-soft)}button:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button:disabled{opacity:.55;cursor:default}.profiles{display:grid;border:1px solid var(--line);border-radius:12px;overflow:hidden}.profile{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px;background:var(--surface-soft)}.profile+.profile{border-top:1px solid var(--line)}.profile-detail{min-width:0;display:grid;gap:5px;overflow-wrap:anywhere}.profile-heading{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.profile-heading strong{font-size:14px}.badge{border:1px solid var(--line);border-radius:6px;padding:1px 7px;font-size:12px;color:var(--text-secondary);background:var(--surface)}.badge.ready{color:var(--success,var(--accent))}.badge.outdated,.badge.restart-required{color:var(--accent)}small,.footnote{font-size:12px;color:var(--text-secondary)}.error{color:var(--danger)}.result{padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft)}.result details{margin-top:8px}.result summary{cursor:pointer}.result code{display:block;margin-top:8px;white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}.install{color:var(--accent)}
+@media(max-width:600px){header{flex-wrap:wrap}.profile{align-items:stretch;flex-direction:column;gap:12px}.install{align-self:flex-start}header button{margin-left:auto}}
+</style>

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

@@ -367,7 +367,7 @@ function requestModeSwitch() {
                 <FileAccessPanel v-else-if="activePage === 'system-file-access' && isAdmin" :profile="activeProfile?.name" @dirty-change="setDirty('system-file-access', $event)" />
                 <LocalVmSettingsPanel v-else-if="activePage === 'system-local-vm' && isAdmin" />
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
-                <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
+                <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :profiles="profiles" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />
                 <WorkspaceNodesPanel v-else-if="activePage === 'system-nodes' && isAdmin" />
                 <section v-else-if="activePage === 'system-voice' && isAdmin"><DuplexVoicePanel @dirty-change="setDirty('system-voice', $event)" /><WorkspaceVoiceProviders /></section>

+ 2 - 0
src/client/components/app/SystemManagementPanel.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import HermesBridgePanel from '@/components/app/HermesBridgePanel.vue'
 import { computed, ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
@@ -452,6 +453,7 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
         <label><span>9119 密码</span><input v-model="upstreamPassword" name="upstream-password" type="password" autocomplete="new-password" :disabled="busy" /></label>
         <button class="solid-button" :disabled="busy || !upstreamUsername.trim() || !upstreamPassword">验证并保存</button>
       </form>
+      <HermesBridgePanel v-if="active" :profiles="profiles" />
       <details class="runner-settings" @toggle="runnerSettingsOpen = ($event.target as HTMLDetailsElement).open">
         <summary>执行节点</summary>
         <RunnerSettingsPanel v-if="runnerSettingsOpen" />

+ 6 - 0
src/server/app.ts

@@ -62,8 +62,10 @@ import {LocalVmService} from './localVm.js'
 import {SharedComputers} from './sharedComputers.js'
 import {ComputerControlService} from './computerControls.js'
 import { RunnerHub } from './runnerHub.js'
+import { HermesBridgeManager } from './hermesBridge.js'
 
 export interface ApplicationOptions {
+  hermesBridge?: HermesBridgeManager
   config?: ServerConfig
   fetchImpl?: typeof fetch
   grokFetch?:typeof fetch
@@ -84,6 +86,7 @@ export interface ApplicationOptions {
 }
 
 export interface ApplicationRuntime {
+  hermesBridge: HermesBridgeManager
   runners: RunnerHub
   workspace: WorkspaceStore
   workspaceRuntime: WorkspaceRuntime
@@ -235,6 +238,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
+  const hermesBridge=options.hermesBridge??new HermesBridgeManager(config,upstreamSession,{isIdle:()=>workspaceRuntime.idleForUpdate&&runners.idleForUpdate&&realtime.broker.idleForUpdate})
   const workspaceMemory = new WorkspaceMemorySynthesis(workspaceRuntime)
   workspaceMemory.start()
   const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers,options.grokFetch)
@@ -472,6 +476,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     apnsConfiguration,
     fcmConfiguration,
     allowedHostsConfiguration,
+    hermesBridge,
     chatCache,
   })
   app.use(router.routes())
@@ -488,6 +493,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
 
   return {
     runners,
+    hermesBridge,
     localVm,
     workspace,
     workspaceRuntime,

+ 119 - 0
src/server/hermesBridge.ts

@@ -0,0 +1,119 @@
+import {execFile} from 'node:child_process'
+import {existsSync,readFileSync} from 'node:fs'
+import {dirname,join,resolve} from 'node:path'
+import {homedir} from 'node:os'
+import {fileURLToPath} from 'node:url'
+import {promisify} from 'node:util'
+import {z} from 'zod'
+import {HttpError} from './errors.js'
+import {isLoopbackHost,type ServerConfig} from './config.js'
+import type {UpstreamServiceSession} from './localAuth.js'
+import type {HermesBridgeStatus,HermesBridgeProfileStatus,HermesBridgeInstallResult} from '../shared/hermesBridge.js'
+
+const execFileAsync=promisify(execFile)
+const profileName=z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/)
+const installInput=z.object({profile:profileName,enable:z.boolean().default(false)}).strict()
+interface LocalProfile {profile:string;exists:boolean;valid:boolean;installedVersion?:string;enabled?:boolean;disabled?:boolean;fingerprint?:string;filesCurrent?:boolean;message?:string}
+interface Options {home?:string;python?:string;assetsRoot?:string;isIdle?:()=>boolean;run?:(python:string,args:string[])=>Promise<string>}
+
+function assetRoot(){
+  const base=dirname(fileURLToPath(import.meta.url))
+  return [base,resolve(base,'../runner'),resolve(base,'../../')].find(root=>existsSync(join(root,'install-hermes-bridge.py'))||existsSync(join(root,'scripts/install-hermes-bridge.py')))
+}
+
+/** Admin-only installer for the configured local Hermes. No client-supplied paths or commands. */
+export class HermesBridgeManager {
+  readonly home:string
+  readonly python:string
+  readonly script:string
+  readonly bundled:boolean
+  readonly bundledVersion:string
+  readonly local:boolean
+  private installing?:string
+  private readonly run:(python:string,args:string[])=>Promise<string>
+  constructor(readonly config:ServerConfig,readonly session:Pick<UpstreamServiceSession,'request'>,readonly options:Options={}){
+    this.home=resolve(options.home??process.env.HERMES_HOME??join(homedir(),'.hermes'))
+    this.python=options.python??join(this.home,'hermes-agent','venv','bin','python')
+    const root=options.assetsRoot??assetRoot()??''
+    this.script=root?(existsSync(join(root,'install-hermes-bridge.py'))?join(root,'install-hermes-bridge.py'):join(root,'scripts/install-hermes-bridge.py')):''
+    this.bundled=!!root&&existsSync(this.script)&&(existsSync(join(root,'hermes-bots-bridge/plugin.yaml'))||existsSync(join(root,'integrations/hermes-bots-bridge/plugin.yaml')))
+    const manifest=root&&[join(root,'hermes-bots-bridge/plugin.yaml'),join(root,'integrations/hermes-bots-bridge/plugin.yaml')].find(existsSync)
+    this.bundledVersion=manifest?readFileSync(manifest,'utf8').match(/^version:\s*["']?([^\s"']+)/m)?.[1]??'未知':'不可用'
+    this.local=isLoopbackHost(config.upstream.hostname)&&config.localVmHost!=='runner'
+    this.run=options.run??(async(python,args)=>(await execFileAsync(python,args,{timeout:20000,maxBuffer:512*1024,env:{PATH:process.env.PATH,HOME:homedir(),PYTHONNOUSERSITE:'1',PYTHONDONTWRITEBYTECODE:'1'}})).stdout)
+  }
+  get idleForUpdate(){return !this.installing}
+  private get available(){return this.local&&this.bundled&&existsSync(this.python)&&existsSync(join(this.home,'config.yaml'))}
+  private async disk():Promise<LocalProfile[]>{
+    if(!this.available)return []
+    try{
+      const output=await this.run(this.python,[this.script,'--hermes-home',this.home,'--check'])
+      return output.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(item=>profileName.safeParse(item.profile).success).slice(0,64)
+    }catch{throw new HttpError(409,'无法检查本机工具桥,请确认 Hermes 的 Python 环境和 Profile 配置可用。','hermes_bridge_check_failed')}
+  }
+  async status():Promise<HermesBridgeStatus>{
+    const [disk,remote]=await Promise.allSettled([this.disk(),this.session.request('/api/profiles',{cache:'reload'})])
+    const localProfiles=disk.status==='fulfilled'?disk.value:[]
+    const connected=remote.status==='fulfilled'&&remote.value.status===200
+    const hostEntry=localProfiles.find(item=>item.profile==='default')
+    const hostEntryInstalled=hostEntry?.filesCurrent===true&&hostEntry.enabled===true&&!hostEntry.disabled
+    const names=new Set(localProfiles.map(item=>item.profile))
+    if(remote.status==='fulfilled'&&remote.value.status===200){
+      try{for(const item of JSON.parse(remote.value.body.toString()).profiles??[]){const name=typeof item==='string'?item:item.name;if(profileName.safeParse(name).success&&names.size<64)names.add(name)}}catch{}
+    }
+    if(!names.size)names.add('default')
+    const profiles:HermesBridgeProfileStatus[]=[]
+    const ordered=[...names].sort((a,b)=>a==='default'?-1:b==='default'?1:a.localeCompare(b))
+    for(let i=0;i<ordered.length;i+=4)profiles.push(...await Promise.all(ordered.slice(i,i+4).map(async profile=>{
+      const local=localProfiles.find(item=>item.profile===profile)
+      let live:any,liveStatus=0
+      if(connected)try{const response=await this.session.request('/api/plugins/yaoyao-bot-bridge/capabilities',{search:new URLSearchParams({profile}),cache:'reload'});liveStatus=response.status;if(response.status===200)live=JSON.parse(response.body.toString())}catch{}
+      const result:HermesBridgeProfileStatus={profile,state:'unavailable',message:'无法连接 Hermes 或读取工具桥状态',canInstall:this.available&&local?.valid===true&&!this.installing&&(this.options.isIdle?.()??true),
+        ...(local?.installedVersion?{installedVersion:local.installedVersion}:{}),...(typeof live?.plugin_version==='string'?{loadedVersion:live.plugin_version}:{})}
+      const ready=live?.ready===true&&live?.native_tools===true&&live?.in_process===true&&live?.computer_runtime_version===2
+      if(local){
+        if(!local.valid){result.message=local.message??'此 Profile 的配置不可用';return result}
+        if(!local.installedVersion){result.state='missing';result.message='尚未安装工具桥插件'}
+        else if(local.disabled||!local.enabled){result.state='disabled';result.message='工具桥插件已安装,但尚未启用'}
+        else if(!local.filesCurrent){result.state='outdated';result.message='本机插件与随附版本不同,需要更新或修复'}
+        else if(!connected||liveStatus===401||liveStatus===403){result.state='unavailable';result.message='插件文件已安装,但无法验证 Hermes 加载状态;请先检查上方连接和权限'}
+        else if(ready&&live?.plugin_fingerprint===local.fingerprint){result.state='ready';result.message='Hermes 已加载当前工具桥,可以使用'}
+        else if(live?.plugin_fingerprint===local.fingerprint&&!ready){result.state='disabled';result.message=typeof live.reason==='string'?live.reason.slice(0,300):'Hermes 尚未启用此 Profile 的工具桥,请检查工具集设置'}
+        else {result.state='restart-required';result.message='插件文件已就位;请在空闲时重启 Hermes 后重新检查'}
+      }else if(ready){result.state='ready';result.message='Hermes 工具桥已就绪'}
+      else if(live?.computer_runtime_version&&live.computer_runtime_version!==2){result.state='outdated';result.message='Hermes 已加载的工具桥版本需要更新'}
+      else if(typeof live?.reason==='string')result.message=live.reason.slice(0,300)
+      if(this.local&&profile!=='default'&&!hostEntryInstalled){result.canInstall=false;result.message+=';请先安装或更新默认 Profile 的工具桥入口'}
+      return result
+    })))
+    return {endpoint:this.config.upstream.origin,local:this.local,bundledVersion:this.bundledVersion,checkedAt:Date.now(),profiles,...(this.installing?{installing:this.installing}:{}),
+      ...(!this.local?{message:'当前 Hermes 由其他节点提供,请在 Hermes 所在节点安装或修复工具桥。'}:
+        !this.bundled?{message:'当前服务未包含工具桥安装包,请更新 Yaoyao。'}:
+        !this.available?{message:'当前服务环境未找到本机 Hermes 安装目录和 Python,无法直接安装。'}:
+        disk.status==='rejected'?{message:'本机插件检查失败,请核对 Hermes 环境。'}:
+        this.options.isIdle&&!this.options.isIdle()?{message:'当前仍有任务运行,请结束任务后安装。'}:{})}
+  }
+  async install(value:unknown):Promise<HermesBridgeInstallResult>{
+    const parsed=installInput.safeParse(value)
+    if(!parsed.success)throw new HttpError(400,'请选择有效的 Profile','invalid_bridge_install_request')
+    if(!this.available)throw new HttpError(409,'只能为当前服务可管理的本机 Hermes 安装工具桥。','hermes_bridge_install_unavailable')
+    if(this.installing)throw new HttpError(409,'工具桥正在安装,请等待完成。','hermes_bridge_install_busy')
+    if(this.options.isIdle&&!this.options.isIdle())throw new HttpError(409,'当前仍有任务运行,请结束任务后安装。','hermes_bridge_tasks_running')
+    this.installing=parsed.data.profile
+    let result:any
+    try{
+      const disk=await this.disk(),local=disk.find(item=>item.profile===parsed.data.profile)
+      if(!local?.valid)throw new HttpError(404,'本机没有此 Profile 的有效配置。','hermes_bridge_profile_missing')
+      const hostEntry=disk.find(item=>item.profile==='default')
+      if(parsed.data.profile!=='default'&&(!hostEntry?.filesCurrent||!hostEntry.enabled||hostEntry.disabled))
+        throw new HttpError(409,'请先安装或更新默认 Profile 的工具桥入口,再安装这个 Profile。','hermes_bridge_host_entry_required')
+      const output=await this.run(this.python,[this.script,'--hermes-home',this.home,'--profile',parsed.data.profile,...(parsed.data.enable?['--enable']:[])])
+      result=JSON.parse(output.trim())
+      if(result.profile!==parsed.data.profile||typeof result.backup!=='string')throw new Error('invalid installer result')
+    }catch(error){
+      if(error instanceof HttpError)throw error
+      throw new HttpError(409,'工具桥安装失败,请检查目录权限后重新检查状态。','hermes_bridge_install_failed')
+    }finally{this.installing=undefined}
+    return {profile:parsed.data.profile,backup:result.backup,message:result.disabled?'插件文件已更新,保留了禁用设置。':'工具桥已安装并启用。请在空闲时重启 Hermes,然后重新检查。',status:await this.status()}
+  }
+}

+ 1 - 1
src/server/index.ts

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

+ 9 - 0
src/server/routes.ts

@@ -54,6 +54,7 @@ import { chatCacheKey, type ChatCacheCoordinator } from './chatCache.js'
 type JsonObject = Record<string, unknown>
 
 export interface RouteDependencies {
+  hermesBridge: import('./hermesBridge.js').HermesBridgeManager
   onChatListChanged?: (owner:string,profile:string,id:string)=>void
   onServerIdentityChanged?: (identity: ServerIdentity) => void
   onUserAccessChanged?: (owner: string) => Promise<void>
@@ -1770,6 +1771,14 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       webNetworkScope: isLoopbackHost(dependencies.config.host) ? 'local' : 'network',
     })
   })
+  router.get('/api/app/admin/hermes-bridge', async ctx => {
+    dependencies.auth.requireAdmin(ctx)
+    json(ctx,200,await dependencies.hermesBridge.status())
+  })
+  router.post('/api/app/admin/hermes-bridge/install', async ctx => {
+    dependencies.auth.requireAdmin(ctx)
+    json(ctx,200,await dependencies.hermesBridge.install(body(ctx)))
+  })
   router.get('/api/app/admin/model-services', async (ctx) => {
     await proxyAdminFeature(ctx, dependencies, '/api/providers/custom-endpoints', {
       search: searchFrom(ctx, ['profile']),

+ 24 - 0
src/shared/hermesBridge.ts

@@ -0,0 +1,24 @@
+export type HermesBridgeState = 'ready' | 'missing' | 'outdated' | 'restart-required' | 'disabled' | 'unavailable'
+export interface HermesBridgeProfileStatus {
+  profile: string
+  state: HermesBridgeState
+  message: string
+  installedVersion?: string
+  loadedVersion?: string
+  canInstall: boolean
+}
+export interface HermesBridgeStatus {
+  endpoint: string
+  local: boolean
+  bundledVersion: string
+  checkedAt: number
+  installing?: string
+  message?: string
+  profiles: HermesBridgeProfileStatus[]
+}
+export interface HermesBridgeInstallResult {
+  profile: string
+  backup: string
+  message: string
+  status: HermesBridgeStatus
+}

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

@@ -27,7 +27,7 @@ describe('allowed hosts settings panel', () => {
     setApiCsrfToken('csrf-panel')
     const wrapper = mount(SystemManagementPanel, {
       props: { section: 'connection', active: true },
-      global: { stubs: { AppIcon: true } },
+      global: { stubs: { AppIcon: true, HermesBridgePanel: true } },
     })
     await flushPromises()
 

+ 49 - 0
tests/client/hermesBridgePanel.test.ts

@@ -0,0 +1,49 @@
+import {flushPromises,mount} from '@vue/test-utils'
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import HermesBridgePanel from '@/components/app/HermesBridgePanel.vue'
+import {getHermesBridgeStatus,installHermesBridge} from '@/api/hermesBridge'
+import type {HermesBridgeStatus} from '@shared/hermesBridge'
+vi.mock('@/api/hermesBridge',()=>({getHermesBridgeStatus:vi.fn(),installHermesBridge:vi.fn()}))
+const snapshot=():HermesBridgeStatus=>({endpoint:'http://127.0.0.1:9119',local:true,bundledVersion:'1.2.0',checkedAt:1,profiles:[
+  {profile:'default',state:'outdated',message:'需要更新',canInstall:true,installedVersion:'1.1.0'},
+  {profile:'writer',state:'disabled',message:'尚未启用',canInstall:true,installedVersion:'1.2.0'},
+]})
+beforeEach(()=>{vi.mocked(getHermesBridgeStatus).mockResolvedValue(snapshot())})
+afterEach(()=>{vi.clearAllMocks()})
+it('shows each Profile and installs only the selected one, with visible progress and restart instructions',async()=>{
+  let finish!:(value:any)=>void
+  vi.mocked(installHermesBridge).mockReturnValue(new Promise(done=>{finish=done}))
+  const wrapper=mount(HermesBridgePanel)
+  await flushPromises()
+  expect(wrapper.text()).toContain('default');expect(wrapper.text()).toContain('writer')
+  await wrapper.get('button[aria-label="安装并启用:writer"]').trigger('click')
+  expect(installHermesBridge).toHaveBeenCalledWith('writer',true)
+  expect(wrapper.text()).toContain('安装中…')
+  expect(wrapper.findAll('button').every(button=>button.attributes('disabled')!==undefined)).toBe(true)
+  const state=snapshot();state.profiles[1]!.state='restart-required'
+  finish({profile:'writer',backup:'/backups/writer',message:'安装完成,请重启 Hermes',status:state})
+  await flushPromises()
+  expect(wrapper.text()).toContain('待重启');expect(wrapper.get('[role="status"]').text()).toContain('重启 Hermes')
+  expect(wrapper.text()).toContain('/backups/writer')
+  await wrapper.findAll('button').find(b=>b.text()==='重新检查')!.trigger('click');await flushPromises()
+  expect(getHermesBridgeStatus).toHaveBeenCalledTimes(2)
+  wrapper.unmount()
+})
+it('provides retry feedback and keeps remote installation unavailable',async()=>{
+  vi.mocked(getHermesBridgeStatus).mockRejectedValueOnce(new Error('连接失败'))
+  const wrapper=mount(HermesBridgePanel);await flushPromises()
+  expect(wrapper.get('[role="alert"]').text()).toBe('连接失败')
+  const state=snapshot();state.local=false;state.profiles=state.profiles.map(p=>({...p,canInstall:false}));state.message='请在 Hermes 所在节点安装'
+  vi.mocked(getHermesBridgeStatus).mockResolvedValue(state)
+  await wrapper.get('button').trigger('click');await flushPromises()
+  expect(wrapper.text()).toContain('所在节点安装');expect(wrapper.findAll('.install')).toHaveLength(0)
+  wrapper.unmount()
+})
+it('shows installation failures without claiming that Hermes has loaded the plugin',async()=>{
+  vi.mocked(installHermesBridge).mockRejectedValue(new Error('没有写入权限'))
+  const wrapper=mount(HermesBridgePanel);await flushPromises()
+  await wrapper.get('button[aria-label="更新工具桥:default"]').trigger('click');await flushPromises()
+  expect(wrapper.get('[role="alert"]').text()).toBe('没有写入权限')
+  expect(wrapper.text()).not.toContain('已就绪');expect(wrapper.text()).not.toContain('安装中…')
+  wrapper.unmount()
+})

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

@@ -49,7 +49,7 @@ async function mountOpen(): Promise<VueWrapper> {
   const wrapper = mount(SystemManagementDialog, {
     attachTo: document.body,
     props: { open: false },
-    global: { stubs: { AppIcon: true } },
+    global: { stubs: { AppIcon: true, HermesBridgePanel: true } },
   })
   await wrapper.setProps({ open: true })
   await flushPromises()

+ 54 - 0
tests/e2e/hermes-bridge.spec.ts

@@ -0,0 +1,54 @@
+import {test,expect} from '@playwright/test'
+import {mkdirSync} from 'node:fs'
+const evidence='docs/verification/2026-09-15-hermes-bridge'
+
+test('checks and installs the selected Profile bridge and displays its actual activation state',async({page})=>{
+  mkdirSync(evidence,{recursive:true})
+  let phase=0,finishInstall!:(value:void)=>void
+  const installations:unknown[]=[]
+  const status=()=>({endpoint:'http://127.0.0.1:9119',local:true,bundledVersion:'1.2.0',checkedAt:Date.now(),profiles:[
+    {profile:'default',state:'ready',message:'Hermes 已加载当前工具桥,可以使用',installedVersion:'1.2.0',loadedVersion:'1.2.0',canInstall:true},
+    {profile:'server',state:phase===0?'missing':phase===1?'restart-required':'ready',message:phase===0?'尚未安装工具桥插件':phase===1?'插件文件已就位;请在空闲时重启 Hermes 后重新检查':'Hermes 已加载当前工具桥,可以使用',installedVersion:phase?'1.2.0':undefined,loadedVersion:phase===2?'1.2.0':undefined,canInstall:true},
+  ]})
+  await page.route('**/api/app/admin/hermes-bridge',route=>route.fulfill({json:status()}))
+  await page.route('**/api/app/admin/hermes-bridge/install',async route=>{
+    installations.push(route.request().postDataJSON())
+    await new Promise<void>(done=>{finishInstall=done})
+    phase=1
+    await route.fulfill({json:{profile:'server',backup:'/Users/fixture/.hermes/profiles/server/backups/yaoyao-bridge-test',message:'工具桥已安装并启用。请在空闲时重启 Hermes,然后重新检查。',status:status()}})
+  })
+  await page.goto('/conversations')
+  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
+  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
+  await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
+  await page.getByRole('menuitem',{name:'设置',exact:true}).click()
+  await page.getByRole('button',{name:'Hermes 连接',exact:true}).click()
+  const panel=page.getByRole('region',{name:'工具桥插件',exact:true})
+  await expect(panel.getByText('未安装',{exact:true})).toBeVisible()
+  await panel.scrollIntoViewIfNeeded()
+  await page.screenshot({path:evidence+'/desktop-before.png'})
+  await panel.getByRole('button',{name:/安装并启用:/}).click()
+  await expect(panel.getByRole('button',{name:/安装并启用:/})).toBeDisabled()
+  await expect(panel.getByText('安装中…',{exact:true})).toBeVisible()
+  finishInstall()
+  await expect(panel.getByText('待重启',{exact:true})).toBeVisible()
+  expect(installations).toEqual([{profile:'server',enable:true}])
+  await page.screenshot({path:evidence+'/desktop-installed.png'})
+  phase=2
+  await panel.getByRole('button',{name:'重新检查',exact:true}).click()
+  await expect(panel.getByText('已就绪',{exact:true})).toHaveCount(2)
+  await page.setViewportSize({width:375,height:812})
+  await panel.scrollIntoViewIfNeeded()
+  await page.screenshot({path:evidence+'/mobile-ready.png'})
+  expect(await panel.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
+  await page.emulateMedia({reducedMotion:'reduce'})
+  await page.setViewportSize({width:812,height:375})
+  await panel.scrollIntoViewIfNeeded()
+  expect(await panel.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
+  await page.screenshot({path:evidence+'/landscape-ready.png'})
+  await page.setViewportSize({width:1440,height:1000})
+  await page.evaluate(()=>document.documentElement.classList.add('dark'))
+  await panel.scrollIntoViewIfNeeded()
+  await page.screenshot({path:evidence+'/dark-ready.png'})
+})

+ 14 - 0
tests/live/profileComputer.live.test.ts

@@ -9,6 +9,7 @@ import {tmpdir} from 'node:os'
 import {join,resolve} from 'node:path'
 import {randomUUID} from 'node:crypto'
 import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
+import {HermesBridgeManager} from '../../src/server/hermesBridge'
 import {UpstreamClient} from '../../src/server/upstream'
 import {UpstreamServiceSession} from '../../src/server/localAuth'
 
@@ -77,6 +78,19 @@ it.skipIf(!available).each(['profile','isolated'] as const)('runs a real Hermes
   expect(JSON.stringify(results)).toContain('authorized-profile-file')
   expect(await readFile(join(workspace,'result.txt'),'utf8')).toBe('authorized-profile-filevm-profile-proof')
   expect(await runtime.extractMemory('default','只保留给定事实:测试已完成')).toBe('fixture')
+  if(mode==='profile'){
+    const assets=join(home,'install-package');await mkdir(assets)
+    await cp(resolve('integrations/hermes-bots-bridge'),join(assets,'hermes-bots-bridge'),{recursive:true})
+    await cp(resolve('scripts/install-hermes-bridge.py'),join(assets,'install-hermes-bridge.py'))
+    const manager=new HermesBridgeManager({upstream:url} as any,target.session,{home,python,assetsRoot:assets})
+    expect((await manager.status()).profiles.find(p=>p.profile==='default')?.state).toBe('ready')
+    const bundled=join(assets,'hermes-bots-bridge','bridge_runtime.py')
+    await writeFile(bundled,(await readFile(bundled,'utf8'))+'\n# Updated fixture package\n')
+    expect((await manager.status()).profiles.find(p=>p.profile==='default')?.state).toBe('outdated')
+    const installed=await manager.install({profile:'default'})
+    expect(installed.status.profiles.find(p=>p.profile==='default')?.state).toBe('restart-required')
+    expect(installed.backup).toContain(home)
+  }
   if(mode==='isolated')expect(approvals.size).toBeGreaterThanOrEqual(3)
   expect(runtime.workers.size).toBe(0)
  }finally{

+ 89 - 0
tests/server/hermesBridge.test.ts

@@ -0,0 +1,89 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {mkdtempSync,writeFileSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import request from 'supertest'
+import {HermesBridgeManager} from '../../src/server/hermesBridge'
+import type {ServerConfig} from '../../src/server/config'
+import {createAuthenticatedApplication,createUserAuthenticatedApplication} from './authenticatedApplication'
+
+let home:string,config:ServerConfig
+beforeEach(()=>{
+  home=mkdtempSync(join(tmpdir(),'yaoyao-bridge-manager-'));writeFileSync(join(home,'config.yaml'),'{}')
+  config={host:'127.0.0.1',port:15300,upstream:new URL('http://127.0.0.1:9119'),home,allowedHosts:new Set(),mediaRoot:home,attachmentsRoot:home,imagesRoot:home,mediaOwner:'fixture',allowInsecureLan:false,insecureLan:false,production:false}
+})
+afterEach(()=>{rmSync(home,{recursive:true,force:true});vi.restoreAllMocks()})
+function fixture(){
+  let disk:any[]=[{profile:'default',exists:true,valid:true,enabled:true,installedVersion:'1.1.0',fingerprint:'old',filesCurrent:false},{profile:'writer',exists:true,valid:true,enabled:false,disabled:true,installedVersion:'1.2.0',fingerprint:'new',filesCurrent:true}]
+  let live:any={ready:true,native_tools:true,in_process:true,computer_runtime_version:2,plugin_version:'1.1.0',plugin_fingerprint:'old'}
+  let idle=true,gate:Promise<void>|undefined
+  const run=vi.fn(async(_python:string,args:string[])=>{
+    if(args.includes('--check'))return disk.map(row=>JSON.stringify(row)).join('\n')
+    await gate
+    const profile=args[args.indexOf('--profile')+1]
+    disk=disk.map(row=>row.profile===profile?{...row,installedVersion:'1.2.0',fingerprint:'new',filesCurrent:true,enabled:true,disabled:false}:row)
+    return JSON.stringify({profile,backup:join(home,'backups',profile),disabled:false})
+  })
+  const session={request:vi.fn(async(path:string)=>({status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'},{name:'writer'}]}:live))}))}
+  const manager=new HermesBridgeManager(config,session,{home,python:process.execPath,assetsRoot:resolve('.'),run,isIdle:()=>idle})
+  return {manager,run,session,setLive:(value:any)=>{live=value},ready:()=>{live={...live,plugin_fingerprint:'new',plugin_version:'1.2.0'}},busy:()=>{idle=false},wait:(value:Promise<void>)=>{gate=value}}
+}
+it('distinguishes stale files, disabled Profile, pending restart and the actually loaded plugin',async()=>{
+  const f=fixture()
+  const before=await f.manager.status();expect(before.profiles.map(p=>p.state)).toEqual(['outdated','disabled'])
+  expect(before.profiles[1].canInstall).toBe(false)
+  await expect(f.manager.install({profile:'writer',enable:true})).rejects.toMatchObject({code:'hermes_bridge_host_entry_required'})
+  await f.manager.install({profile:'default'})
+  const installed=await f.manager.install({profile:'writer',enable:true})
+  expect(f.run.mock.calls.at(-2)?.[1]).toContain('--enable')
+  expect(installed.status.profiles.find(p=>p.profile==='writer')?.state).toBe('restart-required')
+  expect(installed.status.profiles.find(p=>p.profile==='default')?.state).toBe('restart-required')
+  f.ready();expect((await f.manager.status()).profiles.find(p=>p.profile==='writer')?.state).toBe('ready')
+  expect(installed.backup).toBe(join(home,'backups','writer'))
+})
+it('prevents concurrent installs and rejects path injection, unknown profiles and installation during work',async()=>{
+  const f=fixture();let release!:()=>void
+  f.wait(new Promise<void>(done=>{release=done}))
+  const pending=f.manager.install({profile:'default'})
+  await vi.waitFor(()=>expect(f.run.mock.calls.some(([,args])=>!args.includes('--check'))).toBe(true))
+  expect(f.manager.idleForUpdate).toBe(false)
+  await expect(f.manager.install({profile:'writer'})).rejects.toMatchObject({code:'hermes_bridge_install_busy'})
+  release();await pending
+  expect(f.manager.idleForUpdate).toBe(true)
+  await expect(f.manager.install({profile:'../other'})).rejects.toMatchObject({code:'invalid_bridge_install_request'})
+  await expect(f.manager.install({profile:'default',home:'/wrong'})).rejects.toMatchObject({code:'invalid_bridge_install_request'})
+  await expect(f.manager.install({profile:'missing'})).rejects.toMatchObject({code:'hermes_bridge_profile_missing'})
+  f.busy();await expect(f.manager.install({profile:'default'})).rejects.toMatchObject({code:'hermes_bridge_tasks_running'})
+})
+it('checks remote Hermes but never invokes the local installer for it',async()=>{
+  const f=fixture(),run=vi.fn()
+  const manager=new HermesBridgeManager({...config,upstream:new URL('https://hermes.example')},f.session,{home,python:process.execPath,assetsRoot:resolve('.'),run})
+  const status=await manager.status();expect(status.local).toBe(false);expect(status.profiles.every(p=>!p.canInstall)).toBe(true)
+  await expect(manager.install({profile:'default'})).rejects.toMatchObject({code:'hermes_bridge_install_unavailable'})
+  expect(run).not.toHaveBeenCalled()
+})
+it('exposes only admin/CSRF-protected management endpoints and returns the verified status after install',async()=>{
+  const f=fixture(),admin=createAuthenticatedApplication({config,hermesBridge:f.manager})
+  try{
+    const api=request.agent(admin.app.callback()),host='127.0.0.1:15300'
+    await api.get('/api/app/admin/hermes-bridge').set('Host',host).expect(200)
+    await api.post('/api/app/admin/hermes-bridge/install').set('Host',host).send({profile:'default'}).expect(403)
+    const csrf=(await api.get('/api/app/bootstrap?csrfOnly=1').set('Host',host).expect(200)).body.csrfToken
+    const response=await api.post('/api/app/admin/hermes-bridge/install').set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({profile:'default'}).expect(200)
+    expect(response.body.status.profiles[0].state).toBe('restart-required')
+  }finally{admin.close()}
+  const user=createUserAuthenticatedApplication({config,hermesBridge:f.manager})
+  try{await request(user.app.callback()).get('/api/app/admin/hermes-bridge').set('Host','127.0.0.1:15300').expect(403)}finally{user.close()}
+})
+
+it('does not report an offline Hermes as loaded or expose installer diagnostic secrets',async()=>{
+  const f=fixture()
+  await f.manager.install({profile:'default'})
+  f.session.request.mockRejectedValue(new Error('offline'))
+  const state=await f.manager.status()
+  expect(state.profiles[0].state).toBe('unavailable')
+  expect(state.profiles[0].message).toContain('连接和权限')
+  f.run.mockRejectedValue(new Error('sensitive diagnostic must stay private'))
+  await expect(f.manager.install({profile:'default'})).rejects.not.toThrow('sensitive diagnostic')
+})

+ 3 - 1
tests/server/workspace.test.ts

@@ -187,7 +187,9 @@ describe('Web-owned workspace', () => {
     configuredCwds.set('default', '/profile/changed')
     const before = requests.length
     await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: 'second' }).id)
-    const next = requests.slice(before)
+    // The prior turn's asynchronous usage refresh can arrive after completion.
+    // This assertion concerns the next turn's execution RPCs, not telemetry.
+    const next = requests.slice(before).filter(r => r.method !== 'session.usage')
     expect(next[0].method).toBe('session.resume')
     expect(next.find(r => r.method === 'session.cwd.set')?.params.cwd).toBe('/profile/changed')
     expect(next.findIndex(r => r.method === 'session.cwd.set')).toBeLessThan(next.findIndex(r => r.method === 'prompt.submit'))