From ce97a2b54743a111569a3cc750edc773529ea9b4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 21:37:30 +0000 Subject: [PATCH 01/35] feat(runtime): add IAM invoke vertical slice --- src/core/core.test.ts | 99 ++++++++++ src/core/runtime.tsx | 35 +++- src/handlers/runtime/index.tsx | 2 + src/handlers/runtime/invoke/index.tsx | 36 ++++ src/handlers/runtime/invoke/invoke.test.tsx | 200 ++++++++++++++++++++ src/handlers/runtime/invoke/request.ts | 28 +++ src/handlers/runtime/invoke/response.ts | 9 + src/handlers/runtime/types.tsx | 26 +++ src/router/flags.tsx | 7 +- src/router/handler.tsx | 12 +- src/router/router.test.ts | 33 ++++ src/testing/TestCoreClient.tsx | 28 ++- 12 files changed, 508 insertions(+), 7 deletions(-) create mode 100644 src/handlers/runtime/invoke/index.tsx create mode 100644 src/handlers/runtime/invoke/invoke.test.tsx create mode 100644 src/handlers/runtime/invoke/request.ts create mode 100644 src/handlers/runtime/invoke/response.ts diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 2c773710a..9b7f0211a 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -2,10 +2,12 @@ import { test, expect } from "bun:test"; import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore-control"; import type { IAMClient } from "@aws-sdk/client-iam"; import { + InvokeAgentRuntimeCommand, InvokeAgentRuntimeCommandCommand, InvokeHarnessCommand, type BedrockAgentCoreClient, } from "@aws-sdk/client-bedrock-agentcore"; +import type { RuntimeInvokeRequest } from "../handlers/runtime/types"; import { CoreClient } from "./index"; import type { ClientConfig } from "./types"; import { toClientConfig } from "./utils"; @@ -205,6 +207,103 @@ test("invokeAgentRuntimeCommand sends the command on the data client with the ab expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); }); +test("invokeRuntime sends InvokeAgentRuntimeCommand and maps its native byte response", async () => { + const sent: { command: unknown; options: unknown }[] = []; + const body = (async function* () { + yield Uint8Array.from([0, 1, 255]); + })(); + const sdkResponse = { + statusCode: 202, + contentType: "application/octet-stream", + runtimeSessionId: "runtime-session", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "baggage", + response: body, + }; + const core = new CoreClient( + fakeControl, + (config) => + ({ + config, + kind: "data", + send: async (command: unknown, options: unknown) => { + sent.push({ command, options }); + return sdkResponse; + }, + }) as unknown as BedrockAgentCoreClient, + fakeIam, + ); + const request: RuntimeInvokeRequest = { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: Uint8Array.from([123, 125]), + contentType: "application/json", + }; + const controller = new AbortController(); + + const result = await core.runtime.invokeRuntime( + request, + { region: "us-west-2", endpointUrl: "https://custom" }, + controller.signal, + ); + + expect(sent).toHaveLength(1); + expect(sent[0]!.command).toBeInstanceOf(InvokeAgentRuntimeCommand); + expect((sent[0]!.command as InvokeAgentRuntimeCommand).input).toEqual({ + agentRuntimeArn: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: Uint8Array.from([123, 125]), + contentType: "application/json", + }); + expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); + expect(result).toEqual({ + statusCode: 202, + contentType: "application/octet-stream", + runtimeSessionId: "runtime-session", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "baggage", + body, + }); +}); + +test("invokeRuntime exposes an empty async iterable when the SDK omits the body", async () => { + const core = new CoreClient( + fakeControl, + (config) => + ({ + config, + kind: "data", + send: async () => ({ statusCode: 204, contentType: "application/json" }), + }) as unknown as BedrockAgentCoreClient, + fakeIam, + ); + + const response = await core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + }, + { region: "us-east-1" }, + ); + + const chunks: Uint8Array[] = []; + for await (const chunk of response.body) chunks.push(chunk); + expect(chunks).toEqual([]); +}); + test("invokeHarness returns the stream untouched when no abort signal is given", async () => { const stream = (async function* () {})(); const core = new CoreClient({ diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index 77cc6c3e8..b25b44336 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -10,13 +10,46 @@ import { type ListAgentRuntimesResponse, type ListAgentRuntimeVersionsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import type { CoreRuntimeClient } from "../handlers/runtime/types"; +import { InvokeAgentRuntimeCommand } from "@aws-sdk/client-bedrock-agentcore"; +import type { + CoreRuntimeClient, + RuntimeInvokeRequest, + RuntimeInvokeResponse, +} from "../handlers/runtime/types"; import type { AwsClients, CoreOptions } from "./types"; import { toClientConfig } from "./utils"; +async function* emptyBody(): AsyncGenerator {} + export class RuntimeClient implements CoreRuntimeClient { constructor(private readonly clients: AwsClients) {} + async invokeRuntime( + request: RuntimeInvokeRequest, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { + const { runtimeId, ...input } = request; + const response = await this.clients + .data(toClientConfig(options)) + .send(new InvokeAgentRuntimeCommand({ ...input, agentRuntimeArn: runtimeId }), { + abortSignal: signal, + }); + + return { + statusCode: response.statusCode ?? 0, + contentType: response.contentType ?? "", + runtimeSessionId: response.runtimeSessionId, + mcpSessionId: response.mcpSessionId, + mcpProtocolVersion: response.mcpProtocolVersion, + traceId: response.traceId, + traceParent: response.traceParent, + traceState: response.traceState, + baggage: response.baggage, + body: (response.response as AsyncIterable | undefined) ?? emptyBody(), + }; + } + async getRuntime(id: string, options: CoreOptions): Promise { return this.clients .control(toClientConfig(options)) diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index dfe2b73bb..ad4f2e3e1 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -4,6 +4,7 @@ import { renderTui } from "../../tui"; import type { AppIO, Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; +import { createInvokeRuntimeHandler } from "./invoke"; import { createListRuntimesHandler } from "./list"; import { createRuntimeVersionHandler } from "./version"; @@ -13,6 +14,7 @@ export function createRuntimeHandler(core: Core, io: AppIO): Router { .default(renderTui(core, io)) .handler(createGetRuntimeHandler(core)) .handler(createListRuntimesHandler(core)) + .handler(createInvokeRuntimeHandler(core, io)) .handler(createRuntimeVersionHandler(core, io)) .handler(createRuntimeEndpointHandler(core, io)); } diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx new file mode 100644 index 000000000..dcb671cd6 --- /dev/null +++ b/src/handlers/runtime/invoke/index.tsx @@ -0,0 +1,36 @@ +import z from "zod"; +import { createHandler, flag } from "../../../router"; +import type { AppIO, Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { createInlineRuntimeInvokeRequest, runtimeIdSchema } from "./request"; +import { writeRuntimeInvokeResponse } from "./response"; + +export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => + createHandler({ + name: "invoke", + description: "invoke a Runtime", + flags: [ + flag("id", "the ID of the Runtime", runtimeIdSchema.optional()), + flag("payload", "the inline payload to send", z.string().optional()), + flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (flags.id === undefined) { + throw new TypeError("required option '--id ' not specified"); + } + if (flags.payload === undefined) { + throw new TypeError("required option '--payload ' not specified"); + } + + const options = coreOptsFromCtx(ctx); + const runtime = await core.runtime.getRuntime(flags.id, options); + const request = createInlineRuntimeInvokeRequest( + flags.id, + runtime.agentRuntimeArn, + flags.qualifier ?? "DEFAULT", + flags.payload, + ); + const response = await core.runtime.invokeRuntime(request, options); + await writeRuntimeInvokeResponse(response, io.stdout); + }, + }); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx new file mode 100644 index 000000000..0d777fe7b --- /dev/null +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test"; +import { PassThrough } from "node:stream"; +import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { Command } from "commander"; +import type { AppIO } from "../../types"; +import type { RuntimeInvokeRequest, RuntimeInvokeResponse } from "../types"; +import { createSilentLogger, TestCoreClient } from "../../../testing"; +import { compile, ValueContext } from "../../../router"; +import { createRootHandler } from "../../index"; + +const REGION = "us-west-2"; +const RUNTIME_ID = "runtime-123"; +const ACCOUNT_ID = "123456789012"; +const RUNTIME_ARN = `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:runtime/${RUNTIME_ID}`; + +function body(...chunks: Uint8Array[]): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} + +function captureIO(): { io: AppIO; bytes: () => Buffer } { + const stdin = new PassThrough() as unknown as NodeJS.ReadStream; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const chunks: Buffer[] = []; + stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + return { + io: { + stdin, + stdout: stdout as unknown as NodeJS.WriteStream, + stderr: stderr as unknown as NodeJS.WriteStream, + }, + bytes: () => Buffer.concat(chunks), + }; +} + +function commandFor(core: TestCoreClient, io: AppIO): Command { + const root = createRootHandler(core, { io, logger: createSilentLogger() }); + const command = compile(root, ValueContext.EmptyContext()); + const override = (current: Command): void => { + current.exitOverride(); + current.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + current.commands.forEach(override); + }; + override(command); + return command; +} + +async function run( + args: string[], + { + getResponse = { agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse, + invokeResponse = { + statusCode: 200, + contentType: "application/json", + body: body(Uint8Array.from([0, 255]), Uint8Array.from([10, 1])), + } satisfies RuntimeInvokeResponse, + } = {}, +) { + const core = new TestCoreClient(); + core.runtime.setGetResponse(getResponse); + core.runtime.setInvokeResponse(invokeResponse); + const output = captureIO(); + + await commandFor(core, output.io).parseAsync(["node", "agentcore", ...args, "--region", REGION]); + return { core, output }; +} + +describe("runtime invoke", () => { + test("resolves the Runtime, invokes its ID in the current account, and streams exact bytes", async () => { + const { core, output } = await run([ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + '{"prompt":"hello"}', + ]); + + expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime", "invokeRuntime"]); + expect(core.runtime.calls[0]!.args).toEqual([RUNTIME_ID, { region: REGION }]); + + const invoke = core.runtime.calls[1]!; + const request = invoke.args[0] as RuntimeInvokeRequest; + expect(request).toEqual({ + runtimeId: RUNTIME_ID, + accountId: ACCOUNT_ID, + qualifier: "DEFAULT", + payload: new TextEncoder().encode('{"prompt":"hello"}'), + contentType: "application/json", + }); + expect(invoke.args[1]).toEqual({ region: REGION }); + expect(output.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); + + output.io.stdout.write(Uint8Array.from([127])); + expect(output.bytes()).toEqual(Buffer.from([0, 255, 10, 1, 127])); + }); + + test("passes an explicit qualifier", async () => { + const { core } = await run([ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + "--qualifier", + "prod", + ]); + + const invoke = core.runtime.calls.find((call) => call.method === "invokeRuntime")!; + expect((invoke.args[0] as RuntimeInvokeRequest).qualifier).toBe("prod"); + }); + + test("passes an explicitly empty payload as zero bytes", async () => { + const { core } = await run(["runtime", "invoke", "--id", RUNTIME_ID, "--payload", ""]); + + const invoke = core.runtime.calls.find((call) => call.method === "invokeRuntime")!; + expect((invoke.args[0] as RuntimeInvokeRequest).payload).toEqual(new Uint8Array()); + }); + + test("rejects an ARN before making Runtime Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const command = commandFor(core, output.io); + + await expect( + command.parseAsync([ + "node", + "agentcore", + "runtime", + "invoke", + "--id", + RUNTIME_ARN, + "--payload", + "{}", + "--region", + REGION, + ]), + ).rejects.toThrow(/Runtime ID/); + expect(core.runtime.calls).toEqual([]); + }); + + test("rejects --payload without --id before making Runtime Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const command = commandFor(core, output.io); + + await expect( + command.parseAsync([ + "node", + "agentcore", + "runtime", + "invoke", + "--payload", + "{}", + "--region", + REGION, + ]), + ).rejects.toThrow(/--id/); + expect(core.runtime.calls).toEqual([]); + }); + + test("rejects a resolved Runtime ARN without an account ID before invoke", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ + agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}::runtime/${RUNTIME_ID}`, + } as GetAgentRuntimeResponse); + const output = captureIO(); + const command = commandFor(core, output.io); + + await expect( + command.parseAsync([ + "node", + "agentcore", + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + "--region", + REGION, + ]), + ).rejects.toThrow("Runtime returned an invalid ARN"); + expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime"]); + }); + + test("a bare command enters existing TUI middleware without Runtime Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const command = commandFor(core, output.io); + + await expect( + command.parseAsync(["node", "agentcore", "runtime", "invoke", "--region", REGION]), + ).rejects.toThrow("interactive mode requires a TTY on stdin and stdout"); + expect(core.runtime.calls).toEqual([]); + }); +}); diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts new file mode 100644 index 000000000..7cde8a770 --- /dev/null +++ b/src/handlers/runtime/invoke/request.ts @@ -0,0 +1,28 @@ +import z from "zod"; +import type { RuntimeInvokeRequest } from "../types"; + +export const runtimeIdSchema = z + .string() + .refine((value) => !value.startsWith("arn:"), "must be a Runtime ID, not an ARN"); + +export function createInlineRuntimeInvokeRequest( + runtimeId: string, + resolvedArn: string | undefined, + qualifier: string, + payload: string, +): RuntimeInvokeRequest { + const accountId = resolvedArn?.match( + /^arn:[^:]+:bedrock-agentcore:[^:]*:(\d{12}):runtime\//, + )?.[1]; + if (!accountId) { + throw new TypeError("Runtime returned an invalid ARN"); + } + + return { + runtimeId, + accountId, + qualifier, + payload: new TextEncoder().encode(payload), + contentType: "application/json", + }; +} diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts new file mode 100644 index 000000000..1821ce08d --- /dev/null +++ b/src/handlers/runtime/invoke/response.ts @@ -0,0 +1,9 @@ +import { pipeline } from "node:stream/promises"; +import type { RuntimeInvokeResponse } from "../types"; + +export async function writeRuntimeInvokeResponse( + response: RuntimeInvokeResponse, + stdout: NodeJS.WriteStream, +): Promise { + await pipeline(response.body, stdout, { end: false }); +} diff --git a/src/handlers/runtime/types.tsx b/src/handlers/runtime/types.tsx index e2f219f1b..eb8eae072 100644 --- a/src/handlers/runtime/types.tsx +++ b/src/handlers/runtime/types.tsx @@ -7,7 +7,33 @@ import type { } from "@aws-sdk/client-bedrock-agentcore-control"; import type { CoreOptions } from "../../core/types"; +export interface RuntimeInvokeRequest { + runtimeId: string; + accountId: string; + qualifier: string; + payload: Uint8Array; + contentType: string; +} + +export interface RuntimeInvokeResponse { + statusCode: number; + contentType: string; + runtimeSessionId?: string; + mcpSessionId?: string; + mcpProtocolVersion?: string; + traceId?: string; + traceParent?: string; + traceState?: string; + baggage?: string; + body: AsyncIterable; +} + export interface CoreRuntimeClient { + invokeRuntime( + request: RuntimeInvokeRequest, + options: CoreOptions, + signal?: AbortSignal, + ): Promise; getRuntime(id: string, options: CoreOptions): Promise; getRuntimeVersion( id: string, diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 07fb55c91..8c2aed740 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -9,14 +9,15 @@ import { coerce, formatZodError, inspect } from "./schema"; export function toOption(flag: Flag): Option { const info = inspect(flag.schema); const long = `--${flag.name}`; + const names = flag.short ? `-${flag.short}, ${long}` : long; let token: string; if (info.boolean) { - token = long; + token = names; } else if (info.variadic) { - token = `${long} <${flag.name}...>`; + token = `${names} <${flag.name}...>`; } else { - token = `${long} <${flag.name}>`; + token = `${names} <${flag.name}>`; } const option = new Option(token, flag.description); diff --git a/src/router/handler.tsx b/src/router/handler.tsx index a760a2318..972ba34d3 100644 --- a/src/router/handler.tsx +++ b/src/router/handler.tsx @@ -7,6 +7,7 @@ import type { Context, ContextKey } from "./context"; // Flag. export interface Flag { name: N; + short?: string; description: string; schema: z.ZodType; // help is optional long-form documentation rendered in the command's @@ -34,9 +35,16 @@ export function flag( name: N, description: string, schema: z.ZodType, - options?: { help?: string; sensitive?: boolean }, + options?: { help?: string; sensitive?: boolean; short?: string }, ): Flag { - return { name, description, schema, help: options?.help, sensitive: options?.sensitive }; + return { + name, + short: options?.short, + description, + schema, + help: options?.help, + sensitive: options?.sensitive, + }; } // globalFlag constructs a GlobalFlag. The returned value doubles as the typed diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 11d798fac..e201157df 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -264,6 +264,39 @@ test("applies a schema default for an omitted flag", async () => { expect(seen).toEqual({ count: 7 }); }); +test("a short alias preserves encounter order for repeated variadic values", async () => { + let seen: { header: string[] } | undefined; + + const request = createHandler({ + name: "request", + description: "", + flags: [flag("header", "request header", z.array(z.string()), { short: "H" })], + handle: async (_ctx, flags) => { + seen = flags; + }, + }); + + const root = new Router("app"); + root.handler(request); + + const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); + await cmd.parseAsync([ + "node", + "app", + "request", + "-H", + "first:value", + "--header", + "second:value", + "-H", + "third:value", + ]); + + expect(seen).toEqual({ + header: ["first:value", "second:value", "third:value"], + }); +}); + test("reports invalid input via command.error (throws under exitOverride)", async () => { const get = createHandler({ name: "get", diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 531c2df07..426a8d23c 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -37,7 +37,11 @@ import type { import type { Core } from "../handlers/types"; import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; import type { CoreIdentityClient } from "../handlers/identity/types"; -import type { CoreRuntimeClient } from "../handlers/runtime/types"; +import type { + CoreRuntimeClient, + RuntimeInvokeRequest, + RuntimeInvokeResponse, +} from "../handlers/runtime/types"; import type { CoreOptions } from "../core/types"; import type { ProjectManager } from "../handlers/project/types"; import type { Logger } from "../logging"; @@ -133,6 +137,12 @@ async function* events(items: T[]): AsyncGenerator { for (const item of items) yield item; } +const DEFAULT_RUNTIME_INVOKE_RESPONSE: RuntimeInvokeResponse = { + statusCode: 200, + contentType: "application/json", + body: events([]), +}; + // TestHarnessClient is the harness sub-client of TestCoreClient. export class TestHarnessClient implements CoreHarnessClient { // calls records every invocation in order, for assertions. @@ -446,6 +456,7 @@ export class TestRuntimeClient implements CoreRuntimeClient { private listResponses = new Map(); private listVersionResponses = new Map(); private listEndpointResponses = new Map(); + private invokeResponse: RuntimeInvokeResponse = DEFAULT_RUNTIME_INVOKE_RESPONSE; private error?: Error; setGetResponse(response: GetAgentRuntimeResponse): this { @@ -481,6 +492,11 @@ export class TestRuntimeClient implements CoreRuntimeClient { return this; } + setInvokeResponse(response: RuntimeInvokeResponse): this { + this.invokeResponse = response; + return this; + } + setError(error: Error | undefined): this { this.error = error; return this; @@ -492,6 +508,16 @@ export class TestRuntimeClient implements CoreRuntimeClient { return this.getResponse; } + async invokeRuntime( + request: RuntimeInvokeRequest, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { + this.calls.push({ method: "invokeRuntime", args: [request, options, signal] }); + if (this.error) throw this.error; + return this.invokeResponse; + } + async getRuntimeVersion( id: string, version: string, From 7d21c9e0e2274ab6c3a3e36aee1ff2cff971a6bc Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 21:45:52 +0000 Subject: [PATCH 02/35] test(runtime): include invoke in command hierarchy --- src/handlers/runtime/runtime.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 987848006..9151d3224 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -57,7 +57,7 @@ function testRuntimeCommand() { } describe("runtime command hierarchy", () => { - test("registers the Runtime read-only command hierarchy", () => { + test("registers the Runtime command hierarchy", () => { const root = createRootHandler(createFixtureCore(), { io: testIO().io, logger: createSilentLogger(), @@ -68,6 +68,7 @@ describe("runtime command hierarchy", () => { expect(runtime?.children().map((child) => child.name())).toEqual([ "get", "list", + "invoke", "version", "endpoint", ]); From 0ed12ad49b08a66d88626836fb886524b114d90d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 22:04:49 +0000 Subject: [PATCH 03/35] feat(runtime): add persistent invoke console --- src/components/Root.tsx | 13 + src/handlers/runtime/invoke/index.tsx | 16 +- .../runtime/invoke/invoke.screen.test.tsx | 243 ++++++++++++++++++ src/handlers/runtime/invoke/screen.tsx | 196 ++++++++++++++ src/testing/TestCoreClient.tsx | 11 +- 5 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 src/handlers/runtime/invoke/invoke.screen.test.tsx create mode 100644 src/handlers/runtime/invoke/screen.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 5d59130e0..1a7c6ccac 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -28,6 +28,7 @@ import { RuntimeListEndpointsScreen } from "../handlers/runtime/endpoint/list/sc import { RuntimeVersionScreen } from "../handlers/runtime/version/screen.tsx"; import { RuntimeGetVersionScreen } from "../handlers/runtime/version/get/screen.tsx"; import { RuntimeListVersionsScreen } from "../handlers/runtime/version/list/screen.tsx"; +import { RuntimeInvokeScreen } from "../handlers/runtime/invoke/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -255,6 +256,18 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/runtime/endpoint/list/:runtimeId" element={} /> + } + /> + } + /> + } + /> } /> diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index dcb671cd6..e69f7c5ed 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -1,7 +1,9 @@ import z from "zod"; -import { createHandler, flag } from "../../../router"; +import { createHandler, flag, PathKey } from "../../../router"; import type { AppIO, Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; +import { JsonKey } from "../../keys"; +import { renderTuiAt } from "../../../tui"; import { createInlineRuntimeInvokeRequest, runtimeIdSchema } from "./request"; import { writeRuntimeInvokeResponse } from "./response"; @@ -19,7 +21,15 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => throw new TypeError("required option '--id ' not specified"); } if (flags.payload === undefined) { - throw new TypeError("required option '--payload ' not specified"); + if (ctx.require(JsonKey)) { + throw new TypeError("required option '--payload ' not specified"); + } + let path = `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`; + if (flags.qualifier !== undefined) { + path += `/${encodeURIComponent(flags.qualifier)}`; + } + await renderTuiAt(path, ctx, core, io); + return; } const options = coreOptsFromCtx(ctx); @@ -34,3 +44,5 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => await writeRuntimeInvokeResponse(response, io.stdout); }, }); + +export { RuntimeInvokeScreen } from "./screen"; diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx new file mode 100644 index 000000000..b65b8d222 --- /dev/null +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -0,0 +1,243 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { Command } from "commander"; +import type { + AgentRuntime, + AgentRuntimeEndpoint, + GetAgentRuntimeResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { RuntimeInvokeRequest } from "../types"; +import type { AppIO } from "../../types"; +import { createRootHandler } from "../../index"; +import { compile, ValueContext } from "../../../router"; +import { + cleanupScreens, + createSilentLogger, + renderScreen, + TestCoreClient, + testIO, + waitFor, + waitForText, +} from "../../../testing"; +import * as tui from "../../../tui"; + +const REGION = "us-east-1"; +const RUNTIME_ID = "runtime-123"; +const QUALIFIER = "prod"; +const RUNTIME_ARN = `arn:aws:bedrock-agentcore:${REGION}:123456789012:runtime/${RUNTIME_ID}`; +const CONSOLE_PATH = `/agentcore/runtime/invoke/${RUNTIME_ID}/${QUALIFIER}`; + +afterEach(cleanupScreens); + +function runtime(overrides: Partial = {}): AgentRuntime { + return { + agentRuntimeArn: RUNTIME_ARN, + agentRuntimeId: RUNTIME_ID, + agentRuntimeVersion: "3", + agentRuntimeName: "checkout", + description: "Checkout Runtime", + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + status: "READY", + ...overrides, + }; +} + +function endpoint(overrides: Partial = {}): AgentRuntimeEndpoint { + return { + name: QUALIFIER, + id: QUALIFIER, + liveVersion: "3", + targetVersion: "3", + status: "READY", + agentRuntimeEndpointArn: `${RUNTIME_ARN}/endpoint/${QUALIFIER}`, + agentRuntimeArn: RUNTIME_ARN, + description: "Production endpoint", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function body(...chunks: Uint8Array[]): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} + +function splitUtf8(text: string, splitAt: number): AsyncIterable { + const bytes = new TextEncoder().encode(text); + return body(bytes.slice(0, splitAt), bytes.slice(splitAt)); +} + +function commandFor(core: TestCoreClient, io: AppIO): Command { + const root = createRootHandler(core, { io, logger: createSilentLogger() }); + const command = compile(root, ValueContext.EmptyContext()); + const override = (current: Command): void => { + current.exitOverride(); + current.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + current.commands.forEach(override); + }; + override(command); + return command; +} + +async function runCommand(core: TestCoreClient, io: AppIO, args: string[]): Promise { + await commandFor(core, io).parseAsync(["node", "agentcore", ...args, "--region", REGION]); +} + +describe("Runtime invoke routing", () => { + test("selects a Runtime and endpoint before opening one console", async () => { + const runtimeId = "runtime/blue one"; + const qualifier = "prod/green one"; + const core = new TestCoreClient(); + core.runtime + .setListResponse({ + agentRuntimes: [ + runtime({ + agentRuntimeId: runtimeId, + agentRuntimeName: "pick-runtime", + agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}:123456789012:runtime/${runtimeId}`, + }), + ], + }) + .setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: qualifier, id: qualifier })], + }) + .setGetResponse({ + agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}:123456789012:runtime/${runtimeId}`, + } as GetAgentRuntimeResponse); + const screen = renderScreen("/agentcore/runtime/invoke", { core }); + + await waitForText(screen.lastFrame, "pick-runtime"); + await screen.press("return"); + await waitForText(screen.lastFrame, qualifier); + await screen.press("return"); + + await waitForText( + screen.lastFrame, + `agentcore → runtime → invoke → ${runtimeId} → ${qualifier}`, + ); + await waitForText(screen.lastFrame, "Enter an inline payload"); + expect( + core.runtime.calls.some( + (call) => call.method === "listRuntimeEndpoints" && call.args[0] === runtimeId, + ), + ).toBe(true); + }); + + test("handler deep-links id-only and qualified invokes with encoded path segments", async () => { + const core = new TestCoreClient(); + const { io } = testIO(); + const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); + + try { + await runCommand(core, io, ["runtime", "invoke", "--id", "runtime/blue one"]); + await runCommand(core, io, [ + "runtime", + "invoke", + "--id", + "runtime/blue one", + "--qualifier", + "prod/green one", + ]); + + expect(render.mock.calls.map(([path]) => path)).toEqual([ + "/agentcore/runtime/invoke/runtime%2Fblue%20one", + "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one", + ]); + } finally { + render.mockRestore(); + } + }); + + test("handler keeps JSON mode without a payload as a usage error", async () => { + const core = new TestCoreClient(); + const { io } = testIO(); + + await expect( + runCommand(core, io, ["runtime", "invoke", "--id", RUNTIME_ID, "--json"]), + ).rejects.toThrow(/--payload/); + expect(core.runtime.calls).toEqual([]); + }); +}); + +describe("Runtime invoke console", () => { + test("streams opaque UTF-8 and preserves both requests and responses across two sends", async () => { + const firstResponse = 'data: {"first":"€"}\n\nnot-json'; + const secondResponse = '{"second":true}\nraw: ✓'; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const firstBytes = new TextEncoder().encode(firstResponse); + const firstBody = (async function* () { + yield firstBytes.slice(0, 18); + await firstGate; + yield firstBytes.slice(18); + })(); + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .queueInvokeBody(firstBody) + .queueInvokeBody(splitUtf8(secondResponse, 23)); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Enter an inline payload"); + await screen.write("first"); + await screen.press("return"); + await screen.write("payload"); + await screen.write("\x04"); + await waitForText(screen.lastFrame, 'data: {"first":"'); + + await screen.write("\x04"); + expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(1); + + releaseFirst(); + await waitForText(screen.lastFrame, firstResponse); + await waitForText(screen.lastFrame, "idle"); + + await screen.write("second payload"); + await screen.write("\x04"); + await waitForText(screen.lastFrame, secondResponse); + await waitFor( + () => core.runtime.calls.filter((call) => call.method === "invokeRuntime").length === 2, + ); + + const requests = core.runtime.calls + .filter((call) => call.method === "invokeRuntime") + .map((call) => call.args[0] as RuntimeInvokeRequest); + expect(requests.map((request) => new TextDecoder().decode(request.payload))).toEqual([ + "first\npayload", + "second payload", + ]); + expect( + requests.map(({ runtimeId, accountId, qualifier, contentType }) => ({ + runtimeId, + accountId, + qualifier, + contentType, + })), + ).toEqual([ + { + runtimeId: RUNTIME_ID, + accountId: "123456789012", + qualifier: QUALIFIER, + contentType: "application/json", + }, + { + runtimeId: RUNTIME_ID, + accountId: "123456789012", + qualifier: QUALIFIER, + contentType: "application/json", + }, + ]); + expect(core.runtime.calls.filter((call) => call.method === "getRuntime")).toHaveLength(1); + + const finalFrame = screen.lastFrame()!; + expect(finalFrame).toContain("first\npayload"); + expect(finalFrame).toContain(firstResponse); + expect(finalFrame).toContain("second payload"); + expect(finalFrame).toContain(secondResponse); + expect(finalFrame.match(/first\npayload/g)).toHaveLength(1); + expect(finalFrame.match(/data: \{"first":"€"\}/g)).toHaveLength(1); + }); +}); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx new file mode 100644 index 000000000..a24e895ff --- /dev/null +++ b/src/handlers/runtime/invoke/screen.tsx @@ -0,0 +1,196 @@ +import { useEffect, useRef, useState } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { FormTextArea } from "../../../components/FormTextArea"; +import { Layout } from "../../../components/Layout"; +import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; +import { RuntimePicker } from "../../../components/RuntimePicker"; +import { Divider } from "../../../components/ui/divider"; +import { Spinner } from "../../../components/ui/spinner"; +import { createInlineRuntimeInvokeRequest } from "./request"; + +interface Exchange { + id: number; + payload: string; + response: string; +} + +export function RuntimeInvokeScreen(props: ScreenProps) { + const { runtimeId, qualifier } = useParams(); + const navigate = useNavigate(); + + if (!runtimeId) { + return ( + navigate(`/agentcore/runtime/invoke/${encodeURIComponent(id)}`)} + /> + ); + } + + if (!qualifier) { + return ( + + navigate( + `/agentcore/runtime/invoke/${encodeURIComponent(runtimeId)}/${encodeURIComponent(selected)}`, + ) + } + /> + ); + } + + return ; +} + +interface RuntimeInvokeConsoleProps extends ScreenProps { + runtimeId: string; + qualifier: string; +} + +function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvokeConsoleProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const { rows } = useWindowSize(); + const detail = useQuery({ + queryKey: ["runtime", opts.region, runtimeId], + queryFn: () => core.runtime.getRuntime(runtimeId, opts), + }); + const [payload, setPayload] = useState(""); + const [history, setHistory] = useState([]); + const [streaming, setStreaming] = useState(false); + const aliveRef = useRef(true); + const streamingRef = useRef(false); + const nextIdRef = useRef(0); + const scrollRef = useRef(null); + + useEffect( + () => () => { + aliveRef.current = false; + }, + [], + ); + + useEffect(() => { + scrollRef.current?.scrollToBottom(); + }, [history]); + + const appendResponse = (id: number, text: string) => { + if (!aliveRef.current || text === "") return; + setHistory((current) => + current.map((exchange) => + exchange.id === id ? { ...exchange, response: exchange.response + text } : exchange, + ), + ); + }; + + const send = async () => { + if (streamingRef.current || !detail.data) return; + + const id = nextIdRef.current++; + const requestPayload = payload; + const request = createInlineRuntimeInvokeRequest( + runtimeId, + detail.data.agentRuntimeArn, + qualifier, + requestPayload, + ); + setPayload(""); + setHistory((current) => [...current, { id, payload: requestPayload, response: "" }]); + streamingRef.current = true; + setStreaming(true); + + try { + const response = await core.runtime.invokeRuntime(request, opts); + const decoder = new TextDecoder(); + for await (const chunk of response.body) { + if (!aliveRef.current) return; + appendResponse(id, decoder.decode(chunk, { stream: true })); + } + appendResponse(id, decoder.decode()); + } catch (error) { + appendResponse(id, `Error: ${error instanceof Error ? error.message : String(error)}`); + } finally { + streamingRef.current = false; + if (aliveRef.current) setStreaming(false); + } + }; + + useInput((input, key) => { + if (key.ctrl && input === "d") { + void send(); + return; + } + if (key.escape && !streamingRef.current) { + navigate(-1); + return; + } + if (key.upArrow) scrollRef.current?.scrollBy(-1); + if (key.downArrow) scrollRef.current?.scrollBy(1); + }); + + return ( + + {detail.isPending ? ( + + ) : detail.isError ? ( + Error: {(detail.error as Error).message} + ) : ( + + + + {history.map((exchange) => ( + + Request + {exchange.payload} + Response + {exchange.response} + + ))} + + + + + + + {streaming ? : idle} + + + )} + + ); +} diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 426a8d23c..a05cb4fef 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -457,6 +457,7 @@ export class TestRuntimeClient implements CoreRuntimeClient { private listVersionResponses = new Map(); private listEndpointResponses = new Map(); private invokeResponse: RuntimeInvokeResponse = DEFAULT_RUNTIME_INVOKE_RESPONSE; + private invokeBodies: AsyncIterable[] = []; private error?: Error; setGetResponse(response: GetAgentRuntimeResponse): this { @@ -497,6 +498,11 @@ export class TestRuntimeClient implements CoreRuntimeClient { return this; } + queueInvokeBody(body: AsyncIterable): this { + this.invokeBodies.push(body); + return this; + } + setError(error: Error | undefined): this { this.error = error; return this; @@ -515,7 +521,10 @@ export class TestRuntimeClient implements CoreRuntimeClient { ): Promise { this.calls.push({ method: "invokeRuntime", args: [request, options, signal] }); if (this.error) throw this.error; - return this.invokeResponse; + return { + ...this.invokeResponse, + body: this.invokeBodies.shift() ?? this.invokeResponse.body, + }; } async getRuntimeVersion( From 090ad0fb9393e4f194f4e2f1414fc7c549a5ebfb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 22:17:30 +0000 Subject: [PATCH 04/35] refactor(runtime): simplify invoke console tests --- src/handlers/runtime/invoke/index.tsx | 2 - .../runtime/invoke/invoke.screen.test.tsx | 95 +++---------------- src/handlers/runtime/invoke/invoke.test.tsx | 44 ++++++++- 3 files changed, 53 insertions(+), 88 deletions(-) diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index e69f7c5ed..4c583d790 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -44,5 +44,3 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => await writeRuntimeInvokeResponse(response, io.stdout); }, }); - -export { RuntimeInvokeScreen } from "./screen"; diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index b65b8d222..52bb2bb91 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -1,24 +1,17 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { Command } from "commander"; +import { afterEach, describe, expect, test } from "bun:test"; import type { AgentRuntime, AgentRuntimeEndpoint, GetAgentRuntimeResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { RuntimeInvokeRequest } from "../types"; -import type { AppIO } from "../../types"; -import { createRootHandler } from "../../index"; -import { compile, ValueContext } from "../../../router"; import { cleanupScreens, - createSilentLogger, renderScreen, TestCoreClient, - testIO, waitFor, waitForText, } from "../../../testing"; -import * as tui from "../../../tui"; const REGION = "us-east-1"; const RUNTIME_ID = "runtime-123"; @@ -57,31 +50,10 @@ function endpoint(overrides: Partial = {}): AgentRuntimeEn }; } -function body(...chunks: Uint8Array[]): AsyncIterable { - return (async function* () { - yield* chunks; - })(); -} - -function splitUtf8(text: string, splitAt: number): AsyncIterable { +async function* splitUtf8(text: string, splitAt: number): AsyncIterable { const bytes = new TextEncoder().encode(text); - return body(bytes.slice(0, splitAt), bytes.slice(splitAt)); -} - -function commandFor(core: TestCoreClient, io: AppIO): Command { - const root = createRootHandler(core, { io, logger: createSilentLogger() }); - const command = compile(root, ValueContext.EmptyContext()); - const override = (current: Command): void => { - current.exitOverride(); - current.configureOutput({ writeOut: () => {}, writeErr: () => {} }); - current.commands.forEach(override); - }; - override(command); - return command; -} - -async function runCommand(core: TestCoreClient, io: AppIO, args: string[]): Promise { - await commandFor(core, io).parseAsync(["node", "agentcore", ...args, "--region", REGION]); + yield bytes.slice(0, splitAt); + yield bytes.slice(splitAt); } describe("Runtime invoke routing", () => { @@ -123,55 +95,17 @@ describe("Runtime invoke routing", () => { ), ).toBe(true); }); - - test("handler deep-links id-only and qualified invokes with encoded path segments", async () => { - const core = new TestCoreClient(); - const { io } = testIO(); - const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); - - try { - await runCommand(core, io, ["runtime", "invoke", "--id", "runtime/blue one"]); - await runCommand(core, io, [ - "runtime", - "invoke", - "--id", - "runtime/blue one", - "--qualifier", - "prod/green one", - ]); - - expect(render.mock.calls.map(([path]) => path)).toEqual([ - "/agentcore/runtime/invoke/runtime%2Fblue%20one", - "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one", - ]); - } finally { - render.mockRestore(); - } - }); - - test("handler keeps JSON mode without a payload as a usage error", async () => { - const core = new TestCoreClient(); - const { io } = testIO(); - - await expect( - runCommand(core, io, ["runtime", "invoke", "--id", RUNTIME_ID, "--json"]), - ).rejects.toThrow(/--payload/); - expect(core.runtime.calls).toEqual([]); - }); }); describe("Runtime invoke console", () => { test("streams opaque UTF-8 and preserves both requests and responses across two sends", async () => { const firstResponse = 'data: {"first":"€"}\n\nnot-json'; const secondResponse = '{"second":true}\nraw: ✓'; - let releaseFirst!: () => void; - const firstGate = new Promise((resolve) => { - releaseFirst = resolve; - }); + const firstGate = Promise.withResolvers(); const firstBytes = new TextEncoder().encode(firstResponse); const firstBody = (async function* () { yield firstBytes.slice(0, 18); - await firstGate; + await firstGate.promise; yield firstBytes.slice(18); })(); const core = new TestCoreClient(); @@ -191,7 +125,7 @@ describe("Runtime invoke console", () => { await screen.write("\x04"); expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(1); - releaseFirst(); + firstGate.resolve(); await waitForText(screen.lastFrame, firstResponse); await waitForText(screen.lastFrame, "idle"); @@ -205,29 +139,22 @@ describe("Runtime invoke console", () => { const requests = core.runtime.calls .filter((call) => call.method === "invokeRuntime") .map((call) => call.args[0] as RuntimeInvokeRequest); - expect(requests.map((request) => new TextDecoder().decode(request.payload))).toEqual([ - "first\npayload", - "second payload", - ]); expect( - requests.map(({ runtimeId, accountId, qualifier, contentType }) => ({ + requests.map(({ runtimeId, qualifier, payload }) => ({ runtimeId, - accountId, qualifier, - contentType, + payload: new TextDecoder().decode(payload), })), ).toEqual([ { runtimeId: RUNTIME_ID, - accountId: "123456789012", qualifier: QUALIFIER, - contentType: "application/json", + payload: "first\npayload", }, { runtimeId: RUNTIME_ID, - accountId: "123456789012", qualifier: QUALIFIER, - contentType: "application/json", + payload: "second payload", }, ]); expect(core.runtime.calls.filter((call) => call.method === "getRuntime")).toHaveLength(1); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 0d777fe7b..e4b48d3bc 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { PassThrough } from "node:stream"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { Command } from "commander"; @@ -7,6 +7,7 @@ import type { RuntimeInvokeRequest, RuntimeInvokeResponse } from "../types"; import { createSilentLogger, TestCoreClient } from "../../../testing"; import { compile, ValueContext } from "../../../router"; import { createRootHandler } from "../../index"; +import * as tui from "../../../tui"; const REGION = "us-west-2"; const RUNTIME_ID = "runtime-123"; @@ -47,6 +48,10 @@ function commandFor(core: TestCoreClient, io: AppIO): Command { return command; } +async function runCommand(core: TestCoreClient, io: AppIO, args: string[]): Promise { + await commandFor(core, io).parseAsync(["node", "agentcore", ...args, "--region", REGION]); +} + async function run( args: string[], { @@ -63,7 +68,7 @@ async function run( core.runtime.setInvokeResponse(invokeResponse); const output = captureIO(); - await commandFor(core, output.io).parseAsync(["node", "agentcore", ...args, "--region", REGION]); + await runCommand(core, output.io, args); return { core, output }; } @@ -197,4 +202,39 @@ describe("runtime invoke", () => { ).rejects.toThrow("interactive mode requires a TTY on stdin and stdout"); expect(core.runtime.calls).toEqual([]); }); + + test("handler deep-links id-only and qualified invokes with encoded path segments", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); + + try { + await runCommand(core, output.io, ["runtime", "invoke", "--id", "runtime/blue one"]); + await runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + "runtime/blue one", + "--qualifier", + "prod/green one", + ]); + + expect(render.mock.calls.map(([path]) => path)).toEqual([ + "/agentcore/runtime/invoke/runtime%2Fblue%20one", + "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one", + ]); + } finally { + render.mockRestore(); + } + }); + + test("handler keeps JSON mode without a payload as a usage error", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + + await expect( + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--json"]), + ).rejects.toThrow(/--payload/); + expect(core.runtime.calls).toEqual([]); + }); }); From c96217b7e1f16f692ad51d2a91c592394f61ada7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 22:23:53 +0000 Subject: [PATCH 05/35] test(runtime): prove invoke history ownership --- src/handlers/runtime/invoke/invoke.screen.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 52bb2bb91..7b6a03b20 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -162,8 +162,7 @@ describe("Runtime invoke console", () => { const finalFrame = screen.lastFrame()!; expect(finalFrame).toContain("first\npayload"); expect(finalFrame).toContain(firstResponse); - expect(finalFrame).toContain("second payload"); - expect(finalFrame).toContain(secondResponse); + expect(finalFrame).toContain(`Request\nsecond payload\nResponse\n${secondResponse}`); expect(finalFrame.match(/first\npayload/g)).toHaveLength(1); expect(finalFrame.match(/data: \{"first":"€"\}/g)).toHaveLength(1); }); From e279d1d47cd6eea7095692937b428d65c44a4772 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 22:26:04 +0000 Subject: [PATCH 06/35] test(runtime): prove response is not duplicated --- src/handlers/runtime/invoke/invoke.screen.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 7b6a03b20..be482bdec 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -163,6 +163,9 @@ describe("Runtime invoke console", () => { expect(finalFrame).toContain("first\npayload"); expect(finalFrame).toContain(firstResponse); expect(finalFrame).toContain(`Request\nsecond payload\nResponse\n${secondResponse}`); + expect( + finalFrame.match(new RegExp(secondResponse.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")), + ).toHaveLength(1); expect(finalFrame.match(/first\npayload/g)).toHaveLength(1); expect(finalFrame.match(/data: \{"first":"€"\}/g)).toHaveLength(1); }); From d0f73cc4dd54c4a12b7c57c16a495278855c77b1 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 22:33:13 +0000 Subject: [PATCH 07/35] fix(runtime): handle invoke console navigation errors --- src/components/RuntimeEndpointPicker.tsx | 4 +- .../runtime/invoke/invoke.screen.test.tsx | 47 +++++++++++++++++++ src/handlers/runtime/invoke/screen.tsx | 15 +++--- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx index 509d5ca57..3025327ec 100644 --- a/src/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -27,6 +27,7 @@ export interface RuntimeEndpointPickerProps extends ScreenProps { breadcrumb: string[]; description?: string; onSelect: (qualifier: string) => void; + onEscape?: () => void; } export function RuntimeEndpointPicker({ @@ -36,10 +37,11 @@ export function RuntimeEndpointPicker({ breadcrumb, description, onSelect, + onEscape, }: RuntimeEndpointPickerProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); - const goBack = () => navigate(-1); + const goBack = onEscape ?? (() => navigate(-1)); return ( { ), ).toBe(true); }); + + test("esc from an initial endpoint picker returns to the Runtime picker", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }).setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "back-to-runtime-picker" })], + }); + const screen = renderScreen(`/agentcore/runtime/invoke/${RUNTIME_ID}`, { core }); + + await waitForText(screen.lastFrame, QUALIFIER); + await screen.press("escape"); + + await waitForText(screen.lastFrame, "back-to-runtime-picker"); + expect(screen.lastFrame()).toContain("agentcore → runtime → invoke"); + }); + + test("idle esc from an initial console returns to its endpoint picker", async () => { + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: "back-to-endpoint-picker", id: "back-endpoint" })], + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.press("escape"); + + await waitForText(screen.lastFrame, "back-to-endpoint-picker"); + expect(screen.lastFrame()).toContain(`agentcore → runtime → invoke → ${RUNTIME_ID}`); + }); }); describe("Runtime invoke console", () => { @@ -169,4 +199,21 @@ describe("Runtime invoke console", () => { expect(finalFrame.match(/first\npayload/g)).toHaveLength(1); expect(finalFrame.match(/data: \{"first":"€"\}/g)).toHaveLength(1); }); + + test("records request construction errors and returns to idle without invoking", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({} as GetAgentRuntimeResponse); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Enter an inline payload"); + await screen.write("{}"); + await screen.write("\x04"); + + await waitForText(screen.lastFrame, "Error: Runtime returned an invalid ARN"); + await waitForText(screen.lastFrame, "idle"); + expect(screen.lastFrame()).toContain( + "Request\n{}\nResponse\nError: Runtime returned an invalid ARN", + ); + expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(0); + }); }); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index a24e895ff..b48271fff 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -46,6 +46,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { `/agentcore/runtime/invoke/${encodeURIComponent(runtimeId)}/${encodeURIComponent(selected)}`, ) } + onEscape={() => navigate("/agentcore/runtime/invoke")} /> ); } @@ -99,18 +100,18 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const id = nextIdRef.current++; const requestPayload = payload; - const request = createInlineRuntimeInvokeRequest( - runtimeId, - detail.data.agentRuntimeArn, - qualifier, - requestPayload, - ); setPayload(""); setHistory((current) => [...current, { id, payload: requestPayload, response: "" }]); streamingRef.current = true; setStreaming(true); try { + const request = createInlineRuntimeInvokeRequest( + runtimeId, + detail.data.agentRuntimeArn, + qualifier, + requestPayload, + ); const response = await core.runtime.invokeRuntime(request, opts); const decoder = new TextDecoder(); for await (const chunk of response.body) { @@ -132,7 +133,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke return; } if (key.escape && !streamingRef.current) { - navigate(-1); + navigate(`/agentcore/runtime/invoke/${encodeURIComponent(runtimeId)}`); return; } if (key.upArrow) scrollRef.current?.scrollBy(-1); From 6ce02e7f549870b550c29920056656a34e8e5bd8 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 22:52:01 +0000 Subject: [PATCH 08/35] feat(runtime): add invoke request options and JWT --- bun.lock | 146 ++++++++++-- package.json | 2 +- src/core/core.test.ts | 209 +++++++++++++++++- src/core/index.tsx | 6 +- src/core/runtime.tsx | 86 ++++++- src/core/types.tsx | 1 + .../runtime/invoke/RequestOptionsScreen.tsx | 140 ++++++++++++ src/handlers/runtime/invoke/index.tsx | 67 +++++- .../runtime/invoke/invoke.screen.test.tsx | 90 ++++++++ src/handlers/runtime/invoke/invoke.test.tsx | 120 +++++++++- src/handlers/runtime/invoke/request.test.ts | 181 +++++++++++++++ src/handlers/runtime/invoke/request.ts | 169 +++++++++++++- src/handlers/runtime/invoke/screen.tsx | 109 ++++++--- src/handlers/runtime/types.tsx | 13 ++ 14 files changed, 1247 insertions(+), 92 deletions(-) create mode 100644 src/handlers/runtime/invoke/RequestOptionsScreen.tsx create mode 100644 src/handlers/runtime/invoke/request.test.ts diff --git a/bun.lock b/bun.lock index f001ff39c..840f1e68e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "agentcore", "dependencies": { - "@aws-sdk/client-bedrock-agentcore": "^3.1079.0", + "@aws-sdk/client-bedrock-agentcore": "^3.1092.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0", "@aws-sdk/client-iam": "^3.1080.0", "@inkui-cli/data-table": "^0.2.0", @@ -38,39 +38,39 @@ "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], - "@aws-sdk/client-bedrock-agentcore": ["@aws-sdk/client-bedrock-agentcore@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/credential-provider-node": "^3.972.68", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-qv1e7yjIxu4KCRNGkG+Ar/MLYGhyUWkGfjKoAm1H4/jD0viEC1PBklbHJm27HLxYtOcKWYA67k8S0kGb0iJxDA=="], + "@aws-sdk/client-bedrock-agentcore": ["@aws-sdk/client-bedrock-agentcore@3.1094.0", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/credential-provider-node": "^3.972.71", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/node-http-handler": "^4.9.6", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-K8+YaYFyaVJvidwVSnmXlHjpOel/qz4ii2TZY2shXbkMoQxftjHuiXlplKeb5pWhzdS092q6YqPR5CZrMH90Ug=="], "@aws-sdk/client-bedrock-agentcore-control": ["@aws-sdk/client-bedrock-agentcore-control@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/credential-provider-node": "^3.972.68", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ZCWITtXgDZG1g8NfTZVMTIwH/NR9eifXRW1g5+d4Jb93MmwbJWLWRSd0xqxgywMay0sqhNOKXHE4nQRIf9glag=="], "@aws-sdk/client-iam": ["@aws-sdk/client-iam@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/credential-provider-node": "^3.972.68", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-S2UBBQnPMTREF52WPz7yabvags3rPYLOPwq8LOPNJIekyAIqUwtGN46z2tqGNr+NyGz9cpEI+3brJgkWKQGNIQ=="], - "@aws-sdk/core": ["@aws-sdk/core@3.975.2", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@aws-sdk/xml-builder": "^3.972.35", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.3", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-iyeXwziyjJpixq5OmhsIyrSWx8vwcI7gDo4yRUC3EP7NQtOo9iAJiIEc3G+/HkhtNXqOhofiCK7Lc34Sq+fJWg=="], + "@aws-sdk/core": ["@aws-sdk/core@3.976.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.36", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.4", "@smithy/signature-v4": "^5.6.5", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.58", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-vyGtvK1rY940eq7JT0yIGKuZ+2kpPSJcHibSvGlit5oiMFDamzC7cxBGLl4FLnd6suihMXDI2FSF2dL6TmBqPA=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-g9b9YzDrD5pcKiPBJfCSXRfFMrA39eR0guUhZ5SRm+7vMAVc43+effxbcamxBjSd5bUhrdKo5te/yQuWurLXLA=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.62", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/node-http-handler": "^4.9.6", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.2", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/credential-provider-env": "^3.972.58", "@aws-sdk/credential-provider-http": "^3.972.60", "@aws-sdk/credential-provider-login": "^3.972.64", "@aws-sdk/credential-provider-process": "^3.972.58", "@aws-sdk/credential-provider-sso": "^3.973.2", "@aws-sdk/credential-provider-web-identity": "^3.972.64", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Yr7yxNyQ8aHt9Ww0RPFUZx+xiem+vl7vuwhP0tniTijoesJNV5jou9HCgVpI0GEPAF+89TkOvilE5uRrZJnjaw=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.5", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/credential-provider-env": "^3.972.60", "@aws-sdk/credential-provider-http": "^3.972.62", "@aws-sdk/credential-provider-login": "^3.972.67", "@aws-sdk/credential-provider-process": "^3.972.60", "@aws-sdk/credential-provider-sso": "^3.973.4", "@aws-sdk/credential-provider-web-identity": "^3.972.66", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.64", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YQoSI4d6kXvoenoG/0Jv/PqaAuukHzGmGXGyHBQYeEUNsYovlNAn/Sw1wp/WQbhcQ3HsEMGgjEahvD3igz6ecQ=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.67", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.68", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.58", "@aws-sdk/credential-provider-http": "^3.972.60", "@aws-sdk/credential-provider-ini": "^3.973.2", "@aws-sdk/credential-provider-process": "^3.972.58", "@aws-sdk/credential-provider-sso": "^3.973.2", "@aws-sdk/credential-provider-web-identity": "^3.972.64", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-4akjzW9CjorByYfqXBXmYUh/h7Io3U4DtVgGGh9TQraZ7ZlyJqNyHwDRGiUFnHD+BTOeTbCesCa4sJaK7BGZ7A=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.71", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.60", "@aws-sdk/credential-provider-http": "^3.972.62", "@aws-sdk/credential-provider-ini": "^3.973.5", "@aws-sdk/credential-provider-process": "^3.972.60", "@aws-sdk/credential-provider-sso": "^3.973.4", "@aws-sdk/credential-provider-web-identity": "^3.972.66", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.58", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-1nYitRCaDmXWUrpBJt6WlcGjLx1JVsMY8rlYuHHsTYTSaYikbixYdQSyINN2VYq1F798uTO9qHAzytL25M8g3A=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.2", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/token-providers": "3.1087.0", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pjMLaLU/JZi5lVfmR14V1OZqRBTuMHf6AwGNZA0K9hK+JKtO3jcLBarfD8iq5oc8cSowvc/9R32sqMVXZPo6xQ=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.4", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/token-providers": "3.1092.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.64", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-7Buc7p0OvDHW7iBsu4b+YdS0WnaFBDGKDfbVQqaac9dkWiSiUtIoarBDsA1RmOVXZijaZJDoHJFIQiicQvWRlQ=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.66", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.34", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/signature-v4-multi-region": "^3.996.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/node-http-handler": "^4.9.6", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA=="], - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.41", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-umM+qNq16f2fH+VLM5MqXW4ORNQAjk+TOSto73xbUHcKaU41L48j786r3UWQYlejeJk37NlvRYgxBT+MBkfaYQ=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1092.0", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ=="], - "@aws-sdk/types": ["@aws-sdk/types@3.974.1", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-W0IQZR0eaBqlBFIIofMapaWkw1W0U+Xi4dvW+BqwmCEMd8Ng2U6IhkxuPSjMVnR8klLjfuS9PeZWUl1N6UaZdg=="], + "@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.35", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pXzaWe3evZhjxDXAlMnqISe/XefTCGwBJG4nFTXaWSgAnMkqPEhxEPqJNhhpGesEvKFhvNpnozJJ4GTL11bRYw=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.36", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], @@ -118,15 +118,15 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], - "@smithy/core": ["@smithy/core@3.29.3", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A=="], + "@smithy/core": ["@smithy/core@3.29.8", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA=="], - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.8", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.13", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-X+2HNZhWi5i3rJsCas0LPf6fTQUaKyJ40zd8aTO/bwpRfpU3biYaqLr7C1WMibL7PVKJalpi1PyybjGPNoHC8Q=="], - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.5", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-SuqeisTyPoiIPtIYru/sGxGyXzmZ+8nnFOhC+qRPglt06Ebd1yH//CDltZB2J/3WBNVhwfUaZ0EtHB3cm2X32g=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.10", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-5/Yj9mS2JjTsB3B8ZX7euh77mrY9aXW23ag1yAmFykSRmA6vldqBrgqmSeQ50EjY+5SB8+aE4w14B6LKbBVEhQ=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.5", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.10", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ETQz9v/Z+nTQc6fRWTXxUpxJqwpmzB3Tn3WKAdHwWkeT+m+HE5czs6GNG8vW+4vyxXSls65RVcvOZwk7Q/PS/Q=="], - "@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + "@smithy/signature-v4": ["@smithy/signature-v4@5.6.9", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-g5rnEii/mkT0mjVJmlsaOfyNBtHNTecD9Lo4NP8D5HzMUEnZNpz7/FbvBCjNcV4vteHFAxOGiLUYNxPkDZZAPw=="], "@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], @@ -332,6 +332,30 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core": ["@aws-sdk/core@3.975.2", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@aws-sdk/xml-builder": "^3.972.35", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.3", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-iyeXwziyjJpixq5OmhsIyrSWx8vwcI7gDo4yRUC3EP7NQtOo9iAJiIEc3G+/HkhtNXqOhofiCK7Lc34Sq+fJWg=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.68", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.58", "@aws-sdk/credential-provider-http": "^3.972.60", "@aws-sdk/credential-provider-ini": "^3.973.2", "@aws-sdk/credential-provider-process": "^3.972.58", "@aws-sdk/credential-provider-sso": "^3.973.2", "@aws-sdk/credential-provider-web-identity": "^3.972.64", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-4akjzW9CjorByYfqXBXmYUh/h7Io3U4DtVgGGh9TQraZ7ZlyJqNyHwDRGiUFnHD+BTOeTbCesCa4sJaK7BGZ7A=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/types": ["@aws-sdk/types@3.974.1", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-W0IQZR0eaBqlBFIIofMapaWkw1W0U+Xi4dvW+BqwmCEMd8Ng2U6IhkxuPSjMVnR8klLjfuS9PeZWUl1N6UaZdg=="], + + "@aws-sdk/client-bedrock-agentcore-control/@smithy/core": ["@smithy/core@3.29.3", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A=="], + + "@aws-sdk/client-bedrock-agentcore-control/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.5", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-SuqeisTyPoiIPtIYru/sGxGyXzmZ+8nnFOhC+qRPglt06Ebd1yH//CDltZB2J/3WBNVhwfUaZ0EtHB3cm2X32g=="], + + "@aws-sdk/client-bedrock-agentcore-control/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.5", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw=="], + + "@aws-sdk/client-iam/@aws-sdk/core": ["@aws-sdk/core@3.975.2", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@aws-sdk/xml-builder": "^3.972.35", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.3", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-iyeXwziyjJpixq5OmhsIyrSWx8vwcI7gDo4yRUC3EP7NQtOo9iAJiIEc3G+/HkhtNXqOhofiCK7Lc34Sq+fJWg=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.68", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.58", "@aws-sdk/credential-provider-http": "^3.972.60", "@aws-sdk/credential-provider-ini": "^3.973.2", "@aws-sdk/credential-provider-process": "^3.972.58", "@aws-sdk/credential-provider-sso": "^3.973.2", "@aws-sdk/credential-provider-web-identity": "^3.972.64", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-4akjzW9CjorByYfqXBXmYUh/h7Io3U4DtVgGGh9TQraZ7ZlyJqNyHwDRGiUFnHD+BTOeTbCesCa4sJaK7BGZ7A=="], + + "@aws-sdk/client-iam/@aws-sdk/types": ["@aws-sdk/types@3.974.1", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-W0IQZR0eaBqlBFIIofMapaWkw1W0U+Xi4dvW+BqwmCEMd8Ng2U6IhkxuPSjMVnR8klLjfuS9PeZWUl1N6UaZdg=="], + + "@aws-sdk/client-iam/@smithy/core": ["@smithy/core@3.29.3", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A=="], + + "@aws-sdk/client-iam/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.5", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-SuqeisTyPoiIPtIYru/sGxGyXzmZ+8nnFOhC+qRPglt06Ebd1yH//CDltZB2J/3WBNVhwfUaZ0EtHB3cm2X32g=="], + + "@aws-sdk/client-iam/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.5", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw=="], + "listr2/cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -342,14 +366,94 @@ "react-devtools-core/ws": ["ws@7.5.12", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-1xGnbYN3zbog9CwuNDQULNRrTCLIn46/WmpR1f0w6PsCYQHkylZr5vkd6kfMZYV6pRnQkcPNRyiA8LsrNKyhpg=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.35", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pXzaWe3evZhjxDXAlMnqISe/XefTCGwBJG4nFTXaWSgAnMkqPEhxEPqJNhhpGesEvKFhvNpnozJJ4GTL11bRYw=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.58", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-vyGtvK1rY940eq7JT0yIGKuZ+2kpPSJcHibSvGlit5oiMFDamzC7cxBGLl4FLnd6suihMXDI2FSF2dL6TmBqPA=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-g9b9YzDrD5pcKiPBJfCSXRfFMrA39eR0guUhZ5SRm+7vMAVc43+effxbcamxBjSd5bUhrdKo5te/yQuWurLXLA=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.2", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/credential-provider-env": "^3.972.58", "@aws-sdk/credential-provider-http": "^3.972.60", "@aws-sdk/credential-provider-login": "^3.972.64", "@aws-sdk/credential-provider-process": "^3.972.58", "@aws-sdk/credential-provider-sso": "^3.973.2", "@aws-sdk/credential-provider-web-identity": "^3.972.64", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Yr7yxNyQ8aHt9Ww0RPFUZx+xiem+vl7vuwhP0tniTijoesJNV5jou9HCgVpI0GEPAF+89TkOvilE5uRrZJnjaw=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.58", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-1nYitRCaDmXWUrpBJt6WlcGjLx1JVsMY8rlYuHHsTYTSaYikbixYdQSyINN2VYq1F798uTO9qHAzytL25M8g3A=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.2", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/token-providers": "3.1087.0", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pjMLaLU/JZi5lVfmR14V1OZqRBTuMHf6AwGNZA0K9hK+JKtO3jcLBarfD8iq5oc8cSowvc/9R32sqMVXZPo6xQ=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.64", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-7Buc7p0OvDHW7iBsu4b+YdS0WnaFBDGKDfbVQqaac9dkWiSiUtIoarBDsA1RmOVXZijaZJDoHJFIQiicQvWRlQ=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.8", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA=="], + + "@aws-sdk/client-iam/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.35", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pXzaWe3evZhjxDXAlMnqISe/XefTCGwBJG4nFTXaWSgAnMkqPEhxEPqJNhhpGesEvKFhvNpnozJJ4GTL11bRYw=="], + + "@aws-sdk/client-iam/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.58", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-vyGtvK1rY940eq7JT0yIGKuZ+2kpPSJcHibSvGlit5oiMFDamzC7cxBGLl4FLnd6suihMXDI2FSF2dL6TmBqPA=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-g9b9YzDrD5pcKiPBJfCSXRfFMrA39eR0guUhZ5SRm+7vMAVc43+effxbcamxBjSd5bUhrdKo5te/yQuWurLXLA=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.2", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/credential-provider-env": "^3.972.58", "@aws-sdk/credential-provider-http": "^3.972.60", "@aws-sdk/credential-provider-login": "^3.972.64", "@aws-sdk/credential-provider-process": "^3.972.58", "@aws-sdk/credential-provider-sso": "^3.973.2", "@aws-sdk/credential-provider-web-identity": "^3.972.64", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Yr7yxNyQ8aHt9Ww0RPFUZx+xiem+vl7vuwhP0tniTijoesJNV5jou9HCgVpI0GEPAF+89TkOvilE5uRrZJnjaw=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.58", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-1nYitRCaDmXWUrpBJt6WlcGjLx1JVsMY8rlYuHHsTYTSaYikbixYdQSyINN2VYq1F798uTO9qHAzytL25M8g3A=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.2", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/token-providers": "3.1087.0", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pjMLaLU/JZi5lVfmR14V1OZqRBTuMHf6AwGNZA0K9hK+JKtO3jcLBarfD8iq5oc8cSowvc/9R32sqMVXZPo6xQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.64", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-7Buc7p0OvDHW7iBsu4b+YdS0WnaFBDGKDfbVQqaac9dkWiSiUtIoarBDsA1RmOVXZijaZJDoHJFIQiicQvWRlQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.8", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA=="], + "listr2/cli-truncate/slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.64", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YQoSI4d6kXvoenoG/0Jv/PqaAuukHzGmGXGyHBQYeEUNsYovlNAn/Sw1wp/WQbhcQ3HsEMGgjEahvD3igz6ecQ=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-umM+qNq16f2fH+VLM5MqXW4ORNQAjk+TOSto73xbUHcKaU41L48j786r3UWQYlejeJk37NlvRYgxBT+MBkfaYQ=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.64", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YQoSI4d6kXvoenoG/0Jv/PqaAuukHzGmGXGyHBQYeEUNsYovlNAn/Sw1wp/WQbhcQ3HsEMGgjEahvD3igz6ecQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-umM+qNq16f2fH+VLM5MqXW4ORNQAjk+TOSto73xbUHcKaU41L48j786r3UWQYlejeJk37NlvRYgxBT+MBkfaYQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + "log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], } } diff --git a/package.json b/package.json index 9a5e0d80e..629a7f835 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "typescript": "^5" }, "dependencies": { - "@aws-sdk/client-bedrock-agentcore": "^3.1079.0", + "@aws-sdk/client-bedrock-agentcore": "^3.1092.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0", "@aws-sdk/client-iam": "^3.1080.0", "@inkui-cli/data-table": "^0.2.0", diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 9b7f0211a..7ec92b336 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -207,8 +207,9 @@ test("invokeAgentRuntimeCommand sends the command on the data client with the ab expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); }); -test("invokeRuntime sends InvokeAgentRuntimeCommand and maps its native byte response", async () => { +test("invokeRuntime sends all modeled IAM fields and ordered application headers", async () => { const sent: { command: unknown; options: unknown }[] = []; + let applicationHeaders: [string, string][] = []; const body = (async function* () { yield Uint8Array.from([0, 1, 255]); })(); @@ -224,25 +225,53 @@ test("invokeRuntime sends InvokeAgentRuntimeCommand and maps its native byte res baggage: "baggage", response: body, }; - const core = new CoreClient( - fakeControl, - (config) => + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => ({ config, kind: "data", send: async (command: unknown, options: unknown) => { sent.push({ command, options }); - return sdkResponse; + const stack = (command as InvokeAgentRuntimeCommand).middlewareStack; + const handler = stack.resolve(async (args) => { + applicationHeaders = Object.entries( + (args.request as { headers: Record }).headers, + ); + return { output: sdkResponse as never, response: {} as never }; + }, {} as never); + return ( + await handler({ + input: (command as InvokeAgentRuntimeCommand).input, + request: { headers: {} }, + } as never) + ).output; }, }) as unknown as BedrockAgentCoreClient, - fakeIam, - ); + createIamClient: fakeIam, + logger: createSilentLogger(), + }); const request: RuntimeInvokeRequest = { runtimeId: "runtime-123", accountId: "123456789012", qualifier: "DEFAULT", payload: Uint8Array.from([123, 125]), contentType: "application/json", + accept: "text/event-stream", + runtimeSessionId: "runtime-session", + runtimeUserId: "runtime-user", + applicationHeaders: [ + ["X-One", "1"], + ["x-two", "2"], + ], + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + mcpMethod: "tools/call", + mcpName: "weather", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "tenant=retail", }; const controller = new AbortController(); @@ -260,8 +289,23 @@ test("invokeRuntime sends InvokeAgentRuntimeCommand and maps its native byte res qualifier: "DEFAULT", payload: Uint8Array.from([123, 125]), contentType: "application/json", + accept: "text/event-stream", + runtimeSessionId: "runtime-session", + runtimeUserId: "runtime-user", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + mcpMethod: "tools/call", + mcpName: "weather", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "tenant=retail", }); expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); + expect(applicationHeaders).toEqual([ + ["X-One", "1"], + ["x-two", "2"], + ]); expect(result).toEqual({ statusCode: 202, contentType: "application/octet-stream", @@ -276,17 +320,158 @@ test("invokeRuntime sends InvokeAgentRuntimeCommand and maps its native byte res }); }); +test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", async () => { + const calls: { input: string | URL | Request; init?: RequestInit }[] = []; + const controller = new AbortController(); + const fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + calls.push({ input, init }); + return new Response(Uint8Array.from([1, 2]), { + status: 200, + headers: { + "Content-Type": "application/json", + "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "returned-runtime", + "Mcp-Session-Id": "returned-mcp", + }, + }); + }; + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => + ({ + config: { + endpointProvider: () => ({ + url: new URL(config.endpoint ?? "https://runtime.test/base"), + }), + }, + send: async () => { + throw new Error("IAM transport must not be used"); + }, + }) as unknown as BedrockAgentCoreClient, + createIamClient: fakeIam, + fetch, + logger: createSilentLogger(), + }); + const payload = Uint8Array.from([123, 0, 125]); + + const result = await core.runtime.invokeRuntime( + { + runtimeId: "runtime/id", + accountId: "123456789012", + qualifier: "prod green", + payload, + contentType: "application/json", + accept: "text/event-stream", + runtimeSessionId: "runtime-session", + runtimeUserId: "runtime-user", + applicationHeaders: [["X-Tenant", "retail"]], + bearerToken: "secret-token", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + mcpMethod: "tools/call", + mcpName: "weather", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "tenant=retail", + }, + { region: "us-west-2", endpointUrl: "https://runtime.test/base" }, + controller.signal, + ); + + expect(calls).toHaveLength(1); + expect(String(calls[0]!.input)).toBe( + "https://runtime.test/runtimes/runtime%2Fid/invocations?accountId=123456789012&qualifier=prod+green", + ); + expect(calls[0]!.init).toMatchObject({ + method: "POST", + redirect: "error", + body: payload, + signal: controller.signal, + }); + expect(new Headers(calls[0]!.init!.headers)).toEqual( + new Headers({ + Accept: "text/event-stream", + Authorization: "Bearer secret-token", + "Content-Type": "application/json", + "Mcp-Method": "tools/call", + "Mcp-Name": "weather", + "Mcp-Protocol-Version": "2025-06-18", + "Mcp-Session-Id": "mcp-session", + "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "runtime-session", + "X-Amzn-Bedrock-AgentCore-Runtime-User-Id": "runtime-user", + "X-Amzn-Trace-Id": "trace-id", + "X-Tenant": "retail", + baggage: "tenant=retail", + traceparent: "trace-parent", + tracestate: "trace-state", + }), + ); + expect(result.runtimeSessionId).toBe("returned-runtime"); + expect(result.mcpSessionId).toBe("returned-mcp"); + expect(result.body).toBeInstanceOf(ReadableStream); +}); + +test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async () => { + let cancelled = 0; + let read = false; + const response = { + ok: false, + status: 401, + headers: new Headers(), + body: { + cancel: async () => { + cancelled++; + }, + async *[Symbol.asyncIterator]() { + read = true; + yield new TextEncoder().encode("secret response body"); + }, + }, + } as unknown as Response; + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: () => + ({ + config: { endpointProvider: () => ({ url: new URL("https://runtime.test") }) }, + }) as unknown as BedrockAgentCoreClient, + createIamClient: fakeIam, + fetch: async () => response, + logger: createSilentLogger(), + }); + const request: RuntimeInvokeRequest = { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new TextEncoder().encode("secret payload"), + contentType: "application/json", + bearerToken: "secret-token", + }; + + let message = ""; + try { + await core.runtime.invokeRuntime(request, { region: "us-east-1" }); + } catch (error) { + message = (error as Error).message; + } + expect(message).toBe("HTTP 401"); + expect(message).not.toContain("secret-token"); + expect(message).not.toContain("secret payload"); + expect(cancelled).toBe(1); + expect(read).toBe(false); +}); + test("invokeRuntime exposes an empty async iterable when the SDK omits the body", async () => { - const core = new CoreClient( - fakeControl, - (config) => + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => ({ config, kind: "data", send: async () => ({ statusCode: 204, contentType: "application/json" }), }) as unknown as BedrockAgentCoreClient, - fakeIam, - ); + createIamClient: fakeIam, + logger: createSilentLogger(), + }); const response = await core.runtime.invokeRuntime( { diff --git a/src/core/index.tsx b/src/core/index.tsx index b08d01a49..5429e7eb5 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -10,6 +10,7 @@ import type { CreateControlClient, CreateDataClient, CreateIamClient, + RuntimeFetch, } from "./types"; import type { Logger } from "../logging"; import type { ProjectManager } from "../handlers/project/types"; @@ -21,6 +22,7 @@ export type { CreateControlClient, CreateDataClient, CreateIamClient, + RuntimeFetch, } from "./types"; type CoreClientConfig = { @@ -28,6 +30,7 @@ type CoreClientConfig = { createDataClient: CreateDataClient; createIamClient: CreateIamClient; logger: Logger; + fetch?: RuntimeFetch; }; // CoreClient is the single entry point to the Bedrock AgentCore APIs. It owns the @@ -47,7 +50,7 @@ export class CoreClient implements AwsClients { // Feature-scoped sub-clients. Access as e.g. `coreClient.harness.getHarness(...)`. readonly harness: HarnessClient = new HarnessClient(this); readonly identity: IdentityClient = new IdentityClient(this); - readonly runtime: RuntimeClient = new RuntimeClient(this); + readonly runtime: RuntimeClient; readonly projectManager: ProjectManager; @@ -56,6 +59,7 @@ export class CoreClient implements AwsClients { this.createDataClient = config.createDataClient; this.createIamClient = config.createIamClient; this.logger = config.logger; + this.runtime = new RuntimeClient(this, config.fetch ?? globalThis.fetch); this.projectManager = new FsProjectManager({ logger: this.logger.child({ module: "projectManager" }), diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index b25b44336..83226f826 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -16,25 +16,97 @@ import type { RuntimeInvokeRequest, RuntimeInvokeResponse, } from "../handlers/runtime/types"; -import type { AwsClients, CoreOptions } from "./types"; +import type { AwsClients, CoreOptions, RuntimeFetch } from "./types"; import { toClientConfig } from "./utils"; async function* emptyBody(): AsyncGenerator {} export class RuntimeClient implements CoreRuntimeClient { - constructor(private readonly clients: AwsClients) {} + constructor( + private readonly clients: AwsClients, + private readonly fetch: RuntimeFetch, + ) {} async invokeRuntime( request: RuntimeInvokeRequest, options: CoreOptions, signal?: AbortSignal, ): Promise { - const { runtimeId, ...input } = request; - const response = await this.clients - .data(toClientConfig(options)) - .send(new InvokeAgentRuntimeCommand({ ...input, agentRuntimeArn: runtimeId }), { - abortSignal: signal, + const { runtimeId, applicationHeaders, bearerToken, ...input } = request; + if (bearerToken !== undefined) { + const client = this.clients.data(toClientConfig(options)); + const endpoint = client.config.endpointProvider({ + Region: options.region, + Endpoint: options.endpointUrl, }); + const url = new URL(endpoint.url); + url.pathname = `/runtimes/${encodeURIComponent(runtimeId)}/invocations`; + url.search = new URLSearchParams({ + accountId: request.accountId, + qualifier: request.qualifier, + }).toString(); + const headers = new Headers(applicationHeaders); + try { + headers.set("Authorization", `Bearer ${bearerToken}`); + } catch { + throw new TypeError("Invalid bearer token"); + } + for (const [name, value] of [ + ["Content-Type", request.contentType], + ["Accept", request.accept], + ["Mcp-Session-Id", request.mcpSessionId], + ["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", request.runtimeSessionId], + ["Mcp-Protocol-Version", request.mcpProtocolVersion], + ["Mcp-Method", request.mcpMethod], + ["Mcp-Name", request.mcpName], + ["X-Amzn-Bedrock-AgentCore-Runtime-User-Id", request.runtimeUserId], + ["X-Amzn-Trace-Id", request.traceId], + ["traceparent", request.traceParent], + ["tracestate", request.traceState], + ["baggage", request.baggage], + ] as const) { + if (value !== undefined) headers.set(name, value); + } + const response = await this.fetch(url, { + method: "POST", + redirect: "error", + headers, + body: request.payload as RequestInit["body"], + signal, + }); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new Error(`HTTP ${response.status}`); + } + return { + statusCode: response.status, + contentType: response.headers.get("content-type") ?? "", + runtimeSessionId: + response.headers.get("x-amzn-bedrock-agentcore-runtime-session-id") ?? undefined, + mcpSessionId: response.headers.get("mcp-session-id") ?? undefined, + mcpProtocolVersion: response.headers.get("mcp-protocol-version") ?? undefined, + traceId: response.headers.get("x-amzn-trace-id") ?? undefined, + traceParent: response.headers.get("traceparent") ?? undefined, + traceState: response.headers.get("tracestate") ?? undefined, + baggage: response.headers.get("baggage") ?? undefined, + body: (response.body as AsyncIterable | null) ?? emptyBody(), + }; + } + + const command = new InvokeAgentRuntimeCommand({ ...input, agentRuntimeArn: runtimeId }); + if (applicationHeaders?.length) { + command.middlewareStack.add( + (next) => async (args) => { + const sdkRequest = args.request as { headers: Record }; + for (const [name, value] of applicationHeaders) sdkRequest.headers[name] = value; + return next(args); + }, + { step: "build", name: "runtimeApplicationHeaders" }, + ); + } + const response = await this.clients.data(toClientConfig(options)).send(command, { + abortSignal: signal, + }); return { statusCode: response.statusCode ?? 0, diff --git a/src/core/types.tsx b/src/core/types.tsx index 517a0632c..d0df8531e 100644 --- a/src/core/types.tsx +++ b/src/core/types.tsx @@ -25,6 +25,7 @@ export interface ClientConfig { export type CreateControlClient = (config: ClientConfig) => BedrockAgentCoreControlClient; export type CreateDataClient = (config: ClientConfig) => BedrockAgentCoreClient; export type CreateIamClient = (config: ClientConfig) => IAMClient; +export type RuntimeFetch = (input: string | URL | Request, init?: RequestInit) => Promise; // AwsClients hands out configured SDK clients. CoreClient implements it and its // sub-clients (HarnessClient, etc.) consume it, so they all share the same diff --git a/src/handlers/runtime/invoke/RequestOptionsScreen.tsx b/src/handlers/runtime/invoke/RequestOptionsScreen.tsx new file mode 100644 index 000000000..d2efbfba2 --- /dev/null +++ b/src/handlers/runtime/invoke/RequestOptionsScreen.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { FormTextArea } from "../../../components/FormTextArea"; +import { Select } from "../../../components/ui/select"; +import { TextInput } from "../../../components/ui/text-input"; + +export interface RuntimeInvokeOptions { + payloadSource: "Inline" | "File"; + responseDestination: "Console" | "File"; + payloadPath?: string; + contentType?: string; + accept?: string; + outputPath?: string; + runtimeSessionId?: string; + runtimeUserId?: string; + headers?: string; + bearerToken?: string; + mcpSessionId?: string; + mcpProtocolVersion?: string; + mcpMethod?: string; + mcpName?: string; + traceId?: string; + traceParent?: string; + traceState?: string; + baggage?: string; +} + +export const defaultRuntimeInvokeOptions: RuntimeInvokeOptions = { + payloadSource: "Inline", + responseDestination: "Console", + contentType: "application/json", +}; + +type Row = { + field: keyof RuntimeInvokeOptions; + label: string; + choices?: string[]; + multiline?: boolean; + secret?: boolean; +}; + +export function RequestOptionsScreen({ + value, + onChange, + onClose, + customJwt, + mcp, +}: { + value: RuntimeInvokeOptions; + onChange: (value: RuntimeInvokeOptions) => void; + onClose: () => void; + customJwt: boolean; + mcp: boolean; +}) { + const rows = [ + { field: "payloadSource", label: "Payload source", choices: ["Inline", "File"] }, + ...(value.payloadSource === "File" + ? [{ field: "payloadPath", label: "Payload path" } as Row] + : []), + { field: "contentType", label: "Content type" }, + { field: "accept", label: "Accepted response" }, + { + field: "responseDestination", + label: "Response destination", + choices: ["Console", "File"], + }, + ...(value.responseDestination === "File" + ? [{ field: "outputPath", label: "Response path" } as Row] + : []), + { field: "runtimeSessionId", label: "Runtime session ID" }, + { field: "runtimeUserId", label: "Runtime user ID" }, + { field: "headers", label: "Application headers", multiline: true }, + ...(customJwt ? [{ field: "bearerToken", label: "Bearer JWT", secret: true } as Row] : []), + ...(mcp + ? [ + { field: "mcpSessionId", label: "MCP session ID" }, + { field: "mcpProtocolVersion", label: "MCP protocol version" }, + { field: "mcpMethod", label: "MCP method" }, + { field: "mcpName", label: "MCP name" }, + ] + : []), + { field: "traceId", label: "Trace ID" }, + { field: "traceParent", label: "Traceparent" }, + { field: "traceState", label: "Tracestate" }, + { field: "baggage", label: "Baggage" }, + ] as Row[]; + const [selected, setSelected] = useState(0); + const [editing, setEditing] = useState(); + const row = rows[Math.min(selected, rows.length - 1)]!; + const update = (field: keyof RuntimeInvokeOptions, next: string) => + onChange({ ...value, [field]: next }); + + useInput((_input, key) => { + if (key.escape) return onClose(); + if (editing) return; + if (key.upArrow) setSelected((current) => Math.max(0, current - 1)); + if (key.downArrow) setSelected((current) => Math.min(rows.length - 1, current + 1)); + if (key.return) setEditing(row.field); + }); + + const fieldValue = value[row.field] ?? ""; + return ( + + Request Options + {rows.map((item, index) => ( + + {index === selected ? "› " : " "} + {item.label}:{" "} + {item.secret && value[item.field] + ? "*".repeat(value[item.field]!.length) + : value[item.field]} + + ))} + {editing === row.field && row.choices ? ( + ({ label: choice, value: choice }))} onSelect={(item) => { - update(row.field, item.value); - setEditing(undefined); + if (item.value === "Custom") { + update(row.field, ""); + setCustom(true); + } else { + update(row.field, item.value); + setEditing(undefined); + } }} /> ) : editing === row.field && row.multiline ? ( diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index f845d3e67..8c28f0139 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -160,6 +160,12 @@ describe("Runtime invoke console", () => { .split("\n") .every((line) => !(line.includes("Content type") && line.includes("Runtime user ID"))), ).toBe(true); + if (!conditional) { + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "application/octet-stream"); + expect(screen.lastFrame()).toContain("text/plain"); + } }); test("edited file options reach invoke and returned sessions are reused", async () => { @@ -171,6 +177,7 @@ describe("Runtime invoke console", () => { .setGetResponse({ agentRuntimeArn: RUNTIME_ARN, protocolConfiguration: { serverProtocol: "MCP" }, + authorizerConfiguration: { customJWTAuthorizer: {} }, } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, @@ -195,6 +202,11 @@ describe("Runtime invoke console", () => { await screen.press("return"); await screen.write("user-1"); await screen.press("return"); + await screen.press("down"); + await screen.press("down"); + await screen.press("return"); + await screen.write("file://literal-token"); + await screen.press("return"); await screen.press("escape"); await waitForText(screen.lastFrame, "idle"); @@ -211,7 +223,16 @@ describe("Runtime invoke console", () => { const requests = core.runtime.calls .filter((call) => call.method === "invokeRuntime") .map((call) => call.args[0] as RuntimeInvokeRequest); - expect(requests[0]).toMatchObject({ payload: bytes, runtimeUserId: "user-1" }); + expect(requests[0]).toMatchObject({ + payload: bytes, + runtimeUserId: "user-1", + bearerToken: "file://literal-token", + }); + expect( + ["payloadSource", "responseDestination", "payloadPath", "outputPath"].filter( + (key) => key in requests[0]!, + ), + ).toEqual([]); expect(requests[1]).toMatchObject({ runtimeSessionId: "returned-runtime", mcpSessionId: "returned-mcp", diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index ddbd43dce..a5ab1a7bf 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -153,7 +153,7 @@ export function normalizeRuntimeInvokeRequest( qualifier: qualifier ?? "DEFAULT", payload, contentType: contentType || "application/json", - ...Object.fromEntries(Object.entries(modeled).filter(([, value]) => value !== undefined)), + ...modeled, ...(headers?.length && { applicationHeaders: parseHeaders(detail, headers) }), }; } diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 521303279..2f8fd71d5 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -109,27 +109,28 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke if (streamingRef.current || !detail.data) return; const id = nextIdRef.current++; - const requestPayload = - requestOptions.payloadSource === "File" - ? `file://${requestOptions.payloadPath ?? ""}` - : payload; + const { + payloadSource, + payloadPath, + responseDestination: _responseDestination, + outputPath: _outputPath, + ...modeled + } = requestOptions; + const requestPayload = payloadSource === "File" ? `file://${payloadPath ?? ""}` : payload; setPayload(""); setHistory((current) => [...current, { id, payload: requestPayload, response: "" }]); streamingRef.current = true; setStreaming(true); try { - const sources = await resolveRuntimeInvokeSources({ - payload: requestPayload, - bearerToken: customJwt ? requestOptions.bearerToken : undefined, - }); + const sources = await resolveRuntimeInvokeSources({ payload: requestPayload }); const request = normalizeRuntimeInvokeRequest(detail.data, { - ...requestOptions, + ...modeled, runtimeId, qualifier, payload: sources.payload, - headers: requestOptions.headers?.split("\n").filter(Boolean), - bearerToken: sources.bearerToken, + headers: modeled.headers?.split("\n").filter(Boolean), + bearerToken: customJwt ? modeled.bearerToken : undefined, }); const response = await core.runtime.invokeRuntime(request, opts); setRequestOptions((current) => ({ From a0c30cc952f0688a80d423b26ad7d7b0c1e5855e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 23:30:32 +0000 Subject: [PATCH 11/35] fix(runtime): protect invoke secrets and endpoint paths --- src/core/core.test.ts | 2 +- src/core/runtime.tsx | 2 +- src/handlers/runtime/invoke/index.tsx | 11 +++++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 753980010..0d359a365 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -380,7 +380,7 @@ test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", as expect(calls).toHaveLength(1); expect(String(calls[0]!.input)).toBe( - "https://runtime.test/runtimes/runtime%2Fid/invocations?accountId=123456789012&qualifier=prod+green", + "https://runtime.test/base/runtimes/runtime%2Fid/invocations?accountId=123456789012&qualifier=prod+green", ); expect(calls[0]!.init).toMatchObject({ method: "POST", diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index 83226f826..641032ca9 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -40,7 +40,7 @@ export class RuntimeClient implements CoreRuntimeClient { Endpoint: options.endpointUrl, }); const url = new URL(endpoint.url); - url.pathname = `/runtimes/${encodeURIComponent(runtimeId)}/invocations`; + url.pathname = `${url.pathname.replace(/\/?$/, "/")}runtimes/${encodeURIComponent(runtimeId)}/invocations`; url.search = new URLSearchParams({ accountId: request.accountId, qualifier: request.qualifier, diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index ab6dba955..b29a7f879 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -17,7 +17,9 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => description: "invoke a Runtime", flags: [ flag("id", "the ID of the Runtime", runtimeIdSchema.optional()), - flag("payload", "the inline payload to send", z.string().optional()), + flag("payload", "the inline payload to send", z.string().optional(), { + sensitive: true, + }), flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), flag("content-type", "the payload content type", z.string().optional()), flag("accept", "the accepted response content type", z.string().optional()), @@ -27,10 +29,11 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => "header", "an ordered application header", z.array(z.string()).optional(), - undefined, - "H", + { sensitive: true, short: "H" }, ), - flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional()), + flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { + sensitive: true, + }), flag("mcp-session-id", "the MCP session ID", z.string().optional()), flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), flag("mcp-method", "the MCP method", z.string().optional()), From 752b4eb5a787750dec1438bef4720ca5b9021391 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 23:49:45 +0000 Subject: [PATCH 12/35] feat(runtime): add native invoke output --- src/handlers/runtime/invoke/index.tsx | 7 +- .../runtime/invoke/invoke.screen.test.tsx | 160 ++++++++++++++++- src/handlers/runtime/invoke/invoke.test.tsx | 47 ++++- src/handlers/runtime/invoke/response.test.ts | 162 ++++++++++++++++++ src/handlers/runtime/invoke/response.ts | 104 ++++++++++- src/handlers/runtime/invoke/screen.tsx | 118 +++++++++---- 6 files changed, 564 insertions(+), 34 deletions(-) create mode 100644 src/handlers/runtime/invoke/response.test.ts diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index b29a7f879..e52bf13a0 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -93,6 +93,11 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => baggage: flags.baggage, }); const response = await core.runtime.invokeRuntime(request, options); - await writeRuntimeInvokeResponse(response, io.stdout); + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + }); }, }); diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 8c28f0139..97f76665d 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import type { @@ -58,6 +59,12 @@ async function* splitUtf8(text: string, splitAt: number): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} + describe("Runtime invoke routing", () => { test("selects a Runtime and endpoint before opening one console", async () => { const runtimeId = "runtime/blue one"; @@ -239,7 +246,7 @@ describe("Runtime invoke console", () => { }); }); - test("streams opaque UTF-8 and preserves both requests and responses across two sends", async () => { + test("preserves raw SSE text and both requests across two sends", async () => { const firstResponse = 'data: {"first":"€"}\n\nnot-json'; const secondResponse = '{"second":true}\nraw: ✓'; const firstGate = Promise.withResolvers(); @@ -252,6 +259,11 @@ describe("Runtime invoke console", () => { const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/event-stream", + body: firstBody, + }) .queueInvokeBody(firstBody) .queueInvokeBody(splitUtf8(secondResponse, 23)); const screen = renderScreen(CONSOLE_PATH, { core }); @@ -311,6 +323,152 @@ describe("Runtime invoke console", () => { expect(finalFrame.match(/data: \{"first":"€"\}/g)).toHaveLength(1); }); + test("toggles a completed valid JSON response between raw and pretty text with Ctrl+V", async () => { + const raw = '{"z":1,"nested":{"ok":true}}'; + const pretty = JSON.stringify(JSON.parse(raw), null, 2); + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "application/problem+json", + body: responseBody(Buffer.from(raw)), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x04"); + await waitForText(screen.lastFrame, raw); + await waitForText(screen.lastFrame, "idle"); + + await screen.write("\x16"); + await waitForText(screen.lastFrame, pretty); + await screen.write("\x16"); + await waitForText(screen.lastFrame, raw); + }); + + test("keeps invalid completed JSON raw, notes the presentation error, and returns idle", async () => { + const raw = '{"broken":'; + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "application/json", + body: responseBody(Buffer.from(raw)), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x04"); + + await waitForText(screen.lastFrame, raw); + await waitForText(screen.lastFrame, "Invalid JSON response; showing raw text."); + await waitForText(screen.lastFrame, "idle"); + }); + + test("keeps invalid UTF-8 bytes and shows a presentation error after completion", async () => { + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from([0x66, 0x80])), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x04"); + + await waitForText(screen.lastFrame, "f�"); + await waitForText(screen.lastFrame, "Invalid UTF-8 response; showing raw text."); + await waitForText(screen.lastFrame, "idle"); + }); + + test("does not consume a binary Console response", async () => { + let iterations = 0; + const source = (async function* () { + iterations++; + yield Buffer.from([0, 255]); + })(); + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "application/octet-stream", + body: source, + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x04"); + + await waitForText( + screen.lastFrame, + "Binary or unknown response content requires File destination.", + ); + await waitForText(screen.lastFrame, "idle"); + expect(iterations).toBe(0); + }); + + test("writes a binary File response exactly and records its byte count", async () => { + const file = join(tmpdir(), `runtime-binary-response-${process.pid}`); + const bytes = Buffer.from([0, 255, 10, 1]); + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "application/octet-stream", + body: responseBody(bytes.slice(0, 2), bytes.slice(2)), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + try { + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request Options"); + for (let index = 0; index < 3; index++) await screen.press("down"); + await screen.press("return"); + await screen.press("down"); + await screen.press("return"); + await screen.press("down"); + await screen.press("return"); + await screen.write(file); + await screen.press("return"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "idle"); + + await screen.write("\x04"); + + await waitForText(screen.lastFrame, `Saved 4 bytes to ${file}`); + expect(Buffer.from(await Bun.file(file).bytes())).toEqual(bytes); + } finally { + await rm(file, { force: true }); + } + }); + + test("requires a File response path before invoking", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request Options"); + for (let index = 0; index < 3; index++) await screen.press("down"); + await screen.press("return"); + await screen.press("down"); + await screen.press("return"); + await screen.press("escape"); + await screen.write("\x04"); + + await waitForText(screen.lastFrame, "Response path is required for File destination."); + expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(0); + }); + test("records request construction errors and returns to idle without invoking", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse({} as GetAgentRuntimeResponse); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index b9f9c32bc..f8aa89d27 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, spyOn, test } from "bun:test"; +import { rm } from "node:fs/promises"; import { PassThrough } from "node:stream"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -63,7 +64,7 @@ async function run( statusCode: 200, contentType: "application/json", body: body(Uint8Array.from([0, 255]), Uint8Array.from([10, 1])), - } satisfies RuntimeInvokeResponse, + } as RuntimeInvokeResponse, stdin = undefined as Uint8Array | undefined, } = {}, ) { @@ -106,6 +107,50 @@ describe("runtime invoke", () => { expect(output.bytes()).toEqual(Buffer.from([0, 255, 10, 1, 127])); }); + test("wires --output-file to exact file output without writing stdout", async () => { + const file = join(tmpdir(), `agentcore-invoke-output-${process.pid}`); + try { + const { output } = await run( + ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}", "--output-file", file], + { + invokeResponse: { + statusCode: 200, + contentType: "application/octet-stream", + body: body(Buffer.from([0, 255]), Buffer.from([10, 1])), + }, + }, + ); + + expect(Buffer.from(await Bun.file(file).bytes())).toEqual(Buffer.from([0, 255, 10, 1])); + expect(output.bytes()).toHaveLength(0); + } finally { + await rm(file, { force: true }); + } + }); + + test("wires global --json to one response envelope", async () => { + const { output } = await run( + ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}", "--json"], + { + invokeResponse: { + statusCode: 202, + contentType: "text/plain", + runtimeSessionId: "returned-session", + body: body(Buffer.from("accepted")), + }, + }, + ); + + expect(JSON.parse(output.bytes().toString())).toEqual({ + statusCode: 202, + contentType: "text/plain", + runtimeSessionId: "returned-session", + bodyEncoding: "utf8", + body: "accepted", + complete: true, + }); + }); + test("passes an explicit qualifier", async () => { const { core } = await run([ "runtime", diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts new file mode 100644 index 000000000..5ac4d43a4 --- /dev/null +++ b/src/handlers/runtime/invoke/response.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import type { RuntimeInvokeResponse } from "../types"; +import { writeRuntimeInvokeResponse } from "./response"; + +const files: string[] = []; + +afterEach(async () => { + await Promise.all(files.splice(0).map((file) => rm(file, { force: true }))); +}); + +function body(...chunks: Uint8Array[]): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} + +function response(overrides: Partial = {}): RuntimeInvokeResponse { + return { + statusCode: 200, + contentType: "text/plain", + body: body(Buffer.from("ok")), + ...overrides, + }; +} + +function capture() { + const stream = new PassThrough(); + const chunks: Buffer[] = []; + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + return { + stream: stream as unknown as NodeJS.WriteStream, + bytes: () => Buffer.concat(chunks), + }; +} + +describe("Runtime invoke response output", () => { + test("streams exact chunks to stdout, reports metadata, and leaves stdout open", async () => { + const stdout = capture(); + const stderr = capture(); + const result = response({ + statusCode: 206, + runtimeSessionId: "runtime-session", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "tenant=retail", + body: body(Buffer.from([0, 255]), Buffer.from([10, 1])), + }); + + await writeRuntimeInvokeResponse(result, { + stdout: stdout.stream, + stderr: stderr.stream, + }); + + expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); + expect(stderr.bytes().toString()).toBe( + "status=206 content-type=text/plain runtime-session-id=runtime-session " + + "mcp-session-id=mcp-session mcp-protocol-version=2025-06-18 trace-id=trace-id " + + "trace-parent=trace-parent trace-state=trace-state baggage=tenant=retail " + + "complete=true bytes=4\n", + ); + expect(stdout.stream.writableEnded).toBe(false); + stdout.stream.write(Buffer.from([127])); + expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1, 127])); + }); + + test("streams exact bytes to a file and leaves stdout empty", async () => { + const file = join(tmpdir(), `runtime-invoke-output-${process.pid}-${files.length}`); + files.push(file); + const stdout = capture(); + const stderr = capture(); + + await writeRuntimeInvokeResponse( + response({ body: body(Buffer.from([0, 255]), Buffer.from([10, 1])) }), + { + stdout: stdout.stream, + stderr: stderr.stream, + outputFile: file, + }, + ); + + expect(Buffer.from(await Bun.file(file).bytes())).toEqual(Buffer.from([0, 255, 10, 1])); + expect(stdout.bytes()).toHaveLength(0); + }); + + test("JSON mode emits one textual response envelope and no stderr", async () => { + const stdout = capture(); + const stderr = capture(); + const result = response({ + statusCode: 201, + contentType: "application/problem+json", + runtimeSessionId: "runtime-session", + body: body(Buffer.from('{"message":'), Buffer.from('"created"}')), + }); + + await writeRuntimeInvokeResponse(result, { + stdout: stdout.stream, + stderr: stderr.stream, + json: true, + }); + + expect(stdout.bytes().toString()).toBe( + JSON.stringify({ + statusCode: 201, + contentType: "application/problem+json", + runtimeSessionId: "runtime-session", + bodyEncoding: "utf8", + body: '{"message":"created"}', + complete: true, + }), + ); + expect(stderr.bytes()).toHaveLength(0); + }); + + test.each([ + ["binary content", "application/octet-stream", Buffer.from([0, 255, 1])], + ["invalid UTF-8 text", "text/plain", Buffer.from([0xc3, 0x28])], + ])("JSON mode base64-encodes %s", async (_name, contentType, bytes) => { + const stdout = capture(); + const stderr = capture(); + + await writeRuntimeInvokeResponse(response({ contentType, body: body(bytes) }), { + stdout: stdout.stream, + stderr: stderr.stream, + json: true, + }); + + expect(JSON.parse(stdout.bytes().toString())).toMatchObject({ + contentType, + bodyEncoding: "base64", + body: bytes.toString("base64"), + complete: true, + }); + expect(stderr.bytes()).toHaveLength(0); + }); + + test("refuses unknown content on a TTY before iterating the body", async () => { + let iterations = 0; + const stdout = capture(); + const stderr = capture(); + Object.defineProperty(stdout.stream, "isTTY", { value: true }); + const source = (async function* () { + iterations++; + yield Buffer.from([0]); + })(); + + await expect( + writeRuntimeInvokeResponse(response({ contentType: "", body: source }), { + stdout: stdout.stream, + stderr: stderr.stream, + }), + ).rejects.toThrow("Binary or unknown response content requires --output-file or --json"); + expect(iterations).toBe(0); + expect(stdout.bytes()).toHaveLength(0); + }); +}); diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 1821ce08d..a69117aaa 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -1,9 +1,109 @@ +import { createWriteStream } from "node:fs"; import { pipeline } from "node:stream/promises"; import type { RuntimeInvokeResponse } from "../types"; -export async function writeRuntimeInvokeResponse( +type RuntimeResponseKind = "json" | "text" | "binary"; + +interface RuntimeInvokeOutput { + stdout: NodeJS.WriteStream; + stderr: NodeJS.WriteStream; + outputFile?: string; + json?: boolean; +} + +export function classifyRuntimeResponse(contentType: string): RuntimeResponseKind { + const mediaType = contentType.split(";", 1)[0]!.trim().toLowerCase(); + if (mediaType === "application/json" || /^application\/[^/]+\+json$/.test(mediaType)) { + return "json"; + } + return mediaType.startsWith("text/") ? "text" : "binary"; +} + +async function* countBytes( + body: AsyncIterable, + add: (size: number) => void, +): AsyncGenerator { + for await (const chunk of body) { + add(chunk.byteLength); + yield chunk; + } +} + +export async function writeRuntimeInvokeFile( + response: RuntimeInvokeResponse, + path: string, +): Promise { + let byteCount = 0; + await pipeline( + countBytes(response.body, (size) => (byteCount += size)), + createWriteStream(path), + ); + return byteCount; +} + +async function writeJsonResponse( response: RuntimeInvokeResponse, stdout: NodeJS.WriteStream, ): Promise { - await pipeline(response.body, stdout, { end: false }); + const chunks: Uint8Array[] = []; + for await (const chunk of response.body) chunks.push(chunk); + const bytes = Buffer.concat(chunks); + const { body: _body, ...responseMetadata } = response; + let bodyEncoding: "utf8" | "base64" = "base64"; + let body = bytes.toString("base64"); + + if (classifyRuntimeResponse(response.contentType) !== "binary") { + try { + body = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + bodyEncoding = "utf8"; + } catch {} + } + + stdout.write( + JSON.stringify({ + ...responseMetadata, + bodyEncoding, + body, + complete: true, + }), + ); +} + +function successSummary(response: RuntimeInvokeResponse, byteCount: number): string { + const value = (item?: string) => item || "-"; + return ( + `status=${response.statusCode} content-type=${value(response.contentType)} ` + + `runtime-session-id=${value(response.runtimeSessionId)} ` + + `mcp-session-id=${value(response.mcpSessionId)} ` + + `mcp-protocol-version=${value(response.mcpProtocolVersion)} ` + + `trace-id=${value(response.traceId)} trace-parent=${value(response.traceParent)} ` + + `trace-state=${value(response.traceState)} baggage=${value(response.baggage)} ` + + `complete=true bytes=${byteCount}\n` + ); +} + +export async function writeRuntimeInvokeResponse( + response: RuntimeInvokeResponse, + output: RuntimeInvokeOutput, +): Promise { + if (output.json) { + await writeJsonResponse(response, output.stdout); + return; + } + + let byteCount: number; + if (output.outputFile) { + byteCount = await writeRuntimeInvokeFile(response, output.outputFile); + } else { + if (output.stdout.isTTY && classifyRuntimeResponse(response.contentType) === "binary") { + throw new TypeError("Binary or unknown response content requires --output-file or --json"); + } + byteCount = 0; + await pipeline( + countBytes(response.body, (size) => (byteCount += size)), + output.stdout, + { end: false }, + ); + } + output.stderr.write(successSummary(response, byteCount)); } diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 2f8fd71d5..0c69b95c6 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -13,13 +13,23 @@ import { Divider } from "../../../components/ui/divider"; import { Spinner } from "../../../components/ui/spinner"; import { normalizeRuntimeInvokeRequest, resolveRuntimeInvokeSources } from "./request"; import { RequestOptionsScreen, type RuntimeInvokeOptions } from "./RequestOptionsScreen"; +import { classifyRuntimeResponse, writeRuntimeInvokeFile } from "./response"; interface Exchange { id: number; payload: string; + chunks: Uint8Array[]; response: string; + pretty?: string; + note?: string; } +const invokePath = (...parts: string[]) => + ["/agentcore/runtime/invoke", ...parts.map(encodeURIComponent)].join("/"); +const INVALID_UTF8 = "Invalid UTF-8 response; showing raw text."; +const INVALID_JSON = "Invalid JSON response; showing raw text."; +const BINARY_RESPONSE = "Binary or unknown response content requires File destination."; + export function RuntimeInvokeScreen(props: ScreenProps) { const { runtimeId, qualifier } = useParams(); const navigate = useNavigate(); @@ -30,7 +40,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { {...props} breadcrumb={["agentcore", "runtime", "invoke"]} description="choose a Runtime to invoke" - onSelect={(id) => navigate(`/agentcore/runtime/invoke/${encodeURIComponent(id)}`)} + onSelect={(id) => navigate(invokePath(id))} /> ); } @@ -42,12 +52,8 @@ export function RuntimeInvokeScreen(props: ScreenProps) { runtimeId={runtimeId} breadcrumb={["agentcore", "runtime", "invoke", runtimeId]} description="choose an endpoint to invoke" - onSelect={(selected) => - navigate( - `/agentcore/runtime/invoke/${encodeURIComponent(runtimeId)}/${encodeURIComponent(selected)}`, - ) - } - onEscape={() => navigate("/agentcore/runtime/invoke")} + onSelect={(selected) => navigate(invokePath(runtimeId, selected))} + onEscape={() => navigate(invokePath())} /> ); } @@ -55,10 +61,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { return ; } -interface RuntimeInvokeConsoleProps extends ScreenProps { - runtimeId: string; - qualifier: string; -} +type RuntimeInvokeConsoleProps = ScreenProps & { runtimeId: string; qualifier: string }; function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvokeConsoleProps) { const opts = coreOptsFromCtx(ctx); @@ -79,6 +82,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke }); const [showOptions, setShowOptions] = useState(false); const [history, setHistory] = useState([]); + const [prettyJson, setPrettyJson] = useState(false); const [streaming, setStreaming] = useState(false); const aliveRef = useRef(true); const streamingRef = useRef(false); @@ -96,29 +100,42 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke scrollRef.current?.scrollToBottom(); }, [history]); - const appendResponse = (id: number, text: string) => { - if (!aliveRef.current || text === "") return; + const updateExchange = (id: number, update: (exchange: Exchange) => Exchange) => { + if (!aliveRef.current) return; setHistory((current) => - current.map((exchange) => - exchange.id === id ? { ...exchange, response: exchange.response + text } : exchange, - ), + current.map((exchange) => (exchange.id === id ? update(exchange) : exchange)), ); }; + const setResponse = (id: number, response: string) => + updateExchange(id, (exchange) => ({ ...exchange, response })); + const setNote = (id: number, note: string) => + updateExchange(id, (exchange) => ({ ...exchange, note })); const send = async () => { if (streamingRef.current || !detail.data) return; const id = nextIdRef.current++; - const { - payloadSource, - payloadPath, - responseDestination: _responseDestination, - outputPath: _outputPath, - ...modeled - } = requestOptions; + const { payloadSource, payloadPath, responseDestination, outputPath, ...modeled } = + requestOptions; const requestPayload = payloadSource === "File" ? `file://${payloadPath ?? ""}` : payload; + if (responseDestination === "File" && !outputPath?.trim()) { + setHistory((current) => [ + ...current, + { + id, + payload: requestPayload, + chunks: [], + response: "Response path is required for File destination.", + }, + ]); + return; + } setPayload(""); - setHistory((current) => [...current, { id, payload: requestPayload, response: "" }]); + setHistory((current) => [ + ...current, + { id, payload: requestPayload, chunks: [], response: "" }, + ]); + setPrettyJson(false); streamingRef.current = true; setStreaming(true); @@ -138,14 +155,51 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke runtimeSessionId: response.runtimeSessionId ?? current.runtimeSessionId, mcpSessionId: response.mcpSessionId ?? current.mcpSessionId, })); + if (responseDestination === "File") { + const byteCount = await writeRuntimeInvokeFile(response, outputPath!); + setResponse(id, `Saved ${byteCount} bytes to ${outputPath}`); + return; + } + const kind = classifyRuntimeResponse(response.contentType); + if (kind === "binary") { + setResponse(id, BINARY_RESPONSE); + return; + } const decoder = new TextDecoder(); + const chunks: Uint8Array[] = []; for await (const chunk of response.body) { if (!aliveRef.current) return; - appendResponse(id, decoder.decode(chunk, { stream: true })); + chunks.push(chunk); + const text = decoder.decode(chunk, { stream: true }); + updateExchange(id, (exchange) => ({ + ...exchange, + chunks: [...exchange.chunks, chunk], + response: exchange.response + text, + })); + } + const tail = decoder.decode(); + updateExchange(id, (exchange) => ({ ...exchange, response: exchange.response + tail })); + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); + } catch { + setNote(id, INVALID_UTF8); + return; + } + if (kind === "json") { + try { + const pretty = JSON.stringify(JSON.parse(text), null, 2); + updateExchange(id, (exchange) => ({ ...exchange, pretty })); + } catch { + setNote(id, INVALID_JSON); + } } - appendResponse(id, decoder.decode()); } catch (error) { - appendResponse(id, `Error: ${error instanceof Error ? error.message : String(error)}`); + updateExchange(id, (exchange) => ({ + ...exchange, + response: + exchange.response + `Error: ${error instanceof Error ? error.message : String(error)}`, + })); } finally { streamingRef.current = false; if (aliveRef.current) setStreaming(false); @@ -158,12 +212,16 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke setShowOptions(true); return; } + if (key.ctrl && input === "v" && !streamingRef.current) { + setPrettyJson((current) => !current); + return; + } if (key.ctrl && input === "d") { void send(); return; } if (key.escape && !streamingRef.current) { - navigate(`/agentcore/runtime/invoke/${encodeURIComponent(runtimeId)}`); + navigate(invokePath(runtimeId)); return; } if (key.upArrow) scrollRef.current?.scrollBy(-1); @@ -187,6 +245,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke { key: "enter", label: "newline" }, { key: "ctl+d", label: "send" }, { key: "ctl+o", label: "options" }, + { key: "ctl+v", label: prettyJson ? "raw JSON" : "pretty JSON" }, { key: "↑↓", label: "scroll" }, { key: "esc", label: "back" }, { key: "ctl+c", label: "quit" }, @@ -214,7 +273,8 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke Request {exchange.payload} Response - {exchange.response} + {prettyJson && exchange.pretty ? exchange.pretty : exchange.response} + {exchange.note ? {exchange.note} : null} ))} From d0fd5b2ee9c9b8e8c3205aae51700ae1a17e5b2e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 23:55:26 +0000 Subject: [PATCH 13/35] test(runtime): consolidate invoke output coverage --- src/handlers/runtime/invoke/invoke.test.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index f8aa89d27..b539aaa4f 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -102,9 +102,6 @@ describe("runtime invoke", () => { }); expect(invoke.args[1]).toEqual({ region: REGION }); expect(output.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); - - output.io.stdout.write(Uint8Array.from([127])); - expect(output.bytes()).toEqual(Buffer.from([0, 255, 10, 1, 127])); }); test("wires --output-file to exact file output without writing stdout", async () => { @@ -133,21 +130,16 @@ describe("runtime invoke", () => { ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}", "--json"], { invokeResponse: { - statusCode: 202, + statusCode: 200, contentType: "text/plain", - runtimeSessionId: "returned-session", body: body(Buffer.from("accepted")), }, }, ); - expect(JSON.parse(output.bytes().toString())).toEqual({ - statusCode: 202, - contentType: "text/plain", - runtimeSessionId: "returned-session", + expect(JSON.parse(output.bytes().toString())).toMatchObject({ bodyEncoding: "utf8", body: "accepted", - complete: true, }); }); From cb3c63b30ea55517761c5b6e0372dccf6f256cfb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 00:02:41 +0000 Subject: [PATCH 14/35] fix(runtime): snapshot buffered response chunks --- .../runtime/invoke/invoke.screen.test.tsx | 16 +++++++++++++++- src/handlers/runtime/invoke/response.test.ts | 10 +++++++++- src/handlers/runtime/invoke/response.ts | 2 +- src/handlers/runtime/invoke/screen.tsx | 8 ++++---- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 97f76665d..c16c4a296 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -325,19 +325,33 @@ describe("Runtime invoke console", () => { test("toggles a completed valid JSON response between raw and pretty text with Ctrl+V", async () => { const raw = '{"z":1,"nested":{"ok":true}}'; + const first = '{"z":1,'; + const second = '"nested":{"ok":true}}'; const pretty = JSON.stringify(JSON.parse(raw), null, 2); + const release = Promise.withResolvers(); + const mutable = Buffer.alloc(second.length); const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, contentType: "application/problem+json", - body: responseBody(Buffer.from(raw)), + body: (async function* () { + mutable.set(Buffer.from(first)); + yield mutable.subarray(0, first.length); + await release.promise; + mutable.set(Buffer.from(second)); + yield mutable.subarray(0, second.length); + })(), }); const screen = renderScreen(CONSOLE_PATH, { core }); await waitForText(screen.lastFrame, "idle"); await screen.write("\x04"); + await waitForText(screen.lastFrame, first); + expect(screen.lastFrame()).toContain("streaming…"); + + release.resolve(); await waitForText(screen.lastFrame, raw); await waitForText(screen.lastFrame, "idle"); diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index 5ac4d43a4..186336d06 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -92,11 +92,19 @@ describe("Runtime invoke response output", () => { test("JSON mode emits one textual response envelope and no stderr", async () => { const stdout = capture(); const stderr = capture(); + const mutable = Buffer.alloc(16); const result = response({ statusCode: 201, contentType: "application/problem+json", runtimeSessionId: "runtime-session", - body: body(Buffer.from('{"message":'), Buffer.from('"created"}')), + body: (async function* () { + const first = Buffer.from('{"message":'); + const second = Buffer.from('"created"}'); + mutable.set(first); + yield mutable.subarray(0, first.length); + mutable.set(second); + yield mutable.subarray(0, second.length); + })(), }); await writeRuntimeInvokeResponse(result, { diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index a69117aaa..6b1fc3aa3 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -46,7 +46,7 @@ async function writeJsonResponse( stdout: NodeJS.WriteStream, ): Promise { const chunks: Uint8Array[] = []; - for await (const chunk of response.body) chunks.push(chunk); + for await (const chunk of response.body) chunks.push(Uint8Array.from(chunk)); const bytes = Buffer.concat(chunks); const { body: _body, ...responseMetadata } = response; let bodyEncoding: "utf8" | "base64" = "base64"; diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 0c69b95c6..22520cdab 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -169,12 +169,12 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const chunks: Uint8Array[] = []; for await (const chunk of response.body) { if (!aliveRef.current) return; - chunks.push(chunk); - const text = decoder.decode(chunk, { stream: true }); + const snapshot = Uint8Array.from(chunk); + chunks.push(snapshot); updateExchange(id, (exchange) => ({ ...exchange, - chunks: [...exchange.chunks, chunk], - response: exchange.response + text, + chunks: [...exchange.chunks, snapshot], + response: exchange.response + decoder.decode(snapshot, { stream: true }), })); } const tail = decoder.decode(); From 00b0929b131b8b64c688d9d5d02d8b18a6ccac6b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 00:12:13 +0000 Subject: [PATCH 15/35] fix(runtime): preserve native output bytes --- src/handlers/runtime/invoke/index.tsx | 5 ++++- src/handlers/runtime/invoke/invoke.test.tsx | 20 +++++++++++++++++++- src/handlers/runtime/invoke/response.test.ts | 10 +++++++++- src/handlers/runtime/invoke/response.ts | 7 ++++--- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index e52bf13a0..63ac80ef7 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -64,9 +64,12 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => } const jsonOutput = ctx.require(JsonKey); - if (jsonOutput && flags["output-file"]) { + if (jsonOutput && flags["output-file"] !== undefined) { throw new TypeError("--json cannot be used with --output-file"); } + if (flags["output-file"] === "") { + throw new TypeError("--output-file requires a nonempty path"); + } const sources = await resolveRuntimeInvokeSources( { payload: flags.payload, bearerToken: flags["bearer-token"] }, io.stdin, diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index b539aaa4f..76c8f735f 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -213,6 +213,24 @@ describe("runtime invoke", () => { expect(core.runtime.calls).toEqual([]); }); + test("rejects an empty --output-file before Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + await expect( + runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + "--output-file", + "", + ]), + ).rejects.toThrow("--output-file requires a nonempty path"); + expect(core.runtime.calls).toEqual([]); + }); + test("rejects --json with --output-file before Core calls", async () => { const core = new TestCoreClient(); const output = captureIO(); @@ -226,7 +244,7 @@ describe("runtime invoke", () => { "{}", "--json", "--output-file", - "response.bin", + "", ]), ).rejects.toThrow("--json cannot be used with --output-file"); expect(core.runtime.calls).toEqual([]); diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index 186336d06..2bbedc1ad 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -75,9 +75,17 @@ describe("Runtime invoke response output", () => { files.push(file); const stdout = capture(); const stderr = capture(); + const mutable = Buffer.alloc(2); await writeRuntimeInvokeResponse( - response({ body: body(Buffer.from([0, 255]), Buffer.from([10, 1])) }), + response({ + body: (async function* () { + mutable.set([0, 255]); + yield mutable; + mutable.set([10, 1]); + yield mutable; + })(), + }), { stdout: stdout.stream, stderr: stderr.stream, diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 6b1fc3aa3..b4c478c2d 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -24,8 +24,9 @@ async function* countBytes( add: (size: number) => void, ): AsyncGenerator { for await (const chunk of body) { - add(chunk.byteLength); - yield chunk; + const snapshot = Uint8Array.from(chunk); + add(snapshot.byteLength); + yield snapshot; } } @@ -92,7 +93,7 @@ export async function writeRuntimeInvokeResponse( } let byteCount: number; - if (output.outputFile) { + if (output.outputFile !== undefined) { byteCount = await writeRuntimeInvokeFile(response, output.outputFile); } else { if (output.stdout.isTTY && classifyRuntimeResponse(response.contentType) === "binary") { From d6def5ce0b8c87fa30f677769add028dbf3d8f75 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 00:31:06 +0000 Subject: [PATCH 16/35] refactor(runtime): consolidate invoke behavior --- src/core/abortable.ts | 27 +++++ src/core/core.test.ts | 79 +++++++++++++- src/core/harness.tsx | 34 +----- src/core/runtime.tsx | 27 +++-- src/handlers/runtime/invoke/index.tsx | 96 ++++++++++------- .../runtime/invoke/invoke.screen.test.tsx | 71 +++++++++++++ src/handlers/runtime/invoke/invoke.test.tsx | 47 +++++++- src/handlers/runtime/invoke/response.test.ts | 51 +++++++++ src/handlers/runtime/invoke/response.ts | 100 ++++++++++++++---- src/handlers/runtime/invoke/screen.tsx | 39 ++++--- src/index.ts | 5 +- src/router/router.tsx | 2 +- src/runnable/index.test.ts | 35 ++++++ src/runnable/index.tsx | 29 ++++- src/testing/TestCoreClient.tsx | 36 +------ 15 files changed, 521 insertions(+), 157 deletions(-) create mode 100644 src/core/abortable.ts diff --git a/src/core/abortable.ts b/src/core/abortable.ts new file mode 100644 index 000000000..4a0a20dd0 --- /dev/null +++ b/src/core/abortable.ts @@ -0,0 +1,27 @@ +export function abortError(): Error { + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + return error; +} + +export async function* abortable( + source: AsyncIterable, + signal: AbortSignal, +): AsyncGenerator { + const aborted = new Promise((_, reject) => { + if (signal.aborted) reject(abortError()); + else signal.addEventListener("abort", () => reject(abortError()), { once: true }); + }); + aborted.catch(() => {}); + + const iterator = source[Symbol.asyncIterator](); + try { + for (;;) { + const result = await Promise.race([iterator.next(), aborted]); + if (result.done) return; + yield result.value; + } + } finally { + void iterator.return?.()?.catch(() => {}); + } +} diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 0d359a365..75c101439 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -320,6 +320,53 @@ test("invokeRuntime sends all modeled IAM fields and ordered application headers }); }); +test("invokeRuntime aborts an established IAM response stream", async () => { + const source = (async function* () { + yield Buffer.from("partial"); + await new Promise(() => {}); + })(); + const core = new CoreClient( + fakeControl, + (config) => + ({ + config, + kind: "data", + send: async () => ({ + statusCode: 200, + contentType: "text/plain", + response: source, + }), + }) as unknown as BedrockAgentCoreClient, + fakeIam, + ); + const controller = new AbortController(); + const response = await core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + }, + { region: "us-east-1" }, + controller.signal, + ); + const iterator = response.body[Symbol.asyncIterator](); + + expect(await iterator.next()).toEqual({ done: false, value: Buffer.from("partial") }); + const pending = iterator.next(); + controller.abort(); + + const result = await Promise.race([ + pending.then( + () => "completed", + (error: Error) => error.name, + ), + new Promise((resolve) => setTimeout(() => resolve("timed out"), 25)), + ]); + expect(result).toBe("AbortError"); +}); + test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", async () => { const calls: { input: string | URL | Request; init?: RequestInit }[] = []; const controller = new AbortController(); @@ -408,7 +455,9 @@ test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", as ); expect(result.runtimeSessionId).toBe("returned-runtime"); expect(result.mcpSessionId).toBe("returned-mcp"); - expect(result.body).toBeInstanceOf(ReadableStream); + const resultBytes: Uint8Array[] = []; + for await (const chunk of result.body) resultBytes.push(chunk); + expect(Buffer.concat(resultBytes)).toEqual(Buffer.from([1, 2])); }); test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async () => { @@ -454,6 +503,34 @@ test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async expect(read).toBe(false); }); +test("CUSTOM_JWT transport failures do not expose arbitrary causes", async () => { + const core = new CoreClient( + fakeControl, + () => + ({ + config: { endpointProvider: () => ({ url: new URL("https://runtime.test") }) }, + }) as unknown as BedrockAgentCoreClient, + fakeIam, + async () => { + throw new Error("failed with Bearer secret-token"); + }, + ); + + await expect( + core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + bearerToken: "secret-token", + }, + { region: "us-east-1" }, + ), + ).rejects.toThrow(/^Runtime invocation failed$/); +}); + test("invokeRuntime exposes an empty async iterable when the SDK omits the body", async () => { const core = new CoreClient({ createControlClient: fakeControl, diff --git a/src/core/harness.tsx b/src/core/harness.tsx index 69883be40..7a5edeec9 100644 --- a/src/core/harness.tsx +++ b/src/core/harness.tsx @@ -37,6 +37,7 @@ import { } from "@aws-sdk/client-bedrock-agentcore"; import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; import type { AwsClients, CoreOptions } from "./types"; +import { abortable } from "./abortable"; import { ensureDefaultExecutionRole } from "./executionRole"; import { toClientConfig } from "./utils"; @@ -227,36 +228,3 @@ async function retryWhileRoleUnassumable( } } } - -// abortError mirrors the error the SDK rejects with on a pre-stream abort, so -// consumers see one shape either way. -function abortError(): Error { - const error = new Error("The operation was aborted"); - error.name = "AbortError"; - return error; -} - -// abortable relays `source`, rejecting the pending read as soon as `signal` -// aborts. The pre-attached catch swallows the rejection when no read is in -// flight (e.g. abort after the stream ended) to avoid unhandled-rejection -// noise. -async function* abortable(source: AsyncIterable, signal: AbortSignal): AsyncGenerator { - const aborted = new Promise((_, reject) => { - if (signal.aborted) reject(abortError()); - else signal.addEventListener("abort", () => reject(abortError()), { once: true }); - }); - aborted.catch(() => {}); - - const iterator = source[Symbol.asyncIterator](); - try { - for (;;) { - const result = await Promise.race([iterator.next(), aborted]); - if (result.done) return; - yield result.value; - } - } finally { - // Close the SDK stream on early exit to release the connection. Fire and - // forget: its settlement may itself wait on the wedged stream. - void iterator.return?.()?.catch(() => {}); - } -} diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index 641032ca9..e8bcea7ab 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -17,6 +17,7 @@ import type { RuntimeInvokeResponse, } from "../handlers/runtime/types"; import type { AwsClients, CoreOptions, RuntimeFetch } from "./types"; +import { abortable, abortError } from "./abortable"; import { toClientConfig } from "./utils"; async function* emptyBody(): AsyncGenerator {} @@ -67,17 +68,24 @@ export class RuntimeClient implements CoreRuntimeClient { ] as const) { if (value !== undefined) headers.set(name, value); } - const response = await this.fetch(url, { - method: "POST", - redirect: "error", - headers, - body: request.payload as RequestInit["body"], - signal, - }); + let response: Response; + try { + response = await this.fetch(url, { + method: "POST", + redirect: "error", + headers, + body: request.payload as RequestInit["body"], + signal, + }); + } catch (error) { + if (signal?.aborted || (error as Error)?.name === "AbortError") throw abortError(); + throw new Error("Runtime invocation failed"); + } if (!response.ok) { await response.body?.cancel().catch(() => undefined); throw new Error(`HTTP ${response.status}`); } + const body = (response.body as AsyncIterable | null) ?? emptyBody(); return { statusCode: response.status, contentType: response.headers.get("content-type") ?? "", @@ -89,7 +97,7 @@ export class RuntimeClient implements CoreRuntimeClient { traceParent: response.headers.get("traceparent") ?? undefined, traceState: response.headers.get("tracestate") ?? undefined, baggage: response.headers.get("baggage") ?? undefined, - body: (response.body as AsyncIterable | null) ?? emptyBody(), + body: signal ? abortable(body, signal) : body, }; } @@ -108,6 +116,7 @@ export class RuntimeClient implements CoreRuntimeClient { abortSignal: signal, }); + const body = (response.response as AsyncIterable | undefined) ?? emptyBody(); return { statusCode: response.statusCode ?? 0, contentType: response.contentType ?? "", @@ -118,7 +127,7 @@ export class RuntimeClient implements CoreRuntimeClient { traceParent: response.traceParent, traceState: response.traceState, baggage: response.baggage, - body: (response.response as AsyncIterable | undefined) ?? emptyBody(), + body: signal ? abortable(body, signal) : body, }; } diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 63ac80ef7..7674d4751 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -11,6 +11,10 @@ import { } from "./request"; import { writeRuntimeInvokeResponse } from "./response"; +function usageError(error: string | TypeError): TypeError { + return Object.assign(typeof error === "string" ? new TypeError(error) : error, { exitCode: 2 }); +} + export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => createHandler({ name: "invoke", @@ -46,61 +50,79 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => ], handle: async (ctx, flags) => { if (flags.id === undefined) { - throw new TypeError("required option '--id ' not specified"); + throw usageError("required option '--id ' not specified"); } if (flags.payload === undefined) { const requestOption = Object.entries(flags).some( ([name, value]) => !["id", "qualifier", "payload"].includes(name) && value !== undefined, ); if (ctx.require(JsonKey) || requestOption) { - throw new TypeError("required option '--payload ' not specified"); + throw usageError("required option '--payload ' not specified"); } let path = `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`; if (flags.qualifier !== undefined) { path += `/${encodeURIComponent(flags.qualifier)}`; } - await renderTuiAt(path, ctx, core, io); + try { + await renderTuiAt(path, ctx, core, io); + } catch (error) { + if (error instanceof TypeError) throw usageError(error); + throw error; + } return; } const jsonOutput = ctx.require(JsonKey); if (jsonOutput && flags["output-file"] !== undefined) { - throw new TypeError("--json cannot be used with --output-file"); + throw usageError("--json cannot be used with --output-file"); } if (flags["output-file"] === "") { - throw new TypeError("--output-file requires a nonempty path"); + throw usageError("--output-file requires a nonempty path"); + } + try { + const sources = await resolveRuntimeInvokeSources( + { payload: flags.payload, bearerToken: flags["bearer-token"] }, + io.stdin, + ); + const options = coreOptsFromCtx(ctx); + const runtime = await core.runtime.getRuntime(flags.id, options); + const request = normalizeRuntimeInvokeRequest(runtime, { + runtimeId: flags.id, + qualifier: flags.qualifier, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + headers: flags.header, + bearerToken: sources.bearerToken, + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + mcpMethod: flags["mcp-method"], + mcpName: flags["mcp-name"], + traceId: flags["trace-id"], + traceParent: flags["trace-parent"], + traceState: flags["trace-state"], + baggage: flags.baggage, + }); + const controller = new AbortController(); + const interrupt = () => controller.abort(); + process.once("SIGINT", interrupt); + try { + const response = await core.runtime.invokeRuntime(request, options, controller.signal); + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal: controller.signal, + }); + } finally { + process.off("SIGINT", interrupt); + } + } catch (error) { + if (error instanceof TypeError) throw usageError(error); + throw error; } - const sources = await resolveRuntimeInvokeSources( - { payload: flags.payload, bearerToken: flags["bearer-token"] }, - io.stdin, - ); - const options = coreOptsFromCtx(ctx); - const runtime = await core.runtime.getRuntime(flags.id, options); - const request = normalizeRuntimeInvokeRequest(runtime, { - runtimeId: flags.id, - qualifier: flags.qualifier, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - headers: flags.header, - bearerToken: sources.bearerToken, - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - mcpMethod: flags["mcp-method"], - mcpName: flags["mcp-name"], - traceId: flags["trace-id"], - traceParent: flags["trace-parent"], - traceState: flags["trace-state"], - baggage: flags.baggage, - }); - const response = await core.runtime.invokeRuntime(request, options); - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - }); }, }); diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index c16c4a296..d95b656c2 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -499,4 +499,75 @@ describe("Runtime invoke console", () => { ); expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(0); }); + + test("Esc interrupts while connecting and returns the console to idle", async () => { + const core = new TestCoreClient(); + const connection = Promise.withResolvers(); + let signal: AbortSignal | undefined; + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + core.runtime.invokeRuntime = async (_request, _options, nextSignal) => { + signal = nextSignal; + nextSignal?.addEventListener( + "abort", + () => + connection.reject( + Object.assign(new Error("The operation was aborted"), { name: "AbortError" }), + ), + { once: true }, + ); + return connection.promise; + }; + const screen = renderScreen(CONSOLE_PATH, { core }); + + try { + await waitForText(screen.lastFrame, "idle"); + await screen.write("{}"); + await screen.write("\x04"); + await waitFor(() => signal !== undefined); + + await screen.press("escape"); + + expect(signal!.aborted).toBe(true); + await waitForText(screen.lastFrame, "interrupted"); + expect(screen.lastFrame()).toContain("idle"); + } finally { + connection.reject(Object.assign(new Error("stop"), { name: "AbortError" })); + } + }); + + test("Esc interrupts a response stream and keeps its partial text", async () => { + const core = new TestCoreClient(); + const stop = Promise.withResolvers(); + let signal: AbortSignal | undefined; + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + core.runtime.invokeRuntime = async (_request, _options, nextSignal) => { + signal = nextSignal; + return { + statusCode: 200, + contentType: "text/event-stream", + body: (async function* () { + yield Buffer.from("data: partial\n"); + await stop.promise; + })(), + }; + }; + const screen = renderScreen(CONSOLE_PATH, { core }); + + try { + await waitForText(screen.lastFrame, "idle"); + await screen.write("{}"); + await screen.write("\x04"); + await waitForText(screen.lastFrame, "data: partial"); + + await screen.press("escape"); + + expect(signal?.aborted).toBe(true); + stop.reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })); + await waitForText(screen.lastFrame, "interrupted"); + expect(screen.lastFrame()).toContain("data: partial"); + expect(screen.lastFrame()).toContain("idle"); + } finally { + stop.reject(Object.assign(new Error("stop"), { name: "AbortError" })); + } + }); }); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 76c8f735f..ce016f464 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -7,7 +7,7 @@ import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore- import { Command } from "commander"; import type { AppIO } from "../../types"; import type { RuntimeInvokeRequest, RuntimeInvokeResponse } from "../types"; -import { createSilentLogger, TestCoreClient } from "../../../testing"; +import { createSilentLogger, TestCoreClient, waitFor } from "../../../testing"; import { compile, ValueContext } from "../../../router"; import { createRootHandler } from "../../index"; import * as tui from "../../../tui"; @@ -286,6 +286,51 @@ describe("runtime invoke", () => { expect((invoke.args[0] as RuntimeInvokeRequest).payload).toEqual(new Uint8Array()); }); + test("SIGINT aborts an active headless invocation after preserving emitted bytes", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + core.runtime.invokeRuntime = async (request, options, signal) => { + core.runtime.calls.push({ method: "invokeRuntime", args: [request, options, signal] }); + return { + statusCode: 200, + contentType: "text/plain", + body: (async function* () { + yield Buffer.from("partial"); + await new Promise((_, reject) => { + const abort = () => + reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + })(), + }; + }; + const pending = runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + ]); + + try { + await waitFor(() => output.bytes().toString() === "partial"); + const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")!.args[2] as + AbortSignal | undefined; + expect(signal).toBeDefined(); + + process.emit("SIGINT", "SIGINT"); + + expect(signal!.aborted).toBe(true); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(output.bytes().toString()).toBe("partial"); + } finally { + await pending.catch(() => undefined); + } + }); + test("rejects an ARN before making Runtime Core calls", async () => { const core = new TestCoreClient(); const output = captureIO(); diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index 2bbedc1ad..a1e768dc8 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -18,6 +18,13 @@ function body(...chunks: Uint8Array[]): AsyncIterable { })(); } +function failingBody(error: Error): AsyncIterable { + return (async function* () { + yield Buffer.from("partial"); + throw error; + })(); +} + function response(overrides: Partial = {}): RuntimeInvokeResponse { return { statusCode: 200, @@ -134,6 +141,50 @@ describe("Runtime invoke response output", () => { expect(stderr.bytes()).toHaveLength(0); }); + test("preserves partial stdout and reports one static incomplete summary", async () => { + const stdout = capture(); + const stderr = capture(); + const error = new Error("secret upstream detail"); + + await expect( + writeRuntimeInvokeResponse(response({ body: failingBody(error) }), { + stdout: stdout.stream, + stderr: stderr.stream, + }), + ).rejects.toThrow("response stream failed"); + + expect(stdout.bytes().toString()).toBe("partial"); + expect(stderr.bytes().toString()).toBe( + "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=7 error=response-stream-failed\n", + ); + expect(stderr.bytes().toString()).not.toContain(error.message); + }); + + test("JSON mode emits collected bytes and a static error when buffering fails", async () => { + const stdout = capture(); + const stderr = capture(); + const error = new Error("secret upstream detail"); + + await expect( + writeRuntimeInvokeResponse(response({ body: failingBody(error) }), { + stdout: stdout.stream, + stderr: stderr.stream, + json: true, + }), + ).rejects.toThrow("response stream failed"); + + expect(JSON.parse(stdout.bytes().toString())).toMatchObject({ + bodyEncoding: "utf8", + body: "partial", + complete: false, + error: "response stream failed", + }); + expect(stdout.bytes().toString()).not.toContain(error.message); + expect(stderr.bytes()).toHaveLength(0); + }); + test.each([ ["binary content", "application/octet-stream", Buffer.from([0, 255, 1])], ["invalid UTF-8 text", "text/plain", Buffer.from([0xc3, 0x28])], diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index b4c478c2d..75ac34228 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -2,16 +2,17 @@ import { createWriteStream } from "node:fs"; import { pipeline } from "node:stream/promises"; import type { RuntimeInvokeResponse } from "../types"; -type RuntimeResponseKind = "json" | "text" | "binary"; - interface RuntimeInvokeOutput { stdout: NodeJS.WriteStream; stderr: NodeJS.WriteStream; outputFile?: string; json?: boolean; + signal?: AbortSignal; } -export function classifyRuntimeResponse(contentType: string): RuntimeResponseKind { +const RESPONSE_STREAM_FAILED = "response stream failed"; + +export function classifyRuntimeResponse(contentType: string) { const mediaType = contentType.split(";", 1)[0]!.trim().toLowerCase(); if (mediaType === "application/json" || /^application\/[^/]+\+json$/.test(mediaType)) { return "json"; @@ -33,21 +34,43 @@ async function* countBytes( export async function writeRuntimeInvokeFile( response: RuntimeInvokeResponse, path: string, + signal?: AbortSignal, + onBytes?: (size: number) => void, ): Promise { let byteCount = 0; await pipeline( - countBytes(response.body, (size) => (byteCount += size)), + countBytes(response.body, (size) => { + byteCount += size; + onBytes?.(size); + }), createWriteStream(path), + { signal }, ); return byteCount; } +function failure(error: unknown): never { + const interrupted = (error as Error)?.name === "AbortError"; + const reported = new Error(interrupted ? "The operation was aborted" : RESPONSE_STREAM_FAILED); + reported.name = interrupted ? "AbortError" : "Error"; + Object.assign(reported, { reported: true }); + throw reported; +} + async function writeJsonResponse( response: RuntimeInvokeResponse, - stdout: NodeJS.WriteStream, + output: RuntimeInvokeOutput, ): Promise { const chunks: Uint8Array[] = []; - for await (const chunk of response.body) chunks.push(Uint8Array.from(chunk)); + let streamError: unknown; + try { + for await (const chunk of response.body) { + output.signal?.throwIfAborted(); + chunks.push(Uint8Array.from(chunk)); + } + } catch (error) { + streamError = error; + } const bytes = Buffer.concat(chunks); const { body: _body, ...responseMetadata } = response; let bodyEncoding: "utf8" | "base64" = "base64"; @@ -60,17 +83,27 @@ async function writeJsonResponse( } catch {} } - stdout.write( + output.stdout.write( JSON.stringify({ ...responseMetadata, bodyEncoding, body, - complete: true, + complete: streamError === undefined, + ...(streamError !== undefined && { + error: + (streamError as Error)?.name === "AbortError" ? "interrupted" : RESPONSE_STREAM_FAILED, + }), }), ); + if (streamError !== undefined) failure(streamError); } -function successSummary(response: RuntimeInvokeResponse, byteCount: number): string { +function summary( + response: RuntimeInvokeResponse, + byteCount: number, + complete: boolean, + error?: string, +): string { const value = (item?: string) => item || "-"; return ( `status=${response.statusCode} content-type=${value(response.contentType)} ` + @@ -79,7 +112,7 @@ function successSummary(response: RuntimeInvokeResponse, byteCount: number): str `mcp-protocol-version=${value(response.mcpProtocolVersion)} ` + `trace-id=${value(response.traceId)} trace-parent=${value(response.traceParent)} ` + `trace-state=${value(response.traceState)} baggage=${value(response.baggage)} ` + - `complete=true bytes=${byteCount}\n` + `complete=${complete} bytes=${byteCount}${error ? ` error=${error}` : ""}\n` ); } @@ -88,23 +121,44 @@ export async function writeRuntimeInvokeResponse( output: RuntimeInvokeOutput, ): Promise { if (output.json) { - await writeJsonResponse(response, output.stdout); + await writeJsonResponse(response, output); return; } - let byteCount: number; - if (output.outputFile !== undefined) { - byteCount = await writeRuntimeInvokeFile(response, output.outputFile); - } else { - if (output.stdout.isTTY && classifyRuntimeResponse(response.contentType) === "binary") { - throw new TypeError("Binary or unknown response content requires --output-file or --json"); + if ( + output.outputFile === undefined && + output.stdout.isTTY && + classifyRuntimeResponse(response.contentType) === "binary" + ) { + throw new TypeError("Binary or unknown response content requires --output-file or --json"); + } + + let byteCount = 0; + try { + if (output.outputFile !== undefined) { + await writeRuntimeInvokeFile( + response, + output.outputFile, + output.signal, + (size) => (byteCount += size), + ); + } else { + await pipeline( + countBytes(response.body, (size) => (byteCount += size)), + output.stdout, + { end: false, signal: output.signal }, + ); } - byteCount = 0; - await pipeline( - countBytes(response.body, (size) => (byteCount += size)), - output.stdout, - { end: false }, + } catch (error) { + output.stderr.write( + summary( + response, + byteCount, + false, + (error as Error)?.name === "AbortError" ? "interrupted" : "response-stream-failed", + ), ); + failure(error); } - output.stderr.write(successSummary(response, byteCount)); + output.stderr.write(summary(response, byteCount, true)); } diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 22520cdab..2c44366b6 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -26,9 +26,6 @@ interface Exchange { const invokePath = (...parts: string[]) => ["/agentcore/runtime/invoke", ...parts.map(encodeURIComponent)].join("/"); -const INVALID_UTF8 = "Invalid UTF-8 response; showing raw text."; -const INVALID_JSON = "Invalid JSON response; showing raw text."; -const BINARY_RESPONSE = "Binary or unknown response content requires File destination."; export function RuntimeInvokeScreen(props: ScreenProps) { const { runtimeId, qualifier } = useParams(); @@ -86,12 +83,14 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const [streaming, setStreaming] = useState(false); const aliveRef = useRef(true); const streamingRef = useRef(false); + const abortRef = useRef(null); const nextIdRef = useRef(0); const scrollRef = useRef(null); useEffect( () => () => { aliveRef.current = false; + abortRef.current?.abort(); }, [], ); @@ -138,6 +137,8 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke setPrettyJson(false); streamingRef.current = true; setStreaming(true); + const controller = new AbortController(); + abortRef.current = controller; try { const sources = await resolveRuntimeInvokeSources({ payload: requestPayload }); @@ -149,20 +150,20 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke headers: modeled.headers?.split("\n").filter(Boolean), bearerToken: customJwt ? modeled.bearerToken : undefined, }); - const response = await core.runtime.invokeRuntime(request, opts); + const response = await core.runtime.invokeRuntime(request, opts, controller.signal); setRequestOptions((current) => ({ ...current, runtimeSessionId: response.runtimeSessionId ?? current.runtimeSessionId, mcpSessionId: response.mcpSessionId ?? current.mcpSessionId, })); if (responseDestination === "File") { - const byteCount = await writeRuntimeInvokeFile(response, outputPath!); + const byteCount = await writeRuntimeInvokeFile(response, outputPath!, controller.signal); setResponse(id, `Saved ${byteCount} bytes to ${outputPath}`); return; } const kind = classifyRuntimeResponse(response.contentType); if (kind === "binary") { - setResponse(id, BINARY_RESPONSE); + setResponse(id, "Binary or unknown response content requires File destination."); return; } const decoder = new TextDecoder(); @@ -183,7 +184,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke try { text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); } catch { - setNote(id, INVALID_UTF8); + setNote(id, "Invalid UTF-8 response; showing raw text."); return; } if (kind === "json") { @@ -191,16 +192,22 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const pretty = JSON.stringify(JSON.parse(text), null, 2); updateExchange(id, (exchange) => ({ ...exchange, pretty })); } catch { - setNote(id, INVALID_JSON); + setNote(id, "Invalid JSON response; showing raw text."); } } } catch (error) { - updateExchange(id, (exchange) => ({ - ...exchange, - response: - exchange.response + `Error: ${error instanceof Error ? error.message : String(error)}`, - })); + if (controller.signal.aborted || (error as Error)?.name === "AbortError") { + setNote(id, "interrupted"); + } else if (error instanceof TypeError) { + updateExchange(id, (exchange) => ({ + ...exchange, + response: exchange.response + `Error: ${error.message}`, + })); + } else { + setNote(id, "response stream failed"); + } } finally { + abortRef.current = null; streamingRef.current = false; if (aliveRef.current) setStreaming(false); } @@ -220,8 +227,9 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke void send(); return; } - if (key.escape && !streamingRef.current) { - navigate(invokePath(runtimeId)); + if (key.escape) { + if (streamingRef.current) abortRef.current?.abort(); + else navigate(invokePath(runtimeId)); return; } if (key.upArrow) scrollRef.current?.scrollBy(-1); @@ -238,6 +246,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke ? [{ key: "esc", label: "back" }] : streaming ? [ + { key: "esc", label: "interrupt" }, { key: "↑↓", label: "scroll" }, { key: "ctl+c", label: "quit" }, ] diff --git a/src/index.ts b/src/index.ts index 2dd9108e3..87a8a8290 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { CoreClient } from "./core"; import { createControlClient, createDataClient, createIamClient } from "./core/factories"; import { createRootHandler } from "./handlers"; import { createFileLogger, LOG_LEVEL } from "./logging"; -import { runWithExitCode } from "./runnable"; +import { isCommanderError, runWithExitCode } from "./runnable"; process.exit( await runWithExitCode(async (argv: string[]) => { @@ -55,6 +55,9 @@ process.exit( await rootHandler.route(argv); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); + if ((e as { reported?: boolean })?.reported !== true && !isCommanderError(e)) { + io.stderr.write(`${error.name}: ${error.message}\n`); + } rootLogger .child({ errorName: error.name, errorMessage: error.message, stack: error.stack ?? "" }) .error(); diff --git a/src/router/router.tsx b/src/router/router.tsx index eab6778b2..39953bda3 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -105,7 +105,7 @@ export function compile( stack: Middleware[] = [], inheritedGlobals: GlobalFlag[] = [], ): Command { - const c = new Command(node.name()); + const c = new Command(node.name()).exitOverride(); c.description(node.description()); const ownFlags = node.flags(); diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index e9d58ec1d..a8341de9a 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -41,3 +41,38 @@ test("runWithExitCode returns SUCCESS for a resolving function", async () => { const code = await runWithExitCode(async () => {}); expect(code).toBe(ExitCode.SUCCESS); }); + +test("runWithExitCode preserves explicit usage and interruption exit codes", async () => { + const usageError = Object.assign(new Error("bad request"), { exitCode: 2 }); + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + + expect(await runWithExitCode(async () => Promise.reject(usageError))).toBe(2); + expect(await runWithExitCode(async () => Promise.reject(abortError))).toBe(130); +}); + +test("runWithExitCode maps Commander parse failures to usage errors", async () => { + const commanderError = Object.assign(new Error("invalid option"), { + code: "commander.invalidArgument", + exitCode: 1, + }); + + expect(await runWithExitCode(async () => Promise.reject(commanderError))).toBe(2); +}); + +test("runWithExitCode preserves Commander's successful help exit", async () => { + const helpDisplayed = Object.assign(new Error("help displayed"), { + code: "commander.helpDisplayed", + exitCode: 0, + }); + + expect(await runWithExitCode(async () => Promise.reject(helpDisplayed))).toBe(0); +}); + +test("runWithExitCode maps validated input failures to usage errors", async () => { + expect( + await runWithExitCode(async () => { + throw new TypeError("invalid value"); + }), + ).toBe(2); +}); diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index 51fef1ec6..3f412dc2a 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -2,6 +2,8 @@ export enum ExitCode { SUCCESS = 0, FAILURE = 1, + USAGE = 2, + INTERRUPTED = 130, } // Runnable can be implemented by any application's main entrypoint. @@ -9,6 +11,16 @@ export interface Runnable { run(argv: string[]): Promise; } +export function isCommanderError(error: unknown): error is { code: string } { + return ( + typeof error === "object" && + error !== null && + "code" in error && + typeof error.code === "string" && + error.code.startsWith("commander.") + ); +} + // runRunnable creates and runs any instance of Runnable with proper exit code handling. export function runRunnable( createRunnable: () => Runnable, @@ -27,9 +39,20 @@ export async function runWithExitCode( try { await fn(argv); return ExitCode.SUCCESS; - } catch (e) { - const error = e instanceof Error ? e : new Error(String(e)); - console.error(`${error.name}: ${error.message}`); + } catch (error) { + if (isCommanderError(error)) { + return "exitCode" in error && error.exitCode === 0 ? ExitCode.SUCCESS : ExitCode.USAGE; + } + if ( + typeof error === "object" && + error !== null && + "exitCode" in error && + typeof error.exitCode === "number" + ) { + return error.exitCode as ExitCode; + } + if (error instanceof TypeError) return ExitCode.USAGE; + if ((error as Error)?.name === "AbortError") return ExitCode.INTERRUPTED; return ExitCode.FAILURE; } } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index a05cb4fef..72ed7d952 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -42,6 +42,7 @@ import type { RuntimeInvokeRequest, RuntimeInvokeResponse, } from "../handlers/runtime/types"; +import { abortable } from "../core/abortable"; import type { CoreOptions } from "../core/types"; import type { ProjectManager } from "../handlers/project/types"; import type { Logger } from "../logging"; @@ -101,37 +102,6 @@ const DEFAULT_LIST_RUNTIME_ENDPOINTS_RESPONSE: ListAgentRuntimeEndpointsResponse runtimeEndpoints: [], }; -// abortError mirrors the error the SDK's abort handling rejects with. -function abortError(): Error { - const error = new Error("The operation was aborted"); - error.name = "AbortError"; - return error; -} - -// abortable wraps a stream so iteration rejects with an AbortError as soon as -// `signal` aborts, mirroring how the SDK cuts an event stream off mid-read. -// Each next() races against the abortion; the pre-attached catch swallows the -// rejection when nothing is racing (e.g. abort after the stream already ended) -// so tests don't fail on unhandled-rejection noise. -async function* abortable(source: AsyncIterable, signal?: AbortSignal): AsyncGenerator { - if (!signal) { - yield* source; - return; - } - const aborted = new Promise((_, reject) => { - if (signal.aborted) reject(abortError()); - else signal.addEventListener("abort", () => reject(abortError()), { once: true }); - }); - aborted.catch(() => {}); - - const iterator = source[Symbol.asyncIterator](); - for (;;) { - const result = await Promise.race([iterator.next(), aborted]); - if (result.done) return; - yield result.value; - } -} - // events wraps canned events as a one-shot AsyncIterable. async function* events(items: T[]): AsyncGenerator { for (const item of items) yield item; @@ -423,7 +393,7 @@ export class TestHarnessClient implements CoreHarnessClient { this.calls.push({ method: "invokeHarness", args: [request, options, abortSignal] }); if (this.error) throw this.error; const stream = this.invokeStreams.shift() ?? events(this.invokeEvents); - return { stream: abortable(stream, abortSignal) }; + return { stream: abortSignal ? abortable(stream, abortSignal) : stream }; } async invokeAgentRuntimeCommand( @@ -441,7 +411,7 @@ export class TestHarnessClient implements CoreHarnessClient { contentType: "application/json", statusCode: 200, runtimeSessionId: request.runtimeSessionId, - stream: abortable(stream, abortSignal), + stream: abortSignal ? abortable(stream, abortSignal) : stream, }; } } From e37e74cbca4107f5a085a213bfed6ff90d8ac07d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 01:01:18 +0000 Subject: [PATCH 17/35] fix(runtime): complete invoke contracts --- src/components/RuntimePicker.tsx | 4 +- src/core/core.test.ts | 31 ++ src/core/runtime.tsx | 3 + .../runtime/invoke/RequestOptionsScreen.tsx | 39 ++- src/handlers/runtime/invoke/index.tsx | 94 +++--- .../runtime/invoke/invoke.screen.test.tsx | 287 ++++++++++++++--- src/handlers/runtime/invoke/invoke.test.tsx | 203 +++++++----- src/handlers/runtime/invoke/request.test.ts | 43 ++- src/handlers/runtime/invoke/request.ts | 63 +++- src/handlers/runtime/invoke/response.test.ts | 80 ++++- src/handlers/runtime/invoke/response.ts | 65 ++-- src/handlers/runtime/invoke/screen.tsx | 302 +++++++++++------- src/runnable/index.test.ts | 6 +- src/runnable/index.tsx | 1 - 14 files changed, 855 insertions(+), 366 deletions(-) diff --git a/src/components/RuntimePicker.tsx b/src/components/RuntimePicker.tsx index 30cb87301..3178dd361 100644 --- a/src/components/RuntimePicker.tsx +++ b/src/components/RuntimePicker.tsx @@ -27,6 +27,7 @@ export interface RuntimePickerProps extends ScreenProps { breadcrumb: string[]; description?: string; onSelect: (runtimeId: string) => void; + onEscape?: () => void; } export function RuntimePicker({ @@ -35,10 +36,11 @@ export function RuntimePicker({ breadcrumb, description, onSelect, + onEscape, }: RuntimePickerProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); - const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); + const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); return ( { + let fetched = false; + const core = new CoreClient( + fakeControl, + () => + ({ + config: { endpointProvider: () => ({ url: new URL("http://runtime.test") }) }, + }) as unknown as BedrockAgentCoreClient, + fakeIam, + async () => { + fetched = true; + return new Response(); + }, + ); + + await expect( + core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new TextEncoder().encode("secret payload"), + contentType: "application/json", + bearerToken: "secret-token", + }, + { region: "us-east-1" }, + ), + ).rejects.toThrow("CUSTOM_JWT requires an HTTPS endpoint"); + expect(fetched).toBe(false); +}); + test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async () => { let cancelled = 0; let read = false; diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index e8bcea7ab..63c67e2a3 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -41,6 +41,9 @@ export class RuntimeClient implements CoreRuntimeClient { Endpoint: options.endpointUrl, }); const url = new URL(endpoint.url); + if (url.protocol !== "https:") { + throw new TypeError("CUSTOM_JWT requires an HTTPS endpoint"); + } url.pathname = `${url.pathname.replace(/\/?$/, "/")}runtimes/${encodeURIComponent(runtimeId)}/invocations`; url.search = new URLSearchParams({ accountId: request.accountId, diff --git a/src/handlers/runtime/invoke/RequestOptionsScreen.tsx b/src/handlers/runtime/invoke/RequestOptionsScreen.tsx index a0c5d1644..5c0c608e7 100644 --- a/src/handlers/runtime/invoke/RequestOptionsScreen.tsx +++ b/src/handlers/runtime/invoke/RequestOptionsScreen.tsx @@ -98,23 +98,34 @@ export function RequestOptionsScreen({ ] as Row[]; const [selected, setSelected] = useState(0); const [editing, setEditing] = useState(); + const [draft, setDraft] = useState(""); const [custom, setCustom] = useState(false); const row = rows[Math.min(selected, rows.length - 1)]!; - const update = (field: keyof RuntimeInvokeOptions, next: string) => - onChange({ ...value, [field]: next }); + const finish = (next?: string) => { + if (next !== undefined) onChange({ ...value, [row.field]: next }); + setEditing(undefined); + setCustom(false); + }; - useInput((_input, key) => { - if (key.escape) return onClose(); - if (editing) return; + useInput((input, key) => { + if (key.escape) { + if (editing) finish(); + else onClose(); + return; + } + if (editing) { + if (row.multiline && key.ctrl && input === "d") finish(draft); + return; + } if (key.upArrow) setSelected((current) => Math.max(0, current - 1)); if (key.downArrow) setSelected((current) => Math.min(rows.length - 1, current + 1)); if (key.return) { + setDraft(value[row.field] ?? ""); setCustom(false); setEditing(row.field); } }); - const fieldValue = value[row.field] ?? ""; return ( Request Options @@ -132,11 +143,10 @@ export function RequestOptionsScreen({ items={row.choices.map((choice) => ({ label: choice, value: choice }))} onSelect={(item) => { if (item.value === "Custom") { - update(row.field, ""); + setDraft(""); setCustom(true); } else { - update(row.field, item.value); - setEditing(undefined); + finish(item.value); } }} /> @@ -145,16 +155,11 @@ export function RequestOptionsScreen({ name={row.label} helpText="" placeholder="Name: value" - value={fieldValue} - onChange={(next) => update(row.field, next)} + value={draft} + onChange={setDraft} /> ) : editing === row.field ? ( - update(row.field, next)} - onSubmit={() => setEditing(undefined)} - password={row.secret} - /> + ) : null} ); diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 7674d4751..21456956f 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -6,6 +6,7 @@ import { JsonKey } from "../../keys"; import { renderTuiAt } from "../../../tui"; import { normalizeRuntimeInvokeRequest, + parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, runtimeIdSchema, } from "./request"; @@ -66,7 +67,12 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => try { await renderTuiAt(path, ctx, core, io); } catch (error) { - if (error instanceof TypeError) throw usageError(error); + if ( + error instanceof TypeError && + error.message === "interactive mode requires a TTY on stdin and stdout" + ) { + throw usageError(error); + } throw error; } return; @@ -79,50 +85,60 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => if (flags["output-file"] === "") { throw usageError("--output-file requires a nonempty path"); } + const controller = new AbortController(); + const interrupt = () => controller.abort(); + process.once("SIGINT", interrupt); try { - const sources = await resolveRuntimeInvokeSources( - { payload: flags.payload, bearerToken: flags["bearer-token"] }, - io.stdin, - ); + let applicationHeaders: [string, string][]; + let sources: Awaited>; + try { + applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + sources = await resolveRuntimeInvokeSources( + { payload: flags.payload, bearerToken: flags["bearer-token"] }, + io.stdin, + controller.signal, + ); + } catch (error) { + if (error instanceof TypeError) throw usageError(error); + throw error; + } const options = coreOptsFromCtx(ctx); const runtime = await core.runtime.getRuntime(flags.id, options); - const request = normalizeRuntimeInvokeRequest(runtime, { - runtimeId: flags.id, - qualifier: flags.qualifier, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - headers: flags.header, - bearerToken: sources.bearerToken, - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - mcpMethod: flags["mcp-method"], - mcpName: flags["mcp-name"], - traceId: flags["trace-id"], - traceParent: flags["trace-parent"], - traceState: flags["trace-state"], - baggage: flags.baggage, - }); - const controller = new AbortController(); - const interrupt = () => controller.abort(); - process.once("SIGINT", interrupt); + let request: ReturnType; try { - const response = await core.runtime.invokeRuntime(request, options, controller.signal); - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal: controller.signal, + request = normalizeRuntimeInvokeRequest(runtime, { + runtimeId: flags.id, + qualifier: flags.qualifier, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken: sources.bearerToken, + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + mcpMethod: flags["mcp-method"], + mcpName: flags["mcp-name"], + traceId: flags["trace-id"], + traceParent: flags["trace-parent"], + traceState: flags["trace-state"], + baggage: flags.baggage, }); - } finally { - process.off("SIGINT", interrupt); + } catch (error) { + if (error instanceof TypeError) throw usageError(error); + throw error; } - } catch (error) { - if (error instanceof TypeError) throw usageError(error); - throw error; + const response = await core.runtime.invokeRuntime(request, options, controller.signal); + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal: controller.signal, + }); + } finally { + process.off("SIGINT", interrupt); } }, }); diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index d95b656c2..a3593e60d 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -65,6 +65,28 @@ function responseBody(...chunks: Uint8Array[]): AsyncIterable { })(); } +type InvokeScreen = ReturnType; + +async function moveDown(screen: InvokeScreen, count: number) { + for (let index = 0; index < count; index++) await screen.press("down"); +} + +async function editText(screen: InvokeScreen, down: number, value: string) { + await moveDown(screen, down); + await screen.press("return"); + await screen.write(value); + await screen.press("return"); +} + +async function editCustom(screen: InvokeScreen, down: number, choice: number, value: string) { + await moveDown(screen, down); + await screen.press("return"); + await moveDown(screen, choice); + await screen.press("return"); + await screen.write(value); + await screen.press("return"); +} + describe("Runtime invoke routing", () => { test("selects a Runtime and endpoint before opening one console", async () => { const runtimeId = "runtime/blue one"; @@ -175,22 +197,17 @@ describe("Runtime invoke console", () => { } }); - test("edited file options reach invoke and returned sessions are reused", async () => { - const file = join(tmpdir(), `runtime-options-${process.pid}`); - const bytes = Uint8Array.from([0, 255, 1]); - await Bun.write(file, bytes); + test("manually edited protocol options reach invoke", async () => { const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN, protocolConfiguration: { serverProtocol: "MCP" }, - authorizerConfiguration: { customJWTAuthorizer: {} }, + requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, contentType: "text/plain", - runtimeSessionId: "returned-runtime", - mcpSessionId: "returned-mcp", body: splitUtf8("ok", 1), }); const screen = renderScreen(CONSOLE_PATH, { core }); @@ -198,52 +215,149 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); await waitForText(screen.lastFrame, "Request Options"); + await editCustom(screen, 1, 3, "application/vnd.test+json"); + await editCustom(screen, 1, 4, "application/vnd.test-response+json"); + await editText(screen, 2, "runtime-session"); + await moveDown(screen, 2); await screen.press("return"); - await screen.press("down"); - await screen.press("return"); - await screen.press("down"); + await screen.write("X-Tenant: retail\nX-Amzn-Bedrock-AgentCore-Runtime-Custom-Mode: fast"); + await screen.write("\x04"); + await editText(screen, 1, "mcp-session"); + await editText(screen, 1, "2025-06-18"); + await editCustom(screen, 1, 4, "tasks/run"); + await editText(screen, 1, "task-name"); + await editText(screen, 1, "trace-id"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "idle"); + + await screen.write("{}"); + await screen.write("\x04"); + await waitFor( + () => core.runtime.calls.filter((c) => c.method === "invokeRuntime").length === 1, + ); + + const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! + .args[0] as RuntimeInvokeRequest; + expect(request).toMatchObject({ + contentType: "application/vnd.test+json", + accept: "application/vnd.test-response+json", + runtimeSessionId: "runtime-session", + applicationHeaders: [ + ["X-Tenant", "retail"], + ["X-Amzn-Bedrock-AgentCore-Runtime-Custom-Mode", "fast"], + ], + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + mcpMethod: "tasks/run", + mcpName: "task-name", + traceId: "trace-id", + }); + }); + + test("Request Options editors save drafts and Esc cancels them", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request Options"); + await moveDown(screen, 1); await screen.press("return"); - await screen.write(file); + await moveDown(screen, 3); await screen.press("return"); - for (let i = 0; i < 5; i++) await screen.press("down"); + await screen.write("application/cancelled"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "Request Options"); + expect(screen.lastFrame()).toContain("Content type: application/json"); + expect(screen.lastFrame()).not.toContain("application/cancelled"); + await screen.press("return"); - await screen.write("user-1"); + await moveDown(screen, 3); await screen.press("return"); - await screen.press("down"); - await screen.press("down"); + await screen.write("application/saved"); await screen.press("return"); - await screen.write("file://literal-token"); + await waitForText(screen.lastFrame, "Content type: application/saved"); + + await moveDown(screen, 5); await screen.press("return"); + await screen.write("X-Test: cancelled"); await screen.press("escape"); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Request Options"); + expect(screen.lastFrame()).not.toContain("X-Test: cancelled"); + await screen.press("return"); + await screen.write("X-Test: saved"); await screen.write("\x04"); - await waitFor( - () => core.runtime.calls.filter((c) => c.method === "invokeRuntime").length === 1, - ); + await waitForText(screen.lastFrame, "Application headers: X-Test: saved"); + }); + + test("Ctrl+T switches target without remounting request options", async () => { + const nextRuntimeId = "runtime-next"; + const nextQualifier = "canary"; + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setListResponse({ + agentRuntimes: [ + runtime({ + agentRuntimeId: nextRuntimeId, + agentRuntimeName: "next-runtime", + agentRuntimeArn: RUNTIME_ARN.replace(RUNTIME_ID, nextRuntimeId), + }), + ], + }) + .setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], + }) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + runtimeSessionId: "returned-runtime", + mcpSessionId: "returned-mcp", + body: responseBody(Buffer.from("old response")), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request Options"); + await editText(screen, 5, "preserved-user"); + await screen.press("escape"); + await screen.write("first"); + await screen.write("\x04"); + await waitForText(screen.lastFrame, "old response"); await waitForText(screen.lastFrame, "idle"); + + await screen.write("\x14"); + await waitForText(screen.lastFrame, "choose another Runtime"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "old response"); + + await screen.write("\x14"); + await waitForText(screen.lastFrame, "next-runtime"); + await screen.press("return"); + await waitForText(screen.lastFrame, nextQualifier); + await screen.press("return"); + await waitForText(screen.lastFrame, `Runtime: ${nextRuntimeId} · qualifier: ${nextQualifier}`); + expect(screen.lastFrame()).not.toContain("old response"); + expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); + + await screen.write("second"); await screen.write("\x04"); await waitFor( - () => core.runtime.calls.filter((c) => c.method === "invokeRuntime").length === 2, + () => core.runtime.calls.filter((call) => call.method === "invokeRuntime").length === 2, ); - const requests = core.runtime.calls - .filter((call) => call.method === "invokeRuntime") - .map((call) => call.args[0] as RuntimeInvokeRequest); - expect(requests[0]).toMatchObject({ - payload: bytes, - runtimeUserId: "user-1", - bearerToken: "file://literal-token", - }); - expect( - ["payloadSource", "responseDestination", "payloadPath", "outputPath"].filter( - (key) => key in requests[0]!, - ), - ).toEqual([]); - expect(requests[1]).toMatchObject({ - runtimeSessionId: "returned-runtime", - mcpSessionId: "returned-mcp", + const second = core.runtime.calls.filter((call) => call.method === "invokeRuntime")[1]! + .args[0] as RuntimeInvokeRequest; + expect(second).toMatchObject({ + runtimeId: nextRuntimeId, + qualifier: nextQualifier, + runtimeUserId: "preserved-user", }); + expect(second.runtimeSessionId).toBeUndefined(); + expect(second.mcpSessionId).toBeUndefined(); }); test("preserves raw SSE text and both requests across two sends", async () => { @@ -258,10 +372,19 @@ describe("Runtime invoke console", () => { })(); const core = new TestCoreClient(); core.runtime - .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + protocolConfiguration: { serverProtocol: "MCP" }, + } as GetAgentRuntimeResponse) .setInvokeResponse({ - statusCode: 200, + statusCode: 202, contentType: "text/event-stream", + runtimeSessionId: "returned-runtime", + mcpSessionId: "returned-mcp", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "tenant=retail", body: firstBody, }) .queueInvokeBody(firstBody) @@ -315,12 +438,23 @@ describe("Runtime invoke console", () => { const finalFrame = screen.lastFrame()!; expect(finalFrame).toContain("first\npayload"); expect(finalFrame).toContain(firstResponse); - expect(finalFrame).toContain(`Request\nsecond payload\nResponse\n${secondResponse}`); + expect(finalFrame).toContain( + `Request\nsecond payload\nResponse · 202 · text/event-stream\n${secondResponse}`, + ); expect( finalFrame.match(new RegExp(secondResponse.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")), ).toHaveLength(1); expect(finalFrame.match(/first\npayload/g)).toHaveLength(1); expect(finalFrame.match(/data: \{"first":"€"\}/g)).toHaveLength(1); + expect(finalFrame).toContain(`Runtime: ${RUNTIME_ID} · qualifier: ${QUALIFIER}`); + expect(finalFrame).toContain("Sessions: Runtime returned-runtime · MCP returned-mcp"); + expect(finalFrame).toContain("Response · 202 · text/event-stream"); + expect(finalFrame).toContain( + "Runtime returned-runtime · MCP returned-mcp · trace trace-id · traceparent trace-parent", + ); + expect(finalFrame).toContain( + `complete · ${new TextEncoder().encode(secondResponse).byteLength} bytes`, + ); }); test("toggles a completed valid JSON response between raw and pretty text with Ctrl+V", async () => { @@ -412,6 +546,8 @@ describe("Runtime invoke console", () => { .setInvokeResponse({ statusCode: 200, contentType: "application/octet-stream", + runtimeSessionId: "refused-runtime", + mcpSessionId: "refused-mcp", body: source, }); const screen = renderScreen(CONSOLE_PATH, { core }); @@ -425,6 +561,10 @@ describe("Runtime invoke console", () => { ); await waitForText(screen.lastFrame, "idle"); expect(iterations).toBe(0); + const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")! + .args[2] as AbortSignal; + expect(signal.aborted).toBe(true); + expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); }); test("writes a binary File response exactly and records its byte count", async () => { @@ -483,23 +623,67 @@ describe("Runtime invoke console", () => { expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(0); }); - test("records request construction errors and returns to idle without invoking", async () => { + test("shows unreadable payload files as local request errors", async () => { + const missing = join(tmpdir(), `missing-runtime-screen-payload-${process.pid}`); const core = new TestCoreClient(); - core.runtime.setGetResponse({} as GetAgentRuntimeResponse); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "Enter an inline payload"); - await screen.write("{}"); + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request Options"); + await screen.press("return"); + await screen.press("down"); + await screen.press("return"); + await editText(screen, 1, missing); + await screen.press("escape"); await screen.write("\x04"); - await waitForText(screen.lastFrame, "Error: Runtime returned an invalid ARN"); + await waitForText(screen.lastFrame, "Error: Unable to read request source file"); await waitForText(screen.lastFrame, "idle"); - expect(screen.lastFrame()).toContain( - "Request\n{}\nResponse\nError: Runtime returned an invalid ARN", - ); + expect(screen.lastFrame()).not.toContain("response stream failed"); expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(0); }); + test("adopts returned sessions only after response completion", async () => { + const requests: RuntimeInvokeRequest[] = []; + const core = new TestCoreClient(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + core.runtime.invokeRuntime = async (request) => { + requests.push(request); + if (requests.length === 1) { + return { + statusCode: 200, + contentType: "text/plain", + runtimeSessionId: "failed-runtime", + mcpSessionId: "failed-mcp", + body: (async function* () { + yield Buffer.from("partial"); + throw new Error("stream failed"); + })(), + }; + } + return { + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("ok")), + }; + }; + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("first"); + await screen.write("\x04"); + await waitForText(screen.lastFrame, "response stream failed"); + await waitForText(screen.lastFrame, "idle"); + await screen.write("second"); + await screen.write("\x04"); + await waitFor(() => requests.length === 2); + + expect(requests[1]!.runtimeSessionId).toBeUndefined(); + expect(requests[1]!.mcpSessionId).toBeUndefined(); + }); + test("Esc interrupts while connecting and returns the console to idle", async () => { const core = new TestCoreClient(); const connection = Promise.withResolvers(); @@ -524,6 +708,7 @@ describe("Runtime invoke console", () => { await screen.write("{}"); await screen.write("\x04"); await waitFor(() => signal !== undefined); + expect(screen.lastFrame()).toContain("connecting…"); await screen.press("escape"); @@ -545,6 +730,8 @@ describe("Runtime invoke console", () => { return { statusCode: 200, contentType: "text/event-stream", + runtimeSessionId: "interrupted-runtime", + mcpSessionId: "interrupted-mcp", body: (async function* () { yield Buffer.from("data: partial\n"); await stop.promise; @@ -558,14 +745,16 @@ describe("Runtime invoke console", () => { await screen.write("{}"); await screen.write("\x04"); await waitForText(screen.lastFrame, "data: partial"); + expect(screen.lastFrame()).toContain("streaming…"); await screen.press("escape"); expect(signal?.aborted).toBe(true); stop.reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })); - await waitForText(screen.lastFrame, "interrupted"); + await waitForText(screen.lastFrame, "interrupted · 14 bytes"); expect(screen.lastFrame()).toContain("data: partial"); expect(screen.lastFrame()).toContain("idle"); + expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); } finally { stop.reject(Object.assign(new Error("stop"), { name: "AbortError" })); } diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index ce016f464..46a9b0b09 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -1,14 +1,14 @@ import { describe, expect, spyOn, test } from "bun:test"; -import { rm } from "node:fs/promises"; -import { PassThrough } from "node:stream"; -import { join } from "node:path"; import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, Writable } from "node:stream"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { Command } from "commander"; import type { AppIO } from "../../types"; -import type { RuntimeInvokeRequest, RuntimeInvokeResponse } from "../types"; +import type { RuntimeInvokeRequest } from "../types"; import { createSilentLogger, TestCoreClient, waitFor } from "../../../testing"; import { compile, ValueContext } from "../../../router"; +import { ExitCode, runWithExitCode } from "../../../runnable"; import { createRootHandler } from "../../index"; import * as tui from "../../../tui"; @@ -40,6 +40,18 @@ function captureIO(input?: Uint8Array): { io: AppIO; bytes: () => Buffer } { }; } +function failingStdoutIO(): AppIO { + const { io } = captureIO(); + return { + ...io, + stdout: new Writable({ + write(_chunk, _encoding, callback) { + callback(new TypeError("stdout transport failed")); + }, + }) as unknown as NodeJS.WriteStream, + }; +} + function commandFor(core: TestCoreClient, io: AppIO): Command { const root = createRootHandler(core, { io, logger: createSilentLogger() }); const command = compile(root, ValueContext.EmptyContext()); @@ -58,20 +70,16 @@ async function runCommand(core: TestCoreClient, io: AppIO, args: string[]): Prom async function run( args: string[], - { - getResponse = { agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse, - invokeResponse = { - statusCode: 200, - contentType: "application/json", - body: body(Uint8Array.from([0, 255]), Uint8Array.from([10, 1])), - } as RuntimeInvokeResponse, - stdin = undefined as Uint8Array | undefined, - } = {}, + { getResponse = { agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse } = {}, ) { const core = new TestCoreClient(); core.runtime.setGetResponse(getResponse); - core.runtime.setInvokeResponse(invokeResponse); - const output = captureIO(stdin); + core.runtime.setInvokeResponse({ + statusCode: 200, + contentType: "application/json", + body: body(Uint8Array.from([0, 255]), Uint8Array.from([10, 1])), + }); + const output = captureIO(); await runCommand(core, output.io, args); return { core, output }; @@ -104,45 +112,6 @@ describe("runtime invoke", () => { expect(output.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); }); - test("wires --output-file to exact file output without writing stdout", async () => { - const file = join(tmpdir(), `agentcore-invoke-output-${process.pid}`); - try { - const { output } = await run( - ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}", "--output-file", file], - { - invokeResponse: { - statusCode: 200, - contentType: "application/octet-stream", - body: body(Buffer.from([0, 255]), Buffer.from([10, 1])), - }, - }, - ); - - expect(Buffer.from(await Bun.file(file).bytes())).toEqual(Buffer.from([0, 255, 10, 1])); - expect(output.bytes()).toHaveLength(0); - } finally { - await rm(file, { force: true }); - } - }); - - test("wires global --json to one response envelope", async () => { - const { output } = await run( - ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}", "--json"], - { - invokeResponse: { - statusCode: 200, - contentType: "text/plain", - body: body(Buffer.from("accepted")), - }, - }, - ); - - expect(JSON.parse(output.bytes().toString())).toMatchObject({ - bodyEncoding: "utf8", - body: "accepted", - }); - }); - test("passes an explicit qualifier", async () => { const { core } = await run([ "runtime", @@ -250,35 +219,6 @@ describe("runtime invoke", () => { expect(core.runtime.calls).toEqual([]); }); - test("resolves a file payload and stdin bearer token before normalization", async () => { - const file = join(tmpdir(), `agentcore-invoke-handler-${process.pid}`); - const payload = Uint8Array.from([0, 255, 10]); - await Bun.write(file, payload); - const { core } = await run( - [ - "runtime", - "invoke", - "--id", - RUNTIME_ID, - "--payload", - `file://${file}`, - "--bearer-token", - "-", - ], - { - getResponse: { - agentRuntimeArn: RUNTIME_ARN, - authorizerConfiguration: { customJWTAuthorizer: {} }, - } as GetAgentRuntimeResponse, - stdin: new TextEncoder().encode("secret-stdin"), - }, - ); - const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! - .args[0] as RuntimeInvokeRequest; - expect(request.payload).toEqual(payload); - expect(request.bearerToken).toBe("secret-stdin"); - }); - test("passes an explicitly empty payload as zero bytes", async () => { const { core } = await run(["runtime", "invoke", "--id", RUNTIME_ID, "--payload", ""]); @@ -443,4 +383,101 @@ describe("runtime invoke", () => { ).rejects.toThrow(/--payload/); expect(core.runtime.calls).toEqual([]); }); + + test.each([ + ["malformed", "missing separator"], + ["duplicate", "X-Test: one", "x-test: two"], + ["reserved", "Authorization: secret"], + ["count-limited", ...Array.from({ length: 21 }, (_, index) => `X-Test-${index}: value`)], + ["byte-limited", `X-Test: ${"é".repeat(2049)}`], + ])("rejects %s headers as usage before Runtime Core calls", async (_name, ...headers) => { + const core = new TestCoreClient(); + const output = captureIO(); + const args = ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]; + for (const header of headers) args.push("--header", header); + + const code = await runWithExitCode(async () => runCommand(core, output.io, args)); + + expect(code).toBe(ExitCode.USAGE); + expect(core.runtime.calls).toEqual([]); + }); + + test("reports an unreadable payload file as local usage before Runtime Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const missing = join(tmpdir(), `missing-runtime-payload-${process.pid}`); + + await expect( + runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + `file://${missing}`, + ]), + ).rejects.toMatchObject({ + name: "TypeError", + message: "Unable to read request source file", + exitCode: ExitCode.USAGE, + }); + expect(core.runtime.calls).toEqual([]); + }); + + test("keeps Core, transport, and output TypeErrors as failures", async () => { + const lookupCore = new TestCoreClient(); + lookupCore.runtime.setError(new TypeError("lookup failed")); + const transportCore = new TestCoreClient(); + transportCore.runtime.setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + } as GetAgentRuntimeResponse); + transportCore.runtime.invokeRuntime = async () => { + throw new TypeError("transport failed"); + }; + const outputCore = new TestCoreClient(); + outputCore.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: body(Buffer.from("response")), + }); + + const lookupCode = await runWithExitCode(async () => + runCommand(lookupCore, captureIO().io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + ]), + ); + const transportCode = await runWithExitCode(async () => + runCommand(transportCore, captureIO().io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + ]), + ); + const outputCode = await runWithExitCode(async () => + runCommand(outputCore, failingStdoutIO(), [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + ]), + ); + + expect([lookupCode, transportCode, outputCode]).toEqual([ + ExitCode.FAILURE, + ExitCode.FAILURE, + ExitCode.FAILURE, + ]); + }); }); diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index d1c823dce..c5e7c86c3 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -3,7 +3,11 @@ import { Readable } from "node:stream"; import { join } from "node:path"; import { tmpdir } from "node:os"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; -import { normalizeRuntimeInvokeRequest, resolveRuntimeInvokeSources } from "./request"; +import { + normalizeRuntimeInvokeRequest, + parseRuntimeInvokeHeaders, + resolveRuntimeInvokeSources, +} from "./request"; const REGION = "us-west-2"; const RUNTIME_ID = "runtime-123"; @@ -67,6 +71,16 @@ describe("resolveRuntimeInvokeSources", () => { ).rejects.toThrow("Payload and bearer token cannot both read from stdin"); expect(reads).toBe(0); }); + + test("aborts file source resolution through the provided signal", async () => { + await Bun.write(FILE, "payload"); + const controller = new AbortController(); + controller.abort(); + + await expect( + resolveRuntimeInvokeSources({ payload: `file://${FILE}` }, undefined, controller.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + }); }); describe("normalizeRuntimeInvokeRequest", () => { @@ -101,23 +115,17 @@ describe("normalizeRuntimeInvokeRequest", () => { ).toThrow(message); }); - test.each([ - [["X-Test: one", "x-test: two"], "Duplicate header"], - [["Bad Header: value"], "Invalid HTTP header name"], - [["X-Test: bad\nvalue"], "Invalid header value"], - [["Authorization: secret"], "reserved"], - [["Content-Type: text/plain"], "reserved"], - [["Mcp-Method: tools/list"], "reserved"], - [["X-Not-Allowed: value"], "not allowed"], - ])("rejects invalid application headers", (headers, message) => { + test("rejects headers outside the Runtime allowlist", () => { expect(() => normalizeRuntimeInvokeRequest( - detail({ - requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Test"] }, - }), - { runtimeId: RUNTIME_ID, payload: new Uint8Array(), headers }, + detail({ requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Test"] } }), + { + runtimeId: RUNTIME_ID, + payload: new Uint8Array(), + applicationHeaders: [["X-Not-Allowed", "value"]], + }, ), - ).toThrow(message); + ).toThrow("not allowed"); }); test("maps every request field and ordered allowed headers once", () => { @@ -135,7 +143,10 @@ describe("normalizeRuntimeInvokeRequest", () => { accept: "text/event-stream", runtimeSessionId: "runtime-session", runtimeUserId: "runtime-user", - headers: ["X-Tenant: retail", "x-amzn-bedrock-agentcore-runtime-custom-mode: fast"], + applicationHeaders: parseRuntimeInvokeHeaders([ + "X-Tenant: retail", + "x-amzn-bedrock-agentcore-runtime-custom-mode: fast", + ]), bearerToken: "secret-token", mcpSessionId: "mcp-session", mcpProtocolVersion: "2025-06-18", diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index a5ab1a7bf..86559eaec 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import { validateHeaderName, validateHeaderValue } from "node:http"; import z from "zod"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; @@ -15,7 +16,7 @@ interface RuntimeInvokeInput { accept?: string; runtimeSessionId?: string; runtimeUserId?: string; - headers?: string[]; + applicationHeaders?: [string, string][]; bearerToken?: string; mcpSessionId?: string; mcpProtocolVersion?: string; @@ -46,13 +47,26 @@ const RESERVED_HEADERS = new Set([ "baggage", ]); -async function readSource(source: string, input?: NodeJS.ReadStream): Promise { - if (source.startsWith("file://")) return Bun.file(source.slice("file://".length)).bytes(); +async function readSource( + source: string, + input?: NodeJS.ReadStream, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + if (source.startsWith("file://")) { + try { + return await readFile(source.slice("file://".length), { signal }); + } catch (error) { + if ((error as Error)?.name === "AbortError") throw error; + throw new TypeError("Unable to read request source file"); + } + } if (source !== "-") return new TextEncoder().encode(source); if (!input) throw new TypeError("stdin is not available for this request source"); const chunks: Uint8Array[] = []; for await (const chunk of input) { + signal?.throwIfAborted(); chunks.push( typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk), ); @@ -63,25 +77,20 @@ async function readSource(source: string, input?: NodeJS.ReadStream): Promise { if (sources.payload === "-" && sources.bearerToken === "-") { throw new TypeError("Payload and bearer token cannot both read from stdin"); } - const payload = await readSource(sources.payload, stdin); + const payload = await readSource(sources.payload, stdin, signal); if (sources.bearerToken === undefined) return { payload }; - const token = await readSource(sources.bearerToken, stdin); + const token = await readSource(sources.bearerToken, stdin, signal); return { payload, bearerToken: new TextDecoder().decode(token) }; } -function parseHeaders(detail: GetAgentRuntimeResponse, values: string[]): [string, string][] { - const allowlist = - detail.requestHeaderConfiguration && - "requestHeaderAllowlist" in detail.requestHeaderConfiguration - ? (detail.requestHeaderConfiguration.requestHeaderAllowlist ?? []).map((name) => - name.toLowerCase(), - ) - : []; +export function parseRuntimeInvokeHeaders(values: string[] = []): [string, string][] { + if (values.length > 20) throw new TypeError("At most 20 application headers are allowed"); const seen = new Set(); return values.map((header) => { @@ -99,16 +108,35 @@ function parseHeaders(detail: GetAgentRuntimeResponse, values: string[]): [strin } catch { throw new TypeError(`Invalid header value for ${name}`); } + if (Buffer.byteLength(value) > 4096) { + throw new TypeError(`Application header value exceeds 4096 bytes: ${name}`); + } const lower = name.toLowerCase(); if (seen.has(lower)) throw new TypeError(`Duplicate header: ${name}`); seen.add(lower); if (RESERVED_HEADERS.has(lower)) throw new TypeError(`Application header is reserved: ${name}`); + return [name, value]; + }); +} + +function validateAllowedHeaders( + detail: GetAgentRuntimeResponse, + headers: [string, string][], +): void { + const allowlist = + detail.requestHeaderConfiguration && + "requestHeaderAllowlist" in detail.requestHeaderConfiguration + ? (detail.requestHeaderConfiguration.requestHeaderAllowlist ?? []).map((name) => + name.toLowerCase(), + ) + : []; + for (const [name] of headers) { + const lower = name.toLowerCase(); if (!lower.startsWith(CUSTOM_HEADER_PREFIX) && !allowlist.includes(lower)) { throw new TypeError(`Application header is not allowed: ${name}`); } - return [name, value]; - }); + } } export function normalizeRuntimeInvokeRequest( @@ -125,7 +153,7 @@ export function normalizeRuntimeInvokeRequest( const authorizer = detail.authorizerConfiguration; const customJwt = authorizer !== undefined && "customJWTAuthorizer" in authorizer; if (authorizer && !customJwt) throw new TypeError("Runtime uses an unsupported authorizer"); - const { runtimeId, qualifier, payload, contentType, headers, ...modeled } = input; + const { runtimeId, qualifier, payload, contentType, applicationHeaders = [], ...modeled } = input; if (customJwt && !modeled.bearerToken) { throw new TypeError("CUSTOM_JWT Runtime requires --bearer-token"); } @@ -146,6 +174,7 @@ export function normalizeRuntimeInvokeRequest( if (modeled.mcpName !== undefined && modeled.mcpMethod === undefined) { throw new TypeError("--mcp-name requires --mcp-method"); } + validateAllowedHeaders(detail, applicationHeaders); return { runtimeId, @@ -154,6 +183,6 @@ export function normalizeRuntimeInvokeRequest( payload, contentType: contentType || "application/json", ...modeled, - ...(headers?.length && { applicationHeaders: parseHeaders(detail, headers) }), + ...(applicationHeaders.length > 0 && { applicationHeaders }), }; } diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index a1e768dc8..f5c2b30bd 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { PassThrough } from "node:stream"; +import { PassThrough, Writable } from "node:stream"; +import { finished } from "node:stream/promises"; import type { RuntimeInvokeResponse } from "../types"; import { writeRuntimeInvokeResponse } from "./response"; @@ -44,10 +45,31 @@ function capture() { }; } +function backpressuredCapture() { + const chunks: Buffer[] = []; + const stream = new Writable({ + highWaterMark: 32, + write(chunk, _encoding, callback) { + setTimeout(() => { + chunks.push(Buffer.from(chunk)); + callback(); + }, 10); + }, + }); + return { + stream: stream as unknown as NodeJS.WriteStream, + bytes: () => Buffer.concat(chunks), + close: async () => { + stream.end(); + await finished(stream); + }, + }; +} + describe("Runtime invoke response output", () => { test("streams exact chunks to stdout, reports metadata, and leaves stdout open", async () => { const stdout = capture(); - const stderr = capture(); + const stderr = backpressuredCapture(); const result = response({ statusCode: 206, runtimeSessionId: "runtime-session", @@ -65,8 +87,10 @@ describe("Runtime invoke response output", () => { stderr: stderr.stream, }); + const stderrBytes = stderr.bytes(); + await stderr.close(); expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); - expect(stderr.bytes().toString()).toBe( + expect(stderrBytes.toString()).toBe( "status=206 content-type=text/plain runtime-session-id=runtime-session " + "mcp-session-id=mcp-session mcp-protocol-version=2025-06-18 trace-id=trace-id " + "trace-parent=trace-parent trace-state=trace-state baggage=tenant=retail " + @@ -141,6 +165,29 @@ describe("Runtime invoke response output", () => { expect(stderr.bytes()).toHaveLength(0); }); + test("waits for a large JSON envelope to reach a backpressured stdout", async () => { + const stdout = backpressuredCapture(); + const stderr = capture(); + const responseBytes = Buffer.alloc(4 * 1024 * 1024, "x"); + + await writeRuntimeInvokeResponse( + response({ contentType: "text/plain", body: body(responseBytes) }), + { + stdout: stdout.stream, + stderr: stderr.stream, + json: true, + }, + ); + const delivered = stdout.bytes(); + await stdout.close(); + + expect(JSON.parse(delivered.toString())).toMatchObject({ + bodyEncoding: "utf8", + body: responseBytes.toString(), + complete: true, + }); + }); + test("preserves partial stdout and reports one static incomplete summary", async () => { const stdout = capture(); const stderr = capture(); @@ -162,6 +209,33 @@ describe("Runtime invoke response output", () => { expect(stderr.bytes().toString()).not.toContain(error.message); }); + test("writes an interruption summary after the output signal is aborted", async () => { + const controller = new AbortController(); + const stdout = capture(); + const stderr = backpressuredCapture(); + const source = (async function* () { + yield Buffer.from("partial"); + controller.abort(); + throw Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); + })(); + + await expect( + writeRuntimeInvokeResponse(response({ body: source }), { + stdout: stdout.stream, + stderr: stderr.stream, + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + const delivered = stderr.bytes(); + await stderr.close(); + + expect(delivered.toString()).toBe( + "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=7 error=interrupted\n", + ); + }); + test("JSON mode emits collected bytes and a static error when buffering fails", async () => { const stdout = capture(); const stderr = capture(); diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 75ac34228..be68290ff 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -31,6 +31,20 @@ async function* countBytes( } } +async function writeText( + stream: NodeJS.WriteStream, + text: string, + signal?: AbortSignal, +): Promise { + await pipeline( + (async function* () { + yield text; + })(), + stream, + { end: false, signal }, + ); +} + export async function writeRuntimeInvokeFile( response: RuntimeInvokeResponse, path: string, @@ -83,18 +97,20 @@ async function writeJsonResponse( } catch {} } - output.stdout.write( - JSON.stringify({ - ...responseMetadata, - bodyEncoding, - body, - complete: streamError === undefined, - ...(streamError !== undefined && { - error: - (streamError as Error)?.name === "AbortError" ? "interrupted" : RESPONSE_STREAM_FAILED, - }), + const envelope = JSON.stringify({ + ...responseMetadata, + bodyEncoding, + body, + complete: streamError === undefined, + ...(streamError !== undefined && { + error: (streamError as Error)?.name === "AbortError" ? "interrupted" : RESPONSE_STREAM_FAILED, }), - ); + }); + try { + await writeText(output.stdout, envelope, streamError === undefined ? output.signal : undefined); + } catch (error) { + failure(error); + } if (streamError !== undefined) failure(streamError); } @@ -150,15 +166,24 @@ export async function writeRuntimeInvokeResponse( ); } } catch (error) { - output.stderr.write( - summary( - response, - byteCount, - false, - (error as Error)?.name === "AbortError" ? "interrupted" : "response-stream-failed", - ), - ); + try { + await writeText( + output.stderr, + summary( + response, + byteCount, + false, + (error as Error)?.name === "AbortError" ? "interrupted" : "response-stream-failed", + ), + ); + } catch (summaryError) { + failure(summaryError); + } + failure(error); + } + try { + await writeText(output.stderr, summary(response, byteCount, true)); + } catch (error) { failure(error); } - output.stderr.write(summary(response, byteCount, true)); } diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 2c44366b6..d81280075 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -11,22 +11,45 @@ import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker import { RuntimePicker } from "../../../components/RuntimePicker"; import { Divider } from "../../../components/ui/divider"; import { Spinner } from "../../../components/ui/spinner"; -import { normalizeRuntimeInvokeRequest, resolveRuntimeInvokeSources } from "./request"; +import type { RuntimeInvokeResponse } from "../types"; +import { + normalizeRuntimeInvokeRequest, + parseRuntimeInvokeHeaders, + resolveRuntimeInvokeSources, +} from "./request"; import { RequestOptionsScreen, type RuntimeInvokeOptions } from "./RequestOptionsScreen"; import { classifyRuntimeResponse, writeRuntimeInvokeFile } from "./response"; +type ExchangeState = "connecting" | "streaming" | "complete" | "interrupted" | "failed"; + interface Exchange { - id: number; payload: string; - chunks: Uint8Array[]; response: string; pretty?: string; note?: string; + heading?: string; + metadata?: string; + byteCount: number; + state: ExchangeState; } const invokePath = (...parts: string[]) => ["/agentcore/runtime/invoke", ...parts.map(encodeURIComponent)].join("/"); +const metadata = (response: RuntimeInvokeResponse) => + [ + ["Runtime", response.runtimeSessionId], + ["MCP", response.mcpSessionId], + ["MCP version", response.mcpProtocolVersion], + ["trace", response.traceId], + ["traceparent", response.traceParent], + ["tracestate", response.traceState], + ["baggage", response.baggage], + ] + .filter((entry) => entry[1]) + .map((entry) => entry.join(" ")) + .join(" · "); + export function RuntimeInvokeScreen(props: ScreenProps) { const { runtimeId, qualifier } = useParams(); const navigate = useNavigate(); @@ -64,9 +87,11 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); const { rows } = useWindowSize(); + const [target, setTarget] = useState({ runtimeId, qualifier }); + const [switchRuntime, setSwitchRuntime] = useState(null); const detail = useQuery({ - queryKey: ["runtime", opts.region, runtimeId], - queryFn: () => core.runtime.getRuntime(runtimeId, opts), + queryKey: ["runtime", opts.region, target.runtimeId], + queryFn: () => core.runtime.getRuntime(target.runtimeId, opts), }); const customJwt = detail.data?.authorizerConfiguration !== undefined && @@ -80,11 +105,8 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const [showOptions, setShowOptions] = useState(false); const [history, setHistory] = useState([]); const [prettyJson, setPrettyJson] = useState(false); - const [streaming, setStreaming] = useState(false); const aliveRef = useRef(true); - const streamingRef = useRef(false); const abortRef = useRef(null); - const nextIdRef = useRef(0); const scrollRef = useRef(null); useEffect( @@ -95,169 +117,204 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke [], ); - useEffect(() => { - scrollRef.current?.scrollToBottom(); - }, [history]); + useEffect(() => scrollRef.current?.scrollToBottom(), [history]); - const updateExchange = (id: number, update: (exchange: Exchange) => Exchange) => { + const updateExchange = (patch: Partial) => { if (!aliveRef.current) return; - setHistory((current) => - current.map((exchange) => (exchange.id === id ? update(exchange) : exchange)), - ); + setHistory((current) => current.slice(0, -1).concat({ ...current.at(-1)!, ...patch })); }; - const setResponse = (id: number, response: string) => - updateExchange(id, (exchange) => ({ ...exchange, response })); - const setNote = (id: number, note: string) => - updateExchange(id, (exchange) => ({ ...exchange, note })); const send = async () => { - if (streamingRef.current || !detail.data) return; + if (abortRef.current || !detail.data) return; - const id = nextIdRef.current++; - const { payloadSource, payloadPath, responseDestination, outputPath, ...modeled } = - requestOptions; + const { + payloadSource, + payloadPath, + responseDestination, + outputPath, + headers, + bearerToken, + ...modeled + } = requestOptions; const requestPayload = payloadSource === "File" ? `file://${payloadPath ?? ""}` : payload; - if (responseDestination === "File" && !outputPath?.trim()) { + const appendExchange = (response: string, state: ExchangeState) => setHistory((current) => [ ...current, - { - id, - payload: requestPayload, - chunks: [], - response: "Response path is required for File destination.", - }, + { payload: requestPayload, response, byteCount: 0, state }, ]); + if (responseDestination === "File" && !outputPath?.trim()) { + appendExchange("Response path is required for File destination.", "failed"); return; } setPayload(""); - setHistory((current) => [ - ...current, - { id, payload: requestPayload, chunks: [], response: "" }, - ]); + appendExchange("", "connecting"); setPrettyJson(false); - streamingRef.current = true; - setStreaming(true); const controller = new AbortController(); abortRef.current = controller; + let localRequest = true; try { - const sources = await resolveRuntimeInvokeSources({ payload: requestPayload }); + const sources = await resolveRuntimeInvokeSources( + { payload: requestPayload, bearerToken: customJwt ? bearerToken : undefined }, + undefined, + controller.signal, + ); const request = normalizeRuntimeInvokeRequest(detail.data, { ...modeled, - runtimeId, - qualifier, + runtimeId: target.runtimeId, + qualifier: target.qualifier, payload: sources.payload, - headers: modeled.headers?.split("\n").filter(Boolean), - bearerToken: customJwt ? modeled.bearerToken : undefined, + applicationHeaders: parseRuntimeInvokeHeaders(headers?.split("\n").filter(Boolean)), + bearerToken: sources.bearerToken, }); + localRequest = false; const response = await core.runtime.invokeRuntime(request, opts, controller.signal); - setRequestOptions((current) => ({ - ...current, - runtimeSessionId: response.runtimeSessionId ?? current.runtimeSessionId, - mcpSessionId: response.mcpSessionId ?? current.mcpSessionId, - })); + updateExchange({ + heading: `Response · ${response.statusCode} · ${response.contentType || "-"}`, + metadata: metadata(response), + state: "streaming", + }); + let byteCount = 0; if (responseDestination === "File") { - const byteCount = await writeRuntimeInvokeFile(response, outputPath!, controller.signal); - setResponse(id, `Saved ${byteCount} bytes to ${outputPath}`); - return; - } - const kind = classifyRuntimeResponse(response.contentType); - if (kind === "binary") { - setResponse(id, "Binary or unknown response content requires File destination."); - return; - } - const decoder = new TextDecoder(); - const chunks: Uint8Array[] = []; - for await (const chunk of response.body) { - if (!aliveRef.current) return; - const snapshot = Uint8Array.from(chunk); - chunks.push(snapshot); - updateExchange(id, (exchange) => ({ - ...exchange, - chunks: [...exchange.chunks, snapshot], - response: exchange.response + decoder.decode(snapshot, { stream: true }), - })); - } - const tail = decoder.decode(); - updateExchange(id, (exchange) => ({ ...exchange, response: exchange.response + tail })); - let text: string; - try { - text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); - } catch { - setNote(id, "Invalid UTF-8 response; showing raw text."); - return; - } - if (kind === "json") { + await writeRuntimeInvokeFile(response, outputPath!, controller.signal, (size) => + updateExchange({ byteCount: (byteCount += size) }), + ); + updateExchange({ response: `Saved ${byteCount} bytes to ${outputPath}` }); + } else { + const kind = classifyRuntimeResponse(response.contentType); + if (kind === "binary") { + controller.abort(); + updateExchange({ + response: "Binary or unknown response content requires File destination.", + state: "failed", + }); + return; + } + const decoder = new TextDecoder(); + const chunks: Uint8Array[] = []; + let responseText = ""; + for await (const chunk of response.body) { + if (!aliveRef.current) return; + const snapshot = Uint8Array.from(chunk); + chunks.push(snapshot); + byteCount += snapshot.byteLength; + responseText += decoder.decode(snapshot, { stream: true }); + updateExchange({ + response: responseText, + byteCount, + }); + } + responseText += decoder.decode(); + updateExchange({ response: responseText }); + let text: string | undefined; try { - const pretty = JSON.stringify(JSON.parse(text), null, 2); - updateExchange(id, (exchange) => ({ ...exchange, pretty })); + text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); } catch { - setNote(id, "Invalid JSON response; showing raw text."); + updateExchange({ note: "Invalid UTF-8 response; showing raw text." }); + } + if (kind === "json" && text !== undefined) { + try { + const pretty = JSON.stringify(JSON.parse(text), null, 2); + updateExchange({ pretty }); + } catch { + updateExchange({ note: "Invalid JSON response; showing raw text." }); + } } } + setRequestOptions((current) => ({ + ...current, + runtimeSessionId: response.runtimeSessionId ?? current.runtimeSessionId, + mcpSessionId: response.mcpSessionId ?? current.mcpSessionId, + })); + updateExchange({ state: "complete" }); } catch (error) { if (controller.signal.aborted || (error as Error)?.name === "AbortError") { - setNote(id, "interrupted"); - } else if (error instanceof TypeError) { - updateExchange(id, (exchange) => ({ - ...exchange, - response: exchange.response + `Error: ${error.message}`, - })); + updateExchange({ note: "interrupted", state: "interrupted" }); + } else if (localRequest && error instanceof TypeError) { + updateExchange({ response: `Error: ${error.message}`, state: "failed" }); } else { - setNote(id, "response stream failed"); + updateExchange({ note: "response stream failed", state: "failed" }); } } finally { abortRef.current = null; - streamingRef.current = false; - if (aliveRef.current) setStreaming(false); } }; + const liveState = history.at(-1)?.state; + const busy = liveState === "connecting" || liveState === "streaming"; + useInput( (input, key) => { - if (key.ctrl && input === "o" && !streamingRef.current) { - setShowOptions(true); - return; - } - if (key.ctrl && input === "v" && !streamingRef.current) { - setPrettyJson((current) => !current); - return; - } - if (key.ctrl && input === "d") { - void send(); + if (key.ctrl) { + if (input === "o" && !abortRef.current) setShowOptions(true); + else if (input === "v" && !abortRef.current) setPrettyJson((current) => !current); + else if (input === "t" && !abortRef.current) setSwitchRuntime(""); + else if (input === "d") void send(); return; } if (key.escape) { - if (streamingRef.current) abortRef.current?.abort(); - else navigate(invokePath(runtimeId)); + if (abortRef.current) abortRef.current.abort(); + else navigate(invokePath(target.runtimeId)); return; } if (key.upArrow) scrollRef.current?.scrollBy(-1); if (key.downArrow) scrollRef.current?.scrollBy(1); }, - { isActive: !showOptions }, + { isActive: !showOptions && switchRuntime === null }, ); + if (switchRuntime === "") { + return ( + setSwitchRuntime(null)} + /> + ); + } + + if (switchRuntime) { + return ( + { + if (switchRuntime !== target.runtimeId || selected !== target.qualifier) { + setTarget({ runtimeId: switchRuntime, qualifier: selected }); + setRequestOptions( + ({ runtimeSessionId: _runtime, mcpSessionId: _mcp, ...current }) => current, + ); + setHistory([]); + setPrettyJson(false); + } + setSwitchRuntime(null); + }} + onEscape={() => setSwitchRuntime("")} + /> + ); + } + return ( @@ -275,15 +332,26 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke /> ) : ( - + + Runtime: {target.runtimeId} · qualifier: {target.qualifier} + + + Sessions: Runtime {requestOptions.runtimeSessionId ?? "new"} · MCP{" "} + {requestOptions.mcpSessionId ?? "new"} + + - {history.map((exchange) => ( - + {history.map((exchange, index) => ( + Request {exchange.payload} - Response + {exchange.heading ?? "Response"} {prettyJson && exchange.pretty ? exchange.pretty : exchange.response} + {exchange.metadata ? {exchange.metadata} : null} {exchange.note ? {exchange.note} : null} + + {exchange.state} · {exchange.byteCount} bytes + ))} @@ -296,11 +364,11 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke value={payload} onChange={setPayload} previewLines={4} - focused={!streaming} + focused={!busy} /> - {streaming ? : idle} + {busy ? : idle} )} diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index a8341de9a..839f84752 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -69,10 +69,10 @@ test("runWithExitCode preserves Commander's successful help exit", async () => { expect(await runWithExitCode(async () => Promise.reject(helpDisplayed))).toBe(0); }); -test("runWithExitCode maps validated input failures to usage errors", async () => { +test("runWithExitCode does not infer usage from arbitrary TypeErrors", async () => { expect( await runWithExitCode(async () => { - throw new TypeError("invalid value"); + throw new TypeError("transport failed"); }), - ).toBe(2); + ).toBe(ExitCode.FAILURE); }); diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index 3f412dc2a..a83db8bae 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -51,7 +51,6 @@ export async function runWithExitCode( ) { return error.exitCode as ExitCode; } - if (error instanceof TypeError) return ExitCode.USAGE; if ((error as Error)?.name === "AbortError") return ExitCode.INTERRUPTED; return ExitCode.FAILURE; } From 6ddb90001cae0d5c330300f14541a8f5b23ea3e9 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 01:10:27 +0000 Subject: [PATCH 18/35] fix(runtime): preserve usage and cancellation contracts --- src/handlers/runtime/invoke/index.tsx | 19 +++--- src/handlers/runtime/invoke/invoke.test.tsx | 68 ++++++++++++--------- src/router/args.tsx | 7 ++- src/router/flags.tsx | 5 +- src/router/router.test.ts | 24 +++++--- 5 files changed, 74 insertions(+), 49 deletions(-) diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 21456956f..df3d974d4 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -130,13 +130,18 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => throw error; } const response = await core.runtime.invokeRuntime(request, options, controller.signal); - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal: controller.signal, - }); + try { + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal: controller.signal, + }); + } catch (error) { + controller.abort(); + throw error; + } } finally { process.off("SIGINT", interrupt); } diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 46a9b0b09..858448638 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -271,48 +271,56 @@ describe("runtime invoke", () => { } }); - test("rejects an ARN before making Runtime Core calls", async () => { + test("classifies an invalid Runtime ARN as usage before Core calls", async () => { const core = new TestCoreClient(); const output = captureIO(); - const command = commandFor(core, output.io); - await expect( - command.parseAsync([ - "node", - "agentcore", - "runtime", - "invoke", - "--id", - RUNTIME_ARN, - "--payload", - "{}", - "--region", - REGION, - ]), - ).rejects.toThrow(/Runtime ID/); + const code = await runWithExitCode(async () => + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ARN, "--payload", "{}"]), + ); + + expect(code).toBe(ExitCode.USAGE); expect(core.runtime.calls).toEqual([]); }); - test("rejects --payload without --id before making Runtime Core calls", async () => { + test("classifies required local validation as usage before Core calls", async () => { const core = new TestCoreClient(); const output = captureIO(); - const command = commandFor(core, output.io); - await expect( - command.parseAsync([ - "node", - "agentcore", - "runtime", - "invoke", - "--payload", - "{}", - "--region", - REGION, - ]), - ).rejects.toThrow(/--id/); + const code = await runWithExitCode(async () => + runCommand(core, output.io, ["runtime", "invoke", "--payload", "{}"]), + ); + + expect(code).toBe(ExitCode.USAGE); expect(core.runtime.calls).toEqual([]); }); + test("aborts an established request when a TTY refuses binary output", async () => { + let iterations = 0; + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "application/octet-stream", + body: (async function* () { + iterations++; + yield Buffer.from([0, 255]); + })(), + }); + const output = captureIO(); + Object.defineProperty(output.io.stdout, "isTTY", { value: true }); + + await expect( + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]), + ).rejects.toThrow("Binary or unknown response content requires --output-file or --json"); + + const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")! + .args[2] as AbortSignal; + expect(signal.aborted).toBe(true); + expect(iterations).toBe(0); + }); + test("rejects a resolved Runtime ARN without an account ID before invoke", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse({ diff --git a/src/router/args.tsx b/src/router/args.tsx index dd8693eb1..39b2fd2ba 100644 --- a/src/router/args.tsx +++ b/src/router/args.tsx @@ -20,8 +20,11 @@ export function toCommanderArgument(arg: Argument): CommanderArgument { function validateArgument(argument: Argument, input: unknown | undefined): unknown { const result = argument.schema.safeParse(coerce(argument.schema, input)); if (!result.success) { - throw new TypeError( - `Invalid value for argument '${argument.name}': ${formatZodError(result.error)}`, + throw Object.assign( + new TypeError( + `Invalid value for argument '${argument.name}': ${formatZodError(result.error)}`, + ), + { exitCode: 2 }, ); } return result.data; diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 8c2aed740..9a0e2c27d 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -63,8 +63,9 @@ function attributeName(name: string): string { function validateFlag(flag: Flag, opts: Record): unknown { const result = flag.schema.safeParse(coerce(flag.schema, opts[attributeName(flag.name)])); if (!result.success) { - throw new TypeError( - `Invalid value for option '--${flag.name}': ${formatZodError(result.error)}`, + throw Object.assign( + new TypeError(`Invalid value for option '--${flag.name}': ${formatZodError(result.error)}`), + { exitCode: 2 }, ); } return result.data; diff --git a/src/router/router.test.ts b/src/router/router.test.ts index e201157df..4ae34ee77 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -297,7 +297,7 @@ test("a short alias preserves encounter order for repeated variadic values", asy }); }); -test("reports invalid input via command.error (throws under exitOverride)", async () => { +test("marks invalid flag schema input as usage", async () => { const get = createHandler({ name: "get", description: "", @@ -312,9 +312,13 @@ test("reports invalid input via command.error (throws under exitOverride)", asyn const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); - await expect( - cmd.parseAsync(["node", "app", "get", "--harness-id", "toolong"]), // exceeds max(3) - ).rejects.toThrow(/Invalid value for option '--harness-id'/); + const error = await cmd + .parseAsync(["node", "app", "get", "--harness-id", "toolong"]) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toContain("Invalid value for option '--harness-id'"); + expect(error.exitCode).toBe(2); }); test("a required (non-optional) flag is mandatory", async () => { @@ -544,7 +548,7 @@ test("a required positional argument is mandatory", async () => { await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow(); }); -test("rejects an argument that fails schema validation", async () => { +test("marks invalid argument schema input as usage", async () => { const config = createHandler({ name: "config", description: "", @@ -559,9 +563,13 @@ test("rejects an argument that fails schema validation", async () => { const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); - await expect(cmd.parseAsync(["node", "app", "config", "toolong"])).rejects.toThrow( - /Invalid value for argument 'key'/, - ); + const error = await cmd + .parseAsync(["node", "app", "config", "toolong"]) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toContain("Invalid value for argument 'key'"); + expect(error.exitCode).toBe(2); }); test("compile rejects a variadic argument that is not the last positional", () => { From e36d841f7e5ee4abf26d0a8005b634486c461729 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 01:38:27 +0000 Subject: [PATCH 19/35] fix(runtime): abort stdin and omit hidden MCP options --- .../runtime/invoke/invoke.screen.test.tsx | 62 +++++++++++++++++++ src/handlers/runtime/invoke/request.test.ts | 21 +++++++ src/handlers/runtime/invoke/request.ts | 5 +- src/handlers/runtime/invoke/screen.tsx | 8 ++- 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index a3593e60d..f8d7062fd 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -360,6 +360,68 @@ describe("Runtime invoke console", () => { expect(second.mcpSessionId).toBeUndefined(); }); + test("switching from MCP to HTTP omits hidden MCP options", async () => { + const nextRuntimeId = "runtime-http"; + const nextQualifier = "http"; + const nextArn = RUNTIME_ARN.replace(RUNTIME_ID, nextRuntimeId); + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + protocolConfiguration: { serverProtocol: "MCP" }, + } as GetAgentRuntimeResponse) + .setListResponse({ + agentRuntimes: [ + runtime({ + agentRuntimeId: nextRuntimeId, + agentRuntimeName: "http-runtime", + agentRuntimeArn: nextArn, + }), + ], + }) + .setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], + }) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("http response")), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request Options"); + await editText(screen, 7, "mcp-session"); + await editText(screen, 1, "2025-06-18"); + await editCustom(screen, 1, 4, "tasks/run"); + await editText(screen, 1, "task-name"); + await screen.press("escape"); + + core.runtime.setGetResponse({ + agentRuntimeArn: nextArn, + protocolConfiguration: { serverProtocol: "HTTP" }, + } as GetAgentRuntimeResponse); + await screen.write("\x14"); + await waitForText(screen.lastFrame, "http-runtime"); + await screen.press("return"); + await waitForText(screen.lastFrame, nextQualifier); + await screen.press("return"); + await waitForText(screen.lastFrame, `Runtime: ${nextRuntimeId} · qualifier: ${nextQualifier}`); + await waitForText(screen.lastFrame, "idle"); + + await screen.write("{}"); + await screen.write("\x04"); + + await waitForText(screen.lastFrame, "http response"); + const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! + .args[0] as RuntimeInvokeRequest; + expect(request).not.toHaveProperty("mcpSessionId"); + expect(request).not.toHaveProperty("mcpProtocolVersion"); + expect(request).not.toHaveProperty("mcpMethod"); + expect(request).not.toHaveProperty("mcpName"); + }); + test("preserves raw SSE text and both requests across two sends", async () => { const firstResponse = 'data: {"first":"€"}\n\nnot-json'; const secondResponse = '{"second":true}\nraw: ✓'; diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index c5e7c86c3..d560df768 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -51,6 +51,27 @@ describe("resolveRuntimeInvokeSources", () => { expect(result.payload).toEqual(bytes); }); + test("aborts while waiting for stdin", async () => { + const input = new Readable({ read() {} }) as NodeJS.ReadStream; + const controller = new AbortController(); + const resolving = resolveRuntimeInvokeSources({ payload: "-" }, input, controller.signal); + controller.abort(); + + try { + await expect( + Promise.race([ + resolving, + Bun.sleep(100).then(() => { + throw new Error("stdin read did not abort"); + }), + ]), + ).rejects.toMatchObject({ name: "AbortError" }); + } finally { + input.destroy(); + await resolving.catch(() => {}); + } + }); + test.each([ ["inline", "secret-inline", undefined, "secret-inline"], ["file", `file://${FILE}`, undefined, "secret-file"], diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index 86559eaec..602b48ded 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; import { validateHeaderName, validateHeaderValue } from "node:http"; +import { addAbortSignal } from "node:stream"; import z from "zod"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import type { RuntimeInvokeRequest } from "../types"; @@ -65,8 +66,8 @@ async function readSource( if (!input) throw new TypeError("stdin is not available for this request source"); const chunks: Uint8Array[] = []; - for await (const chunk of input) { - signal?.throwIfAborted(); + const stream = signal ? addAbortSignal(signal, input) : input; + for await (const chunk of stream) { chunks.push( typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk), ); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index d81280075..db08b0af7 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -96,6 +96,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const customJwt = detail.data?.authorizerConfiguration !== undefined && "customJWTAuthorizer" in detail.data.authorizerConfiguration; + const mcp = detail.data?.protocolConfiguration?.serverProtocol === "MCP"; const [payload, setPayload] = useState(""); const [requestOptions, setRequestOptions] = useState({ payloadSource: "Inline", @@ -134,6 +135,10 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke outputPath, headers, bearerToken, + mcpSessionId, + mcpProtocolVersion, + mcpMethod, + mcpName, ...modeled } = requestOptions; const requestPayload = payloadSource === "File" ? `file://${payloadPath ?? ""}` : payload; @@ -166,6 +171,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke payload: sources.payload, applicationHeaders: parseRuntimeInvokeHeaders(headers?.split("\n").filter(Boolean)), bearerToken: sources.bearerToken, + ...(mcp && { mcpSessionId, mcpProtocolVersion, mcpMethod, mcpName }), }); localRequest = false; const response = await core.runtime.invokeRuntime(request, opts, controller.signal); @@ -328,7 +334,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke onChange={setRequestOptions} onClose={() => setShowOptions(false)} customJwt={customJwt} - mcp={detail.data.protocolConfiguration?.serverProtocol === "MCP"} + mcp={mcp} /> ) : ( From e8906b9d071b755a2313506dfac38a96accb311c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 01:41:40 +0000 Subject: [PATCH 20/35] docs(runtime): document invoke workflows --- README.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/README.md b/README.md index b2a3be481..3ce079afa 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ agentcore # interactive TUI ├── runtime # inspect deployed AgentCore Runtimes │ ├── get # fetch a Runtime by id │ ├── list # list Runtimes (server-side paginated) +│ ├── invoke # invoke a Runtime headlessly or in a persistent console │ ├── version │ │ ├── get # get a specific Runtime version │ │ └── list # list a Runtime's versions @@ -116,6 +117,88 @@ agentcore identity api-key-credential-provider update --name my-provider --api-k agentcore identity api-key-credential-provider delete --name my-provider ``` +### Invoke a Runtime + +Headless invocation accepts inline, file, or stdin payload bytes: + +```bash +# Inline +agentcore runtime invoke \ + --id \ + --payload '{"action":"status"}' \ + --content-type application/json \ + --accept text/event-stream + +# File +agentcore runtime invoke --id --payload file://request.json + +# stdin +cat request.json | agentcore runtime invoke --id --payload - +``` + +CUSTOM_JWT Runtimes require `--bearer-token`. The token accepts the same inline, +`file://`, or stdin sources as the payload; payload and token cannot both read +stdin. + +```bash +agentcore runtime invoke \ + --id \ + --payload file://request.json \ + --bearer-token file://$HOME/.config/agentcore/runtime-token +``` + +For MCP Runtimes, initialize first, then pass the returned MCP session ID to +later methods: + +```bash +agentcore runtime invoke \ + --id \ + --payload '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"agentcore-cli","version":"1"}}}' \ + --mcp-protocol-version 2025-03-26 \ + --mcp-method initialize + +agentcore runtime invoke \ + --id \ + --payload '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + --mcp-session-id \ + --mcp-protocol-version 2025-03-26 \ + --mcp-method tools/list +``` + +Raw mode streams exact response bytes to stdout and writes response metadata to +stderr. Use `--output-file` for binary responses. `--json` instead buffers one +response and emits a metadata envelope without interpreting the customer body. + +```bash +agentcore runtime invoke \ + --id \ + --payload file://request.bin \ + --content-type application/octet-stream \ + --accept application/octet-stream \ + --output-file response.bin + +agentcore runtime invoke --id --payload '{"action":"status"}' --json +# {"statusCode":200,"contentType":"application/json","bodyEncoding":"utf8","body":"{\"ok\":true}","complete":true} +``` + +Without `--payload`, Runtime Invoke opens a persistent console for repeated +requests. Bare invoke opens the Runtime and endpoint pickers; `--id` skips the +Runtime picker, and `--id` plus `--qualifier` opens the console directly. + +| Shortcut | Action | +| -------- | -------------------------------------------- | +| `Ctrl+D` | Send the request | +| `Ctrl+O` | Open Request Options | +| `Ctrl+T` | Change Runtime or endpoint | +| `Ctrl+V` | Toggle raw and pretty completed JSON | +| `Esc` | Interrupt an active request or navigate back | +| `↑`/`↓` | Scroll response history | + +Runtime Invoke accepts Runtime IDs from the current account only. It does not +accept ARNs, `--version`, `--interactive`, cross-account targets, or custom +request paths. All requests use the Runtime `/invocations` route, including MCP +Runtimes. + Bare Runtime branches and leaves require a TTY on stdin and stdout. Supplying operation flags runs the command headlessly, and `--json` always suppresses TUI rendering. From 2ab077973bd4e3271ca903853bc3a9e61eca73d4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 01:50:31 +0000 Subject: [PATCH 21/35] docs(runtime): clarify MCP session invocation --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ce079afa..7e3e40cce 100644 --- a/README.md +++ b/README.md @@ -147,19 +147,22 @@ agentcore runtime invoke \ --bearer-token file://$HOME/.config/agentcore/runtime-token ``` -For MCP Runtimes, initialize first, then pass the returned MCP session ID to -later methods: +For MCP Runtimes, initialize first, then pass the returned Runtime and MCP +session IDs to later methods. MCP requests accept both JSON and SSE responses. ```bash agentcore runtime invoke \ --id \ --payload '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"agentcore-cli","version":"1"}}}' \ + --accept 'application/json, text/event-stream' \ --mcp-protocol-version 2025-03-26 \ --mcp-method initialize agentcore runtime invoke \ --id \ --payload '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + --accept 'application/json, text/event-stream' \ + --session-id \ --mcp-session-id \ --mcp-protocol-version 2025-03-26 \ --mcp-method tools/list From df700c3ece18b9325072be8f3a4e287505e42a9e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 01:56:53 +0000 Subject: [PATCH 22/35] fix(runtime): default MCP response types --- src/handlers/runtime/invoke/request.test.ts | 12 ++++++++++++ src/handlers/runtime/invoke/request.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index d560df768..98fc7b2ad 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -149,6 +149,18 @@ describe("normalizeRuntimeInvokeRequest", () => { ).toThrow("not allowed"); }); + test("defaults MCP requests to the required JSON and SSE response types", () => { + const request = normalizeRuntimeInvokeRequest( + detail({ protocolConfiguration: { serverProtocol: "MCP" } }), + { + runtimeId: RUNTIME_ID, + payload: new Uint8Array(), + }, + ); + + expect(request.accept).toBe("application/json, text/event-stream"); + }); + test("maps every request field and ordered allowed headers once", () => { const request = normalizeRuntimeInvokeRequest( detail({ diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index 602b48ded..aa749fc61 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -184,6 +184,7 @@ export function normalizeRuntimeInvokeRequest( payload, contentType: contentType || "application/json", ...modeled, + accept: modeled.accept ?? (mcp ? "application/json, text/event-stream" : undefined), ...(applicationHeaders.length > 0 && { applicationHeaders }), }; } From 924c5354c100ad3eb81201e7a8ce0e33672da1d5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 02:02:36 +0000 Subject: [PATCH 23/35] fix(runtime): protect target-scoped request secrets --- src/core/core.test.ts | 32 +++++++++++++++++ src/core/runtime.tsx | 34 +++++++++++-------- .../runtime/invoke/invoke.screen.test.tsx | 26 ++++++++++++-- src/handlers/runtime/invoke/screen.tsx | 9 ++++- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 2cb51922a..3d2b5bf4e 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -491,6 +491,38 @@ test("CUSTOM_JWT rejects non-HTTPS endpoints without calling fetch", async () => expect(fetched).toBe(false); }); +test("CUSTOM_JWT modeled header failures do not expose their values", async () => { + let fetched = false; + const core = new CoreClient( + fakeControl, + () => + ({ + config: { endpointProvider: () => ({ url: new URL("https://runtime.test") }) }, + }) as unknown as BedrockAgentCoreClient, + fakeIam, + async () => { + fetched = true; + return new Response(); + }, + ); + + await expect( + core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + bearerToken: "secret-token", + traceParent: "secret-header\r\nvalue", + }, + { region: "us-east-1" }, + ), + ).rejects.toThrow(/^Invalid Runtime request header$/); + expect(fetched).toBe(false); +}); + test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async () => { let cancelled = 0; let read = false; diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index 63c67e2a3..0cf45c5df 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -55,21 +55,25 @@ export class RuntimeClient implements CoreRuntimeClient { } catch { throw new TypeError("Invalid bearer token"); } - for (const [name, value] of [ - ["Content-Type", request.contentType], - ["Accept", request.accept], - ["Mcp-Session-Id", request.mcpSessionId], - ["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", request.runtimeSessionId], - ["Mcp-Protocol-Version", request.mcpProtocolVersion], - ["Mcp-Method", request.mcpMethod], - ["Mcp-Name", request.mcpName], - ["X-Amzn-Bedrock-AgentCore-Runtime-User-Id", request.runtimeUserId], - ["X-Amzn-Trace-Id", request.traceId], - ["traceparent", request.traceParent], - ["tracestate", request.traceState], - ["baggage", request.baggage], - ] as const) { - if (value !== undefined) headers.set(name, value); + try { + for (const [name, value] of [ + ["Content-Type", request.contentType], + ["Accept", request.accept], + ["Mcp-Session-Id", request.mcpSessionId], + ["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", request.runtimeSessionId], + ["Mcp-Protocol-Version", request.mcpProtocolVersion], + ["Mcp-Method", request.mcpMethod], + ["Mcp-Name", request.mcpName], + ["X-Amzn-Bedrock-AgentCore-Runtime-User-Id", request.runtimeUserId], + ["X-Amzn-Trace-Id", request.traceId], + ["traceparent", request.traceParent], + ["tracestate", request.traceState], + ["baggage", request.baggage], + ] as const) { + if (value !== undefined) headers.set(name, value); + } + } catch { + throw new TypeError("Invalid Runtime request header"); } let response: Response; try { diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index f8d7062fd..3f1ae1c65 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -295,15 +295,21 @@ describe("Runtime invoke console", () => { test("Ctrl+T switches target without remounting request options", async () => { const nextRuntimeId = "runtime-next"; const nextQualifier = "canary"; + const nextArn = RUNTIME_ARN.replace(RUNTIME_ID, nextRuntimeId); + const token = "token-secret"; const core = new TestCoreClient(); core.runtime - .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + authorizerConfiguration: { customJWTAuthorizer: {} }, + requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, + } as GetAgentRuntimeResponse) .setListResponse({ agentRuntimes: [ runtime({ agentRuntimeId: nextRuntimeId, agentRuntimeName: "next-runtime", - agentRuntimeArn: RUNTIME_ARN.replace(RUNTIME_ID, nextRuntimeId), + agentRuntimeArn: nextArn, }), ], }) @@ -323,12 +329,21 @@ describe("Runtime invoke console", () => { await screen.write("\x0f"); await waitForText(screen.lastFrame, "Request Options"); await editText(screen, 5, "preserved-user"); + await screen.press("down"); + await screen.press("return"); + await screen.write("X-Tenant: secret-header"); + await screen.write("\x04"); + await editText(screen, 1, token); await screen.press("escape"); await screen.write("first"); await screen.write("\x04"); await waitForText(screen.lastFrame, "old response"); await waitForText(screen.lastFrame, "idle"); + core.runtime.setGetResponse({ + agentRuntimeArn: nextArn, + protocolConfiguration: { serverProtocol: "HTTP" }, + } as GetAgentRuntimeResponse); await screen.write("\x14"); await waitForText(screen.lastFrame, "choose another Runtime"); await screen.press("escape"); @@ -343,6 +358,13 @@ describe("Runtime invoke console", () => { expect(screen.lastFrame()).not.toContain("old response"); expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request Options"); + const options = screen.lastFrame()!; + expect(options).toContain("Runtime user ID: preserved-user"); + expect(options).not.toContain("secret-header"); + expect(options).not.toContain("*".repeat(token.length)); + await screen.press("escape"); await screen.write("second"); await screen.write("\x04"); await waitFor( diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index db08b0af7..c983aa1d7 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -294,7 +294,14 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke if (switchRuntime !== target.runtimeId || selected !== target.qualifier) { setTarget({ runtimeId: switchRuntime, qualifier: selected }); setRequestOptions( - ({ runtimeSessionId: _runtime, mcpSessionId: _mcp, ...current }) => current, + ({ + runtimeSessionId: _runtime, + mcpSessionId: _mcp, + bearerToken, + headers, + ...current + }) => + switchRuntime === target.runtimeId ? { ...current, bearerToken, headers } : current, ); setHistory([]); setPrettyJson(false); From 3075f0f20e1b392c86a3865399d0937b5925579b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 02:15:28 +0000 Subject: [PATCH 24/35] fix(runtime): close invoke review gaps --- src/core/core.test.ts | 31 ++++++++++++++++++- src/core/runtime.tsx | 8 +++-- src/handlers/runtime/invoke/index.tsx | 2 +- .../runtime/invoke/invoke.screen.test.tsx | 29 ++++++++++++++--- src/handlers/runtime/invoke/invoke.test.tsx | 4 ++- src/handlers/runtime/invoke/response.test.ts | 5 +++ src/handlers/runtime/invoke/response.ts | 5 +++ src/handlers/runtime/types.tsx | 6 +++- src/testing/TestCoreClient.tsx | 8 +++-- 9 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 3d2b5bf4e..59b7a59e0 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -1,5 +1,8 @@ import { test, expect } from "bun:test"; -import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore-control"; +import { + GetAgentRuntimeCommand, + type BedrockAgentCoreControlClient, +} from "@aws-sdk/client-bedrock-agentcore-control"; import type { IAMClient } from "@aws-sdk/client-iam"; import { InvokeAgentRuntimeCommand, @@ -98,6 +101,32 @@ test("exposes a harness sub-client", () => { expect(core.harness).toBeDefined(); }); +test("getRuntime sends the abort signal to the control client", async () => { + const sent: { command: unknown; options: unknown }[] = []; + const core = new CoreClient( + (config) => + ({ + config, + send: async (command: unknown, options: unknown) => { + sent.push({ command, options }); + return {}; + }, + }) as unknown as BedrockAgentCoreControlClient, + fakeData, + fakeIam, + ); + const controller = new AbortController(); + + await core.runtime.getRuntime("runtime-123", { region: "us-east-1" }, controller.signal); + + expect(sent).toHaveLength(1); + expect(sent[0]!.command).toBeInstanceOf(GetAgentRuntimeCommand); + expect((sent[0]!.command as GetAgentRuntimeCommand).input).toEqual({ + agentRuntimeId: "runtime-123", + }); + expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); +}); + test("invokeHarness sends an InvokeHarnessCommand on the data client with the abort signal", async () => { // A fake data client that records what send() receives and resolves a canned // response, so we can assert the harness sub-client's SDK translation. diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index 0cf45c5df..2126ceaa0 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -138,10 +138,14 @@ export class RuntimeClient implements CoreRuntimeClient { }; } - async getRuntime(id: string, options: CoreOptions): Promise { + async getRuntime( + id: string, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { return this.clients .control(toClientConfig(options)) - .send(new GetAgentRuntimeCommand({ agentRuntimeId: id })); + .send(new GetAgentRuntimeCommand({ agentRuntimeId: id }), { abortSignal: signal }); } async getRuntimeVersion( diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index df3d974d4..1b6014ecd 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -103,7 +103,7 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => throw error; } const options = coreOptsFromCtx(ctx); - const runtime = await core.runtime.getRuntime(flags.id, options); + const runtime = await core.runtime.getRuntime(flags.id, options, controller.signal); let request: ReturnType; try { request = normalizeRuntimeInvokeRequest(runtime, { diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 3f1ae1c65..9ea25e959 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -292,7 +292,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "Application headers: X-Test: saved"); }); - test("Ctrl+T switches target without remounting request options", async () => { + test("Ctrl+T preserves Runtime-scoped options only across endpoints", async () => { const nextRuntimeId = "runtime-next"; const nextQualifier = "canary"; const nextArn = RUNTIME_ARN.replace(RUNTIME_ID, nextRuntimeId); @@ -306,6 +306,7 @@ describe("Runtime invoke console", () => { } as GetAgentRuntimeResponse) .setListResponse({ agentRuntimes: [ + runtime(), runtime({ agentRuntimeId: nextRuntimeId, agentRuntimeName: "next-runtime", @@ -340,10 +341,6 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "old response"); await waitForText(screen.lastFrame, "idle"); - core.runtime.setGetResponse({ - agentRuntimeArn: nextArn, - protocolConfiguration: { serverProtocol: "HTTP" }, - } as GetAgentRuntimeResponse); await screen.write("\x14"); await waitForText(screen.lastFrame, "choose another Runtime"); await screen.press("escape"); @@ -354,6 +351,28 @@ describe("Runtime invoke console", () => { await screen.press("return"); await waitForText(screen.lastFrame, nextQualifier); await screen.press("return"); + await waitForText(screen.lastFrame, `Runtime: ${RUNTIME_ID} · qualifier: ${nextQualifier}`); + expect(screen.lastFrame()).not.toContain("old response"); + expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); + + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request Options"); + const endpointOptions = screen.lastFrame()!; + expect(endpointOptions).toContain("Runtime user ID: preserved-user"); + expect(endpointOptions).toContain("Application headers: X-Tenant: secret-header"); + expect(endpointOptions).toContain("*".repeat(token.length)); + await screen.press("escape"); + + core.runtime.setGetResponse({ + agentRuntimeArn: nextArn, + protocolConfiguration: { serverProtocol: "HTTP" }, + } as GetAgentRuntimeResponse); + await screen.write("\x14"); + await waitForText(screen.lastFrame, "next-runtime"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, nextQualifier); + await screen.press("return"); await waitForText(screen.lastFrame, `Runtime: ${nextRuntimeId} · qualifier: ${nextQualifier}`); expect(screen.lastFrame()).not.toContain("old response"); expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 858448638..a10ce9d57 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -97,7 +97,8 @@ describe("runtime invoke", () => { ]); expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime", "invokeRuntime"]); - expect(core.runtime.calls[0]!.args).toEqual([RUNTIME_ID, { region: REGION }]); + const lookup = core.runtime.calls[0]!; + expect(lookup.args.slice(0, 2)).toEqual([RUNTIME_ID, { region: REGION }]); const invoke = core.runtime.calls[1]!; const request = invoke.args[0] as RuntimeInvokeRequest; @@ -109,6 +110,7 @@ describe("runtime invoke", () => { contentType: "application/json", }); expect(invoke.args[1]).toEqual({ region: REGION }); + expect(lookup.args[2]).toBe(invoke.args[2]); expect(output.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); }); diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index f5c2b30bd..200d79f41 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -299,5 +299,10 @@ describe("Runtime invoke response output", () => { ).rejects.toThrow("Binary or unknown response content requires --output-file or --json"); expect(iterations).toBe(0); expect(stdout.bytes()).toHaveLength(0); + expect(stderr.bytes().toString()).toBe( + "status=200 content-type=- runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=0\n", + ); }); }); diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index be68290ff..b923d928b 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -146,6 +146,11 @@ export async function writeRuntimeInvokeResponse( output.stdout.isTTY && classifyRuntimeResponse(response.contentType) === "binary" ) { + try { + await writeText(output.stderr, summary(response, 0, false)); + } catch (error) { + failure(error); + } throw new TypeError("Binary or unknown response content requires --output-file or --json"); } diff --git a/src/handlers/runtime/types.tsx b/src/handlers/runtime/types.tsx index 348a214d3..72dc3bb85 100644 --- a/src/handlers/runtime/types.tsx +++ b/src/handlers/runtime/types.tsx @@ -47,7 +47,11 @@ export interface CoreRuntimeClient { options: CoreOptions, signal?: AbortSignal, ): Promise; - getRuntime(id: string, options: CoreOptions): Promise; + getRuntime( + id: string, + options: CoreOptions, + signal?: AbortSignal, + ): Promise; getRuntimeVersion( id: string, version: string, diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 72ed7d952..550a87aed 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -478,8 +478,12 @@ export class TestRuntimeClient implements CoreRuntimeClient { return this; } - async getRuntime(id: string, options: CoreOptions): Promise { - this.calls.push({ method: "getRuntime", args: [id, options] }); + async getRuntime( + id: string, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { + this.calls.push({ method: "getRuntime", args: [id, options, signal] }); if (this.error) throw this.error; return this.getResponse; } From caf19b72c2500e41cfdfcf9b673cac21aa934dc3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 02:18:57 +0000 Subject: [PATCH 25/35] test(runtime): preserve Core call recording shape --- src/testing/TestCoreClient.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 550a87aed..44fd4a2cd 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -483,7 +483,7 @@ export class TestRuntimeClient implements CoreRuntimeClient { options: CoreOptions, signal?: AbortSignal, ): Promise { - this.calls.push({ method: "getRuntime", args: [id, options, signal] }); + this.calls.push({ method: "getRuntime", args: [id, options, ...(signal ? [signal] : [])] }); if (this.error) throw this.error; return this.getResponse; } From bdd13ae5fa9a2b3fe352bb0f2fc612389ec5b962 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 03:25:14 +0000 Subject: [PATCH 26/35] refactor(runtime): simplify invoke implementation --- src/core/abortable.ts | 14 +- src/core/core.test.ts | 147 +++++++++--------- src/core/runtime.tsx | 16 +- src/core/types.tsx | 4 +- .../runtime/invoke/invoke.screen.test.tsx | 45 ++++++ src/handlers/runtime/invoke/invoke.test.tsx | 57 ++----- src/handlers/runtime/invoke/request.test.ts | 15 +- src/handlers/runtime/invoke/request.ts | 30 +--- src/handlers/runtime/invoke/response.test.ts | 60 +------ src/handlers/runtime/invoke/response.ts | 57 +++---- src/handlers/runtime/invoke/screen.tsx | 4 +- src/runnable/index.test.ts | 54 +++---- src/runnable/index.tsx | 14 +- 13 files changed, 211 insertions(+), 306 deletions(-) diff --git a/src/core/abortable.ts b/src/core/abortable.ts index 4a0a20dd0..7c81aab61 100644 --- a/src/core/abortable.ts +++ b/src/core/abortable.ts @@ -1,16 +1,13 @@ -export function abortError(): Error { - const error = new Error("The operation was aborted"); - error.name = "AbortError"; - return error; -} - export async function* abortable( source: AsyncIterable, signal: AbortSignal, ): AsyncGenerator { + let abort = () => {}; const aborted = new Promise((_, reject) => { - if (signal.aborted) reject(abortError()); - else signal.addEventListener("abort", () => reject(abortError()), { once: true }); + abort = () => + reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError")); + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); }); aborted.catch(() => {}); @@ -22,6 +19,7 @@ export async function* abortable( yield result.value; } } finally { + signal.removeEventListener("abort", abort); void iterator.return?.()?.catch(() => {}); } } diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 59b7a59e0..8464a689a 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -12,7 +12,7 @@ import { } from "@aws-sdk/client-bedrock-agentcore"; import type { RuntimeInvokeRequest } from "../handlers/runtime/types"; import { CoreClient } from "./index"; -import type { ClientConfig } from "./types"; +import type { ClientConfig, RuntimeFetch } from "./types"; import { toClientConfig } from "./utils"; import { createSilentLogger } from "../testing"; @@ -28,6 +28,24 @@ function fakeIam(config: ClientConfig): IAMClient { return { config, kind: "iam" } as unknown as IAMClient; } +function customJwtCore(fetch: RuntimeFetch, endpoint = "https://runtime.test"): CoreClient { + return new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => + ({ + config: { + endpointProvider: () => ({ url: new URL(config.endpoint ?? endpoint) }), + }, + send: async () => { + throw new Error("IAM transport must not be used"); + }, + }) as unknown as BedrockAgentCoreClient, + createIamClient: fakeIam, + fetch, + logger: createSilentLogger(), + }); +} + test("control() constructs a client once per config and caches it", () => { let built = 0; const core = new CoreClient({ @@ -103,8 +121,8 @@ test("exposes a harness sub-client", () => { test("getRuntime sends the abort signal to the control client", async () => { const sent: { command: unknown; options: unknown }[] = []; - const core = new CoreClient( - (config) => + const core = new CoreClient({ + createControlClient: (config) => ({ config, send: async (command: unknown, options: unknown) => { @@ -112,9 +130,10 @@ test("getRuntime sends the abort signal to the control client", async () => { return {}; }, }) as unknown as BedrockAgentCoreControlClient, - fakeData, - fakeIam, - ); + createDataClient: fakeData, + createIamClient: fakeIam, + logger: createSilentLogger(), + }); const controller = new AbortController(); await core.runtime.getRuntime("runtime-123", { region: "us-east-1" }, controller.signal); @@ -354,9 +373,9 @@ test("invokeRuntime aborts an established IAM response stream", async () => { yield Buffer.from("partial"); await new Promise(() => {}); })(); - const core = new CoreClient( - fakeControl, - (config) => + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => ({ config, kind: "data", @@ -366,8 +385,9 @@ test("invokeRuntime aborts an established IAM response stream", async () => { response: source, }), }) as unknown as BedrockAgentCoreClient, - fakeIam, - ); + createIamClient: fakeIam, + logger: createSilentLogger(), + }); const controller = new AbortController(); const response = await core.runtime.invokeRuntime( { @@ -396,6 +416,36 @@ test("invokeRuntime aborts an established IAM response stream", async () => { expect(result).toBe("AbortError"); }); +test("IAM invoke failures do not expose arbitrary causes", async () => { + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => + ({ + config, + send: async () => { + throw Object.assign(new Error("failed with secret request content"), { + name: "AbortError", + }); + }, + }) as unknown as BedrockAgentCoreClient, + createIamClient: fakeIam, + logger: createSilentLogger(), + }); + + await expect( + core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + }, + { region: "us-east-1" }, + ), + ).rejects.toThrow(/^Runtime invocation failed$/); +}); + test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", async () => { const calls: { input: string | URL | Request; init?: RequestInit }[] = []; const controller = new AbortController(); @@ -410,23 +460,7 @@ test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", as }, }); }; - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config: { - endpointProvider: () => ({ - url: new URL(config.endpoint ?? "https://runtime.test/base"), - }), - }, - send: async () => { - throw new Error("IAM transport must not be used"); - }, - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - fetch, - logger: createSilentLogger(), - }); + const core = customJwtCore(fetch); const payload = Uint8Array.from([123, 0, 125]); const result = await core.runtime.invokeRuntime( @@ -491,18 +525,10 @@ test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", as test("CUSTOM_JWT rejects non-HTTPS endpoints without calling fetch", async () => { let fetched = false; - const core = new CoreClient( - fakeControl, - () => - ({ - config: { endpointProvider: () => ({ url: new URL("http://runtime.test") }) }, - }) as unknown as BedrockAgentCoreClient, - fakeIam, - async () => { - fetched = true; - return new Response(); - }, - ); + const core = customJwtCore(async () => { + fetched = true; + return new Response(); + }, "http://runtime.test"); await expect( core.runtime.invokeRuntime( @@ -522,18 +548,10 @@ test("CUSTOM_JWT rejects non-HTTPS endpoints without calling fetch", async () => test("CUSTOM_JWT modeled header failures do not expose their values", async () => { let fetched = false; - const core = new CoreClient( - fakeControl, - () => - ({ - config: { endpointProvider: () => ({ url: new URL("https://runtime.test") }) }, - }) as unknown as BedrockAgentCoreClient, - fakeIam, - async () => { - fetched = true; - return new Response(); - }, - ); + const core = customJwtCore(async () => { + fetched = true; + return new Response(); + }); await expect( core.runtime.invokeRuntime( @@ -569,16 +587,7 @@ test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async }, }, } as unknown as Response; - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: () => - ({ - config: { endpointProvider: () => ({ url: new URL("https://runtime.test") }) }, - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - fetch: async () => response, - logger: createSilentLogger(), - }); + const core = customJwtCore(async () => response); const request: RuntimeInvokeRequest = { runtimeId: "runtime-123", accountId: "123456789012", @@ -596,17 +605,9 @@ test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async }); test("CUSTOM_JWT transport failures do not expose arbitrary causes", async () => { - const core = new CoreClient( - fakeControl, - () => - ({ - config: { endpointProvider: () => ({ url: new URL("https://runtime.test") }) }, - }) as unknown as BedrockAgentCoreClient, - fakeIam, - async () => { - throw new Error("failed with Bearer secret-token"); - }, - ); + const core = customJwtCore(async () => { + throw new Error("failed with Bearer secret-token"); + }); await expect( core.runtime.invokeRuntime( diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index 2126ceaa0..fcd35ead4 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -17,7 +17,7 @@ import type { RuntimeInvokeResponse, } from "../handlers/runtime/types"; import type { AwsClients, CoreOptions, RuntimeFetch } from "./types"; -import { abortable, abortError } from "./abortable"; +import { abortable } from "./abortable"; import { toClientConfig } from "./utils"; async function* emptyBody(): AsyncGenerator {} @@ -85,7 +85,7 @@ export class RuntimeClient implements CoreRuntimeClient { signal, }); } catch (error) { - if (signal?.aborted || (error as Error)?.name === "AbortError") throw abortError(); + if (signal?.aborted) throw signal.reason ?? error; throw new Error("Runtime invocation failed"); } if (!response.ok) { @@ -119,9 +119,15 @@ export class RuntimeClient implements CoreRuntimeClient { { step: "build", name: "runtimeApplicationHeaders" }, ); } - const response = await this.clients.data(toClientConfig(options)).send(command, { - abortSignal: signal, - }); + let response; + try { + response = await this.clients.data(toClientConfig(options)).send(command, { + abortSignal: signal, + }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + throw new Error("Runtime invocation failed"); + } const body = (response.response as AsyncIterable | undefined) ?? emptyBody(); return { diff --git a/src/core/types.tsx b/src/core/types.tsx index d0df8531e..9a9883a77 100644 --- a/src/core/types.tsx +++ b/src/core/types.tsx @@ -25,7 +25,9 @@ export interface ClientConfig { export type CreateControlClient = (config: ClientConfig) => BedrockAgentCoreControlClient; export type CreateDataClient = (config: ClientConfig) => BedrockAgentCoreClient; export type CreateIamClient = (config: ClientConfig) => IAMClient; -export type RuntimeFetch = (input: string | URL | Request, init?: RequestInit) => Promise; +export type RuntimeFetch = ( + ...args: Parameters +) => ReturnType; // AwsClients hands out configured SDK clients. CoreClient implements it and its // sub-clients (HarnessClient, etc.) consume it, so they all share the same diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 9ea25e959..3fc9984f0 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -156,6 +156,21 @@ describe("Runtime invoke routing", () => { await waitForText(screen.lastFrame, "back-to-endpoint-picker"); expect(screen.lastFrame()).toContain(`agentcore → runtime → invoke → ${RUNTIME_ID}`); }); + + test("unmount cancels the Runtime detail lookup", async () => { + let lookupSignal: AbortSignal | undefined; + const core = new TestCoreClient(); + core.runtime.getRuntime = async (_id, _options, signal) => { + lookupSignal = signal; + return new Promise(() => {}); + }; + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitFor(() => lookupSignal !== undefined); + screen.unmount(); + + await waitFor(() => lookupSignal!.aborted); + }); }); describe("Runtime invoke console", () => { @@ -748,6 +763,36 @@ describe("Runtime invoke console", () => { expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(0); }); + test("shows a CUSTOM_JWT HTTP status without exposing request or response secrets", async () => { + const token = "secret-bearer-token"; + const responseBody = "secret response body"; + const core = new TestCoreClient(); + core.runtime.setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + authorizerConfiguration: { customJWTAuthorizer: {} }, + } as GetAgentRuntimeResponse); + core.runtime.invokeRuntime = async () => { + throw Object.assign(new Error("HTTP 401"), { + cause: new Error(`Bearer ${token}: ${responseBody}`), + }); + }; + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request Options"); + await editText(screen, 7, token); + await screen.press("escape"); + await screen.write("{}"); + await screen.write("\x04"); + + await waitForText(screen.lastFrame, "HTTP 401"); + const frame = screen.lastFrame()!; + expect(frame).not.toContain("response stream failed"); + expect(frame).not.toContain(token); + expect(frame).not.toContain(responseBody); + }); + test("adopts returned sessions only after response completion", async () => { const requests: RuntimeInvokeRequest[] = []; const core = new TestCoreClient(); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index a10ce9d57..70fcd1d90 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -3,11 +3,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough, Writable } from "node:stream"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; -import { Command } from "commander"; import type { AppIO } from "../../types"; import type { RuntimeInvokeRequest } from "../types"; import { createSilentLogger, TestCoreClient, waitFor } from "../../../testing"; -import { compile, ValueContext } from "../../../router"; import { ExitCode, runWithExitCode } from "../../../runnable"; import { createRootHandler } from "../../index"; import * as tui from "../../../tui"; @@ -52,20 +50,9 @@ function failingStdoutIO(): AppIO { }; } -function commandFor(core: TestCoreClient, io: AppIO): Command { - const root = createRootHandler(core, { io, logger: createSilentLogger() }); - const command = compile(root, ValueContext.EmptyContext()); - const override = (current: Command): void => { - current.exitOverride(); - current.configureOutput({ writeOut: () => {}, writeErr: () => {} }); - current.commands.forEach(override); - }; - override(command); - return command; -} - async function runCommand(core: TestCoreClient, io: AppIO, args: string[]): Promise { - await commandFor(core, io).parseAsync(["node", "agentcore", ...args, "--region", REGION]); + const root = createRootHandler(core, { io, logger: createSilentLogger() }); + await root.route(["node", "agentcore", ...args, "--region", REGION]); } async function run( @@ -114,22 +101,6 @@ describe("runtime invoke", () => { expect(output.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); }); - test("passes an explicit qualifier", async () => { - const { core } = await run([ - "runtime", - "invoke", - "--id", - RUNTIME_ID, - "--payload", - "{}", - "--qualifier", - "prod", - ]); - - const invoke = core.runtime.calls.find((call) => call.method === "invokeRuntime")!; - expect((invoke.args[0] as RuntimeInvokeRequest).qualifier).toBe("prod"); - }); - test("passes the public request flags through the shared normalizer", async () => { const { core } = await run( [ @@ -139,6 +110,8 @@ describe("runtime invoke", () => { RUNTIME_ID, "--payload", "{}", + "--qualifier", + "prod", "--content-type", "text/plain", "--session-id", @@ -163,6 +136,7 @@ describe("runtime invoke", () => { const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! .args[0] as RuntimeInvokeRequest; expect(request).toMatchObject({ + qualifier: "prod", contentType: "text/plain", runtimeSessionId: "runtime-session", applicationHeaders: [["X-Tenant", "retail"]], @@ -329,21 +303,9 @@ describe("runtime invoke", () => { agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}::runtime/${RUNTIME_ID}`, } as GetAgentRuntimeResponse); const output = captureIO(); - const command = commandFor(core, output.io); await expect( - command.parseAsync([ - "node", - "agentcore", - "runtime", - "invoke", - "--id", - RUNTIME_ID, - "--payload", - "{}", - "--region", - REGION, - ]), + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]), ).rejects.toThrow("Runtime returned an invalid ARN"); expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime"]); }); @@ -351,11 +313,10 @@ describe("runtime invoke", () => { test("a bare command enters existing TUI middleware without Runtime Core calls", async () => { const core = new TestCoreClient(); const output = captureIO(); - const command = commandFor(core, output.io); - await expect( - command.parseAsync(["node", "agentcore", "runtime", "invoke", "--region", REGION]), - ).rejects.toThrow("interactive mode requires a TTY on stdin and stdout"); + await expect(runCommand(core, output.io, ["runtime", "invoke"])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); expect(core.runtime.calls).toEqual([]); }); diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index 98fc7b2ad..4d30f17e0 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -72,14 +72,13 @@ describe("resolveRuntimeInvokeSources", () => { } }); - test.each([ - ["inline", "secret-inline", undefined, "secret-inline"], - ["file", `file://${FILE}`, undefined, "secret-file"], - ["stdin", "-", stdin(new TextEncoder().encode("secret-stdin")), "secret-stdin"], - ])("resolves a bearer token from %s", async (_name, source, input, expected) => { - if (source.startsWith("file://")) await Bun.write(FILE, expected); - const result = await resolveRuntimeInvokeSources({ payload: "{}", bearerToken: source }, input); - expect(result.bearerToken).toBe(expected); + test("decodes a bearer token source as UTF-8", async () => { + const token = "secret-€"; + const result = await resolveRuntimeInvokeSources( + { payload: "{}", bearerToken: "-" }, + stdin(new TextEncoder().encode(token)), + ); + expect(result.bearerToken).toBe(token); }); test("rejects dual stdin before reading", async () => { diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index aa749fc61..4a864a79d 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import { validateHeaderName, validateHeaderValue } from "node:http"; import { addAbortSignal } from "node:stream"; +import { buffer } from "node:stream/consumers"; import z from "zod"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import type { RuntimeInvokeRequest } from "../types"; @@ -9,25 +10,8 @@ export const runtimeIdSchema = z .string() .refine((value) => !value.startsWith("arn:"), "must be a Runtime ID, not an ARN"); -interface RuntimeInvokeInput { - runtimeId: string; - qualifier?: string; - payload: Uint8Array; - contentType?: string; - accept?: string; - runtimeSessionId?: string; - runtimeUserId?: string; - applicationHeaders?: [string, string][]; - bearerToken?: string; - mcpSessionId?: string; - mcpProtocolVersion?: string; - mcpMethod?: string; - mcpName?: string; - traceId?: string; - traceParent?: string; - traceState?: string; - baggage?: string; -} +type RuntimeInvokeInput = Omit & + Partial>; const CUSTOM_HEADER_PREFIX = "x-amzn-bedrock-agentcore-runtime-custom-"; const RESERVED_HEADERS = new Set([ @@ -65,14 +49,8 @@ async function readSource( if (source !== "-") return new TextEncoder().encode(source); if (!input) throw new TypeError("stdin is not available for this request source"); - const chunks: Uint8Array[] = []; const stream = signal ? addAbortSignal(signal, input) : input; - for await (const chunk of stream) { - chunks.push( - typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk), - ); - } - return Buffer.concat(chunks); + return buffer(stream); } export async function resolveRuntimeInvokeSources( diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index 200d79f41..8cf2812a5 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -2,8 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { PassThrough, Writable } from "node:stream"; -import { finished } from "node:stream/promises"; +import { PassThrough } from "node:stream"; import type { RuntimeInvokeResponse } from "../types"; import { writeRuntimeInvokeResponse } from "./response"; @@ -45,31 +44,10 @@ function capture() { }; } -function backpressuredCapture() { - const chunks: Buffer[] = []; - const stream = new Writable({ - highWaterMark: 32, - write(chunk, _encoding, callback) { - setTimeout(() => { - chunks.push(Buffer.from(chunk)); - callback(); - }, 10); - }, - }); - return { - stream: stream as unknown as NodeJS.WriteStream, - bytes: () => Buffer.concat(chunks), - close: async () => { - stream.end(); - await finished(stream); - }, - }; -} - describe("Runtime invoke response output", () => { test("streams exact chunks to stdout, reports metadata, and leaves stdout open", async () => { const stdout = capture(); - const stderr = backpressuredCapture(); + const stderr = capture(); const result = response({ statusCode: 206, runtimeSessionId: "runtime-session", @@ -87,10 +65,8 @@ describe("Runtime invoke response output", () => { stderr: stderr.stream, }); - const stderrBytes = stderr.bytes(); - await stderr.close(); expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); - expect(stderrBytes.toString()).toBe( + expect(stderr.bytes().toString()).toBe( "status=206 content-type=text/plain runtime-session-id=runtime-session " + "mcp-session-id=mcp-session mcp-protocol-version=2025-06-18 trace-id=trace-id " + "trace-parent=trace-parent trace-state=trace-state baggage=tenant=retail " + @@ -165,29 +141,6 @@ describe("Runtime invoke response output", () => { expect(stderr.bytes()).toHaveLength(0); }); - test("waits for a large JSON envelope to reach a backpressured stdout", async () => { - const stdout = backpressuredCapture(); - const stderr = capture(); - const responseBytes = Buffer.alloc(4 * 1024 * 1024, "x"); - - await writeRuntimeInvokeResponse( - response({ contentType: "text/plain", body: body(responseBytes) }), - { - stdout: stdout.stream, - stderr: stderr.stream, - json: true, - }, - ); - const delivered = stdout.bytes(); - await stdout.close(); - - expect(JSON.parse(delivered.toString())).toMatchObject({ - bodyEncoding: "utf8", - body: responseBytes.toString(), - complete: true, - }); - }); - test("preserves partial stdout and reports one static incomplete summary", async () => { const stdout = capture(); const stderr = capture(); @@ -212,7 +165,7 @@ describe("Runtime invoke response output", () => { test("writes an interruption summary after the output signal is aborted", async () => { const controller = new AbortController(); const stdout = capture(); - const stderr = backpressuredCapture(); + const stderr = capture(); const source = (async function* () { yield Buffer.from("partial"); controller.abort(); @@ -226,10 +179,7 @@ describe("Runtime invoke response output", () => { signal: controller.signal, }), ).rejects.toMatchObject({ name: "AbortError" }); - const delivered = stderr.bytes(); - await stderr.close(); - - expect(delivered.toString()).toBe( + expect(stderr.bytes().toString()).toBe( "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + "complete=false bytes=7 error=interrupted\n", diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index b923d928b..05172f8d0 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -36,13 +36,11 @@ async function writeText( text: string, signal?: AbortSignal, ): Promise { - await pipeline( - (async function* () { - yield text; - })(), - stream, - { end: false, signal }, - ); + try { + await pipeline([text], stream, { end: false, signal }); + } catch (error) { + failure(error); + } } export async function writeRuntimeInvokeFile( @@ -50,17 +48,14 @@ export async function writeRuntimeInvokeFile( path: string, signal?: AbortSignal, onBytes?: (size: number) => void, -): Promise { - let byteCount = 0; +): Promise { await pipeline( countBytes(response.body, (size) => { - byteCount += size; onBytes?.(size); }), createWriteStream(path), { signal }, ); - return byteCount; } function failure(error: unknown): never { @@ -106,11 +101,7 @@ async function writeJsonResponse( error: (streamError as Error)?.name === "AbortError" ? "interrupted" : RESPONSE_STREAM_FAILED, }), }); - try { - await writeText(output.stdout, envelope, streamError === undefined ? output.signal : undefined); - } catch (error) { - failure(error); - } + await writeText(output.stdout, envelope, streamError === undefined ? output.signal : undefined); if (streamError !== undefined) failure(streamError); } @@ -146,11 +137,7 @@ export async function writeRuntimeInvokeResponse( output.stdout.isTTY && classifyRuntimeResponse(response.contentType) === "binary" ) { - try { - await writeText(output.stderr, summary(response, 0, false)); - } catch (error) { - failure(error); - } + await writeText(output.stderr, summary(response, 0, false)); throw new TypeError("Binary or unknown response content requires --output-file or --json"); } @@ -171,24 +158,16 @@ export async function writeRuntimeInvokeResponse( ); } } catch (error) { - try { - await writeText( - output.stderr, - summary( - response, - byteCount, - false, - (error as Error)?.name === "AbortError" ? "interrupted" : "response-stream-failed", - ), - ); - } catch (summaryError) { - failure(summaryError); - } - failure(error); - } - try { - await writeText(output.stderr, summary(response, byteCount, true)); - } catch (error) { + await writeText( + output.stderr, + summary( + response, + byteCount, + false, + (error as Error)?.name === "AbortError" ? "interrupted" : "response-stream-failed", + ), + ); failure(error); } + await writeText(output.stderr, summary(response, byteCount, true)); } diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index c983aa1d7..da1dedc3c 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -91,7 +91,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const [switchRuntime, setSwitchRuntime] = useState(null); const detail = useQuery({ queryKey: ["runtime", opts.region, target.runtimeId], - queryFn: () => core.runtime.getRuntime(target.runtimeId, opts), + queryFn: ({ signal }) => core.runtime.getRuntime(target.runtimeId, opts, signal), }); const customJwt = detail.data?.authorizerConfiguration !== undefined && @@ -238,6 +238,8 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke updateExchange({ note: "interrupted", state: "interrupted" }); } else if (localRequest && error instanceof TypeError) { updateExchange({ response: `Error: ${error.message}`, state: "failed" }); + } else if (/^HTTP \d{3}$/.test((error as Error)?.message)) { + updateExchange({ note: (error as Error).message, state: "failed" }); } else { updateExchange({ note: "response stream failed", state: "failed" }); } diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index 839f84752..6fd4125bc 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -1,4 +1,5 @@ import { test, expect } from "bun:test"; +import { CommanderError } from "commander"; import { runRunnable, runWithExitCode, ExitCode, type Runnable } from "./index.tsx"; @@ -42,37 +43,24 @@ test("runWithExitCode returns SUCCESS for a resolving function", async () => { expect(code).toBe(ExitCode.SUCCESS); }); -test("runWithExitCode preserves explicit usage and interruption exit codes", async () => { - const usageError = Object.assign(new Error("bad request"), { exitCode: 2 }); - const abortError = new Error("The operation was aborted"); - abortError.name = "AbortError"; - - expect(await runWithExitCode(async () => Promise.reject(usageError))).toBe(2); - expect(await runWithExitCode(async () => Promise.reject(abortError))).toBe(130); -}); - -test("runWithExitCode maps Commander parse failures to usage errors", async () => { - const commanderError = Object.assign(new Error("invalid option"), { - code: "commander.invalidArgument", - exitCode: 1, - }); - - expect(await runWithExitCode(async () => Promise.reject(commanderError))).toBe(2); -}); - -test("runWithExitCode preserves Commander's successful help exit", async () => { - const helpDisplayed = Object.assign(new Error("help displayed"), { - code: "commander.helpDisplayed", - exitCode: 0, - }); - - expect(await runWithExitCode(async () => Promise.reject(helpDisplayed))).toBe(0); -}); - -test("runWithExitCode does not infer usage from arbitrary TypeErrors", async () => { - expect( - await runWithExitCode(async () => { - throw new TypeError("transport failed"); - }), - ).toBe(ExitCode.FAILURE); +test.each([ + ["explicit usage", Object.assign(new Error("bad request"), { exitCode: 2 }), ExitCode.USAGE], + [ + "interruption", + Object.assign(new Error("The operation was aborted"), { name: "AbortError" }), + ExitCode.INTERRUPTED, + ], + [ + "Commander parse failure", + new CommanderError(1, "commander.invalidArgument", "invalid option"), + ExitCode.USAGE, + ], + [ + "Commander help", + new CommanderError(0, "commander.helpDisplayed", "help displayed"), + ExitCode.SUCCESS, + ], + ["arbitrary TypeError", new TypeError("transport failed"), ExitCode.FAILURE], +])("runWithExitCode maps %s", async (_name, error, expected) => { + expect(await runWithExitCode(async () => Promise.reject(error))).toBe(expected); }); diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index a83db8bae..b932046be 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -1,3 +1,5 @@ +import { CommanderError } from "commander"; + // ExitCode provides names for default Unix exit codes. export enum ExitCode { SUCCESS = 0, @@ -11,14 +13,8 @@ export interface Runnable { run(argv: string[]): Promise; } -export function isCommanderError(error: unknown): error is { code: string } { - return ( - typeof error === "object" && - error !== null && - "code" in error && - typeof error.code === "string" && - error.code.startsWith("commander.") - ); +export function isCommanderError(error: unknown): error is CommanderError { + return error instanceof CommanderError; } // runRunnable creates and runs any instance of Runnable with proper exit code handling. @@ -41,7 +37,7 @@ export async function runWithExitCode( return ExitCode.SUCCESS; } catch (error) { if (isCommanderError(error)) { - return "exitCode" in error && error.exitCode === 0 ? ExitCode.SUCCESS : ExitCode.USAGE; + return error.exitCode === 0 ? ExitCode.SUCCESS : ExitCode.USAGE; } if ( typeof error === "object" && From f72a58d354a9ebe40a1653b30965c6f7c9accc5f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 04:39:27 +0000 Subject: [PATCH 27/35] refactor(runtime): remove redundant invoke guards --- src/handlers/runtime/invoke/screen.tsx | 11 +---------- src/index.ts | 5 +++-- src/runnable/index.tsx | 6 +----- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index da1dedc3c..bb60c7e74 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -106,22 +106,14 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const [showOptions, setShowOptions] = useState(false); const [history, setHistory] = useState([]); const [prettyJson, setPrettyJson] = useState(false); - const aliveRef = useRef(true); const abortRef = useRef(null); const scrollRef = useRef(null); - useEffect( - () => () => { - aliveRef.current = false; - abortRef.current?.abort(); - }, - [], - ); + useEffect(() => () => abortRef.current?.abort(), []); useEffect(() => scrollRef.current?.scrollToBottom(), [history]); const updateExchange = (patch: Partial) => { - if (!aliveRef.current) return; setHistory((current) => current.slice(0, -1).concat({ ...current.at(-1)!, ...patch })); }; @@ -200,7 +192,6 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const chunks: Uint8Array[] = []; let responseText = ""; for await (const chunk of response.body) { - if (!aliveRef.current) return; const snapshot = Uint8Array.from(chunk); chunks.push(snapshot); byteCount += snapshot.byteLength; diff --git a/src/index.ts b/src/index.ts index 87a8a8290..a40789a6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,13 @@ import { homedir } from "os"; import { join } from "path"; +import { CommanderError } from "commander"; import { CoreClient } from "./core"; import { createControlClient, createDataClient, createIamClient } from "./core/factories"; import { createRootHandler } from "./handlers"; import { createFileLogger, LOG_LEVEL } from "./logging"; -import { isCommanderError, runWithExitCode } from "./runnable"; +import { runWithExitCode } from "./runnable"; process.exit( await runWithExitCode(async (argv: string[]) => { @@ -55,7 +56,7 @@ process.exit( await rootHandler.route(argv); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); - if ((e as { reported?: boolean })?.reported !== true && !isCommanderError(e)) { + if ((e as { reported?: boolean })?.reported !== true && !(e instanceof CommanderError)) { io.stderr.write(`${error.name}: ${error.message}\n`); } rootLogger diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index b932046be..4d9ea94fd 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -13,10 +13,6 @@ export interface Runnable { run(argv: string[]): Promise; } -export function isCommanderError(error: unknown): error is CommanderError { - return error instanceof CommanderError; -} - // runRunnable creates and runs any instance of Runnable with proper exit code handling. export function runRunnable( createRunnable: () => Runnable, @@ -36,7 +32,7 @@ export async function runWithExitCode( await fn(argv); return ExitCode.SUCCESS; } catch (error) { - if (isCommanderError(error)) { + if (error instanceof CommanderError) { return error.exitCode === 0 ? ExitCode.SUCCESS : ExitCode.USAGE; } if ( From 1fe8f8fa42c5963f62d92ca662a0c9fbe6392eba Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 15:06:54 +0000 Subject: [PATCH 28/35] fix(runtime): tighten invoke error handling --- src/core/core.test.ts | 22 ++++-- src/core/runtime.tsx | 18 ++++- src/handlers/runtime/invoke/index.tsx | 78 ++++++++------------- src/handlers/runtime/invoke/request.test.ts | 30 ++++++++ src/handlers/runtime/invoke/request.ts | 39 ++++++----- 5 files changed, 116 insertions(+), 71 deletions(-) diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 8464a689a..317153c18 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -416,7 +416,7 @@ test("invokeRuntime aborts an established IAM response stream", async () => { expect(result).toBe("AbortError"); }); -test("IAM invoke failures do not expose arbitrary causes", async () => { +test("IAM invoke failures preserve safe SDK diagnostics without arbitrary causes", async () => { const core = new CoreClient({ createControlClient: fakeControl, createDataClient: (config) => @@ -424,7 +424,11 @@ test("IAM invoke failures do not expose arbitrary causes", async () => { config, send: async () => { throw Object.assign(new Error("failed with secret request content"), { - name: "AbortError", + name: "AccessDeniedException", + $metadata: { + httpStatusCode: 403, + requestId: "request-123", + }, }); }, }) as unknown as BedrockAgentCoreClient, @@ -432,8 +436,8 @@ test("IAM invoke failures do not expose arbitrary causes", async () => { logger: createSilentLogger(), }); - await expect( - core.runtime.invokeRuntime( + const caught = await core.runtime + .invokeRuntime( { runtimeId: "runtime-123", accountId: "123456789012", @@ -442,8 +446,14 @@ test("IAM invoke failures do not expose arbitrary causes", async () => { contentType: "application/json", }, { region: "us-east-1" }, - ), - ).rejects.toThrow(/^Runtime invocation failed$/); + ) + .catch((caught: Error) => caught); + const error = caught as Error; + + expect(error.message).toBe( + "Runtime invocation failed (AccessDeniedException, HTTP 403, request ID request-123)", + ); + expect(error.message).not.toContain("secret request content"); }); test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", async () => { diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index fcd35ead4..71e813b4f 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -126,7 +126,23 @@ export class RuntimeClient implements CoreRuntimeClient { }); } catch (error) { if (signal?.aborted) throw signal.reason ?? error; - throw new Error("Runtime invocation failed"); + const sdkError = error as { + name?: unknown; + $metadata?: { httpStatusCode?: unknown; requestId?: unknown }; + }; + const diagnostics: string[] = []; + if (typeof sdkError?.name === "string" && sdkError.name !== "Error") { + diagnostics.push(sdkError.name); + } + if (typeof sdkError?.$metadata?.httpStatusCode === "number") { + diagnostics.push(`HTTP ${sdkError.$metadata.httpStatusCode}`); + } + if (typeof sdkError?.$metadata?.requestId === "string") { + diagnostics.push(`request ID ${sdkError.$metadata.requestId}`); + } + throw new Error( + `Runtime invocation failed${diagnostics.length ? ` (${diagnostics.join(", ")})` : ""}`, + ); } const body = (response.response as AsyncIterable | undefined) ?? emptyBody(); diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 1b6014ecd..7db0deebd 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -9,13 +9,10 @@ import { parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, runtimeIdSchema, + UsageError, } from "./request"; import { writeRuntimeInvokeResponse } from "./response"; -function usageError(error: string | TypeError): TypeError { - return Object.assign(typeof error === "string" ? new TypeError(error) : error, { exitCode: 2 }); -} - export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => createHandler({ name: "invoke", @@ -51,14 +48,14 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => ], handle: async (ctx, flags) => { if (flags.id === undefined) { - throw usageError("required option '--id ' not specified"); + throw new UsageError("required option '--id ' not specified"); } if (flags.payload === undefined) { const requestOption = Object.entries(flags).some( ([name, value]) => !["id", "qualifier", "payload"].includes(name) && value !== undefined, ); if (ctx.require(JsonKey) || requestOption) { - throw usageError("required option '--payload ' not specified"); + throw new UsageError("required option '--payload ' not specified"); } let path = `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`; if (flags.qualifier !== undefined) { @@ -71,7 +68,7 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => error instanceof TypeError && error.message === "interactive mode requires a TTY on stdin and stdout" ) { - throw usageError(error); + throw new UsageError(error.message); } throw error; } @@ -80,55 +77,42 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => const jsonOutput = ctx.require(JsonKey); if (jsonOutput && flags["output-file"] !== undefined) { - throw usageError("--json cannot be used with --output-file"); + throw new UsageError("--json cannot be used with --output-file"); } if (flags["output-file"] === "") { - throw usageError("--output-file requires a nonempty path"); + throw new UsageError("--output-file requires a nonempty path"); } const controller = new AbortController(); const interrupt = () => controller.abort(); process.once("SIGINT", interrupt); try { - let applicationHeaders: [string, string][]; - let sources: Awaited>; - try { - applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - sources = await resolveRuntimeInvokeSources( - { payload: flags.payload, bearerToken: flags["bearer-token"] }, - io.stdin, - controller.signal, - ); - } catch (error) { - if (error instanceof TypeError) throw usageError(error); - throw error; - } + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const sources = await resolveRuntimeInvokeSources( + { payload: flags.payload, bearerToken: flags["bearer-token"] }, + io.stdin, + controller.signal, + ); const options = coreOptsFromCtx(ctx); const runtime = await core.runtime.getRuntime(flags.id, options, controller.signal); - let request: ReturnType; - try { - request = normalizeRuntimeInvokeRequest(runtime, { - runtimeId: flags.id, - qualifier: flags.qualifier, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - bearerToken: sources.bearerToken, - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - mcpMethod: flags["mcp-method"], - mcpName: flags["mcp-name"], - traceId: flags["trace-id"], - traceParent: flags["trace-parent"], - traceState: flags["trace-state"], - baggage: flags.baggage, - }); - } catch (error) { - if (error instanceof TypeError) throw usageError(error); - throw error; - } + const request = normalizeRuntimeInvokeRequest(runtime, { + runtimeId: flags.id, + qualifier: flags.qualifier, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken: sources.bearerToken, + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + mcpMethod: flags["mcp-method"], + mcpName: flags["mcp-name"], + traceId: flags["trace-id"], + traceParent: flags["trace-parent"], + traceState: flags["trace-state"], + baggage: flags.baggage, + }); const response = await core.runtime.invokeRuntime(request, options, controller.signal); try { await writeRuntimeInvokeResponse(response, { diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index 4d30f17e0..1625cced9 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -7,6 +7,7 @@ import { normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, + UsageError, } from "./request"; const REGION = "us-west-2"; @@ -103,6 +104,35 @@ describe("resolveRuntimeInvokeSources", () => { }); }); +describe("parseRuntimeInvokeHeaders", () => { + test("brands validation failures as usage errors", () => { + let error: unknown; + try { + parseRuntimeInvokeHeaders(["missing separator"]); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(UsageError); + expect(error).toMatchObject({ name: "TypeError", exitCode: 2 }); + }); + + test("accepts and rejects the exact count and byte boundaries", () => { + const twenty = Array.from({ length: 20 }, (_, index) => `X-Test-${index}: value`); + expect(parseRuntimeInvokeHeaders(twenty)).toHaveLength(20); + expect(() => parseRuntimeInvokeHeaders([...twenty, "X-Test-20: value"])).toThrow( + "At most 20 application headers are allowed", + ); + + expect(parseRuntimeInvokeHeaders([`X-Test: ${"x".repeat(4096)}`])).toEqual([ + ["X-Test", "x".repeat(4096)], + ]); + expect(() => parseRuntimeInvokeHeaders([`X-Test: ${"x".repeat(4097)}`])).toThrow( + "Application header value exceeds 4096 bytes", + ); + }); +}); + describe("normalizeRuntimeInvokeRequest", () => { test.each([ [ diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index 4a864a79d..fe03d23af 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -32,6 +32,10 @@ const RESERVED_HEADERS = new Set([ "baggage", ]); +export class UsageError extends TypeError { + readonly exitCode = 2; +} + async function readSource( source: string, input?: NodeJS.ReadStream, @@ -43,11 +47,11 @@ async function readSource( return await readFile(source.slice("file://".length), { signal }); } catch (error) { if ((error as Error)?.name === "AbortError") throw error; - throw new TypeError("Unable to read request source file"); + throw new UsageError("Unable to read request source file"); } } if (source !== "-") return new TextEncoder().encode(source); - if (!input) throw new TypeError("stdin is not available for this request source"); + if (!input) throw new UsageError("stdin is not available for this request source"); const stream = signal ? addAbortSignal(signal, input) : input; return buffer(stream); @@ -59,7 +63,7 @@ export async function resolveRuntimeInvokeSources( signal?: AbortSignal, ): Promise<{ payload: Uint8Array; bearerToken?: string }> { if (sources.payload === "-" && sources.bearerToken === "-") { - throw new TypeError("Payload and bearer token cannot both read from stdin"); + throw new UsageError("Payload and bearer token cannot both read from stdin"); } const payload = await readSource(sources.payload, stdin, signal); @@ -69,32 +73,33 @@ export async function resolveRuntimeInvokeSources( } export function parseRuntimeInvokeHeaders(values: string[] = []): [string, string][] { - if (values.length > 20) throw new TypeError("At most 20 application headers are allowed"); + if (values.length > 20) throw new UsageError("At most 20 application headers are allowed"); const seen = new Set(); return values.map((header) => { const separator = header.indexOf(":"); - if (separator < 1) throw new TypeError("Header must use 'Name: value' format"); + if (separator < 1) throw new UsageError("Header must use 'Name: value' format"); const name = header.slice(0, separator).trim(); const value = header.slice(separator + 1).trim(); try { validateHeaderName(name); } catch { - throw new TypeError(`Invalid HTTP header name: ${name}`); + throw new UsageError(`Invalid HTTP header name: ${name}`); } try { validateHeaderValue(name, value); } catch { - throw new TypeError(`Invalid header value for ${name}`); + throw new UsageError(`Invalid header value for ${name}`); } if (Buffer.byteLength(value) > 4096) { - throw new TypeError(`Application header value exceeds 4096 bytes: ${name}`); + throw new UsageError(`Application header value exceeds 4096 bytes: ${name}`); } const lower = name.toLowerCase(); - if (seen.has(lower)) throw new TypeError(`Duplicate header: ${name}`); + if (seen.has(lower)) throw new UsageError(`Duplicate header: ${name}`); seen.add(lower); - if (RESERVED_HEADERS.has(lower)) throw new TypeError(`Application header is reserved: ${name}`); + if (RESERVED_HEADERS.has(lower)) + throw new UsageError(`Application header is reserved: ${name}`); return [name, value]; }); } @@ -113,7 +118,7 @@ function validateAllowedHeaders( for (const [name] of headers) { const lower = name.toLowerCase(); if (!lower.startsWith(CUSTOM_HEADER_PREFIX) && !allowlist.includes(lower)) { - throw new TypeError(`Application header is not allowed: ${name}`); + throw new UsageError(`Application header is not allowed: ${name}`); } } } @@ -126,18 +131,18 @@ export function normalizeRuntimeInvokeRequest( /^arn:[^:]+:bedrock-agentcore:[^:]*:(\d{12}):runtime\//, )?.[1]; if (!accountId) { - throw new TypeError("Runtime returned an invalid ARN"); + throw new UsageError("Runtime returned an invalid ARN"); } const authorizer = detail.authorizerConfiguration; const customJwt = authorizer !== undefined && "customJWTAuthorizer" in authorizer; - if (authorizer && !customJwt) throw new TypeError("Runtime uses an unsupported authorizer"); + if (authorizer && !customJwt) throw new UsageError("Runtime uses an unsupported authorizer"); const { runtimeId, qualifier, payload, contentType, applicationHeaders = [], ...modeled } = input; if (customJwt && !modeled.bearerToken) { - throw new TypeError("CUSTOM_JWT Runtime requires --bearer-token"); + throw new UsageError("CUSTOM_JWT Runtime requires --bearer-token"); } if (!customJwt && modeled.bearerToken !== undefined) { - throw new TypeError("IAM Runtime does not accept --bearer-token"); + throw new UsageError("IAM Runtime does not accept --bearer-token"); } const mcp = detail.protocolConfiguration?.serverProtocol === "MCP"; @@ -148,10 +153,10 @@ export function normalizeRuntimeInvokeRequest( modeled.mcpName, ]; if (!mcp && mcpValues.some((value) => value !== undefined)) { - throw new TypeError("MCP options are only valid for MCP Runtimes"); + throw new UsageError("MCP options are only valid for MCP Runtimes"); } if (modeled.mcpName !== undefined && modeled.mcpMethod === undefined) { - throw new TypeError("--mcp-name requires --mcp-method"); + throw new UsageError("--mcp-name requires --mcp-method"); } validateAllowedHeaders(detail, applicationHeaders); From 049be163b0f0ff4aa6d1993a4c041ef44e282e45 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 19:22:25 +0000 Subject: [PATCH 29/35] fix(runtime): remove arbitrary header limits --- src/handlers/runtime/invoke/invoke.test.tsx | 4 +--- src/handlers/runtime/invoke/request.test.ts | 22 ++++++++++----------- src/handlers/runtime/invoke/request.ts | 5 ----- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 70fcd1d90..d2735f676 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -355,12 +355,10 @@ describe("runtime invoke", () => { expect(core.runtime.calls).toEqual([]); }); - test.each([ + test.each<[string, ...string[]]>([ ["malformed", "missing separator"], ["duplicate", "X-Test: one", "x-test: two"], ["reserved", "Authorization: secret"], - ["count-limited", ...Array.from({ length: 21 }, (_, index) => `X-Test-${index}: value`)], - ["byte-limited", `X-Test: ${"é".repeat(2049)}`], ])("rejects %s headers as usage before Runtime Core calls", async (_name, ...headers) => { const core = new TestCoreClient(); const output = captureIO(); diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index 1625cced9..be279cc21 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -117,19 +117,17 @@ describe("parseRuntimeInvokeHeaders", () => { expect(error).toMatchObject({ name: "TypeError", exitCode: 2 }); }); - test("accepts and rejects the exact count and byte boundaries", () => { - const twenty = Array.from({ length: 20 }, (_, index) => `X-Test-${index}: value`); - expect(parseRuntimeInvokeHeaders(twenty)).toHaveLength(20); - expect(() => parseRuntimeInvokeHeaders([...twenty, "X-Test-20: value"])).toThrow( - "At most 20 application headers are allowed", - ); + test("does not impose client-only count or value-size limits", () => { + const largeValue = "x".repeat(4097); + const headers = [ + ...Array.from({ length: 20 }, (_, index) => `X-Test-${index}: value`), + `X-Large: ${largeValue}`, + ]; - expect(parseRuntimeInvokeHeaders([`X-Test: ${"x".repeat(4096)}`])).toEqual([ - ["X-Test", "x".repeat(4096)], - ]); - expect(() => parseRuntimeInvokeHeaders([`X-Test: ${"x".repeat(4097)}`])).toThrow( - "Application header value exceeds 4096 bytes", - ); + const parsed = parseRuntimeInvokeHeaders(headers); + + expect(parsed).toHaveLength(21); + expect(parsed.at(-1)).toEqual(["X-Large", largeValue]); }); }); diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index fe03d23af..c5abdfa1d 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -73,7 +73,6 @@ export async function resolveRuntimeInvokeSources( } export function parseRuntimeInvokeHeaders(values: string[] = []): [string, string][] { - if (values.length > 20) throw new UsageError("At most 20 application headers are allowed"); const seen = new Set(); return values.map((header) => { @@ -91,10 +90,6 @@ export function parseRuntimeInvokeHeaders(values: string[] = []): [string, strin } catch { throw new UsageError(`Invalid header value for ${name}`); } - if (Buffer.byteLength(value) > 4096) { - throw new UsageError(`Application header value exceeds 4096 bytes: ${name}`); - } - const lower = name.toLowerCase(); if (seen.has(lower)) throw new UsageError(`Duplicate header: ${name}`); seen.add(lower); From d404b33ca4bc1e893ceea912b9554b762eff9777 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 19:59:41 +0000 Subject: [PATCH 30/35] refactor(runtime): simplify invoke validation and cleanup --- src/handlers/runtime/invoke/index.tsx | 20 ++++++++------------ src/handlers/runtime/invoke/request.test.ts | 16 +--------------- src/handlers/runtime/invoke/request.ts | 3 --- src/handlers/runtime/invoke/screen.tsx | 5 ++--- 4 files changed, 11 insertions(+), 33 deletions(-) diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 7db0deebd..39fa30dc5 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -114,19 +114,15 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => baggage: flags.baggage, }); const response = await core.runtime.invokeRuntime(request, options, controller.signal); - try { - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal: controller.signal, - }); - } catch (error) { - controller.abort(); - throw error; - } + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal: controller.signal, + }); } finally { + controller.abort(); process.off("SIGINT", interrupt); } }, diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index be279cc21..b1b4933e8 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -106,15 +106,7 @@ describe("resolveRuntimeInvokeSources", () => { describe("parseRuntimeInvokeHeaders", () => { test("brands validation failures as usage errors", () => { - let error: unknown; - try { - parseRuntimeInvokeHeaders(["missing separator"]); - } catch (caught) { - error = caught; - } - - expect(error).toBeInstanceOf(UsageError); - expect(error).toMatchObject({ name: "TypeError", exitCode: 2 }); + expect(() => parseRuntimeInvokeHeaders(["missing separator"])).toThrow(UsageError); }); test("does not impose client-only count or value-size limits", () => { @@ -147,12 +139,6 @@ describe("normalizeRuntimeInvokeRequest", () => { "unsupported authorizer", ], ["MCP options on HTTP", detail(), { mcpMethod: "tools/list" }, "only valid for MCP"], - [ - "an MCP name without a method", - detail({ protocolConfiguration: { serverProtocol: "MCP" } }), - { mcpName: "weather" }, - "requires --mcp-method", - ], ])("rejects %s", (_name, runtime, overrides, message) => { expect(() => normalizeRuntimeInvokeRequest(runtime, { diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index c5abdfa1d..57bf60362 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -150,9 +150,6 @@ export function normalizeRuntimeInvokeRequest( if (!mcp && mcpValues.some((value) => value !== undefined)) { throw new UsageError("MCP options are only valid for MCP Runtimes"); } - if (modeled.mcpName !== undefined && modeled.mcpMethod === undefined) { - throw new UsageError("--mcp-name requires --mcp-method"); - } validateAllowedHeaders(detail, applicationHeaders); return { diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index bb60c7e74..f577423c0 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -16,6 +16,7 @@ import { normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, + UsageError, } from "./request"; import { RequestOptionsScreen, type RuntimeInvokeOptions } from "./RequestOptionsScreen"; import { classifyRuntimeResponse, writeRuntimeInvokeFile } from "./response"; @@ -148,7 +149,6 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke setPrettyJson(false); const controller = new AbortController(); abortRef.current = controller; - let localRequest = true; try { const sources = await resolveRuntimeInvokeSources( @@ -165,7 +165,6 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke bearerToken: sources.bearerToken, ...(mcp && { mcpSessionId, mcpProtocolVersion, mcpMethod, mcpName }), }); - localRequest = false; const response = await core.runtime.invokeRuntime(request, opts, controller.signal); updateExchange({ heading: `Response · ${response.statusCode} · ${response.contentType || "-"}`, @@ -227,7 +226,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke } catch (error) { if (controller.signal.aborted || (error as Error)?.name === "AbortError") { updateExchange({ note: "interrupted", state: "interrupted" }); - } else if (localRequest && error instanceof TypeError) { + } else if (error instanceof UsageError) { updateExchange({ response: `Error: ${error.message}`, state: "failed" }); } else if (/^HTTP \d{3}$/.test((error as Error)?.message)) { updateExchange({ note: (error as Error).message, state: "failed" }); From 1f79e4df55d5ba7a093fc4883796b9ed4c9d557d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 20:41:46 +0000 Subject: [PATCH 31/35] chore(runtime): format invoke header flag --- src/handlers/runtime/invoke/index.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 39fa30dc5..60cb9d9d5 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -27,12 +27,10 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => flag("accept", "the accepted response content type", z.string().optional()), flag("session-id", "the Runtime session ID", z.string().optional()), flag("user-id", "the Runtime user ID", z.string().optional()), - flag( - "header", - "an ordered application header", - z.array(z.string()).optional(), - { sensitive: true, short: "H" }, - ), + flag("header", "an ordered application header", z.array(z.string()).optional(), { + sensitive: true, + short: "H", + }), flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { sensitive: true, }), From a296cd01b30d3868dae2a6d301cce7493bc62641 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 24 Jul 2026 14:21:18 +0000 Subject: [PATCH 32/35] test(runtime): complete invoke failure coverage --- src/handlers/runtime/invoke/index.tsx | 9 +++------ src/handlers/runtime/invoke/invoke.test.tsx | 19 ++++++++++++++++++ src/handlers/runtime/invoke/response.test.ts | 21 +++++++++++++++++++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 60cb9d9d5..ad29f62b4 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -62,13 +62,10 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => try { await renderTuiAt(path, ctx, core, io); } catch (error) { - if ( - error instanceof TypeError && + throw error instanceof TypeError && error.message === "interactive mode requires a TTY on stdin and stdout" - ) { - throw new UsageError(error.message); - } - throw error; + ? new UsageError(error.message) + : error; } return; } diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index d2735f676..5f265830a 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -320,6 +320,25 @@ describe("runtime invoke", () => { expect(core.runtime.calls).toEqual([]); }); + test("classifies the TUI requirement as usage at the handler boundary", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const render = spyOn(tui, "renderTuiAt").mockRejectedValue( + new TypeError("interactive mode requires a TTY on stdin and stdout"), + ); + + try { + const code = await runWithExitCode(async () => + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID]), + ); + + expect(code).toBe(ExitCode.USAGE); + expect(core.runtime.calls).toEqual([]); + } finally { + render.mockRestore(); + } + }); + test("handler deep-links id-only and qualified invokes with encoded path segments", async () => { const core = new TestCoreClient(); const output = captureIO(); diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index 8cf2812a5..cb54d039a 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { PassThrough } from "node:stream"; +import { PassThrough, Writable } from "node:stream"; import type { RuntimeInvokeResponse } from "../types"; import { writeRuntimeInvokeResponse } from "./response"; @@ -77,6 +77,25 @@ describe("Runtime invoke response output", () => { expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1, 127])); }); + test("sanitizes metadata output failures", async () => { + const stdout = capture(); + const stderr = new Writable({ + write(_chunk, _encoding, callback) { + callback(new Error("secret metadata sink failure")); + }, + }) as unknown as NodeJS.WriteStream; + + await expect( + writeRuntimeInvokeResponse(response(), { + stdout: stdout.stream, + stderr, + }), + ).rejects.toMatchObject({ + message: "response stream failed", + reported: true, + }); + }); + test("streams exact bytes to a file and leaves stdout empty", async () => { const file = join(tmpdir(), `runtime-invoke-output-${process.pid}-${files.length}`); files.push(file); From e25c3db5a84b16465e579b2d0317e23d54a2143a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 24 Jul 2026 14:41:09 +0000 Subject: [PATCH 33/35] test(core): simplify data client fixtures --- src/core/core.test.ts | 223 ++++++++++++++++-------------------------- 1 file changed, 85 insertions(+), 138 deletions(-) diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 317153c18..b2afe5aa3 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -28,6 +28,30 @@ function fakeIam(config: ClientConfig): IAMClient { return { config, kind: "iam" } as unknown as IAMClient; } +function coreWithDataSend( + send: (command: unknown, options: unknown) => Promise, +): CoreClient { + return new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => + ({ config, kind: "data", send }) as unknown as BedrockAgentCoreClient, + createIamClient: fakeIam, + logger: createSilentLogger(), + }); +} + +async function resolveRuntimeApplicationHeaders( + command: InvokeAgentRuntimeCommand, +): Promise<[string, string][]> { + let headers: [string, string][] = []; + const handler = command.middlewareStack.resolve(async (args) => { + headers = Object.entries((args.request as { headers: Record }).headers); + return { output: { statusCode: 204 } as never, response: {} as never }; + }, {} as never); + await handler({ input: command.input, request: { headers: {} } } as never); + return headers; +} + function customJwtCore(fetch: RuntimeFetch, endpoint = "https://runtime.test"): CoreClient { return new CoreClient({ createControlClient: fakeControl, @@ -198,17 +222,7 @@ test("invokeHarness stream iteration rejects promptly when aborted mid-stream", await new Promise(() => {}); }, }; - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config, - kind: "data", - send: async () => ({ stream: hangingStream }), - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - logger: createSilentLogger(), - }); + const core = coreWithDataSend(async () => ({ stream: hangingStream })); const controller = new AbortController(); const response = await core.harness.invokeHarness( @@ -227,19 +241,9 @@ test("invokeHarness stream iteration rejects promptly when aborted mid-stream", test("invokeAgentRuntimeCommand sends the command on the data client with the abort signal", async () => { const sent: { command: unknown; options: unknown }[] = []; - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config, - kind: "data", - send: async (command: unknown, options: unknown) => { - sent.push({ command, options }); - return { statusCode: 200, stream: undefined }; - }, - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - logger: createSilentLogger(), + const core = coreWithDataSend(async (command, options) => { + sent.push({ command, options }); + return { statusCode: 200, stream: undefined }; }); const request = { @@ -255,9 +259,8 @@ test("invokeAgentRuntimeCommand sends the command on the data client with the ab expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); }); -test("invokeRuntime sends all modeled IAM fields and ordered application headers", async () => { +test("invokeRuntime maps all modeled IAM fields and response metadata", async () => { const sent: { command: unknown; options: unknown }[] = []; - let applicationHeaders: [string, string][] = []; const body = (async function* () { yield Uint8Array.from([0, 1, 255]); })(); @@ -273,31 +276,9 @@ test("invokeRuntime sends all modeled IAM fields and ordered application headers baggage: "baggage", response: body, }; - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config, - kind: "data", - send: async (command: unknown, options: unknown) => { - sent.push({ command, options }); - const stack = (command as InvokeAgentRuntimeCommand).middlewareStack; - const handler = stack.resolve(async (args) => { - applicationHeaders = Object.entries( - (args.request as { headers: Record }).headers, - ); - return { output: sdkResponse as never, response: {} as never }; - }, {} as never); - return ( - await handler({ - input: (command as InvokeAgentRuntimeCommand).input, - request: { headers: {} }, - } as never) - ).output; - }, - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - logger: createSilentLogger(), + const core = coreWithDataSend(async (command, options) => { + sent.push({ command, options }); + return sdkResponse; }); const request: RuntimeInvokeRequest = { runtimeId: "runtime-123", @@ -308,10 +289,6 @@ test("invokeRuntime sends all modeled IAM fields and ordered application headers accept: "text/event-stream", runtimeSessionId: "runtime-session", runtimeUserId: "runtime-user", - applicationHeaders: [ - ["X-One", "1"], - ["x-two", "2"], - ], mcpSessionId: "mcp-session", mcpProtocolVersion: "2025-06-18", mcpMethod: "tools/call", @@ -331,41 +308,47 @@ test("invokeRuntime sends all modeled IAM fields and ordered application headers expect(sent).toHaveLength(1); expect(sent[0]!.command).toBeInstanceOf(InvokeAgentRuntimeCommand); + const { runtimeId, ...input } = request; expect((sent[0]!.command as InvokeAgentRuntimeCommand).input).toEqual({ - agentRuntimeArn: "runtime-123", - accountId: "123456789012", - qualifier: "DEFAULT", - payload: Uint8Array.from([123, 125]), - contentType: "application/json", - accept: "text/event-stream", - runtimeSessionId: "runtime-session", - runtimeUserId: "runtime-user", - mcpSessionId: "mcp-session", - mcpProtocolVersion: "2025-06-18", - mcpMethod: "tools/call", - mcpName: "weather", - traceId: "trace-id", - traceParent: "trace-parent", - traceState: "trace-state", - baggage: "tenant=retail", + ...input, + agentRuntimeArn: runtimeId, }); expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); - expect(applicationHeaders).toEqual([ + const { response, ...metadata } = sdkResponse; + expect(result).toEqual({ + ...metadata, + body: response, + }); +}); + +test("invokeRuntime adds ordered application headers outside the modeled IAM input", async () => { + let command: InvokeAgentRuntimeCommand | undefined; + const core = coreWithDataSend(async (sent) => { + command = sent as InvokeAgentRuntimeCommand; + return { statusCode: 204 }; + }); + + await core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + applicationHeaders: [ + ["X-One", "1"], + ["x-two", "2"], + ], + }, + { region: "us-east-1" }, + ); + + expect(command).toBeInstanceOf(InvokeAgentRuntimeCommand); + expect(command!.input).not.toHaveProperty("applicationHeaders"); + expect(await resolveRuntimeApplicationHeaders(command!)).toEqual([ ["X-One", "1"], ["x-two", "2"], ]); - expect(result).toEqual({ - statusCode: 202, - contentType: "application/octet-stream", - runtimeSessionId: "runtime-session", - mcpSessionId: "mcp-session", - mcpProtocolVersion: "2025-06-18", - traceId: "trace-id", - traceParent: "trace-parent", - traceState: "trace-state", - baggage: "baggage", - body, - }); }); test("invokeRuntime aborts an established IAM response stream", async () => { @@ -373,21 +356,11 @@ test("invokeRuntime aborts an established IAM response stream", async () => { yield Buffer.from("partial"); await new Promise(() => {}); })(); - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config, - kind: "data", - send: async () => ({ - statusCode: 200, - contentType: "text/plain", - response: source, - }), - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - logger: createSilentLogger(), - }); + const core = coreWithDataSend(async () => ({ + statusCode: 200, + contentType: "text/plain", + response: source, + })); const controller = new AbortController(); const response = await core.runtime.invokeRuntime( { @@ -417,23 +390,14 @@ test("invokeRuntime aborts an established IAM response stream", async () => { }); test("IAM invoke failures preserve safe SDK diagnostics without arbitrary causes", async () => { - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config, - send: async () => { - throw Object.assign(new Error("failed with secret request content"), { - name: "AccessDeniedException", - $metadata: { - httpStatusCode: 403, - requestId: "request-123", - }, - }); - }, - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - logger: createSilentLogger(), + const core = coreWithDataSend(async () => { + throw Object.assign(new Error("failed with secret request content"), { + name: "AccessDeniedException", + $metadata: { + httpStatusCode: 403, + requestId: "request-123", + }, + }); }); const caught = await core.runtime @@ -635,17 +599,10 @@ test("CUSTOM_JWT transport failures do not expose arbitrary causes", async () => }); test("invokeRuntime exposes an empty async iterable when the SDK omits the body", async () => { - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config, - kind: "data", - send: async () => ({ statusCode: 204, contentType: "application/json" }), - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - logger: createSilentLogger(), - }); + const core = coreWithDataSend(async () => ({ + statusCode: 204, + contentType: "application/json", + })); const response = await core.runtime.invokeRuntime( { @@ -665,17 +622,7 @@ test("invokeRuntime exposes an empty async iterable when the SDK omits the body" test("invokeHarness returns the stream untouched when no abort signal is given", async () => { const stream = (async function* () {})(); - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config, - kind: "data", - send: async () => ({ stream }), - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - logger: createSilentLogger(), - }); + const core = coreWithDataSend(async () => ({ stream })); const response = await core.harness.invokeHarness( { harnessArn: "arn", runtimeSessionId: "s".repeat(40), messages: [] }, From 0a5a95289f8ac892ef6261c29b6352401652674c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 24 Jul 2026 14:44:53 +0000 Subject: [PATCH 34/35] docs(core): explain abortable stream semantics --- src/core/abortable.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/abortable.ts b/src/core/abortable.ts index 7c81aab61..8e639bda7 100644 --- a/src/core/abortable.ts +++ b/src/core/abortable.ts @@ -1,3 +1,10 @@ +/** + * Relays `source` while making an in-flight read reject as soon as `signal` aborts. + * + * Aborts use `signal.reason` or an `AbortError` fallback. The rejection remains + * observed when no read is pending, and source cleanup is fire-and-forget because + * a stalled stream may also stall `iterator.return()`. + */ export async function* abortable( source: AsyncIterable, signal: AbortSignal, From cbd491b0a69a9754aaa2f75f182fbb7767d93217 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 24 Jul 2026 15:21:20 +0000 Subject: [PATCH 35/35] refactor(core): generalize fetch dependency type --- src/core/core.test.ts | 4 ++-- src/core/index.tsx | 6 +++--- src/core/runtime.tsx | 4 ++-- src/core/types.tsx | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core/core.test.ts b/src/core/core.test.ts index b2afe5aa3..140a966d4 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -12,7 +12,7 @@ import { } from "@aws-sdk/client-bedrock-agentcore"; import type { RuntimeInvokeRequest } from "../handlers/runtime/types"; import { CoreClient } from "./index"; -import type { ClientConfig, RuntimeFetch } from "./types"; +import type { ClientConfig, CoreFetch } from "./types"; import { toClientConfig } from "./utils"; import { createSilentLogger } from "../testing"; @@ -52,7 +52,7 @@ async function resolveRuntimeApplicationHeaders( return headers; } -function customJwtCore(fetch: RuntimeFetch, endpoint = "https://runtime.test"): CoreClient { +function customJwtCore(fetch: CoreFetch, endpoint = "https://runtime.test"): CoreClient { return new CoreClient({ createControlClient: fakeControl, createDataClient: (config) => diff --git a/src/core/index.tsx b/src/core/index.tsx index 5429e7eb5..d87e4b292 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -7,10 +7,10 @@ import { RuntimeClient } from "./runtime"; import type { AwsClients, ClientConfig, + CoreFetch, CreateControlClient, CreateDataClient, CreateIamClient, - RuntimeFetch, } from "./types"; import type { Logger } from "../logging"; import type { ProjectManager } from "../handlers/project/types"; @@ -19,10 +19,10 @@ import { FsProjectManager } from "./project"; export type { AwsClients, ClientConfig, + CoreFetch, CreateControlClient, CreateDataClient, CreateIamClient, - RuntimeFetch, } from "./types"; type CoreClientConfig = { @@ -30,7 +30,7 @@ type CoreClientConfig = { createDataClient: CreateDataClient; createIamClient: CreateIamClient; logger: Logger; - fetch?: RuntimeFetch; + fetch?: CoreFetch; }; // CoreClient is the single entry point to the Bedrock AgentCore APIs. It owns the diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index 71e813b4f..63f7a8158 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -16,7 +16,7 @@ import type { RuntimeInvokeRequest, RuntimeInvokeResponse, } from "../handlers/runtime/types"; -import type { AwsClients, CoreOptions, RuntimeFetch } from "./types"; +import type { AwsClients, CoreFetch, CoreOptions } from "./types"; import { abortable } from "./abortable"; import { toClientConfig } from "./utils"; @@ -25,7 +25,7 @@ async function* emptyBody(): AsyncGenerator {} export class RuntimeClient implements CoreRuntimeClient { constructor( private readonly clients: AwsClients, - private readonly fetch: RuntimeFetch, + private readonly fetch: CoreFetch, ) {} async invokeRuntime( diff --git a/src/core/types.tsx b/src/core/types.tsx index 9a9883a77..41eaf2c4b 100644 --- a/src/core/types.tsx +++ b/src/core/types.tsx @@ -25,7 +25,7 @@ export interface ClientConfig { export type CreateControlClient = (config: ClientConfig) => BedrockAgentCoreControlClient; export type CreateDataClient = (config: ClientConfig) => BedrockAgentCoreClient; export type CreateIamClient = (config: ClientConfig) => IAMClient; -export type RuntimeFetch = ( +export type CoreFetch = ( ...args: Parameters ) => ReturnType;