fix(a2a): scope the HTTP handler thread to the adapter's own profile home - #77872
Open
pierrenode wants to merge 1 commit into
Open
fix(a2a): scope the HTTP handler thread to the adapter's own profile home#77872pierrenode wants to merge 1 commit into
pierrenode wants to merge 1 commit into
Conversation
…home Every other platform adapter processes inbound requests as coroutines inside the gateway's asyncio loop, so the multiplexer's per-profile _HERMES_HOME_OVERRIDE contextvar (set around adapter creation and the message-handler dispatch in gateway/run.py) propagates automatically via asyncio.Task's implicit copy_context(). A2A is architecturally different by design (DESIGN.md: no event-loop dependency at register() time): it runs http.server.ThreadingHTTPServer in a plain threading.Thread, and each inbound connection is handled on a fresh native OS thread. Plain threading.Thread does not copy contextvars — only asyncio.Task/copy_context().run() do — so do_POST runs with zero ambient profile context. Everything do_POST reaches before handing a message off to the async gateway path resolves get_hermes_home() directly: is_trusted_peer()'s config.yaml fallback (a2a.trusted_peers), security.audit(), and protocol.persist_message()/load_conversation(). With no override active, all four fall back through to whatever HERMES_HOME resolves to process-wide — the default profile's home whenever A2A is configured on a secondary multiplex profile. A peer that should be rejected by one profile's trusted_peers allow-list could instead be checked against another profile's (or the default's), and that profile's inbound conversations/audit trail land in the wrong profile's on-disk store. A2AAdapter.__init__ already runs correctly scoped — it's constructed inside gateway/run.py::_start_one_profile_adapters's `with _profile_runtime_scope(profile_home):` block — so capture get_hermes_home() there (self._profile_home) and hand it to the HTTP handler thread explicitly: do_POST now wraps the whole request in set_hermes_home_override(self.adapter._profile_home) before dispatching to the (renamed) _do_POST_scoped, matching the codebase's established idiom for exactly this class of thread boundary (cron/scheduler.py, tools/delegate_tool.py, acp_adapter/server.py, tui_gateway/server.py). The actual agent turn dispatch was already safe independently — gateway/run.py::_make_profile_message_handler re-enters _profile_runtime_scope inside the handler coroutine regardless of caller context — so this fix only needs to cover the raw-thread-side work that runs before that handoff.
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every other platform adapter processes inbound requests as coroutines inside the gateway's asyncio loop, so the multiplexer's per-profile
_HERMES_HOME_OVERRIDEcontextvar (set around adapter creation and message-handler dispatch ingateway/run.py) propagates automatically viaasyncio.Task's implicitcopy_context(). A2A is architecturally different by design (its ownDESIGN.md: no event-loop dependency atregister()time) — it runshttp.server.ThreadingHTTPServerin a plainthreading.Thread, and each inbound connection is handled on a fresh native OS thread. Plainthreading.Threaddoes not copy contextvars — onlyasyncio.Task/copy_context().run()do — sodo_POSTruns with zero ambient profile context.Root cause / impact
Everything
do_POSTreaches before handing a message off to the async gateway path resolvesget_hermes_home()directly:security.is_trusted_peer()'sconfig.yamlfallback (a2a.trusted_peers)security.audit()protocol.persist_message()/protocol.load_conversation()With no override active on the connection thread, all four fall back through to whatever
HERMES_HOMEresolves to process-wide — the default profile's home, whenever A2A is configured on a secondary multiplex profile. Concretely: a peer that should be rejected by profile B'strusted_peersallow-list could instead be checked against the default profile's (or another profile's) allow-list, and profile B's inbound conversations/audit trail land in the wrong profile's on-disk store — a cross-profile trust-check and data-isolation gap.Fix
A2AAdapter.__init__already runs correctly scoped — it's constructed insidegateway/run.py::_start_one_profile_adapters'swith _profile_runtime_scope(profile_home):block — so this capturesget_hermes_home()there (self._profile_home) and hands it to the HTTP handler thread explicitly.do_POSTnow wraps the whole request inset_hermes_home_override(self.adapter._profile_home)before dispatching to the (renamed)_do_POST_scoped, matching the codebase's established idiom for exactly this class of thread boundary (cron/scheduler.py,tools/delegate_tool.py,acp_adapter/server.py,tui_gateway/server.pyall do the equivalentcopy_context()/override-handoff when spawning a worker thread).The actual agent turn dispatch was already safe independently —
gateway/run.py::_make_profile_message_handlerre-enters_profile_runtime_scopeinside the handler coroutine regardless of caller context — so this fix only needs to cover the raw-thread-side work that runs before that handoff (do_GETdoesn't need it:security.authenticate()is env-only, and the config.yaml-readingis_trusted_peer()path is only reachable fromdo_POST).Test plan
TestMultiplexProfileScoping::test_do_post_persists_under_own_profile_not_ambient_home(tests/plugins/test_a2a_plugin.py, markedintegration— starts a realA2AAdapterserver and sends a real HTTP request). Constructs the adapter under a context-local override for one directory (profile_home), resets the override immediately after construction (simulating the real gap: the connection thread ThreadingHTTPServer spawns never inherits it in the first place), points ambientHERMES_HOMEat a different directory (wrong_home), sends a realmessage/sendPOST, and asserts the audit log + persisted conversation land underprofile_homeand never underwrong_home.self._profile_homecapture — the test fails immediately withAttributeError; (2) a targeted mutation disabling only thedo_POSTwrapping (keeping_profile_homeintact) — the test fails on the precise assertion (audit log must land under the adapter's own profile home), proving the artifacts land in the wrong place without the wrap, exactly as diagnosed.tests/plugins/test_a2a_plugin.py+tests/plugins/test_a2a_phase23.py, both unit (151 tests) andintegration-marked (18 tests, including the new one) — 169/169 pass.ruff checkclean on both changed files.A2AAdapter(construction sites intests/andgateway/— only the two already-passing test files construct it directly; no other call site needed updating.Competitor check
Searched multiple keyword combinations (
a2a threading contextvar,a2a profile home,a2a do_POST,a2a ThreadingHTTPServer,a2a multiplex isolation) against open/closed PRs and issues — no PR or issue targets this specific gap. One adjacent, non-conflicting open PR — #77655 — hardens file permissions (0o755→0o700, 0o644→0o600) on the same two on-disk artifacts (a2a_conversations/,a2a_audit.jsonl) but touches onlyprotocol.py/security.py(mode bits at file-creation time), neveradapter.py— orthogonal fix, no overlap.