Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <harness-id>`. You can also select a
Harness by ARN with `agentcore invoke --harness <harness-arn>`, but the CLI still looks up
its configuration before invoking it. `--bearer-token file://<path>` reads a token from a file.

To start from code you own instead, pick a template (`agentcore create --help` lists them):

```bash
Expand Down
42 changes: 32 additions & 10 deletions src/core/harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -206,20 +206,34 @@ export class HarnessClient implements CoreHarnessClient {
}

async invokeHarness(
request: InvokeHarnessRequest,
request: CoreInvokeHarnessRequest,
options: CoreOptions,
abortSignal?: AbortSignal,
): Promise<InvokeHarnessResponse> {
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(
Expand All @@ -236,6 +250,14 @@ export class HarnessClient implements CoreHarnessClient {
}
}

async function* withCleanup<T>(stream: AsyncIterable<T>, cleanup: () => void): AsyncIterable<T> {
try {
yield* stream;
} finally {
cleanup();
}
}

export function harnessRuntimeFromResponse(
id: string,
response: GetHarnessResponse,
Expand Down
35 changes: 35 additions & 0 deletions src/core/harnessBearer.ts
Original file line number Diff line number Diff line change
@@ -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<TokenIdentity> => ({ 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;
}
11 changes: 11 additions & 0 deletions src/handlers/harness/invoke/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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) =>
Expand All @@ -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 <text>' not specified with '--bearer-token'",
);
}
if (ctx.require(JsonKey)) {
throw new InputValidationError("required option '--prompt <text>' not specified");
}
Expand All @@ -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,
Expand All @@ -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,
);
Expand Down
60 changes: 59 additions & 1 deletion src/handlers/harness/invoke/invoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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([
Expand Down
6 changes: 5 additions & 1 deletion src/handlers/harness/invoke/operation.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -18,19 +19,22 @@ export async function invokeHarnessTurn(
qualifier?: string;
sessionId?: string;
userId?: string;
bearerToken?: string;
},
options: CoreOptions,
signal?: AbortSignal,
): Promise<HarnessInvokeResult> {
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,
Expand Down
49 changes: 49 additions & 0 deletions src/handlers/harness/request.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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;
}
}
17 changes: 2 additions & 15 deletions src/handlers/harness/shell/request.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/handlers/harness/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export type ResolvedHarnessRuntime = {
runtimeName: string;
};

export type CoreInvokeHarnessRequest = InvokeHarnessRequest & {
bearerToken?: string;
};

export interface CoreHarnessClient {
openHarnessShell(
request: HarnessShellRequest,
Expand Down Expand Up @@ -103,7 +107,7 @@ export interface CoreHarnessClient {
options: CoreOptions,
): Promise<ListHarnessVersionsResponse>;
invokeHarness(
request: InvokeHarnessRequest,
request: CoreInvokeHarnessRequest,
options: CoreOptions,
abortSignal?: AbortSignal,
): Promise<InvokeHarnessResponse>;
Expand Down
Loading
Loading