diff --git a/src/components/HarnessWizard.tsx b/src/components/HarnessWizard.tsx index 1e69b460f..1417974f1 100644 --- a/src/components/HarnessWizard.tsx +++ b/src/components/HarnessWizard.tsx @@ -7,7 +7,7 @@ import type { HarnessTool, UpdateHarnessRequest, } from "@aws-sdk/client-bedrock-agentcore-control"; -import type { CreateHarnessInput } from "../handlers/harness/types"; +import type { CreateHarnessInput, HarnessRolePolicyWarning } from "../handlers/harness/types"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; import { Layout } from "./Layout"; @@ -241,6 +241,7 @@ export interface HarnessWizardProps extends ScreenProps { harnessId?: string; // initial seeds the form (update mode: the current configuration). initial?: HarnessFormValues; + rolePolicyWarning?: HarnessRolePolicyWarning; // onDone is called after a successful submit is acknowledged. onDone: (harnessId: string) => void; } @@ -258,6 +259,7 @@ export function HarnessWizard({ breadcrumb, harnessId, initial, + rolePolicyWarning, onDone, }: HarnessWizardProps) { const navigate = useNavigate(); @@ -354,6 +356,7 @@ export function HarnessWizard({ values={values} patch={patch} request={request} + rolePolicyWarning={rolePolicyWarning} onNext={next} onBack={back} onSubmit={submit} @@ -430,6 +433,7 @@ interface WizardStepProps { values: HarnessFormValues; patch: (update: Partial) => void; request: unknown; + rolePolicyWarning?: HarnessRolePolicyWarning; onNext: () => void; onBack: () => void; onSubmit: () => void; @@ -441,6 +445,7 @@ function WizardStep({ values, patch, request, + rolePolicyWarning, onNext, onBack, onSubmit, @@ -493,7 +498,15 @@ function WizardStep({ /> ); case "review": - return ; + return ( + + ); default: return null; } @@ -1099,11 +1112,13 @@ function PromptStep({ function ReviewStep({ mode, request, + rolePolicyWarning, onSubmit, onBack, }: { mode: "create" | "update"; request: unknown; + rolePolicyWarning?: HarnessRolePolicyWarning; onSubmit: () => void; onBack: () => void; }) { @@ -1124,6 +1139,11 @@ function ReviewStep({ ? "this request will be sent to CreateHarness" : "only the changed fields are sent to UpdateHarness"} + {rolePolicyWarning && ( + + {`Execution role ${rolePolicyWarning.roleArn} is not managed for this Harness. IAM policies will not be modified; you are responsible for permissions required by this update.`} + + )} {/* The step body is inset by paddingX on both sides. */} diff --git a/src/core/credentialProviderPolicy.test.ts b/src/core/credentialProviderPolicy.test.ts new file mode 100644 index 000000000..419a7d4db --- /dev/null +++ b/src/core/credentialProviderPolicy.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { + GetApiKeyCredentialProviderCommand, + GetOauth2CredentialProviderCommand, + type BedrockAgentCoreControlClient, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { CredentialProviderPolicyResolver } from "./credentialProviderPolicy"; + +const API_KEY_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/openai"; +const OAUTH_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/gateway"; + +describe("CredentialProviderPolicyResolver", () => { + test("resolves each typed provider to its exact backing secret", async () => { + const control = { + send: async ( + command: GetApiKeyCredentialProviderCommand | GetOauth2CredentialProviderCommand, + ) => { + if (command instanceof GetApiKeyCredentialProviderCommand) { + return { + credentialProviderArn: API_KEY_ARN, + apiKeySecretArn: { + secretArn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:openai", + }, + }; + } + return { + credentialProviderArn: OAUTH_ARN, + clientSecretArn: { + secretArn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:gateway", + }, + }; + }, + } as unknown as BedrockAgentCoreControlClient; + + await expect( + new CredentialProviderPolicyResolver(control).resolve([ + { type: "oauth", providerArn: OAUTH_ARN }, + { type: "api-key", providerArn: API_KEY_ARN }, + { type: "api-key", providerArn: API_KEY_ARN }, + ]), + ).resolves.toEqual([ + { + providerArn: API_KEY_ARN, + secretArn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:openai", + }, + { + providerArn: OAUTH_ARN, + secretArn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:gateway", + }, + ]); + }); + + test("rejects a provider ARN whose kind contradicts its configuration", async () => { + const control = { + send: async () => { + throw new Error("mismatch must fail before the service read"); + }, + } as unknown as BedrockAgentCoreControlClient; + + await expect( + new CredentialProviderPolicyResolver(control).resolve([ + { type: "api-key", providerArn: OAUTH_ARN }, + ]), + ).rejects.toThrow(/configured as api-key.*identifies an oauth provider/); + }); +}); diff --git a/src/core/credentialProviderPolicy.ts b/src/core/credentialProviderPolicy.ts new file mode 100644 index 000000000..6b4b07f07 --- /dev/null +++ b/src/core/credentialProviderPolicy.ts @@ -0,0 +1,90 @@ +import { + GetApiKeyCredentialProviderCommand, + GetOauth2CredentialProviderCommand, + type BedrockAgentCoreControlClient, +} from "@aws-sdk/client-bedrock-agentcore-control"; + +export type CredentialProviderPolicyState = { + providerArn: string; + secretArn: string; +}; + +export type CredentialProviderPolicyRequest = { + type: "api-key" | "oauth"; + providerArn: string; +}; + +export class CredentialProviderPolicyResolver { + constructor(private readonly control: BedrockAgentCoreControlClient) {} + + async resolve( + requests: readonly CredentialProviderPolicyRequest[], + ): Promise { + const requestedTypes = new Map(); + for (const request of requests) { + const existing = requestedTypes.get(request.providerArn); + if (existing && existing !== request.type) { + throw new Error(`Credential provider ${request.providerArn} is configured as two types.`); + } + requestedTypes.set(request.providerArn, request.type); + } + + const providers: CredentialProviderPolicyState[] = []; + for (const [providerArn, requestedType] of [...requestedTypes].sort(([left], [right]) => + left.localeCompare(right), + )) { + const identity = credentialProviderIdentity(providerArn); + if (identity.type !== requestedType) { + throw new Error( + `Credential provider ${providerArn} is configured as ${requestedType} but identifies an ${identity.type} provider.`, + ); + } + if (identity.type === "api-key") { + const response = await this.control.send( + new GetApiKeyCredentialProviderCommand({ name: identity.name }), + ); + if (response.credentialProviderArn !== providerArn) { + throw new Error( + `API key credential provider ${identity.name} returned an unexpected ARN.`, + ); + } + const secretArn = response.apiKeySecretArn?.secretArn; + if (!secretArn) { + throw new Error(`API key credential provider ${providerArn} returned no secret ARN.`); + } + providers.push({ providerArn, secretArn }); + continue; + } + + const response = await this.control.send( + new GetOauth2CredentialProviderCommand({ name: identity.name }), + ); + if (response.credentialProviderArn !== providerArn) { + throw new Error(`OAuth credential provider ${identity.name} returned an unexpected ARN.`); + } + const secretArn = response.clientSecretArn?.secretArn; + if (!secretArn) { + throw new Error(`OAuth credential provider ${providerArn} returned no secret ARN.`); + } + providers.push({ providerArn, secretArn }); + } + return providers; + } +} + +function credentialProviderIdentity(providerArn: string): { + type: "api-key" | "oauth"; + name: string; +} { + const resource = providerArn.split(":").slice(5).join(":"); + const match = resource.match( + /^token-vault\/[^/]+\/(apikeycredentialprovider|oauth2credentialprovider)\/([^/]+)$/, + ); + if (!match?.[1] || !match[2]) { + throw new Error(`Invalid credential provider ARN "${providerArn}".`); + } + return { + type: match[1] === "apikeycredentialprovider" ? "api-key" : "oauth", + name: match[2], + }; +} diff --git a/src/core/executionRole.tsx b/src/core/executionRole.tsx deleted file mode 100644 index 0a7581019..000000000 --- a/src/core/executionRole.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { - CreateRoleCommand, - GetRoleCommand, - PutRolePolicyCommand, - type IAMClient, -} from "@aws-sdk/client-iam"; - -// Default harness execution role provisioning. -// -// CreateHarness requires an IAM role the AgentCore service can assume. When the -// caller doesn't bring one, HarnessClient provisions a per-harness default here: -// a role trusting bedrock-agentcore.amazonaws.com with an inline policy granting -// the baseline permissions a harness needs (model invocation, logs/metrics, -// built-in tools, managed memory, ...). The flow is idempotent — an existing -// role is reused and its inline policy refreshed — so repeated creates of the -// same harness name converge on one role. - -const POLICY_NAME = "AgentCoreHarnessExecutionPolicy"; - -// executionRoleName derives the default role's name from the harness name. IAM -// role names cap at 64 characters; harness names are alphanumeric/underscore so -// no further sanitization is needed. -export function executionRoleName(harnessName: string): string { - return `AgentCoreHarness-${harnessName}`.slice(0, 64); -} - -// trustPolicy allows the AgentCore service principal to assume the role. -function trustPolicy(): string { - return JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Effect: "Allow", - Principal: { Service: "bedrock-agentcore.amazonaws.com" }, - Action: "sts:AssumeRole", - }, - ], - }); -} - -// executionPolicy is the default permissions document, parameterized on the -// caller's region/account and the harness name (scoping workload identities and -// managed memory to this harness). -function executionPolicy(region: string, accountId: string, harnessName: string): string { - return JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "BedrockModelInvocation", - Effect: "Allow", - Action: ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"], - Resource: [ - "arn:aws:bedrock:*::foundation-model/*", - `arn:aws:bedrock:${region}:${accountId}:*`, - ], - }, - { - Sid: "BedrockMantleInference", - Effect: "Allow", - Action: ["bedrock-mantle:CreateInference"], - Resource: `arn:aws:bedrock-mantle:us-east-1:${accountId}:*`, - }, - { - Sid: "BedrockMantleCallWithBearerToken", - Effect: "Allow", - Action: ["bedrock-mantle:CallWithBearerToken"], - Resource: "*", - }, - { - Sid: "EcrPublicTokenAccess", - Effect: "Allow", - Action: ["ecr-public:GetAuthorizationToken"], - Resource: "*", - }, - { - Sid: "StsForEcrPublicPull", - Effect: "Allow", - Action: ["sts:GetServiceBearerToken"], - Resource: "*", - }, - { - Sid: "XRayTracingAccess", - Effect: "Allow", - Action: [ - "xray:PutTraceSegments", - "xray:PutTelemetryRecords", - "xray:GetSamplingRules", - "xray:GetSamplingTargets", - ], - Resource: "*", - }, - { - Sid: "CloudWatchLogsGroup", - Effect: "Allow", - Action: ["logs:CreateLogGroup", "logs:DescribeLogStreams"], - Resource: `arn:aws:logs:${region}:${accountId}:log-group:/aws/bedrock-agentcore/runtimes/*`, - }, - { - Sid: "CloudWatchLogsDescribeGroups", - Effect: "Allow", - Action: ["logs:DescribeLogGroups"], - Resource: `arn:aws:logs:${region}:${accountId}:log-group:*`, - }, - { - Sid: "CloudWatchLogsStream", - Effect: "Allow", - Action: ["logs:CreateLogStream", "logs:PutLogEvents"], - Resource: `arn:aws:logs:${region}:${accountId}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*`, - }, - { - Sid: "CloudWatchMetricsPublish", - Effect: "Allow", - Resource: "*", - Action: "cloudwatch:PutMetricData", - Condition: { - StringEquals: { "cloudwatch:namespace": "bedrock-agentcore" }, - }, - }, - { - Sid: "AgentCoreWorkloadIdentity", - Effect: "Allow", - Action: [ - "bedrock-agentcore:GetWorkloadAccessToken", - "bedrock-agentcore:GetWorkloadAccessTokenForJWT", - ], - Resource: [ - `arn:aws:bedrock-agentcore:${region}:${accountId}:workload-identity-directory/default`, - `arn:aws:bedrock-agentcore:${region}:${accountId}:workload-identity-directory/default/workload-identity/${harnessName}-*`, - ], - }, - { - Sid: "AgentCoreBrowserDefault", - Effect: "Allow", - Action: [ - "bedrock-agentcore:StartBrowserSession", - "bedrock-agentcore:StopBrowserSession", - "bedrock-agentcore:GetBrowserSession", - "bedrock-agentcore:ListBrowserSessions", - "bedrock-agentcore:UpdateBrowserStream", - "bedrock-agentcore:ConnectBrowserAutomationStream", - "bedrock-agentcore:ConnectBrowserLiveViewStream", - ], - Resource: `arn:aws:bedrock-agentcore:${region}:aws:browser/*`, - }, - { - Sid: "AgentCoreCodeInterpreterDefault", - Effect: "Allow", - Action: [ - "bedrock-agentcore:StartCodeInterpreterSession", - "bedrock-agentcore:StopCodeInterpreterSession", - "bedrock-agentcore:GetCodeInterpreterSession", - "bedrock-agentcore:ListCodeInterpreterSessions", - "bedrock-agentcore:InvokeCodeInterpreter", - ], - Resource: `arn:aws:bedrock-agentcore:${region}:aws:code-interpreter/*`, - }, - { - Sid: "EFSClientAccess", - Effect: "Allow", - Action: ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite"], - Resource: `arn:aws:elasticfilesystem:${region}:${accountId}:file-system/*`, - Condition: { - ArnLike: { - "elasticfilesystem:AccessPointArn": `arn:aws:elasticfilesystem:${region}:${accountId}:access-point/*`, - }, - }, - }, - { - Sid: "EFSDescribe", - Effect: "Allow", - Action: [ - "elasticfilesystem:DescribeAccessPoints", - "elasticfilesystem:DescribeMountTargets", - ], - Resource: [ - `arn:aws:elasticfilesystem:${region}:${accountId}:file-system/*`, - `arn:aws:elasticfilesystem:${region}:${accountId}:access-point/*`, - ], - }, - { - Sid: "S3FilesClientAccess", - Effect: "Allow", - Action: ["s3files:ClientMount", "s3files:ClientWrite", "s3files:ClientRootAccess"], - Resource: `arn:aws:s3files:${region}:${accountId}:file-system/*`, - Condition: { - ArnLike: { - "s3files:AccessPointArn": `arn:aws:s3files:${region}:${accountId}:file-system/*/access-point/*`, - }, - }, - }, - { - Sid: "S3FilesDescribe", - Effect: "Allow", - Action: ["s3files:GetAccessPoint", "s3files:ListMountTargets"], - Resource: [ - `arn:aws:s3files:${region}:${accountId}:file-system/*`, - `arn:aws:s3files:${region}:${accountId}:file-system/*/access-point/*`, - ], - }, - { - Sid: "AgentCoreMemory", - Effect: "Allow", - Action: [ - "bedrock-agentcore:CreateEvent", - "bedrock-agentcore:DeleteEvent", - "bedrock-agentcore:GetEvent", - "bedrock-agentcore:ListEvents", - "bedrock-agentcore:RetrieveMemoryRecords", - ], - Resource: `arn:aws:bedrock-agentcore:${region}:${accountId}:memory/harness_*`, - }, - { - Sid: "AgentCoreGatewayAccess", - Effect: "Allow", - Action: ["bedrock-agentcore:InvokeGateway"], - Resource: [`arn:aws:bedrock-agentcore:${region}:${accountId}:gateway/*`], - }, - ], - }); -} - -// accountIdFromRoleArn extracts the account id from a role ARN -// (arn:aws:iam:::role/), which saves an STS lookup: the account -// only becomes relevant once we hold the role's ARN anyway. -function accountIdFromRoleArn(arn: string): string { - const accountId = arn.split(":")[4]; - if (!accountId) { - throw new Error(`Cannot extract an account id from role ARN "${arn}"`); - } - return accountId; -} - -// ensureDefaultExecutionRole returns the ARN of the default execution role for -// `harnessName`, creating the role if it doesn't exist and (re)attaching the -// default inline policy either way. -export async function ensureDefaultExecutionRole( - iam: IAMClient, - harnessName: string, - region: string, -): Promise { - const roleName = executionRoleName(harnessName); - - let roleArn: string; - try { - const existing = await iam.send(new GetRoleCommand({ RoleName: roleName })); - roleArn = existing.Role!.Arn!; - } catch (error) { - if ((error as Error).name !== "NoSuchEntityException") throw error; - const created = await iam.send( - new CreateRoleCommand({ - RoleName: roleName, - AssumeRolePolicyDocument: trustPolicy(), - Description: `Default execution role for the AgentCore harness "${harnessName}" (created by the agentcore CLI)`, - }), - ); - roleArn = created.Role!.Arn!; - } - - await iam.send( - new PutRolePolicyCommand({ - RoleName: roleName, - PolicyName: POLICY_NAME, - PolicyDocument: executionPolicy(region, accountIdFromRoleArn(roleArn), harnessName), - }), - ); - - return roleArn; -} diff --git a/src/core/executionRoleManager.test.ts b/src/core/executionRoleManager.test.ts index 728795101..9992fc9df 100644 --- a/src/core/executionRoleManager.test.ts +++ b/src/core/executionRoleManager.test.ts @@ -77,6 +77,27 @@ describe("ExecutionRoleManager policy ownership", () => { }); }); + test("manages a CLI role only for the resource that owns its deterministic name", () => { + const associatedRoleArn = roleArn("AgentCoreCliHarness-orders"); + + expect( + ExecutionRoleManager.policyManagement({ + associatedRoleArn, + expectedCliRoleName: "AgentCoreCliHarness-orders", + }), + ).toMatchObject({ mode: "managed", roleName: "AgentCoreCliHarness-orders" }); + expect( + ExecutionRoleManager.policyManagement({ + associatedRoleArn, + expectedCliRoleName: "AgentCoreCliHarness-invoices", + }), + ).toEqual({ + mode: "external", + reason: "unknown-role", + roleArn: associatedRoleArn, + }); + }); + test("creates bounded deterministic role and per-parent policy names", () => { expect(ExecutionRoleManager.cliRoleName("gateway", "orders")).toBe( "AgentCoreCliGateway-orders", diff --git a/src/core/executionRoleManager.ts b/src/core/executionRoleManager.ts index ebe8fedb9..379873773 100644 --- a/src/core/executionRoleManager.ts +++ b/src/core/executionRoleManager.ts @@ -44,6 +44,7 @@ export type ExecutionRolePolicyManagementInput = { associatedRoleArn: string; explicitRoleArn?: string; skipPolicyUpdate?: boolean; + expectedCliRoleName?: string; }; export class InvalidExecutionRoleArnError extends Error { @@ -137,6 +138,17 @@ export class ExecutionRoleManager { } const roleName = ExecutionRoleManager.roleNameFromArn(input.associatedRoleArn); + if ( + roleName.startsWith("AgentCoreCli") && + input.expectedCliRoleName && + roleName !== input.expectedCliRoleName + ) { + return { + mode: "external", + reason: "unknown-role", + roleArn: input.associatedRoleArn, + }; + } if (RECOGNIZED_EXECUTION_ROLE_PREFIXES.some((prefix) => roleName.startsWith(prefix))) { return { mode: "managed", diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 4cbf158fb..0513e5c05 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -6,11 +6,9 @@ import { DeleteGatewayCommand, DeleteGatewayRuleCommand, DeleteGatewayTargetCommand, - GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayRuleCommand, GetGatewayTargetCommand, - GetOauth2CredentialProviderCommand, ListGatewayRulesCommand, ListGatewaysCommand, ListGatewayTargetsCommand, @@ -61,6 +59,7 @@ import { type ExecutionRoleManagerOptions, type ExecutionRolePolicyManagement, } from "./executionRoleManager"; +import { CredentialProviderPolicyResolver } from "./credentialProviderPolicy"; import type { PolicyContribution } from "./executionRolePolicy"; import { ExecutionRolePolicyUpdater, @@ -240,6 +239,7 @@ export class GatewayClient implements CoreGatewayClient { } const management = ExecutionRoleManager.policyManagement({ associatedRoleArn: gateway.roleArn, + expectedCliRoleName: expectedGatewayCliRoleName(gateway, gatewayId), }); return management.mode === "external" && management.reason === "unknown-role" ? { reason: "unknown-role", roleArn: management.roleArn } @@ -324,11 +324,13 @@ export class GatewayClient implements CoreGatewayClient { associatedRoleArn: roleArn, explicitRoleArn: patch.roleArn, skipPolicyUpdate: patch.skipRolePolicyUpdate, + expectedCliRoleName: ExecutionRoleManager.cliRoleName("gateway", name), }); if (management.mode === "external") { if (management.reason === "explicit-role" && !patch.skipRolePolicyUpdate) { const previousManagement = ExecutionRoleManager.policyManagement({ associatedRoleArn: roleArn, + expectedCliRoleName: ExecutionRoleManager.cliRoleName("gateway", name), }); if (previousManagement.mode === "managed") { const policyName = gatewayPolicyName( @@ -392,6 +394,7 @@ export class GatewayClient implements CoreGatewayClient { const request = { gatewayIdentifier: id }; const management = ExecutionRoleManager.policyManagement({ associatedRoleArn: gateway.roleArn, + expectedCliRoleName: expectedGatewayCliRoleName(gateway, id), }); if (management.mode === "external") { return (await this.deleteGatewayAndWait(request, options)).response; @@ -457,6 +460,7 @@ export class GatewayClient implements CoreGatewayClient { } const management = ExecutionRoleManager.policyManagement({ associatedRoleArn: gateway.roleArn, + expectedCliRoleName: expectedGatewayCliRoleName(gateway, input.gatewayIdentifier!), }); const control = this.clients.control(toClientConfig(options)); @@ -632,6 +636,7 @@ export class GatewayClient implements CoreGatewayClient { }; const management = ExecutionRoleManager.policyManagement({ associatedRoleArn: gateway.roleArn, + expectedCliRoleName: expectedGatewayCliRoleName(gateway, gatewayId), }); if (management.mode === "external") { return (await this.deleteGatewayTargetAndWait(request, options)).response; @@ -775,6 +780,7 @@ export class GatewayClient implements CoreGatewayClient { const management = ExecutionRoleManager.policyManagement({ associatedRoleArn: gateway.roleArn, skipPolicyUpdate: patch.skipRolePolicyUpdate, + expectedCliRoleName: expectedGatewayCliRoleName(gateway, patch.gatewayId), }); if (management.mode === "external") { return (await this.updateGatewayTargetAndWait(request, options)).response; @@ -1261,73 +1267,45 @@ export class GatewayClient implements CoreGatewayClient { }[], options: CoreOptions, ): Promise { - const providerKinds = new Map(); + const providerTypes = new Map(); for (const target of targets) { for (const configuration of target.credentialProviderConfigurations ?? []) { const providerArn = configuration.credentialProvider?.apiKeyCredentialProvider?.providerArn ?? configuration.credentialProvider?.oauthCredentialProvider?.providerArn; - const kind = + const type = configuration.credentialProviderType === "API_KEY" ? "api-key" : configuration.credentialProviderType === "OAUTH" ? "oauth" : undefined; - if (!kind) continue; + if (!type) continue; if (!providerArn) { throw new Error( `${configuration.credentialProviderType} credential provider ARN is missing.`, ); } - const existingKind = providerKinds.get(providerArn); - if (existingKind && existingKind !== kind) { + const existing = providerTypes.get(providerArn); + if (existing && existing !== type) { throw new Error(`Credential provider ${providerArn} is used as two provider types.`); } - providerKinds.set(providerArn, kind); + providerTypes.set(providerArn, type); } } - - const control = this.clients.control(toClientConfig(options)); - const providers: GatewayCredentialProviderPolicyState[] = []; - for (const [providerArn, kind] of [...providerKinds].sort(([left], [right]) => - left.localeCompare(right), - )) { - const name = credentialProviderName(providerArn, kind); - if (kind === "api-key") { - const response = await control.send(new GetApiKeyCredentialProviderCommand({ name })); - if (response.credentialProviderArn !== providerArn) { - throw new Error(`API key credential provider ${name} returned an unexpected ARN.`); - } - const secretArn = response.apiKeySecretArn?.secretArn; - if (!secretArn) { - throw new Error(`API key credential provider ${providerArn} returned no secret ARN.`); - } - providers.push({ providerArn, secretArn }); - continue; - } - - const response = await control.send(new GetOauth2CredentialProviderCommand({ name })); - if (response.credentialProviderArn !== providerArn) { - throw new Error(`OAuth credential provider ${name} returned an unexpected ARN.`); - } - const secretArn = response.clientSecretArn?.secretArn; - if (!secretArn) { - throw new Error(`OAuth credential provider ${providerArn} returned no secret ARN.`); - } - providers.push({ providerArn, secretArn }); - } - return providers; + return new CredentialProviderPolicyResolver( + this.clients.control(toClientConfig(options)), + ).resolve( + [...providerTypes].map(([providerArn, type]) => ({ + type, + providerArn, + })), + ); } } -function credentialProviderName(providerArn: string, kind: "api-key" | "oauth"): string { - const resource = providerArn.split(":").slice(5).join(":"); - const expectedType = kind === "api-key" ? "apikeycredentialprovider" : "oauth2credentialprovider"; - const match = resource.match(new RegExp(`^token-vault/[^/]+/${expectedType}/([^/]+)$`)); - if (!match?.[1]) { - throw new Error(`Invalid ${kind} credential provider ARN "${providerArn}".`); - } - return match[1]; +function expectedGatewayCliRoleName(gateway: GetGatewayResponse, gatewayId: string): string { + if (!gateway.name) throw new Error(`Gateway ${gatewayId} returned no name.`); + return ExecutionRoleManager.cliRoleName("gateway", gateway.name); } function accountIdFromRoleArn(roleArn: string): string { diff --git a/src/core/gatewayPolicy.ts b/src/core/gatewayPolicy.ts index c9b2e0e5f..9176876dc 100644 --- a/src/core/gatewayPolicy.ts +++ b/src/core/gatewayPolicy.ts @@ -6,6 +6,7 @@ import type { TargetConfiguration, } from "@aws-sdk/client-bedrock-agentcore-control"; import { AgentCorePolicyGrants } from "./agentCorePolicyGrants"; +import type { CredentialProviderPolicyState } from "./credentialProviderPolicy"; import type { PolicyContribution } from "./executionRolePolicy"; export type GatewayTargetPolicyState = { @@ -25,10 +26,7 @@ export type GatewayPolicyState = { targets: readonly GatewayTargetPolicyState[]; }; -export type GatewayCredentialProviderPolicyState = { - providerArn: string; - secretArn: string; -}; +export type GatewayCredentialProviderPolicyState = CredentialProviderPolicyState; export class UninferrableGatewayPermissionError extends Error { constructor( diff --git a/src/core/harness.tsx b/src/core/harness.tsx index 7a5edeec9..462523a07 100644 --- a/src/core/harness.tsx +++ b/src/core/harness.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { CreateHarnessCommand, CreateHarnessEndpointCommand, @@ -26,6 +27,7 @@ import { type UpdateHarnessEndpointResponse, type UpdateHarnessRequest, type UpdateHarnessResponse, + type Harness, } from "@aws-sdk/client-bedrock-agentcore-control"; import { InvokeAgentRuntimeCommandCommand, @@ -35,17 +37,98 @@ import { type InvokeHarnessRequest, type InvokeHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore"; -import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; +import type { + CoreHarnessClient, + CreateHarnessInput, + HarnessUpdateInput, +} from "../handlers/harness/types"; import type { AwsClients, CoreOptions } from "./types"; import { abortable } from "./abortable"; -import { ensureDefaultExecutionRole } from "./executionRole"; +import { + CredentialProviderPolicyResolver, + type CredentialProviderPolicyState, +} from "./credentialProviderPolicy"; +import { + ExecutionRoleManager, + type ExecutionRoleManagerOptions, + type ExecutionRolePolicyManagement, +} from "./executionRoleManager"; +import { + ExecutionRolePolicyUpdater, + PolicyFinalizationError, + PolicyOperationOutcomeUnknownError, + type ExecutionRolePolicyUpdaterOptions, +} from "./executionRolePolicyUpdater"; +import type { PolicyContribution } from "./executionRolePolicy"; +import { + HarnessPolicyPlanner, + harnessCredentialProviderRequests, + type HarnessPolicyState, +} from "./harnessPolicy"; import { toClientConfig } from "./utils"; -// HarnessClient implements the harness-facing operations on top of the shared AWS -// clients provided by CoreClient. It owns no clients of its own; it borrows the -// cached ones so every Core sub-client shares the same connections. +const DEFAULT_WAIT_ATTEMPTS = 300; +const DEFAULT_WAIT_DELAY_MS = 2_000; + +export type HarnessClientOptions = { + policyUpdater?: ExecutionRolePolicyUpdaterOptions; + roleManager?: ExecutionRoleManagerOptions; + waitAttempts?: number; + waitDelayMs?: number; + sleep?: (milliseconds: number) => Promise; +}; + +type HarnessPolicyInventory = { + harness: Harness; + credentials: CredentialProviderPolicyState[]; +}; + +type ManagedHarnessPolicy = Extract; + +export class HarnessTerminalStateError extends Error { + constructor( + readonly harnessId: string, + readonly status: string, + readonly failureReason: string | undefined, + ) { + super(`Harness ${harnessId} reached ${status}` + (failureReason ? `: ${failureReason}` : ".")); + this.name = "HarnessTerminalStateError"; + } +} + +export class HarnessOutcomeUnknownError extends Error { + constructor(harnessId: string, options?: ErrorOptions) { + super(`The final state of Harness ${harnessId} could not be determined.`, options); + this.name = "HarnessOutcomeUnknownError"; + } +} + export class HarnessClient implements CoreHarnessClient { - constructor(private readonly clients: AwsClients) {} + private readonly planner = new HarnessPolicyPlanner(); + private readonly sleep: (milliseconds: number) => Promise; + + constructor( + private readonly clients: AwsClients, + private readonly options: HarnessClientOptions = {}, + ) { + this.sleep = options.sleep ?? delay; + } + + async getHarnessRolePolicyWarning( + harnessId: string, + options: CoreOptions, + ): Promise<{ reason: "unknown-role"; roleArn: string } | undefined> { + const harness = requiredHarness(await this.getHarness(harnessId, options), harnessId); + const roleArn = harness.executionRoleArn; + if (!roleArn) throw new Error(`Harness ${harnessId} returned no execution role ARN.`); + const management = ExecutionRoleManager.policyManagement({ + associatedRoleArn: roleArn, + expectedCliRoleName: expectedHarnessCliRoleName(harness), + }); + return management.mode === "external" && management.reason === "unknown-role" + ? { reason: "unknown-role", roleArn } + : undefined; + } async getHarness(id: string, options: CoreOptions): Promise { return this.clients @@ -109,44 +192,175 @@ export class HarnessClient implements CoreHarnessClient { input: CreateHarnessInput, options: CoreOptions, ): Promise { - const control = this.clients.control(toClientConfig(options)); const { executionRoleArn, ...request } = input; + const clientToken = request.clientToken ?? randomUUID(); if (executionRoleArn) { - return control.send(new CreateHarnessCommand({ ...request, executionRoleArn })); + return this.clients + .control(toClientConfig(options)) + .send(new CreateHarnessCommand({ ...request, clientToken, executionRoleArn })); } - // No role supplied: provision (or reuse) the default execution role, then - // create the harness with it. IAM is eventually consistent — a role created - // moments ago may not yet be assumable by the AgentCore service principal — - // so retry the create while the service reports the role as unusable. - const defaultRoleArn = await ensureDefaultExecutionRole( - // IAM is a global service; the region only selects the endpoint, and the - // agentcore endpoint override must not leak onto it. - this.clients.iam({ region: options.region }), - input.harnessName!, - options.region, - ); - return retryWhileRoleUnassumable(() => - control.send(new CreateHarnessCommand({ ...request, executionRoleArn: defaultRoleArn })), - ); + const iam = this.clients.iam({ region: options.region }); + const roleManager = new ExecutionRoleManager(iam, this.options.roleManager); + const managedRole = await roleManager.ensureCliRole({ + primitive: "harness", + resourceName: input.harnessName!, + }); + const policyName = ExecutionRoleManager.generatedPolicyName("harness", { + accountId: accountIdFromRoleArn(managedRole.arn), + region: options.region, + stableResourceKey: managedRole.name, + }); + const updater = this.policyUpdater(iam); + + try { + const state = await this.enrichState( + { + region: options.region, + accountId: accountIdFromRoleArn(managedRole.arn), + harnessName: input.harnessName!, + model: request.model, + tools: request.tools, + skills: request.skills, + memory: request.memory, + environment: request.environment, + environmentArtifact: request.environmentArtifact, + }, + options, + ); + const result = await updater.update({ + roleName: managedRole.name, + policyName, + current: [], + inventoryComplete: managedRole.created, + desired: this.planner.plan(state), + operation: () => + this.createHarnessAndWait( + { + ...request, + clientToken, + executionRoleArn: managedRole.arn, + }, + options, + ), + operationRetry: { + maxAttempts: 8, + delayMs: 2_000, + shouldRetry: isExecutionRolePropagationError, + }, + isOperationOutcomeUnknown: (error) => error instanceof HarnessOutcomeUnknownError, + resolveDesired: async ({ settled }) => { + const inventory = await this.readPolicyInventory(settled.harnessId!, options, settled); + return { + contributions: this.planner.plan(this.policyState(inventory, options)), + inventoryComplete: true, + }; + }, + }); + return result.value.response; + } catch (error) { + if ( + error instanceof PolicyFinalizationError || + error instanceof PolicyOperationOutcomeUnknownError + ) { + throw error; + } + return roleManager.rollbackFailedCreate(managedRole, policyName, error); + } } async updateHarness( - request: UpdateHarnessRequest, + input: HarnessUpdateInput, options: CoreOptions, ): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new UpdateHarnessCommand({ ...request })); + const { skipRolePolicyUpdate, ...request } = input; + const updateRequest = { ...request, clientToken: request.clientToken ?? randomUUID() }; + if (skipRolePolicyUpdate) { + return this.clients + .control(toClientConfig(options)) + .send(new UpdateHarnessCommand(updateRequest)); + } + const currentResponse = await this.getHarness(request.harnessId!, options); + const current = requiredHarness(currentResponse, request.harnessId!); + if (!current.executionRoleArn) { + throw new Error(`Harness ${request.harnessId} returned no execution role ARN.`); + } + const management = ExecutionRoleManager.policyManagement({ + associatedRoleArn: current.executionRoleArn, + explicitRoleArn: request.executionRoleArn, + expectedCliRoleName: expectedHarnessCliRoleName(current), + }); + if (management.mode === "external") { + if ( + management.reason === "explicit-role" && + request.executionRoleArn !== current.executionRoleArn + ) { + const previous = ExecutionRoleManager.policyManagement({ + associatedRoleArn: current.executionRoleArn, + expectedCliRoleName: expectedHarnessCliRoleName(current), + }); + if (previous.mode === "managed") { + const result = await this.policyUpdater( + this.clients.iam({ region: options.region }), + ).removeAfter({ + roleName: previous.roleName, + policyName: harnessPolicyName(previous, current, options.region), + operation: () => this.updateHarnessAndWait(updateRequest, options), + isOperationOutcomeUnknown: (error) => error instanceof HarnessOutcomeUnknownError, + }); + return result.response; + } + } + return this.clients + .control(toClientConfig(options)) + .send(new UpdateHarnessCommand(updateRequest)); + } + + const inventory = await this.readPolicyInventory(request.harnessId!, options, current); + const desiredHarness = applyHarnessUpdate(current, request); + const desiredState = await this.enrichState( + this.policyState({ ...inventory, harness: desiredHarness }, options), + options, + ); + const result = await this.reconcileManaged({ + harnessId: request.harnessId!, + management, + current: inventory, + desired: this.planner.plan(desiredState), + operation: () => this.updateHarnessAndWait(updateRequest, options), + options, + }); + return result.response; } async deleteHarness( request: DeleteHarnessRequest, options: CoreOptions, ): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new DeleteHarnessCommand({ ...request })); + const current = requiredHarness( + await this.getHarness(request.harnessId!, options), + request.harnessId!, + ); + if (!current.executionRoleArn) { + throw new Error(`Harness ${request.harnessId} returned no execution role ARN.`); + } + const management = ExecutionRoleManager.policyManagement({ + associatedRoleArn: current.executionRoleArn, + expectedCliRoleName: expectedHarnessCliRoleName(current), + }); + const operation = () => this.deleteHarnessAndWait(request, options); + if (management.mode === "external") { + return this.clients.control(toClientConfig(options)).send(new DeleteHarnessCommand(request)); + } + const result = await this.policyUpdater( + this.clients.iam({ region: options.region }), + ).removeAfter({ + roleName: management.roleName, + policyName: harnessPolicyName(management, current, options.region), + operation, + isOperationOutcomeUnknown: (error) => error instanceof HarnessOutcomeUnknownError, + }); + return result.response; } async createHarnessEndpoint( @@ -176,6 +390,221 @@ export class HarnessClient implements CoreHarnessClient { .send(new DeleteHarnessEndpointCommand({ ...request })); } + private policyUpdater(iam: ReturnType): ExecutionRolePolicyUpdater { + return new ExecutionRolePolicyUpdater(iam, { + propagationDelayMs: 10_000, + ...this.options.policyUpdater, + }); + } + + private policyState(inventory: HarnessPolicyInventory, options: CoreOptions): HarnessPolicyState { + const harness = inventory.harness; + return { + region: options.region, + accountId: accountIdFromRoleArn(harness.executionRoleArn!), + harnessName: harness.harnessName!, + model: harness.model, + tools: harness.tools, + skills: harness.skills, + memory: harness.memory, + environment: harness.environment, + environmentArtifact: harness.environmentArtifact, + credentialProviders: inventory.credentials, + }; + } + + private async enrichState( + state: HarnessPolicyState, + options: CoreOptions, + ): Promise { + const credentials = await new CredentialProviderPolicyResolver( + this.clients.control(toClientConfig(options)), + ).resolve(harnessCredentialProviderRequests(state)); + return { ...state, credentialProviders: credentials }; + } + + private async readPolicyInventory( + harnessId: string, + options: CoreOptions, + knownHarness?: Harness, + ): Promise { + const harness = + knownHarness ?? requiredHarness(await this.getHarness(harnessId, options), harnessId); + const state: HarnessPolicyState = { + region: options.region, + accountId: accountIdFromRoleArn(harness.executionRoleArn!), + harnessName: harness.harnessName!, + model: harness.model, + tools: harness.tools, + skills: harness.skills, + memory: harness.memory, + environment: harness.environment, + environmentArtifact: harness.environmentArtifact, + }; + const enriched = await this.enrichState(state, options); + return { + harness, + credentials: [...(enriched.credentialProviders ?? [])], + }; + } + + private async reconcileManaged(input: { + harnessId: string; + management: ManagedHarnessPolicy; + current: HarnessPolicyInventory; + desired: readonly PolicyContribution[]; + operation: () => Promise; + options: CoreOptions; + }): Promise { + const iam = this.clients.iam({ region: input.options.region }); + const roleManager = new ExecutionRoleManager(iam, this.options.roleManager); + await roleManager.validateAgentCoreTrust(input.management.roleName, { + sourceAccount: accountIdFromRoleArn(input.management.roleArn), + sourceArn: input.current.harness.arn!, + }); + const result = await this.policyUpdater(iam).update({ + roleName: input.management.roleName, + policyName: harnessPolicyName(input.management, input.current.harness, input.options.region), + current: this.planner.plan(this.policyState(input.current, input.options)), + desired: input.desired, + operation: input.operation, + operationRetry: { + maxAttempts: 8, + delayMs: 2_000, + shouldRetry: isExecutionRolePropagationError, + }, + isOperationOutcomeUnknown: (error) => error instanceof HarnessOutcomeUnknownError, + resolveDesired: async () => { + const settled = await this.readPolicyInventory(input.harnessId, input.options); + return { + contributions: this.planner.plan(this.policyState(settled, input.options)), + inventoryComplete: true, + }; + }, + }); + return result.value; + } + + private async createHarnessAndWait( + input: CreateHarnessCommand["input"], + options: CoreOptions, + ): Promise<{ response: CreateHarnessResponse; settled: Harness }> { + const control = this.clients.control(toClientConfig(options)); + let response: CreateHarnessResponse; + try { + response = await control.send(new CreateHarnessCommand(input)); + } catch (error) { + if (isExecutionRolePropagationError(error)) throw error; + if (isAmbiguousMutationError(error)) { + throw new HarnessOutcomeUnknownError(input.harnessName ?? "unknown", { + cause: error, + }); + } + throw error; + } + const harnessId = response.harness?.harnessId; + if (!harnessId) throw new Error("CreateHarness returned no Harness ID."); + const harnessVersion = response.harness?.harnessVersion; + if (!harnessVersion) throw new Error("CreateHarness returned no Harness version."); + return { + response, + settled: await this.waitForHarness(harnessId, harnessVersion, options), + }; + } + + private async updateHarnessAndWait( + input: UpdateHarnessCommand["input"], + options: CoreOptions, + ): Promise<{ response: UpdateHarnessResponse; settled: Harness }> { + const control = this.clients.control(toClientConfig(options)); + let response: UpdateHarnessResponse; + try { + response = await control.send(new UpdateHarnessCommand(input)); + } catch (error) { + if (isExecutionRolePropagationError(error)) throw error; + if (isAmbiguousMutationError(error)) { + throw new HarnessOutcomeUnknownError(input.harnessId ?? "unknown", { + cause: error, + }); + } + throw error; + } + const harnessVersion = response.harness?.harnessVersion; + if (!harnessVersion) throw new Error("UpdateHarness returned no Harness version."); + return { + response, + settled: await this.waitForHarness(input.harnessId!, harnessVersion, options), + }; + } + + private async deleteHarnessAndWait( + input: DeleteHarnessRequest, + options: CoreOptions, + ): Promise<{ response: DeleteHarnessResponse }> { + const control = this.clients.control(toClientConfig(options)); + let response: DeleteHarnessResponse; + try { + response = await control.send(new DeleteHarnessCommand(input)); + } catch (error) { + if (isAmbiguousMutationError(error)) { + throw new HarnessOutcomeUnknownError(input.harnessId ?? "unknown", { + cause: error, + }); + } + throw error; + } + await this.waitForHarnessDeletion(input.harnessId!, options); + return { response }; + } + + private async waitForHarness( + harnessId: string, + harnessVersion: string, + options: CoreOptions, + ): Promise { + const attempts = this.options.waitAttempts ?? DEFAULT_WAIT_ATTEMPTS; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const harness = requiredHarness(await this.getHarness(harnessId, options), harnessId); + if (harness.harnessVersion === harnessVersion) { + if (harness.status === "READY") return harness; + if (harness.status === "CREATE_FAILED" || harness.status === "UPDATE_FAILED") { + throw new HarnessTerminalStateError(harnessId, harness.status, harness.failureReason); + } + } + } catch (error) { + if (error instanceof HarnessTerminalStateError) throw error; + if ((error as Error).name !== "ResourceNotFoundException" || attempt >= attempts) { + throw new HarnessOutcomeUnknownError(harnessId, { cause: error }); + } + } + if (attempt < attempts) { + await this.sleep(this.options.waitDelayMs ?? DEFAULT_WAIT_DELAY_MS); + } + } + throw new HarnessOutcomeUnknownError(harnessId); + } + + private async waitForHarnessDeletion(harnessId: string, options: CoreOptions): Promise { + const attempts = this.options.waitAttempts ?? DEFAULT_WAIT_ATTEMPTS; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const harness = requiredHarness(await this.getHarness(harnessId, options), harnessId); + if (harness.status === "DELETE_FAILED") { + throw new HarnessTerminalStateError(harnessId, harness.status, harness.failureReason); + } + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException") return; + if (error instanceof HarnessTerminalStateError) throw error; + throw new HarnessOutcomeUnknownError(harnessId, { cause: error }); + } + if (attempt < attempts) { + await this.sleep(this.options.waitDelayMs ?? DEFAULT_WAIT_DELAY_MS); + } + } + throw new HarnessOutcomeUnknownError(harnessId); + } + async invokeHarness( request: InvokeHarnessRequest, options: CoreOptions, @@ -207,24 +636,75 @@ export class HarnessClient implements CoreHarnessClient { } } -// retryWhileRoleUnassumable retries `operation` while it fails with the -// validation error AgentCore raises for an execution role it cannot yet assume -// (fresh IAM roles propagate over several seconds). Any other failure — or -// exhausting the attempts — rethrows. -async function retryWhileRoleUnassumable( - operation: () => Promise, - attempts = 8, - delayMs = 2000, -): Promise { - for (let attempt = 1; ; attempt++) { - try { - return await operation(); - } catch (error) { - const retryable = - (error as Error).name === "ValidationException" && - /role|assume|trust/i.test((error as Error).message ?? ""); - if (!retryable || attempt >= attempts) throw error; - await new Promise((resolve) => setTimeout(resolve, delayMs)); +function requiredHarness(response: GetHarnessResponse, harnessId: string): Harness { + if (!response.harness) throw new Error(`GetHarness returned no Harness for ${harnessId}.`); + return response.harness; +} + +function expectedHarnessCliRoleName(harness: Harness): string { + if (!harness.harnessName) throw new Error("Harness returned no name."); + return ExecutionRoleManager.cliRoleName("harness", harness.harnessName); +} + +function applyHarnessUpdate(current: Harness, request: UpdateHarnessRequest): Harness { + return { + ...current, + executionRoleArn: request.executionRoleArn ?? current.executionRoleArn, + environment: (request.environment ?? current.environment) as Harness["environment"], + environmentArtifact: + request.environmentArtifact === undefined + ? current.environmentArtifact + : request.environmentArtifact.optionalValue, + model: request.model ?? current.model, + tools: request.tools ?? current.tools, + skills: request.skills ?? current.skills, + memory: request.memory === undefined ? current.memory : request.memory.optionalValue, + }; +} + +function harnessPolicyName( + management: ManagedHarnessPolicy, + harness: Harness, + region: string, +): string { + const stableResourceKey = management.roleName.startsWith("AgentCoreCliHarness-") + ? management.roleName + : harness.harnessId!; + return ExecutionRoleManager.generatedPolicyName("harness", { + accountId: accountIdFromRoleArn(management.roleArn), + region, + stableResourceKey, + }); +} + +function accountIdFromRoleArn(roleArn: string): string { + const accountId = roleArn.split(":")[4]; + if (!accountId) throw new Error(`Cannot extract account ID from role ARN "${roleArn}".`); + return accountId; +} + +function isExecutionRolePropagationError(error: unknown): boolean { + return ( + ["ValidationException", "AccessDeniedException"].includes((error as Error).name) && + /role|permission|authoriz|assum|memory|model|browser|interpreter|gateway/i.test( + (error as Error).message ?? "", + ) + ); +} + +function isAmbiguousMutationError(error: unknown): boolean { + const name = (error as Error).name; + const statusCode = ( + error as { + $metadata?: { httpStatusCode?: number }; } - } + ).$metadata?.httpStatusCode; + return ( + ["TimeoutError", "AbortError", "NetworkingError"].includes(name) || + (statusCode !== undefined && statusCode >= 500) + ); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/src/core/harnessIam.test.ts b/src/core/harnessIam.test.ts new file mode 100644 index 000000000..1da8239f3 --- /dev/null +++ b/src/core/harnessIam.test.ts @@ -0,0 +1,584 @@ +import { describe, expect, test } from "bun:test"; +import { + CreateHarnessCommand, + DeleteHarnessCommand, + DeleteHarnessEndpointCommand, + GetApiKeyCredentialProviderCommand, + GetHarnessCommand, + UpdateHarnessCommand, + type BedrockAgentCoreControlClient, + type CreateHarnessResponse, + type DeleteHarnessResponse, + type DeleteHarnessEndpointResponse, + type GetHarnessResponse, + type UpdateHarnessResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + GetRolePolicyCommand, + ListRolePoliciesCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { HarnessClient } from "./harness"; +import { ExecutionRoleManager } from "./executionRoleManager"; +import type { AwsClients } from "./types"; + +const REGION = "us-west-2"; +const ACCOUNT = "123456789012"; +const ROLE_NAME = "AgentCoreCliHarness-orders"; +const ROLE_ARN = `arn:aws:iam::${ACCOUNT}:role/${ROLE_NAME}`; +const HARNESS_ID = "orders-abc123"; +const HARNESS_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:harness/orders-abc123"; +const MEMORY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:memory/harness_orders-abc123"; +const BROWSER_ARN = "arn:aws:bedrock-agentcore:us-west-2:aws:browser/orders"; +const API_KEY_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/openai"; + +type IamCommand = + | CreateRoleCommand + | DeleteRoleCommand + | DeleteRolePolicyCommand + | GetRoleCommand + | GetRolePolicyCommand + | ListRolePoliciesCommand + | PutRolePolicyCommand; + +function atoms(document: string): string[] { + const parsed = JSON.parse(document) as { + Statement: { Action: string[]; Resource: string[] }[]; + }; + return parsed.Statement.flatMap((statement) => + statement.Action.flatMap((action) => + statement.Resource.map((resource) => `${action} ${resource}`), + ), + ).sort(); +} + +class InMemoryHarnessIam { + readonly policies = new Map(); + readonly client: IAMClient; + + constructor(public roleExists = true) { + this.client = { + send: (command: IamCommand) => this.send(command), + } as unknown as IAMClient; + } + + private async send(command: IamCommand): Promise { + if (command instanceof GetRoleCommand) { + if (!this.roleExists) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { + Role: { + RoleName: ROLE_NAME, + Arn: ROLE_ARN, + AssumeRolePolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }), + }, + }; + } + if (command instanceof CreateRoleCommand) { + this.roleExists = true; + return { Role: { RoleName: ROLE_NAME, Arn: ROLE_ARN } }; + } + if (command instanceof ListRolePoliciesCommand) { + return { PolicyNames: [...this.policies.keys()], IsTruncated: false }; + } + if (command instanceof PutRolePolicyCommand) { + this.policies.set(command.input.PolicyName!, command.input.PolicyDocument!); + return {}; + } + if (command instanceof GetRolePolicyCommand) { + const document = this.policies.get(command.input.PolicyName!); + if (!document) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { PolicyDocument: document }; + } + if (command instanceof DeleteRolePolicyCommand) { + this.policies.delete(command.input.PolicyName!); + return {}; + } + if (command instanceof DeleteRoleCommand) { + this.roleExists = false; + return {}; + } + throw new Error("unexpected IAM command"); + } +} + +describe("HarnessClient managed execution role", () => { + test("stages managed Memory and finalizes its generated ARN after READY", async () => { + const iam = new InMemoryHarnessIam(false); + const creating: CreateHarnessResponse = { + harness: { + harnessId: HARNESS_ID, + harnessName: "orders", + arn: HARNESS_ARN, + status: "CREATING", + executionRoleArn: ROLE_ARN, + harnessVersion: "1", + } as never, + }; + const ready: GetHarnessResponse = { + harness: { + ...creating.harness!, + status: "READY", + memory: { managedMemoryConfiguration: { arn: MEMORY_ARN } }, + }, + }; + const control = { + send: async (command: CreateHarnessCommand | GetHarnessCommand) => { + if (command instanceof CreateHarnessCommand) { + const staged = atoms([...iam.policies.values()][0]!); + expect( + staged.some((value) => value.startsWith("bedrock-agentcore:CreateEvent ")), + ).toBeFalse(); + return creating; + } + return ready; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new HarnessClient( + { + control: () => control, + iam: () => iam.client, + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + await expect( + client.createHarness({ harnessName: "orders" }, { region: REGION }), + ).resolves.toEqual(creating); + expect(iam.roleExists).toBeTrue(); + expect(iam.policies.size).toBe(1); + expect(atoms([...iam.policies.values()][0]!)).toContain( + `bedrock-agentcore:CreateEvent ${MEMORY_ARN}`, + ); + }); + + test("leaves explicit-role create entirely outside IAM reconciliation", async () => { + const response: CreateHarnessResponse = { + harness: { + harnessId: HARNESS_ID, + harnessName: "orders", + status: "CREATING", + executionRoleArn: "arn:aws:iam::123456789012:role/CustomerHarnessRole", + } as never, + }; + const control = { + send: async (command: CreateHarnessCommand) => { + expect(command).toBeInstanceOf(CreateHarnessCommand); + return response; + }, + } as unknown as BedrockAgentCoreControlClient; + const iam = { + send: async () => { + throw new Error("explicit-role create must not call IAM"); + }, + } as unknown as IAMClient; + const client = new HarnessClient({ + control: () => control, + iam: () => iam, + } as unknown as AwsClients); + + await expect( + client.createHarness( + { + harnessName: "orders", + executionRoleArn: "arn:aws:iam::123456789012:role/CustomerHarnessRole", + }, + { region: REGION }, + ), + ).resolves.toEqual(response); + }); + + test("removes a newly created role when credential enrichment fails", async () => { + const iam = new InMemoryHarnessIam(false); + const control = { + send: async (command: GetApiKeyCredentialProviderCommand) => { + expect(command).toBeInstanceOf(GetApiKeyCredentialProviderCommand); + throw new Error("credential provider is missing"); + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new HarnessClient( + { + control: () => control, + iam: () => iam.client, + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + }, + ); + + await expect( + client.createHarness( + { + harnessName: "orders", + model: { + openAiModelConfig: { + modelId: "gpt-5", + apiKeyArn: API_KEY_ARN, + }, + }, + }, + { region: REGION }, + ), + ).rejects.toThrow("credential provider is missing"); + expect(iam.roleExists).toBeFalse(); + expect(iam.policies.size).toBe(0); + }); + + test("does not poll or mutate IAM when an update explicitly retains its role", async () => { + const current: GetHarnessResponse = { + harness: { + harnessId: HARNESS_ID, + harnessName: "orders", + arn: HARNESS_ARN, + status: "READY", + executionRoleArn: ROLE_ARN, + } as never, + }; + const response: UpdateHarnessResponse = { + harness: { + ...current.harness!, + status: "UPDATING", + }, + }; + const commands: string[] = []; + const control = { + send: async (command: GetHarnessCommand | UpdateHarnessCommand) => { + commands.push(command.constructor.name); + if (command instanceof GetHarnessCommand) return current; + if (command instanceof UpdateHarnessCommand) return response; + throw new Error("unexpected control command"); + }, + } as unknown as BedrockAgentCoreControlClient; + const iam = { + send: async () => { + throw new Error("explicit-role update must not call IAM"); + }, + } as unknown as IAMClient; + const client = new HarnessClient({ + control: () => control, + iam: () => iam, + } as unknown as AwsClients); + + await expect( + client.updateHarness( + { + harnessId: HARNESS_ID, + executionRoleArn: ROLE_ARN, + maxIterations: 10, + }, + { region: REGION }, + ), + ).resolves.toEqual(response); + expect(commands).toEqual(["GetHarnessCommand", "UpdateHarnessCommand"]); + }); + + test("makes skip-role-policy-update a direct service update", async () => { + const response: UpdateHarnessResponse = { + harness: { + harnessId: HARNESS_ID, + harnessName: "orders", + status: "UPDATING", + executionRoleArn: ROLE_ARN, + } as never, + }; + const control = { + send: async (command: UpdateHarnessCommand) => { + expect(command).toBeInstanceOf(UpdateHarnessCommand); + return response; + }, + } as unknown as BedrockAgentCoreControlClient; + const iam = { + send: async () => { + throw new Error("skipped update must not call IAM"); + }, + } as unknown as IAMClient; + const client = new HarnessClient({ + control: () => control, + iam: () => iam, + } as unknown as AwsClients); + + await expect( + client.updateHarness( + { + harnessId: HARNESS_ID, + maxIterations: 10, + skipRolePolicyUpdate: true, + }, + { region: REGION }, + ), + ).resolves.toEqual(response); + }); + + test("keeps current and desired grants through a managed update, then tightens", async () => { + const iam = new InMemoryHarnessIam(); + const current: GetHarnessResponse = { + harness: { + harnessId: HARNESS_ID, + harnessName: "orders", + arn: HARNESS_ARN, + status: "READY", + executionRoleArn: ROLE_ARN, + memory: { agentCoreMemoryConfiguration: { arn: MEMORY_ARN } }, + tools: [], + } as never, + }; + const ready: GetHarnessResponse = { + harness: { + ...current.harness!, + status: "READY", + memory: { disabled: {} }, + tools: [ + { + type: "agentcore_browser", + config: { agentCoreBrowser: { browserArn: BROWSER_ARN } }, + }, + ], + harnessVersion: "2", + }, + }; + const updating: UpdateHarnessResponse = { + harness: { ...ready.harness!, status: "UPDATING" }, + }; + let getCount = 0; + const control = { + send: async (command: GetHarnessCommand | UpdateHarnessCommand) => { + if (command instanceof GetHarnessCommand) { + getCount += 1; + return getCount <= 3 ? current : ready; + } + const transition = atoms([...iam.policies.values()][0]!); + expect(transition).toContain(`bedrock-agentcore:CreateEvent ${MEMORY_ARN}`); + expect(transition).toContain(`bedrock-agentcore:StartBrowserSession ${BROWSER_ARN}`); + return updating; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new HarnessClient( + { + control: () => control, + iam: () => iam.client, + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + await expect( + client.updateHarness( + { + harnessId: HARNESS_ID, + memory: { optionalValue: { disabled: {} } }, + tools: ready.harness!.tools, + }, + { region: REGION }, + ), + ).resolves.toEqual(updating); + + const finalPolicy = atoms([...iam.policies.values()][0]!); + expect(finalPolicy).not.toContain(`bedrock-agentcore:CreateEvent ${MEMORY_ARN}`); + expect(finalPolicy).toContain(`bedrock-agentcore:StartBrowserSession ${BROWSER_ARN}`); + }); + + test("restores current grants when a managed update fails", async () => { + const iam = new InMemoryHarnessIam(); + const current: GetHarnessResponse = { + harness: { + harnessId: HARNESS_ID, + harnessName: "orders", + arn: HARNESS_ARN, + status: "READY", + executionRoleArn: ROLE_ARN, + memory: { agentCoreMemoryConfiguration: { arn: MEMORY_ARN } }, + tools: [], + } as never, + }; + const control = { + send: async (command: GetHarnessCommand | UpdateHarnessCommand) => { + if (command instanceof GetHarnessCommand) return current; + const transition = atoms([...iam.policies.values()][0]!); + expect(transition).toContain(`bedrock-agentcore:CreateEvent ${MEMORY_ARN}`); + expect(transition).toContain(`bedrock-agentcore:StartBrowserSession ${BROWSER_ARN}`); + throw new Error("service rejected update"); + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new HarnessClient( + { + control: () => control, + iam: () => iam.client, + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + await expect( + client.updateHarness( + { + harnessId: HARNESS_ID, + tools: [ + { + type: "agentcore_browser", + config: { agentCoreBrowser: { browserArn: BROWSER_ARN } }, + }, + ], + }, + { region: REGION }, + ), + ).rejects.toThrow("service rejected update"); + + const restored = atoms([...iam.policies.values()][0]!); + expect(restored).toContain(`bedrock-agentcore:CreateEvent ${MEMORY_ARN}`); + expect(restored).not.toContain(`bedrock-agentcore:StartBrowserSession ${BROWSER_ARN}`); + }); + + test("deletes only the generated policy after confirmed Harness deletion", async () => { + const iam = new InMemoryHarnessIam(); + const policyName = ExecutionRoleManager.generatedPolicyName("harness", { + accountId: ACCOUNT, + region: REGION, + stableResourceKey: ROLE_NAME, + }); + iam.policies.set( + policyName, + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["bedrock-agentcore:CreateEvent"], + Resource: [MEMORY_ARN], + }, + ], + }), + ); + const current: GetHarnessResponse = { + harness: { + harnessId: HARNESS_ID, + harnessName: "orders", + arn: HARNESS_ARN, + status: "READY", + executionRoleArn: ROLE_ARN, + } as never, + }; + const deleting: DeleteHarnessResponse = { + harness: { ...current.harness!, status: "DELETING" }, + }; + let getCount = 0; + const control = { + send: async (command: GetHarnessCommand | DeleteHarnessCommand) => { + if (command instanceof DeleteHarnessCommand) return deleting; + getCount += 1; + if (getCount === 1) return current; + const error = new Error("deleted"); + error.name = "ResourceNotFoundException"; + throw error; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new HarnessClient( + { + control: () => control, + iam: () => iam.client, + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + await expect( + client.deleteHarness({ harnessId: HARNESS_ID }, { region: REGION }), + ).resolves.toEqual(deleting); + expect(iam.policies.has(policyName)).toBeFalse(); + expect(iam.roleExists).toBeTrue(); + }); + + test("does not claim a CLI role owned by a different Harness", async () => { + const current: GetHarnessResponse = { + harness: { + harnessId: "invoices-abc123", + harnessName: "invoices", + status: "READY", + executionRoleArn: ROLE_ARN, + } as never, + }; + const deleting: DeleteHarnessResponse = { + harness: { ...current.harness!, status: "DELETING" }, + }; + const commands: string[] = []; + const control = { + send: async (command: GetHarnessCommand | DeleteHarnessCommand) => { + commands.push(command.constructor.name); + return command instanceof GetHarnessCommand ? current : deleting; + }, + } as unknown as BedrockAgentCoreControlClient; + const iam = { + send: async () => { + throw new Error("shared CLI role must remain externally managed"); + }, + } as unknown as IAMClient; + const client = new HarnessClient({ + control: () => control, + iam: () => iam, + } as unknown as AwsClients); + + await expect( + client.deleteHarness({ harnessId: "invoices-abc123" }, { region: REGION }), + ).resolves.toEqual(deleting); + expect(commands).toEqual(["GetHarnessCommand", "DeleteHarnessCommand"]); + }); + + test("keeps endpoint deletion independent from execution-role reconciliation", async () => { + const response: DeleteHarnessEndpointResponse = { + endpoint: { + harnessId: HARNESS_ID, + endpointName: "live", + status: "DELETING", + } as never, + }; + const control = { + send: async (command: DeleteHarnessEndpointCommand) => { + expect(command).toBeInstanceOf(DeleteHarnessEndpointCommand); + return response; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new HarnessClient({ + control: () => control, + } as unknown as AwsClients); + + await expect( + client.deleteHarnessEndpoint( + { + harnessId: HARNESS_ID, + endpointName: "live", + }, + { region: REGION }, + ), + ).resolves.toEqual(response); + }); +}); diff --git a/src/core/harnessPolicy.test.ts b/src/core/harnessPolicy.test.ts new file mode 100644 index 000000000..3440ae123 --- /dev/null +++ b/src/core/harnessPolicy.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test"; +import { PolicyCompiler } from "./executionRolePolicy"; +import { HarnessPolicyPlanner } from "./harnessPolicy"; + +const REGION = "us-west-2"; +const ACCOUNT = "123456789012"; +const MEMORY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:memory/harness_orders-abc123"; +const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123"; +const BROWSER_ARN = "arn:aws:bedrock-agentcore:us-west-2:aws:browser/browser-1"; +const CODE_ARN = "arn:aws:bedrock-agentcore:us-west-2:aws:code-interpreter/code-1"; +const API_KEY_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/openai"; +const API_KEY_SECRET = "arn:aws:secretsmanager:us-west-2:123456789012:secret:openai"; +const OAUTH_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/gateway"; +const OAUTH_SECRET = "arn:aws:secretsmanager:us-west-2:123456789012:secret:gateway-oauth"; +const WORKLOAD_DIRECTORY = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default"; +const WORKLOAD_IDENTITY = `${WORKLOAD_DIRECTORY}/workload-identity/harness_orders-*`; +const TOKEN_VAULT = "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default"; + +function permissions(state: Parameters[0]): string[] { + return new PolicyCompiler() + .compile(new HarnessPolicyPlanner().plan(state)) + .permissions.map(({ action, resource }) => `${action} ${resource}`); +} + +describe("HarnessPolicyPlanner", () => { + test("plans baseline Bedrock, observability, and public image access", () => { + const actual = permissions({ + region: REGION, + accountId: ACCOUNT, + harnessName: "orders", + }); + + expect(actual).toEqual( + expect.arrayContaining([ + "bedrock:InvokeModel arn:aws:bedrock:*::foundation-model/*", + `bedrock:InvokeModel arn:aws:bedrock:${REGION}:${ACCOUNT}:*`, + "ecr-public:GetAuthorizationToken *", + "sts:GetServiceBearerToken *", + "xray:PutTraceSegments *", + `logs:CreateLogGroup arn:aws:logs:${REGION}:${ACCOUNT}:log-group:/aws/bedrock-agentcore/runtimes/*`, + "cloudwatch:PutMetricData *", + ]), + ); + expect(actual.some((value) => value.startsWith("bedrock-agentcore:CreateEvent "))).toBeFalse(); + expect(actual).not.toContain("logs:PutResourcePolicy *"); + }); + + test("adds Bedrock Mantle only for Responses and Chat Completions formats", () => { + const responses = permissions({ + region: REGION, + accountId: ACCOUNT, + harnessName: "orders", + model: { + bedrockModelConfig: { + modelId: "global.anthropic.claude-sonnet-4-6", + apiFormat: "responses", + }, + }, + }); + const converse = permissions({ + region: REGION, + accountId: ACCOUNT, + harnessName: "orders", + model: { + bedrockModelConfig: { + modelId: "global.anthropic.claude-sonnet-4-6", + apiFormat: "converse_stream", + }, + }, + }); + + expect(responses).toEqual( + expect.arrayContaining([ + `bedrock-mantle:CreateInference arn:aws:bedrock-mantle:${REGION}:${ACCOUNT}:*`, + "bedrock-mantle:CallWithBearerToken *", + ]), + ); + expect(converse.some((value) => value.startsWith("bedrock-mantle:"))).toBeFalse(); + }); + + test("tightens managed Memory and plans exact AgentCore tools", () => { + const actual = permissions({ + region: REGION, + accountId: ACCOUNT, + harnessName: "orders", + memory: { managedMemoryConfiguration: { arn: MEMORY_ARN } }, + tools: [ + { + type: "agentcore_browser", + config: { agentCoreBrowser: { browserArn: BROWSER_ARN } }, + }, + { + type: "agentcore_code_interpreter", + config: { + agentCoreCodeInterpreter: { codeInterpreterArn: CODE_ARN }, + }, + }, + { + type: "agentcore_gateway", + config: { + agentCoreGateway: { + gatewayArn: GATEWAY_ARN, + outboundAuth: { awsIam: {} }, + }, + }, + }, + ], + }); + + expect(actual).toEqual( + expect.arrayContaining([ + `bedrock-agentcore:CreateEvent ${MEMORY_ARN}`, + `bedrock-agentcore:StartBrowserSession ${BROWSER_ARN}`, + `bedrock-agentcore:InvokeCodeInterpreter ${CODE_ARN}`, + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + ]), + ); + }); + + test("plans API key, OAuth, S3 skill, EFS, and private ECR access", () => { + const actual = permissions({ + region: REGION, + accountId: ACCOUNT, + harnessName: "orders", + memory: { disabled: {} }, + model: { + openAiModelConfig: { + modelId: "gpt-5", + apiKeyArn: API_KEY_ARN, + }, + }, + tools: [ + { + type: "agentcore_gateway", + config: { + agentCoreGateway: { + gatewayArn: GATEWAY_ARN, + outboundAuth: { + oauth: { + providerArn: OAUTH_ARN, + scopes: ["gateway.invoke"], + }, + }, + }, + }, + }, + ], + skills: [ + { s3: { uri: "s3://skills-bucket/orders/" } }, + { + git: { + url: "https://github.com/example/private", + auth: { credentialArn: API_KEY_ARN }, + }, + }, + ], + environment: { + agentCoreRuntimeEnvironment: { + filesystemConfigurations: [ + { + efsAccessPoint: { + accessPointArn: + "arn:aws:elasticfilesystem:us-west-2:123456789012:access-point/fsap-123", + mountPath: "/mnt/data", + }, + }, + { + s3FilesAccessPoint: { + accessPointArn: + "arn:aws:s3files:us-west-2:123456789012:file-system/fs-0123456789abcdef0/access-point/fsap-0123456789abcdef0", + mountPath: "/mnt/files", + }, + }, + ], + }, + }, + environmentArtifact: { + containerConfiguration: { + containerUri: "123456789012.dkr.ecr.us-west-2.amazonaws.com/orders:v1", + }, + }, + credentialProviders: [ + { providerArn: API_KEY_ARN, secretArn: API_KEY_SECRET }, + { providerArn: OAUTH_ARN, secretArn: OAUTH_SECRET }, + ], + }); + + expect(actual).toEqual( + expect.arrayContaining([ + `bedrock-agentcore:GetResourceApiKey ${API_KEY_ARN}`, + `bedrock-agentcore:GetResourceApiKey ${TOKEN_VAULT}`, + `bedrock-agentcore:GetResourceApiKey ${WORKLOAD_DIRECTORY}`, + `bedrock-agentcore:GetResourceApiKey ${WORKLOAD_IDENTITY}`, + `secretsmanager:GetSecretValue ${API_KEY_SECRET}`, + `bedrock-agentcore:GetResourceOauth2Token ${OAUTH_ARN}`, + `bedrock-agentcore:GetResourceOauth2Token ${TOKEN_VAULT}`, + `bedrock-agentcore:GetResourceOauth2Token ${WORKLOAD_DIRECTORY}`, + `bedrock-agentcore:GetResourceOauth2Token ${WORKLOAD_IDENTITY}`, + `secretsmanager:GetSecretValue ${OAUTH_SECRET}`, + `bedrock-agentcore:GetWorkloadAccessToken ${WORKLOAD_DIRECTORY}`, + `bedrock-agentcore:GetWorkloadAccessToken ${WORKLOAD_IDENTITY}`, + "s3:ListBucket arn:aws:s3:::skills-bucket", + "s3:GetObject arn:aws:s3:::skills-bucket/orders/*", + "elasticfilesystem:ClientMount arn:aws:elasticfilesystem:us-west-2:123456789012:file-system/*", + "s3files:ClientMount arn:aws:s3files:us-west-2:123456789012:file-system/fs-0123456789abcdef0", + "s3files:GetFileSystem *", + "s3files:GetMountTarget *", + "ecr:BatchGetImage arn:aws:ecr:us-west-2:123456789012:repository/orders", + "ecr:BatchCheckLayerAvailability arn:aws:ecr:us-west-2:123456789012:repository/orders", + ]), + ); + expect(actual.some((value) => value.startsWith("s3files:ClientRootAccess "))).toBeFalse(); + expect(actual.some((value) => value.startsWith("bedrock:InvokeModel "))).toBeFalse(); + }); +}); diff --git a/src/core/harnessPolicy.ts b/src/core/harnessPolicy.ts new file mode 100644 index 000000000..d7b4da651 --- /dev/null +++ b/src/core/harnessPolicy.ts @@ -0,0 +1,413 @@ +import type { + HarnessEnvironmentArtifact, + HarnessEnvironmentProvider, + HarnessEnvironmentProviderRequest, + HarnessMemoryConfiguration, + HarnessModelConfiguration, + HarnessSkill, + HarnessTool, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { AgentCorePolicyGrants } from "./agentCorePolicyGrants"; +import type { + CredentialProviderPolicyRequest, + CredentialProviderPolicyState, +} from "./credentialProviderPolicy"; +import { allow, type PolicyContribution } from "./executionRolePolicy"; + +export type HarnessPolicyState = { + region: string; + accountId: string; + harnessName: string; + model?: HarnessModelConfiguration; + tools?: readonly HarnessTool[]; + skills?: readonly HarnessSkill[]; + memory?: HarnessMemoryConfiguration; + environment?: HarnessEnvironmentProviderRequest | HarnessEnvironmentProvider; + environmentArtifact?: HarnessEnvironmentArtifact; + credentialProviders?: readonly CredentialProviderPolicyState[]; +}; + +export class UninferrableHarnessPermissionError extends Error { + constructor( + readonly owner: string, + message: string, + ) { + super(`Cannot infer execution-role permissions for ${owner}: ${message}`); + this.name = "UninferrableHarnessPermissionError"; + } +} + +export class HarnessPolicyPlanner { + plan(state: HarnessPolicyState): PolicyContribution[] { + if (containsKey(state, "$unknown")) { + throw new UninferrableHarnessPermissionError( + "harness", + "configuration contains an unknown SDK union", + ); + } + const contributions = [ + ...this.baseline(state), + ...this.memory(state), + ...this.tools(state), + ...this.skills(state), + ...this.environment(state), + ]; + return [...contributions, ...this.credentials(state)]; + } + + private baseline(state: HarnessPolicyState): PolicyContribution[] { + const statements = [ + allow(["ecr-public:GetAuthorizationToken"], ["*"]), + allow(["sts:GetServiceBearerToken"], ["*"]), + allow( + [ + "xray:GetSamplingRules", + "xray:GetSamplingTargets", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments", + ], + ["*"], + ), + allow( + ["logs:CreateLogGroup", "logs:DescribeLogStreams"], + [ + `arn:aws:logs:${state.region}:${state.accountId}:log-group:/aws/bedrock-agentcore/runtimes/*`, + ], + ), + allow( + ["logs:DescribeLogGroups"], + [`arn:aws:logs:${state.region}:${state.accountId}:log-group:*`], + ), + allow( + ["logs:CreateLogStream", "logs:PutLogEvents"], + [ + `arn:aws:logs:${state.region}:${state.accountId}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*`, + ], + ), + allow(["cloudwatch:PutMetricData"], ["*"], { + StringEquals: { "cloudwatch:namespace": "bedrock-agentcore" }, + }), + ]; + const model = state.model; + if (!model || model.bedrockModelConfig || isBedrockLiteLlmModel(model)) { + const modelId = + model?.bedrockModelConfig?.modelId ?? + model?.liteLlmModelConfig?.modelId?.replace(/^bedrock\//, ""); + const resources = modelId?.startsWith("arn:") + ? [modelId] + : [ + "arn:aws:bedrock:*::foundation-model/*", + `arn:aws:bedrock:${state.region}:${state.accountId}:*`, + ]; + statements.unshift(AgentCorePolicyGrants.invokeModel(resources)); + const apiFormat = model?.bedrockModelConfig?.apiFormat; + if (apiFormat === "responses" || apiFormat === "chat_completions") { + statements.push( + AgentCorePolicyGrants.createMantleInference([ + `arn:aws:bedrock-mantle:${state.region}:${state.accountId}:*`, + ]), + AgentCorePolicyGrants.callMantleWithBearerToken(), + ); + } + } + return [{ owner: "harness:baseline", reason: "run Harness", statements }]; + } + + private memory(state: HarnessPolicyState): PolicyContribution[] { + if (state.memory?.disabled) return []; + const arn = + state.memory?.agentCoreMemoryConfiguration?.arn ?? + state.memory?.managedMemoryConfiguration?.arn; + if (!arn) return []; + return [ + { + owner: "harness:memory", + reason: "use Harness Memory", + statements: [AgentCorePolicyGrants.useMemory(arn)], + }, + ]; + } + + private tools(state: HarnessPolicyState): PolicyContribution[] { + const contributions: PolicyContribution[] = []; + for (const [index, tool] of (state.tools ?? []).entries()) { + const owner = `harness:tool:${tool.name ?? index}`; + const browser = tool.config?.agentCoreBrowser; + if (browser) { + contributions.push({ + owner, + reason: "use AgentCore Browser", + statements: [ + AgentCorePolicyGrants.useBrowser( + browser.browserArn ?? `arn:aws:bedrock-agentcore:${state.region}:aws:browser/*`, + ), + ], + }); + continue; + } + const codeInterpreter = tool.config?.agentCoreCodeInterpreter; + if (codeInterpreter) { + contributions.push({ + owner, + reason: "use AgentCore Code Interpreter", + statements: [ + AgentCorePolicyGrants.useCodeInterpreter( + codeInterpreter.codeInterpreterArn ?? + `arn:aws:bedrock-agentcore:${state.region}:aws:code-interpreter/*`, + ), + ], + }); + continue; + } + const gateway = tool.config?.agentCoreGateway; + if (gateway) { + if (!gateway.gatewayArn) { + throw new UninferrableHarnessPermissionError(owner, "Gateway ARN is missing"); + } + if (!gateway.outboundAuth || gateway.outboundAuth.awsIam) { + contributions.push({ + owner, + reason: "invoke AgentCore Gateway", + statements: [AgentCorePolicyGrants.invokeGateway(gateway.gatewayArn)], + }); + } + continue; + } + if (tool.config?.inlineFunction || tool.config?.remoteMcp) continue; + throw new UninferrableHarnessPermissionError(owner, "tool configuration is missing"); + } + return contributions; + } + + private skills(state: HarnessPolicyState): PolicyContribution[] { + const contributions: PolicyContribution[] = []; + for (const [index, skill] of (state.skills ?? []).entries()) { + const owner = `harness:skill:${index}`; + if (skill.awsSkills) { + throw new UninferrableHarnessPermissionError( + owner, + "AWS skill actions and resources cannot be inferred", + ); + } + if (skill.s3) { + const location = s3Prefix(skill.s3.uri, owner); + contributions.push({ + owner, + reason: "read S3 skill", + statements: [ + allow(["s3:ListBucket"], [location.bucketArn]), + allow(["s3:GetObject"], [location.objectArn]), + ], + }); + } + } + return contributions; + } + + private environment(state: HarnessPolicyState): PolicyContribution[] { + const statements = []; + const containerUri = state.environmentArtifact?.containerConfiguration?.containerUri; + if (containerUri) { + const repositoryArn = ecrRepositoryArn(containerUri); + if (repositoryArn) { + statements.push( + allow(["ecr:GetAuthorizationToken"], ["*"]), + allow( + ["ecr:BatchCheckLayerAvailability", "ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"], + [repositoryArn], + ), + ); + } + } + const environment = state.environment?.agentCoreRuntimeEnvironment; + for (const filesystem of environment?.filesystemConfigurations ?? []) { + const efs = filesystem.efsAccessPoint; + if (efs?.accessPointArn) { + const context = arnContext(efs.accessPointArn, "harness:environment"); + statements.push( + allow( + ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite"], + [ + `arn:${context.partition}:elasticfilesystem:${context.region}:${context.accountId}:file-system/*`, + ], + { ArnEquals: { "elasticfilesystem:AccessPointArn": efs.accessPointArn } }, + ), + allow( + ["elasticfilesystem:DescribeAccessPoints", "elasticfilesystem:DescribeMountTargets"], + [ + efs.accessPointArn, + `arn:${context.partition}:elasticfilesystem:${context.region}:${context.accountId}:file-system/*`, + ], + ), + ); + } + const s3Files = filesystem.s3FilesAccessPoint; + if (s3Files?.accessPointArn) { + const fileSystemArn = s3FilesFileSystemArn(s3Files.accessPointArn); + statements.push( + allow(["s3files:ClientMount", "s3files:ClientWrite"], [fileSystemArn], { + ArnEquals: { "s3files:AccessPointArn": s3Files.accessPointArn }, + }), + allow( + [ + "s3files:GetAccessPoint", + "s3files:GetFileSystem", + "s3files:GetMountTarget", + "s3files:ListMountTargets", + ], + ["*"], + ), + ); + } + } + return statements.length + ? [{ owner: "harness:environment", reason: "run Harness environment", statements }] + : []; + } + + private credentials(state: HarnessPolicyState): PolicyContribution[] { + const requested = harnessCredentialRequests(state); + if (requested.length === 0) return []; + const resolved = new Map( + (state.credentialProviders ?? []).map((provider) => [provider.providerArn, provider]), + ); + const directory = `arn:aws:bedrock-agentcore:${state.region}:${state.accountId}:workload-identity-directory/default`; + const workload = `${directory}/workload-identity/harness_${state.harnessName}-*`; + const vault = `arn:aws:bedrock-agentcore:${state.region}:${state.accountId}:token-vault/default`; + return requested.map(({ type, arn }) => { + const provider = resolved.get(arn); + if (!provider?.secretArn) { + throw new UninferrableHarnessPermissionError( + `harness:credential:${arn}`, + "resolved secret ARN is missing", + ); + } + return { + owner: `harness:credential:${arn}`, + reason: type === "api-key" ? "retrieve API key" : "retrieve OAuth token", + statements: [ + AgentCorePolicyGrants.getWorkloadAccessToken([directory, workload]), + allow( + [ + type === "api-key" + ? "bedrock-agentcore:GetResourceApiKey" + : "bedrock-agentcore:GetResourceOauth2Token", + ], + [vault, directory, workload, arn], + ), + AgentCorePolicyGrants.readSecret(provider.secretArn), + ], + }; + }); + } +} + +export function harnessCredentialProviderRequests( + state: HarnessPolicyState, +): CredentialProviderPolicyRequest[] { + return harnessCredentialRequests(state).map(({ type, arn }) => ({ + type, + providerArn: arn, + })); +} + +function harnessCredentialRequests( + state: HarnessPolicyState, +): { type: "api-key" | "oauth"; arn: string }[] { + const requests = new Map(); + const modelKey = + state.model?.openAiModelConfig?.apiKeyArn ?? + state.model?.geminiModelConfig?.apiKeyArn ?? + state.model?.liteLlmModelConfig?.apiKeyArn; + if (modelKey) requests.set(modelKey, "api-key"); + for (const tool of state.tools ?? []) { + const oauth = tool.config?.agentCoreGateway?.outboundAuth?.oauth?.providerArn; + if (oauth) requests.set(oauth, "oauth"); + for (const value of Object.values(tool.config?.remoteMcp?.headers ?? {})) { + for (const arn of credentialArns(value)) requests.set(arn, credentialType(arn)); + } + } + for (const skill of state.skills ?? []) { + const arn = skill.git?.auth?.credentialArn; + if (arn) requests.set(arn, "api-key"); + } + return [...requests].map(([arn, type]) => ({ arn, type })); +} + +function credentialArns(value: string): string[] { + return [...value.matchAll(/\$\{(arn:[^}]+)\}/g)].map((match) => match[1]!); +} + +function credentialType(arn: string): "api-key" | "oauth" { + if (arn.includes("/apikeycredentialprovider/")) return "api-key"; + if (arn.includes("/oauth2credentialprovider/")) return "oauth"; + throw new UninferrableHarnessPermissionError( + `harness:credential:${arn}`, + "credential provider type is unknown", + ); +} + +function isBedrockLiteLlmModel(model: HarnessModelConfiguration): boolean { + return model.liteLlmModelConfig?.modelId?.startsWith("bedrock/") ?? false; +} + +function s3Prefix( + uri: string | undefined, + owner: string, +): { + bucketArn: string; + objectArn: string; +} { + const match = uri?.match(/^s3:\/\/([^/]+)\/?(.*)$/); + if (!match?.[1]) { + throw new UninferrableHarnessPermissionError(owner, `invalid S3 URI "${uri ?? ""}"`); + } + const prefix = match[2]?.replace(/\/+$/, ""); + return { + bucketArn: `arn:aws:s3:::${match[1]}`, + objectArn: `arn:aws:s3:::${match[1]}/${prefix ? `${prefix}/` : ""}*`, + }; +} + +function ecrRepositoryArn(uri: string): string | undefined { + const match = uri.match( + /^(\d{12})\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com\/([^@:]+(?:\/[^@:]+)*)(?:[:@].+)?$/, + ); + if (!match?.[1] || !match[2] || !match[3]) return undefined; + return `arn:aws:ecr:${match[2]}:${match[1]}:repository/${match[3]}`; +} + +function arnContext( + arn: string, + owner: string, +): { + partition: string; + region: string; + accountId: string; +} { + const [prefix, partition, , region, accountId] = arn.split(":"); + if (prefix !== "arn" || !partition || !region || !accountId) { + throw new UninferrableHarnessPermissionError(owner, `invalid ARN "${arn}"`); + } + return { partition, region, accountId }; +} + +function s3FilesFileSystemArn(accessPointArn: string): string { + const match = accessPointArn.match( + /^(arn:[^:]+:s3files:[^:]+:\d{12}:file-system\/fs-[^/]+)\/access-point\/fsap-[^/]+$/, + ); + if (!match?.[1]) { + throw new UninferrableHarnessPermissionError( + "harness:environment", + `invalid S3 Files access point ARN "${accessPointArn}"`, + ); + } + return match[1]; +} + +function containsKey(value: unknown, key: string): boolean { + if (Array.isArray(value)) return value.some((entry) => containsKey(entry, key)); + if (value === null || typeof value !== "object") return false; + const record = value as Record; + return key in record || Object.values(record).some((entry) => containsKey(entry, key)); +} diff --git a/src/core/index.tsx b/src/core/index.tsx index e24c96ce8..c99fb16df 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -4,7 +4,7 @@ import { IAMClient } from "@aws-sdk/client-iam"; import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; import { EvalClient } from "./eval"; import { GatewayClient, type GatewayClientOptions } from "./gateway"; -import { HarnessClient } from "./harness"; +import { HarnessClient, type HarnessClientOptions } from "./harness"; import { IdentityClient } from "./identity"; import { MemoryClient } from "./memory"; import { RuntimeClient } from "./runtime"; @@ -39,6 +39,7 @@ type CoreClientConfig = { logger: Logger; fetch?: CoreFetch; gatewayOptions?: GatewayClientOptions; + harnessOptions?: HarnessClientOptions; }; // CoreClient is the single entry point to the Bedrock AgentCore APIs. It owns the @@ -58,7 +59,7 @@ export class CoreClient implements AwsClients { private logger: Logger; // Feature-scoped sub-clients. Access as e.g. `coreClient.harness.getHarness(...)`. - readonly harness: HarnessClient = new HarnessClient(this); + readonly harness: HarnessClient; readonly identity: IdentityClient = new IdentityClient(this); readonly memory: MemoryClient = new MemoryClient(this); readonly runtime: RuntimeClient; @@ -74,6 +75,7 @@ export class CoreClient implements AwsClients { this.createLogsClient = config.createLogsClient; this.logger = config.logger; this.gateway = new GatewayClient(this, config.gatewayOptions); + this.harness = new HarnessClient(this, config.harnessOptions); const fetch = config.fetch ?? globalThis.fetch; this.runtime = new RuntimeClient(this, fetch, this.logger.child({ module: "runtime" })); // EvalClient shares the injected fetch: dataset content is served from a diff --git a/src/handlers/gateway/rolePolicyWarning.ts b/src/handlers/gateway/rolePolicyWarning.ts index e14f0f728..840b6eeb9 100644 --- a/src/handlers/gateway/rolePolicyWarning.ts +++ b/src/handlers/gateway/rolePolicyWarning.ts @@ -25,7 +25,7 @@ export async function warnForGatewayRolePolicyUpdate( if (!warning) return; warn( io, - `Execution role ${warning.roleArn} is not recognized as AgentCore CLI or console managed. ` + + `Execution role ${warning.roleArn} is not recognized as managed for this Gateway. ` + "The CLI will not modify its IAM policies. " + "You are responsible for permissions required by this update.", ); diff --git a/src/handlers/harness/__fixtures__/CreateHarnessCommand.f7693095f14fb2d8.json b/src/handlers/harness/__fixtures__/CreateHarnessCommand.e3718e8f4fc95989.json similarity index 70% rename from src/handlers/harness/__fixtures__/CreateHarnessCommand.f7693095f14fb2d8.json rename to src/handlers/harness/__fixtures__/CreateHarnessCommand.e3718e8f4fc95989.json index 26fe25030..165fb7e4b 100644 --- a/src/handlers/harness/__fixtures__/CreateHarnessCommand.f7693095f14fb2d8.json +++ b/src/handlers/harness/__fixtures__/CreateHarnessCommand.e3718e8f4fc95989.json @@ -1,15 +1,15 @@ { "harness": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV", + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", "status": "CREATING", - "executionRoleArn": "arn:aws:iam::501930284170:role/AgentCoreHarness-AgentCoreCliE2E", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", "createdAt": { - "$date": "2026-07-07T23:10:13.675Z" + "$date": "2026-08-13T01:11:36.697Z" }, "updatedAt": { - "$date": "2026-07-07T23:10:13.675Z" + "$date": "2026-08-13T01:11:36.697Z" }, "model": { "bedrockModelConfig": { @@ -37,7 +37,7 @@ }, "environment": { "agentCoreRuntimeEnvironment": { - "agentRuntimeName": "harness_AgentCoreCliE2E", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", "lifecycleConfiguration": { "idleRuntimeSessionTimeout": 900, "maxLifetime": 28800 diff --git a/src/handlers/harness/__fixtures__/CreateHarnessEndpointCommand.103f0e916867bed4.json b/src/handlers/harness/__fixtures__/CreateHarnessEndpointCommand.103f0e916867bed4.json deleted file mode 100644 index 079b12e3e..000000000 --- a/src/handlers/harness/__fixtures__/CreateHarnessEndpointCommand.103f0e916867bed4.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "endpoint": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "endpointName": "live", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV/harness-endpoint/live", - "status": "CREATING", - "createdAt": { - "$date": "2026-07-07T23:13:05.008Z" - }, - "updatedAt": { - "$date": "2026-07-07T23:13:05.008Z" - }, - "targetVersion": "1" - } -} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/CreateHarnessEndpointCommand.d819f85b45aead8.json b/src/handlers/harness/__fixtures__/CreateHarnessEndpointCommand.d819f85b45aead8.json new file mode 100644 index 000000000..b1069e6e5 --- /dev/null +++ b/src/handlers/harness/__fixtures__/CreateHarnessEndpointCommand.d819f85b45aead8.json @@ -0,0 +1,16 @@ +{ + "endpoint": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "endpointName": "live", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf/harness-endpoint/live", + "status": "CREATING", + "createdAt": { + "$date": "2026-08-13T01:14:42.256Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:42.256Z" + }, + "targetVersion": "1" + } +} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/CreateRoleCommand.8cb410977df880b4.json b/src/handlers/harness/__fixtures__/CreateRoleCommand.bf91bd0dad4029c9.json similarity index 58% rename from src/handlers/harness/__fixtures__/CreateRoleCommand.8cb410977df880b4.json rename to src/handlers/harness/__fixtures__/CreateRoleCommand.bf91bd0dad4029c9.json index 8e391bbc0..4e558be8c 100644 --- a/src/handlers/harness/__fixtures__/CreateRoleCommand.8cb410977df880b4.json +++ b/src/handlers/harness/__fixtures__/CreateRoleCommand.bf91bd0dad4029c9.json @@ -1,11 +1,11 @@ { "Role": { "Path": "/", - "RoleName": "AgentCoreHarness-AgentCoreCliE2E", - "RoleId": "AROAXJXLAKSFNVPXEE6SW", - "Arn": "arn:aws:iam::501930284170:role/AgentCoreHarness-AgentCoreCliE2E", + "RoleName": "AgentCoreCliHarness-AgentCoreCliIamE2E", + "RoleId": "AROAYY3QB54N6B5RW7UQK", + "Arn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", "CreateDate": { - "$date": "2026-07-07T23:10:04.000Z" + "$date": "2026-08-13T01:11:26.000Z" }, "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22bedrock-agentcore.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D" } diff --git a/src/handlers/harness/__fixtures__/DeleteHarnessCommand.a1a9454a080576be.json b/src/handlers/harness/__fixtures__/DeleteHarnessCommand.18f479861d4bce0.json similarity index 62% rename from src/handlers/harness/__fixtures__/DeleteHarnessCommand.a1a9454a080576be.json rename to src/handlers/harness/__fixtures__/DeleteHarnessCommand.18f479861d4bce0.json index dc74aec2f..d6e781f0b 100644 --- a/src/handlers/harness/__fixtures__/DeleteHarnessCommand.a1a9454a080576be.json +++ b/src/handlers/harness/__fixtures__/DeleteHarnessCommand.18f479861d4bce0.json @@ -1,15 +1,15 @@ { "harness": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV", + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", "status": "DELETING", - "executionRoleArn": "arn:aws:iam::501930284170:role/AgentCoreHarness-AgentCoreCliE2E", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", "createdAt": { - "$date": "2026-07-07T23:10:13.675Z" + "$date": "2026-08-13T01:11:36.697Z" }, "updatedAt": { - "$date": "2026-07-07T23:13:26.213Z" + "$date": "2026-08-13T01:15:03.517Z" }, "model": { "bedrockModelConfig": { @@ -37,9 +37,9 @@ }, "environment": { "agentCoreRuntimeEnvironment": { - "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:runtime/harness_AgentCoreCliE2E-THv8WN5rms", - "agentRuntimeName": "harness_AgentCoreCliE2E", - "agentRuntimeId": "harness_AgentCoreCliE2E-THv8WN5rms", + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", "lifecycleConfiguration": { "idleRuntimeSessionTimeout": 900, "maxLifetime": 28800 @@ -52,7 +52,7 @@ "harnessVersion": "2", "memory": { "managedMemoryConfiguration": { - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:memory/harness_AgentCoreCliE2E_a0dc-0y96cIE359" + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" } }, "maxIterations": 30, diff --git a/src/handlers/harness/__fixtures__/DeleteHarnessEndpointCommand.3c3f165cf3bf4dd.json b/src/handlers/harness/__fixtures__/DeleteHarnessEndpointCommand.3c3f165cf3bf4dd.json deleted file mode 100644 index 26a7e402f..000000000 --- a/src/handlers/harness/__fixtures__/DeleteHarnessEndpointCommand.3c3f165cf3bf4dd.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "endpoint": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "endpointName": "live", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV/harness-endpoint/live", - "status": "DELETING", - "createdAt": { - "$date": "2026-07-07T23:13:05.008Z" - }, - "updatedAt": { - "$date": "2026-07-07T23:13:15.996Z" - }, - "liveVersion": "2" - } -} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/DeleteHarnessEndpointCommand.4595b4efcda49692.json b/src/handlers/harness/__fixtures__/DeleteHarnessEndpointCommand.4595b4efcda49692.json new file mode 100644 index 000000000..c3e392b14 --- /dev/null +++ b/src/handlers/harness/__fixtures__/DeleteHarnessEndpointCommand.4595b4efcda49692.json @@ -0,0 +1,16 @@ +{ + "endpoint": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "endpointName": "live", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf/harness-endpoint/live", + "status": "DELETING", + "createdAt": { + "$date": "2026-08-13T01:14:42.256Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:53.225Z" + }, + "liveVersion": "2" + } +} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/PutRolePolicyCommand.6ab5c0e88b7509dc.json b/src/handlers/harness/__fixtures__/DeleteRolePolicyCommand.cdb6f2bed6a6e46c.json similarity index 100% rename from src/handlers/harness/__fixtures__/PutRolePolicyCommand.6ab5c0e88b7509dc.json rename to src/handlers/harness/__fixtures__/DeleteRolePolicyCommand.cdb6f2bed6a6e46c.json diff --git a/src/handlers/harness/__fixtures__/GetHarnessCommand.18f479861d4bce0.json b/src/handlers/harness/__fixtures__/GetHarnessCommand.18f479861d4bce0.json new file mode 100644 index 000000000..b2a96c47d --- /dev/null +++ b/src/handlers/harness/__fixtures__/GetHarnessCommand.18f479861d4bce0.json @@ -0,0 +1,677 @@ +{ + "$sequence": [ + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "CREATING", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are a concise assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "1", + "memory": { + "managedMemoryConfiguration": {} + }, + "maxIterations": 25, + "timeoutSeconds": 3600 + } + }, + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "READY", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:11:48.102Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are a concise assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "1", + "memory": { + "managedMemoryConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" + } + }, + "maxIterations": 25, + "timeoutSeconds": 3600 + } + }, + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "READY", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:11:48.102Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are a concise assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "1", + "memory": { + "managedMemoryConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" + } + }, + "maxIterations": 25, + "timeoutSeconds": 3600 + } + }, + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "READY", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:11:48.102Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are a concise assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "1", + "memory": { + "managedMemoryConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" + } + }, + "maxIterations": 25, + "timeoutSeconds": 3600 + } + }, + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "READY", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:11:48.102Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are a concise assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "1", + "memory": { + "managedMemoryConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" + } + }, + "maxIterations": 25, + "timeoutSeconds": 3600 + } + }, + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "UPDATING", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:28.886Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are an updated assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "2", + "memory": { + "managedMemoryConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" + } + }, + "maxIterations": 30, + "timeoutSeconds": 3600 + } + }, + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "READY", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:39.901Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are an updated assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "2", + "memory": { + "managedMemoryConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" + } + }, + "maxIterations": 30, + "timeoutSeconds": 3600 + } + }, + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "READY", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:39.901Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are an updated assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "2", + "memory": { + "managedMemoryConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" + } + }, + "maxIterations": 30, + "timeoutSeconds": 3600 + } + }, + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "READY", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:39.901Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are an updated assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "2", + "memory": { + "managedMemoryConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" + } + }, + "maxIterations": 30, + "timeoutSeconds": 3600 + } + }, + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "READY", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:39.901Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are an updated assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "2", + "memory": { + "managedMemoryConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" + } + }, + "maxIterations": 30, + "timeoutSeconds": 3600 + } + }, + { + "harness": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", + "status": "DELETING", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": { + "$date": "2026-08-13T01:11:36.697Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:15:03.517Z" + }, + "model": { + "bedrockModelConfig": { + "modelId": "global.anthropic.claude-sonnet-4-6", + "apiFormat": "converse_stream" + } + }, + "systemPrompt": [ + { + "text": "You are an updated assistant used in an end-to-end CLI test." + } + ], + "tools": [], + "skills": [], + "allowedTools": [ + "*" + ], + "truncation": { + "strategy": "sliding_window", + "config": { + "slidingWindow": { + "messagesCount": 150 + } + } + }, + "environment": { + "agentCoreRuntimeEnvironment": { + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "networkConfiguration": { + "networkMode": "PUBLIC" + } + } + }, + "harnessVersion": "2", + "memory": { + "managedMemoryConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" + } + }, + "maxIterations": 30, + "timeoutSeconds": 3600 + } + }, + { + "$error": { + "name": "ResourceNotFoundException", + "message": "Agent with name AgentCoreCliIamE2E-mzw9kCl3pf not found." + } + } + ] +} diff --git a/src/handlers/harness/__fixtures__/GetHarnessCommand.a1a9454a080576be.json b/src/handlers/harness/__fixtures__/GetHarnessCommand.a1a9454a080576be.json deleted file mode 100644 index eef7f5e79..000000000 --- a/src/handlers/harness/__fixtures__/GetHarnessCommand.a1a9454a080576be.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "harness": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV", - "status": "READY", - "executionRoleArn": "arn:aws:iam::501930284170:role/AgentCoreHarness-AgentCoreCliE2E", - "createdAt": { - "$date": "2026-07-07T23:10:13.675Z" - }, - "updatedAt": { - "$date": "2026-07-07T23:12:59.899Z" - }, - "model": { - "bedrockModelConfig": { - "modelId": "global.anthropic.claude-sonnet-4-6", - "apiFormat": "converse_stream" - } - }, - "systemPrompt": [ - { - "text": "You are an updated assistant used in an end-to-end CLI test." - } - ], - "tools": [], - "skills": [], - "allowedTools": [ - "*" - ], - "truncation": { - "strategy": "sliding_window", - "config": { - "slidingWindow": { - "messagesCount": 150 - } - } - }, - "environment": { - "agentCoreRuntimeEnvironment": { - "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:runtime/harness_AgentCoreCliE2E-THv8WN5rms", - "agentRuntimeName": "harness_AgentCoreCliE2E", - "agentRuntimeId": "harness_AgentCoreCliE2E-THv8WN5rms", - "lifecycleConfiguration": { - "idleRuntimeSessionTimeout": 900, - "maxLifetime": 28800 - }, - "networkConfiguration": { - "networkMode": "PUBLIC" - } - } - }, - "harnessVersion": "2", - "memory": { - "managedMemoryConfiguration": { - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:memory/harness_AgentCoreCliE2E_a0dc-0y96cIE359" - } - }, - "maxIterations": 30, - "timeoutSeconds": 3600 - } -} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/GetHarnessEndpointCommand.3c3f165cf3bf4dd.json b/src/handlers/harness/__fixtures__/GetHarnessEndpointCommand.3c3f165cf3bf4dd.json deleted file mode 100644 index 541bf3609..000000000 --- a/src/handlers/harness/__fixtures__/GetHarnessEndpointCommand.3c3f165cf3bf4dd.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "endpoint": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "endpointName": "live", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV/harness-endpoint/live", - "status": "READY", - "createdAt": { - "$date": "2026-07-07T23:13:05.008Z" - }, - "updatedAt": { - "$date": "2026-07-07T23:13:12.187Z" - }, - "liveVersion": "2" - } -} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/GetHarnessEndpointCommand.4595b4efcda49692.json b/src/handlers/harness/__fixtures__/GetHarnessEndpointCommand.4595b4efcda49692.json new file mode 100644 index 000000000..4b9fc208c --- /dev/null +++ b/src/handlers/harness/__fixtures__/GetHarnessEndpointCommand.4595b4efcda49692.json @@ -0,0 +1,69 @@ +{ + "$sequence": [ + { + "endpoint": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "endpointName": "live", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf/harness-endpoint/live", + "status": "CREATING", + "createdAt": { + "$date": "2026-08-13T01:14:42.256Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:42.256Z" + }, + "targetVersion": "1" + } + }, + { + "endpoint": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "endpointName": "live", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf/harness-endpoint/live", + "status": "READY", + "createdAt": { + "$date": "2026-08-13T01:14:42.256Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:43.795Z" + }, + "liveVersion": "1" + } + }, + { + "endpoint": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "endpointName": "live", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf/harness-endpoint/live", + "status": "UPDATING", + "createdAt": { + "$date": "2026-08-13T01:14:42.256Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:47.782Z" + }, + "liveVersion": "1", + "targetVersion": "2" + } + }, + { + "endpoint": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "endpointName": "live", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf/harness-endpoint/live", + "status": "READY", + "createdAt": { + "$date": "2026-08-13T01:14:42.256Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:49.196Z" + }, + "liveVersion": "2" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/GetRoleCommand.18883d32fec6d57.json b/src/handlers/harness/__fixtures__/GetRoleCommand.18883d32fec6d57.json new file mode 100644 index 000000000..9f15a8f2b --- /dev/null +++ b/src/handlers/harness/__fixtures__/GetRoleCommand.18883d32fec6d57.json @@ -0,0 +1,25 @@ +{ + "$sequence": [ + { + "$error": { + "name": "NoSuchEntityException", + "message": "The role with name AgentCoreCliHarness-AgentCoreCliIamE2E cannot be found." + } + }, + { + "Role": { + "Path": "/", + "RoleName": "AgentCoreCliHarness-AgentCoreCliIamE2E", + "RoleId": "AROAYY3QB54N6B5RW7UQK", + "Arn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "CreateDate": { + "$date": "2026-08-13T01:11:26.000Z" + }, + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22bedrock-agentcore.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", + "Description": "AgentCore CLI managed execution role for harness \"AgentCoreCliIamE2E\"", + "MaxSessionDuration": 3600, + "RoleLastUsed": {} + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/GetRoleCommand.37ddb6c8639e273d.json b/src/handlers/harness/__fixtures__/GetRoleCommand.37ddb6c8639e273d.json deleted file mode 100644 index 4d20e91e3..000000000 --- a/src/handlers/harness/__fixtures__/GetRoleCommand.37ddb6c8639e273d.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$error": { - "name": "NoSuchEntityException", - "message": "The role with name AgentCoreHarness-AgentCoreCliE2E cannot be found." - } -} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/GetRolePolicyCommand.cdb6f2bed6a6e46c.json b/src/handlers/harness/__fixtures__/GetRolePolicyCommand.cdb6f2bed6a6e46c.json new file mode 100644 index 000000000..f3a39b769 --- /dev/null +++ b/src/handlers/harness/__fixtures__/GetRolePolicyCommand.cdb6f2bed6a6e46c.json @@ -0,0 +1,35 @@ +{ + "$sequence": [ + { + "RoleName": "AgentCoreCliHarness-AgentCoreCliIamE2E", + "PolicyName": "AgentCoreCliHarnessExecutionPolicy-ba70363c5e1f0fb4", + "PolicyDocument": "%7B%22Statement%22%3A%5B%7B%22Action%22%3A%5B%22bedrock%3AInvokeModel%22%2C%22bedrock%3AInvokeModelWithResponseStream%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Abedrock%3A%2A%3A%3Afoundation-model%2F%2A%22%2C%22arn%3Aaws%3Abedrock%3Aus-west-2%3A603141041947%3A%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22cloudwatch%3APutMetricData%22%5D%2C%22Condition%22%3A%7B%22StringEquals%22%3A%7B%22cloudwatch%3Anamespace%22%3A%22bedrock-agentcore%22%7D%7D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22ecr-public%3AGetAuthorizationToken%22%2C%22sts%3AGetServiceBearerToken%22%2C%22xray%3AGetSamplingRules%22%2C%22xray%3AGetSamplingTargets%22%2C%22xray%3APutTelemetryRecords%22%2C%22xray%3APutTraceSegments%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ACreateLogGroup%22%2C%22logs%3ADescribeLogStreams%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2Faws%2Fbedrock-agentcore%2Fruntimes%2F%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ACreateLogStream%22%2C%22logs%3APutLogEvents%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2Faws%2Fbedrock-agentcore%2Fruntimes%2F%2A%3Alog-stream%3A%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ADescribeLogGroups%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2A%22%5D%7D%5D%2C%22Version%22%3A%222012-10-17%22%7D" + }, + { + "RoleName": "AgentCoreCliHarness-AgentCoreCliIamE2E", + "PolicyName": "AgentCoreCliHarnessExecutionPolicy-ba70363c5e1f0fb4", + "PolicyDocument": "%7B%22Statement%22%3A%5B%7B%22Action%22%3A%5B%22bedrock%3AInvokeModel%22%2C%22bedrock%3AInvokeModelWithResponseStream%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Abedrock%3A%2A%3A%3Afoundation-model%2F%2A%22%2C%22arn%3Aaws%3Abedrock%3Aus-west-2%3A603141041947%3A%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22cloudwatch%3APutMetricData%22%5D%2C%22Condition%22%3A%7B%22StringEquals%22%3A%7B%22cloudwatch%3Anamespace%22%3A%22bedrock-agentcore%22%7D%7D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22ecr-public%3AGetAuthorizationToken%22%2C%22sts%3AGetServiceBearerToken%22%2C%22xray%3AGetSamplingRules%22%2C%22xray%3AGetSamplingTargets%22%2C%22xray%3APutTelemetryRecords%22%2C%22xray%3APutTraceSegments%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ACreateLogGroup%22%2C%22logs%3ADescribeLogStreams%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2Faws%2Fbedrock-agentcore%2Fruntimes%2F%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ACreateLogStream%22%2C%22logs%3APutLogEvents%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2Faws%2Fbedrock-agentcore%2Fruntimes%2F%2A%3Alog-stream%3A%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ADescribeLogGroups%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2A%22%5D%7D%5D%2C%22Version%22%3A%222012-10-17%22%7D" + }, + { + "RoleName": "AgentCoreCliHarness-AgentCoreCliIamE2E", + "PolicyName": "AgentCoreCliHarnessExecutionPolicy-ba70363c5e1f0fb4", + "PolicyDocument": "%7B%22Statement%22%3A%5B%7B%22Action%22%3A%5B%22bedrock-agentcore%3ACreateEvent%22%2C%22bedrock-agentcore%3ADeleteEvent%22%2C%22bedrock-agentcore%3AGetEvent%22%2C%22bedrock-agentcore%3AListEvents%22%2C%22bedrock-agentcore%3ARetrieveMemoryRecords%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Abedrock-agentcore%3Aus-west-2%3A603141041947%3Amemory%2FAgentCoreCliIamE2E-0xYqWxEdbi%22%5D%7D%2C%7B%22Action%22%3A%5B%22bedrock%3AInvokeModel%22%2C%22bedrock%3AInvokeModelWithResponseStream%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Abedrock%3A%2A%3A%3Afoundation-model%2F%2A%22%2C%22arn%3Aaws%3Abedrock%3Aus-west-2%3A603141041947%3A%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22cloudwatch%3APutMetricData%22%5D%2C%22Condition%22%3A%7B%22StringEquals%22%3A%7B%22cloudwatch%3Anamespace%22%3A%22bedrock-agentcore%22%7D%7D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22ecr-public%3AGetAuthorizationToken%22%2C%22sts%3AGetServiceBearerToken%22%2C%22xray%3AGetSamplingRules%22%2C%22xray%3AGetSamplingTargets%22%2C%22xray%3APutTelemetryRecords%22%2C%22xray%3APutTraceSegments%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ACreateLogGroup%22%2C%22logs%3ADescribeLogStreams%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2Faws%2Fbedrock-agentcore%2Fruntimes%2F%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ACreateLogStream%22%2C%22logs%3APutLogEvents%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2Faws%2Fbedrock-agentcore%2Fruntimes%2F%2A%3Alog-stream%3A%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ADescribeLogGroups%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2A%22%5D%7D%5D%2C%22Version%22%3A%222012-10-17%22%7D" + }, + { + "RoleName": "AgentCoreCliHarness-AgentCoreCliIamE2E", + "PolicyName": "AgentCoreCliHarnessExecutionPolicy-ba70363c5e1f0fb4", + "PolicyDocument": "%7B%22Statement%22%3A%5B%7B%22Action%22%3A%5B%22bedrock-agentcore%3ACreateEvent%22%2C%22bedrock-agentcore%3ADeleteEvent%22%2C%22bedrock-agentcore%3AGetEvent%22%2C%22bedrock-agentcore%3AListEvents%22%2C%22bedrock-agentcore%3ARetrieveMemoryRecords%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Abedrock-agentcore%3Aus-west-2%3A603141041947%3Amemory%2FAgentCoreCliIamE2E-0xYqWxEdbi%22%5D%7D%2C%7B%22Action%22%3A%5B%22bedrock%3AInvokeModel%22%2C%22bedrock%3AInvokeModelWithResponseStream%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Abedrock%3A%2A%3A%3Afoundation-model%2F%2A%22%2C%22arn%3Aaws%3Abedrock%3Aus-west-2%3A603141041947%3A%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22cloudwatch%3APutMetricData%22%5D%2C%22Condition%22%3A%7B%22StringEquals%22%3A%7B%22cloudwatch%3Anamespace%22%3A%22bedrock-agentcore%22%7D%7D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22ecr-public%3AGetAuthorizationToken%22%2C%22sts%3AGetServiceBearerToken%22%2C%22xray%3AGetSamplingRules%22%2C%22xray%3AGetSamplingTargets%22%2C%22xray%3APutTelemetryRecords%22%2C%22xray%3APutTraceSegments%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ACreateLogGroup%22%2C%22logs%3ADescribeLogStreams%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2Faws%2Fbedrock-agentcore%2Fruntimes%2F%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ACreateLogStream%22%2C%22logs%3APutLogEvents%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2Faws%2Fbedrock-agentcore%2Fruntimes%2F%2A%3Alog-stream%3A%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ADescribeLogGroups%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2A%22%5D%7D%5D%2C%22Version%22%3A%222012-10-17%22%7D" + }, + { + "RoleName": "AgentCoreCliHarness-AgentCoreCliIamE2E", + "PolicyName": "AgentCoreCliHarnessExecutionPolicy-ba70363c5e1f0fb4", + "PolicyDocument": "%7B%22Statement%22%3A%5B%7B%22Action%22%3A%5B%22bedrock-agentcore%3ACreateEvent%22%2C%22bedrock-agentcore%3ADeleteEvent%22%2C%22bedrock-agentcore%3AGetEvent%22%2C%22bedrock-agentcore%3AListEvents%22%2C%22bedrock-agentcore%3ARetrieveMemoryRecords%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Abedrock-agentcore%3Aus-west-2%3A603141041947%3Amemory%2FAgentCoreCliIamE2E-0xYqWxEdbi%22%5D%7D%2C%7B%22Action%22%3A%5B%22bedrock%3AInvokeModel%22%2C%22bedrock%3AInvokeModelWithResponseStream%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Abedrock%3A%2A%3A%3Afoundation-model%2F%2A%22%2C%22arn%3Aaws%3Abedrock%3Aus-west-2%3A603141041947%3A%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22cloudwatch%3APutMetricData%22%5D%2C%22Condition%22%3A%7B%22StringEquals%22%3A%7B%22cloudwatch%3Anamespace%22%3A%22bedrock-agentcore%22%7D%7D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22ecr-public%3AGetAuthorizationToken%22%2C%22sts%3AGetServiceBearerToken%22%2C%22xray%3AGetSamplingRules%22%2C%22xray%3AGetSamplingTargets%22%2C%22xray%3APutTelemetryRecords%22%2C%22xray%3APutTraceSegments%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ACreateLogGroup%22%2C%22logs%3ADescribeLogStreams%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2Faws%2Fbedrock-agentcore%2Fruntimes%2F%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ACreateLogStream%22%2C%22logs%3APutLogEvents%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2Faws%2Fbedrock-agentcore%2Fruntimes%2F%2A%3Alog-stream%3A%2A%22%5D%7D%2C%7B%22Action%22%3A%5B%22logs%3ADescribeLogGroups%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Alogs%3Aus-west-2%3A603141041947%3Alog-group%3A%2A%22%5D%7D%5D%2C%22Version%22%3A%222012-10-17%22%7D" + }, + { + "$error": { + "name": "NoSuchEntityException", + "message": "The role policy with name AgentCoreCliHarnessExecutionPolicy-ba70363c5e1f0fb4 cannot be found." + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/ListRolePoliciesCommand.18883d32fec6d57.json b/src/handlers/harness/__fixtures__/ListRolePoliciesCommand.18883d32fec6d57.json new file mode 100644 index 000000000..e0a56b18d --- /dev/null +++ b/src/handlers/harness/__fixtures__/ListRolePoliciesCommand.18883d32fec6d57.json @@ -0,0 +1,20 @@ +{ + "$sequence": [ + { + "PolicyNames": [], + "IsTruncated": false + }, + { + "PolicyNames": [ + "AgentCoreCliHarnessExecutionPolicy-ba70363c5e1f0fb4" + ], + "IsTruncated": false + }, + { + "PolicyNames": [ + "AgentCoreCliHarnessExecutionPolicy-ba70363c5e1f0fb4" + ], + "IsTruncated": false + } + ] +} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/PutRolePolicyCommand.e52e8814955782df.json b/src/handlers/harness/__fixtures__/PutRolePolicyCommand.e52e8814955782df.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/harness/__fixtures__/PutRolePolicyCommand.e52e8814955782df.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/PutRolePolicyCommand.e7352f6583ad4cb2.json b/src/handlers/harness/__fixtures__/PutRolePolicyCommand.e7352f6583ad4cb2.json new file mode 100644 index 000000000..5bc0e5c05 --- /dev/null +++ b/src/handlers/harness/__fixtures__/PutRolePolicyCommand.e7352f6583ad4cb2.json @@ -0,0 +1,6 @@ +{ + "$sequence": [ + {}, + {} + ] +} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/UpdateHarnessCommand.752d7aa9731c05df.json b/src/handlers/harness/__fixtures__/UpdateHarnessCommand.39c21094d4f95b84.json similarity index 62% rename from src/handlers/harness/__fixtures__/UpdateHarnessCommand.752d7aa9731c05df.json rename to src/handlers/harness/__fixtures__/UpdateHarnessCommand.39c21094d4f95b84.json index a161d5458..c1ae753a2 100644 --- a/src/handlers/harness/__fixtures__/UpdateHarnessCommand.752d7aa9731c05df.json +++ b/src/handlers/harness/__fixtures__/UpdateHarnessCommand.39c21094d4f95b84.json @@ -1,15 +1,15 @@ { "harness": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV", + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", "status": "UPDATING", - "executionRoleArn": "arn:aws:iam::501930284170:role/AgentCoreHarness-AgentCoreCliE2E", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", "createdAt": { - "$date": "2026-07-07T23:10:13.675Z" + "$date": "2026-08-13T01:11:36.697Z" }, "updatedAt": { - "$date": "2026-07-07T23:12:48.980Z" + "$date": "2026-08-13T01:14:28.886Z" }, "model": { "bedrockModelConfig": { @@ -37,9 +37,9 @@ }, "environment": { "agentCoreRuntimeEnvironment": { - "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:runtime/harness_AgentCoreCliE2E-THv8WN5rms", - "agentRuntimeName": "harness_AgentCoreCliE2E", - "agentRuntimeId": "harness_AgentCoreCliE2E-THv8WN5rms", + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", "lifecycleConfiguration": { "idleRuntimeSessionTimeout": 900, "maxLifetime": 28800 @@ -52,7 +52,7 @@ "harnessVersion": "2", "memory": { "managedMemoryConfiguration": { - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:memory/harness_AgentCoreCliE2E_a0dc-0y96cIE359" + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" } }, "maxIterations": 30, diff --git a/src/handlers/harness/__fixtures__/UpdateHarnessEndpointCommand.1786566889fac24b.json b/src/handlers/harness/__fixtures__/UpdateHarnessEndpointCommand.1786566889fac24b.json deleted file mode 100644 index 77b623f4b..000000000 --- a/src/handlers/harness/__fixtures__/UpdateHarnessEndpointCommand.1786566889fac24b.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "endpoint": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "endpointName": "live", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV/harness-endpoint/live", - "status": "UPDATING", - "createdAt": { - "$date": "2026-07-07T23:13:05.008Z" - }, - "updatedAt": { - "$date": "2026-07-07T23:13:10.512Z" - }, - "liveVersion": "1", - "targetVersion": "2" - } -} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/UpdateHarnessEndpointCommand.d78ce418f177a7c9.json b/src/handlers/harness/__fixtures__/UpdateHarnessEndpointCommand.d78ce418f177a7c9.json new file mode 100644 index 000000000..1739c06ea --- /dev/null +++ b/src/handlers/harness/__fixtures__/UpdateHarnessEndpointCommand.d78ce418f177a7c9.json @@ -0,0 +1,17 @@ +{ + "endpoint": { + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "endpointName": "live", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf/harness-endpoint/live", + "status": "UPDATING", + "createdAt": { + "$date": "2026-08-13T01:14:42.256Z" + }, + "updatedAt": { + "$date": "2026-08-13T01:14:47.782Z" + }, + "liveVersion": "1", + "targetVersion": "2" + } +} \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/create.golden.json b/src/handlers/harness/__fixtures__/create.golden.json index cbf47f77e..a70c48643 100644 --- a/src/handlers/harness/__fixtures__/create.golden.json +++ b/src/handlers/harness/__fixtures__/create.golden.json @@ -1,12 +1,12 @@ { "harness": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV", + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", "status": "CREATING", - "executionRoleArn": "arn:aws:iam::501930284170:role/AgentCoreHarness-AgentCoreCliE2E", - "createdAt": "2026-07-07T23:10:13.675Z", - "updatedAt": "2026-07-07T23:10:13.675Z", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": "2026-08-13T01:11:36.697Z", + "updatedAt": "2026-08-13T01:11:36.697Z", "model": { "bedrockModelConfig": { "modelId": "global.anthropic.claude-sonnet-4-6", @@ -33,7 +33,7 @@ }, "environment": { "agentCoreRuntimeEnvironment": { - "agentRuntimeName": "harness_AgentCoreCliE2E", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", "lifecycleConfiguration": { "idleRuntimeSessionTimeout": 900, "maxLifetime": 28800 diff --git a/src/handlers/harness/__fixtures__/delete.golden.json b/src/handlers/harness/__fixtures__/delete.golden.json index 9f18af79e..07735fa6d 100644 --- a/src/handlers/harness/__fixtures__/delete.golden.json +++ b/src/handlers/harness/__fixtures__/delete.golden.json @@ -1,12 +1,12 @@ { "harness": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV", + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", "status": "DELETING", - "executionRoleArn": "arn:aws:iam::501930284170:role/AgentCoreHarness-AgentCoreCliE2E", - "createdAt": "2026-07-07T23:10:13.675Z", - "updatedAt": "2026-07-07T23:13:26.213Z", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": "2026-08-13T01:11:36.697Z", + "updatedAt": "2026-08-13T01:15:03.517Z", "model": { "bedrockModelConfig": { "modelId": "global.anthropic.claude-sonnet-4-6", @@ -33,9 +33,9 @@ }, "environment": { "agentCoreRuntimeEnvironment": { - "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:runtime/harness_AgentCoreCliE2E-THv8WN5rms", - "agentRuntimeName": "harness_AgentCoreCliE2E", - "agentRuntimeId": "harness_AgentCoreCliE2E-THv8WN5rms", + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", "lifecycleConfiguration": { "idleRuntimeSessionTimeout": 900, "maxLifetime": 28800 @@ -48,7 +48,7 @@ "harnessVersion": "2", "memory": { "managedMemoryConfiguration": { - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:memory/harness_AgentCoreCliE2E_a0dc-0y96cIE359" + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" } }, "maxIterations": 30, diff --git a/src/handlers/harness/__fixtures__/endpoint-create.golden.json b/src/handlers/harness/__fixtures__/endpoint-create.golden.json index 9accfc43b..7d11231a3 100644 --- a/src/handlers/harness/__fixtures__/endpoint-create.golden.json +++ b/src/handlers/harness/__fixtures__/endpoint-create.golden.json @@ -1,12 +1,12 @@ { "endpoint": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", "endpointName": "live", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV/harness-endpoint/live", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf/harness-endpoint/live", "status": "CREATING", - "createdAt": "2026-07-07T23:13:05.008Z", - "updatedAt": "2026-07-07T23:13:05.008Z", + "createdAt": "2026-08-13T01:14:42.256Z", + "updatedAt": "2026-08-13T01:14:42.256Z", "targetVersion": "1" } } \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/endpoint-delete.golden.json b/src/handlers/harness/__fixtures__/endpoint-delete.golden.json index bafa06c8e..2423a151c 100644 --- a/src/handlers/harness/__fixtures__/endpoint-delete.golden.json +++ b/src/handlers/harness/__fixtures__/endpoint-delete.golden.json @@ -1,12 +1,12 @@ { "endpoint": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", "endpointName": "live", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV/harness-endpoint/live", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf/harness-endpoint/live", "status": "DELETING", - "createdAt": "2026-07-07T23:13:05.008Z", - "updatedAt": "2026-07-07T23:13:15.996Z", + "createdAt": "2026-08-13T01:14:42.256Z", + "updatedAt": "2026-08-13T01:14:53.225Z", "liveVersion": "2" } } \ No newline at end of file diff --git a/src/handlers/harness/__fixtures__/endpoint-update.golden.json b/src/handlers/harness/__fixtures__/endpoint-update.golden.json index 7d092a7c5..eaa7ad744 100644 --- a/src/handlers/harness/__fixtures__/endpoint-update.golden.json +++ b/src/handlers/harness/__fixtures__/endpoint-update.golden.json @@ -1,12 +1,12 @@ { "endpoint": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", "endpointName": "live", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV/harness-endpoint/live", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf/harness-endpoint/live", "status": "UPDATING", - "createdAt": "2026-07-07T23:13:05.008Z", - "updatedAt": "2026-07-07T23:13:10.512Z", + "createdAt": "2026-08-13T01:14:42.256Z", + "updatedAt": "2026-08-13T01:14:47.782Z", "liveVersion": "1", "targetVersion": "2" } diff --git a/src/handlers/harness/__fixtures__/update.golden.json b/src/handlers/harness/__fixtures__/update.golden.json index ab6be356b..f024a9432 100644 --- a/src/handlers/harness/__fixtures__/update.golden.json +++ b/src/handlers/harness/__fixtures__/update.golden.json @@ -1,12 +1,12 @@ { "harness": { - "harnessId": "AgentCoreCliE2E-W21v24kBuV", - "harnessName": "AgentCoreCliE2E", - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:harness/AgentCoreCliE2E-W21v24kBuV", + "harnessId": "AgentCoreCliIamE2E-mzw9kCl3pf", + "harnessName": "AgentCoreCliIamE2E", + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:harness/AgentCoreCliIamE2E-mzw9kCl3pf", "status": "UPDATING", - "executionRoleArn": "arn:aws:iam::501930284170:role/AgentCoreHarness-AgentCoreCliE2E", - "createdAt": "2026-07-07T23:10:13.675Z", - "updatedAt": "2026-07-07T23:12:48.980Z", + "executionRoleArn": "arn:aws:iam::603141041947:role/AgentCoreCliHarness-AgentCoreCliIamE2E", + "createdAt": "2026-08-13T01:11:36.697Z", + "updatedAt": "2026-08-13T01:14:28.886Z", "model": { "bedrockModelConfig": { "modelId": "global.anthropic.claude-sonnet-4-6", @@ -33,9 +33,9 @@ }, "environment": { "agentCoreRuntimeEnvironment": { - "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:runtime/harness_AgentCoreCliE2E-THv8WN5rms", - "agentRuntimeName": "harness_AgentCoreCliE2E", - "agentRuntimeId": "harness_AgentCoreCliE2E-THv8WN5rms", + "agentRuntimeArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:runtime/harness_AgentCoreCliIamE2E-87rSrGDxXT", + "agentRuntimeName": "harness_AgentCoreCliIamE2E", + "agentRuntimeId": "harness_AgentCoreCliIamE2E-87rSrGDxXT", "lifecycleConfiguration": { "idleRuntimeSessionTimeout": 900, "maxLifetime": 28800 @@ -48,7 +48,7 @@ "harnessVersion": "2", "memory": { "managedMemoryConfiguration": { - "arn": "arn:aws:bedrock-agentcore:us-west-2:501930284170:memory/harness_AgentCoreCliE2E_a0dc-0y96cIE359" + "arn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:memory/AgentCoreCliIamE2E-0xYqWxEdbi" } }, "maxIterations": 30, diff --git a/src/handlers/harness/create/index.tsx b/src/handlers/harness/create/index.tsx index 1eaab0591..4ccaa8f45 100644 --- a/src/handlers/harness/create/index.tsx +++ b/src/handlers/harness/create/index.tsx @@ -15,8 +15,9 @@ import { coreOptsFromCtx, parseJsonFlag } from "../../utils.tsx"; import { JsonRendererKey } from "../../../tui"; import { InputValidationError } from "../../../errors"; import { parameterHelp } from "../parameterHelp.tsx"; +import { type AppIO, warn } from "../../../io"; -export const createCreateHarnessHandler = (core: Core) => +export const createCreateHarnessHandler = (core: Core, io: AppIO) => createHandler({ name: "create", description: "create a harness", @@ -96,6 +97,12 @@ export const createCreateHarnessHandler = (core: Core) => throw new InputValidationError("required option '--name ' not specified"); } + if (flags["execution-role-arn"]) { + warn( + io, + `Using customer-managed execution role ${flags["execution-role-arn"]}; IAM policies will not be modified.`, + ); + } const response = await core.harness.createHarness( { harnessName: flags["name"], diff --git a/src/handlers/harness/harness.test.tsx b/src/handlers/harness/harness.test.tsx index 532d6e76e..7d3719a42 100644 --- a/src/handlers/harness/harness.test.tsx +++ b/src/handlers/harness/harness.test.tsx @@ -1,6 +1,13 @@ import { test, expect, describe } from "bun:test"; import { join } from "node:path"; import { CoreClient } from "../../core"; +import { createIamClient } from "../../core/factories"; +import { ExecutionRoleManager } from "../../core/executionRoleManager"; +import { + DeleteRoleCommand, + DeleteRolePolicyCommand, + ListRolePoliciesCommand, +} from "@aws-sdk/client-iam"; import { createRootHandler } from "../index"; import { createSilentLogger, @@ -38,6 +45,12 @@ async function run(args: string[]): Promise { createIamClient, createLogsClient, logger: createSilentLogger(), + harnessOptions: isRecording() + ? undefined + : { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + waitDelayMs: 0, + }, }); const io = testIO(); const root = createRootHandler(core, { @@ -219,7 +232,7 @@ describe("write command validation", () => { // from a steady state converges. Later tests consume ids parsed from earlier // output, which replays identically from the fixtures. -const E2E_NAME = "AgentCoreCliE2E"; +const E2E_NAME = "AgentCoreCliIamE2E"; const ENDPOINT_NAME = "live"; // Generous timeouts: in record mode, readiness polls wait on real control-plane // transitions (a create takes ~30s). Replay never sleeps. @@ -235,12 +248,7 @@ async function pollUntil(command: string[], done: (output: any) => boolean): Pro for (let attempt = 0; attempt < 60; attempt++) { const parsed = JSON.parse(await run(command)); if (done(parsed)) return; - if (!isRecording()) { - throw new Error( - `Replayed fixture for \`${command.join(" ")}\` is not in the awaited state; re-record.`, - ); - } - await Bun.sleep(5_000); + if (isRecording()) await Bun.sleep(5_000); } throw new Error(`Timed out waiting for \`${command.join(" ")}\``); } @@ -266,7 +274,7 @@ describe("harness write flow", () => { const parsed = JSON.parse(out); expect(parsed.harness.harnessName).toBe(E2E_NAME); // No --execution-role-arn was passed: the default role was provisioned. - expect(parsed.harness.executionRoleArn).toContain(`AgentCoreHarness-${E2E_NAME}`); + expect(parsed.harness.executionRoleArn).toContain(`AgentCoreCliHarness-${E2E_NAME}`); expect(parsed.harness.harnessId).toBeDefined(); state.harnessId = parsed.harness.harnessId; @@ -385,6 +393,17 @@ describe("harness write flow", () => { const out = await run(["harness", "delete", "--id", state.harnessId!]); matchGolden(FIXTURES, "delete.golden.json", out); expect(JSON.parse(out).harness.status).toBe("DELETING"); + if (isRecording()) { + const roleName = ExecutionRoleManager.cliRoleName("harness", E2E_NAME); + const iam = createIamClient({ region: REGION }); + const policies = await iam.send(new ListRolePoliciesCommand({ RoleName: roleName })); + for (const policyName of policies.PolicyNames ?? []) { + await iam.send( + new DeleteRolePolicyCommand({ RoleName: roleName, PolicyName: policyName }), + ); + } + await iam.send(new DeleteRoleCommand({ RoleName: roleName })); + } }, FLOW_TIMEOUT, ); diff --git a/src/handlers/harness/harnessRolePolicy.test.tsx b/src/handlers/harness/harnessRolePolicy.test.tsx new file mode 100644 index 000000000..741d383ab --- /dev/null +++ b/src/handlers/harness/harnessRolePolicy.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { createRootHandler } from "../index"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; + +async function run( + args: string[], + core = new TestCoreClient(), +): Promise<{ core: TestCoreClient; stderr: string }> { + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", ...args, "--region", "us-west-2"]); + return { core, stderr: io.stderr() }; +} + +describe("Harness execution-role policy UX", () => { + test("identifies an explicit create role as customer-managed", async () => { + const roleArn = "arn:aws:iam::123456789012:role/CustomerHarnessRole"; + const { stderr } = await run([ + "harness", + "create", + "--name", + "orders", + "--execution-role-arn", + roleArn, + ]); + + expect(stderr).toContain( + `Using customer-managed execution role ${roleArn}; IAM policies will not be modified.`, + ); + }); + + test("warns before updating a Harness with an unknown role", async () => { + const core = new TestCoreClient(); + const roleArn = "arn:aws:iam::123456789012:role/CdkHarnessRole"; + core.harness.getHarnessRolePolicyWarning = async () => ({ + reason: "unknown-role", + roleArn, + }); + + const { stderr } = await run( + ["harness", "update", "--id", "orders-abc123", "--max-iterations", "10"], + core, + ); + + expect(stderr).toContain(`Execution role ${roleArn} is not recognized`); + }); + + test("skip-role-policy-update bypasses warning preflight and reaches Core", async () => { + const core = new TestCoreClient(); + core.harness.getHarnessRolePolicyWarning = async () => { + throw new Error("warning preflight must be skipped"); + }; + + const { stderr } = await run( + [ + "harness", + "update", + "--id", + "orders-abc123", + "--max-iterations", + "10", + "--skip-role-policy-update", + ], + core, + ); + + expect(stderr).not.toContain("Execution role"); + expect( + core.harness.calls.find((call) => call.method === "updateHarness")?.args[0], + ).toMatchObject({ + harnessId: "orders-abc123", + maxIterations: 10, + skipRolePolicyUpdate: true, + }); + }); +}); diff --git a/src/handlers/harness/index.tsx b/src/handlers/harness/index.tsx index e835c0b9b..94381ef3d 100644 --- a/src/handlers/harness/index.tsx +++ b/src/handlers/harness/index.tsx @@ -22,10 +22,10 @@ export function createHarnessHandler(core: Core, io: AppIO): Router { harness.default(renderTui(core, io)); // Register handlers - harness.handler(createCreateHarnessHandler(core)); + harness.handler(createCreateHarnessHandler(core, io)); harness.handler(createGetHarnessHandler(core)); harness.handler(createListHarnessHandler(core)); - harness.handler(createUpdateHarnessHandler(core)); + harness.handler(createUpdateHarnessHandler(core, io)); harness.handler(createDeleteHarnessHandler(core)); harness.handler(createInvokeHarnessHandler(core, io)); harness.handler(createExecHarnessHandler(core, io)); diff --git a/src/handlers/harness/parameterHelp.tsx b/src/handlers/harness/parameterHelp.tsx index 4e78c5561..4a5ae2447 100644 --- a/src/handlers/harness/parameterHelp.tsx +++ b/src/handlers/harness/parameterHelp.tsx @@ -22,7 +22,7 @@ the agent needs (Bedrock model invocation, CloudWatch Logs, built-in tools, memory, ...). When omitted, the CLI provisions a default per-harness role named -AgentCoreHarness- with the baseline policy and uses it. +AgentCoreCliHarness- with a generated policy and uses it. Example: --execution-role-arn arn:aws:iam::123456789012:role/MyHarnessRole`, diff --git a/src/handlers/harness/rolePolicyWarning.ts b/src/handlers/harness/rolePolicyWarning.ts new file mode 100644 index 000000000..698a03a2a --- /dev/null +++ b/src/handlers/harness/rolePolicyWarning.ts @@ -0,0 +1,31 @@ +import { warn, type AppIO } from "../../io"; +import type { CoreOptions } from "../../core/types"; +import type { Core } from "../types"; + +export async function warnForHarnessRolePolicyUpdate( + core: Core, + io: AppIO, + harnessId: string, + options: CoreOptions, + input: { + explicitRoleArn?: string; + skipRolePolicyUpdate?: boolean; + }, +): Promise { + if (input.skipRolePolicyUpdate) return; + if (input.explicitRoleArn) { + warn( + io, + `Using customer-managed execution role ${input.explicitRoleArn}; IAM policies will not be modified.`, + ); + return; + } + const warning = await core.harness.getHarnessRolePolicyWarning(harnessId, options); + if (!warning) return; + warn( + io, + `Execution role ${warning.roleArn} is not recognized as managed for this Harness. ` + + "The CLI will not modify its IAM policies. " + + "You are responsible for permissions required by this update.", + ); +} diff --git a/src/handlers/harness/types.tsx b/src/handlers/harness/types.tsx index 6f8ad3a08..943c7e460 100644 --- a/src/handlers/harness/types.tsx +++ b/src/handlers/harness/types.tsx @@ -32,12 +32,22 @@ export type CreateHarnessInput = Omit executionRoleArn?: string; }; +export type HarnessUpdateInput = UpdateHarnessRequest & { + skipRolePolicyUpdate?: boolean; +}; + +export type HarnessRolePolicyWarning = { + reason: "unknown-role"; + roleArn: string; +}; + export interface CoreHarnessClient { - createHarness(input: CreateHarnessInput, options: CoreOptions): Promise; - updateHarness( - request: UpdateHarnessRequest, + getHarnessRolePolicyWarning( + harnessId: string, options: CoreOptions, - ): Promise; + ): Promise; + createHarness(input: CreateHarnessInput, options: CoreOptions): Promise; + updateHarness(request: HarnessUpdateInput, options: CoreOptions): Promise; deleteHarness( request: DeleteHarnessRequest, options: CoreOptions, diff --git a/src/handlers/harness/update/index.tsx b/src/handlers/harness/update/index.tsx index 8bc20fc3f..5225df710 100644 --- a/src/handlers/harness/update/index.tsx +++ b/src/handlers/harness/update/index.tsx @@ -15,6 +15,8 @@ import { coreOptsFromCtx, parseJsonFlag } from "../../utils.tsx"; import { JsonRendererKey } from "../../../tui"; import { InputValidationError } from "../../../errors"; import { parameterHelp } from "../parameterHelp.tsx"; +import type { AppIO } from "../../../io"; +import { warnForHarnessRolePolicyUpdate } from "../rolePolicyWarning"; // updated wraps a PATCH-semantics field: `-- ` replaces the value, // `--clear- true` sends an empty wrapper (unsetting it), and omitting @@ -24,7 +26,7 @@ function updated(value: T | undefined, clear: boolean): { optionalValue?: T } return value !== undefined ? { optionalValue: value } : undefined; } -export const createUpdateHarnessHandler = (core: Core) => +export const createUpdateHarnessHandler = (core: Core, io: AppIO) => createHandler({ name: "update", description: "update a harness (creates a new version)", @@ -105,6 +107,7 @@ export const createUpdateHarnessHandler = (core: Core) => flag("max-iterations", "max agent loop iterations per invocation", z.number().optional()), flag("max-tokens", "max total output tokens per invocation", z.number().optional()), flag("timeout-seconds", "max duration in seconds per invocation", z.number().optional()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { // Required at runtime but declared optional so that a bare @@ -113,51 +116,55 @@ export const createUpdateHarnessHandler = (core: Core) => throw new InputValidationError("required option '--id ' not specified"); } - const response = await core.harness.updateHarness( - { - harnessId: flags["id"], - executionRoleArn: flags["execution-role-arn"], - systemPrompt: flags["system-prompt"] ? [{ text: flags["system-prompt"] }] : undefined, - model: parseJsonFlag("model", flags["model"]), - tools: parseJsonFlag("tools", flags["tools"]), - skills: parseJsonFlag("skills", flags["skills"]), - allowedTools: flags["allowed-tools"], - memory: updated( - parseJsonFlag("memory", flags["memory"]), - flags["clear-memory"] === "true", + const request = { + harnessId: flags["id"], + executionRoleArn: flags["execution-role-arn"], + systemPrompt: flags["system-prompt"] ? [{ text: flags["system-prompt"] }] : undefined, + model: parseJsonFlag("model", flags["model"]), + tools: parseJsonFlag("tools", flags["tools"]), + skills: parseJsonFlag("skills", flags["skills"]), + allowedTools: flags["allowed-tools"], + memory: updated( + parseJsonFlag("memory", flags["memory"]), + flags["clear-memory"] === "true", + ), + truncation: parseJsonFlag( + "truncation", + flags["truncation"], + ), + environment: parseJsonFlag( + "environment", + flags["environment"], + ), + environmentArtifact: updated( + parseJsonFlag( + "environment-artifact", + flags["environment-artifact"], ), - truncation: parseJsonFlag( - "truncation", - flags["truncation"], + flags["clear-environment-artifact"] === "true", + ), + environmentVariables: parseJsonFlag>( + "environment-variables", + flags["environment-variables"], + ), + authorizerConfiguration: updated( + parseJsonFlag( + "authorizer-configuration", + flags["authorizer-configuration"], ), - environment: parseJsonFlag( - "environment", - flags["environment"], - ), - environmentArtifact: updated( - parseJsonFlag( - "environment-artifact", - flags["environment-artifact"], - ), - flags["clear-environment-artifact"] === "true", - ), - environmentVariables: parseJsonFlag>( - "environment-variables", - flags["environment-variables"], - ), - authorizerConfiguration: updated( - parseJsonFlag( - "authorizer-configuration", - flags["authorizer-configuration"], - ), - flags["clear-authorizer-configuration"] === "true", - ), - maxIterations: flags["max-iterations"], - maxTokens: flags["max-tokens"], - timeoutSeconds: flags["timeout-seconds"], - }, - coreOptsFromCtx(ctx), - ); + flags["clear-authorizer-configuration"] === "true", + ), + maxIterations: flags["max-iterations"], + maxTokens: flags["max-tokens"], + timeoutSeconds: flags["timeout-seconds"], + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), + }; + const options = coreOptsFromCtx(ctx); + await warnForHarnessRolePolicyUpdate(core, io, flags.id, options, { + explicitRoleArn: flags["execution-role-arn"], + skipRolePolicyUpdate: flags["skip-role-policy-update"], + }); + const response = await core.harness.updateHarness(request, options); ctx.require(JsonRendererKey).renderJson(response); }, }); diff --git a/src/handlers/harness/update/screen.tsx b/src/handlers/harness/update/screen.tsx index 9b0b1465b..e810cc47f 100644 --- a/src/handlers/harness/update/screen.tsx +++ b/src/handlers/harness/update/screen.tsx @@ -40,8 +40,17 @@ function UpdateWizard({ ctx, core, harnessId }: ScreenProps & { harnessId: strin queryKey: ["harness", opts.region, harnessId], queryFn: () => core.harness.getHarness(harnessId, opts), }); + const rolePolicyWarning = useQuery({ + queryKey: ["harness-role-policy-warning", opts.region, harnessId], + queryFn: async () => (await core.harness.getHarnessRolePolicyWarning(harnessId, opts)) ?? null, + }); - if (detail.isPending || detail.isError) { + if ( + detail.isPending || + detail.isError || + rolePolicyWarning.isPending || + rolePolicyWarning.isError + ) { return ( - {detail.isPending ? ( + {detail.isPending || rolePolicyWarning.isPending ? ( ) : ( - Error: {(detail.error as Error).message} + + Error: {((detail.error ?? rolePolicyWarning.error) as Error).message} + )} ); @@ -67,6 +78,7 @@ function UpdateWizard({ ctx, core, harnessId }: ScreenProps & { harnessId: strin harnessId={harnessId} breadcrumb={["agentcore", "harness", "update", harnessId]} initial={fromHarness(detail.data.harness!)} + rolePolicyWarning={rolePolicyWarning.data ?? undefined} onDone={(id) => finishFlow(`/agentcore/harness/get/${id}`)} /> ); diff --git a/src/handlers/harness/update/update.screen.test.tsx b/src/handlers/harness/update/update.screen.test.tsx index 3e8e27b9b..e4e1f04af 100644 --- a/src/handlers/harness/update/update.screen.test.tsx +++ b/src/handlers/harness/update/update.screen.test.tsx @@ -188,6 +188,29 @@ describe("harness update wizard", () => { r.unmount(); }); + test("shows the external IAM warning before submitting an unknown-role update", async () => { + const core = coreForUpdate(); + const roleArn = "arn:aws:iam::123456789012:role/CdkHarnessRole"; + core.harness.getHarnessRolePolicyWarning = async () => ({ + reason: "unknown-role", + roleArn, + }); + const r = renderScreen("/agentcore/harness/update/MyHarness-abc123", { core }); + + await waitForText(r.lastFrame, "● keep current"); + await r.press("return"); + await waitForText(r.lastFrame, "● managed"); + await r.press("return"); + await waitForText(r.lastFrame, "[✓] browser"); + await r.press("return"); + await waitForText(r.lastFrame, "type or paste the agent's instructions"); + await r.write("\x04"); + await waitForText(r.lastFrame, roleArn); + expect(r.lastFrame()).toContain("policies will not be modified"); + expect(r.lastFrame()).toContain("you are responsible for permissions"); + r.unmount(); + }); + test("disabling memory sends the wrapped disabled configuration", async () => { const core = coreForUpdate(); const r = renderScreen("/agentcore/harness/update/MyHarness-abc123", { core }); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 2a87db5b5..bbc627d79 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -62,7 +62,6 @@ import type { UpdateGatewayTargetResponse, UpdateHarnessEndpointRequest, UpdateHarnessEndpointResponse, - UpdateHarnessRequest, UpdateHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { @@ -89,7 +88,12 @@ import type { StartBatchEvaluationResponse, } from "@aws-sdk/client-bedrock-agentcore"; import type { Core } from "../handlers/types"; -import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; +import type { + CoreHarnessClient, + CreateHarnessInput, + HarnessRolePolicyWarning, + HarnessUpdateInput, +} from "../handlers/harness/types"; import type { CoreGatewayClient, CreateGatewayInput, @@ -274,6 +278,10 @@ export class TestHarnessClient implements CoreHarnessClient { private deleteEndpointResponse: DeleteHarnessEndpointResponse = DEFAULT_DELETE_ENDPOINT_RESPONSE; private error?: Error; + async getHarnessRolePolicyWarning(): Promise { + return undefined; + } + // setListResponse sets what listHarnesses resolves to (when not erroring). // Pass `forNextToken` to serve a later page: the response is returned when // listHarnesses is called with that nextToken. @@ -401,7 +409,7 @@ export class TestHarnessClient implements CoreHarnessClient { } async updateHarness( - request: UpdateHarnessRequest, + request: HarnessUpdateInput, options: CoreOptions, ): Promise { this.calls.push({ method: "updateHarness", args: [request, options] }); diff --git a/src/testing/fixtures.tsx b/src/testing/fixtures.tsx index a22800ae5..90140f981 100644 --- a/src/testing/fixtures.tsx +++ b/src/testing/fixtures.tsx @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { expect } from "bun:test"; import { @@ -10,10 +10,7 @@ import { } from "@aws-sdk/client-bedrock-agentcore-control"; import type { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import { - CreateRoleCommand, - DeleteRoleCommand, DeleteRolePolicyCommand, - GetRoleCommand, GetRolePolicyCommand, ListRolePoliciesCommand, PutRolePolicyCommand, @@ -212,34 +209,10 @@ function makeIamRecordingSend( const recordedSend = makeRecordingSend(realClient, dir); const inlinePolicies = new Map>(); const deletedPolicies = new Map>(); - const rolesCreatedInScenario = recordedCreatedRoleNames(dir); - const existingRoles = new Set(); return async (command: SdkCommand) => { if (isRecording()) return recordedSend(command); - if (command instanceof GetRoleCommand) { - const roleName = command.input.RoleName; - if (roleName && rolesCreatedInScenario.has(roleName) && !existingRoles.has(roleName)) { - const error = new Error(`Role ${roleName} does not exist in replay state.`); - error.name = "NoSuchEntityException"; - throw error; - } - return recordedSend(command); - } - - if (command instanceof CreateRoleCommand) { - const response = await recordedSend(command); - if (command.input.RoleName) existingRoles.add(command.input.RoleName); - return response; - } - - if (command instanceof DeleteRoleCommand) { - const response = await recordedSend(command); - if (command.input.RoleName) existingRoles.delete(command.input.RoleName); - return response; - } - if (command instanceof PutRolePolicyCommand) { const response = await recordedSend(command); const { RoleName, PolicyName, PolicyDocument } = command.input; @@ -305,19 +278,6 @@ function makeIamRecordingSend( return recordedSend(command); }; } - -function recordedCreatedRoleNames(dir: string): Set { - if (!existsSync(dir)) return new Set(); - const names = new Set(); - for (const file of readdirSync(dir).filter((name) => name.startsWith("CreateRoleCommand."))) { - const response = parse(readFileSync(join(dir, file), "utf8")) as { - Role?: { RoleName?: string }; - }; - if (response.Role?.RoleName) names.add(response.Role.RoleName); - } - return names; -} - function makeControlRecordingSend( realClient: BedrockAgentCoreControlClient, dir: string,