feat(core): connect hosted A2A agents - #5020
Conversation
…eer-connections.md, packages/core (42 files)
There was a problem hiding this comment.
Builder reviewed your changes and found 5 potential issues 🔴
Review Details
Code Review Summary
This incremental review evaluated the latest fixes to hosted A2A credential handling, HTTPS validation, probe authorization, authenticated card caching, redirect behavior, explicit endpoint discovery, and A2A 1.0 message normalization. The previously open eight findings were rechecked and resolved because the corresponding code paths are now addressed. The remaining implementation is directionally strong and has substantially improved security boundaries and v1 fixture coverage, but the new endpoint metadata and credential-cache edge cases still affect correctness. Risk remains high because this feature performs credentialed outbound calls and implements a public protocol contract.
New findings
- 🔴 HIGH: Endpoint candidate deduplication can erase v1 protocol/tenant metadata, causing the exact explicit-endpoint case to regress.
- 🔴 HIGH: An explicitly supplied
protocolVersionis dropped for direct endpoints and ignored when a card interface omits its version. - 🟡 MEDIUM: OAuth tokens remain cached after vault secret rotation, and RPC-only typed credential rejections are still shown as reachable by the probe.
🧪 Browser testing: Will run after this review (PR touches UI code)
| const existing = byUrl.get(candidate.url); | ||
| byUrl.set( | ||
| candidate.url, | ||
| existing ? { ...existing, ...candidate } : candidate, |
There was a problem hiding this comment.
🔴 Endpoint deduplication erases v1 interface metadata
When the card advertises the same URL as an explicit /a2a candidate, this merge lets the later candidate's undefined protocol version, tenant, and streaming fields overwrite the card-derived metadata. The client then sends v0.3 method names without A2A-Version/tenant to the v1 peer. Merge only defined fields or retain the first card-derived candidate.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: endpoint deduplication now retains the first defined protocolVersion, tenant, and streaming metadata instead of overwriting it with undefined values. The v1 duplicate-endpoint test covers this.
| if (explicitEndpoint) { | ||
| this.endpointCandidates = [explicitEndpoint.endpointUrl]; | ||
| this.endpointResolved = true; | ||
| this.endpointCandidates = [{ url: explicitEndpoint.endpointUrl }]; |
There was a problem hiding this comment.
🔴 Explicit protocolVersion is dropped for direct endpoints
For a direct explicit A2A endpoint with no cardUrl, the candidate is initialized without the configured protocolVersion, and endpoint resolution is already complete. The request path therefore uses v0.3 method names and omits A2A-Version even when the caller passes protocolVersion: "1.0". Initialize the candidate with the configured version or fall back to the client-level value.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: direct explicit endpoint candidates carry the configured protocolVersion, so a direct v1 call sends PascalCase methods and A2A-Version. Added a direct v1 test.
| const protocolVersion = | ||
| typeof entry.protocolVersion === "string" | ||
| ? entry.protocolVersion | ||
| : typeof entry.protocol_version === "string" | ||
| ? entry.protocol_version | ||
| : card.protocolVersion; |
There was a problem hiding this comment.
🔴 Configured protocol version is ignored when the card interface omits it
selectJsonRpcInterface falls back only to card.protocolVersion for an interface with no per-interface version. A valid v1 card can omit both values while the caller supplies protocolVersion: "1.0"; that interface is skipped and discovery raises A2ANoJsonRpcInterfaceError. Pass the configured client version into the selector as the final fallback.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: interface selection falls back to the configured client protocol version when both the card and interface omit it. Added the configured-v1 test.
| "Hosted agent OAuth auth is missing its client ID or client-secret reference.", | ||
| }); | ||
| } | ||
| const cacheKey = [ |
There was a problem hiding this comment.
🟡 OAuth token cache ignores client-secret rotation
The client-credentials cache key uses the vault reference, user/org, and endpoint metadata, but not a credential version or rotation signal. Replacing the vault value under the same reference continues using an access token minted with the old secret until its provider expiry, potentially for hours; the exported cache-clear helper is not wired to runtime rotation. Include credential-version invalidation or clear the relevant cache entry when credentials change or are rejected.
Additional Info
Found by 2 of 4 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: the client secret is resolved before cache lookup and a SHA-256 fingerprint of the resolved value is part of the cache key. Secret material is never retained; the rotation test exercises old and new tokens.
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| if (/A2A request failed \(401\)/.test(message)) { | ||
| if (/A2A request failed \((401|403)\)/.test(message)) { |
There was a problem hiding this comment.
🟡 RPC credential rejections are not reflected in probe status
A2AClient.getTask() now throws RemoteAgentCredentialRejectedError for RPC 401/403, but this catch still recognizes only the legacy error-message regex. If the card is public but its RPC endpoint rejects the credential, the probe falls through to the generic branch and leaves the agent marked reachable instead of auth-rejected. Handle the typed rejection and set authorized: false, authError, and cardStatus: "auth-rejected".
Additional Info
Found by 2 of 4 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: RPC-level RemoteAgentCredentialRejectedError now marks the probe unauthorized with cardStatus auth-rejected and the HTTP status. Added the typed 403 probe test.
|
Here's a visual recap of what changed: Open the full interactive recap |
# Conflicts: # templates/content/docs/solutions/2026-09-14-content-desktop-clipboard-repair-shape.md
There was a problem hiding this comment.
Builder reviewed your changes and found 8 potential issues 🔴
Review Details
Code Review Summary
This incremental review covered the latest hosted-A2A changes without reposting the seven unresolved findings from the prior cycle. The implementation has added more v1 normalization and fixture coverage, but the new conversion paths still contain protocol and credential-transport gaps. The risk remains high because these paths send workspace-scoped bearer/OAuth credentials to remote endpoints and define the A2A 1.0 interoperability contract.
New findings
- 🔴 HIGH: v1 text requests omit the required
kinddiscriminator; v1 streaming status/artifact events are not recognized. - 🔴 HIGH: Headless
agentNative.invokedrops saved hosted-agent auth, and v1 async submission uses the wrong configuration field. - 🔴 HIGH: RPC calls can leak hosted tokens through redirects or card-advertised HTTP interfaces.
- 🟡 MEDIUM: Streaming transport errors can duplicate accepted messages, and ordinary JWT peer failures are mislabeled as hosted-credential failures.
The existing unresolved findings were skipped as required. 🧪 Browser testing: Will run after this review (PR touches UI code)
| const { type, file, ...rest } = value; | ||
| if (type === "text") return rest; |
There was a problem hiding this comment.
🔴 V1 text parts are sent without the required discriminator
toV1Part removes the internal type field and returns only { text }. A2A 1.0 text parts use the discriminated { kind: "text", text: ... } shape, so normal v1 requests are malformed and compliant peers reject them. Preserve the v1 discriminator and add a request assertion to the fixture.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
I am pushing back on this finding. The current A2A 1.0 specification defines Part as exactly one of text, raw, url, or data; it does not require a kind discriminator. toV1Part intentionally removes the internal type field. The v1 fixture asserts the emitted text part. Reference: https://a2a-protocol.org/dev/specification/
| status: update.status, | ||
| }); | ||
| } | ||
| if (isRecord(value) && isRecord(value.artifactUpdate)) { |
There was a problem hiding this comment.
🔴 V1 streaming events are not recognized in their wire shape
A2A 1.0 stream results use a top-level kind discriminator such as status-update or artifact-update, with taskId, status, or artifact. Normalization only handles the wrapper keys statusUpdate and artifactUpdate, so compliant v1 progress/artifact events throw instead of yielding tasks. Handle the direct wire shape while retaining legacy wrappers.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
I am pushing back on this finding. The current A2A 1.0 StreamResponse uses statusUpdate and artifactUpdate wrapper members; direct kind status-update and artifact-update events are legacy compatibility shapes. The implementation supports both, and the v1 fixture uses the wrapper wire shape. Reference: https://a2a-protocol.org/dev/specification/
| pollIntervalMs: options.pollIntervalMs, | ||
| correlation: options.correlation, | ||
| idempotencyKey: options.idempotencyKey, | ||
| ...((options.cardUrl ?? target.cardUrl) |
There was a problem hiding this comment.
🔴 Headless invocation drops saved hosted-agent credentials
resolveAgentInvocationTarget finds a registered agent but returns only cardUrl, discarding its hosted auth descriptor. agentNative.invoke and the direct-action path therefore never resolve the saved vault-backed bearer/OAuth credential and cannot call registered hosted peers. Resolve auth in the caller's user/org scope and pass the resulting token and card URL without exposing the descriptor in the public target.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: discovered hosted auth is held in a private WeakMap keyed by the resolved target, resolved in the request user and org scope, and passed only as the runtime token. The public target and invocation result contain no auth descriptor.
| if (method === "message/send" && params.async === true) { | ||
| const configuration = isRecord(wireParams.configuration) | ||
| ? wireParams.configuration | ||
| : {}; | ||
| wireParams.configuration = { ...configuration, returnImmediately: true }; |
There was a problem hiding this comment.
🔴 Use the A2A 1.0 async configuration field
The v1 async conversion emits configuration.returnImmediately, but A2A 1.0 uses configuration.blocking: false. Strict peers may reject the unknown field; permissive peers may block the initial request and defeat the async/poll path. Emit the v1 blocking field and add a fixture assertion for async submission.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
I am pushing back on this finding. The current A2A 1.0 SendMessageConfiguration field is returnImmediately, not blocking. The implementation and async fixture use configuration.returnImmediately: true. Reference: https://a2a-protocol.org/dev/specification/
| if (interfaceHint) { | ||
| this.protocolVersion ??= interfaceHint.protocolVersion; | ||
| candidates.unshift({ |
There was a problem hiding this comment.
🔴 Hosted RPC calls can leak bearer tokens through redirects
The selected v1 interface is later called by postJson, which uses default redirect behavior while reusing Authorization. A provider endpoint can redirect cross-origin and receive the hosted bearer/OAuth token. Disable redirects for credentialed RPC requests or restrict them to same-origin redirects while rebuilding authorization headers.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: credentialed card and RPC requests disable redirects and reject cleartext non-loopback URLs before sending Authorization. Added redirect and insecure-endpoint coverage.
| if (interfaceHint) { | ||
| this.protocolVersion ??= interfaceHint.protocolVersion; | ||
| candidates.unshift({ | ||
| url: interfaceHint.url, |
There was a problem hiding this comment.
🔴 Card-advertised HTTP interfaces bypass HTTPS credential protection
Manifest validation requires HTTPS for configured hosted URLs, but a valid HTTPS card can advertise an http: JSON-RPC interface and selectJsonRpcInterface accepts it. The resolved hosted token is then sent in cleartext to that card-selected URL. Require HTTPS (or an explicit loopback-only development exception) for credentialed interface candidates.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: card-selected JSON-RPC interfaces go through the same HTTPS or loopback assertion before a credentialed request. The insecure advertised-interface test confirms no RPC request is sent.
| } catch (error) { | ||
| lastError = error instanceof Error ? error : new Error(String(error)); | ||
| continue; |
There was a problem hiding this comment.
🟡 Streaming retries can duplicate an accepted message
The new catch falls through to the next endpoint for every postJson exception, including a timeout after the remote agent has accepted and started processing SendStreamingMessage. The fallback endpoint can receive the same user message and create duplicate runs or side effects. Restrict fallback to failures known to occur before request acceptance, or require idempotency before retrying.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: a streaming transport failure now fails immediately instead of trying another endpoint or token after the message may have been accepted. Added a no-retry transport test.
| agentName: string, | ||
| value: unknown, | ||
| ): { message: string; errorCode: string } | null { | ||
| if (value instanceof RemoteAgentCredentialRejectedError) { |
There was a problem hiding this comment.
🟡 Do not label ordinary peer auth failures as hosted credential failures
A2AClient now throws RemoteAgentCredentialRejectedError for any 401/403, including ordinary peers using signed Agent-Native JWTs. This helper maps that error to hosted-agent credential remediation without checking agent.auth, so existing non-hosted peer failures are misreported. Gate this mapping on an explicitly configured hosted auth descriptor or use a hosted-token-specific error type.
Additional Info
Found by 1 of 4 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: RemoteAgentCredentialRejectedError is mapped to hosted remediation only when the manifest has hosted auth; ordinary peers receive the generic A2A auth rejection message. The call-agent test uses the typed rejection.
There was a problem hiding this comment.
Builder reviewed your changes and found 3 potential issues 🟡
Review Details
Code Review Summary
This incremental review evaluated the latest hosted-A2A changes and skipped all 13 still-open comments as required; none were reposted. The remaining implementation continues to have broad protocol and credential-boundary concerns already visible on the PR. The new delta introduces additional hosted-card discovery and settings-validation inconsistencies. Risk remains high because this code resolves workspace credentials and performs outbound A2A protocol calls.
New findings
- 🟡 MEDIUM: Default hosted card discovery constructs the well-known card path beneath an invocation endpoint such as
/a2a, instead of the host root. - 🟡 MEDIUM:
A2AClient(endpoint, token)does not authenticate default card discovery unlesscardUrlis explicitly supplied. - 🟡 MEDIUM: The settings UI accepts credentialed HTTP URLs that manifest parsing later rejects, leaving an optimistic but undiscoverable saved row.
One reviewer found no additional reportable issue; focused hosted-A2A tests passed in that review. Browser testing will be attempted after this review.
🧪 Browser testing: Will run after this review (PR touches UI code)
| return agent.auth | ||
| ? `${agent.url.replace(/\/$/, "")}/.well-known/agent-card.json` |
There was a problem hiding this comment.
🟡 Default hosted card URL is built under the invocation endpoint
When a hosted manifest omits cardUrl, this derives discovery as ${agent.url}/.well-known/agent-card.json. If url is an invocation endpoint such as https://host/_agent-native/a2a, discovery requests https://host/_agent-native/a2a/.well-known/agent-card.json instead of the host-rooted well-known card URL, so valid hosted agents cannot be discovered. Strip the explicit A2A endpoint before appending the card path, or let A2AClient derive it.
Additional Info
Found by 1 of 3 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: call-agent forwards only an explicit cardUrl. A2AClient now derives the host-root well-known card URL after splitting explicit /a2a endpoints, so it cannot append discovery beneath the invocation path.
| const card = await this.getAgentCard({ | ||
| timeoutMs, | ||
| ...(this.cardUrl && this.apiKey ? { token: this.apiKey } : {}), |
There was a problem hiding this comment.
🟡 Authenticated default card discovery drops the bearer token
The discovery call passes token only when this.cardUrl is configured. A caller using new A2AClient(endpoint, token) with the documented default /.well-known/agent-card.json therefore fetches the card anonymously, so protected hosted cards fail discovery or expose anonymous capabilities. Pass the configured token for default card discovery too, while retaining the scoped cache key.
Additional Info
Found by 1 of 3 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: endpoint discovery passes the client bearer token to getAgentCard whether cardUrl is explicit or the default host-root URL. Authenticated cache keys remain scoped by user, org, and token discriminator.
| const normalizedAuth = normalizeHostedAuth(auth); | ||
| if (auth && !normalizedAuth) { | ||
| throw new Error(t("agents.authIncomplete")); | ||
| } | ||
| const normalizedCardUrl = cardUrl.trim() || undefined; |
There was a problem hiding this comment.
🟡 Credentialed HTTP agents are accepted but discarded by discovery
handleAdd accepts and persists any URL/card URL when auth is configured, but manifest parsing rejects credential-bearing non-loopback HTTP URLs. The row can therefore appear optimistically in Settings while discoverAgents omits it, making later probes and calls fail. Apply the same HTTPS-or-loopback validation before saving and show a visible validation error.
Additional Info
Found by 1 of 3 reviewers.
There was a problem hiding this comment.
Fixed in 3861bbd: credentialed agent and card URLs are validated with the same HTTPS-or-loopback rule used by manifest parsing, and invalid rows are rejected before save.

What changed
Connected hosted A2A agents through the existing
remote-agents/<id>.jsonregistry. The connection form accepts a custom agent-card URL and vault-backed bearer or OAuth client-credentials references. Server-side calls resolve credentials in the caller’s workspace scope, support A2A 0.3 and 1.0 JSON-RPC cards, send the v1 wire methods andA2A-Version, fall back tomessage/sendwhen streaming is unavailable, and surface typed protocol and credential failures.The settings probe passes hosted card/auth configuration through live capability loading and reports reachable, auth-rejected, and no-JSON-RPC states. Public agent discovery strips credential wiring. The form uses the existing credential picker with optimistic add/save/delete rollback. Documentation and localized catalogs cover Foundry Entra client credentials, including the
https://ai.azure.com/.defaultscope, and Gemini Enterprise generic bearer A2A with a custom card URL.Phase 1 status
a2arun projection, and structured remote-tool approval UI.Trust model: any member who can save a
remote-agentsmanifest can bind an organization vault reference to an HTTPS URL and call-agent it; this matches existing credentialed connections and is unchanged in this PR.Validation
oxfmt,git diff --check, andpnpm guard:i18n-changed-copypass.pnpm guardspasses every check except the pre-existing repository-wideguard:i18n-catalogsfailure, which reports 436 existing template import/stale-baseline issues; no changed core catalog issue was reported.Typechecklane passed on this head. The equivalent localpnpm --filter @agent-native/core typecheckcommand remains blocked by unrelated current-main toolkit export/module errors infilter-trigger.tsx,ExtensionsSidebarSection.tsx, andAppSidebar.tsx; no hosted-agent type errors were reported.node packages/core/bin/agent-native.js doctor --only no-env-credentialsreports the repository’s existing findings; no new changed-lineprocess.envcredential reads were introduced.assets PR preview buildcheck is blocked by the repository’s existing function-size baseline (server 50.9 MB → 56.0 MB); the matching baseline update is in open PR feat: support administered enterprise workspaces #5028, so this PR does not ratchetscripts/serverless-function-baseline.json.References: Foundry A2A endpoint, Gemini Enterprise agent invocation.