Skip to content

[bug] 出站 A2A 客户端在 JSON-RPC 调用中未发送已配置凭据(a2a-sdk 1.1.2 忽略 ClientCallContext.state 中的请求头) #513

Description

@marmotpoll

背景

配置出站凭据(A2A_CLIENT_BEARER_TOKEN / A2A_CLIENT_BASIC_AUTH)后,Agent Card 拉取会正确携带 Authorization 头,但所有实际的 JSON-RPC 调用(SendMessageGetTask 等)完全不携带该头。任何要求鉴权的对端(peer)都会对 SendMessage 返回 HTTP 401,客户端将其映射为:

[Error] Remote A2A peer rejected SendMessage due to authentication failure

CLI(opencode-a2a call)与服务端内嵌客户端(a2a_call 工具路径)均受影响——二者共用同一个 facade 客户端 A2AClient。对于需要双向 peering 的部署,出站鉴权实际上完全不可用。

根因

A2AClient 将每次调用的鉴权头放入 SDK 的 ClientCallContext.state

src/opencode_a2a/client/request_context.pybuild_call_context):

return ClientCallContext(
    state={
        "headers": dict(merged_headers),
        "http_kwargs": {"headers": dict(merged_headers)},
    },
    service_parameters=service_parameters,
)

但 a2a-sdk 1.1.2 在组装 HTTP 请求时完全不读取 statea2a/client/transports/http_helpers.py::get_http_args):

def get_http_args(context: ClientCallContext | None) -> dict[str, Any]:
    http_kwargs: dict[str, Any] = {}
    if context and context.service_parameters:
        http_kwargs['headers'] = context.service_parameters.copy()
    if context and context.timeout is not None:
        http_kwargs['timeout'] = httpx.Timeout(context.timeout)
    return http_kwargs

仅读取 service_parameterstimeoutcontext.state["headers"]context.state["http_kwargs"] 被忽略——尽管 ClientCallContext 的 docstring 明确将 state 描述为用于传递 "authentication details" 的位置。

Agent Card 拉取不受影响,因为它走的是显式 http_kwargs={"headers": ...} 路径(client.py::get_agent_card / _build_client)。这种不对称性使问题排查困难:discovery 成功,随后的每一次鉴权调用都失败。

另注:A2A-Version: 1.0 头同样由 build_default_headers 注入,因此在 JSON-RPC 调用上一并丢失(协议版本协商头缺失)。

证据(抓包)

将 CLI 指向一个记录请求头的 stub peer,可直接观察到不对称现象:

=== GET /.well-known/agent-card.json ===
Host: 192.168.1.17:9999
User-Agent: python-httpx/0.28.1
A2A-Version: 1.0
Authorization: Bearer a2a-…                ← 存在

=== POST / ===
Host: 192.168.1.17:9999
User-Agent: python-httpx/0.28.1
A2A-Version: 1.0
Content-Type: application/json
Content-Length: 247
                                          ← 无 Authorization 头
BODY: {"method":"SendMessage","params":{…},"id":"…","jsonrpc":"2.0"}

复现

对任意启用 bearer 鉴权的 A2A peer:

A2A_CLIENT_BEARER_TOKEN=<peer-token> \
opencode-a2a call http://peer:9900 "ping"
# → [Error] Remote A2A peer rejected SendMessage due to authentication failure

同一 token 用 curl 直接调用则成功:

curl -X POST http://peer:9900/ \
  -H 'content-type: application/json' \
  -H "Authorization: Bearer <peer-token>" \
  -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"ping"}]}}}'
# → 200, TASK_STATE_COMPLETED

建议修复

在共享的 httpx.AsyncClient 上直接配置出站凭据头(JSON-RPC transport 与 card resolver 均复用该 client),不再依赖每次调用的 CallContext.state

--- a/src/opencode_a2a/client/client.py
+++ b/src/opencode_a2a/client/client.py
@@ -338,7 +338,20 @@ class A2AClient:
     async def _get_httpx_client(self) -> httpx.AsyncClient:
         if self._httpx_client is not None:
             return self._httpx_client
-        self._httpx_client = httpx.AsyncClient(timeout=self._settings.default_timeout)
+        # WORKAROUND(a2a-sdk 1.1.2): the SDK JSON-RPC transport ignores
+        # ClientCallContext.state headers (get_http_args only reads
+        # service_parameters/timeout), so per-call auth headers built by
+        # build_call_context never reach the wire. Bake configured outbound
+        # credentials into the shared httpx client instead. Safe: the
+        # server-side A2AClientManager strips credentials from settings
+        # unless the target host matches A2A_CLIENT_ALLOWED_HOSTS.
+        self._httpx_client = httpx.AsyncClient(
+            timeout=self._settings.default_timeout,
+            headers=build_default_headers(
+                self._settings.bearer_token,
+                self._settings.basic_auth,
+            ),
+        )
         return self._httpx_client

build_default_headers 已在该模块导入。

安全性(凭据白名单模型不受影响):

  • 服务端 A2AClientManager.borrow_client 在构造 client 之前即对非白名单 host 剥离凭据(dataclasses.replace),因此携带凭据的 httpx client 只会为白名单 host 创建;且一个 A2AClient 实例绑定单一 peer URL,凭据不会跨 peer 泄漏。
  • CLI 为手动操作,目标 URL 由操作者显式给出,且当前 card 拉取已发送相同凭据,暴露面不变。
  • build_call_context 可保持不变(无害;若 SDK 未来恢复读取 state,两套头内容一致,不产生冲突)。

替代方案

  1. ServiceParameters 传递鉴权头——SDK 目前唯一读取的 per-call 头通道,但该机制的语义是 A2A 扩展/服务参数,且需小心避免覆盖 with_a2a_extensions 条目;SDK 侧语义未来仍可能变化。
  2. 修复 a2a-sdk 使其读取 context.state["http_kwargs"]——从 ClientCallContext docstring 看,SDK 层才是根本问题所在,值得同步向上游报告;但在 SDK 发版修复前 opencode-a2a 出站鉴权一直处于不可用状态,建议先以本补丁兼容(两套头内容相同,SDK 修复后亦无副作用)。

验证

  • 补丁已在实际部署中验证:CLI 与服务端内嵌客户端均完成对启用 bearer 鉴权 peer 的 SendMessage 往返(HTTP 200,TASK_STATE_COMPLETED)。
  • 未鉴权请求仍被正确拒绝(401)。
  • tests/client 全部 84 项测试通过。

验收标准

  • 配置 A2A_CLIENT_BEARER_TOKEN / A2A_CLIENT_BASIC_AUTH 后,SendMessage / GetTask 等 JSON-RPC 调用实际携带 AuthorizationA2A-Version 请求头
  • 新增回归测试:以真实 HTTP stub peer(而非 mock 的 SDK client)断言出站请求头——现有测试 mock 了 SDK client,恰好无法覆盖此问题
  • 凭据白名单行为不变:非 A2A_CLIENT_ALLOWED_HOSTS 的 host 不发送凭据(fail-closed)

基线快照 / 环境

  • 审计起点:4a4c062ab4886a683ca1c0d09b481f27b3a3b974(main)
  • opencode-a2a 1.3.0(uv tool install,Python 3.13,Debian 13 x86_64)
  • a2a-sdk 1.1.2(lockfile 固定)

说明:本 Issue 由作者与 AI 助手协作完成——问题排查、抓包验证与补丁均为真实环境中的实测结果;中文文本由 AI 辅助翻译并经作者校对。如有个别表述不够自然,敬请指出,可另行提供英文版本。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions