A multiplexed gateway answered "which bot received this / who may admit it / where does it run" in three places (`_transport_owner`, `_authorization_home_for_source`, `_resolve_profile_home_for_source` + `_session_key_profile`) that agreed only because they read the same fallback chain. `gateway/session_identity.py` answers them once: `resolve_identity()` folds `_admit_primary_source` + `_stamp_routed_profile` + the transport-owner lookup and pins a frozen `RoutingIdentity` (transport_profile, runtime_profile, authorization_home, runtime_home, weak transport ref) on the source as a wire-invisible attribute, like `_transport_adapter_ref`. Under multiplexing a route to an unserved profile raises `IdentityUnresolved` instead of a `None`-means-default return; `"default"` is spelled out inside the object. Additive: the existing helpers become thin readers of the identity when it is present and keep their fallback chain when it is not, `source.profile` stays the serialized runtime profile (None on the wire ⇔ default) and every historical `agent:main` key is byte-identical. `replace_source()` copies a source without losing its provenance (run_topics used to hand-copy the transport ref). Phase 1 of #88715; the gateway rows of #90142 / #93943.
148 lines
7.4 KiB
Python
148 lines
7.4 KiB
Python
"""Invariants for ``gateway/session_identity.py`` (#88715 phase 1).
|
|
|
|
One frozen ``RoutingIdentity`` per inbound event, resolved by the real ``GatewayRunner`` resolvers
|
|
(no patched predicates) against a temp ``HERMES_HOME`` with two served profiles.
|
|
"""
|
|
|
|
import dataclasses
|
|
import weakref
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
|
from gateway.pairing import PairingStore
|
|
from gateway.platforms.base import BasePlatformAdapter
|
|
from gateway.profile_routing import parse_profile_routes
|
|
from gateway.session import SessionSource, build_session_key
|
|
from gateway.session_identity import (
|
|
IdentityUnresolved, RoutingIdentity, identity_of, replace_source, resolve_identity,
|
|
)
|
|
|
|
|
|
class _Stub(BasePlatformAdapter):
|
|
pass
|
|
|
|
|
|
_Stub.__abstractmethods__ = frozenset()
|
|
|
|
|
|
def _stub(platform, runner, label):
|
|
adapter = _Stub.__new__(_Stub)
|
|
adapter.platform, adapter.gateway_runner, adapter.label = platform, runner, label
|
|
adapter.config = PlatformConfig(enabled=True, extra={})
|
|
adapter._pending_messages, adapter._active_sessions = {}, {}
|
|
return adapter
|
|
|
|
|
|
def _runner(home, *, multiplex, routes=()):
|
|
from gateway.run import GatewayRunner
|
|
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.config = GatewayConfig(multiplex_profiles=multiplex)
|
|
runner.config.platforms = {Platform.TELEGRAM: PlatformConfig(enabled=True, extra={})}
|
|
runner.config.profile_routes = parse_profile_routes(list(routes))
|
|
runner.pairing_store = PairingStore(profile="default")
|
|
runner.pairing_stores = {}
|
|
runner._primary_profile_name = "default"
|
|
primary = _stub(Platform.TELEGRAM, runner, "PRIMARY")
|
|
team_b = _stub(Platform.TELEGRAM, runner, "TEAM_B")
|
|
team_b.set_owner_profile("team_b")
|
|
runner.adapters = {Platform.TELEGRAM: primary}
|
|
runner._profile_adapters = {"team_b": {Platform.TELEGRAM: team_b}, "ops": {}}
|
|
return SimpleNamespace(runner=runner, home=home, primary=primary, team_b=team_b)
|
|
|
|
|
|
@pytest.fixture
|
|
def mux(tmp_path, monkeypatch):
|
|
"""Default home + satellite ``ops`` (routed through the shared bot for chat 72719239) + ``team_b``
|
|
owning its own Telegram bot; a route to unserved ``ghost`` for chat 4040."""
|
|
home = tmp_path / "hh"
|
|
for name in ("ops", "team_b"):
|
|
(home / "profiles" / name).mkdir(parents=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
served = [("default", home), ("ops", home / "profiles" / "ops"), ("team_b", home / "profiles" / "team_b")]
|
|
rig = _runner(home, multiplex=True, routes=[
|
|
{"name": "admin-dm", "platform": "telegram", "profile": "ops", "chat_id": "72719239"},
|
|
{"name": "ghost", "platform": "telegram", "profile": "ghost", "chat_id": "4040"},
|
|
])
|
|
with patch("hermes_cli.profiles.profiles_to_serve", return_value=served), \
|
|
patch("hermes_cli.profiles.get_profile_dir", side_effect=lambda n: home if n == "default" else home / "profiles" / n), \
|
|
patch("hermes_cli.profiles.profile_exists", return_value=True):
|
|
yield rig
|
|
|
|
|
|
def test_identity_is_one_frozen_value_that_every_reader_agrees_on(mux):
|
|
"""Shared bot → routed satellite: transport stays default (authorization), runtime is ``ops``;
|
|
the pinned identity is frozen, equal by value, and the legacy readers all report the same
|
|
answer it does. A dedicated secondary resolves to itself on both axes."""
|
|
routed = mux.primary.build_source(chat_id="72719239", chat_type="dm", user_id="72719239")
|
|
identity = resolve_identity(routed, runner=mux.runner)
|
|
|
|
assert identity == RoutingIdentity(
|
|
transport_profile="default", runtime_profile="ops", authorization_home=mux.home,
|
|
runtime_home=mux.home / "profiles" / "ops")
|
|
assert identity.namespace == "agent:ops" and identity.store_path == mux.home / "profiles" / "ops" / "state.db"
|
|
assert identity.adapter() is mux.primary
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
identity.runtime_profile = "default"
|
|
assert identity_of(routed) is identity
|
|
# Thin readers: same object, same answers — no second derivation.
|
|
assert mux.runner._authorization_home_for_source(routed) == mux.home
|
|
assert mux.runner._resolve_profile_home_for_source(routed) == mux.home / "profiles" / "ops"
|
|
assert mux.runner._transport_owner(routed) == (mux.primary, None)
|
|
assert mux.primary._source_session_key(routed) == "agent:ops:telegram:dm:72719239"
|
|
assert mux.runner._session_key_for_source(routed) == mux.primary._source_session_key(routed)
|
|
|
|
own = mux.team_b.build_source(chat_id="72719239", chat_type="dm", user_id="72719239")
|
|
own_identity = resolve_identity(own, runner=mux.runner, transport_profile="team_b")
|
|
assert (own_identity.transport_profile, own_identity.runtime_profile) == ("team_b", "team_b")
|
|
assert own_identity.authorization_home == own_identity.runtime_home == mux.home / "profiles" / "team_b"
|
|
assert own_identity != identity
|
|
# Provenance is not identity: a second event from the same bot has the same identity.
|
|
again = mux.primary.build_source(chat_id="72719239", chat_type="dm", user_id="72719239")
|
|
assert resolve_identity(again, runner=mux.runner) == identity
|
|
assert hash(resolve_identity(again, runner=mux.runner)) == hash(identity)
|
|
|
|
|
|
def test_unresolved_under_multiplex_raises_and_never_means_default(mux, tmp_path, monkeypatch):
|
|
"""A route to an unserved profile raises ``IdentityUnresolved`` (the runner drops the event);
|
|
outside multiplexing the same source resolves to an explicit default identity whose keys stay
|
|
byte-identical to the legacy ``agent:main`` namespace."""
|
|
rejected = mux.primary.build_source(chat_id="4040", chat_type="dm", user_id="4040")
|
|
with pytest.raises(IdentityUnresolved):
|
|
resolve_identity(rejected, runner=mux.runner)
|
|
assert identity_of(rejected) is None
|
|
assert mux.runner._admit_primary_source(rejected, mux.home) is None
|
|
|
|
solo_home = tmp_path / "solo"
|
|
solo_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(solo_home))
|
|
solo = _runner(solo_home, multiplex=False)
|
|
source = solo.primary.build_source(chat_id="4040", chat_type="dm", user_id="4040")
|
|
identity = resolve_identity(source, runner=solo.runner)
|
|
assert (identity.transport_profile, identity.runtime_profile, identity.multiplexed) == ("default", "default", False)
|
|
assert identity.runtime_home == solo_home
|
|
assert identity.namespace == "agent:main"
|
|
assert solo.primary._source_session_key(source) == build_session_key(source) == "agent:main:telegram:dm:4040"
|
|
assert source.profile is None # wire format untouched
|
|
assert solo.runner._authorization_home_for_source(source) is None # ambient scope, as before
|
|
|
|
|
|
def test_replace_source_keeps_identity_and_transport_where_dataclasses_replace_drops_them(mux):
|
|
routed = mux.primary.build_source(chat_id="72719239", chat_type="dm", user_id="72719239")
|
|
identity = resolve_identity(routed, runner=mux.runner)
|
|
|
|
bare = dataclasses.replace(routed)
|
|
assert identity_of(bare) is None and mux.runner._transport_owner(bare) is None
|
|
|
|
copied = replace_source(routed, thread_id="7")
|
|
assert copied.thread_id == "7" and copied is not routed
|
|
assert identity_of(copied) is identity
|
|
assert mux.runner._transport_owner(copied) == (mux.primary, None)
|
|
assert isinstance(copied._transport_adapter_ref, weakref.ref)
|
|
assert Path(copied._authorization_profile_home) == mux.home
|
|
assert mux.primary._source_session_key(copied) == "agent:ops:telegram:dm:72719239:7"
|