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.
This commit is contained in:
Tranquil-Flow
2026-09-20 12:33:53 +02:00
committed by Teknium
parent d325e53116
commit 1e8fb24bc1
2 changed files with 119 additions and 2 deletions

View File

@@ -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()

View File

@@ -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 [],