From d4c3f981409d87ee795619fdd7189ae505c0548d Mon Sep 17 00:00:00 2001 From: xxxigm <54813621+xxxigm@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:49:39 +0700 Subject: [PATCH] fix(dashboard): unblock basic auth plugin when setting password interactively (#54489) (#63786) * fix(dashboard): unblock basic auth plugin during interactive password setup When the dashboard prompts for username/password on a non-loopback bind, also remove the bundled basic provider from plugins.disabled so discover_plugins(force=True) can register it (#54489). * test(dashboard): cover basic auth plugin blocked by plugins.disabled Regression harness for #54489: credentials in config are not enough when the bundled basic provider is on the deny-list. --- hermes_cli/main.py | 11 ++ hermes_cli/plugins_cmd.py | 27 +++++ hermes_cli/web_server.py | 26 +++++ ...test_dashboard_basic_auth_plugin_enable.py | 106 ++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c3521a064..034438255 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12259,6 +12259,7 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None: try: from hermes_cli.config import load_config, save_config + from hermes_cli.plugins_cmd import ensure_basic_auth_plugin_enabled_in_config cfg = load_config() dash = cfg.setdefault("dashboard", {}) @@ -12269,6 +12270,16 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None: basic["password"] = "" if not str(basic.get("secret", "") or "").strip(): basic["secret"] = secret + # The bundled basic provider is a backend plugin that still honours + # plugins.disabled. Unblock it when we just wrote basic_auth so the + # discover_plugins(force=True) call below can register the provider + # (#54489). Surface the mutation so an operator who deliberately + # disabled it isn't surprised. + if ensure_basic_auth_plugin_enabled_in_config(cfg): + print( + " ✓ Re-enabled the bundled 'basic' auth plugin " + "(was in plugins.disabled)" + ) save_config(cfg) except Exception as exc: print(f" ✗ Failed to write config.yaml: {exc}") diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 6a7c39f3e..f5c57bb88 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -715,6 +715,33 @@ def _save_disabled_set(disabled: set) -> None: save_config(config) +_BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"}) + + +def ensure_basic_auth_plugin_enabled_in_config(cfg: dict) -> bool: + """Re-enable the bundled basic dashboard-auth plugin in *cfg*. + + ``hermes setup`` / ``hermes plugins disable basic`` can park the plugin + in ``plugins.disabled`` while ``dashboard.basic_auth`` is configured. + The basic provider is a bundled backend that still respects the + deny-list, so password auth silently fails until the block is removed. + + Returns True when ``plugins.disabled`` was modified. + """ + plugins_cfg = cfg.get("plugins") + if not isinstance(plugins_cfg, dict): + return False + disabled = plugins_cfg.get("disabled") + if not isinstance(disabled, list): + return False + if not (set(disabled) & _BASIC_AUTH_PLUGIN_KEYS): + return False + plugins_cfg["disabled"] = sorted( + set(disabled) - _BASIC_AUTH_PLUGIN_KEYS + ) + return True + + def _get_enabled_set() -> set: """Read the enabled plugins allow-list from config.yaml. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 2f7f81bfc..86d7b3438 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -17768,6 +17768,32 @@ def start_server( "There is no unauthenticated public-bind option — to keep it " "local, bind 127.0.0.1 and tunnel in (SSH / Tailscale)." ) + # Hint when credentials exist but the bundled provider is blocked + # (#54489). + try: + from hermes_cli.config import load_config as _load_cfg + from hermes_cli.plugins_cmd import _BASIC_AUTH_PLUGIN_KEYS + + _cfg = _load_cfg() + _ba = (_cfg.get("dashboard") or {}).get("basic_auth") or {} + _disabled = (_cfg.get("plugins") or {}).get("disabled") or [] + # Basic auth only activates with a username AND a credential + # (plaintext password or password_hash); don't fire the hint on + # a half-configured block. + _has_creds = bool(_ba.get("username")) and bool( + _ba.get("password_hash") or _ba.get("password") + ) + if _has_creds and (set(_disabled) & _BASIC_AUTH_PLUGIN_KEYS): + _fix_hint = ( + "The 'basic' dashboard-auth plugin is in " + "plugins.disabled but dashboard.basic_auth is " + "configured.\n" + "Remove 'basic' from plugins.disabled (or run " + "`hermes plugins enable basic`), then restart the " + "dashboard.\n\n" + ) + _fix_hint + except Exception: + pass if skip_reasons: raise SystemExit( f"Refusing to bind dashboard to {host} — the auth gate " diff --git a/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py b/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py new file mode 100644 index 000000000..be543a609 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py @@ -0,0 +1,106 @@ +"""Regression tests for dashboard basic-auth plugin enablement (#54489). + +When ``dashboard.basic_auth`` is configured but the bundled ``basic`` +provider plugin is listed in ``plugins.disabled``, plugin discovery skips +it and the dashboard auth gate sees zero providers — even after the +interactive username/password setup path writes credentials to config.yaml. +""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest +import yaml + +from hermes_cli.dashboard_auth import clear_providers, list_providers +from hermes_cli.plugins import PluginManager, discover_plugins +from hermes_cli.plugins_cmd import ensure_basic_auth_plugin_enabled_in_config +import plugins.dashboard_auth.basic as basic_plugin + + +@pytest.fixture(autouse=True) +def _reset_auth_registry(): + clear_providers() + yield + clear_providers() + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +def _write_config(home, cfg: dict) -> None: + (home / "config.yaml").write_text(yaml.safe_dump(cfg), encoding="utf-8") + + +class TestEnsureBasicAuthPluginEnabled: + def test_noop_when_not_disabled(self): + cfg = {"plugins": {"disabled": ["other-plugin"]}} + assert ensure_basic_auth_plugin_enabled_in_config(cfg) is False + + def test_removes_bare_basic_key(self): + cfg = {"plugins": {"disabled": ["basic", "foo"]}} + assert ensure_basic_auth_plugin_enabled_in_config(cfg) is True + assert cfg["plugins"]["disabled"] == ["foo"] + + def test_removes_namespaced_key(self): + cfg = {"plugins": {"disabled": ["dashboard_auth/basic"]}} + assert ensure_basic_auth_plugin_enabled_in_config(cfg) is True + assert cfg["plugins"]["disabled"] == [] + + +class TestBasicProviderLoadsAfterUnblock: + def test_disabled_basic_blocks_registration(self, hermes_home, monkeypatch): + password_hash = basic_plugin.hash_password("hunter2") + _write_config( + hermes_home, + { + "dashboard": { + "basic_auth": { + "username": "admin", + "password_hash": password_hash, + "secret": "a" * 32, + } + }, + "plugins": {"disabled": ["basic"]}, + }, + ) + + import hermes_cli.plugins as plugins_mod + + with patch.object(plugins_mod, "_plugin_manager", None): + discover_plugins(force=True) + + assert list_providers() == [] + + def test_unblock_then_rediscover_registers_provider( + self, hermes_home, monkeypatch, + ): + password_hash = basic_plugin.hash_password("hunter2") + cfg = { + "dashboard": { + "basic_auth": { + "username": "admin", + "password_hash": password_hash, + "secret": "a" * 32, + } + }, + "plugins": {"disabled": ["basic"]}, + } + _write_config(hermes_home, cfg) + + assert ensure_basic_auth_plugin_enabled_in_config(cfg) is True + _write_config(hermes_home, cfg) + + import hermes_cli.plugins as plugins_mod + + with patch.object(plugins_mod, "_plugin_manager", None): + discover_plugins(force=True) + + providers = list_providers() + assert len(providers) == 1 + assert providers[0].name == "basic"