[Bug]: Agent URL in the AgentCard returned by A2AStarletteApplication may be inaccessible
vladkol opened this issue · 1 comments
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
I'm not sure I see a bug in the SDK here. I believe you're saying that if you set your AgentCard.url to an inaccessible endpoint then users will not be able to contact the agent. It seems like the issue is that you set the AgentCard.url to an incorrect URL?
The URL you put in the AgentCard is entirely within your control. If you want to serve from https://api.company.com/agents/my-agent, set that as your AgentCard.url. There's no requirement that the URL be a hard-coded string, it can come from your environment configuration.
# Or any other means you'd like of retrieving this.
my_agent_url = os.environ.get("SERVING_URL")
if not my_agent_url:
raise ValueError("Uh oh you forgot to set the SERVING_URL!")
AgentCard(
url=my_agent_url,
# ...
)This works for Cloud Run -- just pass the URL as an environment variable to your cloud run instance. That URL could be what's exposed via your load balancer, or your auto-generated Cloud Run URL.
You can even use configuration that updates at runtime via an AgentCard modifier.
dynamic_config = MySpecialConfigReader()
def my_modifier(agent_card: AgentCard) -> AgentCard:
url_that_changes = dynamic_config.get_current()
return agent_card.model_copy(update={"url": url_that_changes})
A2AStarletteApp(
agent_card=AgentCard(
url=dynamic_config.get_current(),
),
card_modifier=my_modifier,
)Taking the incoming host name/port and using that for your serving path requires that you trust the source of those values -- they can be set by anyone to anything. In a deployment scenario where your app is only accessible by the load balancer alone, your load balancer is correctly configured to only respond to particular host names, it always sets (and overrides) the X-Forwarded-Host header, etc., then yes it could work. But it's not a secure pattern by default. I would essentially always recommend that developers explicitly configure the serving URL so that they are confident what the value is. You can implement very fancy ways of doing that (like querying the kubernetes API for your job to see what the configured host name is, for example) which do not involve taking values from the incoming request.
Also, as you've seen, you can already achieve this behavior with no changes to the SDK -- just override our base class to customize the behavior. That is an intended form of usage.