fix(gateway): the launch profile's webhooks register at startup under multiplexing

#108319 moved hooks.outbound[].secret_env from os.environ to get_secret, which fails closed
outside a profile scope while multiplexing is on. The launch profile's hooks: block is
registered from start(), before any turn scope exists, so the first secret_env target raised
UnscopedSecretError, register_from_config propagated it, and _register_config_hooks swallowed
it at DEBUG — every launch-profile outbound webhook (signed or not) was silently dropped on a
multiplexed gateway. Secondaries were unaffected: they register inside their own scope.

The launch profile now gets the same treatment: _register_launch_profile_config_hooks enters
_profile_runtime_scope(get_process_hermes_home()) when multiplexing is active, then registers.
Not the environ fallback — a scopeless multi-profile read has no authority over the launch
env; the spawn site is where the scope belongs. The swallow logs at WARNING: a dropped hook
block is not debug noise.

Two invariant tests: multiplex on, no scope, secret in the launch .env -> both targets
register, the signed one with the launch secret (0 register on main); a registration failure
surfaces at WARNING.
This commit is contained in:
kshitijk4poor
2026-09-15 17:55:04 +05:30
committed by kshitij
parent 571a4e0c70
commit a8a2faab64
2 changed files with 88 additions and 1 deletions

View File

@@ -893,7 +893,26 @@ class GatewayStartupMixin:
send_relay_policy()
except Exception:
logger.warning("relay adapter registration failed at gateway startup", exc_info=True)
GatewayStartupMixin._register_config_hooks("shell-hook registration failed at gateway startup")
GatewayStartupMixin._register_launch_profile_config_hooks()
@staticmethod
def _register_launch_profile_config_hooks() -> None:
"""The launch profile's ``hooks:`` block, registered under ITS runtime scope when multiplexing.
Startup runs before any turn scope exists and ``get_secret`` fails closed outside a scope
while multiplexing is on, so the launch profile needs the scope secondaries already get
(``_start_secondary_profile_adapters``) or its ``secret_env`` targets cannot resolve.
"""
from agent.secret_scope import is_multiplex_active
if not is_multiplex_active():
GatewayStartupMixin._register_config_hooks(
"shell-hook/webhook registration failed at gateway startup", level=logging.WARNING)
return
from gateway.run import _profile_runtime_scope
from hermes_constants import get_process_hermes_home
with _profile_runtime_scope(get_process_hermes_home()):
GatewayStartupMixin._register_config_hooks(
"shell-hook/webhook registration failed at gateway startup", level=logging.WARNING)
@staticmethod
def _register_config_hooks(fail_fmt: str, *fail_args, level: int = logging.DEBUG) -> None:

View File

@@ -0,0 +1,68 @@
"""Multiplex invariant: the launch profile's ``hooks:`` block registers at gateway startup.
Startup runs before any turn scope exists. With ``multiplex_profiles`` on, ``get_secret`` fails
closed outside a scope, so the unscoped launch-profile registration raised on the first
``hooks.outbound[].secret_env`` target and ``_register_config_hooks`` swallowed it at DEBUG level —
every launch-profile outbound webhook was silently dropped. Regression for the #108319 follow-up.
"""
from __future__ import annotations
import logging
from agent import secret_scope
from gateway.run_startup import GatewayStartupMixin
def _capture_registration(monkeypatch, hooks_cfg):
seen: dict = {}
import agent.outbound_webhooks as ow
import agent.shell_hooks as sh
import hermes_cli.config as cfgmod
def fake_register_outbound(cfg):
seen["targets"] = ow.iter_configured_targets(cfg)
seen["scope"] = secret_scope.current_secret_scope()
monkeypatch.setattr(cfgmod, "load_config", lambda: hooks_cfg)
monkeypatch.setattr(sh, "register_from_config", lambda cfg, accept_hooks=False: None)
monkeypatch.setattr(ow, "register_from_config", fake_register_outbound)
return seen
def test_launch_profile_webhooks_register_signed_under_multiplex(tmp_path, monkeypatch, caplog):
launch_home = tmp_path / ".hermes"
launch_home.mkdir()
(launch_home / ".env").write_text("MY_HOOK_SECRET=s3cret-of-launch\n")
monkeypatch.setenv("HERMES_HOME", str(launch_home))
monkeypatch.delenv("MY_HOOK_SECRET", raising=False)
cfg = {"hooks": {"outbound": [
{"url": "https://hooks.example/signed", "events": ["on_session_end"], "secret_env": "MY_HOOK_SECRET"},
{"url": "https://hooks.example/plain", "events": ["on_session_end"]},
]}}
seen = _capture_registration(monkeypatch, cfg)
secret_scope.set_multiplex_active(True)
try:
assert secret_scope.current_secret_scope() is None # startup: no turn scope yet
with caplog.at_level(logging.WARNING, logger="gateway.run_startup"):
GatewayStartupMixin._register_launch_profile_config_hooks()
finally:
secret_scope.set_multiplex_active(False)
assert seen.get("scope") is not None, "registration must run inside the launch profile's scope"
assert [t.url for t in seen["targets"]] == ["https://hooks.example/signed", "https://hooks.example/plain"]
assert seen["targets"][0].secret == "s3cret-of-launch"
assert "registration failed" not in caplog.text
def test_registration_failure_is_a_warning_not_debug(monkeypatch, caplog):
"""A dropped hook block must be visible: the swallow used to log at DEBUG."""
import hermes_cli.config as cfgmod
def boom():
raise RuntimeError("config exploded")
monkeypatch.setattr(cfgmod, "load_config", boom)
with caplog.at_level(logging.WARNING, logger="gateway.run_startup"):
GatewayStartupMixin._register_launch_profile_config_hooks()
assert any(r.levelno == logging.WARNING and "registration failed" in r.getMessage() for r in caplog.records)