Skip to content

fix(a2a): scope the HTTP handler thread to the adapter's own profile home - #77872

Open
pierrenode wants to merge 1 commit into
NousResearch:mainfrom
pierrenode:fix/a2a-multiplex-profile-contextvar
Open

fix(a2a): scope the HTTP handler thread to the adapter's own profile home#77872
pierrenode wants to merge 1 commit into
NousResearch:mainfrom
pierrenode:fix/a2a-multiplex-profile-contextvar

Conversation

@pierrenode

Copy link
Copy Markdown
Contributor

Summary

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 message-handler dispatch in gateway/run.py) propagates automatically via asyncio.Task's implicit copy_context(). A2A is architecturally different by design (its own 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.

Root cause / impact

Everything do_POST reaches before handing a message off to the async gateway path resolves get_hermes_home() directly:

  • security.is_trusted_peer()'s config.yaml fallback (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_HOME resolves 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's trusted_peers allow-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 inside gateway/run.py::_start_one_profile_adapters's with _profile_runtime_scope(profile_home): block — so this captures get_hermes_home() there (self._profile_home) and hands 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 all do the equivalent copy_context()/override-handoff when spawning a worker thread).

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 (do_GET doesn't need it: security.authenticate() is env-only, and the config.yaml-reading is_trusted_peer() path is only reachable from do_POST).

Test plan

  • Added TestMultiplexProfileScoping::test_do_post_persists_under_own_profile_not_ambient_home (tests/plugins/test_a2a_plugin.py, marked integration — starts a real A2AAdapter server 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 ambient HERMES_HOME at a different directory (wrong_home), sends a real message/send POST, and asserts the audit log + persisted conversation land under profile_home and never under wrong_home.
  • Mutation-verified two ways: (1) removing the self._profile_home capture — the test fails immediately with AttributeError; (2) a targeted mutation disabling only the do_POST wrapping (keeping _profile_home intact) — 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.
  • Ran the full A2A suite: tests/plugins/test_a2a_plugin.py + tests/plugins/test_a2a_phase23.py, both unit (151 tests) and integration-marked (18 tests, including the new one) — 169/169 pass.
  • ruff check clean on both changed files.
  • Grepped for other A2AAdapter( construction sites in tests/ and gateway/ — 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 only protocol.py/security.py (mode bits at file-creation time), never adapter.py — orthogonal fix, no overlap.

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant