From eace97831ce43a50dd0fa0cd2dd8c5b42ae4ba66 Mon Sep 17 00:00:00 2001 From: Chris Villegas Date: Wed, 20 May 2026 18:43:53 -0700 Subject: [PATCH 1/7] feat(agentex): forward user api key to agent pods via acp headers Co-authored-by: Cursor --- agentex/src/api/middleware_utils.py | 6 +- agentex/src/domain/delegation_headers.py | 48 +++++++++++++ .../src/domain/services/agent_acp_service.py | 39 ++++++----- agentex/src/utils/request_utils.py | 2 + .../unit/services/test_agent_acp_service.py | 69 ++++++++++++++++++- .../use_cases/test_agents_acp_use_case.py | 8 +++ 6 files changed, 152 insertions(+), 20 deletions(-) create mode 100644 agentex/src/domain/delegation_headers.py diff --git a/agentex/src/api/middleware_utils.py b/agentex/src/api/middleware_utils.py index 2df95d2c..c884d22a 100644 --- a/agentex/src/api/middleware_utils.py +++ b/agentex/src/api/middleware_utils.py @@ -142,10 +142,12 @@ async def verify_auth_gateway( method = request.method logger.info( - "[authentication_middleware] Request authenticated successfully for %s %s with principal %s", + "[authentication_middleware] Request authenticated successfully for %s %s " + "(user_id=%s, account_id=%s)", method, route_path, - principal_context, + getattr(principal_context, "user_id", None), + getattr(principal_context, "account_id", None), ) return None # Authentication successful except Exception as exc: diff --git a/agentex/src/domain/delegation_headers.py b/agentex/src/domain/delegation_headers.py new file mode 100644 index 00000000..c58880b4 --- /dev/null +++ b/agentex/src/domain/delegation_headers.py @@ -0,0 +1,48 @@ +"""Build outbound delegation headers for ACP calls to agent pods.""" + +from typing import Any + +HEADER_ACTING_AS_AGENT = "x-acting-as-agent" +HEADER_ACTING_USER_API_KEY = "x-acting-user-api-key" +HEADER_SELECTED_ACCOUNT_ID = "x-selected-account-id" +HEADER_USER_API_KEY = "x-api-key" + + +def _normalize_headers(headers: dict[str, str] | None) -> dict[str, str]: + if not headers: + return {} + return {k.lower(): v for k, v in headers.items()} + + +def build_delegation_headers( + principal: Any, + agent_id: str, + inbound_headers: dict[str, str] | None, + *, + agent_identity: str | None = None, +) -> dict[str, str]: + """ + Headers that let an agent pod call SGP as the invoking user. + + Requires a validated user principal from auth; reads x-api-key from the + inbound request (already checked during auth). Skips delegation when the + request is authenticated as the agent itself (agent_identity set). + """ + if agent_identity or principal is None: + return {} + + normalized = _normalize_headers(inbound_headers) + api_key = normalized.get(HEADER_USER_API_KEY) + if not api_key: + return {} + + result = { + HEADER_ACTING_USER_API_KEY: api_key, + HEADER_ACTING_AS_AGENT: agent_id, + } + + account_id = normalized.get(HEADER_SELECTED_ACCOUNT_ID) + if account_id: + result[HEADER_SELECTED_ACCOUNT_ID] = account_id + + return result diff --git a/agentex/src/domain/services/agent_acp_service.py b/agentex/src/domain/services/agent_acp_service.py index ce214b4b..852f720a 100644 --- a/agentex/src/domain/services/agent_acp_service.py +++ b/agentex/src/domain/services/agent_acp_service.py @@ -3,10 +3,11 @@ from typing import Annotated, Any from uuid import uuid4 -from fastapi import Depends +from fastapi import Depends, Request from pydantic import BaseModel from src.adapters.http.adapter_httpx import DHttpxGateway +from src.domain.delegation_headers import build_delegation_headers from src.domain.entities.agents import AgentEntity from src.domain.entities.agents_rpc import ( AgentRPCMethod, @@ -73,6 +74,8 @@ "authorization", "cookie", "x-agent-api-key", + "x-acting-user-api-key", + "x-acting-as-agent", } ) @@ -118,10 +121,12 @@ def __init__( agent_repository: DAgentRepository, agent_api_key_repository: DAgentAPIKeyRepository, http_gateway: DHttpxGateway, + request: Request, ): self._http_gateway = http_gateway self._agent_repository = agent_repository self._agent_api_key_repository = agent_api_key_repository + self._request = request def _parse_task_message(self, result: dict[str, Any]) -> TaskMessageContentEntity: """Parse a result dict into a TaskMessage""" @@ -254,11 +259,23 @@ async def _call_jsonrpc_stream( logger.error(f"Error calling ACP server at {url}: {e}") raise e - async def get_headers(self, agent: AgentEntity) -> dict[str, str]: + async def get_headers( + self, + agent: AgentEntity, + request_headers: dict[str, str] | None = None, + ) -> dict[str, str]: + headers = filter_request_headers(request_headers) + headers.update( + build_delegation_headers( + getattr(self._request.state, "principal_context", None), + agent.id, + dict(self._request.headers), + agent_identity=getattr(self._request.state, "agent_identity", None), + ) + ) auth_headers = await self.get_agent_auth_headers(agent) or {} - - request_id = ctx_var_request_id.get(uuid4().hex) - headers = {**auth_headers, "x-request-id": request_id} + headers.update(auth_headers) + headers["x-request-id"] = ctx_var_request_id.get(uuid4().hex) return headers async def get_agent_auth_headers( @@ -398,11 +415,6 @@ async def send_event( ) -> dict[str, Any]: """Send an event to a running task""" - # Filter request headers for security (only safe x-* headers) - filtered_headers = filter_request_headers(request_headers) - - # Don't include headers in params body - let SDK extract from HTTP headers - # This ensures single source of truth and avoids duplication params = SendEventParams( agent=agent, task=task, @@ -410,12 +422,7 @@ async def send_event( request=None, ) - # Build HTTP headers: start with filtered request headers, then overlay auth headers - # Auth headers are added last to ensure they cannot be overwritten - # SDK will extract these headers and populate params.request at agent side - headers = filtered_headers.copy() - auth_headers = await self.get_headers(agent) - headers.update(auth_headers) + headers = await self.get_headers(agent, request_headers) return await self._call_jsonrpc( url=acp_url, diff --git a/agentex/src/utils/request_utils.py b/agentex/src/utils/request_utils.py index de4f0b93..5e875ed4 100644 --- a/agentex/src/utils/request_utils.py +++ b/agentex/src/utils/request_utils.py @@ -7,10 +7,12 @@ REQUEST_KEY_REGEXP_BLACKLIST = [ r"api_key", + r"api-key", r"password", r"secret", r"token", r"authorization", + r"acting-user", ] diff --git a/agentex/tests/unit/services/test_agent_acp_service.py b/agentex/tests/unit/services/test_agent_acp_service.py index e226e624..2c0075f1 100644 --- a/agentex/tests/unit/services/test_agent_acp_service.py +++ b/agentex/tests/unit/services/test_agent_acp_service.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 from zoneinfo import ZoneInfo @@ -45,12 +45,25 @@ def agent_api_key_repository(postgres_session_maker): @pytest.fixture -def agent_acp_service(mock_http_gateway, agent_repository, agent_api_key_repository): +def mock_request(): + request = MagicMock() + request.state = MagicMock() + request.state.principal_context = None + request.state.agent_identity = None + request.headers = {} + return request + + +@pytest.fixture +def agent_acp_service( + mock_http_gateway, agent_repository, agent_api_key_repository, mock_request +): """Create AgentACPService instance with mocked HTTP gateway and real repository""" return AgentACPService( http_gateway=mock_http_gateway, agent_repository=agent_repository, agent_api_key_repository=agent_api_key_repository, + request=mock_request, ) @@ -223,6 +236,58 @@ async def test_send_message_success( assert payload["method"] == "message/send" assert payload["params"]["stream"] is False + async def test_send_message_includes_delegation_headers( + self, + agent_acp_service, + mock_http_gateway, + mock_request, + agent_repository, + sample_agent, + sample_task, + sample_text_content, + ): + """User principals get delegation headers on outbound ACP calls.""" + await create_or_get_agent(agent_repository, sample_agent) + + mock_request.state.principal_context = type( + "Principal", + (), + {"user_id": "user-1", "account_id": "acct-1"}, + )() + mock_request.state.agent_identity = None + mock_request.headers = { + "x-api-key": "user-delegation-key", + "x-selected-account-id": "acct-1", + } + + from src.domain.entities.agents_rpc import AgentRPCMethod + + expected_request_id = f"{AgentRPCMethod.MESSAGE_SEND}-{sample_task.id}" + mock_http_gateway.async_call.return_value = { + "jsonrpc": "2.0", + "result": { + "type": "text", + "author": "agent", + "style": "static", + "format": "plain", + "content": "ok", + "attachments": None, + }, + "id": expected_request_id, + } + + await agent_acp_service.send_message( + agent=sample_agent, + task=sample_task, + content=sample_text_content, + acp_url="http://test-acp.example.com", + ) + + http_headers = mock_http_gateway.async_call.call_args[1]["default_headers"] + assert http_headers["x-acting-user-api-key"] == "user-delegation-key" + assert http_headers["x-acting-as-agent"] == sample_agent.id + assert http_headers["x-selected-account-id"] == "acct-1" + async def test_send_message_success_data( self, agent_acp_service, diff --git a/agentex/tests/unit/use_cases/test_agents_acp_use_case.py b/agentex/tests/unit/use_cases/test_agents_acp_use_case.py index cccc11d4..f56ec68b 100644 --- a/agentex/tests/unit/use_cases/test_agents_acp_use_case.py +++ b/agentex/tests/unit/use_cases/test_agents_acp_use_case.py @@ -105,10 +105,18 @@ def task_state_repository(mongodb_database): @pytest.fixture def agent_acp_service(mock_http_gateway, agent_repository, agent_api_key_repository): """Real AgentACPService instance with mocked HTTP gateway""" + from unittest.mock import MagicMock + + request = MagicMock() + request.state = MagicMock() + request.state.principal_context = None + request.state.agent_identity = None + request.headers = {} return AgentACPService( http_gateway=mock_http_gateway, agent_repository=agent_repository, agent_api_key_repository=agent_api_key_repository, + request=request, ) From b8076c8b096412ca221195fbdee2d2a79cfd0556 Mon Sep 17 00:00:00 2001 From: Chris Villegas Date: Wed, 20 May 2026 18:50:46 -0700 Subject: [PATCH 2/7] docs(agentex): neutral delegation docstrings and restore get_headers merge style Co-authored-by: Cursor --- agentex/src/domain/delegation_headers.py | 4 ++-- agentex/src/domain/services/agent_acp_service.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/agentex/src/domain/delegation_headers.py b/agentex/src/domain/delegation_headers.py index c58880b4..3c890e2f 100644 --- a/agentex/src/domain/delegation_headers.py +++ b/agentex/src/domain/delegation_headers.py @@ -1,4 +1,4 @@ -"""Build outbound delegation headers for ACP calls to agent pods.""" +"""Outbound runtime-delegation headers for ACP calls to agent pods.""" from typing import Any @@ -22,7 +22,7 @@ def build_delegation_headers( agent_identity: str | None = None, ) -> dict[str, str]: """ - Headers that let an agent pod call SGP as the invoking user. + Outbound ACP headers so the agent can act on behalf of the authenticated user. Requires a validated user principal from auth; reads x-api-key from the inbound request (already checked during auth). Skips delegation when the diff --git a/agentex/src/domain/services/agent_acp_service.py b/agentex/src/domain/services/agent_acp_service.py index 852f720a..1eecaed2 100644 --- a/agentex/src/domain/services/agent_acp_service.py +++ b/agentex/src/domain/services/agent_acp_service.py @@ -274,9 +274,8 @@ async def get_headers( ) ) auth_headers = await self.get_agent_auth_headers(agent) or {} - headers.update(auth_headers) - headers["x-request-id"] = ctx_var_request_id.get(uuid4().hex) - return headers + request_id = ctx_var_request_id.get(uuid4().hex) + return {**headers, **auth_headers, "x-request-id": request_id} async def get_agent_auth_headers( self, From 7008d70f6c64bca1fd7204b90e109f043e7a155a Mon Sep 17 00:00:00 2001 From: Chris Villegas Date: Wed, 20 May 2026 18:51:52 -0700 Subject: [PATCH 3/7] refactor(agentex): keep get_headers auth merge identical to main Co-authored-by: Cursor --- agentex/src/domain/services/agent_acp_service.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/agentex/src/domain/services/agent_acp_service.py b/agentex/src/domain/services/agent_acp_service.py index 1eecaed2..db464a4b 100644 --- a/agentex/src/domain/services/agent_acp_service.py +++ b/agentex/src/domain/services/agent_acp_service.py @@ -264,8 +264,8 @@ async def get_headers( agent: AgentEntity, request_headers: dict[str, str] | None = None, ) -> dict[str, str]: - headers = filter_request_headers(request_headers) - headers.update( + passthrough_headers = filter_request_headers(request_headers) + passthrough_headers.update( build_delegation_headers( getattr(self._request.state, "principal_context", None), agent.id, @@ -273,9 +273,13 @@ async def get_headers( agent_identity=getattr(self._request.state, "agent_identity", None), ) ) + auth_headers = await self.get_agent_auth_headers(agent) or {} + request_id = ctx_var_request_id.get(uuid4().hex) - return {**headers, **auth_headers, "x-request-id": request_id} + headers = {**auth_headers, "x-request-id": request_id} + headers.update(passthrough_headers) + return headers async def get_agent_auth_headers( self, From 918b32af451985b7866a02eca4069c35b57150f4 Mon Sep 17 00:00:00 2001 From: Chris Villegas Date: Wed, 20 May 2026 19:01:59 -0700 Subject: [PATCH 4/7] refactor(agentex): align delegation and auth header helpers in get_headers Co-authored-by: Cursor --- .../src/domain/services/agent_acp_service.py | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/agentex/src/domain/services/agent_acp_service.py b/agentex/src/domain/services/agent_acp_service.py index db464a4b..eadcdb9a 100644 --- a/agentex/src/domain/services/agent_acp_service.py +++ b/agentex/src/domain/services/agent_acp_service.py @@ -259,41 +259,41 @@ async def _call_jsonrpc_stream( logger.error(f"Error calling ACP server at {url}: {e}") raise e + def get_delegation_headers(self, agent: AgentEntity) -> dict[str, str]: + state = self._request.state + return build_delegation_headers( + getattr(state, "principal_context", None), + agent.id, + dict(self._request.headers), + agent_identity=getattr(state, "agent_identity", None), + ) + async def get_headers( self, agent: AgentEntity, request_headers: dict[str, str] | None = None, ) -> dict[str, str]: - passthrough_headers = filter_request_headers(request_headers) - passthrough_headers.update( - build_delegation_headers( - getattr(self._request.state, "principal_context", None), - agent.id, - dict(self._request.headers), - agent_identity=getattr(self._request.state, "agent_identity", None), - ) - ) - - auth_headers = await self.get_agent_auth_headers(agent) or {} - + filtered_request_headers = filter_request_headers(request_headers) + delegation_headers = self.get_delegation_headers(agent) + auth_headers = await self.get_agent_auth_headers(agent) request_id = ctx_var_request_id.get(uuid4().hex) - headers = {**auth_headers, "x-request-id": request_id} - headers.update(passthrough_headers) - return headers - async def get_agent_auth_headers( - self, - agent: AgentEntity, - ) -> dict[str, str] | None: - """ - Get the authentication headers for an agent by its ID. - """ + # Later keys win. Client passthrough and delegation first; agent auth last. + return { + **filtered_request_headers, + **delegation_headers, + **auth_headers, + "x-request-id": request_id, + } + + async def get_agent_auth_headers(self, agent: AgentEntity) -> dict[str, str]: + """Authentication headers the agent pod uses to call back into agentex.""" api_key = await self._agent_api_key_repository.get_internal_api_key_by_agent_id( agent_id=agent.id ) if api_key: return {"x-agent-api-key": api_key.api_key} - return None + return {} async def create_task( self, From 7e1d88d058fecce0c837222d2eaa6ccb6417addc Mon Sep 17 00:00:00 2001 From: Chris Villegas Date: Wed, 20 May 2026 19:05:47 -0700 Subject: [PATCH 5/7] fix(agentex): block x-api-key passthrough on event send delegation path Co-authored-by: Cursor --- .../src/domain/services/agent_acp_service.py | 3 +- .../unit/services/test_agent_acp_service.py | 92 ++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/agentex/src/domain/services/agent_acp_service.py b/agentex/src/domain/services/agent_acp_service.py index eadcdb9a..a0eb9ccc 100644 --- a/agentex/src/domain/services/agent_acp_service.py +++ b/agentex/src/domain/services/agent_acp_service.py @@ -73,6 +73,7 @@ { "authorization", "cookie", + "x-api-key", "x-agent-api-key", "x-acting-user-api-key", "x-acting-as-agent", @@ -87,7 +88,7 @@ def filter_request_headers(headers: dict[str, str] | None) -> dict[str, str]: Security filtering rules: 1. Allow only x-* prefixed headers (allowlist approach) 2. Block hop-by-hop headers (connection, keep-alive, etc.) - 3. Block sensitive headers (authorization, cookie, x-agent-api-key) + 3. Block sensitive headers (credentials, acting delegation, x-agent-api-key) Args: headers: Raw request headers from client diff --git a/agentex/tests/unit/services/test_agent_acp_service.py b/agentex/tests/unit/services/test_agent_acp_service.py index 2c0075f1..be718195 100644 --- a/agentex/tests/unit/services/test_agent_acp_service.py +++ b/agentex/tests/unit/services/test_agent_acp_service.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 from zoneinfo import ZoneInfo @@ -19,7 +19,10 @@ from src.domain.entities.tasks import TaskEntity, TaskStatus from src.domain.repositories.agent_api_key_repository import AgentAPIKeyRepository from src.domain.repositories.agent_repository import AgentRepository -from src.domain.services.agent_acp_service import AgentACPService +from src.domain.services.agent_acp_service import ( + AgentACPService, + filter_request_headers, +) # UTC timezone constant UTC = ZoneInfo("UTC") @@ -287,6 +290,77 @@ async def test_send_message_includes_delegation_headers( assert http_headers["x-acting-user-api-key"] == "user-delegation-key" assert http_headers["x-acting-as-agent"] == sample_agent.id assert http_headers["x-selected-account-id"] == "acct-1" + assert "x-api-key" not in http_headers + + async def test_send_event_delegation_not_raw_api_key_passthrough( + self, + agent_acp_service, + mock_http_gateway, + mock_request, + agent_repository, + sample_agent, + sample_task, + sample_event, + ): + """EVENT_SEND must not passthrough x-api-key; only x-acting-user-api-key.""" + await create_or_get_agent(agent_repository, sample_agent) + + mock_request.state.principal_context = type( + "Principal", + (), + {"user_id": "user-1", "account_id": "acct-1"}, + )() + mock_request.state.agent_identity = None + mock_request.headers = {"x-api-key": "user-delegation-key"} + + from src.domain.entities.agents_rpc import AgentRPCMethod + + expected_request_id = f"{AgentRPCMethod.EVENT_SEND}-{sample_task.id}" + mock_http_gateway.async_call.return_value = { + "jsonrpc": "2.0", + "result": {"status": "event_sent", "event_id": sample_event.id}, + "id": expected_request_id, + } + + await agent_acp_service.send_event( + agent=sample_agent, + event=sample_event, + task=sample_task, + acp_url="http://test-acp.example.com", + request_headers={ + "x-api-key": "user-delegation-key", + "x-trace-id": "trace-456", + }, + ) + + http_headers = mock_http_gateway.async_call.call_args[1]["default_headers"] + assert http_headers["x-acting-user-api-key"] == "user-delegation-key" + assert http_headers["x-trace-id"] == "trace-456" + assert "x-api-key" not in http_headers + + async def test_get_headers_server_request_id_wins_over_passthrough( + self, + agent_acp_service, + mock_request, + sample_agent, + ): + """Server-generated x-request-id must override client passthrough.""" + mock_request.state.principal_context = None + mock_request.state.agent_identity = None + mock_request.headers = {} + + with patch.object( + agent_acp_service, + "get_agent_auth_headers", + new=AsyncMock(return_value={}), + ): + headers = await agent_acp_service.get_headers( + sample_agent, + request_headers={"x-request-id": "client-request-id"}, + ) + + assert headers["x-request-id"] != "client-request-id" + assert len(headers["x-request-id"]) > 0 async def test_send_message_success_data( self, @@ -753,3 +827,17 @@ async def test_parse_task_message_update_invalid_type(self, agent_acp_service): agent_acp_service._parse_task_message_update(invalid_result) assert "Unknown update type" in str(exc_info.value) + + +class TestFilterRequestHeaders: + def test_blocks_user_api_key_and_acting_headers(self): + result = filter_request_headers( + { + "x-api-key": "user-key", + "x-acting-user-api-key": "spoof", + "x-acting-as-agent": "spoof-agent", + "x-trace-id": "trace-1", + "authorization": "Bearer x", + } + ) + assert result == {"x-trace-id": "trace-1"} From af0e8b44118f1404ede14354e359d25023a5140a Mon Sep 17 00:00:00 2001 From: Chris Villegas Date: Wed, 20 May 2026 19:15:42 -0700 Subject: [PATCH 6/7] test: fix AgentACPService fixtures after Request dependency Co-authored-by: Cursor --- agentex/tests/fixtures/services.py | 20 ++++- .../unit/services/test_agent_acp_service.py | 75 +++++++------------ 2 files changed, 47 insertions(+), 48 deletions(-) diff --git a/agentex/tests/fixtures/services.py b/agentex/tests/fixtures/services.py index 30b16ca2..c30c06c8 100644 --- a/agentex/tests/fixtures/services.py +++ b/agentex/tests/fixtures/services.py @@ -3,7 +3,7 @@ Provides factory functions and specific fixtures for creating services with test repositories. """ -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock import pytest @@ -19,7 +19,22 @@ def create_task_message_service(task_message_repository): return TaskMessageService(task_message_repository=task_message_repository) -def create_agent_acp_service(http_gateway, agent_repository, agent_api_key_repository): +def create_mock_request(): + """Minimal FastAPI Request stand-in for AgentACPService tests.""" + request = MagicMock() + request.state = MagicMock() + request.state.principal_context = None + request.state.agent_identity = None + request.headers = {} + return request + + +def create_agent_acp_service( + http_gateway, + agent_repository, + agent_api_key_repository, + request=None, +): """Factory function to create AgentACPService with given HTTP gateway""" from src.domain.services.agent_acp_service import AgentACPService @@ -27,6 +42,7 @@ def create_agent_acp_service(http_gateway, agent_repository, agent_api_key_repos http_gateway=http_gateway, agent_repository=agent_repository, agent_api_key_repository=agent_api_key_repository, + request=request or create_mock_request(), ) diff --git a/agentex/tests/unit/services/test_agent_acp_service.py b/agentex/tests/unit/services/test_agent_acp_service.py index 91086868..2954b844 100644 --- a/agentex/tests/unit/services/test_agent_acp_service.py +++ b/agentex/tests/unit/services/test_agent_acp_service.py @@ -626,37 +626,31 @@ async def test_send_event_with_request_headers( sample_task, sample_event, ): - """Test event sending with request headers forwarding""" - # Given - Create agent first + """Test event sending with safe request header passthrough (not raw x-api-key).""" await create_or_get_agent(agent_repository, sample_agent) - # Mock get_headers to return auth headers - from unittest.mock import AsyncMock, patch - from src.domain.entities.agents_rpc import AgentRPCMethod + expected_request_id = f"{AgentRPCMethod.EVENT_SEND}-{sample_task.id}" + mock_http_gateway.async_call.return_value = { + "jsonrpc": "2.0", + "result": {"status": "event_sent", "event_id": sample_event.id}, + "id": expected_request_id, + } + + request_headers = { + "x-api-key": "must-not-forward", + "x-user-id": "user-123", + "x-trace-id": "trace-456", + "user-agent": "test-client", + "authorization": "Bearer test-token", + } + with patch.object( agent_acp_service, - "get_headers", + "get_agent_auth_headers", new=AsyncMock(return_value={"x-agent-api-key": "test-api-key"}), ): - expected_request_id = f"{AgentRPCMethod.EVENT_SEND}-{sample_task.id}" - mock_response = { - "jsonrpc": "2.0", - "result": {"status": "event_sent", "event_id": sample_event.id}, - "id": expected_request_id, - } - mock_http_gateway.async_call.return_value = mock_response - - # Request headers to forward - mix of allowed and blocked headers - request_headers = { - "x-user-id": "user-123", # Allowed: x-* prefix - "x-trace-id": "trace-456", # Allowed: x-* prefix - "user-agent": "test-client", # Blocked: no x-* prefix - "authorization": "Bearer test-token", # Blocked: sensitive header - } - - # When result = await agent_acp_service.send_event( agent=sample_agent, event=sample_event, @@ -665,31 +659,20 @@ async def test_send_event_with_request_headers( request_headers=request_headers, ) - # Then - assert result["status"] == "event_sent" - assert result["event_id"] == sample_event.id - - # Verify call was made - headers sent via HTTP headers, not in params body - mock_http_gateway.async_call.assert_called_once() - call_args = mock_http_gateway.async_call.call_args - payload = call_args[1]["payload"] + assert result["status"] == "event_sent" + assert result["event_id"] == sample_event.id - # Headers should NOT be in params body (sent via HTTP headers instead) - assert "params" in payload - assert payload["params"]["request"] is None + call_args = mock_http_gateway.async_call.call_args + payload = call_args[1]["payload"] + assert payload["params"]["request"] is None - # Verify filtered headers were sent via HTTP headers (parameter name is default_headers) - http_headers = call_args[1]["default_headers"] - assert "x-user-id" in http_headers - assert http_headers["x-user-id"] == "user-123" - assert "x-trace-id" in http_headers - assert http_headers["x-trace-id"] == "trace-456" - # Verify auth header is present (overlayed after filtered headers) - assert "x-agent-api-key" in http_headers - assert http_headers["x-agent-api-key"] == "test-api-key" - # Verify blocked headers are NOT in HTTP headers - assert "user-agent" not in http_headers # Blocked: no x-* prefix - assert "authorization" not in http_headers # Blocked: sensitive + http_headers = call_args[1]["default_headers"] + assert http_headers["x-user-id"] == "user-123" + assert http_headers["x-trace-id"] == "trace-456" + assert http_headers["x-agent-api-key"] == "test-api-key" + assert "x-api-key" not in http_headers + assert "user-agent" not in http_headers + assert "authorization" not in http_headers async def test_send_event_without_request_headers( self, From 0dbfd0f9256dddf2dedfd8935d898f2d1441a138 Mon Sep 17 00:00:00 2001 From: Chris Villegas Date: Wed, 20 May 2026 19:36:36 -0700 Subject: [PATCH 7/7] refactor(agentex): drop x-acting-as-agent from delegation headers Co-authored-by: Cursor --- agentex/src/domain/delegation_headers.py | 11 +++++++---- agentex/src/domain/services/agent_acp_service.py | 1 - agentex/tests/unit/services/test_agent_acp_service.py | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/agentex/src/domain/delegation_headers.py b/agentex/src/domain/delegation_headers.py index 3c890e2f..7bf060c7 100644 --- a/agentex/src/domain/delegation_headers.py +++ b/agentex/src/domain/delegation_headers.py @@ -1,8 +1,13 @@ -"""Outbound runtime-delegation headers for ACP calls to agent pods.""" +""" +Outbound runtime-delegation headers for ACP calls to agent pods (v1). + +Forwards the validated user API key on a dedicated header so agents can call +downstream APIs as the user. Agent identity for SGP will eventually be a claim +on a pod-minted delegation token (OBO), not a separate header from agentex. +""" from typing import Any -HEADER_ACTING_AS_AGENT = "x-acting-as-agent" HEADER_ACTING_USER_API_KEY = "x-acting-user-api-key" HEADER_SELECTED_ACCOUNT_ID = "x-selected-account-id" HEADER_USER_API_KEY = "x-api-key" @@ -16,7 +21,6 @@ def _normalize_headers(headers: dict[str, str] | None) -> dict[str, str]: def build_delegation_headers( principal: Any, - agent_id: str, inbound_headers: dict[str, str] | None, *, agent_identity: str | None = None, @@ -38,7 +42,6 @@ def build_delegation_headers( result = { HEADER_ACTING_USER_API_KEY: api_key, - HEADER_ACTING_AS_AGENT: agent_id, } account_id = normalized.get(HEADER_SELECTED_ACCOUNT_ID) diff --git a/agentex/src/domain/services/agent_acp_service.py b/agentex/src/domain/services/agent_acp_service.py index a0eb9ccc..422b9e32 100644 --- a/agentex/src/domain/services/agent_acp_service.py +++ b/agentex/src/domain/services/agent_acp_service.py @@ -264,7 +264,6 @@ def get_delegation_headers(self, agent: AgentEntity) -> dict[str, str]: state = self._request.state return build_delegation_headers( getattr(state, "principal_context", None), - agent.id, dict(self._request.headers), agent_identity=getattr(state, "agent_identity", None), ) diff --git a/agentex/tests/unit/services/test_agent_acp_service.py b/agentex/tests/unit/services/test_agent_acp_service.py index 2954b844..c9158551 100644 --- a/agentex/tests/unit/services/test_agent_acp_service.py +++ b/agentex/tests/unit/services/test_agent_acp_service.py @@ -288,8 +288,8 @@ async def test_send_message_includes_delegation_headers( http_headers = mock_http_gateway.async_call.call_args[1]["default_headers"] assert http_headers["x-acting-user-api-key"] == "user-delegation-key" - assert http_headers["x-acting-as-agent"] == sample_agent.id assert http_headers["x-selected-account-id"] == "acct-1" + assert "x-acting-as-agent" not in http_headers assert "x-api-key" not in http_headers async def test_send_event_delegation_not_raw_api_key_passthrough(