fix(a2a): scope multiplex secondary-profile construction, not shared env
A2AAdapter.__init__ / _default_agent_name / _load_served_agents read A2A_PORT, A2A_AGENT_NAME and A2A_AGENT_DESCRIPTION from raw os.environ, so a secondary multiplex profile borrowed the default profile's port and Agent Card identity. Skip the env read when constructed inside a secondary profile's scope (_profile_scoped(), #98738 pattern) and fall to config.extra / module defaults instead. The default profile keeps its unscoped env precedence. Salvaged from #100382 (tests trimmed to two).
This commit is contained in:
@@ -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": "",
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user