Przeglądaj źródła

合并远端主分支:修复 Bot 模型设置的 Profile 凭据作用域

Samien 1 miesiąc temu
rodzic
commit
7c125526f0

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

@@ -47,6 +47,7 @@ Runner 包内可直接运行同目录的 `install-hermes-bridge.py`。安装器
 - `GET /capabilities?profile=...`:检查对应 Profile 及托管能力。
 - `GET /capabilities?profile=...`:检查对应 Profile 及托管能力。
 - `GET /model-options?profile=...`:读取 Profile 的模型目录、默认值、思考能力与速度能力;只返回公开配置,不返回凭据。
 - `GET /model-options?profile=...`:读取 Profile 的模型目录、默认值、思考能力与速度能力;只返回公开配置,不返回凭据。
 - `POST /model-settings/resolve`:接收 `profile` 与 `settings`,通过 Hermes 原生解析器校验完整组合,返回版本 1 的有效配置和模型确认提示;不写 Profile 配置。
 - `POST /model-settings/resolve`:接收 `profile` 与 `settings`,通过 Hermes 原生解析器校验完整组合,返回版本 1 的有效配置和模型确认提示;不写 Profile 配置。
+- 模型目录与配置解析均使用 Hermes 原生配置作用域,同时绑定 Profile 目录和凭据;多 Profile Dashboard 不会因缺少凭据作用域而报错,也不会借用其他 Profile 的环境变量。默认 Profile 保留 Hermes 自身的启动凭据规则。
 - `POST /bind`:绑定真实 Hermes 会话、Profile 和当前轮次;最多 30 分钟,支持同轮续租。
 - `POST /bind`:绑定真实 Hermes 会话、Profile 和当前轮次;最多 30 分钟,支持同轮续租。
 - `POST /unbind`:只撤销匹配的会话与轮次。
 - `POST /unbind`:只撤销匹配的会话与轮次。
 - `POST /computer-file`:当前运行轮次的文件传输;验证 HTTP 身份、会话、轮次、Profile、文件保护规则,最多 25 MiB。请求不能选择其他 Profile。
 - `POST /computer-file`:当前运行轮次的文件传输;验证 HTTP 身份、会话、轮次、Profile、文件保护规则,最多 25 MiB。请求不能选择其他 Profile。

+ 12 - 5
integrations/hermes-bots-bridge/model_settings.py

@@ -9,15 +9,22 @@ class ModelSettingsError(Exception):
 
 
 @contextmanager
 @contextmanager
 def profile_scope(profile):
 def profile_scope(profile):
-    from hermes_cli.profiles import get_profile_dir, profile_exists
-    from hermes_constants import set_hermes_home_override, reset_hermes_home_override
+    from hermes_cli.profiles import profile_exists
     if not isinstance(profile, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", profile) or not profile_exists(profile):
     if not isinstance(profile, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", profile) or not profile_exists(profile):
         raise ModelSettingsError("基础机器人不存在")
         raise ModelSettingsError("基础机器人不存在")
-    token = set_hermes_home_override(get_profile_dir(profile))
+    # Match Hermes' own model picker: home AND credentials must be scoped.
+    # A home-only override raises UnscopedSecretError under multi-profile hosting.
+    # The native scope also preserves launch credentials and hydrates named profiles
+    # without borrowing process-global credentials or taking the skills lock.
     try:
     try:
+        from hermes_cli.web_server_profiles import _config_profile_scope
+    except ModuleNotFoundError as exc:
+        if exc.name != "hermes_cli.web_server_profiles":
+            raise
+        # Older Hermes versions kept the same helper in the monolithic server.
+        from hermes_cli.web_server import _config_profile_scope
+    with _config_profile_scope(profile):
         yield
         yield
-    finally:
-        reset_hermes_home_override(token)
 
 
 
 
 def defaults(ctx, config):
 def defaults(ctx, config):

+ 87 - 1
integrations/hermes-bots-bridge/tests/test_model_settings.py

@@ -1,6 +1,9 @@
 import importlib.util
 import importlib.util
 import json
 import json
-from contextlib import nullcontext
+from contextlib import contextmanager, nullcontext
+from contextvars import ContextVar
+from concurrent.futures import ThreadPoolExecutor
+from threading import Barrier
 from pathlib import Path
 from pathlib import Path
 from types import SimpleNamespace
 from types import SimpleNamespace
 from unittest.mock import Mock, patch
 from unittest.mock import Mock, patch
@@ -35,6 +38,89 @@ class BotModelSettingsTests(unittest.TestCase):
         self.scoped.start(); self.modules.start()
         self.scoped.start(); self.modules.start()
         self.addCleanup(self.scoped.stop); self.addCleanup(self.modules.stop)
         self.addCleanup(self.scoped.stop); self.addCleanup(self.modules.stop)
 
 
+    def native_scope_fixture(self):
+        self.scoped.stop()
+        home = ContextVar('test_model_home', default='caller')
+        secret = ContextVar('test_model_secret', default=None)
+        calls = []
+
+        @contextmanager
+        def scope(profile):
+            calls.append(profile)
+            home_token = home.set(profile)
+            secret_token = secret.set(profile + '-credential')
+            try:
+                yield
+            finally:
+                secret.reset(secret_token)
+                home.reset(home_token)
+
+        def picker():
+            if secret.get() != home.get() + '-credential':
+                raise RuntimeError('UnscopedSecretError: missing Profile secret scope')
+            return self.ctx
+
+        import sys
+        constants = sys.modules['hermes_constants']
+        constants.set_hermes_home_override = home.set
+        constants.reset_hermes_home_override = home.reset
+        sys.modules['hermes_cli.inventory'].load_picker_context = picker
+        modules = {
+            'hermes_cli.profiles': SimpleNamespace(profile_exists=lambda p: p in ('default', 'writer'), get_profile_dir=lambda p: p),
+            'hermes_cli.web_server_profiles': SimpleNamespace(_config_profile_scope=scope),
+        }
+        patcher = patch.dict('sys.modules', modules)
+        patcher.start(); self.addCleanup(patcher.stop)
+        return home, secret, calls
+
+    def test_catalogue_and_resolution_bind_profile_secrets_in_multiplex_mode(self):
+        home, secret, calls = self.native_scope_fixture()
+        for profile in ('default', 'writer'):
+            self.assertEqual(models.options(profile)['version'], 1)
+            self.assertEqual(models.resolve(profile, None)['version'], 1)
+        self.assertEqual(calls, ['default', 'default', 'writer', 'writer'])
+        self.assertEqual(home.get(), 'caller')
+        self.assertIsNone(secret.get())
+
+    def test_scopes_are_thread_local_and_restore_the_caller_even_on_error(self):
+        home, secret, _ = self.native_scope_fixture()
+        barrier = Barrier(2)
+
+        def read(profile):
+            with models.profile_scope(profile):
+                barrier.wait(timeout=5)
+                self.assertEqual(home.get(), profile)
+                self.assertEqual(secret.get(), profile + '-credential')
+                with models.profile_scope('default'):
+                    self.assertEqual(secret.get(), 'default-credential')
+                self.assertEqual(secret.get(), profile + '-credential')
+            return home.get(), secret.get()
+
+        with ThreadPoolExecutor(max_workers=2) as pool:
+            self.assertEqual(list(pool.map(read, ('default', 'writer'))), [('caller', None), ('caller', None)])
+        with self.assertRaisesRegex(ValueError, 'fixture failure'):
+            with models.profile_scope('writer'):
+                raise ValueError('fixture failure')
+        self.assertEqual(home.get(), 'caller')
+        self.assertIsNone(secret.get())
+
+    def test_invalid_profiles_never_enter_a_credential_scope(self):
+        _, _, calls = self.native_scope_fixture()
+        for profile in ('../writer', 'missing', None, ''):
+            with self.subTest(profile=profile), self.assertRaises(models.ModelSettingsError):
+                models.options(profile)
+        self.assertEqual(calls, [])
+
+    def test_older_hermes_uses_its_native_scope_without_home_only_fallback(self):
+        home, secret, calls = self.native_scope_fixture()
+        import sys
+        native = sys.modules['hermes_cli.web_server_profiles']
+        with patch.dict('sys.modules', {'hermes_cli.web_server_profiles': None, 'hermes_cli.web_server': native}):
+            self.assertEqual(models.options('writer')['version'], 1)
+        self.assertEqual(calls, ['writer'])
+        self.assertEqual(home.get(), 'caller')
+        self.assertIsNone(secret.get())
+
     def test_catalogue_preserves_defaults_modes_and_does_not_disclose_credentials(self):
     def test_catalogue_preserves_defaults_modes_and_does_not_disclose_credentials(self):
         value = models.options('default')
         value = models.options('default')
         self.assertEqual(value['defaults']['fastMode'], 'auto')
         self.assertEqual(value['defaults']['fastMode'], 'auto')