背景
配置出站凭据(A2A_CLIENT_BEARER_TOKEN / A2A_CLIENT_BASIC_AUTH)后,Agent Card 拉取会正确携带 Authorization 头,但所有实际的 JSON-RPC 调用(SendMessage、GetTask 等)完全不携带该头。任何要求鉴权的对端(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.py(build_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 请求时完全不读取 state(a2a/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_parameters 与 timeout;context.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,两套头内容一致,不产生冲突)。
替代方案
- 经
ServiceParameters 传递鉴权头——SDK 目前唯一读取的 per-call 头通道,但该机制的语义是 A2A 扩展/服务参数,且需小心避免覆盖 with_a2a_extensions 条目;SDK 侧语义未来仍可能变化。
- 修复 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 项测试通过。
验收标准
基线快照 / 环境
- 审计起点:
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 辅助翻译并经作者校对。如有个别表述不够自然,敬请指出,可另行提供英文版本。
背景
配置出站凭据(
A2A_CLIENT_BEARER_TOKEN/A2A_CLIENT_BASIC_AUTH)后,Agent Card 拉取会正确携带Authorization头,但所有实际的 JSON-RPC 调用(SendMessage、GetTask等)完全不携带该头。任何要求鉴权的对端(peer)都会对SendMessage返回 HTTP 401,客户端将其映射为:CLI(
opencode-a2a call)与服务端内嵌客户端(a2a_call工具路径)均受影响——二者共用同一个 facade 客户端A2AClient。对于需要双向 peering 的部署,出站鉴权实际上完全不可用。根因
A2AClient将每次调用的鉴权头放入 SDK 的ClientCallContext.state:src/opencode_a2a/client/request_context.py(build_call_context):但 a2a-sdk 1.1.2 在组装 HTTP 请求时完全不读取
state(a2a/client/transports/http_helpers.py::get_http_args):仅读取
service_parameters与timeout;context.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,可直接观察到不对称现象:
复现
对任意启用 bearer 鉴权的 A2A peer:
同一 token 用 curl 直接调用则成功:
建议修复
在共享的
httpx.AsyncClient上直接配置出站凭据头(JSON-RPC transport 与 card resolver 均复用该 client),不再依赖每次调用的CallContext.state:build_default_headers已在该模块导入。安全性(凭据白名单模型不受影响):
A2AClientManager.borrow_client在构造 client 之前即对非白名单 host 剥离凭据(dataclasses.replace),因此携带凭据的 httpx client 只会为白名单 host 创建;且一个A2AClient实例绑定单一 peer URL,凭据不会跨 peer 泄漏。build_call_context可保持不变(无害;若 SDK 未来恢复读取state,两套头内容一致,不产生冲突)。替代方案
ServiceParameters传递鉴权头——SDK 目前唯一读取的 per-call 头通道,但该机制的语义是 A2A 扩展/服务参数,且需小心避免覆盖with_a2a_extensions条目;SDK 侧语义未来仍可能变化。context.state["http_kwargs"]——从ClientCallContextdocstring 看,SDK 层才是根本问题所在,值得同步向上游报告;但在 SDK 发版修复前 opencode-a2a 出站鉴权一直处于不可用状态,建议先以本补丁兼容(两套头内容相同,SDK 修复后亦无副作用)。验证
SendMessage往返(HTTP 200,TASK_STATE_COMPLETED)。tests/client全部 84 项测试通过。验收标准
A2A_CLIENT_BEARER_TOKEN/A2A_CLIENT_BASIC_AUTH后,SendMessage/GetTask等 JSON-RPC 调用实际携带Authorization与A2A-Version请求头A2A_CLIENT_ALLOWED_HOSTS的 host 不发送凭据(fail-closed)基线快照 / 环境
4a4c062ab4886a683ca1c0d09b481f27b3a3b974(main)uv tool install,Python 3.13,Debian 13 x86_64)