diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index a6e32120611e..643e8631244b 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2397,7 +2397,7 @@ def _aux(timeout, *, reasoning_effort=True, **extra): # Extra ports detection probes for an external llama-server (besides 8080). "detect_ports": [], }, - "_config_version": 42, # Config schema version - bump this when adding new required fields + "_config_version": 43, # Config schema version - bump this when adding new required fields } diff --git a/hermes_cli/config_migrations.py b/hermes_cli/config_migrations.py index 92b03cb4fc10..11e55a46d4e0 100644 --- a/hermes_cli/config_migrations.py +++ b/hermes_cli/config_migrations.py @@ -542,6 +542,57 @@ def _migrate_to_41(results: Dict[str, Any], quiet: bool) -> None: f"({', '.join(cleaned)}) — Bot Chat sessions now get the live roster instead.") +def _migrate_to_43(results: Dict[str, Any], quiet: bool) -> None: + # 42 → 43: turn the `connections` toolset on for every platform whose saved `platform_toolsets` + # list predates it. `hermes tools` writes an explicit list, and absence from that list reads as + # "unchecked", so a toolset that ships later stays off for picker users while composite users + # inherit it. Two "no"s are kept: a platform whose `known_builtin_toolsets` records `connections` + # saw the checkbox and left it off, and `agent.disabled_toolsets` (Blank Slate, `hermes tools + # --disable`) is subtracted last by the resolver, so appending there would claim an enable that + # never takes effect. + from agent.skill_utils import parse_config_string_list + from hermes_cli.tools_config import _configurable_keys, _get_plugin_toolset_keys + from hermes_cli.toolset_scope import toolset_allowed_for_platform + + config = read_raw_config() + saved = config.get("platform_toolsets") + if not isinstance(saved, dict): + return + if "connections" in parse_config_string_list(_dict_at(config, "agent").get("disabled_toolsets")): + return + known = _dict_at(config, "known_builtin_toolsets") + # Same predicate the resolver uses to pick its explicit branch: any configurable or plugin key. + explicit_keys = _configurable_keys() | _get_plugin_toolset_keys() + enabled_for: List[str] = [] + for platform, toolsets in saved.items(): + if not isinstance(toolsets, list) or "connections" in toolsets: + continue + if not toolset_allowed_for_platform("connections", platform): + continue + # A composite like [hermes-cli] already inherits every core tool at read time. + if not any(str(ts) in explicit_keys for ts in toolsets): + continue + offered = known.get(platform) + if isinstance(offered, list) and "connections" in offered: + continue + saved[platform] = sorted({*map(str, toolsets), "connections"}) + if isinstance(offered, list): + known[platform] = sorted({*map(str, offered), "connections"}) + enabled_for.append(str(platform)) + if not enabled_for: + return + config["platform_toolsets"] = saved + if known: + config["known_builtin_toolsets"] = known + platforms = ", ".join(sorted(enabled_for)) + _commit( + config, results, quiet, + f"enabled the connections toolset for {platforms}", + f" ✓ Enabled the Connections toolset (Gmail, Linear, Notion, ...) for {platforms} — " + "the manage_connections tool appears when your Nous Portal account has tool access " + "(paid plan or active free pool). Uncheck Connections in `hermes tools` to turn it off.") + + #: Registry of (target_version, step), strictly ascending; simple default-flip steps are #: declared inline via _rewrite_stale_default / _rewrite_key partials. Later steps observe #: earlier steps' writes via read_raw_config() (filesystem state). v12 is the support floor: @@ -637,6 +688,7 @@ def _migrate_to_41(results: Dict[str, Any], quiet: bool) -> None: " ✓ Removed cron.model_drift_guard — unpinned cron jobs now keep running on the " "model/provider they were created under when the global default changes, instead " "of being skipped. Pin a job or set cron.model to move it."))), + (43, _migrate_to_43), ) diff --git a/tests/hermes_cli/test_config_migration_43_connections.py b/tests/hermes_cli/test_config_migration_43_connections.py new file mode 100644 index 000000000000..6285897f9222 --- /dev/null +++ b/tests/hermes_cli/test_config_migration_43_connections.py @@ -0,0 +1,144 @@ +"""Migration 42→43: saved ``platform_toolsets`` lists gain the ``connections`` toolset. + +``hermes tools`` persists an explicit per-platform toolset list, and absence from +that list reads as "unchecked" — so a toolset that ships after the list was saved +stays off for picker users while composite (``[hermes-cli]``) users inherit it. +The 42→43 step turns ``connections`` on for stale lists, preserves an explicit +decline, and leaves composites/empty lists alone. +""" + +import os +from unittest.mock import patch + +import pytest +import yaml + + +class TestConnectionsToolsetMigration: + """Behaviour contract for ``_migrate_to_43`` driven through ``run_migrations``.""" + + @staticmethod + def _run_ladder(tmp_path, current_ver=42): + from hermes_cli.config_migrations import run_migrations + + results = {"env_added": [], "config_added": [], "warnings": []} + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + run_migrations(current_ver, results, quiet=True) + return results + + @staticmethod + def _write_config(tmp_path, config): + (tmp_path / "config.yaml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + + @staticmethod + def _read_config(tmp_path): + return yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + + def test_stale_list_gains_connections_for_every_platform(self, tmp_path): + """A list saved before the toolset shipped is offered it (and records the offer).""" + platforms = { + "cli": ["file", "terminal", "web"], + "telegram": ["file", "web"], + } + self._write_config( + tmp_path, + { + "_config_version": 42, + "platform_toolsets": platforms, + "known_builtin_toolsets": { + "cli": ["browser", "file", "memory", "skills", "terminal", "todo", "web"] + }, + }, + ) + + results = self._run_ladder(tmp_path) + raw = self._read_config(tmp_path) + + assert "connections" in raw["platform_toolsets"]["cli"] + assert "connections" in raw["platform_toolsets"]["telegram"] + # The offer is recorded so a later uncheck is a recorded decline, not another migration. + assert "connections" in raw["known_builtin_toolsets"]["cli"] + added = [entry for entry in results["config_added"] if "connections" in entry.lower()] + assert len(added) == 1, results["config_added"] + + @pytest.mark.parametrize( + "platform_toolsets, known", + [ + pytest.param( # (a) the user saw the checkbox and left it off + {"cli": ["file", "terminal", "web"]}, + {"cli": ["browser", "connections", "file", "web"]}, + id="declined", + ), + pytest.param( # (b) composite list already inherits every core tool + {"cli": ["hermes-cli"]}, + {}, + id="composite", + ), + pytest.param( # (c) empty picker selection — no configurable key to extend + {"cli": []}, + {}, + id="empty", + ), + ], + ) + def test_declines_composites_and_empty_lists_are_untouched( + self, tmp_path, platform_toolsets, known + ): + self._write_config( + tmp_path, + { + "_config_version": 42, + "platform_toolsets": platform_toolsets, + "known_builtin_toolsets": known, + }, + ) + + results = self._run_ladder(tmp_path) + raw = self._read_config(tmp_path) + + assert raw["platform_toolsets"] == platform_toolsets + assert results["config_added"] == [] + + @pytest.mark.parametrize("disabled", [["browser", "connections", "web"], '["connections"]'], ids=["list", "json-string"]) + def test_global_disable_is_not_overridden_or_claimed(self, tmp_path, disabled): + """Blank Slate and `hermes tools --disable` write agent.disabled_toolsets, which the resolver + subtracts last; appending to the platform list would print an enable that never takes effect.""" + platform_toolsets = {"cli": ["file", "skills", "terminal", "vision"]} + self._write_config( + tmp_path, + { + "_config_version": 42, + "platform_toolsets": platform_toolsets, + "agent": {"disabled_toolsets": disabled}, + }, + ) + + results = self._run_ladder(tmp_path) + raw = self._read_config(tmp_path) + + assert raw["platform_toolsets"] == platform_toolsets + assert raw["agent"]["disabled_toolsets"] == disabled + assert results["config_added"] == [] + + def test_rerun_adds_nothing_and_keeps_one_connections(self, tmp_path): + """Running the step twice is a no-op; the toolset appears exactly once.""" + self._write_config( + tmp_path, + { + "_config_version": 42, + "platform_toolsets": {"cli": ["file", "terminal", "web"]}, + "known_builtin_toolsets": {"cli": ["file", "terminal", "web"]}, + }, + ) + + self._run_ladder(tmp_path) + after_first = self._read_config(tmp_path)["platform_toolsets"]["cli"] + second = self._run_ladder(tmp_path) + after_second = self._read_config(tmp_path)["platform_toolsets"]["cli"] + + # Running the step again rewrites nothing and never appends a duplicate. + assert second["config_added"] == [] + assert after_second == after_first + assert after_second.count("connections") <= 1