Files
hermes-agent/tests/cron/test_bot_chat_delivery_child_env.py
John Paul Soliva 786c0e3f9d fix(cron): a bot-chat delivery builds its child env for the DESTINATION profile, not the sender
`deliver: bot-chat:<other profile>` spawned the destination's agent turn with the sending
gateway's whole environment: its `.env` settings, bridged `TERMINAL_*` policy, platform
authorization gates and provider credentials. The lane called
`strip_launch_profile_env(env)` with no target, so the strip resolved against the ambient home
override — which is the SENDER's home, never the destination's. On an ordinary root-profile
gateway (`hermes gateway run`, no `-p`) `_is_routed_home` is then false and the strip is a
complete no-op, including the #113270 gate strip that lives after its early return.

This is the only cron child built for a profile other than the one whose tick spawned it; the
worker lane (`scheduler.py`) targets its own home, so its no-target call is correct. Build this
one through `served_profile_child_env(target_home=home, inherit_credentials=True)` — the helper
`kanban_db_dispatch` and `web_server_gateway` already use for cross-profile spawns: it strips the
launch residue against the real target, scrubs credentials the launch process was given by
systemd/Compose/the shell (which no name-based strip can see), points TMPDIR at the destination's
scratch, and overlays the destination's own secrets, as a standalone `hermes -p <profile>` has.

A failure to build that environment (an unreadable target home under per-user 0700, a broken
secret source) is reported as a refusal string like every other failure in this lane rather than
raised: `_deliver_result`'s fan-out does not catch, unlike the deferred drain.

Regressions drive the real `_deliver_to_bot_chat`; removing the fix fails the two leak witnesses
(`HERMES_MODEL leaked from the launch profile`, and the firing profile's gate reaching another
profile's turn under an active override) and leaves the four guard tests green.

Fixes #117220

(cherry picked from commit 6cc81d7ddad8c9793f21fdda7c2e260c3ee1ca44)
2026-09-20 15:33:28 -07:00

160 lines
7.0 KiB
Python

"""``deliver: bot-chat:<other profile>`` spawns its turn with the TARGET profile's environment.
The Bot Chat CLI lane is the only cron child built for a profile other than the one whose tick
spawned it, so it is the only lane whose launch-residue strip cannot be resolved from the ambient
home override. It built the child env with ``strip_launch_profile_env(env)`` and no target, which
is a no-op whenever the gateway runs its own launch profile — the child then carried the launch
profile's ``.env`` settings, bridged ``TERMINAL_*`` policy, authorization gates (#113270) and
credentials into another profile's turn.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
import cron.scheduler_delivery as delivery
# What a gateway process that loaded the ROOT profile's .env holds in os.environ.
LAUNCH_ENV = {
"HERMES_MODEL": "root-model",
"HERMES_LANGUAGE": "en",
"TERMINAL_ENV": "docker",
"TERMINAL_DOCKER_IMAGE": "root-only-image",
"DISCORD_ALLOWED_USERS": "root-operator", # an authorization gate (#113270)
"DISCORD_IGNORED_CHANNELS": "999",
"ANTHROPIC_API_KEY": "sk-root",
}
@pytest.fixture
def fleet(tmp_path, monkeypatch):
"""A root-profile gateway (``hermes gateway run``) and a second profile to deliver into."""
root = tmp_path / "hermes"
beta = root / "profiles" / "beta"
beta.mkdir(parents=True)
(root / ".env").write_text(
"\n".join(f"{key}={value}" for key, value in LAUNCH_ENV.items()) + "\n", encoding="utf-8")
(beta / ".env").write_text("ANTHROPIC_API_KEY=sk-beta\nHERMES_LANGUAGE=ja\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
for key, value in LAUNCH_ENV.items():
monkeypatch.setenv(key, value)
return root, beta
def _capture_child_env(monkeypatch) -> dict:
"""Run the lane up to its spawn and return the env it would have used."""
captured: dict = {}
def _fake_turn(argv, env, report_path, timeout):
captured.update(env=dict(env), argv=list(argv))
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
monkeypatch.setattr(delivery, "_run_bot_chat_turn", _fake_turn)
return captured
def test_the_turn_for_another_profile_carries_that_profile_s_environment(fleet, monkeypatch):
root, beta = fleet
captured = _capture_child_env(monkeypatch)
assert delivery._deliver_to_bot_chat({"id": "j", "name": "nightly"}, "the brief", "beta") is None
env = captured["env"]
assert env["HERMES_HOME"] == str(beta)
# The launch profile's settings and bridged terminal policy are not beta's.
for key in ("HERMES_MODEL", "TERMINAL_ENV", "TERMINAL_DOCKER_IMAGE"):
assert key not in env, f"{key} leaked from the launch profile"
# Authorization gates decide who may talk to the agent — never inherited across profiles.
for key in ("DISCORD_ALLOWED_USERS", "DISCORD_IGNORED_CHANNELS"):
assert key not in env, f"{key} leaked from the launch profile (#113270)"
# Credentials are the target profile's own, not the gateway's.
assert env["ANTHROPIC_API_KEY"] == "sk-beta"
# Beta's own .env is loaded by the child itself; what matters here is that root's value is gone.
assert env.get("HERMES_LANGUAGE") != "en"
def test_a_delivery_into_the_gateway_s_own_bot_chat_keeps_its_environment(fleet, monkeypatch):
"""``deliver: bot-chat`` (no profile) is the job's own home: nothing to strip."""
root, _beta = fleet
captured = _capture_child_env(monkeypatch)
assert delivery._deliver_to_bot_chat({"id": "j", "name": "nightly"}, "the brief", "") is None
env = captured["env"]
assert env["HERMES_HOME"] == str(root)
assert env["HERMES_MODEL"] == "root-model"
assert env["TERMINAL_DOCKER_IMAGE"] == "root-only-image"
assert env["ANTHROPIC_API_KEY"] == "sk-root"
assert env["DISCORD_ALLOWED_USERS"] == "root-operator"
def test_the_delivery_child_still_runs_this_install_and_targets_the_named_chat(fleet, monkeypatch):
"""Guard rails the env change must not disturb: the running install, and `-p default`
only for a root home."""
root, beta = fleet
captured = _capture_child_env(monkeypatch)
delivery._deliver_to_bot_chat({"id": "j", "name": "nightly"}, "the brief", "beta")
argv = captured["argv"]
assert argv[:3] == [os.sys.executable, "-m", "hermes_cli.main"]
assert "-p" not in argv # a profile home is addressed by HERMES_HOME, not a flag
assert argv[3:10] == ["chat", "--in", "~", "-c", "Bot Chat", "--create-if-missing", "-Q"]
captured.clear()
delivery._deliver_to_bot_chat({"id": "j", "name": "nightly"}, "the brief", "")
assert captured["argv"][3:5] == ["-p", "default"] # a root home keeps its explicit profile flag
def test_a_missing_target_home_is_refused_before_any_child_is_built(fleet, monkeypatch):
root, beta = fleet
captured = _capture_child_env(monkeypatch)
(beta / ".env").unlink()
beta.rmdir()
result = delivery._deliver_to_bot_chat({"id": "j", "name": "nightly"}, "the brief", "beta")
assert result is not None and "no longer exists" in result
assert captured == {}
def test_an_unbuildable_target_environment_is_refused_and_no_turn_is_spawned(fleet, monkeypatch):
"""The lane reports every failure as a string; building the target env must not raise past it
(``_deliver_result``'s fan-out does not catch, unlike the deferred drain)."""
from tools.environments import local as local_env
_root, _beta = fleet
captured = _capture_child_env(monkeypatch)
monkeypatch.setattr(local_env, "served_profile_child_env",
lambda *a, **k: (_ for _ in ()).throw(PermissionError("home is 0700 for another user")))
result = delivery._deliver_to_bot_chat({"id": "j", "name": "nightly"}, "the brief", "beta")
assert result is not None and "do not resend" in result and "PermissionError" in result
assert captured == {}
def test_the_child_env_is_built_for_the_target_even_while_a_sibling_home_override_is_active(fleet, monkeypatch):
"""Under multiplexing the tick runs with the JOB's profile as the home override; the strip must
still be resolved against the delivery target, not against whichever home is ambient."""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
root, beta = fleet
gamma = root / "profiles" / "gamma"
gamma.mkdir(parents=True)
(gamma / ".env").write_text("GAMMA_ONLY=1\nDISCORD_ALLOWED_USERS=gamma-operator\n", encoding="utf-8")
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "gamma-operator")
captured = _capture_child_env(monkeypatch)
token = set_hermes_home_override(str(gamma))
try:
delivery._deliver_to_bot_chat({"id": "j", "name": "nightly"}, "the brief", "beta")
finally:
reset_hermes_home_override(token)
env = captured["env"]
assert env["HERMES_HOME"] == str(beta)
assert "DISCORD_ALLOWED_USERS" not in env, "the firing profile's gate reached another profile's turn"
assert env["ANTHROPIC_API_KEY"] == "sk-beta"