Skip to content
77 changes: 77 additions & 0 deletions gateway/session_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
"""

import threading
from contextvars import ContextVar
from typing import Any

Expand Down Expand Up @@ -82,6 +83,13 @@
"HERMES_CRON_AUTO_DELIVER_THREAD_ID": _CRON_AUTO_DELIVER_THREAD_ID,
}

# Guards _VAR_MAP mutation + iteration. Plugin ``register(ctx)`` typically
# runs at startup, but ``get_registered_var_names()`` may be called from a
# different thread (diagnostics, test introspection). Without the lock,
# concurrent registration + iteration can raise ``RuntimeError: dictionary
# changed size during iteration``.
_VAR_MAP_LOCK = threading.Lock()


def set_session_vars(
platform: str = "",
Expand Down Expand Up @@ -152,6 +160,9 @@ def get_session_env(name: str, default: str = "") -> str:
this context — i.e. CLI, cron scheduler, and test processes that
don't use ``set_session_vars`` at all).
3. *default*

Plugin-registered names (via :func:`register_session_context_var`) are
resolved through the same path as built-in ``HERMES_SESSION_*`` names.
"""
import os

Expand All @@ -162,3 +173,69 @@ def get_session_env(name: str, default: str = "") -> str:
return value
# Fall back to os.environ for CLI, cron, and test compatibility
return os.getenv(name, default)


def register_session_context_var(name: str, var: ContextVar) -> None:
"""Register a plugin-owned ``ContextVar`` resolvable through
:func:`get_session_env` and the ``${context:NAME}`` template syntax in
MCP-server header config.

Plugins call this during ``register(ctx)`` so their per-task state is
visible to Hermes config resolution. The registered ``ContextVar``
follows the same resolution semantics as built-in ``HERMES_SESSION_*``
vars: explicitly set values (even ``""``) are returned as-is; the
sentinel ``_UNSET`` default triggers ``os.environ`` fallback.

Example::

from contextvars import ContextVar
from gateway.session_context import register_session_context_var

PRINCIPAL_EMAIL: ContextVar = ContextVar(
"PRINCIPAL_EMAIL", default="",
)

def register(ctx):
register_session_context_var("PRINCIPAL_EMAIL", PRINCIPAL_EMAIL)
# ... plugin sets PRINCIPAL_EMAIL.set(email) per turn

The config-side consumer (``tools/mcp_tool.py`` ``${context:NAME}``
expansion) reads via :func:`get_session_env`, so a registered name
becomes usable in ``config.yaml`` like::

mcp_servers:
my_server:
headers:
X-Principal-Email: "${context:PRINCIPAL_EMAIL}"

Registration is idempotent — repeated calls with the same name replace
the prior binding (last-writer-wins). Built-in names (``HERMES_SESSION_*``,
``HERMES_CRON_AUTO_DELIVER_*``) can be overridden but typically should
not be.
"""
if not isinstance(name, str) or not name or not name.isascii() or not name.isidentifier():
# The MCP-headers template regex (``${context:NAME}``) only matches
# ASCII identifier characters ``[A-Za-z_][A-Za-z0-9_]*``. Names
# outside that set (hyphens, spaces, unicode) would register
# successfully here but silently fail to resolve in templates —
# surface the invariant at registration time instead.
raise ValueError(
"register_session_context_var: name must be a valid ASCII "
"identifier matching the ${context:NAME} template regex",
)
if not isinstance(var, ContextVar):
raise TypeError("register_session_context_var: var must be a ContextVar")
with _VAR_MAP_LOCK:
_VAR_MAP[name] = var


def get_registered_var_names() -> tuple[str, ...]:
"""Return the set of names currently resolvable via :func:`get_session_env`.

Useful for diagnostics and tests. Includes both built-in and
plugin-registered names. Snapshot under the registry lock so concurrent
plugin registration cannot raise ``RuntimeError: dictionary changed
size during iteration``.
"""
with _VAR_MAP_LOCK:
return tuple(_VAR_MAP.keys())
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"0x.badfriend@gmail.com": "discodirector",
"altriatree@gmail.com": "TruaShamu",
"contact-me@stark-x.cn": "Stark-X",
"chungty@gmail.com": "chungty",
"nat@nthrow.io": "nthrow",
"m@mobrienv.dev": "mikeyobrien",
"saeed919@pm.me": "falasi",
Expand Down
149 changes: 149 additions & 0 deletions tests/gateway/test_session_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
from gateway.config import Platform
from gateway.run import GatewayRunner
from gateway.session import SessionContext, SessionSource
from contextvars import ContextVar

from gateway.session_context import (
get_session_env,
get_registered_var_names,
register_session_context_var,
set_session_vars,
clear_session_vars,
_VAR_MAP,
Expand Down Expand Up @@ -322,3 +326,148 @@ def blow_up():

with pytest.raises(ValueError, match="boom"):
await runner._run_in_executor_with_context(blow_up)


# ---------------------------------------------------------------------------
# register_session_context_var — plugin-extensible registry
# ---------------------------------------------------------------------------

def test_register_session_context_var_makes_name_resolvable(monkeypatch):
"""A registered ContextVar should resolve via get_session_env using
the registered name, even though it isn't a built-in HERMES_SESSION_*
name."""
var: ContextVar = ContextVar("CUSTOM_PRINCIPAL", default=_UNSET)
register_session_context_var("CUSTOM_PRINCIPAL", var)
monkeypatch.delenv("CUSTOM_PRINCIPAL", raising=False)
try:
var.set("alice@example.com")
assert get_session_env("CUSTOM_PRINCIPAL") == "alice@example.com"
finally:
var.set(_UNSET)
_VAR_MAP.pop("CUSTOM_PRINCIPAL", None)


def test_register_session_context_var_falls_back_to_env(monkeypatch):
"""When the ContextVar is _UNSET, the resolver falls back to os.environ
(same semantics as built-in vars)."""
var: ContextVar = ContextVar("CUSTOM_FALLBACK", default=_UNSET)
register_session_context_var("CUSTOM_FALLBACK", var)
monkeypatch.setenv("CUSTOM_FALLBACK", "from-env")
try:
assert get_session_env("CUSTOM_FALLBACK") == "from-env"
finally:
_VAR_MAP.pop("CUSTOM_FALLBACK", None)


def test_register_session_context_var_explicit_empty_does_not_fall_back(
monkeypatch,
):
"""Setting the ContextVar to "" is an explicit clear — the resolver
must return "" and NOT fall back to os.environ."""
var: ContextVar = ContextVar("CUSTOM_EMPTY", default=_UNSET)
register_session_context_var("CUSTOM_EMPTY", var)
monkeypatch.setenv("CUSTOM_EMPTY", "from-env-should-not-be-seen")
try:
var.set("")
assert get_session_env("CUSTOM_EMPTY") == ""
finally:
var.set(_UNSET)
_VAR_MAP.pop("CUSTOM_EMPTY", None)


@pytest.mark.parametrize(
"bad_name,exc_type",
[
("", ValueError),
("HAS-HYPHEN", ValueError),
("has space", ValueError),
("1_leading_digit", ValueError),
("café", ValueError), # non-ASCII
],
)
def test_register_session_context_var_rejects_invalid_inputs_and_keeps_registry_clean(
bad_name, exc_type,
):
"""Defensive checks — bad inputs raise before corrupting the registry.

The validity bar matches what the ``${context:NAME}`` template regex
will actually resolve: ASCII identifiers only. Names with hyphens,
spaces, leading digits, or unicode would register but silently fail
to resolve in templates — failing fast at registration is friendlier.

Also asserts the registry is *not* mutated on rejection (defends
against future refactors that move validation below the assignment).
"""
valid_var: ContextVar = ContextVar("X", default=_UNSET)
with pytest.raises(exc_type):
register_session_context_var(bad_name, valid_var)
assert bad_name not in get_registered_var_names()


def test_register_session_context_var_rejects_non_contextvar():
"""Type check rejects non-ContextVar values without mutating the registry."""
with pytest.raises(TypeError):
register_session_context_var("X", "not a contextvar") # type: ignore[arg-type]
# Name 'X' was the proposed key — it must NOT have been bound.
# (We can't assert "X" not in names if a previous test happened to
# register it; just confirm the registry didn't bind a string.)
from gateway.session_context import _VAR_MAP
binding = _VAR_MAP.get("X")
assert binding is None or isinstance(binding, ContextVar)


def test_register_session_context_var_is_idempotent_and_last_writer_wins():
"""Re-registering the same name swaps the binding (last writer wins)."""
a: ContextVar = ContextVar("DUP", default=_UNSET)
b: ContextVar = ContextVar("DUP", default=_UNSET)
register_session_context_var("DUP", a)
register_session_context_var("DUP", b)
try:
b.set("from-b")
a.set("from-a")
# b is the registered one — resolver should see "from-b".
assert get_session_env("DUP") == "from-b"
finally:
a.set(_UNSET)
b.set(_UNSET)
_VAR_MAP.pop("DUP", None)


def test_get_registered_var_names_includes_builtin_and_plugin():
"""The introspection helper lists both built-in and plugin-registered names."""
var: ContextVar = ContextVar("INTROSPECT_TEST", default=_UNSET)
register_session_context_var("INTROSPECT_TEST", var)
try:
names = get_registered_var_names()
assert "HERMES_SESSION_USER_ID" in names # built-in
assert "INTROSPECT_TEST" in names # plugin-registered
finally:
_VAR_MAP.pop("INTROSPECT_TEST", None)


@pytest.mark.asyncio
async def test_register_session_context_var_asyncio_task_isolation():
"""Custom ContextVars registered via the public API inherit the same
per-asyncio-task isolation as built-in HERMES_SESSION_* vars."""
var: ContextVar = ContextVar("ISO_TEST", default=_UNSET)
register_session_context_var("ISO_TEST", var)

results: dict[str, str] = {}

async def handler(label: str, value: str, delay: float):
token = var.set(value)
try:
await asyncio.sleep(delay)
results[label] = get_session_env("ISO_TEST")
finally:
var.reset(token)

try:
await asyncio.gather(
handler("a", "alice", 0.02),
handler("b", "bob", 0.01),
)
# Each task reads its own value back — no bleed.
assert results == {"a": "alice", "b": "bob"}
finally:
_VAR_MAP.pop("ISO_TEST", None)
5 changes: 4 additions & 1 deletion tests/hermes_cli/test_tui_npm_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,10 @@ def fail_run(*_args, **_kwargs):

argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)

assert argv == ["/bin/node", str(tmp_path / "dist" / "entry.js")]
# _make_tui_argv prepends --expose-gc per fix(tui): pass --expose-gc as
# node argv (commit 3d2f14646 — also at hermes_cli/main.py:1276,1282,1359).
# This test was missed when --expose-gc was added.
assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")]
assert cwd == tmp_path


Expand Down
Loading
Loading