diff --git a/src/core/configBundle.test.ts b/src/core/configBundle.test.ts index 371e81523..4c1021643 100644 --- a/src/core/configBundle.test.ts +++ b/src/core/configBundle.test.ts @@ -5,7 +5,7 @@ import { UpdateConfigurationBundleCommand, type BedrockAgentCoreControlClient, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { NetworkingError } from "../errors"; +import { InputValidationError, NetworkingError } from "../errors"; import { EvalClient } from "./eval"; import type { AwsClients, ClientConfig } from "./types"; @@ -130,4 +130,39 @@ describe("EvalClient configuration bundles", () => { branchName: "mainline", }); }); + + test("rejects a full ARN, telling the caller to pass the bare id", async () => { + // `project status` prints bare ids; a full ARN in --id would reach the service + // as a path segment whose slashes break path→operation parsing (a misleading + // AccessDenied), so it is rejected up front, before any request. + const arn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:configuration-bundle/b-1"; + const sent: unknown[] = []; + const { client } = subject(async (command) => { + sent.push(command); + return { versionId: "v-9" }; + }); + + const rejects = /must be a bare resource id, not an ARN/; + await expect( + client.getConfigurationBundle(arn, undefined, "mainline", OPTIONS), + ).rejects.toThrow(rejects); + await expect( + client.listConfigurationBundleVersions(arn, undefined, undefined, OPTIONS), + ).rejects.toThrow(rejects); + await expect(client.deleteConfigurationBundle(arn, OPTIONS)).rejects.toBeInstanceOf( + InputValidationError, + ); + await expect( + client.updateConfigurationBundle( + arn, + { branchName: "mainline", components: {}, commitMessage: "update" }, + OPTIONS, + ), + ).rejects.toThrow(rejects); + + // Rejected before any SDK call; a bare id still works. + expect(sent).toEqual([]); + await client.getConfigurationBundle("b-1", undefined, "mainline", OPTIONS); + expect((sent[0] as GetConfigurationBundleCommand).input).toMatchObject({ bundleId: "b-1" }); + }); }); diff --git a/src/core/eval.tsx b/src/core/eval.tsx index 4e2a061e1..4838144bc 100644 --- a/src/core/eval.tsx +++ b/src/core/eval.tsx @@ -168,6 +168,7 @@ import type { AwsClients, CoreFetch, CoreOptions } from "./types"; import type { Logger } from "../logging"; import { FilteredPaginator } from "./filteredPaginator"; import { toClientConfig } from "./utils"; +import { parseArn } from "./arn"; import { grantOnlineEvalScope } from "./onlineEvalExecutionRole"; import { accountIdFromArn, deleteAbTestRole, provisionAbTestRole } from "./abTestExecutionRole"; import { harnessRuntimeFromResponse } from "./harness"; @@ -188,6 +189,19 @@ const RETRYABLE_DATASET_STATUSES: ReadonlySet = new Set(["CREATIN // The shared, account-level OTel span log group. const SPANS_LOG_GROUP = "aws/spans"; +// These commands address a resource by its bare id, which the service places in +// the request path. A full ARN's slashes make the service misparse the path and +// return a misleading AccessDenied, so reject it up front with an actionable +// message rather than sending it. `project status` also prints bare ids for this. +function requireBareId(value: string, option: string): string { + if (parseArn(value) !== undefined) { + throw new InputValidationError( + `--${option} must be a bare resource id, not an ARN (pass the id, e.g. the segment after the last '/').`, + ); + } + return value; +} + // Default discovery window when no explicit --start/--end or --lookback-days is // given. Mirrors the batch service's now-7d default. const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; @@ -520,6 +534,7 @@ export class EvalClient implements CoreEvalClient { }) => CreateABTestRequest, options: CoreOptions, ): Promise { + gateway = requireBareId(gateway, "gateway"); const control = this.clients.control(toClientConfig(options)); const gatewayArn = (await control.send(new GetGatewayCommand({ gatewayIdentifier: gateway }))) .gatewayArn!; @@ -1189,6 +1204,7 @@ export class EvalClient implements CoreEvalClient { update: UpdateOnlineEvalInput, options: CoreOptions, ): Promise<{ response: UpdateOnlineEvaluationConfigResponse }> { + id = requireBareId(id, "id"); const control = this.clients.control(toClientConfig(options)); const current = await control.send( new GetOnlineEvaluationConfigCommand({ @@ -1261,6 +1277,7 @@ export class EvalClient implements CoreEvalClient { id: string, options: CoreOptions, ): Promise { + id = requireBareId(id, "id"); return this.clients .control(toClientConfig(options)) .send(new GetOnlineEvaluationConfigCommand({ onlineEvaluationConfigId: id })); @@ -1281,6 +1298,7 @@ export class EvalClient implements CoreEvalClient { executionStatus: "ENABLED" | "DISABLED", options: CoreOptions, ): Promise { + id = requireBareId(id, "id"); return this.clients .control(toClientConfig(options)) .send( @@ -1292,6 +1310,7 @@ export class EvalClient implements CoreEvalClient { id: string, options: CoreOptions, ): Promise { + id = requireBareId(id, "id"); return this.clients .control(toClientConfig(options)) .send(new DeleteOnlineEvaluationConfigCommand({ onlineEvaluationConfigId: id })); @@ -1312,6 +1331,10 @@ export class EvalClient implements CoreEvalClient { branchName: string, options: CoreOptions, ): Promise { + // Accept a full ARN (as `project status` prints) and use its bare id: the id + // is a path segment, and an ARN's slashes would make the service parse the + // path as an unknown operation and return a misleading AccessDenied. + id = requireBareId(id, "id"); const control = this.clients.control(toClientConfig(options)); return version === undefined ? control.send(new GetConfigurationBundleCommand({ bundleId: id, branchName })) @@ -1335,6 +1358,7 @@ export class EvalClient implements CoreEvalClient { update: UpdateConfigurationBundleInput, options: CoreOptions, ): Promise { + id = requireBareId(id, "id"); const control = this.clients.control(toClientConfig(options)); const current = await control.send( new GetConfigurationBundleCommand({ bundleId: id, branchName: update.branchName }), @@ -1364,7 +1388,7 @@ export class EvalClient implements CoreEvalClient { ): Promise { return this.clients .control(toClientConfig(options)) - .send(new DeleteConfigurationBundleCommand({ bundleId: id })); + .send(new DeleteConfigurationBundleCommand({ bundleId: requireBareId(id, "id") })); } async listConfigurationBundleVersions( @@ -1373,9 +1397,13 @@ export class EvalClient implements CoreEvalClient { maxResults: number | undefined, options: CoreOptions, ): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new ListConfigurationBundleVersionsCommand({ bundleId: id, nextToken, maxResults })); + return this.clients.control(toClientConfig(options)).send( + new ListConfigurationBundleVersionsCommand({ + bundleId: requireBareId(id, "id"), + nextToken, + maxResults, + }), + ); } async createDataset(