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:
@@ -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()
|
||||
|
||||
@@ -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 [],
|
||||
|
||||
Reference in New Issue
Block a user