diff --git a/src/assets/templates/bedrock-managed-agents/README.md b/src/assets/templates/bedrock-managed-agents/README.md index a9968b875..2222de3c1 100644 --- a/src/assets/templates/bedrock-managed-agents/README.md +++ b/src/assets/templates/bedrock-managed-agents/README.md @@ -31,6 +31,7 @@ image build downloads Codex and Python from the internet, so a build in VPC mode `agentcore create` writes these settings to `agentcore/agentcore.json`: +- `bedrockManagedAgents: true` on the Runtime, which tells CDK to create a BMA session role for this project stack. - An idle timeout of 1800 seconds (30 minutes) and a maximum lifetime of 28800 seconds (8 hours). - No session storage. Session storage is only for a microVM Runtime, so without it the same settings work on a @@ -110,6 +111,23 @@ agentcore deploy Give the ACR ARN to BMA when you create the BMA environment. +# Session role + +CDK creates two roles for this environment. The ACR execution role gets `bma-acr-policy.json` so it can connect to BMA. +BMA assumes the separate session role to invoke the model, this project's BMA Runtimes, and Gateways in the account +and Region. The session role is created once per project stack, shared by its BMA Runtimes, and deleted with that +stack. The CLI records its ARN and the BMA Runtime ARNs in `agentcore/.cli/deployed-state.json` after a successful +deploy. + +The generated `client.py` looks up the session role for the `--runtime` ARN in that file and sends it when creating a +session. If the Runtime is not in the deployed state, run `agentcore deploy` again. You can instead give +`--role-arn ` to use a role you manage yourself. That role must trust `bedrock-mantle.amazonaws.com` and have the +session role permissions shown by the CDK stack. + +The identity running the client needs `iam:PassRole` on the session role, with `iam:PassedToService` set to +`bedrock-mantle.amazonaws.com`. It does not need IAM role creation or policy update permissions. Existing sessions +continue to use the role selected when they were created. + # Run the client Run the client from this directory with the ACR ARN from `agentcore status`: diff --git a/src/assets/templates/bedrock-managed-agents/client.py b/src/assets/templates/bedrock-managed-agents/client.py index a23f36856..852237f0d 100644 --- a/src/assets/templates/bedrock-managed-agents/client.py +++ b/src/assets/templates/bedrock-managed-agents/client.py @@ -2,6 +2,7 @@ import argparse import json +from pathlib import Path from typing import Any from aws_bedrock_token_generator import provide_token @@ -14,6 +15,28 @@ TOOL_CALLS = ("mcp_call", "function_call", "web_search_call") +def deployed_session_role(runtime_arn: str) -> str: + """Find the CDK-managed session role deployed alongside this Runtime.""" + project_root = Path(__file__).resolve().parents[2] + state_path = project_root / "agentcore" / ".cli" / "deployed-state.json" + try: + state = json.loads(state_path.read_text()) + except (OSError, ValueError) as error: + raise ValueError(f"Cannot read {state_path}: {error}") from error + + matches = [] + for target in state.get("targets", {}).values(): + session = target.get("resources", {}).get("bmaSession") or {} + if runtime_arn in session.get("runtimeArns", []): + matches.append(session["roleArn"]) + if len(matches) != 1: + raise ValueError( + f"Expected one deployed BMA session role for {runtime_arn}, found {len(matches)}. " + "Run agentcore deploy, or give --role-arn." + ) + return matches[0] + + def show(data: dict[str, Any]) -> None: """Prints the session ID, the commands, the tool calls, and the answer.""" kind = data["type"].removeprefix("agent.session.") @@ -46,6 +69,10 @@ def main() -> None: "--gateway", help="The Gateway URL from the output of `agentcore deploy`.", ) + parser.add_argument( + "--role-arn", + help="Use this BMA session role instead of the CDK-managed role for the Runtime.", + ) parser.add_argument("--delete", action="store_true", help="Delete the session.") parser.add_argument("--raw", action="store_true", help="Print events as JSON.") args = parser.parse_args() @@ -68,6 +95,12 @@ def main() -> None: if session["environment"].get("runtime_arn") != args.runtime: raise ValueError(f"Session {session_id} uses another ACR.") + if not session_id and not args.role_arn: + try: + args.role_arn = deployed_session_role(args.runtime) + except ValueError as error: + parser.error(str(error)) + if session_id: # BMA opens the stream only with stream=true, and the SDK does not send it. events = sessions.events.stream(session_id, extra_query={"stream": "true"}) @@ -110,6 +143,7 @@ def main() -> None: }, input=args.input, stream=True, + extra_body={"role_arn": args.role_arn}, ) with events: diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 588c35afa..f00d6eb53 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -6,6 +6,7 @@ import type { Stack } from "@aws-sdk/client-cloudformation"; import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; import { FsReadWriteJson, ProcessFailedError } from "../../../io"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { ProjectRuntimeSchema } from "../../../projectSchemas/runtime"; import { createSilentLogger } from "../../../testing"; import { TransactionSearchSetupError } from "../../../errors"; import { CdkBackend } from "./cdk"; @@ -513,6 +514,76 @@ describe("CdkBackend.deploy", () => { }); }); + test("records the CDK-managed BMA role with its runtime ARN for the sample client", async () => { + const input = await project(); + input.spec.runtimes.push( + ProjectRuntimeSchema.parse({ + name: "bma", + build: "Container", + codeLocation: "app/bma", + entrypoint: "lifecycle/server.py", + bedrockManagedAgents: true, + }), + ); + await writeAssembly(input, [TARGET.name]); + const roleArn = "arn:aws:iam::111122223333:role/ProjectBmaSessionRole"; + const runtimeArn = "arn:aws:bedrock-agentcore:us-east-1:111122223333:runtime/bma123"; + const subject = harness({ + describedStack: { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + { ExportName: "AgentCore-example-default-BmaSessionRoleArn", OutputValue: roleArn }, + { ExportName: "AgentCore-example-default-bma-RuntimeArn", OutputValue: runtimeArn }, + ], + }, + }); + + await collectDeploy(subject.backend.deploy(input, deployInput())); + + const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH); + const state = JSON.parse(await Bun.file(statePath).text()); + expect(state.targets.default.resources.bmaSession).toEqual({ + roleArn, + runtimeArns: [runtimeArn], + }); + + input.spec.runtimes[0]!.bedrockManagedAgents = false; + await collectDeploy(subject.backend.deploy(input, deployInput())); + const afterRemoval = JSON.parse(await Bun.file(statePath).text()); + expect(afterRemoval.targets.default.resources.bmaSession).toBeUndefined(); + + input.spec.runtimes[0]!.bedrockManagedAgents = undefined; + input.spec.runtimes[0]!.tags = { "agentcore:template": "BedrockManagedAgents" }; + input.spec.runtimes[0]!.additionalPolicies = ["bma-acr-policy.json"]; + await collectDeploy(subject.backend.deploy(input, deployInput())); + const afterMarkersOnly = JSON.parse(await Bun.file(statePath).text()); + expect(afterMarkersOnly.targets.default.resources.bmaSession).toBeUndefined(); + }); + + test("reports a missing BMA role output from an older CDK dependency", async () => { + const input = await project(); + input.spec.runtimes.push( + ProjectRuntimeSchema.parse({ + name: "bma", + build: "Container", + codeLocation: "app/bma", + entrypoint: "lifecycle/server.py", + bedrockManagedAgents: true, + }), + ); + await writeAssembly(input, [TARGET.name]); + const subject = harness(); + + await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow( + /Update @aws\/agentcore-cdk/, + ); + const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH); + const state = JSON.parse(await Bun.file(statePath).text()); + expect(state.targets.default.resources.bmaSession).toBeUndefined(); + }); + test("provisions credentials for the target before synth and records them under it", async () => { const input = await project(); await writeAssembly(input, [TARGET.name]); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index bc4fb90ed..821de51e5 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -102,6 +102,12 @@ function cdkId(name: string): string { return name.replace(/_/g, ""); } +function stackExportName(stackName: string, ...parts: string[]): string { + return [stackName, ...parts] + .map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, "")) + .join("-"); +} + function findDeployedResourceId( stack: Stack, input: Pick, @@ -365,7 +371,36 @@ export class CdkBackend implements ProjectBackend { // Persist the deployed stack's ARN so later commands read live resource state // from CloudFormation. Merged per target, so deploying one target never drops // another's recorded state. - await updateTargetState(this.json, project.rootPath, target.name, { stackArn }); + await updateTargetState(this.json, project.rootPath, target.name, { + stackArn, + resources: { bmaSession: undefined }, + }); + + const bmaRuntimes = project.spec.runtimes.filter( + (runtime) => runtime.bedrockManagedAgents === true, + ); + if (bmaRuntimes.length > 0) { + const stack = await this.describeStack(target.region, credentials, stackArn); + const output = (...parts: string[]) => + stack?.Outputs?.find( + (entry) => entry.ExportName === stackExportName(stack.StackName ?? "", ...parts), + )?.OutputValue; + const roleArn = output("BmaSessionRoleArn"); + const runtimeArns: string[] = []; + for (const runtime of bmaRuntimes) { + const arn = output(runtime.name, "RuntimeArn"); + if (arn) runtimeArns.push(arn); + } + if (!roleArn || runtimeArns.length !== bmaRuntimes.length) { + throw new MalformedServiceResponseError( + `The deployed stack '${stack?.StackName ?? artifact.stackName}' is missing BMA session role or runtime ARN outputs. ` + + "Update @aws/agentcore-cdk in agentcore/cdk/package.json to a version with BMA session role support and deploy again.", + ); + } + await updateTargetState(this.json, project.rootPath, target.name, { + resources: { bmaSession: { roleArn, runtimeArns } }, + }); + } // After the stack update, since a resource in it may have been using the provider // until this deploy removed the reference. @@ -542,9 +577,7 @@ export class CdkBackend implements ProjectBackend { if (!stack?.StackName) return undefined; // The CDK library builds every ExportName through this shared helper // https://github.com/aws/agentcore-l3-cdk-constructs/blob/main/src/cdk/logical-ids.ts#L84 - const want = [stack.StackName, ...parts] - .map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, "")) - .join("-"); + const want = stackExportName(stack.StackName, ...parts); return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; }; diff --git a/src/core/project/backends/cdk/deployedState.ts b/src/core/project/backends/cdk/deployedState.ts index bdad833df..019aee363 100644 --- a/src/core/project/backends/cdk/deployedState.ts +++ b/src/core/project/backends/cdk/deployedState.ts @@ -39,6 +39,13 @@ const CredentialStateSchema = z const ResourceStateSchema = z .object({ credentials: z.record(z.string(), CredentialStateSchema).optional(), + /** CDK-managed BMA session role and the runtimes this stack deployed it for. */ + bmaSession: z + .object({ + roleArn: z.string(), + runtimeArns: z.array(z.string()), + }) + .optional(), // The legacy deployer recorded the CloudFormation stack name // here. New deploys record the stack ARN instead. Keep this // field so projects can be correctly inspected after upgrading. diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index abda94a7d..e1c93bf40 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -307,6 +307,7 @@ describe("FsProjectManager.create", () => { { name: "environment_python_bma", build: "Container", + bedrockManagedAgents: true, entrypoint: "lifecycle/server.py", codeLocation: "app/environment_python_bma", dockerfile: "Dockerfile", @@ -896,7 +897,14 @@ describe("FsProjectManager.addResource", () => { async function editSpec( project: Project, edit: (spec: { - runtimes: { name: string; modelProvider?: string; modelId?: string }[]; + runtimes: { + name: string; + modelProvider?: string; + modelId?: string; + bedrockManagedAgents?: boolean; + tags?: Record; + additionalPolicies?: string[]; + }[]; harnesses: unknown[]; memories?: unknown[]; }) => void, @@ -955,6 +963,32 @@ describe("FsProjectManager.addResource", () => { expect(deployCalls).toEqual([]); }); + test("deploy to a China target recognizes the explicit BMA field", async () => { + await inTempDirectory(); + const { subject, project, deployCalls } = await projectWithTarget("cn-north-1"); + await editSpec(project, (spec) => { + spec.runtimes[0]!.bedrockManagedAgents = true; + }); + + const { error } = await deployOutcome(subject, project); + expect(error).toBeInstanceOf(RegionUnsupportedFeatureError); + expect(String(error)).toContain(BMA_CN_MESSAGE); + expect(deployCalls).toEqual([]); + }); + + test("deploy to a China target ignores BMA template markers without the explicit field", async () => { + await inTempDirectory(); + const { subject, project, deployCalls } = await projectWithTarget("cn-north-1"); + await editSpec(project, (spec) => { + spec.runtimes[0]!.tags = { "agentcore:template": "BedrockManagedAgents" }; + spec.runtimes[0]!.additionalPolicies = ["bma-acr-policy.json"]; + }); + + const { error } = await deployOutcome(subject, project); + expect(error).toBeUndefined(); + expect(deployCalls).toHaveLength(1); + }); + test("deploy to a China target proceeds past the gate for LiteLLM", async () => { await inTempDirectory(); const { subject, project, deployCalls } = await projectWithTarget("cn-north-1"); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 371217896..e68771f9a 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -94,7 +94,6 @@ import type { CreateCloudFormationClient } from "../types"; import type { CoreIdentityClient } from "../../handlers/identity/types"; import { templateManagesDependencies } from "../../handlers/project/templateProfile"; import { resolveRuntimeTemplateProfile } from "../../handlers/project/runtimeTemplateProfile"; -import { isBmaRuntime } from "../../handlers/project/bma"; const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]'; @@ -1162,7 +1161,9 @@ export class FsProjectManager implements ProjectManager { // modelProvider (BYO, provider-free, hand-edited, or scaffolded by an // older CLI) cannot be classified and only get an informational note. if (isChinaRegion(target.region)) { - const bmaRuntimes = project.spec.runtimes.filter(isBmaRuntime); + const bmaRuntimes = project.spec.runtimes.filter( + (runtime) => runtime.bedrockManagedAgents === true, + ); if (bmaRuntimes.length > 0) { throw new RegionUnsupportedFeatureError( `Cannot deploy to China region ${target.region}: ` + diff --git a/src/core/project/templates/bedrockManagedAgents.test.ts b/src/core/project/templates/bedrockManagedAgents.test.ts index ad14cdd10..56fb85843 100644 --- a/src/core/project/templates/bedrockManagedAgents.test.ts +++ b/src/core/project/templates/bedrockManagedAgents.test.ts @@ -151,6 +151,10 @@ test("Bedrock Managed Agents assets preserve the environment and lifecycle contr expect(client).toContain('base_url=f"https://bedrock-mantle.{region}.api.aws/openai/v1"'); expect(client).toContain('WORKSPACE_DIRECTORY = "/home/app/workspace"'); expect(client).toContain('CAPABILITY_DIRECTORIES = ["/opt/bma/plugins"]'); + expect(client).toContain("deployed-state.json"); + expect(client).toContain('"bmaSession"'); + expect(client).toContain('extra_body={"role_arn": args.role_arn}'); + expect(client).not.toContain("boto3"); expect(client).toContain('"type": "aws_bedrock_agentcore"'); expect(client).toContain('"runtime_qualifier": "DEFAULT"'); expect(client).toContain('"workspace_directory": WORKSPACE_DIRECTORY'); diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index a7b33f117..62d136e0c 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -100,6 +100,9 @@ function buildRuntimeSpec(input: RuntimeResourceConfig): ProjectRuntime { ...(infra.description && { description: infra.description }), ...(infra.executionRoleArn && { executionRoleArn: infra.executionRoleArn }), ...(additionalPolicies.length > 0 && { additionalPolicies }), + ...(scaffoldRuntimeInput.framework === "bma" && { + bedrockManagedAgents: true, + }), ...(infra.envVars && { envVars: infra.envVars }), ...(infra.networkMode && { networkMode: infra.networkMode }), ...(infra.networkConfig && { networkConfig: infra.networkConfig }), diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index 892d38806..85d04adde 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -106,6 +106,7 @@ describe("project add runtime", () => { }, "environment-python-bma template preset": { build: "Container", + bedrockManagedAgents: true, entrypoint: "lifecycle/server.py", dockerfile: "Dockerfile", protocol: "HTTP", @@ -342,6 +343,7 @@ describe("project add runtime", () => { expect(spec.runtimes).toContainEqual( expect.objectContaining({ name: "my_bma", + bedrockManagedAgents: true, lifecycleConfiguration: { idleRuntimeSessionTimeout: 300, maxLifetime: 3600, @@ -377,6 +379,7 @@ describe("project add runtime", () => { expect(spec.runtimes).toContainEqual( expect.objectContaining({ name: "my_bma", + bedrockManagedAgents: true, executionRoleArn: roleArn, additionalPolicies: ["bma-acr-policy.json"], }), diff --git a/src/handlers/project/add/runtime/runtime.screen.test.tsx b/src/handlers/project/add/runtime/runtime.screen.test.tsx index 09937ba97..46be859e6 100644 --- a/src/handlers/project/add/runtime/runtime.screen.test.tsx +++ b/src/handlers/project/add/runtime/runtime.screen.test.tsx @@ -152,6 +152,7 @@ describe("project add runtime wizard", () => { expect(await runtimeInSpec(projectRoot, "bma_environment")).toMatchObject({ build: "Container", + bedrockManagedAgents: true, entrypoint: "lifecycle/server.py", additionalPolicies: ["bma-acr-policy.json"], tags: { "agentcore:template": "BedrockManagedAgents" }, diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 226f03281..c37e88f10 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -369,6 +369,7 @@ describe("project create", () => { { name: "agent", build: "Container", + bedrockManagedAgents: true, entrypoint: "lifecycle/server.py", codeLocation: "app/agent", dockerfile: "Dockerfile", diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index 8bd4e6043..9e0a92f69 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -292,6 +292,8 @@ export const ProjectRuntimeSchema = z requestHeaderAllowlist: RequestHeaderAllowlistSchema.optional(), executionRoleArn: z.string().optional(), additionalPolicies: z.array(z.string().min(1)).optional(), + /** Enables the CDK-managed Bedrock Managed Agents session role for this runtime. */ + bedrockManagedAgents: z.boolean().optional(), authorizerType: RuntimeAuthorizerTypeSchema.optional(), authorizerConfiguration: AuthorizerConfigSchema.optional(), tags: TagsSchema.optional(),