"""Cross-session ContextVar *inheritance* leak guard. Companion to ``tests/tools/test_local_env_session_leak.py``. That file covers the ``os.environ``-mirror leak (a subprocess inheriting a foreign *global* when this task's ContextVar is ``_UNSET``). THIS file covers a distinct, subtler variant that the ``_UNSET``-strip guard does NOT catch: Each gateway message is processed in its own asyncio task, created via ``create_task`` — which snapshots the spawning context with ``copy_context()``. If message B's task is created from a context where a *concurrent* message A had ALREADY called ``set_session_vars``, B inherits A's **set** ContextVars. Between B's task start and B's own ``set_session_vars`` call, any subprocess B spawns reads A's ``HERMES_SESSION_*`` identity through the subprocess-env bridge. The bridge's strip-on-``_UNSET`` rule is no help: the inherited vars are set-to-A, not ``_UNSET``. Verified in production 2026-06-21: a ``/bug`` turn ran ``bug_thread.py whoami`` and read a concurrent session's ticket (``cursor-captive-modals``) instead of its own, because its task inherited that session's bound ContextVars. The fix: ``gateway.session_context.reset_session_vars`` resets every session var to ``_UNSET`` at the top of the per-message handler (``GatewayRunner._handle_message``), *before* any work, so an inherited identity is dropped and the pre-bind window strips safe instead of leaking the sibling's. The handler then binds its own session a few steps later. """ import asyncio from contextvars import copy_context import pytest import gateway.session_context as sc from gateway.session_context import ( _SESSION_ASYNC_DELIVERY, _UNSET, _VAR_MAP, async_delivery_supported, reset_session_vars, set_session_vars, ) from tools.environments.local import _make_run_env SESSION_VARS = list(_VAR_MAP.keys()) MINE = dict( session_key="agent:main:discord:thread:MINE:MINE", platform="discord", chat_id="MINE_CHAT", thread_id="MINE_THREAD", user_id="MINE_USER", chat_name="mine", message_id="MINE_MSG", ) FOREIGN = dict( session_key="agent:main:discord:thread:FOREIGN:FOREIGN", platform="discord", chat_id="FOREIGN_CHAT", thread_id="FOREIGN_THREAD", user_id="FOREIGN_USER", chat_name="foreign", message_id="FOREIGN_MSG", ) @pytest.fixture(autouse=True) def _isolate_session_context(): """Clean ContextVar + engaged-latch slate per test, restored afterwards.""" import os saved_env = {k: os.environ.get(k) for k in SESSION_VARS} saved_ctx = {name: var.get() for name, var in _VAR_MAP.items()} saved_async = _SESSION_ASYNC_DELIVERY.get() saved_engaged = sc._session_context_engaged for var in _VAR_MAP.values(): var.set(_UNSET) _SESSION_ASYNC_DELIVERY.set(_UNSET) sc._session_context_engaged = True # a concurrent multi-session host is engaged try: yield finally: for var, val in zip(_VAR_MAP.values(), saved_ctx.values()): var.set(val) _SESSION_ASYNC_DELIVERY.set(saved_async) sc._session_context_engaged = saved_engaged for k, v in saved_env.items(): if v is None: os.environ.pop(k, None) else: os.environ[k] = v def _spawn_view(): """What a subprocess spawned right now would see for the session vars.""" env = _make_run_env({}) return { "HERMES_SESSION_CHAT_ID": env.get("HERMES_SESSION_CHAT_ID"), "HERMES_SESSION_THREAD_ID": env.get("HERMES_SESSION_THREAD_ID"), "HERMES_SESSION_KEY": env.get("HERMES_SESSION_KEY"), } async def _child_turn(reset_first: bool): """Simulate message B's processing task: created (copy_context) from a parent context where message A already bound its session. Returns the subprocess view from the *pre-bind window* — before B calls its own set_session_vars. With ``reset_first`` (the fix), B resets at entry. """ captured = {} def _b_body(): if reset_first: reset_session_vars() # THE FIX: handler-entry reset captured["window"] = _spawn_view() # pre-bind window set_session_vars(**FOREIGN) # B binds its own session captured["bound"] = _spawn_view() # create_task snapshots the CURRENT (A-bound) context, exactly like the # gateway's per-message dispatch. await asyncio.create_task(_async_noop(_b_body)) return captured async def _async_noop(fn): fn() def test_reset_session_vars_closes_inheritance_leak(): """THE FIX: resetting at handler entry strips the inherited identity. After reset_session_vars(), the pre-bind window must see NO session vars (stripped, because they are _UNSET in this context and the process is engaged) — NOT the parent's MINE_*. B's own bind then takes effect normally. """ set_session_vars(**MINE) # parent A binds in the current context captured = asyncio.run(_child_turn(reset_first=True)) window = captured["window"] for var in ("HERMES_SESSION_CHAT_ID", "HERMES_SESSION_THREAD_ID", "HERMES_SESSION_KEY"): assert window[var] is None, ( f"{var} leaked the parent session after reset: {window[var]!r}" ) # B's own session still binds correctly after the reset window. assert captured["bound"]["HERMES_SESSION_CHAT_ID"] == "FOREIGN_CHAT" assert captured["bound"]["HERMES_SESSION_KEY"] == FOREIGN["session_key"] # --------------------------------------------------------------------------- # Async-delivery capability inheritance (the sibling var outside _VAR_MAP) # --------------------------------------------------------------------------- # # ``_SESSION_ASYNC_DELIVERY`` is NOT in ``_VAR_MAP`` — it is a bool capability # flag read via ``async_delivery_supported()``, not a string ``HERMES_SESSION_*`` # var read via ``get_session_env``. So the ``for var in _VAR_MAP.values()`` loop # in ``reset_session_vars`` does not touch it; it must be reset explicitly. # # Without that explicit reset, a task created (copy_context) from a context where # a *concurrent* sibling A had bound ``async_delivery=False`` (the stateless API # server) inherits A's ``False``. In B's pre-bind window # ``async_delivery_supported()`` then wrongly reports B's channel as unable to # route a background completion — even though B is e.g. a real gateway turn that # CAN. Tools (terminal notify_on_complete / watch_patterns, delegate_task # background=True) would refuse a promise the channel could actually keep. async def _child_async_delivery(reset_first: bool): """Simulate message B's task created from a parent context where a stateless sibling A bound ``async_delivery=False``. Returns ``async_delivery_supported()`` as seen in B's pre-bind window. """ captured = {} def _b_body(): if reset_first: reset_session_vars() # THE FIX: handler-entry reset captured["window"] = async_delivery_supported() # pre-bind window await asyncio.create_task(_async_noop(_b_body)) return captured def test_reset_session_vars_closes_async_delivery_leak(): """THE FIX: resetting at handler entry drops the inherited async_delivery. After reset_session_vars(), the pre-bind window must fall back to the default-supported behavior (True) — NOT the stateless sibling's False — so a real gateway turn isn't wrongly told its channel can't route async delivery. """ set_session_vars(**FOREIGN, async_delivery=False) # stateless sibling A captured = asyncio.run(_child_async_delivery(reset_first=True)) assert captured["window"] is True, ( "After reset, async delivery must default to supported; " f"got {captured['window']!r}" )