From ca17105f60162f6e06e6c4bfa430a2c70e4cda39 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 5 Aug 2026 15:44:28 +0000 Subject: [PATCH 01/15] feat(gateway): add create commands --- bun.lock | 56 +- package.json | 2 +- src/core/gateway.create.test.ts | 360 +++++++++++ src/core/gateway.tsx | 212 ++++++- src/core/gatewayExecutionRole.test.ts | 593 +++++++++++++++++++ src/core/gatewayExecutionRole.ts | 296 +++++++++ src/core/gatewayExecutionRolePolicy.ts | 505 ++++++++++++++++ src/core/index.tsx | 2 +- src/handlers/gateway/create/index.tsx | 130 ++++ src/handlers/gateway/gateway.create.test.tsx | 252 ++++++++ src/handlers/gateway/gateway.test.tsx | 7 +- src/handlers/gateway/index.tsx | 2 + src/handlers/gateway/mutations.ts | 102 ++++ src/handlers/gateway/rule/create/index.tsx | 51 ++ src/handlers/gateway/rule/index.tsx | 2 + src/handlers/gateway/target/create/index.tsx | 112 ++++ src/handlers/gateway/target/index.tsx | 2 + src/handlers/gateway/types.tsx | 30 + src/handlers/utils.test.tsx | 15 +- src/handlers/utils.tsx | 21 + src/testing/TestCoreClient.tsx | 59 +- 21 files changed, 2774 insertions(+), 37 deletions(-) create mode 100644 src/core/gateway.create.test.ts create mode 100644 src/core/gatewayExecutionRole.test.ts create mode 100644 src/core/gatewayExecutionRole.ts create mode 100644 src/core/gatewayExecutionRolePolicy.ts create mode 100644 src/handlers/gateway/create/index.tsx create mode 100644 src/handlers/gateway/gateway.create.test.tsx create mode 100644 src/handlers/gateway/mutations.ts create mode 100644 src/handlers/gateway/rule/create/index.tsx create mode 100644 src/handlers/gateway/target/create/index.tsx diff --git a/bun.lock b/bun.lock index a61c220ed..0784241cd 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "agentcore", "dependencies": { "@aws-sdk/client-bedrock-agentcore": "^3.1092.0", - "@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0", + "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", "@aws-sdk/client-iam": "^3.1080.0", "@smithy/core": "3.29.3", "@tanstack/react-query": "^5.101.2", @@ -43,7 +43,7 @@ "@aws-sdk/client-bedrock-agentcore": ["@aws-sdk/client-bedrock-agentcore@3.1094.0", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/credential-provider-node": "^3.972.71", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/node-http-handler": "^4.9.6", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-K8+YaYFyaVJvidwVSnmXlHjpOel/qz4ii2TZY2shXbkMoQxftjHuiXlplKeb5pWhzdS092q6YqPR5CZrMH90Ug=="], - "@aws-sdk/client-bedrock-agentcore-control": ["@aws-sdk/client-bedrock-agentcore-control@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/credential-provider-node": "^3.972.68", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ZCWITtXgDZG1g8NfTZVMTIwH/NR9eifXRW1g5+d4Jb93MmwbJWLWRSd0xqxgywMay0sqhNOKXHE4nQRIf9glag=="], + "@aws-sdk/client-bedrock-agentcore-control": ["@aws-sdk/client-bedrock-agentcore-control@3.1102.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/credential-provider-node": "^3.972.77", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6HjZkg14iiJr8VinzAKy5XRJe5JLrDg9USBia2UZZAg8leGff2/+cyH0y4ZYQqRe1f1TaMZfecNSQLCy3XInjQ=="], "@aws-sdk/client-iam": ["@aws-sdk/client-iam@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/credential-provider-node": "^3.972.68", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-S2UBBQnPMTREF52WPz7yabvags3rPYLOPwq8LOPNJIekyAIqUwtGN46z2tqGNr+NyGz9cpEI+3brJgkWKQGNIQ=="], @@ -345,17 +345,15 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core": ["@aws-sdk/core@3.975.2", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@aws-sdk/xml-builder": "^3.972.35", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.3", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-iyeXwziyjJpixq5OmhsIyrSWx8vwcI7gDo4yRUC3EP7NQtOo9iAJiIEc3G+/HkhtNXqOhofiCK7Lc34Sq+fJWg=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core": ["@aws-sdk/core@3.977.5", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-O5otOc1c6UZh5HsHAaPdYBcUUR9HL6mtnKqvc8nxN/CKDGUBUpsdh0q8K04Uz/dd1i0TaGyIQuQNqoO7+ad2TQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.68", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.58", "@aws-sdk/credential-provider-http": "^3.972.60", "@aws-sdk/credential-provider-ini": "^3.973.2", "@aws-sdk/credential-provider-process": "^3.972.58", "@aws-sdk/credential-provider-sso": "^3.973.2", "@aws-sdk/credential-provider-web-identity": "^3.972.64", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-4akjzW9CjorByYfqXBXmYUh/h7Io3U4DtVgGGh9TQraZ7ZlyJqNyHwDRGiUFnHD+BTOeTbCesCa4sJaK7BGZ7A=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.77", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.66", "@aws-sdk/credential-provider-http": "^3.972.68", "@aws-sdk/credential-provider-ini": "^3.973.11", "@aws-sdk/credential-provider-process": "^3.972.66", "@aws-sdk/credential-provider-sso": "^3.973.10", "@aws-sdk/credential-provider-web-identity": "^3.972.72", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-l4nitYCN/Ls57vtUfdextCjTjW41JD7lQiAnuR0RTbdByFc/6OmEAzwGd+lrp6CUtiXGQL1FCaYiamfHASrwBw=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/types": ["@aws-sdk/types@3.974.1", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-W0IQZR0eaBqlBFIIofMapaWkw1W0U+Xi4dvW+BqwmCEMd8Ng2U6IhkxuPSjMVnR8klLjfuS9PeZWUl1N6UaZdg=="], + "@aws-sdk/client-bedrock-agentcore-control/@smithy/core": ["@smithy/core@3.31.1", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg=="], - "@aws-sdk/client-bedrock-agentcore-control/@smithy/core": ["@smithy/core@3.29.3", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A=="], + "@aws-sdk/client-bedrock-agentcore-control/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.13", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A=="], - "@aws-sdk/client-bedrock-agentcore-control/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.5", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-SuqeisTyPoiIPtIYru/sGxGyXzmZ+8nnFOhC+qRPglt06Ebd1yH//CDltZB2J/3WBNVhwfUaZ0EtHB3cm2X32g=="], - - "@aws-sdk/client-bedrock-agentcore-control/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.5", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw=="], + "@aws-sdk/client-bedrock-agentcore-control/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.13", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw=="], "@aws-sdk/client-iam/@aws-sdk/core": ["@aws-sdk/core@3.975.2", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@aws-sdk/xml-builder": "^3.972.35", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.3", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-iyeXwziyjJpixq5OmhsIyrSWx8vwcI7gDo4yRUC3EP7NQtOo9iAJiIEc3G+/HkhtNXqOhofiCK7Lc34Sq+fJWg=="], @@ -379,23 +377,23 @@ "react-devtools-core/ws": ["ws@7.5.12", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-1xGnbYN3zbog9CwuNDQULNRrTCLIn46/WmpR1f0w6PsCYQHkylZr5vkd6kfMZYV6pRnQkcPNRyiA8LsrNKyhpg=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.35", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pXzaWe3evZhjxDXAlMnqISe/XefTCGwBJG4nFTXaWSgAnMkqPEhxEPqJNhhpGesEvKFhvNpnozJJ4GTL11bRYw=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.58", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-vyGtvK1rY940eq7JT0yIGKuZ+2kpPSJcHibSvGlit5oiMFDamzC7cxBGLl4FLnd6suihMXDI2FSF2dL6TmBqPA=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.66", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bOzP2+zdJ0XrghywB4FaJXtGZCx9yS0AGps+VJ5yEgg30wVyHNmVDBwVDXcRypzQY5iLGCS3NSn0nsuISqjFCQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-g9b9YzDrD5pcKiPBJfCSXRfFMrA39eR0guUhZ5SRm+7vMAVc43+effxbcamxBjSd5bUhrdKo5te/yQuWurLXLA=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.68", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lkunS8X+H6V76WE+t/uGQm/U8v0JXK5mLfNFTUAMlE1kqaCjwlmqKJrgCVtqjK/vqnlrSWsLK4Lr4NANBWlfTQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.2", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/credential-provider-env": "^3.972.58", "@aws-sdk/credential-provider-http": "^3.972.60", "@aws-sdk/credential-provider-login": "^3.972.64", "@aws-sdk/credential-provider-process": "^3.972.58", "@aws-sdk/credential-provider-sso": "^3.973.2", "@aws-sdk/credential-provider-web-identity": "^3.972.64", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Yr7yxNyQ8aHt9Ww0RPFUZx+xiem+vl7vuwhP0tniTijoesJNV5jou9HCgVpI0GEPAF+89TkOvilE5uRrZJnjaw=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.11", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/credential-provider-env": "^3.972.66", "@aws-sdk/credential-provider-http": "^3.972.68", "@aws-sdk/credential-provider-login": "^3.972.73", "@aws-sdk/credential-provider-process": "^3.972.66", "@aws-sdk/credential-provider-sso": "^3.973.10", "@aws-sdk/credential-provider-web-identity": "^3.972.72", "@aws-sdk/nested-clients": "^3.997.40", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-KoDEolYtLHG/8C+IiZpXbJWyBOMkrHV+j66Kb9PBXmLv5euGb7aELvuCmLenoGAV6gBW2wM7TsG/1e5iulH4kA=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.58", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-1nYitRCaDmXWUrpBJt6WlcGjLx1JVsMY8rlYuHHsTYTSaYikbixYdQSyINN2VYq1F798uTO9qHAzytL25M8g3A=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.66", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YOnX6bIhdjx0QfaENu2PB0eFm5MEc9ft8XNGQ+NxMfeLSq9aE+XjWCwDupEnV4UWv5ZFpBLJbTREIx7KNOoqpQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.2", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/token-providers": "3.1087.0", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pjMLaLU/JZi5lVfmR14V1OZqRBTuMHf6AwGNZA0K9hK+JKtO3jcLBarfD8iq5oc8cSowvc/9R32sqMVXZPo6xQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.10", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/nested-clients": "^3.997.40", "@aws-sdk/token-providers": "3.1102.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-IsXnQ35j5VE+3ZK6aIhT5ypB+Jim3zRwVz0nYuVwyBKZyu/SYx+O2/LQpng8c2EiuwyqceabsDlYrICHDlJPsA=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.64", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-7Buc7p0OvDHW7iBsu4b+YdS0WnaFBDGKDfbVQqaac9dkWiSiUtIoarBDsA1RmOVXZijaZJDoHJFIQiicQvWRlQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/nested-clients": "^3.997.40", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-nj9Zlsy7ya+fy+jhWTJwgfr7YdtDM4xHyZvgKuftuny0UgROVx9lxwvsWJSLvpKk4lig0m0tHng3k1fEnt0LeA=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.8", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.16", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w=="], "@aws-sdk/client-iam/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.35", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pXzaWe3evZhjxDXAlMnqISe/XefTCGwBJG4nFTXaWSgAnMkqPEhxEPqJNhhpGesEvKFhvNpnozJJ4GTL11bRYw=="], @@ -421,15 +419,15 @@ "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.64", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YQoSI4d6kXvoenoG/0Jv/PqaAuukHzGmGXGyHBQYeEUNsYovlNAn/Sw1wp/WQbhcQ3HsEMGgjEahvD3igz6ecQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/nested-clients": "^3.997.40", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-tjsxMkTAFkmiV9ycmymapb9nLECWVOwFs0bZMQ9gB9bnbY8/HwfukHZlWbXZZp7qkPU6EXAfOcMm3DioFFEywA=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.40", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-hEdHT0PBR4fkGxWhwKG5EtEYKnAM7HKkp0vD10ufk4YcXejH4r4q6G/XhPzjUc6Yxo5kBS2vHg7llj4ViR9VTQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.40", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-hEdHT0PBR4fkGxWhwKG5EtEYKnAM7HKkp0vD10ufk4YcXejH4r4q6G/XhPzjUc6Yxo5kBS2vHg7llj4ViR9VTQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-umM+qNq16f2fH+VLM5MqXW4ORNQAjk+TOSto73xbUHcKaU41L48j786r3UWQYlejeJk37NlvRYgxBT+MBkfaYQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1102.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/nested-clients": "^3.997.40", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Ua700vVvM1q105yABSUQWkCK6FeTrNfU6ORGetJe5BzkZWY7QhkF7SVTOlmDGWRDNd6jbyY0Dv5e+E4bMBEmLg=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.40", "", { "dependencies": { "@aws-sdk/core": "^3.977.5", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-hEdHT0PBR4fkGxWhwKG5EtEYKnAM7HKkp0vD10ufk4YcXejH4r4q6G/XhPzjUc6Yxo5kBS2vHg7llj4ViR9VTQ=="], "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.64", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YQoSI4d6kXvoenoG/0Jv/PqaAuukHzGmGXGyHBQYeEUNsYovlNAn/Sw1wp/WQbhcQ3HsEMGgjEahvD3igz6ecQ=="], @@ -445,11 +443,11 @@ "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="], "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], @@ -457,11 +455,11 @@ "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ=="], - "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ=="], "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ=="], diff --git a/package.json b/package.json index c2fa1da53..19a1e1e5f 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ }, "dependencies": { "@aws-sdk/client-bedrock-agentcore": "^3.1092.0", - "@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0", + "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", "@aws-sdk/client-iam": "^3.1080.0", "@smithy/core": "3.29.3", "@tanstack/react-query": "^5.101.2", diff --git a/src/core/gateway.create.test.ts b/src/core/gateway.create.test.ts new file mode 100644 index 000000000..11c2d9e09 --- /dev/null +++ b/src/core/gateway.create.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, test } from "bun:test"; +import { + CreateGatewayCommand, + CreateGatewayRuleCommand, + CreateGatewayTargetCommand, + GetGatewayTargetCommand, + GetGatewayCommand, + GetOauth2CredentialProviderCommand, + ListGatewayTargetsCommand, + type BedrockAgentCoreControlClient, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; +import { + CreateRoleCommand, + GetRoleCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { createSilentLogger } from "../testing"; +import { + gatewayExecutionRoleName, + GATEWAY_EXECUTION_POLICY_NAME, + GATEWAY_ROLE_MANAGED_BY_TAG, + GATEWAY_ROLE_RESOURCE_TYPE_TAG, +} from "./gatewayExecutionRole"; +import { CoreClient } from "./index"; + +type SentCommand = { input: unknown; constructor: { name: string } }; + +function createCore( + send: (command: SentCommand) => Promise, + sendIam: (command: SentCommand) => Promise = async (command) => { + throw new Error(`unexpected ${command.constructor.name}`); + }, +): CoreClient { + return new CoreClient({ + createControlClient: () => ({ send }) as unknown as BedrockAgentCoreControlClient, + createDataClient: () => ({}) as BedrockAgentCoreClient, + createIamClient: () => ({ send: sendIam }) as unknown as IAMClient, + logger: createSilentLogger(), + }); +} + +function noSuchEntity(): Error { + const error = new Error("not found"); + error.name = "NoSuchEntityException"; + return error; +} + +describe("Gateway create Core command mapping", () => { + test("maps MCP and HTTP protocols to their SDK representations", async () => { + const sent: SentCommand[] = []; + const core = createCore(async (command) => { + sent.push(command); + return {}; + }); + + await core.gateway.createGateway( + { + name: "mcp-gateway", + roleArn: "arn:aws:iam::123456789012:role/gateway", + protocol: "mcp", + authorizerType: "AWS_IAM", + }, + { region: "us-west-2" }, + ); + await core.gateway.createGateway( + { + name: "http-gateway", + roleArn: "arn:aws:iam::123456789012:role/gateway", + protocol: "http", + authorizerType: "AWS_IAM", + }, + { region: "us-west-2" }, + ); + + expect(sent[0]).toBeInstanceOf(CreateGatewayCommand); + expect(sent[0]!.input).toEqual({ + name: "mcp-gateway", + roleArn: "arn:aws:iam::123456789012:role/gateway", + protocolType: "MCP", + authorizerType: "AWS_IAM", + }); + expect(sent[1]).toBeInstanceOf(CreateGatewayCommand); + expect(sent[1]!.input).toEqual({ + name: "http-gateway", + roleArn: "arn:aws:iam::123456789012:role/gateway", + authorizerType: "AWS_IAM", + }); + }); + + test("provisions and tightens a default role around Gateway creation", async () => { + const operations: SentCommand[] = []; + const policyNames: string[] = []; + const roleName = gatewayExecutionRoleName("orders", "us-west-2"); + const roleArn = `arn:aws:iam::123456789012:role/${roleName}`; + const gatewayArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123"; + const core = createCore( + async (command) => { + operations.push(command); + return { gatewayId: "orders-abc123", gatewayArn }; + }, + async (command) => { + operations.push(command); + if (command instanceof GetRoleCommand) throw noSuchEntity(); + if (command instanceof CreateRoleCommand) { + return { + Role: { + Path: "/", + RoleName: roleName, + RoleId: "AROATEST", + Arn: roleArn, + CreateDate: new Date("2026-08-03T00:00:00Z"), + Tags: [GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG], + }, + }; + } + if (command instanceof PutRolePolicyCommand) { + policyNames.push((command.input as { PolicyName: string }).PolicyName); + return {}; + } + return {}; + }, + ); + + await core.gateway.createGateway( + { + name: "orders", + protocol: "http", + authorizerType: "AWS_IAM", + policyEngineConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", + mode: "ENFORCE", + }, + }, + { region: "us-west-2" }, + ); + + expect(operations.map((command) => command.constructor.name)).toEqual([ + "GetRoleCommand", + "CreateRoleCommand", + "UpdateAssumeRolePolicyCommand", + "PutRolePolicyCommand", + "CreateGatewayCommand", + "PutRolePolicyCommand", + "UpdateAssumeRolePolicyCommand", + ]); + expect(operations[4]).toBeInstanceOf(CreateGatewayCommand); + expect(operations[4]!.input).toEqual({ + name: "orders", + roleArn, + authorizerType: "AWS_IAM", + policyEngineConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", + mode: "ENFORCE", + }, + }); + expect(policyNames).toEqual([GATEWAY_EXECUTION_POLICY_NAME, GATEWAY_EXECUTION_POLICY_NAME]); + }); + + test("maps Target and Rule create inputs directly", async () => { + const sent: SentCommand[] = []; + const core = createCore(async (command) => { + sent.push(command); + if (command instanceof GetGatewayCommand) { + return { + gatewayId: "gateway-1", + name: "gateway", + roleArn: "arn:aws:iam::123456789012:role/customer-managed", + }; + } + return {}; + }); + const targetConfiguration = { + http: { + agentcoreRuntime: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/runtime-1", + }, + }, + } as const; + const actions = [{ routeToTarget: { staticRoute: { targetName: "runtime-target" } } }]; + + await core.gateway.createGatewayTarget( + { + gatewayIdentifier: "gateway-1", + targetConfiguration, + }, + { region: "us-west-2" }, + ); + await core.gateway.createGatewayRule( + { + gatewayIdentifier: "gateway-1", + priority: 10, + actions, + }, + { region: "us-west-2" }, + ); + + expect(sent[0]).toBeInstanceOf(GetGatewayCommand); + expect(sent[1]).toBeInstanceOf(CreateGatewayTargetCommand); + expect(sent[1]!.input).toEqual({ + gatewayIdentifier: "gateway-1", + targetConfiguration, + }); + expect(sent[2]).toBeInstanceOf(CreateGatewayRuleCommand); + expect(sent[2]!.input).toEqual({ + gatewayIdentifier: "gateway-1", + priority: 10, + actions, + }); + }); + + test("prepares the union of every paginated Target and deduplicates provider lookups", async () => { + const operations: SentCommand[] = []; + const roleName = gatewayExecutionRoleName("orders", "us-west-2"); + const roleArn = `arn:aws:iam::123456789012:role/${roleName}`; + const oauthProviderArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + + "token-vault/default/oauth2credentialprovider/shared"; + const workloadIdentityArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + + "workload-identity-directory/default/workload-identity/orders-identity"; + let storedPolicy: string | undefined; + const core = createCore( + async (command) => { + operations.push(command); + if (command instanceof GetGatewayCommand) { + return { + gatewayId: "gateway-1", + gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", + name: "orders", + roleArn, + workloadIdentityDetails: { workloadIdentityArn }, + }; + } + if (command instanceof ListGatewayTargetsCommand) { + return command.input.nextToken + ? { items: [{ targetId: "oauth-target" }] } + : { items: [{ targetId: "lambda-target" }], nextToken: "page-2" }; + } + if (command instanceof GetGatewayTargetCommand) { + if (command.input.targetId === "lambda-target") { + return { + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: "arn:aws:lambda:us-west-2:123456789012:function:existing", + toolSchema: { inlinePayload: [] }, + }, + }, + }, + }; + } + return { + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://existing.example.test/mcp" } }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "OAUTH", + credentialProvider: { + oauthCredentialProvider: { + providerArn: oauthProviderArn, + scopes: ["read"], + }, + }, + }, + ], + }; + } + if (command instanceof GetOauth2CredentialProviderCommand) { + return { + clientSecretArn: { + secretArn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:shared", + }, + }; + } + if (command instanceof CreateGatewayTargetCommand) { + return { targetId: "new-target", status: "CREATING" }; + } + throw new Error(`unexpected ${command.constructor.name}`); + }, + async (command) => { + operations.push(command); + if (command instanceof GetRoleCommand) { + return { + Role: { + Path: "/", + RoleName: roleName, + RoleId: "AROATEST", + Arn: roleArn, + CreateDate: new Date("2026-08-03T00:00:00Z"), + Tags: [GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG], + }, + }; + } + if (command instanceof PutRolePolicyCommand) { + storedPolicy = (command.input as { PolicyDocument: string }).PolicyDocument; + expect((command.input as { PolicyName: string }).PolicyName).toBe( + GATEWAY_EXECUTION_POLICY_NAME, + ); + return {}; + } + throw new Error(`unexpected ${command.constructor.name}`); + }, + ); + + await core.gateway.createGatewayTarget( + { + gatewayIdentifier: "gateway-1", + name: "new-target", + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://new.example.test/mcp" } }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "OAUTH", + credentialProvider: { + oauthCredentialProvider: { + providerArn: oauthProviderArn, + scopes: ["write"], + }, + }, + }, + ], + }, + { region: "us-west-2" }, + ); + + const names = operations.map((command) => command.constructor.name); + expect(names).toEqual([ + "GetGatewayCommand", + "GetRoleCommand", + "ListGatewayTargetsCommand", + "GetGatewayTargetCommand", + "ListGatewayTargetsCommand", + "GetGatewayTargetCommand", + "GetOauth2CredentialProviderCommand", + "PutRolePolicyCommand", + "CreateGatewayTargetCommand", + ]); + expect( + operations.filter((command) => command instanceof GetOauth2CredentialProviderCommand), + ).toHaveLength(1); + expect(names.indexOf("PutRolePolicyCommand")).toBeLessThan( + names.indexOf("CreateGatewayTargetCommand"), + ); + const statements = JSON.parse(storedPolicy!).Statement as { + Sid: string; + Resource: string | string[]; + }[]; + expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetLambda")?.Resource).toEqual([ + "arn:aws:lambda:us-west-2:123456789012:function:existing", + "arn:aws:lambda:us-west-2:123456789012:function:existing:*", + ]); + expect( + statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetCredentialSecrets")?.Resource, + ).toEqual(["arn:aws:secretsmanager:us-west-2:123456789012:secret:shared"]); + }); +}); diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 0e4808176..1582332c3 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -1,10 +1,19 @@ import { + CreateGatewayCommand, + CreateGatewayRuleCommand, + CreateGatewayTargetCommand, GetGatewayCommand, GetGatewayRuleCommand, GetGatewayTargetCommand, + GetApiKeyCredentialProviderCommand, + GetOauth2CredentialProviderCommand, ListGatewayRulesCommand, ListGatewaysCommand, ListGatewayTargetsCommand, + type BedrockAgentCoreControlClient, + type CreateGatewayResponse, + type CreateGatewayRuleResponse, + type CreateGatewayTargetResponse, type GetGatewayResponse, type GetGatewayRuleResponse, type GetGatewayTargetResponse, @@ -12,13 +21,57 @@ import { type ListGatewaysResponse, type ListGatewayTargetsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import type { CoreGatewayClient } from "../handlers/gateway/types"; +import { + buildGatewayCreateRequest, + validateGatewayCreateInput, + validateGatewayTargetCreateInput, +} from "../handlers/gateway/mutations"; +import type { + CoreGatewayClient, + CreateGatewayInput, + CreateGatewayRuleInput, + CreateGatewayTargetInput, +} from "../handlers/gateway/types"; import type { AwsClients, CoreOptions } from "./types"; +import { + ensureGatewayExecutionRole, + getManagedGatewayTargetExecutionRole, + retryWhileGatewayRoleChangesPropagate, + type GatewayTargetRoleConfiguration, +} from "./gatewayExecutionRole"; import { toClientConfig } from "./utils"; export class GatewayClient implements CoreGatewayClient { constructor(private readonly clients: AwsClients) {} + async createGateway( + input: CreateGatewayInput, + options: CoreOptions, + ): Promise { + validateGatewayCreateInput(input); + const control = this.clients.control(toClientConfig(options)); + if (input.roleArn) { + return control.send( + new CreateGatewayCommand(buildGatewayCreateRequest({ ...input, roleArn: input.roleArn })), + ); + } + + const iam = this.clients.iam({ region: options.region }); + const role = await ensureGatewayExecutionRole(iam, input.name!, options.region, input); + await role.updatePolicy(); + const response = await retryWhileGatewayRoleChangesPropagate(() => + control.send( + new CreateGatewayCommand(buildGatewayCreateRequest({ ...input, roleArn: role.roleArn })), + ), + ); + if (!response.gatewayArn) { + throw new Error("CreateGateway response did not include the Gateway ARN"); + } + await role.updatePolicy(response.gatewayArn); + await role.updateTrust(response.gatewayArn); + return response; + } + async getGateway(id: string, options: CoreOptions): Promise { return this.clients .control(toClientConfig(options)) @@ -63,6 +116,33 @@ export class GatewayClient implements CoreGatewayClient { ); } + async createGatewayTarget( + input: CreateGatewayTargetInput, + options: CoreOptions, + ): Promise { + const request = validateGatewayTargetCreateInput(input); + const control = this.clients.control(toClientConfig(options)); + const gateway = await control.send( + new GetGatewayCommand({ gatewayIdentifier: request.gatewayIdentifier }), + ); + const iam = this.clients.iam({ region: options.region }); + const role = await getManagedGatewayTargetExecutionRole(iam, gateway, options.region); + if (!role) { + return control.send(new CreateGatewayTargetCommand(request)); + } + + const configurations = await listTargetRoleConfigurations( + control, + request.gatewayIdentifier!, + undefined, + request, + ); + await role.updatePolicy(configurations); + return retryWhileGatewayRoleChangesPropagate(() => + control.send(new CreateGatewayTargetCommand(request)), + ); + } + async getGatewayRule( gatewayId: string, ruleId: string, @@ -90,4 +170,134 @@ export class GatewayClient implements CoreGatewayClient { }), ); } + + async createGatewayRule( + input: CreateGatewayRuleInput, + options: CoreOptions, + ): Promise { + return this.clients.control(toClientConfig(options)).send(new CreateGatewayRuleCommand(input)); + } +} + +async function listTargetRoleConfigurations( + control: BedrockAgentCoreControlClient, + gatewayId: string, + excludedTargetId?: string, + replacement?: TargetRoleConfigurationSource, +): Promise { + const configurations: GatewayTargetRoleConfiguration[] = []; + const providerSecrets = new Map>(); + let nextToken: string | undefined; + do { + const page = await control.send( + new ListGatewayTargetsCommand({ + gatewayIdentifier: gatewayId, + nextToken, + maxResults: 100, + }), + ); + const targets = await Promise.all( + (page.items ?? []) + .filter(({ targetId }) => targetId && targetId !== excludedTargetId) + .map(({ targetId }) => + control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: gatewayId, + targetId, + }), + ), + ), + ); + configurations.push( + ...(await Promise.all( + targets.map((target) => targetRoleConfiguration(control, target, providerSecrets)), + )), + ); + nextToken = page.nextToken; + } while (nextToken); + + if (replacement) { + configurations.push(await targetRoleConfiguration(control, replacement, providerSecrets)); + } + return configurations; +} + +type TargetRoleConfigurationSource = Pick< + CreateGatewayTargetInput, + "targetConfiguration" | "credentialProviderConfigurations" +>; + +async function targetRoleConfiguration( + control: BedrockAgentCoreControlClient, + target: TargetRoleConfigurationSource, + providerSecrets = new Map>(), +): Promise { + return { + targetConfiguration: target.targetConfiguration, + credentialProviderConfigurations: target.credentialProviderConfigurations, + credentialProviderSecretArns: await credentialProviderSecretArns( + control, + target.credentialProviderConfigurations, + providerSecrets, + ), + }; +} + +async function credentialProviderSecretArns( + control: BedrockAgentCoreControlClient, + configurations: CreateGatewayTargetInput["credentialProviderConfigurations"], + cache: Map>, +): Promise { + const providers = new Map(); + for (const configuration of configurations ?? []) { + const provider = configuration.credentialProvider; + if ( + configuration.credentialProviderType === "OAUTH" && + provider && + "oauthCredentialProvider" in provider && + provider.oauthCredentialProvider?.providerArn + ) { + providers.set(provider.oauthCredentialProvider.providerArn, "oauth"); + } else if ( + configuration.credentialProviderType === "API_KEY" && + provider && + "apiKeyCredentialProvider" in provider && + provider.apiKeyCredentialProvider?.providerArn + ) { + providers.set(provider.apiKeyCredentialProvider.providerArn, "apiKey"); + } + } + + const secrets = await Promise.all( + [...providers].map(async ([providerArn, type]) => { + const key = `${type}:${providerArn}`; + let lookup = cache.get(key); + if (!lookup) { + lookup = lookupCredentialProviderSecret(control, providerArn, type); + cache.set(key, lookup); + } + return lookup; + }), + ); + return secrets.filter((secretArn): secretArn is string => Boolean(secretArn)); +} + +async function lookupCredentialProviderSecret( + control: BedrockAgentCoreControlClient, + providerArn: string, + type: "oauth" | "apiKey", +): Promise { + const name = credentialProviderName(providerArn); + if (type === "oauth") { + const provider = await control.send(new GetOauth2CredentialProviderCommand({ name })); + return provider.clientSecretArn?.secretArn; + } + const provider = await control.send(new GetApiKeyCredentialProviderCommand({ name })); + return provider.apiKeySecretArn?.secretArn; +} + +function credentialProviderName(providerArn: string): string { + const name = providerArn.split("/").at(-1); + if (!name) throw new Error(`Invalid credential provider ARN "${providerArn}"`); + return name; } diff --git a/src/core/gatewayExecutionRole.test.ts b/src/core/gatewayExecutionRole.test.ts new file mode 100644 index 000000000..bf164743f --- /dev/null +++ b/src/core/gatewayExecutionRole.test.ts @@ -0,0 +1,593 @@ +import { describe, expect, test } from "bun:test"; +import { + CreateRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + PutRolePolicyCommand, + UpdateAssumeRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { + ensureGatewayExecutionRole, + gatewayExecutionRoleName, + GATEWAY_EXECUTION_POLICY_NAME, + GATEWAY_ROLE_MANAGED_BY_TAG, + GATEWAY_ROLE_RESOURCE_TYPE_TAG, + getManagedGatewayTargetExecutionRole, + retryWhileGatewayRoleChangesPropagate, + type GatewayTargetRoleConfiguration, +} from "./gatewayExecutionRole"; + +type SentCommand = { + input: Record; + constructor: { name: string }; +}; + +const ACCOUNT_ID = "123456789012"; +const REGION = "us-west-2"; +const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123"; +const POLICY_ENGINE_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders-policy"; +const INTERCEPTOR_ARN = "arn:aws:lambda:us-west-2:123456789012:function:interceptor"; +const TRANSFORM_ARN = "arn:aws:lambda:us-west-2:123456789012:function:transform"; + +function noSuchEntity(): Error { + const error = new Error("not found"); + error.name = "NoSuchEntityException"; + return error; +} + +function alreadyExists(): Error { + const error = new Error("already exists"); + error.name = "EntityAlreadyExistsException"; + return error; +} + +function ownedRole(roleName: string) { + return { + Path: "/", + RoleName: roleName, + RoleId: "AROATEST", + Arn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, + CreateDate: new Date("2026-08-03T00:00:00Z"), + Tags: [GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG], + }; +} + +function fakeIam(send: (command: SentCommand) => Promise): IAMClient { + return { send } as unknown as IAMClient; +} + +function requiredString(value: string | undefined): string { + expect(value).toBeString(); + return value!; +} + +function policyStatements(policyDocument: string): Record[] { + return JSON.parse(policyDocument).Statement; +} + +describe("Gateway execution role ownership", () => { + test("derives deterministic collision-resistant names within IAM limits", () => { + const longName = "A".repeat(48); + + expect(gatewayExecutionRoleName(longName, REGION).length).toBeLessThanOrEqual(64); + expect(gatewayExecutionRoleName("orders", REGION)).toBe( + gatewayExecutionRoleName("orders", REGION), + ); + expect(gatewayExecutionRoleName("orders", REGION)).not.toBe( + gatewayExecutionRoleName("Orders", REGION), + ); + expect(gatewayExecutionRoleName("orders", REGION)).not.toBe( + gatewayExecutionRoleName("orders", "us-east-1"), + ); + expect(gatewayExecutionRoleName(`${"a".repeat(32)}-one`, REGION)).not.toBe( + gatewayExecutionRoleName(`${"a".repeat(32)}-two`, REGION), + ); + }); + + test("creates, tags, scopes, and finalizes a least-privilege role", async () => { + const sent: SentCommand[] = []; + const iam = fakeIam(async (command) => { + sent.push(command); + if (command instanceof GetRoleCommand) throw noSuchEntity(); + if (command instanceof CreateRoleCommand) { + return { Role: ownedRole(requiredString(command.input.RoleName)) }; + } + return {}; + }); + + const provisioning = await ensureGatewayExecutionRole(iam, "orders", REGION, { + policyEngineConfiguration: { arn: POLICY_ENGINE_ARN, mode: "ENFORCE" }, + interceptorConfigurations: [ + { + interceptor: { lambda: { arn: INTERCEPTOR_ARN } }, + interceptionPoints: ["REQUEST"], + }, + ], + }); + await provisioning.updatePolicy(); + await provisioning.updateTrust(GATEWAY_ARN); + await provisioning.updatePolicy(GATEWAY_ARN); + + const create = sent.find((command) => command instanceof CreateRoleCommand)!; + const bootstrapTrust = JSON.parse(requiredString(create.input.AssumeRolePolicyDocument)) + .Statement[0]; + expect(bootstrapTrust).toMatchObject({ + Effect: "Deny", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }); + expect(create.input.Tags).toContainEqual(GATEWAY_ROLE_MANAGED_BY_TAG); + expect(create.input.Tags).toContainEqual(GATEWAY_ROLE_RESOURCE_TYPE_TAG); + + const trustUpdates = sent.filter((command) => command instanceof UpdateAssumeRolePolicyCommand); + expect(trustUpdates).toHaveLength(2); + expect( + JSON.parse(requiredString(trustUpdates[0]!.input.PolicyDocument)).Statement[0].Condition, + ).toEqual({ + StringEquals: { "aws:SourceAccount": ACCOUNT_ID }, + ArnLike: { + "aws:SourceArn": `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:gateway/orders-*`, + }, + }); + expect( + JSON.parse(requiredString(trustUpdates[1]!.input.PolicyDocument)).Statement[0].Condition + .ArnLike, + ).toEqual({ "aws:SourceArn": GATEWAY_ARN }); + + const policyUpdates = sent.filter((command) => command instanceof PutRolePolicyCommand); + expect(policyUpdates).toHaveLength(2); + expect(policyUpdates.map(({ input }) => input.PolicyName)).toEqual([ + GATEWAY_EXECUTION_POLICY_NAME, + GATEWAY_EXECUTION_POLICY_NAME, + ]); + const prepared = policyStatements(requiredString(policyUpdates[0]!.input.PolicyDocument)); + const finalized = policyStatements(requiredString(policyUpdates[1]!.input.PolicyDocument)); + expect(prepared.find(({ Sid }) => Sid === "AgentCoreGatewayConfigLambda")).toMatchObject({ + Action: "lambda:InvokeFunction", + Resource: [INTERCEPTOR_ARN], + }); + expect(prepared.find(({ Sid }) => Sid === "AgentCoreGatewayConfigPolicyEngine")).toMatchObject({ + Action: "bedrock-agentcore:GetPolicyEngine", + Resource: POLICY_ENGINE_ARN, + }); + expect( + prepared.find(({ Sid }) => Sid === "AgentCoreGatewayConfigPolicyAuthorization"), + ).toMatchObject({ + Action: ["bedrock-agentcore:AuthorizeAction", "bedrock-agentcore:PartiallyAuthorizeActions"], + Resource: [ + `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:gateway/orders-*`, + POLICY_ENGINE_ARN, + ], + }); + expect(prepared.find(({ Sid }) => Sid === "AgentCoreGatewayConfigGuardrail")).toMatchObject({ + Action: "bedrock:InvokeGuardrailChecks", + Resource: "*", + }); + + expect( + finalized.find(({ Sid }) => Sid === "AgentCoreGatewayConfigPolicyAuthorization")!.Resource, + ).toEqual([GATEWAY_ARN, POLICY_ENGINE_ARN]); + }); + + test("refuses to adopt an untagged role with the generated name", async () => { + const roleName = gatewayExecutionRoleName("orders", REGION); + const iam = fakeIam(async (command) => { + if (command instanceof GetRoleCommand) { + return { Role: { ...ownedRole(roleName), Tags: [] } }; + } + throw new Error(`unexpected ${command.constructor.name}`); + }); + + await expect(ensureGatewayExecutionRole(iam, "orders", REGION, {})).rejects.toThrow( + "not managed by the agentcore CLI", + ); + }); + + test("rechecks ownership after a concurrent creator wins the role-name race", async () => { + const sent: SentCommand[] = []; + const roleName = gatewayExecutionRoleName("orders", REGION); + let getRoleCalls = 0; + const iam = fakeIam(async (command) => { + sent.push(command); + if (command instanceof GetRoleCommand) { + getRoleCalls += 1; + if (getRoleCalls === 1) throw noSuchEntity(); + return { Role: ownedRole(roleName) }; + } + if (command instanceof CreateRoleCommand) throw alreadyExists(); + return {}; + }); + + const provisioning = await ensureGatewayExecutionRole(iam, "orders", REGION, {}); + + expect(provisioning.roleArn).toBe(`arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`); + expect(sent.map((command) => command.constructor.name)).toEqual([ + "GetRoleCommand", + "CreateRoleCommand", + "GetRoleCommand", + "UpdateAssumeRolePolicyCommand", + ]); + }); + + test("omits an inline policy when the Gateway requires no execution permissions", async () => { + const roleName = gatewayExecutionRoleName("orders", REGION); + const sent: SentCommand[] = []; + const iam = fakeIam(async (command) => { + sent.push(command); + if (command instanceof GetRoleCommand) return { Role: ownedRole(roleName) }; + if (command instanceof DeleteRolePolicyCommand) throw noSuchEntity(); + return {}; + }); + + const provisioning = await ensureGatewayExecutionRole(iam, "orders", REGION, {}); + await provisioning.updatePolicy(); + await provisioning.updatePolicy(GATEWAY_ARN); + + expect(sent.filter((command) => command instanceof PutRolePolicyCommand)).toHaveLength(0); + expect(sent.filter((command) => command instanceof DeleteRolePolicyCommand)).toHaveLength(2); + }); + + test("derives least-privilege permissions for modeled Target resources", async () => { + const roleName = gatewayExecutionRoleName("orders", REGION); + const sent: SentCommand[] = []; + const iam = fakeIam(async (command) => { + sent.push(command); + if (command instanceof GetRoleCommand) { + return { Role: ownedRole(roleName) }; + } + if (command instanceof PutRolePolicyCommand) return {}; + throw new Error(`unexpected ${command.constructor.name}`); + }); + const lambdaArn = "arn:aws:lambda:us-west-2:123456789012:function:target"; + const runtimeArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/runtime-1"; + const oauthProviderArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/oauth"; + const apiKeyProviderArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/key"; + const workloadIdentityArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + + "workload-identity-directory/default/workload-identity/orders-workload"; + const workloadIdentityDirectoryArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default"; + const tokenVaultArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default"; + const configurations: GatewayTargetRoleConfiguration[] = [ + { + targetConfiguration: { + mcp: { + lambda: { + lambdaArn, + toolSchema: { s3: { uri: "s3://schemas/lambda.json" } }, + }, + }, + }, + }, + { + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "bedrock-knowledge-bases" }, + configurations: [ + { + name: "Retrieve", + parameterValues: { knowledgeBaseId: "KB12345678" }, + }, + { + name: "AgenticRetrieveStream", + parameterValues: { + retrievers: [ + { + configuration: { + knowledgeBase: { knowledgeBaseId: "KB87654321" }, + }, + }, + ], + }, + }, + ], + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { + targetConfiguration: { + inference: { + connector: { + source: { connectorId: "bedrock-mantle" }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { + targetConfiguration: { + mcp: { + openApiSchema: { s3: { uri: "s3://schemas/openapi.json" } }, + }, + }, + }, + { + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: "api123", + stage: "prod", + apiGatewayToolConfiguration: { toolFilters: [] }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { + targetConfiguration: { + http: { + agentcoreRuntime: { + arn: runtimeArn, + qualifier: "LIVE", + schema: { source: { s3: { uri: "s3://schemas/runtime.json" } } }, + }, + }, + }, + }, + { + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "web-search" }, + }, + }, + }, + }, + { + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.com/mcp" } }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "OAUTH", + credentialProvider: { + oauthCredentialProvider: { + providerArn: oauthProviderArn, + scopes: ["read"], + grantType: "TOKEN_EXCHANGE", + }, + }, + }, + { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { + providerArn: apiKeyProviderArn, + }, + }, + }, + ], + credentialProviderSecretArns: [ + "arn:aws:secretsmanager:us-west-2:123456789012:secret:oauth", + "arn:aws:secretsmanager:us-west-2:123456789012:secret:api-key", + ], + }, + ]; + + const role = await getManagedGatewayTargetExecutionRole( + iam, + { + gatewayId: "orders-abc123", + gatewayArn: GATEWAY_ARN, + name: "orders", + roleArn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, + workloadIdentityDetails: { workloadIdentityArn }, + interceptorConfigurations: [ + { + interceptor: { lambda: { arn: INTERCEPTOR_ARN } }, + interceptionPoints: ["REQUEST"], + }, + ], + customTransformConfiguration: { lambda: { arn: TRANSFORM_ARN } }, + } as Parameters[1], + REGION, + ); + await role!.updatePolicy(configurations); + + const policyUpdate = sent.find((command) => command instanceof PutRolePolicyCommand); + const statements = policyStatements(requiredString(policyUpdate?.input.PolicyDocument)); + expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayConfigLambda")).toMatchObject({ + Action: "lambda:InvokeFunction", + Resource: [INTERCEPTOR_ARN, TRANSFORM_ARN].sort(), + }); + expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetLambda")).toMatchObject({ + Action: "lambda:InvokeFunction", + Resource: [lambdaArn, `${lambdaArn}:*`], + }); + expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetS3Schema")).toMatchObject({ + Action: "s3:GetObject", + Resource: [ + "arn:aws:s3:::schemas/lambda.json", + "arn:aws:s3:::schemas/openapi.json", + "arn:aws:s3:::schemas/runtime.json", + ], + }); + expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetApiGateway")).toMatchObject({ + Action: "execute-api:Invoke", + Resource: ["arn:aws:execute-api:us-west-2:123456789012:api123/prod/*/*"], + }); + expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetRuntime")).toMatchObject({ + Action: "bedrock-agentcore:InvokeAgentRuntime", + Resource: [runtimeArn, `${runtimeArn}/runtime-endpoint/LIVE`], + }); + expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetWebSearch")).toMatchObject({ + Action: "bedrock-agentcore:InvokeWebSearch", + Resource: "arn:aws:bedrock-agentcore:us-west-2:aws:tool/web-search.v1", + }); + expect( + statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetKnowledgeBase"), + ).toMatchObject({ + Action: ["bedrock:GetKnowledgeBase", "bedrock:Retrieve"], + Resource: [ + "arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB12345678", + "arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB87654321", + ], + }); + expect( + statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetAgenticRetrieve"), + ).toMatchObject({ + Action: "bedrock:AgenticRetrieveStream", + Resource: "*", + }); + expect( + statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetBedrockMantle"), + ).toMatchObject({ + Action: "bedrock-mantle:CreateInference", + Resource: "*", + }); + expect( + statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetWorkloadIdentity"), + ).toMatchObject({ + Action: [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + ], + Resource: [workloadIdentityDirectoryArn, workloadIdentityArn], + }); + const oauthResources = [ + tokenVaultArn, + oauthProviderArn, + workloadIdentityDirectoryArn, + workloadIdentityArn, + ].sort(); + expect( + statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetOAuthCompleteAuth"), + ).toMatchObject({ + Action: "bedrock-agentcore:CompleteResourceTokenAuth", + Resource: oauthResources, + }); + expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetOAuth")).toMatchObject({ + Action: "bedrock-agentcore:GetResourceOauth2Token", + Resource: oauthResources, + }); + expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetApiKey")).toMatchObject({ + Action: "bedrock-agentcore:GetResourceApiKey", + Resource: [ + tokenVaultArn, + apiKeyProviderArn, + workloadIdentityDirectoryArn, + workloadIdentityArn, + ].sort(), + }); + expect( + statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetCredentialSecrets"), + ).toMatchObject({ + Action: "secretsmanager:GetSecretValue", + Resource: [ + "arn:aws:secretsmanager:us-west-2:123456789012:secret:api-key", + "arn:aws:secretsmanager:us-west-2:123456789012:secret:oauth", + ], + }); + }); + + test("requires a customer-managed role when SigV4 permissions cannot be inferred", async () => { + const roleName = gatewayExecutionRoleName("orders", REGION); + const iam = fakeIam(async (command) => { + if (command instanceof GetRoleCommand) return { Role: ownedRole(roleName) }; + throw new Error(`unexpected ${command.constructor.name}`); + }); + const role = await getManagedGatewayTargetExecutionRole( + iam, + { + gatewayId: "orders-abc123", + gatewayArn: GATEWAY_ARN, + name: "orders", + roleArn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, + } as Parameters[1], + REGION, + ); + + for (const configuration of [ + { + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://service.us-west-2.amazonaws.com", + protocolType: "CUSTOM", + }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "GATEWAY_IAM_ROLE", + credentialProvider: { + iamCredentialProvider: { + service: "service", + region: REGION, + }, + }, + }, + ], + }, + { + targetConfiguration: { + mcp: { + smithyModel: { + inlinePayload: JSON.stringify({ + smithy: "2.0", + shapes: {}, + }), + }, + }, + }, + }, + ] satisfies GatewayTargetRoleConfiguration[]) { + await expect(role!.updatePolicy([configuration])).rejects.toThrow( + "customer-managed --role-arn", + ); + } + }); +}); + +describe("Gateway role propagation retry", () => { + test("retries only role-assumption validation failures up to the configured limit", async () => { + let attempts = 0; + const delays: number[] = []; + + const result = await retryWhileGatewayRoleChangesPropagate( + async () => { + attempts += 1; + if (attempts < 3) { + const error = new Error("The execution role cannot be assumed yet"); + error.name = "ValidationException"; + throw error; + } + return "created"; + }, + 3, + 25, + async (delay) => { + delays.push(delay); + }, + ); + + expect(result).toBe("created"); + expect(attempts).toBe(3); + expect(delays).toEqual([25, 25]); + }); + + test("does not retry unrelated failures", async () => { + let attempts = 0; + + await expect( + retryWhileGatewayRoleChangesPropagate( + async () => { + attempts += 1; + throw new Error("access denied"); + }, + 3, + 0, + async () => {}, + ), + ).rejects.toThrow("access denied"); + expect(attempts).toBe(1); + }); +}); diff --git a/src/core/gatewayExecutionRole.ts b/src/core/gatewayExecutionRole.ts new file mode 100644 index 000000000..0315ffff6 --- /dev/null +++ b/src/core/gatewayExecutionRole.ts @@ -0,0 +1,296 @@ +import { createHash } from "node:crypto"; +import type { GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { + CreateRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + PutRolePolicyCommand, + UpdateAssumeRolePolicyCommand, + type IAMClient, + type Role, +} from "@aws-sdk/client-iam"; +import { InputValidationError } from "../errors"; +import { + gatewayPolicyDocument, + gatewayTargetPolicyDocument, + type GatewayRoleConfiguration, + type GatewayTargetPolicyContext, + type GatewayTargetRoleConfiguration, +} from "./gatewayExecutionRolePolicy"; + +export type { GatewayTargetRoleConfiguration } from "./gatewayExecutionRolePolicy"; + +export const GATEWAY_EXECUTION_POLICY_NAME = "AgentCoreGatewayExecutionPolicy"; +export const GATEWAY_ROLE_MANAGED_BY_TAG = { + Key: "bedrock-agentcore:managed-by", + Value: "agentcore-cli", +} as const; +export const GATEWAY_ROLE_RESOURCE_TYPE_TAG = { + Key: "bedrock-agentcore:resource-type", + Value: "gateway", +} as const; + +export type GatewayExecutionRoleProvisioning = { + roleArn: string; + roleName: string; + updatePolicy(gatewayArn?: string): Promise; + updateTrust(gatewayArn: string): Promise; +}; + +export interface GatewayTargetExecutionRole { + roleName: string; + updatePolicy(configurations: GatewayTargetRoleConfiguration[]): Promise; +} + +export function gatewayExecutionRoleName(gatewayName: string, region: string): string { + const suffix = createHash("sha256").update(`${region}:${gatewayName}`).digest("hex").slice(0, 12); + return `AgentCoreGateway-${gatewayName.slice(0, 32)}-${suffix}`; +} + +export async function ensureGatewayExecutionRole( + iam: IAMClient, + gatewayName: string, + region: string, + configuration: GatewayRoleConfiguration, +): Promise { + const roleName = gatewayExecutionRoleName(gatewayName, region); + const role = await getOrCreateOwnedRole(iam, roleName, gatewayName); + const roleArn = requiredRoleArn(role, roleName); + const arn = parseRoleArn(roleArn); + const gatewayPattern = gatewayArnPattern(arn.partition, region, arn.accountId, gatewayName); + + await iam.send( + new UpdateAssumeRolePolicyCommand({ + RoleName: roleName, + PolicyDocument: trustPolicy(arn.accountId, gatewayPattern), + }), + ); + + return { + roleArn, + roleName, + updatePolicy: async (gatewayArn = gatewayPattern) => + await replaceGatewayExecutionPolicy( + iam, + roleName, + gatewayPolicyDocument(configuration, gatewayArn), + ), + updateTrust: async (gatewayArn) => { + await iam.send( + new UpdateAssumeRolePolicyCommand({ + RoleName: roleName, + PolicyDocument: trustPolicy(arn.accountId, gatewayArn), + }), + ); + }, + }; +} + +export async function retryWhileGatewayRoleChangesPropagate( + operation: () => Promise, + attempts = 8, + delayMs = 2000, + sleep: (delayMs: number) => Promise = (delay) => + new Promise((resolve) => setTimeout(resolve, delay)), +): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + const retryable = isGatewayRolePropagationError(error); + if (!retryable || attempt >= attempts) throw error; + await sleep(delayMs); + } + } +} + +export async function getManagedGatewayTargetExecutionRole( + iam: IAMClient, + gateway: GetGatewayResponse, + region: string, +): Promise { + if (!gateway.roleArn || !gateway.name) return undefined; + + const parsedArn = parseRoleArn(gateway.roleArn); + const expectedRoleName = gatewayExecutionRoleName(gateway.name, region); + if (parsedArn.roleName !== expectedRoleName) return undefined; + + const response = await iam.send(new GetRoleCommand({ RoleName: parsedArn.roleName })); + if (!isOwnedGatewayRole(response.Role)) return undefined; + + const gatewayId = gateway.gatewayId ?? gateway.gatewayArn?.split("/").at(-1); + if (!gatewayId) { + throw new Error("GetGateway response did not include the Gateway ID"); + } + const context: GatewayTargetPolicyContext = { + partition: parsedArn.partition, + region, + accountId: parsedArn.accountId, + gatewayId, + workloadIdentityArn: gateway.workloadIdentityDetails?.workloadIdentityArn, + }; + const gatewayArn = + gateway.gatewayArn ?? + `arn:${parsedArn.partition}:bedrock-agentcore:${region}:${parsedArn.accountId}:gateway/${gatewayId}`; + + return { + roleName: parsedArn.roleName, + updatePolicy: async (configurations) => + await replaceGatewayExecutionPolicy( + iam, + parsedArn.roleName, + gatewayTargetPolicyDocument(gateway, gatewayArn, configurations, context), + ), + }; +} + +async function replaceGatewayExecutionPolicy( + iam: IAMClient, + roleName: string, + policyDocument: string | undefined, +): Promise { + if (policyDocument) { + await iam.send( + new PutRolePolicyCommand({ + RoleName: roleName, + PolicyName: GATEWAY_EXECUTION_POLICY_NAME, + PolicyDocument: policyDocument, + }), + ); + return; + } + + try { + await iam.send( + new DeleteRolePolicyCommand({ + RoleName: roleName, + PolicyName: GATEWAY_EXECUTION_POLICY_NAME, + }), + ); + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + } +} + +async function getOrCreateOwnedRole( + iam: IAMClient, + roleName: string, + gatewayName: string, +): Promise { + try { + const existing = await iam.send(new GetRoleCommand({ RoleName: roleName })); + assertOwnedGatewayRole(existing.Role, roleName); + return existing.Role!; + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + } + + try { + const created = await iam.send( + new CreateRoleCommand({ + RoleName: roleName, + AssumeRolePolicyDocument: bootstrapTrustPolicy(), + Description: `Execution role for the AgentCore Gateway "${gatewayName}" created by the agentcore CLI`, + Tags: [ + GATEWAY_ROLE_MANAGED_BY_TAG, + GATEWAY_ROLE_RESOURCE_TYPE_TAG, + { Key: "bedrock-agentcore:resource-name", Value: gatewayName }, + ], + }), + ); + return created.Role!; + } catch (error) { + if ((error as Error).name !== "EntityAlreadyExistsException") throw error; + const existing = await iam.send(new GetRoleCommand({ RoleName: roleName })); + assertOwnedGatewayRole(existing.Role, roleName); + return existing.Role!; + } +} + +function assertOwnedGatewayRole(role: Role | undefined, roleName: string): void { + if (isOwnedGatewayRole(role)) return; + throw new InputValidationError( + `IAM role "${roleName}" already exists but is not managed by the agentcore CLI; ` + + "pass --role-arn with a customer-managed Gateway role", + ); +} + +function isOwnedGatewayRole(role: Role | undefined): boolean { + const tags = new Map(role?.Tags?.map(({ Key, Value }) => [Key, Value])); + return ( + tags.get(GATEWAY_ROLE_MANAGED_BY_TAG.Key) === GATEWAY_ROLE_MANAGED_BY_TAG.Value && + tags.get(GATEWAY_ROLE_RESOURCE_TYPE_TAG.Key) === GATEWAY_ROLE_RESOURCE_TYPE_TAG.Value + ); +} + +function requiredRoleArn(role: Role, roleName: string): string { + if (!role.Arn) { + throw new Error(`IAM did not return an ARN for role "${roleName}"`); + } + return role.Arn; +} + +function bootstrapTrustPolicy(): string { + return JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Sid: "GatewayAssumeRoleBootstrap", + Effect: "Deny", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }); +} + +function trustPolicy(accountId: string, sourceArn: string): string { + return JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Sid: "GatewayAssumeRolePolicy", + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + Condition: { + StringEquals: { "aws:SourceAccount": accountId }, + ArnLike: { "aws:SourceArn": sourceArn }, + }, + }, + ], + }); +} + +function isGatewayRolePropagationError(error: unknown): boolean { + if ((error as Error).name !== "ValidationException") return false; + const message = (error as Error).message ?? ""; + return [ + /\b(?:execution|gateway|iam)\s+role\b.*\b(?:assum|trust)\w*/i, + /\b(?:assum|trust)\w*\b.*\brole\b/i, + /\brole\b.*\b(?:lack|missing|permission|authoriz)\w*/i, + /\b(?:permission|authoriz)\w*\b.*\brole\b/i, + ].some((pattern) => pattern.test(message)); +} + +function parseRoleArn(arn: string): { + partition: string; + accountId: string; + roleName: string; +} { + const match = /^arn:([^:]+):iam::(\d{12}):role\/(.+)$/.exec(arn); + const roleName = match?.[3]?.split("/").at(-1); + if (!match || !roleName) { + throw new InputValidationError(`Invalid IAM role ARN "${arn}"`); + } + return { partition: match[1]!, accountId: match[2]!, roleName }; +} + +function gatewayArnPattern( + partition: string, + region: string, + accountId: string, + gatewayName: string, +): string { + return `arn:${partition}:bedrock-agentcore:${region}:${accountId}:gateway/${gatewayName}-*`; +} diff --git a/src/core/gatewayExecutionRolePolicy.ts b/src/core/gatewayExecutionRolePolicy.ts new file mode 100644 index 000000000..8787ad6ac --- /dev/null +++ b/src/core/gatewayExecutionRolePolicy.ts @@ -0,0 +1,505 @@ +import type { + CreateGatewayTargetRequest, + GetGatewayResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError } from "../errors"; + +const GATEWAY_POLICY_SCOPE = "AgentCoreGatewayConfig"; +const GATEWAY_TARGET_POLICY_SCOPE = "AgentCoreGatewayTarget"; + +export type GatewayRoleConfiguration = { + customTransformConfiguration?: GetGatewayResponse["customTransformConfiguration"]; + interceptorConfigurations?: GetGatewayResponse["interceptorConfigurations"]; + policyEngineConfiguration?: GetGatewayResponse["policyEngineConfiguration"]; +}; + +export type GatewayTargetRoleConfiguration = Pick< + CreateGatewayTargetRequest, + "targetConfiguration" | "credentialProviderConfigurations" +> & { + credentialProviderSecretArns?: string[]; +}; + +export type GatewayTargetPolicyContext = { + partition: string; + region: string; + accountId: string; + gatewayId: string; + workloadIdentityArn?: string; +}; + +type PolicyStatement = { + Sid?: string; + Effect?: string; + Action?: string | string[]; + Resource?: string | string[]; + [key: string]: unknown; +}; + +type PolicyDocument = { + Version: "2012-10-17"; + Statement: PolicyStatement[]; +}; + +export function gatewayPolicyDocument( + configuration: GatewayRoleConfiguration, + gatewayArn: string, +): string | undefined { + return policyDocumentForStatements(configurationStatements(configuration, gatewayArn)); +} + +export function gatewayTargetPolicyDocument( + gateway: GatewayRoleConfiguration, + gatewayArn: string, + configurations: GatewayTargetRoleConfiguration[], + context: GatewayTargetPolicyContext, +): string | undefined { + return policyDocumentForStatements([ + ...configurationStatements(gateway, gatewayArn), + ...targetStatements(configurations, context), + ]); +} + +function configurationStatements( + configuration: GatewayRoleConfiguration, + gatewayArn: string, +): PolicyStatement[] { + const statements: PolicyStatement[] = []; + const policyEngineArn = configuration.policyEngineConfiguration?.arn; + if (policyEngineArn) { + statements.push( + { + Sid: `${GATEWAY_POLICY_SCOPE}PolicyEngine`, + Effect: "Allow", + Action: "bedrock-agentcore:GetPolicyEngine", + Resource: policyEngineArn, + }, + { + Sid: `${GATEWAY_POLICY_SCOPE}PolicyAuthorization`, + Effect: "Allow", + Action: [ + "bedrock-agentcore:AuthorizeAction", + "bedrock-agentcore:PartiallyAuthorizeActions", + ], + Resource: [policyEngineArn, gatewayArn].sort(), + }, + { + Sid: `${GATEWAY_POLICY_SCOPE}Guardrail`, + Effect: "Allow", + Action: "bedrock:InvokeGuardrailChecks", + Resource: "*", + }, + ); + } + + const lambdaArns = new Set(); + for (const interceptor of configuration.interceptorConfigurations ?? []) { + const interceptorConfiguration = interceptor.interceptor; + const interceptorArn = + interceptorConfiguration && + "lambda" in interceptorConfiguration && + interceptorConfiguration.lambda + ? interceptorConfiguration.lambda.arn + : undefined; + if (interceptorArn) lambdaArns.add(interceptorArn); + } + const transformArn = configuration.customTransformConfiguration?.lambda?.arn; + if (transformArn) lambdaArns.add(transformArn); + if (lambdaArns.size > 0) { + statements.push({ + Sid: `${GATEWAY_POLICY_SCOPE}Lambda`, + Effect: "Allow", + Action: "lambda:InvokeFunction", + Resource: [...lambdaArns].sort(), + }); + } + + return statements; +} + +function targetStatements( + configurations: GatewayTargetRoleConfiguration[], + context: GatewayTargetPolicyContext, +): PolicyStatement[] { + const lambdaArns = new Set(); + const s3ObjectArns = new Set(); + const apiGatewayArns = new Set(); + const runtimeArns = new Set(); + const knowledgeBaseArns = new Set(); + const oauthProviderArns = new Set(); + const apiKeyProviderArns = new Set(); + const secretArns = new Set(); + let usesWebSearch = false; + let usesAgenticRetrieve = false; + let usesBedrockMantle = false; + + for (const configuration of configurations) { + const target = configuration.targetConfiguration; + assertSupportedGatewayRoleTarget(configuration); + if (target && "mcp" in target && target.mcp) { + const mcp = target.mcp; + if ("lambda" in mcp && mcp.lambda) { + const lambdaArn = mcp.lambda.lambdaArn; + if (lambdaArn) { + lambdaArns.add(lambdaArn); + if (isUnqualifiedLambdaFunctionArn(lambdaArn)) { + lambdaArns.add(`${lambdaArn}:*`); + } + } + addSchemaS3Object(s3ObjectArns, mcp.lambda.toolSchema, context.partition); + } else if ("openApiSchema" in mcp && mcp.openApiSchema) { + addSchemaS3Object(s3ObjectArns, mcp.openApiSchema, context.partition); + } else if ("smithyModel" in mcp && mcp.smithyModel) { + addSchemaS3Object(s3ObjectArns, mcp.smithyModel, context.partition); + } else if ("mcpServer" in mcp && mcp.mcpServer) { + addSchemaS3Object(s3ObjectArns, mcp.mcpServer.mcpToolSchema, context.partition); + } else if ("apiGateway" in mcp && mcp.apiGateway) { + if (usesGatewayExecutionRole(configuration)) { + apiGatewayArns.add( + `arn:${context.partition}:execute-api:${context.region}:${context.accountId}:` + + `${mcp.apiGateway.restApiId}/${mcp.apiGateway.stage}/*/*`, + ); + } + } else if ("connector" in mcp && mcp.connector) { + const connectorId = mcp.connector.source?.connectorId; + if (connectorId === "web-search") { + usesWebSearch = usesWebSearch || usesGatewayExecutionRole(configuration); + } else if (connectorId === "bedrock-knowledge-bases") { + if (usesGatewayExecutionRole(configuration)) { + usesAgenticRetrieve = + addKnowledgeBaseConnectorPermissions( + knowledgeBaseArns, + mcp.connector.configurations, + context, + ) || usesAgenticRetrieve; + } + } + } + } else if (target && "http" in target && target.http) { + const http = target.http; + if ("agentcoreRuntime" in http && http.agentcoreRuntime) { + const runtimeArn = http.agentcoreRuntime.arn; + if (runtimeArn && usesGatewayExecutionRole(configuration)) { + runtimeArns.add(runtimeArn); + runtimeArns.add( + `${runtimeArn}/runtime-endpoint/${http.agentcoreRuntime.qualifier ?? "DEFAULT"}`, + ); + } + addSchemaS3Object(s3ObjectArns, http.agentcoreRuntime.schema?.source, context.partition); + } else if ("passthrough" in http && http.passthrough) { + addSchemaS3Object(s3ObjectArns, http.passthrough.schema?.source, context.partition); + } + } else if (target && "inference" in target && target.inference) { + usesBedrockMantle = + usesBedrockMantle || + (usesGatewayExecutionRole(configuration) && + (("connector" in target.inference && + target.inference.connector?.source?.connectorId === "bedrock-mantle") || + ("provider" in target.inference && + target.inference.provider?.endpoint?.includes("bedrock-mantle.") === true))); + } + + for (const providerConfiguration of configuration.credentialProviderConfigurations ?? []) { + const provider = providerConfiguration.credentialProvider; + if ( + providerConfiguration.credentialProviderType === "OAUTH" && + provider && + "oauthCredentialProvider" in provider && + provider.oauthCredentialProvider + ) { + if (provider.oauthCredentialProvider.providerArn) { + oauthProviderArns.add(provider.oauthCredentialProvider.providerArn); + } + } else if ( + providerConfiguration.credentialProviderType === "API_KEY" && + provider && + "apiKeyCredentialProvider" in provider && + provider.apiKeyCredentialProvider?.providerArn + ) { + apiKeyProviderArns.add(provider.apiKeyCredentialProvider.providerArn); + } + } + for (const secretArn of configuration.credentialProviderSecretArns ?? []) { + secretArns.add(secretArn); + } + } + + const statements: PolicyStatement[] = []; + addResourceStatement( + statements, + `${GATEWAY_TARGET_POLICY_SCOPE}Lambda`, + "lambda:InvokeFunction", + lambdaArns, + ); + addResourceStatement( + statements, + `${GATEWAY_TARGET_POLICY_SCOPE}S3Schema`, + "s3:GetObject", + s3ObjectArns, + ); + addResourceStatement( + statements, + `${GATEWAY_TARGET_POLICY_SCOPE}ApiGateway`, + "execute-api:Invoke", + apiGatewayArns, + ); + addResourceStatement( + statements, + `${GATEWAY_TARGET_POLICY_SCOPE}Runtime`, + "bedrock-agentcore:InvokeAgentRuntime", + runtimeArns, + ); + addResourceStatement( + statements, + `${GATEWAY_TARGET_POLICY_SCOPE}KnowledgeBase`, + ["bedrock:GetKnowledgeBase", "bedrock:Retrieve"], + knowledgeBaseArns, + ); + if (usesAgenticRetrieve) { + statements.push({ + Sid: `${GATEWAY_TARGET_POLICY_SCOPE}AgenticRetrieve`, + Effect: "Allow", + Action: "bedrock:AgenticRetrieveStream", + Resource: "*", + }); + } + if (usesBedrockMantle) { + statements.push({ + Sid: `${GATEWAY_TARGET_POLICY_SCOPE}BedrockMantle`, + Effect: "Allow", + Action: "bedrock-mantle:CreateInference", + Resource: "*", + }); + } + if (usesWebSearch) { + statements.push({ + Sid: `${GATEWAY_TARGET_POLICY_SCOPE}WebSearch`, + Effect: "Allow", + Action: "bedrock-agentcore:InvokeWebSearch", + Resource: `arn:${context.partition}:bedrock-agentcore:${context.region}:aws:tool/web-search.v1`, + }); + } + + const workloadIdentityArns = + oauthProviderArns.size > 0 || apiKeyProviderArns.size > 0 + ? workloadIdentityResources(context) + : []; + if (workloadIdentityArns.length > 0) { + statements.push({ + Sid: `${GATEWAY_TARGET_POLICY_SCOPE}WorkloadIdentity`, + Effect: "Allow", + Action: [ + "bedrock-agentcore:GetWorkloadAccessToken", + ...(oauthProviderArns.size > 0 + ? [ + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + ] + : []), + ].sort(), + Resource: workloadIdentityArns, + }); + } + if (oauthProviderArns.size > 0) { + const resources = tokenVaultResources(context, oauthProviderArns, workloadIdentityArns); + statements.push({ + Sid: `${GATEWAY_TARGET_POLICY_SCOPE}OAuthCompleteAuth`, + Effect: "Allow", + Action: "bedrock-agentcore:CompleteResourceTokenAuth", + Resource: resources, + }); + statements.push({ + Sid: `${GATEWAY_TARGET_POLICY_SCOPE}OAuth`, + Effect: "Allow", + Action: "bedrock-agentcore:GetResourceOauth2Token", + Resource: resources, + }); + } + if (apiKeyProviderArns.size > 0) { + statements.push({ + Sid: `${GATEWAY_TARGET_POLICY_SCOPE}ApiKey`, + Effect: "Allow", + Action: "bedrock-agentcore:GetResourceApiKey", + Resource: tokenVaultResources(context, apiKeyProviderArns, workloadIdentityArns), + }); + } + addResourceStatement( + statements, + `${GATEWAY_TARGET_POLICY_SCOPE}CredentialSecrets`, + "secretsmanager:GetSecretValue", + secretArns, + ); + + return statements; +} + +function workloadIdentityResources(context: GatewayTargetPolicyContext): string[] { + const workloadIdentityArn = context.workloadIdentityArn; + if (!workloadIdentityArn) { + throw new Error( + `Gateway "${context.gatewayId}" did not return a workload identity ARN required for Target credentials`, + ); + } + const marker = "/workload-identity/"; + const markerIndex = workloadIdentityArn.indexOf(marker); + if (markerIndex === -1) { + throw new Error(`Invalid Gateway workload identity ARN "${workloadIdentityArn}"`); + } + const directoryArn = workloadIdentityArn.slice(0, markerIndex); + return [directoryArn, workloadIdentityArn].sort(); +} + +function tokenVaultResources( + context: GatewayTargetPolicyContext, + providerArns: Set, + workloadIdentityArns: string[], +): string[] { + const tokenVaultArn = + `arn:${context.partition}:bedrock-agentcore:${context.region}:${context.accountId}:` + + "token-vault/default"; + return [...new Set([tokenVaultArn, ...providerArns, ...workloadIdentityArns])].sort(); +} + +function assertSupportedGatewayRoleTarget(configuration: GatewayTargetRoleConfiguration): void { + if (!usesGatewayExecutionRole(configuration)) return; + const target = configuration.targetConfiguration; + const supported = + Boolean(target && "mcp" in target && target.mcp && "lambda" in target.mcp) || + Boolean(target && "mcp" in target && target.mcp && "apiGateway" in target.mcp) || + Boolean( + target && + "mcp" in target && + target.mcp && + "connector" in target.mcp && + ["bedrock-knowledge-bases", "web-search"].includes( + target.mcp.connector?.source?.connectorId ?? "", + ), + ) || + Boolean(target && "http" in target && target.http && "agentcoreRuntime" in target.http) || + Boolean( + target && + "inference" in target && + target.inference && + (("connector" in target.inference && + target.inference.connector?.source?.connectorId === "bedrock-mantle") || + ("provider" in target.inference && + target.inference.provider?.endpoint?.includes("bedrock-mantle.") === true)), + ); + if (supported) return; + + throw new InputValidationError( + "The CLI cannot infer least-privilege IAM permissions for this Target's " + + "GATEWAY_IAM_ROLE credential; update the Gateway to use a customer-managed --role-arn", + ); +} + +function isUnqualifiedLambdaFunctionArn(arn: string): boolean { + return /^arn:[^:]+:lambda:[^:]+:\d{12}:function:[^:]+$/.test(arn); +} + +function addKnowledgeBaseConnectorPermissions( + resources: Set, + configurations: + | { + name?: string; + parameterValues?: unknown; + }[] + | undefined, + context: GatewayTargetPolicyContext, +): boolean { + let usesAgenticRetrieve = false; + for (const configuration of configurations ?? []) { + const parameterValues = asRecord(configuration.parameterValues); + if (configuration.name === "Retrieve") { + addKnowledgeBaseArn(resources, parameterValues?.knowledgeBaseId, context); + } else if (configuration.name === "AgenticRetrieveStream") { + usesAgenticRetrieve = true; + const retrievers = parameterValues?.retrievers; + if (!Array.isArray(retrievers)) continue; + for (const retriever of retrievers) { + const retrieverConfiguration = asRecord(asRecord(retriever)?.configuration); + const knowledgeBase = asRecord(retrieverConfiguration?.knowledgeBase); + addKnowledgeBaseArn(resources, knowledgeBase?.knowledgeBaseId, context); + } + } + } + return usesAgenticRetrieve; +} + +function addKnowledgeBaseArn( + resources: Set, + knowledgeBaseId: unknown, + context: GatewayTargetPolicyContext, +): void { + if (typeof knowledgeBaseId !== "string" || !knowledgeBaseId) return; + resources.add( + `arn:${context.partition}:bedrock:${context.region}:${context.accountId}:` + + `knowledge-base/${knowledgeBaseId}`, + ); +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + +function addSchemaS3Object( + resources: Set, + schema: { s3?: { uri?: string } } | undefined, + partition: string, +): void { + const uri = schema?.s3?.uri; + if (!uri) return; + const match = /^s3:\/\/([^/]+)\/(.+)$/.exec(uri); + if (match) resources.add(`arn:${partition}:s3:::${match[1]}/${match[2]}`); +} + +function usesGatewayExecutionRole(configuration: GatewayTargetRoleConfiguration): boolean { + if (configuration.credentialProviderConfigurations !== undefined) { + return configuration.credentialProviderConfigurations.some( + ({ credentialProviderType }) => credentialProviderType === "GATEWAY_IAM_ROLE", + ); + } + + const target = configuration.targetConfiguration; + return Boolean( + target && + (("mcp" in target && + target.mcp && + ("lambda" in target.mcp || + "smithyModel" in target.mcp || + "apiGateway" in target.mcp || + ("connector" in target.mcp && + ["bedrock-knowledge-bases", "web-search"].includes( + target.mcp.connector?.source?.connectorId ?? "", + )))) || + ("http" in target && target.http && "agentcoreRuntime" in target.http) || + ("inference" in target && + target.inference && + (("connector" in target.inference && + target.inference.connector?.source?.connectorId === "bedrock-mantle") || + ("provider" in target.inference && + target.inference.provider?.endpoint?.includes("bedrock-mantle.") === true)))), + ); +} + +function addResourceStatement( + statements: PolicyStatement[], + sid: string, + action: string | string[], + resources: Set, +): void { + if (resources.size === 0) return; + statements.push({ + Sid: sid, + Effect: "Allow", + Action: action, + Resource: [...resources].sort(), + }); +} + +function policyDocumentForStatements(statements: PolicyStatement[]): string | undefined { + if (statements.length === 0) return undefined; + const document: PolicyDocument = { Version: "2012-10-17", Statement: statements }; + return JSON.stringify(document); +} diff --git a/src/core/index.tsx b/src/core/index.tsx index f99f9347e..b34b816d2 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -101,7 +101,7 @@ export class CoreClient implements AwsClients { } // iam returns the IAM client for `config`, creating and caching it on first - // use (used to provision default harness execution roles). + // use (used to provision default execution roles). iam(config: ClientConfig): IAMClient { const key = cacheKey(config); let client = this.iamClients.get(key); diff --git a/src/handlers/gateway/create/index.tsx b/src/handlers/gateway/create/index.tsx new file mode 100644 index 000000000..7f22e3bde --- /dev/null +++ b/src/handlers/gateway/create/index.tsx @@ -0,0 +1,130 @@ +import type { + AuthorizerConfiguration, + GatewayInterceptorConfiguration, + GatewayProtocolConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../errors"; +import { createHandler, flag } from "../../../router"; +import { JsonRendererKey } from "../../../tui"; +import type { Core } from "../../types"; +import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag, parseTags } from "../../utils"; +import { validateGatewayCreateInput } from "../mutations"; +import type { CreateGatewayInput } from "../types"; + +export const createCreateGatewayHandler = (core: Core) => + createHandler({ + name: "create", + description: "create an AgentCore Gateway", + flags: [ + flag("name", "the Gateway name", z.string().optional()), + flag( + "role-arn", + "IAM role the Gateway assumes; a least-privilege role is created when omitted", + z.string().optional(), + ), + flag( + "protocol", + "Gateway protocol: mcp or http (default: http)", + z.enum(["mcp", "http"]).optional(), + ), + flag( + "authorizer-type", + "inbound authorizer: AWS_IAM, CUSTOM_JWT, NONE, or AUTHENTICATE_ONLY", + z.enum(["AWS_IAM", "CUSTOM_JWT", "NONE", "AUTHENTICATE_ONLY"]).optional(), + ), + flag("description", "Gateway description", z.string().optional()), + flag( + "protocol-configuration", + "protocol configuration (JSON GatewayProtocolConfiguration)", + z.string().optional(), + ), + flag( + "authorizer-configuration", + "authorizer configuration (JSON AuthorizerConfiguration)", + z.string().optional(), + ), + flag("kms-key-arn", "KMS key ARN", z.string().optional()), + flag( + "interceptor-configurations", + "interceptor configurations (JSON GatewayInterceptorConfiguration[])", + z.string().optional(), + ), + flag("policy-engine-arn", "Policy Engine ARN", z.string().optional()), + flag( + "policy-engine-mode", + "Policy Engine mode: log-only or enforce", + z.enum(["log-only", "enforce"]).optional(), + ), + flag("exception-level", "exception detail level: debug", z.enum(["debug"]).optional()), + flag( + "tags", + "tags as key=value (repeatable) or a JSON object", + z.array(z.string()).optional(), + ), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + if (!flags["authorizer-type"]) { + throw new InputValidationError( + "required option '--authorizer-type ' not specified", + ); + } + if ( + (flags["policy-engine-arn"] === undefined) !== + (flags["policy-engine-mode"] === undefined) + ) { + throw new InputValidationError( + "--policy-engine-arn and --policy-engine-mode must be supplied together", + ); + } + + const protocolConfiguration = parseJsonObjectFlag( + "protocol-configuration", + flags["protocol-configuration"], + ); + const authorizerConfiguration = parseJsonObjectFlag( + "authorizer-configuration", + flags["authorizer-configuration"], + ); + const interceptorConfigurations = parseJsonArrayFlag( + "interceptor-configurations", + flags["interceptor-configurations"], + ); + const policyEngineConfiguration = + flags["policy-engine-arn"] && flags["policy-engine-mode"] + ? { + arn: flags["policy-engine-arn"], + mode: + flags["policy-engine-mode"] === "enforce" + ? ("ENFORCE" as const) + : ("LOG_ONLY" as const), + } + : undefined; + const tags = parseTags(flags.tags); + + const input: CreateGatewayInput = { + name: flags.name, + ...(flags["role-arn"] ? { roleArn: flags["role-arn"] } : {}), + protocol: flags.protocol ?? "http", + authorizerType: flags["authorizer-type"], + ...(flags.description ? { description: flags.description } : {}), + ...(protocolConfiguration ? { protocolConfiguration } : {}), + ...(authorizerConfiguration ? { authorizerConfiguration } : {}), + ...(flags["kms-key-arn"] ? { kmsKeyArn: flags["kms-key-arn"] } : {}), + ...(interceptorConfigurations ? { interceptorConfigurations } : {}), + ...(policyEngineConfiguration ? { policyEngineConfiguration } : {}), + ...(flags["exception-level"] ? { exceptionLevel: "DEBUG" as const } : {}), + ...(tags ? { tags } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + validateGatewayCreateInput(input); + ctx + .require(JsonRendererKey) + .renderJson(await core.gateway.createGateway(input, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx new file mode 100644 index 000000000..979d82289 --- /dev/null +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; +import type { + CreateGatewayResponse, + CreateGatewayRuleResponse, + CreateGatewayTargetResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; +import { createRootHandler } from "../index"; + +const REGION = "us-west-2"; +const GATEWAY_ID = "gateway-1"; + +async function run( + args: string[], + core = new TestCoreClient(), +): Promise<{ core: TestCoreClient; stdout: string }> { + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return { core, stdout: io.stdout() }; +} + +describe("gateway create commands", () => { + test.each([ + ["mcp", "arn:aws:iam::123456789012:role/gateway"], + ["http", undefined], + ] as const)("creates a %s Gateway", async (protocol, roleArn) => { + const response = { + gatewayId: GATEWAY_ID, + name: "orders", + status: "CREATING", + } as CreateGatewayResponse; + const core = new TestCoreClient(); + core.gateway.setCreateResponse(response); + + const result = await run( + [ + "gateway", + "create", + "--name", + "orders", + ...(roleArn ? ["--role-arn", roleArn] : []), + "--protocol", + protocol, + "--authorizer-type", + "AWS_IAM", + "--tags", + "env=test", + "team=agentcore", + ], + core, + ); + + expect(core.gateway.calls).toEqual([ + { + method: "createGateway", + args: [ + { + name: "orders", + ...(roleArn ? { roleArn } : {}), + protocol, + authorizerType: "AWS_IAM", + tags: { env: "test", team: "agentcore" }, + }, + { region: REGION }, + ], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); + + test("creates a guided MCP server Target", async () => { + const response = { + targetId: "target-1", + name: "calendar", + status: "CREATING", + } as CreateGatewayTargetResponse; + const core = new TestCoreClient(); + core.gateway.setCreateTargetResponse(response); + + const result = await run( + [ + "gateway", + "target", + "create", + "--gateway-id", + GATEWAY_ID, + "--name", + "calendar", + "--type", + "mcp-server", + "--endpoint", + "https://calendar.example.test/mcp", + ], + core, + ); + + expect(core.gateway.calls).toEqual([ + { + method: "createGatewayTarget", + args: [ + { + gatewayIdentifier: GATEWAY_ID, + name: "calendar", + targetConfiguration: { + mcp: { + mcpServer: { + endpoint: "https://calendar.example.test/mcp", + }, + }, + }, + }, + { region: REGION }, + ], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); + + test("creates an exact Runtime Target without requiring a name", async () => { + const response = { targetId: "target-1", status: "CREATING" } as CreateGatewayTargetResponse; + const core = new TestCoreClient(); + core.gateway.setCreateTargetResponse(response); + const targetConfiguration = { + http: { + agentcoreRuntime: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/runtime-1", + qualifier: "DEFAULT", + }, + }, + }; + + await run( + [ + "gateway", + "target", + "create", + "--gateway-id", + GATEWAY_ID, + "--target-configuration", + JSON.stringify(targetConfiguration), + ], + core, + ); + + expect(core.gateway.calls[0]).toEqual({ + method: "createGatewayTarget", + args: [ + { + gatewayIdentifier: GATEWAY_ID, + targetConfiguration, + }, + { region: REGION }, + ], + }); + }); + + test("creates a Rule with exact condition and action arrays", async () => { + const response = { + ruleId: "rule-1", + priority: 10, + status: "ACTIVE", + } as CreateGatewayRuleResponse; + const core = new TestCoreClient(); + core.gateway.setCreateRuleResponse(response); + const conditions = [{ matchPaths: { anyOf: ["/orders/*"] } }]; + const actions = [{ routeToTarget: { staticRoute: { targetName: "orders-api" } } }]; + + const result = await run( + [ + "gateway", + "rule", + "create", + "--gateway-id", + GATEWAY_ID, + "--priority", + "10", + "--conditions", + JSON.stringify(conditions), + "--actions", + JSON.stringify(actions), + ], + core, + ); + + expect(core.gateway.calls).toEqual([ + { + method: "createGatewayRule", + args: [ + { + gatewayIdentifier: GATEWAY_ID, + priority: 10, + conditions, + actions, + }, + { region: REGION }, + ], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); +}); + +describe("gateway create validation", () => { + test.each([ + ["Gateway name", ["gateway", "create"], /--name/], + ["Gateway authorizer", ["gateway", "create", "--name", "orders"], /--authorizer-type/], + ["Target parent", ["gateway", "target", "create"], /--gateway-id/], + ["Rule parent", ["gateway", "rule", "create"], /--gateway-id/], + ] as const)("rejects missing %s input before calling Core", async (_name, args, error) => { + const core = new TestCoreClient(); + + await expect(run([...args], core)).rejects.toThrow(error); + expect(core.gateway.calls).toEqual([]); + }); + + test("rejects conflicting guided and exact Target input", async () => { + const core = new TestCoreClient(); + + await expect( + run( + [ + "gateway", + "target", + "create", + "--gateway-id", + GATEWAY_ID, + "--name", + "calendar", + "--type", + "mcp-server", + "--endpoint", + "https://calendar.example.test/mcp", + "--target-configuration", + '{"mcp":{"mcpServer":{"endpoint":"https://other.example.test/mcp"}}}', + ], + core, + ), + ).rejects.toThrow(/mutually exclusive/); + expect(core.gateway.calls).toEqual([]); + }); +}); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 1dc8fe0ae..3e801e297 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -59,7 +59,7 @@ async function run( } describe("gateway command hierarchy", () => { - test("registers the Gateway read command hierarchy", () => { + test("registers the Gateway command hierarchy", () => { const root = createRootHandler(new TestCoreClient(), { io: testIO().io, logger: createSilentLogger(), @@ -72,15 +72,16 @@ describe("gateway command hierarchy", () => { expect(gateway?.flags().map((flag) => flag.name)).not.toContain("interactive"); expect(gateway?.children().map((child) => child.name())).toEqual([ + "create", "get", "list", "target", "connector", "rule", ]); - expect(target?.children().map((child) => child.name())).toEqual(["get", "list"]); + expect(target?.children().map((child) => child.name())).toEqual(["create", "get", "list"]); expect(connector?.children().map((child) => child.name())).toEqual(["get", "list"]); - expect(rule?.children().map((child) => child.name())).toEqual(["get", "list"]); + expect(rule?.children().map((child) => child.name())).toEqual(["create", "get", "list"]); }); test.each(["gateway", "gateway target", "gateway connector", "gateway rule"])( diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index 698e3719e..877bf9b11 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -3,6 +3,7 @@ import { Router } from "../../router"; import { createHelpDefault } from "../help"; import type { Core } from "../types"; import { createGatewayConnectorHandler } from "./connector"; +import { createCreateGatewayHandler } from "./create"; import { createGetGatewayHandler } from "./get"; import { createListGatewaysHandler } from "./list"; import { createGatewayRuleHandler } from "./rule"; @@ -11,6 +12,7 @@ import { createGatewayTargetHandler } from "./target"; export function createGatewayHandler(core: Core, io: AppIO): Router { return new Router("gateway", "inspect AgentCore Gateways") .default(createHelpDefault(io)) + .handler(createCreateGatewayHandler(core)) .handler(createGetGatewayHandler(core)) .handler(createListGatewaysHandler(core)) .handler(createGatewayTargetHandler(core, io)) diff --git a/src/handlers/gateway/mutations.ts b/src/handlers/gateway/mutations.ts new file mode 100644 index 000000000..dd1a34672 --- /dev/null +++ b/src/handlers/gateway/mutations.ts @@ -0,0 +1,102 @@ +import type { + CreateGatewayRequest, + TargetConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError } from "../../errors"; +import type { + CreateGatewayInput, + CreateGatewayTargetInput, + ResolvedCreateGatewayInput, +} from "./types"; + +export function buildGatewayCreateRequest(input: ResolvedCreateGatewayInput): CreateGatewayRequest { + validateGatewayCreateInput(input); + const { protocol, ...request } = input; + + return { + ...request, + ...(protocol === "mcp" ? { protocolType: "MCP" as const } : {}), + }; +} + +export function validateGatewayCreateInput(input: CreateGatewayInput): CreateGatewayInput { + const { protocol, ...request } = input; + + if (!request.name) { + throw new InputValidationError("Gateway name is required"); + } + if (!request.authorizerType) { + throw new InputValidationError("Gateway authorizer type is required"); + } + if (request.authorizerType === "CUSTOM_JWT" && !request.authorizerConfiguration) { + throw new InputValidationError("CUSTOM_JWT requires --authorizer-configuration"); + } + if (request.authorizerType !== "CUSTOM_JWT" && request.authorizerConfiguration) { + throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); + } + if (protocol === "http" && request.protocolConfiguration) { + throw new InputValidationError("--protocol-configuration is valid only with --protocol mcp"); + } + const policyEngine = request.policyEngineConfiguration; + if (policyEngine && (!policyEngine.arn || !policyEngine.mode)) { + throw new InputValidationError( + "Policy Engine attachment requires --policy-engine-arn and --policy-engine-mode", + ); + } + + return input; +} + +export function validateGatewayTargetCreateInput( + input: CreateGatewayTargetInput, +): CreateGatewayTargetInput { + if (!input.gatewayIdentifier) { + throw new InputValidationError("Gateway ID is required"); + } + if (!input.targetConfiguration) { + throw new InputValidationError("Target configuration is required"); + } + const variant = targetVariant(input.targetConfiguration); + if (!variant) { + throw new InputValidationError("Target configuration must use a known Target variant"); + } + if (!input.name && variant !== "http.agentcoreRuntime") { + throw new InputValidationError("Target name is required for non-Runtime targets"); + } + if ( + input.credentialProviderConfigurations && + input.credentialProviderConfigurations.length === 0 + ) { + throw new InputValidationError("Credential provider configurations cannot be empty"); + } + return input; +} + +export function targetVariant(configuration: TargetConfiguration): string | undefined { + if ("mcp" in configuration && configuration.mcp) { + return memberVariant("mcp", configuration.mcp, [ + "openApiSchema", + "smithyModel", + "lambda", + "mcpServer", + "apiGateway", + "connector", + ]); + } + if ("http" in configuration && configuration.http) { + return memberVariant("http", configuration.http, ["agentcoreRuntime", "passthrough"]); + } + if ("inference" in configuration && configuration.inference) { + return memberVariant("inference", configuration.inference, ["connector", "provider"]); + } + return undefined; +} + +function memberVariant( + outer: string, + value: object, + members: readonly string[], +): string | undefined { + const member = members.find((candidate) => candidate in value); + return member ? `${outer}.${member}` : undefined; +} diff --git a/src/handlers/gateway/rule/create/index.tsx b/src/handlers/gateway/rule/create/index.tsx new file mode 100644 index 000000000..296481b33 --- /dev/null +++ b/src/handlers/gateway/rule/create/index.tsx @@ -0,0 +1,51 @@ +import type { Action, Condition } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx, parseJsonArrayFlag } from "../../../utils"; +import type { CreateGatewayRuleInput } from "../../types"; + +export const createCreateGatewayRuleHandler = (core: Core) => + createHandler({ + name: "create", + description: "create a Gateway Rule", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag( + "priority", + "Rule priority from 1 to 1000000", + z.number().int().min(1).max(1_000_000).optional(), + ), + flag("conditions", "Rule conditions (JSON Condition[])", z.string().optional()), + flag("actions", "Rule actions (JSON Action[])", z.string().optional()), + flag("description", "Rule description", z.string().optional()), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (flags.priority === undefined) { + throw new InputValidationError("required option '--priority ' not specified"); + } + if (flags.actions === undefined) { + throw new InputValidationError("required option '--actions ' not specified"); + } + + const conditions = parseJsonArrayFlag("conditions", flags.conditions); + const actions = parseJsonArrayFlag("actions", flags.actions)!; + const input: CreateGatewayRuleInput = { + gatewayIdentifier: flags["gateway-id"], + priority: flags.priority, + actions, + ...(conditions !== undefined ? { conditions } : {}), + ...(flags.description ? { description: flags.description } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + ctx + .require(JsonRendererKey) + .renderJson(await core.gateway.createGatewayRule(input, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/gateway/rule/index.tsx b/src/handlers/gateway/rule/index.tsx index 40ed75f68..a26daa91b 100644 --- a/src/handlers/gateway/rule/index.tsx +++ b/src/handlers/gateway/rule/index.tsx @@ -2,12 +2,14 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { createHelpDefault } from "../../help"; import type { Core } from "../../types"; +import { createCreateGatewayRuleHandler } from "./create"; import { createGetGatewayRuleHandler } from "./get"; import { createListGatewayRulesHandler } from "./list"; export function createGatewayRuleHandler(core: Core, io: AppIO): Router { return new Router("rule", "inspect rules for an AgentCore Gateway") .default(createHelpDefault(io)) + .handler(createCreateGatewayRuleHandler(core)) .handler(createGetGatewayRuleHandler(core)) .handler(createListGatewayRulesHandler(core)); } diff --git a/src/handlers/gateway/target/create/index.tsx b/src/handlers/gateway/target/create/index.tsx new file mode 100644 index 000000000..a90827310 --- /dev/null +++ b/src/handlers/gateway/target/create/index.tsx @@ -0,0 +1,112 @@ +import type { + CredentialProviderConfiguration, + MetadataConfiguration, + PrivateEndpoint, + TargetConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; +import { validateGatewayTargetCreateInput } from "../../mutations"; +import type { CreateGatewayTargetInput } from "../../types"; + +export const createCreateGatewayTargetHandler = (core: Core) => + createHandler({ + name: "create", + description: "create a Gateway Target", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag( + "name", + "Target name (optional only for an exact Runtime Target)", + z.string().optional(), + ), + flag("description", "Target description", z.string().optional()), + flag("type", "guided Target type: mcp-server", z.enum(["mcp-server"]).optional()), + flag("endpoint", "MCP server HTTPS endpoint", z.string().optional()), + flag( + "target-configuration", + "complete Target configuration (JSON TargetConfiguration)", + z.string().optional(), + ), + flag( + "credential-provider-configurations", + "outbound credentials (JSON CredentialProviderConfiguration[])", + z.string().optional(), + ), + flag( + "metadata-configuration", + "metadata propagation (JSON MetadataConfiguration)", + z.string().optional(), + ), + flag("private-endpoint", "private endpoint (JSON PrivateEndpoint)", z.string().optional()), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + + const hasGuidedInput = flags.type !== undefined || flags.endpoint !== undefined; + if (flags["target-configuration"] !== undefined && hasGuidedInput) { + throw new InputValidationError( + "--target-configuration is mutually exclusive with --type and --endpoint", + ); + } + if (flags.type !== undefined && flags.endpoint === undefined) { + throw new InputValidationError("--type mcp-server requires --endpoint"); + } + if (flags.endpoint !== undefined && flags.type === undefined) { + throw new InputValidationError("--endpoint requires --type mcp-server"); + } + if (!hasGuidedInput && flags["target-configuration"] === undefined) { + throw new InputValidationError( + "either --type mcp-server with --endpoint or --target-configuration is required", + ); + } + + const exactConfiguration = parseJsonObjectFlag( + "target-configuration", + flags["target-configuration"], + ); + const targetConfiguration: TargetConfiguration = + exactConfiguration ?? + ({ + mcp: { + mcpServer: { + endpoint: flags.endpoint!, + }, + }, + } as TargetConfiguration); + const credentialProviderConfigurations = parseJsonArrayFlag( + "credential-provider-configurations", + flags["credential-provider-configurations"], + ); + const metadataConfiguration = parseJsonObjectFlag( + "metadata-configuration", + flags["metadata-configuration"], + ); + const privateEndpoint = parseJsonObjectFlag( + "private-endpoint", + flags["private-endpoint"], + ); + const input: CreateGatewayTargetInput = { + gatewayIdentifier: flags["gateway-id"], + targetConfiguration, + ...(flags.name ? { name: flags.name } : {}), + ...(flags.description ? { description: flags.description } : {}), + ...(credentialProviderConfigurations ? { credentialProviderConfigurations } : {}), + ...(metadataConfiguration ? { metadataConfiguration } : {}), + ...(privateEndpoint ? { privateEndpoint } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + validateGatewayTargetCreateInput(input); + ctx + .require(JsonRendererKey) + .renderJson(await core.gateway.createGatewayTarget(input, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/gateway/target/index.tsx b/src/handlers/gateway/target/index.tsx index 96f05dee9..898855bab 100644 --- a/src/handlers/gateway/target/index.tsx +++ b/src/handlers/gateway/target/index.tsx @@ -2,12 +2,14 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { createHelpDefault } from "../../help"; import type { Core } from "../../types"; +import { createCreateGatewayTargetHandler } from "./create"; import { createGetGatewayTargetHandler } from "./get"; import { createListGatewayTargetsHandler } from "./list"; export function createGatewayTargetHandler(core: Core, io: AppIO): Router { return new Router("target", "inspect targets for an AgentCore Gateway") .default(createHelpDefault(io)) + .handler(createCreateGatewayTargetHandler(core)) .handler(createGetGatewayTargetHandler(core)) .handler(createListGatewayTargetsHandler(core)); } diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index 0ed370130..436834e07 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -1,4 +1,10 @@ import type { + CreateGatewayRequest, + CreateGatewayResponse, + CreateGatewayRuleRequest, + CreateGatewayRuleResponse, + CreateGatewayTargetRequest, + CreateGatewayTargetResponse, GetGatewayResponse, GetGatewayRuleResponse, GetGatewayTargetResponse, @@ -8,7 +14,23 @@ import type { } from "@aws-sdk/client-bedrock-agentcore-control"; import type { CoreOptions } from "../../core/types"; +export type GatewayProtocol = "mcp" | "http"; + +export type CreateGatewayInput = Omit & { + roleArn?: string; + protocol: GatewayProtocol; +}; + +export type ResolvedCreateGatewayInput = CreateGatewayInput & { + roleArn: string; +}; + +export type CreateGatewayTargetInput = CreateGatewayTargetRequest; + +export type CreateGatewayRuleInput = CreateGatewayRuleRequest; + export interface CoreGatewayClient { + createGateway(input: CreateGatewayInput, options: CoreOptions): Promise; getGateway(id: string, options: CoreOptions): Promise; listGateways( nextToken: string | undefined, @@ -26,6 +48,10 @@ export interface CoreGatewayClient { maxResults: number | undefined, options: CoreOptions, ): Promise; + createGatewayTarget( + input: CreateGatewayTargetInput, + options: CoreOptions, + ): Promise; getGatewayRule( gatewayId: string, ruleId: string, @@ -37,4 +63,8 @@ export interface CoreGatewayClient { maxResults: number | undefined, options: CoreOptions, ): Promise; + createGatewayRule( + input: CreateGatewayRuleInput, + options: CoreOptions, + ): Promise; } diff --git a/src/handlers/utils.test.tsx b/src/handlers/utils.test.tsx index b9d5a384a..6b354f52b 100644 --- a/src/handlers/utils.test.tsx +++ b/src/handlers/utils.test.tsx @@ -1,5 +1,18 @@ import { describe, expect, test } from "bun:test"; -import { parseTags } from "./utils"; +import { parseJsonArrayFlag, parseJsonObjectFlag, parseTags } from "./utils"; + +describe("structured JSON flags", () => { + test("parses object and array values", () => { + expect(parseJsonObjectFlag("config", '{"enabled":true}')).toEqual({ enabled: true }); + expect(parseJsonArrayFlag("items", '[{"id":"a"}]')).toEqual([{ id: "a" }]); + }); + + test("rejects the wrong top-level shape", () => { + expect(() => parseJsonObjectFlag("config", "[]")).toThrow("must be a JSON object"); + expect(() => parseJsonObjectFlag("config", "null")).toThrow("must be a JSON object"); + expect(() => parseJsonArrayFlag("items", "{}")).toThrow("must be a JSON array"); + }); +}); describe("parseTags", () => { test("returns undefined for undefined input", () => { diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 514f94e3c..737f02455 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -51,6 +51,27 @@ export function parseJsonFlagWithSchema( return result.data; } +export function parseJsonObjectFlag( + name: string, + raw: string | undefined, +): T | undefined { + const parsed = parseJsonFlag(name, raw); + if (parsed === undefined) return undefined; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new InputValidationError(`Option '--${name}' must be a JSON object`); + } + return parsed as T; +} + +export function parseJsonArrayFlag(name: string, raw: string | undefined): T[] | undefined { + const parsed = parseJsonFlag(name, raw); + if (parsed === undefined) return undefined; + if (!Array.isArray(parsed)) { + throw new InputValidationError(`Option '--${name}' must be a JSON array`); + } + return parsed as T[]; +} + // parseTags parses a tags flag that accepts two mutually exclusive forms: // - Repeated key=value shorthand: ["env=prod", "team=foo"] // - A single JSON object: ['{"env":"prod","team":"foo"}'] diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index da72b69df..d4e246465 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -1,6 +1,9 @@ import type { CreateApiKeyCredentialProviderResponse, CreateOauth2CredentialProviderResponse, + CreateGatewayResponse, + CreateGatewayRuleResponse, + CreateGatewayTargetResponse, CreateHarnessEndpointRequest, CreateHarnessEndpointResponse, CreateHarnessResponse, @@ -78,7 +81,12 @@ import type { } from "@aws-sdk/client-bedrock-agentcore"; import type { Core } from "../handlers/types"; import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; -import type { CoreGatewayClient } from "../handlers/gateway/types"; +import type { + CoreGatewayClient, + CreateGatewayInput, + CreateGatewayRuleInput, + CreateGatewayTargetInput, +} from "../handlers/gateway/types"; import type { CoreIdentityClient, CreateApiKeyCredentialProviderInput, @@ -160,10 +168,13 @@ const DEFAULT_GET_MEMORY_RECORD_RESPONSE: GetMemoryRecordOutput = { memoryRecord const DEFAULT_LIST_MEMORY_RECORDS_RESPONSE: ListMemoryRecordsOutput = { memoryRecordSummaries: [], }; +const DEFAULT_CREATE_GATEWAY_RESPONSE = {} as CreateGatewayResponse; const DEFAULT_GET_GATEWAY_RESPONSE = {} as GetGatewayResponse; const DEFAULT_LIST_GATEWAYS_RESPONSE: ListGatewaysResponse = { items: [] }; +const DEFAULT_CREATE_GATEWAY_TARGET_RESPONSE = {} as CreateGatewayTargetResponse; const DEFAULT_GET_GATEWAY_TARGET_RESPONSE = {} as GetGatewayTargetResponse; const DEFAULT_LIST_GATEWAY_TARGETS_RESPONSE: ListGatewayTargetsResponse = { items: [] }; +const DEFAULT_CREATE_GATEWAY_RULE_RESPONSE = {} as CreateGatewayRuleResponse; const DEFAULT_GET_GATEWAY_RULE_RESPONSE = {} as GetGatewayRuleResponse; const DEFAULT_LIST_GATEWAY_RULES_RESPONSE: ListGatewayRulesResponse = { gatewayRules: [] }; const DEFAULT_CREATE_OAUTH2_RESPONSE = {} as CreateOauth2CredentialProviderResponse; @@ -807,14 +818,23 @@ export class TestMemoryClient implements CoreMemoryClient { export class TestGatewayClient implements CoreGatewayClient { readonly calls: RecordedCall[] = []; + private createResponse: CreateGatewayResponse = DEFAULT_CREATE_GATEWAY_RESPONSE; private getResponse: GetGatewayResponse = DEFAULT_GET_GATEWAY_RESPONSE; private listResponses = new Map(); + private createTargetResponse: CreateGatewayTargetResponse = + DEFAULT_CREATE_GATEWAY_TARGET_RESPONSE; private getTargetResponse: GetGatewayTargetResponse = DEFAULT_GET_GATEWAY_TARGET_RESPONSE; private listTargetResponses = new Map(); + private createRuleResponse: CreateGatewayRuleResponse = DEFAULT_CREATE_GATEWAY_RULE_RESPONSE; private getRuleResponse: GetGatewayRuleResponse = DEFAULT_GET_GATEWAY_RULE_RESPONSE; private listRuleResponses = new Map(); private error?: Error; + setCreateResponse(response: CreateGatewayResponse): this { + this.createResponse = response; + return this; + } + setGetResponse(response: GetGatewayResponse): this { this.getResponse = response; return this; @@ -825,6 +845,11 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + setCreateTargetResponse(response: CreateGatewayTargetResponse): this { + this.createTargetResponse = response; + return this; + } + setGetTargetResponse(response: GetGatewayTargetResponse): this { this.getTargetResponse = response; return this; @@ -835,6 +860,11 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + setCreateRuleResponse(response: CreateGatewayRuleResponse): this { + this.createRuleResponse = response; + return this; + } + setGetRuleResponse(response: GetGatewayRuleResponse): this { this.getRuleResponse = response; return this; @@ -850,6 +880,15 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + async createGateway( + input: CreateGatewayInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "createGateway", args: [input, options] }); + if (this.error) throw this.error; + return this.createResponse; + } + async getGateway(id: string, options: CoreOptions): Promise { this.calls.push({ method: "getGateway", args: [id, options] }); if (this.error) throw this.error; @@ -870,6 +909,15 @@ export class TestGatewayClient implements CoreGatewayClient { ); } + async createGatewayTarget( + input: CreateGatewayTargetInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "createGatewayTarget", args: [input, options] }); + if (this.error) throw this.error; + return this.createTargetResponse; + } + async getGatewayTarget( gatewayId: string, targetId: string, @@ -898,6 +946,15 @@ export class TestGatewayClient implements CoreGatewayClient { ); } + async createGatewayRule( + input: CreateGatewayRuleInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "createGatewayRule", args: [input, options] }); + if (this.error) throw this.error; + return this.createRuleResponse; + } + async getGatewayRule( gatewayId: string, ruleId: string, From b29a618fcb1906641ae0ea823422b0948e482e6f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 5 Aug 2026 18:24:16 +0000 Subject: [PATCH 02/15] refactor(gateway): finalize create command surface --- src/core/gateway.create.test.ts | 390 +++++----- src/core/gateway.tsx | 110 +-- src/core/gatewayExecutionRole.test.ts | 694 ++++++------------ src/core/gatewayExecutionRole.ts | 299 ++++---- src/core/gatewayExecutionRolePolicy.ts | 421 ++++++----- .../gateway/connector/create/index.tsx | 136 ++++ src/handlers/gateway/connector/index.tsx | 2 + src/handlers/gateway/connector/target.ts | 43 ++ src/handlers/gateway/create/index.tsx | 40 +- src/handlers/gateway/gateway.create.test.tsx | 467 +++++++++--- src/handlers/gateway/gateway.test.tsx | 2 +- src/handlers/gateway/index.tsx | 2 +- src/handlers/gateway/mutations.ts | 24 +- src/handlers/gateway/rule/create/index.tsx | 14 +- src/handlers/gateway/rule/index.tsx | 2 +- .../gateway/target/create/configuration.ts | 162 ++++ src/handlers/gateway/target/create/index.tsx | 93 +-- src/handlers/gateway/target/index.tsx | 2 +- src/handlers/gateway/types.tsx | 4 +- 19 files changed, 1670 insertions(+), 1237 deletions(-) create mode 100644 src/handlers/gateway/connector/create/index.tsx create mode 100644 src/handlers/gateway/connector/target.ts create mode 100644 src/handlers/gateway/target/create/configuration.ts diff --git a/src/core/gateway.create.test.ts b/src/core/gateway.create.test.ts index 11c2d9e09..4aac8d82f 100644 --- a/src/core/gateway.create.test.ts +++ b/src/core/gateway.create.test.ts @@ -3,16 +3,14 @@ import { CreateGatewayCommand, CreateGatewayRuleCommand, CreateGatewayTargetCommand, - GetGatewayTargetCommand, GetGatewayCommand, - GetOauth2CredentialProviderCommand, - ListGatewayTargetsCommand, type BedrockAgentCoreControlClient, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import { CreateRoleCommand, GetRoleCommand, + GetRolePolicyCommand, PutRolePolicyCommand, type IAMClient, } from "@aws-sdk/client-iam"; @@ -47,78 +45,66 @@ function noSuchEntity(): Error { return error; } -describe("Gateway create Core command mapping", () => { - test("maps MCP and HTTP protocols to their SDK representations", async () => { +function managedRole(name: string) { + return { + Path: "/", + RoleName: name, + RoleId: "AROATEST", + Arn: `arn:aws:iam::123456789012:role/${name}`, + CreateDate: new Date("2026-08-05T00:00:00Z"), + Tags: [GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG], + }; +} + +describe("Gateway create Core operations", () => { + test("maps unrestricted and MCP Gateways directly when a role is supplied", async () => { const sent: SentCommand[] = []; const core = createCore(async (command) => { sent.push(command); return {}; }); + const roleArn = "arn:aws:iam::123456789012:role/gateway"; await core.gateway.createGateway( - { - name: "mcp-gateway", - roleArn: "arn:aws:iam::123456789012:role/gateway", - protocol: "mcp", - authorizerType: "AWS_IAM", - }, + { name: "unrestricted", roleArn, authorizerType: "AWS_IAM" }, { region: "us-west-2" }, ); await core.gateway.createGateway( - { - name: "http-gateway", - roleArn: "arn:aws:iam::123456789012:role/gateway", - protocol: "http", - authorizerType: "AWS_IAM", - }, + { name: "mcp-only", roleArn, protocol: "mcp", authorizerType: "AWS_IAM" }, { region: "us-west-2" }, ); - expect(sent[0]).toBeInstanceOf(CreateGatewayCommand); - expect(sent[0]!.input).toEqual({ - name: "mcp-gateway", - roleArn: "arn:aws:iam::123456789012:role/gateway", - protocolType: "MCP", - authorizerType: "AWS_IAM", - }); - expect(sent[1]).toBeInstanceOf(CreateGatewayCommand); - expect(sent[1]!.input).toEqual({ - name: "http-gateway", - roleArn: "arn:aws:iam::123456789012:role/gateway", - authorizerType: "AWS_IAM", - }); + expect(sent.map(({ input }) => input)).toEqual([ + { + name: "unrestricted", + roleArn, + authorizerType: "AWS_IAM", + }, + { + name: "mcp-only", + roleArn, + protocolType: "MCP", + authorizerType: "AWS_IAM", + }, + ]); + expect(sent.every((command) => command instanceof CreateGatewayCommand)).toBe(true); }); - test("provisions and tightens a default role around Gateway creation", async () => { + test("prepares and finalizes a CLI-managed Gateway role", async () => { const operations: SentCommand[] = []; - const policyNames: string[] = []; const roleName = gatewayExecutionRoleName("orders", "us-west-2"); - const roleArn = `arn:aws:iam::123456789012:role/${roleName}`; - const gatewayArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123"; + const role = managedRole(roleName); + const gatewayArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc"; const core = createCore( async (command) => { operations.push(command); - return { gatewayId: "orders-abc123", gatewayArn }; + return { gatewayId: "orders-abc", gatewayArn }; }, async (command) => { operations.push(command); if (command instanceof GetRoleCommand) throw noSuchEntity(); - if (command instanceof CreateRoleCommand) { - return { - Role: { - Path: "/", - RoleName: roleName, - RoleId: "AROATEST", - Arn: roleArn, - CreateDate: new Date("2026-08-03T00:00:00Z"), - Tags: [GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG], - }, - }; - } - if (command instanceof PutRolePolicyCommand) { - policyNames.push((command.input as { PolicyName: string }).PolicyName); - return {}; - } + if (command instanceof CreateRoleCommand) return { Role: role }; + if (command instanceof GetRolePolicyCommand) throw noSuchEntity(); return {}; }, ); @@ -126,7 +112,6 @@ describe("Gateway create Core command mapping", () => { await core.gateway.createGateway( { name: "orders", - protocol: "http", authorizerType: "AWS_IAM", policyEngineConfiguration: { arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", @@ -140,86 +125,23 @@ describe("Gateway create Core command mapping", () => { "GetRoleCommand", "CreateRoleCommand", "UpdateAssumeRolePolicyCommand", + "GetRolePolicyCommand", "PutRolePolicyCommand", "CreateGatewayCommand", - "PutRolePolicyCommand", "UpdateAssumeRolePolicyCommand", + "PutRolePolicyCommand", ]); - expect(operations[4]).toBeInstanceOf(CreateGatewayCommand); - expect(operations[4]!.input).toEqual({ - name: "orders", - roleArn, - authorizerType: "AWS_IAM", - policyEngineConfiguration: { - arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", - mode: "ENFORCE", - }, - }); - expect(policyNames).toEqual([GATEWAY_EXECUTION_POLICY_NAME, GATEWAY_EXECUTION_POLICY_NAME]); - }); - - test("maps Target and Rule create inputs directly", async () => { - const sent: SentCommand[] = []; - const core = createCore(async (command) => { - sent.push(command); - if (command instanceof GetGatewayCommand) { - return { - gatewayId: "gateway-1", - name: "gateway", - roleArn: "arn:aws:iam::123456789012:role/customer-managed", - }; - } - return {}; - }); - const targetConfiguration = { - http: { - agentcoreRuntime: { - arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/runtime-1", - }, - }, - } as const; - const actions = [{ routeToTarget: { staticRoute: { targetName: "runtime-target" } } }]; - - await core.gateway.createGatewayTarget( - { - gatewayIdentifier: "gateway-1", - targetConfiguration, - }, - { region: "us-west-2" }, - ); - await core.gateway.createGatewayRule( - { - gatewayIdentifier: "gateway-1", - priority: 10, - actions, - }, - { region: "us-west-2" }, - ); - - expect(sent[0]).toBeInstanceOf(GetGatewayCommand); - expect(sent[1]).toBeInstanceOf(CreateGatewayTargetCommand); - expect(sent[1]!.input).toEqual({ - gatewayIdentifier: "gateway-1", - targetConfiguration, - }); - expect(sent[2]).toBeInstanceOf(CreateGatewayRuleCommand); - expect(sent[2]!.input).toEqual({ - gatewayIdentifier: "gateway-1", - priority: 10, - actions, - }); + expect(operations[5]!.input).toMatchObject({ roleArn: role.Arn }); + for (const command of operations.filter( + (operation) => operation instanceof PutRolePolicyCommand, + )) { + expect(command.input).toMatchObject({ PolicyName: GATEWAY_EXECUTION_POLICY_NAME }); + } }); - test("prepares the union of every paginated Target and deduplicates provider lookups", async () => { + test("adds managed-role Target permissions before create and commits them", async () => { const operations: SentCommand[] = []; const roleName = gatewayExecutionRoleName("orders", "us-west-2"); - const roleArn = `arn:aws:iam::123456789012:role/${roleName}`; - const oauthProviderArn = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + - "token-vault/default/oauth2credentialprovider/shared"; - const workloadIdentityArn = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + - "workload-identity-directory/default/workload-identity/orders-identity"; let storedPolicy: string | undefined; const core = createCore( async (command) => { @@ -229,100 +151,42 @@ describe("Gateway create Core command mapping", () => { gatewayId: "gateway-1", gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", name: "orders", - roleArn, - workloadIdentityDetails: { workloadIdentityArn }, - }; - } - if (command instanceof ListGatewayTargetsCommand) { - return command.input.nextToken - ? { items: [{ targetId: "oauth-target" }] } - : { items: [{ targetId: "lambda-target" }], nextToken: "page-2" }; - } - if (command instanceof GetGatewayTargetCommand) { - if (command.input.targetId === "lambda-target") { - return { - targetConfiguration: { - mcp: { - lambda: { - lambdaArn: "arn:aws:lambda:us-west-2:123456789012:function:existing", - toolSchema: { inlinePayload: [] }, - }, - }, - }, - }; - } - return { - targetConfiguration: { - mcp: { mcpServer: { endpoint: "https://existing.example.test/mcp" } }, - }, - credentialProviderConfigurations: [ - { - credentialProviderType: "OAUTH", - credentialProvider: { - oauthCredentialProvider: { - providerArn: oauthProviderArn, - scopes: ["read"], - }, - }, - }, - ], - }; - } - if (command instanceof GetOauth2CredentialProviderCommand) { - return { - clientSecretArn: { - secretArn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:shared", - }, + roleArn: managedRole(roleName).Arn, }; } if (command instanceof CreateGatewayTargetCommand) { - return { targetId: "new-target", status: "CREATING" }; + return { targetId: "target-1" }; } throw new Error(`unexpected ${command.constructor.name}`); }, async (command) => { operations.push(command); - if (command instanceof GetRoleCommand) { - return { - Role: { - Path: "/", - RoleName: roleName, - RoleId: "AROATEST", - Arn: roleArn, - CreateDate: new Date("2026-08-03T00:00:00Z"), - Tags: [GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG], - }, - }; + if (command instanceof GetRoleCommand) return { Role: managedRole(roleName) }; + if (command instanceof GetRolePolicyCommand) { + if (!storedPolicy) throw noSuchEntity(); + return { PolicyDocument: storedPolicy }; } if (command instanceof PutRolePolicyCommand) { - storedPolicy = (command.input as { PolicyDocument: string }).PolicyDocument; - expect((command.input as { PolicyName: string }).PolicyName).toBe( - GATEWAY_EXECUTION_POLICY_NAME, - ); + storedPolicy = command.input.PolicyDocument; return {}; } throw new Error(`unexpected ${command.constructor.name}`); }, ); + const lambdaArn = "arn:aws:lambda:us-west-2:123456789012:function:calculator"; await core.gateway.createGatewayTarget( { gatewayIdentifier: "gateway-1", - name: "new-target", + name: "calculator", targetConfiguration: { - mcp: { mcpServer: { endpoint: "https://new.example.test/mcp" } }, - }, - credentialProviderConfigurations: [ - { - credentialProviderType: "OAUTH", - credentialProvider: { - oauthCredentialProvider: { - providerArn: oauthProviderArn, - scopes: ["write"], - }, + mcp: { + lambda: { + lambdaArn, + toolSchema: { inlinePayload: [] }, }, }, - ], + }, }, { region: "us-west-2" }, ); @@ -331,30 +195,126 @@ describe("Gateway create Core command mapping", () => { expect(names).toEqual([ "GetGatewayCommand", "GetRoleCommand", - "ListGatewayTargetsCommand", - "GetGatewayTargetCommand", - "ListGatewayTargetsCommand", - "GetGatewayTargetCommand", - "GetOauth2CredentialProviderCommand", + "GetRolePolicyCommand", "PutRolePolicyCommand", "CreateGatewayTargetCommand", + "GetRolePolicyCommand", ]); - expect( - operations.filter((command) => command instanceof GetOauth2CredentialProviderCommand), - ).toHaveLength(1); expect(names.indexOf("PutRolePolicyCommand")).toBeLessThan( names.indexOf("CreateGatewayTargetCommand"), ); - const statements = JSON.parse(storedPolicy!).Statement as { - Sid: string; - Resource: string | string[]; - }[]; - expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetLambda")?.Resource).toEqual([ - "arn:aws:lambda:us-west-2:123456789012:function:existing", - "arn:aws:lambda:us-west-2:123456789012:function:existing:*", - ]); - expect( - statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetCredentialSecrets")?.Resource, - ).toEqual(["arn:aws:secretsmanager:us-west-2:123456789012:secret:shared"]); + expect(JSON.parse(storedPolicy!).Statement).toContainEqual({ + Sid: "AgentCoreGatewayTargetLambda", + Effect: "Allow", + Action: "lambda:InvokeFunction", + Resource: [lambdaArn, `${lambdaArn}:*`], + }); + }); + + test("restores the previous managed-role policy when Target create fails", async () => { + const roleName = gatewayExecutionRoleName("orders", "us-west-2"); + const previousPolicy = JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Sid: "AgentCoreGatewayConfigLambda", + Effect: "Allow", + Action: "lambda:InvokeFunction", + Resource: "arn:aws:lambda:us-west-2:123456789012:function:guard", + }, + ], + }); + const writtenPolicies: string[] = []; + const createError = new Error("target rejected"); + const core = createCore( + async (command) => { + if (command instanceof GetGatewayCommand) { + return { + gatewayId: "gateway-1", + gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", + name: "orders", + roleArn: managedRole(roleName).Arn, + }; + } + if (command instanceof CreateGatewayTargetCommand) throw createError; + throw new Error(`unexpected ${command.constructor.name}`); + }, + async (command) => { + if (command instanceof GetRoleCommand) return { Role: managedRole(roleName) }; + if (command instanceof GetRolePolicyCommand) { + return { PolicyDocument: previousPolicy }; + } + if (command instanceof PutRolePolicyCommand) { + writtenPolicies.push(command.input.PolicyDocument!); + return {}; + } + throw new Error(`unexpected ${command.constructor.name}`); + }, + ); + + await expect( + core.gateway.createGatewayTarget( + { + gatewayIdentifier: "gateway-1", + name: "calculator", + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: "arn:aws:lambda:us-west-2:123456789012:function:calculator", + toolSchema: { inlinePayload: [] }, + }, + }, + }, + }, + { region: "us-west-2" }, + ), + ).rejects.toBe(createError); + + expect(writtenPolicies).toHaveLength(2); + expect(JSON.parse(writtenPolicies[1]!)).toEqual(JSON.parse(previousPolicy)); + }); + + test("leaves customer-managed Target roles untouched and maps Rules directly", async () => { + const sent: SentCommand[] = []; + const core = createCore(async (command) => { + sent.push(command); + if (command instanceof GetGatewayCommand) { + return { + gatewayId: "gateway-1", + name: "orders", + roleArn: "arn:aws:iam::123456789012:role/customer-managed", + }; + } + return {}; + }); + const targetConfiguration = { + http: { + agentcoreRuntime: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", + }, + }, + } as const; + const actions = [{ routeToTarget: { staticRoute: { targetName: "orders" } } }]; + + await core.gateway.createGatewayTarget( + { gatewayIdentifier: "gateway-1", targetConfiguration }, + { region: "us-west-2" }, + ); + await core.gateway.createGatewayRule( + { gatewayIdentifier: "gateway-1", priority: 10, actions }, + { region: "us-west-2" }, + ); + + expect(sent[1]).toBeInstanceOf(CreateGatewayTargetCommand); + expect(sent[1]!.input).toEqual({ + gatewayIdentifier: "gateway-1", + targetConfiguration, + }); + expect(sent[2]).toBeInstanceOf(CreateGatewayRuleCommand); + expect(sent[2]!.input).toEqual({ + gatewayIdentifier: "gateway-1", + priority: 10, + actions, + }); }); }); diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 1582332c3..243ebdccb 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -2,10 +2,10 @@ import { CreateGatewayCommand, CreateGatewayRuleCommand, CreateGatewayTargetCommand, + GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayRuleCommand, GetGatewayTargetCommand, - GetApiKeyCredentialProviderCommand, GetOauth2CredentialProviderCommand, ListGatewayRulesCommand, ListGatewaysCommand, @@ -34,8 +34,7 @@ import type { } from "../handlers/gateway/types"; import type { AwsClients, CoreOptions } from "./types"; import { - ensureGatewayExecutionRole, - getManagedGatewayTargetExecutionRole, + GatewayExecutionRole, retryWhileGatewayRoleChangesPropagate, type GatewayTargetRoleConfiguration, } from "./gatewayExecutionRole"; @@ -57,8 +56,7 @@ export class GatewayClient implements CoreGatewayClient { } const iam = this.clients.iam({ region: options.region }); - const role = await ensureGatewayExecutionRole(iam, input.name!, options.region, input); - await role.updatePolicy(); + const role = await GatewayExecutionRole.prepareCreate(iam, input.name!, options.region, input); const response = await retryWhileGatewayRoleChangesPropagate(() => control.send( new CreateGatewayCommand(buildGatewayCreateRequest({ ...input, roleArn: role.roleArn })), @@ -67,8 +65,14 @@ export class GatewayClient implements CoreGatewayClient { if (!response.gatewayArn) { throw new Error("CreateGateway response did not include the Gateway ARN"); } - await role.updatePolicy(response.gatewayArn); - await role.updateTrust(response.gatewayArn); + try { + await role.finalizeGateway(input, response.gatewayArn); + } catch (error) { + throw new AggregateError( + [error], + `Gateway "${response.gatewayId ?? input.name}" was created, but its CLI-managed execution role could not be finalized`, + ); + } return response; } @@ -126,21 +130,39 @@ export class GatewayClient implements CoreGatewayClient { new GetGatewayCommand({ gatewayIdentifier: request.gatewayIdentifier }), ); const iam = this.clients.iam({ region: options.region }); - const role = await getManagedGatewayTargetExecutionRole(iam, gateway, options.region); + const role = await GatewayExecutionRole.fromGateway(iam, gateway, options.region); if (!role) { return control.send(new CreateGatewayTargetCommand(request)); } - const configurations = await listTargetRoleConfigurations( - control, - request.gatewayIdentifier!, - undefined, - request, - ); - await role.updatePolicy(configurations); - return retryWhileGatewayRoleChangesPropagate(() => - control.send(new CreateGatewayTargetCommand(request)), + const preparation = await role.prepareTargetPolicy( + await targetRoleConfiguration(control, request), ); + let response: CreateGatewayTargetResponse; + try { + response = await retryWhileGatewayRoleChangesPropagate(() => + control.send(new CreateGatewayTargetCommand(request)), + ); + } catch (error) { + try { + await preparation.rollback(); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + "Gateway Target creation failed and its execution-role policy could not be rolled back", + ); + } + throw error; + } + try { + await preparation.commit(); + } catch (error) { + throw new AggregateError( + [error], + `Gateway Target "${response.targetId ?? request.name}" was created, but its CLI-managed execution-role policy could not be finalized`, + ); + } + return response; } async getGatewayRule( @@ -179,49 +201,6 @@ export class GatewayClient implements CoreGatewayClient { } } -async function listTargetRoleConfigurations( - control: BedrockAgentCoreControlClient, - gatewayId: string, - excludedTargetId?: string, - replacement?: TargetRoleConfigurationSource, -): Promise { - const configurations: GatewayTargetRoleConfiguration[] = []; - const providerSecrets = new Map>(); - let nextToken: string | undefined; - do { - const page = await control.send( - new ListGatewayTargetsCommand({ - gatewayIdentifier: gatewayId, - nextToken, - maxResults: 100, - }), - ); - const targets = await Promise.all( - (page.items ?? []) - .filter(({ targetId }) => targetId && targetId !== excludedTargetId) - .map(({ targetId }) => - control.send( - new GetGatewayTargetCommand({ - gatewayIdentifier: gatewayId, - targetId, - }), - ), - ), - ); - configurations.push( - ...(await Promise.all( - targets.map((target) => targetRoleConfiguration(control, target, providerSecrets)), - )), - ); - nextToken = page.nextToken; - } while (nextToken); - - if (replacement) { - configurations.push(await targetRoleConfiguration(control, replacement, providerSecrets)); - } - return configurations; -} - type TargetRoleConfigurationSource = Pick< CreateGatewayTargetInput, "targetConfiguration" | "credentialProviderConfigurations" @@ -230,7 +209,6 @@ type TargetRoleConfigurationSource = Pick< async function targetRoleConfiguration( control: BedrockAgentCoreControlClient, target: TargetRoleConfigurationSource, - providerSecrets = new Map>(), ): Promise { return { targetConfiguration: target.targetConfiguration, @@ -238,7 +216,6 @@ async function targetRoleConfiguration( credentialProviderSecretArns: await credentialProviderSecretArns( control, target.credentialProviderConfigurations, - providerSecrets, ), }; } @@ -246,7 +223,6 @@ async function targetRoleConfiguration( async function credentialProviderSecretArns( control: BedrockAgentCoreControlClient, configurations: CreateGatewayTargetInput["credentialProviderConfigurations"], - cache: Map>, ): Promise { const providers = new Map(); for (const configuration of configurations ?? []) { @@ -270,13 +246,7 @@ async function credentialProviderSecretArns( const secrets = await Promise.all( [...providers].map(async ([providerArn, type]) => { - const key = `${type}:${providerArn}`; - let lookup = cache.get(key); - if (!lookup) { - lookup = lookupCredentialProviderSecret(control, providerArn, type); - cache.set(key, lookup); - } - return lookup; + return lookupCredentialProviderSecret(control, providerArn, type); }), ); return secrets.filter((secretArn): secretArn is string => Boolean(secretArn)); diff --git a/src/core/gatewayExecutionRole.test.ts b/src/core/gatewayExecutionRole.test.ts index bf164743f..0556d1cf3 100644 --- a/src/core/gatewayExecutionRole.test.ts +++ b/src/core/gatewayExecutionRole.test.ts @@ -1,264 +1,149 @@ import { describe, expect, test } from "bun:test"; +import type { GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { GetRoleCommand, type IAMClient } from "@aws-sdk/client-iam"; import { - CreateRoleCommand, - DeleteRolePolicyCommand, - GetRoleCommand, - PutRolePolicyCommand, - UpdateAssumeRolePolicyCommand, - type IAMClient, -} from "@aws-sdk/client-iam"; -import { - ensureGatewayExecutionRole, + GatewayExecutionRole, gatewayExecutionRoleName, - GATEWAY_EXECUTION_POLICY_NAME, GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG, - getManagedGatewayTargetExecutionRole, retryWhileGatewayRoleChangesPropagate, type GatewayTargetRoleConfiguration, } from "./gatewayExecutionRole"; +import { GatewayExecutionPolicy } from "./gatewayExecutionRolePolicy"; -type SentCommand = { - input: Record; - constructor: { name: string }; -}; - -const ACCOUNT_ID = "123456789012"; const REGION = "us-west-2"; -const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123"; -const POLICY_ENGINE_ARN = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders-policy"; -const INTERCEPTOR_ARN = "arn:aws:lambda:us-west-2:123456789012:function:interceptor"; -const TRANSFORM_ARN = "arn:aws:lambda:us-west-2:123456789012:function:transform"; - -function noSuchEntity(): Error { - const error = new Error("not found"); - error.name = "NoSuchEntityException"; - return error; -} - -function alreadyExists(): Error { - const error = new Error("already exists"); - error.name = "EntityAlreadyExistsException"; - return error; -} - -function ownedRole(roleName: string) { - return { - Path: "/", - RoleName: roleName, - RoleId: "AROATEST", - Arn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, - CreateDate: new Date("2026-08-03T00:00:00Z"), - Tags: [GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG], - }; -} - -function fakeIam(send: (command: SentCommand) => Promise): IAMClient { - return { send } as unknown as IAMClient; -} - -function requiredString(value: string | undefined): string { - expect(value).toBeString(); - return value!; -} - -function policyStatements(policyDocument: string): Record[] { - return JSON.parse(policyDocument).Statement; +const ACCOUNT_ID = "123456789012"; +const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc"; +const WORKLOAD_IDENTITY_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + + "workload-identity-directory/default/workload-identity/orders-identity"; + +function policyStatements(policy: GatewayExecutionPolicy) { + return JSON.parse(policy.toJSON()!).Statement as { + Sid: string; + Action: string | string[]; + Resource: string | string[]; + }[]; } describe("Gateway execution role ownership", () => { test("derives deterministic collision-resistant names within IAM limits", () => { - const longName = "A".repeat(48); + const name = "a".repeat(100); + const first = gatewayExecutionRoleName(name, REGION); - expect(gatewayExecutionRoleName(longName, REGION).length).toBeLessThanOrEqual(64); - expect(gatewayExecutionRoleName("orders", REGION)).toBe( - gatewayExecutionRoleName("orders", REGION), - ); - expect(gatewayExecutionRoleName("orders", REGION)).not.toBe( - gatewayExecutionRoleName("Orders", REGION), - ); - expect(gatewayExecutionRoleName("orders", REGION)).not.toBe( - gatewayExecutionRoleName("orders", "us-east-1"), - ); - expect(gatewayExecutionRoleName(`${"a".repeat(32)}-one`, REGION)).not.toBe( - gatewayExecutionRoleName(`${"a".repeat(32)}-two`, REGION), - ); - }); - - test("creates, tags, scopes, and finalizes a least-privilege role", async () => { - const sent: SentCommand[] = []; - const iam = fakeIam(async (command) => { - sent.push(command); - if (command instanceof GetRoleCommand) throw noSuchEntity(); - if (command instanceof CreateRoleCommand) { - return { Role: ownedRole(requiredString(command.input.RoleName)) }; - } - return {}; - }); - - const provisioning = await ensureGatewayExecutionRole(iam, "orders", REGION, { - policyEngineConfiguration: { arn: POLICY_ENGINE_ARN, mode: "ENFORCE" }, - interceptorConfigurations: [ - { - interceptor: { lambda: { arn: INTERCEPTOR_ARN } }, - interceptionPoints: ["REQUEST"], - }, - ], - }); - await provisioning.updatePolicy(); - await provisioning.updateTrust(GATEWAY_ARN); - await provisioning.updatePolicy(GATEWAY_ARN); - - const create = sent.find((command) => command instanceof CreateRoleCommand)!; - const bootstrapTrust = JSON.parse(requiredString(create.input.AssumeRolePolicyDocument)) - .Statement[0]; - expect(bootstrapTrust).toMatchObject({ - Effect: "Deny", - Principal: { Service: "bedrock-agentcore.amazonaws.com" }, - Action: "sts:AssumeRole", - }); - expect(create.input.Tags).toContainEqual(GATEWAY_ROLE_MANAGED_BY_TAG); - expect(create.input.Tags).toContainEqual(GATEWAY_ROLE_RESOURCE_TYPE_TAG); - - const trustUpdates = sent.filter((command) => command instanceof UpdateAssumeRolePolicyCommand); - expect(trustUpdates).toHaveLength(2); - expect( - JSON.parse(requiredString(trustUpdates[0]!.input.PolicyDocument)).Statement[0].Condition, - ).toEqual({ - StringEquals: { "aws:SourceAccount": ACCOUNT_ID }, - ArnLike: { - "aws:SourceArn": `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:gateway/orders-*`, - }, - }); - expect( - JSON.parse(requiredString(trustUpdates[1]!.input.PolicyDocument)).Statement[0].Condition - .ArnLike, - ).toEqual({ "aws:SourceArn": GATEWAY_ARN }); - - const policyUpdates = sent.filter((command) => command instanceof PutRolePolicyCommand); - expect(policyUpdates).toHaveLength(2); - expect(policyUpdates.map(({ input }) => input.PolicyName)).toEqual([ - GATEWAY_EXECUTION_POLICY_NAME, - GATEWAY_EXECUTION_POLICY_NAME, - ]); - const prepared = policyStatements(requiredString(policyUpdates[0]!.input.PolicyDocument)); - const finalized = policyStatements(requiredString(policyUpdates[1]!.input.PolicyDocument)); - expect(prepared.find(({ Sid }) => Sid === "AgentCoreGatewayConfigLambda")).toMatchObject({ - Action: "lambda:InvokeFunction", - Resource: [INTERCEPTOR_ARN], - }); - expect(prepared.find(({ Sid }) => Sid === "AgentCoreGatewayConfigPolicyEngine")).toMatchObject({ - Action: "bedrock-agentcore:GetPolicyEngine", - Resource: POLICY_ENGINE_ARN, - }); - expect( - prepared.find(({ Sid }) => Sid === "AgentCoreGatewayConfigPolicyAuthorization"), - ).toMatchObject({ - Action: ["bedrock-agentcore:AuthorizeAction", "bedrock-agentcore:PartiallyAuthorizeActions"], - Resource: [ - `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:gateway/orders-*`, - POLICY_ENGINE_ARN, - ], - }); - expect(prepared.find(({ Sid }) => Sid === "AgentCoreGatewayConfigGuardrail")).toMatchObject({ - Action: "bedrock:InvokeGuardrailChecks", - Resource: "*", - }); - - expect( - finalized.find(({ Sid }) => Sid === "AgentCoreGatewayConfigPolicyAuthorization")!.Resource, - ).toEqual([GATEWAY_ARN, POLICY_ENGINE_ARN]); + expect(first.length).toBeLessThanOrEqual(64); + expect(gatewayExecutionRoleName(name, REGION)).toBe(first); + expect(gatewayExecutionRoleName(name, "us-east-1")).not.toBe(first); + expect(gatewayExecutionRoleName(`${name}b`, REGION)).not.toBe(first); }); test("refuses to adopt an untagged role with the generated name", async () => { const roleName = gatewayExecutionRoleName("orders", REGION); - const iam = fakeIam(async (command) => { - if (command instanceof GetRoleCommand) { - return { Role: { ...ownedRole(roleName), Tags: [] } }; - } - throw new Error(`unexpected ${command.constructor.name}`); - }); + const iam = { + send: async (command: unknown) => { + expect(command).toBeInstanceOf(GetRoleCommand); + return { + Role: { + RoleName: roleName, + Arn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, + Tags: [], + }, + }; + }, + } as unknown as IAMClient; - await expect(ensureGatewayExecutionRole(iam, "orders", REGION, {})).rejects.toThrow( - "not managed by the agentcore CLI", + await expect(GatewayExecutionRole.prepareCreate(iam, "orders", REGION, {})).rejects.toThrow( + "is not managed by the agentcore CLI", ); }); - test("rechecks ownership after a concurrent creator wins the role-name race", async () => { - const sent: SentCommand[] = []; + test("recognizes CLI ownership only when both Gateway tags are present", async () => { const roleName = gatewayExecutionRoleName("orders", REGION); - let getRoleCalls = 0; - const iam = fakeIam(async (command) => { - sent.push(command); - if (command instanceof GetRoleCommand) { - getRoleCalls += 1; - if (getRoleCalls === 1) throw noSuchEntity(); - return { Role: ownedRole(roleName) }; - } - if (command instanceof CreateRoleCommand) throw alreadyExists(); - return {}; - }); + const iam = { + send: async () => ({ + Role: { + RoleName: roleName, + Arn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, + Tags: [GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG], + }, + }), + } as unknown as IAMClient; - const provisioning = await ensureGatewayExecutionRole(iam, "orders", REGION, {}); + const role = await GatewayExecutionRole.fromGateway( + iam, + { + gatewayId: "orders-abc", + gatewayArn: GATEWAY_ARN, + name: "orders", + roleArn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, + } as GetGatewayResponse, + REGION, + ); - expect(provisioning.roleArn).toBe(`arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`); - expect(sent.map((command) => command.constructor.name)).toEqual([ - "GetRoleCommand", - "CreateRoleCommand", - "GetRoleCommand", - "UpdateAssumeRolePolicyCommand", - ]); + expect(role?.roleName).toBe(roleName); }); +}); - test("omits an inline policy when the Gateway requires no execution permissions", async () => { - const roleName = gatewayExecutionRoleName("orders", REGION); - const sent: SentCommand[] = []; - const iam = fakeIam(async (command) => { - sent.push(command); - if (command instanceof GetRoleCommand) return { Role: ownedRole(roleName) }; - if (command instanceof DeleteRolePolicyCommand) throw noSuchEntity(); - return {}; - }); - - const provisioning = await ensureGatewayExecutionRole(iam, "orders", REGION, {}); - await provisioning.updatePolicy(); - await provisioning.updatePolicy(GATEWAY_ARN); +describe("Gateway execution policy", () => { + test("derives Gateway configuration permissions", () => { + const policy = GatewayExecutionPolicy.forGateway( + { + interceptorConfigurations: [ + { + interceptor: { + lambda: { + arn: "arn:aws:lambda:us-west-2:123456789012:function:interceptor", + }, + }, + interceptionPoints: ["REQUEST"], + }, + ], + policyEngineConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", + mode: "ENFORCE", + }, + }, + GATEWAY_ARN, + ); - expect(sent.filter((command) => command instanceof PutRolePolicyCommand)).toHaveLength(0); - expect(sent.filter((command) => command instanceof DeleteRolePolicyCommand)).toHaveLength(2); + expect(policyStatements(policy).map(({ Sid }) => Sid)).toEqual([ + "AgentCoreGatewayConfigGuardrail", + "AgentCoreGatewayConfigLambda", + "AgentCoreGatewayConfigPolicyAuthorization", + "AgentCoreGatewayConfigPolicyEngine", + ]); }); - test("derives least-privilege permissions for modeled Target resources", async () => { - const roleName = gatewayExecutionRoleName("orders", REGION); - const sent: SentCommand[] = []; - const iam = fakeIam(async (command) => { - sent.push(command); - if (command instanceof GetRoleCommand) { - return { Role: ownedRole(roleName) }; - } - if (command instanceof PutRolePolicyCommand) return {}; - throw new Error(`unexpected ${command.constructor.name}`); - }); - const lambdaArn = "arn:aws:lambda:us-west-2:123456789012:function:target"; - const runtimeArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/runtime-1"; + test("derives the least-privilege union for modeled Target resources", () => { const oauthProviderArn = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/oauth"; + "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + + "token-vault/default/oauth2credentialprovider/calendar"; const apiKeyProviderArn = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/key"; - const workloadIdentityArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + - "workload-identity-directory/default/workload-identity/orders-workload"; - const workloadIdentityDirectoryArn = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default"; - const tokenVaultArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default"; + "token-vault/default/apikeycredentialprovider/weather"; + const secretArn = "arn:aws:secretsmanager:us-west-2:123456789012:secret:calendar"; + const lambdaArn = "arn:aws:lambda:us-west-2:123456789012:function:calculator"; + const runtimeArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders"; const configurations: GatewayTargetRoleConfiguration[] = [ { targetConfiguration: { mcp: { lambda: { lambdaArn, - toolSchema: { s3: { uri: "s3://schemas/lambda.json" } }, + toolSchema: { s3: { uri: "s3://schemas/calculator.json" } }, + }, + }, + }, + }, + { + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: "api123", + stage: "prod", + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: "/orders", methods: ["GET"] }], + }, }, }, }, @@ -289,60 +174,27 @@ describe("Gateway execution role ownership", () => { }, }, }, - credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], - }, - { - targetConfiguration: { - inference: { - connector: { - source: { connectorId: "bedrock-mantle" }, - }, - }, - }, - credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], }, { targetConfiguration: { - mcp: { - openApiSchema: { s3: { uri: "s3://schemas/openapi.json" } }, - }, + mcp: { connector: { source: { connectorId: "web-search" } } }, }, }, { targetConfiguration: { - mcp: { - apiGateway: { - restApiId: "api123", - stage: "prod", - apiGatewayToolConfiguration: { toolFilters: [] }, - }, - }, + http: { agentcoreRuntime: { arn: runtimeArn, qualifier: "prod" } }, }, - credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], }, { targetConfiguration: { - http: { - agentcoreRuntime: { - arn: runtimeArn, - qualifier: "LIVE", - schema: { source: { s3: { uri: "s3://schemas/runtime.json" } } }, - }, - }, - }, - }, - { - targetConfiguration: { - mcp: { - connector: { - source: { connectorId: "web-search" }, - }, + inference: { + connector: { source: { connectorId: "bedrock-mantle" } }, }, }, }, { targetConfiguration: { - mcp: { mcpServer: { endpoint: "https://example.com/mcp" } }, + mcp: { mcpServer: { endpoint: "https://calendar.example.test/mcp" } }, }, credentialProviderConfigurations: [ { @@ -350,244 +202,174 @@ describe("Gateway execution role ownership", () => { credentialProvider: { oauthCredentialProvider: { providerArn: oauthProviderArn, - scopes: ["read"], - grantType: "TOKEN_EXCHANGE", + scopes: ["calendar.read"], }, }, }, { credentialProviderType: "API_KEY", credentialProvider: { - apiKeyCredentialProvider: { - providerArn: apiKeyProviderArn, - }, + apiKeyCredentialProvider: { providerArn: apiKeyProviderArn }, }, }, ], - credentialProviderSecretArns: [ - "arn:aws:secretsmanager:us-west-2:123456789012:secret:oauth", - "arn:aws:secretsmanager:us-west-2:123456789012:secret:api-key", - ], + credentialProviderSecretArns: [secretArn], }, ]; - const role = await getManagedGatewayTargetExecutionRole( - iam, - { - gatewayId: "orders-abc123", - gatewayArn: GATEWAY_ARN, - name: "orders", - roleArn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, - workloadIdentityDetails: { workloadIdentityArn }, - interceptorConfigurations: [ - { - interceptor: { lambda: { arn: INTERCEPTOR_ARN } }, - interceptionPoints: ["REQUEST"], - }, - ], - customTransformConfiguration: { lambda: { arn: TRANSFORM_ARN } }, - } as Parameters[1], - REGION, + const statements = policyStatements( + GatewayExecutionPolicy.forTargets(configurations, { + partition: "aws", + region: REGION, + accountId: ACCOUNT_ID, + gatewayId: "orders-abc", + workloadIdentityArn: WORKLOAD_IDENTITY_ARN, + }), ); - await role!.updatePolicy(configurations); - - const policyUpdate = sent.find((command) => command instanceof PutRolePolicyCommand); - const statements = policyStatements(requiredString(policyUpdate?.input.PolicyDocument)); - expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayConfigLambda")).toMatchObject({ - Action: "lambda:InvokeFunction", - Resource: [INTERCEPTOR_ARN, TRANSFORM_ARN].sort(), - }); - expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetLambda")).toMatchObject({ - Action: "lambda:InvokeFunction", - Resource: [lambdaArn, `${lambdaArn}:*`], - }); - expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetS3Schema")).toMatchObject({ - Action: "s3:GetObject", - Resource: [ - "arn:aws:s3:::schemas/lambda.json", - "arn:aws:s3:::schemas/openapi.json", - "arn:aws:s3:::schemas/runtime.json", - ], - }); - expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetApiGateway")).toMatchObject({ - Action: "execute-api:Invoke", - Resource: ["arn:aws:execute-api:us-west-2:123456789012:api123/prod/*/*"], - }); - expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetRuntime")).toMatchObject({ - Action: "bedrock-agentcore:InvokeAgentRuntime", - Resource: [runtimeArn, `${runtimeArn}/runtime-endpoint/LIVE`], - }); - expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetWebSearch")).toMatchObject({ - Action: "bedrock-agentcore:InvokeWebSearch", - Resource: "arn:aws:bedrock-agentcore:us-west-2:aws:tool/web-search.v1", - }); - expect( - statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetKnowledgeBase"), - ).toMatchObject({ - Action: ["bedrock:GetKnowledgeBase", "bedrock:Retrieve"], - Resource: [ - "arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB12345678", - "arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB87654321", - ], - }); - expect( - statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetAgenticRetrieve"), - ).toMatchObject({ - Action: "bedrock:AgenticRetrieveStream", - Resource: "*", - }); - expect( - statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetBedrockMantle"), - ).toMatchObject({ - Action: "bedrock-mantle:CreateInference", - Resource: "*", - }); - expect( - statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetWorkloadIdentity"), - ).toMatchObject({ - Action: [ - "bedrock-agentcore:GetWorkloadAccessToken", - "bedrock-agentcore:GetWorkloadAccessTokenForJWT", - "bedrock-agentcore:GetWorkloadAccessTokenForUserId", - ], - Resource: [workloadIdentityDirectoryArn, workloadIdentityArn], - }); - const oauthResources = [ - tokenVaultArn, - oauthProviderArn, - workloadIdentityDirectoryArn, - workloadIdentityArn, - ].sort(); - expect( - statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetOAuthCompleteAuth"), - ).toMatchObject({ - Action: "bedrock-agentcore:CompleteResourceTokenAuth", - Resource: oauthResources, - }); - expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetOAuth")).toMatchObject({ - Action: "bedrock-agentcore:GetResourceOauth2Token", - Resource: oauthResources, - }); - expect(statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetApiKey")).toMatchObject({ - Action: "bedrock-agentcore:GetResourceApiKey", - Resource: [ - tokenVaultArn, - apiKeyProviderArn, - workloadIdentityDirectoryArn, - workloadIdentityArn, - ].sort(), - }); - expect( - statements.find(({ Sid }) => Sid === "AgentCoreGatewayTargetCredentialSecrets"), - ).toMatchObject({ - Action: "secretsmanager:GetSecretValue", - Resource: [ - "arn:aws:secretsmanager:us-west-2:123456789012:secret:api-key", - "arn:aws:secretsmanager:us-west-2:123456789012:secret:oauth", - ], - }); + const bySid = new Map(statements.map((statement) => [statement.Sid, statement])); + + expect([...bySid.keys()]).toEqual([ + "AgentCoreGatewayTargetAgenticRetrieve", + "AgentCoreGatewayTargetApiGateway", + "AgentCoreGatewayTargetApiKey", + "AgentCoreGatewayTargetBedrockMantle", + "AgentCoreGatewayTargetCredentialSecrets", + "AgentCoreGatewayTargetKnowledgeBase", + "AgentCoreGatewayTargetLambda", + "AgentCoreGatewayTargetOAuth", + "AgentCoreGatewayTargetOAuthCompleteAuth", + "AgentCoreGatewayTargetRuntime", + "AgentCoreGatewayTargetS3Schema", + "AgentCoreGatewayTargetWebSearch", + "AgentCoreGatewayTargetWorkloadIdentity", + ]); + expect(bySid.get("AgentCoreGatewayTargetLambda")?.Resource).toEqual([ + lambdaArn, + `${lambdaArn}:*`, + ]); + expect(bySid.get("AgentCoreGatewayTargetRuntime")?.Resource).toEqual([ + runtimeArn, + `${runtimeArn}/runtime-endpoint/prod`, + ]); + expect(bySid.get("AgentCoreGatewayTargetS3Schema")?.Resource).toEqual([ + "arn:aws:s3:::schemas/calculator.json", + ]); + expect(bySid.get("AgentCoreGatewayTargetCredentialSecrets")?.Resource).toEqual([secretArn]); }); - test("requires a customer-managed role when SigV4 permissions cannot be inferred", async () => { - const roleName = gatewayExecutionRoleName("orders", REGION); - const iam = fakeIam(async (command) => { - if (command instanceof GetRoleCommand) return { Role: ownedRole(roleName) }; - throw new Error(`unexpected ${command.constructor.name}`); - }); - const role = await getManagedGatewayTargetExecutionRole( - iam, - { - gatewayId: "orders-abc123", - gatewayArn: GATEWAY_ARN, - name: "orders", - roleArn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, - } as Parameters[1], - REGION, - ); - - for (const configuration of [ - { - targetConfiguration: { - http: { - passthrough: { - endpoint: "https://service.us-west-2.amazonaws.com", - protocolType: "CUSTOM", + test("rejects role-based Targets whose permissions cannot be inferred", () => { + expect(() => + GatewayExecutionPolicy.forTargets( + [ + { + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://api.example.test", + protocolType: "CUSTOM", + }, + }, }, + credentialProviderConfigurations: [ + { + credentialProviderType: "GATEWAY_IAM_ROLE", + credentialProvider: { + iamCredentialProvider: { + service: "execute-api", + region: REGION, + }, + }, + }, + ], }, + ], + { + partition: "aws", + region: REGION, + accountId: ACCOUNT_ID, + gatewayId: "orders-abc", }, - credentialProviderConfigurations: [ + ), + ).toThrow("cannot infer least-privilege IAM permissions"); + }); + + test("merges CLI policy statements by Sid without dropping resources", () => { + const current = GatewayExecutionPolicy.parse( + JSON.stringify({ + Version: "2012-10-17", + Statement: [ { - credentialProviderType: "GATEWAY_IAM_ROLE", - credentialProvider: { - iamCredentialProvider: { - service: "service", - region: REGION, - }, - }, + Sid: "AgentCoreGatewayTargetLambda", + Effect: "Allow", + Action: "lambda:InvokeFunction", + Resource: "arn:aws:lambda:us-west-2:123456789012:function:first", }, ], - }, - { - targetConfiguration: { - mcp: { - smithyModel: { - inlinePayload: JSON.stringify({ - smithy: "2.0", - shapes: {}, - }), + }), + ); + const candidate = GatewayExecutionPolicy.forTargets( + [ + { + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: "arn:aws:lambda:us-west-2:123456789012:function:second", + toolSchema: { inlinePayload: [] }, + }, }, }, }, + ], + { + partition: "aws", + region: REGION, + accountId: ACCOUNT_ID, + gatewayId: "orders-abc", }, - ] satisfies GatewayTargetRoleConfiguration[]) { - await expect(role!.updatePolicy([configuration])).rejects.toThrow( - "customer-managed --role-arn", - ); - } + ); + + expect(policyStatements(current.merge(candidate))[0]!.Resource).toEqual([ + "arn:aws:lambda:us-west-2:123456789012:function:first", + "arn:aws:lambda:us-west-2:123456789012:function:second", + "arn:aws:lambda:us-west-2:123456789012:function:second:*", + ]); }); }); describe("Gateway role propagation retry", () => { - test("retries only role-assumption validation failures up to the configured limit", async () => { + test("retries role propagation errors up to the configured limit", async () => { let attempts = 0; const delays: number[] = []; - const result = await retryWhileGatewayRoleChangesPropagate( - async () => { - attempts += 1; - if (attempts < 3) { - const error = new Error("The execution role cannot be assumed yet"); + await expect( + retryWhileGatewayRoleChangesPropagate( + async () => { + attempts += 1; + const error = new Error("Gateway execution role cannot be assumed"); error.name = "ValidationException"; throw error; - } - return "created"; - }, - 3, - 25, - async (delay) => { - delays.push(delay); - }, - ); - - expect(result).toBe("created"); + }, + 3, + 10, + async (delay) => { + delays.push(delay); + }, + ), + ).rejects.toThrow("cannot be assumed"); expect(attempts).toBe(3); - expect(delays).toEqual([25, 25]); + expect(delays).toEqual([10, 10]); }); test("does not retry unrelated failures", async () => { let attempts = 0; + const error = new Error("target rejected"); await expect( - retryWhileGatewayRoleChangesPropagate( - async () => { - attempts += 1; - throw new Error("access denied"); - }, - 3, - 0, - async () => {}, - ), - ).rejects.toThrow("access denied"); + retryWhileGatewayRoleChangesPropagate(async () => { + attempts += 1; + throw error; + }), + ).rejects.toBe(error); expect(attempts).toBe(1); }); }); diff --git a/src/core/gatewayExecutionRole.ts b/src/core/gatewayExecutionRole.ts index 0315ffff6..278b1664c 100644 --- a/src/core/gatewayExecutionRole.ts +++ b/src/core/gatewayExecutionRole.ts @@ -4,6 +4,7 @@ import { CreateRoleCommand, DeleteRolePolicyCommand, GetRoleCommand, + GetRolePolicyCommand, PutRolePolicyCommand, UpdateAssumeRolePolicyCommand, type IAMClient, @@ -11,14 +12,16 @@ import { } from "@aws-sdk/client-iam"; import { InputValidationError } from "../errors"; import { - gatewayPolicyDocument, - gatewayTargetPolicyDocument, + GatewayExecutionPolicy, type GatewayRoleConfiguration, type GatewayTargetPolicyContext, type GatewayTargetRoleConfiguration, } from "./gatewayExecutionRolePolicy"; -export type { GatewayTargetRoleConfiguration } from "./gatewayExecutionRolePolicy"; +export type { + GatewayRoleConfiguration, + GatewayTargetRoleConfiguration, +} from "./gatewayExecutionRolePolicy"; export const GATEWAY_EXECUTION_POLICY_NAME = "AgentCoreGatewayExecutionPolicy"; export const GATEWAY_ROLE_MANAGED_BY_TAG = { @@ -30,60 +33,177 @@ export const GATEWAY_ROLE_RESOURCE_TYPE_TAG = { Value: "gateway", } as const; -export type GatewayExecutionRoleProvisioning = { - roleArn: string; - roleName: string; - updatePolicy(gatewayArn?: string): Promise; - updateTrust(gatewayArn: string): Promise; +type PolicySnapshot = { + exists: boolean; + policy: GatewayExecutionPolicy; }; -export interface GatewayTargetExecutionRole { - roleName: string; - updatePolicy(configurations: GatewayTargetRoleConfiguration[]): Promise; -} +export type GatewayTargetPolicyPreparation = { + commit(): Promise; + rollback(): Promise; +}; -export function gatewayExecutionRoleName(gatewayName: string, region: string): string { - const suffix = createHash("sha256").update(`${region}:${gatewayName}`).digest("hex").slice(0, 12); - return `AgentCoreGateway-${gatewayName.slice(0, 32)}-${suffix}`; -} +export class GatewayExecutionRole { + private constructor( + private readonly iam: IAMClient, + readonly roleName: string, + readonly roleArn: string, + private readonly accountId: string, + private readonly targetContext?: GatewayTargetPolicyContext, + ) {} -export async function ensureGatewayExecutionRole( - iam: IAMClient, - gatewayName: string, - region: string, - configuration: GatewayRoleConfiguration, -): Promise { - const roleName = gatewayExecutionRoleName(gatewayName, region); - const role = await getOrCreateOwnedRole(iam, roleName, gatewayName); - const roleArn = requiredRoleArn(role, roleName); - const arn = parseRoleArn(roleArn); - const gatewayPattern = gatewayArnPattern(arn.partition, region, arn.accountId, gatewayName); - - await iam.send( - new UpdateAssumeRolePolicyCommand({ - RoleName: roleName, - PolicyDocument: trustPolicy(arn.accountId, gatewayPattern), - }), - ); + static async prepareCreate( + iam: IAMClient, + gatewayName: string, + region: string, + configuration: GatewayRoleConfiguration, + ): Promise { + const roleName = gatewayExecutionRoleName(gatewayName, region); + const role = await getOrCreateOwnedRole(iam, roleName, gatewayName); + const roleArn = requiredRoleArn(role, roleName); + const arn = parseRoleArn(roleArn); + const gatewayPattern = gatewayArnPattern(arn.partition, region, arn.accountId, gatewayName); + const executionRole = new GatewayExecutionRole(iam, roleName, roleArn, arn.accountId); + + await executionRole.writeTrust(gatewayPattern); + await executionRole.mergePolicy( + GatewayExecutionPolicy.forGateway(configuration, gatewayPattern), + ); + return executionRole; + } + + static async fromGateway( + iam: IAMClient, + gateway: GetGatewayResponse, + region: string, + ): Promise { + if (!gateway.roleArn || !gateway.name) return undefined; + + const arn = parseRoleArn(gateway.roleArn); + if (arn.roleName !== gatewayExecutionRoleName(gateway.name, region)) return undefined; + + const response = await iam.send(new GetRoleCommand({ RoleName: arn.roleName })); + if (!isOwnedGatewayRole(response.Role)) return undefined; + + const gatewayId = gateway.gatewayId ?? gateway.gatewayArn?.split("/").at(-1); + if (!gatewayId) throw new Error("GetGateway response did not include the Gateway ID"); + return new GatewayExecutionRole(iam, arn.roleName, gateway.roleArn, arn.accountId, { + partition: arn.partition, + region, + accountId: arn.accountId, + gatewayId, + workloadIdentityArn: gateway.workloadIdentityDetails?.workloadIdentityArn, + }); + } + + async finalizeGateway( + configuration: GatewayRoleConfiguration, + gatewayArn: string, + ): Promise { + await this.writeTrust(gatewayArn); + await this.writePolicy(GatewayExecutionPolicy.forGateway(configuration, gatewayArn)); + } + + async prepareTargetPolicy( + configuration: GatewayTargetRoleConfiguration, + ): Promise { + const contribution = GatewayExecutionPolicy.forTargets( + [configuration], + this.requiredTargetContext(), + ); + const previous = await this.readPolicy(); + await this.writePolicy(previous.policy.merge(contribution)); + + return { + commit: async () => { + const current = await this.readPolicy(); + const desired = current.policy.merge(contribution); + if (!current.policy.equals(desired)) await this.writePolicy(desired); + }, + rollback: async () => await this.restorePolicy(previous), + }; + } + + private requiredTargetContext(): GatewayTargetPolicyContext { + if (!this.targetContext) { + throw new Error("Gateway Target permissions require an existing Gateway"); + } + return this.targetContext; + } + + private async mergePolicy(candidate: GatewayExecutionPolicy): Promise { + const current = await this.readPolicy(); + const desired = current.policy.merge(candidate); + if (!current.policy.equals(desired)) await this.writePolicy(desired); + } + + private async readPolicy(): Promise { + try { + const response = await this.iam.send( + new GetRolePolicyCommand({ + RoleName: this.roleName, + PolicyName: GATEWAY_EXECUTION_POLICY_NAME, + }), + ); + return { + exists: true, + policy: GatewayExecutionPolicy.parse(response.PolicyDocument), + }; + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + return { exists: false, policy: GatewayExecutionPolicy.empty() }; + } + } + + private async writePolicy(policy: GatewayExecutionPolicy): Promise { + const document = policy.toJSON(); + if (!document) { + await this.deletePolicy(); + return; + } + await this.iam.send( + new PutRolePolicyCommand({ + RoleName: this.roleName, + PolicyName: GATEWAY_EXECUTION_POLICY_NAME, + PolicyDocument: document, + }), + ); + } + + private async restorePolicy(snapshot: PolicySnapshot): Promise { + if (snapshot.exists) { + await this.writePolicy(snapshot.policy); + } else { + await this.deletePolicy(); + } + } - return { - roleArn, - roleName, - updatePolicy: async (gatewayArn = gatewayPattern) => - await replaceGatewayExecutionPolicy( - iam, - roleName, - gatewayPolicyDocument(configuration, gatewayArn), - ), - updateTrust: async (gatewayArn) => { - await iam.send( - new UpdateAssumeRolePolicyCommand({ - RoleName: roleName, - PolicyDocument: trustPolicy(arn.accountId, gatewayArn), + private async deletePolicy(): Promise { + try { + await this.iam.send( + new DeleteRolePolicyCommand({ + RoleName: this.roleName, + PolicyName: GATEWAY_EXECUTION_POLICY_NAME, }), ); - }, - }; + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + } + } + + private async writeTrust(sourceArn: string): Promise { + await this.iam.send( + new UpdateAssumeRolePolicyCommand({ + RoleName: this.roleName, + PolicyDocument: trustPolicy(this.accountId, sourceArn), + }), + ); + } +} + +export function gatewayExecutionRoleName(gatewayName: string, region: string): string { + const suffix = createHash("sha256").update(`${region}:${gatewayName}`).digest("hex").slice(0, 12); + return `AgentCoreGateway-${gatewayName.slice(0, 32)}-${suffix}`; } export async function retryWhileGatewayRoleChangesPropagate( @@ -97,81 +217,12 @@ export async function retryWhileGatewayRoleChangesPropagate( try { return await operation(); } catch (error) { - const retryable = isGatewayRolePropagationError(error); - if (!retryable || attempt >= attempts) throw error; + if (!isGatewayRolePropagationError(error) || attempt >= attempts) throw error; await sleep(delayMs); } } } -export async function getManagedGatewayTargetExecutionRole( - iam: IAMClient, - gateway: GetGatewayResponse, - region: string, -): Promise { - if (!gateway.roleArn || !gateway.name) return undefined; - - const parsedArn = parseRoleArn(gateway.roleArn); - const expectedRoleName = gatewayExecutionRoleName(gateway.name, region); - if (parsedArn.roleName !== expectedRoleName) return undefined; - - const response = await iam.send(new GetRoleCommand({ RoleName: parsedArn.roleName })); - if (!isOwnedGatewayRole(response.Role)) return undefined; - - const gatewayId = gateway.gatewayId ?? gateway.gatewayArn?.split("/").at(-1); - if (!gatewayId) { - throw new Error("GetGateway response did not include the Gateway ID"); - } - const context: GatewayTargetPolicyContext = { - partition: parsedArn.partition, - region, - accountId: parsedArn.accountId, - gatewayId, - workloadIdentityArn: gateway.workloadIdentityDetails?.workloadIdentityArn, - }; - const gatewayArn = - gateway.gatewayArn ?? - `arn:${parsedArn.partition}:bedrock-agentcore:${region}:${parsedArn.accountId}:gateway/${gatewayId}`; - - return { - roleName: parsedArn.roleName, - updatePolicy: async (configurations) => - await replaceGatewayExecutionPolicy( - iam, - parsedArn.roleName, - gatewayTargetPolicyDocument(gateway, gatewayArn, configurations, context), - ), - }; -} - -async function replaceGatewayExecutionPolicy( - iam: IAMClient, - roleName: string, - policyDocument: string | undefined, -): Promise { - if (policyDocument) { - await iam.send( - new PutRolePolicyCommand({ - RoleName: roleName, - PolicyName: GATEWAY_EXECUTION_POLICY_NAME, - PolicyDocument: policyDocument, - }), - ); - return; - } - - try { - await iam.send( - new DeleteRolePolicyCommand({ - RoleName: roleName, - PolicyName: GATEWAY_EXECUTION_POLICY_NAME, - }), - ); - } catch (error) { - if ((error as Error).name !== "NoSuchEntityException") throw error; - } -} - async function getOrCreateOwnedRole( iam: IAMClient, roleName: string, @@ -224,9 +275,7 @@ function isOwnedGatewayRole(role: Role | undefined): boolean { } function requiredRoleArn(role: Role, roleName: string): string { - if (!role.Arn) { - throw new Error(`IAM did not return an ARN for role "${roleName}"`); - } + if (!role.Arn) throw new Error(`IAM did not return an ARN for role "${roleName}"`); return role.Arn; } @@ -280,9 +329,7 @@ function parseRoleArn(arn: string): { } { const match = /^arn:([^:]+):iam::(\d{12}):role\/(.+)$/.exec(arn); const roleName = match?.[3]?.split("/").at(-1); - if (!match || !roleName) { - throw new InputValidationError(`Invalid IAM role ARN "${arn}"`); - } + if (!match || !roleName) throw new InputValidationError(`Invalid IAM role ARN "${arn}"`); return { partition: match[1]!, accountId: match[2]!, roleName }; } diff --git a/src/core/gatewayExecutionRolePolicy.ts b/src/core/gatewayExecutionRolePolicy.ts index 8787ad6ac..13042f890 100644 --- a/src/core/gatewayExecutionRolePolicy.ts +++ b/src/core/gatewayExecutionRolePolicy.ts @@ -41,23 +41,66 @@ type PolicyDocument = { Statement: PolicyStatement[]; }; -export function gatewayPolicyDocument( - configuration: GatewayRoleConfiguration, - gatewayArn: string, -): string | undefined { - return policyDocumentForStatements(configurationStatements(configuration, gatewayArn)); -} +export class GatewayExecutionPolicy { + private constructor(private readonly document: PolicyDocument) {} -export function gatewayTargetPolicyDocument( - gateway: GatewayRoleConfiguration, - gatewayArn: string, - configurations: GatewayTargetRoleConfiguration[], - context: GatewayTargetPolicyContext, -): string | undefined { - return policyDocumentForStatements([ - ...configurationStatements(gateway, gatewayArn), - ...targetStatements(configurations, context), - ]); + static empty(): GatewayExecutionPolicy { + return new GatewayExecutionPolicy(policyDocument([])); + } + + static parse(value: string | undefined): GatewayExecutionPolicy { + if (!value) return GatewayExecutionPolicy.empty(); + + let parsed: { Statement?: PolicyStatement | PolicyStatement[] }; + try { + parsed = JSON.parse(value); + } catch { + parsed = JSON.parse(decodeURIComponent(value)); + } + const statements = parsed.Statement + ? Array.isArray(parsed.Statement) + ? parsed.Statement + : [parsed.Statement] + : []; + return new GatewayExecutionPolicy(policyDocument(statements)); + } + + static forGateway( + configuration: GatewayRoleConfiguration, + gatewayArn: string, + ): GatewayExecutionPolicy { + return new GatewayExecutionPolicy( + policyDocument(configurationStatements(configuration, gatewayArn)), + ); + } + + static forTargets( + configurations: GatewayTargetRoleConfiguration[], + context: GatewayTargetPolicyContext, + ): GatewayExecutionPolicy { + return new GatewayExecutionPolicy(policyDocument(targetStatements(configurations, context))); + } + + merge(candidate: GatewayExecutionPolicy): GatewayExecutionPolicy { + const statements = [...this.document.Statement]; + for (const requested of candidate.document.Statement) { + const index = requested.Sid ? statements.findIndex(({ Sid }) => Sid === requested.Sid) : -1; + if (index === -1) { + statements.push(requested); + } else { + statements[index] = mergeStatements(statements[index]!, requested); + } + } + return new GatewayExecutionPolicy(policyDocument(statements)); + } + + equals(other: GatewayExecutionPolicy): boolean { + return JSON.stringify(this.document) === JSON.stringify(other.document); + } + + toJSON(): string | undefined { + return this.document.Statement.length > 0 ? JSON.stringify(this.document) : undefined; + } } function configurationStatements( @@ -105,15 +148,12 @@ function configurationStatements( } const transformArn = configuration.customTransformConfiguration?.lambda?.arn; if (transformArn) lambdaArns.add(transformArn); - if (lambdaArns.size > 0) { - statements.push({ - Sid: `${GATEWAY_POLICY_SCOPE}Lambda`, - Effect: "Allow", - Action: "lambda:InvokeFunction", - Resource: [...lambdaArns].sort(), - }); - } - + addResourceStatement( + statements, + `${GATEWAY_POLICY_SCOPE}Lambda`, + "lambda:InvokeFunction", + lambdaArns, + ); return statements; } @@ -121,107 +161,110 @@ function targetStatements( configurations: GatewayTargetRoleConfiguration[], context: GatewayTargetPolicyContext, ): PolicyStatement[] { - const lambdaArns = new Set(); - const s3ObjectArns = new Set(); - const apiGatewayArns = new Set(); - const runtimeArns = new Set(); - const knowledgeBaseArns = new Set(); - const oauthProviderArns = new Set(); - const apiKeyProviderArns = new Set(); - const secretArns = new Set(); + const resources = { + lambda: new Set(), + s3: new Set(), + apiGateway: new Set(), + runtime: new Set(), + knowledgeBase: new Set(), + oauthProvider: new Set(), + apiKeyProvider: new Set(), + secret: new Set(), + }; let usesWebSearch = false; let usesAgenticRetrieve = false; let usesBedrockMantle = false; for (const configuration of configurations) { const target = configuration.targetConfiguration; - assertSupportedGatewayRoleTarget(configuration); + let roleRequested = false; + let roleSupported = false; + if (target && "mcp" in target && target.mcp) { const mcp = target.mcp; if ("lambda" in mcp && mcp.lambda) { - const lambdaArn = mcp.lambda.lambdaArn; - if (lambdaArn) { - lambdaArns.add(lambdaArn); - if (isUnqualifiedLambdaFunctionArn(lambdaArn)) { - lambdaArns.add(`${lambdaArn}:*`); + roleRequested = usesGatewayRole(configuration, true); + roleSupported = true; + if (roleRequested && mcp.lambda.lambdaArn) { + resources.lambda.add(mcp.lambda.lambdaArn); + if (isUnqualifiedLambdaFunctionArn(mcp.lambda.lambdaArn)) { + resources.lambda.add(`${mcp.lambda.lambdaArn}:*`); } } - addSchemaS3Object(s3ObjectArns, mcp.lambda.toolSchema, context.partition); + addSchemaS3Object(resources.s3, mcp.lambda.toolSchema, context.partition); } else if ("openApiSchema" in mcp && mcp.openApiSchema) { - addSchemaS3Object(s3ObjectArns, mcp.openApiSchema, context.partition); + roleRequested = usesGatewayRole(configuration, false); + addSchemaS3Object(resources.s3, mcp.openApiSchema, context.partition); } else if ("smithyModel" in mcp && mcp.smithyModel) { - addSchemaS3Object(s3ObjectArns, mcp.smithyModel, context.partition); + roleRequested = usesGatewayRole(configuration, true); + addSchemaS3Object(resources.s3, mcp.smithyModel, context.partition); } else if ("mcpServer" in mcp && mcp.mcpServer) { - addSchemaS3Object(s3ObjectArns, mcp.mcpServer.mcpToolSchema, context.partition); + roleRequested = usesGatewayRole(configuration, false); + addSchemaS3Object(resources.s3, mcp.mcpServer.mcpToolSchema, context.partition); } else if ("apiGateway" in mcp && mcp.apiGateway) { - if (usesGatewayExecutionRole(configuration)) { - apiGatewayArns.add( + roleRequested = usesGatewayRole(configuration, true); + roleSupported = true; + if (roleRequested) { + resources.apiGateway.add( `arn:${context.partition}:execute-api:${context.region}:${context.accountId}:` + `${mcp.apiGateway.restApiId}/${mcp.apiGateway.stage}/*/*`, ); } } else if ("connector" in mcp && mcp.connector) { const connectorId = mcp.connector.source?.connectorId; - if (connectorId === "web-search") { - usesWebSearch = usesWebSearch || usesGatewayExecutionRole(configuration); - } else if (connectorId === "bedrock-knowledge-bases") { - if (usesGatewayExecutionRole(configuration)) { - usesAgenticRetrieve = - addKnowledgeBaseConnectorPermissions( - knowledgeBaseArns, - mcp.connector.configurations, - context, - ) || usesAgenticRetrieve; - } + const hasKnownRole = ["web-search", "bedrock-knowledge-bases"].includes(connectorId ?? ""); + roleRequested = usesGatewayRole(configuration, hasKnownRole); + roleSupported = hasKnownRole; + if (roleRequested && connectorId === "web-search") { + usesWebSearch = true; + } else if (roleRequested && connectorId === "bedrock-knowledge-bases") { + usesAgenticRetrieve = + addKnowledgeBaseConnectorPermissions( + resources.knowledgeBase, + mcp.connector.configurations, + context, + ) || usesAgenticRetrieve; } } } else if (target && "http" in target && target.http) { const http = target.http; if ("agentcoreRuntime" in http && http.agentcoreRuntime) { - const runtimeArn = http.agentcoreRuntime.arn; - if (runtimeArn && usesGatewayExecutionRole(configuration)) { - runtimeArns.add(runtimeArn); - runtimeArns.add( - `${runtimeArn}/runtime-endpoint/${http.agentcoreRuntime.qualifier ?? "DEFAULT"}`, + roleRequested = usesGatewayRole(configuration, true); + roleSupported = true; + if (roleRequested && http.agentcoreRuntime.arn) { + resources.runtime.add(http.agentcoreRuntime.arn); + resources.runtime.add( + `${http.agentcoreRuntime.arn}/runtime-endpoint/` + + `${http.agentcoreRuntime.qualifier ?? "DEFAULT"}`, ); } - addSchemaS3Object(s3ObjectArns, http.agentcoreRuntime.schema?.source, context.partition); + addSchemaS3Object(resources.s3, http.agentcoreRuntime.schema?.source, context.partition); } else if ("passthrough" in http && http.passthrough) { - addSchemaS3Object(s3ObjectArns, http.passthrough.schema?.source, context.partition); + roleRequested = usesGatewayRole(configuration, false); + addSchemaS3Object(resources.s3, http.passthrough.schema?.source, context.partition); } } else if (target && "inference" in target && target.inference) { - usesBedrockMantle = - usesBedrockMantle || - (usesGatewayExecutionRole(configuration) && - (("connector" in target.inference && - target.inference.connector?.source?.connectorId === "bedrock-mantle") || - ("provider" in target.inference && - target.inference.provider?.endpoint?.includes("bedrock-mantle.") === true))); + const inference = target.inference; + const isMantle = + ("connector" in inference && + inference.connector?.source?.connectorId === "bedrock-mantle") || + ("provider" in inference && + inference.provider?.endpoint?.includes("bedrock-mantle.") === true); + roleRequested = usesGatewayRole(configuration, isMantle); + roleSupported = isMantle; + usesBedrockMantle = usesBedrockMantle || (roleRequested && isMantle); + } else { + roleRequested = usesGatewayRole(configuration, false); } - for (const providerConfiguration of configuration.credentialProviderConfigurations ?? []) { - const provider = providerConfiguration.credentialProvider; - if ( - providerConfiguration.credentialProviderType === "OAUTH" && - provider && - "oauthCredentialProvider" in provider && - provider.oauthCredentialProvider - ) { - if (provider.oauthCredentialProvider.providerArn) { - oauthProviderArns.add(provider.oauthCredentialProvider.providerArn); - } - } else if ( - providerConfiguration.credentialProviderType === "API_KEY" && - provider && - "apiKeyCredentialProvider" in provider && - provider.apiKeyCredentialProvider?.providerArn - ) { - apiKeyProviderArns.add(provider.apiKeyCredentialProvider.providerArn); - } - } - for (const secretArn of configuration.credentialProviderSecretArns ?? []) { - secretArns.add(secretArn); + if (roleRequested && !roleSupported) { + throw new InputValidationError( + "The CLI cannot infer least-privilege IAM permissions for this Target's " + + "GATEWAY_IAM_ROLE credential; use a customer-managed Gateway --role-arn", + ); } + + collectCredentialResources(configuration, resources); } const statements: PolicyStatement[] = []; @@ -229,31 +272,31 @@ function targetStatements( statements, `${GATEWAY_TARGET_POLICY_SCOPE}Lambda`, "lambda:InvokeFunction", - lambdaArns, + resources.lambda, ); addResourceStatement( statements, `${GATEWAY_TARGET_POLICY_SCOPE}S3Schema`, "s3:GetObject", - s3ObjectArns, + resources.s3, ); addResourceStatement( statements, `${GATEWAY_TARGET_POLICY_SCOPE}ApiGateway`, "execute-api:Invoke", - apiGatewayArns, + resources.apiGateway, ); addResourceStatement( statements, `${GATEWAY_TARGET_POLICY_SCOPE}Runtime`, "bedrock-agentcore:InvokeAgentRuntime", - runtimeArns, + resources.runtime, ); addResourceStatement( statements, `${GATEWAY_TARGET_POLICY_SCOPE}KnowledgeBase`, ["bedrock:GetKnowledgeBase", "bedrock:Retrieve"], - knowledgeBaseArns, + resources.knowledgeBase, ); if (usesAgenticRetrieve) { statements.push({ @@ -281,7 +324,7 @@ function targetStatements( } const workloadIdentityArns = - oauthProviderArns.size > 0 || apiKeyProviderArns.size > 0 + resources.oauthProvider.size > 0 || resources.apiKeyProvider.size > 0 ? workloadIdentityResources(context) : []; if (workloadIdentityArns.length > 0) { @@ -290,7 +333,7 @@ function targetStatements( Effect: "Allow", Action: [ "bedrock-agentcore:GetWorkloadAccessToken", - ...(oauthProviderArns.size > 0 + ...(resources.oauthProvider.size > 0 ? [ "bedrock-agentcore:GetWorkloadAccessTokenForJWT", "bedrock-agentcore:GetWorkloadAccessTokenForUserId", @@ -300,39 +343,85 @@ function targetStatements( Resource: workloadIdentityArns, }); } - if (oauthProviderArns.size > 0) { - const resources = tokenVaultResources(context, oauthProviderArns, workloadIdentityArns); - statements.push({ - Sid: `${GATEWAY_TARGET_POLICY_SCOPE}OAuthCompleteAuth`, - Effect: "Allow", - Action: "bedrock-agentcore:CompleteResourceTokenAuth", - Resource: resources, - }); - statements.push({ - Sid: `${GATEWAY_TARGET_POLICY_SCOPE}OAuth`, - Effect: "Allow", - Action: "bedrock-agentcore:GetResourceOauth2Token", - Resource: resources, - }); + if (resources.oauthProvider.size > 0) { + const targetResources = tokenVaultResources( + context, + resources.oauthProvider, + workloadIdentityArns, + ); + statements.push( + { + Sid: `${GATEWAY_TARGET_POLICY_SCOPE}OAuthCompleteAuth`, + Effect: "Allow", + Action: "bedrock-agentcore:CompleteResourceTokenAuth", + Resource: targetResources, + }, + { + Sid: `${GATEWAY_TARGET_POLICY_SCOPE}OAuth`, + Effect: "Allow", + Action: "bedrock-agentcore:GetResourceOauth2Token", + Resource: targetResources, + }, + ); } - if (apiKeyProviderArns.size > 0) { + if (resources.apiKeyProvider.size > 0) { statements.push({ Sid: `${GATEWAY_TARGET_POLICY_SCOPE}ApiKey`, Effect: "Allow", Action: "bedrock-agentcore:GetResourceApiKey", - Resource: tokenVaultResources(context, apiKeyProviderArns, workloadIdentityArns), + Resource: tokenVaultResources(context, resources.apiKeyProvider, workloadIdentityArns), }); } addResourceStatement( statements, `${GATEWAY_TARGET_POLICY_SCOPE}CredentialSecrets`, "secretsmanager:GetSecretValue", - secretArns, + resources.secret, ); - return statements; } +function usesGatewayRole( + configuration: GatewayTargetRoleConfiguration, + byDefault: boolean, +): boolean { + if (configuration.credentialProviderConfigurations === undefined) return byDefault; + return configuration.credentialProviderConfigurations.some( + ({ credentialProviderType }) => credentialProviderType === "GATEWAY_IAM_ROLE", + ); +} + +function collectCredentialResources( + configuration: GatewayTargetRoleConfiguration, + resources: { + oauthProvider: Set; + apiKeyProvider: Set; + secret: Set; + }, +): void { + for (const providerConfiguration of configuration.credentialProviderConfigurations ?? []) { + const provider = providerConfiguration.credentialProvider; + if ( + providerConfiguration.credentialProviderType === "OAUTH" && + provider && + "oauthCredentialProvider" in provider && + provider.oauthCredentialProvider?.providerArn + ) { + resources.oauthProvider.add(provider.oauthCredentialProvider.providerArn); + } else if ( + providerConfiguration.credentialProviderType === "API_KEY" && + provider && + "apiKeyCredentialProvider" in provider && + provider.apiKeyCredentialProvider?.providerArn + ) { + resources.apiKeyProvider.add(provider.apiKeyCredentialProvider.providerArn); + } + } + for (const secretArn of configuration.credentialProviderSecretArns ?? []) { + resources.secret.add(secretArn); + } +} + function workloadIdentityResources(context: GatewayTargetPolicyContext): string[] { const workloadIdentityArn = context.workloadIdentityArn; if (!workloadIdentityArn) { @@ -345,8 +434,7 @@ function workloadIdentityResources(context: GatewayTargetPolicyContext): string[ if (markerIndex === -1) { throw new Error(`Invalid Gateway workload identity ARN "${workloadIdentityArn}"`); } - const directoryArn = workloadIdentityArn.slice(0, markerIndex); - return [directoryArn, workloadIdentityArn].sort(); + return [workloadIdentityArn.slice(0, markerIndex), workloadIdentityArn].sort(); } function tokenVaultResources( @@ -360,39 +448,6 @@ function tokenVaultResources( return [...new Set([tokenVaultArn, ...providerArns, ...workloadIdentityArns])].sort(); } -function assertSupportedGatewayRoleTarget(configuration: GatewayTargetRoleConfiguration): void { - if (!usesGatewayExecutionRole(configuration)) return; - const target = configuration.targetConfiguration; - const supported = - Boolean(target && "mcp" in target && target.mcp && "lambda" in target.mcp) || - Boolean(target && "mcp" in target && target.mcp && "apiGateway" in target.mcp) || - Boolean( - target && - "mcp" in target && - target.mcp && - "connector" in target.mcp && - ["bedrock-knowledge-bases", "web-search"].includes( - target.mcp.connector?.source?.connectorId ?? "", - ), - ) || - Boolean(target && "http" in target && target.http && "agentcoreRuntime" in target.http) || - Boolean( - target && - "inference" in target && - target.inference && - (("connector" in target.inference && - target.inference.connector?.source?.connectorId === "bedrock-mantle") || - ("provider" in target.inference && - target.inference.provider?.endpoint?.includes("bedrock-mantle.") === true)), - ); - if (supported) return; - - throw new InputValidationError( - "The CLI cannot infer least-privilege IAM permissions for this Target's " + - "GATEWAY_IAM_ROLE credential; update the Gateway to use a customer-managed --role-arn", - ); -} - function isUnqualifiedLambdaFunctionArn(arn: string): boolean { return /^arn:[^:]+:lambda:[^:]+:\d{12}:function:[^:]+$/.test(arn); } @@ -454,35 +509,6 @@ function addSchemaS3Object( if (match) resources.add(`arn:${partition}:s3:::${match[1]}/${match[2]}`); } -function usesGatewayExecutionRole(configuration: GatewayTargetRoleConfiguration): boolean { - if (configuration.credentialProviderConfigurations !== undefined) { - return configuration.credentialProviderConfigurations.some( - ({ credentialProviderType }) => credentialProviderType === "GATEWAY_IAM_ROLE", - ); - } - - const target = configuration.targetConfiguration; - return Boolean( - target && - (("mcp" in target && - target.mcp && - ("lambda" in target.mcp || - "smithyModel" in target.mcp || - "apiGateway" in target.mcp || - ("connector" in target.mcp && - ["bedrock-knowledge-bases", "web-search"].includes( - target.mcp.connector?.source?.connectorId ?? "", - )))) || - ("http" in target && target.http && "agentcoreRuntime" in target.http) || - ("inference" in target && - target.inference && - (("connector" in target.inference && - target.inference.connector?.source?.connectorId === "bedrock-mantle") || - ("provider" in target.inference && - target.inference.provider?.endpoint?.includes("bedrock-mantle.") === true)))), - ); -} - function addResourceStatement( statements: PolicyStatement[], sid: string, @@ -498,8 +524,33 @@ function addResourceStatement( }); } -function policyDocumentForStatements(statements: PolicyStatement[]): string | undefined { - if (statements.length === 0) return undefined; - const document: PolicyDocument = { Version: "2012-10-17", Statement: statements }; - return JSON.stringify(document); +function mergeStatements(current: PolicyStatement, requested: PolicyStatement): PolicyStatement { + return { + ...current, + ...requested, + Action: unionValues(current.Action, requested.Action), + Resource: unionValues(current.Resource, requested.Resource), + }; +} + +function unionValues( + left: string | string[] | undefined, + right: string | string[] | undefined, +): string | string[] | undefined { + const values = new Set([...asArray(left), ...asArray(right)]); + if (values.size === 0) return undefined; + const result = [...values].sort(); + return result.length === 1 ? result[0] : result; +} + +function asArray(value: string | string[] | undefined): string[] { + if (value === undefined) return []; + return Array.isArray(value) ? value : [value]; +} + +function policyDocument(statements: PolicyStatement[]): PolicyDocument { + return { + Version: "2012-10-17", + Statement: statements.sort((left, right) => (left.Sid ?? "").localeCompare(right.Sid ?? "")), + }; } diff --git a/src/handlers/gateway/connector/create/index.tsx b/src/handlers/gateway/connector/create/index.tsx new file mode 100644 index 000000000..91cfe34f6 --- /dev/null +++ b/src/handlers/gateway/connector/create/index.tsx @@ -0,0 +1,136 @@ +import type { + CredentialProviderConfiguration, + MetadataConfiguration, + PrivateEndpoint, + TargetConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; +import { validateGatewayTargetCreateInput } from "../../mutations"; +import type { CreateGatewayTargetInput } from "../../types"; +import { GatewayConnectorTarget } from "../target"; + +export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => + createHandler({ + name: "create", + description: "create a connector-backed Gateway Target", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag("name", "Connector Target name", z.string().optional()), + flag("description", "Connector Target description", z.string().optional()), + flag( + "connector-configuration", + "connector-backed Target configuration (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "connector", + "curated connector", + z.enum(["web-search", "bedrock-knowledge-bases", "bedrock-mantle"]).optional(), + ), + flag( + "knowledge-base-id", + "Knowledge Base ID for the bedrock-knowledge-bases connector", + z.string().optional(), + ), + flag( + "credential-provider-configurations", + "outbound credentials (JSON array; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "metadata-configuration", + "metadata propagation (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "private-endpoint", + "private endpoint (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + if ((flags.connector === undefined) === (flags["connector-configuration"] === undefined)) { + throw new InputValidationError( + "specify exactly one of '--connector' or '--connector-configuration'", + ); + } + if ( + flags.connector === "bedrock-knowledge-bases" && + flags["knowledge-base-id"] === undefined + ) { + throw new InputValidationError( + "--connector bedrock-knowledge-bases requires --knowledge-base-id", + ); + } + if ( + flags["knowledge-base-id"] !== undefined && + flags.connector !== "bedrock-knowledge-bases" + ) { + throw new InputValidationError( + "--knowledge-base-id requires --connector bedrock-knowledge-bases", + ); + } + + const source = new SourceResolver({ stdin: io.stdin }); + const exactConfiguration = parseJsonObjectFlag( + "connector-configuration", + await source.resolveText("connector-configuration", flags["connector-configuration"]), + ); + const targetConfiguration = + exactConfiguration ?? + GatewayConnectorTarget.fromShortcut(flags.connector!, flags["knowledge-base-id"]); + if (!GatewayConnectorTarget.is(targetConfiguration)) { + throw new InputValidationError( + "--connector-configuration must contain an MCP or inference connector Target", + ); + } + + const credentialProviderConfigurations = parseJsonArrayFlag( + "credential-provider-configurations", + await source.resolveText( + "credential-provider-configurations", + flags["credential-provider-configurations"], + ), + ); + const metadataConfiguration = parseJsonObjectFlag( + "metadata-configuration", + await source.resolveText("metadata-configuration", flags["metadata-configuration"]), + ); + const privateEndpoint = parseJsonObjectFlag( + "private-endpoint", + await source.resolveText("private-endpoint", flags["private-endpoint"]), + ); + const input: CreateGatewayTargetInput = { + gatewayIdentifier: flags["gateway-id"], + name: flags.name, + targetConfiguration, + ...(flags.description ? { description: flags.description } : {}), + ...(credentialProviderConfigurations ? { credentialProviderConfigurations } : {}), + ...(metadataConfiguration ? { metadataConfiguration } : {}), + ...(privateEndpoint ? { privateEndpoint } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson( + await core.gateway.createGatewayTarget( + validateGatewayTargetCreateInput(input), + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/gateway/connector/index.tsx b/src/handlers/gateway/connector/index.tsx index c4eddc556..4a87991ca 100644 --- a/src/handlers/gateway/connector/index.tsx +++ b/src/handlers/gateway/connector/index.tsx @@ -2,12 +2,14 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { createHelpDefault } from "../../help"; import type { Core } from "../../types"; +import { createCreateGatewayConnectorHandler } from "./create"; import { createGetGatewayConnectorHandler } from "./get"; import { createListGatewayConnectorsHandler } from "./list"; export function createGatewayConnectorHandler(core: Core, io: AppIO): Router { return new Router("connector", "inspect connectors configured for an AgentCore Gateway") .default(createHelpDefault(io)) + .handler(createCreateGatewayConnectorHandler(core, io)) .handler(createGetGatewayConnectorHandler(core)) .handler(createListGatewayConnectorsHandler(core)); } diff --git a/src/handlers/gateway/connector/target.ts b/src/handlers/gateway/connector/target.ts new file mode 100644 index 000000000..51e74761c --- /dev/null +++ b/src/handlers/gateway/connector/target.ts @@ -0,0 +1,43 @@ +import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; + +export type GatewayConnectorShortcut = "web-search" | "bedrock-knowledge-bases" | "bedrock-mantle"; + +export class GatewayConnectorTarget { + static is(configuration: TargetConfiguration | undefined): boolean { + return ( + configuration?.mcp?.connector !== undefined || + configuration?.inference?.connector !== undefined + ); + } + + static fromShortcut( + shortcut: GatewayConnectorShortcut, + knowledgeBaseId?: string, + ): TargetConfiguration { + if (shortcut === "bedrock-mantle") { + return { inference: { connector: { source: { connectorId: shortcut } } } }; + } + + if (shortcut === "bedrock-knowledge-bases" && !knowledgeBaseId) { + throw new Error("A Knowledge Base ID is required for the bedrock-knowledge-bases connector"); + } + + return { + mcp: { + connector: { + source: { connectorId: shortcut }, + ...(shortcut === "bedrock-knowledge-bases" + ? { + configurations: [ + { + name: "Retrieve", + parameterValues: { knowledgeBaseId: knowledgeBaseId! }, + }, + ], + } + : {}), + }, + }, + }; + } +} diff --git a/src/handlers/gateway/create/index.tsx b/src/handlers/gateway/create/index.tsx index 7f22e3bde..a848a68ec 100644 --- a/src/handlers/gateway/create/index.tsx +++ b/src/handlers/gateway/create/index.tsx @@ -5,6 +5,7 @@ import type { } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../errors"; +import { type AppIO, SourceResolver } from "../../../io"; import { createHandler, flag } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { Core } from "../../types"; @@ -12,7 +13,7 @@ import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag, parseTags } f import { validateGatewayCreateInput } from "../mutations"; import type { CreateGatewayInput } from "../types"; -export const createCreateGatewayHandler = (core: Core) => +export const createCreateGatewayHandler = (core: Core, io: AppIO) => createHandler({ name: "create", description: "create an AgentCore Gateway", @@ -25,8 +26,8 @@ export const createCreateGatewayHandler = (core: Core) => ), flag( "protocol", - "Gateway protocol: mcp or http (default: http)", - z.enum(["mcp", "http"]).optional(), + "restrict Target protocols to MCP; omitted allows every Target protocol", + z.enum(["mcp"]).optional(), ), flag( "authorizer-type", @@ -36,18 +37,18 @@ export const createCreateGatewayHandler = (core: Core) => flag("description", "Gateway description", z.string().optional()), flag( "protocol-configuration", - "protocol configuration (JSON GatewayProtocolConfiguration)", + "MCP protocol configuration (JSON; inline, file://, or - for stdin)", z.string().optional(), ), flag( "authorizer-configuration", - "authorizer configuration (JSON AuthorizerConfiguration)", + "authorizer configuration (JSON; inline, file://, or - for stdin)", z.string().optional(), ), flag("kms-key-arn", "KMS key ARN", z.string().optional()), flag( "interceptor-configurations", - "interceptor configurations (JSON GatewayInterceptorConfiguration[])", + "interceptor configurations (JSON array; inline, file://, or - for stdin)", z.string().optional(), ), flag("policy-engine-arn", "Policy Engine ARN", z.string().optional()), @@ -82,17 +83,18 @@ export const createCreateGatewayHandler = (core: Core) => ); } + const source = new SourceResolver({ stdin: io.stdin }); const protocolConfiguration = parseJsonObjectFlag( "protocol-configuration", - flags["protocol-configuration"], + await source.resolveText("protocol-configuration", flags["protocol-configuration"]), ); const authorizerConfiguration = parseJsonObjectFlag( "authorizer-configuration", - flags["authorizer-configuration"], + await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), ); const interceptorConfigurations = parseJsonArrayFlag( "interceptor-configurations", - flags["interceptor-configurations"], + await source.resolveText("interceptor-configurations", flags["interceptor-configurations"]), ); const policyEngineConfiguration = flags["policy-engine-arn"] && flags["policy-engine-mode"] @@ -104,12 +106,12 @@ export const createCreateGatewayHandler = (core: Core) => : ("LOG_ONLY" as const), } : undefined; - const tags = parseTags(flags.tags); + const tags = await resolveTags(source, flags.tags); const input: CreateGatewayInput = { name: flags.name, ...(flags["role-arn"] ? { roleArn: flags["role-arn"] } : {}), - protocol: flags.protocol ?? "http", + ...(flags.protocol ? { protocol: flags.protocol } : {}), authorizerType: flags["authorizer-type"], ...(flags.description ? { description: flags.description } : {}), ...(protocolConfiguration ? { protocolConfiguration } : {}), @@ -128,3 +130,19 @@ export const createCreateGatewayHandler = (core: Core) => .renderJson(await core.gateway.createGateway(input, coreOptsFromCtx(ctx))); }, }); + +async function resolveTags( + source: SourceResolver, + values: string[] | undefined, +): Promise | undefined> { + const value = values?.[0]; + if ( + values?.length === 1 && + value && + (value === "-" || value.startsWith("file://") || value.trimStart().startsWith("{")) + ) { + const resolved = await source.resolveText("tags", value); + return parseTags([resolved!]); + } + return parseTags(values); +} diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index 979d82289..f50f1c6e9 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -1,9 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { - CreateGatewayResponse, - CreateGatewayRuleResponse, - CreateGatewayTargetResponse, -} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; import { createSilentLogger, TestCoreClient, @@ -30,30 +26,33 @@ async function run( return { core, stdout: io.stdout() }; } +function createdTargetInput(core: TestCoreClient): Record { + return core.gateway.calls[0]!.args[0] as Record; +} + describe("gateway create commands", () => { - test.each([ - ["mcp", "arn:aws:iam::123456789012:role/gateway"], - ["http", undefined], - ] as const)("creates a %s Gateway", async (protocol, roleArn) => { - const response = { - gatewayId: GATEWAY_ID, - name: "orders", - status: "CREATING", - } as CreateGatewayResponse; + test("creates an unrestricted Gateway with the complete flag surface", async () => { const core = new TestCoreClient(); - core.gateway.setCreateResponse(response); - - const result = await run( + await run( [ "gateway", "create", "--name", "orders", - ...(roleArn ? ["--role-arn", roleArn] : []), - "--protocol", - protocol, "--authorizer-type", - "AWS_IAM", + "CUSTOM_JWT", + "--authorizer-configuration", + '{"customJWTAuthorizer":{"discoveryUrl":"https://auth.example.test","allowedClients":["client"]}}', + "--protocol-configuration", + '{"mcp":{"supportedVersions":["2025-03-26"]}}', + "--interceptor-configurations", + '[{"interceptor":{"lambda":{"arn":"arn:aws:lambda:us-west-2:123456789012:function:guard"}},"interceptionPoints":["REQUEST"]}]', + "--policy-engine-arn", + "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", + "--policy-engine-mode", + "enforce", + "--exception-level", + "debug", "--tags", "env=test", "team=agentcore", @@ -67,78 +66,178 @@ describe("gateway create commands", () => { args: [ { name: "orders", - ...(roleArn ? { roleArn } : {}), - protocol, - authorizerType: "AWS_IAM", + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl: "https://auth.example.test", + allowedClients: ["client"], + }, + }, + protocolConfiguration: { mcp: { supportedVersions: ["2025-03-26"] } }, + interceptorConfigurations: [ + { + interceptor: { + lambda: { + arn: "arn:aws:lambda:us-west-2:123456789012:function:guard", + }, + }, + interceptionPoints: ["REQUEST"], + }, + ], + policyEngineConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", + mode: "ENFORCE", + }, + exceptionLevel: "DEBUG", tags: { env: "test", team: "agentcore" }, }, { region: REGION }, ], }, ]); - expect(JSON.parse(result.stdout)).toEqual(response); }); - test("creates a guided MCP server Target", async () => { - const response = { - targetId: "target-1", - name: "calendar", - status: "CREATING", - } as CreateGatewayTargetResponse; + test("maps --protocol mcp without requiring protocol configuration", async () => { const core = new TestCoreClient(); - core.gateway.setCreateTargetResponse(response); - - const result = await run( + await run( [ "gateway", - "target", "create", - "--gateway-id", - GATEWAY_ID, "--name", - "calendar", - "--type", - "mcp-server", - "--endpoint", - "https://calendar.example.test/mcp", + "mcp-only", + "--role-arn", + "arn:aws:iam::123456789012:role/gateway", + "--protocol", + "mcp", + "--authorizer-type", + "AWS_IAM", ], core, ); - expect(core.gateway.calls).toEqual([ - { - method: "createGatewayTarget", - args: [ - { - gatewayIdentifier: GATEWAY_ID, - name: "calendar", - targetConfiguration: { - mcp: { - mcpServer: { - endpoint: "https://calendar.example.test/mcp", + expect(core.gateway.calls[0]!.args[0]).toEqual({ + name: "mcp-only", + roleArn: "arn:aws:iam::123456789012:role/gateway", + protocol: "mcp", + authorizerType: "AWS_IAM", + }); + }); + + test.each([ + { + name: "MCP server", + args: [ + "--name", + "calendar", + "--endpoint", + "https://calendar.example.test/mcp", + "--tool-schema", + '{"tools":[]}', + ], + expected: { + mcp: { + mcpServer: { + endpoint: "https://calendar.example.test/mcp", + mcpToolSchema: { inlinePayload: '{"tools":[]}' }, + }, + }, + }, + }, + { + name: "Lambda", + args: [ + "--name", + "calculator", + "--lambda-arn", + "arn:aws:lambda:us-west-2:123456789012:function:calculator", + "--tool-schema", + '[{"name":"add","description":"Add","inputSchema":{"type":"object"}}]', + ], + expected: { + mcp: { + lambda: { + lambdaArn: "arn:aws:lambda:us-west-2:123456789012:function:calculator", + toolSchema: { + inlinePayload: [ + { + name: "add", + description: "Add", + inputSchema: { type: "object" }, }, - }, + ], }, }, - { region: REGION }, - ], + }, }, - ]); - expect(JSON.parse(result.stdout)).toEqual(response); - }); + }, + { + name: "OpenAPI", + args: ["--name", "orders", "--openapi-schema", '{"openapi":"3.0.0"}'], + expected: { mcp: { openApiSchema: { inlinePayload: '{"openapi":"3.0.0"}' } } }, + }, + { + name: "Smithy", + args: ["--name", "inventory", "--smithy-model", "s3://schemas/inventory.json"], + expected: { mcp: { smithyModel: { s3: { uri: "s3://schemas/inventory.json" } } } }, + }, + { + name: "AgentCore Runtime", + args: [ + "--runtime-arn", + "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", + "--qualifier", + "prod", + ], + expected: { + http: { + agentcoreRuntime: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", + qualifier: "prod", + }, + }, + }, + }, + { + name: "HTTP passthrough", + args: [ + "--name", + "external", + "--passthrough-endpoint", + "https://api.example.test", + "--passthrough-protocol", + "custom", + ], + expected: { + http: { + passthrough: { + endpoint: "https://api.example.test", + protocolType: "CUSTOM", + }, + }, + }, + }, + ] as { name: string; args: string[]; expected: TargetConfiguration }[])( + "creates a $name Target", + async ({ args, expected }) => { + const core = new TestCoreClient(); + await run(["gateway", "target", "create", "--gateway-id", GATEWAY_ID, ...args], core); + + expect(createdTargetInput(core)).toEqual({ + gatewayIdentifier: GATEWAY_ID, + ...(args.includes("--name") ? { name: args[args.indexOf("--name") + 1] } : {}), + targetConfiguration: expected, + }); + }, + ); - test("creates an exact Runtime Target without requiring a name", async () => { - const response = { targetId: "target-1", status: "CREATING" } as CreateGatewayTargetResponse; + test("creates an exact Target with authentication, metadata, and network configuration", async () => { const core = new TestCoreClient(); - core.gateway.setCreateTargetResponse(response); const targetConfiguration = { - http: { - agentcoreRuntime: { - arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/runtime-1", - qualifier: "DEFAULT", - }, - }, + inference: { provider: { endpoint: "https://inference.example.test" } }, }; + const credentials = [{ credentialProviderType: "CALLER_IAM_CREDENTIALS" }]; + const metadata = { allowedRequestHeaders: ["x-request-id"] }; + const privateEndpoint = { vpcEndpointId: "vpce-123" }; await run( [ @@ -147,36 +246,115 @@ describe("gateway create commands", () => { "create", "--gateway-id", GATEWAY_ID, + "--name", + "inference", "--target-configuration", JSON.stringify(targetConfiguration), + "--credential-provider-configurations", + JSON.stringify(credentials), + "--metadata-configuration", + JSON.stringify(metadata), + "--private-endpoint", + JSON.stringify(privateEndpoint), ], core, ); - expect(core.gateway.calls[0]).toEqual({ - method: "createGatewayTarget", - args: [ - { - gatewayIdentifier: GATEWAY_ID, - targetConfiguration, + expect(createdTargetInput(core)).toEqual({ + gatewayIdentifier: GATEWAY_ID, + name: "inference", + targetConfiguration, + credentialProviderConfigurations: credentials, + metadataConfiguration: metadata, + privateEndpoint, + }); + }); + + test.each([ + [ + ["--connector", "web-search"], + { mcp: { connector: { source: { connectorId: "web-search" } } } }, + ], + [ + ["--connector", "bedrock-knowledge-bases", "--knowledge-base-id", "KB12345678"], + { + mcp: { + connector: { + source: { connectorId: "bedrock-knowledge-bases" }, + configurations: [ + { + name: "Retrieve", + parameterValues: { knowledgeBaseId: "KB12345678" }, + }, + ], + }, }, - { region: REGION }, + }, + ], + [ + ["--connector", "bedrock-mantle"], + { inference: { connector: { source: { connectorId: "bedrock-mantle" } } } }, + ], + ] as [string[], TargetConfiguration][])( + "creates a curated Connector Target", + async (args, targetConfiguration) => { + const core = new TestCoreClient(); + await run( + [ + "gateway", + "connector", + "create", + "--gateway-id", + GATEWAY_ID, + "--name", + "connector", + ...args, + ], + core, + ); + + expect(createdTargetInput(core)).toEqual({ + gatewayIdentifier: GATEWAY_ID, + name: "connector", + targetConfiguration, + }); + }, + ); + + test("creates a Connector from exact connector-backed Target JSON", async () => { + const core = new TestCoreClient(); + const targetConfiguration = { + inference: { connector: { source: { connectorId: "openai" } } }, + }; + + await run( + [ + "gateway", + "connector", + "create", + "--gateway-id", + GATEWAY_ID, + "--name", + "openai", + "--connector-configuration", + JSON.stringify(targetConfiguration), ], + core, + ); + + expect(createdTargetInput(core)).toEqual({ + gatewayIdentifier: GATEWAY_ID, + name: "openai", + targetConfiguration, }); }); test("creates a Rule with exact condition and action arrays", async () => { - const response = { - ruleId: "rule-1", - priority: 10, - status: "ACTIVE", - } as CreateGatewayRuleResponse; const core = new TestCoreClient(); - core.gateway.setCreateRuleResponse(response); const conditions = [{ matchPaths: { anyOf: ["/orders/*"] } }]; const actions = [{ routeToTarget: { staticRoute: { targetName: "orders-api" } } }]; - const result = await run( + await run( [ "gateway", "rule", @@ -193,21 +371,18 @@ describe("gateway create commands", () => { core, ); - expect(core.gateway.calls).toEqual([ - { - method: "createGatewayRule", - args: [ - { - gatewayIdentifier: GATEWAY_ID, - priority: 10, - conditions, - actions, - }, - { region: REGION }, - ], - }, - ]); - expect(JSON.parse(result.stdout)).toEqual(response); + expect(core.gateway.calls[0]).toEqual({ + method: "createGatewayRule", + args: [ + { + gatewayIdentifier: GATEWAY_ID, + priority: 10, + conditions, + actions, + }, + { region: REGION }, + ], + }); }); }); @@ -216,15 +391,83 @@ describe("gateway create validation", () => { ["Gateway name", ["gateway", "create"], /--name/], ["Gateway authorizer", ["gateway", "create", "--name", "orders"], /--authorizer-type/], ["Target parent", ["gateway", "target", "create"], /--gateway-id/], + [ + "Target input", + ["gateway", "target", "create", "--gateway-id", GATEWAY_ID], + /specify exactly one/, + ], + [ + "Target name", + [ + "gateway", + "target", + "create", + "--gateway-id", + GATEWAY_ID, + "--endpoint", + "https://example.test/mcp", + ], + /Target name/, + ], + [ + "Lambda tool schema", + [ + "gateway", + "target", + "create", + "--gateway-id", + GATEWAY_ID, + "--name", + "lambda", + "--lambda-arn", + "arn:aws:lambda:us-west-2:123456789012:function:lambda", + ], + /--tool-schema/, + ], + [ + "Passthrough protocol", + [ + "gateway", + "target", + "create", + "--gateway-id", + GATEWAY_ID, + "--name", + "http", + "--passthrough-endpoint", + "https://example.test", + ], + /--passthrough-protocol/, + ], + [ + "Connector name", + ["gateway", "connector", "create", "--gateway-id", GATEWAY_ID, "--connector", "web-search"], + /--name/, + ], + [ + "Knowledge Base ID", + [ + "gateway", + "connector", + "create", + "--gateway-id", + GATEWAY_ID, + "--name", + "kb", + "--connector", + "bedrock-knowledge-bases", + ], + /--knowledge-base-id/, + ], ["Rule parent", ["gateway", "rule", "create"], /--gateway-id/], - ] as const)("rejects missing %s input before calling Core", async (_name, args, error) => { + ] as const)("rejects missing or inconsistent %s before Core", async (_name, args, error) => { const core = new TestCoreClient(); await expect(run([...args], core)).rejects.toThrow(error); expect(core.gateway.calls).toEqual([]); }); - test("rejects conflicting guided and exact Target input", async () => { + test("rejects multiple Target input modes", async () => { const core = new TestCoreClient(); await expect( @@ -237,8 +480,6 @@ describe("gateway create validation", () => { GATEWAY_ID, "--name", "calendar", - "--type", - "mcp-server", "--endpoint", "https://calendar.example.test/mcp", "--target-configuration", @@ -246,7 +487,29 @@ describe("gateway create validation", () => { ], core, ), - ).rejects.toThrow(/mutually exclusive/); + ).rejects.toThrow(/specify exactly one/); + expect(core.gateway.calls).toEqual([]); + }); + + test("rejects a non-Connector exact configuration", async () => { + const core = new TestCoreClient(); + + await expect( + run( + [ + "gateway", + "connector", + "create", + "--gateway-id", + GATEWAY_ID, + "--name", + "not-connector", + "--connector-configuration", + '{"mcp":{"mcpServer":{"endpoint":"https://example.test/mcp"}}}', + ], + core, + ), + ).rejects.toThrow(/connector Target/); expect(core.gateway.calls).toEqual([]); }); }); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 3e801e297..08845e29b 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -80,7 +80,7 @@ describe("gateway command hierarchy", () => { "rule", ]); expect(target?.children().map((child) => child.name())).toEqual(["create", "get", "list"]); - expect(connector?.children().map((child) => child.name())).toEqual(["get", "list"]); + expect(connector?.children().map((child) => child.name())).toEqual(["create", "get", "list"]); expect(rule?.children().map((child) => child.name())).toEqual(["create", "get", "list"]); }); diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index 877bf9b11..e17669880 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -12,7 +12,7 @@ import { createGatewayTargetHandler } from "./target"; export function createGatewayHandler(core: Core, io: AppIO): Router { return new Router("gateway", "inspect AgentCore Gateways") .default(createHelpDefault(io)) - .handler(createCreateGatewayHandler(core)) + .handler(createCreateGatewayHandler(core, io)) .handler(createGetGatewayHandler(core)) .handler(createListGatewaysHandler(core)) .handler(createGatewayTargetHandler(core, io)) diff --git a/src/handlers/gateway/mutations.ts b/src/handlers/gateway/mutations.ts index dd1a34672..0e9309478 100644 --- a/src/handlers/gateway/mutations.ts +++ b/src/handlers/gateway/mutations.ts @@ -20,24 +20,19 @@ export function buildGatewayCreateRequest(input: ResolvedCreateGatewayInput): Cr } export function validateGatewayCreateInput(input: CreateGatewayInput): CreateGatewayInput { - const { protocol, ...request } = input; - - if (!request.name) { + if (!input.name) { throw new InputValidationError("Gateway name is required"); } - if (!request.authorizerType) { + if (!input.authorizerType) { throw new InputValidationError("Gateway authorizer type is required"); } - if (request.authorizerType === "CUSTOM_JWT" && !request.authorizerConfiguration) { + if (input.authorizerType === "CUSTOM_JWT" && !input.authorizerConfiguration) { throw new InputValidationError("CUSTOM_JWT requires --authorizer-configuration"); } - if (request.authorizerType !== "CUSTOM_JWT" && request.authorizerConfiguration) { + if (input.authorizerType !== "CUSTOM_JWT" && input.authorizerConfiguration) { throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); } - if (protocol === "http" && request.protocolConfiguration) { - throw new InputValidationError("--protocol-configuration is valid only with --protocol mcp"); - } - const policyEngine = request.policyEngineConfiguration; + const policyEngine = input.policyEngineConfiguration; if (policyEngine && (!policyEngine.arn || !policyEngine.mode)) { throw new InputValidationError( "Policy Engine attachment requires --policy-engine-arn and --policy-engine-mode", @@ -57,18 +52,9 @@ export function validateGatewayTargetCreateInput( throw new InputValidationError("Target configuration is required"); } const variant = targetVariant(input.targetConfiguration); - if (!variant) { - throw new InputValidationError("Target configuration must use a known Target variant"); - } if (!input.name && variant !== "http.agentcoreRuntime") { throw new InputValidationError("Target name is required for non-Runtime targets"); } - if ( - input.credentialProviderConfigurations && - input.credentialProviderConfigurations.length === 0 - ) { - throw new InputValidationError("Credential provider configurations cannot be empty"); - } return input; } diff --git a/src/handlers/gateway/rule/create/index.tsx b/src/handlers/gateway/rule/create/index.tsx index 296481b33..8c3c36a92 100644 --- a/src/handlers/gateway/rule/create/index.tsx +++ b/src/handlers/gateway/rule/create/index.tsx @@ -1,13 +1,14 @@ import type { Action, Condition } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx, parseJsonArrayFlag } from "../../../utils"; import type { CreateGatewayRuleInput } from "../../types"; -export const createCreateGatewayRuleHandler = (core: Core) => +export const createCreateGatewayRuleHandler = (core: Core, io: AppIO) => createHandler({ name: "create", description: "create a Gateway Rule", @@ -34,8 +35,15 @@ export const createCreateGatewayRuleHandler = (core: Core) => throw new InputValidationError("required option '--actions ' not specified"); } - const conditions = parseJsonArrayFlag("conditions", flags.conditions); - const actions = parseJsonArrayFlag("actions", flags.actions)!; + const source = new SourceResolver({ stdin: io.stdin }); + const conditions = parseJsonArrayFlag( + "conditions", + await source.resolveText("conditions", flags.conditions), + ); + const actions = parseJsonArrayFlag( + "actions", + await source.resolveText("actions", flags.actions), + )!; const input: CreateGatewayRuleInput = { gatewayIdentifier: flags["gateway-id"], priority: flags.priority, diff --git a/src/handlers/gateway/rule/index.tsx b/src/handlers/gateway/rule/index.tsx index a26daa91b..be6c1a494 100644 --- a/src/handlers/gateway/rule/index.tsx +++ b/src/handlers/gateway/rule/index.tsx @@ -9,7 +9,7 @@ import { createListGatewayRulesHandler } from "./list"; export function createGatewayRuleHandler(core: Core, io: AppIO): Router { return new Router("rule", "inspect rules for an AgentCore Gateway") .default(createHelpDefault(io)) - .handler(createCreateGatewayRuleHandler(core)) + .handler(createCreateGatewayRuleHandler(core, io)) .handler(createGetGatewayRuleHandler(core)) .handler(createListGatewayRulesHandler(core)); } diff --git a/src/handlers/gateway/target/create/configuration.ts b/src/handlers/gateway/target/create/configuration.ts new file mode 100644 index 000000000..d68932b72 --- /dev/null +++ b/src/handlers/gateway/target/create/configuration.ts @@ -0,0 +1,162 @@ +import type { + ApiSchemaConfiguration, + McpToolSchemaConfiguration, + TargetConfiguration, + ToolDefinition, + ToolSchema, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError } from "../../../../errors"; +import type { SourceResolver } from "../../../../io"; +import { parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; + +type TargetConfigurationInput = { + endpoint?: string; + lambdaArn?: string; + openapiSchema?: string; + smithyModel?: string; + runtimeArn?: string; + passthroughEndpoint?: string; + targetConfiguration?: string; + toolSchema?: string; + qualifier?: string; + passthroughProtocol?: "mcp" | "a2a" | "inference" | "custom"; +}; + +const TARGET_INPUT_NAMES = [ + "endpoint", + "lambda-arn", + "openapi-schema", + "smithy-model", + "runtime-arn", + "passthrough-endpoint", + "target-configuration", +] as const; + +export class TargetConfigurationResolver { + constructor(private readonly source: SourceResolver) {} + + async resolve(input: TargetConfigurationInput): Promise { + this.validate(input); + + if (input.targetConfiguration) { + return parseJsonObjectFlag( + "target-configuration", + await this.source.resolveText("target-configuration", input.targetConfiguration), + )!; + } + if (input.endpoint) { + const mcpToolSchema = await this.mcpToolSchema(input.toolSchema); + return { + mcp: { + mcpServer: { + endpoint: input.endpoint, + ...(mcpToolSchema ? { mcpToolSchema } : {}), + }, + }, + }; + } + if (input.lambdaArn) { + return { + mcp: { + lambda: { + lambdaArn: input.lambdaArn, + toolSchema: await this.lambdaToolSchema(input.toolSchema!), + }, + }, + }; + } + if (input.openapiSchema) { + return { + mcp: { openApiSchema: await this.apiSchema("openapi-schema", input.openapiSchema) }, + }; + } + if (input.smithyModel) { + return { mcp: { smithyModel: await this.apiSchema("smithy-model", input.smithyModel) } }; + } + if (input.runtimeArn) { + return { + http: { + agentcoreRuntime: { + arn: input.runtimeArn, + ...(input.qualifier ? { qualifier: input.qualifier } : {}), + }, + }, + }; + } + return { + http: { + passthrough: { + endpoint: input.passthroughEndpoint!, + protocolType: input.passthroughProtocol!.toUpperCase() as + "MCP" | "A2A" | "INFERENCE" | "CUSTOM", + }, + }, + }; + } + + private validate(input: TargetConfigurationInput): void { + const values = [ + input.endpoint, + input.lambdaArn, + input.openapiSchema, + input.smithyModel, + input.runtimeArn, + input.passthroughEndpoint, + input.targetConfiguration, + ]; + if (values.filter((value) => value !== undefined).length !== 1) { + throw new InputValidationError( + `specify exactly one of ${TARGET_INPUT_NAMES.map((name) => `'--${name}'`).join(", ")}`, + ); + } + + const guidedOptions = [ + ["tool-schema", input.toolSchema], + ["qualifier", input.qualifier], + ["passthrough-protocol", input.passthroughProtocol], + ] as const; + if (input.targetConfiguration && guidedOptions.some(([, value]) => value !== undefined)) { + throw new InputValidationError( + "--target-configuration conflicts with guided Target input options", + ); + } + if (input.toolSchema && !input.endpoint && !input.lambdaArn) { + throw new InputValidationError("--tool-schema requires --endpoint or --lambda-arn"); + } + if (input.lambdaArn && !input.toolSchema) { + throw new InputValidationError("--lambda-arn requires --tool-schema"); + } + if (input.qualifier && !input.runtimeArn) { + throw new InputValidationError("--qualifier requires --runtime-arn"); + } + if (input.passthroughEndpoint && !input.passthroughProtocol) { + throw new InputValidationError("--passthrough-endpoint requires --passthrough-protocol"); + } + if (input.passthroughProtocol && !input.passthroughEndpoint) { + throw new InputValidationError("--passthrough-protocol requires --passthrough-endpoint"); + } + } + + private async apiSchema(name: string, value: string): Promise { + if (value.startsWith("s3://")) return { s3: { uri: value } }; + return { inlinePayload: (await this.source.resolveText(name, value))! }; + } + + private async mcpToolSchema( + value: string | undefined, + ): Promise { + if (!value) return undefined; + if (value.startsWith("s3://")) return { s3: { uri: value } }; + return { inlinePayload: (await this.source.resolveText("tool-schema", value))! }; + } + + private async lambdaToolSchema(value: string): Promise { + if (value.startsWith("s3://")) return { s3: { uri: value } }; + return { + inlinePayload: parseJsonArrayFlag( + "tool-schema", + await this.source.resolveText("tool-schema", value), + )!, + }; + } +} diff --git a/src/handlers/gateway/target/create/index.tsx b/src/handlers/gateway/target/create/index.tsx index a90827310..9f153df4b 100644 --- a/src/handlers/gateway/target/create/index.tsx +++ b/src/handlers/gateway/target/create/index.tsx @@ -2,18 +2,19 @@ import type { CredentialProviderConfiguration, MetadataConfiguration, PrivateEndpoint, - TargetConfiguration, } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; import { validateGatewayTargetCreateInput } from "../../mutations"; import type { CreateGatewayTargetInput } from "../../types"; +import { TargetConfigurationResolver } from "./configuration"; -export const createCreateGatewayTargetHandler = (core: Core) => +export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => createHandler({ name: "create", description: "create a Gateway Target", @@ -21,28 +22,43 @@ export const createCreateGatewayTargetHandler = (core: Core) => flag("gateway-id", "the parent Gateway ID", z.string().optional()), flag( "name", - "Target name (optional only for an exact Runtime Target)", + "Target name; optional only for AgentCore Runtime Targets", z.string().optional(), ), flag("description", "Target description", z.string().optional()), - flag("type", "guided Target type: mcp-server", z.enum(["mcp-server"]).optional()), flag("endpoint", "MCP server HTTPS endpoint", z.string().optional()), + flag("lambda-arn", "MCP Lambda function ARN", z.string().optional()), + flag("openapi-schema", "OpenAPI schema source", z.string().optional()), + flag("smithy-model", "Smithy model source", z.string().optional()), + flag("runtime-arn", "AgentCore Runtime ARN", z.string().optional()), + flag("passthrough-endpoint", "HTTP passthrough HTTPS endpoint", z.string().optional()), flag( "target-configuration", - "complete Target configuration (JSON TargetConfiguration)", + "complete Target configuration (JSON; inline, file://, or - for stdin)", z.string().optional(), ), + flag("tool-schema", "MCP tool schema source", z.string().optional()), + flag("qualifier", "AgentCore Runtime qualifier", z.string().optional()), + flag( + "passthrough-protocol", + "HTTP passthrough application protocol", + z.enum(["mcp", "a2a", "inference", "custom"]).optional(), + ), flag( "credential-provider-configurations", - "outbound credentials (JSON CredentialProviderConfiguration[])", + "outbound credentials (JSON array; inline, file://, or - for stdin)", z.string().optional(), ), flag( "metadata-configuration", - "metadata propagation (JSON MetadataConfiguration)", + "metadata propagation (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "private-endpoint", + "private endpoint (JSON; inline, file://, or - for stdin)", z.string().optional(), ), - flag("private-endpoint", "private endpoint (JSON PrivateEndpoint)", z.string().optional()), flag("client-token", "idempotency token", z.string().optional()), ], handle: async (ctx, flags) => { @@ -50,48 +66,33 @@ export const createCreateGatewayTargetHandler = (core: Core) => throw new InputValidationError("required option '--gateway-id ' not specified"); } - const hasGuidedInput = flags.type !== undefined || flags.endpoint !== undefined; - if (flags["target-configuration"] !== undefined && hasGuidedInput) { - throw new InputValidationError( - "--target-configuration is mutually exclusive with --type and --endpoint", - ); - } - if (flags.type !== undefined && flags.endpoint === undefined) { - throw new InputValidationError("--type mcp-server requires --endpoint"); - } - if (flags.endpoint !== undefined && flags.type === undefined) { - throw new InputValidationError("--endpoint requires --type mcp-server"); - } - if (!hasGuidedInput && flags["target-configuration"] === undefined) { - throw new InputValidationError( - "either --type mcp-server with --endpoint or --target-configuration is required", - ); - } - - const exactConfiguration = parseJsonObjectFlag( - "target-configuration", - flags["target-configuration"], - ); - const targetConfiguration: TargetConfiguration = - exactConfiguration ?? - ({ - mcp: { - mcpServer: { - endpoint: flags.endpoint!, - }, - }, - } as TargetConfiguration); + const source = new SourceResolver({ stdin: io.stdin }); + const targetConfiguration = await new TargetConfigurationResolver(source).resolve({ + endpoint: flags.endpoint, + lambdaArn: flags["lambda-arn"], + openapiSchema: flags["openapi-schema"], + smithyModel: flags["smithy-model"], + runtimeArn: flags["runtime-arn"], + passthroughEndpoint: flags["passthrough-endpoint"], + targetConfiguration: flags["target-configuration"], + toolSchema: flags["tool-schema"], + qualifier: flags.qualifier, + passthroughProtocol: flags["passthrough-protocol"], + }); const credentialProviderConfigurations = parseJsonArrayFlag( "credential-provider-configurations", - flags["credential-provider-configurations"], + await source.resolveText( + "credential-provider-configurations", + flags["credential-provider-configurations"], + ), ); const metadataConfiguration = parseJsonObjectFlag( "metadata-configuration", - flags["metadata-configuration"], + await source.resolveText("metadata-configuration", flags["metadata-configuration"]), ); const privateEndpoint = parseJsonObjectFlag( "private-endpoint", - flags["private-endpoint"], + await source.resolveText("private-endpoint", flags["private-endpoint"]), ); const input: CreateGatewayTargetInput = { gatewayIdentifier: flags["gateway-id"], @@ -104,9 +105,13 @@ export const createCreateGatewayTargetHandler = (core: Core) => ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), }; - validateGatewayTargetCreateInput(input); ctx .require(JsonRendererKey) - .renderJson(await core.gateway.createGatewayTarget(input, coreOptsFromCtx(ctx))); + .renderJson( + await core.gateway.createGatewayTarget( + validateGatewayTargetCreateInput(input), + coreOptsFromCtx(ctx), + ), + ); }, }); diff --git a/src/handlers/gateway/target/index.tsx b/src/handlers/gateway/target/index.tsx index 898855bab..93cd6dac6 100644 --- a/src/handlers/gateway/target/index.tsx +++ b/src/handlers/gateway/target/index.tsx @@ -9,7 +9,7 @@ import { createListGatewayTargetsHandler } from "./list"; export function createGatewayTargetHandler(core: Core, io: AppIO): Router { return new Router("target", "inspect targets for an AgentCore Gateway") .default(createHelpDefault(io)) - .handler(createCreateGatewayTargetHandler(core)) + .handler(createCreateGatewayTargetHandler(core, io)) .handler(createGetGatewayTargetHandler(core)) .handler(createListGatewayTargetsHandler(core)); } diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index 436834e07..e5f4644e7 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -14,11 +14,11 @@ import type { } from "@aws-sdk/client-bedrock-agentcore-control"; import type { CoreOptions } from "../../core/types"; -export type GatewayProtocol = "mcp" | "http"; +export type GatewayProtocol = "mcp"; export type CreateGatewayInput = Omit & { roleArn?: string; - protocol: GatewayProtocol; + protocol?: GatewayProtocol; }; export type ResolvedCreateGatewayInput = CreateGatewayInput & { From d1633c2c8ec2d1847013a47c1fdb6e2e0cb857f6 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 5 Aug 2026 19:29:39 +0000 Subject: [PATCH 03/15] refactor(gateway): simplify default execution role --- src/core/gateway.create.test.ts | 216 +--------- src/core/gateway.tsx | 147 +------ src/core/gatewayExecutionRole.test.ts | 405 +++--------------- src/core/gatewayExecutionRole.ts | 370 +++------------- src/core/gatewayExecutionRolePolicy.ts | 556 ------------------------- src/handlers/gateway/create/index.tsx | 2 +- 6 files changed, 141 insertions(+), 1555 deletions(-) delete mode 100644 src/core/gatewayExecutionRolePolicy.ts diff --git a/src/core/gateway.create.test.ts b/src/core/gateway.create.test.ts index 4aac8d82f..4b2c04ee3 100644 --- a/src/core/gateway.create.test.ts +++ b/src/core/gateway.create.test.ts @@ -3,24 +3,12 @@ import { CreateGatewayCommand, CreateGatewayRuleCommand, CreateGatewayTargetCommand, - GetGatewayCommand, type BedrockAgentCoreControlClient, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; -import { - CreateRoleCommand, - GetRoleCommand, - GetRolePolicyCommand, - PutRolePolicyCommand, - type IAMClient, -} from "@aws-sdk/client-iam"; +import { CreateRoleCommand, GetRoleCommand, type IAMClient } from "@aws-sdk/client-iam"; import { createSilentLogger } from "../testing"; -import { - gatewayExecutionRoleName, - GATEWAY_EXECUTION_POLICY_NAME, - GATEWAY_ROLE_MANAGED_BY_TAG, - GATEWAY_ROLE_RESOURCE_TYPE_TAG, -} from "./gatewayExecutionRole"; +import { GatewayExecutionRole } from "./gatewayExecutionRole"; import { CoreClient } from "./index"; type SentCommand = { input: unknown; constructor: { name: string } }; @@ -45,17 +33,6 @@ function noSuchEntity(): Error { return error; } -function managedRole(name: string) { - return { - Path: "/", - RoleName: name, - RoleId: "AROATEST", - Arn: `arn:aws:iam::123456789012:role/${name}`, - CreateDate: new Date("2026-08-05T00:00:00Z"), - Tags: [GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG], - }; -} - describe("Gateway create Core operations", () => { test("maps unrestricted and MCP Gateways directly when a role is supplied", async () => { const sent: SentCommand[] = []; @@ -90,201 +67,44 @@ describe("Gateway create Core operations", () => { expect(sent.every((command) => command instanceof CreateGatewayCommand)).toBe(true); }); - test("prepares and finalizes a CLI-managed Gateway role", async () => { + test("creates a Gateway with the default role when none is supplied", async () => { const operations: SentCommand[] = []; - const roleName = gatewayExecutionRoleName("orders", "us-west-2"); - const role = managedRole(roleName); - const gatewayArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc"; + const roleName = GatewayExecutionRole.roleName("orders", "us-west-2"); + const roleArn = `arn:aws:iam::123456789012:role/${roleName}`; const core = createCore( async (command) => { operations.push(command); - return { gatewayId: "orders-abc", gatewayArn }; + return { gatewayId: "orders-abc" }; }, async (command) => { operations.push(command); if (command instanceof GetRoleCommand) throw noSuchEntity(); - if (command instanceof CreateRoleCommand) return { Role: role }; - if (command instanceof GetRolePolicyCommand) throw noSuchEntity(); - return {}; + if (command instanceof CreateRoleCommand) return { Role: { Arn: roleArn } }; + throw new Error(`unexpected ${command.constructor.name}`); }, ); await core.gateway.createGateway( - { - name: "orders", - authorizerType: "AWS_IAM", - policyEngineConfiguration: { - arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", - mode: "ENFORCE", - }, - }, + { name: "orders", authorizerType: "AWS_IAM" }, { region: "us-west-2" }, ); expect(operations.map((command) => command.constructor.name)).toEqual([ "GetRoleCommand", "CreateRoleCommand", - "UpdateAssumeRolePolicyCommand", - "GetRolePolicyCommand", - "PutRolePolicyCommand", "CreateGatewayCommand", - "UpdateAssumeRolePolicyCommand", - "PutRolePolicyCommand", - ]); - expect(operations[5]!.input).toMatchObject({ roleArn: role.Arn }); - for (const command of operations.filter( - (operation) => operation instanceof PutRolePolicyCommand, - )) { - expect(command.input).toMatchObject({ PolicyName: GATEWAY_EXECUTION_POLICY_NAME }); - } - }); - - test("adds managed-role Target permissions before create and commits them", async () => { - const operations: SentCommand[] = []; - const roleName = gatewayExecutionRoleName("orders", "us-west-2"); - let storedPolicy: string | undefined; - const core = createCore( - async (command) => { - operations.push(command); - if (command instanceof GetGatewayCommand) { - return { - gatewayId: "gateway-1", - gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", - name: "orders", - roleArn: managedRole(roleName).Arn, - }; - } - if (command instanceof CreateGatewayTargetCommand) { - return { targetId: "target-1" }; - } - throw new Error(`unexpected ${command.constructor.name}`); - }, - async (command) => { - operations.push(command); - if (command instanceof GetRoleCommand) return { Role: managedRole(roleName) }; - if (command instanceof GetRolePolicyCommand) { - if (!storedPolicy) throw noSuchEntity(); - return { PolicyDocument: storedPolicy }; - } - if (command instanceof PutRolePolicyCommand) { - storedPolicy = command.input.PolicyDocument; - return {}; - } - throw new Error(`unexpected ${command.constructor.name}`); - }, - ); - const lambdaArn = "arn:aws:lambda:us-west-2:123456789012:function:calculator"; - - await core.gateway.createGatewayTarget( - { - gatewayIdentifier: "gateway-1", - name: "calculator", - targetConfiguration: { - mcp: { - lambda: { - lambdaArn, - toolSchema: { inlinePayload: [] }, - }, - }, - }, - }, - { region: "us-west-2" }, - ); - - const names = operations.map((command) => command.constructor.name); - expect(names).toEqual([ - "GetGatewayCommand", - "GetRoleCommand", - "GetRolePolicyCommand", - "PutRolePolicyCommand", - "CreateGatewayTargetCommand", - "GetRolePolicyCommand", ]); - expect(names.indexOf("PutRolePolicyCommand")).toBeLessThan( - names.indexOf("CreateGatewayTargetCommand"), - ); - expect(JSON.parse(storedPolicy!).Statement).toContainEqual({ - Sid: "AgentCoreGatewayTargetLambda", - Effect: "Allow", - Action: "lambda:InvokeFunction", - Resource: [lambdaArn, `${lambdaArn}:*`], + expect(operations[2]!.input).toEqual({ + name: "orders", + roleArn, + authorizerType: "AWS_IAM", }); }); - test("restores the previous managed-role policy when Target create fails", async () => { - const roleName = gatewayExecutionRoleName("orders", "us-west-2"); - const previousPolicy = JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "AgentCoreGatewayConfigLambda", - Effect: "Allow", - Action: "lambda:InvokeFunction", - Resource: "arn:aws:lambda:us-west-2:123456789012:function:guard", - }, - ], - }); - const writtenPolicies: string[] = []; - const createError = new Error("target rejected"); - const core = createCore( - async (command) => { - if (command instanceof GetGatewayCommand) { - return { - gatewayId: "gateway-1", - gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", - name: "orders", - roleArn: managedRole(roleName).Arn, - }; - } - if (command instanceof CreateGatewayTargetCommand) throw createError; - throw new Error(`unexpected ${command.constructor.name}`); - }, - async (command) => { - if (command instanceof GetRoleCommand) return { Role: managedRole(roleName) }; - if (command instanceof GetRolePolicyCommand) { - return { PolicyDocument: previousPolicy }; - } - if (command instanceof PutRolePolicyCommand) { - writtenPolicies.push(command.input.PolicyDocument!); - return {}; - } - throw new Error(`unexpected ${command.constructor.name}`); - }, - ); - - await expect( - core.gateway.createGatewayTarget( - { - gatewayIdentifier: "gateway-1", - name: "calculator", - targetConfiguration: { - mcp: { - lambda: { - lambdaArn: "arn:aws:lambda:us-west-2:123456789012:function:calculator", - toolSchema: { inlinePayload: [] }, - }, - }, - }, - }, - { region: "us-west-2" }, - ), - ).rejects.toBe(createError); - - expect(writtenPolicies).toHaveLength(2); - expect(JSON.parse(writtenPolicies[1]!)).toEqual(JSON.parse(previousPolicy)); - }); - - test("leaves customer-managed Target roles untouched and maps Rules directly", async () => { + test("maps Target and Rule creation directly to the service", async () => { const sent: SentCommand[] = []; const core = createCore(async (command) => { sent.push(command); - if (command instanceof GetGatewayCommand) { - return { - gatewayId: "gateway-1", - name: "orders", - roleArn: "arn:aws:iam::123456789012:role/customer-managed", - }; - } return {}; }); const targetConfiguration = { @@ -305,13 +125,13 @@ describe("Gateway create Core operations", () => { { region: "us-west-2" }, ); - expect(sent[1]).toBeInstanceOf(CreateGatewayTargetCommand); - expect(sent[1]!.input).toEqual({ + expect(sent[0]).toBeInstanceOf(CreateGatewayTargetCommand); + expect(sent[0]!.input).toEqual({ gatewayIdentifier: "gateway-1", targetConfiguration, }); - expect(sent[2]).toBeInstanceOf(CreateGatewayRuleCommand); - expect(sent[2]!.input).toEqual({ + expect(sent[1]).toBeInstanceOf(CreateGatewayRuleCommand); + expect(sent[1]!.input).toEqual({ gatewayIdentifier: "gateway-1", priority: 10, actions, diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 243ebdccb..82c7545cd 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -2,15 +2,12 @@ import { CreateGatewayCommand, CreateGatewayRuleCommand, CreateGatewayTargetCommand, - GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayRuleCommand, GetGatewayTargetCommand, - GetOauth2CredentialProviderCommand, ListGatewayRulesCommand, ListGatewaysCommand, ListGatewayTargetsCommand, - type BedrockAgentCoreControlClient, type CreateGatewayResponse, type CreateGatewayRuleResponse, type CreateGatewayTargetResponse, @@ -33,11 +30,7 @@ import type { CreateGatewayTargetInput, } from "../handlers/gateway/types"; import type { AwsClients, CoreOptions } from "./types"; -import { - GatewayExecutionRole, - retryWhileGatewayRoleChangesPropagate, - type GatewayTargetRoleConfiguration, -} from "./gatewayExecutionRole"; +import { GatewayExecutionRole } from "./gatewayExecutionRole"; import { toClientConfig } from "./utils"; export class GatewayClient implements CoreGatewayClient { @@ -55,25 +48,14 @@ export class GatewayClient implements CoreGatewayClient { ); } - const iam = this.clients.iam({ region: options.region }); - const role = await GatewayExecutionRole.prepareCreate(iam, input.name!, options.region, input); - const response = await retryWhileGatewayRoleChangesPropagate(() => - control.send( - new CreateGatewayCommand(buildGatewayCreateRequest({ ...input, roleArn: role.roleArn })), - ), + const roleArn = await GatewayExecutionRole.ensure( + this.clients.iam({ region: options.region }), + input.name!, + options.region, + ); + return GatewayExecutionRole.retryWhileUnassumable(() => + control.send(new CreateGatewayCommand(buildGatewayCreateRequest({ ...input, roleArn }))), ); - if (!response.gatewayArn) { - throw new Error("CreateGateway response did not include the Gateway ARN"); - } - try { - await role.finalizeGateway(input, response.gatewayArn); - } catch (error) { - throw new AggregateError( - [error], - `Gateway "${response.gatewayId ?? input.name}" was created, but its CLI-managed execution role could not be finalized`, - ); - } - return response; } async getGateway(id: string, options: CoreOptions): Promise { @@ -124,45 +106,9 @@ export class GatewayClient implements CoreGatewayClient { input: CreateGatewayTargetInput, options: CoreOptions, ): Promise { - const request = validateGatewayTargetCreateInput(input); - const control = this.clients.control(toClientConfig(options)); - const gateway = await control.send( - new GetGatewayCommand({ gatewayIdentifier: request.gatewayIdentifier }), - ); - const iam = this.clients.iam({ region: options.region }); - const role = await GatewayExecutionRole.fromGateway(iam, gateway, options.region); - if (!role) { - return control.send(new CreateGatewayTargetCommand(request)); - } - - const preparation = await role.prepareTargetPolicy( - await targetRoleConfiguration(control, request), - ); - let response: CreateGatewayTargetResponse; - try { - response = await retryWhileGatewayRoleChangesPropagate(() => - control.send(new CreateGatewayTargetCommand(request)), - ); - } catch (error) { - try { - await preparation.rollback(); - } catch (rollbackError) { - throw new AggregateError( - [error, rollbackError], - "Gateway Target creation failed and its execution-role policy could not be rolled back", - ); - } - throw error; - } - try { - await preparation.commit(); - } catch (error) { - throw new AggregateError( - [error], - `Gateway Target "${response.targetId ?? request.name}" was created, but its CLI-managed execution-role policy could not be finalized`, - ); - } - return response; + return this.clients + .control(toClientConfig(options)) + .send(new CreateGatewayTargetCommand(validateGatewayTargetCreateInput(input))); } async getGatewayRule( @@ -200,74 +146,3 @@ export class GatewayClient implements CoreGatewayClient { return this.clients.control(toClientConfig(options)).send(new CreateGatewayRuleCommand(input)); } } - -type TargetRoleConfigurationSource = Pick< - CreateGatewayTargetInput, - "targetConfiguration" | "credentialProviderConfigurations" ->; - -async function targetRoleConfiguration( - control: BedrockAgentCoreControlClient, - target: TargetRoleConfigurationSource, -): Promise { - return { - targetConfiguration: target.targetConfiguration, - credentialProviderConfigurations: target.credentialProviderConfigurations, - credentialProviderSecretArns: await credentialProviderSecretArns( - control, - target.credentialProviderConfigurations, - ), - }; -} - -async function credentialProviderSecretArns( - control: BedrockAgentCoreControlClient, - configurations: CreateGatewayTargetInput["credentialProviderConfigurations"], -): Promise { - const providers = new Map(); - for (const configuration of configurations ?? []) { - const provider = configuration.credentialProvider; - if ( - configuration.credentialProviderType === "OAUTH" && - provider && - "oauthCredentialProvider" in provider && - provider.oauthCredentialProvider?.providerArn - ) { - providers.set(provider.oauthCredentialProvider.providerArn, "oauth"); - } else if ( - configuration.credentialProviderType === "API_KEY" && - provider && - "apiKeyCredentialProvider" in provider && - provider.apiKeyCredentialProvider?.providerArn - ) { - providers.set(provider.apiKeyCredentialProvider.providerArn, "apiKey"); - } - } - - const secrets = await Promise.all( - [...providers].map(async ([providerArn, type]) => { - return lookupCredentialProviderSecret(control, providerArn, type); - }), - ); - return secrets.filter((secretArn): secretArn is string => Boolean(secretArn)); -} - -async function lookupCredentialProviderSecret( - control: BedrockAgentCoreControlClient, - providerArn: string, - type: "oauth" | "apiKey", -): Promise { - const name = credentialProviderName(providerArn); - if (type === "oauth") { - const provider = await control.send(new GetOauth2CredentialProviderCommand({ name })); - return provider.clientSecretArn?.secretArn; - } - const provider = await control.send(new GetApiKeyCredentialProviderCommand({ name })); - return provider.apiKeySecretArn?.secretArn; -} - -function credentialProviderName(providerArn: string): string { - const name = providerArn.split("/").at(-1); - if (!name) throw new Error(`Invalid credential provider ARN "${providerArn}"`); - return name; -} diff --git a/src/core/gatewayExecutionRole.test.ts b/src/core/gatewayExecutionRole.test.ts index 0556d1cf3..9a9088dad 100644 --- a/src/core/gatewayExecutionRole.test.ts +++ b/src/core/gatewayExecutionRole.test.ts @@ -1,375 +1,94 @@ import { describe, expect, test } from "bun:test"; -import type { GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; -import { GetRoleCommand, type IAMClient } from "@aws-sdk/client-iam"; -import { - GatewayExecutionRole, - gatewayExecutionRoleName, - GATEWAY_ROLE_MANAGED_BY_TAG, - GATEWAY_ROLE_RESOURCE_TYPE_TAG, - retryWhileGatewayRoleChangesPropagate, - type GatewayTargetRoleConfiguration, -} from "./gatewayExecutionRole"; -import { GatewayExecutionPolicy } from "./gatewayExecutionRolePolicy"; +import { CreateRoleCommand, GetRoleCommand, type IAMClient } from "@aws-sdk/client-iam"; +import { GatewayExecutionRole } from "./gatewayExecutionRole"; const REGION = "us-west-2"; const ACCOUNT_ID = "123456789012"; -const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc"; -const WORKLOAD_IDENTITY_ARN = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + - "workload-identity-directory/default/workload-identity/orders-identity"; -function policyStatements(policy: GatewayExecutionPolicy) { - return JSON.parse(policy.toJSON()!).Statement as { - Sid: string; - Action: string | string[]; - Resource: string | string[]; - }[]; +function noSuchEntity(): Error { + const error = new Error("not found"); + error.name = "NoSuchEntityException"; + return error; } -describe("Gateway execution role ownership", () => { - test("derives deterministic collision-resistant names within IAM limits", () => { - const name = "a".repeat(100); - const first = gatewayExecutionRoleName(name, REGION); +describe("GatewayExecutionRole", () => { + test("derives deterministic role names within IAM limits", () => { + const gatewayName = "a".repeat(100); + const name = GatewayExecutionRole.roleName(gatewayName, REGION); - expect(first.length).toBeLessThanOrEqual(64); - expect(gatewayExecutionRoleName(name, REGION)).toBe(first); - expect(gatewayExecutionRoleName(name, "us-east-1")).not.toBe(first); - expect(gatewayExecutionRoleName(`${name}b`, REGION)).not.toBe(first); + expect(name.length).toBeLessThanOrEqual(64); + expect(GatewayExecutionRole.roleName(gatewayName, REGION)).toBe(name); + expect(GatewayExecutionRole.roleName(gatewayName, "us-east-1")).not.toBe(name); }); - test("refuses to adopt an untagged role with the generated name", async () => { - const roleName = gatewayExecutionRoleName("orders", REGION); + test("creates the default role with AgentCore trust", async () => { + const commands: unknown[] = []; + const roleName = GatewayExecutionRole.roleName("orders", REGION); + const roleArn = `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`; const iam = { send: async (command: unknown) => { - expect(command).toBeInstanceOf(GetRoleCommand); - return { - Role: { - RoleName: roleName, - Arn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, - Tags: [], - }, - }; + commands.push(command); + if (command instanceof GetRoleCommand) throw noSuchEntity(); + return { Role: { Arn: roleArn } }; }, } as unknown as IAMClient; - await expect(GatewayExecutionRole.prepareCreate(iam, "orders", REGION, {})).rejects.toThrow( - "is not managed by the agentcore CLI", - ); - }); - - test("recognizes CLI ownership only when both Gateway tags are present", async () => { - const roleName = gatewayExecutionRoleName("orders", REGION); - const iam = { - send: async () => ({ - Role: { - RoleName: roleName, - Arn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, - Tags: [GATEWAY_ROLE_MANAGED_BY_TAG, GATEWAY_ROLE_RESOURCE_TYPE_TAG], - }, - }), - } as unknown as IAMClient; - - const role = await GatewayExecutionRole.fromGateway( - iam, - { - gatewayId: "orders-abc", - gatewayArn: GATEWAY_ARN, - name: "orders", - roleArn: `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`, - } as GetGatewayResponse, - REGION, - ); - - expect(role?.roleName).toBe(roleName); - }); -}); - -describe("Gateway execution policy", () => { - test("derives Gateway configuration permissions", () => { - const policy = GatewayExecutionPolicy.forGateway( - { - interceptorConfigurations: [ - { - interceptor: { - lambda: { - arn: "arn:aws:lambda:us-west-2:123456789012:function:interceptor", - }, - }, - interceptionPoints: ["REQUEST"], - }, - ], - policyEngineConfiguration: { - arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", - mode: "ENFORCE", - }, - }, - GATEWAY_ARN, - ); - - expect(policyStatements(policy).map(({ Sid }) => Sid)).toEqual([ - "AgentCoreGatewayConfigGuardrail", - "AgentCoreGatewayConfigLambda", - "AgentCoreGatewayConfigPolicyAuthorization", - "AgentCoreGatewayConfigPolicyEngine", - ]); - }); - - test("derives the least-privilege union for modeled Target resources", () => { - const oauthProviderArn = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + - "token-vault/default/oauth2credentialprovider/calendar"; - const apiKeyProviderArn = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:" + - "token-vault/default/apikeycredentialprovider/weather"; - const secretArn = "arn:aws:secretsmanager:us-west-2:123456789012:secret:calendar"; - const lambdaArn = "arn:aws:lambda:us-west-2:123456789012:function:calculator"; - const runtimeArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders"; - const configurations: GatewayTargetRoleConfiguration[] = [ - { - targetConfiguration: { - mcp: { - lambda: { - lambdaArn, - toolSchema: { s3: { uri: "s3://schemas/calculator.json" } }, - }, - }, - }, - }, - { - targetConfiguration: { - mcp: { - apiGateway: { - restApiId: "api123", - stage: "prod", - apiGatewayToolConfiguration: { - toolFilters: [{ filterPath: "/orders", methods: ["GET"] }], - }, - }, - }, - }, - }, - { - targetConfiguration: { - mcp: { - connector: { - source: { connectorId: "bedrock-knowledge-bases" }, - configurations: [ - { - name: "Retrieve", - parameterValues: { knowledgeBaseId: "KB12345678" }, - }, - { - name: "AgenticRetrieveStream", - parameterValues: { - retrievers: [ - { - configuration: { - knowledgeBase: { knowledgeBaseId: "KB87654321" }, - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, - { - targetConfiguration: { - mcp: { connector: { source: { connectorId: "web-search" } } }, - }, - }, - { - targetConfiguration: { - http: { agentcoreRuntime: { arn: runtimeArn, qualifier: "prod" } }, - }, - }, - { - targetConfiguration: { - inference: { - connector: { source: { connectorId: "bedrock-mantle" } }, - }, - }, - }, - { - targetConfiguration: { - mcp: { mcpServer: { endpoint: "https://calendar.example.test/mcp" } }, - }, - credentialProviderConfigurations: [ - { - credentialProviderType: "OAUTH", - credentialProvider: { - oauthCredentialProvider: { - providerArn: oauthProviderArn, - scopes: ["calendar.read"], - }, - }, - }, - { - credentialProviderType: "API_KEY", - credentialProvider: { - apiKeyCredentialProvider: { providerArn: apiKeyProviderArn }, - }, - }, - ], - credentialProviderSecretArns: [secretArn], - }, - ]; - - const statements = policyStatements( - GatewayExecutionPolicy.forTargets(configurations, { - partition: "aws", - region: REGION, - accountId: ACCOUNT_ID, - gatewayId: "orders-abc", - workloadIdentityArn: WORKLOAD_IDENTITY_ARN, - }), - ); - const bySid = new Map(statements.map((statement) => [statement.Sid, statement])); - - expect([...bySid.keys()]).toEqual([ - "AgentCoreGatewayTargetAgenticRetrieve", - "AgentCoreGatewayTargetApiGateway", - "AgentCoreGatewayTargetApiKey", - "AgentCoreGatewayTargetBedrockMantle", - "AgentCoreGatewayTargetCredentialSecrets", - "AgentCoreGatewayTargetKnowledgeBase", - "AgentCoreGatewayTargetLambda", - "AgentCoreGatewayTargetOAuth", - "AgentCoreGatewayTargetOAuthCompleteAuth", - "AgentCoreGatewayTargetRuntime", - "AgentCoreGatewayTargetS3Schema", - "AgentCoreGatewayTargetWebSearch", - "AgentCoreGatewayTargetWorkloadIdentity", - ]); - expect(bySid.get("AgentCoreGatewayTargetLambda")?.Resource).toEqual([ - lambdaArn, - `${lambdaArn}:*`, - ]); - expect(bySid.get("AgentCoreGatewayTargetRuntime")?.Resource).toEqual([ - runtimeArn, - `${runtimeArn}/runtime-endpoint/prod`, - ]); - expect(bySid.get("AgentCoreGatewayTargetS3Schema")?.Resource).toEqual([ - "arn:aws:s3:::schemas/calculator.json", - ]); - expect(bySid.get("AgentCoreGatewayTargetCredentialSecrets")?.Resource).toEqual([secretArn]); - }); - - test("rejects role-based Targets whose permissions cannot be inferred", () => { - expect(() => - GatewayExecutionPolicy.forTargets( - [ - { - targetConfiguration: { - http: { - passthrough: { - endpoint: "https://api.example.test", - protocolType: "CUSTOM", - }, - }, - }, - credentialProviderConfigurations: [ - { - credentialProviderType: "GATEWAY_IAM_ROLE", - credentialProvider: { - iamCredentialProvider: { - service: "execute-api", - region: REGION, - }, - }, - }, - ], - }, - ], + await expect(GatewayExecutionRole.ensure(iam, "orders", REGION)).resolves.toBe(roleArn); + expect(commands).toHaveLength(2); + expect(commands[1]).toBeInstanceOf(CreateRoleCommand); + expect((commands[1] as CreateRoleCommand).input).toMatchObject({ RoleName: roleName }); + expect( + JSON.parse((commands[1] as CreateRoleCommand).input.AssumeRolePolicyDocument!), + ).toMatchObject({ + Statement: [ { - partition: "aws", - region: REGION, - accountId: ACCOUNT_ID, - gatewayId: "orders-abc", + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", }, - ), - ).toThrow("cannot infer least-privilege IAM permissions"); + ], + }); }); - test("merges CLI policy statements by Sid without dropping resources", () => { - const current = GatewayExecutionPolicy.parse( - JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "AgentCoreGatewayTargetLambda", - Effect: "Allow", - Action: "lambda:InvokeFunction", - Resource: "arn:aws:lambda:us-west-2:123456789012:function:first", - }, - ], - }), - ); - const candidate = GatewayExecutionPolicy.forTargets( - [ - { - targetConfiguration: { - mcp: { - lambda: { - lambdaArn: "arn:aws:lambda:us-west-2:123456789012:function:second", - toolSchema: { inlinePayload: [] }, - }, - }, - }, - }, - ], - { - partition: "aws", - region: REGION, - accountId: ACCOUNT_ID, - gatewayId: "orders-abc", + test("reuses an existing default role without editing it", async () => { + const roleName = GatewayExecutionRole.roleName("orders", REGION); + const roleArn = `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`; + const commands: unknown[] = []; + const iam = { + send: async (command: unknown) => { + commands.push(command); + return { Role: { Arn: roleArn } }; }, - ); + } as unknown as IAMClient; - expect(policyStatements(current.merge(candidate))[0]!.Resource).toEqual([ - "arn:aws:lambda:us-west-2:123456789012:function:first", - "arn:aws:lambda:us-west-2:123456789012:function:second", - "arn:aws:lambda:us-west-2:123456789012:function:second:*", - ]); + await expect(GatewayExecutionRole.ensure(iam, "orders", REGION)).resolves.toBe(roleArn); + expect(commands).toHaveLength(1); + expect(commands[0]).toBeInstanceOf(GetRoleCommand); }); -}); -describe("Gateway role propagation retry", () => { - test("retries role propagation errors up to the configured limit", async () => { + test("retries while the default role is propagating", async () => { let attempts = 0; const delays: number[] = []; - await expect( - retryWhileGatewayRoleChangesPropagate( - async () => { - attempts += 1; + const result = await GatewayExecutionRole.retryWhileUnassumable( + async () => { + attempts += 1; + if (attempts === 1) { const error = new Error("Gateway execution role cannot be assumed"); error.name = "ValidationException"; throw error; - }, - 3, - 10, - async (delay) => { - delays.push(delay); - }, - ), - ).rejects.toThrow("cannot be assumed"); - expect(attempts).toBe(3); - expect(delays).toEqual([10, 10]); - }); - - test("does not retry unrelated failures", async () => { - let attempts = 0; - const error = new Error("target rejected"); + } + return "created"; + }, + 3, + 10, + async (delay) => { + delays.push(delay); + }, + ); - await expect( - retryWhileGatewayRoleChangesPropagate(async () => { - attempts += 1; - throw error; - }), - ).rejects.toBe(error); - expect(attempts).toBe(1); + expect(result).toBe("created"); + expect(attempts).toBe(2); + expect(delays).toEqual([10]); }); }); diff --git a/src/core/gatewayExecutionRole.ts b/src/core/gatewayExecutionRole.ts index 278b1664c..9879c7046 100644 --- a/src/core/gatewayExecutionRole.ts +++ b/src/core/gatewayExecutionRole.ts @@ -1,343 +1,71 @@ import { createHash } from "node:crypto"; -import type { GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; -import { - CreateRoleCommand, - DeleteRolePolicyCommand, - GetRoleCommand, - GetRolePolicyCommand, - PutRolePolicyCommand, - UpdateAssumeRolePolicyCommand, - type IAMClient, - type Role, -} from "@aws-sdk/client-iam"; -import { InputValidationError } from "../errors"; -import { - GatewayExecutionPolicy, - type GatewayRoleConfiguration, - type GatewayTargetPolicyContext, - type GatewayTargetRoleConfiguration, -} from "./gatewayExecutionRolePolicy"; - -export type { - GatewayRoleConfiguration, - GatewayTargetRoleConfiguration, -} from "./gatewayExecutionRolePolicy"; - -export const GATEWAY_EXECUTION_POLICY_NAME = "AgentCoreGatewayExecutionPolicy"; -export const GATEWAY_ROLE_MANAGED_BY_TAG = { - Key: "bedrock-agentcore:managed-by", - Value: "agentcore-cli", -} as const; -export const GATEWAY_ROLE_RESOURCE_TYPE_TAG = { - Key: "bedrock-agentcore:resource-type", - Value: "gateway", -} as const; - -type PolicySnapshot = { - exists: boolean; - policy: GatewayExecutionPolicy; -}; - -export type GatewayTargetPolicyPreparation = { - commit(): Promise; - rollback(): Promise; -}; +import { CreateRoleCommand, GetRoleCommand, type IAMClient, type Role } from "@aws-sdk/client-iam"; export class GatewayExecutionRole { - private constructor( - private readonly iam: IAMClient, - readonly roleName: string, - readonly roleArn: string, - private readonly accountId: string, - private readonly targetContext?: GatewayTargetPolicyContext, - ) {} - - static async prepareCreate( - iam: IAMClient, - gatewayName: string, - region: string, - configuration: GatewayRoleConfiguration, - ): Promise { - const roleName = gatewayExecutionRoleName(gatewayName, region); - const role = await getOrCreateOwnedRole(iam, roleName, gatewayName); - const roleArn = requiredRoleArn(role, roleName); - const arn = parseRoleArn(roleArn); - const gatewayPattern = gatewayArnPattern(arn.partition, region, arn.accountId, gatewayName); - const executionRole = new GatewayExecutionRole(iam, roleName, roleArn, arn.accountId); - - await executionRole.writeTrust(gatewayPattern); - await executionRole.mergePolicy( - GatewayExecutionPolicy.forGateway(configuration, gatewayPattern), - ); - return executionRole; - } - - static async fromGateway( - iam: IAMClient, - gateway: GetGatewayResponse, - region: string, - ): Promise { - if (!gateway.roleArn || !gateway.name) return undefined; - - const arn = parseRoleArn(gateway.roleArn); - if (arn.roleName !== gatewayExecutionRoleName(gateway.name, region)) return undefined; - - const response = await iam.send(new GetRoleCommand({ RoleName: arn.roleName })); - if (!isOwnedGatewayRole(response.Role)) return undefined; - - const gatewayId = gateway.gatewayId ?? gateway.gatewayArn?.split("/").at(-1); - if (!gatewayId) throw new Error("GetGateway response did not include the Gateway ID"); - return new GatewayExecutionRole(iam, arn.roleName, gateway.roleArn, arn.accountId, { - partition: arn.partition, - region, - accountId: arn.accountId, - gatewayId, - workloadIdentityArn: gateway.workloadIdentityDetails?.workloadIdentityArn, - }); + static roleName(gatewayName: string, region: string): string { + const suffix = createHash("sha256") + .update(`${region}:${gatewayName}`) + .digest("hex") + .slice(0, 12); + return `AgentCoreGateway-${gatewayName.slice(0, 32)}-${suffix}`; } - async finalizeGateway( - configuration: GatewayRoleConfiguration, - gatewayArn: string, - ): Promise { - await this.writeTrust(gatewayArn); - await this.writePolicy(GatewayExecutionPolicy.forGateway(configuration, gatewayArn)); - } - - async prepareTargetPolicy( - configuration: GatewayTargetRoleConfiguration, - ): Promise { - const contribution = GatewayExecutionPolicy.forTargets( - [configuration], - this.requiredTargetContext(), - ); - const previous = await this.readPolicy(); - await this.writePolicy(previous.policy.merge(contribution)); - - return { - commit: async () => { - const current = await this.readPolicy(); - const desired = current.policy.merge(contribution); - if (!current.policy.equals(desired)) await this.writePolicy(desired); - }, - rollback: async () => await this.restorePolicy(previous), - }; - } + static async ensure(iam: IAMClient, gatewayName: string, region: string): Promise { + const roleName = GatewayExecutionRole.roleName(gatewayName, region); - private requiredTargetContext(): GatewayTargetPolicyContext { - if (!this.targetContext) { - throw new Error("Gateway Target permissions require an existing Gateway"); - } - return this.targetContext; - } - - private async mergePolicy(candidate: GatewayExecutionPolicy): Promise { - const current = await this.readPolicy(); - const desired = current.policy.merge(candidate); - if (!current.policy.equals(desired)) await this.writePolicy(desired); - } - - private async readPolicy(): Promise { try { - const response = await this.iam.send( - new GetRolePolicyCommand({ - RoleName: this.roleName, - PolicyName: GATEWAY_EXECUTION_POLICY_NAME, - }), - ); - return { - exists: true, - policy: GatewayExecutionPolicy.parse(response.PolicyDocument), - }; + const response = await iam.send(new GetRoleCommand({ RoleName: roleName })); + return GatewayExecutionRole.requiredArn(response.Role, roleName); } catch (error) { if ((error as Error).name !== "NoSuchEntityException") throw error; - return { exists: false, policy: GatewayExecutionPolicy.empty() }; } - } - private async writePolicy(policy: GatewayExecutionPolicy): Promise { - const document = policy.toJSON(); - if (!document) { - await this.deletePolicy(); - return; - } - await this.iam.send( - new PutRolePolicyCommand({ - RoleName: this.roleName, - PolicyName: GATEWAY_EXECUTION_POLICY_NAME, - PolicyDocument: document, - }), - ); - } - - private async restorePolicy(snapshot: PolicySnapshot): Promise { - if (snapshot.exists) { - await this.writePolicy(snapshot.policy); - } else { - await this.deletePolicy(); - } - } - - private async deletePolicy(): Promise { - try { - await this.iam.send( - new DeleteRolePolicyCommand({ - RoleName: this.roleName, - PolicyName: GATEWAY_EXECUTION_POLICY_NAME, - }), - ); - } catch (error) { - if ((error as Error).name !== "NoSuchEntityException") throw error; - } - } - - private async writeTrust(sourceArn: string): Promise { - await this.iam.send( - new UpdateAssumeRolePolicyCommand({ - RoleName: this.roleName, - PolicyDocument: trustPolicy(this.accountId, sourceArn), + const response = await iam.send( + new CreateRoleCommand({ + RoleName: roleName, + AssumeRolePolicyDocument: GatewayExecutionRole.trustPolicy(), + Description: `Default execution role for the AgentCore Gateway "${gatewayName}" created by the agentcore CLI`, }), ); + return GatewayExecutionRole.requiredArn(response.Role, roleName); } -} - -export function gatewayExecutionRoleName(gatewayName: string, region: string): string { - const suffix = createHash("sha256").update(`${region}:${gatewayName}`).digest("hex").slice(0, 12); - return `AgentCoreGateway-${gatewayName.slice(0, 32)}-${suffix}`; -} -export async function retryWhileGatewayRoleChangesPropagate( - operation: () => Promise, - attempts = 8, - delayMs = 2000, - sleep: (delayMs: number) => Promise = (delay) => - new Promise((resolve) => setTimeout(resolve, delay)), -): Promise { - for (let attempt = 1; ; attempt += 1) { - try { - return await operation(); - } catch (error) { - if (!isGatewayRolePropagationError(error) || attempt >= attempts) throw error; - await sleep(delayMs); + static async retryWhileUnassumable( + operation: () => Promise, + attempts = 8, + delayMs = 2000, + sleep: (delayMs: number) => Promise = (delay) => + new Promise((resolve) => setTimeout(resolve, delay)), + ): Promise { + for (let attempt = 1; ; attempt += 1) { + 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 sleep(delayMs); + } } } -} - -async function getOrCreateOwnedRole( - iam: IAMClient, - roleName: string, - gatewayName: string, -): Promise { - try { - const existing = await iam.send(new GetRoleCommand({ RoleName: roleName })); - assertOwnedGatewayRole(existing.Role, roleName); - return existing.Role!; - } catch (error) { - if ((error as Error).name !== "NoSuchEntityException") throw error; - } - try { - const created = await iam.send( - new CreateRoleCommand({ - RoleName: roleName, - AssumeRolePolicyDocument: bootstrapTrustPolicy(), - Description: `Execution role for the AgentCore Gateway "${gatewayName}" created by the agentcore CLI`, - Tags: [ - GATEWAY_ROLE_MANAGED_BY_TAG, - GATEWAY_ROLE_RESOURCE_TYPE_TAG, - { Key: "bedrock-agentcore:resource-name", Value: gatewayName }, - ], - }), - ); - return created.Role!; - } catch (error) { - if ((error as Error).name !== "EntityAlreadyExistsException") throw error; - const existing = await iam.send(new GetRoleCommand({ RoleName: roleName })); - assertOwnedGatewayRole(existing.Role, roleName); - return existing.Role!; + private static requiredArn(role: Role | undefined, roleName: string): string { + if (!role?.Arn) throw new Error(`IAM did not return an ARN for role "${roleName}"`); + return role.Arn; } -} - -function assertOwnedGatewayRole(role: Role | undefined, roleName: string): void { - if (isOwnedGatewayRole(role)) return; - throw new InputValidationError( - `IAM role "${roleName}" already exists but is not managed by the agentcore CLI; ` + - "pass --role-arn with a customer-managed Gateway role", - ); -} - -function isOwnedGatewayRole(role: Role | undefined): boolean { - const tags = new Map(role?.Tags?.map(({ Key, Value }) => [Key, Value])); - return ( - tags.get(GATEWAY_ROLE_MANAGED_BY_TAG.Key) === GATEWAY_ROLE_MANAGED_BY_TAG.Value && - tags.get(GATEWAY_ROLE_RESOURCE_TYPE_TAG.Key) === GATEWAY_ROLE_RESOURCE_TYPE_TAG.Value - ); -} - -function requiredRoleArn(role: Role, roleName: string): string { - if (!role.Arn) throw new Error(`IAM did not return an ARN for role "${roleName}"`); - return role.Arn; -} - -function bootstrapTrustPolicy(): string { - return JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "GatewayAssumeRoleBootstrap", - Effect: "Deny", - Principal: { Service: "bedrock-agentcore.amazonaws.com" }, - Action: "sts:AssumeRole", - }, - ], - }); -} -function trustPolicy(accountId: string, sourceArn: string): string { - return JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "GatewayAssumeRolePolicy", - Effect: "Allow", - Principal: { Service: "bedrock-agentcore.amazonaws.com" }, - Action: "sts:AssumeRole", - Condition: { - StringEquals: { "aws:SourceAccount": accountId }, - ArnLike: { "aws:SourceArn": sourceArn }, + private static trustPolicy(): string { + return JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Sid: "GatewayAssumeRolePolicy", + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", }, - }, - ], - }); -} - -function isGatewayRolePropagationError(error: unknown): boolean { - if ((error as Error).name !== "ValidationException") return false; - const message = (error as Error).message ?? ""; - return [ - /\b(?:execution|gateway|iam)\s+role\b.*\b(?:assum|trust)\w*/i, - /\b(?:assum|trust)\w*\b.*\brole\b/i, - /\brole\b.*\b(?:lack|missing|permission|authoriz)\w*/i, - /\b(?:permission|authoriz)\w*\b.*\brole\b/i, - ].some((pattern) => pattern.test(message)); -} - -function parseRoleArn(arn: string): { - partition: string; - accountId: string; - roleName: string; -} { - const match = /^arn:([^:]+):iam::(\d{12}):role\/(.+)$/.exec(arn); - const roleName = match?.[3]?.split("/").at(-1); - if (!match || !roleName) throw new InputValidationError(`Invalid IAM role ARN "${arn}"`); - return { partition: match[1]!, accountId: match[2]!, roleName }; -} - -function gatewayArnPattern( - partition: string, - region: string, - accountId: string, - gatewayName: string, -): string { - return `arn:${partition}:bedrock-agentcore:${region}:${accountId}:gateway/${gatewayName}-*`; + ], + }); + } } diff --git a/src/core/gatewayExecutionRolePolicy.ts b/src/core/gatewayExecutionRolePolicy.ts deleted file mode 100644 index 13042f890..000000000 --- a/src/core/gatewayExecutionRolePolicy.ts +++ /dev/null @@ -1,556 +0,0 @@ -import type { - CreateGatewayTargetRequest, - GetGatewayResponse, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { InputValidationError } from "../errors"; - -const GATEWAY_POLICY_SCOPE = "AgentCoreGatewayConfig"; -const GATEWAY_TARGET_POLICY_SCOPE = "AgentCoreGatewayTarget"; - -export type GatewayRoleConfiguration = { - customTransformConfiguration?: GetGatewayResponse["customTransformConfiguration"]; - interceptorConfigurations?: GetGatewayResponse["interceptorConfigurations"]; - policyEngineConfiguration?: GetGatewayResponse["policyEngineConfiguration"]; -}; - -export type GatewayTargetRoleConfiguration = Pick< - CreateGatewayTargetRequest, - "targetConfiguration" | "credentialProviderConfigurations" -> & { - credentialProviderSecretArns?: string[]; -}; - -export type GatewayTargetPolicyContext = { - partition: string; - region: string; - accountId: string; - gatewayId: string; - workloadIdentityArn?: string; -}; - -type PolicyStatement = { - Sid?: string; - Effect?: string; - Action?: string | string[]; - Resource?: string | string[]; - [key: string]: unknown; -}; - -type PolicyDocument = { - Version: "2012-10-17"; - Statement: PolicyStatement[]; -}; - -export class GatewayExecutionPolicy { - private constructor(private readonly document: PolicyDocument) {} - - static empty(): GatewayExecutionPolicy { - return new GatewayExecutionPolicy(policyDocument([])); - } - - static parse(value: string | undefined): GatewayExecutionPolicy { - if (!value) return GatewayExecutionPolicy.empty(); - - let parsed: { Statement?: PolicyStatement | PolicyStatement[] }; - try { - parsed = JSON.parse(value); - } catch { - parsed = JSON.parse(decodeURIComponent(value)); - } - const statements = parsed.Statement - ? Array.isArray(parsed.Statement) - ? parsed.Statement - : [parsed.Statement] - : []; - return new GatewayExecutionPolicy(policyDocument(statements)); - } - - static forGateway( - configuration: GatewayRoleConfiguration, - gatewayArn: string, - ): GatewayExecutionPolicy { - return new GatewayExecutionPolicy( - policyDocument(configurationStatements(configuration, gatewayArn)), - ); - } - - static forTargets( - configurations: GatewayTargetRoleConfiguration[], - context: GatewayTargetPolicyContext, - ): GatewayExecutionPolicy { - return new GatewayExecutionPolicy(policyDocument(targetStatements(configurations, context))); - } - - merge(candidate: GatewayExecutionPolicy): GatewayExecutionPolicy { - const statements = [...this.document.Statement]; - for (const requested of candidate.document.Statement) { - const index = requested.Sid ? statements.findIndex(({ Sid }) => Sid === requested.Sid) : -1; - if (index === -1) { - statements.push(requested); - } else { - statements[index] = mergeStatements(statements[index]!, requested); - } - } - return new GatewayExecutionPolicy(policyDocument(statements)); - } - - equals(other: GatewayExecutionPolicy): boolean { - return JSON.stringify(this.document) === JSON.stringify(other.document); - } - - toJSON(): string | undefined { - return this.document.Statement.length > 0 ? JSON.stringify(this.document) : undefined; - } -} - -function configurationStatements( - configuration: GatewayRoleConfiguration, - gatewayArn: string, -): PolicyStatement[] { - const statements: PolicyStatement[] = []; - const policyEngineArn = configuration.policyEngineConfiguration?.arn; - if (policyEngineArn) { - statements.push( - { - Sid: `${GATEWAY_POLICY_SCOPE}PolicyEngine`, - Effect: "Allow", - Action: "bedrock-agentcore:GetPolicyEngine", - Resource: policyEngineArn, - }, - { - Sid: `${GATEWAY_POLICY_SCOPE}PolicyAuthorization`, - Effect: "Allow", - Action: [ - "bedrock-agentcore:AuthorizeAction", - "bedrock-agentcore:PartiallyAuthorizeActions", - ], - Resource: [policyEngineArn, gatewayArn].sort(), - }, - { - Sid: `${GATEWAY_POLICY_SCOPE}Guardrail`, - Effect: "Allow", - Action: "bedrock:InvokeGuardrailChecks", - Resource: "*", - }, - ); - } - - const lambdaArns = new Set(); - for (const interceptor of configuration.interceptorConfigurations ?? []) { - const interceptorConfiguration = interceptor.interceptor; - const interceptorArn = - interceptorConfiguration && - "lambda" in interceptorConfiguration && - interceptorConfiguration.lambda - ? interceptorConfiguration.lambda.arn - : undefined; - if (interceptorArn) lambdaArns.add(interceptorArn); - } - const transformArn = configuration.customTransformConfiguration?.lambda?.arn; - if (transformArn) lambdaArns.add(transformArn); - addResourceStatement( - statements, - `${GATEWAY_POLICY_SCOPE}Lambda`, - "lambda:InvokeFunction", - lambdaArns, - ); - return statements; -} - -function targetStatements( - configurations: GatewayTargetRoleConfiguration[], - context: GatewayTargetPolicyContext, -): PolicyStatement[] { - const resources = { - lambda: new Set(), - s3: new Set(), - apiGateway: new Set(), - runtime: new Set(), - knowledgeBase: new Set(), - oauthProvider: new Set(), - apiKeyProvider: new Set(), - secret: new Set(), - }; - let usesWebSearch = false; - let usesAgenticRetrieve = false; - let usesBedrockMantle = false; - - for (const configuration of configurations) { - const target = configuration.targetConfiguration; - let roleRequested = false; - let roleSupported = false; - - if (target && "mcp" in target && target.mcp) { - const mcp = target.mcp; - if ("lambda" in mcp && mcp.lambda) { - roleRequested = usesGatewayRole(configuration, true); - roleSupported = true; - if (roleRequested && mcp.lambda.lambdaArn) { - resources.lambda.add(mcp.lambda.lambdaArn); - if (isUnqualifiedLambdaFunctionArn(mcp.lambda.lambdaArn)) { - resources.lambda.add(`${mcp.lambda.lambdaArn}:*`); - } - } - addSchemaS3Object(resources.s3, mcp.lambda.toolSchema, context.partition); - } else if ("openApiSchema" in mcp && mcp.openApiSchema) { - roleRequested = usesGatewayRole(configuration, false); - addSchemaS3Object(resources.s3, mcp.openApiSchema, context.partition); - } else if ("smithyModel" in mcp && mcp.smithyModel) { - roleRequested = usesGatewayRole(configuration, true); - addSchemaS3Object(resources.s3, mcp.smithyModel, context.partition); - } else if ("mcpServer" in mcp && mcp.mcpServer) { - roleRequested = usesGatewayRole(configuration, false); - addSchemaS3Object(resources.s3, mcp.mcpServer.mcpToolSchema, context.partition); - } else if ("apiGateway" in mcp && mcp.apiGateway) { - roleRequested = usesGatewayRole(configuration, true); - roleSupported = true; - if (roleRequested) { - resources.apiGateway.add( - `arn:${context.partition}:execute-api:${context.region}:${context.accountId}:` + - `${mcp.apiGateway.restApiId}/${mcp.apiGateway.stage}/*/*`, - ); - } - } else if ("connector" in mcp && mcp.connector) { - const connectorId = mcp.connector.source?.connectorId; - const hasKnownRole = ["web-search", "bedrock-knowledge-bases"].includes(connectorId ?? ""); - roleRequested = usesGatewayRole(configuration, hasKnownRole); - roleSupported = hasKnownRole; - if (roleRequested && connectorId === "web-search") { - usesWebSearch = true; - } else if (roleRequested && connectorId === "bedrock-knowledge-bases") { - usesAgenticRetrieve = - addKnowledgeBaseConnectorPermissions( - resources.knowledgeBase, - mcp.connector.configurations, - context, - ) || usesAgenticRetrieve; - } - } - } else if (target && "http" in target && target.http) { - const http = target.http; - if ("agentcoreRuntime" in http && http.agentcoreRuntime) { - roleRequested = usesGatewayRole(configuration, true); - roleSupported = true; - if (roleRequested && http.agentcoreRuntime.arn) { - resources.runtime.add(http.agentcoreRuntime.arn); - resources.runtime.add( - `${http.agentcoreRuntime.arn}/runtime-endpoint/` + - `${http.agentcoreRuntime.qualifier ?? "DEFAULT"}`, - ); - } - addSchemaS3Object(resources.s3, http.agentcoreRuntime.schema?.source, context.partition); - } else if ("passthrough" in http && http.passthrough) { - roleRequested = usesGatewayRole(configuration, false); - addSchemaS3Object(resources.s3, http.passthrough.schema?.source, context.partition); - } - } else if (target && "inference" in target && target.inference) { - const inference = target.inference; - const isMantle = - ("connector" in inference && - inference.connector?.source?.connectorId === "bedrock-mantle") || - ("provider" in inference && - inference.provider?.endpoint?.includes("bedrock-mantle.") === true); - roleRequested = usesGatewayRole(configuration, isMantle); - roleSupported = isMantle; - usesBedrockMantle = usesBedrockMantle || (roleRequested && isMantle); - } else { - roleRequested = usesGatewayRole(configuration, false); - } - - if (roleRequested && !roleSupported) { - throw new InputValidationError( - "The CLI cannot infer least-privilege IAM permissions for this Target's " + - "GATEWAY_IAM_ROLE credential; use a customer-managed Gateway --role-arn", - ); - } - - collectCredentialResources(configuration, resources); - } - - const statements: PolicyStatement[] = []; - addResourceStatement( - statements, - `${GATEWAY_TARGET_POLICY_SCOPE}Lambda`, - "lambda:InvokeFunction", - resources.lambda, - ); - addResourceStatement( - statements, - `${GATEWAY_TARGET_POLICY_SCOPE}S3Schema`, - "s3:GetObject", - resources.s3, - ); - addResourceStatement( - statements, - `${GATEWAY_TARGET_POLICY_SCOPE}ApiGateway`, - "execute-api:Invoke", - resources.apiGateway, - ); - addResourceStatement( - statements, - `${GATEWAY_TARGET_POLICY_SCOPE}Runtime`, - "bedrock-agentcore:InvokeAgentRuntime", - resources.runtime, - ); - addResourceStatement( - statements, - `${GATEWAY_TARGET_POLICY_SCOPE}KnowledgeBase`, - ["bedrock:GetKnowledgeBase", "bedrock:Retrieve"], - resources.knowledgeBase, - ); - if (usesAgenticRetrieve) { - statements.push({ - Sid: `${GATEWAY_TARGET_POLICY_SCOPE}AgenticRetrieve`, - Effect: "Allow", - Action: "bedrock:AgenticRetrieveStream", - Resource: "*", - }); - } - if (usesBedrockMantle) { - statements.push({ - Sid: `${GATEWAY_TARGET_POLICY_SCOPE}BedrockMantle`, - Effect: "Allow", - Action: "bedrock-mantle:CreateInference", - Resource: "*", - }); - } - if (usesWebSearch) { - statements.push({ - Sid: `${GATEWAY_TARGET_POLICY_SCOPE}WebSearch`, - Effect: "Allow", - Action: "bedrock-agentcore:InvokeWebSearch", - Resource: `arn:${context.partition}:bedrock-agentcore:${context.region}:aws:tool/web-search.v1`, - }); - } - - const workloadIdentityArns = - resources.oauthProvider.size > 0 || resources.apiKeyProvider.size > 0 - ? workloadIdentityResources(context) - : []; - if (workloadIdentityArns.length > 0) { - statements.push({ - Sid: `${GATEWAY_TARGET_POLICY_SCOPE}WorkloadIdentity`, - Effect: "Allow", - Action: [ - "bedrock-agentcore:GetWorkloadAccessToken", - ...(resources.oauthProvider.size > 0 - ? [ - "bedrock-agentcore:GetWorkloadAccessTokenForJWT", - "bedrock-agentcore:GetWorkloadAccessTokenForUserId", - ] - : []), - ].sort(), - Resource: workloadIdentityArns, - }); - } - if (resources.oauthProvider.size > 0) { - const targetResources = tokenVaultResources( - context, - resources.oauthProvider, - workloadIdentityArns, - ); - statements.push( - { - Sid: `${GATEWAY_TARGET_POLICY_SCOPE}OAuthCompleteAuth`, - Effect: "Allow", - Action: "bedrock-agentcore:CompleteResourceTokenAuth", - Resource: targetResources, - }, - { - Sid: `${GATEWAY_TARGET_POLICY_SCOPE}OAuth`, - Effect: "Allow", - Action: "bedrock-agentcore:GetResourceOauth2Token", - Resource: targetResources, - }, - ); - } - if (resources.apiKeyProvider.size > 0) { - statements.push({ - Sid: `${GATEWAY_TARGET_POLICY_SCOPE}ApiKey`, - Effect: "Allow", - Action: "bedrock-agentcore:GetResourceApiKey", - Resource: tokenVaultResources(context, resources.apiKeyProvider, workloadIdentityArns), - }); - } - addResourceStatement( - statements, - `${GATEWAY_TARGET_POLICY_SCOPE}CredentialSecrets`, - "secretsmanager:GetSecretValue", - resources.secret, - ); - return statements; -} - -function usesGatewayRole( - configuration: GatewayTargetRoleConfiguration, - byDefault: boolean, -): boolean { - if (configuration.credentialProviderConfigurations === undefined) return byDefault; - return configuration.credentialProviderConfigurations.some( - ({ credentialProviderType }) => credentialProviderType === "GATEWAY_IAM_ROLE", - ); -} - -function collectCredentialResources( - configuration: GatewayTargetRoleConfiguration, - resources: { - oauthProvider: Set; - apiKeyProvider: Set; - secret: Set; - }, -): void { - for (const providerConfiguration of configuration.credentialProviderConfigurations ?? []) { - const provider = providerConfiguration.credentialProvider; - if ( - providerConfiguration.credentialProviderType === "OAUTH" && - provider && - "oauthCredentialProvider" in provider && - provider.oauthCredentialProvider?.providerArn - ) { - resources.oauthProvider.add(provider.oauthCredentialProvider.providerArn); - } else if ( - providerConfiguration.credentialProviderType === "API_KEY" && - provider && - "apiKeyCredentialProvider" in provider && - provider.apiKeyCredentialProvider?.providerArn - ) { - resources.apiKeyProvider.add(provider.apiKeyCredentialProvider.providerArn); - } - } - for (const secretArn of configuration.credentialProviderSecretArns ?? []) { - resources.secret.add(secretArn); - } -} - -function workloadIdentityResources(context: GatewayTargetPolicyContext): string[] { - const workloadIdentityArn = context.workloadIdentityArn; - if (!workloadIdentityArn) { - throw new Error( - `Gateway "${context.gatewayId}" did not return a workload identity ARN required for Target credentials`, - ); - } - const marker = "/workload-identity/"; - const markerIndex = workloadIdentityArn.indexOf(marker); - if (markerIndex === -1) { - throw new Error(`Invalid Gateway workload identity ARN "${workloadIdentityArn}"`); - } - return [workloadIdentityArn.slice(0, markerIndex), workloadIdentityArn].sort(); -} - -function tokenVaultResources( - context: GatewayTargetPolicyContext, - providerArns: Set, - workloadIdentityArns: string[], -): string[] { - const tokenVaultArn = - `arn:${context.partition}:bedrock-agentcore:${context.region}:${context.accountId}:` + - "token-vault/default"; - return [...new Set([tokenVaultArn, ...providerArns, ...workloadIdentityArns])].sort(); -} - -function isUnqualifiedLambdaFunctionArn(arn: string): boolean { - return /^arn:[^:]+:lambda:[^:]+:\d{12}:function:[^:]+$/.test(arn); -} - -function addKnowledgeBaseConnectorPermissions( - resources: Set, - configurations: - | { - name?: string; - parameterValues?: unknown; - }[] - | undefined, - context: GatewayTargetPolicyContext, -): boolean { - let usesAgenticRetrieve = false; - for (const configuration of configurations ?? []) { - const parameterValues = asRecord(configuration.parameterValues); - if (configuration.name === "Retrieve") { - addKnowledgeBaseArn(resources, parameterValues?.knowledgeBaseId, context); - } else if (configuration.name === "AgenticRetrieveStream") { - usesAgenticRetrieve = true; - const retrievers = parameterValues?.retrievers; - if (!Array.isArray(retrievers)) continue; - for (const retriever of retrievers) { - const retrieverConfiguration = asRecord(asRecord(retriever)?.configuration); - const knowledgeBase = asRecord(retrieverConfiguration?.knowledgeBase); - addKnowledgeBaseArn(resources, knowledgeBase?.knowledgeBaseId, context); - } - } - } - return usesAgenticRetrieve; -} - -function addKnowledgeBaseArn( - resources: Set, - knowledgeBaseId: unknown, - context: GatewayTargetPolicyContext, -): void { - if (typeof knowledgeBaseId !== "string" || !knowledgeBaseId) return; - resources.add( - `arn:${context.partition}:bedrock:${context.region}:${context.accountId}:` + - `knowledge-base/${knowledgeBaseId}`, - ); -} - -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; - return value as Record; -} - -function addSchemaS3Object( - resources: Set, - schema: { s3?: { uri?: string } } | undefined, - partition: string, -): void { - const uri = schema?.s3?.uri; - if (!uri) return; - const match = /^s3:\/\/([^/]+)\/(.+)$/.exec(uri); - if (match) resources.add(`arn:${partition}:s3:::${match[1]}/${match[2]}`); -} - -function addResourceStatement( - statements: PolicyStatement[], - sid: string, - action: string | string[], - resources: Set, -): void { - if (resources.size === 0) return; - statements.push({ - Sid: sid, - Effect: "Allow", - Action: action, - Resource: [...resources].sort(), - }); -} - -function mergeStatements(current: PolicyStatement, requested: PolicyStatement): PolicyStatement { - return { - ...current, - ...requested, - Action: unionValues(current.Action, requested.Action), - Resource: unionValues(current.Resource, requested.Resource), - }; -} - -function unionValues( - left: string | string[] | undefined, - right: string | string[] | undefined, -): string | string[] | undefined { - const values = new Set([...asArray(left), ...asArray(right)]); - if (values.size === 0) return undefined; - const result = [...values].sort(); - return result.length === 1 ? result[0] : result; -} - -function asArray(value: string | string[] | undefined): string[] { - if (value === undefined) return []; - return Array.isArray(value) ? value : [value]; -} - -function policyDocument(statements: PolicyStatement[]): PolicyDocument { - return { - Version: "2012-10-17", - Statement: statements.sort((left, right) => (left.Sid ?? "").localeCompare(right.Sid ?? "")), - }; -} diff --git a/src/handlers/gateway/create/index.tsx b/src/handlers/gateway/create/index.tsx index a848a68ec..9699e3fbe 100644 --- a/src/handlers/gateway/create/index.tsx +++ b/src/handlers/gateway/create/index.tsx @@ -21,7 +21,7 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => flag("name", "the Gateway name", z.string().optional()), flag( "role-arn", - "IAM role the Gateway assumes; a least-privilege role is created when omitted", + "IAM role the Gateway assumes; a default role is created when omitted", z.string().optional(), ), flag( From 5d37062257458b2687600af7ea14dcb6cfcc5238 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 5 Aug 2026 20:57:13 +0000 Subject: [PATCH 04/15] fix(gateway): verify create flows with service fixtures --- .../CreateGatewayCommand.a0ea607af6e17f4.json | 17 ++ ...ateGatewayRuleCommand.cbb05d778d22a55.json | 19 ++ ...GatewayTargetCommand.60d88dfe16fb4c85.json | 20 ++ .../CreateRoleCommand.f4622691a336e689.json | 12 + .../GetGatewayCommand.2e61cb938818685f.json | 19 ++ ...etGatewayRuleCommand.f407cc1dd803bf9d.json | 22 ++ ...etGatewayTargetCommand.ad7aa1ace2dc5f.json | 20 ++ .../GetRoleCommand.e7adf912c1bf2c67.json | 6 + .../create/gateway-create.golden.json | 13 + .../create/rule-create.golden.json | 17 ++ .../create/target-create.golden.json | 16 ++ .../gateway/gateway.create.fixture.test.tsx | 244 ++++++++++++++++++ src/handlers/gateway/gateway.create.test.tsx | 7 +- src/handlers/gateway/mutations.ts | 37 +-- 14 files changed, 433 insertions(+), 36 deletions(-) create mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.a0ea607af6e17f4.json create mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.cbb05d778d22a55.json create mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.60d88dfe16fb4c85.json create mode 100644 src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json create mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayCommand.2e61cb938818685f.json create mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.f407cc1dd803bf9d.json create mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.ad7aa1ace2dc5f.json create mode 100644 src/handlers/gateway/__fixtures__/create/GetRoleCommand.e7adf912c1bf2c67.json create mode 100644 src/handlers/gateway/__fixtures__/create/gateway-create.golden.json create mode 100644 src/handlers/gateway/__fixtures__/create/rule-create.golden.json create mode 100644 src/handlers/gateway/__fixtures__/create/target-create.golden.json create mode 100644 src/handlers/gateway/gateway.create.fixture.test.tsx diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.a0ea607af6e17f4.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.a0ea607af6e17f4.json new file mode 100644 index 000000000..520fdbffb --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.a0ea607af6e17f4.json @@ -0,0 +1,17 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "gatewayId": "agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "createdAt": { + "$date": "2026-08-05T20:52:40.278Z" + }, + "updatedAt": { + "$date": "2026-08-05T20:52:40.278Z" + }, + "status": "CREATING", + "name": "agentcore-cli-gateway-create-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-7ky2bb3jku.gateway.bedrock-agentcore.us-west-2.amazonaws.com", + "description": "Disposable Gateway Create fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "workloadIdentityDetails": {} +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.cbb05d778d22a55.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.cbb05d778d22a55.json new file mode 100644 index 000000000..78bf26240 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.cbb05d778d22a55.json @@ -0,0 +1,19 @@ +{ + "ruleId": "b68aa940-0a41-40bf-9b49-aab1647066b3", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "priority": 10, + "actions": [ + { + "routeToTarget": { + "staticRoute": { + "targetName": "http-fixture" + } + } + } + ], + "createdAt": { + "$date": "2026-08-05T20:52:51.379Z" + }, + "status": "CREATING", + "description": "Disposable Gateway Rule Create fixture" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.60d88dfe16fb4c85.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.60d88dfe16fb4c85.json new file mode 100644 index 000000000..2a5b75399 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.60d88dfe16fb4c85.json @@ -0,0 +1,20 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "targetId": "APUTKOW1TL", + "createdAt": { + "$date": "2026-08-05T20:52:45.837Z" + }, + "updatedAt": { + "$date": "2026-08-05T20:52:45.837Z" + }, + "status": "CREATING", + "name": "http-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json b/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json new file mode 100644 index 000000000..a7031a3e1 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json @@ -0,0 +1,12 @@ +{ + "Role": { + "Path": "/", + "RoleName": "AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "RoleId": "AROAYY3QB54NYN6N6G33W", + "Arn": "arn:aws:iam::603141041947:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "CreateDate": { + "$date": "2026-08-05T20:52:39.000Z" + }, + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Sid%22%3A%22GatewayAssumeRolePolicy%22%2C%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" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.2e61cb938818685f.json b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.2e61cb938818685f.json new file mode 100644 index 000000000..f7bc630e4 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.2e61cb938818685f.json @@ -0,0 +1,19 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "gatewayId": "agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "createdAt": { + "$date": "2026-08-05T20:52:40.278Z" + }, + "updatedAt": { + "$date": "2026-08-05T20:52:41.233Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-create-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-7ky2bb3jku.gateway.bedrock-agentcore.us-west-2.amazonaws.com", + "description": "Disposable Gateway Create fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-create-fixture-7ky2bb3jku" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.f407cc1dd803bf9d.json b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.f407cc1dd803bf9d.json new file mode 100644 index 000000000..c25d7053c --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.f407cc1dd803bf9d.json @@ -0,0 +1,22 @@ +{ + "ruleId": "b68aa940-0a41-40bf-9b49-aab1647066b3", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "priority": 10, + "actions": [ + { + "routeToTarget": { + "staticRoute": { + "targetName": "http-fixture" + } + } + } + ], + "createdAt": { + "$date": "2026-08-05T20:52:51.379Z" + }, + "status": "ACTIVE", + "description": "Disposable Gateway Rule Create fixture", + "updatedAt": { + "$date": "2026-08-05T20:52:51.379Z" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.ad7aa1ace2dc5f.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.ad7aa1ace2dc5f.json new file mode 100644 index 000000000..9bd780d7b --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.ad7aa1ace2dc5f.json @@ -0,0 +1,20 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "targetId": "APUTKOW1TL", + "createdAt": { + "$date": "2026-08-05T20:52:45.837Z" + }, + "updatedAt": { + "$date": "2026-08-05T20:52:46.644Z" + }, + "status": "READY", + "name": "http-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetRoleCommand.e7adf912c1bf2c67.json b/src/handlers/gateway/__fixtures__/create/GetRoleCommand.e7adf912c1bf2c67.json new file mode 100644 index 000000000..ac862bdbb --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetRoleCommand.e7adf912c1bf2c67.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "NoSuchEntityException", + "message": "The role with name AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4 cannot be found." + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json new file mode 100644 index 000000000..e556f0dbd --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json @@ -0,0 +1,13 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "gatewayId": "agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "createdAt": "2026-08-05T20:52:40.278Z", + "updatedAt": "2026-08-05T20:52:40.278Z", + "status": "CREATING", + "name": "agentcore-cli-gateway-create-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-7ky2bb3jku.gateway.bedrock-agentcore.us-west-2.amazonaws.com", + "description": "Disposable Gateway Create fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "workloadIdentityDetails": {} +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/rule-create.golden.json b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json new file mode 100644 index 000000000..46602727e --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json @@ -0,0 +1,17 @@ +{ + "ruleId": "b68aa940-0a41-40bf-9b49-aab1647066b3", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "priority": 10, + "actions": [ + { + "routeToTarget": { + "staticRoute": { + "targetName": "http-fixture" + } + } + } + ], + "createdAt": "2026-08-05T20:52:51.379Z", + "status": "CREATING", + "description": "Disposable Gateway Rule Create fixture" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/target-create.golden.json b/src/handlers/gateway/__fixtures__/create/target-create.golden.json new file mode 100644 index 000000000..88dbfff84 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/target-create.golden.json @@ -0,0 +1,16 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "targetId": "APUTKOW1TL", + "createdAt": "2026-08-05T20:52:45.837Z", + "updatedAt": "2026-08-05T20:52:45.837Z", + "status": "CREATING", + "name": "http-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + } +} \ No newline at end of file diff --git a/src/handlers/gateway/gateway.create.fixture.test.tsx b/src/handlers/gateway/gateway.create.fixture.test.tsx new file mode 100644 index 000000000..7a071a3c3 --- /dev/null +++ b/src/handlers/gateway/gateway.create.fixture.test.tsx @@ -0,0 +1,244 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + DeleteGatewayCommand, + DeleteGatewayRuleCommand, + DeleteGatewayTargetCommand, + GetGatewayCommand, + GetGatewayTargetCommand, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { DeleteRoleCommand } from "@aws-sdk/client-iam"; +import { CoreClient } from "../../core"; +import { createControlClient, createIamClient } from "../../core/factories"; +import { GatewayExecutionRole } from "../../core/gatewayExecutionRole"; +import { + createSilentLogger, + fixtureFactories, + isRecording, + matchGolden, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; +import { createRootHandler } from "../index"; + +const REGION = "us-west-2"; +const GATEWAY_NAME = "agentcore-cli-gateway-create-fixture"; +const HTTP_TARGET_NAME = "http-fixture"; +const FIXTURES = join(import.meta.dir, "__fixtures__", "create"); +const FLOW_TIMEOUT = 600_000; + +// Record with AWS_PROFILE=deploy RECORD=1 bun test src/handlers/gateway/gateway.create.fixture.test.tsx +type FixtureState = { + gatewayId?: string; + targetId?: string; + ruleId?: string; +}; + +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + logger: createSilentLogger(), + }); +} + +async function run(args: string[]): Promise { + const io = testIO(); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return io.stdout(); +} + +async function pollUntil( + args: string[], + done: (response: Record) => boolean, +): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + const response = JSON.parse(await run(args)) as Record; + if (done(response)) return; + if (!isRecording()) { + throw new Error(`Replayed fixture for \`${args.join(" ")}\` is not settled`); + } + await Bun.sleep(5_000); + } + throw new Error(`Timed out waiting for \`${args.join(" ")}\``); +} + +async function ignoreMissing(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + if (!["ResourceNotFoundException", "NoSuchEntityException"].includes((error as Error).name)) { + throw error; + } + } +} + +async function waitUntilMissing(operation: () => Promise): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + await operation(); + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException") return; + throw error; + } + await Bun.sleep(2_000); + } + throw new Error("Timed out waiting for fixture resource deletion"); +} + +async function cleanup(state: FixtureState): Promise { + if (!isRecording()) return; + + const control = createControlClient({ region: REGION }); + if (state.gatewayId && state.ruleId) { + await ignoreMissing(() => + control.send( + new DeleteGatewayRuleCommand({ + gatewayIdentifier: state.gatewayId, + ruleId: state.ruleId, + }), + ), + ); + } + + if (state.gatewayId) { + if (state.targetId) { + await ignoreMissing(() => + control.send( + new DeleteGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId: state.targetId, + }), + ), + ); + await waitUntilMissing(() => + control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId: state.targetId, + }), + ), + ); + } + + await ignoreMissing(() => + control.send(new DeleteGatewayCommand({ gatewayIdentifier: state.gatewayId })), + ); + await waitUntilMissing(() => + control.send(new GetGatewayCommand({ gatewayIdentifier: state.gatewayId })), + ); + } + + const iam = createIamClient({ region: REGION }); + await ignoreMissing(() => + iam.send( + new DeleteRoleCommand({ + RoleName: GatewayExecutionRole.roleName(GATEWAY_NAME, REGION), + }), + ), + ); +} + +describe("Gateway fixture-backed creates", () => { + test( + "creates a Gateway, Target, and Rule through the real Core", + async () => { + const state: FixtureState = {}; + + try { + const gatewayStdout = await run([ + "gateway", + "create", + "--name", + GATEWAY_NAME, + "--authorizer-type", + "NONE", + "--description", + "Disposable Gateway Create fixture", + ]); + matchGolden(FIXTURES, "gateway-create.golden.json", gatewayStdout); + const gateway = JSON.parse(gatewayStdout); + expect(gateway.name).toBe(GATEWAY_NAME); + expect(gateway.gatewayId).toBeString(); + state.gatewayId = gateway.gatewayId; + + await pollUntil( + ["gateway", "get", "--id", state.gatewayId!], + (response) => response.status === "READY", + ); + + const targetStdout = await run([ + "gateway", + "target", + "create", + "--gateway-id", + state.gatewayId!, + "--name", + HTTP_TARGET_NAME, + "--passthrough-endpoint", + "https://example.com", + "--passthrough-protocol", + "custom", + ]); + matchGolden(FIXTURES, "target-create.golden.json", targetStdout); + const target = JSON.parse(targetStdout); + expect(target.targetId).toBeString(); + state.targetId = target.targetId; + + await pollUntil( + [ + "gateway", + "target", + "get", + "--gateway-id", + state.gatewayId!, + "--target-id", + target.targetId, + ], + (response) => response.status === "READY", + ); + + const ruleStdout = await run([ + "gateway", + "rule", + "create", + "--gateway-id", + state.gatewayId!, + "--priority", + "10", + "--actions", + JSON.stringify([ + { + routeToTarget: { + staticRoute: { + targetName: HTTP_TARGET_NAME, + }, + }, + }, + ]), + "--description", + "Disposable Gateway Rule Create fixture", + ]); + matchGolden(FIXTURES, "rule-create.golden.json", ruleStdout); + const rule = JSON.parse(ruleStdout); + expect(rule.ruleId).toBeString(); + state.ruleId = rule.ruleId; + + await pollUntil( + ["gateway", "rule", "get", "--gateway-id", state.gatewayId!, "--rule-id", state.ruleId!], + (response) => response.status === "ACTIVE", + ); + } finally { + await cleanup(state); + } + }, + FLOW_TIMEOUT, + ); +}); diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index f50f1c6e9..b5ca05d9b 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -237,7 +237,12 @@ describe("gateway create commands", () => { }; const credentials = [{ credentialProviderType: "CALLER_IAM_CREDENTIALS" }]; const metadata = { allowedRequestHeaders: ["x-request-id"] }; - const privateEndpoint = { vpcEndpointId: "vpce-123" }; + const privateEndpoint = { + selfManagedLatticeResource: { + resourceConfigurationIdentifier: + "arn:aws:vpc-lattice:us-west-2:123456789012:resourceconfiguration/rcfg-123", + }, + }; await run( [ diff --git a/src/handlers/gateway/mutations.ts b/src/handlers/gateway/mutations.ts index 0e9309478..9eaac9aef 100644 --- a/src/handlers/gateway/mutations.ts +++ b/src/handlers/gateway/mutations.ts @@ -1,7 +1,4 @@ -import type { - CreateGatewayRequest, - TargetConfiguration, -} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { CreateGatewayRequest } from "@aws-sdk/client-bedrock-agentcore-control"; import { InputValidationError } from "../../errors"; import type { CreateGatewayInput, @@ -51,38 +48,8 @@ export function validateGatewayTargetCreateInput( if (!input.targetConfiguration) { throw new InputValidationError("Target configuration is required"); } - const variant = targetVariant(input.targetConfiguration); - if (!input.name && variant !== "http.agentcoreRuntime") { + if (!input.name && input.targetConfiguration.http?.agentcoreRuntime === undefined) { throw new InputValidationError("Target name is required for non-Runtime targets"); } return input; } - -export function targetVariant(configuration: TargetConfiguration): string | undefined { - if ("mcp" in configuration && configuration.mcp) { - return memberVariant("mcp", configuration.mcp, [ - "openApiSchema", - "smithyModel", - "lambda", - "mcpServer", - "apiGateway", - "connector", - ]); - } - if ("http" in configuration && configuration.http) { - return memberVariant("http", configuration.http, ["agentcoreRuntime", "passthrough"]); - } - if ("inference" in configuration && configuration.inference) { - return memberVariant("inference", configuration.inference, ["connector", "provider"]); - } - return undefined; -} - -function memberVariant( - outer: string, - value: object, - members: readonly string[], -): string | undefined { - const member = members.find((candidate) => candidate in value); - return member ? `${outer}.${member}` : undefined; -} From 359c7999c5a5e7a4ce046dfabc5971763d673bea Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 5 Aug 2026 21:13:05 +0000 Subject: [PATCH 05/15] refactor(gateway): simplify target create shortcuts --- .../gateway/gateway.create.fixture.test.tsx | 13 +- src/handlers/gateway/gateway.create.test.tsx | 167 ++++++------------ .../gateway/target/create/configuration.ts | 162 ----------------- src/handlers/gateway/target/create/index.tsx | 55 +++--- 4 files changed, 95 insertions(+), 302 deletions(-) delete mode 100644 src/handlers/gateway/target/create/configuration.ts diff --git a/src/handlers/gateway/gateway.create.fixture.test.tsx b/src/handlers/gateway/gateway.create.fixture.test.tsx index 7a071a3c3..371a6f26d 100644 --- a/src/handlers/gateway/gateway.create.fixture.test.tsx +++ b/src/handlers/gateway/gateway.create.fixture.test.tsx @@ -182,10 +182,15 @@ describe("Gateway fixture-backed creates", () => { state.gatewayId!, "--name", HTTP_TARGET_NAME, - "--passthrough-endpoint", - "https://example.com", - "--passthrough-protocol", - "custom", + "--target-configuration", + JSON.stringify({ + http: { + passthrough: { + endpoint: "https://example.com", + protocolType: "CUSTOM", + }, + }, + }), ]); matchGolden(FIXTURES, "target-create.golden.json", targetStdout); const target = JSON.parse(targetStdout); diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index b5ca05d9b..a479cba2f 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -123,10 +123,15 @@ describe("gateway create commands", () => { }); }); - test.each([ - { - name: "MCP server", - args: [ + test("creates an MCP server Target from the endpoint shortcut", async () => { + const core = new TestCoreClient(); + await run( + [ + "gateway", + "target", + "create", + "--gateway-id", + GATEWAY_ID, "--name", "calendar", "--endpoint", @@ -134,7 +139,13 @@ describe("gateway create commands", () => { "--tool-schema", '{"tools":[]}', ], - expected: { + core, + ); + + expect(createdTargetInput(core)).toEqual({ + gatewayIdentifier: GATEWAY_ID, + name: "calendar", + targetConfiguration: { mcp: { mcpServer: { endpoint: "https://calendar.example.test/mcp", @@ -142,93 +153,8 @@ describe("gateway create commands", () => { }, }, }, - }, - { - name: "Lambda", - args: [ - "--name", - "calculator", - "--lambda-arn", - "arn:aws:lambda:us-west-2:123456789012:function:calculator", - "--tool-schema", - '[{"name":"add","description":"Add","inputSchema":{"type":"object"}}]', - ], - expected: { - mcp: { - lambda: { - lambdaArn: "arn:aws:lambda:us-west-2:123456789012:function:calculator", - toolSchema: { - inlinePayload: [ - { - name: "add", - description: "Add", - inputSchema: { type: "object" }, - }, - ], - }, - }, - }, - }, - }, - { - name: "OpenAPI", - args: ["--name", "orders", "--openapi-schema", '{"openapi":"3.0.0"}'], - expected: { mcp: { openApiSchema: { inlinePayload: '{"openapi":"3.0.0"}' } } }, - }, - { - name: "Smithy", - args: ["--name", "inventory", "--smithy-model", "s3://schemas/inventory.json"], - expected: { mcp: { smithyModel: { s3: { uri: "s3://schemas/inventory.json" } } } }, - }, - { - name: "AgentCore Runtime", - args: [ - "--runtime-arn", - "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", - "--qualifier", - "prod", - ], - expected: { - http: { - agentcoreRuntime: { - arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", - qualifier: "prod", - }, - }, - }, - }, - { - name: "HTTP passthrough", - args: [ - "--name", - "external", - "--passthrough-endpoint", - "https://api.example.test", - "--passthrough-protocol", - "custom", - ], - expected: { - http: { - passthrough: { - endpoint: "https://api.example.test", - protocolType: "CUSTOM", - }, - }, - }, - }, - ] as { name: string; args: string[]; expected: TargetConfiguration }[])( - "creates a $name Target", - async ({ args, expected }) => { - const core = new TestCoreClient(); - await run(["gateway", "target", "create", "--gateway-id", GATEWAY_ID, ...args], core); - - expect(createdTargetInput(core)).toEqual({ - gatewayIdentifier: GATEWAY_ID, - ...(args.includes("--name") ? { name: args[args.indexOf("--name") + 1] } : {}), - targetConfiguration: expected, - }); - }, - ); + }); + }); test("creates an exact Target with authentication, metadata, and network configuration", async () => { const core = new TestCoreClient(); @@ -275,6 +201,36 @@ describe("gateway create commands", () => { }); }); + test("creates an exact Runtime Target without requiring a name", async () => { + const core = new TestCoreClient(); + const targetConfiguration: TargetConfiguration = { + http: { + agentcoreRuntime: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", + qualifier: "prod", + }, + }, + }; + + await run( + [ + "gateway", + "target", + "create", + "--gateway-id", + GATEWAY_ID, + "--target-configuration", + JSON.stringify(targetConfiguration), + ], + core, + ); + + expect(createdTargetInput(core)).toEqual({ + gatewayIdentifier: GATEWAY_ID, + targetConfiguration, + }); + }); + test.each([ [ ["--connector", "web-search"], @@ -415,22 +371,7 @@ describe("gateway create validation", () => { /Target name/, ], [ - "Lambda tool schema", - [ - "gateway", - "target", - "create", - "--gateway-id", - GATEWAY_ID, - "--name", - "lambda", - "--lambda-arn", - "arn:aws:lambda:us-west-2:123456789012:function:lambda", - ], - /--tool-schema/, - ], - [ - "Passthrough protocol", + "Target tool schema", [ "gateway", "target", @@ -438,11 +379,13 @@ describe("gateway create validation", () => { "--gateway-id", GATEWAY_ID, "--name", - "http", - "--passthrough-endpoint", - "https://example.test", + "calendar", + "--target-configuration", + '{"mcp":{"mcpServer":{"endpoint":"https://example.test/mcp"}}}', + "--tool-schema", + '{"tools":[]}', ], - /--passthrough-protocol/, + /--tool-schema requires --endpoint/, ], [ "Connector name", diff --git a/src/handlers/gateway/target/create/configuration.ts b/src/handlers/gateway/target/create/configuration.ts deleted file mode 100644 index d68932b72..000000000 --- a/src/handlers/gateway/target/create/configuration.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { - ApiSchemaConfiguration, - McpToolSchemaConfiguration, - TargetConfiguration, - ToolDefinition, - ToolSchema, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { InputValidationError } from "../../../../errors"; -import type { SourceResolver } from "../../../../io"; -import { parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; - -type TargetConfigurationInput = { - endpoint?: string; - lambdaArn?: string; - openapiSchema?: string; - smithyModel?: string; - runtimeArn?: string; - passthroughEndpoint?: string; - targetConfiguration?: string; - toolSchema?: string; - qualifier?: string; - passthroughProtocol?: "mcp" | "a2a" | "inference" | "custom"; -}; - -const TARGET_INPUT_NAMES = [ - "endpoint", - "lambda-arn", - "openapi-schema", - "smithy-model", - "runtime-arn", - "passthrough-endpoint", - "target-configuration", -] as const; - -export class TargetConfigurationResolver { - constructor(private readonly source: SourceResolver) {} - - async resolve(input: TargetConfigurationInput): Promise { - this.validate(input); - - if (input.targetConfiguration) { - return parseJsonObjectFlag( - "target-configuration", - await this.source.resolveText("target-configuration", input.targetConfiguration), - )!; - } - if (input.endpoint) { - const mcpToolSchema = await this.mcpToolSchema(input.toolSchema); - return { - mcp: { - mcpServer: { - endpoint: input.endpoint, - ...(mcpToolSchema ? { mcpToolSchema } : {}), - }, - }, - }; - } - if (input.lambdaArn) { - return { - mcp: { - lambda: { - lambdaArn: input.lambdaArn, - toolSchema: await this.lambdaToolSchema(input.toolSchema!), - }, - }, - }; - } - if (input.openapiSchema) { - return { - mcp: { openApiSchema: await this.apiSchema("openapi-schema", input.openapiSchema) }, - }; - } - if (input.smithyModel) { - return { mcp: { smithyModel: await this.apiSchema("smithy-model", input.smithyModel) } }; - } - if (input.runtimeArn) { - return { - http: { - agentcoreRuntime: { - arn: input.runtimeArn, - ...(input.qualifier ? { qualifier: input.qualifier } : {}), - }, - }, - }; - } - return { - http: { - passthrough: { - endpoint: input.passthroughEndpoint!, - protocolType: input.passthroughProtocol!.toUpperCase() as - "MCP" | "A2A" | "INFERENCE" | "CUSTOM", - }, - }, - }; - } - - private validate(input: TargetConfigurationInput): void { - const values = [ - input.endpoint, - input.lambdaArn, - input.openapiSchema, - input.smithyModel, - input.runtimeArn, - input.passthroughEndpoint, - input.targetConfiguration, - ]; - if (values.filter((value) => value !== undefined).length !== 1) { - throw new InputValidationError( - `specify exactly one of ${TARGET_INPUT_NAMES.map((name) => `'--${name}'`).join(", ")}`, - ); - } - - const guidedOptions = [ - ["tool-schema", input.toolSchema], - ["qualifier", input.qualifier], - ["passthrough-protocol", input.passthroughProtocol], - ] as const; - if (input.targetConfiguration && guidedOptions.some(([, value]) => value !== undefined)) { - throw new InputValidationError( - "--target-configuration conflicts with guided Target input options", - ); - } - if (input.toolSchema && !input.endpoint && !input.lambdaArn) { - throw new InputValidationError("--tool-schema requires --endpoint or --lambda-arn"); - } - if (input.lambdaArn && !input.toolSchema) { - throw new InputValidationError("--lambda-arn requires --tool-schema"); - } - if (input.qualifier && !input.runtimeArn) { - throw new InputValidationError("--qualifier requires --runtime-arn"); - } - if (input.passthroughEndpoint && !input.passthroughProtocol) { - throw new InputValidationError("--passthrough-endpoint requires --passthrough-protocol"); - } - if (input.passthroughProtocol && !input.passthroughEndpoint) { - throw new InputValidationError("--passthrough-protocol requires --passthrough-endpoint"); - } - } - - private async apiSchema(name: string, value: string): Promise { - if (value.startsWith("s3://")) return { s3: { uri: value } }; - return { inlinePayload: (await this.source.resolveText(name, value))! }; - } - - private async mcpToolSchema( - value: string | undefined, - ): Promise { - if (!value) return undefined; - if (value.startsWith("s3://")) return { s3: { uri: value } }; - return { inlinePayload: (await this.source.resolveText("tool-schema", value))! }; - } - - private async lambdaToolSchema(value: string): Promise { - if (value.startsWith("s3://")) return { s3: { uri: value } }; - return { - inlinePayload: parseJsonArrayFlag( - "tool-schema", - await this.source.resolveText("tool-schema", value), - )!, - }; - } -} diff --git a/src/handlers/gateway/target/create/index.tsx b/src/handlers/gateway/target/create/index.tsx index 9f153df4b..1f4cf3293 100644 --- a/src/handlers/gateway/target/create/index.tsx +++ b/src/handlers/gateway/target/create/index.tsx @@ -1,7 +1,9 @@ import type { CredentialProviderConfiguration, + McpToolSchemaConfiguration, MetadataConfiguration, PrivateEndpoint, + TargetConfiguration, } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../../errors"; @@ -12,7 +14,6 @@ import type { Core } from "../../../types"; import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; import { validateGatewayTargetCreateInput } from "../../mutations"; import type { CreateGatewayTargetInput } from "../../types"; -import { TargetConfigurationResolver } from "./configuration"; export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => createHandler({ @@ -27,23 +28,12 @@ export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => ), flag("description", "Target description", z.string().optional()), flag("endpoint", "MCP server HTTPS endpoint", z.string().optional()), - flag("lambda-arn", "MCP Lambda function ARN", z.string().optional()), - flag("openapi-schema", "OpenAPI schema source", z.string().optional()), - flag("smithy-model", "Smithy model source", z.string().optional()), - flag("runtime-arn", "AgentCore Runtime ARN", z.string().optional()), - flag("passthrough-endpoint", "HTTP passthrough HTTPS endpoint", z.string().optional()), flag( "target-configuration", "complete Target configuration (JSON; inline, file://, or - for stdin)", z.string().optional(), ), flag("tool-schema", "MCP tool schema source", z.string().optional()), - flag("qualifier", "AgentCore Runtime qualifier", z.string().optional()), - flag( - "passthrough-protocol", - "HTTP passthrough application protocol", - z.enum(["mcp", "a2a", "inference", "custom"]).optional(), - ), flag( "credential-provider-configurations", "outbound credentials (JSON array; inline, file://, or - for stdin)", @@ -65,20 +55,37 @@ export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => if (!flags["gateway-id"]) { throw new InputValidationError("required option '--gateway-id ' not specified"); } + if ((flags.endpoint === undefined) === (flags["target-configuration"] === undefined)) { + throw new InputValidationError( + "specify exactly one of '--endpoint' or '--target-configuration'", + ); + } + if (flags["tool-schema"] !== undefined && flags.endpoint === undefined) { + throw new InputValidationError("--tool-schema requires --endpoint"); + } const source = new SourceResolver({ stdin: io.stdin }); - const targetConfiguration = await new TargetConfigurationResolver(source).resolve({ - endpoint: flags.endpoint, - lambdaArn: flags["lambda-arn"], - openapiSchema: flags["openapi-schema"], - smithyModel: flags["smithy-model"], - runtimeArn: flags["runtime-arn"], - passthroughEndpoint: flags["passthrough-endpoint"], - targetConfiguration: flags["target-configuration"], - toolSchema: flags["tool-schema"], - qualifier: flags.qualifier, - passthroughProtocol: flags["passthrough-protocol"], - }); + const exactConfiguration = parseJsonObjectFlag( + "target-configuration", + await source.resolveText("target-configuration", flags["target-configuration"]), + ); + const toolSchemaSource = flags["tool-schema"]; + let mcpToolSchema: McpToolSchemaConfiguration | undefined; + if (toolSchemaSource !== undefined) { + mcpToolSchema = toolSchemaSource.startsWith("s3://") + ? { s3: { uri: toolSchemaSource } } + : { + inlinePayload: (await source.resolveText("tool-schema", toolSchemaSource))!, + }; + } + const targetConfiguration: TargetConfiguration = exactConfiguration ?? { + mcp: { + mcpServer: { + endpoint: flags.endpoint!, + ...(mcpToolSchema ? { mcpToolSchema } : {}), + }, + }, + }; const credentialProviderConfigurations = parseJsonArrayFlag( "credential-provider-configurations", await source.resolveText( From 6bfa7e1615679c7eb7fc4dd13a240207101b83e8 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 5 Aug 2026 22:59:04 +0000 Subject: [PATCH 06/15] refactor(gateway): align create handler and core ownership --- src/core/gateway.tsx | 26 ++++----- .../gateway/connector/create/index.tsx | 8 +-- src/handlers/gateway/create/index.tsx | 14 ++++- src/handlers/gateway/gateway.create.test.tsx | 33 +++++++++++ src/handlers/gateway/mutations.ts | 55 ------------------- src/handlers/gateway/target/create/index.tsx | 11 ++-- src/handlers/gateway/types.tsx | 4 -- 7 files changed, 62 insertions(+), 89 deletions(-) delete mode 100644 src/handlers/gateway/mutations.ts diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 82c7545cd..248d6b967 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -18,11 +18,6 @@ import { type ListGatewaysResponse, type ListGatewayTargetsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { - buildGatewayCreateRequest, - validateGatewayCreateInput, - validateGatewayTargetCreateInput, -} from "../handlers/gateway/mutations"; import type { CoreGatewayClient, CreateGatewayInput, @@ -40,22 +35,25 @@ export class GatewayClient implements CoreGatewayClient { input: CreateGatewayInput, options: CoreOptions, ): Promise { - validateGatewayCreateInput(input); const control = this.clients.control(toClientConfig(options)); - if (input.roleArn) { - return control.send( - new CreateGatewayCommand(buildGatewayCreateRequest({ ...input, roleArn: input.roleArn })), + const { protocol, roleArn: providedRoleArn, ...request } = input; + const send = (roleArn: string) => + control.send( + new CreateGatewayCommand({ + ...request, + roleArn, + ...(protocol === "mcp" ? { protocolType: "MCP" as const } : {}), + }), ); - } + + if (providedRoleArn) return send(providedRoleArn); const roleArn = await GatewayExecutionRole.ensure( this.clients.iam({ region: options.region }), input.name!, options.region, ); - return GatewayExecutionRole.retryWhileUnassumable(() => - control.send(new CreateGatewayCommand(buildGatewayCreateRequest({ ...input, roleArn }))), - ); + return GatewayExecutionRole.retryWhileUnassumable(() => send(roleArn)); } async getGateway(id: string, options: CoreOptions): Promise { @@ -108,7 +106,7 @@ export class GatewayClient implements CoreGatewayClient { ): Promise { return this.clients .control(toClientConfig(options)) - .send(new CreateGatewayTargetCommand(validateGatewayTargetCreateInput(input))); + .send(new CreateGatewayTargetCommand(input)); } async getGatewayRule( diff --git a/src/handlers/gateway/connector/create/index.tsx b/src/handlers/gateway/connector/create/index.tsx index 91cfe34f6..9da457f06 100644 --- a/src/handlers/gateway/connector/create/index.tsx +++ b/src/handlers/gateway/connector/create/index.tsx @@ -11,7 +11,6 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; -import { validateGatewayTargetCreateInput } from "../../mutations"; import type { CreateGatewayTargetInput } from "../../types"; import { GatewayConnectorTarget } from "../target"; @@ -126,11 +125,6 @@ export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => ctx .require(JsonRendererKey) - .renderJson( - await core.gateway.createGatewayTarget( - validateGatewayTargetCreateInput(input), - coreOptsFromCtx(ctx), - ), - ); + .renderJson(await core.gateway.createGatewayTarget(input, coreOptsFromCtx(ctx))); }, }); diff --git a/src/handlers/gateway/create/index.tsx b/src/handlers/gateway/create/index.tsx index 9699e3fbe..001816bbd 100644 --- a/src/handlers/gateway/create/index.tsx +++ b/src/handlers/gateway/create/index.tsx @@ -10,7 +10,6 @@ import { createHandler, flag } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { Core } from "../../types"; import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag, parseTags } from "../../utils"; -import { validateGatewayCreateInput } from "../mutations"; import type { CreateGatewayInput } from "../types"; export const createCreateGatewayHandler = (core: Core, io: AppIO) => @@ -74,6 +73,18 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => "required option '--authorizer-type ' not specified", ); } + if ( + flags["authorizer-type"] === "CUSTOM_JWT" && + flags["authorizer-configuration"] === undefined + ) { + throw new InputValidationError("CUSTOM_JWT requires --authorizer-configuration"); + } + if ( + flags["authorizer-type"] !== "CUSTOM_JWT" && + flags["authorizer-configuration"] !== undefined + ) { + throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); + } if ( (flags["policy-engine-arn"] === undefined) !== (flags["policy-engine-mode"] === undefined) @@ -124,7 +135,6 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), }; - validateGatewayCreateInput(input); ctx .require(JsonRendererKey) .renderJson(await core.gateway.createGateway(input, coreOptsFromCtx(ctx))); diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index a479cba2f..ced385308 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -351,6 +351,39 @@ describe("gateway create validation", () => { test.each([ ["Gateway name", ["gateway", "create"], /--name/], ["Gateway authorizer", ["gateway", "create", "--name", "orders"], /--authorizer-type/], + [ + "CUSTOM_JWT configuration", + ["gateway", "create", "--name", "orders", "--authorizer-type", "CUSTOM_JWT"], + /CUSTOM_JWT requires --authorizer-configuration/, + ], + [ + "non-JWT authorizer configuration", + [ + "gateway", + "create", + "--name", + "orders", + "--authorizer-type", + "AWS_IAM", + "--authorizer-configuration", + '{"customJWTAuthorizer":{"discoveryUrl":"https://auth.example.test"}}', + ], + /valid only with CUSTOM_JWT/, + ], + [ + "Policy Engine pair", + [ + "gateway", + "create", + "--name", + "orders", + "--authorizer-type", + "AWS_IAM", + "--policy-engine-arn", + "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", + ], + /must be supplied together/, + ], ["Target parent", ["gateway", "target", "create"], /--gateway-id/], [ "Target input", diff --git a/src/handlers/gateway/mutations.ts b/src/handlers/gateway/mutations.ts deleted file mode 100644 index 9eaac9aef..000000000 --- a/src/handlers/gateway/mutations.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { CreateGatewayRequest } from "@aws-sdk/client-bedrock-agentcore-control"; -import { InputValidationError } from "../../errors"; -import type { - CreateGatewayInput, - CreateGatewayTargetInput, - ResolvedCreateGatewayInput, -} from "./types"; - -export function buildGatewayCreateRequest(input: ResolvedCreateGatewayInput): CreateGatewayRequest { - validateGatewayCreateInput(input); - const { protocol, ...request } = input; - - return { - ...request, - ...(protocol === "mcp" ? { protocolType: "MCP" as const } : {}), - }; -} - -export function validateGatewayCreateInput(input: CreateGatewayInput): CreateGatewayInput { - if (!input.name) { - throw new InputValidationError("Gateway name is required"); - } - if (!input.authorizerType) { - throw new InputValidationError("Gateway authorizer type is required"); - } - if (input.authorizerType === "CUSTOM_JWT" && !input.authorizerConfiguration) { - throw new InputValidationError("CUSTOM_JWT requires --authorizer-configuration"); - } - if (input.authorizerType !== "CUSTOM_JWT" && input.authorizerConfiguration) { - throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); - } - const policyEngine = input.policyEngineConfiguration; - if (policyEngine && (!policyEngine.arn || !policyEngine.mode)) { - throw new InputValidationError( - "Policy Engine attachment requires --policy-engine-arn and --policy-engine-mode", - ); - } - - return input; -} - -export function validateGatewayTargetCreateInput( - input: CreateGatewayTargetInput, -): CreateGatewayTargetInput { - if (!input.gatewayIdentifier) { - throw new InputValidationError("Gateway ID is required"); - } - if (!input.targetConfiguration) { - throw new InputValidationError("Target configuration is required"); - } - if (!input.name && input.targetConfiguration.http?.agentcoreRuntime === undefined) { - throw new InputValidationError("Target name is required for non-Runtime targets"); - } - return input; -} diff --git a/src/handlers/gateway/target/create/index.tsx b/src/handlers/gateway/target/create/index.tsx index 1f4cf3293..cd399347d 100644 --- a/src/handlers/gateway/target/create/index.tsx +++ b/src/handlers/gateway/target/create/index.tsx @@ -12,7 +12,6 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; -import { validateGatewayTargetCreateInput } from "../../mutations"; import type { CreateGatewayTargetInput } from "../../types"; export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => @@ -86,6 +85,9 @@ export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => }, }, }; + if (!flags.name && targetConfiguration.http?.agentcoreRuntime === undefined) { + throw new InputValidationError("Target name is required for non-Runtime targets"); + } const credentialProviderConfigurations = parseJsonArrayFlag( "credential-provider-configurations", await source.resolveText( @@ -114,11 +116,6 @@ export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => ctx .require(JsonRendererKey) - .renderJson( - await core.gateway.createGatewayTarget( - validateGatewayTargetCreateInput(input), - coreOptsFromCtx(ctx), - ), - ); + .renderJson(await core.gateway.createGatewayTarget(input, coreOptsFromCtx(ctx))); }, }); diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index e5f4644e7..938537a62 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -21,10 +21,6 @@ export type CreateGatewayInput = Omit Date: Thu, 6 Aug 2026 13:03:14 +0000 Subject: [PATCH 07/15] refactor(gateway): reuse connector target classification --- src/handlers/gateway/connector/get/index.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/handlers/gateway/connector/get/index.tsx b/src/handlers/gateway/connector/get/index.tsx index 5932fcddb..0317b6fb2 100644 --- a/src/handlers/gateway/connector/get/index.tsx +++ b/src/handlers/gateway/connector/get/index.tsx @@ -1,16 +1,10 @@ -import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; - -function isConnectorTarget(configuration: TargetConfiguration | undefined): boolean { - return ( - configuration?.mcp?.connector !== undefined || configuration?.inference?.connector !== undefined - ); -} +import { GatewayConnectorTarget } from "../target"; export const createGetGatewayConnectorHandler = (core: Core) => createHandler({ @@ -33,7 +27,7 @@ export const createGetGatewayConnectorHandler = (core: Core) => flags.id, coreOptsFromCtx(ctx), ); - if (!isConnectorTarget(target.targetConfiguration)) { + if (!GatewayConnectorTarget.is(target.targetConfiguration)) { throw new InputValidationError(`Gateway Target "${flags.id}" is not connector-backed`); } From a089c9ddaf98632bada7cbf48a530ea713bbeca3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 6 Aug 2026 13:11:41 +0000 Subject: [PATCH 08/15] refactor(gateway): name connector target module explicitly --- src/handlers/gateway/connector/create/index.tsx | 2 +- .../gateway/connector/{target.ts => gatewayConnectorTarget.ts} | 0 src/handlers/gateway/connector/get/index.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/handlers/gateway/connector/{target.ts => gatewayConnectorTarget.ts} (100%) diff --git a/src/handlers/gateway/connector/create/index.tsx b/src/handlers/gateway/connector/create/index.tsx index 9da457f06..70fe892fb 100644 --- a/src/handlers/gateway/connector/create/index.tsx +++ b/src/handlers/gateway/connector/create/index.tsx @@ -12,7 +12,7 @@ import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; import type { CreateGatewayTargetInput } from "../../types"; -import { GatewayConnectorTarget } from "../target"; +import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => createHandler({ diff --git a/src/handlers/gateway/connector/target.ts b/src/handlers/gateway/connector/gatewayConnectorTarget.ts similarity index 100% rename from src/handlers/gateway/connector/target.ts rename to src/handlers/gateway/connector/gatewayConnectorTarget.ts diff --git a/src/handlers/gateway/connector/get/index.tsx b/src/handlers/gateway/connector/get/index.tsx index 0317b6fb2..111e9265c 100644 --- a/src/handlers/gateway/connector/get/index.tsx +++ b/src/handlers/gateway/connector/get/index.tsx @@ -4,7 +4,7 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -import { GatewayConnectorTarget } from "../target"; +import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; export const createGetGatewayConnectorHandler = (core: Core) => createHandler({ From 902cfb03bb1127a58cd8213a8d706bc6c5e86728 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 6 Aug 2026 13:19:19 +0000 Subject: [PATCH 09/15] refactor(gateway): make connector shortcuts exhaustive --- .../connector/gatewayConnectorTarget.ts | 58 +++++++++++-------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/src/handlers/gateway/connector/gatewayConnectorTarget.ts b/src/handlers/gateway/connector/gatewayConnectorTarget.ts index 51e74761c..8894d0d43 100644 --- a/src/handlers/gateway/connector/gatewayConnectorTarget.ts +++ b/src/handlers/gateway/connector/gatewayConnectorTarget.ts @@ -14,30 +14,40 @@ export class GatewayConnectorTarget { shortcut: GatewayConnectorShortcut, knowledgeBaseId?: string, ): TargetConfiguration { - if (shortcut === "bedrock-mantle") { - return { inference: { connector: { source: { connectorId: shortcut } } } }; + switch (shortcut) { + case "bedrock-mantle": + return { inference: { connector: { source: { connectorId: shortcut } } } }; + case "bedrock-knowledge-bases": + if (!knowledgeBaseId) { + throw new Error( + "A Knowledge Base ID is required for the bedrock-knowledge-bases connector", + ); + } + return { + mcp: { + connector: { + source: { connectorId: shortcut }, + configurations: [ + { + name: "Retrieve", + parameterValues: { knowledgeBaseId }, + }, + ], + }, + }, + }; + case "web-search": + return { + mcp: { + connector: { + source: { connectorId: shortcut }, + }, + }, + }; + default: { + const unsupported: never = shortcut; + throw new Error(`Unsupported connector shortcut: ${unsupported}`); + } } - - if (shortcut === "bedrock-knowledge-bases" && !knowledgeBaseId) { - throw new Error("A Knowledge Base ID is required for the bedrock-knowledge-bases connector"); - } - - return { - mcp: { - connector: { - source: { connectorId: shortcut }, - ...(shortcut === "bedrock-knowledge-bases" - ? { - configurations: [ - { - name: "Retrieve", - parameterValues: { knowledgeBaseId: knowledgeBaseId! }, - }, - ], - } - : {}), - }, - }, - }; } } From a57fcecbba7012cfbe5e2997b3934c52a89734b5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 6 Aug 2026 14:27:17 +0000 Subject: [PATCH 10/15] test(gateway): align create coverage with harness --- src/core/gateway.create.test.ts | 140 ---- .../connector/gatewayConnectorTarget.test.ts | 57 ++ .../gateway/gateway.create.fixture.test.tsx | 249 ------- src/handlers/gateway/gateway.create.test.tsx | 655 +++++++----------- src/testing/TestCoreClient.tsx | 25 +- 5 files changed, 323 insertions(+), 803 deletions(-) delete mode 100644 src/core/gateway.create.test.ts create mode 100644 src/handlers/gateway/connector/gatewayConnectorTarget.test.ts delete mode 100644 src/handlers/gateway/gateway.create.fixture.test.tsx diff --git a/src/core/gateway.create.test.ts b/src/core/gateway.create.test.ts deleted file mode 100644 index 4b2c04ee3..000000000 --- a/src/core/gateway.create.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - CreateGatewayCommand, - CreateGatewayRuleCommand, - CreateGatewayTargetCommand, - type BedrockAgentCoreControlClient, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import type { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; -import { CreateRoleCommand, GetRoleCommand, type IAMClient } from "@aws-sdk/client-iam"; -import { createSilentLogger } from "../testing"; -import { GatewayExecutionRole } from "./gatewayExecutionRole"; -import { CoreClient } from "./index"; - -type SentCommand = { input: unknown; constructor: { name: string } }; - -function createCore( - send: (command: SentCommand) => Promise, - sendIam: (command: SentCommand) => Promise = async (command) => { - throw new Error(`unexpected ${command.constructor.name}`); - }, -): CoreClient { - return new CoreClient({ - createControlClient: () => ({ send }) as unknown as BedrockAgentCoreControlClient, - createDataClient: () => ({}) as BedrockAgentCoreClient, - createIamClient: () => ({ send: sendIam }) as unknown as IAMClient, - logger: createSilentLogger(), - }); -} - -function noSuchEntity(): Error { - const error = new Error("not found"); - error.name = "NoSuchEntityException"; - return error; -} - -describe("Gateway create Core operations", () => { - test("maps unrestricted and MCP Gateways directly when a role is supplied", async () => { - const sent: SentCommand[] = []; - const core = createCore(async (command) => { - sent.push(command); - return {}; - }); - const roleArn = "arn:aws:iam::123456789012:role/gateway"; - - await core.gateway.createGateway( - { name: "unrestricted", roleArn, authorizerType: "AWS_IAM" }, - { region: "us-west-2" }, - ); - await core.gateway.createGateway( - { name: "mcp-only", roleArn, protocol: "mcp", authorizerType: "AWS_IAM" }, - { region: "us-west-2" }, - ); - - expect(sent.map(({ input }) => input)).toEqual([ - { - name: "unrestricted", - roleArn, - authorizerType: "AWS_IAM", - }, - { - name: "mcp-only", - roleArn, - protocolType: "MCP", - authorizerType: "AWS_IAM", - }, - ]); - expect(sent.every((command) => command instanceof CreateGatewayCommand)).toBe(true); - }); - - test("creates a Gateway with the default role when none is supplied", async () => { - const operations: SentCommand[] = []; - const roleName = GatewayExecutionRole.roleName("orders", "us-west-2"); - const roleArn = `arn:aws:iam::123456789012:role/${roleName}`; - const core = createCore( - async (command) => { - operations.push(command); - return { gatewayId: "orders-abc" }; - }, - async (command) => { - operations.push(command); - if (command instanceof GetRoleCommand) throw noSuchEntity(); - if (command instanceof CreateRoleCommand) return { Role: { Arn: roleArn } }; - throw new Error(`unexpected ${command.constructor.name}`); - }, - ); - - await core.gateway.createGateway( - { name: "orders", authorizerType: "AWS_IAM" }, - { region: "us-west-2" }, - ); - - expect(operations.map((command) => command.constructor.name)).toEqual([ - "GetRoleCommand", - "CreateRoleCommand", - "CreateGatewayCommand", - ]); - expect(operations[2]!.input).toEqual({ - name: "orders", - roleArn, - authorizerType: "AWS_IAM", - }); - }); - - test("maps Target and Rule creation directly to the service", async () => { - const sent: SentCommand[] = []; - const core = createCore(async (command) => { - sent.push(command); - return {}; - }); - const targetConfiguration = { - http: { - agentcoreRuntime: { - arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", - }, - }, - } as const; - const actions = [{ routeToTarget: { staticRoute: { targetName: "orders" } } }]; - - await core.gateway.createGatewayTarget( - { gatewayIdentifier: "gateway-1", targetConfiguration }, - { region: "us-west-2" }, - ); - await core.gateway.createGatewayRule( - { gatewayIdentifier: "gateway-1", priority: 10, actions }, - { region: "us-west-2" }, - ); - - expect(sent[0]).toBeInstanceOf(CreateGatewayTargetCommand); - expect(sent[0]!.input).toEqual({ - gatewayIdentifier: "gateway-1", - targetConfiguration, - }); - expect(sent[1]).toBeInstanceOf(CreateGatewayRuleCommand); - expect(sent[1]!.input).toEqual({ - gatewayIdentifier: "gateway-1", - priority: 10, - actions, - }); - }); -}); diff --git a/src/handlers/gateway/connector/gatewayConnectorTarget.test.ts b/src/handlers/gateway/connector/gatewayConnectorTarget.test.ts new file mode 100644 index 000000000..bd224ae76 --- /dev/null +++ b/src/handlers/gateway/connector/gatewayConnectorTarget.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; +import { GatewayConnectorTarget, type GatewayConnectorShortcut } from "./gatewayConnectorTarget"; + +describe("GatewayConnectorTarget.fromShortcut", () => { + test.each([ + { + shortcut: "web-search", + expected: { + mcp: { + connector: { + source: { connectorId: "web-search" }, + }, + }, + }, + }, + { + shortcut: "bedrock-knowledge-bases", + knowledgeBaseId: "KB12345678", + expected: { + mcp: { + connector: { + source: { connectorId: "bedrock-knowledge-bases" }, + configurations: [ + { + name: "Retrieve", + parameterValues: { knowledgeBaseId: "KB12345678" }, + }, + ], + }, + }, + }, + }, + { + shortcut: "bedrock-mantle", + expected: { + inference: { + connector: { + source: { connectorId: "bedrock-mantle" }, + }, + }, + }, + }, + ] as { + shortcut: GatewayConnectorShortcut; + knowledgeBaseId?: string; + expected: TargetConfiguration; + }[])("builds the $shortcut configuration", ({ shortcut, knowledgeBaseId, expected }) => { + expect(GatewayConnectorTarget.fromShortcut(shortcut, knowledgeBaseId)).toEqual(expected); + }); + + test("requires a Knowledge Base ID", () => { + expect(() => GatewayConnectorTarget.fromShortcut("bedrock-knowledge-bases")).toThrow( + /Knowledge Base ID/, + ); + }); +}); diff --git a/src/handlers/gateway/gateway.create.fixture.test.tsx b/src/handlers/gateway/gateway.create.fixture.test.tsx deleted file mode 100644 index 371a6f26d..000000000 --- a/src/handlers/gateway/gateway.create.fixture.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; -import { - DeleteGatewayCommand, - DeleteGatewayRuleCommand, - DeleteGatewayTargetCommand, - GetGatewayCommand, - GetGatewayTargetCommand, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { DeleteRoleCommand } from "@aws-sdk/client-iam"; -import { CoreClient } from "../../core"; -import { createControlClient, createIamClient } from "../../core/factories"; -import { GatewayExecutionRole } from "../../core/gatewayExecutionRole"; -import { - createSilentLogger, - fixtureFactories, - isRecording, - matchGolden, - TestGlobalConfigAccessor, - testIO, -} from "../../testing"; -import { createRootHandler } from "../index"; - -const REGION = "us-west-2"; -const GATEWAY_NAME = "agentcore-cli-gateway-create-fixture"; -const HTTP_TARGET_NAME = "http-fixture"; -const FIXTURES = join(import.meta.dir, "__fixtures__", "create"); -const FLOW_TIMEOUT = 600_000; - -// Record with AWS_PROFILE=deploy RECORD=1 bun test src/handlers/gateway/gateway.create.fixture.test.tsx -type FixtureState = { - gatewayId?: string; - targetId?: string; - ruleId?: string; -}; - -function createFixtureCore(): CoreClient { - const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); - return new CoreClient({ - createControlClient, - createDataClient, - createIamClient, - logger: createSilentLogger(), - }); -} - -async function run(args: string[]): Promise { - const io = testIO(); - const root = createRootHandler(createFixtureCore(), { - io: io.io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - await root.route(["node", "agentcore", ...args, "--region", REGION]); - return io.stdout(); -} - -async function pollUntil( - args: string[], - done: (response: Record) => boolean, -): Promise { - for (let attempt = 0; attempt < 60; attempt += 1) { - const response = JSON.parse(await run(args)) as Record; - if (done(response)) return; - if (!isRecording()) { - throw new Error(`Replayed fixture for \`${args.join(" ")}\` is not settled`); - } - await Bun.sleep(5_000); - } - throw new Error(`Timed out waiting for \`${args.join(" ")}\``); -} - -async function ignoreMissing(operation: () => Promise): Promise { - try { - await operation(); - } catch (error) { - if (!["ResourceNotFoundException", "NoSuchEntityException"].includes((error as Error).name)) { - throw error; - } - } -} - -async function waitUntilMissing(operation: () => Promise): Promise { - for (let attempt = 0; attempt < 60; attempt += 1) { - try { - await operation(); - } catch (error) { - if ((error as Error).name === "ResourceNotFoundException") return; - throw error; - } - await Bun.sleep(2_000); - } - throw new Error("Timed out waiting for fixture resource deletion"); -} - -async function cleanup(state: FixtureState): Promise { - if (!isRecording()) return; - - const control = createControlClient({ region: REGION }); - if (state.gatewayId && state.ruleId) { - await ignoreMissing(() => - control.send( - new DeleteGatewayRuleCommand({ - gatewayIdentifier: state.gatewayId, - ruleId: state.ruleId, - }), - ), - ); - } - - if (state.gatewayId) { - if (state.targetId) { - await ignoreMissing(() => - control.send( - new DeleteGatewayTargetCommand({ - gatewayIdentifier: state.gatewayId, - targetId: state.targetId, - }), - ), - ); - await waitUntilMissing(() => - control.send( - new GetGatewayTargetCommand({ - gatewayIdentifier: state.gatewayId, - targetId: state.targetId, - }), - ), - ); - } - - await ignoreMissing(() => - control.send(new DeleteGatewayCommand({ gatewayIdentifier: state.gatewayId })), - ); - await waitUntilMissing(() => - control.send(new GetGatewayCommand({ gatewayIdentifier: state.gatewayId })), - ); - } - - const iam = createIamClient({ region: REGION }); - await ignoreMissing(() => - iam.send( - new DeleteRoleCommand({ - RoleName: GatewayExecutionRole.roleName(GATEWAY_NAME, REGION), - }), - ), - ); -} - -describe("Gateway fixture-backed creates", () => { - test( - "creates a Gateway, Target, and Rule through the real Core", - async () => { - const state: FixtureState = {}; - - try { - const gatewayStdout = await run([ - "gateway", - "create", - "--name", - GATEWAY_NAME, - "--authorizer-type", - "NONE", - "--description", - "Disposable Gateway Create fixture", - ]); - matchGolden(FIXTURES, "gateway-create.golden.json", gatewayStdout); - const gateway = JSON.parse(gatewayStdout); - expect(gateway.name).toBe(GATEWAY_NAME); - expect(gateway.gatewayId).toBeString(); - state.gatewayId = gateway.gatewayId; - - await pollUntil( - ["gateway", "get", "--id", state.gatewayId!], - (response) => response.status === "READY", - ); - - const targetStdout = await run([ - "gateway", - "target", - "create", - "--gateway-id", - state.gatewayId!, - "--name", - HTTP_TARGET_NAME, - "--target-configuration", - JSON.stringify({ - http: { - passthrough: { - endpoint: "https://example.com", - protocolType: "CUSTOM", - }, - }, - }), - ]); - matchGolden(FIXTURES, "target-create.golden.json", targetStdout); - const target = JSON.parse(targetStdout); - expect(target.targetId).toBeString(); - state.targetId = target.targetId; - - await pollUntil( - [ - "gateway", - "target", - "get", - "--gateway-id", - state.gatewayId!, - "--target-id", - target.targetId, - ], - (response) => response.status === "READY", - ); - - const ruleStdout = await run([ - "gateway", - "rule", - "create", - "--gateway-id", - state.gatewayId!, - "--priority", - "10", - "--actions", - JSON.stringify([ - { - routeToTarget: { - staticRoute: { - targetName: HTTP_TARGET_NAME, - }, - }, - }, - ]), - "--description", - "Disposable Gateway Rule Create fixture", - ]); - matchGolden(FIXTURES, "rule-create.golden.json", ruleStdout); - const rule = JSON.parse(ruleStdout); - expect(rule.ruleId).toBeString(); - state.ruleId = rule.ruleId; - - await pollUntil( - ["gateway", "rule", "get", "--gateway-id", state.gatewayId!, "--rule-id", state.ruleId!], - (response) => response.status === "ACTIVE", - ); - } finally { - await cleanup(state); - } - }, - FLOW_TIMEOUT, - ); -}); diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index ced385308..ce178358a 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -1,375 +1,160 @@ import { describe, expect, test } from "bun:test"; -import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; +import { join } from "node:path"; +import { + DeleteGatewayCommand, + DeleteGatewayRuleCommand, + DeleteGatewayTargetCommand, + GetGatewayCommand, + GetGatewayTargetCommand, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { DeleteRoleCommand } from "@aws-sdk/client-iam"; +import { CoreClient } from "../../core"; +import { createControlClient, createIamClient } from "../../core/factories"; +import { GatewayExecutionRole } from "../../core/gatewayExecutionRole"; import { createSilentLogger, - TestCoreClient, + fixtureFactories, + isRecording, + matchGolden, TestGlobalConfigAccessor, testIO, } from "../../testing"; import { createRootHandler } from "../index"; const REGION = "us-west-2"; -const GATEWAY_ID = "gateway-1"; +const GATEWAY_NAME = "agentcore-cli-gateway-create-fixture"; +const HTTP_TARGET_NAME = "http-fixture"; +const FIXTURES = join(import.meta.dir, "__fixtures__", "create"); +const FLOW_TIMEOUT = 600_000; -async function run( - args: string[], - core = new TestCoreClient(), -): Promise<{ core: TestCoreClient; stdout: string }> { +// Record with AWS_PROFILE=deploy RECORD=1 bun test src/handlers/gateway/gateway.create.test.tsx +type FixtureState = { + gatewayId?: string; + targetId?: string; + ruleId?: string; +}; + +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + logger: createSilentLogger(), + }); +} + +async function run(args: string[]): Promise { const io = testIO(); - const root = createRootHandler(core, { + const root = createRootHandler(createFixtureCore(), { io: io.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), }); - await root.route(["node", "agentcore", ...args, "--region", REGION]); - return { core, stdout: io.stdout() }; + return io.stdout(); } -function createdTargetInput(core: TestCoreClient): Record { - return core.gateway.calls[0]!.args[0] as Record; +async function pollUntil( + args: string[], + done: (response: Record) => boolean, +): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + const response = JSON.parse(await run(args)) as Record; + if (done(response)) return; + if (!isRecording()) { + throw new Error(`Replayed fixture for \`${args.join(" ")}\` is not settled`); + } + await Bun.sleep(5_000); + } + throw new Error(`Timed out waiting for \`${args.join(" ")}\``); } -describe("gateway create commands", () => { - test("creates an unrestricted Gateway with the complete flag surface", async () => { - const core = new TestCoreClient(); - await run( - [ - "gateway", - "create", - "--name", - "orders", - "--authorizer-type", - "CUSTOM_JWT", - "--authorizer-configuration", - '{"customJWTAuthorizer":{"discoveryUrl":"https://auth.example.test","allowedClients":["client"]}}', - "--protocol-configuration", - '{"mcp":{"supportedVersions":["2025-03-26"]}}', - "--interceptor-configurations", - '[{"interceptor":{"lambda":{"arn":"arn:aws:lambda:us-west-2:123456789012:function:guard"}},"interceptionPoints":["REQUEST"]}]', - "--policy-engine-arn", - "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", - "--policy-engine-mode", - "enforce", - "--exception-level", - "debug", - "--tags", - "env=test", - "team=agentcore", - ], - core, - ); - - expect(core.gateway.calls).toEqual([ - { - method: "createGateway", - args: [ - { - name: "orders", - authorizerType: "CUSTOM_JWT", - authorizerConfiguration: { - customJWTAuthorizer: { - discoveryUrl: "https://auth.example.test", - allowedClients: ["client"], - }, - }, - protocolConfiguration: { mcp: { supportedVersions: ["2025-03-26"] } }, - interceptorConfigurations: [ - { - interceptor: { - lambda: { - arn: "arn:aws:lambda:us-west-2:123456789012:function:guard", - }, - }, - interceptionPoints: ["REQUEST"], - }, - ], - policyEngineConfiguration: { - arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders", - mode: "ENFORCE", - }, - exceptionLevel: "DEBUG", - tags: { env: "test", team: "agentcore" }, - }, - { region: REGION }, - ], - }, - ]); - }); - - test("maps --protocol mcp without requiring protocol configuration", async () => { - const core = new TestCoreClient(); - await run( - [ - "gateway", - "create", - "--name", - "mcp-only", - "--role-arn", - "arn:aws:iam::123456789012:role/gateway", - "--protocol", - "mcp", - "--authorizer-type", - "AWS_IAM", - ], - core, - ); - - expect(core.gateway.calls[0]!.args[0]).toEqual({ - name: "mcp-only", - roleArn: "arn:aws:iam::123456789012:role/gateway", - protocol: "mcp", - authorizerType: "AWS_IAM", - }); - }); - - test("creates an MCP server Target from the endpoint shortcut", async () => { - const core = new TestCoreClient(); - await run( - [ - "gateway", - "target", - "create", - "--gateway-id", - GATEWAY_ID, - "--name", - "calendar", - "--endpoint", - "https://calendar.example.test/mcp", - "--tool-schema", - '{"tools":[]}', - ], - core, - ); - - expect(createdTargetInput(core)).toEqual({ - gatewayIdentifier: GATEWAY_ID, - name: "calendar", - targetConfiguration: { - mcp: { - mcpServer: { - endpoint: "https://calendar.example.test/mcp", - mcpToolSchema: { inlinePayload: '{"tools":[]}' }, - }, - }, - }, - }); - }); - - test("creates an exact Target with authentication, metadata, and network configuration", async () => { - const core = new TestCoreClient(); - const targetConfiguration = { - inference: { provider: { endpoint: "https://inference.example.test" } }, - }; - const credentials = [{ credentialProviderType: "CALLER_IAM_CREDENTIALS" }]; - const metadata = { allowedRequestHeaders: ["x-request-id"] }; - const privateEndpoint = { - selfManagedLatticeResource: { - resourceConfigurationIdentifier: - "arn:aws:vpc-lattice:us-west-2:123456789012:resourceconfiguration/rcfg-123", - }, - }; - - await run( - [ - "gateway", - "target", - "create", - "--gateway-id", - GATEWAY_ID, - "--name", - "inference", - "--target-configuration", - JSON.stringify(targetConfiguration), - "--credential-provider-configurations", - JSON.stringify(credentials), - "--metadata-configuration", - JSON.stringify(metadata), - "--private-endpoint", - JSON.stringify(privateEndpoint), - ], - core, - ); +async function ignoreMissing(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + if (!["ResourceNotFoundException", "NoSuchEntityException"].includes((error as Error).name)) { + throw error; + } + } +} - expect(createdTargetInput(core)).toEqual({ - gatewayIdentifier: GATEWAY_ID, - name: "inference", - targetConfiguration, - credentialProviderConfigurations: credentials, - metadataConfiguration: metadata, - privateEndpoint, - }); - }); +async function waitUntilMissing(operation: () => Promise): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + await operation(); + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException") return; + throw error; + } + await Bun.sleep(2_000); + } + throw new Error("Timed out waiting for fixture resource deletion"); +} - test("creates an exact Runtime Target without requiring a name", async () => { - const core = new TestCoreClient(); - const targetConfiguration: TargetConfiguration = { - http: { - agentcoreRuntime: { - arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", - qualifier: "prod", - }, - }, - }; +async function cleanup(state: FixtureState): Promise { + if (!isRecording()) return; - await run( - [ - "gateway", - "target", - "create", - "--gateway-id", - GATEWAY_ID, - "--target-configuration", - JSON.stringify(targetConfiguration), - ], - core, + const control = createControlClient({ region: REGION }); + if (state.gatewayId && state.ruleId) { + await ignoreMissing(() => + control.send( + new DeleteGatewayRuleCommand({ + gatewayIdentifier: state.gatewayId, + ruleId: state.ruleId, + }), + ), ); + } - expect(createdTargetInput(core)).toEqual({ - gatewayIdentifier: GATEWAY_ID, - targetConfiguration, - }); - }); - - test.each([ - [ - ["--connector", "web-search"], - { mcp: { connector: { source: { connectorId: "web-search" } } } }, - ], - [ - ["--connector", "bedrock-knowledge-bases", "--knowledge-base-id", "KB12345678"], - { - mcp: { - connector: { - source: { connectorId: "bedrock-knowledge-bases" }, - configurations: [ - { - name: "Retrieve", - parameterValues: { knowledgeBaseId: "KB12345678" }, - }, - ], - }, - }, - }, - ], - [ - ["--connector", "bedrock-mantle"], - { inference: { connector: { source: { connectorId: "bedrock-mantle" } } } }, - ], - ] as [string[], TargetConfiguration][])( - "creates a curated Connector Target", - async (args, targetConfiguration) => { - const core = new TestCoreClient(); - await run( - [ - "gateway", - "connector", - "create", - "--gateway-id", - GATEWAY_ID, - "--name", - "connector", - ...args, - ], - core, + if (state.gatewayId) { + if (state.targetId) { + await ignoreMissing(() => + control.send( + new DeleteGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId: state.targetId, + }), + ), ); + await waitUntilMissing(() => + control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId: state.targetId, + }), + ), + ); + } - expect(createdTargetInput(core)).toEqual({ - gatewayIdentifier: GATEWAY_ID, - name: "connector", - targetConfiguration, - }); - }, - ); - - test("creates a Connector from exact connector-backed Target JSON", async () => { - const core = new TestCoreClient(); - const targetConfiguration = { - inference: { connector: { source: { connectorId: "openai" } } }, - }; - - await run( - [ - "gateway", - "connector", - "create", - "--gateway-id", - GATEWAY_ID, - "--name", - "openai", - "--connector-configuration", - JSON.stringify(targetConfiguration), - ], - core, + await ignoreMissing(() => + control.send(new DeleteGatewayCommand({ gatewayIdentifier: state.gatewayId })), ); - - expect(createdTargetInput(core)).toEqual({ - gatewayIdentifier: GATEWAY_ID, - name: "openai", - targetConfiguration, - }); - }); - - test("creates a Rule with exact condition and action arrays", async () => { - const core = new TestCoreClient(); - const conditions = [{ matchPaths: { anyOf: ["/orders/*"] } }]; - const actions = [{ routeToTarget: { staticRoute: { targetName: "orders-api" } } }]; - - await run( - [ - "gateway", - "rule", - "create", - "--gateway-id", - GATEWAY_ID, - "--priority", - "10", - "--conditions", - JSON.stringify(conditions), - "--actions", - JSON.stringify(actions), - ], - core, + await waitUntilMissing(() => + control.send(new GetGatewayCommand({ gatewayIdentifier: state.gatewayId })), ); + } - expect(core.gateway.calls[0]).toEqual({ - method: "createGatewayRule", - args: [ - { - gatewayIdentifier: GATEWAY_ID, - priority: 10, - conditions, - actions, - }, - { region: REGION }, - ], - }); - }); -}); + const iam = createIamClient({ region: REGION }); + await ignoreMissing(() => + iam.send( + new DeleteRoleCommand({ + RoleName: GatewayExecutionRole.roleName(GATEWAY_NAME, REGION), + }), + ), + ); +} -describe("gateway create validation", () => { +describe("Gateway create validation", () => { test.each([ - ["Gateway name", ["gateway", "create"], /--name/], + ["Gateway name", ["gateway", "create", "--authorizer-type", "NONE"], /--name/], ["Gateway authorizer", ["gateway", "create", "--name", "orders"], /--authorizer-type/], [ "CUSTOM_JWT configuration", ["gateway", "create", "--name", "orders", "--authorizer-type", "CUSTOM_JWT"], /CUSTOM_JWT requires --authorizer-configuration/, ], - [ - "non-JWT authorizer configuration", - [ - "gateway", - "create", - "--name", - "orders", - "--authorizer-type", - "AWS_IAM", - "--authorizer-configuration", - '{"customJWTAuthorizer":{"discoveryUrl":"https://auth.example.test"}}', - ], - /valid only with CUSTOM_JWT/, - ], [ "Policy Engine pair", [ @@ -384,10 +169,10 @@ describe("gateway create validation", () => { ], /must be supplied together/, ], - ["Target parent", ["gateway", "target", "create"], /--gateway-id/], + ["Target parent", ["gateway", "target", "create", "--name", "target"], /--gateway-id/], [ "Target input", - ["gateway", "target", "create", "--gateway-id", GATEWAY_ID], + ["gateway", "target", "create", "--gateway-id", "gateway-1", "--name", "target"], /specify exactly one/, ], [ @@ -397,32 +182,15 @@ describe("gateway create validation", () => { "target", "create", "--gateway-id", - GATEWAY_ID, + "gateway-1", "--endpoint", "https://example.test/mcp", ], /Target name/, ], - [ - "Target tool schema", - [ - "gateway", - "target", - "create", - "--gateway-id", - GATEWAY_ID, - "--name", - "calendar", - "--target-configuration", - '{"mcp":{"mcpServer":{"endpoint":"https://example.test/mcp"}}}', - "--tool-schema", - '{"tools":[]}', - ], - /--tool-schema requires --endpoint/, - ], [ "Connector name", - ["gateway", "connector", "create", "--gateway-id", GATEWAY_ID, "--connector", "web-search"], + ["gateway", "connector", "create", "--gateway-id", "gateway-1", "--connector", "web-search"], /--name/, ], [ @@ -432,7 +200,7 @@ describe("gateway create validation", () => { "connector", "create", "--gateway-id", - GATEWAY_ID, + "gateway-1", "--name", "kb", "--connector", @@ -440,57 +208,160 @@ describe("gateway create validation", () => { ], /--knowledge-base-id/, ], - ["Rule parent", ["gateway", "rule", "create"], /--gateway-id/], + ["Rule parent", ["gateway", "rule", "create", "--priority", "10"], /--gateway-id/], ] as const)("rejects missing or inconsistent %s before Core", async (_name, args, error) => { - const core = new TestCoreClient(); + await expect(run([...args])).rejects.toThrow(error); + }); - await expect(run([...args], core)).rejects.toThrow(error); - expect(core.gateway.calls).toEqual([]); + test("rejects conflicting Target inputs", async () => { + await expect( + run([ + "gateway", + "target", + "create", + "--gateway-id", + "gateway-1", + "--name", + "calendar", + "--endpoint", + "https://calendar.example.test/mcp", + "--target-configuration", + '{"mcp":{"mcpServer":{"endpoint":"https://other.example.test/mcp"}}}', + ]), + ).rejects.toThrow(/specify exactly one/); }); - test("rejects multiple Target input modes", async () => { - const core = new TestCoreClient(); + test("rejects malformed Target JSON", async () => { + await expect( + run([ + "gateway", + "target", + "create", + "--gateway-id", + "gateway-1", + "--name", + "broken", + "--target-configuration", + "{not json", + ]), + ).rejects.toThrow(/Invalid JSON for option '--target-configuration'/); + }); + test("rejects a non-Connector exact configuration", async () => { await expect( - run( - [ + run([ + "gateway", + "connector", + "create", + "--gateway-id", + "gateway-1", + "--name", + "not-connector", + "--connector-configuration", + '{"mcp":{"mcpServer":{"endpoint":"https://example.test/mcp"}}}', + ]), + ).rejects.toThrow(/connector Target/); + }); +}); + +describe("Gateway fixture-backed creates", () => { + test( + "creates a Gateway, Target, and Rule through the real Core", + async () => { + const state: FixtureState = {}; + + try { + const gatewayStdout = await run([ + "gateway", + "create", + "--name", + GATEWAY_NAME, + "--authorizer-type", + "NONE", + "--description", + "Disposable Gateway Create fixture", + ]); + matchGolden(FIXTURES, "gateway-create.golden.json", gatewayStdout); + const gateway = JSON.parse(gatewayStdout); + expect(gateway.name).toBe(GATEWAY_NAME); + expect(gateway.gatewayId).toBeString(); + state.gatewayId = gateway.gatewayId; + + await pollUntil( + ["gateway", "get", "--id", state.gatewayId!], + (response) => response.status === "READY", + ); + + const targetStdout = await run([ "gateway", "target", "create", "--gateway-id", - GATEWAY_ID, + state.gatewayId!, "--name", - "calendar", - "--endpoint", - "https://calendar.example.test/mcp", + HTTP_TARGET_NAME, "--target-configuration", - '{"mcp":{"mcpServer":{"endpoint":"https://other.example.test/mcp"}}}', - ], - core, - ), - ).rejects.toThrow(/specify exactly one/); - expect(core.gateway.calls).toEqual([]); - }); + JSON.stringify({ + http: { + passthrough: { + endpoint: "https://example.com", + protocolType: "CUSTOM", + }, + }, + }), + ]); + matchGolden(FIXTURES, "target-create.golden.json", targetStdout); + const target = JSON.parse(targetStdout); + expect(target.targetId).toBeString(); + state.targetId = target.targetId; - test("rejects a non-Connector exact configuration", async () => { - const core = new TestCoreClient(); + await pollUntil( + [ + "gateway", + "target", + "get", + "--gateway-id", + state.gatewayId!, + "--target-id", + target.targetId, + ], + (response) => response.status === "READY", + ); - await expect( - run( - [ + const ruleStdout = await run([ "gateway", - "connector", + "rule", "create", "--gateway-id", - GATEWAY_ID, - "--name", - "not-connector", - "--connector-configuration", - '{"mcp":{"mcpServer":{"endpoint":"https://example.test/mcp"}}}', - ], - core, - ), - ).rejects.toThrow(/connector Target/); - expect(core.gateway.calls).toEqual([]); - }); + state.gatewayId!, + "--priority", + "10", + "--actions", + JSON.stringify([ + { + routeToTarget: { + staticRoute: { + targetName: HTTP_TARGET_NAME, + }, + }, + }, + ]), + "--description", + "Disposable Gateway Rule Create fixture", + ]); + matchGolden(FIXTURES, "rule-create.golden.json", ruleStdout); + const rule = JSON.parse(ruleStdout); + expect(rule.ruleId).toBeString(); + state.ruleId = rule.ruleId; + + await pollUntil( + ["gateway", "rule", "get", "--gateway-id", state.gatewayId!, "--rule-id", state.ruleId!], + (response) => response.status === "ACTIVE", + ); + } finally { + await cleanup(state); + } + }, + FLOW_TIMEOUT, + ); }); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index d4e246465..f845c3faa 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -818,23 +818,14 @@ export class TestMemoryClient implements CoreMemoryClient { export class TestGatewayClient implements CoreGatewayClient { readonly calls: RecordedCall[] = []; - private createResponse: CreateGatewayResponse = DEFAULT_CREATE_GATEWAY_RESPONSE; private getResponse: GetGatewayResponse = DEFAULT_GET_GATEWAY_RESPONSE; private listResponses = new Map(); - private createTargetResponse: CreateGatewayTargetResponse = - DEFAULT_CREATE_GATEWAY_TARGET_RESPONSE; private getTargetResponse: GetGatewayTargetResponse = DEFAULT_GET_GATEWAY_TARGET_RESPONSE; private listTargetResponses = new Map(); - private createRuleResponse: CreateGatewayRuleResponse = DEFAULT_CREATE_GATEWAY_RULE_RESPONSE; private getRuleResponse: GetGatewayRuleResponse = DEFAULT_GET_GATEWAY_RULE_RESPONSE; private listRuleResponses = new Map(); private error?: Error; - setCreateResponse(response: CreateGatewayResponse): this { - this.createResponse = response; - return this; - } - setGetResponse(response: GetGatewayResponse): this { this.getResponse = response; return this; @@ -845,11 +836,6 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } - setCreateTargetResponse(response: CreateGatewayTargetResponse): this { - this.createTargetResponse = response; - return this; - } - setGetTargetResponse(response: GetGatewayTargetResponse): this { this.getTargetResponse = response; return this; @@ -860,11 +846,6 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } - setCreateRuleResponse(response: CreateGatewayRuleResponse): this { - this.createRuleResponse = response; - return this; - } - setGetRuleResponse(response: GetGatewayRuleResponse): this { this.getRuleResponse = response; return this; @@ -886,7 +867,7 @@ export class TestGatewayClient implements CoreGatewayClient { ): Promise { this.calls.push({ method: "createGateway", args: [input, options] }); if (this.error) throw this.error; - return this.createResponse; + return DEFAULT_CREATE_GATEWAY_RESPONSE; } async getGateway(id: string, options: CoreOptions): Promise { @@ -915,7 +896,7 @@ export class TestGatewayClient implements CoreGatewayClient { ): Promise { this.calls.push({ method: "createGatewayTarget", args: [input, options] }); if (this.error) throw this.error; - return this.createTargetResponse; + return DEFAULT_CREATE_GATEWAY_TARGET_RESPONSE; } async getGatewayTarget( @@ -952,7 +933,7 @@ export class TestGatewayClient implements CoreGatewayClient { ): Promise { this.calls.push({ method: "createGatewayRule", args: [input, options] }); if (this.error) throw this.error; - return this.createRuleResponse; + return DEFAULT_CREATE_GATEWAY_RULE_RESPONSE; } async getGatewayRule( From 9e9bab2d027aa86f747af7972f3500e5aee56ee2 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 6 Aug 2026 14:55:06 +0000 Subject: [PATCH 11/15] test(gateway): record create fixtures with e2e profile --- ...CreateGatewayCommand.4b814f32ee3dd379.json | 17 +++++++++++++++++ .../CreateGatewayCommand.a0ea607af6e17f4.json | 17 ----------------- ...eGatewayRuleCommand.51ec9dd7549a4856.json} | 6 +++--- ...atewayTargetCommand.e2d2f41c9a13664a.json} | 8 ++++---- .../CreateRoleCommand.f4622691a336e689.json | 6 +++--- .../GetGatewayCommand.2e61cb938818685f.json | 19 ------------------- .../GetGatewayCommand.5d5a1f7c5e7993c3.json | 19 +++++++++++++++++++ ...tGatewayRuleCommand.b5c1104f1f6665aa.json} | 8 ++++---- ...atewayTargetCommand.3e8dade2854ca999.json} | 8 ++++---- .../create/gateway-create.golden.json | 12 ++++++------ .../create/rule-create.golden.json | 6 +++--- .../create/target-create.golden.json | 8 ++++---- src/handlers/gateway/gateway.create.test.tsx | 2 +- 13 files changed, 68 insertions(+), 68 deletions(-) create mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.4b814f32ee3dd379.json delete mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.a0ea607af6e17f4.json rename src/handlers/gateway/__fixtures__/create/{CreateGatewayRuleCommand.cbb05d778d22a55.json => CreateGatewayRuleCommand.51ec9dd7549a4856.json} (55%) rename src/handlers/gateway/__fixtures__/create/{CreateGatewayTargetCommand.60d88dfe16fb4c85.json => CreateGatewayTargetCommand.e2d2f41c9a13664a.json} (52%) delete mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayCommand.2e61cb938818685f.json create mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5d5a1f7c5e7993c3.json rename src/handlers/gateway/__fixtures__/create/{GetGatewayRuleCommand.f407cc1dd803bf9d.json => GetGatewayRuleCommand.b5c1104f1f6665aa.json} (52%) rename src/handlers/gateway/__fixtures__/create/{GetGatewayTargetCommand.ad7aa1ace2dc5f.json => GetGatewayTargetCommand.3e8dade2854ca999.json} (52%) diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.4b814f32ee3dd379.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.4b814f32ee3dd379.json new file mode 100644 index 000000000..698eb9358 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.4b814f32ee3dd379.json @@ -0,0 +1,17 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "gatewayId": "agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "createdAt": { + "$date": "2026-08-06T14:53:37.760Z" + }, + "updatedAt": { + "$date": "2026-08-06T14:53:37.760Z" + }, + "status": "CREATING", + "name": "agentcore-cli-gateway-create-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-a9vcrbbtn6.gateway.bedrock-agentcore.us-west-2.amazonaws.com", + "description": "Disposable Gateway Create fixture", + "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "workloadIdentityDetails": {} +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.a0ea607af6e17f4.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.a0ea607af6e17f4.json deleted file mode 100644 index 520fdbffb..000000000 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.a0ea607af6e17f4.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", - "gatewayId": "agentcore-cli-gateway-create-fixture-7ky2bb3jku", - "createdAt": { - "$date": "2026-08-05T20:52:40.278Z" - }, - "updatedAt": { - "$date": "2026-08-05T20:52:40.278Z" - }, - "status": "CREATING", - "name": "agentcore-cli-gateway-create-fixture", - "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-7ky2bb3jku.gateway.bedrock-agentcore.us-west-2.amazonaws.com", - "description": "Disposable Gateway Create fixture", - "roleArn": "arn:aws:iam::603141041947:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", - "workloadIdentityDetails": {} -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.cbb05d778d22a55.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.51ec9dd7549a4856.json similarity index 55% rename from src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.cbb05d778d22a55.json rename to src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.51ec9dd7549a4856.json index 78bf26240..dde07c086 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.cbb05d778d22a55.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.51ec9dd7549a4856.json @@ -1,6 +1,6 @@ { - "ruleId": "b68aa940-0a41-40bf-9b49-aab1647066b3", - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "ruleId": "45a03672-18ea-455b-beee-35aafca110a3", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", "priority": 10, "actions": [ { @@ -12,7 +12,7 @@ } ], "createdAt": { - "$date": "2026-08-05T20:52:51.379Z" + "$date": "2026-08-06T14:53:48.716Z" }, "status": "CREATING", "description": "Disposable Gateway Rule Create fixture" diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.60d88dfe16fb4c85.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.e2d2f41c9a13664a.json similarity index 52% rename from src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.60d88dfe16fb4c85.json rename to src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.e2d2f41c9a13664a.json index 2a5b75399..c04701250 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.60d88dfe16fb4c85.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.e2d2f41c9a13664a.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", - "targetId": "APUTKOW1TL", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "targetId": "C6K0TWPVPG", "createdAt": { - "$date": "2026-08-05T20:52:45.837Z" + "$date": "2026-08-06T14:53:43.219Z" }, "updatedAt": { - "$date": "2026-08-05T20:52:45.837Z" + "$date": "2026-08-06T14:53:43.219Z" }, "status": "CREATING", "name": "http-fixture", diff --git a/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json b/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json index a7031a3e1..3c4ebd2f5 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json +++ b/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json @@ -2,10 +2,10 @@ "Role": { "Path": "/", "RoleName": "AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", - "RoleId": "AROAYY3QB54NYN6N6G33W", - "Arn": "arn:aws:iam::603141041947:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "RoleId": "AROAZ7CHXJWHQ4V57AH3R", + "Arn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", "CreateDate": { - "$date": "2026-08-05T20:52:39.000Z" + "$date": "2026-08-06T14:53:37.000Z" }, "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Sid%22%3A%22GatewayAssumeRolePolicy%22%2C%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/gateway/__fixtures__/create/GetGatewayCommand.2e61cb938818685f.json b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.2e61cb938818685f.json deleted file mode 100644 index f7bc630e4..000000000 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.2e61cb938818685f.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", - "gatewayId": "agentcore-cli-gateway-create-fixture-7ky2bb3jku", - "createdAt": { - "$date": "2026-08-05T20:52:40.278Z" - }, - "updatedAt": { - "$date": "2026-08-05T20:52:41.233Z" - }, - "status": "READY", - "name": "agentcore-cli-gateway-create-fixture", - "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-7ky2bb3jku.gateway.bedrock-agentcore.us-west-2.amazonaws.com", - "description": "Disposable Gateway Create fixture", - "roleArn": "arn:aws:iam::603141041947:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", - "workloadIdentityDetails": { - "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-create-fixture-7ky2bb3jku" - } -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5d5a1f7c5e7993c3.json b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5d5a1f7c5e7993c3.json new file mode 100644 index 000000000..aaa2b3c49 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5d5a1f7c5e7993c3.json @@ -0,0 +1,19 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "gatewayId": "agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "createdAt": { + "$date": "2026-08-06T14:53:37.760Z" + }, + "updatedAt": { + "$date": "2026-08-06T14:53:38.670Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-create-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-a9vcrbbtn6.gateway.bedrock-agentcore.us-west-2.amazonaws.com", + "description": "Disposable Gateway Create fixture", + "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-create-fixture-a9vcrbbtn6" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.f407cc1dd803bf9d.json b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.b5c1104f1f6665aa.json similarity index 52% rename from src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.f407cc1dd803bf9d.json rename to src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.b5c1104f1f6665aa.json index c25d7053c..0857fac59 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.f407cc1dd803bf9d.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.b5c1104f1f6665aa.json @@ -1,6 +1,6 @@ { - "ruleId": "b68aa940-0a41-40bf-9b49-aab1647066b3", - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "ruleId": "45a03672-18ea-455b-beee-35aafca110a3", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", "priority": 10, "actions": [ { @@ -12,11 +12,11 @@ } ], "createdAt": { - "$date": "2026-08-05T20:52:51.379Z" + "$date": "2026-08-06T14:53:48.716Z" }, "status": "ACTIVE", "description": "Disposable Gateway Rule Create fixture", "updatedAt": { - "$date": "2026-08-05T20:52:51.379Z" + "$date": "2026-08-06T14:53:48.716Z" } } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.ad7aa1ace2dc5f.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3e8dade2854ca999.json similarity index 52% rename from src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.ad7aa1ace2dc5f.json rename to src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3e8dade2854ca999.json index 9bd780d7b..07ec68385 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.ad7aa1ace2dc5f.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3e8dade2854ca999.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", - "targetId": "APUTKOW1TL", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "targetId": "C6K0TWPVPG", "createdAt": { - "$date": "2026-08-05T20:52:45.837Z" + "$date": "2026-08-06T14:53:43.219Z" }, "updatedAt": { - "$date": "2026-08-05T20:52:46.644Z" + "$date": "2026-08-06T14:53:43.935Z" }, "status": "READY", "name": "http-fixture", diff --git a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json index e556f0dbd..f7492561b 100644 --- a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json @@ -1,13 +1,13 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", - "gatewayId": "agentcore-cli-gateway-create-fixture-7ky2bb3jku", - "createdAt": "2026-08-05T20:52:40.278Z", - "updatedAt": "2026-08-05T20:52:40.278Z", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "gatewayId": "agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "createdAt": "2026-08-06T14:53:37.760Z", + "updatedAt": "2026-08-06T14:53:37.760Z", "status": "CREATING", "name": "agentcore-cli-gateway-create-fixture", "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-7ky2bb3jku.gateway.bedrock-agentcore.us-west-2.amazonaws.com", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-a9vcrbbtn6.gateway.bedrock-agentcore.us-west-2.amazonaws.com", "description": "Disposable Gateway Create fixture", - "roleArn": "arn:aws:iam::603141041947:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", "workloadIdentityDetails": {} } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/rule-create.golden.json b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json index 46602727e..7d09b30b9 100644 --- a/src/handlers/gateway/__fixtures__/create/rule-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json @@ -1,6 +1,6 @@ { - "ruleId": "b68aa940-0a41-40bf-9b49-aab1647066b3", - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", + "ruleId": "45a03672-18ea-455b-beee-35aafca110a3", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", "priority": 10, "actions": [ { @@ -11,7 +11,7 @@ } } ], - "createdAt": "2026-08-05T20:52:51.379Z", + "createdAt": "2026-08-06T14:53:48.716Z", "status": "CREATING", "description": "Disposable Gateway Rule Create fixture" } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/target-create.golden.json b/src/handlers/gateway/__fixtures__/create/target-create.golden.json index 88dbfff84..79ad75ba7 100644 --- a/src/handlers/gateway/__fixtures__/create/target-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/target-create.golden.json @@ -1,8 +1,8 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:gateway/agentcore-cli-gateway-create-fixture-7ky2bb3jku", - "targetId": "APUTKOW1TL", - "createdAt": "2026-08-05T20:52:45.837Z", - "updatedAt": "2026-08-05T20:52:45.837Z", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "targetId": "C6K0TWPVPG", + "createdAt": "2026-08-06T14:53:43.219Z", + "updatedAt": "2026-08-06T14:53:43.219Z", "status": "CREATING", "name": "http-fixture", "targetConfiguration": { diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index ce178358a..350c00bd0 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -27,7 +27,7 @@ const HTTP_TARGET_NAME = "http-fixture"; const FIXTURES = join(import.meta.dir, "__fixtures__", "create"); const FLOW_TIMEOUT = 600_000; -// Record with AWS_PROFILE=deploy RECORD=1 bun test src/handlers/gateway/gateway.create.test.tsx +// Record with AWS_PROFILE=e2e-test RECORD=1 bun test src/handlers/gateway/gateway.create.test.tsx type FixtureState = { gatewayId?: string; targetId?: string; From 9396bc8d28e6e070eddf4a35605e31965f267744 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 6 Aug 2026 16:24:20 +0000 Subject: [PATCH 12/15] test(gateway): cover connector create flow --- ...CreateGatewayCommand.4b814f32ee3dd379.json | 17 -- ...CreateGatewayCommand.7f678b79455bc400.json | 17 ++ ...eGatewayRuleCommand.d099663f07f604e8.json} | 6 +- ...atewayTargetCommand.8ed7bdc58ed5cfe3.json} | 8 +- ...GatewayTargetCommand.b9bd759c932fb313.json | 35 ++++ ...> CreateRoleCommand.8c882992929fc867.json} | 8 +- .../GetGatewayCommand.5d5a1f7c5e7993c3.json | 19 -- .../GetGatewayCommand.e10f48bff6d765f4.json | 19 ++ ...tGatewayRuleCommand.aeea9dd972f29009.json} | 8 +- ...GatewayTargetCommand.51d3c1380690cc2e.json | 38 ++++ ...atewayTargetCommand.a2b64be6031c8c59.json} | 8 +- ...n => GetRoleCommand.54b6c6ece67ab6d4.json} | 2 +- .../create/connector-create.golden.json | 31 +++ .../create/gateway-create.golden.json | 12 +- .../create/rule-create.golden.json | 6 +- .../create/target-create.golden.json | 8 +- .../connector/gatewayConnectorTarget.ts | 4 - src/handlers/gateway/gateway.create.test.tsx | 189 ++++++++++++++++-- 18 files changed, 340 insertions(+), 95 deletions(-) delete mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.4b814f32ee3dd379.json create mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json rename src/handlers/gateway/__fixtures__/create/{CreateGatewayRuleCommand.51ec9dd7549a4856.json => CreateGatewayRuleCommand.d099663f07f604e8.json} (55%) rename src/handlers/gateway/__fixtures__/create/{CreateGatewayTargetCommand.e2d2f41c9a13664a.json => CreateGatewayTargetCommand.8ed7bdc58ed5cfe3.json} (52%) create mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.b9bd759c932fb313.json rename src/handlers/gateway/__fixtures__/create/{CreateRoleCommand.f4622691a336e689.json => CreateRoleCommand.8c882992929fc867.json} (77%) delete mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5d5a1f7c5e7993c3.json create mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayCommand.e10f48bff6d765f4.json rename src/handlers/gateway/__fixtures__/create/{GetGatewayRuleCommand.b5c1104f1f6665aa.json => GetGatewayRuleCommand.aeea9dd972f29009.json} (52%) create mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.51d3c1380690cc2e.json rename src/handlers/gateway/__fixtures__/create/{GetGatewayTargetCommand.3e8dade2854ca999.json => GetGatewayTargetCommand.a2b64be6031c8c59.json} (52%) rename src/handlers/gateway/__fixtures__/create/{GetRoleCommand.e7adf912c1bf2c67.json => GetRoleCommand.54b6c6ece67ab6d4.json} (70%) create mode 100644 src/handlers/gateway/__fixtures__/create/connector-create.golden.json diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.4b814f32ee3dd379.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.4b814f32ee3dd379.json deleted file mode 100644 index 698eb9358..000000000 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.4b814f32ee3dd379.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", - "gatewayId": "agentcore-cli-gateway-create-fixture-a9vcrbbtn6", - "createdAt": { - "$date": "2026-08-06T14:53:37.760Z" - }, - "updatedAt": { - "$date": "2026-08-06T14:53:37.760Z" - }, - "status": "CREATING", - "name": "agentcore-cli-gateway-create-fixture", - "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-a9vcrbbtn6.gateway.bedrock-agentcore.us-west-2.amazonaws.com", - "description": "Disposable Gateway Create fixture", - "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", - "workloadIdentityDetails": {} -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json new file mode 100644 index 000000000..110f348d2 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json @@ -0,0 +1,17 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "gatewayId": "agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "createdAt": { + "$date": "2026-08-06T16:16:36.091Z" + }, + "updatedAt": { + "$date": "2026-08-06T16:16:36.091Z" + }, + "status": "CREATING", + "name": "agentcore-cli-gateway-create-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-b8lxs2arsg.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Create fixture", + "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", + "workloadIdentityDetails": {} +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.51ec9dd7549a4856.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d099663f07f604e8.json similarity index 55% rename from src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.51ec9dd7549a4856.json rename to src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d099663f07f604e8.json index dde07c086..2f8d3e7fb 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.51ec9dd7549a4856.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d099663f07f604e8.json @@ -1,6 +1,6 @@ { - "ruleId": "45a03672-18ea-455b-beee-35aafca110a3", - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "ruleId": "0bb02e22-d2cd-42b1-8a17-8fd71d277f0c", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", "priority": 10, "actions": [ { @@ -12,7 +12,7 @@ } ], "createdAt": { - "$date": "2026-08-06T14:53:48.716Z" + "$date": "2026-08-06T16:16:52.214Z" }, "status": "CREATING", "description": "Disposable Gateway Rule Create fixture" diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.e2d2f41c9a13664a.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.8ed7bdc58ed5cfe3.json similarity index 52% rename from src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.e2d2f41c9a13664a.json rename to src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.8ed7bdc58ed5cfe3.json index c04701250..5e102f763 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.e2d2f41c9a13664a.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.8ed7bdc58ed5cfe3.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", - "targetId": "C6K0TWPVPG", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "targetId": "C5MERQNEMC", "createdAt": { - "$date": "2026-08-06T14:53:43.219Z" + "$date": "2026-08-06T16:16:41.439Z" }, "updatedAt": { - "$date": "2026-08-06T14:53:43.219Z" + "$date": "2026-08-06T16:16:41.439Z" }, "status": "CREATING", "name": "http-fixture", diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.b9bd759c932fb313.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.b9bd759c932fb313.json new file mode 100644 index 000000000..70dcf7002 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.b9bd759c932fb313.json @@ -0,0 +1,35 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "targetId": "GZHW6TACLJ", + "createdAt": { + "$date": "2026-08-06T16:16:46.873Z" + }, + "updatedAt": { + "$date": "2026-08-06T16:16:46.873Z" + }, + "status": "CREATING", + "name": "openai-fixture", + "targetConfiguration": { + "inference": { + "connector": { + "source": { + "connectorId": "openai" + } + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "API_KEY", + "credentialProvider": { + "apiKeyCredentialProvider": { + "providerArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:token-vault/default/apikeycredentialprovider/DONOTDELETEe2eOAI", + "credentialParameterName": "Authorization", + "credentialPrefix": "Bearer", + "credentialLocation": "HEADER" + } + } + } + ], + "description": "Disposable Gateway Connector Create fixture" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json b/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.8c882992929fc867.json similarity index 77% rename from src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json rename to src/handlers/gateway/__fixtures__/create/CreateRoleCommand.8c882992929fc867.json index 3c4ebd2f5..fbe4b0db1 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.f4622691a336e689.json +++ b/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.8c882992929fc867.json @@ -1,11 +1,11 @@ { "Role": { "Path": "/", - "RoleName": "AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", - "RoleId": "AROAZ7CHXJWHQ4V57AH3R", - "Arn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "RoleName": "AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", + "RoleId": "AROAZ7CHXJWHVZ2RH6I5X", + "Arn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", "CreateDate": { - "$date": "2026-08-06T14:53:37.000Z" + "$date": "2026-08-06T16:16:35.000Z" }, "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Sid%22%3A%22GatewayAssumeRolePolicy%22%2C%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/gateway/__fixtures__/create/GetGatewayCommand.5d5a1f7c5e7993c3.json b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5d5a1f7c5e7993c3.json deleted file mode 100644 index aaa2b3c49..000000000 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5d5a1f7c5e7993c3.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", - "gatewayId": "agentcore-cli-gateway-create-fixture-a9vcrbbtn6", - "createdAt": { - "$date": "2026-08-06T14:53:37.760Z" - }, - "updatedAt": { - "$date": "2026-08-06T14:53:38.670Z" - }, - "status": "READY", - "name": "agentcore-cli-gateway-create-fixture", - "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-a9vcrbbtn6.gateway.bedrock-agentcore.us-west-2.amazonaws.com", - "description": "Disposable Gateway Create fixture", - "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", - "workloadIdentityDetails": { - "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-create-fixture-a9vcrbbtn6" - } -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.e10f48bff6d765f4.json b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.e10f48bff6d765f4.json new file mode 100644 index 000000000..b76de80cc --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.e10f48bff6d765f4.json @@ -0,0 +1,19 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "gatewayId": "agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "createdAt": { + "$date": "2026-08-06T16:16:36.091Z" + }, + "updatedAt": { + "$date": "2026-08-06T16:16:36.647Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-create-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-b8lxs2arsg.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Create fixture", + "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-create-fixture-b8lxs2arsg" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.b5c1104f1f6665aa.json b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.aeea9dd972f29009.json similarity index 52% rename from src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.b5c1104f1f6665aa.json rename to src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.aeea9dd972f29009.json index 0857fac59..80bf0498e 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.b5c1104f1f6665aa.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.aeea9dd972f29009.json @@ -1,6 +1,6 @@ { - "ruleId": "45a03672-18ea-455b-beee-35aafca110a3", - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "ruleId": "0bb02e22-d2cd-42b1-8a17-8fd71d277f0c", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", "priority": 10, "actions": [ { @@ -12,11 +12,11 @@ } ], "createdAt": { - "$date": "2026-08-06T14:53:48.716Z" + "$date": "2026-08-06T16:16:52.214Z" }, "status": "ACTIVE", "description": "Disposable Gateway Rule Create fixture", "updatedAt": { - "$date": "2026-08-06T14:53:48.716Z" + "$date": "2026-08-06T16:16:52.214Z" } } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.51d3c1380690cc2e.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.51d3c1380690cc2e.json new file mode 100644 index 000000000..00cc44878 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.51d3c1380690cc2e.json @@ -0,0 +1,38 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "targetId": "GZHW6TACLJ", + "createdAt": { + "$date": "2026-08-06T16:16:46.873Z" + }, + "updatedAt": { + "$date": "2026-08-06T16:16:51.480Z" + }, + "status": "READY", + "name": "openai-fixture", + "targetConfiguration": { + "inference": { + "connector": { + "source": { + "connectorId": "openai" + } + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "API_KEY", + "credentialProvider": { + "apiKeyCredentialProvider": { + "providerArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:token-vault/default/apikeycredentialprovider/DONOTDELETEe2eOAI", + "credentialParameterName": "Authorization", + "credentialPrefix": "Bearer", + "credentialLocation": "HEADER" + } + } + } + ], + "description": "Disposable Gateway Connector Create fixture", + "lastSynchronizedAt": { + "$date": "2026-08-06T16:16:51.263Z" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3e8dade2854ca999.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.a2b64be6031c8c59.json similarity index 52% rename from src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3e8dade2854ca999.json rename to src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.a2b64be6031c8c59.json index 07ec68385..6adee227c 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3e8dade2854ca999.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.a2b64be6031c8c59.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", - "targetId": "C6K0TWPVPG", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "targetId": "C5MERQNEMC", "createdAt": { - "$date": "2026-08-06T14:53:43.219Z" + "$date": "2026-08-06T16:16:41.439Z" }, "updatedAt": { - "$date": "2026-08-06T14:53:43.935Z" + "$date": "2026-08-06T16:16:42.021Z" }, "status": "READY", "name": "http-fixture", diff --git a/src/handlers/gateway/__fixtures__/create/GetRoleCommand.e7adf912c1bf2c67.json b/src/handlers/gateway/__fixtures__/create/GetRoleCommand.54b6c6ece67ab6d4.json similarity index 70% rename from src/handlers/gateway/__fixtures__/create/GetRoleCommand.e7adf912c1bf2c67.json rename to src/handlers/gateway/__fixtures__/create/GetRoleCommand.54b6c6ece67ab6d4.json index ac862bdbb..4b0f00a50 100644 --- a/src/handlers/gateway/__fixtures__/create/GetRoleCommand.e7adf912c1bf2c67.json +++ b/src/handlers/gateway/__fixtures__/create/GetRoleCommand.54b6c6ece67ab6d4.json @@ -1,6 +1,6 @@ { "$error": { "name": "NoSuchEntityException", - "message": "The role with name AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4 cannot be found." + "message": "The role with name AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b cannot be found." } } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/connector-create.golden.json b/src/handlers/gateway/__fixtures__/create/connector-create.golden.json new file mode 100644 index 000000000..a2eb2e5a2 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/connector-create.golden.json @@ -0,0 +1,31 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "targetId": "GZHW6TACLJ", + "createdAt": "2026-08-06T16:16:46.873Z", + "updatedAt": "2026-08-06T16:16:46.873Z", + "status": "CREATING", + "name": "openai-fixture", + "targetConfiguration": { + "inference": { + "connector": { + "source": { + "connectorId": "openai" + } + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "API_KEY", + "credentialProvider": { + "apiKeyCredentialProvider": { + "providerArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:token-vault/default/apikeycredentialprovider/DONOTDELETEe2eOAI", + "credentialParameterName": "Authorization", + "credentialPrefix": "Bearer", + "credentialLocation": "HEADER" + } + } + } + ], + "description": "Disposable Gateway Connector Create fixture" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json index f7492561b..bfc20a59c 100644 --- a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json @@ -1,13 +1,13 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", - "gatewayId": "agentcore-cli-gateway-create-fixture-a9vcrbbtn6", - "createdAt": "2026-08-06T14:53:37.760Z", - "updatedAt": "2026-08-06T14:53:37.760Z", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "gatewayId": "agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "createdAt": "2026-08-06T16:16:36.091Z", + "updatedAt": "2026-08-06T16:16:36.091Z", "status": "CREATING", "name": "agentcore-cli-gateway-create-fixture", "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-a9vcrbbtn6.gateway.bedrock-agentcore.us-west-2.amazonaws.com", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-b8lxs2arsg.gateway.bedrock-agentcore.us-east-1.amazonaws.com", "description": "Disposable Gateway Create fixture", - "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-64147103bcd4", + "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", "workloadIdentityDetails": {} } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/rule-create.golden.json b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json index 7d09b30b9..947cb3dd8 100644 --- a/src/handlers/gateway/__fixtures__/create/rule-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json @@ -1,6 +1,6 @@ { - "ruleId": "45a03672-18ea-455b-beee-35aafca110a3", - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", + "ruleId": "0bb02e22-d2cd-42b1-8a17-8fd71d277f0c", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", "priority": 10, "actions": [ { @@ -11,7 +11,7 @@ } } ], - "createdAt": "2026-08-06T14:53:48.716Z", + "createdAt": "2026-08-06T16:16:52.214Z", "status": "CREATING", "description": "Disposable Gateway Rule Create fixture" } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/target-create.golden.json b/src/handlers/gateway/__fixtures__/create/target-create.golden.json index 79ad75ba7..fe31b7a4f 100644 --- a/src/handlers/gateway/__fixtures__/create/target-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/target-create.golden.json @@ -1,8 +1,8 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:gateway/agentcore-cli-gateway-create-fixture-a9vcrbbtn6", - "targetId": "C6K0TWPVPG", - "createdAt": "2026-08-06T14:53:43.219Z", - "updatedAt": "2026-08-06T14:53:43.219Z", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "targetId": "C5MERQNEMC", + "createdAt": "2026-08-06T16:16:41.439Z", + "updatedAt": "2026-08-06T16:16:41.439Z", "status": "CREATING", "name": "http-fixture", "targetConfiguration": { diff --git a/src/handlers/gateway/connector/gatewayConnectorTarget.ts b/src/handlers/gateway/connector/gatewayConnectorTarget.ts index 8894d0d43..da8e37f73 100644 --- a/src/handlers/gateway/connector/gatewayConnectorTarget.ts +++ b/src/handlers/gateway/connector/gatewayConnectorTarget.ts @@ -44,10 +44,6 @@ export class GatewayConnectorTarget { }, }, }; - default: { - const unsupported: never = shortcut; - throw new Error(`Unsupported connector shortcut: ${unsupported}`); - } } } } diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index 350c00bd0..8206cde97 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -21,16 +21,18 @@ import { } from "../../testing"; import { createRootHandler } from "../index"; -const REGION = "us-west-2"; +const REGION = "us-east-1"; const GATEWAY_NAME = "agentcore-cli-gateway-create-fixture"; const HTTP_TARGET_NAME = "http-fixture"; +const CONNECTOR_TARGET_NAME = "openai-fixture"; +const API_KEY_PROVIDER_NAME = "DONOTDELETEe2eOAI"; const FIXTURES = join(import.meta.dir, "__fixtures__", "create"); const FLOW_TIMEOUT = 600_000; // Record with AWS_PROFILE=e2e-test RECORD=1 bun test src/handlers/gateway/gateway.create.test.tsx type FixtureState = { gatewayId?: string; - targetId?: string; + targetIds: string[]; ruleId?: string; }; @@ -93,6 +95,42 @@ async function waitUntilMissing(operation: () => Promise): Promise, + gatewayId: string, + targetId: string, +): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + await control.send( + new DeleteGatewayTargetCommand({ + gatewayIdentifier: gatewayId, + targetId, + }), + ); + await waitUntilMissing(() => + control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: gatewayId, + targetId, + }), + ), + ); + return; + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException") return; + if ( + (error as Error).name !== "ValidationException" || + !/valid state|Creating state/i.test((error as Error).message) + ) { + throw error; + } + await Bun.sleep(2_000); + } + } + throw new Error(`Timed out deleting fixture Target "${targetId}"`); +} + async function cleanup(state: FixtureState): Promise { if (!isRecording()) return; @@ -109,23 +147,8 @@ async function cleanup(state: FixtureState): Promise { } if (state.gatewayId) { - if (state.targetId) { - await ignoreMissing(() => - control.send( - new DeleteGatewayTargetCommand({ - gatewayIdentifier: state.gatewayId, - targetId: state.targetId, - }), - ), - ); - await waitUntilMissing(() => - control.send( - new GetGatewayTargetCommand({ - gatewayIdentifier: state.gatewayId, - targetId: state.targetId, - }), - ), - ); + for (const targetId of state.targetIds) { + await deleteTarget(control, state.gatewayId, targetId); } await ignoreMissing(() => @@ -208,7 +231,68 @@ describe("Gateway create validation", () => { ], /--knowledge-base-id/, ], + [ + "Connector exclusivity", + [ + "gateway", + "connector", + "create", + "--gateway-id", + "gateway-1", + "--name", + "search", + "--connector", + "web-search", + "--connector-configuration", + '{"mcp":{"connector":{"source":{"connectorId":"web-search"}}}}', + ], + /specify exactly one/, + ], + [ + "Connector Knowledge Base ID", + [ + "gateway", + "connector", + "create", + "--gateway-id", + "gateway-1", + "--name", + "search", + "--connector", + "web-search", + "--knowledge-base-id", + "KB12345678", + ], + /--knowledge-base-id requires --connector bedrock-knowledge-bases/, + ], + [ + "Target tool schema", + [ + "gateway", + "target", + "create", + "--gateway-id", + "gateway-1", + "--name", + "target", + "--target-configuration", + "{}", + "--tool-schema", + "{}", + ], + /--tool-schema requires --endpoint/, + ], ["Rule parent", ["gateway", "rule", "create", "--priority", "10"], /--gateway-id/], + [ + "Rule priority", + ["gateway", "rule", "create", "--gateway-id", "gateway-1", "--actions", "[]"], + /--priority/, + ], + [ + "Rule actions", + ["gateway", "rule", "create", "--gateway-id", "gateway-1", "--priority", "10"], + /--actions/, + ], ] as const)("rejects missing or inconsistent %s before Core", async (_name, args, error) => { await expect(run([...args])).rejects.toThrow(error); }); @@ -266,9 +350,9 @@ describe("Gateway create validation", () => { describe("Gateway fixture-backed creates", () => { test( - "creates a Gateway, Target, and Rule through the real Core", + "creates a Gateway, Target, Connector, and Rule through the real Core", async () => { - const state: FixtureState = {}; + const state: FixtureState = { targetIds: [] }; try { const gatewayStdout = await run([ @@ -285,6 +369,7 @@ describe("Gateway fixture-backed creates", () => { const gateway = JSON.parse(gatewayStdout); expect(gateway.name).toBe(GATEWAY_NAME); expect(gateway.gatewayId).toBeString(); + expect(gateway.gatewayArn).toBeString(); state.gatewayId = gateway.gatewayId; await pollUntil( @@ -313,7 +398,7 @@ describe("Gateway fixture-backed creates", () => { matchGolden(FIXTURES, "target-create.golden.json", targetStdout); const target = JSON.parse(targetStdout); expect(target.targetId).toBeString(); - state.targetId = target.targetId; + state.targetIds.push(target.targetId); await pollUntil( [ @@ -328,6 +413,58 @@ describe("Gateway fixture-backed creates", () => { (response) => response.status === "READY", ); + const connectorStdout = await run([ + "gateway", + "connector", + "create", + "--gateway-id", + state.gatewayId!, + "--name", + CONNECTOR_TARGET_NAME, + "--connector-configuration", + '{"inference":{"connector":{"source":{"connectorId":"openai"}}}}', + "--credential-provider-configurations", + JSON.stringify([ + { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { + providerArn: apiKeyProviderArn(gateway.gatewayArn), + credentialParameterName: "Authorization", + credentialPrefix: "Bearer", + credentialLocation: "HEADER", + }, + }, + }, + ]), + "--description", + "Disposable Gateway Connector Create fixture", + ]); + matchGolden(FIXTURES, "connector-create.golden.json", connectorStdout); + const connector = JSON.parse(connectorStdout); + expect(connector.targetId).toBeString(); + expect(connector.targetConfiguration).toEqual({ + inference: { + connector: { + source: { connectorId: "openai" }, + }, + }, + }); + state.targetIds.push(connector.targetId); + + await pollUntil( + [ + "gateway", + "connector", + "get", + "--gateway-id", + state.gatewayId!, + "--id", + connector.targetId, + ], + (response) => response.status === "READY", + ); + const ruleStdout = await run([ "gateway", "rule", @@ -365,3 +502,11 @@ describe("Gateway fixture-backed creates", () => { FLOW_TIMEOUT, ); }); + +function apiKeyProviderArn(gatewayArn: string): string { + const [prefix, partition, service, region, accountId] = gatewayArn.split(":"); + if (prefix !== "arn" || service !== "bedrock-agentcore" || !region || !accountId) { + throw new Error(`Unexpected Gateway ARN: ${gatewayArn}`); + } + return `arn:${partition}:${service}:${region}:${accountId}:token-vault/default/apikeycredentialprovider/${API_KEY_PROVIDER_NAME}`; +} From 67d095434a790239ebea4dcd9d5921313e744a5e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 6 Aug 2026 17:28:48 +0000 Subject: [PATCH 13/15] fix(gateway): create web search connector shortcut --- ...CreateGatewayCommand.7f678b79455bc400.json | 10 +-- ...eGatewayRuleCommand.d87304453bfe9534.json} | 6 +- ...GatewayTargetCommand.22512d9b9027f76a.json | 35 +++++++++ ...atewayTargetCommand.93c8d0503a636294.json} | 8 +- ...GatewayTargetCommand.b9bd759c932fb313.json | 35 --------- .../CreateRoleCommand.8c882992929fc867.json | 4 +- ...> GetGatewayCommand.5ff84cafc95022b4.json} | 12 +-- ...tGatewayRuleCommand.9a6bd6a64eebb5df.json} | 8 +- ...GatewayTargetCommand.3855b58e8bc470ab.json | 35 +++++++++ ...GatewayTargetCommand.51d3c1380690cc2e.json | 38 --------- ...GatewayTargetCommand.91cea07923c15ff.json} | 8 +- .../create/connector-create.golden.json | 34 ++++---- .../create/gateway-create.golden.json | 10 +-- .../create/rule-create.golden.json | 6 +- .../create/target-create.golden.json | 8 +- .../connector/gatewayConnectorTarget.test.ts | 6 ++ .../connector/gatewayConnectorTarget.ts | 6 ++ src/handlers/gateway/gateway.create.test.tsx | 78 +++++++++++++------ 18 files changed, 193 insertions(+), 154 deletions(-) rename src/handlers/gateway/__fixtures__/create/{CreateGatewayRuleCommand.d099663f07f604e8.json => CreateGatewayRuleCommand.d87304453bfe9534.json} (68%) create mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.22512d9b9027f76a.json rename src/handlers/gateway/__fixtures__/create/{CreateGatewayTargetCommand.8ed7bdc58ed5cfe3.json => CreateGatewayTargetCommand.93c8d0503a636294.json} (65%) delete mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.b9bd759c932fb313.json rename src/handlers/gateway/__fixtures__/create/{GetGatewayCommand.e10f48bff6d765f4.json => GetGatewayCommand.5ff84cafc95022b4.json} (58%) rename src/handlers/gateway/__fixtures__/create/{GetGatewayRuleCommand.aeea9dd972f29009.json => GetGatewayRuleCommand.9a6bd6a64eebb5df.json} (64%) create mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3855b58e8bc470ab.json delete mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.51d3c1380690cc2e.json rename src/handlers/gateway/__fixtures__/create/{GetGatewayTargetCommand.a2b64be6031c8c59.json => GetGatewayTargetCommand.91cea07923c15ff.json} (65%) diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json index 110f348d2..f64f9cb8f 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json @@ -1,16 +1,16 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", - "gatewayId": "agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "gatewayId": "agentcore-cli-gateway-create-fixture-bkf6yayfce", "createdAt": { - "$date": "2026-08-06T16:16:36.091Z" + "$date": "2026-08-06T17:25:41.425Z" }, "updatedAt": { - "$date": "2026-08-06T16:16:36.091Z" + "$date": "2026-08-06T17:25:41.425Z" }, "status": "CREATING", "name": "agentcore-cli-gateway-create-fixture", "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-b8lxs2arsg.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-bkf6yayfce.gateway.bedrock-agentcore.us-east-1.amazonaws.com", "description": "Disposable Gateway Create fixture", "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", "workloadIdentityDetails": {} diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d099663f07f604e8.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d87304453bfe9534.json similarity index 68% rename from src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d099663f07f604e8.json rename to src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d87304453bfe9534.json index 2f8d3e7fb..2b0ab7ee5 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d099663f07f604e8.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d87304453bfe9534.json @@ -1,6 +1,6 @@ { - "ruleId": "0bb02e22-d2cd-42b1-8a17-8fd71d277f0c", - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "ruleId": "93d59ee8-8a9b-4535-9f11-df7006241fed", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", "priority": 10, "actions": [ { @@ -12,7 +12,7 @@ } ], "createdAt": { - "$date": "2026-08-06T16:16:52.214Z" + "$date": "2026-08-06T17:25:57.672Z" }, "status": "CREATING", "description": "Disposable Gateway Rule Create fixture" diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.22512d9b9027f76a.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.22512d9b9027f76a.json new file mode 100644 index 000000000..f11639c22 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.22512d9b9027f76a.json @@ -0,0 +1,35 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "targetId": "U4ADV7VNSV", + "createdAt": { + "$date": "2026-08-06T17:25:52.273Z" + }, + "updatedAt": { + "$date": "2026-08-06T17:25:52.273Z" + }, + "status": "CREATING", + "name": "web-search-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Disposable Gateway Connector Create fixture" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.8ed7bdc58ed5cfe3.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.93c8d0503a636294.json similarity index 65% rename from src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.8ed7bdc58ed5cfe3.json rename to src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.93c8d0503a636294.json index 5e102f763..35aa79c1b 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.8ed7bdc58ed5cfe3.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.93c8d0503a636294.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", - "targetId": "C5MERQNEMC", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "targetId": "VZ7M0WBGJF", "createdAt": { - "$date": "2026-08-06T16:16:41.439Z" + "$date": "2026-08-06T17:25:46.743Z" }, "updatedAt": { - "$date": "2026-08-06T16:16:41.439Z" + "$date": "2026-08-06T17:25:46.743Z" }, "status": "CREATING", "name": "http-fixture", diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.b9bd759c932fb313.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.b9bd759c932fb313.json deleted file mode 100644 index 70dcf7002..000000000 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.b9bd759c932fb313.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", - "targetId": "GZHW6TACLJ", - "createdAt": { - "$date": "2026-08-06T16:16:46.873Z" - }, - "updatedAt": { - "$date": "2026-08-06T16:16:46.873Z" - }, - "status": "CREATING", - "name": "openai-fixture", - "targetConfiguration": { - "inference": { - "connector": { - "source": { - "connectorId": "openai" - } - } - } - }, - "credentialProviderConfigurations": [ - { - "credentialProviderType": "API_KEY", - "credentialProvider": { - "apiKeyCredentialProvider": { - "providerArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:token-vault/default/apikeycredentialprovider/DONOTDELETEe2eOAI", - "credentialParameterName": "Authorization", - "credentialPrefix": "Bearer", - "credentialLocation": "HEADER" - } - } - } - ], - "description": "Disposable Gateway Connector Create fixture" -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.8c882992929fc867.json b/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.8c882992929fc867.json index fbe4b0db1..6c4dd371a 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.8c882992929fc867.json +++ b/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.8c882992929fc867.json @@ -2,10 +2,10 @@ "Role": { "Path": "/", "RoleName": "AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", - "RoleId": "AROAZ7CHXJWHVZ2RH6I5X", + "RoleId": "AROAZ7CHXJWHQNHN3DNFW", "Arn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", "CreateDate": { - "$date": "2026-08-06T16:16:35.000Z" + "$date": "2026-08-06T17:25:41.000Z" }, "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Sid%22%3A%22GatewayAssumeRolePolicy%22%2C%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/gateway/__fixtures__/create/GetGatewayCommand.e10f48bff6d765f4.json b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5ff84cafc95022b4.json similarity index 58% rename from src/handlers/gateway/__fixtures__/create/GetGatewayCommand.e10f48bff6d765f4.json rename to src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5ff84cafc95022b4.json index b76de80cc..58e25d0d8 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.e10f48bff6d765f4.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5ff84cafc95022b4.json @@ -1,19 +1,19 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", - "gatewayId": "agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "gatewayId": "agentcore-cli-gateway-create-fixture-bkf6yayfce", "createdAt": { - "$date": "2026-08-06T16:16:36.091Z" + "$date": "2026-08-06T17:25:41.425Z" }, "updatedAt": { - "$date": "2026-08-06T16:16:36.647Z" + "$date": "2026-08-06T17:25:42.396Z" }, "status": "READY", "name": "agentcore-cli-gateway-create-fixture", "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-b8lxs2arsg.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-bkf6yayfce.gateway.bedrock-agentcore.us-east-1.amazonaws.com", "description": "Disposable Gateway Create fixture", "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", "workloadIdentityDetails": { - "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-create-fixture-b8lxs2arsg" + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-create-fixture-bkf6yayfce" } } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.aeea9dd972f29009.json b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.9a6bd6a64eebb5df.json similarity index 64% rename from src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.aeea9dd972f29009.json rename to src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.9a6bd6a64eebb5df.json index 80bf0498e..fd163aa51 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.aeea9dd972f29009.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.9a6bd6a64eebb5df.json @@ -1,6 +1,6 @@ { - "ruleId": "0bb02e22-d2cd-42b1-8a17-8fd71d277f0c", - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "ruleId": "93d59ee8-8a9b-4535-9f11-df7006241fed", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", "priority": 10, "actions": [ { @@ -12,11 +12,11 @@ } ], "createdAt": { - "$date": "2026-08-06T16:16:52.214Z" + "$date": "2026-08-06T17:25:57.672Z" }, "status": "ACTIVE", "description": "Disposable Gateway Rule Create fixture", "updatedAt": { - "$date": "2026-08-06T16:16:52.214Z" + "$date": "2026-08-06T17:25:57.672Z" } } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3855b58e8bc470ab.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3855b58e8bc470ab.json new file mode 100644 index 000000000..41f19859a --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3855b58e8bc470ab.json @@ -0,0 +1,35 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "targetId": "U4ADV7VNSV", + "createdAt": { + "$date": "2026-08-06T17:25:52.273Z" + }, + "updatedAt": { + "$date": "2026-08-06T17:25:53.039Z" + }, + "status": "READY", + "name": "web-search-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Disposable Gateway Connector Create fixture" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.51d3c1380690cc2e.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.51d3c1380690cc2e.json deleted file mode 100644 index 00cc44878..000000000 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.51d3c1380690cc2e.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", - "targetId": "GZHW6TACLJ", - "createdAt": { - "$date": "2026-08-06T16:16:46.873Z" - }, - "updatedAt": { - "$date": "2026-08-06T16:16:51.480Z" - }, - "status": "READY", - "name": "openai-fixture", - "targetConfiguration": { - "inference": { - "connector": { - "source": { - "connectorId": "openai" - } - } - } - }, - "credentialProviderConfigurations": [ - { - "credentialProviderType": "API_KEY", - "credentialProvider": { - "apiKeyCredentialProvider": { - "providerArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:token-vault/default/apikeycredentialprovider/DONOTDELETEe2eOAI", - "credentialParameterName": "Authorization", - "credentialPrefix": "Bearer", - "credentialLocation": "HEADER" - } - } - } - ], - "description": "Disposable Gateway Connector Create fixture", - "lastSynchronizedAt": { - "$date": "2026-08-06T16:16:51.263Z" - } -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.a2b64be6031c8c59.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.91cea07923c15ff.json similarity index 65% rename from src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.a2b64be6031c8c59.json rename to src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.91cea07923c15ff.json index 6adee227c..4bb1476e1 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.a2b64be6031c8c59.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.91cea07923c15ff.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", - "targetId": "C5MERQNEMC", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "targetId": "VZ7M0WBGJF", "createdAt": { - "$date": "2026-08-06T16:16:41.439Z" + "$date": "2026-08-06T17:25:46.743Z" }, "updatedAt": { - "$date": "2026-08-06T16:16:42.021Z" + "$date": "2026-08-06T17:25:47.390Z" }, "status": "READY", "name": "http-fixture", diff --git a/src/handlers/gateway/__fixtures__/create/connector-create.golden.json b/src/handlers/gateway/__fixtures__/create/connector-create.golden.json index a2eb2e5a2..86bb9c031 100644 --- a/src/handlers/gateway/__fixtures__/create/connector-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/connector-create.golden.json @@ -1,30 +1,30 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", - "targetId": "GZHW6TACLJ", - "createdAt": "2026-08-06T16:16:46.873Z", - "updatedAt": "2026-08-06T16:16:46.873Z", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "targetId": "U4ADV7VNSV", + "createdAt": "2026-08-06T17:25:52.273Z", + "updatedAt": "2026-08-06T17:25:52.273Z", "status": "CREATING", - "name": "openai-fixture", + "name": "web-search-fixture", "targetConfiguration": { - "inference": { + "mcp": { "connector": { "source": { - "connectorId": "openai" - } + "connectorId": "web-search" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] } } }, "credentialProviderConfigurations": [ { - "credentialProviderType": "API_KEY", - "credentialProvider": { - "apiKeyCredentialProvider": { - "providerArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:token-vault/default/apikeycredentialprovider/DONOTDELETEe2eOAI", - "credentialParameterName": "Authorization", - "credentialPrefix": "Bearer", - "credentialLocation": "HEADER" - } - } + "credentialProviderType": "GATEWAY_IAM_ROLE" } ], "description": "Disposable Gateway Connector Create fixture" diff --git a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json index bfc20a59c..4ec1da7bb 100644 --- a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json @@ -1,12 +1,12 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", - "gatewayId": "agentcore-cli-gateway-create-fixture-b8lxs2arsg", - "createdAt": "2026-08-06T16:16:36.091Z", - "updatedAt": "2026-08-06T16:16:36.091Z", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "gatewayId": "agentcore-cli-gateway-create-fixture-bkf6yayfce", + "createdAt": "2026-08-06T17:25:41.425Z", + "updatedAt": "2026-08-06T17:25:41.425Z", "status": "CREATING", "name": "agentcore-cli-gateway-create-fixture", "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-b8lxs2arsg.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-bkf6yayfce.gateway.bedrock-agentcore.us-east-1.amazonaws.com", "description": "Disposable Gateway Create fixture", "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", "workloadIdentityDetails": {} diff --git a/src/handlers/gateway/__fixtures__/create/rule-create.golden.json b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json index 947cb3dd8..b011a33b3 100644 --- a/src/handlers/gateway/__fixtures__/create/rule-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json @@ -1,6 +1,6 @@ { - "ruleId": "0bb02e22-d2cd-42b1-8a17-8fd71d277f0c", - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", + "ruleId": "93d59ee8-8a9b-4535-9f11-df7006241fed", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", "priority": 10, "actions": [ { @@ -11,7 +11,7 @@ } } ], - "createdAt": "2026-08-06T16:16:52.214Z", + "createdAt": "2026-08-06T17:25:57.672Z", "status": "CREATING", "description": "Disposable Gateway Rule Create fixture" } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/target-create.golden.json b/src/handlers/gateway/__fixtures__/create/target-create.golden.json index fe31b7a4f..183dfcdac 100644 --- a/src/handlers/gateway/__fixtures__/create/target-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/target-create.golden.json @@ -1,8 +1,8 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-b8lxs2arsg", - "targetId": "C5MERQNEMC", - "createdAt": "2026-08-06T16:16:41.439Z", - "updatedAt": "2026-08-06T16:16:41.439Z", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "targetId": "VZ7M0WBGJF", + "createdAt": "2026-08-06T17:25:46.743Z", + "updatedAt": "2026-08-06T17:25:46.743Z", "status": "CREATING", "name": "http-fixture", "targetConfiguration": { diff --git a/src/handlers/gateway/connector/gatewayConnectorTarget.test.ts b/src/handlers/gateway/connector/gatewayConnectorTarget.test.ts index bd224ae76..aa27adc85 100644 --- a/src/handlers/gateway/connector/gatewayConnectorTarget.test.ts +++ b/src/handlers/gateway/connector/gatewayConnectorTarget.test.ts @@ -10,6 +10,12 @@ describe("GatewayConnectorTarget.fromShortcut", () => { mcp: { connector: { source: { connectorId: "web-search" }, + configurations: [ + { + name: "WebSearch", + parameterValues: { maxResults: 10 }, + }, + ], }, }, }, diff --git a/src/handlers/gateway/connector/gatewayConnectorTarget.ts b/src/handlers/gateway/connector/gatewayConnectorTarget.ts index da8e37f73..bb895e036 100644 --- a/src/handlers/gateway/connector/gatewayConnectorTarget.ts +++ b/src/handlers/gateway/connector/gatewayConnectorTarget.ts @@ -41,6 +41,12 @@ export class GatewayConnectorTarget { mcp: { connector: { source: { connectorId: shortcut }, + configurations: [ + { + name: "WebSearch", + parameterValues: { maxResults: 10 }, + }, + ], }, }, }; diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index 8206cde97..3cad89753 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -7,7 +7,11 @@ import { GetGatewayCommand, GetGatewayTargetCommand, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { DeleteRoleCommand } from "@aws-sdk/client-iam"; +import { + DeleteRoleCommand, + DeleteRolePolicyCommand, + PutRolePolicyCommand, +} from "@aws-sdk/client-iam"; import { CoreClient } from "../../core"; import { createControlClient, createIamClient } from "../../core/factories"; import { GatewayExecutionRole } from "../../core/gatewayExecutionRole"; @@ -24,8 +28,8 @@ import { createRootHandler } from "../index"; const REGION = "us-east-1"; const GATEWAY_NAME = "agentcore-cli-gateway-create-fixture"; const HTTP_TARGET_NAME = "http-fixture"; -const CONNECTOR_TARGET_NAME = "openai-fixture"; -const API_KEY_PROVIDER_NAME = "DONOTDELETEe2eOAI"; +const CONNECTOR_TARGET_NAME = "web-search-fixture"; +const WEB_SEARCH_POLICY_NAME = "AgentCoreCliWebSearchFixture"; const FIXTURES = join(import.meta.dir, "__fixtures__", "create"); const FLOW_TIMEOUT = 600_000; @@ -160,6 +164,14 @@ async function cleanup(state: FixtureState): Promise { } const iam = createIamClient({ region: REGION }); + await ignoreMissing(() => + iam.send( + new DeleteRolePolicyCommand({ + RoleName: GatewayExecutionRole.roleName(GATEWAY_NAME, REGION), + PolicyName: WEB_SEARCH_POLICY_NAME, + }), + ), + ); await ignoreMissing(() => iam.send( new DeleteRoleCommand({ @@ -413,6 +425,16 @@ describe("Gateway fixture-backed creates", () => { (response) => response.status === "READY", ); + if (isRecording()) { + await createIamClient({ region: REGION }).send( + new PutRolePolicyCommand({ + RoleName: GatewayExecutionRole.roleName(GATEWAY_NAME, REGION), + PolicyName: WEB_SEARCH_POLICY_NAME, + PolicyDocument: JSON.stringify(webSearchPolicy(gateway.gatewayArn)), + }), + ); + } + const connectorStdout = await run([ "gateway", "connector", @@ -421,22 +443,10 @@ describe("Gateway fixture-backed creates", () => { state.gatewayId!, "--name", CONNECTOR_TARGET_NAME, - "--connector-configuration", - '{"inference":{"connector":{"source":{"connectorId":"openai"}}}}', + "--connector", + "web-search", "--credential-provider-configurations", - JSON.stringify([ - { - credentialProviderType: "API_KEY", - credentialProvider: { - apiKeyCredentialProvider: { - providerArn: apiKeyProviderArn(gateway.gatewayArn), - credentialParameterName: "Authorization", - credentialPrefix: "Bearer", - credentialLocation: "HEADER", - }, - }, - }, - ]), + '[{"credentialProviderType":"GATEWAY_IAM_ROLE"}]', "--description", "Disposable Gateway Connector Create fixture", ]); @@ -444,9 +454,15 @@ describe("Gateway fixture-backed creates", () => { const connector = JSON.parse(connectorStdout); expect(connector.targetId).toBeString(); expect(connector.targetConfiguration).toEqual({ - inference: { + mcp: { connector: { - source: { connectorId: "openai" }, + source: { connectorId: "web-search" }, + configurations: [ + { + name: "WebSearch", + parameterValues: { maxResults: 10 }, + }, + ], }, }, }); @@ -503,10 +519,24 @@ describe("Gateway fixture-backed creates", () => { ); }); -function apiKeyProviderArn(gatewayArn: string): string { - const [prefix, partition, service, region, accountId] = gatewayArn.split(":"); - if (prefix !== "arn" || service !== "bedrock-agentcore" || !region || !accountId) { +function webSearchPolicy(gatewayArn: string): Record { + const [prefix, partition, service, region] = gatewayArn.split(":"); + if (prefix !== "arn" || service !== "bedrock-agentcore" || !region) { throw new Error(`Unexpected Gateway ARN: ${gatewayArn}`); } - return `arn:${partition}:${service}:${region}:${accountId}:token-vault/default/apikeycredentialprovider/${API_KEY_PROVIDER_NAME}`; + return { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeGateway", + Resource: gatewayArn, + }, + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeWebSearch", + Resource: `arn:${partition}:${service}:${region}:aws:tool/web-search.v1`, + }, + ], + }; } From 70f36846cdb1c1efe68b629d1b583388b6fb4391 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 6 Aug 2026 18:01:16 +0000 Subject: [PATCH 14/15] test(gateway): mark IAM fixture workaround --- src/handlers/gateway/gateway.create.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index 3cad89753..fbfacedb3 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -425,6 +425,7 @@ describe("Gateway fixture-backed creates", () => { (response) => response.status === "READY", ); + // TODO: Remove this fixture-only grant when managed Gateway roles reconcile Target permissions. if (isRecording()) { await createIamClient({ region: REGION }).send( new PutRolePolicyCommand({ From f2a81814275936116ec4c8b9cf8db8c4dedace15 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 6 Aug 2026 18:13:34 +0000 Subject: [PATCH 15/15] fix(gateway): reject generated role collisions --- src/core/gatewayExecutionRole.test.ts | 7 +++++-- src/core/gatewayExecutionRole.ts | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/core/gatewayExecutionRole.test.ts b/src/core/gatewayExecutionRole.test.ts index 9a9088dad..6d77188f6 100644 --- a/src/core/gatewayExecutionRole.test.ts +++ b/src/core/gatewayExecutionRole.test.ts @@ -50,7 +50,7 @@ describe("GatewayExecutionRole", () => { }); }); - test("reuses an existing default role without editing it", async () => { + test("rejects an existing generated role with explicit reuse guidance", async () => { const roleName = GatewayExecutionRole.roleName("orders", REGION); const roleArn = `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`; const commands: unknown[] = []; @@ -61,7 +61,10 @@ describe("GatewayExecutionRole", () => { }, } as unknown as IAMClient; - await expect(GatewayExecutionRole.ensure(iam, "orders", REGION)).resolves.toBe(roleArn); + await expect(GatewayExecutionRole.ensure(iam, "orders", REGION)).rejects.toThrow( + `IAM role "${roleName}" already exists. Pass --role-arn "${roleArn}" to reuse it, ` + + "or choose a different Gateway name.", + ); expect(commands).toHaveLength(1); expect(commands[0]).toBeInstanceOf(GetRoleCommand); }); diff --git a/src/core/gatewayExecutionRole.ts b/src/core/gatewayExecutionRole.ts index 9879c7046..389415307 100644 --- a/src/core/gatewayExecutionRole.ts +++ b/src/core/gatewayExecutionRole.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { CreateRoleCommand, GetRoleCommand, type IAMClient, type Role } from "@aws-sdk/client-iam"; +import { InputValidationError } from "../errors"; export class GatewayExecutionRole { static roleName(gatewayName: string, region: string): string { @@ -15,7 +16,11 @@ export class GatewayExecutionRole { try { const response = await iam.send(new GetRoleCommand({ RoleName: roleName })); - return GatewayExecutionRole.requiredArn(response.Role, roleName); + const roleArn = GatewayExecutionRole.requiredArn(response.Role, roleName); + throw new InputValidationError( + `IAM role "${roleName}" already exists. Pass --role-arn "${roleArn}" to reuse it, ` + + "or choose a different Gateway name.", + ); } catch (error) { if ((error as Error).name !== "NoSuchEntityException") throw error; }