Skip to content

[Bug]: Agent URL in the AgentCard returned by A2AStarletteApplication may be inaccessible #398

@vladkol

Description

@vladkol

What happened?

When running behind load balancers or any kind of gateways, the original request may come with a different hostname, schema, port, and even path.

For example, an agent in Cloud Run service may be exposed via a load balancer with a hostname, port and schema different from the Google Cloud Run service URL.

Proposed behavior

In A2AStarletteApplication, _handle_get_agent_card should create a copy of the saved AgentCard with agent's URL constructed with regards to the AgentCard HTTP request URL (host, schema, port) and, if present, X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Path headers.

Even though X-Forwarded-Path is a non-standard header, load balancers with URL maps use it to pass original URL path used by the client.

When X-Forwarded-Path is present, part of the path before the AgentCard path, should be added to the beginning of the agent RPC url path.
For example, if the AgentCard path is set to /.well-known/agent-card.json (default value), but X-Forwarded-Path header is /agents/myagent/.well-known/agent-card.json then the result path for the agent's RPC URL should be /agents/myagent.

So, It maybe that an agent deployed to https://my-agent-1234567.us-central1.run.app service is exposed via a load balancer as https://api.company.com/agents/my-agent via a URL map. The agent card would be exposed https://api.company.com/agents/my-agent/.well-known/agent-card.json.

Here is what I use as a workaround:

class A2AStarletteApplicationWithHost(A2AStarletteApplication):
    """This class makes sure the agent's url in the AgentCard has the same
    host, port and schema as in the request.
    """

    @override
    def build(
            self,
            agent_card_url: str = AGENT_CARD_WELL_KNOWN_PATH,
            rpc_url: str = DEFAULT_RPC_URL,
            **kwargs: Any,
        ) -> Starlette:
        self.rpc_url = rpc_url
        self.card_url = agent_card_url
        return super().build(
            agent_card_url=agent_card_url,
            rpc_url=rpc_url,
            **kwargs
        )


    @override
    async def _handle_get_agent_card(self, request: Request) -> JSONResponse:
        """Handles requests for the agent card endpoint
        by dynamically building the agent's RPC url (`url` property in AgentCard).

        Args:
            request: The incoming Starlette Request object.

        Returns:
            A JSONResponse containing the agent card data.
        """
        port = None
        if "X-Forwarded-Host" in request.headers:
            host = request.headers["X-Forwarded-Host"]
        else:
            host = request.url.hostname
            port = request.url.port
        if "X-Forwarded-Proto" in request.headers:
            scheme = request.headers["X-Forwarded-Proto"]
        else:
            scheme = request.url.scheme
        if not scheme:
            scheme = "http"
        if ":" in host: # type: ignore
            comps = host.rsplit(":", 1) # type: ignore
            host = comps[0]
            port = comps[1]

        path = ""
        if "X-Forwarded-Path" in request.headers:
            # Handle URL maps, e.g. https://myagents.com/agents/myagent to https://myagent-12345678.us-central1.run.app
            path = request.headers["X-Forwarded-Path"].strip()
            if (path
                and path.lower().endswith(self.card_url.lower())
                and len(path) - len(self.card_url) > 1
            ):
                path = path[:-len(self.card_url)].rstrip("/") + self.rpc_url
        else:
            path = self.rpc_url

        card = self.agent_card.model_copy()
        source_parsed = URL(card.url)
        if port:
            card.url = str(
                source_parsed.replace(
                    hostname=host,
                    port=port,
                    scheme=scheme,
                    path=path
                )
            )
        else:
            card.url = str(
                source_parsed.replace(
                    hostname=host,
                    scheme=scheme,
                    path=path
                )
            )

        return JSONResponse(
            card.model_dump(mode='json', exclude_none=True)
        )

Relevant log output

Code of Conduct

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions