From 54bab64491372bbaa247be45cf1d9ef0bd29e3d4 Mon Sep 17 00:00:00 2001 From: jariy17 Date: Wed, 4 Nov 2026 20:53:06 +0000 Subject: [PATCH 1/2] fix(harness): use bearer auth for looked-up JWT invocations --- README.md | 13 +++++ src/core/harness.tsx | 42 ++++++++++---- src/core/harnessBearer.test.ts | 64 +++++++++++++++++++++ src/core/harnessBearer.ts | 35 +++++++++++ src/handlers/harness/invoke/index.tsx | 11 ++++ src/handlers/harness/invoke/invoke.test.tsx | 60 ++++++++++++++++++- src/handlers/harness/invoke/operation.ts | 6 +- src/handlers/harness/request.ts | 49 ++++++++++++++++ src/handlers/harness/shell/request.ts | 17 +----- src/handlers/harness/types.tsx | 6 +- src/handlers/project/invoke/index.test.tsx | 29 ++++++++++ 11 files changed, 304 insertions(+), 28 deletions(-) create mode 100644 src/core/harnessBearer.test.ts create mode 100644 src/core/harnessBearer.ts create mode 100644 src/handlers/harness/request.ts diff --git a/README.md b/README.md index 07988524a..38ced65e1 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,19 @@ agentcore deploy agentcore invoke --harness MyAssistant --prompt "Hey, what can you do for me?" ``` +To invoke a CUSTOM_JWT Harness, supply AWS credentials for the Harness lookup and pipe a +bearer token for the invocation: + +```bash +printf '%s' "$JWT" | agentcore invoke \ + --harness MyAssistant-AbCdEf1234 \ + --region us-east-1 --bearer-token - --prompt "Hello" +``` + +The same flags work with `agentcore harness invoke --id `. You can also select a +Harness by ARN with `agentcore invoke --harness `, but the CLI still looks up +its configuration before invoking it. `--bearer-token file://` reads a token from a file. + To start from code you own instead, pick a template (`agentcore create --help` lists them): ```bash diff --git a/src/core/harness.tsx b/src/core/harness.tsx index ef3f9d598..0a12a30cb 100644 --- a/src/core/harness.tsx +++ b/src/core/harness.tsx @@ -32,26 +32,26 @@ import { InvokeHarnessCommand, type InvokeAgentRuntimeCommandRequest, type InvokeAgentRuntimeCommandResponse, - type InvokeHarnessRequest, type InvokeHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore"; import type { CoreHarnessClient, + CoreInvokeHarnessRequest, CreateHarnessInput, ResolvedHarnessRuntime, HarnessShellRequest, } from "../handlers/harness/types"; import type { RuntimeShellSession } from "../handlers/runtime/types"; import { createHarnessShellOpener, type OpenHarnessShell } from "./harnessShell"; +import { createBearerHarnessClient } from "./harnessBearer"; import { InputValidationError } from "../errors"; import type { AwsClients, CoreOptions } from "./types"; import { abortable } from "./abortable"; import { ensureDefaultExecutionRole } from "./executionRole"; import { toClientConfig } from "./utils"; -// HarnessClient implements the harness-facing operations on top of the shared AWS -// clients provided by CoreClient. It owns no clients of its own; it borrows the -// cached ones so every Core sub-client shares the same connections. +// HarnessClient uses CoreClient's cached AWS clients for IAM requests. CUSTOM_JWT +// invocation uses a short-lived Harness-only bearer client. export class HarnessClient implements CoreHarnessClient { constructor( private readonly clients: AwsClients, @@ -206,20 +206,34 @@ export class HarnessClient implements CoreHarnessClient { } async invokeHarness( - request: InvokeHarnessRequest, + request: CoreInvokeHarnessRequest, options: CoreOptions, abortSignal?: AbortSignal, ): Promise { - const response = await this.clients - .data(toClientConfig(options)) - .send(new InvokeHarnessCommand(request), { abortSignal }); - if (!response.stream || !abortSignal) return response; + const { bearerToken, ...sdkRequest } = request; + const client = + bearerToken === undefined + ? this.clients.data(toClientConfig(options)) + : createBearerHarnessClient(options, bearerToken); + let response: InvokeHarnessResponse; + try { + response = await client.send(new InvokeHarnessCommand(sdkRequest), { abortSignal }); + } catch (error) { + if (bearerToken !== undefined) client.destroy(); + throw error; + } + if (!response.stream) { + if (bearerToken !== undefined) client.destroy(); + return response; + } // The SDK honors the abort signal while the request is being established, // but once the event stream is flowing, aborting no longer tears the // iteration down — consumers awaiting the next event would hang until the // turn ran to completion. Racing each read against the signal keeps abort // (esc in the TUI) responsive mid-stream. - return { ...response, stream: abortable(response.stream, abortSignal) }; + let stream = abortSignal ? abortable(response.stream, abortSignal) : response.stream; + if (bearerToken !== undefined) stream = withCleanup(stream, () => client.destroy()); + return { ...response, stream }; } async invokeAgentRuntimeCommand( @@ -236,6 +250,14 @@ export class HarnessClient implements CoreHarnessClient { } } +async function* withCleanup(stream: AsyncIterable, cleanup: () => void): AsyncIterable { + try { + yield* stream; + } finally { + cleanup(); + } +} + export function harnessRuntimeFromResponse( id: string, response: GetHarnessResponse, diff --git a/src/core/harnessBearer.test.ts b/src/core/harnessBearer.test.ts new file mode 100644 index 000000000..5f37d3f03 --- /dev/null +++ b/src/core/harnessBearer.test.ts @@ -0,0 +1,64 @@ +import { expect, spyOn, test } from "bun:test"; +import { InvokeHarnessCommand } from "@aws-sdk/client-bedrock-agentcore"; +import { createBearerHarnessClient } from "./harnessBearer"; + +const command = () => + new InvokeHarnessCommand({ + harnessArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:harness/MyHarness-abc123", + runtimeSessionId: "s".repeat(40), + messages: [{ role: "user", content: [{ text: "hi" }] }], + }); + +test("Harness bearer client sends a JWT without SigV4", async () => { + let authorization: string | undefined; + let sigv4Date: string | undefined; + const client = createBearerHarnessClient( + { + region: "us-west-2", + endpointUrl: "https://example.test", + credentials: async () => { + throw new Error("Bearer invocation must not resolve AWS credentials"); + }, + }, + "secret-token", + ); + const handle = spyOn(client.config.requestHandler, "handle").mockImplementation( + async (request) => { + const headers = (request as { headers: Record }).headers; + authorization = headers.Authorization; + sigv4Date = headers["x-amz-date"]; + return { + response: { + statusCode: 403, + headers: { "content-type": "application/json" }, + body: Buffer.from('{"message":"expected test rejection"}'), + }, + } as never; + }, + ); + try { + await expect(client.send(command())).rejects.toThrow(); + expect(authorization).toBe("Bearer secret-token"); + expect(sigv4Date).toBeUndefined(); + } finally { + handle.mockRestore(); + client.destroy(); + } +}); + +test("Harness bearer client never sends a token to a resolved HTTP endpoint", async () => { + const client = createBearerHarnessClient({ region: "us-west-2" }, "secret-token"); + client.config.endpointProvider = () => ({ url: new URL("http://example.test") }); + let sent = false; + const handle = spyOn(client.config.requestHandler, "handle").mockImplementation(async () => { + sent = true; + throw new Error("Bearer token reached the transport"); + }); + try { + await expect(client.send(command())).rejects.toThrow("CUSTOM_JWT requires an HTTPS endpoint"); + expect(sent).toBe(false); + } finally { + handle.mockRestore(); + client.destroy(); + } +}); diff --git a/src/core/harnessBearer.ts b/src/core/harnessBearer.ts new file mode 100644 index 000000000..6db23ecda --- /dev/null +++ b/src/core/harnessBearer.ts @@ -0,0 +1,35 @@ +import { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; +import { HttpBearerAuthSigner } from "@smithy/core"; +import type { HttpRequest, TokenIdentity } from "@smithy/types"; +import { InputValidationError } from "../errors"; +import type { CoreOptions } from "./types"; +import { toClientConfig } from "./utils"; + +const HTTP_BEARER_AUTH_SCHEME = "smithy.api#httpBearerAuth"; + +export function createBearerHarnessClient( + options: CoreOptions, + token: string, +): BedrockAgentCoreClient { + const client = new BedrockAgentCoreClient({ + ...toClientConfig(options), + httpAuthSchemeProvider: () => [{ schemeId: HTTP_BEARER_AUTH_SCHEME }], + httpAuthSchemes: [ + { + schemeId: HTTP_BEARER_AUTH_SCHEME, + identityProvider: () => async (): Promise => ({ token }), + signer: new HttpBearerAuthSigner(), + }, + ], + }); + client.middlewareStack.add( + (next) => async (args) => { + if ((args.request as HttpRequest).protocol !== "https:") { + throw new InputValidationError("CUSTOM_JWT requires an HTTPS endpoint"); + } + return next(args); + }, + { step: "finalizeRequest", priority: "low", name: "requireHttpsForHarnessBearerToken" }, + ); + return client; +} diff --git a/src/handlers/harness/invoke/index.tsx b/src/handlers/harness/invoke/index.tsx index 1c091eb0e..f8852c95f 100644 --- a/src/handlers/harness/invoke/index.tsx +++ b/src/handlers/harness/invoke/index.tsx @@ -9,6 +9,7 @@ import { runWithProgress } from "../../../tui/progress"; import { InputValidationError } from "../../../errors"; import { invokeHarnessTurn } from "./operation.ts"; import { HarnessInvokeLaunchContextKey } from "./launchContext"; +import { resolveHarnessBearerToken } from "../request"; export const invokeHarnessFlags = [ flag("id", "the ID of the harness", z.string().min(1).max(51)), @@ -28,6 +29,9 @@ export const invokeHarnessFlags = [ "the harness endpoint qualifier to invoke (default DEFAULT)", z.string().optional(), ), + flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { + sensitive: true, + }), ] as const; export const createInvokeHarnessHandler = (core: Core, io: AppIO) => @@ -41,6 +45,11 @@ export const createInvokeHarnessHandler = (core: Core, io: AppIO) => // one-shot CLI transcript below needs --prompt (and is the only shape // JSON mode supports). if (!flags["prompt"]) { + if (flags["bearer-token"] !== undefined) { + throw new InputValidationError( + "required option '--prompt ' not specified with '--bearer-token'", + ); + } if (ctx.require(JsonKey)) { throw new InputValidationError("required option '--prompt ' not specified"); } @@ -61,6 +70,7 @@ export const createInvokeHarnessHandler = (core: Core, io: AppIO) => const opts = coreOptsFromCtx(ctx); const prompt = flags["prompt"]; + const bearerToken = await resolveHarnessBearerToken(flags["bearer-token"], io.stdin); const invoke = () => invokeHarnessTurn( core.harness, @@ -70,6 +80,7 @@ export const createInvokeHarnessHandler = (core: Core, io: AppIO) => qualifier: flags["qualifier"] ?? "DEFAULT", sessionId: flags["session-id"], userId: flags["user-id"], + bearerToken, }, opts, ); diff --git a/src/handlers/harness/invoke/invoke.test.tsx b/src/handlers/harness/invoke/invoke.test.tsx index 2d8e3cae5..508653642 100644 --- a/src/handlers/harness/invoke/invoke.test.tsx +++ b/src/handlers/harness/invoke/invoke.test.tsx @@ -24,7 +24,7 @@ import { HarnessInvokeLaunchContextKey } from "./launchContext"; // TestCoreClient rather than recorded fixtures: the invoke response is an // AsyncIterable stream, which the fixture serialization cannot capture. -const ARN = "arn:aws:bedrock-agentcore:us-west-2:123:harness/MyHarness-abc123"; +const ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:harness/MyHarness-abc123"; const GET_RESPONSE = { harness: { harnessId: "MyHarness-abc123", harnessName: "MyHarness", arn: ARN }, @@ -134,6 +134,64 @@ describe("harness invoke", () => { expect((invoke.args[1] as { region: string }).region).toBe("us-west-2"); }); + test("looks up a CUSTOM_JWT harness before invoking with a bearer token", async () => { + const { core } = await run( + ["harness", "invoke", "--id", "MyHarness-abc123", "--prompt", "hi", "--bearer-token", "-"], + (core) => + core.harness.setGetResponse({ + harness: { + ...GET_RESPONSE.harness, + authorizerConfiguration: { + customJWTAuthorizer: { discoveryUrl: "https://idp.example.test" }, + }, + }, + } as GetHarnessResponse), + testIO({ stdin: "secret-token\n" }), + ); + expect(core.harness.calls.find((call) => call.method === "getHarness")?.args[0]).toBe( + "MyHarness-abc123", + ); + expect(core.harness.calls.find((call) => call.method === "invokeHarness")?.args[0]).toEqual( + expect.objectContaining({ harnessArn: ARN, bearerToken: "secret-token" }), + ); + }); + + test("requires a bearer token for a CUSTOM_JWT harness", async () => { + await expect( + run(["harness", "invoke", "--id", "MyHarness-abc123", "--prompt", "hi"], (core) => + core.harness.setGetResponse({ + harness: { + ...GET_RESPONSE.harness, + authorizerConfiguration: { + customJWTAuthorizer: { discoveryUrl: "https://idp.example.test" }, + }, + }, + } as GetHarnessResponse), + ), + ).rejects.toThrow("CUSTOM_JWT Harness requires --bearer-token"); + }); + + test("rejects a bearer token for an IAM harness", async () => { + await expect( + run([ + "harness", + "invoke", + "--id", + "MyHarness-abc123", + "--prompt", + "hi", + "--bearer-token", + "secret-token", + ]), + ).rejects.toThrow("IAM Harness does not accept --bearer-token"); + }); + + test("--bearer-token requires a one-shot prompt", async () => { + await expect( + run(["harness", "invoke", "--id", "MyHarness-abc123", "--bearer-token", "secret-token"]), + ).rejects.toThrow("--prompt"); + }); + test("--session-id is passed through and echoed in the output", async () => { const sessionId = "custom-session-id-that-is-long-enough"; const { core, stdout } = await run([ diff --git a/src/handlers/harness/invoke/operation.ts b/src/handlers/harness/invoke/operation.ts index 334b66bed..da781611f 100644 --- a/src/handlers/harness/invoke/operation.ts +++ b/src/handlers/harness/invoke/operation.ts @@ -1,5 +1,6 @@ import type { CoreOptions } from "../../../core/types"; import type { CoreHarnessClient } from "../types"; +import { resolveHarnessArn } from "../request"; import { applyEvent, finishTurn, newSessionId, newTurn, type TranscriptItem } from "./transcript"; export type HarnessInvokeResult = { @@ -18,19 +19,22 @@ export async function invokeHarnessTurn( qualifier?: string; sessionId?: string; userId?: string; + bearerToken?: string; }, options: CoreOptions, signal?: AbortSignal, ): Promise { const detail = await client.getHarness(input.harnessId, options); + const harnessArn = resolveHarnessArn(detail, input.bearerToken); const sessionId = input.sessionId ?? newSessionId(); const response = await client.invokeHarness( { - harnessArn: detail.harness?.arn, + harnessArn, qualifier: input.qualifier ?? "DEFAULT", runtimeSessionId: sessionId, runtimeUserId: input.userId, messages: [{ role: "user", content: [{ text: input.prompt }] }], + bearerToken: input.bearerToken, }, options, signal, diff --git a/src/handlers/harness/request.ts b/src/handlers/harness/request.ts new file mode 100644 index 000000000..4d98f185c --- /dev/null +++ b/src/handlers/harness/request.ts @@ -0,0 +1,49 @@ +import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError, SourceResolutionError } from "../../errors"; +import { SourceResolver } from "../../io"; + +export function resolveHarnessArn( + detail: GetHarnessResponse, + bearerToken: string | undefined, +): string { + const harness = detail.harness; + const harnessArn = harness?.arn; + if (!harnessArn?.match(/^arn:[^:]+:bedrock-agentcore:[^:]+:\d{12}:harness\/.+$/)) { + throw new InputValidationError("Harness returned an invalid ARN"); + } + const authorizer = harness?.authorizerConfiguration; + const customJwt = authorizer !== undefined && "customJWTAuthorizer" in authorizer; + if (authorizer && !customJwt) { + throw new InputValidationError("Harness uses an unsupported authorizer"); + } + if (customJwt && !bearerToken) { + throw new InputValidationError("CUSTOM_JWT Harness requires --bearer-token"); + } + if (!customJwt && bearerToken !== undefined) { + throw new InputValidationError("IAM Harness does not accept --bearer-token"); + } + return harnessArn; +} + +export async function resolveHarnessBearerToken( + source: string | undefined, + stdin: NodeJS.ReadStream, +): Promise { + try { + const value = await new SourceResolver({ stdin }).resolveText("bearer-token", source); + if (value === undefined) return undefined; + const normalized = value.replace(/\r?\n$/, ""); + if (normalized.length === 0) { + throw new InputValidationError("--bearer-token must not be empty"); + } + if (/[\r\n]/.test(normalized)) { + throw new InputValidationError("--bearer-token must be a single-line value"); + } + return normalized; + } catch (error) { + if (error instanceof SourceResolutionError) { + throw new InputValidationError(error.message, { cause: error }); + } + throw error; + } +} diff --git a/src/handlers/harness/shell/request.ts b/src/handlers/harness/shell/request.ts index ee4dff23f..0efa286b8 100644 --- a/src/handlers/harness/shell/request.ts +++ b/src/handlers/harness/shell/request.ts @@ -1,6 +1,7 @@ import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { InputValidationError } from "../../../errors"; import type { HarnessShellRequest } from "../types"; +import { resolveHarnessArn } from "../request"; export function normalizeHarnessShellRequest( detail: GetHarnessResponse, @@ -12,21 +13,7 @@ export function normalizeHarnessShellRequest( `Harness is not ready (status: ${harness?.status ?? "unknown"})`, ); } - const harnessArn = harness.arn; - if (!harnessArn?.match(/^arn:[^:]+:bedrock-agentcore:[^:]+:\d{12}:harness\/.+$/)) { - throw new InputValidationError("Harness returned an invalid ARN"); - } - const authorizer = harness.authorizerConfiguration; - const customJwt = authorizer !== undefined && "customJWTAuthorizer" in authorizer; - if (authorizer && !customJwt) { - throw new InputValidationError("Harness uses an unsupported authorizer"); - } - if (customJwt && !input.bearerToken) { - throw new InputValidationError("CUSTOM_JWT Harness requires --bearer-token"); - } - if (!customJwt && input.bearerToken !== undefined) { - throw new InputValidationError("IAM Harness does not accept --bearer-token"); - } + const harnessArn = resolveHarnessArn(detail, input.bearerToken); return { harnessArn, qualifier: input.qualifier, diff --git a/src/handlers/harness/types.tsx b/src/handlers/harness/types.tsx index 043ce5df8..ec7196c24 100644 --- a/src/handlers/harness/types.tsx +++ b/src/handlers/harness/types.tsx @@ -47,6 +47,10 @@ export type ResolvedHarnessRuntime = { runtimeName: string; }; +export type CoreInvokeHarnessRequest = InvokeHarnessRequest & { + bearerToken?: string; +}; + export interface CoreHarnessClient { openHarnessShell( request: HarnessShellRequest, @@ -103,7 +107,7 @@ export interface CoreHarnessClient { options: CoreOptions, ): Promise; invokeHarness( - request: InvokeHarnessRequest, + request: CoreInvokeHarnessRequest, options: CoreOptions, abortSignal?: AbortSignal, ): Promise; diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index ddc40b5f2..1bfdf65c6 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -221,6 +221,35 @@ describe("invoke", () => { }, ); + test("looks up a Harness selected by ARN before a bearer invocation", async () => { + const subject = await routedCommand( + ["--harness", arn(`harness/${HARNESS_ID}`), "--prompt", "hi", "--bearer-token", "jwt"], + undefined, + ); + subject.core.harness.setGetResponse({ + harness: { + arn: arn(`harness/${HARNESS_ID}`), + authorizerConfiguration: { + customJWTAuthorizer: { discoveryUrl: "https://idp.example.test" }, + }, + }, + } as GetHarnessResponse); + + await subject.route(); + + expect(subject.core.harness.calls.find((call) => call.method === "getHarness")?.args[0]).toBe( + HARNESS_ID, + ); + expect( + subject.core.harness.calls.find((call) => call.method === "invokeHarness")?.args[0], + ).toEqual( + expect.objectContaining({ + harnessArn: arn(`harness/${HARNESS_ID}`), + bearerToken: "jwt", + }), + ); + }); + test.each([ [["--runtime", "checkout"], `/agentcore/runtime/invoke/${RUNTIME_ID}`], [ From 92cfefc9abcbb34fa7bf16ba860234f01d5a81f6 Mon Sep 17 00:00:00 2001 From: jariy17 Date: Thu, 5 Nov 2026 22:15:46 +0000 Subject: [PATCH 2/2] test(harness): remove bearer client core tests --- src/core/harnessBearer.test.ts | 64 ---------------------------------- 1 file changed, 64 deletions(-) delete mode 100644 src/core/harnessBearer.test.ts diff --git a/src/core/harnessBearer.test.ts b/src/core/harnessBearer.test.ts deleted file mode 100644 index 5f37d3f03..000000000 --- a/src/core/harnessBearer.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect, spyOn, test } from "bun:test"; -import { InvokeHarnessCommand } from "@aws-sdk/client-bedrock-agentcore"; -import { createBearerHarnessClient } from "./harnessBearer"; - -const command = () => - new InvokeHarnessCommand({ - harnessArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:harness/MyHarness-abc123", - runtimeSessionId: "s".repeat(40), - messages: [{ role: "user", content: [{ text: "hi" }] }], - }); - -test("Harness bearer client sends a JWT without SigV4", async () => { - let authorization: string | undefined; - let sigv4Date: string | undefined; - const client = createBearerHarnessClient( - { - region: "us-west-2", - endpointUrl: "https://example.test", - credentials: async () => { - throw new Error("Bearer invocation must not resolve AWS credentials"); - }, - }, - "secret-token", - ); - const handle = spyOn(client.config.requestHandler, "handle").mockImplementation( - async (request) => { - const headers = (request as { headers: Record }).headers; - authorization = headers.Authorization; - sigv4Date = headers["x-amz-date"]; - return { - response: { - statusCode: 403, - headers: { "content-type": "application/json" }, - body: Buffer.from('{"message":"expected test rejection"}'), - }, - } as never; - }, - ); - try { - await expect(client.send(command())).rejects.toThrow(); - expect(authorization).toBe("Bearer secret-token"); - expect(sigv4Date).toBeUndefined(); - } finally { - handle.mockRestore(); - client.destroy(); - } -}); - -test("Harness bearer client never sends a token to a resolved HTTP endpoint", async () => { - const client = createBearerHarnessClient({ region: "us-west-2" }, "secret-token"); - client.config.endpointProvider = () => ({ url: new URL("http://example.test") }); - let sent = false; - const handle = spyOn(client.config.requestHandler, "handle").mockImplementation(async () => { - sent = true; - throw new Error("Bearer token reached the transport"); - }); - try { - await expect(client.send(command())).rejects.toThrow("CUSTOM_JWT requires an HTTPS endpoint"); - expect(sent).toBe(false); - } finally { - handle.mockRestore(); - client.destroy(); - } -});