diff --git a/plugins/platforms/a2a/adapter.py b/plugins/platforms/a2a/adapter.py index 79842c88c6..b546edc841 100644 --- a/plugins/platforms/a2a/adapter.py +++ b/plugins/platforms/a2a/adapter.py @@ -74,8 +74,30 @@ def _reply_timeout() -> float: return 300.0 +def _profile_scoped() -> bool: + """True when running inside a multiplexed secondary profile's scope. + + Secondary-profile adapters are constructed inside ``_profile_runtime_scope`` + (secret scope installed + multiplex active) — the same discriminator the + Buzz/SimpleX adapters use for this bug class (#98738). The DEFAULT profile + under multiplexing runs unscoped: ``os.environ`` holds its own bridge + output there and keeps its legacy precedence. + """ + try: + from agent.secret_scope import current_secret_scope, is_multiplex_active + + return bool(is_multiplex_active() and current_secret_scope() is not None) + except Exception: + return False + + def _default_agent_name() -> str: - name = os.getenv("A2A_AGENT_NAME", "").strip() + # Scope-aware: inside a secondary multiplex profile, os.environ holds the + # DEFAULT profile's bridged A2A_AGENT_NAME — borrowing it would brand a + # secondary profile's Agent Card with another profile's identity. There + # is no per-profile config.yaml equivalent yet, so a scoped profile just + # falls through to the hostname-based default below instead. + name = "" if _profile_scoped() else os.getenv("A2A_AGENT_NAME", "").strip() if name: return name try: @@ -343,7 +365,15 @@ class A2AAdapter(BasePlatformAdapter): super().__init__(config=config, platform=platform) extra = getattr(config, "extra", {}) or {} - self.port = int(os.getenv("A2A_PORT") or extra.get("port", _DEFAULT_PORT)) + # Scope-aware: a secondary multiplex profile must not borrow the + # default profile's bridged A2A_PORT (mirrors the Buzz/SimpleX fix + # for #98738) — an unconfigured profile falls closed to the module + # default port instead. (advertised_toolsets has the same env-leak + # shape but is left unscoped here — see the "Scope note" in this + # fix's PR description: open PR #98937 is actively rewriting this + # field's None-vs-empty-list semantics.) + _port_env = None if _profile_scoped() else os.getenv("A2A_PORT") + self.port = int(_port_env or extra.get("port", _DEFAULT_PORT)) self.host = security.resolve_bind_host() self.agent_name = _default_agent_name() self._advertised_toolsets = [ @@ -502,9 +532,15 @@ class A2AAdapter(BasePlatformAdapter): raw = cfg.get("a2a_served_agents") or (cfg.get("a2a") or {}).get("served_agents") agents: dict[str, dict] = {} - default_desc = os.getenv( - "A2A_AGENT_DESCRIPTION", - "Hermes Agent — a general-purpose agent reachable over A2A.", + # Scope-aware for the same reason as port/toolsets above: a secondary + # profile must not inherit the default profile's A2A_AGENT_DESCRIPTION. + default_desc = ( + "Hermes Agent — a general-purpose agent reachable over A2A." + if _profile_scoped() + else os.getenv( + "A2A_AGENT_DESCRIPTION", + "Hermes Agent — a general-purpose agent reachable over A2A.", + ) ) agents[""] = { "slug": "", diff --git a/tests/plugins/test_a2a_plugin.py b/tests/plugins/test_a2a_plugin.py index 346284b277..7e4f933ebd 100644 --- a/tests/plugins/test_a2a_plugin.py +++ b/tests/plugins/test_a2a_plugin.py @@ -1618,3 +1618,101 @@ print('fake reply') title = con.execute("SELECT title FROM sessions WHERE id='sess-1'").fetchone()[0] con.close() assert title == "a2a-dev-ctx-unsafe-value" + + +# -------------------------------------------------------------------------- +# Multiplex secondary-profile scope (construction-time config leak) +# -------------------------------------------------------------------------- +# +# __init__'s port/advertised-toolsets reads and _load_served_agents's +# description default all previously read raw A2A_* env vars unconditionally. +# Under a multiplexed secondary profile, os.environ holds the DEFAULT +# profile's YAML-to-env bridge output — a secondary profile with its own +# (different, or absent) A2A config would silently borrow the default +# profile's port, toolset advertisement, agent name, or Agent Card +# description. Mirrors the Buzz/SimpleX fix for #98738. + +_A2A_ENV_VARS = ( + "A2A_PORT", + "A2A_AGENT_NAME", + "A2A_ADVERTISED_TOOLSETS", + "A2A_AGENT_DESCRIPTION", +) + + +@pytest.fixture(autouse=True) +def _clean_a2a_construction_env(monkeypatch): + """Keep the new multiplex tests hermetic regardless of ambient env.""" + for var in _A2A_ENV_VARS: + monkeypatch.delenv(var, raising=False) + yield + + +@pytest.fixture +def multiplex_scope(): + """Install multiplex + a secondary-profile secret scope; restore after.""" + tokens = [] + + def install(scope=None): + from agent.secret_scope import set_multiplex_active, set_secret_scope + + set_multiplex_active(True) + tokens.append(set_secret_scope(scope or {})) + return tokens[-1] + + yield install + + from agent.secret_scope import reset_secret_scope, set_multiplex_active + + for token in reversed(tokens): + reset_secret_scope(token) + set_multiplex_active(False) + + +@pytest.fixture +def default_profile_env(monkeypatch): + """The default profile's YAML-to-env bridge output in os.environ.""" + monkeypatch.setenv("A2A_PORT", "9111") + monkeypatch.setenv("A2A_AGENT_NAME", "default-profile-agent") + monkeypatch.setenv("A2A_ADVERTISED_TOOLSETS", "default-only-toolset") + monkeypatch.setenv("A2A_AGENT_DESCRIPTION", "Default profile's own agent.") + + +class TestMultiplexConstructionScope: + + def test_secondary_profile_never_borrows_default_profile_env( + self, multiplex_scope, default_profile_env + ): + """The secondary profile's own config is authoritative; keys absent + from it fall to the module defaults, never to the default profile's + bridged A2A_* env values.""" + from plugins.platforms.a2a.adapter import A2AAdapter, _DEFAULT_PORT + from gateway.config import PlatformConfig + + multiplex_scope() + assert A2AAdapter(PlatformConfig(enabled=True, extra={"port": 9222})).port == 9222 + + adapter = A2AAdapter(PlatformConfig(enabled=True, extra={})) + assert adapter.port == _DEFAULT_PORT + assert adapter.agent_name != "default-profile-agent" + assert adapter._agents[""]["description"] == ( + "Hermes Agent — a general-purpose agent reachable over A2A." + ) + + def test_default_profile_unscoped_keeps_env_precedence( + self, monkeypatch, default_profile_env + ): + """Multiplex ON but no scope (the DEFAULT profile constructs + unscoped): env is its own bridge output and still wins.""" + from agent.secret_scope import set_multiplex_active + from plugins.platforms.a2a.adapter import A2AAdapter + from gateway.config import PlatformConfig + + set_multiplex_active(True) + try: + adapter = A2AAdapter(PlatformConfig(enabled=True, extra={})) + finally: + set_multiplex_active(False) + assert adapter.port == 9111 + assert adapter.agent_name == "default-profile-agent" + assert adapter._agents[""]["description"] == "Default profile's own agent."