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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 47 additions & 37 deletions src/core/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { IAMClient } from "@aws-sdk/client-iam";
import type { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
import type { XRayClient } from "@aws-sdk/client-xray";
import type { ApplicationSignalsClient } from "@aws-sdk/client-application-signals";
import { SilentCLIError } from "../errors";
import {
GetEventCommand,
GetMemoryRecordCommand,
Expand Down Expand Up @@ -573,6 +574,7 @@ test("invokeRuntime maps all modeled IAM fields and response metadata", async ()
expect(sent[0]!.options).toEqual({ abortSignal: controller.signal });
const { response, ...metadata } = sdkResponse;
expect(result).toEqual({
kind: "stream",
...metadata,
body: response,
});
Expand Down Expand Up @@ -630,6 +632,7 @@ test("invokeRuntime aborts an established IAM response stream", async () => {
{ region: "us-east-1" },
controller.signal,
);
if (response.kind !== "stream") throw new Error("expected a streaming response");
const iterator = response.body[Symbol.asyncIterator]();

expect(await iterator.next()).toEqual({ done: false, value: Buffer.from("partial") });
Expand Down Expand Up @@ -745,6 +748,7 @@ 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");
if (result.kind !== "stream") throw new Error("expected a streaming response");
const resultBytes: Uint8Array[] = [];
for await (const chunk of result.body) resultBytes.push(chunk);
expect(Buffer.concat(resultBytes)).toEqual(Buffer.from([1, 2]));
Expand Down Expand Up @@ -824,40 +828,45 @@ test("CUSTOM_JWT modeled header failures do not expose their values", async () =
expect(fetched).toBe(false);
});

test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async () => {
test("CUSTOM_JWT non-2xx cancels the response body and returns status metadata", async () => {
const { logger, logs } = captureLogs();
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 = customJwtCore(async () => response, { logger });
const core = customJwtCore(
async () =>
new Response(
new ReadableStream<Uint8Array>({
cancel() {
cancelled++;
},
}),
{
status: 401,
headers: { "X-Amzn-RequestId": "request-123" },
},
),
{ logger },
);
const request: RuntimeInvokeRequest = {
runtimeId: "runtime-123",
accountId: "123456789012",
qualifier: "DEFAULT",
payload: new TextEncoder().encode("secret payload"),
contentType: "application/json",
bearerToken: "secret-token",
runtimeSessionId: "runtime-session-value",
applicationHeaders: [["X-Secret", "secret-header-value"]],
};

await expect(core.runtime.invokeRuntime(request, { region: "us-east-1" })).rejects.toThrow(
/^HTTP 401$/,
);
const response = await core.runtime.invokeRuntime(request, { region: "us-east-1" });

expect(response).toMatchObject({
kind: "rejected",
statusCode: 401,
error: {
requestId: "request-123",
},
});
expect(cancelled).toBe(1);
expect(read).toBe(false);
expectSafeDebugLog(
logs,
"Runtime invocation returned a non-success response",
Expand All @@ -868,11 +877,11 @@ test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async
region: "us-east-1",
httpStatusCode: 401,
},
["secret response body", "secret payload", "secret-token", "secret-header-value"],
["secret payload", "secret-token", "secret-header-value", "runtime-session-value"],
);
});

test("CUSTOM_JWT transport failures do not expose arbitrary causes", async () => {
test("CUSTOM_JWT transport failures are visible without exposing arbitrary causes", async () => {
const { logger, logs } = captureLogs();
const core = customJwtCore(
async () => {
Expand All @@ -883,20 +892,20 @@ test("CUSTOM_JWT transport failures do not expose arbitrary causes", async () =>
{ logger },
);

await expect(
core.runtime.invokeRuntime(
{
runtimeId: "runtime-123",
accountId: "123456789012",
qualifier: "DEFAULT",
payload: new TextEncoder().encode("secret payload"),
contentType: "application/json",
bearerToken: "secret-token",
applicationHeaders: [["X-Secret", "secret-header-value"]],
},
{ region: "us-east-1" },
),
).rejects.toThrow(/^Runtime invocation failed$/);
const pending = core.runtime.invokeRuntime(
{
runtimeId: "runtime-123",
accountId: "123456789012",
qualifier: "DEFAULT",
payload: new TextEncoder().encode("secret payload"),
contentType: "application/json",
bearerToken: "secret-token",
applicationHeaders: [["X-Secret", "secret-header-value"]],
},
{ region: "us-east-1" },
);
await expect(pending).rejects.toThrow(/^Runtime invocation failed$/);
await expect(pending).rejects.not.toBeInstanceOf(SilentCLIError);
expectSafeDebugLog(
logs,
"Runtime invocation transport failed",
Expand Down Expand Up @@ -928,6 +937,7 @@ test("invokeRuntime exposes an empty async iterable when the SDK omits the body"
{ region: "us-east-1" },
);

if (response.kind !== "stream") throw new Error("expected a streaming response");
const chunks: Uint8Array[] = [];
for await (const chunk of response.body) chunks.push(chunk);
expect(chunks).toEqual([]);
Expand Down
1 change: 1 addition & 0 deletions src/core/dev/localInvoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export async function invokeLocalRuntime(

const body = (response.body as AsyncIterable<Uint8Array> | null) ?? emptyBody();
return {
kind: "stream",
statusCode: response.status,
contentType: response.headers.get("content-type") ?? "",
runtimeSessionId:
Expand Down
14 changes: 14 additions & 0 deletions src/core/eval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,20 @@ export class EvalClient implements CoreEvalClient {
options,
signal,
);
if (response.kind === "rejected") {
throw new AgentCoreCLIError(`Runtime invocation failed (HTTP ${response.statusCode})`, {
source:
response.statusCode >= 400 && response.statusCode < 500
? ERROR_SOURCE.USER
: ERROR_SOURCE.SERVICE,
meta: {
httpStatusCode: response.statusCode,
...(response.error.requestId !== undefined && {
requestId: response.error.requestId,
}),
},
});
}
// Read to completion to free the socket; a scripted example ignores the text.
let text = "";
const decoder = new TextDecoder();
Expand Down
42 changes: 38 additions & 4 deletions src/core/invokeRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { InvokeAgentRuntimeCommand } from "@aws-sdk/client-bedrock-agentcore";
import { InputValidationError, RuntimeInvokeResponseError } from "../errors";
import { InputValidationError, NetworkingError } from "../errors";
import type { Logger } from "../logging";
import type { AwsClients, CoreFetch, CoreOptions } from "./types";
import { abortable } from "./abortable";
Expand Down Expand Up @@ -30,7 +30,7 @@ export type RuntimeInvokeRequest = {
baggage?: string;
};

export type RuntimeInvokeResponse = {
export type RuntimeInvokeResponseMetadata = {
statusCode: number;
contentType: string;
runtimeSessionId?: string;
Expand All @@ -40,9 +40,22 @@ export type RuntimeInvokeResponse = {
traceParent?: string;
traceState?: string;
baggage?: string;
};

export type RuntimeInvokeStreamResponse = RuntimeInvokeResponseMetadata & {
kind: "stream";
body: AsyncIterable<Uint8Array>;
};

export type RuntimeInvokeRejectedResponse = RuntimeInvokeResponseMetadata & {
kind: "rejected";
error: {
requestId?: string;
};
};

export type RuntimeInvokeResponse = RuntimeInvokeStreamResponse | RuntimeInvokeRejectedResponse;

// The CUSTOM_JWT path puts the account id in the invocation URL, so a bad ARN must fail here.
export function accountIdFromRuntimeArn(arn: string | undefined): string {
const id = arn?.match(/^arn:[^:]+:bedrock-agentcore:[^:]*:(\d{12}):runtime\//)?.[1];
Expand Down Expand Up @@ -152,17 +165,37 @@ async function invokeRuntimeWithCustomJwt(
: typeof error,
})
.debug("Runtime invocation transport failed");
throw new RuntimeInvokeResponseError("Runtime invocation failed", error);
throw new NetworkingError("Runtime invocation failed");
}
if (!response.ok) {
logger
.child({ httpStatusCode: response.status })
.debug("Runtime invocation returned a non-success response");
const requestId =
response.headers.get("x-amzn-requestid") ??
response.headers.get("x-amz-request-id") ??
undefined;
await response.body?.cancel().catch(() => undefined);
throw new RuntimeInvokeResponseError(`HTTP ${response.status}`);
return {
kind: "rejected",
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,
error: {
...(requestId !== undefined && { requestId }),
},
};
}
const body = (response.body as AsyncIterable<Uint8Array> | null) ?? emptyBody();
return {
kind: "stream",
statusCode: response.status,
contentType: response.headers.get("content-type") ?? "",
runtimeSessionId:
Expand Down Expand Up @@ -207,6 +240,7 @@ async function invokeRuntimeWithIam(

const body = (response.response as AsyncIterable<Uint8Array> | undefined) ?? emptyBody();
return {
kind: "stream",
statusCode: response.statusCode ?? 0,
contentType: response.contentType ?? "",
runtimeSessionId: response.runtimeSessionId,
Expand Down
1 change: 1 addition & 0 deletions src/handlers/project/invoke/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ async function routedCommand(
core.runtime
.setGetResponse({ agentRuntimeArn: arn(`runtime/${RUNTIME_ID}`) } as GetAgentRuntimeResponse)
.setInvokeResponse({
kind: "stream",
statusCode: 200,
contentType: "text/plain",
body: (async function* () {
Expand Down
Loading
Loading