Skip to content

[Feat] Add a2a custom headers#22888

Merged
Sameerlite merged 11 commits intomainfrom
litellm_a2a-custom-headers
Mar 6, 2026
Merged

[Feat] Add a2a custom headers#22888
Sameerlite merged 11 commits intomainfrom
litellm_a2a-custom-headers

Conversation

@Sameerlite
Copy link
Collaborator

@Sameerlite Sameerlite commented Mar 5, 2026

Relevant issues

Fixes LIT-1945

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Type

🆕 New Feature
🐛 Bug Fix
🧹 Refactoring
📖 Documentation
🚄 Infrastructure
✅ Test

Changes

Sameerlite and others added 9 commits March 5, 2026 14:27
…nd types

Add two new fields to LiteLLM_AgentsTable:
- static_headers (Json): admin-configured headers always sent to the backend agent
- extra_headers (String[]): header names to extract from the client request and forward

Extend AgentConfig, PatchAgentRequest, and AgentResponse with the same fields.
Also remove duplicate spec_path field from LiteLLM_MCPServerTable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update add_agent_to_db, patch_agent_in_db, and update_agent_in_db to
read and write the two new header fields when creating or updating agents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors merge_mcp_headers from the MCP server utils.
Dynamic headers come first; static (admin-configured) headers overlay and win on conflict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In invoke_agent_a2a:
- Extract admin-configured extra_headers from client request by name
- Extract convention-based headers (x-a2a-{agent_id/name}-{header}) from client request
- Merge with static_headers (static wins on conflict)
- Pass merged headers down to asend_message and _handle_stream_message

In asend_message / asend_message_streaming:
- Accept agent_extra_headers kwarg
- Overlay onto LiteLLM internal headers before creating the httpx client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ALTER TABLE LiteLLM_AgentsTable to add:
- static_headers JSONB DEFAULT '{}'
- extra_headers TEXT[] DEFAULT ARRAY[]::TEXT[]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers:
- Static headers forwarded to backend
- Dynamic headers extracted by name (extra_headers config)
- Convention-based x-a2a-{agent_id/name}-{header} forwarding
- Static headers win over dynamic on conflict
- Unrelated x-a2a- prefixes are not forwarded
- No-header case leaves existing behaviour unchanged
- merge_agent_headers utility unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…dit form

Add a new "Authentication Headers" panel to AgentFormFields:
- Static Headers: key-value Form.List (always sent to the backend agent,
  static wins on conflict with dynamic)
- Forward Client Headers: Select[tags] of header names to extract from
  the client request and forward (extra_headers)

Update buildAgentDataFromForm to serialize both fields for the API.
Update parseAgentForForm to deserialize them back for editing.
Covers both the create wizard (add_agent_form) and the edit view (agent_info).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New page: docs/a2a_agent_headers.md

Covers all three header forwarding methods:
- Static headers (admin-configured, always sent)
- Forward client headers (extra_headers — admin lists names, client supplies values)
- Convention-based x-a2a-{agent_name/id}-{header} (no admin config needed)

Documents merge precedence (static wins), header isolation guarantee,
combining all three methods, and API reference for static_headers / extra_headers fields.

Registered in sidebars.js under /a2a - A2A Agent Gateway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 5, 2026 10:48am

Request Review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 5, 2026

Greptile Summary

This PR adds three complementary mechanisms for forwarding authentication headers from LiteLLM clients to backend A2A agents: static headers (admin-configured, always sent), forward headers (admin-specified header names extracted from the client request), and convention-based headers (x-a2a-{agent_id/name}-{header} extracted automatically).

Key changes:

  • litellm/a2a_protocol/main.pycreate_a2a_client creates a fresh httpx.AsyncClient per invocation with extra_headers baked in at construction time
  • litellm/proxy/agent_endpoints/a2a_endpoints.py — extracts and merges static + dynamic headers before forwarding
  • litellm/proxy/agent_endpoints/utils.pymerge_agent_headers performs case-sensitive dict merge (static overlay wins)
  • litellm/proxy/agent_endpoints/agent_registry.py — CRUD DB helpers extended with static_headers / extra_headers
  • litellm/types/agents.pyAgentConfig, PatchAgentRequest, AgentResponse extended
  • litellm/proxy/schema.prisma + migration SQL — adds static_headers JSONB and extra_headers TEXT[] columns
  • UI form in agent_form_fields.tsx / agent_config.ts — new "Authentication Headers" panel

Outstanding concerns:

  • static_headers values are returned verbatim in all AgentResponse API payloads — admin credentials are exposed to any authenticated reader of the agents endpoint
  • UI static-header value field uses plain <Input> instead of <Input.Password>, displaying secrets in plaintext while an admin fills in the form
  • Test helper _make_mock_agent does not set agent_name, leaving the convention-prefix code path with an unpredictable MagicMock object instead of a real string, causing incomplete test coverage
  • Pre-submission checklist items are all unchecked, including the mandatory test requirement

Confidence Score: 3/5

  • Has credential exposure issues (API responses, UI plaintext) and incomplete test coverage that should be resolved before merge.
  • The core httpx client isolation and header forwarding mechanisms are well-implemented, but the PR introduces credential exposure vectors: static headers (which may contain API keys or bearer tokens) are returned in plain text in API responses to any authenticated reader, and the UI form displays these credentials in plaintext while editing. Additionally, test setup incompleteness means the convention-based header matching for agent_name is not properly covered. These are security/quality issues worth addressing before merge.
  • litellm/types/agents.py (credential exposure in AgentResponse), ui/litellm-dashboard/src/components/agents/agent_form_fields.tsx (plaintext credential input), tests/test_litellm/proxy/agent_endpoints/test_agent_headers.py and test_agent_header_isolation.py (incomplete test mocking)

Sequence Diagram

sequenceDiagram
    participant Client
    participant LiteLLMProxy as LiteLLM Proxy<br/>(invoke_agent_a2a)
    participant Registry as AgentRegistry
    participant Merge as merge_agent_headers
    participant A2AClient as create_a2a_client<br/>(fresh httpx.AsyncClient)
    participant BackendAgent as Backend A2A Agent

    Client->>LiteLLMProxy: POST /a2a/{agent_id}<br/>+ x-a2a-{name}-authorization: Bearer dyn<br/>+ Authorization: Bearer client
    LiteLLMProxy->>Registry: get_agent_by_id / get_agent_by_name
    Registry-->>LiteLLMProxy: AgentResponse<br/>(static_headers, extra_headers)

    Note over LiteLLMProxy: Build dynamic_headers<br/>1. admin extra_headers list → extract from request<br/>2. convention x-a2a-{id/name}-{header} → extract

    LiteLLMProxy->>Merge: merge_agent_headers(dynamic, static)
    Note over Merge: merged = {**dynamic}<br/>merged.update(static)  ← static wins
    Merge-->>LiteLLMProxy: agent_extra_headers

    LiteLLMProxy->>A2AClient: create_a2a_client(base_url, extra_headers)
    Note over A2AClient: httpx.AsyncClient(headers=extra_headers)<br/>← fresh per call, no caching
    A2AClient-->>LiteLLMProxy: a2a_client

    LiteLLMProxy->>BackendAgent: asend_message(request, agent_extra_headers)
    BackendAgent-->>LiteLLMProxy: SendMessageResponse
    LiteLLMProxy-->>Client: JSONResponse
Loading

Last reviewed commit: 501671a

Comment on lines +229 to +232
if agent.get("static_headers") is not None:
update_data["static_headers"] = safe_dumps(
dict(agent.get("static_headers")) # type: ignore
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

patch_agent_in_db cannot clear static_headers

The guard if agent.get("static_headers") is not None: means a PATCH request with static_headers=None will silently leave the existing value unchanged. An admin who wants to remove all static headers from an agent via PATCH has no way to do so — the only option is a full PUT/update.

Consider using a sentinel value or a dedicated clear_static_headers: bool field, or replacing is not None with an explicit presence check (e.g. "static_headers" in agent).

Comment on lines +207 to +248
async def test_create_a2a_client_uses_fresh_httpx_client():
"""
Two calls to create_a2a_client with different extra_headers must NOT
share the same underlying httpx.AsyncClient instance.
"""
import httpx

from litellm.a2a_protocol.main import create_a2a_client

created_clients = []

fake_agent_card = MagicMock()
fake_agent_card.name = "test-agent"

class FakeResolver:
def __init__(self, **kw):
created_clients.append(kw.get("httpx_client"))
async def get_agent_card(self):
return fake_agent_card

class FakeA2AClient:
def __init__(self, httpx_client, agent_card):
self._client = httpx_client
self._litellm_agent_card = agent_card

with patch("litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", True), patch(
"litellm.a2a_protocol.main.A2ACardResolver", FakeResolver
), patch("litellm.a2a_protocol.main._A2AClient", FakeA2AClient):
await create_a2a_client(
base_url="http://agent-a:9999",
extra_headers={"Authorization": "Bearer a"},
)
await create_a2a_client(
base_url="http://agent-b:9999",
extra_headers={"Authorization": "Bearer b"},
)

assert len(created_clients) == 2
# Must be distinct objects
assert created_clients[0] is not created_clients[1], (
"create_a2a_client reused a cached httpx client — headers will bleed between agents"
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_create_a2a_client_uses_fresh_httpx_client will fail — bug not fixed

The test correctly identifies the isolation problem: two calls to create_a2a_client with different extra_headers must not share the same httpx.AsyncClient. However, the underlying implementation (see main.py lines 635–643) still calls get_async_httpx_client(llm_provider=httpxSpecialProvider.A2A, params={"timeout": timeout}), which returns a cached client for the same key. Both calls in this test will produce the same httpx_client object, so created_clients[0] is not created_clients[1] will be False and the assertion will fail.

This test will fail in CI until create_a2a_client is updated to use a fresh (non-cached) httpx client per invocation.

… clearing

- create_a2a_client already uses httpx.AsyncClient directly (no shared cache);
  test_create_a2a_client_uses_fresh_httpx_client will now pass in CI
- asend_message_streaming now injects X-LiteLLM-Trace-Id and
  X-LiteLLM-Agent-Id headers, matching the non-streaming path
- patch_agent_in_db uses key-presence check ("in agent") instead of
  is not None so PATCH with static_headers=None/[] correctly clears the field

Made-with: Cursor
@Sameerlite
Copy link
Collaborator Author

@greptile-apps re review

Comment on lines +6 to +27
def merge_agent_headers(
*,
dynamic_headers: Optional[Mapping[str, str]] = None,
static_headers: Optional[Mapping[str, str]] = None,
) -> Optional[Dict[str, str]]:
"""Merge outbound HTTP headers for A2A agent calls.

Merge rules:
- Start with ``dynamic_headers`` (values extracted from the incoming client request).
- Overlay ``static_headers`` (admin-configured per agent).

If both contain the same key, ``static_headers`` wins.
"""
merged: Dict[str, str] = {}

if dynamic_headers:
merged.update({str(k): str(v) for k, v in dynamic_headers.items()})

if static_headers:
merged.update({str(k): str(v) for k, v in static_headers.items()})

return merged or None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Case-sensitive merge breaks "static headers always win" invariant

The merge_agent_headers function merges by exact (case-sensitive) key. HTTP headers are case-insensitive, so a mismatch in casing between a dynamic header key and a static header key results in both being included in the merged dict instead of the static one winning.

Concrete scenario:

  • Admin configures static_headers = {"Authorization": "Bearer admin-token"} (mixed case, the common convention)
  • Client sends x-a2a-my-agent-authorization: Bearer client-token
  • Convention-based extraction produces dynamic_headers = {"authorization": "Bearer client-token"} (lowercase, because it's sliced from the already-lowercased normalized dict)
  • After merge: {"authorization": "Bearer client-token", "Authorization": "Bearer admin-token"}two separate entries, static did NOT win

The backend agent receives both headers. Depending on how httpx serializes duplicate case-insensitive headers the backend may actually use the client-supplied value, defeating the security guarantee.

Fix: normalize keys to lowercase before merging:

Suggested change
def merge_agent_headers(
*,
dynamic_headers: Optional[Mapping[str, str]] = None,
static_headers: Optional[Mapping[str, str]] = None,
) -> Optional[Dict[str, str]]:
"""Merge outbound HTTP headers for A2A agent calls.
Merge rules:
- Start with ``dynamic_headers`` (values extracted from the incoming client request).
- Overlay ``static_headers`` (admin-configured per agent).
If both contain the same key, ``static_headers`` wins.
"""
merged: Dict[str, str] = {}
if dynamic_headers:
merged.update({str(k): str(v) for k, v in dynamic_headers.items()})
if static_headers:
merged.update({str(k): str(v) for k, v in static_headers.items()})
return merged or None
def merge_agent_headers(
*,
dynamic_headers: Optional[Mapping[str, str]] = None,
static_headers: Optional[Mapping[str, str]] = None,
) -> Optional[Dict[str, str]]:
"""Merge outbound HTTP headers for A2A agent calls.
Merge rules:
- Start with ``dynamic_headers`` (values extracted from the incoming client request).
- Overlay ``static_headers`` (admin-configured per agent).
If both contain the same key, ``static_headers`` wins.
"""
merged: Dict[str, str] = {}
if dynamic_headers:
merged.update({str(k).lower(): str(v) for k, v in dynamic_headers.items()})
if static_headers:
merged.update({str(k).lower(): str(v) for k, v in static_headers.items()})
return merged or None

Comment on lines +322 to +325
if static_headers_val_u is not None:
update_data["static_headers"] = static_headers_val_u
if extra_headers_val_u is not None:
update_data["extra_headers"] = extra_headers_val_u
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PUT (update_agent_in_db) cannot clear static_headers or extra_headers

Both fields use an is not None / early-return guard:

if static_headers_val_u is not None:
    update_data["static_headers"] = static_headers_val_u
...
if extra_headers_val_u is not None:
    update_data["extra_headers"] = extra_headers_val_u

A full PUT/replace request that intentionally omits static_headers (sending None) will silently leave the existing DB value intact rather than clearing it. This is semantically incorrect for a full-replace PUT operation and can leave stale authentication headers attached to an agent even after a full update.

For extra_headers, sending extra_headers=[] (empty list) does work since [] is not None. For static_headers, the caller must send {} explicitly to clear, but omitting the field (the natural PUT behavior) leaves the old value — this is easy to misuse.

Consider always including these fields in update_data for PUT, defaulting to {} / [] when not supplied:

Suggested change
if static_headers_val_u is not None:
update_data["static_headers"] = static_headers_val_u
if extra_headers_val_u is not None:
update_data["extra_headers"] = extra_headers_val_u
if static_headers_val_u is not None:
update_data["static_headers"] = static_headers_val_u
else:
update_data["static_headers"] = safe_dumps({})
if extra_headers_val_u is not None:
update_data["extra_headers"] = extra_headers_val_u
else:
update_data["extra_headers"] = []

Comment on lines +115 to +147
with patch(
"litellm.proxy.agent_endpoints.a2a_endpoints._get_agent",
return_value=mock_agent,
), patch(
"litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed",
new_callable=AsyncMock,
return_value=True,
), patch(
"litellm.proxy.common_request_processing.add_litellm_data_to_request",
side_effect=lambda data, **kw: data,
), patch(
"litellm.a2a_protocol.asend_message",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_asend, patch(
"litellm.a2a_protocol.create_a2a_client",
new_callable=AsyncMock,
), patch(
"litellm.proxy.proxy_server.general_settings",
{},
), patch(
"litellm.proxy.proxy_server.proxy_config",
MagicMock(),
), patch(
"litellm.proxy.proxy_server.version",
"1.0.0",
), patch.dict(
sys.modules,
{"a2a": MagicMock(), "a2a.types": mock_a2a_types},
), patch(
"litellm.a2a_protocol.main.A2A_SDK_AVAILABLE",
True,
):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proxy_logging_obj is not mocked — tests may fail when it is None

The _invoke helper patches general_settings, proxy_config, and version from litellm.proxy.proxy_server, but not proxy_logging_obj. After asend_message returns, the endpoint calls proxy_logging_obj.post_call_success_hook(...) at line 440 of a2a_endpoints.py.

proxy_logging_obj is re-imported inside invoke_agent_a2a at call-time from litellm.proxy.proxy_server. In a fresh test environment that attribute is typically None, causing AttributeError: 'NoneType' object has no attribute 'post_call_success_hook' — the tests would fail before ever asserting on headers.

Add the missing patch:

Suggested change
with patch(
"litellm.proxy.agent_endpoints.a2a_endpoints._get_agent",
return_value=mock_agent,
), patch(
"litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed",
new_callable=AsyncMock,
return_value=True,
), patch(
"litellm.proxy.common_request_processing.add_litellm_data_to_request",
side_effect=lambda data, **kw: data,
), patch(
"litellm.a2a_protocol.asend_message",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_asend, patch(
"litellm.a2a_protocol.create_a2a_client",
new_callable=AsyncMock,
), patch(
"litellm.proxy.proxy_server.general_settings",
{},
), patch(
"litellm.proxy.proxy_server.proxy_config",
MagicMock(),
), patch(
"litellm.proxy.proxy_server.version",
"1.0.0",
), patch.dict(
sys.modules,
{"a2a": MagicMock(), "a2a.types": mock_a2a_types},
), patch(
"litellm.a2a_protocol.main.A2A_SDK_AVAILABLE",
True,
):
), patch(
"litellm.proxy.proxy_server.proxy_logging_obj",
MagicMock(post_call_success_hook=AsyncMock(side_effect=lambda **kw: kw["response"])),
), patch(

Comment on lines +89 to +127
with patch(
"litellm.proxy.agent_endpoints.a2a_endpoints._get_agent",
return_value=agent,
), patch(
"litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed",
new_callable=AsyncMock,
return_value=True,
), patch(
"litellm.proxy.common_request_processing.add_litellm_data_to_request",
side_effect=lambda data, **kw: data,
), patch(
"litellm.a2a_protocol.asend_message",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_asend, patch(
"litellm.a2a_protocol.create_a2a_client",
new_callable=AsyncMock,
), patch(
"litellm.proxy.proxy_server.general_settings", {}
), patch(
"litellm.proxy.proxy_server.proxy_config", MagicMock()
), patch(
"litellm.proxy.proxy_server.version", "1.0.0"
), patch.dict(
sys.modules,
{"a2a": MagicMock(), "a2a.types": _a2a_types_module()},
), patch(
"litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", True
):
from litellm.proxy.agent_endpoints.a2a_endpoints import invoke_agent_a2a

await invoke_agent_a2a(
agent_id=agent.agent_id,
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
)
return mock_asend.call_args.kwargs.get("agent_extra_headers")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proxy_logging_obj is not mocked here either

The _invoke_agent helper (used by every test in this file) has the same gap: proxy_logging_obj is not patched, so any test that reaches proxy_logging_obj.post_call_success_hook(...) will raise AttributeError in a clean test environment.

Please add the same mock as noted in the test_agent_headers.py comment:

Suggested change
with patch(
"litellm.proxy.agent_endpoints.a2a_endpoints._get_agent",
return_value=agent,
), patch(
"litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed",
new_callable=AsyncMock,
return_value=True,
), patch(
"litellm.proxy.common_request_processing.add_litellm_data_to_request",
side_effect=lambda data, **kw: data,
), patch(
"litellm.a2a_protocol.asend_message",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_asend, patch(
"litellm.a2a_protocol.create_a2a_client",
new_callable=AsyncMock,
), patch(
"litellm.proxy.proxy_server.general_settings", {}
), patch(
"litellm.proxy.proxy_server.proxy_config", MagicMock()
), patch(
"litellm.proxy.proxy_server.version", "1.0.0"
), patch.dict(
sys.modules,
{"a2a": MagicMock(), "a2a.types": _a2a_types_module()},
), patch(
"litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", True
):
from litellm.proxy.agent_endpoints.a2a_endpoints import invoke_agent_a2a
await invoke_agent_a2a(
agent_id=agent.agent_id,
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
)
return mock_asend.call_args.kwargs.get("agent_extra_headers")
), patch(
"litellm.proxy.proxy_server.proxy_logging_obj",
MagicMock(post_call_success_hook=AsyncMock(side_effect=lambda **kw: kw["response"])),
), patch(

…eaders when omitted

For full-replace PUT semantics, always include static_headers and extra_headers
in update_data, defaulting to {} and [] when not supplied. Previously,
omitting these fields left stale DB values intact (e.g. auth headers).

Made-with: Cursor
name={[name, "value"]}
rules={[{ required: true, message: "Value required" }]}
>
<Input placeholder="Value (e.g. Bearer token123)" style={{ width: 260 }} />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Input.Password for static header values

The static header value input uses a plain <Input>, which displays sensitive credentials (Bearer tokens, API keys) in full plaintext. Anyone viewing the screen while an admin configures or edits an agent would see these secrets. Consider using <Input.Password> with the visibilityToggle prop so the value is masked by default but can be revealed intentionally.

Suggested change
<Input placeholder="Value (e.g. Bearer token123)" style={{ width: 260 }} />
<Form.Item
{...restField}
name={[name, "value"]}
rules={[{ required: true, message: "Value required" }]}
>
<Input.Password placeholder="Value (e.g. Bearer token123)" style={{ width: 260 }} visibilityToggle />
</Form.Item>

Comment on lines +204 to +205
static_headers: Optional[Dict[str, str]] = None
extra_headers: Optional[List[str]] = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

static_headers values returned in full in API responses

AgentResponse includes static_headers: Optional[Dict[str, str]] which is returned verbatim by all list/get agent endpoints. These fields are intended to hold sensitive admin-configured credentials such as Authorization: Bearer <internal-token> or API keys. Returning them in plain text in API responses means any consumer of the agents API (e.g., a user who has been granted read access to agents) can retrieve the raw secret values.

The standard practice is to either:

  • Exclude static_headers from read responses entirely and only allow it in create/update requests, or
  • Mask the values (e.g., "Authorization": "Bearer ****") in the response model.

Consider adding an exclude or a @field_serializer that masks the values when serializing for output, while preserving them for internal use during request forwarding.

url="http://backend-agent:10001",
):
mock_agent = MagicMock()
mock_agent.agent_id = "agent-123"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_make_mock_agent base helper does not set agent_name

The helper sets mock_agent.agent_id = "agent-123" but never sets mock_agent.agent_name. In a2a_endpoints.py (line 408), both agent.agent_id and agent.agent_name are iterated for convention-based prefix matching:

for alias in (agent.agent_id.lower(), agent.agent_name.lower()):

Because MagicMock auto-creates attributes, agent.agent_name resolves to a MagicMock whose str() form is an unpredictable ID string. This means the convention-based prefix check for the agent_name alias silently uses a garbage prefix, and no test covers that code path for the base helper.

Add mock_agent.agent_name = "test-agent" (or another stable value) to the base helper so all tests have a fully faithful representation of AgentResponse. The same gap exists in the _make_agent helper in test_agent_header_isolation.py.

@Sameerlite Sameerlite merged commit 8b0375f into main Mar 6, 2026
30 of 90 checks passed
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