From f1f14dd629cbeb5c7c76b4e245c610bd631a4c50 Mon Sep 17 00:00:00 2001 From: Abhimanyu Siwach Date: Wed, 30 Sep 2026 18:36:46 -0700 Subject: [PATCH] fix(bma): create the BMA session role in client.py and pass it as role_arn BMA needs a session role to call the model and the ACR. The template did not create it, so a new account failed on CreateAgentSession until the reader created BedrockManagedAgentsPreviewInferenceServiceRole by hand. client.py now creates or repairs BmaSessionRole- from policies/bma-session-trust.json and policies/bma-session-policy.json, and sends its ARN with extra_body role_arn. --role-arn uses a role that the reader created. The ACR policy moves to policies/ with no change. Also override brace-expansion ^5.0.12, fast-uri ^3.1.8 and @humanfs/node ^0.16.8 so that npm audit --omit=dev passes (GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p, GHSA-hrr3-gc8f-f4qj, GHSA-p498-v437-472g). --- docs/frameworks.md | 13 + npm-shrinkwrap.json | 43 ++-- package.json | 4 +- .../assets.snapshot.test.ts.snap | 226 ++++++++++++++++-- src/assets/python/http/bma/base/README.md | 60 ++++- src/assets/python/http/bma/base/client.py | 68 ++++++ .../base/{ => policies}/bma-acr-policy.json | 0 .../bma/base/policies/bma-session-policy.json | 35 +++ .../bma/base/policies/bma-session-trust.json | 20 ++ .../python/http/bma/base/pyproject.toml | 1 + src/cli/templates/__tests__/bma.test.ts | 74 +++++- src/cli/templates/bmaProfile.ts | 2 +- 12 files changed, 502 insertions(+), 44 deletions(-) rename src/assets/python/http/bma/base/{ => policies}/bma-acr-policy.json (100%) create mode 100644 src/assets/python/http/bma/base/policies/bma-session-policy.json create mode 100644 src/assets/python/http/bma/base/policies/bma-session-trust.json diff --git a/docs/frameworks.md b/docs/frameworks.md index 2c55d28a25..e78b31d56c 100644 --- a/docs/frameworks.md +++ b/docs/frameworks.md @@ -137,10 +137,23 @@ the session's commands to this Runtime. The Runtime runs no model code. seconds, and a policy that lets the Runtime connect to BMA. It adds no session storage, so the same project deploys to a microVM Runtime and to a capacity provider. +BMA assumes a session role to call the Runtime. **The generated `client.py` creates this IAM role, +`BmaSessionRole-`, in your account.** `agentcore deploy` does not create the session role, and +`agentcore remove` does not delete it. The policy files of the session role are in `policies/` in the agent directory. +They are limited to the account and the Region of the Runtime. + ```bash agentcore create --name MyManagedAgent --framework BedrockManagedAgents ``` +After `agentcore deploy`, give the Runtime ARN from `agentcore status` to the client. If the role exists, the client +compares it with the files in `policies/`, and puts the files back if someone changed the role. To use your own role, +give `--role-arn`. The `README.md` in the agent directory gives the details and the IAM permissions of the client. + +```bash +uv run client.py --runtime +``` + ## Import from Bedrock Agents If you have an existing Bedrock Agent, you can import its configuration and translate it into runnable Strands or diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 7c0b869343..6e02ac5afa 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.30.0", + "version": "0.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.30.0", + "version": "0.31.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -3756,27 +3756,40 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -7598,9 +7611,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -9412,9 +9425,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 9513465284..c6ed779fc9 100644 --- a/package.json +++ b/package.json @@ -183,7 +183,9 @@ "@aws-sdk/xml-builder": "3.972.15", "@opentelemetry/core": ">=2.8.0", "protobufjs": ">=8.7.0", - "fast-uri": "^3.1.5" + "fast-uri": "^3.1.8", + "brace-expansion": "^5.0.12", + "@humanfs/node": "^0.16.8" }, "engines": { "node": ">=20" diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 285e085d41..9d9798bdc9 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -874,12 +874,14 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "python/http/autogen/base/pyproject.toml", "python/http/bma/base/Dockerfile", "python/http/bma/base/README.md", - "python/http/bma/base/bma-acr-policy.json", "python/http/bma/base/client.py", "python/http/bma/base/lifecycle/server.py", "python/http/bma/base/otel/collector.yaml", "python/http/bma/base/plugins/acr-report/.codex-plugin/plugin.json", "python/http/bma/base/plugins/acr-report/skills/acr-report/SKILL.md", + "python/http/bma/base/policies/bma-acr-policy.json", + "python/http/bma/base/policies/bma-session-policy.json", + "python/http/bma/base/policies/bma-session-trust.json", "python/http/bma/base/pyproject.toml", "python/http/googleadk/base/README.md", "python/http/googleadk/base/gitignore.template", @@ -3916,9 +3918,11 @@ environment where BMA runs commands. The ACR has no model code. | \`lifecycle/server.py\` | The environment lifecycle server, \`bma-acr-lifecycle\`. It handles the lifecycle calls from BMA and starts \`codex exec-server\`. | | \`otel/collector.yaml\` | The configuration of the CloudWatch agent. The agent gets the spans and logs of \`codex exec-server\`, puts the session ID on them, and sends them to X-Ray and CloudWatch Logs with the ACR role. To turn off observability, add \`DISABLE_ADOT_OBSERVABILITY\` with the value \`true\` to \`envVars\`. | | \`plugins/acr-report\` | A Codex plugin with the \`acr-report\` skill. The skill saves the Python version, the user ID, and the working directory in \`acr-report.txt\`. | -| \`bma-acr-policy.json\` | Lets the ACR role call \`bedrock-mantle:RegisterEnvironment\` and \`bedrock-mantle:ConnectEnvironment\` on every Mantle project. To limit the role to your projects, change \`Resource\` to \`arn:aws:bedrock-mantle:::project/\`. | +| \`policies/bma-acr-policy.json\` | Lets the ACR role call \`bedrock-mantle:RegisterEnvironment\` and \`bedrock-mantle:ConnectEnvironment\` on every Mantle project. To limit the role to your projects, change \`Resource\` to \`arn:aws:bedrock-mantle:::project/\`. | +| \`policies/bma-session-trust.json\` | The trust policy of the session role. BMA assumes the session role to call the ACR. The conditions let only BMA sessions and projects in the account and the Region of the ACR assume the role. \`client.py\` changes \`\${AWS::Partition}\`, \`\${AWS::Region}\`, and \`\${AWS::AccountId}\` to the values of the ACR ARN. | +| \`policies/bma-session-policy.json\` | The policy of the session role. It lets BMA call the model with \`bedrock-mantle:CreateInference\`, and call and stop the ACRs and the Gateways in the account and the Region of the ACR. | | \`pyproject.toml\` | The Python dependencies. \`aws-opentelemetry-distro\` sends a span for each call from BMA and a child span for each step of the call, for example the state load or the exec-server start. \`bedrock-agentcore\` is the AgentCore SDK. The \`dev\` group has the dependencies of \`client.py\`, and the image does not install it. Each image build installs the latest Python and the latest releases. To pin them, run \`uv lock\` and keep \`uv.lock\` next to this file. \`requires-python\` sets only a minimum, 3.12, so that \`uv run client.py\` does not use an older system Python. | -| \`client.py\` | A sample OpenAI SDK client. It creates a session in BMA, and BMA sends the session's commands to this ACR. | +| \`client.py\` | A sample OpenAI SDK client. It creates the session role if necessary, and it creates a session in BMA. BMA sends the session's commands to this ACR. | Do not change \`lifecycle/server.py\`. It must match the lifecycle calls that BMA makes. @@ -3936,6 +3940,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\`: +- \`policies/bma-acr-policy.json\` in \`additionalPolicies\`. The ACR role gets this policy. - 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 @@ -4003,6 +4008,50 @@ agentcore deploy Give the ACR ARN to BMA when you create the BMA environment. +# Session role + +BMA needs two IAM roles in your account: + +| Role | Who uses it | Who creates it | +| --- | --- | --- | +| The ACR role | The ACR, to connect to BMA. | \`agentcore deploy\`, with \`policies/bma-acr-policy.json\`. | +| The session role | BMA, to call the model and to call and stop this ACR. | \`client.py\`, with \`policies/bma-session-trust.json\` and \`policies/bma-session-policy.json\`. | + +**\`client.py\` creates an IAM role in your account.** \`agentcore deploy\` does not create the session role, and +\`agentcore remove\` does not delete it. The client always gives the session role to BMA, so you do not need the role +\`BedrockManagedAgentsPreviewInferenceServiceRole\`. + +When the client creates a session and you do not give \`--role-arn\`, the client does these steps: + +1. It gets the partition, the Region, and the account from the ACR ARN, and it puts them in the two files. +2. If the role \`BmaSessionRole-\` does not exist, the client creates it. +3. If the trust policy of the role is not the same as \`policies/bma-session-trust.json\`, the client writes the file to + the role. +4. If the inline policy \`BmaSession\` of the role is not the same as \`policies/bma-session-policy.json\`, the client + writes the file to the role. +5. If it changed the role, the client waits 15 seconds, because IAM needs this time before BMA can use the change. + +Thus, if you or another person changes the trust policy or the policy \`BmaSession\`, the next new session puts the +files back. To change the role, change the files. Then run the client. + +The client does not change these items: + +- Other policies on the role. For example, a \`Deny\` policy that someone adds stays on the role, and the session fails. +- A role that you give with \`--role-arn\`. +- The role, when you give \`--session-id\` for a session that exists. BMA assumes the role again at each turn. If the role + is broken, the turn fails until a new session puts the files back. + +To use a role that you created, add \`--role-arn \`. The trust policy must let +\`bedrock-mantle.amazonaws.com\` assume the role, and the role needs the permissions in +\`policies/bma-session-policy.json\`. + +To delete the session role: + +\`\`\`bash +aws iam delete-role-policy --role-name BmaSessionRole- --policy-name BmaSession +aws iam delete-role --role-name BmaSessionRole- +\`\`\` + # Run the client Run the client from this directory with the ACR ARN from \`agentcore status\`: @@ -4011,6 +4060,15 @@ Run the client from this directory with the ACR ARN from \`agentcore status\`: uv run client.py --runtime \`\`\` +The identity that runs the client needs these permissions: + +- \`bedrock-mantle\` permissions for the BMA sessions, for example \`bedrock-mantle:CreateAgentSession\`. +- \`bedrock-mantle:CallWithBearerToken\`, because the client signs in with a bearer token. +- \`iam:PassRole\` on the session role, with the condition \`iam:PassedToService\` set to \`bedrock-mantle.amazonaws.com\`. +- \`iam:GetRole\`, \`iam:CreateRole\`, \`iam:UpdateAssumeRolePolicy\`, \`iam:GetRolePolicy\`, and \`iam:PutRolePolicy\` on + \`arn:aws:iam:::role/BmaSessionRole-*\`. The client does not need these permissions if you give + \`--role-arn\`. Without them, the client stops and tells you to give \`--role-arn\`. + \`uv run\` installs the dependencies and the \`dev\` group from \`pyproject.toml\` in \`.venv\`. The client creates a BMA session and prints the session ID, each command with its output, and the answer of the agent as it streams. @@ -4028,29 +4086,18 @@ To delete the session after the turn, add \`--delete\`. To print each stream eve " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/bma-acr-policy.json should match snapshot 1`] = ` -"{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AttachToBmaEnvironment", - "Effect": "Allow", - "Action": ["bedrock-mantle:RegisterEnvironment", "bedrock-mantle:ConnectEnvironment"], - "Resource": "arn:*:bedrock-mantle:*:*:project/*" - } - ] -} -" -`; - exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/client.py should match snapshot 1`] = ` """"Send an input to a Bedrock Managed Agents session that uses this project's ACR.""" import argparse import json +import time +from pathlib import Path from typing import Any +import boto3 from aws_bedrock_token_generator import provide_token +from botocore.exceptions import ClientError from openai import NotFoundError, OpenAI BMA_MODEL_ID = "openai.gpt-5.6-luna" @@ -4058,6 +4105,9 @@ WORKSPACE_DIRECTORY = "/home/app/workspace" CAPABILITY_DIRECTORIES = ["/opt/bma/plugins"] TURN_END = ("completed", "failed", "cancelled") TOOL_CALLS = ("mcp_call", "function_call", "web_search_call") +POLICIES = Path(__file__).parent / "policies" +SESSION_ROLE = "BmaSessionRole-{region}" +SESSION_POLICY = "BmaSession" def show(data: dict[str, Any]) -> None: @@ -4077,6 +4127,52 @@ def show(data: dict[str, Any]) -> None: print(f"\\n{kind} {(source or data).get('error') or ''}".rstrip()) +def load_policy(name: str, partition: str, region: str, account: str) -> str: + """Reads a policy file and puts in the partition, the Region, and the account.""" + text = (POLICIES / name).read_text() + for key, value in (("Partition", partition), ("Region", region), ("AccountId", account)): + text = text.replace("\${AWS::" + key + "}", value) + return text + + +def session_role(runtime_arn: str) -> str: + """Creates or repairs the session role, and returns its ARN. + + If the trust policy or the policy of the role is not the same as the file in \`policies/\`, + the client writes the file to the role. + """ + _, partition, _, region, account = runtime_arn.split(":")[:5] + name = SESSION_ROLE.format(region=region) + trust = load_policy("bma-session-trust.json", partition, region, account) + policy = load_policy("bma-session-policy.json", partition, region, account) + iam = boto3.client("iam") + changed = False + try: + role = iam.get_role(RoleName=name)["Role"] + except iam.exceptions.NoSuchEntityException: + role = iam.create_role(RoleName=name, AssumeRolePolicyDocument=trust)["Role"] + print(f"Created the session role {name}") + changed = True + else: + if role["AssumeRolePolicyDocument"] != json.loads(trust): + iam.update_assume_role_policy(RoleName=name, PolicyDocument=trust) + print(f"Updated the trust policy of {name}") + changed = True + try: + current = iam.get_role_policy(RoleName=name, PolicyName=SESSION_POLICY)["PolicyDocument"] + except iam.exceptions.NoSuchEntityException: + current = None + if current != json.loads(policy): + iam.put_role_policy(RoleName=name, PolicyName=SESSION_POLICY, PolicyDocument=policy) + if not changed: + print(f"Updated the policy of {name}") + changed = True + if changed: + # IAM needs some seconds before BMA can use a new or changed role. + time.sleep(15) + return role["Arn"] + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--runtime", required=True, help="The ACR ARN.") @@ -4092,6 +4188,10 @@ def main() -> None: "--gateway", help="The Gateway URL from the output of \`agentcore deploy\`.", ) + parser.add_argument( + "--role-arn", + help="The session role that BMA assumes. If you do not give it, the client creates a role.", + ) 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() @@ -4113,6 +4213,15 @@ def main() -> None: else: 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 = session_role(args.runtime) + except ClientError as error: + parser.error( + f"The client cannot create or check the session role: {error}. " + "Get the IAM permissions in README.md, or give --role-arn." + ) + print(f"Session role {args.role_arn}") if session_id: # BMA opens the stream only with stream=true, and the SDK does not send it. @@ -4156,6 +4265,8 @@ def main() -> None: }, input=args.input, stream=True, + # The SDK has no role_arn parameter. BMA assumes this role to call the ACR. + extra_body={"role_arn": args.role_arn}, ) with events: @@ -4927,6 +5038,84 @@ If a command fails, put its error in the report. Do not change the commands. " `; +exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/policies/bma-acr-policy.json should match snapshot 1`] = ` +"{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AttachToBmaEnvironment", + "Effect": "Allow", + "Action": ["bedrock-mantle:RegisterEnvironment", "bedrock-mantle:ConnectEnvironment"], + "Resource": "arn:*:bedrock-mantle:*:*:project/*" + } + ] +} +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/policies/bma-session-policy.json should match snapshot 1`] = ` +"{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "CreateInference", + "Effect": "Allow", + "Action": "bedrock-mantle:CreateInference", + "Resource": "*" + }, + { + "Sid": "InvokeAgentCoreRuntime", + "Effect": "Allow", + "Action": "bedrock-agentcore:InvokeAgentRuntime", + "Resource": [ + "arn:\${AWS::Partition}:bedrock-agentcore:\${AWS::Region}:\${AWS::AccountId}:runtime/*", + "arn:\${AWS::Partition}:bedrock-agentcore:\${AWS::Region}:\${AWS::AccountId}:runtime/*/runtime-endpoint/*" + ] + }, + { + "Sid": "StopAgentCoreRuntimeSession", + "Effect": "Allow", + "Action": "bedrock-agentcore:StopRuntimeSession", + "Resource": [ + "arn:\${AWS::Partition}:bedrock-agentcore:\${AWS::Region}:\${AWS::AccountId}:runtime/*", + "arn:\${AWS::Partition}:bedrock-agentcore:\${AWS::Region}:\${AWS::AccountId}:runtime/*/runtime-endpoint/*" + ] + }, + { + "Sid": "InvokeAgentCoreGateway", + "Effect": "Allow", + "Action": "bedrock-agentcore:InvokeGateway", + "Resource": "arn:\${AWS::Partition}:bedrock-agentcore:\${AWS::Region}:\${AWS::AccountId}:gateway/*" + } + ] +} +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/policies/bma-session-trust.json should match snapshot 1`] = ` +"{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumeFromBmaSession", + "Effect": "Allow", + "Principal": { "Service": "bedrock-mantle.amazonaws.com" }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { "aws:SourceAccount": "\${AWS::AccountId}" }, + "ArnLike": { + "aws:SourceArn": [ + "arn:\${AWS::Partition}:bedrock-mantle:\${AWS::Region}:\${AWS::AccountId}:session/*", + "arn:\${AWS::Partition}:bedrock-mantle:\${AWS::Region}:\${AWS::AccountId}:project/*" + ] + } + } + } + ] +} +" +`; + exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/pyproject.toml should match snapshot 1`] = ` "[project] name = "{{ name }}" @@ -4943,6 +5132,7 @@ dependencies = [ [dependency-groups] dev = [ "aws-bedrock-token-generator>=1.1.0", + "boto3>=1.43.0", "openai>=3.16.2", ] diff --git a/src/assets/python/http/bma/base/README.md b/src/assets/python/http/bma/base/README.md index 931fad06bb..050536ff02 100644 --- a/src/assets/python/http/bma/base/README.md +++ b/src/assets/python/http/bma/base/README.md @@ -11,9 +11,11 @@ environment where BMA runs commands. The ACR has no model code. | `lifecycle/server.py` | The environment lifecycle server, `bma-acr-lifecycle`. It handles the lifecycle calls from BMA and starts `codex exec-server`. | | `otel/collector.yaml` | The configuration of the CloudWatch agent. The agent gets the spans and logs of `codex exec-server`, puts the session ID on them, and sends them to X-Ray and CloudWatch Logs with the ACR role. To turn off observability, add `DISABLE_ADOT_OBSERVABILITY` with the value `true` to `envVars`. | | `plugins/acr-report` | A Codex plugin with the `acr-report` skill. The skill saves the Python version, the user ID, and the working directory in `acr-report.txt`. | -| `bma-acr-policy.json` | Lets the ACR role call `bedrock-mantle:RegisterEnvironment` and `bedrock-mantle:ConnectEnvironment` on every Mantle project. To limit the role to your projects, change `Resource` to `arn:aws:bedrock-mantle:::project/`. | +| `policies/bma-acr-policy.json` | Lets the ACR role call `bedrock-mantle:RegisterEnvironment` and `bedrock-mantle:ConnectEnvironment` on every Mantle project. To limit the role to your projects, change `Resource` to `arn:aws:bedrock-mantle:::project/`. | +| `policies/bma-session-trust.json` | The trust policy of the session role. BMA assumes the session role to call the ACR. The conditions let only BMA sessions and projects in the account and the Region of the ACR assume the role. `client.py` changes `${AWS::Partition}`, `${AWS::Region}`, and `${AWS::AccountId}` to the values of the ACR ARN. | +| `policies/bma-session-policy.json` | The policy of the session role. It lets BMA call the model with `bedrock-mantle:CreateInference`, and call and stop the ACRs and the Gateways in the account and the Region of the ACR. | | `pyproject.toml` | The Python dependencies. `aws-opentelemetry-distro` sends a span for each call from BMA and a child span for each step of the call, for example the state load or the exec-server start. `bedrock-agentcore` is the AgentCore SDK. The `dev` group has the dependencies of `client.py`, and the image does not install it. Each image build installs the latest Python and the latest releases. To pin them, run `uv lock` and keep `uv.lock` next to this file. `requires-python` sets only a minimum, 3.12, so that `uv run client.py` does not use an older system Python. | -| `client.py` | A sample OpenAI SDK client. It creates a session in BMA, and BMA sends the session's commands to this ACR. | +| `client.py` | A sample OpenAI SDK client. It creates the session role if necessary, and it creates a session in BMA. BMA sends the session's commands to this ACR. | Do not change `lifecycle/server.py`. It must match the lifecycle calls that BMA makes. @@ -31,6 +33,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`: +- `policies/bma-acr-policy.json` in `additionalPolicies`. The ACR role gets this policy. - 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 @@ -98,6 +101,50 @@ agentcore deploy Give the ACR ARN to BMA when you create the BMA environment. +# Session role + +BMA needs two IAM roles in your account: + +| Role | Who uses it | Who creates it | +| --- | --- | --- | +| The ACR role | The ACR, to connect to BMA. | `agentcore deploy`, with `policies/bma-acr-policy.json`. | +| The session role | BMA, to call the model and to call and stop this ACR. | `client.py`, with `policies/bma-session-trust.json` and `policies/bma-session-policy.json`. | + +**`client.py` creates an IAM role in your account.** `agentcore deploy` does not create the session role, and +`agentcore remove` does not delete it. The client always gives the session role to BMA, so you do not need the role +`BedrockManagedAgentsPreviewInferenceServiceRole`. + +When the client creates a session and you do not give `--role-arn`, the client does these steps: + +1. It gets the partition, the Region, and the account from the ACR ARN, and it puts them in the two files. +2. If the role `BmaSessionRole-` does not exist, the client creates it. +3. If the trust policy of the role is not the same as `policies/bma-session-trust.json`, the client writes the file to + the role. +4. If the inline policy `BmaSession` of the role is not the same as `policies/bma-session-policy.json`, the client + writes the file to the role. +5. If it changed the role, the client waits 15 seconds, because IAM needs this time before BMA can use the change. + +Thus, if you or another person changes the trust policy or the policy `BmaSession`, the next new session puts the +files back. To change the role, change the files. Then run the client. + +The client does not change these items: + +- Other policies on the role. For example, a `Deny` policy that someone adds stays on the role, and the session fails. +- A role that you give with `--role-arn`. +- The role, when you give `--session-id` for a session that exists. BMA assumes the role again at each turn. If the role + is broken, the turn fails until a new session puts the files back. + +To use a role that you created, add `--role-arn `. The trust policy must let +`bedrock-mantle.amazonaws.com` assume the role, and the role needs the permissions in +`policies/bma-session-policy.json`. + +To delete the session role: + +```bash +aws iam delete-role-policy --role-name BmaSessionRole- --policy-name BmaSession +aws iam delete-role --role-name BmaSessionRole- +``` + # Run the client Run the client from this directory with the ACR ARN from `agentcore status`: @@ -106,6 +153,15 @@ Run the client from this directory with the ACR ARN from `agentcore status`: uv run client.py --runtime ``` +The identity that runs the client needs these permissions: + +- `bedrock-mantle` permissions for the BMA sessions, for example `bedrock-mantle:CreateAgentSession`. +- `bedrock-mantle:CallWithBearerToken`, because the client signs in with a bearer token. +- `iam:PassRole` on the session role, with the condition `iam:PassedToService` set to `bedrock-mantle.amazonaws.com`. +- `iam:GetRole`, `iam:CreateRole`, `iam:UpdateAssumeRolePolicy`, `iam:GetRolePolicy`, and `iam:PutRolePolicy` on + `arn:aws:iam:::role/BmaSessionRole-*`. The client does not need these permissions if you give + `--role-arn`. Without them, the client stops and tells you to give `--role-arn`. + `uv run` installs the dependencies and the `dev` group from `pyproject.toml` in `.venv`. The client creates a BMA session and prints the session ID, each command with its output, and the answer of the agent as it streams. diff --git a/src/assets/python/http/bma/base/client.py b/src/assets/python/http/bma/base/client.py index a23f36856a..4eac975017 100644 --- a/src/assets/python/http/bma/base/client.py +++ b/src/assets/python/http/bma/base/client.py @@ -2,9 +2,13 @@ import argparse import json +import time +from pathlib import Path from typing import Any +import boto3 from aws_bedrock_token_generator import provide_token +from botocore.exceptions import ClientError from openai import NotFoundError, OpenAI BMA_MODEL_ID = "openai.gpt-5.6-luna" @@ -12,6 +16,9 @@ CAPABILITY_DIRECTORIES = ["/opt/bma/plugins"] TURN_END = ("completed", "failed", "cancelled") TOOL_CALLS = ("mcp_call", "function_call", "web_search_call") +POLICIES = Path(__file__).parent / "policies" +SESSION_ROLE = "BmaSessionRole-{region}" +SESSION_POLICY = "BmaSession" def show(data: dict[str, Any]) -> None: @@ -31,6 +38,52 @@ def show(data: dict[str, Any]) -> None: print(f"\n{kind} {(source or data).get('error') or ''}".rstrip()) +def load_policy(name: str, partition: str, region: str, account: str) -> str: + """Reads a policy file and puts in the partition, the Region, and the account.""" + text = (POLICIES / name).read_text() + for key, value in (("Partition", partition), ("Region", region), ("AccountId", account)): + text = text.replace("${AWS::" + key + "}", value) + return text + + +def session_role(runtime_arn: str) -> str: + """Creates or repairs the session role, and returns its ARN. + + If the trust policy or the policy of the role is not the same as the file in `policies/`, + the client writes the file to the role. + """ + _, partition, _, region, account = runtime_arn.split(":")[:5] + name = SESSION_ROLE.format(region=region) + trust = load_policy("bma-session-trust.json", partition, region, account) + policy = load_policy("bma-session-policy.json", partition, region, account) + iam = boto3.client("iam") + changed = False + try: + role = iam.get_role(RoleName=name)["Role"] + except iam.exceptions.NoSuchEntityException: + role = iam.create_role(RoleName=name, AssumeRolePolicyDocument=trust)["Role"] + print(f"Created the session role {name}") + changed = True + else: + if role["AssumeRolePolicyDocument"] != json.loads(trust): + iam.update_assume_role_policy(RoleName=name, PolicyDocument=trust) + print(f"Updated the trust policy of {name}") + changed = True + try: + current = iam.get_role_policy(RoleName=name, PolicyName=SESSION_POLICY)["PolicyDocument"] + except iam.exceptions.NoSuchEntityException: + current = None + if current != json.loads(policy): + iam.put_role_policy(RoleName=name, PolicyName=SESSION_POLICY, PolicyDocument=policy) + if not changed: + print(f"Updated the policy of {name}") + changed = True + if changed: + # IAM needs some seconds before BMA can use a new or changed role. + time.sleep(15) + return role["Arn"] + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--runtime", required=True, help="The ACR ARN.") @@ -46,6 +99,10 @@ def main() -> None: "--gateway", help="The Gateway URL from the output of `agentcore deploy`.", ) + parser.add_argument( + "--role-arn", + help="The session role that BMA assumes. If you do not give it, the client creates a role.", + ) 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() @@ -67,6 +124,15 @@ def main() -> None: else: 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 = session_role(args.runtime) + except ClientError as error: + parser.error( + f"The client cannot create or check the session role: {error}. " + "Get the IAM permissions in README.md, or give --role-arn." + ) + print(f"Session role {args.role_arn}") if session_id: # BMA opens the stream only with stream=true, and the SDK does not send it. @@ -110,6 +176,8 @@ def main() -> None: }, input=args.input, stream=True, + # The SDK has no role_arn parameter. BMA assumes this role to call the ACR. + extra_body={"role_arn": args.role_arn}, ) with events: diff --git a/src/assets/python/http/bma/base/bma-acr-policy.json b/src/assets/python/http/bma/base/policies/bma-acr-policy.json similarity index 100% rename from src/assets/python/http/bma/base/bma-acr-policy.json rename to src/assets/python/http/bma/base/policies/bma-acr-policy.json diff --git a/src/assets/python/http/bma/base/policies/bma-session-policy.json b/src/assets/python/http/bma/base/policies/bma-session-policy.json new file mode 100644 index 0000000000..3bef5b20e7 --- /dev/null +++ b/src/assets/python/http/bma/base/policies/bma-session-policy.json @@ -0,0 +1,35 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "CreateInference", + "Effect": "Allow", + "Action": "bedrock-mantle:CreateInference", + "Resource": "*" + }, + { + "Sid": "InvokeAgentCoreRuntime", + "Effect": "Allow", + "Action": "bedrock-agentcore:InvokeAgentRuntime", + "Resource": [ + "arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:runtime/*", + "arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:runtime/*/runtime-endpoint/*" + ] + }, + { + "Sid": "StopAgentCoreRuntimeSession", + "Effect": "Allow", + "Action": "bedrock-agentcore:StopRuntimeSession", + "Resource": [ + "arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:runtime/*", + "arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:runtime/*/runtime-endpoint/*" + ] + }, + { + "Sid": "InvokeAgentCoreGateway", + "Effect": "Allow", + "Action": "bedrock-agentcore:InvokeGateway", + "Resource": "arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:gateway/*" + } + ] +} diff --git a/src/assets/python/http/bma/base/policies/bma-session-trust.json b/src/assets/python/http/bma/base/policies/bma-session-trust.json new file mode 100644 index 0000000000..43d2e7bdb4 --- /dev/null +++ b/src/assets/python/http/bma/base/policies/bma-session-trust.json @@ -0,0 +1,20 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumeFromBmaSession", + "Effect": "Allow", + "Principal": { "Service": "bedrock-mantle.amazonaws.com" }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { "aws:SourceAccount": "${AWS::AccountId}" }, + "ArnLike": { + "aws:SourceArn": [ + "arn:${AWS::Partition}:bedrock-mantle:${AWS::Region}:${AWS::AccountId}:session/*", + "arn:${AWS::Partition}:bedrock-mantle:${AWS::Region}:${AWS::AccountId}:project/*" + ] + } + } + } + ] +} diff --git a/src/assets/python/http/bma/base/pyproject.toml b/src/assets/python/http/bma/base/pyproject.toml index 10a862ddd7..16a65303a9 100644 --- a/src/assets/python/http/bma/base/pyproject.toml +++ b/src/assets/python/http/bma/base/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ [dependency-groups] dev = [ "aws-bedrock-token-generator>=1.1.0", + "boto3>=1.43.0", "openai>=3.16.2", ] diff --git a/src/cli/templates/__tests__/bma.test.ts b/src/cli/templates/__tests__/bma.test.ts index 17827fc832..e2603d4ba8 100644 --- a/src/cli/templates/__tests__/bma.test.ts +++ b/src/cli/templates/__tests__/bma.test.ts @@ -31,6 +31,19 @@ const bmaConfig: GenerateConfig = { const TEMPLATE_DIR = join(TEMPLATE_ROOT, 'python', 'http', 'bma', 'base'); +interface PolicyDocument { + Statement: { + Action: string | string[]; + Resource?: string | string[]; + Principal?: unknown; + Condition?: unknown; + }[]; +} + +function readPolicy(name: string): PolicyDocument { + return JSON.parse(readFileSync(join(TEMPLATE_DIR, 'policies', name), 'utf-8')) as PolicyDocument; +} + describe('BMA runtime spec', () => { it('writes a Container runtime with no session storage or env vars, a 30 minute idle timeout, an 8 hour lifetime, the Mantle policy, and the template tag', () => { const agent = mapGenerateConfigToAgent(bmaConfig); @@ -41,7 +54,7 @@ describe('BMA runtime spec', () => { expect(agent.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 1800, maxLifetime: 28800 }); expect(agent.filesystemConfigurations).toBeUndefined(); expect(agent.envVars).toBeUndefined(); - expect(agent.additionalPolicies).toEqual(['bma-acr-policy.json']); + expect(agent.additionalPolicies).toEqual(['policies/bma-acr-policy.json']); expect(agent.tags).toEqual({ 'agentcore:template': 'BedrockManagedAgents' }); }); @@ -95,10 +108,8 @@ describe('BMA runtime spec', () => { } }); - it('policy file grants only RegisterEnvironment and ConnectEnvironment in any partition', () => { - const policy = JSON.parse(readFileSync(join(TEMPLATE_DIR, 'bma-acr-policy.json'), 'utf-8')) as { - Statement: { Action: string[]; Resource: string }[]; - }; + it('ACR policy grants only RegisterEnvironment and ConnectEnvironment in any partition', () => { + const policy = readPolicy('bma-acr-policy.json'); expect(policy.Statement.flatMap(s => s.Action)).toEqual([ 'bedrock-mantle:RegisterEnvironment', @@ -106,6 +117,39 @@ describe('BMA runtime spec', () => { ]); expect(policy.Statement.map(s => s.Resource)).toEqual(['arn:*:bedrock-mantle:*:*:project/*']); }); + + it('session trust lets only BMA assume the role, for sessions and projects in the account and the Region', () => { + const trust = readPolicy('bma-session-trust.json'); + + expect(trust.Statement).toHaveLength(1); + const [statement] = trust.Statement; + expect(statement!.Principal).toEqual({ Service: 'bedrock-mantle.amazonaws.com' }); + expect(statement!.Action).toBe('sts:AssumeRole'); + expect(statement!.Condition).toEqual({ + StringEquals: { 'aws:SourceAccount': '${AWS::AccountId}' }, + ArnLike: { + 'aws:SourceArn': [ + 'arn:${AWS::Partition}:bedrock-mantle:${AWS::Region}:${AWS::AccountId}:session/*', + 'arn:${AWS::Partition}:bedrock-mantle:${AWS::Region}:${AWS::AccountId}:project/*', + ], + }, + }); + }); + + it('session policy grants inference, and the ACR and Gateway calls in the account and the Region', () => { + const policy = readPolicy('bma-session-policy.json'); + + expect(policy.Statement.flatMap(s => s.Action)).toEqual([ + 'bedrock-mantle:CreateInference', + 'bedrock-agentcore:InvokeAgentRuntime', + 'bedrock-agentcore:StopRuntimeSession', + 'bedrock-agentcore:InvokeGateway', + ]); + const scoped = policy.Statement.flatMap(s => [s.Resource].flat()).filter(r => r !== '*'); + for (const resource of scoped) { + expect(resource).toMatch(/^arn:\$\{AWS::Partition\}:bedrock-agentcore:\$\{AWS::Region\}:\$\{AWS::AccountId\}:/); + } + }); }); describe('BmaRenderer', () => { @@ -135,7 +179,15 @@ describe('BmaRenderer', () => { const agentDir = join(outputDir, 'app', 'BmaEnv'); expect(readFileSync(join(agentDir, 'Dockerfile'), 'utf-8')).toContain('install-codex.sh'); expect(existsSync(join(agentDir, '.dockerignore'))).toBe(true); - expect(existsSync(join(agentDir, 'bma-acr-policy.json'))).toBe(true); + }); + + it('copies the policy files without changes, so that the client substitutes the placeholders', () => { + const agentDir = join(outputDir, 'app', 'BmaEnv'); + for (const file of ['bma-acr-policy.json', 'bma-session-trust.json', 'bma-session-policy.json']) { + const path = join('policies', file); + expect(readFileSync(join(agentDir, path), 'utf-8')).toBe(readFileSync(join(TEMPLATE_DIR, path), 'utf-8')); + } + expect(existsSync(join(agentDir, 'bma-acr-policy.json'))).toBe(false); }); it('runs the server with OpenTelemetry and writes its dependency', () => { @@ -153,7 +205,7 @@ describe('BmaRenderer', () => { expect(pyproject).toContain('"bedrock-agentcore",'); // The client dependencies are a dev group, which `uv sync --no-dev` leaves out of the image. expect(pyproject).toContain( - '[dependency-groups]\ndev = [\n "aws-bedrock-token-generator>=1.1.0",\n "openai>=3.16.2",\n]\n' + '[dependency-groups]\ndev = [\n "aws-bedrock-token-generator>=1.1.0",\n "boto3>=1.43.0",\n "openai>=3.16.2",\n]\n' ); expect(readFileSync(join(agentDir, 'client.py'), 'utf-8')).not.toContain('# /// script'); expect(mapGenerateConfigToAgent(bmaConfig).instrumentation).toBeUndefined(); @@ -218,6 +270,14 @@ describe('BmaRenderer', () => { expect(main).not.toContain('BMA_REGION'); }); + it('writes a client that creates the session role and gives it to CreateAgentSession', () => { + const client = readFileSync(join(outputDir, 'app', 'BmaEnv', 'client.py'), 'utf-8'); + expect(client).toContain('"--role-arn"'); + expect(client).toContain('args.role_arn = session_role(args.runtime)'); + expect(client).toContain('POLICIES / name'); + expect(client).toContain('extra_body={"role_arn": args.role_arn}'); + }); + it('writes a client that uses a workspace in the home directory', () => { const client = readFileSync(join(outputDir, 'app', 'BmaEnv', 'client.py'), 'utf-8'); expect(client).toContain('WORKSPACE_DIRECTORY = "/home/app/workspace"'); diff --git a/src/cli/templates/bmaProfile.ts b/src/cli/templates/bmaProfile.ts index 9c2abe84bc..c17e33f854 100644 --- a/src/cli/templates/bmaProfile.ts +++ b/src/cli/templates/bmaProfile.ts @@ -16,7 +16,7 @@ export const BMA_TEMPLATE_PROFILE: TemplateProfile = { dockerfile: 'Dockerfile', idleRuntimeSessionTimeout: 1800, maxLifetime: 28800, - additionalPolicies: ['bma-acr-policy.json'], + additionalPolicies: ['policies/bma-acr-policy.json'], tags: { 'agentcore:template': 'BedrockManagedAgents' }, }, };