From 1e8fb24bc1c3f5ca3b31a91c679a716aaa6ed771 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:33:53 +0200 Subject: [PATCH] fix(bot-mode): a teammate roster entry must also pass the profile-id name gate (#116905) A marker-carrying directory whose name is not a profile id (a parked backup like _backup_removed_20260920, a .staging-area dotdir) passed the roster's identity predicate and was injected as a phantom teammate into every Bot's system prompt, while profile list hides it via _PROFILE_ID_RE. Add the same name gate to _roster so the roster agrees with every other profiles/ enumerator; friendly-name aliases and the message_agent target set no longer admit dirs the CLI refuses to treat as profiles. --- tests/tools/test_bot_mode_probe.py | 112 +++++++++++++++++++++++++++++ tools/bot_mode_probe.py | 9 ++- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_bot_mode_probe.py b/tests/tools/test_bot_mode_probe.py index c1aba3073e..dbd3437719 100644 --- a/tests/tools/test_bot_mode_probe.py +++ b/tests/tools/test_bot_mode_probe.py @@ -53,6 +53,118 @@ def test_roster_excludes_infra_dirs_and_tombstones(tmp_path): assert not any(f"`@{s}`" in section for s in ("sessions", "logs", "ghost", ".deleted")) +def test_roster_excludes_dirs_failing_the_profile_id_regex(tmp_path): + """#116905: a directory carrying an identity marker but named like anything other than a + profile id (a parked backup, a dotfile staging dir) is not a teammate. ``profile list`` + hides such dirs via ``_PROFILE_ID_RE``; the roster must agree with that predicate.""" + home = tmp_path / ".hermes" + home.mkdir() + _make_bot_profile(home, "researcher", managed=True) + for stray in ("_backup_removed_20260920", ".staging-area"): + d = home / "profiles" / stray + d.mkdir() + (d / "config.yaml").write_text("model:\n name: test\n", encoding="utf-8") + + assert [name for name, _ in bot_mode_probe._roster(home)] == ["default", "researcher"] + section = bot_mode_probe.get_bot_mode_protocol_section(home) + assert "`@researcher`" in section + assert not any(f"`@{s}`" in section for s in ("_backup_removed_20260920", ".staging-area")) + + +def test_roster_contract_matches_list_profile_names(tmp_path, monkeypatch): + """#116905 contract layer: every non-default roster name is a name ``hermes profile list`` + would show. The roster is the ``message_agent`` target set — a phantom entry the CLI hides + is a target no delivery path can resolve.""" + from pathlib import Path + + from hermes_cli.profiles import list_profile_names + + home = tmp_path / ".hermes" + home.mkdir() + # Profile enumeration is HOME-anchored, not root-arg-anchored: pin both so the + # roster and the CLI listing observe the same tree (tests/hermes_cli/test_profiles.py + # profile_env pattern). + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + _make_bot_profile(home, "researcher", managed=True) + _make_bot_profile(home, "trade-ops2", managed=True) + for stray in ("_backup_removed_20260920", ".staging-area", "has space", "UPPER"): + d = home / "profiles" / stray + d.mkdir() + (d / "state.db").write_bytes(b"") + + roster_names = {name for name, _ in bot_mode_probe._roster(home)} + listed = set(list_profile_names()) | {"default"} + assert roster_names <= listed + assert roster_names == {"default", "researcher", "trade-ops2"} + + +def test_profile_id_regex_mirror_stays_in_sync_with_canonical(): + """The roster's mirrored profile-id predicate must accept and reject exactly what the + canonical ``hermes_cli.profiles._PROFILE_ID_RE`` accepts and rejects — the #116905 bug + class was precisely these two predicates drifting apart (review follow-up).""" + from hermes_cli.profiles import _PROFILE_ID_RE as canonical + + samples = [ + "researcher", + "content-creator", + "trade-ops2", + "a", + "0", + "a" * 64, + "a" * 65, + "_backup_removed", + ".staging-area", + "has space", + "UPPER", + "default", + "-leading-dash", + "trailing-dash-", + "sla/sh", + "emoji-\U0001f916", + ] + for sample in samples: + assert bool(bot_mode_probe._PROFILE_ID_RE.match(sample)) == bool( + canonical.match(sample) + ), sample + + +def test_local_alias_map_ignores_non_profile_dirs(tmp_path): + """#116905 downstream: friendly-name aliases resolve only for real profiles, so a parked + backup dir's display name can never capture a ``message_agent`` target.""" + home = tmp_path / ".hermes" + home.mkdir() + researcher = _make_bot_profile(home, "researcher", managed=True) + (researcher / "profile.yaml").write_text( + textwrap.dedent( + """\ + display_name: Research Buddy + ui_meta: + hermes-bots: + shape: cloud + """ + ), + encoding="utf-8", + ) + stray = home / "profiles" / "_backup_removed_20260920" + stray.mkdir() + (stray / "profile.yaml").write_text( + textwrap.dedent( + """\ + ui_meta: + hermes-bots: + shape: cloud + display_name: Research Buddy + """ + ), + encoding="utf-8", + ) + + aliases = bot_mode_probe.local_alias_map(home) + assert aliases.get("research-buddy") == {"researcher"} + assert "_backup_removed_20260920" not in aliases + + def test_silent_when_no_profile_is_bot_managed(tmp_path): home = tmp_path / ".hermes" home.mkdir() diff --git a/tools/bot_mode_probe.py b/tools/bot_mode_probe.py index ea0c3f33c1..3f9f63d56c 100644 --- a/tools/bot_mode_probe.py +++ b/tools/bot_mode_probe.py @@ -20,6 +20,10 @@ from pathlib import Path _PROTOCOL_HEADING = "## Messaging other agents" # The legacy section through the next H2 heading (or EOF), plus the blank lines before it. _LEGACY_PROTOCOL_RE = re.compile(r"\n*" + re.escape(_PROTOCOL_HEADING) + r"[ \t]*\n.*?(?=\n## |\Z)", re.S) +# Mirrors hermes_cli.profiles._PROFILE_ID_RE (not imported: this module must stay +# import-light on the system-prompt path, and hermes_cli.profiles drags in the full +# profile machinery — archive/tar, subprocess, threading — for one regex). +_PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") def strip_legacy_protocol(text: str) -> str: @@ -72,7 +76,8 @@ def _handle(name: str) -> str: def _roster(root: Path) -> list[tuple[str, Path]]: """(name, dir) for the default profile + every live named profile, sorted. Same identity predicate as ``profile list``: infra dirs (``sessions/``, ``logs/``) and tombstones are not - teammates (#99392).""" + teammates (#99392), and neither is a marker-carrying dir whose name is not a profile id — + a parked backup or staging dir must never become a ``message_agent`` target (#116905).""" from hermes_constants import named_profile_is_live profiles = root / "profiles" @@ -80,7 +85,7 @@ def _roster(root: Path) -> list[tuple[str, Path]]: lambda: [ (c.name, c) for c in sorted(profiles.iterdir()) - if c.name != "default" and named_profile_is_live(c) + if c.name != "default" and _PROFILE_ID_RE.match(c.name) and named_profile_is_live(c) ] if profiles.is_dir() else [],