Sfoglia il codice sorgente

修复命名 Profile 模型串用与工具桥初始化等待

Samien 1 mese fa
parent
commit
a6a8eae

+ 26 - 0
docs/verification/2026-09-19-profile-routing/README.md

@@ -0,0 +1,26 @@
+# Profile 模型与工具桥验证
+
+2026-09-19,使用 Hermes `e6f5ce2` 的临时源码副本、临时 Profile 目录和本地确定性模型接口。实际 Hermes 安装、模型密钥、记忆和聊天数据均未修改,也没有调用外部付费模型。
+
+| 场景 | yaoer 配置 | 创建/恢复返回模型 | 实际请求模型 |
+| --- | --- | --- | --- |
+| 原版,无启动模型参数 | grok-4.6 | fixture-default(错误读取默认 Profile) | grok-4.6 |
+| 原版,启动参数为 gpt-5.6-terra | grok-4.6 | gpt-5.6-terra | gpt-5.6-terra(覆盖配置) |
+| 修复后,启动参数为 gpt-5.6-terra | grok-4.6 | grok-4.6 | grok-4.6 |
+
+默认 Profile 另行配置 `fixture-default`,修复后仍使用自己的模型。工具桥绑定和聊天完成事件均成功,命名 Profile 的绑定耗时约 0.2 秒。这证明了复现环境中的模型路由修复;生产服务器的 30 秒超时尚未在本地复现,不能据此宣称线上聊天已经恢复。
+
+复现命令(从 Yaoyao 仓库执行):
+
+```sh
+~/.hermes/hermes-agent/venv/bin/python docs/verification/2026-09-19-profile-routing/verify.py --baseline --no-env-seed
+~/.hermes/hermes-agent/venv/bin/python docs/verification/2026-09-19-profile-routing/verify.py --baseline
+~/.hermes/hermes-agent/venv/bin/python docs/verification/2026-09-19-profile-routing/verify.py
+~/.hermes/hermes-agent/venv/bin/python docs/verification/2026-09-19-profile-routing/verify.py --no-env-seed
+```
+
+可用 `--source` 指定其他 Hermes 源码安装。脚本会保留临时验证目录并打印位置,子进程在退出时结束。原版模式预期断言旧行为,修复模式预期断言 Profile 配置得到使用。
+
+自动回归覆盖模型和 Provider 优先级、空配置回退、Profile 上下文恢复、源码修复幂等性和不兼容拒绝、安装备份及失败回滚、延迟会话初始化、绑定锁争用、工具目录回调超时和错误信息脱敏。TypeScript 测试还覆盖管理员安装入口及错误到聊天状态的转换。
+
+安装与回退步骤见[工具桥文档](../../../integrations/hermes-bots-bridge/README.md)。安装器需要在 Hermes 所在服务器执行,并重启 Hermes Dashboard 后生效;客户端无需安装 Hermes。

+ 193 - 0
docs/verification/2026-09-19-profile-routing/verify.py

@@ -0,0 +1,193 @@
+"""Verify Profile routing against real Hermes with a local fake model endpoint.
+
+Run with the Hermes virtualenv Python; no production config or source is changed.
+Use --baseline to reproduce the unpatched metadata/environment override defects.
+"""
+import argparse
+import asyncio
+import importlib.util
+import json
+import os
+import shutil
+import socket
+import subprocess
+import tempfile
+import threading
+import time
+import uuid
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+import requests
+import websockets
+
+REPO = Path(__file__).resolve().parents[3]
+PLUGIN = REPO / 'integrations/hermes-bots-bridge'
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--source', type=Path, default=Path.home() / '.hermes/hermes-agent')
+    parser.add_argument('--baseline', action='store_true')
+    parser.add_argument('--no-env-seed', action='store_true', help='Isolate the default-Profile metadata leak')
+    args = parser.parse_args()
+    source = args.source.resolve()
+    root = Path(tempfile.mkdtemp(prefix='yaoyao-profile-routing-'))
+    overlay = root / 'source'; overlay.mkdir()
+    # Copy Python packages: native bootstrap modules resolve their own path and
+    # prepend that root. Symlinking them would silently load the installed code.
+    for item in source.iterdir():
+        if item.is_dir() and (item / '__init__.py').is_file():
+            shutil.copytree(item, overlay / item.name, ignore=shutil.ignore_patterns('__pycache__'))
+        elif item.is_file() and item.suffix == '.py':
+            shutil.copy2(item, overlay / item.name)
+        else:
+            (overlay / item.name).symlink_to(item, target_is_directory=item.is_dir())
+    home = root / 'home'
+    seen_models = []
+
+    class Handler(BaseHTTPRequestHandler):
+        def log_message(self, *args):
+            pass
+
+        def do_GET(self):
+            self.send_response(200); self.end_headers()
+            self.wfile.write(json.dumps({'tools': [{'id': 'fixture_ping', 'name': 'fixture_ping',
+                'description': 'Return fixture proof', 'inputSchema': {'type': 'object', 'properties': {}}}]}
+                if self.path == '/tools/list' else {'data': [{'id': 'fixture-default'}]}).encode())
+
+        def do_POST(self):
+            body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))))
+            if self.path == '/tools/list':
+                return self.do_GET()
+            seen_models.append(body.get('model'))
+            message = {'role': 'assistant', 'content': 'fixture reply'}
+            self.send_response(200)
+            if body.get('stream'):
+                self.send_header('Content-Type', 'text/event-stream'); self.end_headers()
+                chunk = {'id': 'fixture', 'object': 'chat.completion.chunk',
+                         'choices': [{'index': 0, 'delta': message, 'finish_reason': 'stop'}]}
+                self.wfile.write(('data: ' + json.dumps(chunk) + '\n\ndata: [DONE]\n\n').encode())
+            else:
+                self.send_header('Content-Type', 'application/json'); self.end_headers()
+                self.wfile.write(json.dumps({'id': 'fixture', 'choices': [{'index': 0,
+                    'message': message, 'finish_reason': 'stop'}],
+                    'usage': {'prompt_tokens': 100, 'completion_tokens': 5, 'total_tokens': 105}}).encode())
+
+    http = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+    threading.Thread(target=http.serve_forever, daemon=True).start()
+    model_url = f'http://127.0.0.1:{http.server_port}'
+    spec = importlib.util.spec_from_file_location('installer', REPO / 'scripts/install-hermes-bridge.py')
+    installer = importlib.util.module_from_spec(spec); spec.loader.exec_module(installer)
+    for profile, model in [('default', 'fixture-default'), ('yaoer', 'grok-4.6')]:
+        selected = home if profile == 'default' else home / 'profiles' / profile
+        (selected / 'workspace').mkdir(parents=True)
+        config = {'model': {'default': model, 'provider': 'custom', 'base_url': model_url + '/v1',
+                  'api_key': 'fixture', 'context_length': 65536},
+                  'terminal': {'cwd': str(selected / 'workspace')},
+                  'plugins': {'enabled': ['yaoyao-bot-bridge']}, 'toolsets': ['yaoyao_bot_bridge'],
+                  'tools': {'tool_search': {'enabled': 'off'}}, 'dashboard': {'turn_isolation': False},
+                  'compression': {'enabled': False},
+                  'memory': {'memory_enabled': False, 'user_profile_enabled': False}}
+        (selected / 'config.yaml').write_text(json.dumps(config))
+        (selected / '.env').write_text('')
+        installer.install(home, profile, PLUGIN, repair_source=None if args.baseline else overlay)
+
+    with socket.socket() as sock:
+        sock.bind(('127.0.0.1', 0)); port = sock.getsockname()[1]
+    env = {'PATH': os.environ['PATH'], 'HOME': str(root / 'os-home'), 'LANG': 'en_US.UTF-8',
+           'HERMES_HOME': str(home), 'PYTHONPATH': str(overlay), 'HERMES_MODEL': 'gpt-5.6-terra',
+           'HERMES_DASHBOARD_SESSION_TOKEN': 'fixture-profile-token-123456789',
+           'NO_PROXY': '127.0.0.1,localhost', 'PYTHONDONTWRITEBYTECODE': '1'}
+    if args.no_env_seed:
+        env.pop('HERMES_MODEL')
+    log = (root / 'hermes.log').open('w')
+    child = subprocess.Popen([str(source / 'venv/bin/python'), '-m', 'hermes_cli.main', 'serve',
+                              '--host', '127.0.0.1', '--port', str(port), '--skip-build', '--isolated'],
+                             env=env, cwd=root, stdout=log, stderr=log)
+    base = f'http://127.0.0.1:{port}'
+    client = requests.Session(); client.trust_env = False
+    client.headers['X-Hermes-Session-Token'] = env['HERMES_DASHBOARD_SESSION_TOKEN']
+
+    def request(path, body=None):
+        response = client.get(base + path, timeout=30) if body is None else client.post(base + path, json=body, timeout=30)
+        response.raise_for_status()
+        return response.json()
+
+    async def rpc(ws, method, params):
+        key = str(uuid.uuid4())
+        await ws.send(json.dumps({'jsonrpc': '2.0', 'id': key, 'method': method, 'params': params}))
+        while True:
+            frame = json.loads(await asyncio.wait_for(ws.recv(), 30))
+            if frame.get('id') == key:
+                assert 'error' not in frame, frame
+                return frame['result']
+
+    async def verify():
+        results = []
+        for profile, expected in [('default', 'fixture-default'), ('yaoer', 'grok-4.6')]:
+            await asyncio.to_thread(request, '/api/config?profile=' + profile)
+            caps = await asyncio.to_thread(request, '/api/plugins/yaoyao-bot-bridge/capabilities?profile=' + profile)
+            assert caps['ready'], caps
+            async with websockets.connect(f'ws://127.0.0.1:{port}/api/ws?token=' + env['HERMES_DASHBOARD_SESSION_TOKEN'],
+                                          origin=base, proxy=None) as ws:
+                while 'gateway.ready' not in await asyncio.wait_for(ws.recv(), 30):
+                    pass
+                created = await rpc(ws, 'session.create', {'profile': profile, 'source': 'yaoyao_workspace', 'close_on_disconnect': False})
+                stored = created.get('stored_session_id') or created['session_key']
+                resumed = await rpc(ws, 'session.resume', {'profile': profile, 'session_id': stored,
+                                                           'omit_messages': True, 'close_on_disconnect': False})
+                sid = resumed['session_id']; generation = str(uuid.uuid4())
+                start = time.monotonic()
+                bound = await asyncio.to_thread(request, '/api/plugins/yaoyao-bot-bridge/bind', {
+                    'session_id': sid, 'stored_session_id': stored, 'profile': profile, 'generation': generation,
+                    'bridge_url': model_url, 'token': 'a' * 40, 'expires_at': time.time() * 1000 + 60000,
+                    'native_tools': True, 'workspace_memory': True})
+                assert bound['ok'], bound
+                elapsed = round(time.monotonic() - start, 3)
+                offset = len(seen_models)
+                await ws.send(json.dumps({'jsonrpc': '2.0', 'id': 'prompt', 'method': 'prompt.submit',
+                                          'params': {'session_id': sid, 'text': 'Hello'}}))
+                while True:
+                    frame = json.loads(await asyncio.wait_for(ws.recv(), 30))
+                    event = frame.get('params', {}).get('type')
+                    assert event != 'error' and 'error' not in frame, frame
+                    if event == 'message.complete':
+                        break
+                models = seen_models[offset:]
+                expected_model = 'gpt-5.6-terra' if args.baseline and not args.no_env_seed else expected
+                expected_info = ('fixture-default' if args.no_env_seed else 'gpt-5.6-terra') if args.baseline else expected
+                assert models and all(model == expected_model for model in models), models
+                assert created['info']['model'] == expected_info, created
+                assert resumed['info']['model'] == expected_info, resumed
+                results.append({'profile': profile, 'configured': expected, 'create': created['info']['model'],
+                                'resume': resumed['info']['model'], 'requests': models, 'bindSeconds': elapsed})
+                await asyncio.to_thread(request, '/api/plugins/yaoyao-bot-bridge/unbind', {'session_id': sid, 'generation': generation})
+        print(json.dumps({'baseline': args.baseline, 'environmentSeed': not args.no_env_seed, 'results': results}, ensure_ascii=False, indent=2), flush=True)
+
+    try:
+        deadline = time.monotonic() + 30
+        while time.monotonic() < deadline:
+            if child.poll() is not None:
+                raise RuntimeError('Hermes exited; inspect ' + str(root / 'hermes.log'))
+            try:
+                if client.get(base + '/api/status', timeout=1).status_code == 200:
+                    break
+            except requests.RequestException:
+                pass
+            time.sleep(.2)
+        else:
+            raise TimeoutError('Hermes startup exceeded 30 seconds')
+        asyncio.run(verify())
+    finally:
+        child.terminate()
+        try:
+            child.wait(timeout=5)
+        except subprocess.TimeoutExpired:
+            child.kill(); child.wait()
+        http.shutdown(); http.server_close(); log.close(); client.close()
+        print('Fixture logs: ' + str(root), flush=True)
+
+
+if __name__ == '__main__':
+    main()

+ 15 - 3
integrations/hermes-bots-bridge/README.md

@@ -1,6 +1,6 @@
 # 夭夭 Bot 的 Hermes 工具桥
 
-这是运行在 Hermes 内的用户插件,版本 1.2.4。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
+这是运行在 Hermes 内的用户插件,版本 1.2.5。它连接 Yaoyao 每轮授权的电脑、团队和应用工具。随附安装器还提供有备份的 Hermes Profile 模型兼容修复。
 
 ## Hermes 托管虚拟机会话
 
@@ -18,18 +18,26 @@ Yaoyao 的虚拟机模式和本机协作模式都使用真实 Hermes Profile 会
 
 ## 安装
 
-Yaoyao 管理员可从“设置 → Hermes 连接 → 工具桥插件”执行检查和安装。默认 Profile 提供共享后台入口,应先安装默认入口,再处理命名 Profile。界面依据磁盘与已加载代码的指纹区分待重启与已就绪。
+Yaoyao 管理员可从“设置 → Hermes 连接 → 工具桥插件”执行检查和安装。安装会同时备份并修复 Hermes 的 Profile 模型解析与会话元信息。默认 Profile 提供共享后台入口,应先安装默认入口,再处理命名 Profile。界面依据磁盘与已加载插件代码的指纹区分待重启与已就绪。
 
 
 使用 Hermes 的 Python,从 Yaoyao 仓库运行:
 
 ```sh
 ~/.hermes/hermes-agent/venv/bin/python scripts/install-hermes-bridge.py \
-  --hermes-home ~/.hermes --profile default --profile server
+  --hermes-home ~/.hermes --profile default --profile yaoer --repair-profile-runtime
 ```
 
 Runner 包内可直接运行同目录的 `install-hermes-bridge.py`。安装器备份原插件与配置,保留其他插件和显式禁用项,不重启服务。每个使用中的 Named Profile 都需安装;在空闲时重启 Hermes,再使用新版 Runner。
 
+`--repair-profile-runtime` 只在 Hermes 所在服务器执行,客户端不需要安装 Hermes。它定位所用 Python 实际导入的 Hermes,验证源码结构后修复 `tui_gateway/server.py` 和 `tui_gateway/methods_session.py`:
+
+- 创建、恢复会话的模型元信息在请求指定的 Profile 中读取。
+- 已配置的 Profile 模型及 Provider 优先于启动进程中的 `HERMES_MODEL`、`HERMES_INFERENCE_MODEL` 和 `HERMES_TUI_PROVIDER`;不会把 `yaoer` 配置的 `grok-4.6` 换成默认 Profile 或启动环境里的模型。
+- Hermes 的显式会话模型覆盖仍使用原生逻辑。未配置模型时继续使用原生回退,不写死任何模型名称,也不改动 Profile 的模型、认证、记忆文件或聊天历史。
+
+修复先完整验证两份源码,再备份至本次安装返回的 `backup/hermes-runtime/`,最后原子替换;安装失败会回滚插件、配置及已替换的源码。源码结构不兼容时拒绝修改。该修复需要重启 Hermes Dashboard,升级 Hermes 后应重新安装以检查兼容性并恢复修复。需要手动回退时,在服务停止后用备份中的同名文件恢复这两份源码,再恢复该次备份的插件和配置。单独安装插件时可省略这个参数;`--check` 始终只读。
+
 需要支持原生插件工具注册、`pre_tool_call` 审批及 Dashboard 非进程隔离 WebSocket 会话的 Hermes。插件通过实际 Profile 的工具目录检查就绪状态;接口或插件未更新时会明确拒绝启动新路径。
 
 ## API 与认证
@@ -56,6 +64,8 @@ HTTP 使用 Hermes 自身认证。绑定身份必须与 WebSocket 的真实身
 
 覆盖隔离与本机策略、撤权、会话替换、续租变更、文件身份和保护路径、安装备份及失败回滚。
 
+`docs/verification/2026-09-19-profile-routing/verify.py` 使用已安装 Hermes 的临时源码副本与本地模拟模型服务,验证 Profile 模型、会话创建/恢复元信息以及工具桥绑定;不会修改实际 Hermes 安装。`--baseline` 可复现启动环境覆盖配置的问题。
+
 `tests/live/profileComputer.live.test.ts` 使用临时 Hermes 目录、真实 Hermes、真实 Docker 和确定性模型响应,验证两种模式的 Skill、Profile 环境变量、授权文件双向传输、原图输入及无工具记忆提炼。Worker 路径故意配置为不存在,证明新链路不依赖 Worker 配置读取。
 
 真实测试使用已安装的 Tirith 检查器,默认查找 Hermes 主目录的 `bin/tirith`,也可通过 `YAOYAO_TIRITH_BIN` 指定,避免临时环境下载依赖造成超时。隔离模式关闭 YOLO,实际接收审批 RPC 并逐次回复 `once`;模型响应与授权文件使用测试数据。
@@ -64,6 +74,8 @@ HTTP 使用 Hermes 自身认证。绑定身份必须与 WebSocket 的真实身
 
 CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确启用所选插件及工具集,仅移除这个工具桥的显式禁用项。默认安装行为继续保留禁用设置。
 
+1.2.5 增加上述 Profile 模型兼容修复;工具桥绑定会主动触发延迟会话的原生初始化,并区分初始化失败、绑定锁等待超时及工具目录回调失败。锁获取计入绑定截止时间,工具目录回调最多等待 10 秒;这些步骤失败时尚未提交聊天消息。实际工具执行仍保留原有等待期限。
+
 1.2.1 将 Bot 记忆隔离放在受认证的 `/bind` 授权中(`workspace_memory: true`),在首个模型请求前关闭该会话的 Profile 记忆注入、原生记忆工具及后台复盘。不会向 Hermes 的严格 `session.create` 接口发送自定义记忆字段,也不修改 Profile 配置。安装后需重启 Dashboard 服务(默认 9119),消息网关的重启不负责重载这份插件。
 
 1.2.2 对 `computer_policy.mode: isolated` 的会话应用 Hermes 现有的 `skip_context_files=True`、`load_soul_identity=False`,关闭自动上下文文件加载。策略只在工具桥内部应用,不增加 `session.create` 参数;`isolated_context_files: true` 表示已加载的插件支持此行为。

+ 1 - 1
integrations/hermes-bots-bridge/__init__.py

@@ -1,4 +1,4 @@
-"""Hermes plugin entry point. No core patch or native tool override."""
+"""Hermes plugin entry point; source compatibility repairs belong to the installer."""
 
 from .bootstrap import runtime
 

+ 39 - 7
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -22,6 +22,7 @@ import urllib.parse
 import urllib.request
 import uuid
 from contextvars import ContextVar
+from contextlib import contextmanager
 from dataclasses import dataclass, field
 from pathlib import Path
 from typing import Any
@@ -186,12 +187,12 @@ def refresh_catalog(session_id=None, function_name=None, tool_name=None, **kwarg
         return
 
 
-def _mount_native_tools(binding):
+def _mount_native_tools(binding, *, timeout=10):
     """Register session-scoped schemas in Hermes' own searchable catalog."""
     ctx = _contexts.get(binding.profile_home)
     if ctx is None:
         raise BridgeError("请安装新版工具桥并重新打开 Hermes 会话", 409, "tools_unavailable")
-    catalog = _request(binding, "/tools/list")
+    catalog = _request(binding, "/tools/list", timeout=timeout)
     fingerprint = hashlib.sha256(json.dumps(catalog.get("tools", []), sort_keys=True).encode()).hexdigest()
     if fingerprint == binding.catalog_fingerprint:
         return
@@ -454,6 +455,22 @@ def _purge(server) -> None:
             _children.pop(sid, None)
 
 
+@contextmanager
+def _bind_locks(server, deadline):
+    # Lock contention is part of the binding deadline, not an unbounded wait
+    # outside it. In particular a stalled earlier bind must not trap retries.
+    acquired = []
+    try:
+        for lock in (server._sessions_lock, _lock):
+            if not lock.acquire(timeout=max(0, deadline - time.monotonic())):
+                raise BridgeError("Hermes 工具桥正在处理其他绑定,请稍后重试", 409, "binding_busy")
+            acquired.append(lock)
+        yield
+    finally:
+        for lock in reversed(acquired):
+            lock.release()
+
+
 def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dict:
     workspace_memory = body.get("workspace_memory", False)
     if type(workspace_memory) is not bool:
@@ -474,8 +491,9 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
         raise BridgeError("工具桥令牌长度不足", 400, "invalid_request")
     expiry = _expiry(body)
     deadline = time.monotonic() + wait_seconds
+    build_requested = False
     while True:
-        with server._sessions_lock, _lock:
+        with _bind_locks(server, deadline):
             session = server._sessions.get(sid)
             if not isinstance(session, dict):
                 raise BridgeError("Hermes 会话不存在", 404, "session_missing")
@@ -486,17 +504,29 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
             if session.get("_finalized"):
                 raise BridgeError("Hermes 会话已经结束", 409, "session_closed")
             agent = session.get("agent")
+            if session.get("agent_error"):
+                # Provider errors can contain credentials. Keep this response
+                # bounded and let Hermes retain its detailed initialization log.
+                raise BridgeError("Hermes Profile 初始化失败,工具桥尚未绑定", 409, "agent_initialization_failed")
             current = _bindings.get(sid)
             busy = bool(session.get("running")) and not (current and current.generation == generation)
             if agent is not None and not busy:
                 break
+            start_build = getattr(server, "_start_agent_build", None)
+            needs_build = agent is None and not busy and not build_requested and callable(start_build)
+        if needs_build:
+            # Native, idempotent startup also covers resumed lazy sessions.
+            # Never submit a prompt merely to trigger initialization: it would
+            # run the first turn before memory/tools have been bound.
+            build_requested = True
+            start_build(sid, session)
         if time.monotonic() >= deadline:
             if busy:
                 raise BridgeError("Hermes 会话正在运行,不能更换轮次授权", 409, "session_busy")
             raise BridgeError("Hermes 会话仍在初始化,请重试", 409, "initializing")
         time.sleep(0.1)
 
-    with server._sessions_lock, _lock:
+    with _bind_locks(server, deadline):
         _purge(server)
         expiry = _expiry(body)
         if server._sessions.get(sid) is not session or session.get("agent") is not agent:
@@ -545,7 +575,7 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
         _bindings[sid] = binding
         if body.get("native_tools") is True:
             try:
-                _mount_native_tools(binding)
+                _mount_native_tools(binding, timeout=max(0.1, min(10, deadline - time.monotonic())))
             except Exception:
                 _bindings.pop(sid, None)
                 _retire(binding)
@@ -614,7 +644,7 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
         raise BridgeError("工具桥拒绝重定向", 502, "bridge_redirect")
 
 
-def _request(binding: Binding, path: str, body: dict | None = None) -> dict:
+def _request(binding: Binding, path: str, body: dict | None = None, *, timeout=600) -> dict:
     remaining = (binding.expires_at - time.time() * 1000) / 1000
     if remaining <= 0:
         raise BridgeError("本轮工具授权已过期")
@@ -627,7 +657,7 @@ def _request(binding: Binding, path: str, body: dict | None = None) -> dict:
     # Loopback never uses machine proxy settings; redirects must not leak token.
     opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
     try:
-        with opener.open(request, timeout=min(remaining, 600)) as response:
+        with opener.open(request, timeout=min(remaining, timeout)) as response:
             raw = response.read(MAX_RESPONSE_BYTES + 1)
         if len(raw) > MAX_RESPONSE_BYTES:
             raise BridgeError("工具结果过大,请改用文件附件", 502, "result_too_large")
@@ -639,6 +669,8 @@ def _request(binding: Binding, path: str, body: dict | None = None) -> dict:
         # Do not reflect upstream response/request objects: they can carry keys.
         raise BridgeError(f"工具桥拒绝请求(HTTP {exc.code})", 502, "bridge_rejected") from None
     except (urllib.error.URLError, TimeoutError, OSError):
+        if path == "/tools/list":
+            raise BridgeError("Hermes 未能读取夭夭的本轮工具目录,尚未提交聊天消息", 502, "bridge_catalog_unreachable") from None
         raise BridgeError("工具桥连接中断;操作可能已执行,请先检查结果,不要自动重试", 502, "bridge_unreachable") from None
     except (ValueError, UnicodeError):
         raise BridgeError("工具桥返回了无效结果", 502, "invalid_result") from None

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

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

+ 77 - 0
integrations/hermes-bots-bridge/profile_runtime_repair.py

@@ -0,0 +1,77 @@
+"""Prepare narrowly scoped repairs for Hermes' multi-Profile session routing.
+
+The installer owns backup/activation/rollback. This module never writes files.
+Unsupported source layouts fail before anything is changed.
+"""
+import ast
+from pathlib import Path
+
+
+def _function(source, name):
+    matches = [node for node in ast.parse(source).body
+               if isinstance(node, ast.FunctionDef) and node.name == name]
+    if len(matches) != 1:
+        raise ValueError(f'Hermes 接口 {name} 不兼容,未修改核心文件')
+    node = matches[0]
+    lines = source.splitlines(keepends=True)
+    return node, ''.join(lines[node.lineno - 1:node.end_lineno])
+
+
+def _replace_function(source, name, transform):
+    node, body = _function(source, name)
+    lines = source.splitlines(keepends=True)
+    return ''.join(lines[:node.lineno - 1]) + transform(body) + ''.join(lines[node.end_lineno:])
+
+
+def _config_first(body):
+    marker = '# Yaoyao: each Profile owns its configured model.'
+    if marker in body:
+        return body
+    old = '    if env := _env_model_seed():\n        return env\n    m = _load_cfg().get("model", "")\n'
+    empty_model = '    if isinstance(m, dict):\n        return str(m.get("default", "") or "").strip()\n'
+    anchor = '    # No env seed / config preference:'
+    if body.count(old) != 1 or body.count(anchor) != 1 or body.count(empty_model) != 1:
+        raise ValueError('Hermes 模型解析接口已改变,未修改核心文件')
+    body = body.replace(old, f'    {marker}\n    m = _load_cfg().get("model", "")\n')
+    body = body.replace(empty_model, '    if isinstance(m, dict):\n        m = str(m.get("default", "") or "").strip()\n')
+    return body.replace(anchor, '    if env := _env_model_seed():\n        return env\n' + anchor)
+
+
+def _provider_from_profile(body):
+    marker = '# Yaoyao: launch-model/provider seeds cannot replace a configured Profile.'
+    if marker in body:
+        return body
+    anchor = '    model = _resolve_model()\n'
+    if body.count(anchor) != 1:
+        raise ValueError('Hermes Provider 解析接口已改变,未修改核心文件')
+    return body.replace(anchor, f'    {marker}\n'
+                        '    configured_model, configured_provider = _config_model_target()\n'
+                        '    if configured_model:\n'
+                        '        return configured_model, configured_provider or None\n' + anchor)
+
+
+def _scope_session_metadata(source):
+    for method in ('session.create', 'session.resume'):
+        needle = f'@method("{method}")\n'
+        if source.count(needle) != 1:
+            raise ValueError(f'Hermes {method} 接口已改变,未修改核心文件')
+        if needle + '@_profile_scoped\n' not in source:
+            source = source.replace(needle, needle + '@_profile_scoped\n')
+    return source
+
+
+def prepare(source_root: Path):
+    """Return (path, original, replacement) tuples, including no-op entries."""
+    files = [(source_root / 'tui_gateway' / 'server.py', lambda text:
+              _replace_function(_replace_function(text, '_resolve_model', _config_first),
+                                '_resolve_startup_runtime', _provider_from_profile)),
+             (source_root / 'tui_gateway' / 'methods_session.py', _scope_session_metadata)]
+    changes = []
+    for path, transform in files:
+        if not path.is_file() or path.is_symlink():
+            raise ValueError('未找到可修复的 Hermes 核心文件')
+        original = path.read_bytes()
+        repaired = transform(original.decode('utf-8'))
+        compile(repaired, str(path), 'exec')
+        changes.append((path, original, repaired.encode('utf-8')))
+    return changes

+ 61 - 0
integrations/hermes-bots-bridge/tests/test_computer_policy.py

@@ -53,6 +53,67 @@ class ComputerPolicyTests(unittest.TestCase):
         for p in reversed(self.patches): p.stop()
         self.temp.cleanup()
 
+    def test_lazy_binding_starts_native_initialization_without_submitting_a_prompt(self):
+        self.session.update(agent=None, running=False, agent_error=None)
+        started = []
+        def start(sid, session):
+            started.append(sid)
+            session['agent'] = self.agent
+        self.server._start_agent_build = start
+        body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
+                'generation': 'lazy-new', 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
+                'expires_at': time.time() * 1000 + 60000}
+        with patch.object(bridge, '_agent_has_tools', return_value=True), patch.object(bridge, '_isolate_profile_context'):
+            self.assertTrue(bridge.bind(self.binding.owner, body)['ok'])
+        self.assertEqual(started, ['runtime'])
+
+    def test_failed_initialization_is_reported_immediately_without_exposing_credentials(self):
+        self.session.update(agent=None, running=False, agent_error='api-key=fixture-secret')
+        body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
+                'generation': 'failed-new', 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
+                'expires_at': time.time() * 1000 + 60000}
+        with self.assertRaises(bridge.BridgeError) as raised:
+            bridge.bind(self.binding.owner, body)
+        self.assertEqual(raised.exception.code, 'agent_initialization_failed')
+        self.assertNotIn('fixture-secret', str(raised.exception))
+
+    def test_binding_deadline_includes_lock_contention(self):
+        held, release = threading.Event(), threading.Event()
+        def hold():
+            with bridge._lock:
+                held.set(); release.wait(2)
+        worker = threading.Thread(target=hold)
+        worker.start(); self.assertTrue(held.wait(1))
+        try:
+            with self.assertRaises(bridge.BridgeError) as raised:
+                with bridge._bind_locks(self.server, time.monotonic() + 0.02):
+                    self.fail('contended lock unexpectedly acquired')
+            self.assertEqual(raised.exception.code, 'binding_busy')
+        finally:
+            release.set(); worker.join(1)
+        available = []
+        def check_released():
+            acquired = self.server._sessions_lock.acquire(blocking=False)
+            available.append(acquired)
+            if acquired:
+                self.server._sessions_lock.release()
+        checker = threading.Thread(target=check_released)
+        checker.start(); checker.join(1)
+        self.assertEqual(available, [True])
+
+    def test_catalog_callback_wait_is_bounded_and_distinct_from_tool_execution(self):
+        seen = []
+        def opening(request, timeout):
+            seen.append(timeout)
+            raise TimeoutError('fixture-secret')
+        opener = SimpleNamespace(open=opening)
+        with patch.object(bridge.urllib.request, 'build_opener', return_value=opener):
+            with self.assertRaises(bridge.BridgeError) as raised:
+                bridge._request(self.binding, '/tools/list', timeout=0.5)
+        self.assertEqual(seen, [0.5])
+        self.assertEqual(raised.exception.code, 'bridge_catalog_unreachable')
+        self.assertNotIn('fixture-secret', str(raised.exception))
+
     def test_native_catalog_uses_binding_profile_and_restores_request_context(self):
         current = ContextVar('fixture_profile', default=self.home)
         constants = SimpleNamespace(set_hermes_home_override=current.set, reset_hermes_home_override=current.reset)

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

@@ -4,6 +4,7 @@ import unittest
 from pathlib import Path
 from unittest.mock import patch
 import yaml
+from test_profile_runtime_repair import SERVER, METHODS
 
 spec = importlib.util.spec_from_file_location('bridge_installer', Path(__file__).parents[3] / 'scripts/install-hermes-bridge.py')
 installer = importlib.util.module_from_spec(spec)
@@ -76,6 +77,27 @@ class InstallerTests(unittest.TestCase):
         (self.target / 'bridge_runtime.py').write_text('old implementation')
         self.assertFalse(installer.check(self.home, 'default', self.source)['filesCurrent'])
 
+    def test_core_repair_is_backed_up_and_rolled_back_with_a_failed_plugin_install(self):
+        installer.shutil.copyfile(Path(__file__).parents[1] / 'profile_runtime_repair.py', self.source / 'profile_runtime_repair.py')
+        runtime = self.root / 'runtime'
+        (runtime / 'tui_gateway').mkdir(parents=True)
+        server = runtime / 'tui_gateway' / 'server.py'
+        methods = runtime / 'tui_gateway' / 'methods_session.py'
+        server.write_text(SERVER); methods.write_text(METHODS)
+        config = self.config.read_bytes()
+        with patch.object(installer.yaml, 'safe_dump', side_effect=RuntimeError('fixture-failure')):
+            with self.assertRaises(RuntimeError):
+                installer.install(self.home, 'default', self.source, repair_source=runtime)
+        self.assertEqual(server.read_text(), SERVER)
+        self.assertEqual(methods.read_text(), METHODS)
+        self.assertEqual(self.config.read_bytes(), config)
+        self.assertEqual((self.target / 'old').read_text(), 'previous-plugin')
+        result = installer.install(self.home, 'default', self.source, repair_source=runtime)
+        self.assertTrue(result['profileRuntimeRepaired'])
+        self.assertIn('@_profile_scoped', methods.read_text())
+        self.assertEqual((Path(result['backup']) / 'hermes-runtime' / 'server.py').read_text(), SERVER)
+        self.assertEqual(yaml.safe_load(self.config.read_text())['model']['default'], 'keep-model')
+
 
 if __name__ == '__main__':
     unittest.main()

+ 96 - 0
integrations/hermes-bots-bridge/tests/test_profile_runtime_repair.py

@@ -0,0 +1,96 @@
+import importlib.util
+import tempfile
+import unittest
+from pathlib import Path
+
+spec = importlib.util.spec_from_file_location('profile_repair', Path(__file__).parents[1] / 'profile_runtime_repair.py')
+repair = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(repair)
+
+SERVER = '''
+def _resolve_model() -> str:
+    if env := _env_model_seed():
+        return env
+    m = _load_cfg().get("model", "")
+    if isinstance(m, dict):
+        return str(m.get("default", "") or "").strip()
+    if isinstance(m, str) and m:
+        return m.strip()
+    # No env seed / config preference: native fallback.
+    return "fallback"
+
+def _resolve_startup_runtime():
+    model = _resolve_model()
+    return model, "launch-provider"
+'''
+METHODS = '''
+@method("session.create")
+def create(rid, params):
+    return _resolve_model()
+
+@method("session.resume")
+def resume(rid, params):
+    return _resolve_model()
+'''
+
+
+class ProfileRepairTests(unittest.TestCase):
+    def setUp(self):
+        self.temp = tempfile.TemporaryDirectory()
+        self.root = Path(self.temp.name)
+        gateway = self.root / 'tui_gateway'
+        gateway.mkdir()
+        (gateway / 'server.py').write_text(SERVER)
+        (gateway / 'methods_session.py').write_text(METHODS)
+
+    def tearDown(self):
+        self.temp.cleanup()
+
+    def test_configured_profile_model_and_provider_win_over_launch_environment(self):
+        prepared = repair.prepare(self.root)
+        namespace = {'_env_model_seed': lambda: 'gpt-5.6-terra',
+                     '_load_cfg': lambda: {'model': {'default': 'grok-4.6'}},
+                     '_config_model_target': lambda: ('grok-4.6', 'custom:yaoer')}
+        exec(prepared[0][2], namespace)
+        self.assertEqual(namespace['_resolve_model'](), 'grok-4.6')
+        self.assertEqual(namespace['_resolve_startup_runtime'](), ('grok-4.6', 'custom:yaoer'))
+        namespace['_load_cfg'] = lambda: {}
+        self.assertEqual(namespace['_resolve_model'](), 'gpt-5.6-terra')
+        namespace['_load_cfg'] = lambda: {'model': {'default': ''}}
+        self.assertEqual(namespace['_resolve_model'](), 'gpt-5.6-terra')
+        # Preparation is read-only; activation belongs to the transactional installer.
+        self.assertEqual(prepared[0][0].read_text(), SERVER)
+
+    def test_create_and_resume_metadata_run_in_requested_profile_and_restore_context(self):
+        profile = ['default']
+        methods = {}
+        def method(name):
+            def register(handler):
+                methods[name] = handler
+                return handler
+            return register
+        def scoped(handler):
+            def wrapped(rid, params):
+                old = profile[0]
+                profile[0] = params['profile']
+                try:
+                    return handler(rid, params)
+                finally:
+                    profile[0] = old
+            return wrapped
+        namespace = {'method': method, '_profile_scoped': scoped,
+                     '_resolve_model': lambda: {'default': 'gpt-5.6-terra', 'yaoer': 'grok-4.6'}[profile[0]]}
+        exec(repair.prepare(self.root)[1][2], namespace)
+        for handler in methods.values():
+            self.assertEqual(handler('id', {'profile': 'yaoer'}), 'grok-4.6')
+            self.assertEqual(profile[0], 'default')
+
+    def test_idempotent_repair_and_unknown_source_is_rejected_without_writes(self):
+        for path, _, updated in repair.prepare(self.root):
+            path.write_bytes(updated)
+        self.assertTrue(all(before == after for _, before, after in repair.prepare(self.root)))
+        server = self.root / 'tui_gateway' / 'server.py'
+        server.write_text('def incompatible(): pass\n')
+        with self.assertRaises(ValueError):
+            repair.prepare(self.root)
+        self.assertEqual(server.read_text(), 'def incompatible(): pass\n')

+ 51 - 7
scripts/install-hermes-bridge.py

@@ -5,6 +5,7 @@ Run with the Hermes virtualenv Python. This command does not restart Hermes.
 import argparse
 import json
 import hashlib
+import importlib.util
 import os
 import re
 import shutil
@@ -48,17 +49,37 @@ def check(root, profile, source):
     return result
 
 
-def install(root, profile, source, enable=False):
+def _atomic_bytes(path, data, mode):
+    fd, temporary = tempfile.mkstemp(prefix='.yaoyao-runtime-', dir=path.parent)
+    try:
+        with os.fdopen(fd, 'wb') as stream:
+            stream.write(data)
+            os.fchmod(stream.fileno(), mode)
+        os.replace(temporary, path)
+    finally:
+        Path(temporary).unlink(missing_ok=True)
+
+
+def install(root, profile, source, enable=False, repair_source=None):
     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() or home.is_symlink():
         raise ValueError('Profile 配置不存在或是符号链接')
-    original = config_path.read_bytes()
-    config = yaml.safe_load(original) or {}
+    original_config = config_path.read_bytes()
+    config = yaml.safe_load(original_config) or {}
     if not isinstance(config, dict):
         raise ValueError('Profile 配置格式无效')
+    repairs = []
+    if repair_source is not None:
+        spec = importlib.util.spec_from_file_location('yaoyao_profile_runtime_repair', source / 'profile_runtime_repair.py')
+        if spec is None or spec.loader is None:
+            raise ValueError('缺少 Profile 模型兼容修复模块')
+        repair = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(repair)
+        repairs = [(path, original, changed, path.stat().st_mode & 0o777)
+                   for path, original, changed in repair.prepare(repair_source) if original != changed]
     plugins = config.setdefault('plugins', {})
     if not isinstance(plugins, dict):
         raise ValueError('Profile 插件配置格式无效')
@@ -87,12 +108,17 @@ def install(root, profile, source, enable=False):
     stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '-' + uuid.uuid4().hex[:8]
     backup = home / 'backups' / ('yaoyao-bridge-' + stamp)
     backup.mkdir(parents=True, mode=0o700)
-    (backup / 'config.yaml').write_bytes(original)
+    (backup / 'config.yaml').write_bytes(original_config)
     (backup / 'config.yaml').chmod(0o600)
+    for path, original, _, _ in repairs:
+        saved = backup / 'hermes-runtime' / path.name
+        saved.parent.mkdir(exist_ok=True)
+        saved.write_bytes(original)
     staged = directory / ('.yaoyao-bridge-' + uuid.uuid4().hex)
     previous = backup / name
     moved = False
     activated = False
+    applied = []
     try:
         shutil.copytree(source, staged, ignore=shutil.ignore_patterns('__pycache__', '*.pyc', 'tests'))
         if target.exists():
@@ -100,6 +126,12 @@ def install(root, profile, source, enable=False):
             moved = True
         staged.rename(target)
         activated = True
+        for path, original, changed, mode in repairs:
+            # Refuse to overwrite another tool's edit made after validation.
+            if path.read_bytes() != original:
+                raise ValueError('Hermes 核心文件在修复期间已改变,请重新安装')
+            _atomic_bytes(path, changed, mode)
+            applied.append((path, original, mode))
         fd, temporary = tempfile.mkstemp(prefix='.config-yaoyao-', dir=home)
         try:
             with os.fdopen(fd, 'w') as stream:
@@ -108,17 +140,22 @@ def install(root, profile, source, enable=False):
         finally:
             Path(temporary).unlink(missing_ok=True)
     except BaseException:
+        for path, original, mode in reversed(applied):
+            _atomic_bytes(path, original, mode)
         if activated:
             shutil.rmtree(target)
         if moved:
             previous.rename(target)
-        config_path.write_bytes(original)
+        config_path.write_bytes(original_config)
         raise
     finally:
         if staged.exists():
             shutil.rmtree(staged)
     return {'profile': profile, 'installed': str(target), 'backup': str(backup), 'disabled': disabled,
-            'message': '已更新,保留显式禁用设置' if disabled else '已更新并启用;空闲时重启 Hermes Dashboard 服务后生效'}
+            'profileRuntimeRepaired': repair_source is not None,
+            'message': ('工具桥已更新,保留显式禁用设置。' if disabled else '工具桥已更新并启用。')
+                       + ('已应用 Profile 模型兼容修复。' if repair_source is not None else '')
+                       + '空闲时重启 Hermes Dashboard 服务后生效。'}
 
 
 def main():
@@ -127,6 +164,7 @@ def main():
     parser.add_argument('--profile', action='append', default=[])
     parser.add_argument('--check', action='store_true')
     parser.add_argument('--enable', action='store_true', help='明确启用此插件及其工具集,保留其他禁用项')
+    parser.add_argument('--repair-profile-runtime', action='store_true', help='备份并修复此 Python 对应 Hermes 的 Profile 模型解析和会话元信息')
     args = parser.parse_args()
     source = Path(__file__).resolve().parent / 'hermes-bots-bridge'
     if not source.is_dir():
@@ -134,13 +172,19 @@ def main():
     if not (source / 'plugin.yaml').is_file():
         raise ValueError('缺少随附的工具桥插件')
     root = args.hermes_home.expanduser().resolve()
+    repair_source = None
+    if args.repair_profile_runtime and not args.check:
+        package = importlib.util.find_spec('tui_gateway')
+        if package is None or not package.submodule_search_locations:
+            raise ValueError('当前 Python 找不到 Hermes,请使用运行 Hermes 的 Python 安装修复')
+        repair_source = Path(next(iter(package.submodule_search_locations))).resolve().parent
     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)
+        result = check(root, profile, source) if args.check else install(root, profile, source, enable=args.enable, repair_source=repair_source)
         print(json.dumps(result, ensure_ascii=False))
 
 

+ 6 - 3
src/server/hermesBridge.ts

@@ -110,13 +110,16 @@ export class HermesBridgeManager {
       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']:[])])
+      const output=await this.run(this.python,[this.script,'--hermes-home',this.home,'--profile',parsed.data.profile,'--repair-profile-runtime',...(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')
+      throw new HttpError(409,'工具桥或 Profile 模型兼容修复安装失败,请检查 Hermes 版本兼容性与目录权限后重新检查状态。','hermes_bridge_install_failed')
     }finally{this.installing=undefined}
-    return {profile:parsed.data.profile,backup:result.backup,message:result.disabled?'插件文件已更新,保留了禁用设置。':'工具桥已安装并启用。请在空闲时重启 Hermes Dashboard 服务,然后重新检查。',status:await this.status()}
+    const message=(result.disabled?'插件文件已更新,保留了禁用设置。':'工具桥已安装并启用。')
+      +(result.profileRuntimeRepaired?'已备份并应用 Profile 模型兼容修复。':'')
+      +'请在空闲时重启 Hermes Dashboard 服务,然后重新检查。'
+    return {profile:parsed.data.profile,backup:result.backup,message,status:await this.status()}
   }
 }

+ 14 - 1
src/server/workspaceToolLease.ts

@@ -17,8 +17,21 @@ async function bridgeRequest(target: GatewayTarget, path: string, body: unknown)
   })
   let value: Record<string, unknown> = {}
   try { value = JSON.parse(response.body.toString()) } catch { /* report a bounded, credential-free error */ }
-  if (response.status !== 200 || value.ok !== true)
+  if (response.status !== 200 || value.ok !== true) {
+    const failures: Record<string, string> = {
+      initializing: '仍在初始化,尚未提交聊天消息,请稍后重试',
+      agent_initialization_failed: '初始化失败,工具桥尚未绑定',
+      binding_busy: '工具桥等待其他绑定超时,请稍后重试',
+      bridge_catalog_unreachable: '无法读取夭夭的本轮工具目录,尚未提交聊天消息',
+      session_busy: '会话仍在执行,暂时无法绑定新的轮次',
+    }
+    const code = typeof value.code === 'string' ? value.code : ''
+    const profile = body && typeof body === 'object' && 'profile' in body && typeof body.profile === 'string'
+      && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(body.profile) ? ` Profile「${body.profile}」` : ''
+    if (path === '/bind' && Object.hasOwn(failures, code))
+      throw new HttpError(response.status >= 500 ? 502 : 409, `Hermes${profile}${failures[code]}。`, `hermes_bridge_${code}`)
     throw new HttpError(409, 'Hermes 工具桥绑定失败,请确认该 Profile 已启用工具桥,并新建会话后重试。', 'team_tools_bind_failed')
+  }
   return value
 }
 

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

@@ -37,6 +37,7 @@ it('distinguishes stale files, disabled Profile, pending restart and the actuall
   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(f.run.mock.calls.at(-2)?.[1]).toContain('--repair-profile-runtime')
   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')

+ 7 - 0
tests/server/workspaceTeamTools.test.ts

@@ -218,6 +218,13 @@ function http(path:string, body:unknown, options:RequestInit={}) {
 }
 
 describe('loopback tool lease', () => {
+  it.each(['initializing','agent_initialization_failed','binding_busy','bridge_catalog_unreachable'])('preserves the Profile and the %s binding failure without reflecting upstream secrets',async code=>{
+    manager.profile='yaoer'
+    vi.mocked(target.session.request).mockImplementation(async path=>({status:path.endsWith('/bind')?409:200,
+      body:Buffer.from(JSON.stringify(path.endsWith('/bind')?{code,detail:'api-key=fixture-secret'}:{ok:true})),headers:new Headers()}))
+    await expect(mount()).rejects.toMatchObject({code:`hermes_bridge_${code}`,message:expect.stringContaining('Profile「yaoer」')})
+    await expect(leases[0]!.bind()).rejects.not.toThrow('fixture-secret')
+  })
   it('binds only the current session and executes the real HTTP catalog and call protocol', async () => {
     const lease = await mount()
     expect(binding).toMatchObject({native_tools:true,session_id:'runtime',stored_session_id:'stored',profile:'default'})