From 097d0af1b7a2c9077a2bf34a5e8cdc5021deab3d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 27 Jul 2026 18:18:06 +0000 Subject: [PATCH 01/10] feat(runtime): add invoke transport --- bun.lock | 146 +++++++-- package.json | 2 +- src/core/abortable.ts | 32 ++ src/core/core.test.ts | 580 ++++++++++++++++++++++++++++++--- src/core/harness.tsx | 34 +- src/core/index.tsx | 10 +- src/core/runtime.tsx | 190 ++++++++++- src/core/types.tsx | 3 + src/handlers/runtime/types.tsx | 45 ++- src/testing/TestCoreClient.tsx | 81 +++-- 10 files changed, 988 insertions(+), 135 deletions(-) create mode 100644 src/core/abortable.ts diff --git a/bun.lock b/bun.lock index 14ce1002f..4be453a15 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "agentcore", "dependencies": { - "@aws-sdk/client-bedrock-agentcore": "^3.1079.0", + "@aws-sdk/client-bedrock-agentcore": "^3.1092.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0", "@aws-sdk/client-iam": "^3.1080.0", "@inkui-cli/data-table": "^0.2.0", @@ -39,39 +39,39 @@ "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], - "@aws-sdk/client-bedrock-agentcore": ["@aws-sdk/client-bedrock-agentcore@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-qv1e7yjIxu4KCRNGkG+Ar/MLYGhyUWkGfjKoAm1H4/jD0viEC1PBklbHJm27HLxYtOcKWYA67k8S0kGb0iJxDA=="], + "@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-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=="], - "@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/core": ["@aws-sdk/core@3.976.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.36", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.4", "@smithy/signature-v4": "^5.6.5", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA=="], - "@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/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg=="], - "@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/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.62", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@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-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ=="], - "@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/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.5", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/credential-provider-env": "^3.972.60", "@aws-sdk/credential-provider-http": "^3.972.62", "@aws-sdk/credential-provider-login": "^3.972.67", "@aws-sdk/credential-provider-process": "^3.972.60", "@aws-sdk/credential-provider-sso": "^3.973.4", "@aws-sdk/credential-provider-web-identity": "^3.972.66", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw=="], - "@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/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.67", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ=="], - "@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/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.71", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.60", "@aws-sdk/credential-provider-http": "^3.972.62", "@aws-sdk/credential-provider-ini": "^3.973.5", "@aws-sdk/credential-provider-process": "^3.972.60", "@aws-sdk/credential-provider-sso": "^3.973.4", "@aws-sdk/credential-provider-web-identity": "^3.972.66", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg=="], - "@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/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw=="], - "@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/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.4", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/token-providers": "3.1092.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA=="], - "@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/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.66", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng=="], - "@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/nested-clients": ["@aws-sdk/nested-clients@3.997.34", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/signature-v4-multi-region": "^3.996.41", "@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-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA=="], - "@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/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.41", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng=="], - "@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/token-providers": ["@aws-sdk/token-providers@3.1092.0", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ=="], - "@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/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="], - "@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/xml-builder": ["@aws-sdk/xml-builder@3.972.36", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], @@ -119,15 +119,15 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], - "@smithy/core": ["@smithy/core@3.29.3", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A=="], + "@smithy/core": ["@smithy/core@3.29.8", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA=="], - "@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=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.13", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-X+2HNZhWi5i3rJsCas0LPf6fTQUaKyJ40zd8aTO/bwpRfpU3biYaqLr7C1WMibL7PVKJalpi1PyybjGPNoHC8Q=="], - "@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=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.10", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-5/Yj9mS2JjTsB3B8ZX7euh77mrY9aXW23ag1yAmFykSRmA6vldqBrgqmSeQ50EjY+5SB8+aE4w14B6LKbBVEhQ=="], - "@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=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.10", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ETQz9v/Z+nTQc6fRWTXxUpxJqwpmzB3Tn3WKAdHwWkeT+m+HE5czs6GNG8vW+4vyxXSls65RVcvOZwk7Q/PS/Q=="], - "@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=="], + "@smithy/signature-v4": ["@smithy/signature-v4@5.6.9", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-g5rnEii/mkT0mjVJmlsaOfyNBtHNTecD9Lo4NP8D5HzMUEnZNpz7/FbvBCjNcV4vteHFAxOGiLUYNxPkDZZAPw=="], "@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], @@ -333,6 +333,30 @@ "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/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/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.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.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-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=="], + + "@aws-sdk/client-iam/@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-iam/@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-iam/@smithy/core": ["@smithy/core@3.29.3", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A=="], + + "@aws-sdk/client-iam/@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-iam/@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=="], + "listr2/cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -343,14 +367,94 @@ "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/@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-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-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-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-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-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-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/@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-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=="], + + "@aws-sdk/client-iam/@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-iam/@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-iam/@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-iam/@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-iam/@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-iam/@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-iam/@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-iam/@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=="], + "listr2/cli-truncate/slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "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/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.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/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-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-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=="], + + "@aws-sdk/client-iam/@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-iam/@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-iam/@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-iam/@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=="], + "log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "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-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-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-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=="], + + "@aws-sdk/client-iam/@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-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-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-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-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=="], + + "@aws-sdk/client-iam/@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-iam/@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=="], } } diff --git a/package.json b/package.json index 3f2182a84..63625847d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "typescript": "^5" }, "dependencies": { - "@aws-sdk/client-bedrock-agentcore": "^3.1079.0", + "@aws-sdk/client-bedrock-agentcore": "^3.1092.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0", "@aws-sdk/client-iam": "^3.1080.0", "@inkui-cli/data-table": "^0.2.0", diff --git a/src/core/abortable.ts b/src/core/abortable.ts new file mode 100644 index 000000000..8e639bda7 --- /dev/null +++ b/src/core/abortable.ts @@ -0,0 +1,32 @@ +/** + * Relays `source` while making an in-flight read reject as soon as `signal` aborts. + * + * Aborts use `signal.reason` or an `AbortError` fallback. The rejection remains + * observed when no read is pending, and source cleanup is fire-and-forget because + * a stalled stream may also stall `iterator.return()`. + */ +export async function* abortable( + source: AsyncIterable, + signal: AbortSignal, +): AsyncGenerator { + let abort = () => {}; + const aborted = new Promise((_, reject) => { + abort = () => + reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError")); + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + }); + aborted.catch(() => {}); + + const iterator = source[Symbol.asyncIterator](); + try { + for (;;) { + const result = await Promise.race([iterator.next(), aborted]); + if (result.done) return; + yield result.value; + } + } finally { + signal.removeEventListener("abort", abort); + void iterator.return?.()?.catch(() => {}); + } +} diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 2c773710a..6db51e5a3 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -1,16 +1,63 @@ import { test, expect } from "bun:test"; -import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore-control"; +import { + GetAgentRuntimeCommand, + type BedrockAgentCoreControlClient, +} from "@aws-sdk/client-bedrock-agentcore-control"; import type { IAMClient } from "@aws-sdk/client-iam"; import { + InvokeAgentRuntimeCommand, InvokeAgentRuntimeCommandCommand, InvokeHarnessCommand, type BedrockAgentCoreClient, } from "@aws-sdk/client-bedrock-agentcore"; +import type { RuntimeInvokeRequest } from "../handlers/runtime/types"; +import type { Logger, LoggerBindings } from "../logging"; import { CoreClient } from "./index"; -import type { ClientConfig } from "./types"; +import type { ClientConfig, CoreFetch } from "./types"; import { toClientConfig } from "./utils"; import { createSilentLogger } from "../testing"; +interface CapturedLog { + level: "debug" | "info" | "warn" | "error"; + message: string; + bindings: LoggerBindings; +} + +function captureLogs(): { logger: Logger; logs: CapturedLog[] } { + const logs: CapturedLog[] = []; + const makeLogger = (bindings: LoggerBindings): Logger => { + const log = + (level: CapturedLog["level"]) => + (...messages: string[]) => + logs.push({ level, message: messages.join(" "), bindings }); + return { + debug: log("debug"), + info: log("info"), + warn: log("warn"), + error: log("error"), + child: (childBindings) => makeLogger({ ...bindings, ...childBindings }), + }; + }; + return { logger: makeLogger({}), logs }; +} + +function expectSafeDebugLog( + logs: CapturedLog[], + message: string, + bindings: LoggerBindings, + secrets: string[], +): void { + expect(logs).toEqual([ + { + level: "debug", + message, + bindings: { module: "runtime", operation: "invokeRuntime", ...bindings }, + }, + ]); + const serialized = JSON.stringify(logs); + for (const secret of secrets) expect(serialized).not.toContain(secret); +} + // A minimal stand-in for the SDK clients; CoreClient only stores and returns // them, so an opaque tagged object is enough to assert identity/caching. function fakeControl(config: ClientConfig): BedrockAgentCoreControlClient { @@ -23,6 +70,55 @@ function fakeIam(config: ClientConfig): IAMClient { return { config, kind: "iam" } as unknown as IAMClient; } +function coreWithDataSend( + send: (command: unknown, options: unknown) => Promise, + logger: Logger = createSilentLogger(), +): CoreClient { + return new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => + ({ config, kind: "data", send }) as unknown as BedrockAgentCoreClient, + createIamClient: fakeIam, + logger, + }); +} + +async function resolveRuntimeApplicationHeaders( + command: InvokeAgentRuntimeCommand, +): Promise<[string, string][]> { + let headers: [string, string][] = []; + const handler = command.middlewareStack.resolve(async (args) => { + headers = Object.entries((args.request as { headers: Record }).headers); + return { output: { statusCode: 204 } as never, response: {} as never }; + }, {} as never); + await handler({ input: command.input, request: { headers: {} } } as never); + return headers; +} + +function customJwtCore( + fetch: CoreFetch, + { + endpoint = "https://runtime.test", + logger = createSilentLogger(), + }: { endpoint?: string; logger?: Logger } = {}, +): CoreClient { + return new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => + ({ + config: { + endpointProvider: () => ({ url: new URL(config.endpoint ?? endpoint) }), + }, + send: async () => { + throw new Error("IAM transport must not be used"); + }, + }) as unknown as BedrockAgentCoreClient, + createIamClient: fakeIam, + fetch, + logger, + }); +} + test("control() constructs a client once per config and caches it", () => { let built = 0; const core = new CoreClient({ @@ -96,6 +192,33 @@ test("exposes a harness sub-client", () => { expect(core.harness).toBeDefined(); }); +test("getRuntime sends the abort signal to the control client", async () => { + const sent: { command: unknown; options: unknown }[] = []; + const core = new CoreClient({ + createControlClient: (config) => + ({ + config, + send: async (command: unknown, options: unknown) => { + sent.push({ command, options }); + return {}; + }, + }) as unknown as BedrockAgentCoreControlClient, + createDataClient: fakeData, + createIamClient: fakeIam, + logger: createSilentLogger(), + }); + const controller = new AbortController(); + + await core.runtime.getRuntime("runtime-123", { region: "us-east-1" }, controller.signal); + + expect(sent).toHaveLength(1); + expect(sent[0]!.command).toBeInstanceOf(GetAgentRuntimeCommand); + expect((sent[0]!.command as GetAgentRuntimeCommand).input).toEqual({ + agentRuntimeId: "runtime-123", + }); + expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); +}); + test("invokeHarness sends an InvokeHarnessCommand on the data client with the abort signal", async () => { // A fake data client that records what send() receives and resolves a canned // response, so we can assert the harness sub-client's SDK translation. @@ -148,17 +271,7 @@ test("invokeHarness stream iteration rejects promptly when aborted mid-stream", await new Promise(() => {}); }, }; - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config, - kind: "data", - send: async () => ({ stream: hangingStream }), - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - logger: createSilentLogger(), - }); + const core = coreWithDataSend(async () => ({ stream: hangingStream })); const controller = new AbortController(); const response = await core.harness.invokeHarness( @@ -177,19 +290,9 @@ test("invokeHarness stream iteration rejects promptly when aborted mid-stream", test("invokeAgentRuntimeCommand sends the command on the data client with the abort signal", async () => { const sent: { command: unknown; options: unknown }[] = []; - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config, - kind: "data", - send: async (command: unknown, options: unknown) => { - sent.push({ command, options }); - return { statusCode: 200, stream: undefined }; - }, - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - logger: createSilentLogger(), + const core = coreWithDataSend(async (command, options) => { + sent.push({ command, options }); + return { statusCode: 200, stream: undefined }; }); const request = { @@ -205,19 +308,422 @@ test("invokeAgentRuntimeCommand sends the command on the data client with the ab expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); }); +test("invokeRuntime maps all modeled IAM fields and response metadata", async () => { + const sent: { command: unknown; options: unknown }[] = []; + const body = (async function* () { + yield Uint8Array.from([0, 1, 255]); + })(); + const sdkResponse = { + statusCode: 202, + contentType: "application/octet-stream", + runtimeSessionId: "runtime-session", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "baggage", + response: body, + }; + const core = coreWithDataSend(async (command, options) => { + sent.push({ command, options }); + return sdkResponse; + }); + const request: RuntimeInvokeRequest = { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: Uint8Array.from([123, 125]), + contentType: "application/json", + accept: "text/event-stream", + runtimeSessionId: "runtime-session", + runtimeUserId: "runtime-user", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + mcpMethod: "tools/call", + mcpName: "weather", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "tenant=retail", + }; + const controller = new AbortController(); + + const result = await core.runtime.invokeRuntime( + request, + { region: "us-west-2", endpointUrl: "https://custom" }, + controller.signal, + ); + + expect(sent).toHaveLength(1); + expect(sent[0]!.command).toBeInstanceOf(InvokeAgentRuntimeCommand); + const { runtimeId, ...input } = request; + expect((sent[0]!.command as InvokeAgentRuntimeCommand).input).toEqual({ + ...input, + agentRuntimeArn: runtimeId, + }); + expect(sent[0]!.options).toEqual({ abortSignal: controller.signal }); + const { response, ...metadata } = sdkResponse; + expect(result).toEqual({ + ...metadata, + body: response, + }); +}); + +test("invokeRuntime adds ordered application headers outside the modeled IAM input", async () => { + let command: InvokeAgentRuntimeCommand | undefined; + const core = coreWithDataSend(async (sent) => { + command = sent as InvokeAgentRuntimeCommand; + return { statusCode: 204 }; + }); + + await core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + applicationHeaders: [ + ["X-One", "1"], + ["x-two", "2"], + ], + }, + { region: "us-east-1" }, + ); + + expect(command).toBeInstanceOf(InvokeAgentRuntimeCommand); + expect(command!.input).not.toHaveProperty("applicationHeaders"); + expect(await resolveRuntimeApplicationHeaders(command!)).toEqual([ + ["X-One", "1"], + ["x-two", "2"], + ]); +}); + +test("invokeRuntime aborts an established IAM response stream", async () => { + const source = (async function* () { + yield Buffer.from("partial"); + await new Promise(() => {}); + })(); + const core = coreWithDataSend(async () => ({ + statusCode: 200, + contentType: "text/plain", + response: source, + })); + const controller = new AbortController(); + const response = await core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + }, + { region: "us-east-1" }, + controller.signal, + ); + const iterator = response.body[Symbol.asyncIterator](); + + expect(await iterator.next()).toEqual({ done: false, value: Buffer.from("partial") }); + const pending = iterator.next(); + controller.abort(); + + const result = await Promise.race([ + pending.then( + () => "completed", + (error: Error) => error.name, + ), + new Promise((resolve) => setTimeout(() => resolve("timed out"), 25)), + ]); + expect(result).toBe("AbortError"); +}); + +test("IAM invoke failures preserve safe SDK diagnostics without arbitrary causes", async () => { + const { logger, logs } = captureLogs(); + const core = coreWithDataSend(async () => { + throw Object.assign(new Error("failed with secret request content"), { + name: "AccessDeniedException", + $metadata: { + httpStatusCode: 403, + requestId: "request-123", + }, + }); + }, logger); + + const caught = await core.runtime + .invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new TextEncoder().encode("secret payload"), + contentType: "application/json", + applicationHeaders: [["X-Secret", "secret-header-value"]], + }, + { region: "us-east-1" }, + ) + .catch((caught: Error) => caught); + const error = caught as Error; + + expect(error.message).toBe( + "Runtime invocation failed (AccessDeniedException, HTTP 403, request ID request-123)", + ); + expect(error.message).not.toContain("secret request content"); + expectSafeDebugLog( + logs, + "Runtime invocation SDK request failed", + { + authMode: "IAM", + runtimeId: "runtime-123", + qualifier: "DEFAULT", + region: "us-east-1", + errorName: "AccessDeniedException", + httpStatusCode: 403, + requestId: "request-123", + }, + ["secret request content", "secret payload", "secret-header-value"], + ); +}); + +test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", async () => { + const calls: { input: string | URL | Request; init?: RequestInit }[] = []; + const controller = new AbortController(); + const fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + calls.push({ input, init }); + return new Response(Uint8Array.from([1, 2]), { + status: 200, + headers: { + "Content-Type": "application/json", + "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "returned-runtime", + "Mcp-Session-Id": "returned-mcp", + }, + }); + }; + const core = customJwtCore(fetch); + const payload = Uint8Array.from([123, 0, 125]); + + const result = await core.runtime.invokeRuntime( + { + runtimeId: "runtime/id", + accountId: "123456789012", + qualifier: "prod green", + payload, + contentType: "application/json", + accept: "text/event-stream", + runtimeSessionId: "runtime-session", + runtimeUserId: "runtime-user", + applicationHeaders: [["X-Tenant", "retail"]], + bearerToken: "secret-token", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + mcpMethod: "tools/call", + mcpName: "weather", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "tenant=retail", + }, + { region: "us-west-2", endpointUrl: "https://runtime.test/base" }, + controller.signal, + ); + + expect(calls).toHaveLength(1); + expect(String(calls[0]!.input)).toBe( + "https://runtime.test/base/runtimes/runtime%2Fid/invocations?accountId=123456789012&qualifier=prod+green", + ); + expect(calls[0]!.init).toMatchObject({ + method: "POST", + redirect: "error", + body: payload, + signal: controller.signal, + }); + expect(new Headers(calls[0]!.init!.headers)).toEqual( + new Headers({ + Accept: "text/event-stream", + Authorization: "Bearer secret-token", + "Content-Type": "application/json", + "Mcp-Method": "tools/call", + "Mcp-Name": "weather", + "Mcp-Protocol-Version": "2025-06-18", + "Mcp-Session-Id": "mcp-session", + "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "runtime-session", + "X-Amzn-Bedrock-AgentCore-Runtime-User-Id": "runtime-user", + "X-Amzn-Trace-Id": "trace-id", + "X-Tenant": "retail", + baggage: "tenant=retail", + traceparent: "trace-parent", + tracestate: "trace-state", + }), + ); + expect(result.runtimeSessionId).toBe("returned-runtime"); + expect(result.mcpSessionId).toBe("returned-mcp"); + const resultBytes: Uint8Array[] = []; + for await (const chunk of result.body) resultBytes.push(chunk); + expect(Buffer.concat(resultBytes)).toEqual(Buffer.from([1, 2])); +}); + +test("CUSTOM_JWT rejects non-HTTPS endpoints without calling fetch", async () => { + let fetched = false; + const core = customJwtCore( + async () => { + fetched = true; + return new Response(); + }, + { endpoint: "http://runtime.test" }, + ); + + await expect( + core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new TextEncoder().encode("secret payload"), + contentType: "application/json", + bearerToken: "secret-token", + }, + { region: "us-east-1" }, + ), + ).rejects.toThrow("CUSTOM_JWT requires an HTTPS endpoint"); + expect(fetched).toBe(false); +}); + +test("CUSTOM_JWT modeled header failures do not expose their values", async () => { + let fetched = false; + const core = customJwtCore(async () => { + fetched = true; + return new Response(); + }); + + await expect( + core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + bearerToken: "secret-token", + traceParent: "secret-header\r\nvalue", + }, + { region: "us-east-1" }, + ), + ).rejects.toThrow(/^Invalid Runtime request header$/); + expect(fetched).toBe(false); +}); + +test("CUSTOM_JWT non-2xx cancels without reading and exposes status only", async () => { + const { logger, logs } = captureLogs(); + let cancelled = 0; + let read = false; + const response = { + ok: false, + status: 401, + headers: new Headers(), + body: { + cancel: async () => { + cancelled++; + }, + async *[Symbol.asyncIterator]() { + read = true; + yield new TextEncoder().encode("secret response body"); + }, + }, + } as unknown as Response; + const core = customJwtCore(async () => response, { logger }); + const request: RuntimeInvokeRequest = { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new TextEncoder().encode("secret payload"), + contentType: "application/json", + bearerToken: "secret-token", + applicationHeaders: [["X-Secret", "secret-header-value"]], + }; + + await expect(core.runtime.invokeRuntime(request, { region: "us-east-1" })).rejects.toThrow( + /^HTTP 401$/, + ); + expect(cancelled).toBe(1); + expect(read).toBe(false); + expectSafeDebugLog( + logs, + "Runtime invocation returned a non-success response", + { + authMode: "CUSTOM_JWT", + runtimeId: "runtime-123", + qualifier: "DEFAULT", + region: "us-east-1", + httpStatusCode: 401, + }, + ["secret response body", "secret payload", "secret-token", "secret-header-value"], + ); +}); + +test("CUSTOM_JWT transport failures do not expose arbitrary causes", async () => { + const { logger, logs } = captureLogs(); + const core = customJwtCore( + async () => { + const error = new Error("failed with Bearer secret-token"); + error.name = "Bearer secret-token"; + throw error; + }, + { logger }, + ); + + await expect( + core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new TextEncoder().encode("secret payload"), + contentType: "application/json", + bearerToken: "secret-token", + applicationHeaders: [["X-Secret", "secret-header-value"]], + }, + { region: "us-east-1" }, + ), + ).rejects.toThrow(/^Runtime invocation failed$/); + expectSafeDebugLog( + logs, + "Runtime invocation transport failed", + { + authMode: "CUSTOM_JWT", + runtimeId: "runtime-123", + qualifier: "DEFAULT", + region: "us-east-1", + errorName: "Error", + }, + ["failed with Bearer secret-token", "secret payload", "secret-token", "secret-header-value"], + ); +}); + +test("invokeRuntime exposes an empty async iterable when the SDK omits the body", async () => { + const core = coreWithDataSend(async () => ({ + statusCode: 204, + contentType: "application/json", + })); + + const response = await core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + }, + { region: "us-east-1" }, + ); + + const chunks: Uint8Array[] = []; + for await (const chunk of response.body) chunks.push(chunk); + expect(chunks).toEqual([]); +}); + test("invokeHarness returns the stream untouched when no abort signal is given", async () => { const stream = (async function* () {})(); - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: (config) => - ({ - config, - kind: "data", - send: async () => ({ stream }), - }) as unknown as BedrockAgentCoreClient, - createIamClient: fakeIam, - logger: createSilentLogger(), - }); + const core = coreWithDataSend(async () => ({ stream })); const response = await core.harness.invokeHarness( { harnessArn: "arn", runtimeSessionId: "s".repeat(40), messages: [] }, diff --git a/src/core/harness.tsx b/src/core/harness.tsx index 69883be40..7a5edeec9 100644 --- a/src/core/harness.tsx +++ b/src/core/harness.tsx @@ -37,6 +37,7 @@ import { } from "@aws-sdk/client-bedrock-agentcore"; import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; import type { AwsClients, CoreOptions } from "./types"; +import { abortable } from "./abortable"; import { ensureDefaultExecutionRole } from "./executionRole"; import { toClientConfig } from "./utils"; @@ -227,36 +228,3 @@ async function retryWhileRoleUnassumable( } } } - -// abortError mirrors the error the SDK rejects with on a pre-stream abort, so -// consumers see one shape either way. -function abortError(): Error { - const error = new Error("The operation was aborted"); - error.name = "AbortError"; - return error; -} - -// abortable relays `source`, rejecting the pending read as soon as `signal` -// aborts. The pre-attached catch swallows the rejection when no read is in -// flight (e.g. abort after the stream ended) to avoid unhandled-rejection -// noise. -async function* abortable(source: AsyncIterable, signal: AbortSignal): AsyncGenerator { - const aborted = new Promise((_, reject) => { - if (signal.aborted) reject(abortError()); - else signal.addEventListener("abort", () => reject(abortError()), { once: true }); - }); - aborted.catch(() => {}); - - const iterator = source[Symbol.asyncIterator](); - try { - for (;;) { - const result = await Promise.race([iterator.next(), aborted]); - if (result.done) return; - yield result.value; - } - } finally { - // Close the SDK stream on early exit to release the connection. Fire and - // forget: its settlement may itself wait on the wedged stream. - void iterator.return?.()?.catch(() => {}); - } -} diff --git a/src/core/index.tsx b/src/core/index.tsx index 702680512..b6cb02a3a 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -8,6 +8,7 @@ import { RuntimeClient } from "./runtime"; import type { AwsClients, ClientConfig, + CoreFetch, CreateControlClient, CreateDataClient, CreateIamClient, @@ -19,6 +20,7 @@ import { FsProjectManager } from "./project"; export type { AwsClients, ClientConfig, + CoreFetch, CreateControlClient, CreateDataClient, CreateIamClient, @@ -29,6 +31,7 @@ type CoreClientConfig = { createDataClient: CreateDataClient; createIamClient: CreateIamClient; logger: Logger; + fetch?: CoreFetch; }; // CoreClient is the single entry point to the Bedrock AgentCore APIs. It owns the @@ -48,7 +51,7 @@ export class CoreClient implements AwsClients { // Feature-scoped sub-clients. Access as e.g. `coreClient.harness.getHarness(...)`. readonly harness: HarnessClient = new HarnessClient(this); readonly identity: IdentityClient = new IdentityClient(this); - readonly runtime: RuntimeClient = new RuntimeClient(this); + readonly runtime: RuntimeClient; readonly eval: EvalClient = new EvalClient(this); readonly projectManager: ProjectManager; @@ -58,6 +61,11 @@ export class CoreClient implements AwsClients { this.createDataClient = config.createDataClient; this.createIamClient = config.createIamClient; this.logger = config.logger; + this.runtime = new RuntimeClient( + this, + config.fetch ?? globalThis.fetch, + this.logger.child({ module: "runtime" }), + ); this.projectManager = new FsProjectManager({ logger: this.logger.child({ module: "projectManager" }), diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index 77cc6c3e8..ea2626a1a 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -10,17 +10,197 @@ import { type ListAgentRuntimesResponse, type ListAgentRuntimeVersionsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import type { CoreRuntimeClient } from "../handlers/runtime/types"; -import type { AwsClients, CoreOptions } from "./types"; +import { InvokeAgentRuntimeCommand } from "@aws-sdk/client-bedrock-agentcore"; +import type { + CoreRuntimeClient, + RuntimeInvokeRequest, + RuntimeInvokeResponse, +} from "../handlers/runtime/types"; +import type { Logger } from "../logging"; +import type { AwsClients, CoreFetch, CoreOptions } from "./types"; +import { abortable } from "./abortable"; import { toClientConfig } from "./utils"; +async function* emptyBody(): AsyncGenerator {} + export class RuntimeClient implements CoreRuntimeClient { - constructor(private readonly clients: AwsClients) {} + constructor( + private readonly clients: AwsClients, + private readonly fetch: CoreFetch, + private readonly logger: Logger, + ) {} + + async invokeRuntime( + request: RuntimeInvokeRequest, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { + const { runtimeId, applicationHeaders, bearerToken, ...input } = request; + const logger = this.logger.child({ + operation: "invokeRuntime", + authMode: bearerToken === undefined ? "IAM" : "CUSTOM_JWT", + runtimeId, + qualifier: request.qualifier, + region: options.region, + }); + if (bearerToken !== undefined) { + const client = this.clients.data(toClientConfig(options)); + const endpoint = client.config.endpointProvider({ + Region: options.region, + Endpoint: options.endpointUrl, + }); + const url = new URL(endpoint.url); + if (url.protocol !== "https:") { + throw new TypeError("CUSTOM_JWT requires an HTTPS endpoint"); + } + url.pathname = `${url.pathname.replace(/\/?$/, "/")}runtimes/${encodeURIComponent(runtimeId)}/invocations`; + url.search = new URLSearchParams({ + accountId: request.accountId, + qualifier: request.qualifier, + }).toString(); + const headers = new Headers(applicationHeaders); + try { + headers.set("Authorization", `Bearer ${bearerToken}`); + } catch { + throw new TypeError("Invalid bearer token"); + } + try { + for (const [name, value] of [ + ["Content-Type", request.contentType], + ["Accept", request.accept], + ["Mcp-Session-Id", request.mcpSessionId], + ["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", request.runtimeSessionId], + ["Mcp-Protocol-Version", request.mcpProtocolVersion], + ["Mcp-Method", request.mcpMethod], + ["Mcp-Name", request.mcpName], + ["X-Amzn-Bedrock-AgentCore-Runtime-User-Id", request.runtimeUserId], + ["X-Amzn-Trace-Id", request.traceId], + ["traceparent", request.traceParent], + ["tracestate", request.traceState], + ["baggage", request.baggage], + ] as const) { + if (value !== undefined) headers.set(name, value); + } + } catch { + throw new TypeError("Invalid Runtime request header"); + } + let response: Response; + try { + response = await this.fetch(url, { + method: "POST", + redirect: "error", + headers, + body: request.payload as RequestInit["body"], + signal, + }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + logger + .child({ + errorName: + error instanceof TypeError + ? "TypeError" + : error instanceof Error + ? "Error" + : typeof error, + }) + .debug("Runtime invocation transport failed"); + throw new Error("Runtime invocation failed"); + } + if (!response.ok) { + logger + .child({ httpStatusCode: response.status }) + .debug("Runtime invocation returned a non-success response"); + await response.body?.cancel().catch(() => undefined); + throw new Error(`HTTP ${response.status}`); + } + const body = (response.body as AsyncIterable | null) ?? emptyBody(); + return { + statusCode: response.status, + contentType: response.headers.get("content-type") ?? "", + runtimeSessionId: + response.headers.get("x-amzn-bedrock-agentcore-runtime-session-id") ?? undefined, + mcpSessionId: response.headers.get("mcp-session-id") ?? undefined, + mcpProtocolVersion: response.headers.get("mcp-protocol-version") ?? undefined, + traceId: response.headers.get("x-amzn-trace-id") ?? undefined, + traceParent: response.headers.get("traceparent") ?? undefined, + traceState: response.headers.get("tracestate") ?? undefined, + baggage: response.headers.get("baggage") ?? undefined, + body: signal ? abortable(body, signal) : body, + }; + } - async getRuntime(id: string, options: CoreOptions): Promise { + const command = new InvokeAgentRuntimeCommand({ ...input, agentRuntimeArn: runtimeId }); + if (applicationHeaders?.length) { + command.middlewareStack.add( + (next) => async (args) => { + const sdkRequest = args.request as { headers: Record }; + for (const [name, value] of applicationHeaders) sdkRequest.headers[name] = value; + return next(args); + }, + { step: "build", name: "runtimeApplicationHeaders" }, + ); + } + let response; + try { + response = await this.clients.data(toClientConfig(options)).send(command, { + abortSignal: signal, + }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + const sdkError = error as { + name?: unknown; + $metadata?: { httpStatusCode?: unknown; requestId?: unknown }; + }; + const diagnostics: string[] = []; + if (typeof sdkError?.name === "string" && sdkError.name !== "Error") { + diagnostics.push(sdkError.name); + } + if (typeof sdkError?.$metadata?.httpStatusCode === "number") { + diagnostics.push(`HTTP ${sdkError.$metadata.httpStatusCode}`); + } + if (typeof sdkError?.$metadata?.requestId === "string") { + diagnostics.push(`request ID ${sdkError.$metadata.requestId}`); + } + logger + .child({ + ...(typeof sdkError?.name === "string" && { errorName: sdkError.name }), + ...(typeof sdkError?.$metadata?.httpStatusCode === "number" && { + httpStatusCode: sdkError.$metadata.httpStatusCode, + }), + ...(typeof sdkError?.$metadata?.requestId === "string" && { + requestId: sdkError.$metadata.requestId, + }), + }) + .debug("Runtime invocation SDK request failed"); + throw new Error( + `Runtime invocation failed${diagnostics.length ? ` (${diagnostics.join(", ")})` : ""}`, + ); + } + + const body = (response.response as AsyncIterable | undefined) ?? emptyBody(); + return { + statusCode: response.statusCode ?? 0, + contentType: response.contentType ?? "", + runtimeSessionId: response.runtimeSessionId, + mcpSessionId: response.mcpSessionId, + mcpProtocolVersion: response.mcpProtocolVersion, + traceId: response.traceId, + traceParent: response.traceParent, + traceState: response.traceState, + baggage: response.baggage, + body: signal ? abortable(body, signal) : body, + }; + } + + async getRuntime( + id: string, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { return this.clients .control(toClientConfig(options)) - .send(new GetAgentRuntimeCommand({ agentRuntimeId: id })); + .send(new GetAgentRuntimeCommand({ agentRuntimeId: id }), { abortSignal: signal }); } async getRuntimeVersion( diff --git a/src/core/types.tsx b/src/core/types.tsx index 517a0632c..41eaf2c4b 100644 --- a/src/core/types.tsx +++ b/src/core/types.tsx @@ -25,6 +25,9 @@ export interface ClientConfig { export type CreateControlClient = (config: ClientConfig) => BedrockAgentCoreControlClient; export type CreateDataClient = (config: ClientConfig) => BedrockAgentCoreClient; export type CreateIamClient = (config: ClientConfig) => IAMClient; +export type CoreFetch = ( + ...args: Parameters +) => ReturnType; // AwsClients hands out configured SDK clients. CoreClient implements it and its // sub-clients (HarnessClient, etc.) consume it, so they all share the same diff --git a/src/handlers/runtime/types.tsx b/src/handlers/runtime/types.tsx index e2f219f1b..a7ff1c38a 100644 --- a/src/handlers/runtime/types.tsx +++ b/src/handlers/runtime/types.tsx @@ -7,8 +7,51 @@ import type { } from "@aws-sdk/client-bedrock-agentcore-control"; import type { CoreOptions } from "../../core/types"; +export type RuntimeInvokeRequest = { + runtimeId: string; + accountId: string; + qualifier: string; + payload: Uint8Array; + contentType: string; + accept?: string; + runtimeSessionId?: string; + runtimeUserId?: string; + applicationHeaders?: [string, string][]; + bearerToken?: string; + mcpSessionId?: string; + mcpProtocolVersion?: string; + mcpMethod?: string; + mcpName?: string; + traceId?: string; + traceParent?: string; + traceState?: string; + baggage?: string; +}; + +export type RuntimeInvokeResponse = { + statusCode: number; + contentType: string; + runtimeSessionId?: string; + mcpSessionId?: string; + mcpProtocolVersion?: string; + traceId?: string; + traceParent?: string; + traceState?: string; + baggage?: string; + body: AsyncIterable; +}; + export interface CoreRuntimeClient { - getRuntime(id: string, options: CoreOptions): Promise; + invokeRuntime( + request: RuntimeInvokeRequest, + options: CoreOptions, + signal?: AbortSignal, + ): Promise; + getRuntime( + id: string, + options: CoreOptions, + signal?: AbortSignal, + ): Promise; getRuntimeVersion( id: string, version: string, diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 915ade506..00d3edf95 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -47,8 +47,13 @@ import type { CreateApiKeyCredentialProviderInput, UpdateApiKeyCredentialProviderInput, } from "../handlers/identity/types"; -import type { CoreRuntimeClient } from "../handlers/runtime/types"; +import type { + CoreRuntimeClient, + RuntimeInvokeRequest, + RuntimeInvokeResponse, +} from "../handlers/runtime/types"; import type { CodeBasedUpdate, CoreEvalClient, LlmAsAJudgeUpdate } from "../handlers/eval/types"; +import { abortable } from "../core/abortable"; import type { CoreOptions } from "../core/types"; import type { ProjectManager } from "../handlers/project/types"; import type { Logger } from "../logging"; @@ -113,42 +118,17 @@ const DEFAULT_GET_EVALUATOR_RESPONSE = {} as GetEvaluatorResponse; const DEFAULT_LIST_EVALUATORS_RESPONSE: ListEvaluatorsResponse = { evaluators: [] }; const DEFAULT_DELETE_EVALUATOR_RESPONSE = {} as DeleteEvaluatorResponse; -// abortError mirrors the error the SDK's abort handling rejects with. -function abortError(): Error { - const error = new Error("The operation was aborted"); - error.name = "AbortError"; - return error; -} - -// abortable wraps a stream so iteration rejects with an AbortError as soon as -// `signal` aborts, mirroring how the SDK cuts an event stream off mid-read. -// Each next() races against the abortion; the pre-attached catch swallows the -// rejection when nothing is racing (e.g. abort after the stream already ended) -// so tests don't fail on unhandled-rejection noise. -async function* abortable(source: AsyncIterable, signal?: AbortSignal): AsyncGenerator { - if (!signal) { - yield* source; - return; - } - const aborted = new Promise((_, reject) => { - if (signal.aborted) reject(abortError()); - else signal.addEventListener("abort", () => reject(abortError()), { once: true }); - }); - aborted.catch(() => {}); - - const iterator = source[Symbol.asyncIterator](); - for (;;) { - const result = await Promise.race([iterator.next(), aborted]); - if (result.done) return; - yield result.value; - } -} - // events wraps canned events as a one-shot AsyncIterable. async function* events(items: T[]): AsyncGenerator { for (const item of items) yield item; } +const DEFAULT_RUNTIME_INVOKE_RESPONSE: RuntimeInvokeResponse = { + statusCode: 200, + contentType: "application/json", + body: events([]), +}; + // TestHarnessClient is the harness sub-client of TestCoreClient. export class TestHarnessClient implements CoreHarnessClient { // calls records every invocation in order, for assertions. @@ -429,7 +409,7 @@ export class TestHarnessClient implements CoreHarnessClient { this.calls.push({ method: "invokeHarness", args: [request, options, abortSignal] }); if (this.error) throw this.error; const stream = this.invokeStreams.shift() ?? events(this.invokeEvents); - return { stream: abortable(stream, abortSignal) }; + return { stream: abortSignal ? abortable(stream, abortSignal) : stream }; } async invokeAgentRuntimeCommand( @@ -447,7 +427,7 @@ export class TestHarnessClient implements CoreHarnessClient { contentType: "application/json", statusCode: 200, runtimeSessionId: request.runtimeSessionId, - stream: abortable(stream, abortSignal), + stream: abortSignal ? abortable(stream, abortSignal) : stream, }; } } @@ -462,6 +442,8 @@ export class TestRuntimeClient implements CoreRuntimeClient { private listResponses = new Map(); private listVersionResponses = new Map(); private listEndpointResponses = new Map(); + private invokeResponse: RuntimeInvokeResponse = DEFAULT_RUNTIME_INVOKE_RESPONSE; + private invokeBodies: AsyncIterable[] = []; private error?: Error; setGetResponse(response: GetAgentRuntimeResponse): this { @@ -497,17 +479,44 @@ export class TestRuntimeClient implements CoreRuntimeClient { return this; } + setInvokeResponse(response: RuntimeInvokeResponse): this { + this.invokeResponse = response; + return this; + } + + queueInvokeBody(body: AsyncIterable): this { + this.invokeBodies.push(body); + return this; + } + setError(error: Error | undefined): this { this.error = error; return this; } - async getRuntime(id: string, options: CoreOptions): Promise { - this.calls.push({ method: "getRuntime", args: [id, options] }); + async getRuntime( + id: string, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { + this.calls.push({ method: "getRuntime", args: [id, options, ...(signal ? [signal] : [])] }); if (this.error) throw this.error; return this.getResponse; } + async invokeRuntime( + request: RuntimeInvokeRequest, + options: CoreOptions, + signal?: AbortSignal, + ): Promise { + this.calls.push({ method: "invokeRuntime", args: [request, options, signal] }); + if (this.error) throw this.error; + return { + ...this.invokeResponse, + body: this.invokeBodies.shift() ?? this.invokeResponse.body, + }; + } + async getRuntimeVersion( id: string, version: string, From 4a43475f32a605290001f74e941462e5121937f8 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 27 Jul 2026 18:21:13 +0000 Subject: [PATCH 02/10] feat(runtime): add headless invoke command --- src/handlers/runtime/index.tsx | 2 + src/handlers/runtime/invoke/index.tsx | 99 +++++ src/handlers/runtime/invoke/invoke.test.tsx | 417 +++++++++++++++++++ src/handlers/runtime/invoke/request.test.ts | 256 ++++++++++++ src/handlers/runtime/invoke/request.ts | 156 +++++++ src/handlers/runtime/invoke/response.test.ts | 277 ++++++++++++ src/handlers/runtime/invoke/response.ts | 173 ++++++++ src/handlers/runtime/runtime.test.tsx | 3 +- src/io/source.test.ts | 16 + src/io/source.ts | 5 +- src/router/router.test.ts | 14 +- src/router/router.tsx | 2 +- src/runnable/index.test.ts | 81 +++- src/runnable/index.tsx | 30 +- 14 files changed, 1506 insertions(+), 25 deletions(-) create mode 100644 src/handlers/runtime/invoke/index.tsx create mode 100644 src/handlers/runtime/invoke/invoke.test.tsx create mode 100644 src/handlers/runtime/invoke/request.test.ts create mode 100644 src/handlers/runtime/invoke/request.ts create mode 100644 src/handlers/runtime/invoke/response.test.ts create mode 100644 src/handlers/runtime/invoke/response.ts diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index d2e8b2885..edcc9e4a7 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -5,6 +5,7 @@ import type { AppIO } from "../../io"; import type { Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; +import { createInvokeRuntimeHandler } from "./invoke"; import { createListRuntimesHandler } from "./list"; import { createRuntimeVersionHandler } from "./version"; @@ -14,6 +15,7 @@ export function createRuntimeHandler(core: Core, io: AppIO): Router { .default(renderTui(core, io)) .handler(createGetRuntimeHandler(core)) .handler(createListRuntimesHandler(core)) + .handler(createInvokeRuntimeHandler(core, io)) .handler(createRuntimeVersionHandler(core, io)) .handler(createRuntimeEndpointHandler(core, io)); } diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx new file mode 100644 index 000000000..51e380cd7 --- /dev/null +++ b/src/handlers/runtime/invoke/index.tsx @@ -0,0 +1,99 @@ +import z from "zod"; +import { createHandler, flag } from "../../../router"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { JsonKey } from "../../keys"; +import { + normalizeRuntimeInvokeRequest, + parseRuntimeInvokeHeaders, + resolveRuntimeInvokeSources, + runtimeIdSchema, + UsageError, +} from "./request"; +import { writeRuntimeInvokeResponse } from "./response"; + +export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => + createHandler({ + name: "invoke", + description: "invoke a Runtime", + flags: [ + flag("id", "the ID of the Runtime", runtimeIdSchema), + flag("payload", "the inline payload to send", z.string(), { + sensitive: true, + }), + flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), + flag("content-type", "the payload content type", z.string().optional()), + flag("accept", "the accepted response content type", z.string().optional()), + flag("session-id", "the Runtime session ID", z.string().optional()), + flag("user-id", "the Runtime user ID", z.string().optional()), + flag("header", "an ordered application header", z.array(z.string()).optional(), { + sensitive: true, + }), + flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { + sensitive: true, + }), + flag("mcp-session-id", "the MCP session ID", z.string().optional()), + flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), + flag("mcp-method", "the MCP method", z.string().optional()), + flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional()), + flag("trace-id", "the X-Ray trace ID", z.string().optional()), + flag("trace-parent", "the W3C trace parent", z.string().optional()), + flag("trace-state", "the W3C trace state", z.string().optional()), + flag("baggage", "the W3C baggage", z.string().optional()), + flag( + "output-file", + "the response output file", + z.string().min(1, "requires a nonempty path").optional(), + ), + ], + handle: async (ctx, flags) => { + const jsonOutput = ctx.require(JsonKey); + if (jsonOutput && flags["output-file"] !== undefined) { + throw new UsageError("--json cannot be used with --output-file"); + } + const controller = new AbortController(); + const interrupt = () => controller.abort(); + process.once("SIGINT", interrupt); + try { + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const sources = await resolveRuntimeInvokeSources( + { payload: flags.payload, bearerToken: flags["bearer-token"] }, + io.stdin, + controller.signal, + ); + const options = coreOptsFromCtx(ctx); + const runtime = await core.runtime.getRuntime(flags.id, options, controller.signal); + const request = normalizeRuntimeInvokeRequest(runtime, { + runtimeId: flags.id, + qualifier: flags.qualifier, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken: sources.bearerToken, + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + mcpMethod: flags["mcp-method"], + mcpName: flags["mcp-name"], + traceId: flags["trace-id"], + traceParent: flags["trace-parent"], + traceState: flags["trace-state"], + baggage: flags.baggage, + }); + const response = await core.runtime.invokeRuntime(request, options, controller.signal); + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal: controller.signal, + }); + } finally { + controller.abort(); + process.off("SIGINT", interrupt); + } + }, + }); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx new file mode 100644 index 000000000..a94891e8c --- /dev/null +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -0,0 +1,417 @@ +import { describe, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, Writable } from "node:stream"; +import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import type { AppIO } from "../../../io"; +import type { RuntimeInvokeRequest } from "../types"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + waitFor, +} from "../../../testing"; +import { ExitCode, runWithExitCode } from "../../../runnable"; +import { createRootHandler } from "../../index"; + +const REGION = "us-west-2"; +const RUNTIME_ID = "runtime-123"; +const ACCOUNT_ID = "123456789012"; +const RUNTIME_ARN = `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:runtime/${RUNTIME_ID}`; + +function body(...chunks: Uint8Array[]): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} + +function captureIO(input?: Uint8Array): { io: AppIO; bytes: () => Buffer } { + const stdin = new PassThrough() as unknown as NodeJS.ReadStream; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const chunks: Buffer[] = []; + stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + if (input) stdin.end(input); + return { + io: { + stdin, + stdout: stdout as unknown as NodeJS.WriteStream, + stderr: stderr as unknown as NodeJS.WriteStream, + }, + bytes: () => Buffer.concat(chunks), + }; +} + +function failingStdoutIO(): AppIO { + const { io } = captureIO(); + return { + ...io, + stdout: new Writable({ + write(_chunk, _encoding, callback) { + callback(new TypeError("stdout transport failed")); + }, + }) as unknown as NodeJS.WriteStream, + }; +} + +async function runCommand(core: TestCoreClient, io: AppIO, args: string[]): Promise { + const root = createRootHandler(core, { + io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", ...args, "--region", REGION]); +} + +async function run( + args: string[], + { getResponse = { agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse } = {}, +) { + const core = new TestCoreClient(); + core.runtime.setGetResponse(getResponse); + core.runtime.setInvokeResponse({ + statusCode: 200, + contentType: "application/json", + body: body(Uint8Array.from([0, 255]), Uint8Array.from([10, 1])), + }); + const output = captureIO(); + + await runCommand(core, output.io, args); + return { core, output }; +} + +describe("runtime invoke", () => { + test("resolves the Runtime, invokes its ID in the current account, and streams exact bytes", async () => { + const { core, output } = await run([ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + '{"prompt":"hello"}', + ]); + + expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime", "invokeRuntime"]); + const lookup = core.runtime.calls[0]!; + expect(lookup.args.slice(0, 2)).toEqual([RUNTIME_ID, { region: REGION }]); + + const invoke = core.runtime.calls[1]!; + const request = invoke.args[0] as RuntimeInvokeRequest; + expect(request).toEqual({ + runtimeId: RUNTIME_ID, + accountId: ACCOUNT_ID, + qualifier: "DEFAULT", + payload: new TextEncoder().encode('{"prompt":"hello"}'), + contentType: "application/json", + }); + expect(invoke.args[1]).toEqual({ region: REGION }); + expect(lookup.args[2]).toBe(invoke.args[2]); + expect(output.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); + }); + + test("passes the public request flags through the shared normalizer", async () => { + const { core } = await run( + [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + "--qualifier", + "prod", + "--content-type", + "text/plain", + "--session-id", + "runtime-session", + "--header", + "X-Tenant: retail", + "--mcp-method", + "tools/call", + "--bearer-token", + "secret-token", + ], + { + getResponse: { + agentRuntimeArn: RUNTIME_ARN, + authorizerConfiguration: { customJWTAuthorizer: {} }, + protocolConfiguration: { serverProtocol: "MCP" }, + requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, + } as GetAgentRuntimeResponse, + }, + ); + + const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! + .args[0] as RuntimeInvokeRequest; + expect(request).toMatchObject({ + qualifier: "prod", + contentType: "text/plain", + runtimeSessionId: "runtime-session", + applicationHeaders: [["X-Tenant", "retail"]], + mcpMethod: "tools/call", + bearerToken: "secret-token", + }); + }); + + test.each<[string, string[]]>([ + ["no request options", []], + ["--content-type", ["--content-type", "text/plain"]], + ["--header", ["--header", "X-Test: value"]], + ["--output-file", ["--output-file", "response.bin"]], + ["--json", ["--json"]], + ])("requires a payload with %s before Core calls", async (_name, extraArgs) => { + const core = new TestCoreClient(); + const output = captureIO(); + await expect( + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, ...extraArgs]), + ).rejects.toThrow(/--payload/); + expect(core.runtime.calls).toEqual([]); + }); + + test("rejects an empty --output-file before Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + await expect( + runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + "--output-file", + "", + ]), + ).rejects.toThrow("requires a nonempty path"); + expect(core.runtime.calls).toEqual([]); + }); + + test("rejects --json with --output-file before Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + await expect( + runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + "--json", + "--output-file", + "response.bin", + ]), + ).rejects.toThrow("--json cannot be used with --output-file"); + expect(core.runtime.calls).toEqual([]); + }); + + test("passes an explicitly empty payload as zero bytes", async () => { + const { core } = await run(["runtime", "invoke", "--id", RUNTIME_ID, "--payload", ""]); + + const invoke = core.runtime.calls.find((call) => call.method === "invokeRuntime")!; + expect((invoke.args[0] as RuntimeInvokeRequest).payload).toEqual(new Uint8Array()); + }); + + test("SIGINT aborts an active headless invocation after preserving emitted bytes", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + core.runtime.invokeRuntime = async (request, options, signal) => { + core.runtime.calls.push({ method: "invokeRuntime", args: [request, options, signal] }); + return { + statusCode: 200, + contentType: "text/plain", + body: (async function* () { + yield Buffer.from("partial"); + await new Promise((_, reject) => { + const abort = () => + reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + })(), + }; + }; + const pending = runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + ]); + + try { + await waitFor(() => output.bytes().toString() === "partial"); + const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")!.args[2] as + AbortSignal | undefined; + expect(signal).toBeDefined(); + + process.emit("SIGINT", "SIGINT"); + + expect(signal!.aborted).toBe(true); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(output.bytes().toString()).toBe("partial"); + } finally { + await pending.catch(() => undefined); + } + }); + + test("classifies an invalid Runtime ARN as usage before Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + + const code = await runWithExitCode(async () => + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ARN, "--payload", "{}"]), + ); + + expect(code).toBe(ExitCode.USAGE); + expect(core.runtime.calls).toEqual([]); + }); + + test("classifies required local validation as usage before Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + + const code = await runWithExitCode(async () => + runCommand(core, output.io, ["runtime", "invoke", "--payload", "{}"]), + ); + + expect(code).toBe(ExitCode.USAGE); + expect(core.runtime.calls).toEqual([]); + }); + + test("aborts an established request when a TTY refuses binary output", async () => { + let iterations = 0; + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "application/octet-stream", + body: (async function* () { + iterations++; + yield Buffer.from([0, 255]); + })(), + }); + const output = captureIO(); + Object.defineProperty(output.io.stdout, "isTTY", { value: true }); + + await expect( + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]), + ).rejects.toThrow("Binary or unknown response content requires --output-file or --json"); + + const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")! + .args[2] as AbortSignal; + expect(signal.aborted).toBe(true); + expect(iterations).toBe(0); + }); + + test("rejects a resolved Runtime ARN without an account ID before invoke", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ + agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}::runtime/${RUNTIME_ID}`, + } as GetAgentRuntimeResponse); + const output = captureIO(); + + await expect( + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]), + ).rejects.toThrow("Runtime returned an invalid ARN"); + expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime"]); + }); + + test.each<[string, ...string[]]>([ + ["malformed", "missing separator"], + ["duplicate", "X-Test: one", "x-test: two"], + ["reserved", "Authorization: secret"], + ])("rejects %s headers as usage before Runtime Core calls", async (_name, ...headers) => { + const core = new TestCoreClient(); + const output = captureIO(); + const args = ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]; + for (const header of headers) args.push("--header", header); + + const code = await runWithExitCode(async () => runCommand(core, output.io, args)); + + expect(code).toBe(ExitCode.USAGE); + expect(core.runtime.calls).toEqual([]); + }); + + test("reports an unreadable payload file as local usage before Runtime Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const missing = join(tmpdir(), `missing-runtime-payload-${process.pid}`); + + await expect( + runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + `file://${missing}`, + ]), + ).rejects.toMatchObject({ + name: "TypeError", + message: `could not read '--payload' from file '${missing}'`, + exitCode: ExitCode.USAGE, + }); + expect(core.runtime.calls).toEqual([]); + }); + + test("keeps Core, transport, and output TypeErrors as failures", async () => { + const lookupCore = new TestCoreClient(); + lookupCore.runtime.setError(new TypeError("lookup failed")); + const transportCore = new TestCoreClient(); + transportCore.runtime.setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + } as GetAgentRuntimeResponse); + transportCore.runtime.invokeRuntime = async () => { + throw new TypeError("transport failed"); + }; + const outputCore = new TestCoreClient(); + outputCore.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: body(Buffer.from("response")), + }); + + const lookupCode = await runWithExitCode(async () => + runCommand(lookupCore, captureIO().io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + ]), + ); + const transportCode = await runWithExitCode(async () => + runCommand(transportCore, captureIO().io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + ]), + ); + const outputCode = await runWithExitCode(async () => + runCommand(outputCore, failingStdoutIO(), [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + ]), + ); + + expect([lookupCode, transportCode, outputCode]).toEqual([ + ExitCode.FAILURE, + ExitCode.FAILURE, + ExitCode.FAILURE, + ]); + }); +}); diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts new file mode 100644 index 000000000..296b91d5c --- /dev/null +++ b/src/handlers/runtime/invoke/request.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from "bun:test"; +import { Readable } from "node:stream"; +import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { SourceResolutionError } from "../../../io"; +import { + normalizeRuntimeInvokeRequest, + parseRuntimeInvokeHeaders, + resolveRuntimeInvokeSources, + UsageError, +} from "./request"; + +const REGION = "us-west-2"; +const RUNTIME_ID = "runtime-123"; +const ACCOUNT_ID = "123456789012"; +const ARN = `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:runtime/${RUNTIME_ID}`; + +function detail(overrides: Partial = {}): GetAgentRuntimeResponse { + return { + agentRuntimeArn: ARN, + protocolConfiguration: { serverProtocol: "HTTP" }, + ...overrides, + } as GetAgentRuntimeResponse; +} + +function stdin(bytes: Uint8Array, onRead?: () => void): NodeJS.ReadStream { + return Readable.from( + (async function* () { + onRead?.(); + yield bytes; + })(), + ) as NodeJS.ReadStream; +} + +describe("resolveRuntimeInvokeSources", () => { + test("resolves payload bytes and bearer-token text through one resolver", async () => { + const bytes = Uint8Array.from([9, 0, 254]); + const result = await resolveRuntimeInvokeSources( + { payload: "-", bearerToken: "secret-€" }, + stdin(bytes), + ); + + expect(result.payload).toEqual(bytes); + expect(result.bearerToken).toBe("secret-€"); + }); + + test("rejects dual stdin before reading", async () => { + let reads = 0; + await expect( + resolveRuntimeInvokeSources( + { payload: "-", bearerToken: "-" }, + stdin(new Uint8Array(), () => reads++), + ), + ).rejects.toThrow("Payload and bearer token cannot both read from stdin"); + expect(reads).toBe(0); + }); + + test("classifies source-resolution failures as usage", async () => { + const error = await resolveRuntimeInvokeSources({ payload: "-" }).catch((error) => error); + + expect(error).toBeInstanceOf(UsageError); + expect(error).toMatchObject({ + message: "stdin is not available for '--payload'", + exitCode: 2, + }); + expect(error.cause).toBeInstanceOf(SourceResolutionError); + }); + + test("rejects a bearer token that is not valid UTF-8", async () => { + const error = await resolveRuntimeInvokeSources( + { payload: "{}", bearerToken: "-" }, + stdin(Uint8Array.from([0xff, 0x61])), + ).catch((error) => error); + + expect(error).toBeInstanceOf(UsageError); + expect(error.message).toBe("'--bearer-token' must contain valid UTF-8"); + expect(error.cause).toBeInstanceOf(SourceResolutionError); + }); + + test("preserves abort errors", async () => { + const input = new Readable({ read() {} }) as NodeJS.ReadStream; + const controller = new AbortController(); + const resolving = resolveRuntimeInvokeSources({ payload: "-" }, input, controller.signal); + controller.abort(); + + try { + await expect( + Promise.race([ + resolving, + Bun.sleep(100).then(() => { + throw new Error("stdin read did not abort"); + }), + ]), + ).rejects.toMatchObject({ name: "AbortError" }); + } finally { + input.destroy(); + await resolving.catch(() => {}); + } + }); +}); + +describe("parseRuntimeInvokeHeaders", () => { + test("brands validation failures as usage errors", () => { + expect(() => parseRuntimeInvokeHeaders(["missing separator"])).toThrow(UsageError); + }); + + test.each([ + [ + "name", + "Bad Name: secret-name-value", + "Invalid HTTP header name: Bad Name (must use valid HTTP token characters)", + "secret-name-value", + ], + [ + "value", + "X-Test: secret-value ☃", + "Invalid header value for X-Test: contains a character not allowed in HTTP headers", + "secret-value ☃", + ], + ])( + "explains an invalid header %s without exposing its value", + (_case, header, message, secret) => { + const error = (() => { + try { + parseRuntimeInvokeHeaders([header]); + } catch (caught) { + return caught; + } + })(); + + expect(error).toBeInstanceOf(UsageError); + expect((error as Error).message).toBe(message); + expect((error as Error).message).not.toContain(secret); + }, + ); + + test("does not impose client-only count or value-size limits", () => { + const largeValue = "x".repeat(4097); + const headers = [ + ...Array.from({ length: 20 }, (_, index) => `X-Test-${index}: value`), + `X-Large: ${largeValue}`, + ]; + + const parsed = parseRuntimeInvokeHeaders(headers); + + expect(parsed).toHaveLength(21); + expect(parsed.at(-1)).toEqual(["X-Large", largeValue]); + }); +}); + +describe("normalizeRuntimeInvokeRequest", () => { + test.each([ + [ + "CUSTOM_JWT without a token", + detail({ authorizerConfiguration: { customJWTAuthorizer: {} } as never }), + {}, + "requires --bearer-token", + ], + ["IAM with a token", detail(), { bearerToken: "secret" }, "does not accept --bearer-token"], + [ + "an unsupported authorizer", + detail({ authorizerConfiguration: { $unknown: ["future", {}] } as never }), + {}, + "unsupported authorizer", + ], + ["MCP options on HTTP", detail(), { mcpMethod: "tools/list" }, "only valid for MCP"], + ])("rejects %s", (_name, runtime, overrides, message) => { + expect(() => + normalizeRuntimeInvokeRequest(runtime, { + runtimeId: RUNTIME_ID, + payload: new Uint8Array(), + ...overrides, + }), + ).toThrow(message); + }); + + test("rejects headers outside the Runtime allowlist", () => { + expect(() => + normalizeRuntimeInvokeRequest( + detail({ requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Test"] } }), + { + runtimeId: RUNTIME_ID, + payload: new Uint8Array(), + applicationHeaders: [["X-Not-Allowed", "value"]], + }, + ), + ).toThrow("not allowed"); + }); + + test("defaults MCP requests to the required JSON and SSE response types", () => { + const request = normalizeRuntimeInvokeRequest( + detail({ protocolConfiguration: { serverProtocol: "MCP" } }), + { + runtimeId: RUNTIME_ID, + payload: new Uint8Array(), + }, + ); + + expect(request.accept).toBe("application/json, text/event-stream"); + }); + + test("maps every request field and ordered allowed headers once", () => { + const request = normalizeRuntimeInvokeRequest( + detail({ + authorizerConfiguration: { customJWTAuthorizer: {} } as never, + protocolConfiguration: { serverProtocol: "MCP" }, + requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, + }), + { + runtimeId: RUNTIME_ID, + qualifier: "prod", + payload: Uint8Array.from([1, 2, 3]), + contentType: "application/octet-stream", + accept: "text/event-stream", + runtimeSessionId: "runtime-session", + runtimeUserId: "runtime-user", + applicationHeaders: parseRuntimeInvokeHeaders([ + "X-Tenant: retail", + "x-amzn-bedrock-agentcore-runtime-custom-mode: fast", + ]), + bearerToken: "secret-token", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + mcpMethod: "tools/call", + mcpName: "weather", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "tenant=retail", + }, + ); + + expect(request).toEqual({ + runtimeId: RUNTIME_ID, + accountId: ACCOUNT_ID, + qualifier: "prod", + payload: Uint8Array.from([1, 2, 3]), + contentType: "application/octet-stream", + accept: "text/event-stream", + runtimeSessionId: "runtime-session", + runtimeUserId: "runtime-user", + applicationHeaders: [ + ["X-Tenant", "retail"], + ["x-amzn-bedrock-agentcore-runtime-custom-mode", "fast"], + ], + bearerToken: "secret-token", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + mcpMethod: "tools/call", + mcpName: "weather", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "tenant=retail", + }); + }); +}); diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts new file mode 100644 index 000000000..2e32b35d3 --- /dev/null +++ b/src/handlers/runtime/invoke/request.ts @@ -0,0 +1,156 @@ +import { validateHeaderName, validateHeaderValue } from "node:http"; +import z from "zod"; +import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { SourceResolutionError, SourceResolver } from "../../../io"; +import type { RuntimeInvokeRequest } from "../types"; + +export const runtimeIdSchema = z + .string() + .refine((value) => !value.startsWith("arn:"), "must be a Runtime ID, not an ARN"); + +type RuntimeInvokeInput = Omit & + Partial>; + +const CUSTOM_HEADER_PREFIX = "x-amzn-bedrock-agentcore-runtime-custom-"; +const RESERVED_HEADERS = new Set([ + "authorization", + "accept", + "content-length", + "content-type", + "host", + "mcp-method", + "mcp-name", + "mcp-protocol-version", + "mcp-session-id", + "x-amzn-bedrock-agentcore-runtime-session-id", + "x-amzn-bedrock-agentcore-runtime-user-id", + "x-amzn-trace-id", + "traceparent", + "tracestate", + "baggage", +]); + +export class UsageError extends TypeError { + readonly exitCode = 2; +} + +export async function resolveRuntimeInvokeSources( + sources: { payload: string; bearerToken?: string }, + stdin?: NodeJS.ReadStream, + signal?: AbortSignal, +): Promise<{ payload: Uint8Array; bearerToken?: string }> { + if (sources.payload === "-" && sources.bearerToken === "-") { + throw new UsageError("Payload and bearer token cannot both read from stdin"); + } + + const resolver = new SourceResolver({ stdin, signal }); + try { + const payload = await resolver.resolveBytes("payload", sources.payload); + const bearerToken = await resolver.resolveText("bearer-token", sources.bearerToken); + return { + payload: payload!, + ...(bearerToken !== undefined && { bearerToken }), + }; + } catch (error) { + if (error instanceof SourceResolutionError) { + throw new UsageError(error.message, { cause: error }); + } + throw error; + } +} + +export function parseRuntimeInvokeHeaders(values: string[] = []): [string, string][] { + const seen = new Set(); + + return values.map((header) => { + const separator = header.indexOf(":"); + if (separator < 1) throw new UsageError("Header must use 'Name: value' format"); + const name = header.slice(0, separator).trim(); + const value = header.slice(separator + 1).trim(); + try { + validateHeaderName(name); + } catch { + throw new UsageError( + `Invalid HTTP header name: ${name} (must use valid HTTP token characters)`, + ); + } + try { + validateHeaderValue(name, value); + } catch { + throw new UsageError( + `Invalid header value for ${name}: contains a character not allowed in HTTP headers`, + ); + } + const lower = name.toLowerCase(); + if (seen.has(lower)) throw new UsageError(`Duplicate header: ${name}`); + seen.add(lower); + if (RESERVED_HEADERS.has(lower)) + throw new UsageError(`Application header is reserved: ${name}`); + return [name, value]; + }); +} + +function validateAllowedHeaders( + detail: GetAgentRuntimeResponse, + headers: [string, string][], +): void { + const allowlist = + detail.requestHeaderConfiguration && + "requestHeaderAllowlist" in detail.requestHeaderConfiguration + ? (detail.requestHeaderConfiguration.requestHeaderAllowlist ?? []).map((name) => + name.toLowerCase(), + ) + : []; + for (const [name] of headers) { + const lower = name.toLowerCase(); + if (!lower.startsWith(CUSTOM_HEADER_PREFIX) && !allowlist.includes(lower)) { + throw new UsageError(`Application header is not allowed: ${name}`); + } + } +} + +export function normalizeRuntimeInvokeRequest( + detail: GetAgentRuntimeResponse, + input: RuntimeInvokeInput, +): RuntimeInvokeRequest { + const accountId = detail.agentRuntimeArn?.match( + /^arn:[^:]+:bedrock-agentcore:[^:]*:(\d{12}):runtime\//, + )?.[1]; + if (!accountId) { + throw new UsageError("Runtime returned an invalid ARN"); + } + + const authorizer = detail.authorizerConfiguration; + const customJwt = authorizer !== undefined && "customJWTAuthorizer" in authorizer; + if (authorizer && !customJwt) throw new UsageError("Runtime uses an unsupported authorizer"); + const { runtimeId, qualifier, payload, contentType, applicationHeaders = [], ...modeled } = input; + if (customJwt && !modeled.bearerToken) { + throw new UsageError("CUSTOM_JWT Runtime requires --bearer-token"); + } + if (!customJwt && modeled.bearerToken !== undefined) { + throw new UsageError("IAM Runtime does not accept --bearer-token"); + } + + const mcp = detail.protocolConfiguration?.serverProtocol === "MCP"; + const mcpValues = [ + modeled.mcpSessionId, + modeled.mcpProtocolVersion, + modeled.mcpMethod, + modeled.mcpName, + ]; + if (!mcp && mcpValues.some((value) => value !== undefined)) { + throw new UsageError("MCP options are only valid for MCP Runtimes"); + } + validateAllowedHeaders(detail, applicationHeaders); + + return { + runtimeId, + accountId, + qualifier: qualifier ?? "DEFAULT", + payload, + contentType: contentType || "application/json", + ...modeled, + accept: modeled.accept ?? (mcp ? "application/json, text/event-stream" : undefined), + ...(applicationHeaders.length > 0 && { applicationHeaders }), + }; +} diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts new file mode 100644 index 000000000..cb54d039a --- /dev/null +++ b/src/handlers/runtime/invoke/response.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, Writable } from "node:stream"; +import type { RuntimeInvokeResponse } from "../types"; +import { writeRuntimeInvokeResponse } from "./response"; + +const files: string[] = []; + +afterEach(async () => { + await Promise.all(files.splice(0).map((file) => rm(file, { force: true }))); +}); + +function body(...chunks: Uint8Array[]): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} + +function failingBody(error: Error): AsyncIterable { + return (async function* () { + yield Buffer.from("partial"); + throw error; + })(); +} + +function response(overrides: Partial = {}): RuntimeInvokeResponse { + return { + statusCode: 200, + contentType: "text/plain", + body: body(Buffer.from("ok")), + ...overrides, + }; +} + +function capture() { + const stream = new PassThrough(); + const chunks: Buffer[] = []; + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + return { + stream: stream as unknown as NodeJS.WriteStream, + bytes: () => Buffer.concat(chunks), + }; +} + +describe("Runtime invoke response output", () => { + test("streams exact chunks to stdout, reports metadata, and leaves stdout open", async () => { + const stdout = capture(); + const stderr = capture(); + const result = response({ + statusCode: 206, + runtimeSessionId: "runtime-session", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + traceId: "trace-id", + traceParent: "trace-parent", + traceState: "trace-state", + baggage: "tenant=retail", + body: body(Buffer.from([0, 255]), Buffer.from([10, 1])), + }); + + await writeRuntimeInvokeResponse(result, { + stdout: stdout.stream, + stderr: stderr.stream, + }); + + expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); + expect(stderr.bytes().toString()).toBe( + "status=206 content-type=text/plain runtime-session-id=runtime-session " + + "mcp-session-id=mcp-session mcp-protocol-version=2025-06-18 trace-id=trace-id " + + "trace-parent=trace-parent trace-state=trace-state baggage=tenant=retail " + + "complete=true bytes=4\n", + ); + expect(stdout.stream.writableEnded).toBe(false); + stdout.stream.write(Buffer.from([127])); + expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1, 127])); + }); + + test("sanitizes metadata output failures", async () => { + const stdout = capture(); + const stderr = new Writable({ + write(_chunk, _encoding, callback) { + callback(new Error("secret metadata sink failure")); + }, + }) as unknown as NodeJS.WriteStream; + + await expect( + writeRuntimeInvokeResponse(response(), { + stdout: stdout.stream, + stderr, + }), + ).rejects.toMatchObject({ + message: "response stream failed", + reported: true, + }); + }); + + test("streams exact bytes to a file and leaves stdout empty", async () => { + const file = join(tmpdir(), `runtime-invoke-output-${process.pid}-${files.length}`); + files.push(file); + const stdout = capture(); + const stderr = capture(); + const mutable = Buffer.alloc(2); + + await writeRuntimeInvokeResponse( + response({ + body: (async function* () { + mutable.set([0, 255]); + yield mutable; + mutable.set([10, 1]); + yield mutable; + })(), + }), + { + stdout: stdout.stream, + stderr: stderr.stream, + outputFile: file, + }, + ); + + expect(Buffer.from(await Bun.file(file).bytes())).toEqual(Buffer.from([0, 255, 10, 1])); + expect(stdout.bytes()).toHaveLength(0); + }); + + test("JSON mode emits one textual response envelope and no stderr", async () => { + const stdout = capture(); + const stderr = capture(); + const mutable = Buffer.alloc(16); + const result = response({ + statusCode: 201, + contentType: "application/problem+json", + runtimeSessionId: "runtime-session", + body: (async function* () { + const first = Buffer.from('{"message":'); + const second = Buffer.from('"created"}'); + mutable.set(first); + yield mutable.subarray(0, first.length); + mutable.set(second); + yield mutable.subarray(0, second.length); + })(), + }); + + await writeRuntimeInvokeResponse(result, { + stdout: stdout.stream, + stderr: stderr.stream, + json: true, + }); + + expect(stdout.bytes().toString()).toBe( + JSON.stringify({ + statusCode: 201, + contentType: "application/problem+json", + runtimeSessionId: "runtime-session", + bodyEncoding: "utf8", + body: '{"message":"created"}', + complete: true, + }), + ); + expect(stderr.bytes()).toHaveLength(0); + }); + + test("preserves partial stdout and reports one static incomplete summary", async () => { + const stdout = capture(); + const stderr = capture(); + const error = new Error("secret upstream detail"); + + await expect( + writeRuntimeInvokeResponse(response({ body: failingBody(error) }), { + stdout: stdout.stream, + stderr: stderr.stream, + }), + ).rejects.toThrow("response stream failed"); + + expect(stdout.bytes().toString()).toBe("partial"); + expect(stderr.bytes().toString()).toBe( + "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=7 error=response-stream-failed\n", + ); + expect(stderr.bytes().toString()).not.toContain(error.message); + }); + + test("writes an interruption summary after the output signal is aborted", async () => { + const controller = new AbortController(); + const stdout = capture(); + const stderr = capture(); + const source = (async function* () { + yield Buffer.from("partial"); + controller.abort(); + throw Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); + })(); + + await expect( + writeRuntimeInvokeResponse(response({ body: source }), { + stdout: stdout.stream, + stderr: stderr.stream, + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(stderr.bytes().toString()).toBe( + "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=7 error=interrupted\n", + ); + }); + + test("JSON mode emits collected bytes and a static error when buffering fails", async () => { + const stdout = capture(); + const stderr = capture(); + const error = new Error("secret upstream detail"); + + await expect( + writeRuntimeInvokeResponse(response({ body: failingBody(error) }), { + stdout: stdout.stream, + stderr: stderr.stream, + json: true, + }), + ).rejects.toThrow("response stream failed"); + + expect(JSON.parse(stdout.bytes().toString())).toMatchObject({ + bodyEncoding: "utf8", + body: "partial", + complete: false, + error: "response stream failed", + }); + expect(stdout.bytes().toString()).not.toContain(error.message); + expect(stderr.bytes()).toHaveLength(0); + }); + + test.each([ + ["binary content", "application/octet-stream", Buffer.from([0, 255, 1])], + ["invalid UTF-8 text", "text/plain", Buffer.from([0xc3, 0x28])], + ])("JSON mode base64-encodes %s", async (_name, contentType, bytes) => { + const stdout = capture(); + const stderr = capture(); + + await writeRuntimeInvokeResponse(response({ contentType, body: body(bytes) }), { + stdout: stdout.stream, + stderr: stderr.stream, + json: true, + }); + + expect(JSON.parse(stdout.bytes().toString())).toMatchObject({ + contentType, + bodyEncoding: "base64", + body: bytes.toString("base64"), + complete: true, + }); + expect(stderr.bytes()).toHaveLength(0); + }); + + test("refuses unknown content on a TTY before iterating the body", async () => { + let iterations = 0; + const stdout = capture(); + const stderr = capture(); + Object.defineProperty(stdout.stream, "isTTY", { value: true }); + const source = (async function* () { + iterations++; + yield Buffer.from([0]); + })(); + + await expect( + writeRuntimeInvokeResponse(response({ contentType: "", body: source }), { + stdout: stdout.stream, + stderr: stderr.stream, + }), + ).rejects.toThrow("Binary or unknown response content requires --output-file or --json"); + expect(iterations).toBe(0); + expect(stdout.bytes()).toHaveLength(0); + expect(stderr.bytes().toString()).toBe( + "status=200 content-type=- runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=0\n", + ); + }); +}); diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts new file mode 100644 index 000000000..05172f8d0 --- /dev/null +++ b/src/handlers/runtime/invoke/response.ts @@ -0,0 +1,173 @@ +import { createWriteStream } from "node:fs"; +import { pipeline } from "node:stream/promises"; +import type { RuntimeInvokeResponse } from "../types"; + +interface RuntimeInvokeOutput { + stdout: NodeJS.WriteStream; + stderr: NodeJS.WriteStream; + outputFile?: string; + json?: boolean; + signal?: AbortSignal; +} + +const RESPONSE_STREAM_FAILED = "response stream failed"; + +export function classifyRuntimeResponse(contentType: string) { + const mediaType = contentType.split(";", 1)[0]!.trim().toLowerCase(); + if (mediaType === "application/json" || /^application\/[^/]+\+json$/.test(mediaType)) { + return "json"; + } + return mediaType.startsWith("text/") ? "text" : "binary"; +} + +async function* countBytes( + body: AsyncIterable, + add: (size: number) => void, +): AsyncGenerator { + for await (const chunk of body) { + const snapshot = Uint8Array.from(chunk); + add(snapshot.byteLength); + yield snapshot; + } +} + +async function writeText( + stream: NodeJS.WriteStream, + text: string, + signal?: AbortSignal, +): Promise { + try { + await pipeline([text], stream, { end: false, signal }); + } catch (error) { + failure(error); + } +} + +export async function writeRuntimeInvokeFile( + response: RuntimeInvokeResponse, + path: string, + signal?: AbortSignal, + onBytes?: (size: number) => void, +): Promise { + await pipeline( + countBytes(response.body, (size) => { + onBytes?.(size); + }), + createWriteStream(path), + { signal }, + ); +} + +function failure(error: unknown): never { + const interrupted = (error as Error)?.name === "AbortError"; + const reported = new Error(interrupted ? "The operation was aborted" : RESPONSE_STREAM_FAILED); + reported.name = interrupted ? "AbortError" : "Error"; + Object.assign(reported, { reported: true }); + throw reported; +} + +async function writeJsonResponse( + response: RuntimeInvokeResponse, + output: RuntimeInvokeOutput, +): Promise { + const chunks: Uint8Array[] = []; + let streamError: unknown; + try { + for await (const chunk of response.body) { + output.signal?.throwIfAborted(); + chunks.push(Uint8Array.from(chunk)); + } + } catch (error) { + streamError = error; + } + const bytes = Buffer.concat(chunks); + const { body: _body, ...responseMetadata } = response; + let bodyEncoding: "utf8" | "base64" = "base64"; + let body = bytes.toString("base64"); + + if (classifyRuntimeResponse(response.contentType) !== "binary") { + try { + body = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + bodyEncoding = "utf8"; + } catch {} + } + + const envelope = JSON.stringify({ + ...responseMetadata, + bodyEncoding, + body, + complete: streamError === undefined, + ...(streamError !== undefined && { + error: (streamError as Error)?.name === "AbortError" ? "interrupted" : RESPONSE_STREAM_FAILED, + }), + }); + await writeText(output.stdout, envelope, streamError === undefined ? output.signal : undefined); + if (streamError !== undefined) failure(streamError); +} + +function summary( + response: RuntimeInvokeResponse, + byteCount: number, + complete: boolean, + error?: string, +): string { + const value = (item?: string) => item || "-"; + return ( + `status=${response.statusCode} content-type=${value(response.contentType)} ` + + `runtime-session-id=${value(response.runtimeSessionId)} ` + + `mcp-session-id=${value(response.mcpSessionId)} ` + + `mcp-protocol-version=${value(response.mcpProtocolVersion)} ` + + `trace-id=${value(response.traceId)} trace-parent=${value(response.traceParent)} ` + + `trace-state=${value(response.traceState)} baggage=${value(response.baggage)} ` + + `complete=${complete} bytes=${byteCount}${error ? ` error=${error}` : ""}\n` + ); +} + +export async function writeRuntimeInvokeResponse( + response: RuntimeInvokeResponse, + output: RuntimeInvokeOutput, +): Promise { + if (output.json) { + await writeJsonResponse(response, output); + return; + } + + if ( + output.outputFile === undefined && + output.stdout.isTTY && + classifyRuntimeResponse(response.contentType) === "binary" + ) { + await writeText(output.stderr, summary(response, 0, false)); + throw new TypeError("Binary or unknown response content requires --output-file or --json"); + } + + let byteCount = 0; + try { + if (output.outputFile !== undefined) { + await writeRuntimeInvokeFile( + response, + output.outputFile, + output.signal, + (size) => (byteCount += size), + ); + } else { + await pipeline( + countBytes(response.body, (size) => (byteCount += size)), + output.stdout, + { end: false, signal: output.signal }, + ); + } + } catch (error) { + await writeText( + output.stderr, + summary( + response, + byteCount, + false, + (error as Error)?.name === "AbortError" ? "interrupted" : "response-stream-failed", + ), + ); + failure(error); + } + await writeText(output.stderr, summary(response, byteCount, true)); +} diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 5b2814dae..584287b07 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -60,7 +60,7 @@ function testRuntimeCommand() { } describe("runtime command hierarchy", () => { - test("registers the Runtime read-only command hierarchy", () => { + test("registers the Runtime command hierarchy", () => { const root = createRootHandler(createFixtureCore(), { io: testIO().io, logger: createSilentLogger(), @@ -72,6 +72,7 @@ describe("runtime command hierarchy", () => { expect(runtime?.children().map((child) => child.name())).toEqual([ "get", "list", + "invoke", "version", "endpoint", ]); diff --git a/src/io/source.test.ts b/src/io/source.test.ts index 4092f9be3..19aea7a3b 100644 --- a/src/io/source.test.ts +++ b/src/io/source.test.ts @@ -33,6 +33,22 @@ describe("SourceResolver bytes", () => { expect(await resolver.resolveBytes("payload", undefined)).toBeUndefined(); }); + test("does not require stdin for non-stdin sources", async () => { + const resolver = new SourceResolver({}); + + expect(await resolver.resolveBytes("payload", "hello")).toEqual( + new TextEncoder().encode("hello"), + ); + }); + + test("rejects a stdin source when stdin is unavailable", async () => { + const resolver = new SourceResolver({}); + const resolution = resolver.resolveBytes("payload", "-"); + + await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); + await expect(resolution).rejects.toThrow("stdin is not available for '--payload'"); + }); + test("encodes inline values as UTF-8", async () => { const resolver = new SourceResolver({ stdin: stdin() }); diff --git a/src/io/source.ts b/src/io/source.ts index eb0d332bd..7246524ab 100644 --- a/src/io/source.ts +++ b/src/io/source.ts @@ -7,7 +7,7 @@ const FILE_PREFIX = "file://"; const STDIN = "-"; export type SourceResolverConfig = { - stdin: NodeJS.ReadStream; + stdin?: NodeJS.ReadStream; signal?: AbortSignal; }; @@ -52,6 +52,9 @@ export class SourceResolver { } private async readStdin(name: string): Promise { + if (!this.config.stdin) { + throw new SourceResolutionError(`stdin is not available for '--${name}'`); + } if (this.stdinClaimedBy !== undefined) { throw new SourceResolutionError( `only one option may read from stdin; '--${name}' conflicts with ` + diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 5d1798b8b..c582399c8 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -265,7 +265,7 @@ test("applies a schema default for an omitted flag", async () => { expect(seen).toEqual({ count: 7 }); }); -test("reports invalid input via command.error (throws under exitOverride)", async () => { +test("marks invalid flag schema input as usage", async () => { const get = createHandler({ name: "get", description: "", @@ -512,7 +512,7 @@ test("a required positional argument is mandatory", async () => { await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow(); }); -test("rejects an argument that fails schema validation", async () => { +test("marks invalid argument schema input as usage", async () => { const config = createHandler({ name: "config", description: "", @@ -527,9 +527,13 @@ test("rejects an argument that fails schema validation", async () => { const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); - await expect(cmd.parseAsync(["node", "app", "config", "toolong"])).rejects.toThrow( - /Invalid value for argument 'key'/, - ); + const error = await cmd + .parseAsync(["node", "app", "config", "toolong"]) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toContain("Invalid value for argument 'key'"); + expect(error.exitCode).toBe(2); }); test("compile rejects a variadic argument that is not the last positional", () => { diff --git a/src/router/router.tsx b/src/router/router.tsx index 5245b5f01..aef8e584e 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -108,7 +108,7 @@ export function compile( stack: Middleware[] = [], inheritedGlobals: GlobalFlag[] = [], ): Command { - const c = new Command(node.name()); + const c = new Command(node.name()).exitOverride(); c.description(node.description()); const ownFlags = node.flags(); diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index f756a6e62..ff98529a3 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -1,9 +1,22 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import { CommanderError } from "commander"; import { AgentCoreCLIError } from "../errors"; -import { runRunnable, type Runnable } from "./index.tsx"; +import { ExitCode, runRunnable, runWithExitCode, type Runnable } from "./index.tsx"; -test("returns zero and forwards argv when run completes", async () => { +async function captureErrors(run: () => Promise) { + const errors: string[] = []; + const errorLog = spyOn(console, "error").mockImplementation((message) => { + errors.push(String(message)); + }); + try { + return { code: await run(), errors }; + } finally { + errorLog.mockRestore(); + } +} + +test("returns SUCCESS and forwards argv when run completes", async () => { let receivedArgv: string[] | undefined; const runnable: Runnable = { run: async (argv: string[]) => { @@ -14,7 +27,7 @@ test("returns zero and forwards argv when run completes", async () => { const argv = ["node", "script", "--flag"]; const code = await runRunnable(() => runnable, argv); - expect(code).toBe(0); + expect(code).toBe(ExitCode.SUCCESS); expect(receivedArgv).toEqual(argv); }); @@ -25,17 +38,21 @@ test("returns the default failure code when run rejects with an Error", async () }, }; - const code = await runRunnable(() => runnable, []); + const { code, errors } = await captureErrors(() => runRunnable(() => runnable, [])); - expect(code).toBe(1); + expect(code).toBe(ExitCode.FAILURE); + expect(errors).toEqual(["Error: boom"]); }); -test("returns the default failure code when the factory throws a non-Error value", async () => { - const code = await runRunnable(() => { - throw "kaboom"; - }, []); +test("returns FAILURE when the factory throws a non-Error value", async () => { + const { code, errors } = await captureErrors(() => + runRunnable(() => { + throw "kaboom"; + }, []), + ); - expect(code).toBe(1); + expect(code).toBe(ExitCode.FAILURE); + expect(errors).toEqual(["Error: kaboom"]); }); test("respects custom errors codes from known errors", async () => { @@ -49,3 +66,45 @@ test("respects custom errors codes from known errors", async () => { expect(code).toBe(42); }); + +test.each([ + [ + "explicit usage", + new AgentCoreCLIError("bad request", { exitCode: ExitCode.USAGE }), + ExitCode.USAGE, + ["AgentCoreCLIError: bad request"], + ], + [ + "interruption", + Object.assign(new Error("The operation was aborted"), { name: "AbortError" }), + ExitCode.INTERRUPTED, + ["AbortError: The operation was aborted"], + ], + [ + "Commander parse failure", + new CommanderError(1, "commander.invalidArgument", "invalid option"), + ExitCode.USAGE, + [], + ], + [ + "Commander help", + new CommanderError(0, "commander.helpDisplayed", "help displayed"), + ExitCode.SUCCESS, + [], + ], + [ + "reported failure", + Object.assign(new Error("already reported"), { reported: true }), + ExitCode.FAILURE, + [], + ], + [ + "arbitrary TypeError", + new TypeError("transport failed"), + ExitCode.FAILURE, + ["TypeError: transport failed"], + ], +])("runWithExitCode maps %s", async (_name, error, expected, expectedErrors) => { + const result = await captureErrors(() => runWithExitCode(async () => Promise.reject(error))); + expect(result).toEqual({ code: expected, errors: expectedErrors }); +}); diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index 8f22aa371..0ec505f23 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -1,5 +1,14 @@ +import { CommanderError } from "commander"; import { AgentCoreCLIError } from "../errors"; +// ExitCode provides names for default Unix exit codes. +export enum ExitCode { + SUCCESS = 0, + FAILURE = 1, + USAGE = 2, + INTERRUPTED = 130, +} + // Runnable can be implemented by any application's main entrypoint. export interface Runnable { run(argv: string[]): Promise; @@ -22,11 +31,20 @@ export async function runWithExitCode( ): Promise { try { await fn(argv); - return 0; - } catch (e) { - const error = e instanceof Error ? e : new Error(String(e)); - console.error(`Error: ${error.message}`); - - return error instanceof AgentCoreCLIError ? error.exitCode : 1; + return ExitCode.SUCCESS; + } catch (error) { + if ( + !(error instanceof CommanderError) && + (error as { reported?: boolean } | null)?.reported !== true + ) { + const reported = error instanceof Error ? error : new Error(String(error)); + console.error(`${reported.name}: ${reported.message}`); + } + if (error instanceof CommanderError) { + return error.exitCode === 0 ? ExitCode.SUCCESS : ExitCode.USAGE; + } + if (error instanceof AgentCoreCLIError) return error.exitCode; + if ((error as Error)?.name === "AbortError") return ExitCode.INTERRUPTED; + return ExitCode.FAILURE; } } From 3ea4f2738526fe5dc9c24d2578bfa44284596e64 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 27 Jul 2026 18:22:29 +0000 Subject: [PATCH 03/10] docs(runtime): document invoke workflows --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/README.md b/README.md index 61fffa6b8..1ed95378a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ agentcore # interactive TUI ├── runtime # inspect deployed AgentCore Runtimes │ ├── get # fetch a Runtime by id │ ├── list # list Runtimes (server-side paginated) +│ ├── invoke # invoke a Runtime │ ├── version │ │ ├── get # get a specific Runtime version │ │ └── list # list a Runtime's versions @@ -154,6 +155,78 @@ Source-aware values: any field flag documented as such accepts the value inline, `file://` convention). A command reads stdin from at most one flag. For example, `--instructions file://order-quality.txt` or `--instructions -`. +### Invoke a Runtime + +Runtime invocation accepts inline, file, or stdin payload bytes: + +```bash +# Inline +agentcore runtime invoke \ + --id \ + --payload '{"action":"status"}' \ + --content-type application/json \ + --accept text/event-stream + +# File +agentcore runtime invoke --id --payload file://request.json + +# stdin +cat request.json | agentcore runtime invoke --id --payload - +``` + +CUSTOM_JWT Runtimes require `--bearer-token`. The token accepts the same inline, +`file://`, or stdin sources as the payload; payload and token cannot both read +stdin. + +```bash +agentcore runtime invoke \ + --id \ + --payload file://request.json \ + --bearer-token file://$HOME/.config/agentcore/runtime-token +``` + +For MCP Runtimes, initialize first, then pass the returned Runtime and MCP +session IDs to later methods. MCP requests accept both JSON and SSE responses. + +```bash +agentcore runtime invoke \ + --id \ + --payload '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"agentcore-cli","version":"1"}}}' \ + --accept 'application/json, text/event-stream' \ + --mcp-protocol-version 2025-03-26 \ + --mcp-method initialize + +agentcore runtime invoke \ + --id \ + --payload '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + --accept 'application/json, text/event-stream' \ + --session-id \ + --mcp-session-id \ + --mcp-protocol-version 2025-03-26 \ + --mcp-method tools/list +``` + +Raw mode streams exact response bytes to stdout and writes response metadata to +stderr. Use `--output-file` for binary responses. `--json` instead buffers one +response and emits a metadata envelope without interpreting the customer body. + +```bash +agentcore runtime invoke \ + --id \ + --payload file://request.bin \ + --content-type application/octet-stream \ + --accept application/octet-stream \ + --output-file response.bin + +agentcore runtime invoke --id --payload '{"action":"status"}' --json +# {"statusCode":200,"contentType":"application/json","bodyEncoding":"utf8","body":"{\"ok\":true}","complete":true} +``` + +Runtime Invoke accepts Runtime IDs from the current account only. It does not +accept ARNs, `--version`, `--interactive`, cross-account targets, or custom +request paths. All requests use the Runtime `/invocations` route, including MCP +Runtimes. + Bare Runtime branches and leaves require a TTY on stdin and stdout. Supplying operation flags runs the command headlessly, and `--json` always suppresses TUI rendering. From 03660857da7d268db5cca5235baa3016458a8390 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 27 Jul 2026 18:58:49 +0000 Subject: [PATCH 04/10] fix(runtime): distinguish streaming invoke responses --- README.md | 13 +- src/handlers/runtime/invoke/errors.ts | 3 + src/handlers/runtime/invoke/index.tsx | 2 +- src/handlers/runtime/invoke/invoke.test.tsx | 39 +++- src/handlers/runtime/invoke/request.test.ts | 2 +- src/handlers/runtime/invoke/request.ts | 5 +- src/handlers/runtime/invoke/response.test.ts | 176 +++++++++++++++++-- src/handlers/runtime/invoke/response.ts | 89 ++++++---- 8 files changed, 270 insertions(+), 59 deletions(-) create mode 100644 src/handlers/runtime/invoke/errors.ts diff --git a/README.md b/README.md index 1ed95378a..e1fbbc676 100644 --- a/README.md +++ b/README.md @@ -206,9 +206,16 @@ agentcore runtime invoke \ --mcp-method tools/list ``` -Raw mode streams exact response bytes to stdout and writes response metadata to -stderr. Use `--output-file` for binary responses. `--json` instead buffers one -response and emits a metadata envelope without interpreting the customer body. +Responses with `text/event-stream`, `application/x-ndjson`, +`application/ndjson`, or `application/json-seq` content types stream exact bytes +to stdout as they arrive. Other responses are buffered and written once after +the body completes. Response metadata is written to stderr. + +`--output-file` streams either kind of response directly to disk and is required +for binary output when stdout is a terminal. `--json` supports non-streaming +responses only; it buffers one response and emits a metadata envelope without +interpreting the customer body. If a streaming response fails, bytes already +written remain available. A failed non-streaming response writes no partial body. ```bash agentcore runtime invoke \ diff --git a/src/handlers/runtime/invoke/errors.ts b/src/handlers/runtime/invoke/errors.ts new file mode 100644 index 000000000..d07b7d483 --- /dev/null +++ b/src/handlers/runtime/invoke/errors.ts @@ -0,0 +1,3 @@ +export class UsageError extends TypeError { + readonly exitCode = 2; +} diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 51e380cd7..7016b83f8 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -4,12 +4,12 @@ import type { AppIO } from "../../../io"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; import { JsonKey } from "../../keys"; +import { UsageError } from "./errors"; import { normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, runtimeIdSchema, - UsageError, } from "./request"; import { writeRuntimeInvokeResponse } from "./response"; diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index a94891e8c..4d12ea996 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -81,7 +81,7 @@ async function run( } describe("runtime invoke", () => { - test("resolves the Runtime, invokes its ID in the current account, and streams exact bytes", async () => { + test("resolves the Runtime, invokes its ID in the current account, and writes exact bytes", async () => { const { core, output } = await run([ "runtime", "invoke", @@ -205,6 +205,41 @@ describe("runtime invoke", () => { expect(core.runtime.calls).toEqual([]); }); + test("classifies --json with a streaming response as usage before reading the body", async () => { + let iterations = 0; + const core = new TestCoreClient(); + const output = captureIO(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/event-stream", + body: (async function* () { + iterations++; + yield Buffer.from("data: ready\n\n"); + })(), + }); + + const code = await runWithExitCode(async () => + runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + "--json", + ]), + ); + + expect(code).toBe(ExitCode.USAGE); + expect(iterations).toBe(0); + expect(output.bytes()).toHaveLength(0); + const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")! + .args[2] as AbortSignal; + expect(signal.aborted).toBe(true); + }); + test("passes an explicitly empty payload as zero bytes", async () => { const { core } = await run(["runtime", "invoke", "--id", RUNTIME_ID, "--payload", ""]); @@ -220,7 +255,7 @@ describe("runtime invoke", () => { core.runtime.calls.push({ method: "invokeRuntime", args: [request, options, signal] }); return { statusCode: 200, - contentType: "text/plain", + contentType: "text/event-stream", body: (async function* () { yield Buffer.from("partial"); await new Promise((_, reject) => { diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index 296b91d5c..d3a9b3b88 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -2,11 +2,11 @@ import { describe, expect, test } from "bun:test"; import { Readable } from "node:stream"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { SourceResolutionError } from "../../../io"; +import { UsageError } from "./errors"; import { normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, - UsageError, } from "./request"; const REGION = "us-west-2"; diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index 2e32b35d3..5095c8bf6 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -3,6 +3,7 @@ import z from "zod"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { SourceResolutionError, SourceResolver } from "../../../io"; import type { RuntimeInvokeRequest } from "../types"; +import { UsageError } from "./errors"; export const runtimeIdSchema = z .string() @@ -30,10 +31,6 @@ const RESERVED_HEADERS = new Set([ "baggage", ]); -export class UsageError extends TypeError { - readonly exitCode = 2; -} - export async function resolveRuntimeInvokeSources( sources: { payload: string; bearerToken?: string }, stdin?: NodeJS.ReadStream, diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index cb54d039a..d3b5da43f 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -3,8 +3,9 @@ import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough, Writable } from "node:stream"; +import { waitFor } from "../../../testing"; import type { RuntimeInvokeResponse } from "../types"; -import { writeRuntimeInvokeResponse } from "./response"; +import { isStreamingRuntimeResponse, writeRuntimeInvokeResponse } from "./response"; const files: string[] = []; @@ -45,11 +46,24 @@ function capture() { } describe("Runtime invoke response output", () => { - test("streams exact chunks to stdout, reports metadata, and leaves stdout open", async () => { + test.each([ + ["SSE", "text/event-stream", true], + ["SSE with parameters", " Text/Event-Stream; charset=utf-8 ", true], + ["NDJSON", "application/x-ndjson", true], + ["NDJSON alias", "application/ndjson", true], + ["JSON text sequences", "application/json-seq", true], + ["JSON", "application/json", false], + ["plain text", "text/plain", false], + ])("classifies %s responses by media type", (_name, contentType, expected) => { + expect(isStreamingRuntimeResponse(contentType)).toBe(expected); + }); + + test("streams exact chunks for streaming content, reports metadata, and leaves stdout open", async () => { const stdout = capture(); const stderr = capture(); const result = response({ statusCode: 206, + contentType: "text/event-stream", runtimeSessionId: "runtime-session", mcpSessionId: "mcp-session", mcpProtocolVersion: "2025-06-18", @@ -67,7 +81,7 @@ describe("Runtime invoke response output", () => { expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); expect(stderr.bytes().toString()).toBe( - "status=206 content-type=text/plain runtime-session-id=runtime-session " + + "status=206 content-type=text/event-stream runtime-session-id=runtime-session " + "mcp-session-id=mcp-session mcp-protocol-version=2025-06-18 trace-id=trace-id " + "trace-parent=trace-parent trace-state=trace-state baggage=tenant=retail " + "complete=true bytes=4\n", @@ -77,6 +91,40 @@ describe("Runtime invoke response output", () => { expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1, 127])); }); + test("buffers non-streaming content before writing it once", async () => { + const stdout = capture(); + const stderr = capture(); + const firstConsumed = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const pending = writeRuntimeInvokeResponse( + response({ + body: (async function* () { + yield Buffer.from("first"); + firstConsumed.resolve(); + await finish.promise; + yield Buffer.from("second"); + })(), + }), + { + stdout: stdout.stream, + stderr: stderr.stream, + }, + ); + + await firstConsumed.promise; + const bytesBeforeCompletion = stdout.bytes(); + finish.resolve(); + await pending; + + expect(bytesBeforeCompletion).toHaveLength(0); + expect(stdout.bytes().toString()).toBe("firstsecond"); + expect(stderr.bytes().toString()).toBe( + "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=true bytes=11\n", + ); + }); + test("sanitizes metadata output failures", async () => { const stdout = capture(); const stderr = new Writable({ @@ -123,6 +171,50 @@ describe("Runtime invoke response output", () => { expect(stdout.bytes()).toHaveLength(0); }); + test("streams non-streaming content directly to a file", async () => { + const file = join(tmpdir(), `runtime-invoke-output-${process.pid}-${files.length}`); + files.push(file); + const stdout = capture(); + const stderr = capture(); + const firstWritten = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const pending = writeRuntimeInvokeResponse( + response({ + body: (async function* () { + yield Buffer.from("first"); + firstWritten.resolve(); + await finish.promise; + yield Buffer.from("second"); + })(), + }), + { + stdout: stdout.stream, + stderr: stderr.stream, + outputFile: file, + }, + ); + + await firstWritten.promise; + let bytesBeforeCompletion: Buffer; + try { + await waitFor(async () => { + try { + return (await Bun.file(file).text()) === "first"; + } catch { + return false; + } + }); + bytesBeforeCompletion = Buffer.from(await Bun.file(file).bytes()); + } finally { + finish.resolve(); + await pending; + } + + expect(bytesBeforeCompletion.toString()).toBe("first"); + expect(Buffer.from(await Bun.file(file).bytes()).toString()).toBe("firstsecond"); + expect(stdout.bytes()).toHaveLength(0); + }); + test("JSON mode emits one textual response envelope and no stderr", async () => { const stdout = capture(); const stderr = capture(); @@ -166,15 +258,18 @@ describe("Runtime invoke response output", () => { const error = new Error("secret upstream detail"); await expect( - writeRuntimeInvokeResponse(response({ body: failingBody(error) }), { - stdout: stdout.stream, - stderr: stderr.stream, - }), + writeRuntimeInvokeResponse( + response({ contentType: "text/event-stream", body: failingBody(error) }), + { + stdout: stdout.stream, + stderr: stderr.stream, + }, + ), ).rejects.toThrow("response stream failed"); expect(stdout.bytes().toString()).toBe("partial"); expect(stderr.bytes().toString()).toBe( - "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + + "status=200 content-type=text/event-stream runtime-session-id=- mcp-session-id=- " + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + "complete=false bytes=7 error=response-stream-failed\n", ); @@ -192,20 +287,42 @@ describe("Runtime invoke response output", () => { })(); await expect( - writeRuntimeInvokeResponse(response({ body: source }), { + writeRuntimeInvokeResponse(response({ contentType: "text/event-stream", body: source }), { stdout: stdout.stream, stderr: stderr.stream, signal: controller.signal, }), ).rejects.toMatchObject({ name: "AbortError" }); + expect(stdout.bytes().toString()).toBe("partial"); expect(stderr.bytes().toString()).toBe( - "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + + "status=200 content-type=text/event-stream runtime-session-id=- mcp-session-id=- " + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + "complete=false bytes=7 error=interrupted\n", ); }); - test("JSON mode emits collected bytes and a static error when buffering fails", async () => { + test("does not write a partial non-streaming response when buffering fails", async () => { + const stdout = capture(); + const stderr = capture(); + const error = new Error("secret upstream detail"); + + await expect( + writeRuntimeInvokeResponse(response({ body: failingBody(error) }), { + stdout: stdout.stream, + stderr: stderr.stream, + }), + ).rejects.toThrow("response stream failed"); + + expect(stdout.bytes()).toHaveLength(0); + expect(stderr.bytes().toString()).toBe( + "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=7 error=response-stream-failed\n", + ); + expect(stderr.bytes().toString()).not.toContain(error.message); + }); + + test("JSON mode emits no partial envelope and reports a static error when buffering fails", async () => { const stdout = capture(); const stderr = capture(); const error = new Error("secret upstream detail"); @@ -218,13 +335,38 @@ describe("Runtime invoke response output", () => { }), ).rejects.toThrow("response stream failed"); - expect(JSON.parse(stdout.bytes().toString())).toMatchObject({ - bodyEncoding: "utf8", - body: "partial", - complete: false, - error: "response stream failed", + expect(stdout.bytes()).toHaveLength(0); + expect(stderr.bytes().toString()).toBe( + "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=7 error=response-stream-failed\n", + ); + expect(stderr.bytes().toString()).not.toContain(error.message); + }); + + test("rejects JSON mode for streaming content before iterating the body", async () => { + let iterations = 0; + const stdout = capture(); + const stderr = capture(); + const source = (async function* () { + iterations++; + yield Buffer.from("{}\n"); + })(); + + await expect( + writeRuntimeInvokeResponse(response({ contentType: "application/x-ndjson", body: source }), { + stdout: stdout.stream, + stderr: stderr.stream, + json: true, + }), + ).rejects.toMatchObject({ + message: + "--json cannot be used with a streaming Runtime response; omit --json or use --output-file", + exitCode: 2, }); - expect(stdout.bytes().toString()).not.toContain(error.message); + + expect(iterations).toBe(0); + expect(stdout.bytes()).toHaveLength(0); expect(stderr.bytes()).toHaveLength(0); }); diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 05172f8d0..8c638513f 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -1,6 +1,7 @@ import { createWriteStream } from "node:fs"; import { pipeline } from "node:stream/promises"; import type { RuntimeInvokeResponse } from "../types"; +import { UsageError } from "./errors"; interface RuntimeInvokeOutput { stdout: NodeJS.WriteStream; @@ -11,13 +12,27 @@ interface RuntimeInvokeOutput { } const RESPONSE_STREAM_FAILED = "response stream failed"; +const STREAMING_MEDIA_TYPES = new Set([ + "text/event-stream", + "application/x-ndjson", + "application/ndjson", + "application/json-seq", +]); + +function mediaType(contentType: string): string { + return contentType.split(";", 1)[0]!.trim().toLowerCase(); +} + +export function isStreamingRuntimeResponse(contentType: string): boolean { + return STREAMING_MEDIA_TYPES.has(mediaType(contentType)); +} export function classifyRuntimeResponse(contentType: string) { - const mediaType = contentType.split(";", 1)[0]!.trim().toLowerCase(); - if (mediaType === "application/json" || /^application\/[^/]+\+json$/.test(mediaType)) { + const type = mediaType(contentType); + if (type === "application/json" || /^application\/[^/]+\+json$/.test(type)) { return "json"; } - return mediaType.startsWith("text/") ? "text" : "binary"; + return type.startsWith("text/") || STREAMING_MEDIA_TYPES.has(type) ? "text" : "binary"; } async function* countBytes( @@ -31,13 +46,13 @@ async function* countBytes( } } -async function writeText( +async function writeChunk( stream: NodeJS.WriteStream, - text: string, + chunk: string | Uint8Array, signal?: AbortSignal, ): Promise { try { - await pipeline([text], stream, { end: false, signal }); + await pipeline([chunk], stream, { end: false, signal }); } catch (error) { failure(error); } @@ -66,24 +81,29 @@ function failure(error: unknown): never { throw reported; } +async function readBody( + body: AsyncIterable, + signal: AbortSignal | undefined, + onBytes: (size: number) => void, +): Promise { + const chunks: Uint8Array[] = []; + for await (const chunk of body) { + signal?.throwIfAborted(); + const snapshot = Uint8Array.from(chunk); + onBytes(snapshot.byteLength); + chunks.push(snapshot); + } + return Buffer.concat(chunks); +} + async function writeJsonResponse( response: RuntimeInvokeResponse, + bytes: Uint8Array, output: RuntimeInvokeOutput, ): Promise { - const chunks: Uint8Array[] = []; - let streamError: unknown; - try { - for await (const chunk of response.body) { - output.signal?.throwIfAborted(); - chunks.push(Uint8Array.from(chunk)); - } - } catch (error) { - streamError = error; - } - const bytes = Buffer.concat(chunks); const { body: _body, ...responseMetadata } = response; let bodyEncoding: "utf8" | "base64" = "base64"; - let body = bytes.toString("base64"); + let body = Buffer.from(bytes).toString("base64"); if (classifyRuntimeResponse(response.contentType) !== "binary") { try { @@ -96,13 +116,9 @@ async function writeJsonResponse( ...responseMetadata, bodyEncoding, body, - complete: streamError === undefined, - ...(streamError !== undefined && { - error: (streamError as Error)?.name === "AbortError" ? "interrupted" : RESPONSE_STREAM_FAILED, - }), + complete: true, }); - await writeText(output.stdout, envelope, streamError === undefined ? output.signal : undefined); - if (streamError !== undefined) failure(streamError); + await writeChunk(output.stdout, envelope, output.signal); } function summary( @@ -127,9 +143,11 @@ export async function writeRuntimeInvokeResponse( response: RuntimeInvokeResponse, output: RuntimeInvokeOutput, ): Promise { - if (output.json) { - await writeJsonResponse(response, output); - return; + const streaming = isStreamingRuntimeResponse(response.contentType); + if (output.json && streaming) { + throw new UsageError( + "--json cannot be used with a streaming Runtime response; omit --json or use --output-file", + ); } if ( @@ -137,7 +155,7 @@ export async function writeRuntimeInvokeResponse( output.stdout.isTTY && classifyRuntimeResponse(response.contentType) === "binary" ) { - await writeText(output.stderr, summary(response, 0, false)); + await writeChunk(output.stderr, summary(response, 0, false)); throw new TypeError("Binary or unknown response content requires --output-file or --json"); } @@ -150,15 +168,22 @@ export async function writeRuntimeInvokeResponse( output.signal, (size) => (byteCount += size), ); - } else { + } else if (streaming) { await pipeline( countBytes(response.body, (size) => (byteCount += size)), output.stdout, { end: false, signal: output.signal }, ); + } else { + const bytes = await readBody(response.body, output.signal, (size) => (byteCount += size)); + if (output.json) { + await writeJsonResponse(response, bytes, output); + } else { + await writeChunk(output.stdout, bytes, output.signal); + } } } catch (error) { - await writeText( + await writeChunk( output.stderr, summary( response, @@ -169,5 +194,7 @@ export async function writeRuntimeInvokeResponse( ); failure(error); } - await writeText(output.stderr, summary(response, byteCount, true)); + if (!output.json) { + await writeChunk(output.stderr, summary(response, byteCount, true)); + } } From c12a5e24ecf05b2113b24445245588fa651660fe Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 28 Jul 2026 13:28:51 +0000 Subject: [PATCH 05/10] refactor(errors): adopt shared CLI error types --- src/handlers/config/config.test.tsx | 1 + src/handlers/runtime/invoke/errors.ts | 26 +++++++++++++-- src/handlers/runtime/invoke/index.tsx | 8 ++++- src/handlers/runtime/invoke/invoke.test.tsx | 2 +- src/handlers/runtime/invoke/response.ts | 8 ++--- src/router/router.test.ts | 3 +- src/runnable/index.test.ts | 37 ++++++++++++++++++--- src/runnable/index.tsx | 23 +++++++++++-- 8 files changed, 91 insertions(+), 17 deletions(-) diff --git a/src/handlers/config/config.test.tsx b/src/handlers/config/config.test.tsx index 6a81b50e0..4a907a250 100644 --- a/src/handlers/config/config.test.tsx +++ b/src/handlers/config/config.test.tsx @@ -7,6 +7,7 @@ import { createSilentLogger, TestCoreClient, testIO } from "../../testing"; import { DefaultGlobalConfigAccessor } from "../../globalConfig"; import { InputValidationError } from "../../errors"; import { FsReadWriteJson } from "../../io"; +import { InputValidationError } from "../../errors"; describe("config", () => { let tempDir: string; diff --git a/src/handlers/runtime/invoke/errors.ts b/src/handlers/runtime/invoke/errors.ts index d07b7d483..f52fb299c 100644 --- a/src/handlers/runtime/invoke/errors.ts +++ b/src/handlers/runtime/invoke/errors.ts @@ -1,3 +1,25 @@ -export class UsageError extends TypeError { - readonly exitCode = 2; +import { AgentCoreCLIError, InputValidationError } from "../../../errors"; + +export class UsageError extends InputValidationError { + constructor(message?: string, options?: ErrorOptions) { + super(message, { ...options, exitCode: 2 }); + } +} + +export class RuntimeInvokeInterruptedError extends AgentCoreCLIError { + readonly reported: boolean; + + constructor(cause?: unknown, reported = false) { + super("The operation was aborted", { cause, exitCode: 130 }); + this.name = "AbortError"; + this.reported = reported; + } +} + +export class RuntimeInvokeResponseError extends AgentCoreCLIError { + readonly reported = true; + + constructor(message: string, cause?: unknown) { + super(message, { cause }); + } } diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 7016b83f8..7485a2282 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -4,7 +4,7 @@ import type { AppIO } from "../../../io"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; import { JsonKey } from "../../keys"; -import { UsageError } from "./errors"; +import { RuntimeInvokeInterruptedError, UsageError } from "./errors"; import { normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, @@ -91,6 +91,12 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => json: jsonOutput, signal: controller.signal, }); + } catch (error) { + if (controller.signal.aborted && (error as Error)?.name === "AbortError") { + if (error instanceof RuntimeInvokeInterruptedError) throw error; + throw new RuntimeInvokeInterruptedError(error); + } + throw error; } finally { controller.abort(); process.off("SIGINT", interrupt); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 4d12ea996..295176312 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -386,7 +386,7 @@ describe("runtime invoke", () => { `file://${missing}`, ]), ).rejects.toMatchObject({ - name: "TypeError", + name: "UsageError", message: `could not read '--payload' from file '${missing}'`, exitCode: ExitCode.USAGE, }); diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 8c638513f..0af2c972f 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -1,7 +1,7 @@ import { createWriteStream } from "node:fs"; import { pipeline } from "node:stream/promises"; import type { RuntimeInvokeResponse } from "../types"; -import { UsageError } from "./errors"; +import { RuntimeInvokeInterruptedError, RuntimeInvokeResponseError, UsageError } from "./errors"; interface RuntimeInvokeOutput { stdout: NodeJS.WriteStream; @@ -75,10 +75,8 @@ export async function writeRuntimeInvokeFile( function failure(error: unknown): never { const interrupted = (error as Error)?.name === "AbortError"; - const reported = new Error(interrupted ? "The operation was aborted" : RESPONSE_STREAM_FAILED); - reported.name = interrupted ? "AbortError" : "Error"; - Object.assign(reported, { reported: true }); - throw reported; + if (interrupted) throw new RuntimeInvokeInterruptedError(error, true); + throw new RuntimeInvokeResponseError(RESPONSE_STREAM_FAILED, error); } async function readBody( diff --git a/src/router/router.test.ts b/src/router/router.test.ts index c582399c8..04e2a3f59 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -2,6 +2,7 @@ import { test, expect } from "bun:test"; import { Command } from "commander"; import z from "zod"; +import { InputValidationError } from "../errors"; import { Router, ValueContext, @@ -531,7 +532,7 @@ test("marks invalid argument schema input as usage", async () => { .parseAsync(["node", "app", "config", "toolong"]) .catch((caught) => caught); - expect(error).toBeInstanceOf(TypeError); + expect(error).toBeInstanceOf(InputValidationError); expect(error.message).toContain("Invalid value for argument 'key'"); expect(error.exitCode).toBe(2); }); diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index ff98529a3..acf43b090 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -1,7 +1,7 @@ import { expect, spyOn, test } from "bun:test"; import { CommanderError } from "commander"; -import { AgentCoreCLIError } from "../errors"; +import { AgentCoreCLIError, InputValidationError } from "../errors"; import { ExitCode, runRunnable, runWithExitCode, type Runnable } from "./index.tsx"; async function captureErrors(run: () => Promise) { @@ -62,17 +62,18 @@ test("respects custom errors codes from known errors", async () => { }, }; - const code = await runRunnable(() => runnable, []); + const { code, errors } = await captureErrors(() => runRunnable(() => runnable, [])); expect(code).toBe(42); + expect(errors).toEqual(["Error: custom failure"]); }); test.each([ [ "explicit usage", - new AgentCoreCLIError("bad request", { exitCode: ExitCode.USAGE }), + new InputValidationError("bad request", { exitCode: ExitCode.USAGE }), ExitCode.USAGE, - ["AgentCoreCLIError: bad request"], + ["Error: bad request"], ], [ "interruption", @@ -80,18 +81,46 @@ test.each([ ExitCode.INTERRUPTED, ["AbortError: The operation was aborted"], ], + [ + "classified interruption", + AgentCoreCLIError.fromError( + Object.assign(new Error("The operation was aborted"), { name: "AbortError" }), + ), + ExitCode.INTERRUPTED, + ["AbortError: The operation was aborted"], + ], [ "Commander parse failure", new CommanderError(1, "commander.invalidArgument", "invalid option"), ExitCode.USAGE, [], ], + [ + "classified Commander parse failure", + AgentCoreCLIError.fromError( + new CommanderError(1, "commander.invalidArgument", "invalid option"), + ), + ExitCode.USAGE, + [], + ], [ "Commander help", new CommanderError(0, "commander.helpDisplayed", "help displayed"), ExitCode.SUCCESS, [], ], + [ + "classified Commander help", + AgentCoreCLIError.fromError(new CommanderError(0, "commander.helpDisplayed", "help displayed")), + ExitCode.SUCCESS, + [], + ], + [ + "classified reported failure", + AgentCoreCLIError.fromError(Object.assign(new Error("already reported"), { reported: true })), + ExitCode.FAILURE, + [], + ], [ "reported failure", Object.assign(new Error("already reported"), { reported: true }), diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index 0ec505f23..5cfc399f2 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -9,6 +9,21 @@ export enum ExitCode { INTERRUPTED = 130, } +function externallyHandledError(error: unknown): unknown { + if ((error as { reported?: boolean } | null)?.reported === true) return error; + if (!(error instanceof AgentCoreCLIError)) return error; + + const cause = error.cause; + if ( + cause instanceof CommanderError || + (cause as { reported?: boolean } | null)?.reported === true || + (cause as Error)?.name === "AbortError" + ) { + return cause; + } + return error; +} + // Runnable can be implemented by any application's main entrypoint. export interface Runnable { run(argv: string[]): Promise; @@ -32,19 +47,21 @@ export async function runWithExitCode( try { await fn(argv); return ExitCode.SUCCESS; - } catch (error) { + } catch (caught) { + const error = externallyHandledError(caught); if ( !(error instanceof CommanderError) && (error as { reported?: boolean } | null)?.reported !== true ) { const reported = error instanceof Error ? error : new Error(String(error)); - console.error(`${reported.name}: ${reported.message}`); + const name = reported instanceof AgentCoreCLIError ? "Error" : reported.name; + console.error(`${name}: ${reported.message}`); } if (error instanceof CommanderError) { return error.exitCode === 0 ? ExitCode.SUCCESS : ExitCode.USAGE; } - if (error instanceof AgentCoreCLIError) return error.exitCode; if ((error as Error)?.name === "AbortError") return ExitCode.INTERRUPTED; + if (caught instanceof AgentCoreCLIError) return caught.exitCode; return ExitCode.FAILURE; } } From 643b258bb1755662deb764aebd1051d07c8a1216 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 28 Jul 2026 20:30:19 +0000 Subject: [PATCH 06/10] refactor(runtime): dispatch invoke output by mode --- src/handlers/runtime/invoke/invoke.test.tsx | 36 ++++---- src/handlers/runtime/invoke/response.test.ts | 90 ++++++++++++-------- src/handlers/runtime/invoke/response.ts | 31 ++----- 3 files changed, 82 insertions(+), 75 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 295176312..8079635a9 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -205,7 +205,7 @@ describe("runtime invoke", () => { expect(core.runtime.calls).toEqual([]); }); - test("classifies --json with a streaming response as usage before reading the body", async () => { + test("buffers a streaming response when --json is requested", async () => { let iterations = 0; const core = new TestCoreClient(); const output = captureIO(); @@ -220,24 +220,24 @@ describe("runtime invoke", () => { })(), }); - const code = await runWithExitCode(async () => - runCommand(core, output.io, [ - "runtime", - "invoke", - "--id", - RUNTIME_ID, - "--payload", - "{}", - "--json", - ]), - ); + await runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + "--json", + ]); - expect(code).toBe(ExitCode.USAGE); - expect(iterations).toBe(0); - expect(output.bytes()).toHaveLength(0); - const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")! - .args[2] as AbortSignal; - expect(signal.aborted).toBe(true); + expect(iterations).toBe(1); + expect(JSON.parse(output.bytes().toString())).toMatchObject({ + statusCode: 200, + contentType: "text/event-stream", + bodyEncoding: "utf8", + body: "data: ready\n\n", + complete: true, + }); }); test("passes an explicitly empty payload as zero bytes", async () => { diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index d3b5da43f..d918b2350 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { PassThrough, Writable } from "node:stream"; import { waitFor } from "../../../testing"; import type { RuntimeInvokeResponse } from "../types"; -import { isStreamingRuntimeResponse, writeRuntimeInvokeResponse } from "./response"; +import { writeRuntimeInvokeResponse } from "./response"; const files: string[] = []; @@ -46,24 +46,12 @@ function capture() { } describe("Runtime invoke response output", () => { - test.each([ - ["SSE", "text/event-stream", true], - ["SSE with parameters", " Text/Event-Stream; charset=utf-8 ", true], - ["NDJSON", "application/x-ndjson", true], - ["NDJSON alias", "application/ndjson", true], - ["JSON text sequences", "application/json-seq", true], - ["JSON", "application/json", false], - ["plain text", "text/plain", false], - ])("classifies %s responses by media type", (_name, contentType, expected) => { - expect(isStreamingRuntimeResponse(contentType)).toBe(expected); - }); - - test("streams exact chunks for streaming content, reports metadata, and leaves stdout open", async () => { + test("streams exact chunks in raw mode, reports metadata, and leaves stdout open", async () => { const stdout = capture(); const stderr = capture(); const result = response({ statusCode: 206, - contentType: "text/event-stream", + contentType: "text/plain", runtimeSessionId: "runtime-session", mcpSessionId: "mcp-session", mcpProtocolVersion: "2025-06-18", @@ -81,7 +69,7 @@ describe("Runtime invoke response output", () => { expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1])); expect(stderr.bytes().toString()).toBe( - "status=206 content-type=text/event-stream runtime-session-id=runtime-session " + + "status=206 content-type=text/plain runtime-session-id=runtime-session " + "mcp-session-id=mcp-session mcp-protocol-version=2025-06-18 trace-id=trace-id " + "trace-parent=trace-parent trace-state=trace-state baggage=tenant=retail " + "complete=true bytes=4\n", @@ -91,7 +79,7 @@ describe("Runtime invoke response output", () => { expect(stdout.bytes()).toEqual(Buffer.from([0, 255, 10, 1, 127])); }); - test("buffers non-streaming content before writing it once", async () => { + test("streams plain text before the Runtime response completes", async () => { const stdout = capture(); const stderr = capture(); const firstConsumed = Promise.withResolvers(); @@ -116,7 +104,7 @@ describe("Runtime invoke response output", () => { finish.resolve(); await pending; - expect(bytesBeforeCompletion).toHaveLength(0); + expect(bytesBeforeCompletion.toString()).toBe("first"); expect(stdout.bytes().toString()).toBe("firstsecond"); expect(stderr.bytes().toString()).toBe( "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + @@ -301,7 +289,7 @@ describe("Runtime invoke response output", () => { ); }); - test("does not write a partial non-streaming response when buffering fails", async () => { + test("preserves partial raw output regardless of response media type", async () => { const stdout = capture(); const stderr = capture(); const error = new Error("secret upstream detail"); @@ -313,7 +301,7 @@ describe("Runtime invoke response output", () => { }), ).rejects.toThrow("response stream failed"); - expect(stdout.bytes()).toHaveLength(0); + expect(stdout.bytes().toString()).toBe("partial"); expect(stderr.bytes().toString()).toBe( "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + @@ -344,29 +332,39 @@ describe("Runtime invoke response output", () => { expect(stderr.bytes().toString()).not.toContain(error.message); }); - test("rejects JSON mode for streaming content before iterating the body", async () => { - let iterations = 0; + test("JSON mode buffers a streaming representation into one atomic envelope", async () => { const stdout = capture(); const stderr = capture(); + const firstConsumed = Promise.withResolvers(); + const finish = Promise.withResolvers(); const source = (async function* () { - iterations++; - yield Buffer.from("{}\n"); + yield Buffer.from('data: {"part":1}\n\n'); + firstConsumed.resolve(); + await finish.promise; + yield Buffer.from('data: {"part":2}\n\n'); })(); - await expect( - writeRuntimeInvokeResponse(response({ contentType: "application/x-ndjson", body: source }), { + const pending = writeRuntimeInvokeResponse( + response({ contentType: "text/event-stream", body: source }), + { stdout: stdout.stream, stderr: stderr.stream, json: true, - }), - ).rejects.toMatchObject({ - message: - "--json cannot be used with a streaming Runtime response; omit --json or use --output-file", - exitCode: 2, - }); + }, + ); - expect(iterations).toBe(0); - expect(stdout.bytes()).toHaveLength(0); + await firstConsumed.promise; + const bytesBeforeCompletion = stdout.bytes(); + finish.resolve(); + await pending; + + expect(bytesBeforeCompletion).toHaveLength(0); + expect(JSON.parse(stdout.bytes().toString())).toMatchObject({ + contentType: "text/event-stream", + bodyEncoding: "utf8", + body: 'data: {"part":1}\n\ndata: {"part":2}\n\n', + complete: true, + }); expect(stderr.bytes()).toHaveLength(0); }); @@ -392,6 +390,30 @@ describe("Runtime invoke response output", () => { expect(stderr.bytes()).toHaveLength(0); }); + test("JSON mode base64-encodes binary content on a TTY", async () => { + const stdout = capture(); + const stderr = capture(); + Object.defineProperty(stdout.stream, "isTTY", { value: true }); + const bytes = Buffer.from([0, 255, 1]); + + await writeRuntimeInvokeResponse( + response({ contentType: "application/octet-stream", body: body(bytes) }), + { + stdout: stdout.stream, + stderr: stderr.stream, + json: true, + }, + ); + + expect(JSON.parse(stdout.bytes().toString())).toMatchObject({ + contentType: "application/octet-stream", + bodyEncoding: "base64", + body: bytes.toString("base64"), + complete: true, + }); + expect(stderr.bytes()).toHaveLength(0); + }); + test("refuses unknown content on a TTY before iterating the body", async () => { let iterations = 0; const stdout = capture(); diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 0af2c972f..d4877517d 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -1,7 +1,7 @@ import { createWriteStream } from "node:fs"; import { pipeline } from "node:stream/promises"; import type { RuntimeInvokeResponse } from "../types"; -import { RuntimeInvokeInterruptedError, RuntimeInvokeResponseError, UsageError } from "./errors"; +import { RuntimeInvokeInterruptedError, RuntimeInvokeResponseError } from "./errors"; interface RuntimeInvokeOutput { stdout: NodeJS.WriteStream; @@ -12,8 +12,7 @@ interface RuntimeInvokeOutput { } const RESPONSE_STREAM_FAILED = "response stream failed"; -const STREAMING_MEDIA_TYPES = new Set([ - "text/event-stream", +const TEXTUAL_SEQUENCE_MEDIA_TYPES = new Set([ "application/x-ndjson", "application/ndjson", "application/json-seq", @@ -23,16 +22,12 @@ function mediaType(contentType: string): string { return contentType.split(";", 1)[0]!.trim().toLowerCase(); } -export function isStreamingRuntimeResponse(contentType: string): boolean { - return STREAMING_MEDIA_TYPES.has(mediaType(contentType)); -} - export function classifyRuntimeResponse(contentType: string) { const type = mediaType(contentType); if (type === "application/json" || /^application\/[^/]+\+json$/.test(type)) { return "json"; } - return type.startsWith("text/") || STREAMING_MEDIA_TYPES.has(type) ? "text" : "binary"; + return type.startsWith("text/") || TEXTUAL_SEQUENCE_MEDIA_TYPES.has(type) ? "text" : "binary"; } async function* countBytes( @@ -141,15 +136,9 @@ export async function writeRuntimeInvokeResponse( response: RuntimeInvokeResponse, output: RuntimeInvokeOutput, ): Promise { - const streaming = isStreamingRuntimeResponse(response.contentType); - if (output.json && streaming) { - throw new UsageError( - "--json cannot be used with a streaming Runtime response; omit --json or use --output-file", - ); - } - if ( output.outputFile === undefined && + !output.json && output.stdout.isTTY && classifyRuntimeResponse(response.contentType) === "binary" ) { @@ -166,19 +155,15 @@ export async function writeRuntimeInvokeResponse( output.signal, (size) => (byteCount += size), ); - } else if (streaming) { + } else if (output.json) { + const bytes = await readBody(response.body, output.signal, (size) => (byteCount += size)); + await writeJsonResponse(response, bytes, output); + } else { await pipeline( countBytes(response.body, (size) => (byteCount += size)), output.stdout, { end: false, signal: output.signal }, ); - } else { - const bytes = await readBody(response.body, output.signal, (size) => (byteCount += size)); - if (output.json) { - await writeJsonResponse(response, bytes, output); - } else { - await writeChunk(output.stdout, bytes, output.signal); - } } } catch (error) { await writeChunk( From e766351c636d14fc09d5335e6fb8f3b5b105236d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 29 Jul 2026 04:23:29 +0000 Subject: [PATCH 07/10] refactor(runtime): split invoke transports --- src/core/runtime.tsx | 187 ++++++++++++++++++++++++------------------- 1 file changed, 103 insertions(+), 84 deletions(-) diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index ea2626a1a..dbcc86738 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -35,7 +35,7 @@ export class RuntimeClient implements CoreRuntimeClient { options: CoreOptions, signal?: AbortSignal, ): Promise { - const { runtimeId, applicationHeaders, bearerToken, ...input } = request; + const { runtimeId, bearerToken } = request; const logger = this.logger.child({ operation: "invokeRuntime", authMode: bearerToken === undefined ? "IAM" : "CUSTOM_JWT", @@ -44,92 +44,111 @@ export class RuntimeClient implements CoreRuntimeClient { region: options.region, }); if (bearerToken !== undefined) { - const client = this.clients.data(toClientConfig(options)); - const endpoint = client.config.endpointProvider({ - Region: options.region, - Endpoint: options.endpointUrl, - }); - const url = new URL(endpoint.url); - if (url.protocol !== "https:") { - throw new TypeError("CUSTOM_JWT requires an HTTPS endpoint"); - } - url.pathname = `${url.pathname.replace(/\/?$/, "/")}runtimes/${encodeURIComponent(runtimeId)}/invocations`; - url.search = new URLSearchParams({ - accountId: request.accountId, - qualifier: request.qualifier, - }).toString(); - const headers = new Headers(applicationHeaders); - try { - headers.set("Authorization", `Bearer ${bearerToken}`); - } catch { - throw new TypeError("Invalid bearer token"); - } - try { - for (const [name, value] of [ - ["Content-Type", request.contentType], - ["Accept", request.accept], - ["Mcp-Session-Id", request.mcpSessionId], - ["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", request.runtimeSessionId], - ["Mcp-Protocol-Version", request.mcpProtocolVersion], - ["Mcp-Method", request.mcpMethod], - ["Mcp-Name", request.mcpName], - ["X-Amzn-Bedrock-AgentCore-Runtime-User-Id", request.runtimeUserId], - ["X-Amzn-Trace-Id", request.traceId], - ["traceparent", request.traceParent], - ["tracestate", request.traceState], - ["baggage", request.baggage], - ] as const) { - if (value !== undefined) headers.set(name, value); - } - } catch { - throw new TypeError("Invalid Runtime request header"); - } - let response: Response; - try { - response = await this.fetch(url, { - method: "POST", - redirect: "error", - headers, - body: request.payload as RequestInit["body"], - signal, - }); - } catch (error) { - if (signal?.aborted) throw signal.reason ?? error; - logger - .child({ - errorName: - error instanceof TypeError - ? "TypeError" - : error instanceof Error - ? "Error" - : typeof error, - }) - .debug("Runtime invocation transport failed"); - throw new Error("Runtime invocation failed"); - } - if (!response.ok) { - logger - .child({ httpStatusCode: response.status }) - .debug("Runtime invocation returned a non-success response"); - await response.body?.cancel().catch(() => undefined); - throw new Error(`HTTP ${response.status}`); + return this.invokeRuntimeWithCustomJwt(request, bearerToken, options, logger, signal); + } + return this.invokeRuntimeWithIam(request, options, logger, signal); + } + + private async invokeRuntimeWithCustomJwt( + request: RuntimeInvokeRequest, + bearerToken: string, + options: CoreOptions, + logger: Logger, + signal?: AbortSignal, + ): Promise { + const client = this.clients.data(toClientConfig(options)); + const endpoint = client.config.endpointProvider({ + Region: options.region, + Endpoint: options.endpointUrl, + }); + const url = new URL(endpoint.url); + if (url.protocol !== "https:") { + throw new TypeError("CUSTOM_JWT requires an HTTPS endpoint"); + } + url.pathname = `${url.pathname.replace(/\/?$/, "/")}runtimes/${encodeURIComponent(request.runtimeId)}/invocations`; + url.search = new URLSearchParams({ + accountId: request.accountId, + qualifier: request.qualifier, + }).toString(); + const headers = new Headers(request.applicationHeaders); + try { + headers.set("Authorization", `Bearer ${bearerToken}`); + } catch { + throw new TypeError("Invalid bearer token"); + } + try { + for (const [name, value] of [ + ["Content-Type", request.contentType], + ["Accept", request.accept], + ["Mcp-Session-Id", request.mcpSessionId], + ["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", request.runtimeSessionId], + ["Mcp-Protocol-Version", request.mcpProtocolVersion], + ["Mcp-Method", request.mcpMethod], + ["Mcp-Name", request.mcpName], + ["X-Amzn-Bedrock-AgentCore-Runtime-User-Id", request.runtimeUserId], + ["X-Amzn-Trace-Id", request.traceId], + ["traceparent", request.traceParent], + ["tracestate", request.traceState], + ["baggage", request.baggage], + ] as const) { + if (value !== undefined) headers.set(name, value); } - const body = (response.body as AsyncIterable | null) ?? emptyBody(); - return { - statusCode: response.status, - contentType: response.headers.get("content-type") ?? "", - runtimeSessionId: - response.headers.get("x-amzn-bedrock-agentcore-runtime-session-id") ?? undefined, - mcpSessionId: response.headers.get("mcp-session-id") ?? undefined, - mcpProtocolVersion: response.headers.get("mcp-protocol-version") ?? undefined, - traceId: response.headers.get("x-amzn-trace-id") ?? undefined, - traceParent: response.headers.get("traceparent") ?? undefined, - traceState: response.headers.get("tracestate") ?? undefined, - baggage: response.headers.get("baggage") ?? undefined, - body: signal ? abortable(body, signal) : body, - }; + } catch { + throw new TypeError("Invalid Runtime request header"); } + let response: Response; + try { + response = await this.fetch(url, { + method: "POST", + redirect: "error", + headers, + body: request.payload as RequestInit["body"], + signal, + }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + logger + .child({ + errorName: + error instanceof TypeError + ? "TypeError" + : error instanceof Error + ? "Error" + : typeof error, + }) + .debug("Runtime invocation transport failed"); + throw new Error("Runtime invocation failed"); + } + if (!response.ok) { + logger + .child({ httpStatusCode: response.status }) + .debug("Runtime invocation returned a non-success response"); + await response.body?.cancel().catch(() => undefined); + throw new Error(`HTTP ${response.status}`); + } + const body = (response.body as AsyncIterable | null) ?? emptyBody(); + return { + statusCode: response.status, + contentType: response.headers.get("content-type") ?? "", + runtimeSessionId: + response.headers.get("x-amzn-bedrock-agentcore-runtime-session-id") ?? undefined, + mcpSessionId: response.headers.get("mcp-session-id") ?? undefined, + mcpProtocolVersion: response.headers.get("mcp-protocol-version") ?? undefined, + traceId: response.headers.get("x-amzn-trace-id") ?? undefined, + traceParent: response.headers.get("traceparent") ?? undefined, + traceState: response.headers.get("tracestate") ?? undefined, + baggage: response.headers.get("baggage") ?? undefined, + body: signal ? abortable(body, signal) : body, + }; + } + private async invokeRuntimeWithIam( + request: RuntimeInvokeRequest, + options: CoreOptions, + logger: Logger, + signal?: AbortSignal, + ): Promise { + const { runtimeId, applicationHeaders, bearerToken: _bearerToken, ...input } = request; const command = new InvokeAgentRuntimeCommand({ ...input, agentRuntimeArn: runtimeId }); if (applicationHeaders?.length) { command.middlewareStack.add( From bd81d5e8711df7e0f20a35fdae3a165c430c474d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 29 Jul 2026 14:10:15 +0000 Subject: [PATCH 08/10] refactor(io): require stdin for source resolution --- src/handlers/runtime/invoke/request.test.ts | 11 ----------- src/handlers/runtime/invoke/request.ts | 2 +- src/io/source.test.ts | 16 ---------------- src/io/source.ts | 5 +---- 4 files changed, 2 insertions(+), 32 deletions(-) diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index d3a9b3b88..7bba65080 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -54,17 +54,6 @@ describe("resolveRuntimeInvokeSources", () => { expect(reads).toBe(0); }); - test("classifies source-resolution failures as usage", async () => { - const error = await resolveRuntimeInvokeSources({ payload: "-" }).catch((error) => error); - - expect(error).toBeInstanceOf(UsageError); - expect(error).toMatchObject({ - message: "stdin is not available for '--payload'", - exitCode: 2, - }); - expect(error.cause).toBeInstanceOf(SourceResolutionError); - }); - test("rejects a bearer token that is not valid UTF-8", async () => { const error = await resolveRuntimeInvokeSources( { payload: "{}", bearerToken: "-" }, diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index 5095c8bf6..cf016a9d7 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -33,7 +33,7 @@ const RESERVED_HEADERS = new Set([ export async function resolveRuntimeInvokeSources( sources: { payload: string; bearerToken?: string }, - stdin?: NodeJS.ReadStream, + stdin: NodeJS.ReadStream, signal?: AbortSignal, ): Promise<{ payload: Uint8Array; bearerToken?: string }> { if (sources.payload === "-" && sources.bearerToken === "-") { diff --git a/src/io/source.test.ts b/src/io/source.test.ts index 19aea7a3b..4092f9be3 100644 --- a/src/io/source.test.ts +++ b/src/io/source.test.ts @@ -33,22 +33,6 @@ describe("SourceResolver bytes", () => { expect(await resolver.resolveBytes("payload", undefined)).toBeUndefined(); }); - test("does not require stdin for non-stdin sources", async () => { - const resolver = new SourceResolver({}); - - expect(await resolver.resolveBytes("payload", "hello")).toEqual( - new TextEncoder().encode("hello"), - ); - }); - - test("rejects a stdin source when stdin is unavailable", async () => { - const resolver = new SourceResolver({}); - const resolution = resolver.resolveBytes("payload", "-"); - - await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); - await expect(resolution).rejects.toThrow("stdin is not available for '--payload'"); - }); - test("encodes inline values as UTF-8", async () => { const resolver = new SourceResolver({ stdin: stdin() }); diff --git a/src/io/source.ts b/src/io/source.ts index 7246524ab..eb0d332bd 100644 --- a/src/io/source.ts +++ b/src/io/source.ts @@ -7,7 +7,7 @@ const FILE_PREFIX = "file://"; const STDIN = "-"; export type SourceResolverConfig = { - stdin?: NodeJS.ReadStream; + stdin: NodeJS.ReadStream; signal?: AbortSignal; }; @@ -52,9 +52,6 @@ export class SourceResolver { } private async readStdin(name: string): Promise { - if (!this.config.stdin) { - throw new SourceResolutionError(`stdin is not available for '--${name}'`); - } if (this.stdinClaimedBy !== undefined) { throw new SourceResolutionError( `only one option may read from stdin; '--${name}' conflicts with ` + From 15dde775150ee330c3c6a5601dbef70a12773f1f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 29 Jul 2026 14:47:34 +0000 Subject: [PATCH 09/10] fix(runtime): align validation with rebased errors --- src/handlers/config/config.test.tsx | 1 - src/handlers/runtime/invoke/invoke.test.tsx | 4 ++-- src/router/router.test.ts | 15 +++++---------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/handlers/config/config.test.tsx b/src/handlers/config/config.test.tsx index 4a907a250..6a81b50e0 100644 --- a/src/handlers/config/config.test.tsx +++ b/src/handlers/config/config.test.tsx @@ -7,7 +7,6 @@ import { createSilentLogger, TestCoreClient, testIO } from "../../testing"; import { DefaultGlobalConfigAccessor } from "../../globalConfig"; import { InputValidationError } from "../../errors"; import { FsReadWriteJson } from "../../io"; -import { InputValidationError } from "../../errors"; describe("config", () => { let tempDir: string; diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 8079635a9..53603c95f 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -292,7 +292,7 @@ describe("runtime invoke", () => { } }); - test("classifies an invalid Runtime ARN as usage before Core calls", async () => { + test("rejects an invalid Runtime ARN before Core calls", async () => { const core = new TestCoreClient(); const output = captureIO(); @@ -300,7 +300,7 @@ describe("runtime invoke", () => { runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ARN, "--payload", "{}"]), ); - expect(code).toBe(ExitCode.USAGE); + expect(code).toBe(ExitCode.FAILURE); expect(core.runtime.calls).toEqual([]); }); diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 04e2a3f59..5d1798b8b 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -2,7 +2,6 @@ import { test, expect } from "bun:test"; import { Command } from "commander"; import z from "zod"; -import { InputValidationError } from "../errors"; import { Router, ValueContext, @@ -266,7 +265,7 @@ test("applies a schema default for an omitted flag", async () => { expect(seen).toEqual({ count: 7 }); }); -test("marks invalid flag schema input as usage", async () => { +test("reports invalid input via command.error (throws under exitOverride)", async () => { const get = createHandler({ name: "get", description: "", @@ -513,7 +512,7 @@ test("a required positional argument is mandatory", async () => { await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow(); }); -test("marks invalid argument schema input as usage", async () => { +test("rejects an argument that fails schema validation", async () => { const config = createHandler({ name: "config", description: "", @@ -528,13 +527,9 @@ test("marks invalid argument schema input as usage", async () => { const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); - const error = await cmd - .parseAsync(["node", "app", "config", "toolong"]) - .catch((caught) => caught); - - expect(error).toBeInstanceOf(InputValidationError); - expect(error.message).toContain("Invalid value for argument 'key'"); - expect(error.exitCode).toBe(2); + await expect(cmd.parseAsync(["node", "app", "config", "toolong"])).rejects.toThrow( + /Invalid value for argument 'key'/, + ); }); test("compile rejects a variadic argument that is not the last positional", () => { From 5c9bd7827d2ae5a65c53fe2a7585df177e2a4d9f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 29 Jul 2026 21:22:35 +0000 Subject: [PATCH 10/10] fix(runtime): address invoke review feedback --- README.md | 20 +++--- src/core/core.test.ts | 24 ++++++++ src/core/runtime.tsx | 3 +- src/handlers/runtime/invoke/errors.ts | 8 +-- src/handlers/runtime/invoke/index.tsx | 5 +- src/handlers/runtime/invoke/invoke.test.tsx | 67 ++++++++++++++++----- src/handlers/runtime/invoke/request.test.ts | 10 +-- src/handlers/runtime/invoke/request.ts | 29 ++++----- 8 files changed, 113 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index e1fbbc676..89c15367a 100644 --- a/README.md +++ b/README.md @@ -206,16 +206,16 @@ agentcore runtime invoke \ --mcp-method tools/list ``` -Responses with `text/event-stream`, `application/x-ndjson`, -`application/ndjson`, or `application/json-seq` content types stream exact bytes -to stdout as they arrive. Other responses are buffered and written once after -the body completes. Response metadata is written to stderr. - -`--output-file` streams either kind of response directly to disk and is required -for binary output when stdout is a terminal. `--json` supports non-streaming -responses only; it buffers one response and emits a metadata envelope without -interpreting the customer body. If a streaming response fails, bytes already -written remain available. A failed non-streaming response writes no partial body. +Raw stdout always streams exact response bytes as they arrive, regardless of +content type. `--output-file` streams the same bytes directly to disk. Binary or +unknown responses require `--output-file` or `--json` when stdout is a terminal. +Response metadata is written to stderr. + +`--json` buffers the complete response, including streaming representations, and +emits one metadata envelope without interpreting the customer body. If a raw or +file response fails, bytes already written remain available and the stderr +summary reports `complete=false`. A failed JSON response emits no partial +envelope. ```bash agentcore runtime invoke \ diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 6db51e5a3..c4424552b 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -562,6 +562,30 @@ test("CUSTOM_JWT invoke uses the generated endpoint and exact fetch request", as expect(Buffer.concat(resultBytes)).toEqual(Buffer.from([1, 2])); }); +test("CUSTOM_JWT invoke generates a Runtime session ID when omitted", async () => { + let headers = new Headers(); + const core = customJwtCore(async (_input, init) => { + headers = new Headers(init?.headers); + return new Response(undefined, { status: 204 }); + }); + + await core.runtime.invokeRuntime( + { + runtimeId: "runtime-123", + accountId: "123456789012", + qualifier: "DEFAULT", + payload: new Uint8Array(), + contentType: "application/json", + bearerToken: "secret-token", + }, + { region: "us-east-1" }, + ); + + expect(headers.get("X-Amzn-Bedrock-AgentCore-Runtime-Session-Id")).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); +}); + test("CUSTOM_JWT rejects non-HTTPS endpoints without calling fetch", async () => { let fetched = false; const core = customJwtCore( diff --git a/src/core/runtime.tsx b/src/core/runtime.tsx index dbcc86738..f5b28bba8 100644 --- a/src/core/runtime.tsx +++ b/src/core/runtime.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { GetAgentRuntimeCommand, GetAgentRuntimeEndpointCommand, @@ -81,7 +82,7 @@ export class RuntimeClient implements CoreRuntimeClient { ["Content-Type", request.contentType], ["Accept", request.accept], ["Mcp-Session-Id", request.mcpSessionId], - ["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", request.runtimeSessionId], + ["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", request.runtimeSessionId ?? randomUUID()], ["Mcp-Protocol-Version", request.mcpProtocolVersion], ["Mcp-Method", request.mcpMethod], ["Mcp-Name", request.mcpName], diff --git a/src/handlers/runtime/invoke/errors.ts b/src/handlers/runtime/invoke/errors.ts index f52fb299c..490e4b042 100644 --- a/src/handlers/runtime/invoke/errors.ts +++ b/src/handlers/runtime/invoke/errors.ts @@ -1,10 +1,4 @@ -import { AgentCoreCLIError, InputValidationError } from "../../../errors"; - -export class UsageError extends InputValidationError { - constructor(message?: string, options?: ErrorOptions) { - super(message, { ...options, exitCode: 2 }); - } -} +import { AgentCoreCLIError } from "../../../errors"; export class RuntimeInvokeInterruptedError extends AgentCoreCLIError { readonly reported: boolean; diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 7485a2282..bfba602a8 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -1,10 +1,11 @@ import z from "zod"; +import { InputValidationError } from "../../../errors"; import { createHandler, flag } from "../../../router"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; import { JsonKey } from "../../keys"; -import { RuntimeInvokeInterruptedError, UsageError } from "./errors"; +import { RuntimeInvokeInterruptedError } from "./errors"; import { normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, @@ -50,7 +51,7 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => handle: async (ctx, flags) => { const jsonOutput = ctx.require(JsonKey); if (jsonOutput && flags["output-file"] !== undefined) { - throw new UsageError("--json cannot be used with --output-file"); + throw new InputValidationError("--json cannot be used with --output-file"); } const controller = new AbortController(); const interrupt = () => controller.abort(); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 53603c95f..627561b30 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -292,6 +292,42 @@ describe("runtime invoke", () => { } }); + test("wraps a raw Core abort after SIGINT", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const rawAbort = Object.assign(new Error("transport aborted"), { name: "AbortError" }); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + core.runtime.invokeRuntime = async (request, options, signal) => { + core.runtime.calls.push({ method: "invokeRuntime", args: [request, options, signal] }); + return new Promise((_, reject) => { + const abort = () => reject(rawAbort); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + }; + const pending = runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + ]); + + try { + await waitFor(() => core.runtime.calls.some((call) => call.method === "invokeRuntime")); + process.emit("SIGINT", "SIGINT"); + + await expect(pending).rejects.toMatchObject({ + name: "AbortError", + cause: rawAbort, + reported: false, + }); + } finally { + await pending.catch(() => undefined); + } + }); + test("rejects an invalid Runtime ARN before Core calls", async () => { const core = new TestCoreClient(); const output = captureIO(); @@ -359,19 +395,22 @@ describe("runtime invoke", () => { ["malformed", "missing separator"], ["duplicate", "X-Test: one", "x-test: two"], ["reserved", "Authorization: secret"], - ])("rejects %s headers as usage before Runtime Core calls", async (_name, ...headers) => { - const core = new TestCoreClient(); - const output = captureIO(); - const args = ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]; - for (const header of headers) args.push("--header", header); - - const code = await runWithExitCode(async () => runCommand(core, output.io, args)); - - expect(code).toBe(ExitCode.USAGE); - expect(core.runtime.calls).toEqual([]); - }); + ])( + "rejects %s headers as input failures before Runtime Core calls", + async (_name, ...headers) => { + const core = new TestCoreClient(); + const output = captureIO(); + const args = ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]; + for (const header of headers) args.push("--header", header); + + const code = await runWithExitCode(async () => runCommand(core, output.io, args)); + + expect(code).toBe(ExitCode.FAILURE); + expect(core.runtime.calls).toEqual([]); + }, + ); - test("reports an unreadable payload file as local usage before Runtime Core calls", async () => { + test("reports an unreadable payload file as an input failure before Runtime Core calls", async () => { const core = new TestCoreClient(); const output = captureIO(); const missing = join(tmpdir(), `missing-runtime-payload-${process.pid}`); @@ -386,9 +425,9 @@ describe("runtime invoke", () => { `file://${missing}`, ]), ).rejects.toMatchObject({ - name: "UsageError", + name: "InputValidationError", message: `could not read '--payload' from file '${missing}'`, - exitCode: ExitCode.USAGE, + exitCode: ExitCode.FAILURE, }); expect(core.runtime.calls).toEqual([]); }); diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index 7bba65080..cd0a2732e 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; import { Readable } from "node:stream"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError } from "../../../errors"; import { SourceResolutionError } from "../../../io"; -import { UsageError } from "./errors"; import { normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, @@ -60,7 +60,7 @@ describe("resolveRuntimeInvokeSources", () => { stdin(Uint8Array.from([0xff, 0x61])), ).catch((error) => error); - expect(error).toBeInstanceOf(UsageError); + expect(error).toBeInstanceOf(InputValidationError); expect(error.message).toBe("'--bearer-token' must contain valid UTF-8"); expect(error.cause).toBeInstanceOf(SourceResolutionError); }); @@ -88,8 +88,8 @@ describe("resolveRuntimeInvokeSources", () => { }); describe("parseRuntimeInvokeHeaders", () => { - test("brands validation failures as usage errors", () => { - expect(() => parseRuntimeInvokeHeaders(["missing separator"])).toThrow(UsageError); + test("brands validation failures as input errors", () => { + expect(() => parseRuntimeInvokeHeaders(["missing separator"])).toThrow(InputValidationError); }); test.each([ @@ -116,7 +116,7 @@ describe("parseRuntimeInvokeHeaders", () => { } })(); - expect(error).toBeInstanceOf(UsageError); + expect(error).toBeInstanceOf(InputValidationError); expect((error as Error).message).toBe(message); expect((error as Error).message).not.toContain(secret); }, diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index cf016a9d7..c54905335 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -1,9 +1,9 @@ import { validateHeaderName, validateHeaderValue } from "node:http"; import z from "zod"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError } from "../../../errors"; import { SourceResolutionError, SourceResolver } from "../../../io"; import type { RuntimeInvokeRequest } from "../types"; -import { UsageError } from "./errors"; export const runtimeIdSchema = z .string() @@ -37,7 +37,7 @@ export async function resolveRuntimeInvokeSources( signal?: AbortSignal, ): Promise<{ payload: Uint8Array; bearerToken?: string }> { if (sources.payload === "-" && sources.bearerToken === "-") { - throw new UsageError("Payload and bearer token cannot both read from stdin"); + throw new InputValidationError("Payload and bearer token cannot both read from stdin"); } const resolver = new SourceResolver({ stdin, signal }); @@ -50,7 +50,7 @@ export async function resolveRuntimeInvokeSources( }; } catch (error) { if (error instanceof SourceResolutionError) { - throw new UsageError(error.message, { cause: error }); + throw new InputValidationError(error.message, { cause: error }); } throw error; } @@ -61,28 +61,28 @@ export function parseRuntimeInvokeHeaders(values: string[] = []): [string, strin return values.map((header) => { const separator = header.indexOf(":"); - if (separator < 1) throw new UsageError("Header must use 'Name: value' format"); + if (separator < 1) throw new InputValidationError("Header must use 'Name: value' format"); const name = header.slice(0, separator).trim(); const value = header.slice(separator + 1).trim(); try { validateHeaderName(name); } catch { - throw new UsageError( + throw new InputValidationError( `Invalid HTTP header name: ${name} (must use valid HTTP token characters)`, ); } try { validateHeaderValue(name, value); } catch { - throw new UsageError( + throw new InputValidationError( `Invalid header value for ${name}: contains a character not allowed in HTTP headers`, ); } const lower = name.toLowerCase(); - if (seen.has(lower)) throw new UsageError(`Duplicate header: ${name}`); + if (seen.has(lower)) throw new InputValidationError(`Duplicate header: ${name}`); seen.add(lower); if (RESERVED_HEADERS.has(lower)) - throw new UsageError(`Application header is reserved: ${name}`); + throw new InputValidationError(`Application header is reserved: ${name}`); return [name, value]; }); } @@ -101,7 +101,7 @@ function validateAllowedHeaders( for (const [name] of headers) { const lower = name.toLowerCase(); if (!lower.startsWith(CUSTOM_HEADER_PREFIX) && !allowlist.includes(lower)) { - throw new UsageError(`Application header is not allowed: ${name}`); + throw new InputValidationError(`Application header is not allowed: ${name}`); } } } @@ -114,18 +114,19 @@ export function normalizeRuntimeInvokeRequest( /^arn:[^:]+:bedrock-agentcore:[^:]*:(\d{12}):runtime\//, )?.[1]; if (!accountId) { - throw new UsageError("Runtime returned an invalid ARN"); + throw new InputValidationError("Runtime returned an invalid ARN"); } const authorizer = detail.authorizerConfiguration; const customJwt = authorizer !== undefined && "customJWTAuthorizer" in authorizer; - if (authorizer && !customJwt) throw new UsageError("Runtime uses an unsupported authorizer"); + if (authorizer && !customJwt) + throw new InputValidationError("Runtime uses an unsupported authorizer"); const { runtimeId, qualifier, payload, contentType, applicationHeaders = [], ...modeled } = input; if (customJwt && !modeled.bearerToken) { - throw new UsageError("CUSTOM_JWT Runtime requires --bearer-token"); + throw new InputValidationError("CUSTOM_JWT Runtime requires --bearer-token"); } if (!customJwt && modeled.bearerToken !== undefined) { - throw new UsageError("IAM Runtime does not accept --bearer-token"); + throw new InputValidationError("IAM Runtime does not accept --bearer-token"); } const mcp = detail.protocolConfiguration?.serverProtocol === "MCP"; @@ -136,7 +137,7 @@ export function normalizeRuntimeInvokeRequest( modeled.mcpName, ]; if (!mcp && mcpValues.some((value) => value !== undefined)) { - throw new UsageError("MCP options are only valid for MCP Runtimes"); + throw new InputValidationError("MCP options are only valid for MCP Runtimes"); } validateAllowedHeaders(detail, applicationHeaders);