Skip to content

feat(a2a): implement A2A transport and server bridge (Track 3)#22

Open
MichielDean wants to merge 16 commits into
mainfrom
feat/a2a-transport-track3
Open

feat(a2a): implement A2A transport and server bridge (Track 3)#22
MichielDean wants to merge 16 commits into
mainfrom
feat/a2a-transport-track3

Conversation

@MichielDean
Copy link
Copy Markdown
Collaborator

Summary

Completes Track 3: A2A transport implementation for both caller and server sides.

Caller side (adcp module)

  • A2aCaller — JSON-RPC tool dispatch over A2A protocol with SSE streaming, SSRF-safe redirect policy, response size limits, and idempotency key
  • A2aConnectionManager — credential-isolated client cache using HMAC cache hash; protected headers (Authorization, Cookie) stripped from cache keys but forwarded to agent-card discovery; exact-key eviction on transport errors
  • ProtocolClient — A2A dispatch path wired with computeCacheHash, evict-and-retry on transient failures

Server side (adcp-server module)

  • A2aServlet — Jakarta servlet bridge for A2A JSON-RPC; AsyncContext-based SSE streaming; writerLock guards concurrent writes; exactly-once asyncContext.complete() via AtomicBoolean CAS; completeAsync holds writerLock to eliminate write-after-complete race
  • A2aAgentExecutor — adapts A2A message requests to AdcpPlatform.handleTool; plumbs ServerCallContext state as request headers (primitive values only)
  • A2aAuthProvider — pluggable authentication SPI
  • A2aServerBuilder — fluent builder wiring AgentExecutor + RequestHandler

Security hardening (5 audit cycles + 3 code review cycles)

  • SSRF: redirect-never HttpClient, DNS pre-validation, AgentCard URL pinning to validated origin
  • Input bounds: request body cap (1 MB), method/message ID/tool name length limits, args scan cap, adcp_version field length cap
  • Auth isolation: per-credential HMAC cache hash prevents cross-tenant client sharing; raw secrets never encoded into cache key strings
  • SSE concurrency: completeAsync holds writerLock to close the write-after-complete race; subscription backpressure via request(SSE_PREFETCH) / request(1)
  • Log safety: control-character stripping in all user-controlled log fields

Dependencies

  • Added a2a-java-sdk 1.0.0.CR1; upgrade to GA deferred to a follow-up (expected minimal changeset)

ROADMAP

  • Track 3 marked complete
  • A2A SDK version decision documented

@MichielDean
Copy link
Copy Markdown
Collaborator Author

I know it said to wait for a full 1.0.0 release, but I think this CR1 release is likely close enough for now and we likely won't have any major breaking changes and we can punt the full 1.0.0 upgrade to later. This will allow us to close track 3 for now.

@MichielDean MichielDean marked this pull request as ready for review May 20, 2026 22:23
@MichielDean MichielDean requested a review from bokelley as a code owner May 20, 2026 22:23
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes — narrow block. The only thing keeping this from approval is a missing .changeset/*.md. Expert verdicts came back clean: security-reviewer no HIGH/MEDIUM with all five hardening claims verified line-by-line; ad-tech-protocol-expert sound-with-caveats; code-reviewer no Blockers. The implementation is well-built — 5 audit cycles show in the diff.

Why this blocks

.changeset/ has only README.md and config.json. This PR adds a new public transport surface on two published artifacts — adcp (org.adcontextprotocol.adcp.transport.a2a.A2aCaller, A2aConnectionManager) and adcp-server (A2aServlet, A2aServerBuilder, A2aAgentExecutor, A2aAuthProvider). Per .changeset/README.md and D18, that is the textbook adopter-visible change. changeset-check is green because npx changeset status succeeds on zero entries — the workflow is informational, the policy is not. Run npx changeset, pick adcp + adcp-server at minor, and ship the entry alongside.

Things I checked

  • D10 alignment. a2a-java-sdk:1.0.0.CR1 in gradle/libs.versions.toml is the explicit pinned version per D10 — not drift. Upgrade to GA is a straight version bump per the decision row.
  • D9 alignment. No drift off mcp-core:1.1.2 + mcp-json-jackson2:1.1.2. No mcp bundle artifact slipped in.
  • D2 / D7 / D3. JDK 21 features used (no *Async mirror), jakarta.servlet throughout, org.adcontextprotocol.adcp.{transport.a2a,server.a2a} sub-packages match the surface convention.
  • *Request / *Response invariant. ad-tech-protocol-expert confirmed no new public records violate the naming guard.
  • No Optional<T> on the public surface. Confirmed by both reviewers.
  • SSRF posture (A2aCaller, A2aConnectionManager). security-reviewer walked the path: AdcpHttpClient.sendDnsPinResolver.resolveAndPinRedirect.NEVER → body cap; StrictSsrfPolicy denies loopback / link-local / site-local / multicast / IPv4-mapped-v6 / CGN / ULA / 6to4-private / Teredo-private / NAT64-private. AgentCard URL pinning at A2aConnectionManager.java:359-366 overrides url + supportedInterfaces from the validated baseUri — hostile card cannot redirect subsequent JSON-RPC POSTs.
  • Cache-key isolation (A2aConnectionManager.java:77). HMAC-SHA256 with a per-process random key via ProtocolClient.java:252; raw secrets never enter the key. Protected headers (Authorization, Cookie, Proxy-Authorization) stripped from cache keys but forwarded to agent-card discovery. A2aConnectionManagerTest.java:110-116 asserts Authorization absent from the key — the right assertion shape.
  • SSE concurrency (A2aServlet.java). writerLock guards every writer touch; completed.get() checked inside the lock at :362 before any write; completeAsync CAS-flips completed inside the same lock at :379. Write-after-complete is closed.
  • Input caps. Request body 1 MB at A2aServlet.java:188 enforced before parse; method/message-id/tool-name length caps + control-character strip throughout; adcp_version capped at 20; parts-/history-scan capped at 20.
  • Build. ./gradlew build green; lockfile regen across all eight modules looks like an honest sweep, not drift.
  • Tests. A2aServletTest.java (556 LoC, largest-file rule) covers body-cap, malformed JSON, missing method, unknown method — solid edge-case shape. @SuppressWarnings("deprecation") at :54 is the symptom of the unauth-provider footgun called out below.

Follow-ups (non-blocking — file as issues)

  1. A2aServerBuilder.ensureStarted() lifecycle (adcp-server/.../A2aServerBuilder.java:75). mainEventBusProcessor is started but never closed. Adopters who hot-reload or call build() more than once leak the event bus + queue manager + task store per call. Either return a Closeable server wrapper, or move the processor's lifecycle to a parent component.

  2. extractCallContextHeaders projects state into headers (adcp-server/.../A2aAgentExecutor.java:179). ServerCallContext.getState() carries non-HTTP-header data; flattening it into AdcpContext.headers() (primitive-only filter or not) means tool handlers see internal context entries as if they came from the wire. Allowlist the keys, or namespace them, or surface them on AdcpContext as a distinct field.

  3. AgentCard well-known path (adcp/.../A2aConnectionManager.java:356). Hand-rolled /.well-known/agent.json; current A2A spec is /.well-known/agent-card.json. The pinned SDK likely already has a discovery helper — delegate to it rather than hand-rolling the URL, and you stop paying this tax on every spec revision.

  4. Idempotency-Key not first-class. A2aCaller.callTool forwards a generic headers map (A2aCaller.java:111); AdcpContext (AdcpContext.java:18-22) has no idempotencyKey field. PR description claims plumbing, but the canonical header name isn't enforced at either end. Either honor the IETF draft header name in A2aCaller + surface it on AdcpContext, or drop the claim from the description.

  5. adcp_version extracted from args, not metadata (A2aAgentExecutor.java:138-171). Tool-name discriminator lives in metadata.adcp_tool_name on the caller side; version lives in args on the server side. Cross-transport invariant check against the MCP path would be worth filing. Also the major >= 3 gate at :156-158 is a magic number without a citation.

  6. Transport-error retry walk doesn't check e itself (adcp/.../ProtocolClient.java:169). isTransportError walks getCause() but skips e. A2aCaller.java:122 throws timeout ProtocolError with cause == null, so the timeout path never retries — likely the opposite of intent.

  7. Hard-coded 30s RESPONSE_TIMEOUT_SECONDS (A2aCaller.java:122). Ignores CallToolOptions.timeout. MCP path honors it; A2A doesn't. v0.1 limitation but file it.

  8. Deprecated single-arg A2aServlet constructor (A2aServlet.java:91-97). Wires an unauthenticated provider. @Deprecated is the right marker, but it's also the copy-paste path from A2aServerBuilderTest. Drop it before GA, or require an explicit A2aAuthProvider.unauthenticated() sentinel so the call-site reads as a deliberate opt-out.

  9. Unbounded objectMapper.valueToTree(first) fallback (A2aCaller.java:235-238). Last-ditch path when DataPart and TextPart scans both miss. Bounded upstream by the SSE chunk cap, so impact is small, but gate by MAX_CONTENT_LENGTH or throw ProtocolError and stop encoding "unknown" into a tree.

Minor nits (non-blocking)

  1. JSONRPC literal string repeated in A2aServerBuilder.java:96-102 — pull the constant from A2aConnectionManager.JSONRPC_TRANSPORT or shared A2aConstants.
  2. A2aServlet.writeError(response, SC_OK, …) (A2aServlet.java:171) returns HTTP 200 with a JSON-RPC error body — correct per JSON-RPC 2.0, inconsistent with the 4xx paths above. Pick a convention or document the split.
  3. HttpAgentCardLoader.load log level (A2aConnectionManager.java:330). Non-2xx responses currently debug; warn would give adopters a signal when an agent URL is wrong without spamming on transient network failures.

Add the changeset and the verdict flips to approve. Sensible v0.1 baseline; the audit-cycle claims hold up under scrutiny.

This comment was marked as resolved.

This comment was marked as resolved.

github-actions[bot]
github-actions Bot previously approved these changes May 20, 2026
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid implementation of Track 3. SSRF closure via HttpAgentCardLoader.normalize pinning url + supportedInterfaces to the validated baseUri (A2aConnectionManager.java:359-392) is the right shape, and the SSE writerLock + AtomicBoolean completed CAS inside the lock (A2aServlet.java:377-383) is the correct exactly-once machinery. code-reviewer, security-reviewer, and ad-tech-protocol-expert all came back with no Critical/Blocker — Mediums and Minors only.

Things I checked

  • D-decision audit. D2 (no *Async, JDK 21) — clean. D7 (jakarta only) — A2aServlet is on jakarta.servlet.*. D9 (mcp-core + mcp-json-jackson2 1.1.2) — unchanged, no jackson3 leak via the A2A bundle. D10 — modified in-PR (see Follow-up 1). D18 — Conventional Commits clean.
  • Lockfile diff. Seven gradle.lockfiles touched. No Bouncy Castle (D2 honored), no jackson3 pull from the A2A transitive tree.
  • Largest-file rule. Read A2aConnectionManager.java (414), A2aServlet.java (390), A2aCaller.java (313), A2aAgentExecutor.java (199 — borderline), A2aServletTest.java (556). Findings cited inline below.
  • Per-process random HMAC for cache hashing (ProtocolClient.java:241-245) — prevents ghp_*-style token reversal from heap dumps. Right call.
  • Storyboard CI still in progress at the time of review.

Follow-ups (non-blocking — file as issues)

  1. D10 governance. ROADMAP.md:22 flips D10 from "keep types in-tree until ≥1.0.0" to "depend on 1.0.0.CR1 directly" in-PR. Per CONTRIBUTING.md, changing a confirmed decision starts with an issue. Technical rationale (Beta1→CR1 is bug-fix only, package layout stable) holds, but this should have an explicit governance trail rather than landing bundled with the implementation. Flagging for maintainer sign-off — not blocking, since the change is documented openly in the PR body and the row itself.

  2. specs/a2a-binding.md is missing. AdCP-over-A2A wire shape is invented in code: adcp_tool_name metadata key, TextPart(toolName) duplicated as first part, version envelope merged into DataPart args (not metadata), extractResponse precedence DataPart→TextPart→tree-of-first-part, history-scan of up to 20 messages on terminal tasks. None of this is in specs/. Without a binding doc, the TS / Python SDKs diverge silently. mcp-prototype-findings.md set the precedent — A2A should match.

  3. Cross-tenant cache collision on shared OAuth clientId. ProtocolClient.computeTokenHash() at L283-L286 keys oauthClientCredentials on "cc:" + clientId only; two tenants sharing the same clientId but different clientSecret collide. basicAuth (username + ":" + password) collides at ("a:b", "c") ≡ ("a", "b:c"). Include the secret in the HMAC input (delimiter-separated or HMAC-per-field). Edge-case for single-tenant adopters, real gap for shared-IdP multi-tenant.

  4. A2aAgentExecutor.extractCallContextHeaders missing ProtectedHeaders filter (A2aAgentExecutor.java:173-188). Client side filters extraHeaders through ProtectedHeaders.isProtected() before sending; server side copies ServerCallContext.state into AdcpContext.headers() with no symmetric filter. If an adopter's A2aAuthProvider stashes Authorization / Cookie in state, those leak into tool implementations.

  5. A2aServlet JSON-RPC body is parsed by Gson with no depth bound. 1 MB byte cap is enforced (readRequestBody, L183-L197) but Gson defaults to depth 1000 and is not configured by JsonUtil. A 1 MB crafted-nested payload OOM-multiplies into ~16 MB of wrapper allocations. Not code-exec — DoS multiplier. Either depth-limit JsonReader or transcode through AdcpObjectMapperFactory (which has StreamReadConstraints) before re-handing to Gson.

  6. A2aConnectionManager.evictOldest holds cacheLock during client.close() (A2aConnectionManager.java:120-121, 200-209). A blocking socket teardown pins the global lock; concurrent getOrConnect() across all 32 stripes queues behind it. Collect evictees locally, release the lock, then close.

  7. Test coverage gap on the SSE concurrency machinery. A2aServletTest.AsyncStreamingRequestHandler (L529-L555) submits two events sequentially from one thread and closes. The writerLock + completed CAS guard exists to serialize onError / onTimeout against in-flight onNext — none of which is exercised. The PR claims this race is closed; tests don't validate the claim. Worth at least one race-test before this code goes load-bearing.

  8. A2aCaller synchronous-delivery guard is dead (A2aCaller.java:128-131). If callbacks fired synchronously, the latch is already at 0, so the getCount() > 0 && (latestMessage|task|failure != null) predicate is unreachable in the synchronous-delivery case. In the only scenario it could fire (async, callback path broken), force-counting-down masks the bug. Remove or convert to an assertion.

  9. extractResponse history-scan is too forgiving (A2aCaller.java:182-189). A task in TASK_STATE_COMPLETED with no status.message is server-misbehavior; walking up to 20 history messages risks returning a stale prior-turn payload. Scope the scan to non-terminal states.

  10. AgentCard skills=List.of() may fail A2A schema validation. A2aServerBuilder.buildAgentCard (L102) and HttpAgentCardLoader.fallbackCard (L410) both emit empty skills. A2A 0.3+ requires at least one. Stub adcp.invoke until codegen surfaces skill metadata.

  11. A2aServerBuilder.build() wires Runnable::run for both executors (L84-85). Tool execution runs on the servlet container's request thread — fine if the adopter pairs this with a virtual-thread container per D2, worth a Javadoc note pointing at Thread.ofVirtual().factory() for adopters who aren't.

  12. No changeset. New public packages (transport.a2a, server.a2a) plus a new a2a-java-sdk coordinate. changeset-check CI passed because the workflow runs changeset status informationally — but per .changeset/README.md ("when in doubt, add one"), a new transport surface isn't in doubt. Add a minor for adcp + adcp-server.

Minor nits (non-blocking)

  1. Stripe selection uses signed mod. A2aConnectionManager.java:90(hashCode & 0x7FFFFFFF) % STRIPE_COUNT distributes weakly across near-identical keys. Prefer Math.floorMod(cacheKey.hashCode(), STRIPE_COUNT).

  2. A2aServlet sets status / content-type after startAsync (A2aServlet.java:247-253). Move the header block above startAsync so subscribers invoked synchronously inside publisher.subscribe(...) always observe the right headers.

  3. Single-arg A2aServlet(RequestHandler) is correctly deprecated and Javadoc-warned as test-only, but production safety rides entirely on adopters reading the doc. Worth an explicit opt-in (-Dadcp.a2a.allow-unauthenticated=true) so a misconfigured prod deploy fails fast instead of accepting everyone.

Interesting that a PR advertising "5 audit cycles + 3 code review cycles" lands with zero concurrent-write tests on the writerLock machinery it's claiming to harden — worth closing that loop before this gets load-bearing.

LGTM. Ship it once storyboard CI goes green. D10 governance and specs/a2a-binding.md are the two follow-ups worth catching the maintainer on; the rest can roll into Track-3 follow-up PRs.

This comment was marked as resolved.

github-actions[bot]
github-actions Bot previously approved these changes May 20, 2026
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The Track 3 wire-up is the right shape: caller-side A2aConnectionManager with HMAC-keyed cache, server-side A2aServlet with writerLock-guarded SSE writes, and ProtocolClient evict-and-retry — and the D10 pin at 1.0.0.CR1 matches the amended ROADMAP row authorizing CR1.

Things I checked

  • D10-aligned. gradle/libs.versions.toml:24 sets a2a-sdk = "1.0.0.CR1"; ROADMAP D10 (line 22) explicitly authorizes CR1 with 1.0.0 final as a straight version bump. No drift.
  • D9-aligned. Still on mcp-core:1.1.2 + mcp-json-jackson2:1.1.2. No jackson3 leak through the A2A artifacts.
  • D7-aligned. jakarta.servlet.* everywhere; no javax surface.
  • D2-aligned. No *Async mirror methods, no Bouncy Castle.
  • SSE concurrency invariant. A2aServlet#writerLock covers both response.getWriter().write(...) and the completed.compareAndSet in completeAsync (A2aServlet.java:361-367, 377-383). The write-after-complete race the description claims to close is in fact closed.
  • Cache-key isolation. ProtocolClient#computeCacheHash HMACs token + sorted extraHeaders with \0/=/\n delimiters keyed by a per-process SecureRandom key. Raw secrets never appear in the cache-key string. CR/LF in extraHeaders is rejected upstream at AgentConfig.validateExtraHeaders, so the \n delimiter is unambiguous.
  • SSRF posture. HttpAgentCardLoader#normalize unconditionally overrides card.url() and supportedInterfaces with the validated baseUri (A2aConnectionManager.java:365-366), and the underlying HttpClient uses followRedirects(NEVER). JSON-RPC traffic cannot be redirected to internal addresses via a hostile agent card.
  • Body cap. A2aServlet#readRequestBody enforces the 1 MB cap before out.write (A2aServlet.java:189-193); max overshoot bounded by buffer.length.
  • Auth opt-in. A2aServerBuilder.build() returns a RequestHandler, not a servlet — adopters must instantiate A2aServlet themselves, so the @Deprecated no-arg unauthenticated constructor is opt-in.

Follow-ups (non-blocking — file as issues)

  1. AgentCard URL pinning over-pins. A2aConnectionManager.java:365-366 replaces card.url() and supportedInterfaces with the bare baseUri. That forces all JSON-RPC traffic to baseUri root and breaks any agent that legitimately advertises a path-suffixed endpoint (e.g. https://host/agents/seller/jsonrpc). The SSRF concern is real but the fix is broader than it needs to be — pin the origin to baseUri, allow the path the card declares. (ad-tech-protocol-expert flagged this as the highest-priority finding.)
  2. Caller/server wire-shape asymmetry. Caller sends metadata["adcp_tool_name"] + TextPart(toolName) + DataPart(args) (A2aCaller.java:152-162); server reads metadata["adcp_args"] first (which the caller never writes), then falls back to TextPart+DataPart (A2aAgentExecutor.java:108-118). Pick one canonical shape and land specs/a2a-binding.md before GA — right now the Java SDK is unilaterally defining the wire convention.
  3. subRef race vs. async onSubscribe. A2aServlet.java:294-296 assumes Flow.Publisher.subscribe invokes onSubscribe synchronously. If a publisher dispatches onSubscribe on its own executor, onTimeout/onError can run first, cancelSubscription(subRef) returns null, and the later-arriving subscription leaks. Cancel inside onSubscribe if completed.get() is already true.
  4. Runnable::run inline executor. A2aServerBuilder.java:84-85 wires both executors to Runnable::run, so any blocking work in AdcpPlatform.handleTool runs on the servlet request thread and stalls the SSE writer. At minimum add a javadoc warning; ideally accept an Executor parameter.
  5. No changeset. ~700 LoC of new public API (A2aCaller, A2aConnectionManager, A2aServlet, A2aServerBuilder, A2aAuthProvider, A2aAgentExecutor) ships with no .changeset/*.md. CI doesn't fail (changeset-check.yml lacks --exit-code) and no artifact is published yet, but the v1.0 release notes will be the poorer for it.
  6. Test coverage gap. 6 happy-path tests for the 390-LOC servlet (A2aServletTest.java). No coverage for: writerLock contention, onTimeout mid-stream, body at MAX_REQUEST_BYTES + 1, async onSubscribe. Add at least one publisher-firing-onComplete-before-onSubscribe-returns case to lock in the race fix when Follow-up 3 lands.
  7. Dual JSON-mapper exposure. A2aServlet:104 uses com.google.gson.JsonParser for envelope parsing while the rest of the SDK is on Jackson. implementation(libs.gson) keeps it off the public surface, but introducing a second parser for one envelope is a notable choice. Replace with Jackson readTree or document why.

Minor nits (non-blocking)

  1. Dead synchronous-delivery guard. A2aCaller.java:124-131 — every state-setting branch (MessageEvent, terminal task, failure.compareAndSet) already calls countDown(). The extra countDown() is unreachable as a recovery path; the comment misrepresents what protection it provides. Drop or rewrite.
  2. Stack-trace leak in error log. A2aAgentExecutor.java:70 logs the full exception chain; if Jackson surfaces a fragment of the attacker-controlled request, that lands in logs verbatim. Prefer e.getClass().getName() + sanitizeErrorMessage(e.getMessage()).
  3. SSE timeout not configurable. A2aServlet.java:66 SSE_STREAM_TIMEOUT_SECONDS = 300 is private static final. Expose via constructor or init-param so adopters with long-running tools can tune.
  4. evict(URI) dead equals(prefix) branch. A2aConnectionManager.java:131-135buildCacheKey always appends #cacheHash, so no key equals agentUri.toString() alone. Harmless; trim or comment.
  5. Discovery Authorization silently dropped. AdcpHttpClient strips protected headers from outbound discovery; adopters whose /.well-known/agent.json requires auth will silently fall back to the synthetic card. Document the behavior.

Approving on the strength of the SSE concurrency invariant plus the unconditional AgentCard URL override closing the C-2 SSRF surface. The "5 audit cycles + 3 code review cycles" framing in the description sets a notable bar for what counts as a security-hardened PR around here.

This comment was marked as resolved.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes on the missing changeset. The code is sound; the block is a release-notes gap, not a defect.

Things I checked

  • D10 update (ROADMAP.md:22) is intentional governance — pin moves from "wait for 1.0 GA" to 1.0.0.CR1 with rationale (Beta1→CR1 delta is bug fixes only, in-tree fallback would have been throwaway). Aligned, not drift.
  • D9 still on mcp-core:1.1.2 + mcp-json-jackson2:1.1.2. No swap to the mcp bundle artifact.
  • D7 jakarta-only: A2aServlet imports jakarta.servlet.*, compileOnly(libs.jakarta.servlet.api). Confirmed.
  • D2 invariants: no *Async mirrors, no Bouncy Castle, JDK 21 features in use.
  • *Request / *Response naming invariant: holds across the new surface.
  • @Nullable over Optional<T>: confirmed across A2aAgentExecutor, ProtocolClient.
  • SSRF posture (caller): JdkA2AHttpClient is built on the AdcpHttpClient builder which sets followRedirects(NEVER). HttpAgentCardLoader.normalize (A2aConnectionManager.java:359-392) unconditionally pins url and supportedInterfaces to baseUri regardless of what the remote card declares — closes the obvious bypass.
  • Cache-key cross-tenant isolation: HMAC-keyed via per-process random in ProtocolClient.computeCacheHash (ProtocolClient.java:241-269). filterProtected strips Authorization/Cookie before buildCacheKey (A2aConnectionManager.java:77-78). No raw secret in the key.
  • SSE write-after-complete: completeAsync CAS is under writerLock (A2aServlet.java:377-383); writeStreamingResponse re-checks completed.get() inside the same lock (line 361-364). Race is closed.
  • Caller-side version envelope is merged at ProtocolClient.callTool:111 via VersionEnvelope.mergeInto(args, version) before dispatch — A2aCaller receives merged args. Looked like a gap on first read; it isn't.
  • code-reviewer: SOUND-WITH-CAVEATS, no Blockers.
  • security-reviewer: SOUND-WITH-CAVEATS, no High findings.
  • ad-tech-protocol-expert: SOUND-WITH-CAVEATS.

MUST FIX

  1. No changeset for an adopter-visible PR. Adds two new public packages (org.adcontextprotocol.adcp.transport.a2a.* and org.adcontextprotocol.adcp.server.a2a.*) across two of the eight published artifacts plus pulls in a new top-level dep (a2a-java-sdk:1.0.0.CR1). Per .changeset/README.md and CONTRIBUTING.md, this is the canonical "adopter-visible behavior" case. The changeset-check job passes because npx changeset status doesn't exit non-zero on empty — that's a CI gap, not permission. Add a minor changeset for adcp + adcp-server describing the A2A transport addition, and call out the D10 pin so the v0.3 release notes carry it.

Follow-ups (non-blocking — file as issues)

  • Caller-side tasks/cancel fire-and-forget on buyer abort. ROADMAP §7.x explicitly requires it (ROADMAP.md:93). A2aCaller currently only times out the latch (A2aCaller.java:133); no POST on interrupt/timeout. Track for v0.4.
  • DNS TOCTOU between ProtocolClient.validateUrl probe and JDK HttpClient re-resolve. Acknowledged in the comment at ProtocolClient.java:217-221. DnsPinResolver already exists; the A2A path doesn't yet flow through it. Decide before v0.4 whether to thread the pinned resolver through JdkA2AHttpClient or accept the JDK's re-resolve.
  • Streaming SSE has no event-count or byte-budget cap. A2aServlet.stream honors the 300s timeout but does not bound total bytes written. A misbehaving AgentEmitter can stream unbounded data.
  • A2aCaller.extractFromParts fallback bypasses MAX_CONTENT_LENGTH. A2aCaller.java:246-256: when neither DataPart nor TextPart matches in the scan window, the valueToTree/treeToValue path runs without the size guard. Bounded by Jackson stream constraints but inconsistent with the rest of the file.
  • A2aServlet.doPost catch-all logs nothing. Line 172. The 500 envelope reaches the client; the operator gets no stack. Add log.error("A2A request handling failed", e) on this branch and on the IO branch at 163-169.
  • A2aConnectionManager.close() holds cacheLock during closeQuietly for every cached client. A slow Client.close() blocks every concurrent getOrConnect. Snapshot values, clear+flag closed under the lock, close outside.
  • A2aServerBuilder uses caller-thread executors and unbounded in-memory stores by default (Runnable::run, in-memory task/queue/notification stores). Javadoc warns about the stores but not the executors. Strengthen before v0.3.
  • CR1 → 1.0 final transition. D10 calls this "a straight version bump." Surface area is small but isolated to A2aServlet.java:302/347/354; be ready for wrapper-class rename at GA.

Minor nits (non-blocking)

  1. A2aConnectionManager.java:90cacheKey.hashCode() & 0x7FFFFFFF) % STRIPE_COUNT is correct; Math.floorMod(cacheKey.hashCode(), STRIPE_COUNT) reads cleaner.
  2. A2aCaller.java:128-131 — the synchronous-delivery latch guard is a no-op when the latch is already at 0; the comment admits it. Delete the dead branch.
  3. A2aConnectionManager.java:213-216MAX_HEADERS=50 silently truncates rather than rejects. A caller passing 60 routing headers gets the last 10 dropped and the connection still proceeds with potentially-wrong routing. Reject is the safer default.
  4. A2aServlet.java:90-97 — deprecated single-arg constructor allocates a new UnauthenticatedUser ServerCallContext lambda per request. Hoist or static-field it. The WARNING Javadoc reads stronger than the @Deprecated annotation — since="0.1" would help.
  5. A2aCaller.java:220-221, 240-241 — Jackson e.getMessage() is logged unsanitized at debug level. Source-input excerpts from a malicious agent can include CRLF; inconsistent with the rest of the file's sanitize-then-log discipline.

Five audit cycles plus three code-review cycles show in the diff — concurrency model is the right shape and the SSRF defenses are real. Add the changeset and this lands.

This comment was marked as resolved.

github-actions[bot]
github-actions Bot previously approved these changes May 20, 2026
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. D10 pin (a2a-sdk = "1.0.0.CR1" at gradle/libs.versions.toml:24) is exactly what the ROADMAP authorized, the SSRF posture is the right shape (redirect=NEVER + AgentCard URL/interface pinning to validated baseUri), and the public surface stays D2-compliant — no *Async mirrors, no Optional returns.

Things I checked

  • D10 lineagelibs.versions.toml:24 and every a2a-java-sdk-* line in adcp/gradle.lockfile + adcp-server/gradle.lockfile resolve to 1.0.0.CR1. Matches ROADMAP §Confirmed decisions D10 and §7.x line 231.
  • D2 / surfaceA2aCaller.callTool is synchronous-blocking; Flow.Publisher use is internal to the SSE bridge in A2aServlet.stream. No async mirror methods leaked to the public API.
  • *Request / *Response naming — locally defined types comply; the Send*Request / Send*Response envelopes come from upstream a2a-java and are not ours to rename.
  • SSRF (caller)A2aConnectionManager.DefaultClientFactory:303-305 wires JdkA2AHttpClient over the AdcpHttpClient-built HttpClient (redirect=NEVER); HttpAgentCardLoader.normalize:357-364 pins AgentCard.url and supportedInterfaces to the validated baseUri regardless of what the remote card declares. SSRF bypass through card-embedded URLs is closed.
  • HMAC cache isolation — per-process random key in ProtocolClient HMACs the credential into cacheHash; raw secrets never enter the cache key string; filterProtected keeps Authorization/Cookie out of the cache-key portion that includes discovery headers.
  • SSE write-after-completewriterLock + AtomicBoolean completed form a correct atomic gate in A2aServlet.writeStreamingResponse:363-369 and completeAsync:379-385. No write can land on a completed AsyncContext.
  • Input bounds — 1 MB request body cap (A2aServlet:188-194), tool-name/method/message-ID/version caps with control-char stripping applied before any log site in A2aCaller:80-91, A2aServlet:217-223, A2aConnectionManager.sanitizeForLog:265-269, A2aAgentExecutor.sanitizeErrorMessage.
  • code-reviewer: sound-with-caveats — flagged the timeout leak below.
  • security-reviewer: sound-with-caveats — no Critical/High; two Mediums on agent-card discovery posture (below).
  • ad-tech-protocol-expert: sound-with-caveats — D10 aligned, A2A wire shape conformant, MCP↔A2A parity gaps to track (below).

Follow-ups (non-blocking — file as issues)

  1. A2aServlet.onTimeout does not explicitly complete the AsyncContext. A2aServlet.java:336-346 — when timeout fires before the response is committed, writeTimeoutResponse sets completed=true inside writerLock, then onTimeout calls completeAsync (line 273), whose CAS now fails and asyncContext.complete() is never invoked. Container teardown still tears it down on its own timeout, but explicit completion is the contract. Fix: have the not-committed-yet branch in writeTimeoutResponse call asyncContext.complete() itself, or split the gate so completeAsync can complete even after completed was set by the committed-error path.
  2. A2aServletTest does not exercise the load-bearing concurrency claims. The 556-line file covers happy-path streaming. There is no timeout test, no onError test, no concurrent-write test, no subscription.cancel test — the writerLock / CAS / write-after-complete invariants the PR description leans on are untested. Adding even a basic timeout test would have caught (1).
  3. Agent-card discovery is unauthenticated despite the PR description. PR body claims protected headers are "forwarded to agent-card discovery." They are not — AdcpHttpClient.send unconditionally strips ProtectedHeaders.NAMES, so any /.well-known/agent.json behind auth always 401s and the loader silently returns fallbackCard. URL pinning still keeps the JSON-RPC origin safe, but skill enumeration is degraded against auth-gated agents. Either update the PR description or add an SDK-managed-auth path for .well-known GETs.
  4. AgentCard body cap drops real cards. A2aConnectionManager:336 short-circuits on response.truncated(). The default AdcpHttpClient body cap is small enough that production AgentCards with skills + capabilities + OAuth metadata trip it and fall back to synthetic — and the synthetic card always declares JSONRPC + streaming(true). Add a per-call maxResponseBytes for .well-known GETs (e.g., 256 KiB).
  5. MCP↔A2A parity gap on AdcpContext.requestId. AdcpServerBuilder.java:148 passes requestId=null to MCP handlers; A2aAgentExecutor.execute:62 forwards A2A messageId. Same handleTool will see different context shapes across transports. Track 3 marked complete shouldn't ship without MCP being brought to parity here.
  6. Error-envelope divergence MCP vs A2A. MCP returns {error, message} in CallToolResult.isError=true; A2A path emits the same JSON inside a TextPart via emitter.fail(). A2aCaller.extractResponse:165-194 only treats TaskState.TASK_STATE_FAILED/CANCELED as failure — structured {error, message} bodies get parsed back as the success type, losing the code discriminator on A2A.
  7. Idempotency-key not wired on A2A. A2aCaller.callTool accepts a headers map but there is no first-class handling of idempotency_key; A2aAgentExecutor does not read it from metadata or callContext.state. ROADMAP §Tracks puts idempotency cache in adcp-server scope — covering A2A here closes the L0 parity gap.
  8. Missing .changeset/*.md. feat(a2a) adds adopter-visible surface in adcp and adcp-server. changeset-check is green (the workflow doesn't fail on absence), but .changeset/README.md says "Add a changeset whenever your PR changes adopter-visible behavior" — fix before v0.3 publish cuts.

Minor nits (non-blocking)

  1. A2aServlet.java:172-175 swallows non-A2AError exceptions with no log. Operators get a 500 with no root cause. Add log.warn("A2A request failed: {}", e.getClass().getSimpleName(), e) in the catch.
  2. DefaultClientFactory.safeHttpClient is never closed. A2aConnectionManager.close() closes per-agent Client instances but not the underlying HttpClient. On JDK 21 HttpClient is AutoCloseable. Close it in close() or document external ownership.
  3. OAuth client-credentials cacheHash only uses clientId in ProtocolClient.java:284-285. Not exploitable today (CC throws FeatureUnsupportedError), but a foot-gun the moment CC is implemented. Include clientSecret in the HMAC input now.
  4. A2aAgentExecutor.extractCallContextHeaders:184 logs the raw key without the control-char stripping used everywhere else. Route through sanitizeForLog.
  5. Tool name carried in both metadata.adcp_tool_name AND parts[0] as TextPart. Robust but breaks A2A's convention that TextPart is human/agent natural-language content. Canonical channel is metadata; drop the TextPart or move into DataPart.data.
  6. A2aCaller.java:128-132 synchronous-client guard is dead code. The condition can't fire — every consumer path that sets a ref also countDowns the latch. Either delete or add a test that justifies it.
  7. JSON-RPC id Number magnitude unbounded at A2aServlet:208-209. Gson returns a LazilyParsedNumber backed by the raw string; a multi-megabyte 1e1000000 id literal would echo back through the response serializer. Clamp via Long.parseLong with fallback to null.

5 audit cycles + 3 code review cycles, and the load-bearing SSE concurrency claim still has no test for the path that breaks it. Worth a follow-up.

Approved on the strength of D10 alignment, the SSRF posture, and a clean public surface. Ship it once (1) and (2) land — those are the only items that touch correctness on a path the PR actually changed.

This comment was marked as resolved.

github-actions[bot]
github-actions Bot previously approved these changes May 20, 2026
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The implementation is sound and the security posture holds; the gaps are scope-and-paperwork, not code.

Five audit cycles plus three review rounds show. SSE concurrency (A2aServlet.stream writerLock + AtomicBoolean CAS, adcp-server/src/main/java/org/adcontextprotocol/adcp/server/a2a/A2aServlet.java:247-396) is exactly-once; normalize() pinning url + supportedInterfaces to the validated baseUri (adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java:357-390) closes the AgentCard SSRF bypass that JdkA2AHttpClient's default Redirect.NORMAL would otherwise leave open; HMAC'd cache key with per-process random key (ProtocolClient.java:241-269) keeps raw tokens out of cache strings; jakarta-only imports under adcp-server (D7-aligned).

Things I checked

  • D-decisions: D2 (no *Async mirrors), D7 (jakarta.servlet.* only, compileOnly(libs.jakarta.servlet.api) in adcp-server/build.gradle.kts:28), D9 (mcp-core/mcp-json-jackson2 still 1.1.2), D10 (a2a-sdk = \"1.0.0.CR1\" in gradle/libs.versions.toml:24).
  • *Request/*Response naming invariant: A2A SDK request wrappers (SendMessageRequest, GetTaskRequest, etc.) come from upstream; nothing new on this PR's side breaks the rule. No .builder() added on a *Response record.
  • SSE write-after-complete race: every writer path gates on completed inside writerLock; completeAsync flips completed before asyncContext.complete() (A2aServlet.java:390-396). Lock-free completed read at L310 is harmless — subsequent onNext re-enters the lock and short-circuits.
  • Auth isolation: cacheHash is HMAC-SHA256 hex ([0-9a-f]) — no #/? collision risk in evict(URI, String) prefix matching (A2aConnectionManager.java:138-146). filterProtected() strips Authorization/Cookie from cache-key construction (L77, L229-238).
  • Body cap: MAX_REQUEST_BYTES = 1 MB enforced as a streaming check in readRequestBody() (A2aServlet.java:188-202) before JsonParser.parseString runs. Gson 2.14.0 carries the default 255-deep nesting limit.
  • DNS TOCTOU on JSON-RPC POST: the JdkA2AHttpClient built from newHttpClientBuilder() (A2aConnectionManager.java:300-305) inherits MCP's accepted TOCTOU window — same posture, not a new vector. AdcpHttpClient.java:33-37 already documents the limitation; the A2A path does not.

Follow-ups (non-blocking — file as issues)

  • AdCP-over-A2A wire shape isn't pinned cross-SDK. A2aCaller.buildRequest emits both metadata.adcp_tool_name and a leading TextPart(toolName) (A2aCaller.java:153-163); server reads metadata first, falls back to TextPart (A2aAgentExecutor.java:80-101). Defensible as "robust receivers," but I can't confirm from in-repo material that this matches what @adcp/sdk (TS) actually puts on the wire. Pin the shape in specs/a2a-wire.md per D16, with a citation to the TS source. Until that's done this is implementation-led, not spec-led.
  • tasks/cancel on buyer abort is not wired. ROADMAP.md:93 lists this as a v0.1 transport-track requirement (fire-and-forget, 5s timeout, swallow rejection, threaded through outbound signing). No tasks/cancel POST exists in A2aCaller or ProtocolClient today; only the server-side inbound CancelTaskRequest handler is here (A2aServlet.java:150-155). The PR description and the ROADMAP edit both mark Track 3 "complete" — that's a notable claim given a v0.1 gate item is open. Either land the caller-side cancel POST or update Track 3's completion claim and file a follow-up issue.
  • A2A SDK surface scope vs. "straight version bump" promise. This PR pulls in 60 references across org.a2aproject.sdk.spec, jsonrpc.common.wrappers, server.requesthandlers, server.agentexecution, server.events, server.tasks, client.transport.jsonrpc, client.http, server.auth, server.util.sse. D10's CR1→1.0 "straight version bump" rests on all of those package paths holding stable through GA. The server-side event-bus surface is the riskiest area for late renames. Name an owner to re-validate against a2aproject/a2a-java CHANGELOG before v0.4 cut.
  • Missing changeset. The CI check passed (the README treats it as "informational for borderline cases"), but .changeset/README.md says "adopter-visible behavior on any of the eight published artifacts" — this PR adds eight new public classes to adcp and adcp-server. When in doubt, add one.
  • A2aServerBuilder.build() is not idempotent. Calls mainEventBusProcessor.ensureStarted() with no shutdown surface (A2aServerBuilder.java:64-86). Repeated builds leak processors. Document the single-shot contract or guard against re-build.
  • A2aAgentExecutor.extractArgs swallows shape errors. objectMapper.convertValue(..., LinkedHashMap.class) throws IllegalArgumentException on a non-map DataPart payload (A2aAgentExecutor.java:109,116), surfaces as generic internal_error. Catch specifically and emit InvalidRequestError-shaped failure so callers know it's a request-shape problem.

Minor nits (non-blocking)

  1. TOCTOU comment parity. ProtocolClient.java:217-235 documents the JDK HttpClient DNS-re-resolve limitation for MCP. The same limitation applies to the A2A JdkA2AHttpClient path and isn't called out at the A2A site. Add a one-line comment near DefaultClientFactory (A2aConnectionManager.java:289-319).
  2. Dead branch in evict(URI). key.equals(prefix) || key.startsWith(prefix + \"#\") (A2aConnectionManager.java:131-136) — every cached key includes #<hash> per buildCacheKey, so the equals branch can never fire. Drop it or document why it's defensive.
  3. Empty skills[] on the agent card. A2aServerBuilder.buildAgentCard():102 and normalize()/fallbackCard() (A2aConnectionManager.java:386-388, 408) emit .skills(List.of()). Defensible default for a generic platform but worth a tracked follow-up — TS 7.0.0's "don't advertise missing tools" invariant maps onto A2A skills, and an empty list is not the same thing as "the wired set."

Safe to merge.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hold. The AgentCard.normalize() SSRF guard (C-2) is incomplete — it pins url + supportedInterfaces but leaves at least one other URL-bearing field on the rebuilt card to whatever the remote agent ships, which re-opens the exact SSRF surface the C-2 comment claims to close. Block is on the v0.1 SSRF baseline (DNS pin + address-guards + redirect: manual + AgentCard URL pinning); the rest of the PR is strong.

Must fix (blocking)

  1. AgentCard normalize() is not comprehensive — SSRF re-open via untrusted card fields (adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java:373-406). Client.builder(agentCard).clientConfig(setUseClientPreference(true)) (L325-333) lets the A2A client consult fields on the card for transport selection. normalize() only overwrites url and supportedInterfaces. security-reviewer H1: confirm whether AgentCard in a2a-java 1.0.0.CR1 carries an additionalInterfaces (or equivalent) array that the JSON-RPC transport will consult with useClientPreference(true) — if it does, a malicious card setting additionalInterfaces: [{transport:"JSONRPC", url:"http://169.254.169.254/..."}] reaches the metadata endpoint past the DNS pin. Also explicitly null/empty iconUrl, documentationUrl, provider, securitySchemes on the rebuilt card since the same untrusted body controls them.

  2. normalize() transport-downgrade inconsistency (same file, L390-391). supportedInterfaces is force-pinned to [JSONRPC] for SSRF, but preferredTransport is only set when blank. A remote card advertising preferredTransport=GRPC plus a GRPC interface will keep preferredTransport=GRPC while supportedInterfaces is silently rewritten to [JSONRPC]getInterface(preferredTransport) then mismatches. Force preferredTransport=JSONRPC (and apply the same to fallbackCard for symmetry), or log a warning when stripping a non-JSONRPC preference.

Things I checked

  • D10 update is consistent — ROADMAP row at ROADMAP.md:22 flips from in-tree fallback to a2a-java 1.0.0.CR1, matching the code at gradle/libs.versions.toml:24. The CR1→GA bump path is reasonable for JBoss-cadence releases.
  • D2/D7/D9/D16 unchanged. No *Async mirrors, jakarta-only servlet imports (compileOnly per adcp-server/build.gradle.kts:28), MCP still on mcp-core:1.1.2, no new ADRs.
  • *Request/*Response invariant intact — A2aServerBuilder is a builder for a model class, not a response record.
  • @Nullable used throughout A2aServerBuilder, A2aAgentExecutor. No Optional<T> returns on the public surface.
  • A2aServlet exactly-once protocol (A2aServlet.java:393-399, 362-372, 374-384, 338-360): lock-on-writerLock + CAS on completed is sound. onNext reads completed under the same lock; no write-after-complete race.
  • A2aConnectionManager double-checked locking around cacheLock + connectStripes is correct; no double-create for the same key. evict(URI)'s startsWith(agentUri + "#") cannot match unrelated URIs since # is the unique terminator.
  • extractVersion(args) is called at A2aAgentExecutor.java:53 before the args.remove(...) at L54-55 — ordering correct.
  • Cache-key collision surface (ProtocolClient.java:267-272, A2aConnectionManager.java:178-206): HMAC-SHA256 with per-process key, header keys lowercased + URL-encoded + TreeMap-sorted. Sound for realistic header inputs.
  • Path-traversal guards in SchemaBundle.java and AdcpSchemaValidator.java catch the relevant vectors (.., leading /).
  • AdcpObjectMapperFactory.deactivateDefaultTyping() is the canonical Jackson gadget defense; belt-and-suspenders since the Jackson default is already off.
  • Largest-file rule: read A2aServlet.java (406), A2aConnectionManager.java (428), A2aCaller.java (316), A2aAgentExecutor.java (205), A2aServletTest.java (sampled).

Follow-ups (non-blocking — file as issues)

  • adcp_tool_name over A2A is not yet a documented AdCP cross-language convention. ad-tech-protocol-expert flagged that the constant lives only at A2aCaller.java:43, with no specs/ or docs/ doc defining the wire shape. Cross-check against @adcp/sdk (TS) A2A caller before v1.0 GA; if TS encodes differently this PR breaks interop. Move the convention into specs/a2a-wire.md per D16.
  • A2aServerBuilder.buildAgentCard() declares skills: [] (A2aServerBuilder.java:102). Fine for v0.4 beta but populate from AdcpPlatform.registeredTools() (specialism declaration) before v1.0 GA — that is exactly what A2A AgentSkill exists for.
  • Adopter-visible PR, no .changeset/*.md. changeset-check CI passes only because no Java module is registered as a changesets package (package.json lists only adcp-sdk-java-tools, which is on the ignore list). Per .changeset/README.md this PR is exactly the case that needs a minor bump on adcp + adcp-server. Wire the Java modules into changesets and add the entry — until that happens, the changeset-check workflow has no teeth on the SDK itself.
  • computeCacheHash separator ambiguity (ProtocolClient.java:267-272). Header-name grammar in HTTP forbids =, but AgentConfig.extraHeaders() is an unvalidated Map. Length-prefix or validate against the token grammar.
  • extractCallContextHeaders namespace (A2aAgentExecutor.java:179-194). Coerces ServerCallContext.state to String and hands to AdcpContext.headers as if they were caller-controlled. Namespace-prefix (e.g. a2a_state:) or document the contract.
  • A2aServlet deprecated single-arg constructor lacks forRemoval=true (A2aServlet.java:91). Add forRemoval=true and consider fail-fast in init() when no auth provider is wired.
  • A2aServletTest has no concurrent-write race test. Author can land that as a follow-up issue.

Minor nits (non-blocking)

  1. Sync-delivery guard in A2aCaller.callTool (A2aCaller.java:130-133) is effectively a defensive no-op — every callback path already calls completion.countDown() itself. The comment overstates its role; either drop it or recomment as "defensive no-op."
  2. Retry-success swallows the original transport error silently (ProtocolClient.java:147-165, 187-203). Add log.debug("... recovered after retry, original: {}", original.getMessage()) so operators can see flapping.
  3. A2aServlet catch-all Exception (A2aServlet.java:178-181) returns 500 with no log. Add log.error("Unhandled A2A servlet error", e) so production bugs aren't invisible.

Once normalize() pins all untrusted URL-bearing fields and the preferredTransport inconsistency is closed, this is ready to ship.

@github-actions
Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Follow-ups noted below. The SSRF posture (agent-card URL pinning in A2aConnectionManager.normalize at adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java:373) and the SSE concurrency discipline in A2aServlet (writerLock + AtomicBoolean CAS across all four write paths) are the right shapes for this surface.

Things I checked

  • D10-aligned. gradle/libs.versions.toml:24 pins a2a-sdk = "1.0.0.CR1" exactly per the ROADMAP row; the CR1 → 1.0.0 final bump is on-roadmap, not a blocker here.
  • D2-aligned. No *Async mirror methods on the public surface. No Bouncy Castle. JDK 21 baseline preserved.
  • D7-aligned. adcp-server uses jakarta.servlet.* at compile time, compileOnly per the existing convention.
  • *Request / *Response invariant — confirmed sound by ad-tech-protocol-expert; no new *Response record has a .builder().
  • @Nullable vs Optional<T> — clean across the new surface (A2aServerBuilder, A2aAgentExecutor, A2aConnectionManager).
  • Credential cache isolation. ProtocolClient.computeCacheHash (adcp/src/main/java/org/adcontextprotocol/adcp/transport/ProtocolClient.java:253) HMAC-SHA256s the token with a per-process random key; "anonymous" collides only with other token-less agents; filterProtected correctly excludes Authorization from the cache key while leaving it in the per-call header map.
  • A2A SSE write-after-complete raceA2aServlet.completeAsync (line 393) and writeFinalStreamingResponse (line 362) both hold writerLock during the CAS-and-complete; no path can write to the response after asyncContext.complete() returns.
  • Outbound auth precedence. ProtocolClient.callTool line 100-108 filters caller extraHeaders through ProtectedHeaders before adding SDK-resolved auth — callers cannot override Authorization on the wire.
  • Changeset. None in the diff. Pre-v0.3 per D6 and the .changeset/README.md exemptions; Check for changeset CI is green.
  • CI. ./gradlew build (JDK 21), Check for changeset, commitlint, check / check, GitGuardian all green at review time.

Follow-ups (non-blocking — file as issues)

  1. A2aServerBuilder leaks the MainEventBusProcessor on rebuild. adcp-server/src/main/java/org/adcontextprotocol/adcp/server/a2a/A2aServerBuilder.java:76 calls mainEventBusProcessor.ensureStarted(), but neither the builder nor the returned DefaultRequestHandler is AutoCloseable. Hot-reload, test cycles, and multi-tenant boot will accumulate background work with no documented shutdown path. Either return a holder that implements close() or document the adopter-side teardown sequence in the class Javadoc.

  2. Version-extraction divergence between A2A and MCP server paths. A2aAgentExecutor.extractVersion (adcp-server/src/main/java/org/adcontextprotocol/adcp/server/a2a/A2aAgentExecutor.java:144) returns null when adcp_major_version is absent; the MCP equivalent AdcpServerBuilder.extractVersion (line 198 of that file) falls back to the server's configured default. Same protocol, two semantics — identical clients will see different AdcpContext.version() depending on transport. The A2A executor isn't even constructor-injected with the server's default. Mirror the MCP fallback.

  3. A2aServerBuilder.buildAgentCard hardcodes .skills(List.of()). adcp-server/src/main/java/org/adcontextprotocol/adcp/server/a2a/A2aServerBuilder.java:102. Empty skills means discovery returns no tools — the CreativeBuilderPlatform doesn't advertise missing tools rule (ROADMAP.md §7.x, 7.0.0 line) is satisfied by accident. Plumb the AdcpPlatform's registered tool list into AgentSkill entries so discovery actually works.

  4. A2aAgentExecutor.extractArgs returns 500 on a non-map adcp_args. Line 115 does objectMapper.convertValue(..., LinkedHashMap.class); if a peer sends adcp_args as an array/string the resulting IllegalArgumentException falls into the generic Exception catch and emits internal_error. Adopters see a 500 for what is really a 400. Add an instanceof Map precheck and throw InvalidRequestError.

  5. A2aServlet body-cap IOException is silently mapped to 400 with no log breadcrumb. Line 169-175. Operators investigating rejected uploads get no SLF4J signal. Add a single log.debug before writeError so the cap is observable.

  6. Unauthenticated single-arg A2aServlet constructor is fenced only by Javadoc. Line 91. Once Spring Boot autoconfig lands and starts auto-wiring, a careless adopter could ship the deprecated convenience constructor to prod. Consider either (a) an explicit A2aAuthProvider.allowAnonymous() opt-in sentinel, or (b) a WARN at servlet init when UnauthenticatedUser is wired against a non-loopback bind.

  7. A2aConnectionManager.normalize overwrites supportedInterfaces blanket. Line 373-406. SSRF reason for URL pinning is sound, but if an agent legitimately advertises GRPC alongside JSONRPC on the same origin, the SDK forces JSONRPC. Acceptable for v0.1 (JSONRPC-only); add a TODO so this gets revisited when GRPC lands.

  8. extractCallContextHeaders doesn't validate header values. adcp-server/src/main/java/org/adcontextprotocol/adcp/server/a2a/A2aAgentExecutor.java:184-193. Adopters whose A2aAuthProvider stuffs raw HTTP headers into ServerCallContext.state will pass attacker-controlled X-Forwarded-* through to tool handlers without the CRLF / control-char filter that A2aConnectionManager.sanitizeHeaders applies. Reuse that filter on the way in.

Minor nits (non-blocking)

  1. A2aCaller.java:127-129 comment. The comment says "this countDown() is a no-op" but in the failure.get() != null window from a truly async error path the countDown is real (just harmless). Tighten the wording.

  2. A2aConnectionManager.java:192 header-case dedup. The comment is correct ("alphabetically-last original key wins among case-insensitive duplicates") but counterintuitive — worth a unit test asserting the X-Tenant vs x-tenant collision shape so the invariant doesn't silently regress.

  3. ProtocolClient.java:242-246 per-process HMAC key. Worth a one-line Javadoc noting connection caches do not persist across JVM restarts so adopters don't expect warm-cache behavior on rolling restarts.

Five audit cycles plus three review rounds is visible — the credential cache hash, header normalization, and SSE concurrency are all sitting in places that take a while to reach without that scrutiny.

Approving.

@github-actions
Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

github-actions[bot]
github-actions Bot previously approved these changes May 22, 2026
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Track 3 lands. Clean shape: caller in adcp/transport/a2a, server bridge in adcp-server/a2a, D10 row updated in-PR to authorize the 1.0.0.CR1 pin.

Things I checked

  • D-decisions hold. D2 (no *Async, no Bouncy Castle), D3 (org.adcontextprotocol.adcp.transport.a2a / ...server.a2a), D7 (jakarta.servlet.http.HttpServletRequest, no javax), D9 (mcp-core:1.1.2 still pinned; jackson 2.18.2 → 2.20.1 stays in the jackson2 line). D10 itself is updated in this PR — ROADMAP.md precedes CLAUDE.md per CLAUDE.md's own precedence rule, and the new D10 row authorizes CR1 with the path to 1.0.0 final documented as a straight version bump.
  • SSE write-after-complete race. Every write site in A2aServlet.java:338-399 (writeStreamingResponse, writeFinalStreamingResponse, writeTimeoutResponse, completeAsync) routes through writerLock plus the completed AtomicBoolean gate. security-reviewer: "No bypass exists."
  • SSRF posture. AdcpHttpClient.newHttpClientBuilder() pins followRedirects(NEVER) at AdcpHttpClient.java:200-204; DefaultClientFactory wraps that into JdkA2AHttpClient at A2aConnectionManager.java:319-321; HttpAgentCardLoader.normalize rewrites url and supportedInterfaces to the validated baseUri regardless of what the remote card declares (A2aConnectionManager.java:373-380).
  • Cache isolation. HMAC-SHA256 with per-process random key in ProtocolClient.computeTokenHash (ProtocolClient.java:242-298); protected headers stripped from the cache key via filterProtected before key construction. Raw tokens never enter cache-key strings.
  • Wire shape. ad-tech-protocol-expert: AdCP-over-A2A conventions match @adcp/sdk (TS) and adcp (Python) — metadata.adcp_tool_name + DataPart payload, version envelope merged into args fields, adcp_major_version / adcp_version stripped before dispatch.
  • *Request / *Response invariant. Only *Request records carry .builder(). No Optional<T> returns. SLF4J throughout. Jackson default typing deactivated in four mappers (AdcpObjectMapperFactory, SchemaBundle.MAPPER, A2aCaller copy, HttpAgentCardLoader copy).
  • Largest-file reads. A2aServlet.java (406), A2aConnectionManager.java (428), A2aCaller.java (316), A2aAgentExecutor.java (205), ProtocolClient.java. Read end-to-end.
  • CI green. ./gradlew build (JDK 21), Check for changeset, commitlint, storyboard, check / check, GitGuardian all SUCCESS.

Follow-ups (non-blocking — file as issues)

  • Auth-side header forwarding to /.well-known/agent.json doesn't actually happen. AdcpHttpClient.send strips all protected headers from outbound requests (AdcpHttpClient.java:128-134), so the Authorization minted by AuthTokenResolver never reaches the agent-card discovery probe. The PR description claims protected headers "stripped from cache key but still forwarded to agent-card discovery"; the second half is not true on the wire. Either document the discovery probe as anonymous (so private agents whitelist /.well-known/agent.json) or add a sendAuthorized path for SDK-minted tokens. (security-reviewer Medium 1.)
  • Deprecated single-arg A2aServlet(handler) is too easy to ship to prod (A2aServlet.java:90-97). @Deprecated on the Javadoc is invisible at deploy time. Either remove pre-GA and require an explicit A2aAuthProvider.alwaysUnauthenticated() factory (so the "no auth" choice is grep-able at the call site) or gate it on a -Dadcp.allow-unauthenticated=true system property. (security-reviewer Medium 2.)
  • A2aAgentExecutor.extractArgs emits internal_error for non-object adcp_args (A2aAgentExecutor.java:115,122). objectMapper.convertValue(42, LinkedHashMap.class) throws IllegalArgumentException and falls through to the generic Exception handler at line 69. Should be InvalidRequestError. (code-reviewer finding 3.)
  • AgentCard.normalize() silently rewrites multi-interface cards (A2aConnectionManager.java:373-405). Acceptable for v0.4 since AdCP-over-A2A is JSONRPC-only today, but log a WARN when the remote card declares interfaces beyond the pinned one — surfaces the divergence loudly when a multi-interface agent shows up. (ad-tech-protocol-expert caveat 3.)
  • Server-side AgentCard ships empty skills = [] (A2aServerBuilder.java:103). A2A discovery clients (a2a-inspector etc.) treat skills as the primary capability advertisement; the idiomatic move is one AgentSkill per AdCP tool, populated from the platform's tool registry. (ad-tech-protocol-expert caveat 4.)
  • tasks/resubscribe not surfaced (A2aServlet.java:128-165). Long-running AdCP tasks (HITL create_media_buy) need reconnect-after-disconnect; the five methods wired today cover the dispatch happy path but not the resume path. (ad-tech-protocol-expert caveat 5.)
  • No .changeset/*.md for an adopter-visible Track 3 milestone. changeset-check passed (informational per .changeset/README.md), and first publish lands at v0.3 alpha per D6, but the README asks for a changeset whenever a PR changes adopter-visible behavior on any of the eight artifacts. feat(a2a): is a minor bump on adcp and adcp-server.
  • Auth runs after body + JSON parse (A2aServlet.java:103-126). Move authProvider.authenticate(request) before readRequestBody — cheap reorder, removes 1 MB of unauthenticated parsing work per request. (security-reviewer Low 1.)
  • Auth failures return HTTP 200 with JSON-RPC error. JSON-RPC convention, but breaks WAFs and rate-limiters that scope by HTTP status. Consider 401 for explicit Unauthorized from A2aAuthProvider. (security-reviewer Low 3.)

Minor nits (non-blocking)

  1. Retry path discards the original transport exception on success. ProtocolClient.java:152-165 and :191-204. original is captured but only attached via addSuppressed on retry-failure. On retry-success the first failure is silent — log it at debug for flaky-agent forensics. (code-reviewer finding 1.)
  2. safeToolName.replaceAll(\"[\\\\p{Cc}]\", \"\") is dead defense at A2aCaller.java:91toolName was already rejected for control characters at :87-89. Drop the replaceAll, or comment it as belt-and-braces. (code-reviewer finding 4.)

Three independent expert reads landed at sound-with-caveats. The PR-description-versus-code mismatch on agent-card auth forwarding is the one notable miss — the rest of the hardening prose holds up.

Approving on the strength of the D-coherent shape, the verified SSE concurrency gate, and the three expert verdicts.

@github-actions
Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

Copy link
Copy Markdown
Contributor

@bokelley bokelley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the Track 3 A2A implementation. I did a full pass over the caller/server bridge, tests, and the upstream A2A Java SDK CR1 behavior.

I think this needs another revision before merge.

Findings:

  1. High: Agent Card discovery uses the wrong well-known path.

    A2aConnectionManager.java builds /.well-known/agent.json, but the A2A Java SDK resolver and the spec default to /.well-known/agent-card.json. A conforming A2A server that only publishes the standard path will be missed and the caller will fall back to the synthetic card. That bypasses the server-declared RPC URL, skills, security schemes, and any tenant-specific interfaces. Please switch discovery to the standard Agent Card path, ideally by using the SDK resolver/default constant instead of hard-coding a local path.

  2. High: authenticated Agent Card discovery drops auth headers.

    A2aConnectionManager.java passes headers into adcpHttpClient.get(...), but AdcpHttpClient strips protected headers, including Authorization, on GET. That means private Agent Card endpoints using Basic/OAuth/Bearer will 401 during discovery and the caller will again fall back to a synthetic card. The card fetch path needs a way to send the same caller auth material used for the eventual A2A RPC request, while preserving the protected-header safeguards for redirects/cross-origin cases.

  3. Medium: invalid A2A request shape is reported as internal_error.

    A2aAgentExecutor.java throws InvalidRequestError for client request problems such as missing tool names, control characters, or non-object adcp_args, but the broad catch (Exception) catches that A2A SDK error and converts it to internal_error. Please let A2AError propagate or catch it before the generic exception handler so malformed client input is reported as an invalid request instead of a server fault.

  4. Medium: the server Agent Card always advertises no skills.

    A2aServerBuilder.java sets skills(List.of()) even when the platform exposes supported tools. This makes the generated card much less useful for discovery and conformance. Please populate AgentSkill entries from platform.supportedTools() with stable ids/names and descriptions where available.

  5. Medium: server execution is collapsed into the servlet caller thread.

    A2aServerBuilder.java passes Runnable::run into DefaultRequestHandler.create(...). The upstream handler uses those executors for agent execution and event consumption, so this makes nominal async work run inline. For the streaming path, that can delay or block the response before the SSE stream is established. Please use injectable real executors, with a sensible default such as virtual threads for agent work.

Lower priority:

  • The deprecated unauthenticated A2aServlet constructor is still easy to call accidentally. Consider removing it before release, or replacing it with an explicit allowAnonymous builder/API.
  • A2aServlet reads and parses up to 1 MB before auth. If the auth model allows it, authenticate before body parsing to reduce unauthenticated request cost.
  • Please add targeted tests for the standard Agent Card path, authenticated card discovery, invalid adcp_args error mapping, skill population in generated cards, and non-inline server execution.

I could not run local Gradle tests on my review machine because no Java runtime is installed. GitHub checks are green, but the discovery/auth issues above are enough that I would not merge this as-is.

@bokelley
Copy link
Copy Markdown
Contributor

Follow-up on local verification: the machine did have Homebrew openjdk@21 installed, but macOS could not find it because JAVA_HOME/PATH were not configured. With JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home, the targeted A2A test command now passes:

JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :adcp:test --tests '*A2a*' :adcp-server:test --tests '*A2a*'\n```\n\nResult: `BUILD SUCCESSFUL in 40s`. The request-changes findings still stand; the local-test caveat in my review was just an environment setup issue.

MichielBugherJelli and others added 16 commits May 29, 2026 14:39
Caller side (adcp module):
- A2aCaller: JSON-RPC tool dispatch over A2A protocol with SSE streaming,
  SSRF-safe redirect policy, response size limits, and idempotency key
- A2aConnectionManager: credential-isolated client cache using HMAC cache
  hash; protected headers (Authorization, Cookie) are stripped from cache
  keys but forwarded to agent-card discovery; exact-key eviction on retry
- ProtocolClient: A2A dispatch path with computeCacheHash, evict-and-retry

Server side (adcp-server module):
- A2aServlet: Jakarta servlet bridge for A2A JSON-RPC; AsyncContext-based
  SSE streaming with writerLock guarding concurrent writes and exactly-once
  asyncContext.complete() via AtomicBoolean CAS
- A2aAgentExecutor: adapts A2A message requests to AdcpPlatform.handleTool;
  plumbs ServerCallContext state as request headers (primitives only)
- A2aAuthProvider: pluggable authentication SPI
- A2aServerBuilder: fluent builder wiring AgentExecutor + RequestHandler

Security hardening (5 audit cycles + 3 code review cycles):
- SSRF: redirect-never HttpClient, DNS pre-validation, AgentCard URL pinning
- Input bounds: request body cap (1 MB), method length, message ID length,
  tool name length, args scan limit, adcp_version field length
- Auth isolation: per-credential cache hash prevents cross-tenant sharing;
  raw secrets never appear in cache key strings
- SSE concurrency: completeAsync holds writerLock to eliminate write-after-
  complete race; subscription backpressure via request(SSE_PREFETCH)/request(1)
- Log safety: control-character stripping in all user-controlled log fields

Dependencies: a2a-java-sdk 1.0.0.CR1 (upgrade to GA deferred)
ROADMAP.md: mark Track 3 complete, document a2a-java-sdk version decision

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Lockfiles regenerated to match CI resolution. The a2a-java-sdk 1.0.0.CR1
transitive dependency tree resolved differently on Linux (CI) vs macOS
(local), causing the lock-drift check to fail.

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Move extractVersion() inside try block in A2aAgentExecutor so
  VersionUnsupportedError is caught and reported via emitter.fail()
- Validate toolName in A2aCaller upfront (non-null, non-blank, length,
  control chars); use original value for outbound request, safeToolName
  only in log/error strings
- Add AdcpHttpClient.newHttpClientBuilder() transport-agnostic alias;
  update A2aConnectionManager to use it instead of newMcpClientBuilder()
- Fix Javadoc link in A2aServerBuilder: DefaultRequestHandler -> RequestHandler

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add com.google.code.gson:gson 2.14.0 as an explicit implementation
dependency in adcp-server so A2aServlet's Gson usage is not reliant
on transitive resolution through the A2A SDK.

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Normalize Accept header to lowercase (Locale.ROOT) in wantsStreaming()
  so Accept: Text/Event-Stream (or any other casing) correctly enables
  SSE streaming per RFC 7231 case-insensitivity rules
- Add explicit implementation deps for a2a-java-sdk-client-transport-jsonrpc
  (JSONRPCTransport, JSONRPCTransportConfigBuilder) and a2a-java-sdk-http-client
  (JdkA2AHttpClient) to adcp/build.gradle.kts; add catalog aliases for both

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Bump slf4j to 2.0.17 in version catalog to match lockfile resolution;
  update adcp-cli/gradle.lockfile accordingly
- Fix buildAgentCardUri() to use origin (scheme+authority) rather than
  appending to the full agent URI path — A2A Agent Card is always at
  /.well-known/agent.json on the origin root
- Enforce MAX_CONTENT_LENGTH in bytes (UTF-8) not chars in A2aCaller:
  DataPart uses writeValueAsBytes; TextPart uses getBytes(UTF_8).length
- Set completed=true inside writeTimeoutResponse() writerLock block
  (non-committed path) to close the race where an in-flight onNext()
  could write SSE data after the timeout error was sent

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Make streaming error/timeout write+complete atomic in A2aServlet:
  add writeFinalStreamingResponse() that sets completed=true,
  writes the SSE event, and calls asyncContext.complete() in one
  synchronized block; update onTimeout, async onError, and subscriber
  onError to use this pattern; writeTimeoutResponse() now takes
  asyncContext and handles both paths atomically
- Enforce MAX_METHOD_LENGTH: reject JSON-RPC requests with method
  names longer than 128 chars with a 400 InvalidRequestError
- Add SchemaBundle tests: null, path-traversal, and leading-slash
  inputs rejected by load() and silently rejected by exists()
- Add AdcpSchemaValidator test: path-traversal URI throws
  IllegalArgumentException

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Args into try

writeTimeoutResponse() was releasing writerLock before calling
writeFinalStreamingResponse(), creating a race window where onNext()
could write another SSE event between the timeout decision and the
final error event. Inline the committed-case logic under the same
synchronized block so both paths (committed and uncommitted) complete
atomically.

extractArgs() calls objectMapper.convertValue() which can throw
IllegalArgumentException. Moving it inside the try block ensures
that exception is caught by the existing catch(Exception) path and
converted to emitter.fail() rather than escaping uncaught.

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ring JSON-RPC method

extractToolName() checked non-blank before sanitization but the
replaceAll control-char strip could reduce a non-blank input to blank
(e.g., a name consisting entirely of control characters). Now validates
the sanitized result is non-blank before returning in both the metadata
and TextPart paths; falls through to InvalidRequestError if all
candidates sanitize to blank.

A2aServlet method parsing used isJsonPrimitive() which coerces numbers
and booleans to strings, violating JSON-RPC 2.0 which requires method
to be a string. Now additionally checks isString() on the primitive.

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…sults

The post-sendMessage guard that protects against synchronous clients
firing callbacks inline was checking latestTask.get() != null, which
also triggers for non-terminal TaskEvents/TaskUpdateEvents. This caused
the latch to count down prematurely when an in-progress task was
delivered inline, leading extractResponse() to run on a non-terminal
task and throw 'Empty response' or return incorrect results.

Changed the guard to use isTerminal(latestTask.get()), consistent with
the terminal-only countDown() logic in the event handlers above.

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…add @nullable to builder fields

A2aCaller: the toolName control-char check excluded tab (c != '\t') but
tabs are ISO control characters and the error message already promised
no control characters. Removed the exception to match the documented
invariant and the sanitization regex which already strips all \p{Cc}.

A2aConnectionManager: buildCacheKey() sorted headers with case-sensitive
TreeMap keys, so X-Tenant and x-tenant would produce different cache
entries for semantically identical credentials. Header names are now
lowercased (Locale.ROOT) before insertion into the sorted map, and the
test updated to reflect the normalized lowercase key.

A2aServerBuilder: agentName/agentUrl/agentVersion fields are null until
configured but were declared non-null in a @NullMarked package. Annotated
them @nullable and updated require() to accept @nullable String.

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ly stripping them

Stripping control characters from tool names before dispatch was a
security concern: a caller could send 'foo\x00bar' and end up executing
'foobar', silently changing which tool runs. Both the metadata and
TextPart extraction paths now throw InvalidRequestError if the capped
name contains any ISO control character, matching the same policy as
A2aCaller. The validated name is safe to use directly in logs.

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… sanitize debug logs

Bump catalog jackson version from 2.18.2 to 2.20.1 to match what the
A2A SDK dependency resolves in lockfiles; regenerate all lockfiles.

buildCacheKey() used putIfAbsent when normalizing header names to
lowercase, making value selection non-deterministic for case-insensitive
duplicate keys from non-ordered input maps. Now pre-sorts entries by
original key via TreeMap before lowercasing, so the alphabetically-last
original key always wins — deterministic regardless of input map type.

Two debug log statements were logging raw exception messages from
remote A2A responses, which can contain untrusted payload content or
control characters. Both now pass through sanitizeErrorText/sanitizeLogText
to strip control chars and truncate before logging.

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…hable evict branch

computeCacheHash() was hashing all extraHeaders including protected ones
(Authorization, Cookie, etc.) that AdcpHttpClient strips before every
request. This fragmented the connection cache unnecessarily — agents
differing only in their Authorization header would get separate cache
entries even though they'd make identical requests. Now filters out
ProtectedHeaders.isProtected() entries before hashing.

evict(URI agentUri) had an unreachable key.equals(prefix) branch since
buildCacheKey() always produces 'agentUri#cacheHash[?headers]' — the
bare agentUri string can never equal a cache key. Simplified to a single
startsWith(agentUri + "#") predicate with a clarifying comment.

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ClientCallContext, MessageSendParams, BiConsumer, and Consumer were
imported but not directly referenced — the A2aMessageClient lambda
interface uses them internally. Removing to keep -Xlint:all clean.

jira-issue: N/A

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
High: fix agent card well-known path from /.well-known/agent.json to
/.well-known/agent-card.json (A2A spec default).

High: add AdcpHttpClient.getForAgentCard() that forwards auth headers
(including Authorization) for private card endpoints while keeping SSRF
and redirect-NEVER protection. Wire it into HttpAgentCardLoader.load().

Medium: let A2AError propagate from A2aAgentExecutor.execute() instead
of being caught by the broad Exception handler and mapped to internal_error.
Also type-check adcp_args (metadata and DataPart) and throw
InvalidRequestError for non-object values.

Medium: populate AgentSkill entries in A2aServerBuilder.buildAgentCard()
from platform.supportedTools() and platform.toolDescriptions(). Skills
are sorted for stable, deterministic card output.

Medium: replace Runnable::run (inline execution) with a virtual-thread-
per-task executor in A2aServerBuilder.build(). Use the lambda form
(Thread.ofVirtual().start(task)) instead of Executors.newVirtualThreadPerTask
Executor() to avoid an ExecutorService resource leak. Expose injectable
agentExecutor() and eventConsumerExecutor() builder methods for tests.

Add targeted tests for all five fixes: standard agent card path, auth
header forwarding, invalid adcp_args error mapping (metadata + DataPart),
skill population in generated cards, and off-caller-thread execution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@aao-ipr-bot
Copy link
Copy Markdown

aao-ipr-bot Bot commented May 31, 2026

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@aao-ipr-bot
Copy link
Copy Markdown

aao-ipr-bot Bot commented May 31, 2026

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants