{"repo":"NousResearch/hermes-agent","public_id":"13828","display_label":"Session context propagation fixes for tools","summary":"These items address a bug where session identity (HERMES_SESSION_KEY/ID) was not reliably available to tools in gateway and Desktop modes. The fixes bind and bridge session context through the API server and subprocess environment.","subsystem":"environments","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53300},"resolution":{"state":"partially_resolved","by":"pr-53300"},"freshness":{"state":"verified","last_verified_at":"2026-06-26T23:01:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.19428571428571428},"fix_edges":[{"issue_number":24100,"pr_number":53300,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":13828,"title":"gateway.session_context empty context values fall back to stale HERMES_SESSION_* env vars","url":null,"state":"closed"},{"kind":"issue","number":24100,"title":"[Bug]: gateway command approval prompts route to wrong Discord thread — stale os.environ session key leaks across concurrent gateway sessions","url":null,"state":"closed"},{"kind":"issue","number":55202,"title":"bug: HERMES_SESSION_KEY and HERMES_SESSION_ID unreliable in gateway/Desktop mode — tools cannot determine current session ID","url":null,"state":"closed"},{"kind":"issue","number":67083,"title":"[Bug]: terminal-tool subprocess launched from one Telegram thread's turn received a sibling thread's HERMES_SESSION_KEY, causing it to end the wrong session","url":null,"state":"closed"},{"kind":"pr","number":24105,"title":"fix(gateway): remove stale os.environ HERMES_SESSION_KEY write that leaks session key across concurrent gateway sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53300","number":53300}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":53300,"title":"fix(gateway): remove process-global HERMES_SESSION_KEY write that misroutes approval prompts across concurrent sessions","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_approve_deny_commands.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -15864,10 +15864,21 @@ def run_sync():\n             # `_resolve_turn_agent_config(message, …)`.\n             nonlocal message\n \n-            # session_key is now set via contextvars in _set_session_env()\n-            # (concurrency-safe). Keep os.environ as fallback for CLI/cron.\n-            os.environ[\"HERMES_SESSION_KEY\"] = session_key or \"\"\n-            \n+            # session_key is propagated via contextvars in _set_session_env()\n+            # (_SESSION_KEY) and via set_current_session_key() (_approval_session_key)\n+            # below — both concurrency-safe and inherited by tool worker threads.\n+            # We deliberately do NOT write os.environ[\"HERMES_SESSION_KEY\"] here:\n+            # os.environ is process-global, so concurrent gateway sessions (e.g.\n+            # two Discord threads) would clobber each other's value, and a tool\n+            # thread whose contextvar is unset would fall back to os.environ and\n+            # read the wrong session key — misrouting command-approval prompts to\n+            # the wrong thread (#24100). The non-gateway surfaces don't depend on\n+            # this write: CLI and cron bind the session via contextvars\n+            # (set_current_session_key / session context), and only the TUI\n+            # slash-worker *subprocess* exports HERMES_SESSION_KEY (from its own\n+            # --session-key argv, a separate process) — so removing this in-process\n+            # gateway write does not affect any of them.\n+\n             # Map platform enum to the platform hint key the agent understands.\n             # Platform.LOCAL (\"local\") maps to \"cli\"; others pass through as-is.\n             platform_key = \"cli\" if source.platform == Platform.LOCAL else source.platform.value"]},{"file":"tests/gateway/test_approve_deny_commands.py","hunks":["@@ -647,3 +647,229 @@ def test_no_callback_returns_approval_required(self):\n         assert result[\"approved\"] is False\n         assert result.get(\"status\") == \"pending_approval\"\n         assert result.get(\"approval_pending\") is True\n+\n+\n+# ------------------------------------------------------------------\n+# Regression: cross-session approval routing isolation (#24100)\n+# ------------------------------------------------------------------\n+\n+\n+class TestCrossSessionApprovalIsolation:\n+    \"\"\"Regression for #24100.\n+\n+    The gateway used to write the per-turn session key to the\n+    process-global ``os.environ[\"HERMES_SESSION_KEY\"]`` inside\n+    ``GatewayRunner._run_agent``. Because ``os.environ`` is process-global,\n+    a concurrent gateway session (e.g. a second Discord thread) clobbered\n+    the value, and a tool worker thread whose approval contextvar was unset\n+    fell back to ``os.environ`` and read the *wrong* session key — routing\n+    the \"Command Approval Required\" prompt to the wrong thread.\n+\n+    The fix removes that ``os.environ`` write; routing is driven solely by\n+    the ``_approval_session_key`` contextvar. These tests assert that a\n+    worker thread carrying session A's contextvar resolves to session A\n+    even when ``os.environ`` has been clobbered to session B.\n+    \"\"\"\n+\n+    def setup_method(self):\n+        _clear_approval_state()\n+        os.environ.pop(\"HERMES_SESSION_KEY\", None)\n+\n+    def teardown_method(self):\n+        os.environ.pop(\"HERMES_SESSION_KEY\", None)\n+\n+    def test_contextvar_wins_over_clobbered_environ(self):\n+        \"\"\"get_current_session_key honors the contextvar, not stale env.\"\"\"\n+        from tools.approval import (\n+            get_current_session_key,\n+            reset_current_session_key,\n+            set_current_session_key,\n+        )\n+\n+        # Simulate a concurrent session B having written process-global env\n+        # last (the \"last writer wins\" clobber that caused #24100).\n+        os.environ[\"HERMES_SESSION_KEY\"] = \"session-B\"\n+\n+        token = [redacted:secret-assignment]\"session-A\")\n+        try:\n+            # The worker running under session A's contextvar must resolve\n+            # to session A, NOT the env-pinned session B.\n+            assert get_current_session_key() == \"session-A\"\n+        finally:\n+            reset_current_session_key(token)\n+\n+    def test_unset_contextvar_does_not_fall_back_to_clobbered_environ(self):\n+        \"\"\"The resolver must not leak a concurrent session's clobbered\n+        ``os.environ`` value once the session-context vars are cleared (#24100).\n+\n+        This exercises the resolver contract directly (not a separate worker\n+        thread): while the session-context var holds a key, resolution returns\n+        it; after ``clear_session_vars`` marks the vars explicitly cleared, the\n+        ``os.environ`` fallback is suppressed and resolution must NOT return the\n+        stale, process-global value a concurrent session wrote. Under the buggy\n+        gateway that value would be another live session's key; here we assert\n+        the resolver never surfaces it.\n+        \"\"\"\n+        from gateway.session_context import clear_session_vars, set_session_vars\n+        from tools.approval import get_current_session_key\n+\n+        # Simulate: concurrent session B was the last to clobber os.environ\n+        # under the OLD buggy gateway. With the fix this write never happens,\n+        # but we set it here to prove the resolver no longer trusts it once\n+        # the session-context contextvars are explicitly cleared (as the\n+        # gateway does in its finally block via clear_session_vars()).\n+        os.environ[\"HERMES_SESSION_KEY\"] = \"session-B-stale\"\n+\n+        # The gateway explicitly clears its session contextvars at turn end;\n+        # clear_session_vars sets them to \"\" to *suppress* the os.environ\n+        # fallback. A bare worker thread therefore must NOT see session-B.\n+        tokens = [redacted:secret-assignment]\"session-A\")\n+        try:\n+            assert get_current_session_key() == \"session-A\"\n+        finally:\n+            clear_session_vars(tokens)\n+\n+        # After clearing, resolution returns the explicit empty/default —\n+        # never the clobbered process-global value from session B.\n+        assert get_current_session_key() != \"session-B-stale\", (\n+            \"resolver leaked a concurrent session's clobbered os.environ \"\n+            \"value — #24100 regression\"\n+        )\n+\n+    def test_approval_prompt_routes_to_originating_session(self):\n+        \"\"\"A dangerous command in session A's worker thread notifies\n+        session A's callback, even though os.environ points at session B.\"\"\"\n+        from tools.approval import (\n+            check_all_command_guards,\n+            register_gateway_notify,\n+            reset_current_session_key,\n+            resolve_gateway_approval,\n+            set_current_session_key,\n+            unregister_gateway_notify,\n+        )\n+        notified_a = []\n+        notified_b = []\n+        register_gateway_notify(\"session-A\", lambda d: notified_a.append(d))\n+        register_gateway_notify(\"session-B\", lambda d: notified_b.append(d))\n+\n+        # Concurrent session B clobbered the process-global env var last.\n+        os.environ[\"HERMES_SESSION_KEY\"] = \"session-B\"\n+        os.environ[\"HERMES_GATEWAY_SESSION\"] = \"1\"\n+        os.environ[\"HERMES_EXEC_ASK\"] = \"1\"\n+\n+        result_holder = [None]\n+\n+        def worker_a():\n+            # This worker belongs to session A — only its contextvar is set;\n+            # it deliberately does NOT touch os.environ (mirroring the fixed\n+            # gateway, which no longer writes HERMES_SESSION_KEY).\n+            token = [redacted:secret-assignment]\"session-A\")\n+            try:\n+                result_holder[0] = check_all_command_guards(\n+                    \"rm -rf /important\", \"local\"\n+                )\n+            finally:\n+                reset_current_session_key(token)\n+\n+        t = threading.Thread(target=worker_a)\n+        t.start()\n+        try:\n+            for _ in range(50):\n+                if notified_a or notified_b:\n+                    break\n+                time.sleep(0.05)\n+\n+            # The prompt must land in session A (the originator), never B.\n+            assert len(notified_a) == 1, \"approval prompt did not route to session A\"\n+            assert len(notified_b) == 0, \"approval prompt leaked to session B (#24100)\"\n+            assert \"rm -rf /important\" in notified_a[0][\"command\"]\n+\n+            resolve_gateway_approval(\"session-A\", \"once\")\n+            t.join(timeout=5)\n+            assert result_holder[0] is not None\n+            assert result_holder[0][\"approved\"] is True\n+        finally:\n+            os.environ.pop(\"HERMES_GATEWAY_SESSION\", None)\n+            os.environ.pop(\"HERMES_EXEC_ASK\", None)\n+            unregister_gateway_notify(\"session-A\")\n+            unregister_gateway_notify(\"session-B\")\n+\n+    def test_two_concurrent_sessions_route_to_own_queue_contextvar_only(self):\n+        \"\"\"Cross-session isolation driven by contextvars ALONE (#24100).\n+\n+        Two concurrent worker threads with DISTINCT session keys each set\n+        only ``set_current_session_key()`` — they deliberately never write\n+        ``os.environ[\"HERMES_SESSION_KEY\"]``. This proves the contextvar is\n+        sufficient post-fix, and would FAIL if contextvar routing regressed\n+        (the prior 'parallel' tests share one key and dual-set env+contextvar,\n+        so they cannot guard this invariant). Each session's dangerous command\n+        must land in its OWN gateway queue, and resolving one must not resolve\n+        the other.\n+        \"\"\"\n+        from tools.approval import (\n+            _gateway_queues,\n+            check_all_command_guards,\n+            register_gateway_notify,\n+            reset_current_session_key,\n+            resolve_gateway_approval,\n+            set_current_session_key,\n+            unregister_gateway_notify,\n+        )\n+\n+        # No HERMES_SESSION_KEY in os.environ at all — pure contextvar routing.\n+        os.environ.pop(\"HERMES_SESSION_KEY\", None)\n+        os.environ[\"HERMES_GATEWAY_SESSION\"] = \"1\"\n+        os.environ[\"HERMES_EXEC_ASK\"] = \"1\"\n+\n+        register_gateway_notify(\"sess-A\", lambda d: None)\n+        register_gateway_notify(\"sess-B\", lambda d: None)\n+\n+        results = {\"sess-A\": None, \"sess-B\": None}\n+\n+        def worker(key, cmd):\n+            token = [redacted:secret-assignment]\n+            try:\n+                results[key] = check_all_command_guards(cmd, \"local\")\n+            finally:\n+                reset_current_session_key(token)\n+\n+        ta = threading.Thread(target=worker, args=(\"sess-A\", \"rm -rf /a-data\"))\n+        tb = threading.Thread(target=worker, args=(\"sess-B\", \"rm -rf /b-data\"))\n+        ta.start()\n+        tb.start()\n+        try:\n+            # Wait until both sessions have a pending approval in their queue.\n+            for _ in range(100):\n+                if (len(_gateway_queues.get(\"sess-A\", [])) >= 1\n+                        and len(_gateway_queues.get(\"sess-B\", [])) >= 1):\n+                    break\n+                time.sleep(0.05)\n+\n+            # Each command must be parked in its OWN session queue.\n+            qa = _gateway_queues.get(\"sess-A\", [])\n+            qb = _gateway_queues.get(\"sess-B\", [])\n+            assert len(qa) == 1, f\"sess-A queue should hold 1, got {len(qa)}\"\n+            assert len(qb) == 1, f\"sess-B queue should hold 1, got {len(qb)}\"\n+\n+            # Resolve ONLY sess-A; sess-B must stay blocked (no cross-leak).\n+            resolve_gateway_approval(\"sess-A\", \"once\")\n+            ta.join(timeout=5)\n+            assert results[\"sess-A\"] is not None\n+            assert results[\"sess-A\"][\"approved\"] is True\n+            assert results[\"sess-B\"] is None, \"sess-B resolved by sess-A's approval (#24100)\"\n+            assert len(_gateway_queues.get(\"sess-B\", [])) == 1\n+\n+            # Now resolve sess-B independently.\n+            resolve_gateway_approval(\"sess-B\", \"once\")\n+            tb.join(timeout=5)\n+            assert results[\"sess-B\"] is not None\n+            assert results[\"sess-B\"][\"approved\"] is True\n+        finally:\n+            resolve_gateway_approval(\"sess-A\", \"deny\")\n+            resolve_gateway_approval(\"sess-B\", \"deny\")\n+            ta.join(timeout=2)\n+            tb.join(timeout=2)\n+            os.environ.pop(\"HERMES_GATEWAY_SESSION\", None)\n+            os.environ.pop(\"HERMES_EXEC_ASK\", None)\n+            unregister_gateway_notify(\"sess-A\")\n+            unregister_gateway_notify(\"sess-B\")\n"]}]},{"kind":"pr","number":55333,"title":"fix: bridge session context into subprocess env","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cc395e8050dffe5c4cbb85acaca1a8fec2307e52","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}