From c86d57d1be4bb7c0a3b1e31e616a91e78e6f4bee Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 25 Jun 2026 06:09:55 +0000 Subject: [PATCH 1/3] fix(unit-only): thread bearer token through A2A invoke path for CUSTOM_JWT runtimes A2A-protocol agents configured with CUSTOM_JWT authorization could not be invoked: the CLI auto-fetched a bearer token but invokeA2ARuntime always used the SigV4 client, dropping the token and triggering an "Authorization method mismatch" service error. Add bearerToken support to A2AInvokeOptions and, when set, send a raw HTTP POST of the JSON-RPC body with an Authorization: Bearer header via buildInvokeUrl / buildBearerInvokeHeaders, parsing the response through parseA2AResponse. Thread the token through all three call sites (invoke action, TUI invoke flow, dev web-UI invocations handler). Fixes #815 --- .../__tests__/agentcore-a2a-bearer.test.ts | 97 +++++++++++++++++++ src/cli/aws/agentcore.ts | 25 ++++- src/cli/commands/invoke/action.ts | 1 + .../dev/web-ui/handlers/invocations.ts | 2 + src/cli/tui/screens/invoke/useInvokeFlow.ts | 1 + 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/cli/aws/__tests__/agentcore-a2a-bearer.test.ts diff --git a/src/cli/aws/__tests__/agentcore-a2a-bearer.test.ts b/src/cli/aws/__tests__/agentcore-a2a-bearer.test.ts new file mode 100644 index 000000000..c51cc9609 --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-a2a-bearer.test.ts @@ -0,0 +1,97 @@ +import { invokeA2ARuntime } from '../agentcore.js'; +import type { A2AInvokeOptions } from '../agentcore.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the SDK so the SigV4 path doesn't need real credentials +const mockSdkSend = vi.fn(); +vi.mock('@aws-sdk/client-bedrock-agentcore', () => { + class MockBedrockAgentCoreClient { + send = mockSdkSend; + middlewareStack = { add: vi.fn() }; + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor(_config: unknown) {} + } + return { + BedrockAgentCoreClient: MockBedrockAgentCoreClient, + InvokeAgentRuntimeCommand: vi.fn(), + StopRuntimeSessionCommand: vi.fn(), + EvaluateCommand: vi.fn(), + }; +}); + +vi.mock('../account.js', () => ({ + getCredentialProvider: vi.fn().mockReturnValue(() => + Promise.resolve({ accessKeyId: 'test', secretAccessKey: 'test' }) + ), +})); + +const a2aResultBody = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { artifacts: [{ parts: [{ kind: 'text', text: 'Hello from A2A' }] }] }, +}); + +const baseOpts: A2AInvokeOptions = { + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:runtime/test-runtime', + userId: 'test-user', +}; + +async function drain(stream: AsyncGenerator): Promise { + let out = ''; + for await (const chunk of stream) out += chunk; + return out; +} + +describe('invokeA2ARuntime bearer-token auth path', () => { + let fetchSpy: ReturnType; + let capturedRequests: { url: string; init: RequestInit }[]; + + beforeEach(() => { + capturedRequests = []; + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + capturedRequests.push({ url: input as string, init: init! }); + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(a2aResultBody), + headers: { get: () => null }, + } as unknown as Response); + }); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + vi.clearAllMocks(); + }); + + it('uses fetch with Bearer Authorization header and never the SigV4 client', async () => { + const result = await invokeA2ARuntime({ ...baseOpts, bearerToken: 'test-jwt-token' }, 'hi'); + const text = await drain(result.stream); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(mockSdkSend).not.toHaveBeenCalled(); + + const headers = capturedRequests[0]!.init.headers as Record; + expect(headers.Authorization).toBe('Bearer test-jwt-token'); + + // JSON-RPC message/send body is carried in the fetch payload + const body = JSON.parse(capturedRequests[0]!.init.body as string); + expect(body.method).toBe('message/send'); + expect(body.params.message.parts[0].text).toBe('hi'); + + // Response is still routed through parseA2AResponse + expect(text).toBe('Hello from A2A'); + }); + + it('falls back to the SigV4 client when no bearerToken is supplied', async () => { + mockSdkSend.mockResolvedValue({ + response: { transformToByteArray: () => Promise.resolve(new TextEncoder().encode(a2aResultBody)) }, + }); + + await invokeA2ARuntime(baseOpts, 'hi'); + + expect(mockSdkSend).toHaveBeenCalledTimes(1); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index b91df356d..49b0cfb60 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -931,6 +931,8 @@ export interface A2AInvokeOptions { logger?: SSELogger; /** Custom headers to forward to the agent runtime */ headers?: Record; + /** Bearer token for CUSTOM_JWT auth. When provided, uses raw HTTP with Authorization header instead of SigV4. */ + bearerToken?: string; } let a2aRequestId = 1; @@ -940,8 +942,6 @@ let a2aRequestId = 1; * Streams text parts from the response artifacts. */ export async function invokeA2ARuntime(options: A2AInvokeOptions, message: string): Promise { - const client = createAgentCoreClient(options.region, options.headers); - const body = { jsonrpc: '2.0', id: a2aRequestId++, @@ -957,6 +957,27 @@ export async function invokeA2ARuntime(options: A2AInvokeOptions, message: strin options.logger?.logSSEEvent(`A2A request: ${JSON.stringify(body)}`); + if (options.bearerToken) { + const url = buildInvokeUrl(options.region, options.runtimeArn); + const headers = buildBearerInvokeHeaders(options, 'application/json, text/event-stream'); + + const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`Invoke failed (${res.status}): ${errBody || res.statusText}`); + } + + const text = await res.text(); + options.logger?.logSSEEvent(`A2A response: ${text}`); + + return { + stream: singleValueStream(parseA2AResponse(text)), + sessionId: res.headers.get('X-Amzn-Bedrock-AgentCore-Runtime-Session-Id') ?? undefined, + }; + } + + const client = createAgentCoreClient(options.region, options.headers); + const command = new InvokeAgentRuntimeCommand({ agentRuntimeArn: options.runtimeArn, payload: new TextEncoder().encode(JSON.stringify(body)), diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 7840b50b0..2a6cb139d 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -593,6 +593,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption userId: options.userId, sessionId: options.sessionId, headers: options.headers, + bearerToken: options.bearerToken, }, options.prompt ); diff --git a/src/cli/operations/dev/web-ui/handlers/invocations.ts b/src/cli/operations/dev/web-ui/handlers/invocations.ts index 4b8fe09b5..906eecd3c 100644 --- a/src/cli/operations/dev/web-ui/handlers/invocations.ts +++ b/src/cli/operations/dev/web-ui/handlers/invocations.ts @@ -460,6 +460,7 @@ async function handleDeployedInvocation( prompt, sessionId, userId, + bearerToken: resolved.bearerToken, }); } else if (protocol === 'AGUI') { await handleDeployedAguiInvocation(ctx, res, origin, { @@ -555,6 +556,7 @@ async function handleDeployedA2AInvocation( runtimeArn: params.runtimeArn, userId: params.userId, sessionId: params.sessionId, + bearerToken: params.bearerToken, }, params.prompt ); diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index fd01237b6..d4f9b9d6c 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -783,6 +783,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState sessionId: sessionId ?? undefined, logger, headers, + bearerToken: bearerToken || undefined, }, prompt ) From ec61f1e34dc4b95603ba3bdf629638a41b90750d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 26 Jun 2026 19:27:22 +0000 Subject: [PATCH 2/3] fix(invoke): add bearer-token support to the A2A invoke path for CUSTOM_JWT (#815) From 75d5f7c9cecf9149d19862cd6e9f219932cae877 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 26 Jun 2026 19:35:40 +0000 Subject: [PATCH 3/3] style: apply prettier formatting to a2a bearer test --- src/cli/aws/__tests__/agentcore-a2a-bearer.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/aws/__tests__/agentcore-a2a-bearer.test.ts b/src/cli/aws/__tests__/agentcore-a2a-bearer.test.ts index c51cc9609..71b5af6d9 100644 --- a/src/cli/aws/__tests__/agentcore-a2a-bearer.test.ts +++ b/src/cli/aws/__tests__/agentcore-a2a-bearer.test.ts @@ -20,9 +20,9 @@ vi.mock('@aws-sdk/client-bedrock-agentcore', () => { }); vi.mock('../account.js', () => ({ - getCredentialProvider: vi.fn().mockReturnValue(() => - Promise.resolve({ accessKeyId: 'test', secretAccessKey: 'test' }) - ), + getCredentialProvider: vi + .fn() + .mockReturnValue(() => Promise.resolve({ accessKeyId: 'test', secretAccessKey: 'test' })), })); const a2aResultBody = JSON.stringify({