Skip to content
Open
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
1 change: 1 addition & 0 deletions command.md
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,7 @@ invoke a harness
- `--user-id <user-id>`: the end-user ID for user-scoped AgentCore Identity credentials
- `--session-id <session-id>`: the Runtime session ID to continue (33-100 characters)
- `--qualifier <qualifier>`: the harness endpoint qualifier to invoke (default DEFAULT)
- `--bearer-token <bearer-token>`: the CUSTOM\_JWT bearer token, from stdin ('-') or a file ('file://&lt;path&gt;')

#### `agentcore harness exec`

Expand Down
27 changes: 26 additions & 1 deletion src/core/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
import type { RuntimeInvokeRequest } from "../handlers/runtime/types";
import type { Logger, LoggerBindings } from "../logging";
import { CoreClient } from "./index";
import type { ClientConfig, CoreFetch } from "./types";
import type { ClientConfig, CoreFetch, DataClientConfig } from "./types";
import { toClientConfig } from "./utils";
import { createSilentLogger } from "../testing";

Expand Down Expand Up @@ -216,6 +216,31 @@ test("data() caches independently of control()", () => {
expect(dataBuilt).toBe(1);
});

test("data() never caches a bearer-token client or hands one to a SigV4 caller", () => {
const configs: DataClientConfig[] = [];
const core = new CoreClient({
createControlClient: fakeControl,
createDataClient: (config) => {
configs.push(config);
return fakeData(config);
},
createIamClient: fakeIam,
createLogsClient: fakeLogs,
createXrayClient: fakeXray,
createApplicationSignalsClient: fakeApplicationSignals,
logger: createSilentLogger(),
});

const b1 = core.data({ region: "us-east-1", bearerToken: "jwt" });
const b2 = core.data({ region: "us-east-1", bearerToken: "jwt" });
const sigv4 = core.data({ region: "us-east-1" });

expect(b1).not.toBe(b2);
expect(sigv4).not.toBe(b1);
expect(core.data({ region: "us-east-1" })).toBe(sigv4);
expect(configs.map((config) => config.bearerToken)).toEqual(["jwt", "jwt", undefined]);
});

test("exposes feature sub-clients", () => {
const core = new CoreClient({
createControlClient: fakeControl,
Expand Down
54 changes: 54 additions & 0 deletions src/core/factories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { InvokeHarnessCommand } from "@aws-sdk/client-bedrock-agentcore";
import { createDataClient } from "./factories";
import type { DataClientConfig } from "./types";

const HARNESS_ARN = "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/MyHarness-abc123";

// signedHeaders sends an InvokeHarness through a real data client and returns the
// headers of the request as it would leave for the wire, after signing.
async function signedHeaders(config: DataClientConfig): Promise<Record<string, string>> {
const client = createDataClient(config);
let headers: Record<string, string> | undefined;
client.middlewareStack.add(
() => async (args) => {
headers = (args.request as { headers: Record<string, string> }).headers;
throw new Error("request captured");
},
{ step: "deserialize", priority: "low" },
);
await expect(
client.send(
new InvokeHarnessCommand({
harnessArn: HARNESS_ARN,
runtimeSessionId: "s".repeat(33),
messages: [{ role: "user", content: [{ text: "hi" }] }],
}),
),
).rejects.toThrow("request captured");
return headers!;
}

describe("createDataClient", () => {
test("a bearer token authenticates with Authorization: Bearer and never resolves AWS credentials", async () => {
const headers = await signedHeaders({
region: "us-east-1",
bearerToken: "jwt-123",
credentials: () => {
throw new Error("AWS credentials must not be resolved");
},
});

expect(headers["Authorization"] ?? headers["authorization"]).toBe("Bearer jwt-123");
expect(Object.keys(headers).map((name) => name.toLowerCase())).not.toContain("x-amz-date");
});

test("without a bearer token the request is SigV4-signed", async () => {
const headers = await signedHeaders({
region: "us-east-1",
credentials: { accessKeyId: "AKIDEXAMPLE", secretAccessKey: "secret" },
});

expect(headers["authorization"]).toStartWith("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/");
});
});
20 changes: 18 additions & 2 deletions src/core/factories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
import { XRayClient } from "@aws-sdk/client-xray";
import { ApplicationSignalsClient } from "@aws-sdk/client-application-signals";
import { HttpBearerAuthSigner } from "@smithy/core";
import type { TokenIdentity } from "@smithy/types";
import type {
CreateApplicationSignalsClient,
CreateCloudFormationClient,
Expand All @@ -23,8 +25,22 @@ import type {
export const createControlClient: CreateControlClient = (config) =>
new BedrockAgentCoreControlClient({ ...config });

export const createDataClient: CreateDataClient = (config) =>
new BedrockAgentCoreClient({ ...config });
// A bearer token swaps SigV4 for Smithy's HTTP bearer auth scheme, so the client
// sends `Authorization: Bearer <token>` and never resolves AWS credentials.
export const createDataClient: CreateDataClient = ({ bearerToken, ...config }) =>
new BedrockAgentCoreClient({
...config,
...(bearerToken !== undefined && {
httpAuthSchemes: [
{
schemeId: "smithy.api#httpBearerAuth",
identityProvider: () => async (): Promise<TokenIdentity> => ({ token: bearerToken }),
signer: new HttpBearerAuthSigner(),
},
],
httpAuthSchemeProvider: () => [{ schemeId: "smithy.api#httpBearerAuth" }],
}),
});

export const createIamClient: CreateIamClient = (config) => new IAMClient({ ...config });

Expand Down
45 changes: 44 additions & 1 deletion src/core/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import {
GetHarnessCommand,
type GetHarnessResponse,
} from "@aws-sdk/client-bedrock-agentcore-control";
import { InvokeHarnessCommand } from "@aws-sdk/client-bedrock-agentcore";
import { InputValidationError } from "../errors";
import type { AwsClients, ClientConfig } from "./types";
import type { AwsClients, ClientConfig, DataClientConfig } from "./types";
import { HarnessClient, harnessRuntimeFromResponse } from "./harness";

const RESPONSE = {
Expand Down Expand Up @@ -57,3 +58,45 @@ describe("HarnessClient.resolveRuntime", () => {
expect(configs).toEqual([{ region: "us-west-2", endpoint: "https://control.test" }]);
});
});

describe("HarnessClient.invokeHarness", () => {
const REQUEST = {
harnessArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:harness/MyHarness-abc123",
runtimeSessionId: "s".repeat(33),
messages: [{ role: "user" as const, content: [{ text: "hi" }] }],
};

async function invoke(bearerToken?: string) {
const configs: DataClientConfig[] = [];
const inputs: unknown[] = [];
const clients = {
data: (config: DataClientConfig) => {
configs.push(config);
return {
send: async (command: unknown) => {
expect(command).toBeInstanceOf(InvokeHarnessCommand);
inputs.push((command as InvokeHarnessCommand).input);
return {};
},
};
},
} as unknown as AwsClients;
await new HarnessClient(clients).invokeHarness(
{ ...REQUEST, ...(bearerToken !== undefined && { bearerToken }) },
{ region: "us-west-2" },
);
return { configs, inputs };
}

test("routes a bearer token to the data client config, not the request", async () => {
const { configs, inputs } = await invoke("jwt-123");
expect(configs).toEqual([{ region: "us-west-2", bearerToken: "jwt-123" }]);
expect(inputs).toEqual([REQUEST]);
});

test("uses the SigV4 client without a bearer token", async () => {
const { configs, inputs } = await invoke();
expect(configs).toEqual([{ region: "us-west-2" }]);
expect(inputs).toEqual([REQUEST]);
});
});
6 changes: 3 additions & 3 deletions src/core/harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ import {
InvokeHarnessCommand,
type InvokeAgentRuntimeCommandRequest,
type InvokeAgentRuntimeCommandResponse,
type InvokeHarnessRequest,
type InvokeHarnessResponse,
} from "@aws-sdk/client-bedrock-agentcore";
import type {
CoreHarnessClient,
CreateHarnessInput,
HarnessInvokeRequest,
ResolvedHarnessRuntime,
HarnessShellRequest,
} from "../handlers/harness/types";
Expand Down Expand Up @@ -206,12 +206,12 @@ export class HarnessClient implements CoreHarnessClient {
}

async invokeHarness(
request: InvokeHarnessRequest,
{ bearerToken, ...request }: HarnessInvokeRequest,
options: CoreOptions,
abortSignal?: AbortSignal,
): Promise<InvokeHarnessResponse> {
const response = await this.clients
.data(toClientConfig(options))
.data({ ...toClientConfig(options), ...(bearerToken !== undefined && { bearerToken }) })
.send(new InvokeHarnessCommand(request), { abortSignal });
if (!response.stream || !abortSignal) return response;
// The SDK honors the abort signal while the request is being established,
Expand Down
8 changes: 6 additions & 2 deletions src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
CreateIamClient,
CreateLogsClient,
CreateXrayClient,
DataClientConfig,
} from "./types";
import type { Logger } from "../logging";
import type { ProjectManager } from "../handlers/project/types";
Expand All @@ -46,6 +47,7 @@ export type {
CreateIamClient,
CreateLogsClient,
CreateXrayClient,
DataClientConfig,
} from "./types";

type CoreClientConfig = {
Expand Down Expand Up @@ -166,8 +168,10 @@ export class CoreClient implements AwsClients {
}

// data returns the data-plane client for `config`, creating and caching it on
// first use.
data(config: ClientConfig): BedrockAgentCoreClient {
// first use. A bearer-token client is built fresh each time: caching it would
// keep the token alive and risk reusing it for a SigV4 caller.
data(config: DataClientConfig): BedrockAgentCoreClient {
if (config.bearerToken !== undefined) return this.createDataClient(config);
return this.dataClients.get(config, this.createDataClient);
}

Expand Down
11 changes: 9 additions & 2 deletions src/core/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export interface ClientConfig {
credentials?: AwsCredentials;
}

// DataClientConfig is a ClientConfig for the data plane, which alone can
// authenticate with a CUSTOM_JWT bearer token instead of SigV4. A bearer client
// never resolves AWS credentials, so callers holding only a JWT can use it.
export interface DataClientConfig extends ClientConfig {
bearerToken?: string;
}

export type CredentialedClientConfig = {
region: string;
credentials: AwsCredentials;
Expand All @@ -48,7 +55,7 @@ export type CredentialedClientConfig = {
// than the clients themselves) lets CoreClient create/cache one client per config
// while keeping construction swappable for unit tests.
export type CreateControlClient = (config: ClientConfig) => BedrockAgentCoreControlClient;
export type CreateDataClient = (config: ClientConfig) => BedrockAgentCoreClient;
export type CreateDataClient = (config: DataClientConfig) => BedrockAgentCoreClient;
export type CreateIamClient = (config: ClientConfig) => IAMClient;
export type CreateLogsClient = (config: ClientConfig) => CloudWatchLogsClient;
export type CreateXrayClient = (config: ClientConfig) => XRayClient;
Expand All @@ -65,7 +72,7 @@ export type CoreFetch = (
// endpoint, ...).
export interface AwsClients {
control(config: ClientConfig): BedrockAgentCoreControlClient;
data(config: ClientConfig): BedrockAgentCoreClient;
data(config: DataClientConfig): BedrockAgentCoreClient;
iam(config: ClientConfig): IAMClient;
// logs reads the CloudWatch Logs streams AgentCore writes batch-evaluation
// results to. CloudWatch is a distinct service from the AgentCore data plane,
Expand Down
47 changes: 43 additions & 4 deletions src/handlers/harness/invoke/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import z from "zod";
import { createHandler, flag, PathKey } from "../../../router";
import type { AppIO } from "../../../io";
import { SourceResolver, type AppIO } from "../../../io";
import type { Core } from "../../types.tsx";
import { coreOptsFromCtx } from "../../utils.tsx";
import { JsonKey } from "../../keys.tsx";
import { AwsCredentialProviderKey, JsonKey } from "../../keys.tsx";
import { JsonRendererKey, renderTuiAt } from "../../../tui";
import { runWithProgress } from "../../../tui/progress";
import { InputValidationError } from "../../../errors";
import { InputValidationError, SourceResolutionError } from "../../../errors";
import { invokeHarnessTurn } from "./operation.ts";
import { HarnessInvokeLaunchContextKey } from "./launchContext";
import { HarnessInvokeArnKey, HarnessInvokeLaunchContextKey } from "./launchContext";

export const invokeHarnessFlags = [
flag("id", "the ID of the harness", z.string().min(1).max(51)),
Expand All @@ -28,6 +28,12 @@ export const invokeHarnessFlags = [
"the harness endpoint qualifier to invoke (default DEFAULT)",
z.string().optional(),
),
flag(
"bearer-token",
"the CUSTOM_JWT bearer token, from stdin ('-') or a file ('file://<path>')",
z.string().optional(),
{ sensitive: true },
),
] as const;

export const createInvokeHarnessHandler = (core: Core, io: AppIO) =>
Expand All @@ -44,6 +50,9 @@ export const createInvokeHarnessHandler = (core: Core, io: AppIO) =>
if (ctx.require(JsonKey)) {
throw new InputValidationError("required option '--prompt <text>' not specified");
}
if (flags["bearer-token"] !== undefined) {
throw new InputValidationError("--bearer-token requires --prompt");
}
let path = `${ctx.require(PathKey)}/${flags["id"]}`;
if (flags["session-id"]) path += `/${flags["session-id"]}`;
if (flags["qualifier"]) path += `?qualifier=${encodeURIComponent(flags["qualifier"])}`;
Expand All @@ -59,17 +68,33 @@ export const createInvokeHarnessHandler = (core: Core, io: AppIO) =>
return;
}

// Without the ARN the invoke must look the harness up with GetHarness. A
// project harness gets that from its target's credentials; otherwise it
// would need the default AWS credentials a bearer-token caller lacks.
const harnessArn = ctx.value(HarnessInvokeArnKey);
if (
flags["bearer-token"] !== undefined &&
harnessArn === undefined &&
ctx.value(AwsCredentialProviderKey) === undefined
) {
throw new InputValidationError(
"--bearer-token needs the harness ARN; use 'agentcore invoke --harness <arn> --bearer-token ...'",
);
}
const opts = coreOptsFromCtx(ctx);
const prompt = flags["prompt"];
const bearerToken = await resolveBearerToken(flags["bearer-token"], io.stdin);
const invoke = () =>
invokeHarnessTurn(
core.harness,
{
harnessId: flags["id"],
harnessArn,
prompt,
qualifier: flags["qualifier"] ?? "DEFAULT",
sessionId: flags["session-id"],
userId: flags["user-id"],
bearerToken,
},
opts,
);
Expand All @@ -82,4 +107,18 @@ export const createInvokeHarnessHandler = (core: Core, io: AppIO) =>
},
});

async function resolveBearerToken(
source: string | undefined,
stdin: NodeJS.ReadStream,
): Promise<string | undefined> {
try {
return await new SourceResolver({ stdin }).resolveSecret("bearer-token", source);
} catch (error) {
if (error instanceof SourceResolutionError) {
throw new InputValidationError(error.message, { cause: error });
}
throw error;
}
}

export { HarnessInvokeScreen } from "./screen.tsx";
Loading
Loading